diff --git a/.gitignore b/.gitignore
index 72a6bbf..2b236e2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -25,8 +25,6 @@ node_modules/
# Runtime data and artifacts
data/
-!android-app/app/src/main/java/com/aiglasses/app/data/
-!android-app/app/src/main/java/com/aiglasses/app/data/**
output/
*.log
diff --git a/README.md b/README.md
index 0124ed1..b6a9755 100644
--- a/README.md
+++ b/README.md
@@ -3,10 +3,11 @@
StoryForge 现在拆成独立项目目录,和 `AI-glasses` 分开维护。
仓库边界和维护约束见:[StoryForge 仓库边界说明](./docs/STORYFORGE_REPO_BOUNDARY_2026-03-26.md)。
+拆分治理方案见:[StoryForge / AI Glasses 拆分评估方案](./docs/STORYFORGE_SPLIT_ASSESSMENT_2026-03-26.md)。
+`AI-glasses` 独立代码仓库已单独维护在 [krisolo/ai-glasses](https://git.hyzq.site/krisolo/ai-glasses)。
## 目录
-- `android-app/`:StoryForge Android 客户端
- `collector-service/`:FastAPI 后端,负责用户体系、项目、Agent、任务、内容分析和对外能力接入
- `n8n/`:工作流导出文件,作为流程编排中枢
- `docker-compose.yml`:本地 `collector + n8n + cli-proxy-api` 编排
@@ -20,14 +21,7 @@ StoryForge 现在拆成独立项目目录,和 `AI-glasses` 分开维护。
- [新媒体运营平台 UI 参考包](./output/ui/new-media-ops-reference-2026-03-22/README.md)
- [Web V4 UI 原型](./output/ui/storyforge-web-v4-html-prototype-2026-03-22/README.md)
- [Web V4 前端骨架](./web/storyforge-web-v4/README.md)(国内平台 UI 承载,当前工作台仅 `douyin` 完整实现)
-- [Mobile V4 UI 原型](./output/ui/storyforge-mobile-v4-html-prototype-2026-03-22/README.md)
-
-## Android
-
-```bash
-cd /Users/kris/code/StoryForge-gitea/android-app
-./gradlew assembleDebug
-```
+- [Mobile V4 UI 原型](./output/ui/storyforge-mobile-v4-html-prototype-2026-03-22/README.md)(仅 UI 原型,不代表当前仓库承载 Android 工程)
## Douyin Browser Capture
diff --git a/android-app/README.md b/android-app/README.md
deleted file mode 100644
index ea221ee..0000000
--- a/android-app/README.md
+++ /dev/null
@@ -1,35 +0,0 @@
-# StoryForge Android App
-
-StoryForge Android client for the current workspace entry: authentication, content import, agent management, production tracking, and OTA install.
-
-## Current flow
-
-- Compose-based StoryForge shell
-- Secure session storage for base URL and token
-- Backend API calls for login, project/content import, agent management, and update checks
-- Local video picking for learning tasks
-- OTA download and install from the "我的" tab
-
-## Default backend
-
-The app defaults to:
-
-`https://storyforge.hyzq.net`
-
-For local development, cleartext HTTP is only allowed for `localhost`, `127.0.0.1`, and `10.0.2.2`.
-
-## Build APK
-
-Open this folder in Android Studio:
-
-`/Users/kris/code/StoryForge-gitea/android-app`
-
-Then run:
-
-```bash
-./gradlew assembleDebug
-```
-
-APK output:
-
-`app/build/outputs/apk/debug/app-debug.apk`
diff --git a/android-app/app/build.gradle.kts b/android-app/app/build.gradle.kts
deleted file mode 100644
index 366c677..0000000
--- a/android-app/app/build.gradle.kts
+++ /dev/null
@@ -1,86 +0,0 @@
-plugins {
- id("com.android.application")
- id("org.jetbrains.kotlin.android")
- id("org.jetbrains.kotlin.plugin.serialization")
-}
-
-android {
- namespace = "com.aiglasses.app"
- compileSdk = 35
-
- defaultConfig {
- applicationId = "com.storyforge.app"
- minSdk = 26
- targetSdk = 35
- versionCode = 37
- versionName = "0.6.4"
-
- testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
- buildConfigField("String", "DEFAULT_STORYFORGE_BASE_URL", "\"https://storyforge.hyzq.net\"")
- buildConfigField("String", "DEFAULT_STORYFORGE_FALLBACK_IP", "\"111.231.132.51\"")
- buildConfigField("String", "DEFAULT_LOCAL_MODEL_BASE_URL", "\"http://127.0.0.1:8317/v1\"")
- }
-
- buildTypes {
- release {
- isMinifyEnabled = false
- proguardFiles(
- getDefaultProguardFile("proguard-android-optimize.txt"),
- "proguard-rules.pro"
- )
- }
- }
-
- compileOptions {
- sourceCompatibility = JavaVersion.VERSION_17
- targetCompatibility = JavaVersion.VERSION_17
- }
- kotlinOptions {
- jvmTarget = "17"
- }
-
- buildFeatures {
- compose = true
- buildConfig = true
- }
- composeOptions {
- kotlinCompilerExtensionVersion = "1.5.14"
- }
-}
-
-dependencies {
- val composeBom = platform("androidx.compose:compose-bom:2025.02.00")
- implementation(composeBom)
- androidTestImplementation(composeBom)
-
- implementation("androidx.core:core-ktx:1.15.0")
- implementation("androidx.activity:activity-compose:1.10.0")
- implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
- implementation("androidx.security:security-crypto:1.1.0-alpha06")
- implementation("com.google.android.material:material:1.12.0")
-
- implementation("androidx.compose.ui:ui")
- implementation("androidx.compose.ui:ui-tooling-preview")
- implementation("androidx.compose.material3:material3")
- debugImplementation("androidx.compose.ui:ui-tooling")
- debugImplementation("androidx.compose.ui:ui-test-manifest")
-
- implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
- implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
-
- implementation("androidx.camera:camera-core:1.4.2")
- implementation("androidx.camera:camera-camera2:1.4.2")
- implementation("androidx.camera:camera-lifecycle:1.4.2")
- implementation(files("libs/brtc-3.5.0.1a.aar"))
- implementation(files("libs/lib_agent-1.0.1.4.aar"))
-
- implementation("com.squareup.okhttp3:okhttp:4.12.0")
- implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
- implementation("com.squareup.retrofit2:retrofit:2.11.0")
- implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0")
-
- testImplementation("junit:junit:4.13.2")
- androidTestImplementation("androidx.test.ext:junit:1.2.1")
- androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
- androidTestImplementation("androidx.compose.ui:ui-test-junit4")
-}
diff --git a/android-app/app/libs/brtc-3.5.0.1a.aar b/android-app/app/libs/brtc-3.5.0.1a.aar
deleted file mode 100644
index 8e870c3..0000000
Binary files a/android-app/app/libs/brtc-3.5.0.1a.aar and /dev/null differ
diff --git a/android-app/app/libs/lib_agent-1.0.1.4.aar b/android-app/app/libs/lib_agent-1.0.1.4.aar
deleted file mode 100644
index 2602820..0000000
Binary files a/android-app/app/libs/lib_agent-1.0.1.4.aar and /dev/null differ
diff --git a/android-app/app/proguard-rules.pro b/android-app/app/proguard-rules.pro
deleted file mode 100644
index 99142f9..0000000
--- a/android-app/app/proguard-rules.pro
+++ /dev/null
@@ -1,2 +0,0 @@
-# Keep default for demo stage.
-
diff --git a/android-app/app/src/main/AndroidManifest.xml b/android-app/app/src/main/AndroidManifest.xml
deleted file mode 100644
index b6e1e6f..0000000
--- a/android-app/app/src/main/AndroidManifest.xml
+++ /dev/null
@@ -1,35 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/android-app/app/src/main/java/com/aiglasses/app/MainActivity.kt b/android-app/app/src/main/java/com/aiglasses/app/MainActivity.kt
deleted file mode 100644
index 84f57ad..0000000
--- a/android-app/app/src/main/java/com/aiglasses/app/MainActivity.kt
+++ /dev/null
@@ -1,51 +0,0 @@
-package com.aiglasses.app
-
-import android.os.Bundle
-import androidx.activity.ComponentActivity
-import androidx.activity.compose.rememberLauncherForActivityResult
-import androidx.activity.compose.setContent
-import androidx.activity.enableEdgeToEdge
-import androidx.activity.result.contract.ActivityResultContracts
-import androidx.compose.runtime.DisposableEffect
-import androidx.compose.runtime.collectAsState
-import androidx.compose.runtime.getValue
-import androidx.lifecycle.viewmodel.compose.viewModel
-import com.aiglasses.app.storyforge.StoryForgeScreen
-import com.aiglasses.app.storyforge.StoryForgeViewModel
-import com.aiglasses.app.ui.theme.AIGlassesTheme
-import com.aiglasses.app.update.AppOtaUpdater
-
-class MainActivity : ComponentActivity() {
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- enableEdgeToEdge()
- setContent {
- AIGlassesTheme {
- val vm: StoryForgeViewModel = viewModel()
- val state by vm.state.collectAsState()
- val otaUpdater = AppOtaUpdater(this) { vm.onOtaLog(it) }
- DisposableEffect(Unit) {
- otaUpdater.register()
- onDispose { otaUpdater.release() }
- }
- val videoPicker = rememberLauncherForActivityResult(
- contract = ActivityResultContracts.OpenDocument()
- ) { uri ->
- if (uri != null) {
- val fileName = contentResolver.query(uri, null, null, null, null)?.use { cursor ->
- val nameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
- if (nameIndex >= 0 && cursor.moveToFirst()) cursor.getString(nameIndex) else null
- } ?: (uri.lastPathSegment ?: "selected-video.mp4")
- vm.setPickedVideo(uri, fileName)
- }
- }
- StoryForgeScreen(
- state = state,
- vm = vm,
- onPickVideo = { videoPicker.launch(arrayOf("video/*")) },
- onInstallLatestUpdate = { vm.installLatestUpdate(otaUpdater) }
- )
- }
- }
- }
-}
diff --git a/android-app/app/src/main/java/com/aiglasses/app/ble/BleManager.kt b/android-app/app/src/main/java/com/aiglasses/app/ble/BleManager.kt
deleted file mode 100644
index 5e0bc88..0000000
--- a/android-app/app/src/main/java/com/aiglasses/app/ble/BleManager.kt
+++ /dev/null
@@ -1,638 +0,0 @@
-package com.aiglasses.app.ble
-
-import android.annotation.SuppressLint
-import android.bluetooth.BluetoothAdapter
-import android.bluetooth.BluetoothDevice
-import android.bluetooth.BluetoothGatt
-import android.bluetooth.BluetoothGattCallback
-import android.bluetooth.BluetoothGattCharacteristic
-import android.bluetooth.BluetoothGattDescriptor
-import android.bluetooth.BluetoothGattService
-import android.bluetooth.BluetoothManager
-import android.bluetooth.BluetoothProfile
-import android.bluetooth.BluetoothStatusCodes
-import android.bluetooth.le.ScanCallback
-import android.bluetooth.le.ScanFilter
-import android.bluetooth.le.ScanResult
-import android.bluetooth.le.ScanSettings
-import android.content.Context
-import android.os.Build
-import android.os.ParcelUuid
-import java.nio.ByteBuffer
-import java.nio.ByteOrder
-import java.util.ArrayDeque
-import java.util.UUID
-import kotlinx.coroutines.flow.MutableSharedFlow
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.SharedFlow
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asSharedFlow
-import kotlinx.coroutines.flow.asStateFlow
-import org.json.JSONObject
-
-private const val MAX_FRAME_DATA = 8 * 1024
-
-data class BleLinkState(
- val scanning: Boolean = false,
- val connected: Boolean = false,
- val notificationsReady: Boolean = false,
- val handshaked: Boolean = false,
- val deviceName: String = "",
- val deviceAddress: String = "",
- val devUuid: String = "",
- val lastError: String = ""
-)
-
-sealed interface GlassesBleEvent {
- data class Log(val message: String) : GlassesBleEvent
- data class HandshakeOk(
- val devUuid: String,
- val devName: String,
- val devFwVer: String
- ) : GlassesBleEvent
- data class StatusUpdate(val payloadJson: String) : GlassesBleEvent
- data class AudioFrame(val bytes: ByteArray, val index: Int) : GlassesBleEvent
- data class CameraThumbInfo(val sourceFileName: String, val isVideo: Boolean) : GlassesBleEvent
- data class CameraThumbData(val bytes: ByteArray, val index: Int, val isVideo: Boolean) : GlassesBleEvent
-}
-
-private data class HichipsFrame(
- val command: Int,
- val index: Int,
- val payload: ByteArray
-)
-
-private object HichipsUuid {
- val service3D20: UUID = shortUuid("3d20")
- val char3D21Notify: UUID = shortUuid("3d21")
- val char3D22NotifyData: UUID = shortUuid("3d22")
- val char3D23Write: UUID = shortUuid("3d23")
-
- val service5DC0: UUID = shortUuid("5dc0")
- val char5DC1Notify: UUID = shortUuid("5dc1")
- val char5DC2NotifyData: UUID = shortUuid("5dc2")
- val char5DC3Write: UUID = shortUuid("5dc3")
-
- val cccd: UUID = shortUuid("2902")
-
- private fun shortUuid(hex: String): UUID {
- return UUID.fromString("0000${hex.lowercase()}-0000-1000-8000-00805f9b34fb")
- }
-}
-
-private object HichipsCmd {
- // 5DC0 wake-up stream commands
- const val ASR_DEV_WAKE_UP = 0x0000
- const val ASR_APP_WAKE_UP = 0x0001
- const val ASR_TRANS_SETTING = 0x0002
- const val ASR_TRANS_START = 0x0003
- const val ASR_TRANS_FLOW_CTRL = 0x0004
- const val ASR_TRANS_AUDIO = 0x0005
- const val ASR_TRANS_APP_SET_STOP = 0x0006
- const val ASR_TRANS_STOP = 0x0007
-
- // 3D20 common commands
- const val AG_HS_DEV_UUID = 0x0000
- const val AG_HS_APP_UUID = 0x0001
- const val AG_HS_DEV_INFO = 0x0002
- const val AG_GET_ALL_STATUS = 0x0013
- const val AG_P_TAKE_START = 0x00A0
- const val AG_P_TAKE_STOP = 0x00A1
- const val AG_P_THUMB_INFO = 0x00A2
- const val AG_P_THUMB_DATA = 0x00A3
- const val AG_V_THUMB_INFO = 0x0094
- const val AG_V_THUMB_DATA = 0x0095
-}
-
-private class FrameAssembler {
- private var buffer = byteArrayOf()
- private val head = byteArrayOf(0x48, 0x49, 0x43, 0x48) // HICH
- private val end = byteArrayOf(0x49, 0x50, 0x53, 0x45) // IPSE
-
- fun append(chunk: ByteArray): List {
- if (chunk.isEmpty()) return emptyList()
- buffer += chunk
- val out = mutableListOf()
- while (true) {
- val start = indexOf(buffer, head)
- if (start < 0) {
- buffer = if (buffer.size > 3) buffer.copyOfRange(buffer.size - 3, buffer.size) else buffer
- break
- }
- if (start > 0) {
- buffer = buffer.copyOfRange(start, buffer.size)
- }
- if (buffer.size < 18) break
-
- val dataLength = leUInt32(buffer, 8)
- if (dataLength < 0 || dataLength > MAX_FRAME_DATA) {
- buffer = buffer.copyOfRange(1, buffer.size)
- continue
- }
- val total = 18 + dataLength
- if (buffer.size < total) break
- val tail = buffer.copyOfRange(total - 4, total)
- if (!tail.contentEquals(end)) {
- buffer = buffer.copyOfRange(1, buffer.size)
- continue
- }
-
- val command = leUInt16(buffer, 4)
- val index = leUInt16(buffer, 6)
- val payload = if (dataLength > 0) {
- buffer.copyOfRange(14, 14 + dataLength)
- } else {
- byteArrayOf()
- }
- val crcExpected = leUInt16(buffer, 12)
- val crcActual = crc16(payload)
- if (crcExpected == crcActual) {
- out += HichipsFrame(command = command, index = index, payload = payload)
- }
- buffer = if (buffer.size == total) byteArrayOf() else buffer.copyOfRange(total, buffer.size)
- }
- return out
- }
-
- fun hasPendingFrame(): Boolean {
- return buffer.isNotEmpty()
- }
-
- private fun leUInt16(bytes: ByteArray, offset: Int): Int {
- return ((bytes[offset].toInt() and 0xFF) or ((bytes[offset + 1].toInt() and 0xFF) shl 8))
- }
-
- private fun leUInt32(bytes: ByteArray, offset: Int): Int {
- val b0 = bytes[offset].toInt() and 0xFF
- val b1 = bytes[offset + 1].toInt() and 0xFF
- val b2 = bytes[offset + 2].toInt() and 0xFF
- val b3 = bytes[offset + 3].toInt() and 0xFF
- return b0 or (b1 shl 8) or (b2 shl 16) or (b3 shl 24)
- }
-
- private fun indexOf(source: ByteArray, target: ByteArray): Int {
- if (target.isEmpty()) return 0
- if (source.size < target.size) return -1
- for (i in 0..(source.size - target.size)) {
- var matched = true
- for (j in target.indices) {
- if (source[i + j] != target[j]) {
- matched = false
- break
- }
- }
- if (matched) return i
- }
- return -1
- }
-
- private fun crc16(data: ByteArray): Int {
- var crc = 0xFFFF
- for (b in data) {
- crc = ((crc ushr 8) or ((crc and 0xFF) shl 8)) and 0xFFFF
- crc = crc xor (b.toInt() and 0xFF)
- crc = crc xor ((crc and 0xFF) ushr 4)
- crc = crc xor ((crc shl 8) shl 4)
- crc = crc xor (((crc and 0xFF) shl 4) shl 1)
- crc = crc and 0xFFFF
- }
- return crc and 0xFFFF
- }
-}
-
-class BleManager(private val context: Context) {
- private val btManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
- private val adapter: BluetoothAdapter? = btManager.adapter
-
- private val _state = MutableStateFlow(BleLinkState())
- val state: StateFlow = _state.asStateFlow()
-
- private val _events = MutableSharedFlow(extraBufferCapacity = 256)
- val events: SharedFlow = _events.asSharedFlow()
-
- private var gatt: BluetoothGatt? = null
- private var scannerCallback: ScanCallback? = null
- private var pendingAppUuid: String = ""
- private var waitingAsrStart = false
-
- private var write3D23: BluetoothGattCharacteristic? = null
- private var write5DC3: BluetoothGattCharacteristic? = null
-
- private val notifyQueue = ArrayDeque()
- private val assembler3D21 = FrameAssembler()
- private val assembler3D22 = FrameAssembler()
- private val assembler5DC1 = FrameAssembler()
- private val assembler5DC2 = FrameAssembler()
-
- @SuppressLint("MissingPermission")
- fun connectAndHandshake(appUuid: String, nameHint: String? = null) {
- val bt = adapter
- if (bt == null || !bt.isEnabled) {
- updateError("Bluetooth not enabled")
- return
- }
- pendingAppUuid = appUuid.take(32)
- if (_state.value.connected) {
- emitLog("BLE already connected, waiting for handshake packets")
- return
- }
- stopScan()
- _state.value = _state.value.copy(scanning = true, lastError = "")
- val filters = listOf(
- ScanFilter.Builder()
- .setServiceUuid(ParcelUuid(HichipsUuid.service3D20))
- .build()
- )
- val settings = ScanSettings.Builder()
- .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
- .build()
- scannerCallback = object : ScanCallback() {
- override fun onScanResult(callbackType: Int, result: ScanResult) {
- val device = result.device ?: return
- val deviceName = runCatching { device.name.orEmpty() }.getOrDefault("")
- if (nameHint.isNullOrBlank().not() && !deviceName.contains(nameHint!!, ignoreCase = true)) {
- return
- }
- stopScan()
- emitLog("BLE found ${device.address} ${deviceName.ifBlank { "(no-name)" }}")
- connectDevice(device)
- }
-
- override fun onScanFailed(errorCode: Int) {
- updateError("BLE scan failed: $errorCode")
- }
- }
- bt.bluetoothLeScanner?.startScan(filters, settings, scannerCallback)
- emitLog("BLE scanning...")
- }
-
- @SuppressLint("MissingPermission")
- fun disconnect() {
- stopScan()
- runCatching { gatt?.disconnect() }
- runCatching { gatt?.close() }
- gatt = null
- _state.value = BleLinkState()
- }
-
- fun startWakeUpAudio() {
- waitingAsrStart = true
- val ok = sendAsrCommand(HichipsCmd.ASR_APP_WAKE_UP, null)
- emitLog(if (ok) "ASR wake-up command sent" else "ASR wake-up send failed")
- }
-
- fun stopWakeUpAudio() {
- waitingAsrStart = false
- val ok = sendAsrCommand(HichipsCmd.ASR_TRANS_APP_SET_STOP, null)
- emitLog(if (ok) "ASR stop command sent" else "ASR stop send failed")
- }
-
- fun triggerPhotoCapture() {
- val ok = sendAgCommand(HichipsCmd.AG_P_TAKE_START, null)
- emitLog(if (ok) "Photo capture command sent" else "Photo capture send failed")
- }
-
- fun requestAllStatus() {
- sendAgCommand(HichipsCmd.AG_GET_ALL_STATUS, null)
- }
-
- @SuppressLint("MissingPermission")
- private fun connectDevice(device: BluetoothDevice) {
- runCatching { gatt?.close() }
- gatt = device.connectGatt(context, false, callback, BluetoothDevice.TRANSPORT_LE)
- _state.value = _state.value.copy(
- scanning = false,
- connected = false,
- notificationsReady = false,
- handshaked = false,
- deviceAddress = device.address,
- deviceName = runCatching { device.name.orEmpty() }.getOrDefault("")
- )
- }
-
- @SuppressLint("MissingPermission")
- private fun stopScan() {
- scannerCallback?.let { cb ->
- adapter?.bluetoothLeScanner?.stopScan(cb)
- }
- scannerCallback = null
- _state.value = _state.value.copy(scanning = false)
- }
-
- private val callback = object : BluetoothGattCallback() {
- @SuppressLint("MissingPermission")
- override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
- if (status != BluetoothGatt.GATT_SUCCESS) {
- updateError("BLE connect error status=$status")
- return
- }
- if (newState == BluetoothProfile.STATE_CONNECTED) {
- _state.value = _state.value.copy(connected = true, lastError = "")
- emitLog("BLE connected, discovering services")
- gatt.requestMtu(247)
- gatt.discoverServices()
- } else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
- _state.value = _state.value.copy(
- connected = false,
- notificationsReady = false,
- handshaked = false
- )
- emitLog("BLE disconnected")
- }
- }
-
- override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) {
- emitLog("BLE mtu=$mtu status=$status")
- }
-
- override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
- if (status != BluetoothGatt.GATT_SUCCESS) {
- updateError("Service discovery failed: $status")
- return
- }
- bindCharacteristics(gatt)
- startEnableNotifications()
- }
-
- override fun onDescriptorWrite(
- gatt: BluetoothGatt,
- descriptor: BluetoothGattDescriptor,
- status: Int
- ) {
- if (status != BluetoothGatt.GATT_SUCCESS) {
- updateError("Descriptor write failed: $status")
- return
- }
- writeNextNotificationDescriptor()
- }
-
- @Deprecated("Deprecated in API 33")
- override fun onCharacteristicChanged(
- gatt: BluetoothGatt,
- characteristic: BluetoothGattCharacteristic
- ) {
- handleCharacteristicChanged(characteristic.uuid, characteristic.value ?: byteArrayOf())
- }
-
- override fun onCharacteristicChanged(
- gatt: BluetoothGatt,
- characteristic: BluetoothGattCharacteristic,
- value: ByteArray
- ) {
- handleCharacteristicChanged(characteristic.uuid, value)
- }
- }
-
- @SuppressLint("MissingPermission")
- private fun bindCharacteristics(gatt: BluetoothGatt) {
- val s3 = gatt.getService(HichipsUuid.service3D20)
- val s5 = gatt.getService(HichipsUuid.service5DC0)
- write3D23 = s3?.getCharacteristic(HichipsUuid.char3D23Write)
- write5DC3 = s5?.getCharacteristic(HichipsUuid.char5DC3Write)
- }
-
- private fun startEnableNotifications() {
- val g = gatt ?: return
- notifyQueue.clear()
- enqueueNotify(g, HichipsUuid.service3D20, HichipsUuid.char3D21Notify)
- enqueueNotify(g, HichipsUuid.service3D20, HichipsUuid.char3D22NotifyData)
- enqueueNotify(g, HichipsUuid.service5DC0, HichipsUuid.char5DC1Notify)
- enqueueNotify(g, HichipsUuid.service5DC0, HichipsUuid.char5DC2NotifyData)
- writeNextNotificationDescriptor()
- }
-
- private fun enqueueNotify(gatt: BluetoothGatt, serviceUuid: UUID, charUuid: UUID) {
- val characteristic = gatt.getService(serviceUuid)?.getCharacteristic(charUuid) ?: return
- notifyQueue.add(characteristic)
- }
-
- @SuppressLint("MissingPermission")
- private fun writeNextNotificationDescriptor() {
- val g = gatt ?: return
- if (notifyQueue.isEmpty()) {
- _state.value = _state.value.copy(notificationsReady = true)
- emitLog("BLE notifications enabled")
- return
- }
- val c = notifyQueue.removeFirst()
- g.setCharacteristicNotification(c, true)
- val descriptor = c.getDescriptor(HichipsUuid.cccd) ?: run {
- writeNextNotificationDescriptor()
- return
- }
- val value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- val result = g.writeDescriptor(descriptor, value)
- if (result != BluetoothStatusCodes.SUCCESS) {
- updateError("writeDescriptor failed: $result")
- }
- } else {
- @Suppress("DEPRECATION")
- run {
- descriptor.value = value
- val ok = g.writeDescriptor(descriptor)
- if (!ok) updateError("writeDescriptor returned false")
- }
- }
- }
-
- private fun handleCharacteristicChanged(uuid: UUID, value: ByteArray) {
- if (value.isEmpty()) return
- when (uuid) {
- HichipsUuid.char3D21Notify -> decodeAndDispatchFrames(value, assembler3D21, isWakeChannel = false, isDataChannel = false)
- HichipsUuid.char5DC1Notify -> decodeAndDispatchFrames(value, assembler5DC1, isWakeChannel = true, isDataChannel = false)
- HichipsUuid.char3D22NotifyData -> decodeAndDispatchFrames(value, assembler3D22, isWakeChannel = false, isDataChannel = true)
- HichipsUuid.char5DC2NotifyData -> decodeAndDispatchFrames(value, assembler5DC2, isWakeChannel = true, isDataChannel = true)
- }
- }
-
- private fun decodeAndDispatchFrames(
- value: ByteArray,
- assembler: FrameAssembler,
- isWakeChannel: Boolean,
- isDataChannel: Boolean
- ) {
- val isPacketized = value.size >= 4 &&
- value[0] == 0x48.toByte() &&
- value[1] == 0x49.toByte() &&
- value[2] == 0x43.toByte() &&
- value[3] == 0x48.toByte()
-
- if (isDataChannel && !isPacketized && !assembler.hasPendingFrame()) {
- if (isWakeChannel) {
- _events.tryEmit(GlassesBleEvent.AudioFrame(bytes = value, index = 0))
- }
- return
- }
-
- val frames = assembler.append(value)
- for (frame in frames) {
- onFrame(frame, isWakeChannel, isDataChannel)
- }
- }
-
- private fun onFrame(frame: HichipsFrame, isWakeChannel: Boolean, isDataChannel: Boolean) {
- if (isWakeChannel && isDataChannel && frame.command == HichipsCmd.ASR_TRANS_AUDIO) {
- _events.tryEmit(GlassesBleEvent.AudioFrame(bytes = frame.payload, index = frame.index))
- return
- }
-
- if (!isWakeChannel && isDataChannel) {
- when (frame.command) {
- HichipsCmd.AG_P_THUMB_DATA -> _events.tryEmit(
- GlassesBleEvent.CameraThumbData(
- bytes = frame.payload,
- index = frame.index,
- isVideo = false
- )
- )
- HichipsCmd.AG_V_THUMB_DATA -> _events.tryEmit(
- GlassesBleEvent.CameraThumbData(
- bytes = frame.payload,
- index = frame.index,
- isVideo = true
- )
- )
- }
- return
- }
-
- if (isWakeChannel) {
- when (frame.command) {
- HichipsCmd.ASR_DEV_WAKE_UP -> {
- emitLog("Device wake-up received")
- if (waitingAsrStart) {
- val setting = JSONObject()
- .put("FlowCtrl", 0)
- .put("LengthByte", 80)
- .put("IntervalMs", 20)
- .put("Packag", 1)
- sendAsrCommand(HichipsCmd.ASR_TRANS_SETTING, setting.toString())
- }
- }
- HichipsCmd.ASR_TRANS_START -> emitLog("ASR trans start")
- HichipsCmd.ASR_TRANS_STOP -> emitLog("ASR trans stop")
- }
- return
- }
-
- when (frame.command) {
- HichipsCmd.AG_HS_DEV_UUID -> {
- val json = parseJson(frame.payload)
- val devUuid = json?.optString("DevUuid", "").orEmpty()
- if (devUuid.isNotBlank()) {
- _state.value = _state.value.copy(devUuid = devUuid)
- val appUuidPayload = JSONObject()
- .put("Time", System.currentTimeMillis() / 1000L)
- .put("AppUuid", pendingAppUuid.take(32))
- .toString()
- sendAgCommand(HichipsCmd.AG_HS_APP_UUID, appUuidPayload)
- emitLog("Handshake step2 done, app uuid sent")
- }
- }
- HichipsCmd.AG_HS_DEV_INFO -> {
- val json = parseJson(frame.payload)
- val fail = json?.optString("Status") == "Fail"
- if (fail) {
- updateError("Handshake rejected: ${json?.optInt("ErrorCode", -1)}")
- return
- }
- val devUuid = json?.optString("DevUuid", _state.value.devUuid).orEmpty()
- val devName = json?.optString("DevName", "").orEmpty()
- val fw = json?.optString("DevFwVer", "").orEmpty()
- _state.value = _state.value.copy(
- handshaked = true,
- devUuid = devUuid.ifBlank { _state.value.devUuid },
- deviceName = devName.ifBlank { _state.value.deviceName }
- )
- _events.tryEmit(GlassesBleEvent.HandshakeOk(devUuid = _state.value.devUuid, devName = devName, devFwVer = fw))
- emitLog("Handshake completed")
- }
- HichipsCmd.AG_GET_ALL_STATUS -> {
- val jsonText = frame.payload.decodeToString()
- _events.tryEmit(GlassesBleEvent.StatusUpdate(jsonText))
- }
- HichipsCmd.AG_P_THUMB_INFO -> {
- val source = parseJson(frame.payload)?.optString("SourceFileName", "").orEmpty()
- _events.tryEmit(GlassesBleEvent.CameraThumbInfo(sourceFileName = source, isVideo = false))
- }
- HichipsCmd.AG_V_THUMB_INFO -> {
- val source = parseJson(frame.payload)?.optString("SourceFileName", "").orEmpty()
- _events.tryEmit(GlassesBleEvent.CameraThumbInfo(sourceFileName = source, isVideo = true))
- }
- }
- }
-
- private fun parseJson(bytes: ByteArray): JSONObject? {
- if (bytes.isEmpty()) return null
- return runCatching {
- JSONObject(bytes.decodeToString())
- }.getOrNull()
- }
-
- private fun sendAgCommand(command: Int, jsonPayload: String?): Boolean {
- val payload = jsonPayload?.toByteArray(Charsets.UTF_8) ?: byteArrayOf()
- return writeFrame(write3D23, command, payload)
- }
-
- private fun sendAsrCommand(command: Int, jsonPayload: String?): Boolean {
- val payload = jsonPayload?.toByteArray(Charsets.UTF_8) ?: byteArrayOf()
- return writeFrame(write5DC3, command, payload)
- }
-
- @SuppressLint("MissingPermission")
- private fun writeFrame(
- characteristic: BluetoothGattCharacteristic?,
- command: Int,
- payload: ByteArray
- ): Boolean {
- val g = gatt ?: return false
- val c = characteristic ?: return false
- val frame = buildFrame(command = command, index = 0, payload = payload)
- return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- g.writeCharacteristic(c, frame, BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE) ==
- BluetoothStatusCodes.SUCCESS
- } else {
- @Suppress("DEPRECATION")
- run {
- c.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
- c.value = frame
- g.writeCharacteristic(c)
- }
- }
- }
-
- private fun buildFrame(command: Int, index: Int, payload: ByteArray): ByteArray {
- val buffer = ByteBuffer.allocate(18 + payload.size).order(ByteOrder.LITTLE_ENDIAN)
- buffer.put(byteArrayOf(0x48, 0x49, 0x43, 0x48)) // HICH
- buffer.putShort(command.toShort())
- buffer.putShort(index.toShort())
- buffer.putInt(payload.size)
- buffer.putShort(crc16(payload).toShort())
- if (payload.isNotEmpty()) buffer.put(payload)
- buffer.put(byteArrayOf(0x49, 0x50, 0x53, 0x45)) // IPSE
- return buffer.array()
- }
-
- private fun crc16(data: ByteArray): Int {
- var crc = 0xFFFF
- for (b in data) {
- crc = ((crc ushr 8) or ((crc and 0xFF) shl 8)) and 0xFFFF
- crc = crc xor (b.toInt() and 0xFF)
- crc = crc xor ((crc and 0xFF) ushr 4)
- crc = crc xor ((crc shl 8) shl 4)
- crc = crc xor (((crc and 0xFF) shl 4) shl 1)
- crc = crc and 0xFFFF
- }
- return crc and 0xFFFF
- }
-
- private fun emitLog(message: String) {
- _events.tryEmit(GlassesBleEvent.Log(message))
- }
-
- private fun updateError(message: String) {
- _state.value = _state.value.copy(lastError = message)
- _events.tryEmit(GlassesBleEvent.Log("ERROR: $message"))
- }
-}
diff --git a/android-app/app/src/main/java/com/aiglasses/app/data/ApiClient.kt b/android-app/app/src/main/java/com/aiglasses/app/data/ApiClient.kt
deleted file mode 100644
index f2e1a22..0000000
--- a/android-app/app/src/main/java/com/aiglasses/app/data/ApiClient.kt
+++ /dev/null
@@ -1,51 +0,0 @@
-package com.aiglasses.app.data
-
-import com.aiglasses.app.BuildConfig
-import kotlinx.serialization.json.Json
-import kotlinx.serialization.ExperimentalSerializationApi
-import java.util.concurrent.TimeUnit
-import okhttp3.MediaType.Companion.toMediaType
-import okhttp3.Protocol
-import okhttp3.OkHttpClient
-import okhttp3.Request
-import okhttp3.logging.HttpLoggingInterceptor
-import retrofit2.Retrofit
-import retrofit2.create
-import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
-
-object ApiClient {
- @OptIn(ExperimentalSerializationApi::class)
- val json = Json {
- ignoreUnknownKeys = true
- explicitNulls = false
- }
-
- inline fun createService(baseUrl: String): T {
- val logging = HttpLoggingInterceptor().apply {
- level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BASIC else HttpLoggingInterceptor.Level.NONE
- }
- val client = OkHttpClient.Builder()
- .protocols(listOf(Protocol.HTTP_1_1))
- .connectTimeout(12, TimeUnit.SECONDS)
- .readTimeout(20, TimeUnit.SECONDS)
- .writeTimeout(20, TimeUnit.SECONDS)
- .callTimeout(25, TimeUnit.SECONDS)
- .addInterceptor { chain ->
- val request: Request = chain.request().newBuilder()
- .header("Connection", "close")
- .build()
- chain.proceed(request)
- }
- .addInterceptor(logging)
- .build()
-
- val normalizedBaseUrl = if (baseUrl.endsWith("/")) baseUrl else "$baseUrl/"
-
- return Retrofit.Builder()
- .baseUrl(normalizedBaseUrl)
- .client(client)
- .addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
- .build()
- .create()
- }
-}
diff --git a/android-app/app/src/main/java/com/aiglasses/app/data/ApiService.kt b/android-app/app/src/main/java/com/aiglasses/app/data/ApiService.kt
deleted file mode 100644
index ee50332..0000000
--- a/android-app/app/src/main/java/com/aiglasses/app/data/ApiService.kt
+++ /dev/null
@@ -1,154 +0,0 @@
-package com.aiglasses.app.data
-
-import retrofit2.http.Body
-import retrofit2.http.GET
-import retrofit2.http.Header
-import retrofit2.http.POST
-import retrofit2.http.Path
-import retrofit2.http.Query
-
-interface ApiService {
- @GET("/healthz")
- suspend fun healthz(): ApiEnvelope
-
- @POST("/api/v1/devices/bind-confirm")
- suspend fun bindConfirm(
- @Body request: BindConfirmRequest
- ): ApiEnvelope
-
- @POST("/api/v1/ai/sessions")
- suspend fun createSession(
- @Header("Idempotency-Key") idempotencyKey: String?,
- @Body request: CreateSessionRequest
- ): ApiEnvelope
-
- @POST("/api/v1/ai/sessions/{sessionId}/stop")
- suspend fun stopSession(
- @Path("sessionId") sessionId: String,
- @Body request: StopSessionRequest
- ): ApiEnvelope
-
- @POST("/api/v1/ai/sessions/{sessionId}/heartbeat")
- suspend fun heartbeat(
- @Path("sessionId") sessionId: String,
- @Body request: HeartbeatRequest
- ): ApiEnvelope
-
- @GET("/api/v1/devices/{deviceId}/status")
- suspend fun getDeviceStatus(
- @Path("deviceId") deviceId: String
- ): ApiEnvelope
-
- @POST("/api/v1/events")
- suspend fun postEvent(
- @Body request: ClientEventRequest
- ): ApiEnvelope
-
- @POST("/api/v1/events/batch")
- suspend fun postEventsBatch(
- @Body request: ClientEventBatchRequest
- ): ApiEnvelope
-
- @POST("/api/v1/ai/sessions/{sessionId}/messages")
- suspend fun sendMessage(
- @Path("sessionId") sessionId: String,
- @Body request: SessionMessageRequest
- ): ApiEnvelope
-
- @POST("/api/v1/ai/sessions/{sessionId}/scene-role")
- suspend fun switchRole(
- @Path("sessionId") sessionId: String,
- @Body request: SwitchRoleRequest
- ): ApiEnvelope
-
- @POST("/api/v1/ai/sessions/{sessionId}/interrupt")
- suspend fun interruptSession(
- @Path("sessionId") sessionId: String,
- @Body request: SessionInterruptRequest
- ): ApiEnvelope
-
- @GET("/api/v1/baidu/activation/query")
- suspend fun activationQuery(
- @Query("deviceId") deviceId: String,
- @Query("appId") appId: String? = null
- ): ApiEnvelope
-
- @POST("/api/v1/licenses/reload")
- suspend fun reloadLicenses(): ApiEnvelope
-
- @GET("/api/v1/admin/overview")
- suspend fun adminOverview(): ApiEnvelope
-
- @GET("/api/v1/app/update/latest")
- suspend fun appUpdateLatest(
- @Query("platform") platform: String = "android",
- @Query("channel") channel: String = "stable",
- @Query("currentVersionCode") currentVersionCode: Int
- ): ApiEnvelope
-
- @GET("/v2/douyin/accounts")
- suspend fun listDouyinAccounts(): ApiEnvelope>
-
- @POST("/v2/douyin/accounts/sync")
- suspend fun syncDouyinAccount(
- @Body request: DouyinAccountSyncRequest
- ): ApiEnvelope
-
- @GET("/v2/douyin/accounts/{accountId}")
- suspend fun getDouyinAccount(
- @Path("accountId") accountId: String
- ): ApiEnvelope
-
- @GET("/v2/douyin/accounts/{accountId}/workspace")
- suspend fun getDouyinWorkspace(
- @Path("accountId") accountId: String
- ): ApiEnvelope
-
- @GET("/v2/douyin/accounts/{accountId}/snapshots")
- suspend fun listDouyinSnapshots(
- @Path("accountId") accountId: String
- ): ApiEnvelope>
-
- @GET("/v2/douyin/accounts/{accountId}/snapshots/{snapshotId}")
- suspend fun getDouyinSnapshot(
- @Path("accountId") accountId: String,
- @Path("snapshotId") snapshotId: String
- ): ApiEnvelope
-
- @GET("/v2/douyin/accounts/{accountId}/creator-fields")
- suspend fun getDouyinCreatorFields(
- @Path("accountId") accountId: String
- ): ApiEnvelope
-
- @POST("/v2/douyin/accounts/{accountId}/analysis")
- suspend fun analyzeDouyinAccount(
- @Path("accountId") accountId: String,
- @Body request: DouyinAccountAnalysisRequest
- ): ApiEnvelope
-
- @GET("/v2/douyin/accounts/{accountId}/analysis-reports")
- suspend fun listDouyinAnalysisReports(
- @Path("accountId") accountId: String
- ): ApiEnvelope>
-
- @POST("/v2/douyin/similar-searches")
- suspend fun createDouyinSimilarSearch(
- @Body request: DouyinSimilarSearchRequest
- ): ApiEnvelope
-
- @GET("/v2/douyin/similar-searches/{searchId}")
- suspend fun getDouyinSimilarSearch(
- @Path("searchId") searchId: String
- ): ApiEnvelope
-
- @GET("/v2/douyin/accounts/{accountId}/benchmark-links")
- suspend fun listDouyinBenchmarkLinks(
- @Path("accountId") accountId: String
- ): ApiEnvelope>
-
- @POST("/v2/douyin/accounts/{accountId}/benchmark-links")
- suspend fun createDouyinBenchmarkLinks(
- @Path("accountId") accountId: String,
- @Body request: DouyinBenchmarkLinkRequest
- ): ApiEnvelope
-}
diff --git a/android-app/app/src/main/java/com/aiglasses/app/data/BackendRepository.kt b/android-app/app/src/main/java/com/aiglasses/app/data/BackendRepository.kt
deleted file mode 100644
index f1b77f1..0000000
--- a/android-app/app/src/main/java/com/aiglasses/app/data/BackendRepository.kt
+++ /dev/null
@@ -1,276 +0,0 @@
-package com.aiglasses.app.data
-
-import java.util.UUID
-
-class BackendRepository(private var baseUrl: String) {
- private var api: ApiService = ApiClient.createService(baseUrl)
-
- fun updateBaseUrl(url: String) {
- if (url != baseUrl) {
- baseUrl = url
- api = ApiClient.createService(baseUrl)
- }
- }
-
- suspend fun bindDevice(deviceId: String, userId: String): BindConfirmData {
- val resp = api.bindConfirm(BindConfirmRequest(deviceId = deviceId, appUserId = userId))
- return resp.data
- }
-
- suspend fun healthz(): HealthzData {
- val resp = api.healthz()
- return resp.data
- }
-
- suspend fun createSession(deviceId: String, userId: String): SessionData {
- val idempotencyKey = "app-${UUID.randomUUID()}"
- val resp = api.createSession(
- idempotencyKey = idempotencyKey,
- request = CreateSessionRequest(deviceId = deviceId, appUserId = userId)
- )
- return resp.data
- }
-
- suspend fun stopSession(sessionId: String): StopSessionData {
- val resp = api.stopSession(sessionId, StopSessionRequest())
- return resp.data
- }
-
- suspend fun heartbeat(sessionId: String): HeartbeatData {
- val resp = api.heartbeat(sessionId, HeartbeatRequest())
- return resp.data
- }
-
- suspend fun getDeviceStatus(deviceId: String): DeviceStatusData {
- val resp = api.getDeviceStatus(deviceId)
- return resp.data
- }
-
- suspend fun postDemoEvent(deviceId: String, sessionId: String?): EventSavedData {
- return postEvent(
- deviceId = deviceId,
- sessionId = sessionId,
- eventType = "APP_DEBUG_PING",
- eventLevel = "INFO",
- payload = mapOf("source" to "android")
- )
- }
-
- suspend fun postEvent(
- deviceId: String,
- sessionId: String?,
- eventType: String,
- eventLevel: String = "INFO",
- payload: Map = emptyMap()
- ): EventSavedData {
- val resp = api.postEvent(
- ClientEventRequest(
- sessionId = sessionId,
- deviceId = deviceId,
- eventType = eventType,
- eventLevel = eventLevel,
- payload = payload
- )
- )
- return resp.data
- }
-
- suspend fun postEventsBatch(events: List): EventsBatchSavedData {
- val resp = api.postEventsBatch(ClientEventBatchRequest(events = events))
- return resp.data
- }
-
- suspend fun sendMessage(sessionId: String, message: String): ProviderActionData {
- val resp = api.sendMessage(
- sessionId = sessionId,
- request = SessionMessageRequest(message = message)
- )
- return resp.data
- }
-
- suspend fun sendVoiceMessage(
- sessionId: String,
- pcmBase64: String,
- sampleRate: Int,
- durationMs: Int,
- rms: Int
- ): ProviderActionData {
- val resp = api.sendMessage(
- sessionId = sessionId,
- request = SessionMessageRequest(
- message = "voice_chunk",
- messageType = "voice",
- extra = mapOf(
- "audio_base64" to pcmBase64,
- "audio_format" to "pcm_s16le",
- "sample_rate" to sampleRate.toString(),
- "channels" to "1",
- "duration_ms" to durationMs.toString(),
- "rms" to rms.toString(),
- "encoding" to "base64"
- )
- )
- )
- return resp.data
- }
-
- suspend fun sendVisionMessage(
- sessionId: String,
- message: String,
- imageBase64: String,
- width: Int,
- height: Int,
- bytes: Int
- ): ProviderActionData {
- val resp = api.sendMessage(
- sessionId = sessionId,
- request = SessionMessageRequest(
- message = message,
- messageType = "text",
- extra = mapOf(
- "image_base64" to imageBase64,
- "imageBase64" to imageBase64,
- "image" to imageBase64,
- "resource_base64" to imageBase64,
- "resourceBase64" to imageBase64,
- "image_encoding" to "base64",
- "imageEncoding" to "base64",
- "encoding" to "base64",
- "image_format" to "jpeg",
- "imageFormat" to "jpeg",
- "mime_type" to "image/jpeg",
- "mimeType" to "image/jpeg",
- "image_width" to width.toString(),
- "imageWidth" to width.toString(),
- "image_height" to height.toString(),
- "imageHeight" to height.toString(),
- "image_bytes" to bytes.toString(),
- "imageBytes" to bytes.toString(),
- "resource_type" to "image",
- "resourceType" to "image",
- "camera_source" to "android_phone",
- "multimodal" to "true",
- "with_vision" to "1"
- )
- )
- )
- return resp.data
- }
-
- suspend fun switchRole(sessionId: String, sceneId: String, roleId: String): ProviderActionData {
- val resp = api.switchRole(
- sessionId = sessionId,
- request = SwitchRoleRequest(sceneId = sceneId, roleId = roleId)
- )
- return resp.data
- }
-
- suspend fun interrupt(
- sessionId: String,
- interrupt: Boolean,
- extra: Map = emptyMap()
- ): ProviderActionData {
- val resp = api.interruptSession(
- sessionId = sessionId,
- request = SessionInterruptRequest(interrupt = interrupt, extra = extra)
- )
- return resp.data
- }
-
- suspend fun activationQuery(deviceId: String): ActivationQueryData {
- val resp = api.activationQuery(deviceId = deviceId)
- return resp.data
- }
-
- suspend fun reloadLicenses(): ReloadLicensesData {
- val resp = api.reloadLicenses()
- return resp.data
- }
-
- suspend fun adminOverview(): AdminOverviewData {
- val resp = api.adminOverview()
- return resp.data
- }
-
- suspend fun appUpdateLatest(currentVersionCode: Int): AppUpdateLatestData {
- val resp = api.appUpdateLatest(
- platform = "android",
- channel = "stable",
- currentVersionCode = currentVersionCode
- )
- return resp.data
- }
-
- suspend fun listDouyinAccounts(): List {
- val resp = api.listDouyinAccounts()
- return resp.data
- }
-
- suspend fun syncDouyinAccount(request: DouyinAccountSyncRequest): DouyinAccountWorkspace {
- val resp = api.syncDouyinAccount(request)
- return resp.data
- }
-
- suspend fun getDouyinAccount(accountId: String): DouyinAccountWorkspace {
- val resp = api.getDouyinAccount(accountId)
- return resp.data
- }
-
- suspend fun getDouyinWorkspace(accountId: String): DouyinAccountWorkspace {
- val resp = api.getDouyinWorkspace(accountId)
- return resp.data
- }
-
- suspend fun listDouyinSnapshots(accountId: String): List {
- val resp = api.listDouyinSnapshots(accountId)
- return resp.data
- }
-
- suspend fun getDouyinSnapshot(accountId: String, snapshotId: String): DouyinSnapshotDetail {
- val resp = api.getDouyinSnapshot(accountId, snapshotId)
- return resp.data
- }
-
- suspend fun getDouyinCreatorFields(accountId: String): DouyinSnapshotDetail {
- val resp = api.getDouyinCreatorFields(accountId)
- return resp.data
- }
-
- suspend fun analyzeDouyinAccount(
- accountId: String,
- request: DouyinAccountAnalysisRequest
- ): DouyinAnalysisResult {
- val resp = api.analyzeDouyinAccount(accountId, request)
- return resp.data
- }
-
- suspend fun listDouyinAnalysisReports(accountId: String): List {
- val resp = api.listDouyinAnalysisReports(accountId)
- return resp.data
- }
-
- suspend fun createDouyinSimilarSearch(
- request: DouyinSimilarSearchRequest
- ): DouyinSimilaritySearchResult {
- val resp = api.createDouyinSimilarSearch(request)
- return resp.data
- }
-
- suspend fun getDouyinSimilarSearch(searchId: String): DouyinSimilaritySearchDetail {
- val resp = api.getDouyinSimilarSearch(searchId)
- return resp.data
- }
-
- suspend fun listDouyinBenchmarkLinks(accountId: String): List {
- val resp = api.listDouyinBenchmarkLinks(accountId)
- return resp.data
- }
-
- suspend fun createDouyinBenchmarkLinks(
- accountId: String,
- request: DouyinBenchmarkLinkRequest
- ): DouyinBenchmarkLinkResult {
- val resp = api.createDouyinBenchmarkLinks(accountId, request)
- return resp.data
- }
-}
diff --git a/android-app/app/src/main/java/com/aiglasses/app/data/Models.kt b/android-app/app/src/main/java/com/aiglasses/app/data/Models.kt
deleted file mode 100644
index 74f6c2b..0000000
--- a/android-app/app/src/main/java/com/aiglasses/app/data/Models.kt
+++ /dev/null
@@ -1,540 +0,0 @@
-package com.aiglasses.app.data
-
-import kotlinx.serialization.SerialName
-import kotlinx.serialization.Serializable
-import kotlinx.serialization.json.JsonElement
-import kotlinx.serialization.json.JsonObject
-
-@Serializable
-data class ApiEnvelope(
- val code: Int,
- val message: String,
- val traceId: String,
- val data: T
-)
-
-@Serializable
-data class HealthzData(
- val status: String = "",
- val env: String = "",
- val dbPath: String = ""
-)
-
-@Serializable
-data class BindConfirmRequest(
- val deviceId: String,
- val deviceSn: String? = null,
- val deviceModel: String? = null,
- val deviceFwVer: String? = null,
- val appUserId: String
-)
-
-@Serializable
-data class BindConfirmData(
- val bindStatus: String,
- val licenseStatus: String,
- val licenseKeyMasked: String,
- val licenseKey: String = ""
-)
-
-@Serializable
-data class CreateSessionRequest(
- val deviceId: String,
- val appUserId: String,
- val scene: String = "voice_assistant",
- val language: String = "zh-CN",
- val clientTs: Long? = null
-)
-
-@Serializable
-data class SessionData(
- val sessionId: String,
- val provider: String,
- val cid: String,
- val token: String,
- val tokenExpireAt: Long,
- val wsUrl: String,
- val heartbeatSec: Int,
- val appId: String = "",
- val context: String = "",
- val realtimeWsUrl: String = ""
-)
-
-@Serializable
-data class StopSessionRequest(
- val reason: String = "user_stop"
-)
-
-@Serializable
-data class StopSessionData(
- val sessionStatus: String
-)
-
-@Serializable
-data class HeartbeatRequest(
- val networkType: String? = "wifi",
- val bleRssi: Int? = null
-)
-
-@Serializable
-data class HeartbeatData(
- val sessionStatus: String,
- val heartbeatAt: Long
-)
-
-@Serializable
-data class ClientEventRequest(
- val sessionId: String? = null,
- val deviceId: String,
- val eventType: String,
- val eventLevel: String = "INFO",
- val payload: Map = emptyMap(),
- val ts: Long? = null
-)
-
-@Serializable
-data class ClientEventBatchRequest(
- val events: List = emptyList()
-)
-
-@Serializable
-data class EventSavedData(
- val saved: Boolean
-)
-
-@Serializable
-data class EventsBatchSavedData(
- val saved: Int = 0
-)
-
-@Serializable
-data class SessionMessageRequest(
- val message: String,
- val messageType: String = "text",
- val messageId: String? = null,
- val extra: Map = emptyMap()
-)
-
-@Serializable
-data class ProviderActionData(
- val status: String = "UNKNOWN",
- val detail: String = "",
- val asrText: String = "",
- val ttsText: String = "",
- val audioBase64: String = "",
- val audioUrl: String = ""
-)
-
-@Serializable
-data class SwitchRoleRequest(
- val sceneId: String,
- val roleId: String,
- val extra: Map = emptyMap()
-)
-
-@Serializable
-data class SessionInterruptRequest(
- val interrupt: Boolean = true,
- val extra: Map = emptyMap()
-)
-
-@Serializable
-data class DeviceStatusData(
- val bindStatus: String,
- val licenseStatus: String,
- val activeSessionId: String? = null,
- val activeSessionStatus: String? = null
-)
-
-@Serializable
-data class AdminStats(
- val totalDevices: Int = 0,
- val totalSessions: Int = 0,
- val runningSessions: Int = 0,
- val totalLicenses: Int = 0,
- val usedLicenseQuota: Int = 0
-)
-
-@Serializable
-data class BaiduInfo(
- val mode: String = "-",
- val generateConfigured: Boolean = false,
- val stopConfigured: Boolean = false,
- val activationQueryConfigured: Boolean = false
-)
-
-@Serializable
-data class AdminOverviewData(
- val stats: AdminStats = AdminStats(),
- val baidu: BaiduInfo = BaiduInfo()
-)
-
-@Serializable
-data class ActivationQueryData(
- val deviceId: String = "",
- val appId: String = "",
- val status: String = "UNKNOWN",
- val detail: String = "",
- val licenseKeyMasked: String = ""
-)
-
-@Serializable
-data class ReloadLicensesData(
- val inserted: Int = 0
-)
-
-@Serializable
-data class AppUpdateLatestData(
- val platform: String = "android",
- val channel: String = "stable",
- val hasUpdate: Boolean = false,
- val latestVersionCode: Int = 0,
- val latestVersionName: String = "",
- val minSupportedCode: Int = 0,
- val downloadUrl: String = "",
- val apkSha256: String = "",
- val releaseNotes: String = "",
- val forceUpdate: Boolean = false,
- val publishedAt: Long = 0L
-)
-
-@Serializable
-data class DouyinManualPageCaptureRequest(
- val url: String = "",
- val title: String = "",
- val payload: JsonObject = JsonObject(emptyMap())
-)
-
-@Serializable
-data class DouyinAccountSyncRequest(
- @SerialName("profile_url")
- val profileUrl: String = "",
- @SerialName("session_cookie")
- val sessionCookie: String = "",
- @SerialName("creator_center_urls")
- val creatorCenterUrls: List = emptyList(),
- @SerialName("manual_profile_payload")
- val manualProfilePayload: JsonObject? = null,
- @SerialName("manual_creator_pages")
- val manualCreatorPages: List = emptyList(),
- @SerialName("manual_work_payloads")
- val manualWorkPayloads: List = emptyList(),
- @SerialName("discovery_note")
- val discoveryNote: String = ""
-)
-
-@Serializable
-data class DouyinProfileStats(
- val followers: Double = 0.0,
- val following: Double = 0.0,
- val likes: Double = 0.0,
- val videos: Double = 0.0
-)
-
-@Serializable
-data class DouyinVideoStats(
- val play: Double = 0.0,
- val like: Double = 0.0,
- val comment: Double = 0.0,
- val share: Double = 0.0,
- val collect: Double = 0.0
-)
-
-@Serializable
-data class DouyinVideoSummaryItem(
- @SerialName("aweme_id")
- val awemeId: String = "",
- val title: String = "",
- val description: String = "",
- val tags: List = emptyList(),
- @SerialName("published_at")
- val publishedAt: String? = null,
- val stats: DouyinVideoStats = DouyinVideoStats()
-)
-
-@Serializable
-data class DouyinVideoSummary(
- val count: Int = 0,
- @SerialName("top_tags")
- val topTags: List = emptyList(),
- @SerialName("avg_play")
- val avgPlay: Double = 0.0,
- @SerialName("avg_like")
- val avgLike: Double = 0.0,
- @SerialName("avg_comment")
- val avgComment: Double = 0.0,
- @SerialName("avg_share")
- val avgShare: Double = 0.0,
- val videos: List = emptyList()
-)
-
-@Serializable
-data class DouyinAccountSummary(
- val id: String = "",
- val nickname: String = "",
- val signature: String = "",
- @SerialName("profile_url")
- val profileUrl: String = "",
- @SerialName("avatar_url")
- val avatarUrl: String = "",
- @SerialName("sec_uid")
- val secUid: String = "",
- @SerialName("douyin_id")
- val douyinId: String = "",
- @SerialName("profile_stats")
- val profileStats: DouyinProfileStats = DouyinProfileStats(),
- val tags: List = emptyList(),
- val keywords: List = emptyList(),
- @SerialName("sync_status")
- val syncStatus: String = "",
- @SerialName("video_summary")
- val videoSummary: DouyinVideoSummary = DouyinVideoSummary()
-)
-
-@Serializable
-data class DouyinSnapshotSummary(
- val id: String = "",
- @SerialName("snapshot_type")
- val snapshotType: String = "",
- @SerialName("source_url")
- val sourceUrl: String = "",
- @SerialName("field_count")
- val fieldCount: Int = 0,
- @SerialName("collected_at")
- val collectedAt: String = "",
- val summary: JsonObject = JsonObject(emptyMap())
-)
-
-@Serializable
-data class DouyinModelProfileSummary(
- val id: String = "",
- val name: String = "",
- @SerialName("model_name")
- val modelName: String = "",
- @SerialName("base_url")
- val baseUrl: String = "",
- @SerialName("is_default")
- val isDefault: Boolean = false
-)
-
-@Serializable
-data class DouyinAnalysisSuggestion(
- val id: String = "",
- @SerialName("model_profile_id")
- val modelProfileId: String = "",
- @SerialName("model_label")
- val modelLabel: String = "",
- val status: String = "",
- @SerialName("suggestion_text")
- val suggestionText: String = "",
- @SerialName("parsed_json")
- val parsedJson: JsonElement = JsonObject(emptyMap())
-)
-
-@Serializable
-data class DouyinAnalysisReport(
- val id: String = "",
- @SerialName("focus_text")
- val focusText: String = "",
- @SerialName("model_profile_ids")
- val modelProfileIds: List = emptyList(),
- @SerialName("linked_account_ids")
- val linkedAccountIds: List = emptyList(),
- @SerialName("created_at")
- val createdAt: String = "",
- val suggestions: List = emptyList()
-)
-
-@Serializable
-data class DouyinSimilaritySearchPreview(
- val id: String = "",
- val keywords: List = emptyList(),
- @SerialName("created_at")
- val createdAt: String = ""
-)
-
-@Serializable
-data class DouyinLinkedAccount(
- @SerialName("relation_id")
- val relationId: String = "",
- @SerialName("relation_type")
- val relationType: String = "",
- val note: String = "",
- @SerialName("search_id")
- val searchId: String = "",
- @SerialName("created_at")
- val createdAt: String = "",
- @SerialName("target_account_id")
- val targetAccountId: String? = null,
- @SerialName("target_profile_url")
- val targetProfileUrl: String = "",
- @SerialName("target_nickname")
- val targetNickname: String = "",
- @SerialName("target_signature")
- val targetSignature: String = "",
- @SerialName("target_profile_stats")
- val targetProfileStats: DouyinProfileStats = DouyinProfileStats(),
- @SerialName("target_tags")
- val targetTags: List = emptyList()
-)
-
-@Serializable
-data class DouyinAccountWorkspace(
- val account: DouyinAccountSummary = DouyinAccountSummary(),
- @SerialName("latest_public_snapshot")
- val latestPublicSnapshot: DouyinSnapshotSummary? = null,
- @SerialName("latest_creator_snapshot")
- val latestCreatorSnapshot: DouyinSnapshotSummary? = null,
- @SerialName("linked_accounts")
- val linkedAccounts: List = emptyList(),
- @SerialName("recent_reports")
- val recentReports: List = emptyList(),
- @SerialName("recent_similarity_searches")
- val recentSimilaritySearches: List = emptyList(),
- @SerialName("available_model_profiles")
- val availableModelProfiles: List = emptyList(),
- @SerialName("sync_errors")
- val syncErrors: List = emptyList()
-)
-
-@Serializable
-data class DouyinAccountAnalysisRequest(
- @SerialName("model_profile_ids")
- val modelProfileIds: List = emptyList(),
- @SerialName("linked_account_ids")
- val linkedAccountIds: List = emptyList(),
- @SerialName("include_linked_accounts")
- val includeLinkedAccounts: Boolean = true,
- @SerialName("include_recent_similar_candidates")
- val includeRecentSimilarCandidates: Boolean = true,
- @SerialName("max_videos")
- val maxVideos: Int = 12,
- @SerialName("extra_focus")
- val extraFocus: String = "",
- val temperature: Double = 0.35
-)
-
-@Serializable
-data class DouyinAnalysisResult(
- @SerialName("report_id")
- val reportId: String = "",
- @SerialName("created_at")
- val createdAt: String = "",
- val context: JsonElement = JsonObject(emptyMap()),
- val suggestions: List = emptyList()
-)
-
-@Serializable
-data class DouyinSimilarSearchRequest(
- @SerialName("source_account_id")
- val sourceAccountId: String? = null,
- @SerialName("profile_url")
- val profileUrl: String? = null,
- @SerialName("candidate_urls")
- val candidateUrls: List = emptyList(),
- @SerialName("seed_linked_accounts")
- val seedLinkedAccounts: Boolean = true,
- @SerialName("search_public_pages")
- val searchPublicPages: Boolean = true,
- @SerialName("model_profile_id")
- val modelProfileId: String? = null,
- @SerialName("max_candidates")
- val maxCandidates: Int = 10,
- @SerialName("extra_requirements")
- val extraRequirements: String = ""
-)
-
-@Serializable
-data class DouyinSimilarCandidate(
- val id: String = "",
- @SerialName("candidate_account_id")
- val candidateAccountId: String? = null,
- @SerialName("candidate_profile_url")
- val candidateProfileUrl: String = "",
- @SerialName("candidate_nickname")
- val candidateNickname: String = "",
- @SerialName("heuristic_score")
- val heuristicScore: Double = 0.0,
- @SerialName("agent_score")
- val agentScore: Double = 0.0,
- @SerialName("rationale_text")
- val rationaleText: String = "",
- val dimensions: JsonElement = JsonObject(emptyMap()),
- @SerialName("rank_index")
- val rankIndex: Int = 0
-)
-
-@Serializable
-data class DouyinSimilaritySearchResult(
- @SerialName("search_id")
- val searchId: String = "",
- @SerialName("source_account")
- val sourceAccount: DouyinAccountSummary = DouyinAccountSummary(),
- @SerialName("model_profile")
- val modelProfile: JsonObject = JsonObject(emptyMap()),
- @SerialName("raw_model_output")
- val rawModelOutput: String = "",
- val candidates: List = emptyList()
-)
-
-@Serializable
-data class DouyinSimilaritySearchDetail(
- val id: String = "",
- @SerialName("source_account_id")
- val sourceAccountId: String? = null,
- @SerialName("source_profile_url")
- val sourceProfileUrl: String = "",
- val keywords: List = emptyList(),
- val context: JsonElement = JsonObject(emptyMap()),
- @SerialName("created_at")
- val createdAt: String = "",
- val candidates: List = emptyList()
-)
-
-@Serializable
-data class DouyinBenchmarkLinkRequest(
- @SerialName("target_account_ids")
- val targetAccountIds: List = emptyList(),
- @SerialName("target_profile_urls")
- val targetProfileUrls: List = emptyList(),
- @SerialName("relation_type")
- val relationType: String = "benchmark",
- val note: String = "",
- @SerialName("search_id")
- val searchId: String = ""
-)
-
-@Serializable
-data class DouyinBenchmarkLinkResult(
- val saved: Int = 0,
- @SerialName("relation_ids")
- val relationIds: List = emptyList(),
- val links: List = emptyList()
-)
-
-@Serializable
-data class DouyinSnapshotField(
- @SerialName("field_path")
- val fieldPath: String = "",
- @SerialName("field_type")
- val fieldType: String = "",
- @SerialName("field_value_text")
- val fieldValueText: String = ""
-)
-
-@Serializable
-data class DouyinSnapshotDetail(
- val id: String = "",
- @SerialName("snapshot_type")
- val snapshotType: String = "",
- @SerialName("source_url")
- val sourceUrl: String = "",
- @SerialName("field_count")
- val fieldCount: Int = 0,
- @SerialName("collected_at")
- val collectedAt: String = "",
- val summary: JsonObject = JsonObject(emptyMap()),
- @SerialName("raw_payload")
- val rawPayload: JsonElement = JsonObject(emptyMap()),
- val fields: List = emptyList()
-)
diff --git a/android-app/app/src/main/java/com/aiglasses/app/software/BaiduConversationAgent.kt b/android-app/app/src/main/java/com/aiglasses/app/software/BaiduConversationAgent.kt
deleted file mode 100644
index c08aa0c..0000000
--- a/android-app/app/src/main/java/com/aiglasses/app/software/BaiduConversationAgent.kt
+++ /dev/null
@@ -1,325 +0,0 @@
-package com.aiglasses.app.software
-
-import android.content.Context
-import android.os.Bundle
-import android.os.Handler
-import android.os.Looper
-import android.os.SystemClock
-import com.baidu.rtc.agent.AIAgentEngine
-import com.baidu.rtc.agent.AIAgentEngineCallback
-import com.baidu.rtc.agent.Constants
-import java.io.File
-
-private const val BAIDU_AGENT_RECONNECT_DELAY_MS = 900L
-private const val BAIDU_IMAGE_UPLOAD_EXPIRE_SECONDS = 0
-
-class BaiduConversationAgent(
- context: Context,
- private val onLog: (String) -> Unit,
- private val onCallReady: () -> Unit,
- private val onCallEnded: (String) -> Unit,
- private val onFinalAsr: (String) -> Unit,
- private val onAgentText: (String) -> Unit,
- private val onTtsStart: () -> Unit,
- private val onTtsEnd: () -> Unit,
- private val onPlaybackAudio: (pcm: ByteArray, sampleRate: Int, channelCount: Int) -> Unit,
- private val onImageUploadRequest: () -> Unit,
-) {
- private val appContext = context.applicationContext
- private val mainHandler = Handler(Looper.getMainLooper())
-
- private var engine: AIAgentEngine? = null
- private var session: SessionConfig? = null
- private var running = false
- private var callBegun = false
- private var reconnectScheduled = false
- private var stopRequested = false
- private var pendingUploadFile: File? = null
-
- private val callback = object : AIAgentEngineCallback() {
- override fun onConnectionStateChange(state: Int) {
- onLog("Baidu agent connection state=$state")
- }
-
- override fun onCallStateChange(state: Int) {
- when (state) {
- Constants.CallState.ON_CALL_BEGIN -> {
- callBegun = true
- onLog("Baidu agent call begin")
- onCallReady()
- flushPendingUpload()
- }
-
- Constants.CallState.ON_CALL_END -> {
- callBegun = false
- onLog("Baidu agent call ended")
- onCallEnded("call_end")
- if (running && !stopRequested) {
- scheduleReconnect("call_end")
- }
- }
- }
- }
-
- override fun onError(error: Int, msg: String?, bundle: Bundle?) {
- onLog("Baidu agent error: code=$error, msg=${msg?.take(80) ?: "-"}")
- onCallEnded("error:$error")
- if (running && !stopRequested) {
- restart("error:$error")
- }
- }
-
- override fun onLicenseStatus(code: Int) {
- onLog("Baidu agent license status=$code")
- }
-
- override fun onUserAsrSubtitle(text: String?, isFinal: Boolean) {
- if (!isFinal) return
- val normalized = sanitizeText(text.orEmpty())
- if (normalized.isNotBlank()) {
- onFinalAsr(normalized)
- }
- }
-
- override fun onAIAgentSubtitle(text: String?, isFinal: Boolean) {
- if (!isFinal) return
- val normalized = sanitizeText(text.orEmpty())
- if (normalized.isNotBlank()) {
- onAgentText(normalized)
- }
- }
-
- override fun onAIAgentAudioStateChange(newState: Int) {
- when (newState) {
- Constants.AIAgentAudioStateType.SPEAKING -> onTtsStart()
- Constants.AIAgentAudioStateType.STOPPED -> onTtsEnd()
- }
- }
-
- override fun onPlaybackAudioFrame(data: ByteArray?, sampleRate: Int, channelCount: Int) {
- val frame = data ?: return
- if (frame.isEmpty()) return
- onPlaybackAudio(frame, sampleRate, channelCount)
- }
-
- override fun onAgentIntent(type: String?, bundle: Bundle?) {
- if (type == Constants.AgentIntentType.IMAGE_UPLOAD) {
- onImageUploadRequest()
- }
- }
-
- override fun onUploadFileStatus(code: Int, msg: String?) {
- onLog("Baidu visual upload status: code=$code, msg=${msg?.take(80) ?: "-"}")
- }
-
- override fun onMessage(message: String?) {
- val text = sanitizeText(message.orEmpty())
- if (text.isNotBlank()) {
- onLog("Baidu agent message: ${text.take(120)}")
- }
- }
- }
-
- fun updateSession(
- appId: String,
- cid: String,
- token: String,
- contextJson: String,
- deviceId: String,
- appUserId: String,
- licenseKey: String,
- ) {
- val next = SessionConfig(
- appId = appId.trim(),
- cid = cid.trim(),
- token = token.trim(),
- contextJson = contextJson.trim(),
- deviceId = deviceId.trim(),
- appUserId = appUserId.trim(),
- licenseKey = licenseKey.trim(),
- )
- val changed = next != session
- session = next
- if (running && changed) {
- onLog("Baidu session updated, restarting agent")
- restart("session_updated")
- }
- }
-
- fun start() {
- running = true
- stopRequested = false
- startIfReady()
- }
-
- fun stop() {
- running = false
- stopRequested = true
- reconnectScheduled = false
- mainHandler.removeCallbacksAndMessages(RECONNECT_TOKEN)
- pendingUploadFile?.let { safeDelete(it) }
- pendingUploadFile = null
- destroyEngine()
- }
-
- fun isCallActive(): Boolean = callBegun
-
- fun pushAudioFrame(pcm: ByteArray, sampleRate: Int, channelCount: Int) {
- if (!callBegun || pcm.isEmpty()) return
- runCatching {
- engine?.pushAudioFrame(pcm, System.nanoTime(), sampleRate, channelCount)
- }.onFailure {
- onLog("Baidu audio push failed: ${it.message}")
- }
- }
-
- fun interrupt() {
- if (!callBegun) return
- runCatching { engine?.interrupt() }
- .onFailure { onLog("Baidu interrupt failed: ${it.message}") }
- }
-
- fun uploadJpeg(jpegBytes: ByteArray): Boolean {
- val file = prepareUploadFile(jpegBytes) ?: return false
- if (!callBegun) {
- pendingUploadFile?.let { safeDelete(it) }
- pendingUploadFile = file
- onLog("Baidu visual upload queued: waiting call begin")
- return true
- }
- return sendUploadFile(file)
- }
-
- private fun startIfReady() {
- if (!running || engine != null) return
- val cfg = session ?: run {
- onLog("Baidu agent start pending: session missing")
- return
- }
- if (cfg.appId.isBlank() || cfg.cid.isBlank() || cfg.token.isBlank()) {
- onLog("Baidu agent start pending: missing appId/cid/token")
- return
- }
- val cidLong = cfg.cid.toLongOrNull()
- if (cidLong == null) {
- onLog("Baidu agent start failed: cid not numeric")
- return
- }
- val params = AIAgentEngine.AIAgentEngineParams().apply {
- appId = cfg.appId
- workflow = "voiceChat"
- aiAgentInstanceId = cidLong
- context = cfg.contextJson
- verbose = true
- enableExternalAudioInput = true
- enableExternalAudioOutput = true
- enableVoiceInterrupt = false
- licenseKey = cfg.licenseKey
- // SDK internal license activation sends devId=userId, so this must be the device identity.
- userId = cfg.deviceId
- }
- val nextEngine = runCatching { AIAgentEngine.init(appContext, params) }
- .onFailure { onLog("Baidu agent init failed: ${it.message}") }
- .getOrNull() ?: return
- engine = nextEngine
- nextEngine.setCallback(callback)
- onLog(
- "Baidu agent calling: cid=${cfg.cid}, deviceId=${cfg.deviceId}, " +
- "appUserId=${cfg.appUserId}, contextLen=${cfg.contextJson.length}"
- )
- runCatching {
- nextEngine.call(cfg.token, cidLong)
- nextEngine.switchToSpeaker(true)
- }.onFailure {
- onLog("Baidu agent call failed: ${it.message}")
- destroyEngine()
- scheduleReconnect("call_failed")
- }
- }
-
- private fun restart(reason: String) {
- destroyEngine()
- scheduleReconnect(reason)
- }
-
- private fun scheduleReconnect(reason: String) {
- if (!running || reconnectScheduled) return
- reconnectScheduled = true
- onLog("Baidu agent reconnect scheduled: $reason")
- mainHandler.postAtTime(
- {
- reconnectScheduled = false
- if (!running) return@postAtTime
- startIfReady()
- },
- RECONNECT_TOKEN,
- SystemClock.uptimeMillis() + BAIDU_AGENT_RECONNECT_DELAY_MS
- )
- }
-
- private fun destroyEngine() {
- val current = engine ?: run {
- callBegun = false
- return
- }
- engine = null
- callBegun = false
- runCatching { current.hangup() }
- runCatching { current.destroy() }
- }
-
- private fun flushPendingUpload() {
- val pending = pendingUploadFile ?: return
- pendingUploadFile = null
- sendUploadFile(pending)
- }
-
- private fun sendUploadFile(file: File): Boolean {
- val current = engine ?: run {
- safeDelete(file)
- return false
- }
- val ok = runCatching { current.uploadFile(file.absolutePath, BAIDU_IMAGE_UPLOAD_EXPIRE_SECONDS) }
- .onFailure { onLog("Baidu visual upload call failed: ${it.message}") }
- .getOrDefault(false)
- if (ok) {
- onLog("Baidu visual upload sent: ${file.name}, bytes=${file.length()}")
- mainHandler.postDelayed({ safeDelete(file) }, 60_000L)
- } else {
- safeDelete(file)
- onLog("Baidu visual upload send failed")
- }
- return ok
- }
-
- private fun prepareUploadFile(jpegBytes: ByteArray): File? {
- return runCatching {
- val dir = File(appContext.cacheDir, "baidu_uploads").apply { mkdirs() }
- File.createTempFile("vision_", ".jpg", dir).apply { writeBytes(jpegBytes) }
- }.onFailure {
- onLog("Baidu visual file prepare failed: ${it.message}")
- }.getOrNull()
- }
-
- private fun sanitizeText(raw: String): String {
- return raw.substringBefore("|||").trim()
- }
-
- private fun safeDelete(file: File) {
- runCatching { file.delete() }
- }
-
- private data class SessionConfig(
- val appId: String,
- val cid: String,
- val token: String,
- val contextJson: String,
- val deviceId: String,
- val appUserId: String,
- val licenseKey: String,
- )
-
- private companion object {
- val RECONNECT_TOKEN = Any()
- }
-}
diff --git a/android-app/app/src/main/java/com/aiglasses/app/software/BaiduRealtimeWsClient.kt b/android-app/app/src/main/java/com/aiglasses/app/software/BaiduRealtimeWsClient.kt
deleted file mode 100644
index be9a60e..0000000
--- a/android-app/app/src/main/java/com/aiglasses/app/software/BaiduRealtimeWsClient.kt
+++ /dev/null
@@ -1,98 +0,0 @@
-package com.aiglasses.app.software
-
-import java.util.concurrent.TimeUnit
-import okhttp3.OkHttpClient
-import okhttp3.Request
-import okhttp3.Response
-import okhttp3.WebSocket
-import okhttp3.WebSocketListener
-import okio.ByteString
-import okio.ByteString.Companion.toByteString
-
-class BaiduRealtimeWsClient(
- private val onLog: (String) -> Unit,
- private val onOpen: () -> Unit,
- private val onText: (String) -> Unit,
- private val onBinary: (ByteArray) -> Unit,
- private val onClosed: (reason: String, byClient: Boolean) -> Unit,
-) {
- private val client = OkHttpClient.Builder()
- .retryOnConnectionFailure(true)
- .pingInterval(20, TimeUnit.SECONDS)
- .build()
-
- @Volatile
- private var webSocket: WebSocket? = null
-
- @Volatile
- private var closedByClient = false
-
- fun connect(url: String) {
- disconnect("reconnect")
- closedByClient = false
- val request = Request.Builder().url(url).build()
- webSocket = client.newWebSocket(request, listener)
- }
-
- fun disconnect(reason: String = "client_stop") {
- closedByClient = true
- val current = webSocket
- webSocket = null
- runCatching { current?.close(1000, reason) }
- runCatching { current?.cancel() }
- }
-
- fun sendText(text: String): Boolean {
- return runCatching { webSocket?.send(text) == true }
- .onFailure { onLog("Realtime WS send text failed: ${it.message}") }
- .getOrDefault(false)
- }
-
- fun sendBinary(bytes: ByteArray): Boolean {
- if (bytes.isEmpty()) return false
- return runCatching { webSocket?.send(bytes.toByteString()) == true }
- .onFailure { onLog("Realtime WS send binary failed: ${it.message}") }
- .getOrDefault(false)
- }
-
- fun release() {
- disconnect("release")
- runCatching { client.dispatcher.executorService.shutdown() }
- runCatching { client.connectionPool.evictAll() }
- }
-
- private val listener = object : WebSocketListener() {
- override fun onOpen(webSocket: WebSocket, response: Response) {
- this@BaiduRealtimeWsClient.webSocket = webSocket
- onOpen()
- }
-
- override fun onMessage(webSocket: WebSocket, text: String) {
- onText(text)
- }
-
- override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
- onBinary(bytes.toByteArray())
- }
-
- override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
- if (this@BaiduRealtimeWsClient.webSocket === webSocket) {
- this@BaiduRealtimeWsClient.webSocket = null
- }
- onClosed("closed:$code:${reason.ifBlank { "-" }}", closedByClient)
- }
-
- override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
- runCatching { webSocket.close(code, reason) }
- }
-
- override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
- if (this@BaiduRealtimeWsClient.webSocket === webSocket) {
- this@BaiduRealtimeWsClient.webSocket = null
- }
- val code = response?.code ?: -1
- val message = t.message ?: response?.message ?: "unknown"
- onClosed("failure:$code:$message", closedByClient)
- }
- }
-}
diff --git a/android-app/app/src/main/java/com/aiglasses/app/software/BaiduVisualUploader.kt b/android-app/app/src/main/java/com/aiglasses/app/software/BaiduVisualUploader.kt
deleted file mode 100644
index b741e2b..0000000
--- a/android-app/app/src/main/java/com/aiglasses/app/software/BaiduVisualUploader.kt
+++ /dev/null
@@ -1,240 +0,0 @@
-package com.aiglasses.app.software
-
-import android.content.Context
-import android.os.Bundle
-import com.baidu.rtc.agent.AIAgentEngine
-import com.baidu.rtc.agent.AIAgentEngineCallback
-import com.baidu.rtc.agent.Constants
-import java.io.File
-
-private const val VISUAL_UPLOAD_EXPIRE_SECONDS = 0
-private const val VISUAL_UPLOAD_KEEP_MS = 10 * 60 * 1000L
-
-class BaiduVisualUploader(
- context: Context,
- private val onLog: (String) -> Unit
-) {
- private data class SessionConfig(
- val appId: String,
- val cid: String,
- val token: String,
- val userId: String,
- val licenseKey: String
- ) {
- fun isValid(): Boolean = appId.isNotBlank() && cid.isNotBlank() && token.isNotBlank()
- fun key(): String = listOf(appId, cid, token, userId, licenseKey).joinToString("|")
- }
-
- private val appContext = context.applicationContext
- private val uploadDir = File(appContext.cacheDir, "baidu_visual_uploads").apply { mkdirs() }
-
- private var sessionConfig: SessionConfig? = null
- private var startedKey = ""
- private var engine: AIAgentEngine? = null
- private var ready = false
- private var activeUploadFile: File? = null
- private var pendingUploadFile: File? = null
-
- private val callback = object : AIAgentEngineCallback() {
- override fun onCallStateChange(state: Int) {
- when (state) {
- Constants.CallState.ON_CALL_BEGIN -> {
- ready = true
- engine?.muteMic(true)
- engine?.mutePlayback(true)
- onLog("Baidu visual uploader ready")
- flushPendingUpload()
- }
-
- Constants.CallState.ON_CALL_END -> {
- ready = false
- onLog("Baidu visual uploader call ended")
- }
- }
- }
-
- override fun onConnectionStateChange(state: Int) {
- onLog("Baidu visual connection state=$state")
- }
-
- override fun onUploadFileStatus(code: Int, msg: String) {
- onLog("Baidu visual upload status: code=$code, msg=${msg.take(80)}")
- deleteFile(activeUploadFile)
- activeUploadFile = null
- }
-
- override fun onLicenseStatus(code: Int) {
- onLog("Baidu visual license status=$code")
- }
-
- override fun onAgentIntent(type: String, bundle: Bundle?) {
- if (type == Constants.AgentIntentType.IMAGE_UPLOAD) {
- onLog("Baidu visual agent intent: IMAGE_UPLOAD")
- }
- }
-
- override fun onError(error: Int, msg: String?, bundle: Bundle?) {
- onLog("Baidu visual uploader error: code=$error, msg=${msg ?: "-"}")
- }
-
- override fun onMessage(message: String?) {
- if (!message.isNullOrBlank()) {
- onLog("Baidu visual message: ${message.take(80)}")
- }
- }
- }
-
- fun updateSession(appId: String, cid: String, token: String, userId: String, licenseKey: String) {
- val next = SessionConfig(
- appId = appId.trim(),
- cid = cid.trim(),
- token = token.trim(),
- userId = userId.trim(),
- licenseKey = licenseKey.trim()
- )
- if (next == sessionConfig) return
- sessionConfig = next
- val key = next.key()
- if (engine != null && startedKey.isNotBlank() && key != startedKey) {
- onLog("Baidu visual uploader session changed, restarting")
- stop()
- }
- }
-
- fun start() {
- ensureStarted()
- }
-
- fun stop() {
- ready = false
- startedKey = ""
- runCatching { engine?.hangup() }
- runCatching { engine?.destroy() }
- engine = null
- deleteFile(activeUploadFile)
- activeUploadFile = null
- deleteFile(pendingUploadFile)
- pendingUploadFile = null
- }
-
- fun uploadJpeg(jpegBytes: ByteArray): Boolean {
- if (jpegBytes.isEmpty()) return false
- val cfg = sessionConfig
- if (cfg == null || !cfg.isValid()) {
- onLog("Baidu visual uploader skipped: missing appId/cid/token")
- return false
- }
- cleanupStaleFiles()
- val file = runCatching {
- File(uploadDir, "visual_${System.currentTimeMillis()}.jpg").apply {
- writeBytes(jpegBytes)
- }
- }.getOrElse {
- onLog("Baidu visual file prepare failed: ${it.message}")
- return false
- }
- if (!ensureStarted()) {
- deleteFile(file)
- return false
- }
- if (!ready) {
- replacePendingUpload(file)
- onLog("Baidu visual upload queued: waiting call begin")
- return true
- }
- return sendUploadFile(file)
- }
-
- private fun ensureStarted(): Boolean {
- val cfg = sessionConfig
- if (cfg == null || !cfg.isValid()) return false
- val key = cfg.key()
- if (engine != null && startedKey == key) return true
- val cidLong = cfg.cid.toLongOrNull()
- if (cidLong == null) {
- onLog("Baidu visual uploader skipped: cid not numeric")
- return false
- }
- stop()
- val params = AIAgentEngine.AIAgentEngineParams().apply {
- appId = cfg.appId
- workflow = "voiceChat"
- context = ""
- verbose = true
- enableExternalAudioInput = true
- enableExternalAudioOutput = true
- licenseKey = cfg.licenseKey
- userId = cfg.userId
- }
- val nextEngine = runCatching {
- AIAgentEngine.init(appContext, params)
- }.getOrElse {
- onLog("Baidu visual uploader init failed: ${it.message}")
- return false
- }
- engine = nextEngine
- engine?.setCallback(callback)
- ready = false
- startedKey = key
- onLog("Baidu visual uploader calling: cid=${cfg.cid}")
- runCatching {
- nextEngine.call(cfg.token, cidLong)
- }.onFailure {
- onLog("Baidu visual uploader call failed: ${it.message}")
- stop()
- return false
- }
- return true
- }
-
- private fun flushPendingUpload() {
- val file = pendingUploadFile ?: return
- pendingUploadFile = null
- if (!sendUploadFile(file)) {
- replacePendingUpload(file)
- }
- }
-
- private fun sendUploadFile(file: File): Boolean {
- val nextEngine = engine ?: return false
- deleteFile(activeUploadFile)
- activeUploadFile = file
- val ok = runCatching {
- nextEngine.uploadFile(file.absolutePath, VISUAL_UPLOAD_EXPIRE_SECONDS)
- }.getOrElse {
- onLog("Baidu visual upload call failed: ${it.message}")
- false
- }
- if (ok) {
- onLog("Baidu visual upload sent: ${file.name}, bytes=${file.length()}")
- } else {
- onLog("Baidu visual upload send failed")
- deleteFile(activeUploadFile)
- activeUploadFile = null
- }
- return ok
- }
-
- private fun replacePendingUpload(file: File) {
- deleteFile(pendingUploadFile)
- pendingUploadFile = file
- }
-
- private fun cleanupStaleFiles() {
- val cutoff = System.currentTimeMillis() - VISUAL_UPLOAD_KEEP_MS
- uploadDir.listFiles()?.forEach { file ->
- if (file.lastModified() < cutoff) {
- deleteFile(file)
- }
- }
- }
-
- private fun deleteFile(file: File?) {
- if (file == null) return
- runCatching {
- if (file.exists()) {
- file.delete()
- }
- }
- }
-}
diff --git a/android-app/app/src/main/java/com/aiglasses/app/software/SoftwareConversationController.kt b/android-app/app/src/main/java/com/aiglasses/app/software/SoftwareConversationController.kt
deleted file mode 100644
index e5a5982..0000000
--- a/android-app/app/src/main/java/com/aiglasses/app/software/SoftwareConversationController.kt
+++ /dev/null
@@ -1,1482 +0,0 @@
-package com.aiglasses.app.software
-
-import android.content.Context
-import android.content.Intent
-import android.graphics.Bitmap
-import android.graphics.BitmapFactory
-import android.media.AudioAttributes
-import android.media.AudioFormat
-import android.media.AudioManager
-import android.media.AudioRecord
-import android.media.AudioTrack
-import android.media.MediaRecorder
-import android.media.MediaPlayer
-import android.media.ToneGenerator
-import android.media.audiofx.AcousticEchoCanceler
-import android.media.audiofx.AutomaticGainControl
-import android.media.audiofx.NoiseSuppressor
-import android.os.Bundle
-import android.os.Handler
-import android.os.Looper
-import android.os.SystemClock
-import android.speech.RecognitionListener
-import android.speech.RecognizerIntent
-import android.speech.SpeechRecognizer
-import android.speech.tts.TextToSpeech
-import android.util.Size
-import androidx.camera.core.CameraSelector
-import androidx.camera.core.ImageCapture
-import androidx.camera.core.ImageCaptureException
-import androidx.camera.lifecycle.ProcessCameraProvider
-import androidx.core.content.ContextCompat
-import androidx.lifecycle.LifecycleOwner
-import java.io.ByteArrayOutputStream
-import java.io.File
-import java.util.Locale
-import java.util.concurrent.ExecutorService
-import java.util.concurrent.Executors
-import android.util.Base64
-import kotlin.math.PI
-import kotlin.math.sin
-
-private const val CAMERA_INTERVAL_MS = 12_000L
-private const val FIRST_CAPTURE_DELAY_MS = 1_200L
-private const val MIC_SAMPLE_RATE = 16_000
-private const val MIC_LOG_INTERVAL_MS = 3_000L
-private const val REALTIME_FRAME_MS = 20
-private const val REALTIME_PCM_FRAME_BYTES = MIC_SAMPLE_RATE * 2 * REALTIME_FRAME_MS / 1000
-private const val REALTIME_BINARY_LOG_INTERVAL_MS = 3_000L
-private const val REALTIME_UPLINK_LOG_INTERVAL_MS = 3_000L
-private const val REALTIME_ENABLE_AUDIO_GRACE_MS = 240L
-private const val BEEP_SAMPLE_RATE = 16_000
-private const val CAMERA_TARGET_WIDTH = 960
-private const val CAMERA_TARGET_HEIGHT = 540
-private const val CAMERA_JPEG_QUALITY = 62
-private const val BARGE_IN_RMS_THRESHOLD = 2_200
-private const val BARGE_IN_REMOTE_RMS_THRESHOLD = 3_000
-private const val BARGE_IN_CONSECUTIVE_FRAMES = 4
-private const val BARGE_IN_MIN_AFTER_TTS_MS = 900L
-private const val BARGE_IN_COOLDOWN_MS = 1800L
-private const val REALTIME_IMAGE_STALE_MS = 20_000L
-private const val REALTIME_IMAGE_FRESH_MS = 3_000L
-private const val REMOTE_AUDIO_DROP_AFTER_BREAK_MS = 1_200L
-private const val VISION_RESPONSE_TIMEOUT_MS = 8_000L
-private const val TTS_BINARY_LOG_SAMPLE_BYTES = 640
-private const val REALTIME_IMAGE_UPLOAD_MAX_DIM = 960
-private const val REALTIME_IMAGE_UPLOAD_TARGET_BYTES = 72_000
-private const val REALTIME_IMAGE_UPLOAD_MIN_QUALITY = 38
-private const val REALTIME_ASR_RETRY_WINDOW_MS = 800L
-private const val VISION_SYSTEM_PROMPT =
- "你具备视觉理解能力。收到上传图片后,必须结合最近一次上传的图片回答用户关于当前画面的问题。" +
- "优先描述主体、位置、颜色、动作和环境;如果图片模糊、过暗或被遮挡,再说明原因,不要直接说自己看不到。"
-
-class SoftwareConversationController(
- private val context: Context,
- private val lifecycleOwner: LifecycleOwner,
- private val onLog: (String) -> Unit,
- private val onSpeechText: (String) -> Unit,
- private val onRealtimeAsrText: (String) -> Unit,
- private val onBargeInDetected: () -> Unit,
- private val onCameraCapture: (imageBase64: String, bytes: Int, width: Int, height: Int) -> Unit,
- private val onRealtimeVisionFrameReady: (
- imageBase64: String,
- bytes: Int,
- width: Int,
- height: Int,
- source: String,
- ) -> Unit,
- private val onMicFallbackFrame: (pcmBytes: ByteArray, rms: Int, sampleRate: Int) -> Unit,
-) {
- private val mainHandler = Handler(Looper.getMainLooper())
- private val cameraExecutor: ExecutorService = Executors.newSingleThreadExecutor()
- private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
- private val realtimeWsClient = BaiduRealtimeWsClient(
- onLog = onLog,
- onOpen = {
- realtimeConnected = true
- reconnectRealtimePending = false
- onLog("Realtime WS connected")
- },
- onText = { text ->
- onLog("Realtime WS text: ${text.take(160)}")
- handleRealtimeText(text)
- },
- onBinary = { pcm ->
- val now = SystemClock.elapsedRealtime()
- remoteVoiceActive = true
- onRealtimeBinary(now, pcm.size)
- if (now - lastRealtimeBinaryLogAt >= REALTIME_BINARY_LOG_INTERVAL_MS) {
- lastRealtimeBinaryLogAt = now
- onLog("Realtime PCM packet: bytes=${pcm.size}, sampleRate=$MIC_SAMPLE_RATE, channels=1")
- }
- playRealtimePcm(pcm, MIC_SAMPLE_RATE, 1)
- },
- onClosed = { reason, byClient ->
- realtimeConnected = false
- remoteVoiceActive = false
- if (byClient) {
- onLog("Realtime WS closed: $reason")
- } else {
- onLog("Realtime WS failed: $reason")
- maybeReconnectRealtime(reason)
- }
- }
- )
- private val baiduConversationAgent = BaiduConversationAgent(
- context = context,
- onLog = onLog,
- onCallReady = {
- realtimeConnected = true
- realtimeAsrEnabled = true
- realtimeMicSuppressed = false
- realtimeDeviceInfoSent = true
- realtimeVisionPromptSent = true
- onLog("Realtime ASR pipeline enabled: official agent")
- },
- onCallEnded = {
- realtimeConnected = false
- realtimeAsrEnabled = false
- realtimeMicSuppressed = false
- remoteVoiceActive = false
- },
- onFinalAsr = { recognized ->
- onLog("Realtime ASR: ${recognized.take(80)}")
- maybeDispatchRealtimeAsr(recognized)
- },
- onAgentText = { answer ->
- onLog("Realtime AI: ${answer.take(80)}")
- },
- onTtsStart = {
- realtimeMicSuppressed = true
- ttsBeginAt = SystemClock.elapsedRealtime()
- bargeInConsecutiveFrames = 0
- remoteVoiceActive = true
- resetRealtimeTtsStats()
- onLog("Realtime MIC suppressed: TTS begin")
- },
- onTtsEnd = {
- realtimeMicSuppressed = false
- bargeInConsecutiveFrames = 0
- remoteVoiceActive = false
- logRealtimeTtsSummary()
- onLog("Realtime MIC resumed: TTS end")
- },
- onPlaybackAudio = { pcm, sampleRate, channelCount ->
- val now = SystemClock.elapsedRealtime()
- if (now >= dropRemoteAudioUntil) {
- remoteVoiceActive = true
- onRealtimeBinary(now, pcm.size)
- if (now - lastRealtimeBinaryLogAt >= REALTIME_BINARY_LOG_INTERVAL_MS) {
- lastRealtimeBinaryLogAt = now
- onLog("Realtime PCM packet: bytes=${pcm.size}, sampleRate=$sampleRate, channels=$channelCount")
- }
- playRealtimePcm(pcm, sampleRate, channelCount)
- }
- },
- onImageUploadRequest = {
- handleRealtimeImageRequest()
- }
- )
-
- private var running = false
- private var speechRecognizer: SpeechRecognizer? = null
- private var textToSpeech: TextToSpeech? = null
- private var ttsReady = false
- private var toneGenerator: ToneGenerator? = null
- private var cameraProvider: ProcessCameraProvider? = null
- private var imageCapture: ImageCapture? = null
- private var cameraTask: Runnable? = null
- private var audioRecord: AudioRecord? = null
- private var micThread: Thread? = null
- private var mediaPlayer: MediaPlayer? = null
- private var acousticEchoCanceler: AcousticEchoCanceler? = null
- private var noiseSuppressor: NoiseSuppressor? = null
- private var automaticGainControl: AutomaticGainControl? = null
- private var realtimeUrl: String = ""
- private var realtimeConnected = false
- private var realtimeAudioTrack: AudioTrack? = null
- private var realtimeAudioTrackSampleRate = MIC_SAMPLE_RATE
- private var realtimeAudioTrackChannelCount = 1
- private var activationDevId: String = ""
- private var activationUserId: String = ""
- private var activationLicKey: String = ""
- private var visualAppId: String = ""
- private var visualCid: String = ""
- private var visualToken: String = ""
- private var visualContext = ""
- private var realtimeLicensePassed = true
- private var realtimeMediaReady = false
- private var realtimeAsrEnabled = false
- private var realtimeMicSuppressed = false
- private var lastRealtimeBinaryLogAt = 0L
- private var ttsBeginAt = 0L
- private var bargeInConsecutiveFrames = 0
- private var lastBargeInAt = 0L
- private var lastRealtimeAsrText = ""
- private var lastRealtimeAsrEmitAt = 0L
- private var remoteVoiceActive = false
- private var dropRemoteAudioUntil = 0L
- private var originalAudioMode = AudioManager.MODE_NORMAL
- private var latestCameraImageBase64 = ""
- private var latestCameraImageBytes = 0
- private var latestCameraImageWidth = 0
- private var latestCameraImageHeight = 0
- private var latestCameraImageAt = 0L
- private var pendingRealtimeImageUpload = false
- private var cameraCaptureInFlight = false
- private var realtimeDeviceInfoSent = false
- private var realtimeVisionPromptSent = false
- private var lastVisionImageUploadAt = 0L
- private var currentTtsPacketCount = 0
- private var currentTtsBytes = 0
- private var currentTtsFirstPacketAt = 0L
- private var currentTtsLastPacketAt = 0L
- private var reconnectRealtimePending = false
- private var realtimeClosedByClient = false
- private var lastRealtimeAsrEnableAt = 0L
- private var realtimeAudioSendAfterAt = 0L
- private var lastRealtimeAudioGateLogAt = 0L
- private var lastRealtimeUplinkLogAt = 0L
- private var realtimeUplinkFrames = 0
- private var realtimeUplinkBytes = 0
- private var realtimeUplinkFailures = 0
- private var realtimeUplinkFirstSendAt = 0L
- private var realtimeUplinkLastSendAt = 0L
-
- fun start() {
- if (running) return
- running = true
- configureCommunicationAudio()
- onLog("Software flow starting")
- initTextToSpeech()
- bindCamera()
- startCameraLoop()
- speak("软件对话已开启")
- }
-
- fun stop() {
- if (!running) return
- running = false
- stopCameraLoop()
- releaseSpeechRecognizer()
- stopMicFallback()
- releaseCamera()
- disconnectRealtime()
- releaseMediaPlayer()
- releaseTextToSpeech()
- restoreCommunicationAudio()
- onLog("Software flow stopped")
- }
-
- fun release() {
- stop()
- releaseRealtimeAudioTrack()
- realtimeWsClient.release()
- cameraExecutor.shutdown()
- }
-
- fun connectRealtime(url: String) {
- if (!running) return
- realtimeUrl = url
- if (speechRecognizer != null) {
- releaseSpeechRecognizer()
- }
- startMicFallback()
- realtimeAsrEnabled = false
- realtimeMicSuppressed = false
- ttsBeginAt = 0L
- bargeInConsecutiveFrames = 0
- lastBargeInAt = 0L
- lastRealtimeAsrText = ""
- lastRealtimeAsrEmitAt = 0L
- remoteVoiceActive = false
- dropRemoteAudioUntil = 0L
- realtimeDeviceInfoSent = false
- realtimeVisionPromptSent = false
- lastVisionImageUploadAt = 0L
- resetRealtimeTtsStats()
- reconnectRealtimePending = false
- realtimeClosedByClient = false
- lastRealtimeAsrEnableAt = 0L
- realtimeAudioSendAfterAt = 0L
- lastRealtimeAudioGateLogAt = 0L
- resetRealtimeUplinkStats()
- // Match the known-good 0.4.13/0.4.14 sequencing:
- // always wait for LIC PASS before enabling realtime ASR.
- realtimeLicensePassed = false
- realtimeMediaReady = false
- onLog("Realtime WS connecting...")
- realtimeWsClient.connect(realtimeUrl)
- }
-
- fun updateActivationInfo(devId: String, userId: String, licenseKey: String) {
- activationDevId = devId.trim()
- activationUserId = userId.trim()
- activationLicKey = licenseKey.trim()
- }
-
- fun updateBaiduVisualSessionInfo(
- appId: String,
- cid: String,
- token: String,
- contextJson: String,
- deviceId: String,
- appUserId: String,
- licenseKey: String
- ) {
- visualAppId = appId.trim()
- visualCid = cid.trim()
- visualToken = token.trim()
- visualContext = contextJson.trim()
- baiduConversationAgent.updateSession(
- appId = visualAppId,
- cid = visualCid,
- token = visualToken,
- contextJson = visualContext,
- deviceId = deviceId.trim(),
- appUserId = appUserId.trim(),
- licenseKey = licenseKey.trim()
- )
- }
-
- fun disconnectRealtime() {
- sendRealtimeAsrStop()
- realtimeConnected = false
- reconnectRealtimePending = false
- realtimeClosedByClient = true
- realtimeUrl = ""
- realtimeLicensePassed = true
- realtimeAsrEnabled = false
- realtimeAudioSendAfterAt = 0L
- lastRealtimeAudioGateLogAt = 0L
- realtimeMicSuppressed = false
- ttsBeginAt = 0L
- bargeInConsecutiveFrames = 0
- lastRealtimeAsrText = ""
- lastRealtimeAsrEmitAt = 0L
- remoteVoiceActive = false
- dropRemoteAudioUntil = 0L
- realtimeDeviceInfoSent = false
- realtimeVisionPromptSent = false
- lastVisionImageUploadAt = 0L
- resetRealtimeTtsStats()
- resetRealtimeUplinkStats()
- realtimeMediaReady = false
- realtimeWsClient.disconnect("client_stop")
- baiduConversationAgent.stop()
- releaseRealtimeAudioTrack()
- stopMicFallback()
- }
-
- fun playServerAudio(audioBase64: String, audioUrl: String): Boolean {
- if (!running) return false
- if (audioBase64.isBlank() && audioUrl.isBlank()) return false
- return if (audioBase64.isNotBlank()) {
- playBase64Audio(audioBase64)
- } else {
- playUrlAudio(audioUrl)
- }
- }
-
- fun speak(text: String): Boolean {
- if (!running || text.isBlank()) return false
- onLog("Speak request: ${text.take(24)}")
- if (ttsReady) {
- val ok = runCatching {
- textToSpeech?.speak(
- text,
- TextToSpeech.QUEUE_ADD,
- null,
- "soft-flow-${System.currentTimeMillis()}"
- )
- }.isSuccess
- if (ok) {
- return true
- }
- onLog("TTS speak failed, fallback to tone")
- return playFallbackTone()
- }
- return playFallbackTone()
- }
-
- private fun initTextToSpeech() {
- if (textToSpeech != null) return
- textToSpeech = TextToSpeech(context) { status ->
- if (status != TextToSpeech.SUCCESS) {
- onLog("TTS init failed: status=$status")
- initToneFallback()
- return@TextToSpeech
- }
- val setLanguageResult = textToSpeech?.setLanguage(Locale.SIMPLIFIED_CHINESE)
- ttsReady = setLanguageResult != TextToSpeech.LANG_NOT_SUPPORTED &&
- setLanguageResult != TextToSpeech.LANG_MISSING_DATA
- if (!ttsReady) {
- onLog("TTS language unsupported, fallback to default voice")
- ttsReady = true
- }
- }
- }
-
- private fun releaseTextToSpeech() {
- runCatching { textToSpeech?.stop() }
- runCatching { textToSpeech?.shutdown() }
- textToSpeech = null
- ttsReady = false
- runCatching { toneGenerator?.release() }
- toneGenerator = null
- }
-
- private fun releaseMediaPlayer() {
- runCatching { mediaPlayer?.stop() }
- runCatching { mediaPlayer?.release() }
- mediaPlayer = null
- }
-
- private fun sendRealtimePcm(pcm: ByteArray) {
- if (!running || !realtimeConnected || !realtimeAsrEnabled || realtimeMicSuppressed || pcm.isEmpty()) {
- return
- }
- val now = SystemClock.elapsedRealtime()
- if (now < realtimeAudioSendAfterAt) {
- if (now - lastRealtimeAudioGateLogAt >= REALTIME_UPLINK_LOG_INTERVAL_MS) {
- lastRealtimeAudioGateLogAt = now
- onLog("Realtime PCM gated: wait=${realtimeAudioSendAfterAt - now}ms after ASR enable")
- }
- return
- }
- val ok = realtimeWsClient.sendBinary(pcm)
- if (ok) {
- realtimeUplinkFrames += 1
- realtimeUplinkBytes += pcm.size
- realtimeUplinkLastSendAt = now
- if (realtimeUplinkFirstSendAt == 0L) {
- realtimeUplinkFirstSendAt = now
- onLog("Realtime PCM first uplink: delay=${now - lastRealtimeAsrEnableAt}ms, bytes=${pcm.size}")
- }
- if (now - lastRealtimeUplinkLogAt >= REALTIME_UPLINK_LOG_INTERVAL_MS) {
- lastRealtimeUplinkLogAt = now
- onLog(
- "Realtime PCM uplink: frames=$realtimeUplinkFrames, bytes=$realtimeUplinkBytes, failures=$realtimeUplinkFailures, lastOkAgo=${now - realtimeUplinkLastSendAt}ms"
- )
- }
- } else {
- realtimeUplinkFailures += 1
- onLog("Realtime PCM send failed: bytes=${pcm.size}")
- }
- }
-
- private fun handleRealtimeText(text: String) {
- if (text.contains("[E]:[VOICE_COMING]")) {
- remoteVoiceActive = true
- if (lastVisionImageUploadAt > 0L) {
- val delay = SystemClock.elapsedRealtime() - lastVisionImageUploadAt
- if (delay in 1..15_000L) {
- onLog("Vision response voice started: delay=${delay}ms")
- }
- }
- }
- if (text.contains("[E]:[VOICE_DISAPPEAR]")) {
- remoteVoiceActive = false
- }
- if (text.contains("[E]:[LIC]:[MUST]")) {
- realtimeLicensePassed = false
- realtimeAsrEnabled = false
- sendRealtimeLicenseActive()
- }
- if (text.contains("[E]:[LIC]:[RES]:[PASS]")) {
- realtimeLicensePassed = true
- maybeEnableRealtimeAsr()
- }
- if (text.contains("[E]:[MEDIA]:[READY]")) {
- realtimeMediaReady = true
- maybeEnableRealtimeAsr()
- }
- if (text.contains("[E]:[TTS_BEGIN_SPEAKING]")) {
- realtimeMicSuppressed = true
- ttsBeginAt = SystemClock.elapsedRealtime()
- bargeInConsecutiveFrames = 0
- remoteVoiceActive = true
- resetRealtimeTtsStats()
- onLog("Realtime MIC suppressed: TTS begin")
- }
- if (text.contains("[E]:[TTS_END_SPEAKING]")) {
- realtimeMicSuppressed = false
- bargeInConsecutiveFrames = 0
- remoteVoiceActive = false
- logRealtimeTtsSummary()
- onLog("Realtime MIC resumed: TTS end")
- }
- if (text.contains("[E]:[UPLOAD_IMAGE]")) {
- handleRealtimeImageRequest()
- }
- if (text.startsWith("[E]:[PLAY_AUDIO]:")) {
- val audioUrl = text.substringAfter("[E]:[PLAY_AUDIO]:").trim()
- if (audioUrl.startsWith("http://") || audioUrl.startsWith("https://")) {
- playUrlAudio(audioUrl)
- }
- }
- if (text.startsWith("[Q]:")) {
- val recognized = extractRealtimeTextPayload(text, "[Q]:")
- if (recognized.isNotBlank() && !isRealtimeAsrPartial(text)) {
- onLog("Realtime ASR: ${recognized.take(80)}")
- maybeDispatchRealtimeAsr(recognized)
- }
- }
- if (text.startsWith("[A]:")) {
- val answer = extractRealtimeTextPayload(text, "[A]:")
- if (answer.isNotBlank()) {
- onLog("Realtime AI: ${answer.take(80)}")
- }
- }
- }
-
- private fun isRealtimeAsrPartial(text: String): Boolean {
- return text.startsWith("[Q]:[M]:")
- }
-
- private fun maybeDispatchRealtimeAsr(text: String) {
- val normalized = text.trim()
- if (normalized.isBlank()) return
- val now = SystemClock.elapsedRealtime()
- if (normalized == lastRealtimeAsrText && now - lastRealtimeAsrEmitAt < 1500L) {
- return
- }
- lastRealtimeAsrText = normalized
- lastRealtimeAsrEmitAt = now
- onRealtimeAsrText(normalized)
- }
-
- private fun sendRealtimeLicenseActive() {
- if (!running || !realtimeConnected) return
- if (activationDevId.isBlank() || activationLicKey.isBlank()) {
- onLog("Realtime license active skipped: activation info missing")
- return
- }
- val payload = "[E]:[LIC]:[ACTIVE]:{\"devId\":\"${jsonEscape(activationDevId)}\",\"uId\":\"${jsonEscape(activationUserId.ifBlank { activationDevId })}\",\"licKey\":\"${jsonEscape(activationLicKey)}\"}"
- val ok = realtimeWsClient.sendText(payload)
- if (ok) {
- onLog("Realtime license active sent")
- } else {
- onLog("Realtime license active send failed")
- }
- }
-
- private fun maybeEnableRealtimeAsr() {
- if (!running || !realtimeConnected) return
- if (!realtimeMediaReady) {
- onLog("Realtime ASR enable pending: media not ready")
- return
- }
- if (!realtimeLicensePassed) {
- onLog("Realtime ASR enable pending: license not pass yet")
- return
- }
- if (realtimeAsrEnabled) return
- val now = SystemClock.elapsedRealtime()
- if (now - lastRealtimeAsrEnableAt < REALTIME_ASR_RETRY_WINDOW_MS) return
- lastRealtimeAsrEnableAt = now
- val deviceInfoOk = sendRealtimeDeviceInfo()
- val visionPromptOk = sendRealtimeVisionPrompt()
- val autoInt = sendRealtimeCmd("[SET]:[AUTO_INT]:[TRUE]", "AUTO_INT")
- val realtimeAsr = sendRealtimeCmd("[E]:[CMD]:[ASR_ENABLE_REALTIME]", "ASR_ENABLE_REALTIME")
- val longText = sendRealtimeCmd("[E]:[CMD]:[ASR_START_LONGTEXT_REC]", "ASR_START_LONGTEXT_REC")
- if (autoInt || realtimeAsr || longText) {
- realtimeAsrEnabled = true
- realtimeAudioSendAfterAt = SystemClock.elapsedRealtime() + REALTIME_ENABLE_AUDIO_GRACE_MS
- lastRealtimeAudioGateLogAt = 0L
- resetRealtimeUplinkStats()
- onLog("Realtime ASR pipeline enabled: deviceInfo=$deviceInfoOk, visionPrompt=$visionPromptOk")
- onLog("Realtime PCM uplink armed: grace=${REALTIME_ENABLE_AUDIO_GRACE_MS}ms")
- }
- }
-
- private fun sendRealtimeAsrStop() {
- sendRealtimeCmd("[E]:[CMD]:[ASR_STOP_LONGTEXT_REC]", "ASR_STOP_LONGTEXT_REC")
- realtimeAsrEnabled = false
- realtimeMicSuppressed = false
- remoteVoiceActive = false
- }
-
- private fun sendRealtimeCmd(cmd: String, tag: String): Boolean {
- if (!running || !realtimeConnected) return false
- val ok = realtimeWsClient.sendText(cmd)
- onLog(
- if (ok) "Realtime CMD sent: $tag" else "Realtime CMD failed: $tag"
- )
- return ok
- }
-
- private fun extractRealtimeTextPayload(raw: String, prefix: String): String {
- if (!raw.startsWith(prefix)) return ""
- var text = raw.removePrefix(prefix).trim()
- while (text.startsWith("[") && text.contains("]:")) {
- text = text.substringAfter("]:").trimStart(':').trim()
- }
- text = text.substringBefore("|||").trim()
- if (text.isBlank()) return ""
- return text
- }
-
- private fun jsonEscape(value: String): String {
- return value
- .replace("\\", "\\\\")
- .replace("\"", "\\\"")
- }
-
- private fun sendRealtimeDeviceInfo(): Boolean {
- if (realtimeDeviceInfoSent) return true
- realtimeDeviceInfoSent = true
- return true
- }
-
- private fun sendRealtimeVisionPrompt(): Boolean {
- if (realtimeVisionPromptSent) return true
- if (!running || !realtimeConnected) return false
- val payload = "[SET]:[UPDATE_SYSTEM_PROMPT]:{\"model_type\":\"2\",\"prompt\":\"${jsonEscape(VISION_SYSTEM_PROMPT)}\"}"
- val ok = realtimeWsClient.sendText(payload)
- if (ok) {
- realtimeVisionPromptSent = true
- onLog("Realtime vision prompt sent")
- } else {
- onLog("Realtime vision prompt failed")
- }
- return ok
- }
-
- private fun handleRealtimeImageRequest() {
- if (!running || !realtimeConnected) return
- if (!realtimeVisionPromptSent) {
- sendRealtimeVisionPrompt()
- }
- pendingRealtimeImageUpload = true
- onLog("Realtime image request received")
- val now = System.currentTimeMillis()
- val hasFreshFrame = latestCameraImageBase64.isNotBlank() &&
- latestCameraImageBytes > 0 &&
- now - latestCameraImageAt <= REALTIME_IMAGE_FRESH_MS
- if (hasFreshFrame) {
- uploadLatestRealtimeImage(source = "fresh-cache")
- return
- }
- onLog("Realtime image request: capturing fresh frame")
- mainHandler.post { captureOnce() }
- }
-
- private fun uploadLatestRealtimeImage(source: String) {
- if (!running || !realtimeConnected) return
- if (!realtimeVisionPromptSent) {
- sendRealtimeVisionPrompt()
- }
- if (latestCameraImageBase64.isBlank() || latestCameraImageBytes <= 0) {
- onLog("Realtime image upload skipped: no camera frame")
- return
- }
- val now = System.currentTimeMillis()
- if (now - latestCameraImageAt > REALTIME_IMAGE_STALE_MS) {
- onLog("Realtime image upload skipped: stale frame")
- return
- }
- val uploadImage = prepareRealtimeImagePayload()
- if (uploadImage == null) {
- onLog("Realtime image upload skipped: image prep failed")
- return
- }
- pendingRealtimeImageUpload = false
- lastVisionImageUploadAt = SystemClock.elapsedRealtime()
- scheduleVisionResponseWatchdog()
- val imageBase64 = Base64.encodeToString(uploadImage.bytes, Base64.NO_WRAP)
- onLog(
- "Realtime image prepared($source): ${uploadImage.width}x${uploadImage.height}, jpeg=${uploadImage.bytes.size}, b64=${imageBase64.length}"
- )
- mainHandler.post {
- if (!running) return@post
- onRealtimeVisionFrameReady(
- imageBase64,
- uploadImage.bytes.size,
- uploadImage.width,
- uploadImage.height,
- source,
- )
- }
- }
-
- private fun playRealtimePcm(pcm: ByteArray, sampleRate: Int, channelCount: Int) {
- if (pcm.isEmpty()) return
- val track = if (
- realtimeAudioTrack == null ||
- realtimeAudioTrackSampleRate != sampleRate ||
- realtimeAudioTrackChannelCount != channelCount
- ) {
- releaseRealtimeAudioTrack()
- buildRealtimeAudioTrack(sampleRate, channelCount)
- } else {
- realtimeAudioTrack
- } ?: return
- val written = runCatching {
- track.write(pcm, 0, pcm.size, AudioTrack.WRITE_NON_BLOCKING)
- }.getOrElse {
- onLog("Realtime audio write failed: ${it.message}")
- -1
- }
- if (written > 0 && track.playState != AudioTrack.PLAYSTATE_PLAYING) {
- runCatching { track.play() }
- }
- }
-
- private fun buildRealtimeAudioTrack(sampleRate: Int, channelCount: Int): AudioTrack? {
- val channelMask = if (channelCount <= 1) {
- AudioFormat.CHANNEL_OUT_MONO
- } else {
- AudioFormat.CHANNEL_OUT_STEREO
- }
- val minBuffer = AudioTrack.getMinBufferSize(
- sampleRate,
- channelMask,
- AudioFormat.ENCODING_PCM_16BIT
- )
- if (minBuffer <= 0) {
- onLog("Realtime audio track init failed: minBuffer=$minBuffer")
- return null
- }
- val track = runCatching {
- AudioTrack(
- AudioAttributes.Builder()
- .setUsage(AudioAttributes.USAGE_MEDIA)
- .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
- .build(),
- AudioFormat.Builder()
- .setSampleRate(sampleRate)
- .setEncoding(AudioFormat.ENCODING_PCM_16BIT)
- .setChannelMask(channelMask)
- .build(),
- minBuffer * 4,
- AudioTrack.MODE_STREAM,
- AudioManager.AUDIO_SESSION_ID_GENERATE
- )
- }.getOrElse {
- onLog("Realtime audio track init failed: ${it.message}")
- return null
- }
- realtimeAudioTrack = track
- realtimeAudioTrackSampleRate = sampleRate
- realtimeAudioTrackChannelCount = channelCount
- runCatching { track.play() }
- onLog("Realtime audio track ready: sampleRate=$sampleRate, channels=$channelCount")
- return track
- }
-
- private fun releaseRealtimeAudioTrack() {
- runCatching { realtimeAudioTrack?.stop() }
- runCatching { realtimeAudioTrack?.release() }
- realtimeAudioTrack = null
- realtimeAudioTrackSampleRate = MIC_SAMPLE_RATE
- realtimeAudioTrackChannelCount = 1
- remoteVoiceActive = false
- }
-
- private fun initSpeechRecognizer() {
- if (!SpeechRecognizer.isRecognitionAvailable(context)) {
- onLog("SpeechRecognizer unavailable on this device")
- onLog("Speech fallback: raw microphone capture only (no STT)")
- startMicFallback()
- return
- }
- if (speechRecognizer == null) {
- speechRecognizer = SpeechRecognizer.createSpeechRecognizer(context).apply {
- setRecognitionListener(recognitionListener)
- }
- }
- startListeningDelayed(350L)
- }
-
- private fun releaseSpeechRecognizer() {
- mainHandler.removeCallbacksAndMessages(SPEECH_TOKEN)
- runCatching { speechRecognizer?.stopListening() }
- runCatching { speechRecognizer?.cancel() }
- runCatching { speechRecognizer?.destroy() }
- speechRecognizer = null
- }
-
- private fun initToneFallback() {
- if (toneGenerator != null) return
- val vol = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
- val max = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
- onLog("Audio stream volume: music=$vol/$max")
- toneGenerator = runCatching { ToneGenerator(AudioManager.STREAM_MUSIC, 100) }
- .onFailure { onLog("Tone fallback init failed: ${it.message}") }
- .getOrNull()
- }
-
- private fun playFallbackTone(): Boolean {
- if (!running) return false
- if (toneGenerator == null) initToneFallback()
- val toneOk = runCatching {
- toneGenerator?.startTone(ToneGenerator.TONE_PROP_BEEP2, 260) ?: false
- }.getOrDefault(false)
- if (toneOk) {
- onLog("Tone fallback played")
- return true
- }
- onLog("Tone fallback failed, trying AudioTrack beep")
- return playPcmBeep()
- }
-
- private fun playPcmBeep(): Boolean {
- return runCatching {
- val durationMs = 280
- val totalSamples = BEEP_SAMPLE_RATE * durationMs / 1000
- val pcm = ShortArray(totalSamples)
- val freqHz = 880.0
- for (i in 0 until totalSamples) {
- val t = i.toDouble() / BEEP_SAMPLE_RATE
- val amp = 0.32 * sin(2.0 * PI * freqHz * t)
- pcm[i] = (amp * Short.MAX_VALUE).toInt().toShort()
- }
- val track = AudioTrack.Builder()
- .setAudioAttributes(
- AudioAttributes.Builder()
- .setUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION)
- .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
- .build()
- )
- .setAudioFormat(
- AudioFormat.Builder()
- .setSampleRate(BEEP_SAMPLE_RATE)
- .setEncoding(AudioFormat.ENCODING_PCM_16BIT)
- .setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
- .build()
- )
- .setTransferMode(AudioTrack.MODE_STATIC)
- .setBufferSizeInBytes(pcm.size * 2)
- .build()
- track.write(pcm, 0, pcm.size)
- track.play()
- Thread.sleep((durationMs + 60).toLong())
- track.stop()
- track.release()
- onLog("AudioTrack beep played")
- true
- }.onFailure {
- onLog("AudioTrack beep failed: ${it.message}")
- }.getOrDefault(false)
- }
-
- private fun playBase64Audio(audioBase64: String): Boolean {
- return runCatching {
- val bytes = Base64.decode(audioBase64, Base64.DEFAULT)
- if (bytes.isEmpty()) return@runCatching false
- val ext = when {
- bytes.size >= 4 &&
- bytes[0] == 'R'.code.toByte() &&
- bytes[1] == 'I'.code.toByte() &&
- bytes[2] == 'F'.code.toByte() &&
- bytes[3] == 'F'.code.toByte() -> ".wav"
- bytes.size >= 3 &&
- bytes[0] == 'I'.code.toByte() &&
- bytes[1] == 'D'.code.toByte() &&
- bytes[2] == '3'.code.toByte() -> ".mp3"
- else -> ".bin"
- }
- val tmp = File.createTempFile("tts_", ext, context.cacheDir)
- tmp.writeBytes(bytes)
- playFileAudio(tmp)
- }.onFailure {
- onLog("Play base64 audio failed: ${it.message}")
- }.getOrDefault(false)
- }
-
- private fun playUrlAudio(url: String): Boolean {
- return runCatching {
- if (url.isBlank()) return@runCatching false
- releaseMediaPlayer()
- val p = MediaPlayer().apply {
- setAudioStreamType(AudioManager.STREAM_MUSIC)
- setDataSource(url)
- setOnPreparedListener { it.start() }
- setOnCompletionListener {
- onLog("Server audio played(url)")
- releaseMediaPlayer()
- }
- setOnErrorListener { _, what, extra ->
- onLog("Server audio play error(url): what=$what extra=$extra")
- releaseMediaPlayer()
- true
- }
- prepareAsync()
- }
- mediaPlayer = p
- onLog("Server audio loading(url)")
- true
- }.onFailure {
- onLog("Play url audio failed: ${it.message}")
- }.getOrDefault(false)
- }
-
- private fun playFileAudio(file: File): Boolean {
- return runCatching {
- releaseMediaPlayer()
- val p = MediaPlayer().apply {
- setAudioStreamType(AudioManager.STREAM_MUSIC)
- setDataSource(file.absolutePath)
- setOnPreparedListener { it.start() }
- setOnCompletionListener {
- onLog("Server audio played(base64)")
- runCatching { file.delete() }
- releaseMediaPlayer()
- }
- setOnErrorListener { _, what, extra ->
- onLog("Server audio play error(base64): what=$what extra=$extra")
- runCatching { file.delete() }
- releaseMediaPlayer()
- true
- }
- prepareAsync()
- }
- mediaPlayer = p
- onLog("Server audio loading(base64, ${file.length()} bytes)")
- true
- }.onFailure {
- runCatching { file.delete() }
- onLog("Play file audio failed: ${it.message}")
- }.getOrDefault(false)
- }
-
- private fun startMicFallback() {
- if (audioRecord != null) return
- val minBuffer = AudioRecord.getMinBufferSize(
- MIC_SAMPLE_RATE,
- AudioFormat.CHANNEL_IN_MONO,
- AudioFormat.ENCODING_PCM_16BIT
- )
- if (minBuffer <= 0) {
- onLog("Mic fallback init failed: bad buffer size=$minBuffer")
- return
- }
- val bufferSize = maxOf(minBuffer * 2, MIC_SAMPLE_RATE)
- val record = buildFallbackAudioRecord(bufferSize) ?: return
- if (record.state != AudioRecord.STATE_INITIALIZED) {
- onLog("Mic fallback init failed: state=${record.state}")
- runCatching { record.release() }
- return
- }
- audioRecord = record
- enableAudioEffects(record.audioSessionId)
- runCatching { record.startRecording() }
- .onFailure {
- onLog("Mic fallback start failed: ${it.message}")
- releaseAudioEffects()
- runCatching { record.release() }
- audioRecord = null
- return
- }
- onLog("Mic fallback recording started")
- micThread = Thread {
- val frame = ByteArray(REALTIME_PCM_FRAME_BYTES)
- var lastLogAt = 0L
- while (running && audioRecord === record) {
- val read = record.read(frame, 0, frame.size)
- if (read <= 0) {
- if (read < 0) {
- onLog("Mic fallback read error=$read")
- }
- continue
- }
- val rms = calculateRms(frame, read)
- val pcmCopy = frame.copyOf(read)
- mainHandler.post {
- if (running) {
- onMicFallbackFrame(pcmCopy, rms, MIC_SAMPLE_RATE)
- }
- }
- maybeHandleBargeIn(rms)
- sendRealtimePcm(pcmCopy)
- val now = SystemClock.elapsedRealtime()
- if (now - lastLogAt >= MIC_LOG_INTERVAL_MS) {
- lastLogAt = now
- onLog("Mic fallback active: bytes=$read, rms=$rms")
- }
- }
- }.apply {
- name = "software-mic-fallback"
- isDaemon = true
- start()
- }
- }
-
- private fun maybeHandleBargeIn(rms: Int) {
- if (!running || !realtimeConnected || !realtimeAsrEnabled) return
- if (!realtimeMicSuppressed && !remoteVoiceActive) return
- val now = SystemClock.elapsedRealtime()
- if (realtimeMicSuppressed && now - ttsBeginAt < BARGE_IN_MIN_AFTER_TTS_MS) return
- if (now - lastBargeInAt < BARGE_IN_COOLDOWN_MS) return
- val rmsThreshold = if (remoteVoiceActive) BARGE_IN_REMOTE_RMS_THRESHOLD else BARGE_IN_RMS_THRESHOLD
- if (rms >= rmsThreshold) {
- bargeInConsecutiveFrames += 1
- } else {
- bargeInConsecutiveFrames = 0
- return
- }
- if (bargeInConsecutiveFrames < BARGE_IN_CONSECUTIVE_FRAMES) return
- bargeInConsecutiveFrames = 0
- lastBargeInAt = now
- realtimeMicSuppressed = false
- remoteVoiceActive = false
- dropRemoteAudioUntil = now + REMOTE_AUDIO_DROP_AFTER_BREAK_MS
- sendRealtimeBreakWithWindow()
- stopRealtimePlaybackForBargeIn()
- onLog("Barge-in detected: rms=$rms")
- mainHandler.post {
- if (running) {
- onBargeInDetected()
- }
- }
- }
-
- private fun sendRealtimeBreakWithWindow() {
- onLog("Realtime interrupt sent")
- baiduConversationAgent.interrupt()
- }
-
- private fun stopRealtimePlaybackForBargeIn() {
- runCatching { mediaPlayer?.stop() }
- runCatching { mediaPlayer?.release() }
- mediaPlayer = null
- runCatching { realtimeAudioTrack?.pause() }
- runCatching { realtimeAudioTrack?.flush() }
- runCatching { realtimeAudioTrack?.play() }
- }
-
- private fun stopMicFallback() {
- val record = audioRecord
- audioRecord = null
- micThread?.interrupt()
- micThread = null
- releaseAudioEffects()
- if (record != null) {
- runCatching { record.stop() }
- runCatching { record.release() }
- }
- }
-
- private fun buildFallbackAudioRecord(bufferSize: Int): AudioRecord? {
- val sources = listOf(
- MediaRecorder.AudioSource.VOICE_COMMUNICATION,
- MediaRecorder.AudioSource.MIC
- )
- for (source in sources) {
- val record = runCatching {
- AudioRecord(
- source,
- MIC_SAMPLE_RATE,
- AudioFormat.CHANNEL_IN_MONO,
- AudioFormat.ENCODING_PCM_16BIT,
- bufferSize
- )
- }.getOrNull() ?: continue
- if (record.state == AudioRecord.STATE_INITIALIZED) {
- onLog("Mic fallback source selected: $source")
- return record
- }
- runCatching { record.release() }
- }
- onLog("Mic fallback init failed: no usable audio source")
- return null
- }
-
- private fun enableAudioEffects(audioSessionId: Int) {
- releaseAudioEffects()
- acousticEchoCanceler = runCatching {
- if (AcousticEchoCanceler.isAvailable()) {
- AcousticEchoCanceler.create(audioSessionId)?.apply { enabled = true }
- } else {
- null
- }
- }.getOrNull()
- noiseSuppressor = runCatching {
- if (NoiseSuppressor.isAvailable()) {
- NoiseSuppressor.create(audioSessionId)?.apply { enabled = true }
- } else {
- null
- }
- }.getOrNull()
- automaticGainControl = runCatching {
- if (AutomaticGainControl.isAvailable()) {
- AutomaticGainControl.create(audioSessionId)?.apply { enabled = true }
- } else {
- null
- }
- }.getOrNull()
- onLog(
- "Mic audio effects: aec=${acousticEchoCanceler != null}, ns=${noiseSuppressor != null}, agc=${automaticGainControl != null}"
- )
- }
-
- private fun releaseAudioEffects() {
- runCatching { acousticEchoCanceler?.release() }
- runCatching { noiseSuppressor?.release() }
- runCatching { automaticGainControl?.release() }
- acousticEchoCanceler = null
- noiseSuppressor = null
- automaticGainControl = null
- }
-
- private fun configureCommunicationAudio() {
- originalAudioMode = audioManager.mode
- if (audioManager.mode != AudioManager.MODE_IN_COMMUNICATION) {
- audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
- onLog("Audio mode -> MODE_IN_COMMUNICATION")
- }
- }
-
- private fun restoreCommunicationAudio() {
- if (audioManager.mode != originalAudioMode) {
- audioManager.mode = originalAudioMode
- onLog("Audio mode restored: $originalAudioMode")
- }
- }
-
- private fun onRealtimeBinary(now: Long, size: Int) {
- if (ttsBeginAt <= 0L) return
- currentTtsPacketCount += 1
- currentTtsBytes += size
- currentTtsLastPacketAt = now
- if (currentTtsFirstPacketAt == 0L) {
- currentTtsFirstPacketAt = now
- onLog("Realtime TTS first audio: delay=${now - ttsBeginAt}ms, bytes=$size")
- }
- if (size != TTS_BINARY_LOG_SAMPLE_BYTES && currentTtsPacketCount == 1) {
- onLog("Realtime TTS first packet size=$size")
- }
- }
-
- private fun resetRealtimeTtsStats() {
- currentTtsPacketCount = 0
- currentTtsBytes = 0
- currentTtsFirstPacketAt = 0L
- currentTtsLastPacketAt = 0L
- }
-
- private fun resetRealtimeUplinkStats() {
- realtimeUplinkFrames = 0
- realtimeUplinkBytes = 0
- realtimeUplinkFailures = 0
- realtimeUplinkFirstSendAt = 0L
- realtimeUplinkLastSendAt = 0L
- lastRealtimeUplinkLogAt = 0L
- }
-
- private fun logRealtimeTtsSummary() {
- if (ttsBeginAt <= 0L) return
- val now = SystemClock.elapsedRealtime()
- val firstDelay = if (currentTtsFirstPacketAt > 0L) currentTtsFirstPacketAt - ttsBeginAt else -1L
- val playDuration = if (currentTtsLastPacketAt > 0L) currentTtsLastPacketAt - ttsBeginAt else 0L
- onLog(
- "Realtime TTS summary: firstDelay=${firstDelay}ms, playDuration=${playDuration}ms, packets=$currentTtsPacketCount, bytes=$currentTtsBytes, endGap=${now - (currentTtsLastPacketAt.takeIf { it > 0L } ?: now)}ms"
- )
- ttsBeginAt = 0L
- resetRealtimeTtsStats()
- }
-
- private fun maybeReconnectRealtime(reason: String) {
- if (!running || realtimeClosedByClient || realtimeUrl.isBlank()) return
- if (reconnectRealtimePending) return
- reconnectRealtimePending = true
- onLog("Realtime reconnect scheduled: $reason")
- mainHandler.postDelayed(
- {
- reconnectRealtimePending = false
- if (!running || realtimeUrl.isBlank()) return@postDelayed
- onLog("Realtime reconnecting...")
- connectRealtime(realtimeUrl)
- },
- 900L
- )
- }
-
- private fun scheduleVisionResponseWatchdog() {
- val expectedAt = lastVisionImageUploadAt
- mainHandler.postDelayed(
- {
- if (!running || !realtimeConnected) return@postDelayed
- if (expectedAt != lastVisionImageUploadAt) return@postDelayed
- val idleFor = SystemClock.elapsedRealtime() - expectedAt
- if (idleFor >= VISION_RESPONSE_TIMEOUT_MS && !remoteVoiceActive) {
- onLog("Vision response pending: no voice reply ${idleFor}ms after image upload")
- }
- },
- VISION_RESPONSE_TIMEOUT_MS
- )
- }
-
- private data class PreparedRealtimeImage(
- val bytes: ByteArray,
- val width: Int,
- val height: Int
- )
-
- private fun prepareRealtimeImagePayload(): PreparedRealtimeImage? {
- val rawBase64 = latestCameraImageBase64
- if (rawBase64.isBlank()) return null
- val rawBytes = runCatching { Base64.decode(rawBase64, Base64.DEFAULT) }.getOrNull() ?: return null
- val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
- BitmapFactory.decodeByteArray(rawBytes, 0, rawBytes.size, bounds)
- val srcWidth = bounds.outWidth.takeIf { it > 0 } ?: latestCameraImageWidth
- val srcHeight = bounds.outHeight.takeIf { it > 0 } ?: latestCameraImageHeight
- if (rawBytes.size <= REALTIME_IMAGE_UPLOAD_TARGET_BYTES && srcWidth <= REALTIME_IMAGE_UPLOAD_MAX_DIM) {
- return PreparedRealtimeImage(
- bytes = rawBytes,
- width = srcWidth,
- height = srcHeight
- )
- }
- val bitmap = BitmapFactory.decodeByteArray(rawBytes, 0, rawBytes.size) ?: return null
- val scaled = scaleBitmapForRealtime(bitmap)
- if (scaled !== bitmap) {
- bitmap.recycle()
- }
- var quality = 58
- var encoded = encodeBitmapJpeg(scaled, quality)
- while (encoded.size > REALTIME_IMAGE_UPLOAD_TARGET_BYTES && quality > REALTIME_IMAGE_UPLOAD_MIN_QUALITY) {
- quality -= 6
- encoded = encodeBitmapJpeg(scaled, quality)
- }
- val width = scaled.width
- val height = scaled.height
- scaled.recycle()
- return PreparedRealtimeImage(
- bytes = encoded,
- width = width,
- height = height
- )
- }
-
- private fun scaleBitmapForRealtime(bitmap: Bitmap): Bitmap {
- val maxDim = maxOf(bitmap.width, bitmap.height)
- if (maxDim <= REALTIME_IMAGE_UPLOAD_MAX_DIM) return bitmap
- val ratio = REALTIME_IMAGE_UPLOAD_MAX_DIM.toFloat() / maxDim.toFloat()
- val targetWidth = (bitmap.width * ratio).toInt().coerceAtLeast(1)
- val targetHeight = (bitmap.height * ratio).toInt().coerceAtLeast(1)
- return Bitmap.createScaledBitmap(bitmap, targetWidth, targetHeight, true)
- }
-
- private fun encodeBitmapJpeg(bitmap: Bitmap, quality: Int): ByteArray {
- val out = ByteArrayOutputStream()
- bitmap.compress(Bitmap.CompressFormat.JPEG, quality, out)
- return out.toByteArray()
- }
-
- private fun calculateRms(buffer: ByteArray, length: Int): Int {
- if (length < 2) return 0
- var sum = 0L
- var count = 0
- var i = 0
- while (i + 1 < length) {
- val low = buffer[i].toInt() and 0xFF
- val high = buffer[i + 1].toInt()
- val sample = ((high shl 8) or low).toShort().toInt()
- sum += kotlin.math.abs(sample)
- count += 1
- i += 2
- }
- if (count == 0) return 0
- return (sum / count).toInt()
- }
-
- private fun buildSpeechIntent(): Intent {
- return Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
- putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
- putExtra(RecognizerIntent.EXTRA_LANGUAGE, Locale.SIMPLIFIED_CHINESE.toLanguageTag())
- putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, false)
- putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 1)
- }
- }
-
- private fun startListeningDelayed(delayMs: Long) {
- if (!running) return
- mainHandler.postAtTime(
- {
- if (!running) return@postAtTime
- runCatching {
- speechRecognizer?.cancel()
- speechRecognizer?.startListening(buildSpeechIntent())
- }.onFailure {
- onLog("Speech start failed: ${it.message}")
- }
- },
- SPEECH_TOKEN,
- System.currentTimeMillis() + delayMs
- )
- }
-
- private fun bindCamera() {
- val future = ProcessCameraProvider.getInstance(context)
- future.addListener(
- {
- if (!running) return@addListener
- val provider = runCatching { future.get() }.getOrElse {
- onLog("Camera provider failed: ${it.message}")
- return@addListener
- }
- val capture = ImageCapture.Builder()
- .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
- .setTargetResolution(Size(CAMERA_TARGET_WIDTH, CAMERA_TARGET_HEIGHT))
- .setJpegQuality(CAMERA_JPEG_QUALITY)
- .build()
- val selector = chooseCamera(provider) ?: run {
- onLog("No usable camera found")
- return@addListener
- }
- runCatching {
- provider.unbindAll()
- provider.bindToLifecycle(lifecycleOwner, selector, capture)
- }.onSuccess {
- cameraProvider = provider
- imageCapture = capture
- onLog("Camera bound for software flow")
- }.onFailure {
- onLog("Camera bind failed: ${it.message}")
- }
- },
- ContextCompat.getMainExecutor(context)
- )
- }
-
- private fun chooseCamera(provider: ProcessCameraProvider): CameraSelector? {
- return runCatching {
- when {
- provider.hasCamera(CameraSelector.DEFAULT_BACK_CAMERA) ->
- CameraSelector.DEFAULT_BACK_CAMERA
- provider.hasCamera(CameraSelector.DEFAULT_FRONT_CAMERA) ->
- CameraSelector.DEFAULT_FRONT_CAMERA
- else -> null
- }
- }.getOrNull()
- }
-
- private fun releaseCamera() {
- runCatching { cameraProvider?.unbindAll() }
- imageCapture = null
- cameraProvider = null
- latestCameraImageBase64 = ""
- latestCameraImageBytes = 0
- latestCameraImageWidth = 0
- latestCameraImageHeight = 0
- latestCameraImageAt = 0L
- pendingRealtimeImageUpload = false
- cameraCaptureInFlight = false
- }
-
- private fun startCameraLoop() {
- stopCameraLoop()
- val task = object : Runnable {
- override fun run() {
- if (!running) return
- captureOnce()
- mainHandler.postDelayed(this, CAMERA_INTERVAL_MS)
- }
- }
- cameraTask = task
- mainHandler.postDelayed(task, FIRST_CAPTURE_DELAY_MS)
- }
-
- private fun stopCameraLoop() {
- cameraTask?.let { mainHandler.removeCallbacks(it) }
- cameraTask = null
- }
-
- private fun captureOnce() {
- val capture = imageCapture ?: return
- if (cameraCaptureInFlight) return
- cameraCaptureInFlight = true
- val output = runCatching {
- File.createTempFile("software_cam_", ".jpg", context.cacheDir)
- }.getOrElse {
- cameraCaptureInFlight = false
- onLog("Camera capture skipped: ${it.message}")
- return
- }
- val options = ImageCapture.OutputFileOptions.Builder(output).build()
- capture.takePicture(
- options,
- cameraExecutor,
- object : ImageCapture.OnImageSavedCallback {
- override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
- cameraCaptureInFlight = false
- val raw = runCatching { output.readBytes() }
- .onFailure { onLog("Camera read failed: ${it.message}") }
- .getOrNull()
- if (raw == null || raw.isEmpty()) {
- runCatching { output.delete() }
- return
- }
- val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true }
- runCatching { BitmapFactory.decodeFile(output.absolutePath, opts) }
- val width = opts.outWidth.coerceAtLeast(0)
- val height = opts.outHeight.coerceAtLeast(0)
- val base64 = Base64.encodeToString(raw, Base64.NO_WRAP)
- latestCameraImageBase64 = base64
- latestCameraImageBytes = raw.size
- latestCameraImageWidth = width
- latestCameraImageHeight = height
- latestCameraImageAt = System.currentTimeMillis()
- runCatching { output.delete() }
- mainHandler.post {
- if (running) {
- onCameraCapture(base64, raw.size, width, height)
- if (pendingRealtimeImageUpload) {
- uploadLatestRealtimeImage(source = "fresh-capture")
- }
- }
- }
- }
-
- override fun onError(exception: ImageCaptureException) {
- cameraCaptureInFlight = false
- runCatching { output.delete() }
- onLog("Camera capture failed: ${exception.message}")
- }
- }
- )
- }
-
- private val recognitionListener = object : RecognitionListener {
- override fun onReadyForSpeech(params: Bundle?) = Unit
-
- override fun onBeginningOfSpeech() = Unit
-
- override fun onRmsChanged(rmsdB: Float) = Unit
-
- override fun onBufferReceived(buffer: ByteArray?) = Unit
-
- override fun onEndOfSpeech() = Unit
-
- override fun onError(error: Int) {
- if (!running) return
- if (error != SpeechRecognizer.ERROR_NO_MATCH && error != SpeechRecognizer.ERROR_SPEECH_TIMEOUT) {
- onLog("Speech recognition error=$error")
- }
- startListeningDelayed(600L)
- }
-
- override fun onResults(results: Bundle?) {
- if (!running) return
- val text = results
- ?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
- ?.firstOrNull()
- ?.trim()
- .orEmpty()
- if (text.isNotBlank()) {
- onSpeechText(text)
- }
- startListeningDelayed(450L)
- }
-
- override fun onPartialResults(partialResults: Bundle?) = Unit
-
- override fun onEvent(eventType: Int, params: Bundle?) = Unit
- }
-
- private companion object {
- val SPEECH_TOKEN = Any()
- }
-}
diff --git a/android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeApiService.kt b/android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeApiService.kt
deleted file mode 100644
index cc7615c..0000000
--- a/android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeApiService.kt
+++ /dev/null
@@ -1,109 +0,0 @@
-package com.aiglasses.app.storyforge
-
-import okhttp3.MultipartBody
-import okhttp3.RequestBody
-import retrofit2.http.Body
-import retrofit2.http.GET
-import retrofit2.http.Multipart
-import retrofit2.http.PATCH
-import retrofit2.http.POST
-import retrofit2.http.Part
-import retrofit2.http.Path
-import retrofit2.http.Query
-
-interface StoryForgeApiService {
- @POST("v2/auth/register")
- suspend fun register(@Body request: RegisterAccountRequest): AccountDto
-
- @POST("v2/auth/login")
- suspend fun login(@Body request: LoginRequest): AuthResponseDto
-
- @POST("v2/auth/logout")
- suspend fun logout(): Map
-
- @GET("v2/me")
- suspend fun me(): AccountDto
-
- @GET("v2/me/dashboard")
- suspend fun dashboard(): DashboardDto
-
- @GET("v2/model-profiles")
- suspend fun modelProfiles(): List
-
- @POST("v2/model-profiles")
- suspend fun createModelProfile(@Body request: ModelProfileRequest): ModelProfileDto
-
- @POST("v2/me/preferences/analysis-model")
- suspend fun setPreferredAnalysisModel(@Body request: PreferredModelRequest): AccountDto
-
- @GET("v2/knowledge-bases")
- suspend fun knowledgeBases(): List
-
- @POST("v2/knowledge-bases")
- suspend fun createKnowledgeBase(@Body request: KnowledgeBaseCreateRequest): KnowledgeBaseDto
-
- @GET("v2/knowledge-bases/{knowledgeBaseId}/documents")
- suspend fun knowledgeDocuments(@Path("knowledgeBaseId") knowledgeBaseId: String): List
-
- @GET("v2/explore/jobs")
- suspend fun jobs(): List
-
- @GET("v2/explore/jobs/{jobId}")
- suspend fun job(@Path("jobId") jobId: String): JobDto
-
- @POST("v2/explore/video-link")
- suspend fun createVideoLinkJob(@Body request: ExploreVideoLinkRequest): JobDto
-
- @POST("v2/explore/text")
- suspend fun createTextJob(@Body request: ExploreTextRequest): JobDto
-
- @POST("v2/pipelines/content-source-sync")
- suspend fun createContentSourceSyncJob(@Body request: ContentSourceSyncRequest): JobDto
-
- @Multipart
- @POST("v2/explore/upload-video")
- suspend fun uploadVideo(
- @Part file: MultipartBody.Part,
- @Part("title") title: RequestBody,
- @Part("knowledge_base_id") knowledgeBaseId: RequestBody,
- @Part("assistant_id") assistantId: RequestBody,
- @Part("analysis_model_profile_id") analysisModelProfileId: RequestBody
- ): JobDto
-
- @GET("v2/assistants")
- suspend fun assistants(): List
-
- @POST("v2/assistants")
- suspend fun createAssistant(@Body request: AssistantCreateRequest): AssistantDto
-
- @PATCH("v2/assistants/{assistantId}")
- suspend fun updateAssistant(
- @Path("assistantId") assistantId: String,
- @Body request: AssistantUpdateRequest
- ): AssistantDto
-
- @POST("v2/assistants/{assistantId}/generate")
- suspend fun generateCopy(
- @Path("assistantId") assistantId: String,
- @Body request: GenerateCopyRequest
- ): GenerateCopyResponseDto
-
- @GET("v2/admin/accounts/pending")
- suspend fun pendingAccounts(): List
-
- @POST("v2/admin/accounts/{accountId}/approve")
- suspend fun approveAccount(@Path("accountId") accountId: String): ApprovalDecisionDto
-
- @POST("v2/admin/accounts/{accountId}/reject")
- suspend fun rejectAccount(@Path("accountId") accountId: String): ApprovalDecisionDto
-
- @GET("api/v1/app/update/latest")
- suspend fun latestUpdate(
- @Query("platform") platform: String = "android",
- @Query("channel") channel: String = "stable",
- @Query("currentVersionCode") currentVersionCode: Int? = null
- ): AppUpdateLatestDto
-
- @POST("v2/admin/app/update/publish")
- suspend fun publishAppUpdate(@Body request: PublishAppUpdateRequest): PublishAppUpdateResponseDto
-}
diff --git a/android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeModels.kt b/android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeModels.kt
deleted file mode 100644
index 9c23777..0000000
--- a/android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeModels.kt
+++ /dev/null
@@ -1,295 +0,0 @@
-package com.aiglasses.app.storyforge
-
-import kotlinx.serialization.Serializable
-import kotlinx.serialization.json.JsonArray
-import kotlinx.serialization.json.JsonObject
-import kotlinx.serialization.json.buildJsonArray
-import kotlinx.serialization.json.buildJsonObject
-
-@Serializable
-data class RegisterAccountRequest(
- val username: String,
- val password: String,
- val display_name: String
-)
-
-@Serializable
-data class LoginRequest(
- val username: String,
- val password: String
-)
-
-@Serializable
-data class AccountDto(
- val id: String,
- val username: String,
- val display_name: String,
- val role: String,
- val approval_status: String,
- val approved_by: String? = null,
- val approved_at: String? = null,
- val preferred_analysis_model_id: String = "",
- val created_at: String = "",
- val updated_at: String = ""
-)
-
-@Serializable
-data class AuthResponseDto(
- val token: String,
- val account: AccountDto,
- val default_external_base_url: String = ""
-)
-
-@Serializable
-data class ModelProfileDto(
- val id: String,
- val owner_account_id: String? = null,
- val name: String,
- val provider: String,
- val base_url: String,
- val api_key_masked: String = "",
- val model_name: String,
- val is_system: Boolean = false,
- val is_default: Boolean = false,
- val created_at: String = "",
- val updated_at: String = ""
-)
-
-@Serializable
-data class ModelProfileRequest(
- val name: String,
- val base_url: String,
- val api_key: String,
- val model_name: String,
- val is_default: Boolean = false
-)
-
-@Serializable
-data class PreferredModelRequest(
- val model_profile_id: String
-)
-
-@Serializable
-data class ProjectDto(
- val id: String,
- val user_id: String,
- val name: String,
- val description: String = "",
- val created_at: String = "",
- val updated_at: String = ""
-)
-
-@Serializable
-data class KnowledgeBaseDto(
- val id: String,
- val user_id: String,
- val project_id: String = "",
- val name: String,
- val description: String = "",
- val sync_status: String = "pending",
- val document_count: Int = 0,
- val linked_assistant_count: Int = 0,
- val created_at: String = "",
- val updated_at: String = ""
-)
-
-@Serializable
-data class KnowledgeBaseCreateRequest(
- val name: String,
- val project_id: String = "",
- val description: String = ""
-)
-
-@Serializable
-data class AssistantDto(
- val id: String,
- val user_id: String,
- val project_id: String = "",
- val name: String,
- val description: String = "",
- val system_prompt: String = "",
- val generation_goal: String = "",
- val knowledge_base_ids: List = emptyList(),
- val config: JsonObject = buildJsonObject { },
- val model_profile_id: String = "",
- val created_at: String = "",
- val updated_at: String = ""
-)
-
-@Serializable
-data class AssistantCreateRequest(
- val name: String,
- val description: String = "",
- val system_prompt: String = "",
- val generation_goal: String = "",
- val knowledge_base_ids: List = emptyList(),
- val project_id: String = "",
- val model_profile_id: String = ""
-)
-
-@Serializable
-data class AssistantUpdateRequest(
- val name: String? = null,
- val description: String? = null,
- val system_prompt: String? = null,
- val generation_goal: String? = null,
- val knowledge_base_ids: List? = null,
- val project_id: String? = null,
- val model_profile_id: String? = null
-)
-
-@Serializable
-data class ExploreVideoLinkRequest(
- val video_url: String,
- val title: String? = null,
- val project_id: String? = null,
- val knowledge_base_id: String? = null,
- val assistant_id: String? = null,
- val analysis_model_profile_id: String? = null,
- val language: String = "auto"
-)
-
-@Serializable
-data class ExploreTextRequest(
- val title: String,
- val content: String,
- val project_id: String? = null,
- val knowledge_base_id: String? = null,
- val assistant_id: String? = null,
- val analysis_model_profile_id: String? = null
-)
-
-@Serializable
-data class ContentSourceSyncRequest(
- val project_id: String = "",
- val knowledge_base_id: String = "",
- val assistant_id: String = "",
- val content_source_id: String = "",
- val platform: String = "",
- val handle: String = "",
- val source_url: String = "",
- val title: String = "",
- val analysis_model_profile_id: String = "",
- val language: String = "auto",
- val max_items: Int = 5,
- val skip_existing: Boolean = true,
- val auto_trigger_analysis: Boolean = true
-)
-
-@Serializable
-data class JobDto(
- val id: String,
- val user_id: String,
- val project_id: String = "",
- val parent_job_id: String = "",
- val assistant_id: String? = null,
- val knowledge_base_id: String,
- val content_source_id: String = "",
- val source_type: String,
- val line_type: String = "analysis",
- val workflow_key: String = "",
- val orchestrator: String = "n8n",
- val provider_name: String = "",
- val provider_task_id: String = "",
- val source_url: String? = null,
- val title: String,
- val language: String,
- val status: String,
- val transcript_text: String = "",
- val style_summary: String = "",
- val upload_status: String = "pending",
- val error: String = "",
- val artifacts: JsonObject = buildJsonObject { },
- val result: JsonObject = buildJsonObject { },
- val analysis_model_profile_id: String = "",
- val created_at: String = "",
- val updated_at: String = ""
-)
-
-@Serializable
-data class KnowledgeDocumentDto(
- val id: String,
- val knowledge_base_id: String,
- val title: String,
- val source_type: String,
- val source_url: String = "",
- val transcript_text: String = "",
- val style_summary: String = "",
- val combined_text: String = "",
- val analysis: JsonObject = buildJsonObject { },
- val storyboards: JsonArray = buildJsonArray { },
- val source_artifacts: JsonObject = buildJsonObject { },
- val analysis_model_profile_id: String = "",
- val created_at: String = "",
- val updated_at: String = ""
-)
-
-@Serializable
-data class GenerateCopyRequest(
- val brief: String,
- val platform: String = "抖音",
- val audience: String = "创业者",
- val extra_requirements: String = "",
- val knowledge_base_ids: List = emptyList()
-)
-
-@Serializable
-data class GenerateCopyResponseDto(
- val assistant_id: String,
- val knowledge_base_ids: List,
- val content: String,
- val prompt_excerpt: String,
- val used_documents: List = emptyList()
-)
-
-@Serializable
-data class DashboardDto(
- val account: AccountDto,
- val projects: List = emptyList(),
- val knowledge_bases: List = emptyList(),
- val assistants: List = emptyList(),
- val recent_jobs: List = emptyList(),
- val model_profiles: List = emptyList()
-)
-
-@Serializable
-data class ApprovalDecisionDto(
- val saved: Boolean,
- val account: AccountDto
-)
-
-@Serializable
-data class PublishAppUpdateRequest(
- val platform: String = "android",
- val channel: String = "stable",
- val versionCode: Int,
- val versionName: String,
- val minSupportedCode: Int,
- val apkUrl: String,
- val apkSha256: String = "",
- val notes: String = "",
- val forceUpdate: Boolean = false,
- val isActive: Boolean = true
-)
-
-@Serializable
-data class PublishAppUpdateResponseDto(
- val saved: Boolean,
- val action: String,
- val updateId: Int = 0
-)
-
-@Serializable
-data class AppUpdateLatestDto(
- val platform: String = "android",
- val channel: String = "stable",
- val hasUpdate: Boolean = false,
- val latestVersionCode: Int = 0,
- val latestVersionName: String = "",
- val minSupportedCode: Int = 0,
- val downloadUrl: String = "",
- val apkSha256: String = "",
- val releaseNotes: String = "",
- val forceUpdate: Boolean = false,
- val publishedAt: Long = 0L
-)
diff --git a/android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeRepository.kt b/android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeRepository.kt
deleted file mode 100644
index e270801..0000000
--- a/android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeRepository.kt
+++ /dev/null
@@ -1,396 +0,0 @@
-package com.aiglasses.app.storyforge
-
-import android.content.Context
-import android.net.Uri
-import android.provider.OpenableColumns
-import com.aiglasses.app.BuildConfig
-import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
-import java.io.File
-import java.io.FileOutputStream
-import java.net.InetAddress
-import java.util.concurrent.TimeUnit
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.withContext
-import kotlinx.serialization.ExperimentalSerializationApi
-import kotlinx.serialization.json.Json
-import okhttp3.MediaType.Companion.toMediaType
-import okhttp3.MediaType.Companion.toMediaTypeOrNull
-import okhttp3.MultipartBody
-import okhttp3.OkHttpClient
-import okhttp3.Protocol
-import okhttp3.Request
-import okhttp3.RequestBody.Companion.asRequestBody
-import okhttp3.RequestBody.Companion.toRequestBody
-import okhttp3.logging.HttpLoggingInterceptor
-import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
-import retrofit2.Retrofit
-import retrofit2.create
-
-data class StoryForgeConnectionInfo(
- val rawBaseUrl: String,
- val requestBaseUrl: String,
- val originalHostHeader: String,
- val resolvedIp: String
-)
-
-data class StoryForgeLoginResult(
- val auth: AuthResponseDto,
- val connection: StoryForgeConnectionInfo
-)
-
-class StoryForgeRepository(private val context: Context) {
- private val appContext = context.applicationContext
- private val sessionStore = StoryForgeSessionStore(appContext)
-
- @OptIn(ExperimentalSerializationApi::class)
- private val json = Json {
- ignoreUnknownKeys = true
- explicitNulls = false
- }
-
- @Volatile
- private var cachedService: StoryForgeApiService? = null
-
- @Volatile
- private var cachedConnection: StoryForgeConnectionInfo? = null
-
- @Volatile
- private var cachedToken: String = ""
-
- fun savedSession(): SavedStoryForgeSession = sessionStore.load()
-
- fun saveBaseUrl(baseUrl: String) {
- sessionStore.saveBaseUrl(normalizeRawBaseUrl(baseUrl))
- }
-
- suspend fun resolveConnection(baseUrl: String): StoryForgeConnectionInfo = withContext(Dispatchers.IO) {
- resolveConnectionInternal(baseUrl)
- }
-
- suspend fun register(baseUrl: String, username: String, password: String, displayName: String): AccountDto {
- sessionStore.saveBaseUrl(normalizeRawBaseUrl(baseUrl))
- return api(baseUrl = baseUrl, token = "").register(
- RegisterAccountRequest(
- username = username,
- password = password,
- display_name = displayName
- )
- )
- }
-
- suspend fun login(baseUrl: String, username: String, password: String): StoryForgeLoginResult {
- val auth = api(baseUrl = baseUrl, token = "").login(LoginRequest(username = username, password = password))
- val effectiveBaseUrl = auth.default_external_base_url.ifBlank { normalizeRawBaseUrl(baseUrl) }
- sessionStore.save(effectiveBaseUrl, auth.token)
- cachedService = null
- val connection = apiConnection(baseUrl = effectiveBaseUrl, token = auth.token)
- return StoryForgeLoginResult(auth = auth, connection = connection)
- }
-
- suspend fun logout() {
- runCatching { api().logout() }
- sessionStore.clearToken()
- cachedToken = ""
- cachedService = null
- }
-
- suspend fun me(): AccountDto = api().me()
-
- suspend fun dashboard(): DashboardDto = api().dashboard()
-
- suspend fun modelProfiles(): List = api().modelProfiles()
-
- suspend fun createModelProfile(request: ModelProfileRequest): ModelProfileDto = api().createModelProfile(request)
-
- suspend fun setPreferredAnalysisModel(modelProfileId: String): AccountDto =
- api().setPreferredAnalysisModel(PreferredModelRequest(model_profile_id = modelProfileId))
-
- suspend fun createKnowledgeBase(name: String, description: String): KnowledgeBaseDto =
- api().createKnowledgeBase(KnowledgeBaseCreateRequest(name = name, description = description))
-
- suspend fun knowledgeDocuments(knowledgeBaseId: String): List =
- api().knowledgeDocuments(knowledgeBaseId)
-
- suspend fun jobs(): List = api().jobs()
-
- suspend fun job(jobId: String): JobDto = api().job(jobId)
-
- suspend fun createVideoLinkJob(
- videoUrl: String,
- title: String,
- knowledgeBaseId: String,
- assistantId: String,
- analysisModelProfileId: String
- ): JobDto = api().createVideoLinkJob(
- ExploreVideoLinkRequest(
- video_url = videoUrl,
- title = title.ifBlank { null },
- knowledge_base_id = knowledgeBaseId.ifBlank { null },
- assistant_id = assistantId.ifBlank { null },
- analysis_model_profile_id = analysisModelProfileId.ifBlank { null }
- )
- )
-
- suspend fun createTextJob(
- title: String,
- content: String,
- knowledgeBaseId: String,
- assistantId: String,
- analysisModelProfileId: String
- ): JobDto = api().createTextJob(
- ExploreTextRequest(
- title = title,
- content = content,
- knowledge_base_id = knowledgeBaseId.ifBlank { null },
- assistant_id = assistantId.ifBlank { null },
- analysis_model_profile_id = analysisModelProfileId.ifBlank { null }
- )
- )
-
- suspend fun createContentSourceSyncJob(
- platform: String,
- handle: String,
- sourceUrl: String,
- title: String,
- knowledgeBaseId: String,
- assistantId: String,
- analysisModelProfileId: String,
- maxItems: Int,
- skipExisting: Boolean,
- autoTriggerAnalysis: Boolean
- ): JobDto = api().createContentSourceSyncJob(
- ContentSourceSyncRequest(
- knowledge_base_id = knowledgeBaseId,
- assistant_id = assistantId,
- platform = platform,
- handle = handle,
- source_url = sourceUrl,
- title = title,
- analysis_model_profile_id = analysisModelProfileId,
- max_items = maxItems,
- skip_existing = skipExisting,
- auto_trigger_analysis = autoTriggerAnalysis
- )
- )
-
- suspend fun uploadVideo(
- uri: Uri,
- title: String,
- knowledgeBaseId: String,
- assistantId: String,
- analysisModelProfileId: String
- ): JobDto = withContext(Dispatchers.IO) {
- val tempFile = copyUriToCache(uri)
- try {
- val filePart = MultipartBody.Part.createFormData(
- name = "file",
- filename = tempFile.name,
- body = tempFile.asRequestBody(guessMimeType(tempFile.name).toMediaTypeOrNull())
- )
- api().uploadVideo(
- file = filePart,
- title = title.toRequestBody("text/plain".toMediaType()),
- knowledgeBaseId = knowledgeBaseId.toRequestBody("text/plain".toMediaType()),
- assistantId = assistantId.toRequestBody("text/plain".toMediaType()),
- analysisModelProfileId = analysisModelProfileId.toRequestBody("text/plain".toMediaType())
- )
- } finally {
- tempFile.delete()
- }
- }
-
- suspend fun createAssistant(request: AssistantCreateRequest): AssistantDto = api().createAssistant(request)
-
- suspend fun updateAssistant(assistantId: String, request: AssistantUpdateRequest): AssistantDto =
- api().updateAssistant(assistantId, request)
-
- suspend fun generateCopy(assistantId: String, request: GenerateCopyRequest): GenerateCopyResponseDto =
- api().generateCopy(assistantId, request)
-
- suspend fun pendingAccounts(): List = api().pendingAccounts()
-
- suspend fun approveAccount(accountId: String): ApprovalDecisionDto = api().approveAccount(accountId)
-
- suspend fun rejectAccount(accountId: String): ApprovalDecisionDto = api().rejectAccount(accountId)
-
- suspend fun latestUpdate(currentVersionCode: Int): AppUpdateLatestDto =
- api().latestUpdate(currentVersionCode = currentVersionCode)
-
- suspend fun publishAppUpdate(request: PublishAppUpdateRequest): PublishAppUpdateResponseDto =
- api().publishAppUpdate(request)
-
- suspend fun currentConnection(): StoryForgeConnectionInfo = apiConnection()
-
- private suspend fun api(
- baseUrl: String? = null,
- token: String? = null
- ): StoryForgeApiService = withContext(Dispatchers.IO) {
- val connection = apiConnection(baseUrl = baseUrl, token = token)
- val authToken = token ?: sessionStore.load().token
- if (cachedService != null && cachedConnection == connection && cachedToken == authToken) {
- return@withContext cachedService!!
- }
- val client = buildClient(connection, authToken)
- val retrofit = Retrofit.Builder()
- .baseUrl(connection.requestBaseUrl)
- .client(client)
- .addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
- .build()
- retrofit.create().also {
- cachedService = it
- cachedConnection = connection
- cachedToken = authToken
- }
- }
-
- private suspend fun apiConnection(
- baseUrl: String? = null,
- token: String? = null
- ): StoryForgeConnectionInfo = withContext(Dispatchers.IO) {
- val saved = sessionStore.load()
- val targetBaseUrl = normalizeRawBaseUrl(baseUrl ?: saved.baseUrl)
- val resolved = resolveConnectionInternal(targetBaseUrl)
- cachedConnection = resolved
- if (token != null) {
- cachedToken = token
- }
- resolved
- }
-
- private fun buildClient(connection: StoryForgeConnectionInfo, token: String): OkHttpClient {
- val logging = HttpLoggingInterceptor().apply {
- level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BASIC else HttpLoggingInterceptor.Level.NONE
- }
- return OkHttpClient.Builder()
- .protocols(listOf(Protocol.HTTP_1_1))
- .connectTimeout(12, TimeUnit.SECONDS)
- .readTimeout(120, TimeUnit.SECONDS)
- .writeTimeout(120, TimeUnit.SECONDS)
- .callTimeout(150, TimeUnit.SECONDS)
- .addInterceptor { chain ->
- val builder: Request.Builder = chain.request().newBuilder()
- if (token.isNotBlank()) {
- builder.header("Authorization", "Bearer $token")
- }
- if (connection.originalHostHeader.isNotBlank()) {
- builder.header("Host", connection.originalHostHeader)
- }
- builder.header("Connection", "close")
- chain.proceed(builder.build())
- }
- .addInterceptor(logging)
- .build()
- }
-
- private fun normalizeRawBaseUrl(baseUrl: String): String {
- val trimmed = baseUrl.trim().ifBlank { BuildConfig.DEFAULT_STORYFORGE_BASE_URL }
- val migrated = when {
- trimmed.startsWith("http://test.hyzq.net:8081") -> BuildConfig.DEFAULT_STORYFORGE_BASE_URL
- trimmed.startsWith("https://test.hyzq.net/storyforge") -> BuildConfig.DEFAULT_STORYFORGE_BASE_URL
- trimmed.startsWith("http://111.231.132.51:8081") -> BuildConfig.DEFAULT_STORYFORGE_BASE_URL
- else -> trimmed
- }
- val withScheme = if (migrated.startsWith("http://") || migrated.startsWith("https://")) migrated else "http://$migrated"
- return if (withScheme.endsWith('/')) withScheme else "$withScheme/"
- }
-
- private fun resolveConnectionInternal(baseUrl: String): StoryForgeConnectionInfo {
- val normalized = normalizeRawBaseUrl(baseUrl)
- val httpUrl = normalized.toHttpUrlOrNull() ?: error("无效后端地址: $baseUrl")
- val host = httpUrl.host
- val scheme = httpUrl.scheme
- if (scheme == "https" || isIpHost(host) || host == "localhost" || host == "10.0.2.2") {
- return StoryForgeConnectionInfo(
- rawBaseUrl = normalized,
- requestBaseUrl = normalized,
- originalHostHeader = "",
- resolvedIp = if (isIpHost(host)) host else ""
- )
- }
- val resolvedIp = runCatching {
- InetAddress.getAllByName(host).firstOrNull()?.hostAddress.orEmpty()
- }.getOrDefault("")
- .takeUnless { isInvalidResolvedIp(it) }
- .orEmpty()
- .ifBlank {
- if (
- host.equals("test.hyzq.net", ignoreCase = true) ||
- host.equals("storyforge.hyzq.net", ignoreCase = true)
- ) BuildConfig.DEFAULT_STORYFORGE_FALLBACK_IP else ""
- }
- if (resolvedIp.isBlank()) {
- return StoryForgeConnectionInfo(
- rawBaseUrl = normalized,
- requestBaseUrl = normalized,
- originalHostHeader = "",
- resolvedIp = ""
- )
- }
- val rewritten = httpUrl.newBuilder().host(resolvedIp).build().toString()
- return StoryForgeConnectionInfo(
- rawBaseUrl = normalized,
- requestBaseUrl = rewritten,
- originalHostHeader = hostHeaderValue(httpUrl.host, httpUrl.port, scheme),
- resolvedIp = resolvedIp
- )
- }
-
- private fun hostHeaderValue(host: String, port: Int, scheme: String): String {
- val isDefaultPort = (scheme == "http" && port == 80) || (scheme == "https" && port == 443)
- return if (isDefaultPort) host else "$host:$port"
- }
-
- private fun isIpHost(host: String): Boolean {
- return IPV4_REGEX.matches(host) || host.contains(':')
- }
-
- private fun isInvalidResolvedIp(ip: String): Boolean {
- if (ip.isBlank()) return true
- if (!IPV4_REGEX.matches(ip)) return false
- val octets = ip.split('.').mapNotNull { it.toIntOrNull() }
- if (octets.size != 4) return false
- if (octets[0] == 127) return true
- if (octets[0] == 0) return true
- if (octets[0] == 169 && octets[1] == 254) return true
- if (octets[0] == 198 && (octets[1] == 18 || octets[1] == 19)) return true
- return false
- }
-
- private fun copyUriToCache(uri: Uri): File {
- val displayName = queryDisplayName(uri)
- val safeName = displayName.ifBlank { "upload-${System.currentTimeMillis()}.mp4" }
- val suffix = safeName.substringAfterLast('.', missingDelimiterValue = "mp4")
- val target = File(appContext.cacheDir, "storyforge-${System.currentTimeMillis()}.$suffix")
- appContext.contentResolver.openInputStream(uri).use { input ->
- requireNotNull(input) { "无法读取所选视频" }
- FileOutputStream(target).use { output ->
- input.copyTo(output)
- }
- }
- return target
- }
-
- private fun queryDisplayName(uri: Uri): String {
- if (uri.scheme == "file") {
- return File(uri.path.orEmpty()).name
- }
- val cursor = appContext.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
- cursor?.use {
- val index = it.getColumnIndex(OpenableColumns.DISPLAY_NAME)
- if (index >= 0 && it.moveToFirst()) {
- return it.getString(index).orEmpty()
- }
- }
- return uri.lastPathSegment.orEmpty()
- }
-
- private fun guessMimeType(fileName: String): String = when {
- fileName.endsWith(".mov", ignoreCase = true) -> "video/quicktime"
- fileName.endsWith(".m4v", ignoreCase = true) -> "video/x-m4v"
- else -> "video/mp4"
- }
-
- private companion object {
- private val IPV4_REGEX = Regex("""^\\d{1,3}(?:\\.\\d{1,3}){3}$""")
- }
-}
diff --git a/android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeScreen.kt b/android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeScreen.kt
deleted file mode 100644
index a629e74..0000000
--- a/android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeScreen.kt
+++ /dev/null
@@ -1,1125 +0,0 @@
-package com.aiglasses.app.storyforge
-
-import androidx.compose.foundation.background
-import androidx.compose.foundation.border
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.ExperimentalLayoutApi
-import androidx.compose.foundation.layout.FlowRow
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.navigationBarsPadding
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.foundation.verticalScroll
-import androidx.compose.material3.Button
-import androidx.compose.material3.ButtonDefaults
-import androidx.compose.material3.Card
-import androidx.compose.material3.CardDefaults
-import androidx.compose.material3.CircularProgressIndicator
-import androidx.compose.material3.FilterChip
-import androidx.compose.material3.HorizontalDivider
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.NavigationBar
-import androidx.compose.material3.OutlinedButton
-import androidx.compose.material3.OutlinedTextField
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Surface
-import androidx.compose.material3.Switch
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-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.foundation.text.KeyboardOptions
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.text.input.KeyboardType
-import androidx.compose.ui.text.input.PasswordVisualTransformation
-import androidx.compose.ui.text.style.TextOverflow
-import androidx.compose.ui.unit.dp
-
-@Composable
-fun StoryForgeScreen(
- state: StoryForgeUiState,
- vm: StoryForgeViewModel,
- onPickVideo: () -> Unit,
- onInstallLatestUpdate: () -> Unit
-) {
- val heroBrush = Brush.linearGradient(
- colors = listOf(Color(0xFFEAF3FF), Color(0xFFD6E9FF), Color(0xFFF7FBFF))
- )
-
- Scaffold(
- bottomBar = {
- if (state.isAuthenticated && state.isApproved) {
- NavigationBar(
- modifier = Modifier.navigationBarsPadding(),
- containerColor = MaterialTheme.colorScheme.surface
- ) {
- BottomTabItem(label = "总览", tab = StoryForgeTab.Overview, state = state, onSelect = vm::selectTab)
- BottomTabItem(label = "对标", tab = StoryForgeTab.Benchmark, state = state, onSelect = vm::selectTab)
- BottomTabItem(label = "Agent", tab = StoryForgeTab.Agent, state = state, onSelect = vm::selectTab)
- BottomTabItem(label = "生产", tab = StoryForgeTab.Production, state = state, onSelect = vm::selectTab)
- BottomTabItem(label = "我的", tab = StoryForgeTab.Mine, state = state, onSelect = vm::selectTab)
- }
- }
- }
- ) { innerPadding ->
- Surface(
- modifier = Modifier
- .fillMaxSize()
- .background(MaterialTheme.colorScheme.background)
- .padding(innerPadding)
- ) {
- when {
- !state.isAuthenticated -> AuthScreen(state = state, vm = vm, heroBrush = heroBrush)
- !state.isApproved -> PendingApprovalScreen(state = state, vm = vm, heroBrush = heroBrush)
- else -> AppShell(
- state = state,
- vm = vm,
- heroBrush = heroBrush,
- onPickVideo = onPickVideo,
- onInstallLatestUpdate = onInstallLatestUpdate
- )
- }
- }
- }
-}
-
-@Composable
-private fun BottomTabItem(
- label: String,
- tab: StoryForgeTab,
- state: StoryForgeUiState,
- onSelect: (StoryForgeTab) -> Unit
-) {
- val selected = state.currentTab == tab
- Box(
- modifier = Modifier
- .clip(RoundedCornerShape(18.dp))
- .clickable { onSelect(tab) }
- .background(
- if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) else Color.Transparent
- )
- .padding(horizontal = 10.dp, vertical = 10.dp),
- contentAlignment = Alignment.Center
- ) {
- Column(horizontalAlignment = Alignment.CenterHorizontally) {
- Box(
- modifier = Modifier
- .size(10.dp)
- .clip(RoundedCornerShape(999.dp))
- .background(
- if (selected) MaterialTheme.colorScheme.primary
- else MaterialTheme.colorScheme.outline.copy(alpha = 0.45f)
- )
- )
- Spacer(modifier = Modifier.height(6.dp))
- Text(
- text = label,
- style = MaterialTheme.typography.labelSmall,
- fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium,
- color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.72f)
- )
- }
- }
-}
-
-@Composable
-private fun AuthScreen(
- state: StoryForgeUiState,
- vm: StoryForgeViewModel,
- heroBrush: Brush
-) {
- Box(
- modifier = Modifier
- .fillMaxSize()
- .background(heroBrush)
- .padding(18.dp),
- contentAlignment = Alignment.Center
- ) {
- Card(
- colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
- shape = RoundedCornerShape(28.dp),
- elevation = CardDefaults.cardElevation(defaultElevation = 6.dp),
- modifier = Modifier.fillMaxWidth()
- ) {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(22.dp),
- verticalArrangement = Arrangement.spacedBy(12.dp)
- ) {
- Text("StoryForge", style = MaterialTheme.typography.headlineMedium)
- Text(
- if (state.authMode == StoryForgeAuthMode.Login) "登录工作区,继续对标、Agent 和生产流程。"
- else "先创建账号,审批通过后就能开始搭项目和 Agent。",
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.72f)
- )
- ChoiceRow(
- options = listOf(
- "登录" to (state.authMode == StoryForgeAuthMode.Login),
- "注册" to (state.authMode == StoryForgeAuthMode.Register)
- ),
- onSelect = { label ->
- vm.setAuthMode(if (label == "登录") StoryForgeAuthMode.Login else StoryForgeAuthMode.Register)
- }
- )
- OutlinedTextField(
- value = state.username,
- onValueChange = vm::updateUsername,
- modifier = Modifier.fillMaxWidth(),
- label = { Text("账号") },
- singleLine = true
- )
- OutlinedTextField(
- value = state.password,
- onValueChange = vm::updatePassword,
- modifier = Modifier.fillMaxWidth(),
- label = { Text("密码") },
- singleLine = true,
- visualTransformation = PasswordVisualTransformation(),
- keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)
- )
- Button(
- onClick = { if (state.authMode == StoryForgeAuthMode.Login) vm.login() else vm.registerAccount() },
- enabled = !state.busy,
- modifier = Modifier.fillMaxWidth()
- ) {
- if (state.busy) {
- CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
- } else {
- Text(if (state.authMode == StoryForgeAuthMode.Login) "进入工作区" else "提交注册")
- }
- }
- if (state.statusMessage.isNotBlank()) {
- Text(state.statusMessage, style = MaterialTheme.typography.bodySmall)
- }
- if (state.errorMessage.isNotBlank()) {
- Text(state.errorMessage, color = MaterialTheme.colorScheme.error)
- }
- }
- }
- }
-}
-
-@Composable
-private fun PendingApprovalScreen(
- state: StoryForgeUiState,
- vm: StoryForgeViewModel,
- heroBrush: Brush
-) {
- val account = state.account
- Column(
- modifier = Modifier
- .fillMaxSize()
- .padding(18.dp),
- verticalArrangement = Arrangement.spacedBy(16.dp)
- ) {
- HeroCard(
- title = "等待审批",
- subtitle = "${account?.display_name ?: account?.username ?: "当前账号"} 已登录,待主管理员通过后继续使用。",
- heroBrush = heroBrush,
- badges = listOf(
- "状态 ${account?.approval_status ?: "pending"}",
- if (state.resolvedIp.isNotBlank()) "已解析 ${state.resolvedIp}" else ""
- ).filter { it.isNotBlank() }
- )
- SectionCard(title = "当前说明", subtitle = state.statusMessage) {
- Text("审批通过前,项目、对标、Agent 和生产入口都会先锁定。")
- Spacer(modifier = Modifier.height(12.dp))
- Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
- Button(onClick = vm::refreshApprovalStatus, enabled = !state.busy) {
- Text("刷新状态")
- }
- OutlinedButton(onClick = vm::logout) {
- Text("退出")
- }
- }
- if (state.errorMessage.isNotBlank()) {
- Spacer(modifier = Modifier.height(10.dp))
- Text(state.errorMessage, color = MaterialTheme.colorScheme.error)
- }
- }
- }
-}
-
-@Composable
-private fun AppShell(
- state: StoryForgeUiState,
- vm: StoryForgeViewModel,
- heroBrush: Brush,
- onPickVideo: () -> Unit,
- onInstallLatestUpdate: () -> Unit
-) {
- val scroll = rememberScrollState()
- Column(
- modifier = Modifier
- .fillMaxSize()
- .verticalScroll(scroll)
- .padding(18.dp),
- verticalArrangement = Arrangement.spacedBy(16.dp)
- ) {
- HeroCard(
- title = when (state.currentTab) {
- StoryForgeTab.Overview -> "项目总览"
- StoryForgeTab.Benchmark -> "找对标"
- StoryForgeTab.Agent -> "Agent"
- StoryForgeTab.Production -> "生产中心"
- StoryForgeTab.Mine -> "我的"
- },
- subtitle = when (state.currentTab) {
- StoryForgeTab.Overview -> "今天先看项目状态、跟踪日报和高价值动作。"
- StoryForgeTab.Benchmark -> "导入主页、作品或本地视频,让 Agent 识别并归类学习。"
- StoryForgeTab.Agent -> "定义账号方向、主模型和调研目标,再生成内容。"
- StoryForgeTab.Production -> "把文案、封面、实拍剪辑和 AI 视频放进同一条生产泳道。"
- StoryForgeTab.Mine -> "管理账号、模型、审批、OTA 和系统状态。"
- },
- heroBrush = heroBrush,
- badges = listOf(
- state.account?.display_name ?: state.account?.username.orEmpty(),
- state.account?.role ?: "",
- if (state.resolvedIp.isNotBlank()) "IP ${state.resolvedIp}" else ""
- ).filter { it.isNotBlank() }
- )
- StatusStrip(state = state, onRefresh = vm::refreshWorkspace)
- when (state.currentTab) {
- StoryForgeTab.Overview -> OverviewTab(state = state, vm = vm)
- StoryForgeTab.Benchmark -> BenchmarkTab(state = state, vm = vm, onPickVideo = onPickVideo)
- StoryForgeTab.Agent -> AgentTab(state = state, vm = vm)
- StoryForgeTab.Production -> ProductionTab(state = state, vm = vm)
- StoryForgeTab.Mine -> MineTab(state = state, vm = vm, onInstallLatestUpdate = onInstallLatestUpdate)
- }
- }
-}
-
-@Composable
-private fun StatusStrip(state: StoryForgeUiState, onRefresh: () -> Unit) {
- SectionCard(title = "连接状态", subtitle = if (state.busy) "正在同步" else "已连接") {
- Text(
- text = if (state.originalHost.isNotBlank()) {
- "当前请求会保留 Host=${state.originalHost},解析 IP=${state.resolvedIp.ifBlank { "未解析" }}"
- } else {
- "当前地址:${state.baseUrl}"
- },
- style = MaterialTheme.typography.bodySmall
- )
- Spacer(modifier = Modifier.height(12.dp))
- Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
- OutlinedButton(onClick = onRefresh) {
- Text("刷新")
- }
- if (state.busy) {
- CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
- }
- if (state.errorMessage.isNotBlank()) {
- Text(state.errorMessage, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
- }
- }
- }
-}
-
-@Composable
-private fun OverviewTab(state: StoryForgeUiState, vm: StoryForgeViewModel) {
- SectionCard(title = "今日概览", subtitle = "先看库存、活跃 Agent 和待处理任务。") {
- StatsRow(
- metrics = listOf(
- "知识库" to state.knowledgeBases.size.toString(),
- "Agent" to state.assistants.size.toString(),
- "任务" to state.jobs.size.toString(),
- "素材" to state.documents.size.toString()
- )
- )
- }
-
- SectionCard(title = "跟踪日报", subtitle = "这里先用最近任务和时间线模拟移动端日报摘要。") {
- val latest = state.jobs.take(3)
- if (latest.isEmpty()) {
- Text("今天还没有新的更新,先去找对标导入一个账号或作品。")
- } else {
- latest.forEach { job ->
- MiniCard(
- title = job.title,
- subtitle = buildString {
- append("状态 ${job.status}")
- if (job.workflow_key.isNotBlank()) append(" · ${job.workflow_key}")
- if (job.style_summary.isNotBlank()) append(" · ${job.style_summary.take(42)}")
- }
- )
- Spacer(modifier = Modifier.height(10.dp))
- }
- }
- }
-
- SectionCard(title = "今天先做什么", subtitle = "把最高频动作放到首屏。") {
- ActionRow(
- actions = listOf(
- "找对标" to { vm.selectTab(StoryForgeTab.Benchmark) },
- "配 Agent" to { vm.selectTab(StoryForgeTab.Agent) },
- "去生产" to { vm.selectTab(StoryForgeTab.Production) },
- "看我的" to { vm.selectTab(StoryForgeTab.Mine) }
- )
- )
- }
-
- SectionCard(title = "最近动态", subtitle = "确认最近一次导入、审批和生成结果。") {
- state.timeline.take(6).forEach { item ->
- Text(item, style = MaterialTheme.typography.bodySmall)
- Spacer(modifier = Modifier.height(6.dp))
- }
- }
-}
-
-@Composable
-private fun BenchmarkTab(state: StoryForgeUiState, vm: StoryForgeViewModel, onPickVideo: () -> Unit) {
- SectionCard(title = "导入对标", subtitle = "导入主页、视频或本地素材,再决定手动绑定还是交给 Agent 自动归类。") {
- ChoiceRow(
- options = listOf(
- "主页" to (state.exploreInputMode == ExploreInputMode.ContentSource),
- "视频" to (state.exploreInputMode == ExploreInputMode.VideoLink),
- "上传" to (state.exploreInputMode == ExploreInputMode.UploadVideo),
- "文本" to (state.exploreInputMode == ExploreInputMode.Text)
- ),
- onSelect = { label ->
- vm.setExploreInputMode(
- when (label) {
- "主页" -> ExploreInputMode.ContentSource
- "视频" -> ExploreInputMode.VideoLink
- "上传" -> ExploreInputMode.UploadVideo
- else -> ExploreInputMode.Text
- }
- )
- }
- )
- Spacer(modifier = Modifier.height(12.dp))
- AssistantSelector(state = state, onSelect = vm::selectAssistant)
- Spacer(modifier = Modifier.height(12.dp))
- KnowledgeBaseSelector(state = state, onSelect = vm::selectKnowledgeBase)
- Spacer(modifier = Modifier.height(12.dp))
- BenchmarkInputPanel(state = state, vm = vm, onPickVideo = onPickVideo)
- }
-
- SectionCard(title = "对标池", subtitle = "已经导入的任务和沉淀素材会先堆在这里。") {
- if (state.jobs.isEmpty() && state.documents.isEmpty()) {
- Text("先导入一个主页或作品,这里会开始形成你的学习池。")
- } else {
- state.jobs.take(3).forEach { job ->
- MiniCard(
- title = job.title,
- subtitle = "${job.source_type} · ${job.status} · ${job.line_type.ifBlank { "analysis" }}"
- )
- Spacer(modifier = Modifier.height(10.dp))
- }
- state.documents.take(2).forEach { document ->
- MiniCard(
- title = document.title,
- subtitle = document.style_summary.ifBlank { document.transcript_text.take(48) }
- )
- Spacer(modifier = Modifier.height(10.dp))
- }
- }
- }
-
- state.latestJob?.let { latest ->
- SectionCard(title = "参考详情", subtitle = latest.title) {
- KeyValueRow(label = "状态", value = latest.status)
- KeyValueRow(label = "工作流", value = latest.workflow_key.ifBlank { latest.line_type.ifBlank { "-" } })
- if (latest.transcript_text.isNotBlank()) {
- KeyValueBlock(label = "文本转写", value = latest.transcript_text)
- }
- if (latest.style_summary.isNotBlank()) {
- KeyValueBlock(label = "学习摘要", value = latest.style_summary)
- }
- }
- }
-}
-
-@Composable
-private fun BenchmarkInputPanel(
- state: StoryForgeUiState,
- vm: StoryForgeViewModel,
- onPickVideo: () -> Unit
-) {
- when (state.exploreInputMode) {
- ExploreInputMode.ContentSource -> {
- ChoiceRow(
- options = listOf(
- "抖音" to (state.accountSyncPlatform == "抖音"),
- "B站" to (state.accountSyncPlatform == "bilibili"),
- "小红书" to (state.accountSyncPlatform == "小红书")
- ),
- onSelect = { label ->
- vm.updateAccountSyncPlatform(if (label == "B站") "bilibili" else label)
- }
- )
- Spacer(modifier = Modifier.height(10.dp))
- OutlinedTextField(
- value = state.accountSyncUrl,
- onValueChange = vm::updateAccountSyncUrl,
- modifier = Modifier.fillMaxWidth(),
- label = { Text("主页链接或分享页") },
- minLines = 2
- )
- Spacer(modifier = Modifier.height(10.dp))
- OutlinedTextField(
- value = state.accountSyncHandle,
- onValueChange = vm::updateAccountSyncHandle,
- modifier = Modifier.fillMaxWidth(),
- label = { Text("账号标识,可选") },
- singleLine = true
- )
- Spacer(modifier = Modifier.height(10.dp))
- OutlinedTextField(
- value = state.accountSyncTitle,
- onValueChange = vm::updateAccountSyncTitle,
- modifier = Modifier.fillMaxWidth(),
- label = { Text("导入标题,可选") },
- singleLine = true
- )
- Spacer(modifier = Modifier.height(10.dp))
- OutlinedTextField(
- value = state.accountSyncMaxItems,
- onValueChange = vm::updateAccountSyncMaxItems,
- modifier = Modifier.fillMaxWidth(),
- label = { Text("最近拉取数量") },
- singleLine = true
- )
- Spacer(modifier = Modifier.height(12.dp))
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.spacedBy(12.dp)
- ) {
- Row(
- modifier = Modifier.weight(1f),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.SpaceBetween
- ) {
- Text("跳过旧内容", style = MaterialTheme.typography.bodySmall)
- Switch(
- checked = state.accountSyncSkipExisting,
- onCheckedChange = vm::setAccountSyncSkipExisting
- )
- }
- Row(
- modifier = Modifier.weight(1f),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.SpaceBetween
- ) {
- Text("自动分析", style = MaterialTheme.typography.bodySmall)
- Switch(
- checked = state.accountSyncAutoTriggerAnalysis,
- onCheckedChange = vm::setAccountSyncAutoTriggerAnalysis
- )
- }
- }
- Spacer(modifier = Modifier.height(12.dp))
- ActionRow(
- actions = listOf(
- "手动绑定" to vm::submitContentSourceSync,
- "交给 Agent" to vm::submitContentSourceSync
- )
- )
- }
-
- ExploreInputMode.VideoLink -> {
- OutlinedTextField(
- value = state.videoUrl,
- onValueChange = vm::updateVideoUrl,
- modifier = Modifier.fillMaxWidth(),
- label = { Text("作品链接") },
- minLines = 2
- )
- Spacer(modifier = Modifier.height(10.dp))
- OutlinedTextField(
- value = state.videoTitle,
- onValueChange = vm::updateVideoTitle,
- modifier = Modifier.fillMaxWidth(),
- label = { Text("作品标题,可选") },
- singleLine = true
- )
- Spacer(modifier = Modifier.height(12.dp))
- ActionRow(
- actions = listOf(
- "手动导入" to vm::submitVideoLink,
- "交给 Agent" to vm::submitVideoLink
- )
- )
- }
-
- ExploreInputMode.UploadVideo -> {
- OutlinedTextField(
- value = state.videoTitle,
- onValueChange = vm::updateVideoTitle,
- modifier = Modifier.fillMaxWidth(),
- label = { Text("素材标题,可选") },
- singleLine = true
- )
- Spacer(modifier = Modifier.height(10.dp))
- Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
- OutlinedButton(onClick = onPickVideo) {
- Text(if (state.pickedVideoName.isBlank()) "选择视频" else "重新选择")
- }
- Text(
- text = if (state.pickedVideoName.isBlank()) "未选择文件" else state.pickedVideoName,
- modifier = Modifier.weight(1f),
- maxLines = 2,
- overflow = TextOverflow.Ellipsis
- )
- }
- Spacer(modifier = Modifier.height(12.dp))
- ActionRow(
- actions = listOf(
- "手动导入" to vm::submitUploadVideo,
- "交给 Agent" to vm::submitUploadVideo
- ),
- enabled = !state.busy && state.pickedVideoName.isNotBlank()
- )
- }
-
- ExploreInputMode.Text -> {
- OutlinedTextField(
- value = state.textTitle,
- onValueChange = vm::updateTextTitle,
- modifier = Modifier.fillMaxWidth(),
- label = { Text("标题") },
- singleLine = true
- )
- Spacer(modifier = Modifier.height(10.dp))
- OutlinedTextField(
- value = state.textContent,
- onValueChange = vm::updateTextContent,
- modifier = Modifier.fillMaxWidth(),
- label = { Text("正文") },
- minLines = 5
- )
- Spacer(modifier = Modifier.height(12.dp))
- ActionRow(
- actions = listOf(
- "手动导入" to vm::submitText,
- "交给 Agent" to vm::submitText
- )
- )
- }
- }
-}
-
-@Composable
-private fun AgentTab(state: StoryForgeUiState, vm: StoryForgeViewModel) {
- SectionCard(title = "Agent 列表", subtitle = "一个 Agent 可以学习多个知识库,并服务多个平台。") {
- ChoiceRow(
- options = state.assistants.map { it.name to (state.selectedAssistantId == it.id) },
- onSelect = { label ->
- state.assistants.firstOrNull { it.name == label }?.let { vm.selectAssistant(it.id) }
- }
- )
- Spacer(modifier = Modifier.height(12.dp))
- OutlinedButton(onClick = vm::startNewAssistant) {
- Text("新建 Agent")
- }
- }
-
- SectionCard(title = "Agent 定义", subtitle = "先定义账号方向、变现方式和主模型,再决定学习哪些知识库。") {
- OutlinedTextField(
- value = state.assistantName,
- onValueChange = vm::updateAssistantName,
- modifier = Modifier.fillMaxWidth(),
- label = { Text("Agent 名称") },
- singleLine = true
- )
- Spacer(modifier = Modifier.height(10.dp))
- OutlinedTextField(
- value = state.assistantDescription,
- onValueChange = vm::updateAssistantDescription,
- modifier = Modifier.fillMaxWidth(),
- label = { Text("账号方向 / 变现方式") },
- minLines = 2
- )
- Spacer(modifier = Modifier.height(10.dp))
- OutlinedTextField(
- value = state.assistantSystemPrompt,
- onValueChange = vm::updateAssistantSystemPrompt,
- modifier = Modifier.fillMaxWidth(),
- label = { Text("系统提示词") },
- minLines = 5
- )
- Spacer(modifier = Modifier.height(10.dp))
- OutlinedTextField(
- value = state.assistantGenerationGoal,
- onValueChange = vm::updateAssistantGenerationGoal,
- modifier = Modifier.fillMaxWidth(),
- label = { Text("Agent 目标") },
- minLines = 3
- )
- Spacer(modifier = Modifier.height(12.dp))
- Text("目标平台", style = MaterialTheme.typography.titleSmall)
- Spacer(modifier = Modifier.height(8.dp))
- ChoiceRow(
- options = listOf(
- "抖音" to state.generationPlatform.contains("抖音"),
- "小红书" to state.generationPlatform.contains("小红书"),
- "快手" to state.generationPlatform.contains("快手"),
- "视频号" to state.generationPlatform.contains("视频号"),
- "YouTube" to state.generationPlatform.contains("YouTube"),
- "B站" to state.generationPlatform.contains("B站")
- ),
- onSelect = {}
- )
- Spacer(modifier = Modifier.height(12.dp))
- Text("主模型", style = MaterialTheme.typography.titleSmall)
- Spacer(modifier = Modifier.height(8.dp))
- ChoiceRow(
- options = state.modelProfiles.map { it.name to (state.assistantModelProfileId == it.id) },
- onSelect = { label ->
- state.modelProfiles.firstOrNull { it.name == label }?.let { vm.updateAssistantModelProfileId(it.id) }
- }
- )
- Spacer(modifier = Modifier.height(12.dp))
- Text("学习知识库", style = MaterialTheme.typography.titleSmall)
- Spacer(modifier = Modifier.height(8.dp))
- ChoiceRow(
- options = state.knowledgeBases.map { it.name to state.selectedAssistantKnowledgeBaseIds.contains(it.id) },
- onSelect = { label ->
- state.knowledgeBases.firstOrNull { it.name == label }?.let { vm.toggleAssistantKnowledgeBase(it.id) }
- }
- )
- Spacer(modifier = Modifier.height(14.dp))
- Button(onClick = vm::saveAssistant, enabled = !state.busy) {
- Text(if (state.assistantEditorId.isNullOrBlank()) "创建 Agent" else "保存 Agent")
- }
- }
-
- SectionCard(title = "调研与试跑", subtitle = "创建完 Agent 后,先跑一轮调研,再试一次文案输出。") {
- OutlinedTextField(
- value = state.generationBrief,
- onValueChange = vm::updateGenerationBrief,
- modifier = Modifier.fillMaxWidth(),
- label = { Text("本轮调研或文案需求") },
- minLines = 4
- )
- Spacer(modifier = Modifier.height(10.dp))
- Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
- OutlinedTextField(
- value = state.generationPlatform,
- onValueChange = vm::updateGenerationPlatform,
- modifier = Modifier.weight(1f),
- label = { Text("主平台") },
- singleLine = true
- )
- OutlinedTextField(
- value = state.generationAudience,
- onValueChange = vm::updateGenerationAudience,
- modifier = Modifier.weight(1f),
- label = { Text("人群") },
- singleLine = true
- )
- }
- Spacer(modifier = Modifier.height(10.dp))
- OutlinedTextField(
- value = state.generationExtraRequirements,
- onValueChange = vm::updateGenerationExtraRequirements,
- modifier = Modifier.fillMaxWidth(),
- label = { Text("额外要求") },
- minLines = 3
- )
- Spacer(modifier = Modifier.height(12.dp))
- Button(onClick = vm::generateCopy, enabled = !state.generateBusy) {
- if (state.generateBusy) {
- CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
- } else {
- Text("开始试跑")
- }
- }
- if (state.generationOutput.isNotBlank()) {
- Spacer(modifier = Modifier.height(16.dp))
- KeyValueBlock(label = "最近输出", value = state.generationOutput)
- }
- }
-}
-
-@Composable
-private fun ProductionTab(state: StoryForgeUiState, vm: StoryForgeViewModel) {
- SectionCard(title = "生产泳道", subtitle = "同一页管理文案、封面、实拍剪辑和 AI 视频。") {
- StatsRow(
- metrics = listOf(
- "文案" to if (state.generationOutput.isNotBlank()) "就绪" else "待生成",
- "封面" to "待接入",
- "实拍剪辑" to state.jobs.count { it.line_type == "real_cut" }.toString(),
- "AI 视频" to state.jobs.count { it.line_type == "ai_video" }.toString()
- )
- )
- Spacer(modifier = Modifier.height(12.dp))
- ActionRow(
- actions = listOf(
- "写文案" to vm::generateCopy,
- "补封面" to {},
- "实拍剪辑" to {},
- "AI 视频" to {}
- ),
- enabled = !state.generateBusy
- )
- }
-
- SectionCard(title = "作品与成片", subtitle = "这里承接生产完成后的作品库和当前任务。") {
- state.latestJob?.let { latest ->
- MiniCard(
- title = latest.title,
- subtitle = buildString {
- append("状态 ${latest.status}")
- if (latest.upload_status.isNotBlank()) append(" · 上传 ${latest.upload_status}")
- if (latest.style_summary.isNotBlank()) append(" · ${latest.style_summary.take(32)}")
- }
- )
- Spacer(modifier = Modifier.height(10.dp))
- }
- state.documents.take(3).forEach { document ->
- MiniCard(
- title = document.title,
- subtitle = document.style_summary.ifBlank { document.transcript_text.take(54) }
- )
- Spacer(modifier = Modifier.height(10.dp))
- }
- if (state.latestJob == null && state.documents.isEmpty()) {
- Text("还没有可看的作品,先去找对标导入,或者先创建一个 Agent。")
- }
- }
-
- SectionCard(title = "文案结果", subtitle = "先保留当前可用链路,后续把封面和视频能力一起接进来。") {
- if (state.generationOutput.isBlank()) {
- Text("还没有生成结果,先到 Agent 页完成一次试跑。")
- } else {
- KeyValueBlock(label = "文案", value = state.generationOutput)
- if (state.generationPromptExcerpt.isNotBlank()) {
- Spacer(modifier = Modifier.height(10.dp))
- KeyValueBlock(label = "提示词摘要", value = state.generationPromptExcerpt)
- }
- }
- }
-}
-
-@Composable
-private fun MineTab(state: StoryForgeUiState, vm: StoryForgeViewModel, onInstallLatestUpdate: () -> Unit) {
- SectionCard(title = "我的账号", subtitle = state.account?.display_name ?: state.account?.username.orEmpty()) {
- KeyValueRow(label = "用户名", value = state.account?.username ?: "-")
- KeyValueRow(label = "角色", value = state.account?.role ?: "-")
- KeyValueRow(label = "审批", value = state.account?.approval_status ?: "-")
- KeyValueRow(label = "地址", value = state.baseUrl)
- if (state.resolvedIp.isNotBlank()) {
- KeyValueRow(label = "解析 IP", value = state.resolvedIp)
- }
- Spacer(modifier = Modifier.height(12.dp))
- OutlinedButton(onClick = vm::logout) {
- Text("退出")
- }
- }
-
- SectionCard(title = "分析模型", subtitle = "用户不管 Key,只切主模型和默认分析模型。") {
- ChoiceRow(
- options = state.modelProfiles.map { it.name to (state.account?.preferred_analysis_model_id == it.id) },
- onSelect = { label ->
- state.modelProfiles.firstOrNull { it.name == label }?.let { vm.setPreferredModel(it.id) }
- }
- )
- Spacer(modifier = Modifier.height(14.dp))
- HorizontalDivider()
- Spacer(modifier = Modifier.height(14.dp))
- OutlinedTextField(
- value = state.newModelName,
- onValueChange = vm::updateNewModelName,
- modifier = Modifier.fillMaxWidth(),
- label = { Text("模型别名") },
- singleLine = true
- )
- Spacer(modifier = Modifier.height(10.dp))
- OutlinedTextField(
- value = state.newModelBaseUrl,
- onValueChange = vm::updateNewModelBaseUrl,
- modifier = Modifier.fillMaxWidth(),
- label = { Text("Base URL") },
- singleLine = true
- )
- Spacer(modifier = Modifier.height(10.dp))
- OutlinedTextField(
- value = state.newModelModelName,
- onValueChange = vm::updateNewModelModelName,
- modifier = Modifier.fillMaxWidth(),
- label = { Text("模型名称") },
- singleLine = true
- )
- Spacer(modifier = Modifier.height(10.dp))
- OutlinedTextField(
- value = state.newModelApiKey,
- onValueChange = vm::updateNewModelApiKey,
- modifier = Modifier.fillMaxWidth(),
- label = { Text("API Key") },
- singleLine = true,
- visualTransformation = PasswordVisualTransformation(),
- keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)
- )
- Spacer(modifier = Modifier.height(12.dp))
- Button(onClick = vm::createModelProfile) {
- Text("保存默认模型")
- }
- }
-
- SectionCard(title = "OTA 更新", subtitle = state.otaStatus.ifBlank { "检查并安装最新版本。" }) {
- Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
- Button(onClick = vm::checkForUpdates) {
- Text("检查")
- }
- OutlinedButton(onClick = onInstallLatestUpdate, enabled = state.otaInfo?.hasUpdate == true) {
- Text("安装")
- }
- }
- state.otaInfo?.let { ota ->
- Spacer(modifier = Modifier.height(12.dp))
- KeyValueRow(label = "版本", value = "${ota.latestVersionName} (${ota.latestVersionCode})")
- if (ota.releaseNotes.isNotBlank()) {
- KeyValueBlock(label = "更新说明", value = ota.releaseNotes)
- }
- }
- }
-
- if (state.account?.role == "super_admin") {
- SectionCard(title = "审批", subtitle = "主管理员审批新用户。") {
- if (state.pendingAccounts.isEmpty()) {
- Text("当前没有待审批账号。")
- } else {
- state.pendingAccounts.forEach { account ->
- Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(14.dp),
- verticalArrangement = Arrangement.spacedBy(8.dp)
- ) {
- Text(account.display_name, fontWeight = FontWeight.Bold)
- Text(account.username, style = MaterialTheme.typography.bodySmall)
- Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
- Button(onClick = { vm.approveAccount(account.id) }) {
- Text("通过")
- }
- OutlinedButton(onClick = { vm.rejectAccount(account.id) }) {
- Text("拒绝")
- }
- }
- }
- }
- Spacer(modifier = Modifier.height(10.dp))
- }
- }
- }
- }
-
- SectionCard(title = "最近日志", subtitle = "用来确认审批、解析、任务和 OTA 状态。") {
- state.timeline.take(8).forEach { item ->
- Text(item, style = MaterialTheme.typography.bodySmall)
- Spacer(modifier = Modifier.height(6.dp))
- }
- }
-}
-
-@OptIn(ExperimentalLayoutApi::class)
-@Composable
-private fun ChoiceRow(
- options: List>,
- onSelect: (String) -> Unit
-) {
- FlowRow(
- horizontalArrangement = Arrangement.spacedBy(10.dp),
- verticalArrangement = Arrangement.spacedBy(10.dp)
- ) {
- options.forEach { (label, selected) ->
- FilterChip(
- selected = selected,
- onClick = { onSelect(label) },
- label = { Text(label) }
- )
- }
- }
-}
-
-@OptIn(ExperimentalLayoutApi::class)
-@Composable
-private fun ActionRow(
- actions: List Unit>>,
- enabled: Boolean = true
-) {
- FlowRow(
- horizontalArrangement = Arrangement.spacedBy(10.dp),
- verticalArrangement = Arrangement.spacedBy(10.dp)
- ) {
- actions.forEachIndexed { index, (label, action) ->
- if (index == 0) {
- Button(onClick = action, enabled = enabled) {
- Text(label)
- }
- } else {
- OutlinedButton(onClick = action, enabled = enabled) {
- Text(label)
- }
- }
- }
- }
-}
-
-@OptIn(ExperimentalLayoutApi::class)
-@Composable
-private fun StatsRow(metrics: List>) {
- FlowRow(
- horizontalArrangement = Arrangement.spacedBy(10.dp),
- verticalArrangement = Arrangement.spacedBy(10.dp)
- ) {
- metrics.forEach { (label, value) ->
- Box(
- modifier = Modifier
- .width(140.dp)
- .clip(RoundedCornerShape(18.dp))
- .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f))
- .padding(14.dp)
- ) {
- Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
- Text(label, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.68f))
- Text(value, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
- }
- }
- }
- }
-}
-
-@Composable
-private fun KnowledgeBaseSelector(state: StoryForgeUiState, onSelect: (String) -> Unit) {
- Text("选择知识库", style = MaterialTheme.typography.titleSmall)
- Spacer(modifier = Modifier.height(8.dp))
- ChoiceRow(
- options = state.knowledgeBases.map { it.name to (state.selectedKnowledgeBaseId == it.id) },
- onSelect = { label ->
- state.knowledgeBases.firstOrNull { it.name == label }?.let { onSelect(it.id) }
- }
- )
-}
-
-@Composable
-private fun AssistantSelector(state: StoryForgeUiState, onSelect: (String) -> Unit) {
- Text("绑定 Agent", style = MaterialTheme.typography.titleSmall)
- Spacer(modifier = Modifier.height(8.dp))
- ChoiceRow(
- options = state.assistants.map { it.name to (state.selectedAssistantId == it.id) },
- onSelect = { label ->
- state.assistants.firstOrNull { it.name == label }?.let { onSelect(it.id) }
- }
- )
-}
-
-@Composable
-private fun HeroCard(title: String, subtitle: String, heroBrush: Brush, badges: List) {
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .clip(RoundedCornerShape(28.dp))
- .background(heroBrush)
- .border(
- width = 1.dp,
- color = MaterialTheme.colorScheme.outline.copy(alpha = 0.12f),
- shape = RoundedCornerShape(28.dp)
- )
- .padding(20.dp)
- ) {
- Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
- Text(title, style = MaterialTheme.typography.headlineLarge, color = MaterialTheme.colorScheme.onSurface)
- Text(subtitle, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.74f))
- if (badges.isNotEmpty()) {
- ChoiceRow(options = badges.map { it to true }, onSelect = {})
- }
- }
- }
-}
-
-@Composable
-private fun SectionCard(title: String, subtitle: String, content: @Composable () -> Unit) {
- Card(
- colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
- shape = RoundedCornerShape(22.dp),
- elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
- ) {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(18.dp),
- verticalArrangement = Arrangement.spacedBy(6.dp)
- ) {
- Text(title, style = MaterialTheme.typography.headlineSmall)
- if (subtitle.isNotBlank()) {
- Text(
- subtitle,
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.68f)
- )
- }
- Spacer(modifier = Modifier.height(6.dp))
- content()
- }
- }
-}
-
-@Composable
-private fun KeyValueRow(label: String, value: String) {
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically
- ) {
- Text(label, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f))
- Spacer(modifier = Modifier.width(12.dp))
- Text(value, modifier = Modifier.weight(1f), maxLines = 2, overflow = TextOverflow.Ellipsis)
- }
-}
-
-@Composable
-private fun KeyValueBlock(label: String, value: String) {
- Text(label, style = MaterialTheme.typography.titleSmall)
- Spacer(modifier = Modifier.height(6.dp))
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .clip(RoundedCornerShape(16.dp))
- .border(
- 1.dp,
- MaterialTheme.colorScheme.outline.copy(alpha = 0.2f),
- RoundedCornerShape(16.dp)
- )
- .padding(14.dp)
- ) {
- Text(value)
- }
-}
-
-@Composable
-private fun MiniCard(title: String, subtitle: String) {
- Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.58f))) {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(14.dp),
- verticalArrangement = Arrangement.spacedBy(6.dp)
- ) {
- Text(title, fontWeight = FontWeight.Bold)
- Text(
- subtitle,
- maxLines = 4,
- overflow = TextOverflow.Ellipsis,
- style = MaterialTheme.typography.bodySmall
- )
- }
- }
-}
diff --git a/android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeSessionStore.kt b/android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeSessionStore.kt
deleted file mode 100644
index b716921..0000000
--- a/android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeSessionStore.kt
+++ /dev/null
@@ -1,103 +0,0 @@
-package com.aiglasses.app.storyforge
-
-import android.content.Context
-import androidx.security.crypto.EncryptedSharedPreferences
-import androidx.security.crypto.MasterKey
-import com.aiglasses.app.BuildConfig
-
-data class SavedStoryForgeSession(
- val baseUrl: String,
- val token: String
-)
-
-class StoryForgeSessionStore(context: Context) {
- private val appContext = context.applicationContext
- private val legacyPrefs = appContext.getSharedPreferences(PREFS_NAME_LEGACY, Context.MODE_PRIVATE)
- private val prefs: android.content.SharedPreferences by lazy { createSecurePrefs() }
-
- init {
- migrateLegacySessionIfNeeded()
- }
-
- fun load(): SavedStoryForgeSession = SavedStoryForgeSession(
- baseUrl = migrateBaseUrl(prefs.getString(KEY_BASE_URL, BuildConfig.DEFAULT_STORYFORGE_BASE_URL).orEmpty()),
- token = prefs.getString(KEY_TOKEN, "").orEmpty()
- )
-
- fun saveBaseUrl(baseUrl: String) {
- prefs.edit().putString(KEY_BASE_URL, migrateBaseUrl(baseUrl)).apply()
- }
-
- fun saveToken(token: String) {
- prefs.edit().putString(KEY_TOKEN, token).apply()
- }
-
- fun save(baseUrl: String, token: String) {
- prefs.edit()
- .putString(KEY_BASE_URL, migrateBaseUrl(baseUrl))
- .putString(KEY_TOKEN, token)
- .apply()
- }
-
- fun clearToken() {
- prefs.edit().remove(KEY_TOKEN).apply()
- }
-
- fun clearAll() {
- prefs.edit().remove(KEY_BASE_URL).remove(KEY_TOKEN).apply()
- legacyPrefs.edit().remove(KEY_BASE_URL).remove(KEY_TOKEN).apply()
- }
-
- private companion object {
- private const val PREFS_NAME_LEGACY = "storyforge_session"
- private const val PREFS_NAME_SECURE = "storyforge_session_secure"
- private const val KEY_BASE_URL = "base_url"
- private const val KEY_TOKEN = "token"
- private const val LEGACY_DOMAIN_URL = "http://test.hyzq.net:8081"
- private const val LEGACY_PUBLIC_URL = "https://test.hyzq.net/storyforge"
- private const val LEGACY_IP_URL = "http://111.231.132.51:8081"
- }
-
- private fun createSecurePrefs(): android.content.SharedPreferences {
- return runCatching {
- val masterKey = MasterKey.Builder(appContext)
- .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
- .build()
- EncryptedSharedPreferences.create(
- appContext,
- PREFS_NAME_SECURE,
- masterKey,
- EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
- EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
- )
- }.getOrElse {
- throw IllegalStateException("Unable to create secure session storage", it)
- }
- }
-
- private fun migrateLegacySessionIfNeeded() {
- if (prefs.contains(KEY_BASE_URL) || prefs.contains(KEY_TOKEN)) {
- return
- }
- if (!legacyPrefs.contains(KEY_BASE_URL) && !legacyPrefs.contains(KEY_TOKEN)) {
- return
- }
- val baseUrl = migrateBaseUrl(
- legacyPrefs.getString(KEY_BASE_URL, BuildConfig.DEFAULT_STORYFORGE_BASE_URL).orEmpty()
- )
- val token = legacyPrefs.getString(KEY_TOKEN, "").orEmpty()
- save(baseUrl, token)
- legacyPrefs.edit().remove(KEY_BASE_URL).remove(KEY_TOKEN).apply()
- }
-
- private fun migrateBaseUrl(baseUrl: String): String {
- val trimmed = baseUrl.trim()
- return when {
- trimmed.isBlank() -> BuildConfig.DEFAULT_STORYFORGE_BASE_URL
- trimmed.startsWith(LEGACY_DOMAIN_URL) -> BuildConfig.DEFAULT_STORYFORGE_BASE_URL
- trimmed.startsWith(LEGACY_PUBLIC_URL) -> BuildConfig.DEFAULT_STORYFORGE_BASE_URL
- trimmed.startsWith(LEGACY_IP_URL) -> BuildConfig.DEFAULT_STORYFORGE_BASE_URL
- else -> trimmed
- }
- }
-}
diff --git a/android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeViewModel.kt b/android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeViewModel.kt
deleted file mode 100644
index 925bdb2..0000000
--- a/android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeViewModel.kt
+++ /dev/null
@@ -1,985 +0,0 @@
-package com.aiglasses.app.storyforge
-
-import android.app.Application
-import android.net.Uri
-import androidx.lifecycle.AndroidViewModel
-import androidx.lifecycle.viewModelScope
-import com.aiglasses.app.BuildConfig
-import com.aiglasses.app.update.AppOtaUpdater
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.launch
-import retrofit2.HttpException
-
-enum class StoryForgeTab {
- Overview,
- Benchmark,
- Agent,
- Production,
- Mine
-}
-
-enum class StoryForgeAuthMode {
- Login,
- Register
-}
-
-enum class ExploreInputMode {
- ContentSource,
- VideoLink,
- UploadVideo,
- Text
-}
-
-private const val DEFAULT_SYSTEM_PROMPT = "你是一个擅长学习短视频口播风格的 AI 文案助手,请优先保留素材中的钩子、节奏、转折和行动号召。"
-private const val DEFAULT_GENERATION_GOAL = "为不同渠道生成稳定风格的短视频标题、口播脚本和收尾行动号召。"
-
-private fun nextVersionName(current: String): String {
- val parts = current.split('.').toMutableList()
- val last = parts.lastOrNull()?.toIntOrNull()
- if (last != null) {
- parts[parts.lastIndex] = (last + 1).toString()
- return parts.joinToString(".")
- }
- return current
-}
-
-data class StoryForgeUiState(
- val authMode: StoryForgeAuthMode = StoryForgeAuthMode.Login,
- val baseUrl: String = BuildConfig.DEFAULT_STORYFORGE_BASE_URL,
- val resolvedBaseUrl: String = "",
- val resolvedIp: String = "",
- val originalHost: String = "",
- val isAuthenticated: Boolean = false,
- val isApproved: Boolean = false,
- val currentTab: StoryForgeTab = StoryForgeTab.Overview,
- val busy: Boolean = false,
- val generateBusy: Boolean = false,
- val statusMessage: String = "准备连接 StoryForge",
- val errorMessage: String = "",
- val account: AccountDto? = null,
- val knowledgeBases: List = emptyList(),
- val assistants: List = emptyList(),
- val modelProfiles: List = emptyList(),
- val jobs: List = emptyList(),
- val documents: List = emptyList(),
- val selectedKnowledgeBaseId: String = "",
- val selectedAssistantId: String = "",
- val selectedAssistantKnowledgeBaseIds: Set = emptySet(),
- val assistantEditorId: String? = null,
- val username: String = "",
- val password: String = "",
- val createKnowledgeBaseName: String = "",
- val createKnowledgeBaseDescription: String = "",
- val exploreInputMode: ExploreInputMode = ExploreInputMode.VideoLink,
- val accountSyncPlatform: String = "抖音",
- val accountSyncHandle: String = "",
- val accountSyncUrl: String = "",
- val accountSyncTitle: String = "",
- val accountSyncMaxItems: String = "5",
- val accountSyncSkipExisting: Boolean = true,
- val accountSyncAutoTriggerAnalysis: Boolean = true,
- val videoUrl: String = "",
- val videoTitle: String = "",
- val textTitle: String = "",
- val textContent: String = "",
- val pickedVideoName: String = "",
- val latestJobId: String = "",
- val latestJob: JobDto? = null,
- val assistantName: String = "",
- val assistantDescription: String = "",
- val assistantSystemPrompt: String = DEFAULT_SYSTEM_PROMPT,
- val assistantGenerationGoal: String = DEFAULT_GENERATION_GOAL,
- val assistantModelProfileId: String = "",
- val generationBrief: String = "围绕 AI 创业做一条 60 秒短视频口播文案",
- val generationPlatform: String = "抖音",
- val generationAudience: String = "创业者",
- val generationExtraRequirements: String = "开头结论先行,结尾给一个明确行动建议。",
- val generationOutput: String = "",
- val generationPromptExcerpt: String = "",
- val newModelName: String = "",
- val newModelBaseUrl: String = BuildConfig.DEFAULT_LOCAL_MODEL_BASE_URL,
- val newModelApiKey: String = "",
- val newModelModelName: String = "GLM-5",
- val pendingAccounts: List = emptyList(),
- val otaInfo: AppUpdateLatestDto? = null,
- val otaStatus: String = "",
- val publishVersionCode: String = (BuildConfig.VERSION_CODE + 1).toString(),
- val publishVersionName: String = nextVersionName(BuildConfig.VERSION_NAME),
- val publishMinSupportedCode: String = BuildConfig.VERSION_CODE.toString(),
- val publishApkUrl: String = "",
- val publishNotes: String = "",
- val publishForceUpdate: Boolean = false,
- val timeline: List = listOf("应用已启动,等待连接")
-)
-
-class StoryForgeViewModel(application: Application) : AndroidViewModel(application) {
- private val repository = StoryForgeRepository(application.applicationContext)
- private val _state = MutableStateFlow(StoryForgeUiState(baseUrl = repository.savedSession().baseUrl))
- val state: StateFlow = _state.asStateFlow()
-
- private var jobPollingJob: Job? = null
- private var pickedVideoUri: Uri? = null
-
- init {
- restoreSession()
- }
-
- fun updateBaseUrl(value: String) {
- _state.value = _state.value.copy(baseUrl = value)
- repository.saveBaseUrl(value)
- }
-
- fun updateUsername(value: String) {
- _state.value = _state.value.copy(username = value)
- }
-
- fun updatePassword(value: String) {
- _state.value = _state.value.copy(password = value)
- }
-
-
- fun setAuthMode(mode: StoryForgeAuthMode) {
- _state.value = _state.value.copy(authMode = mode, errorMessage = "")
- }
-
- fun selectTab(tab: StoryForgeTab) {
- _state.value = _state.value.copy(currentTab = tab)
- if (tab == StoryForgeTab.Mine && state.value.account?.role == "super_admin") {
- loadPendingAccounts()
- }
- }
-
- fun updateCreateKnowledgeBaseName(value: String) {
- _state.value = _state.value.copy(createKnowledgeBaseName = value)
- }
-
- fun updateCreateKnowledgeBaseDescription(value: String) {
- _state.value = _state.value.copy(createKnowledgeBaseDescription = value)
- }
-
- fun updateVideoUrl(value: String) {
- _state.value = _state.value.copy(videoUrl = value)
- }
-
- fun updateAccountSyncPlatform(value: String) {
- _state.value = _state.value.copy(accountSyncPlatform = value)
- }
-
- fun updateAccountSyncHandle(value: String) {
- _state.value = _state.value.copy(accountSyncHandle = value)
- }
-
- fun updateAccountSyncUrl(value: String) {
- _state.value = _state.value.copy(accountSyncUrl = value)
- }
-
- fun updateAccountSyncTitle(value: String) {
- _state.value = _state.value.copy(accountSyncTitle = value)
- }
-
- fun updateAccountSyncMaxItems(value: String) {
- val digits = value.filter { it.isDigit() }
- _state.value = _state.value.copy(accountSyncMaxItems = digits)
- }
-
- fun setAccountSyncSkipExisting(value: Boolean) {
- _state.value = _state.value.copy(accountSyncSkipExisting = value)
- }
-
- fun setAccountSyncAutoTriggerAnalysis(value: Boolean) {
- _state.value = _state.value.copy(accountSyncAutoTriggerAnalysis = value)
- }
-
- fun updateVideoTitle(value: String) {
- _state.value = _state.value.copy(videoTitle = value)
- }
-
- fun updateTextTitle(value: String) {
- _state.value = _state.value.copy(textTitle = value)
- }
-
- fun updateTextContent(value: String) {
- _state.value = _state.value.copy(textContent = value)
- }
-
- fun setExploreInputMode(mode: ExploreInputMode) {
- _state.value = _state.value.copy(exploreInputMode = mode, errorMessage = "")
- }
-
- fun setPickedVideo(uri: Uri?, fileName: String) {
- pickedVideoUri = uri
- _state.value = _state.value.copy(pickedVideoName = fileName)
- }
-
- fun selectKnowledgeBase(knowledgeBaseId: String) {
- _state.value = _state.value.copy(selectedKnowledgeBaseId = knowledgeBaseId)
- refreshDocuments()
- }
-
- fun selectAssistant(assistantId: String) {
- val assistant = _state.value.assistants.firstOrNull { it.id == assistantId }
- _state.value = _state.value.copy(
- selectedAssistantId = assistantId,
- selectedAssistantKnowledgeBaseIds = assistant?.knowledge_base_ids?.toSet() ?: emptySet(),
- assistantEditorId = assistant?.id,
- assistantName = assistant?.name.orEmpty(),
- assistantDescription = assistant?.description.orEmpty(),
- assistantSystemPrompt = assistant?.system_prompt ?: DEFAULT_SYSTEM_PROMPT,
- assistantGenerationGoal = assistant?.generation_goal ?: DEFAULT_GENERATION_GOAL,
- assistantModelProfileId = assistant?.model_profile_id.orEmpty(),
- generationOutput = "",
- generationPromptExcerpt = ""
- )
- }
-
- fun startNewAssistant() {
- _state.value = _state.value.copy(
- assistantEditorId = null,
- assistantName = "",
- assistantDescription = "",
- assistantSystemPrompt = DEFAULT_SYSTEM_PROMPT,
- assistantGenerationGoal = DEFAULT_GENERATION_GOAL,
- assistantModelProfileId = preferredModelId(),
- selectedAssistantKnowledgeBaseIds = listOfNotNull(state.value.selectedKnowledgeBaseId.takeIf { it.isNotBlank() }).toSet()
- )
- }
-
- fun toggleAssistantKnowledgeBase(knowledgeBaseId: String) {
- val updated = _state.value.selectedAssistantKnowledgeBaseIds.toMutableSet()
- if (!updated.add(knowledgeBaseId)) {
- updated.remove(knowledgeBaseId)
- }
- _state.value = _state.value.copy(selectedAssistantKnowledgeBaseIds = updated)
- }
-
- fun updateAssistantName(value: String) {
- _state.value = _state.value.copy(assistantName = value)
- }
-
- fun updateAssistantDescription(value: String) {
- _state.value = _state.value.copy(assistantDescription = value)
- }
-
- fun updateAssistantSystemPrompt(value: String) {
- _state.value = _state.value.copy(assistantSystemPrompt = value)
- }
-
- fun updateAssistantGenerationGoal(value: String) {
- _state.value = _state.value.copy(assistantGenerationGoal = value)
- }
-
- fun updateAssistantModelProfileId(value: String) {
- _state.value = _state.value.copy(assistantModelProfileId = value)
- }
-
- fun updateGenerationBrief(value: String) {
- _state.value = _state.value.copy(generationBrief = value)
- }
-
- fun updateGenerationPlatform(value: String) {
- _state.value = _state.value.copy(generationPlatform = value)
- }
-
- fun updateGenerationAudience(value: String) {
- _state.value = _state.value.copy(generationAudience = value)
- }
-
- fun updateGenerationExtraRequirements(value: String) {
- _state.value = _state.value.copy(generationExtraRequirements = value)
- }
-
- fun updateNewModelName(value: String) {
- _state.value = _state.value.copy(newModelName = value)
- }
-
- fun updateNewModelBaseUrl(value: String) {
- _state.value = _state.value.copy(newModelBaseUrl = value)
- }
-
- fun updateNewModelApiKey(value: String) {
- _state.value = _state.value.copy(newModelApiKey = value)
- }
-
- fun updateNewModelModelName(value: String) {
- _state.value = _state.value.copy(newModelModelName = value)
- }
-
- fun updatePublishVersionCode(value: String) {
- _state.value = _state.value.copy(publishVersionCode = value)
- }
-
- fun updatePublishVersionName(value: String) {
- _state.value = _state.value.copy(publishVersionName = value)
- }
-
- fun updatePublishMinSupportedCode(value: String) {
- _state.value = _state.value.copy(publishMinSupportedCode = value)
- }
-
- fun updatePublishApkUrl(value: String) {
- _state.value = _state.value.copy(publishApkUrl = value)
- }
-
- fun updatePublishNotes(value: String) {
- _state.value = _state.value.copy(publishNotes = value)
- }
-
- fun setPublishForceUpdate(value: Boolean) {
- _state.value = _state.value.copy(publishForceUpdate = value)
- }
-
- fun registerAccount() {
- val current = state.value
- if (current.username.isBlank() || current.password.isBlank()) {
- setError("请填写用户名和密码")
- return
- }
- runBusy(message = "正在提交注册申请...", task = {
- repository.register(
- baseUrl = current.baseUrl,
- username = current.username.trim(),
- password = current.password,
- displayName = current.username.trim()
- )
- }) { account ->
- appendTimeline("账号 ${account.username} 已注册,等待主管理员审批")
- _state.value = _state.value.copy(
- authMode = StoryForgeAuthMode.Login,
- password = "",
- statusMessage = "注册成功,请等待主管理员审批",
- errorMessage = ""
- )
- }
- }
-
- fun login() {
- val current = state.value
- if (current.username.isBlank() || current.password.isBlank()) {
- setError("请先填写用户名和密码")
- return
- }
- runBusy(message = "正在登录 StoryForge...", task = {
- repository.login(
- baseUrl = current.baseUrl,
- username = current.username.trim(),
- password = current.password
- )
- }) { result ->
- applyConnection(result.connection)
- appendTimeline("账号 ${result.auth.account.username} 登录成功")
- val account = result.auth.account
- _state.value = _state.value.copy(
- isAuthenticated = true,
- isApproved = account.approval_status == "approved",
- account = account,
- password = "",
- statusMessage = if (account.approval_status == "approved") "登录成功,正在同步工作台" else "账号待主管理员审批",
- errorMessage = ""
- )
- if (account.approval_status == "approved") {
- refreshWorkspace()
- }
- }
- }
-
- fun refreshApprovalStatus() {
- runBusy(message = "正在刷新审批状态...", task = {
- repository.me() to repository.currentConnection()
- }) { (account, connection) ->
- applyConnection(connection)
- _state.value = _state.value.copy(
- isAuthenticated = true,
- isApproved = account.approval_status == "approved",
- account = account,
- statusMessage = if (account.approval_status == "approved") "审批已通过,正在同步工作台" else "当前账号仍在等待审批",
- errorMessage = ""
- )
- appendTimeline("审批状态更新为 ${account.approval_status}")
- if (account.approval_status == "approved") {
- refreshWorkspace()
- }
- }
- }
-
- fun logout() {
- viewModelScope.launch {
- repository.logout()
- jobPollingJob?.cancel()
- pickedVideoUri = null
- appendTimeline("已退出当前账号")
- _state.value = StoryForgeUiState(baseUrl = repository.savedSession().baseUrl)
- }
- }
-
- fun refreshWorkspace() {
- viewModelScope.launch {
- val current = state.value
- _state.value = current.copy(busy = true, errorMessage = "", statusMessage = "正在同步工作台数据...")
- runCatching {
- val me = repository.me()
- val connection = repository.currentConnection()
- if (me.approval_status != "approved") {
- Triple(me, connection, null)
- } else {
- Triple(me, connection, repository.dashboard())
- }
- }.onSuccess { (account, connection, dashboard) ->
- applyConnection(connection)
- if (dashboard == null) {
- _state.value = state.value.copy(
- busy = false,
- isAuthenticated = true,
- isApproved = false,
- account = account,
- statusMessage = "账号待主管理员审批"
- )
- } else {
- applyDashboard(account, dashboard)
- }
- }.onFailure { throwable ->
- if (throwable is HttpException && throwable.code() == 401) {
- repository.logout()
- _state.value = StoryForgeUiState(baseUrl = repository.savedSession().baseUrl).copy(
- errorMessage = "登录已失效,请重新登录",
- statusMessage = "请重新登录 StoryForge"
- )
- } else {
- _state.value = state.value.copy(
- busy = false,
- errorMessage = throwable.toReadableMessage(),
- statusMessage = "同步失败,请检查网络或稍后重试"
- )
- appendTimeline("同步失败: ${throwable.toReadableMessage()}")
- }
- }
- }
- }
-
- fun createKnowledgeBase() {
- val current = state.value
- if (current.createKnowledgeBaseName.isBlank()) {
- setError("请先填写知识库名称")
- return
- }
- runBusy(message = "正在创建知识库...", task = {
- repository.createKnowledgeBase(current.createKnowledgeBaseName.trim(), current.createKnowledgeBaseDescription.trim())
- }) { knowledgeBase ->
- appendTimeline("已创建知识库 ${knowledgeBase.name}")
- _state.value = state.value.copy(
- createKnowledgeBaseName = "",
- createKnowledgeBaseDescription = "",
- selectedKnowledgeBaseId = knowledgeBase.id
- )
- refreshWorkspace()
- }
- }
-
- fun submitVideoLink() {
- val current = state.value
- if (current.videoUrl.isBlank()) {
- setError("请先输入视频链接")
- return
- }
- val knowledgeBaseId = selectedKnowledgeBaseIdOrFallback()
- if (knowledgeBaseId.isBlank()) {
- setError("请先选择知识库")
- return
- }
- runBusy(message = "正在提交视频学习任务...", task = {
- repository.createVideoLinkJob(
- videoUrl = current.videoUrl.trim(),
- title = current.videoTitle.trim(),
- knowledgeBaseId = knowledgeBaseId,
- assistantId = current.selectedAssistantId,
- analysisModelProfileId = preferredModelId()
- )
- }) { job ->
- appendTimeline("视频链接任务已创建: ${job.title}")
- _state.value = state.value.copy(videoUrl = "", videoTitle = "")
- afterJobCreated(job)
- }
- }
-
- fun submitContentSourceSync() {
- val current = state.value
- if (current.accountSyncUrl.isBlank()) {
- setError("请先输入账号主页链接")
- return
- }
- val knowledgeBaseId = selectedKnowledgeBaseIdOrFallback()
- if (knowledgeBaseId.isBlank()) {
- setError("请先选择知识库")
- return
- }
- val maxItems = current.accountSyncMaxItems.toIntOrNull()?.coerceIn(1, 20) ?: 5
- runBusy(message = "正在创建账号同步任务...", task = {
- repository.createContentSourceSyncJob(
- platform = current.accountSyncPlatform.trim(),
- handle = current.accountSyncHandle.trim(),
- sourceUrl = current.accountSyncUrl.trim(),
- title = current.accountSyncTitle.trim(),
- knowledgeBaseId = knowledgeBaseId,
- assistantId = current.selectedAssistantId,
- analysisModelProfileId = preferredModelId(),
- maxItems = maxItems,
- skipExisting = current.accountSyncSkipExisting,
- autoTriggerAnalysis = current.accountSyncAutoTriggerAnalysis
- )
- }) { job ->
- appendTimeline("账号同步任务已创建: ${job.title}")
- _state.value = state.value.copy(
- accountSyncHandle = "",
- accountSyncUrl = "",
- accountSyncTitle = "",
- accountSyncMaxItems = maxItems.toString()
- )
- afterJobCreated(job)
- }
- }
-
- fun submitText() {
- val current = state.value
- if (current.textTitle.isBlank() || current.textContent.isBlank()) {
- setError("请输入素材标题和文字内容")
- return
- }
- val knowledgeBaseId = selectedKnowledgeBaseIdOrFallback()
- if (knowledgeBaseId.isBlank()) {
- setError("请先选择知识库")
- return
- }
- runBusy(message = "正在提交文字分析任务...", task = {
- repository.createTextJob(
- title = current.textTitle.trim(),
- content = current.textContent.trim(),
- knowledgeBaseId = knowledgeBaseId,
- assistantId = current.selectedAssistantId,
- analysisModelProfileId = preferredModelId()
- )
- }) { job ->
- appendTimeline("文字素材已进入分析队列: ${job.title}")
- _state.value = state.value.copy(textTitle = "", textContent = "")
- afterJobCreated(job)
- }
- }
-
- fun submitUploadVideo() {
- val current = state.value
- val uri = pickedVideoUri
- if (uri == null) {
- setError("请先选择本地视频文件")
- return
- }
- val knowledgeBaseId = selectedKnowledgeBaseIdOrFallback()
- if (knowledgeBaseId.isBlank()) {
- setError("请先选择知识库")
- return
- }
- runBusy(message = "正在上传视频并创建学习任务...", task = {
- repository.uploadVideo(
- uri = uri,
- title = current.videoTitle.trim(),
- knowledgeBaseId = knowledgeBaseId,
- assistantId = current.selectedAssistantId,
- analysisModelProfileId = preferredModelId()
- )
- }) { job ->
- appendTimeline("视频上传成功,任务已创建: ${job.title}")
- pickedVideoUri = null
- _state.value = state.value.copy(videoTitle = "", pickedVideoName = "")
- afterJobCreated(job)
- }
- }
-
- fun saveAssistant() {
- val current = state.value
- if (current.assistantName.isBlank()) {
- setError("请先填写智能体名称")
- return
- }
- if (current.selectedAssistantKnowledgeBaseIds.isEmpty()) {
- setError("请至少关联一个知识库")
- return
- }
- val request = AssistantCreateRequest(
- name = current.assistantName.trim(),
- description = current.assistantDescription.trim(),
- system_prompt = current.assistantSystemPrompt.trim(),
- generation_goal = current.assistantGenerationGoal.trim(),
- knowledge_base_ids = current.selectedAssistantKnowledgeBaseIds.toList(),
- model_profile_id = current.assistantModelProfileId.ifBlank { preferredModelId() }
- )
- if (current.assistantEditorId.isNullOrBlank()) {
- runBusy(message = "正在创建智能体...", task = {
- repository.createAssistant(request)
- }) { assistant ->
- appendTimeline("已创建智能体 ${assistant.name}")
- _state.value = state.value.copy(selectedAssistantId = assistant.id)
- refreshWorkspace()
- }
- } else {
- runBusy(message = "正在保存智能体配置...", task = {
- repository.updateAssistant(
- current.assistantEditorId,
- AssistantUpdateRequest(
- name = request.name,
- description = request.description,
- system_prompt = request.system_prompt,
- generation_goal = request.generation_goal,
- knowledge_base_ids = request.knowledge_base_ids,
- model_profile_id = request.model_profile_id
- )
- )
- }) { assistant ->
- appendTimeline("已更新智能体 ${assistant.name}")
- _state.value = state.value.copy(selectedAssistantId = assistant.id)
- refreshWorkspace()
- }
- }
- }
-
- fun generateCopy() {
- val current = state.value
- val assistantId = current.selectedAssistantId.ifBlank { current.assistantEditorId.orEmpty() }
- if (assistantId.isBlank()) {
- setError("请先选择一个智能体")
- return
- }
- if (current.generationBrief.isBlank()) {
- setError("请先填写文案需求")
- return
- }
- viewModelScope.launch {
- _state.value = state.value.copy(generateBusy = true, errorMessage = "", statusMessage = "正在生成文案,请稍候...")
- runCatching {
- repository.generateCopy(
- assistantId,
- GenerateCopyRequest(
- brief = current.generationBrief.trim(),
- platform = current.generationPlatform.trim(),
- audience = current.generationAudience.trim(),
- extra_requirements = current.generationExtraRequirements.trim(),
- knowledge_base_ids = current.selectedAssistantKnowledgeBaseIds.toList()
- )
- )
- }.onSuccess { result ->
- _state.value = state.value.copy(
- generateBusy = false,
- generationOutput = result.content,
- generationPromptExcerpt = result.prompt_excerpt,
- statusMessage = "文案生成完成"
- )
- appendTimeline("智能体已生成一条新文案")
- }.onFailure { throwable ->
- _state.value = state.value.copy(
- generateBusy = false,
- errorMessage = throwable.toReadableMessage(),
- statusMessage = "文案生成失败"
- )
- appendTimeline("文案生成失败: ${throwable.toReadableMessage()}")
- }
- }
- }
-
- fun createModelProfile() {
- val current = state.value
- if (current.newModelName.isBlank() || current.newModelBaseUrl.isBlank() || current.newModelApiKey.isBlank() || current.newModelModelName.isBlank()) {
- setError("请完整填写模型名称、Base URL、API Key 和模型名")
- return
- }
- runBusy(message = "正在保存模型配置...", task = {
- repository.createModelProfile(
- ModelProfileRequest(
- name = current.newModelName.trim(),
- base_url = current.newModelBaseUrl.trim(),
- api_key = current.newModelApiKey.trim(),
- model_name = current.newModelModelName.trim(),
- is_default = true
- )
- )
- }) { profile ->
- appendTimeline("已新增模型配置 ${profile.name}")
- _state.value = state.value.copy(
- newModelName = "",
- newModelApiKey = "",
- newModelModelName = current.newModelModelName,
- assistantModelProfileId = profile.id
- )
- refreshWorkspace()
- }
- }
-
- fun setPreferredModel(modelProfileId: String) {
- runBusy(message = "正在切换默认分析模型...", task = {
- repository.setPreferredAnalysisModel(modelProfileId)
- }) { account ->
- _state.value = state.value.copy(account = account)
- appendTimeline("已切换默认分析模型")
- refreshWorkspace()
- }
- }
-
- fun loadPendingAccounts() {
- if (state.value.account?.role != "super_admin") return
- viewModelScope.launch {
- runCatching { repository.pendingAccounts() }
- .onSuccess { pending ->
- _state.value = state.value.copy(pendingAccounts = pending)
- }
- .onFailure { throwable ->
- _state.value = state.value.copy(errorMessage = throwable.toReadableMessage())
- }
- }
- }
-
- fun approveAccount(accountId: String) {
- runBusy(message = "正在通过账号审批...", task = {
- repository.approveAccount(accountId)
- }) {
- appendTimeline("已通过一条账号审批")
- refreshWorkspace()
- }
- }
-
- fun rejectAccount(accountId: String) {
- runBusy(message = "正在拒绝账号申请...", task = {
- repository.rejectAccount(accountId)
- }) {
- appendTimeline("已拒绝一条账号申请")
- refreshWorkspace()
- }
- }
-
- fun checkForUpdates() {
- viewModelScope.launch {
- _state.value = state.value.copy(otaStatus = "正在检查更新...")
- runCatching { repository.latestUpdate(BuildConfig.VERSION_CODE) }
- .onSuccess { latest ->
- _state.value = state.value.copy(
- otaInfo = latest,
- otaStatus = if (latest.hasUpdate) {
- "发现新版本 ${latest.latestVersionName} (${latest.latestVersionCode})"
- } else {
- "当前已经是最新版本"
- }
- )
- appendTimeline("OTA 检查完成")
- }
- .onFailure { throwable ->
- _state.value = state.value.copy(otaStatus = throwable.toReadableMessage(), errorMessage = throwable.toReadableMessage())
- }
- }
- }
-
- fun publishUpdate() {
- val current = state.value
- val versionCode = current.publishVersionCode.toIntOrNull()
- val minSupportedCode = current.publishMinSupportedCode.toIntOrNull()
- if (versionCode == null || minSupportedCode == null || current.publishVersionName.isBlank() || current.publishApkUrl.isBlank()) {
- setError("请完整填写 OTA 的版本号、最小支持版本、下载地址")
- return
- }
- runBusy(message = "正在发布 OTA 配置...", task = {
- repository.publishAppUpdate(
- PublishAppUpdateRequest(
- versionCode = versionCode,
- versionName = current.publishVersionName.trim(),
- minSupportedCode = minSupportedCode,
- apkUrl = current.publishApkUrl.trim(),
- notes = current.publishNotes.trim(),
- forceUpdate = current.publishForceUpdate
- )
- )
- }) { response ->
- _state.value = state.value.copy(otaStatus = "已发布 OTA: ${response.action}")
- appendTimeline("主管理员已发布 OTA ${current.publishVersionName}")
- checkForUpdates()
- }
- }
-
- fun onOtaLog(message: String) {
- appendTimeline(message)
- _state.value = state.value.copy(otaStatus = message)
- }
-
- fun installLatestUpdate(otaUpdater: AppOtaUpdater) {
- val latest = state.value.otaInfo
- if (latest == null || !latest.hasUpdate || latest.downloadUrl.isBlank()) {
- setError("当前没有可安装的更新")
- return
- }
- val started = otaUpdater.downloadAndInstall(
- apkUrl = latest.downloadUrl,
- versionName = latest.latestVersionName.ifBlank { "${latest.latestVersionCode}" },
- expectedSha256 = latest.apkSha256
- )
- _state.value = state.value.copy(otaStatus = if (started) "OTA 下载已启动" else "OTA 下载启动失败")
- }
-
- private fun restoreSession() {
- val saved = repository.savedSession()
- _state.value = state.value.copy(baseUrl = saved.baseUrl)
- if (saved.token.isBlank()) {
- viewModelScope.launch {
- runCatching { repository.resolveConnection(saved.baseUrl) }
- .onSuccess { applyConnection(it) }
- }
- return
- }
- refreshWorkspace()
- }
-
- private fun refreshDocuments() {
- val knowledgeBaseId = state.value.selectedKnowledgeBaseId
- if (knowledgeBaseId.isBlank() || !state.value.isApproved) return
- viewModelScope.launch {
- runCatching { repository.knowledgeDocuments(knowledgeBaseId) }
- .onSuccess { documents ->
- _state.value = state.value.copy(documents = documents)
- }
- .onFailure { throwable ->
- _state.value = state.value.copy(errorMessage = throwable.toReadableMessage())
- }
- }
- }
-
- private fun afterJobCreated(job: JobDto) {
- _state.value = state.value.copy(
- latestJob = job,
- latestJobId = job.id,
- currentTab = StoryForgeTab.Benchmark
- )
- refreshWorkspace()
- startJobPolling(job.id)
- }
-
- private fun startJobPolling(jobId: String) {
- jobPollingJob?.cancel()
- jobPollingJob = viewModelScope.launch {
- repeat(30) {
- delay(5000)
- runCatching { repository.job(jobId) }
- .onSuccess { job ->
- _state.value = state.value.copy(latestJob = job, latestJobId = job.id)
- if (job.status == "completed" || job.status == "failed") {
- appendTimeline("素材任务 ${job.title} 已${if (job.status == "completed") "完成" else "失败"}")
- refreshWorkspace()
- return@launch
- }
- }
- }
- }
- }
-
- private fun applyDashboard(account: AccountDto, dashboard: DashboardDto) {
- val selectedKbId = state.value.selectedKnowledgeBaseId.takeIf { id -> dashboard.knowledge_bases.any { it.id == id } }
- ?: dashboard.knowledge_bases.firstOrNull()?.id.orEmpty()
- val selectedAssistantId = state.value.selectedAssistantId.takeIf { id -> dashboard.assistants.any { it.id == id } }
- ?: dashboard.assistants.firstOrNull()?.id.orEmpty()
- val selectedAssistant = dashboard.assistants.firstOrNull { it.id == selectedAssistantId }
- _state.value = state.value.copy(
- busy = false,
- isAuthenticated = true,
- isApproved = true,
- account = account,
- knowledgeBases = dashboard.knowledge_bases,
- assistants = dashboard.assistants,
- modelProfiles = dashboard.model_profiles,
- jobs = dashboard.recent_jobs,
- documents = emptyList(),
- selectedKnowledgeBaseId = selectedKbId,
- selectedAssistantId = selectedAssistantId,
- selectedAssistantKnowledgeBaseIds = selectedAssistant?.knowledge_base_ids?.toSet()
- ?: listOfNotNull(selectedKbId.takeIf { it.isNotBlank() }).toSet(),
- assistantEditorId = selectedAssistant?.id,
- assistantName = selectedAssistant?.name.orEmpty(),
- assistantDescription = selectedAssistant?.description.orEmpty(),
- assistantSystemPrompt = selectedAssistant?.system_prompt ?: DEFAULT_SYSTEM_PROMPT,
- assistantGenerationGoal = selectedAssistant?.generation_goal ?: DEFAULT_GENERATION_GOAL,
- assistantModelProfileId = (selectedAssistant?.model_profile_id ?: "").ifBlank { preferredModelId(dashboard, account) },
- latestJob = dashboard.recent_jobs.firstOrNull(),
- latestJobId = dashboard.recent_jobs.firstOrNull()?.id.orEmpty(),
- pendingAccounts = if (account.role == "super_admin") state.value.pendingAccounts else emptyList(),
- statusMessage = "工作台已同步完成",
- errorMessage = ""
- )
- refreshDocuments()
- if (account.role == "super_admin") {
- loadPendingAccounts()
- }
- }
-
- private fun preferredModelId(
- dashboard: DashboardDto? = null,
- account: AccountDto? = state.value.account
- ): String {
- val currentDashboard = dashboard
- val accountPreferred = account?.preferred_analysis_model_id.orEmpty()
- if (accountPreferred.isNotBlank()) return accountPreferred
- val profiles = currentDashboard?.model_profiles ?: state.value.modelProfiles
- return profiles.firstOrNull { it.is_default }?.id.orEmpty()
- }
-
- private fun selectedKnowledgeBaseIdOrFallback(): String {
- return state.value.selectedKnowledgeBaseId.ifBlank {
- state.value.knowledgeBases.firstOrNull()?.id.orEmpty()
- }
- }
-
- private fun applyConnection(connection: StoryForgeConnectionInfo) {
- _state.value = state.value.copy(
- baseUrl = connection.rawBaseUrl,
- resolvedBaseUrl = connection.requestBaseUrl,
- resolvedIp = connection.resolvedIp,
- originalHost = connection.originalHostHeader
- )
- }
-
- private fun setError(message: String) {
- _state.value = state.value.copy(errorMessage = message, statusMessage = message)
- }
-
- private fun appendTimeline(message: String) {
- val next = (listOf(message) + state.value.timeline).distinct().take(16)
- _state.value = state.value.copy(timeline = next)
- }
-
- private fun runBusy(
- message: String,
- task: suspend () -> T,
- onSuccess: (T) -> Unit
- ) {
- viewModelScope.launch {
- _state.value = state.value.copy(busy = true, errorMessage = "", statusMessage = message)
- runCatching { task() }
- .onSuccess { result ->
- _state.value = state.value.copy(busy = false, errorMessage = "")
- onSuccess(result)
- }
- .onFailure { throwable ->
- _state.value = state.value.copy(
- busy = false,
- errorMessage = throwable.toReadableMessage(),
- statusMessage = throwable.toReadableMessage()
- )
- appendTimeline(throwable.toReadableMessage())
- }
- }
- }
-}
-
-private fun Throwable.toReadableMessage(): String {
- if (this is HttpException) {
- val body = response()?.errorBody()?.string().orEmpty()
- return if (body.isNotBlank()) {
- body.take(240)
- } else {
- "请求失败 (${code()})"
- }
- }
- return message ?: "发生未知错误"
-}
diff --git a/android-app/app/src/main/java/com/aiglasses/app/ui/MainViewModel.kt b/android-app/app/src/main/java/com/aiglasses/app/ui/MainViewModel.kt
deleted file mode 100644
index c3c5796..0000000
--- a/android-app/app/src/main/java/com/aiglasses/app/ui/MainViewModel.kt
+++ /dev/null
@@ -1,1387 +0,0 @@
-package com.aiglasses.app.ui
-
-import android.app.Application
-import android.content.Context
-import android.util.Base64
-import androidx.lifecycle.AndroidViewModel
-import androidx.lifecycle.viewModelScope
-import com.aiglasses.app.BuildConfig
-import com.aiglasses.app.ble.BleManager
-import com.aiglasses.app.ble.GlassesBleEvent
-import com.aiglasses.app.data.AdminStats
-import com.aiglasses.app.data.BaiduInfo
-import com.aiglasses.app.data.BackendRepository
-import com.aiglasses.app.data.ClientEventRequest
-import com.baidu.rtc.ndk.NdkLoader
-import com.baidu.rtc.ndk.NdkPhoenix
-import java.io.ByteArrayOutputStream
-import java.io.IOException
-import java.security.MessageDigest
-import java.text.SimpleDateFormat
-import java.util.Date
-import java.util.Locale
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.isActive
-import kotlinx.coroutines.launch
-import kotlinx.serialization.Serializable
-import kotlinx.serialization.decodeFromString
-import kotlinx.serialization.encodeToString
-import kotlinx.serialization.json.Json
-import retrofit2.HttpException
-
-data class MainUiState(
- val deviceId: String = "dev-test-001",
- val userId: String = "user-1",
- val sessionId: String = "",
- val messageText: String = "你好,请做个自我介绍。",
- val sceneId: String = "voice_assistant",
- val roleId: String = "assistant",
- val activationSummary: String = "Activation: -",
- val adminSummary: String = "Admin: loading",
- val adminStats: AdminStats = AdminStats(),
- val baiduInfo: BaiduInfo = BaiduInfo(),
- val bleSummary: String = "BLE: idle",
- val conversationRunning: Boolean = false,
- val softwareConversationRunning: Boolean = false,
- val softwareConversationStarting: Boolean = false,
- val softwareSummary: String = "Software: idle",
- val audioFrameCount: Int = 0,
- val photoChunkCount: Int = 0,
- val softwareSpeechCount: Int = 0,
- val softwarePhotoCount: Int = 0,
- val softwareMicFrameCount: Int = 0,
- val softwareVoiceUploadCount: Int = 0,
- val softwareLogs: List = listOf("software-ready"),
- val softwareSpeakText: String = "",
- val softwareSpeakSeq: Long = 0L,
- val softwareAudioBase64: String = "",
- val softwareAudioUrl: String = "",
- val softwareAudioSeq: Long = 0L,
- val softwareRealtimeWsUrl: String = "",
- val softwareLicenseKey: String = "",
- val softwareAppId: String = "",
- val softwareCid: String = "",
- val softwareToken: String = "",
- val softwareContext: String = "",
- val otaSummary: String = "OTA: not checked",
- val otaChecking: Boolean = false,
- val otaHasUpdate: Boolean = false,
- val otaLatestVersionCode: Int = 0,
- val otaLatestVersionName: String = "",
- val otaDownloadUrl: String = "",
- val otaApkSha256: String = "",
- val otaReleaseNotes: String = "",
- val otaForceUpdate: Boolean = false,
- val otaInstallSeq: Long = 0L,
- val lastMessage: String = "Ready",
- val isLoading: Boolean = false,
- val timeline: List = listOf("System ready")
-)
-
-@Serializable
-private data class PendingRemoteLog(
- val localId: Long,
- val sessionId: String? = null,
- val deviceId: String = "",
- val eventType: String,
- val eventLevel: String = "INFO",
- val payload: Map = emptyMap(),
- val ts: Long
-)
-
-class MainViewModel(application: Application) : AndroidViewModel(application) {
- companion object {
- private const val BACKEND_BASE_URL = "https://storyforge.hyzq.net"
- private const val REMOTE_LOG_PREFS = "remote_log_queue"
- private const val REMOTE_LOG_KEY_QUEUE = "pending_events_json"
- private const val REMOTE_LOG_MAX_PENDING = 400
- private const val REMOTE_LOG_BATCH_SIZE = 25
- private const val REMOTE_LOG_FLUSH_INTERVAL_MS = 2_500L
- }
-
- private val _state = MutableStateFlow(MainUiState())
- val state: StateFlow = _state.asStateFlow()
-
- private var repository = BackendRepository(BACKEND_BASE_URL)
- private val bleManager = BleManager(application.applicationContext)
- private val remoteLogPrefs = application.applicationContext.getSharedPreferences(
- REMOTE_LOG_PREFS,
- Context.MODE_PRIVATE
- )
- private val remoteLogJson = Json {
- ignoreUnknownKeys = true
- encodeDefaults = true
- }
-
- private var metricsJob: Job? = null
- private var cameraPollingJob: Job? = null
- private var softwareStartJob: Job? = null
- private var softwareHeartbeatJob: Job? = null
- private var remoteLogFlushJob: Job? = null
- private var bootstrapInProgress = false
- private var softwareSpeakSeqCounter = 0L
- private var softwareAudioSeqCounter = 0L
- private var lastMicFallbackReportMs = 0L
- private var lastVoiceUploadMs = 0L
- private var voiceMaxRms = 0
- private val voiceBuffer = ByteArrayOutputStream()
- private var softwareRemoteLogSeq = 0L
- private var otaRemoteLogSeq = 0L
- private var otaInstallSeqCounter = 0L
- private var pendingAudioFrames = 0
- private var pendingAudioBytes = 0
- private var pendingPhotoChunks = 0
- private var latestCameraImageBase64 = ""
- private var latestCameraImageBytes = 0
- private var latestCameraImageWidth = 0
- private var latestCameraImageHeight = 0
- private var latestCameraImageAt = 0L
- private var lastVisionTriggerAt = 0L
- private var lastVisionTriggerText = ""
- private var pendingVisionQueryText = ""
- private var pendingVisionQueryAt = 0L
- private var lastVisionSendAt = 0L
- private var lastVisionSentText = ""
- private var lastBargeInDispatchAt = 0L
- private val pendingRemoteLogsLock = Any()
- private val pendingRemoteLogs = mutableListOf()
- private var remoteLogFlushInProgress = false
- private var remoteLogLocalId = 0L
-
- init {
- loadPendingRemoteLogs()
- startRemoteLogFlushJob()
- observeBle()
- }
-
- fun updateDeviceId(value: String) {
- _state.value = _state.value.copy(deviceId = value)
- }
-
- fun updateUserId(value: String) {
- _state.value = _state.value.copy(userId = value)
- }
-
- fun updateMessageText(value: String) {
- _state.value = _state.value.copy(messageText = value)
- }
-
- fun updateSceneId(value: String) {
- _state.value = _state.value.copy(sceneId = value)
- }
-
- fun updateRoleId(value: String) {
- _state.value = _state.value.copy(roleId = value)
- }
-
- fun checkAppUpdate(currentVersionCode: Int, currentVersionName: String, autoInstall: Boolean) {
- _state.value = _state.value.copy(
- otaChecking = true,
- otaSummary = "OTA: checking (current=$currentVersionName/$currentVersionCode)"
- )
- viewModelScope.launch {
- runCatching {
- repository.appUpdateLatest(currentVersionCode)
- }.onSuccess { data ->
- val hasUpdate = data.hasUpdate && data.downloadUrl.isNotBlank()
- _state.value = _state.value.copy(
- otaChecking = false,
- otaHasUpdate = hasUpdate,
- otaLatestVersionCode = data.latestVersionCode,
- otaLatestVersionName = data.latestVersionName,
- otaDownloadUrl = data.downloadUrl,
- otaApkSha256 = data.apkSha256,
- otaReleaseNotes = data.releaseNotes,
- otaForceUpdate = data.forceUpdate,
- otaSummary = if (hasUpdate) {
- "OTA: new ${data.latestVersionName}/${data.latestVersionCode}"
- } else {
- "OTA: already latest"
- }
- )
- logResult(
- if (hasUpdate) {
- "Update found: ${data.latestVersionName} (${data.latestVersionCode})"
- } else {
- "App is up to date"
- }
- )
- if (hasUpdate && autoInstall) {
- triggerOtaInstall()
- }
- }.onFailure {
- _state.value = _state.value.copy(
- otaChecking = false,
- otaSummary = "OTA: check failed ${it.message}"
- )
- logResult("OTA check failed: ${it.message}")
- }
- }
- }
-
- fun triggerOtaInstall() {
- if (_state.value.otaDownloadUrl.isBlank()) {
- _state.value = _state.value.copy(otaSummary = "OTA: download url missing")
- return
- }
- otaInstallSeqCounter += 1
- _state.value = _state.value.copy(
- otaInstallSeq = otaInstallSeqCounter,
- otaSummary = "OTA: downloading..."
- )
- appendSoftwareLog("OTA: 开始下载安装")
- }
-
- fun consumeOtaInstallTrigger() {
- if (_state.value.otaInstallSeq == 0L) return
- _state.value = _state.value.copy(otaInstallSeq = 0L)
- }
-
- fun onOtaStatus(status: String) {
- val msg = status.trim()
- if (msg.isBlank()) return
- _state.value = _state.value.copy(otaSummary = msg.take(120))
- appendSoftwareLog(msg)
- pushOtaLogRemote(msg)
- logResult(msg)
- }
-
- fun bindDevice() {
- perform("Bind") {
- val data = repository.bindDevice(_state.value.deviceId, _state.value.userId)
- "Bind success: ${data.bindStatus}, license=${data.licenseKeyMasked}"
- }
- }
-
- fun createSession() {
- perform("CreateSession") {
- val data = repository.createSession(_state.value.deviceId, _state.value.userId)
- _state.value = _state.value.copy(sessionId = data.sessionId)
- "Session created: ${data.sessionId}"
- }
- }
-
- fun stopSession() {
- val sid = _state.value.sessionId
- if (sid.isBlank()) {
- _state.value = _state.value.copy(lastMessage = "No sessionId to stop")
- return
- }
- perform("StopSession") {
- val data = repository.stopSession(sid)
- "Session stop result: ${data.sessionStatus}"
- }
- }
-
- fun refreshStatus() {
- perform("Status") {
- val data = repository.getDeviceStatus(_state.value.deviceId)
- "Status: bind=${data.bindStatus}, active=${data.activeSessionStatus ?: "NONE"}"
- }
- }
-
- fun heartbeat() {
- val sid = _state.value.sessionId
- if (sid.isBlank()) {
- _state.value = _state.value.copy(lastMessage = "No sessionId for heartbeat")
- return
- }
- perform("Heartbeat") {
- val data = repository.heartbeat(sid)
- "Heartbeat ok: ${data.heartbeatAt}"
- }
- }
-
- fun postEvent() {
- perform("PostEvent") {
- val data = repository.postDemoEvent(_state.value.deviceId, _state.value.sessionId.ifBlank { null })
- "Event saved: ${data.saved}"
- }
- }
-
- fun sendMessage() {
- val sid = _state.value.sessionId
- if (sid.isBlank()) {
- logResult("No sessionId for send-message")
- return
- }
- val text = _state.value.messageText.trim()
- if (text.isBlank()) {
- logResult("Message cannot be empty")
- return
- }
- perform("SendMessage") {
- val data = sendMessageWithOptionalVision(
- sessionId = sid,
- message = text,
- source = "manual_input"
- )
- handleProviderAction(data)
- "Send message: ${data.status}"
- }
- }
-
- fun switchRole() {
- val sid = _state.value.sessionId
- if (sid.isBlank()) {
- logResult("No sessionId for switch-role")
- return
- }
- val sceneId = _state.value.sceneId.trim()
- val roleId = _state.value.roleId.trim()
- if (sceneId.isBlank() || roleId.isBlank()) {
- logResult("SceneId/RoleId cannot be empty")
- return
- }
- perform("SwitchRole") {
- val data = repository.switchRole(sid, sceneId, roleId)
- "Switch role: ${data.status}"
- }
- }
-
- fun interrupt() {
- toggleInterrupt(true)
- }
-
- fun resumeInterrupt() {
- toggleInterrupt(false)
- }
-
- private fun toggleInterrupt(interrupt: Boolean) {
- val sid = _state.value.sessionId
- if (sid.isBlank()) {
- logResult("No sessionId for interrupt")
- return
- }
- perform(if (interrupt) "Interrupt" else "Resume") {
- val data = repository.interrupt(sid, interrupt)
- "Interrupt=$interrupt: ${data.status}"
- }
- }
-
- fun queryActivation() {
- perform("ActivationQuery") {
- val data = repository.activationQuery(_state.value.deviceId)
- _state.value = _state.value.copy(
- activationSummary = "Activation: ${data.status} (${data.detail})"
- )
- "Activation fetched for ${data.deviceId}"
- }
- }
-
- fun reloadLicenses() {
- perform("ReloadLicenses") {
- val data = repository.reloadLicenses()
- "License reloaded: inserted=${data.inserted}"
- }
- }
-
- fun loadAdminOverview() {
- perform("AdminOverview") {
- val data = repository.adminOverview()
- val stats = data.stats
- val baidu = data.baidu
- _state.value = _state.value.copy(
- adminSummary = "Admin: devices=${stats.totalDevices}, running=${stats.runningSessions}, baidu=${baidu.mode}",
- adminStats = stats,
- baiduInfo = baidu
- )
- "Admin overview refreshed"
- }
- }
-
- fun startHardwareConversation() {
- if (_state.value.conversationRunning) {
- logResult("Conversation already running")
- return
- }
- if (_state.value.softwareConversationRunning) {
- stopSoftwareConversation()
- }
- val appUuid = buildAppUuid(_state.value.userId)
- _state.value = _state.value.copy(
- conversationRunning = true,
- bleSummary = "BLE: scanning..."
- )
- logResult("Starting hardware conversation")
- bleManager.connectAndHandshake(appUuid = appUuid)
- }
-
- fun stopHardwareConversation() {
- _state.value = _state.value.copy(conversationRunning = false)
- cameraPollingJob?.cancel()
- metricsJob?.cancel()
- bleManager.stopWakeUpAudio()
- bleManager.disconnect()
- val sid = _state.value.sessionId
- if (sid.isNotBlank()) {
- viewModelScope.launch {
- runCatching { repository.stopSession(sid) }
- }
- }
- logResult("Hardware conversation stopped")
- }
-
- fun startSoftwareConversation() {
- if (_state.value.softwareConversationRunning || _state.value.softwareConversationStarting) {
- logResult("Software conversation already running")
- return
- }
- if (_state.value.conversationRunning) {
- stopHardwareConversation()
- }
- _state.value = _state.value.copy(
- softwareConversationStarting = true,
- softwareSummary = "Software: bootstrapping..."
- )
- voiceBuffer.reset()
- voiceMaxRms = 0
- lastVoiceUploadMs = 0L
- appendSoftwareLog("Bootstrapping software conversation")
- softwareStartJob?.cancel()
- softwareStartJob = viewModelScope.launch {
- var startStep = "resolve_device_id"
- suspend fun runStartStep(name: String, block: suspend () -> T): T {
- startStep = name
- return block()
- }
- try {
- val deviceId = resolveSoftwareBaiduDeviceId(_state.value.userId)
- appendSoftwareLog("Baidu device id: $deviceId")
- appendSoftwareLog("Health check: $BACKEND_BASE_URL/healthz")
- val health = runStartStep("healthz") { repository.healthz() }
- appendSoftwareLog("Health OK: status=${health.status}, env=${health.env}")
- appendSoftwareLog("Bind device: $deviceId")
- val bind = runStartStep("bind_device") {
- repository.bindDevice(deviceId, _state.value.userId)
- }
- appendSoftwareLog("Bind success: bind=${bind.bindStatus}, license=${bind.licenseStatus}")
- appendSoftwareLog("Create session start")
- val session = runStartStep("create_session") {
- repository.createSession(deviceId, _state.value.userId)
- }
- _state.value = _state.value.copy(
- deviceId = deviceId,
- sessionId = session.sessionId,
- softwareConversationRunning = true,
- softwareConversationStarting = false,
- softwareSummary = "Software: running / session=${session.sessionId.takeLast(8)}",
- softwareRealtimeWsUrl = session.realtimeWsUrl,
- softwareLicenseKey = bind.licenseKey,
- softwareAppId = session.appId,
- softwareCid = session.cid,
- softwareToken = session.token,
- softwareContext = session.context
- )
- startSoftwareHeartbeatJob()
- appendSoftwareLog("Session created: ${session.sessionId}")
- queueSoftwareSpeak("软件对话已连接")
- logResult("Software conversation started: ${session.sessionId}")
- } catch (_: kotlinx.coroutines.CancellationException) {
- _state.value = _state.value.copy(
- softwareConversationStarting = false,
- softwareConversationRunning = false,
- softwareSummary = "Software: cancelled"
- )
- appendSoftwareLog("Start cancelled")
- logResult("Software conversation start cancelled")
- } catch (e: Exception) {
- _state.value = _state.value.copy(
- softwareConversationStarting = false,
- softwareConversationRunning = false,
- softwareSummary = "Software: start failed"
- )
- val detail = describeStartFailure(startStep, e)
- appendSoftwareLog("Start failed($startStep): $detail")
- queueSoftwareSpeak("软件对话启动失败")
- logResult("Software conversation failed($startStep): $detail")
- } finally {
- softwareStartJob = null
- }
- }
- }
-
- fun stopSoftwareConversation() {
- val sid = _state.value.sessionId
- softwareStartJob?.cancel()
- softwareStartJob = null
- _state.value = _state.value.copy(
- softwareConversationStarting = false,
- softwareConversationRunning = false,
- softwareSummary = "Software: stopped",
- softwareRealtimeWsUrl = "",
- softwareLicenseKey = "",
- softwareAppId = "",
- softwareCid = "",
- softwareToken = "",
- softwareContext = ""
- )
- voiceBuffer.reset()
- voiceMaxRms = 0
- latestCameraImageBase64 = ""
- latestCameraImageBytes = 0
- latestCameraImageWidth = 0
- latestCameraImageHeight = 0
- latestCameraImageAt = 0L
- lastVisionTriggerAt = 0L
- lastVisionTriggerText = ""
- pendingVisionQueryText = ""
- pendingVisionQueryAt = 0L
- lastVisionSendAt = 0L
- lastVisionSentText = ""
- lastBargeInDispatchAt = 0L
- softwareHeartbeatJob?.cancel()
- if (sid.isNotBlank()) {
- viewModelScope.launch {
- runCatching { repository.stopSession(sid) }
- }
- }
- appendSoftwareLog("Software conversation stopped")
- logResult("Software conversation stopped")
- }
-
- fun onSoftwareSpeechText(text: String) {
- val message = text.trim()
- if (message.isBlank()) return
- _state.value = _state.value.copy(
- softwareSpeechCount = _state.value.softwareSpeechCount + 1,
- softwareSummary = "Software: last speech=${message.take(16)}"
- )
- appendSoftwareLog("Speech recognized: ${message.take(80)}")
- val sid = _state.value.sessionId
- if (sid.isBlank()) {
- appendSoftwareLog("Drop speech: session not ready")
- logResult("Software speech dropped: session not ready")
- return
- }
- viewModelScope.launch {
- runCatching {
- repository.postEvent(
- deviceId = _state.value.deviceId,
- sessionId = sid,
- eventType = "APP_PHONE_MIC_TEXT",
- payload = mapOf("text" to message.take(120))
- )
- sendMessageWithOptionalVision(
- sessionId = sid,
- message = message,
- source = "speech_recognizer"
- )
- }.onSuccess { data ->
- appendSoftwareLog("Send message success: ${data.status}, detail=${data.detail.take(60)}")
- handleProviderAction(data)
- logResult("Software message forwarded")
- }.onFailure {
- appendSoftwareLog("Send message failed: ${it.message}")
- queueSoftwareSpeak("消息发送失败")
- logResult("Software message failed: ${it.message}")
- }
- }
- }
-
- fun onSoftwareRealtimeAsrText(text: String) {
- val message = text.trim()
- if (message.isBlank()) return
- _state.value = _state.value.copy(
- softwareSpeechCount = _state.value.softwareSpeechCount + 1,
- softwareSummary = "Software: realtime asr=${message.take(16)}"
- )
- appendSoftwareLog("Realtime ASR text: ${message.take(80)}")
- maybeTriggerVisionByRealtimeAsr(message)
- }
-
- fun onSoftwareRealtimeVisionFrameReady(
- imageBase64: String,
- bytes: Int,
- width: Int,
- height: Int,
- source: String
- ) {
- val sid = _state.value.sessionId
- if (sid.isBlank()) {
- appendSoftwareLog("Vision skipped($source): session not ready")
- return
- }
- val now = System.currentTimeMillis()
- if (pendingVisionQueryAt > 0L && now - pendingVisionQueryAt > 20_000L) {
- pendingVisionQueryText = ""
- pendingVisionQueryAt = 0L
- }
- val message = pendingVisionQueryText.ifBlank {
- "请结合刚上传的图片回答用户当前画面内容。"
- }
- if (message == lastVisionSentText && now - lastVisionSendAt < 2000L) {
- appendSoftwareLog("Vision skipped($source): duplicate send suppressed")
- return
- }
- appendSoftwareLog(
- "Vision send($source): text=${message.take(40)}, frame=${width}x${height}, bytes=$bytes"
- )
- viewModelScope.launch {
- runCatching {
- repository.postEvent(
- deviceId = _state.value.deviceId,
- sessionId = sid,
- eventType = "APP_PHONE_VISION_FRAME_READY",
- payload = mapOf(
- "source" to source,
- "text" to message.take(120),
- "width" to width.toString(),
- "height" to height.toString(),
- "bytes" to bytes.toString()
- )
- )
- repository.sendVisionMessage(
- sessionId = sid,
- message = message,
- imageBase64 = imageBase64,
- width = width,
- height = height,
- bytes = bytes
- )
- }.onSuccess { data ->
- lastVisionSendAt = now
- lastVisionSentText = message
- if (pendingVisionQueryText == message) {
- pendingVisionQueryText = ""
- pendingVisionQueryAt = 0L
- }
- appendSoftwareLog(
- "Vision send result($source): status=${data.status}, detail=${data.detail.take(60)}"
- )
- if (data.audioBase64.isNotBlank() || data.audioUrl.isNotBlank()) {
- handleProviderAction(data)
- }
- }.onFailure {
- appendSoftwareLog("Vision send failed($source): ${it.message}")
- }
- }
- }
-
- fun onSoftwareBargeInDetected() {
- val sid = _state.value.sessionId
- if (sid.isBlank() || !_state.value.softwareConversationRunning) return
- val now = System.currentTimeMillis()
- if (now - lastBargeInDispatchAt < 1500L) return
- lastBargeInDispatchAt = now
- appendSoftwareLog("Barge-in detected: realtime break only")
- viewModelScope.launch {
- runCatching {
- repository.postEvent(
- deviceId = _state.value.deviceId,
- sessionId = sid,
- eventType = "APP_BARGE_IN",
- payload = mapOf(
- "source" to "android_app",
- "mode" to "realtime_break_only"
- )
- )
- }.onSuccess {
- appendSoftwareLog("Barge-in break acknowledged")
- }.onFailure {
- appendSoftwareLog("Barge-in event upload failed: ${it.message}")
- }
- }
- }
-
- fun onSoftwareCameraCapture(imageBase64: String, bytes: Int, width: Int, height: Int) {
- _state.value = _state.value.copy(
- softwarePhotoCount = _state.value.softwarePhotoCount + 1,
- softwareSummary = "Software: camera ${width}x${height}"
- )
- appendSoftwareLog("Camera capture: ${width}x${height}, bytes=$bytes")
- latestCameraImageBase64 = imageBase64
- latestCameraImageBytes = bytes
- latestCameraImageWidth = width
- latestCameraImageHeight = height
- latestCameraImageAt = System.currentTimeMillis()
- val sid = _state.value.sessionId
- if (sid.isBlank()) return
- viewModelScope.launch {
- runCatching {
- repository.postEvent(
- deviceId = _state.value.deviceId,
- sessionId = sid,
- eventType = "APP_PHONE_CAMERA_CAPTURE",
- payload = mapOf(
- "bytes" to bytes.toString(),
- "width" to width.toString(),
- "height" to height.toString(),
- "base64Size" to imageBase64.length.toString()
- )
- )
- }
- }
- }
-
- fun onSoftwareMicFallbackFrame(pcmBytes: ByteArray, rms: Int, sampleRate: Int) {
- val bytes = pcmBytes.size
- _state.value = _state.value.copy(
- softwareMicFrameCount = _state.value.softwareMicFrameCount + 1,
- softwareSummary = "Software: mic fallback rms=$rms"
- )
- if (bytes > 0) {
- voiceBuffer.write(pcmBytes, 0, bytes)
- if (voiceBuffer.size() > 320_000) {
- val all = voiceBuffer.toByteArray()
- voiceBuffer.reset()
- val tail = all.copyOfRange(all.size - 240_000, all.size)
- voiceBuffer.write(tail)
- }
- }
- if (rms > voiceMaxRms) voiceMaxRms = rms
- val now = System.currentTimeMillis()
- if (now - lastMicFallbackReportMs < 3_000) return
- lastMicFallbackReportMs = now
- appendSoftwareLog("Mic fallback frame: bytes=$bytes, rms=$rms, sampleRate=$sampleRate")
- val sid = _state.value.sessionId
- if (sid.isBlank()) return
- maybeUploadVoiceChunk(sessionId = sid, sampleRate = sampleRate, now = now)
- viewModelScope.launch {
- runCatching {
- repository.postEvent(
- deviceId = _state.value.deviceId,
- sessionId = sid,
- eventType = "APP_PHONE_MIC_FALLBACK",
- payload = mapOf(
- "bytes" to bytes.toString(),
- "rms" to rms.toString()
- )
- )
- }
- }
- }
-
- fun onSoftwareLog(message: String) {
- _state.value = _state.value.copy(softwareSummary = "Software: $message")
- appendSoftwareLog(message)
- logResult("Software: $message")
- }
-
- private fun maybeUploadVoiceChunk(sessionId: String, sampleRate: Int, now: Long) {
- if (now - lastVoiceUploadMs < 2200) return
- if (voiceBuffer.size() < 24_000) return
- if (voiceMaxRms < 300) {
- voiceBuffer.reset()
- voiceMaxRms = 0
- return
- }
- val pcm = voiceBuffer.toByteArray()
- voiceBuffer.reset()
- val peakRms = voiceMaxRms
- voiceMaxRms = 0
- lastVoiceUploadMs = now
- val durationMs = ((pcm.size / 2.0) / sampleRate * 1000.0).toInt().coerceAtLeast(200)
- val payload = Base64.encodeToString(pcm, Base64.NO_WRAP)
- if (_state.value.softwareRealtimeWsUrl.isNotBlank()) {
- appendSoftwareLog("Realtime PCM window: bytes=${pcm.size}, dur=${durationMs}ms, peakRms=$peakRms")
- _state.value = _state.value.copy(
- softwareVoiceUploadCount = _state.value.softwareVoiceUploadCount + 1
- )
- return
- }
- appendSoftwareLog("Voice chunk upload: bytes=${pcm.size}, dur=${durationMs}ms, peakRms=$peakRms")
- viewModelScope.launch {
- runCatching {
- repository.sendVoiceMessage(
- sessionId = sessionId,
- pcmBase64 = payload,
- sampleRate = sampleRate,
- durationMs = durationMs,
- rms = peakRms
- )
- }.onSuccess { data ->
- _state.value = _state.value.copy(
- softwareVoiceUploadCount = _state.value.softwareVoiceUploadCount + 1
- )
- appendSoftwareLog(
- "Voice response: status=${data.status}, asr=${data.asrText.take(30)}, tts=${data.ttsText.take(30)}"
- )
- handleProviderAction(data)
- }.onFailure {
- appendSoftwareLog("Voice upload failed: ${it.message}")
- }
- }
- }
-
- private suspend fun sendMessageWithOptionalVision(
- sessionId: String,
- message: String,
- source: String
- ): com.aiglasses.app.data.ProviderActionData {
- val now = System.currentTimeMillis()
- if (!isVisionIntent(message)) {
- return repository.sendMessage(sessionId, message)
- }
- if (!hasRecentVisionFrame(now)) {
- appendSoftwareLog("Vision skipped($source): no recent camera frame")
- return repository.sendMessage(sessionId, message)
- }
- appendSoftwareLog(
- "Vision request($source): text=${message.take(40)}, frame=${latestCameraImageWidth}x${latestCameraImageHeight}"
- )
- return repository.sendVisionMessage(
- sessionId = sessionId,
- message = message,
- imageBase64 = latestCameraImageBase64,
- width = latestCameraImageWidth,
- height = latestCameraImageHeight,
- bytes = latestCameraImageBytes
- )
- }
-
- private fun maybeTriggerVisionByRealtimeAsr(message: String) {
- if (!isVisionIntent(message)) return
- val sid = _state.value.sessionId
- if (sid.isBlank()) {
- appendSoftwareLog("Vision skipped(realtime): session not ready")
- return
- }
- val now = System.currentTimeMillis()
- if (message == lastVisionTriggerText && now - lastVisionTriggerAt < 2500L) {
- return
- }
- if (!hasRecentVisionFrame(now)) {
- appendSoftwareLog("Vision skipped(realtime): no recent camera frame")
- return
- }
- lastVisionTriggerAt = now
- lastVisionTriggerText = message
- pendingVisionQueryText = message
- pendingVisionQueryAt = now
- viewModelScope.launch {
- runCatching {
- repository.postEvent(
- deviceId = _state.value.deviceId,
- sessionId = sid,
- eventType = "APP_PHONE_VISION_QUERY",
- payload = mapOf(
- "source" to "realtime_asr",
- "text" to message.take(120),
- "width" to latestCameraImageWidth.toString(),
- "height" to latestCameraImageHeight.toString(),
- "bytes" to latestCameraImageBytes.toString()
- )
- )
- }.onSuccess {
- appendSoftwareLog("Vision intent queued(realtime): waiting for [E]:[UPLOAD_IMAGE]")
- }.onFailure {
- appendSoftwareLog("Vision request failed: ${it.message}")
- }
- }
- }
-
- private fun hasRecentVisionFrame(now: Long): Boolean {
- if (latestCameraImageBase64.isBlank()) return false
- if (latestCameraImageBytes <= 0) return false
- return now - latestCameraImageAt <= 25_000L
- }
-
- private fun isVisionIntent(text: String): Boolean {
- val normalized = text.trim().lowercase(Locale.ROOT)
- if (normalized.isBlank()) return false
- val keywords = listOf(
- "看到什么",
- "看到了什么",
- "看见什么",
- "看眼前",
- "眼前",
- "眼前的东西",
- "面前是什么",
- "周围是什么",
- "前面是什么",
- "前面有",
- "画面",
- "镜头",
- "摄像头",
- "图里",
- "图中",
- "视频",
- "识别",
- "这是什么",
- "帮我看看",
- "看一下",
- "look",
- "what do you see",
- "what can you see",
- "camera"
- )
- return keywords.any { normalized.contains(it) }
- }
-
- private fun handleProviderAction(data: com.aiglasses.app.data.ProviderActionData) {
- if (data.audioBase64.isNotBlank() || data.audioUrl.isNotBlank()) {
- queueSoftwareAudio(data.audioBase64, data.audioUrl)
- return
- }
- if (data.ttsText.isNotBlank()) {
- queueSoftwareSpeak(data.ttsText)
- return
- }
- val ackOnly = data.status.equals("ACCEPTED", ignoreCase = true) &&
- data.detail.contains("accepted", ignoreCase = true)
- if (ackOnly) {
- return
- }
- if (data.detail.isNotBlank()) {
- queueSoftwareSpeak(data.detail)
- }
- }
-
- fun consumeSoftwareSpeakText() {
- if (_state.value.softwareSpeakText.isBlank()) return
- _state.value = _state.value.copy(softwareSpeakText = "")
- }
-
- fun consumeSoftwareAudio() {
- if (_state.value.softwareAudioBase64.isBlank() && _state.value.softwareAudioUrl.isBlank()) return
- _state.value = _state.value.copy(
- softwareAudioBase64 = "",
- softwareAudioUrl = ""
- )
- }
-
- private fun perform(action: String, block: suspend () -> String) {
- _state.value = _state.value.copy(isLoading = true, lastMessage = "$action running...")
- viewModelScope.launch {
- try {
- val message = block()
- logResult(message)
- } catch (e: Exception) {
- logResult("$action failed: ${e.message}")
- }
- }
- }
-
- private fun observeBle() {
- viewModelScope.launch {
- bleManager.state.collect { ble ->
- val summary = buildString {
- append("BLE: ")
- if (ble.connected) append("connected") else append("disconnected")
- if (ble.notificationsReady) append(" / notify")
- if (ble.handshaked) append(" / handshaked")
- if (ble.devUuid.isNotBlank()) append(" / ${ble.devUuid.takeLast(6)}")
- }
- _state.value = _state.value.copy(
- bleSummary = summary,
- deviceId = if (ble.devUuid.isNotBlank()) ble.devUuid else _state.value.deviceId
- )
- }
- }
-
- viewModelScope.launch {
- bleManager.events.collect { event ->
- when (event) {
- is GlassesBleEvent.Log -> {
- logResult("BLE: ${event.message}")
- }
- is GlassesBleEvent.HandshakeOk -> {
- _state.value = _state.value.copy(deviceId = event.devUuid)
- if (_state.value.conversationRunning) {
- bootstrapConversation(event.devUuid)
- }
- }
- is GlassesBleEvent.AudioFrame -> {
- pendingAudioFrames += 1
- pendingAudioBytes += event.bytes.size
- _state.value = _state.value.copy(
- audioFrameCount = _state.value.audioFrameCount + 1
- )
- }
- is GlassesBleEvent.CameraThumbInfo -> {
- val sid = _state.value.sessionId
- if (sid.isNotBlank()) {
- viewModelScope.launch {
- runCatching {
- repository.postEvent(
- deviceId = _state.value.deviceId,
- sessionId = sid,
- eventType = if (event.isVideo) "VIDEO_THUMB_INFO" else "PHOTO_THUMB_INFO",
- payload = mapOf("sourceFileName" to event.sourceFileName)
- )
- }
- }
- }
- }
- is GlassesBleEvent.CameraThumbData -> {
- pendingPhotoChunks += 1
- _state.value = _state.value.copy(
- photoChunkCount = _state.value.photoChunkCount + 1
- )
- }
- is GlassesBleEvent.StatusUpdate -> {
- val sid = _state.value.sessionId
- if (sid.isNotBlank()) {
- viewModelScope.launch {
- runCatching {
- repository.postEvent(
- deviceId = _state.value.deviceId,
- sessionId = sid,
- eventType = "DEVICE_STATUS_UPDATE",
- payload = mapOf("rawJson" to event.payloadJson.take(512))
- )
- }
- }
- }
- }
- }
- }
- }
- }
-
- private fun bootstrapConversation(devUuid: String) {
- if (bootstrapInProgress) return
- bootstrapInProgress = true
- perform("HardwareBootstrap") {
- try {
- repository.bindDevice(devUuid, _state.value.userId)
- val session = repository.createSession(devUuid, _state.value.userId)
- _state.value = _state.value.copy(sessionId = session.sessionId)
- bleManager.startWakeUpAudio()
- bleManager.triggerPhotoCapture()
- startReporterJobs()
- "Hardware conversation started: ${session.sessionId}"
- } finally {
- bootstrapInProgress = false
- }
- }
- }
-
- private fun startReporterJobs() {
- metricsJob?.cancel()
- metricsJob = viewModelScope.launch {
- while (_state.value.conversationRunning) {
- delay(1500)
- val sid = _state.value.sessionId
- if (sid.isBlank()) continue
- val audioFrames = pendingAudioFrames
- val audioBytes = pendingAudioBytes
- val photoChunks = pendingPhotoChunks
- pendingAudioFrames = 0
- pendingAudioBytes = 0
- pendingPhotoChunks = 0
- if (audioFrames > 0) {
- runCatching {
- repository.postEvent(
- deviceId = _state.value.deviceId,
- sessionId = sid,
- eventType = "BLE_AUDIO_STATS",
- payload = mapOf(
- "frames" to audioFrames.toString(),
- "bytes" to audioBytes.toString()
- )
- )
- }
- }
- if (photoChunks > 0) {
- runCatching {
- repository.postEvent(
- deviceId = _state.value.deviceId,
- sessionId = sid,
- eventType = "CAMERA_THUMB_CHUNKS",
- payload = mapOf("chunks" to photoChunks.toString())
- )
- }
- }
- }
- }
-
- cameraPollingJob?.cancel()
- cameraPollingJob = viewModelScope.launch {
- while (_state.value.conversationRunning) {
- delay(12000)
- bleManager.triggerPhotoCapture()
- }
- }
- }
-
- private fun startSoftwareHeartbeatJob() {
- softwareHeartbeatJob?.cancel()
- softwareHeartbeatJob = viewModelScope.launch {
- while (_state.value.softwareConversationRunning) {
- delay(20_000)
- val sid = _state.value.sessionId
- if (sid.isBlank()) continue
- runCatching { repository.heartbeat(sid) }
- .onFailure { appendSoftwareLog("Heartbeat failed: ${it.message}") }
- }
- }
- }
-
- private fun startRemoteLogFlushJob() {
- remoteLogFlushJob?.cancel()
- remoteLogFlushJob = viewModelScope.launch {
- while (isActive) {
- delay(REMOTE_LOG_FLUSH_INTERVAL_MS)
- flushPendingRemoteLogs()
- }
- }
- }
-
- private fun appendSoftwareLog(message: String) {
- val ts = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date())
- val logs = listOf("[$ts] $message") + _state.value.softwareLogs.take(79)
- _state.value = _state.value.copy(softwareLogs = logs)
- pushSoftwareLogRemote("[$ts] $message")
- }
-
- private fun pushSoftwareLogRemote(line: String) {
- softwareRemoteLogSeq += 1
- enqueueRemoteLog(
- eventType = "APP_SOFTWARE_LOG",
- payload = mapOf(
- "seq" to softwareRemoteLogSeq.toString(),
- "line" to line.take(300),
- "channel" to "software"
- )
- )
- }
-
- private fun pushOtaLogRemote(line: String) {
- if (!line.contains("OTA:", ignoreCase = true)) return
- otaRemoteLogSeq += 1
- enqueueRemoteLog(
- eventType = "APP_OTA_LOG",
- payload = mapOf(
- "seq" to otaRemoteLogSeq.toString(),
- "line" to line.take(300),
- "latestVersionName" to _state.value.otaLatestVersionName,
- "latestVersionCode" to _state.value.otaLatestVersionCode.toString(),
- "channel" to "ota"
- )
- )
- }
-
- private fun queueSoftwareSpeak(text: String) {
- val msg = text.trim()
- if (msg.isBlank()) return
- softwareSpeakSeqCounter += 1
- _state.value = _state.value.copy(
- softwareSpeakText = msg.take(80),
- softwareSpeakSeq = softwareSpeakSeqCounter
- )
- }
-
- private fun queueSoftwareAudio(audioBase64: String, audioUrl: String) {
- softwareAudioSeqCounter += 1
- _state.value = _state.value.copy(
- softwareAudioBase64 = audioBase64,
- softwareAudioUrl = audioUrl,
- softwareAudioSeq = softwareAudioSeqCounter
- )
- }
-
- private fun logResult(message: String) {
- val ts = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date())
- val history = listOf("[$ts] $message") + _state.value.timeline.take(11)
- _state.value = _state.value.copy(
- isLoading = false,
- lastMessage = message,
- timeline = history
- )
- enqueueRemoteLog(
- eventType = "APP_TIMELINE_LOG",
- payload = mapOf(
- "line" to "[$ts] $message".take(300),
- "channel" to "timeline"
- )
- )
- }
-
- private fun enqueueRemoteLog(
- eventType: String,
- eventLevel: String = "INFO",
- payload: Map = emptyMap(),
- sessionId: String? = _state.value.sessionId.takeIf { it.isNotBlank() },
- ts: Long = System.currentTimeMillis()
- ) {
- synchronized(pendingRemoteLogsLock) {
- remoteLogLocalId += 1
- pendingRemoteLogs += PendingRemoteLog(
- localId = remoteLogLocalId,
- sessionId = sessionId,
- deviceId = _state.value.deviceId.trim(),
- eventType = eventType,
- eventLevel = eventLevel,
- payload = payload,
- ts = ts
- )
- if (pendingRemoteLogs.size > REMOTE_LOG_MAX_PENDING) {
- val overflow = pendingRemoteLogs.size - REMOTE_LOG_MAX_PENDING
- repeat(overflow) {
- pendingRemoteLogs.removeAt(0)
- }
- }
- }
- persistPendingRemoteLogs()
- if (pendingRemoteLogCount() >= REMOTE_LOG_BATCH_SIZE) {
- flushPendingRemoteLogs()
- }
- }
-
- private fun flushPendingRemoteLogs() {
- if (remoteLogFlushInProgress) return
- val batchItems = synchronized(pendingRemoteLogsLock) {
- pendingRemoteLogs.take(REMOTE_LOG_BATCH_SIZE)
- }
- if (batchItems.isEmpty()) return
- val fallbackDeviceId = _state.value.deviceId.trim()
- if (fallbackDeviceId.isBlank()) return
- remoteLogFlushInProgress = true
- viewModelScope.launch {
- runCatching {
- repository.postEventsBatch(
- batchItems.map { item ->
- ClientEventRequest(
- sessionId = item.sessionId,
- deviceId = item.deviceId.ifBlank { fallbackDeviceId },
- eventType = item.eventType,
- eventLevel = item.eventLevel,
- payload = item.payload + mapOf(
- "appVersionName" to BuildConfig.VERSION_NAME,
- "appVersionCode" to BuildConfig.VERSION_CODE.toString()
- ),
- ts = item.ts
- )
- }
- )
- }.onSuccess {
- val sentIds = batchItems.map { item -> item.localId }.toSet()
- synchronized(pendingRemoteLogsLock) {
- pendingRemoteLogs.removeAll { item -> item.localId in sentIds }
- }
- persistPendingRemoteLogs()
- }.also {
- remoteLogFlushInProgress = false
- if (it.isSuccess && pendingRemoteLogCount() > 0) {
- flushPendingRemoteLogs()
- }
- }
- }
- }
-
- private fun loadPendingRemoteLogs() {
- val raw = remoteLogPrefs.getString(REMOTE_LOG_KEY_QUEUE, null) ?: return
- runCatching {
- remoteLogJson.decodeFromString>(raw)
- }.onSuccess { restored ->
- synchronized(pendingRemoteLogsLock) {
- pendingRemoteLogs.clear()
- pendingRemoteLogs.addAll(restored.takeLast(REMOTE_LOG_MAX_PENDING))
- remoteLogLocalId = pendingRemoteLogs.maxOfOrNull { it.localId } ?: 0L
- }
- }.onFailure {
- remoteLogPrefs.edit().remove(REMOTE_LOG_KEY_QUEUE).apply()
- synchronized(pendingRemoteLogsLock) {
- pendingRemoteLogs.clear()
- remoteLogLocalId = 0L
- }
- }
- }
-
- private fun persistPendingRemoteLogs() {
- val snapshot = synchronized(pendingRemoteLogsLock) {
- pendingRemoteLogs.takeLast(REMOTE_LOG_MAX_PENDING)
- }
- if (snapshot.isEmpty()) {
- remoteLogPrefs.edit().remove(REMOTE_LOG_KEY_QUEUE).apply()
- return
- }
- remoteLogPrefs.edit()
- .putString(
- REMOTE_LOG_KEY_QUEUE,
- remoteLogJson.encodeToString(snapshot)
- )
- .apply()
- }
-
- private fun pendingRemoteLogCount(): Int {
- return synchronized(pendingRemoteLogsLock) {
- pendingRemoteLogs.size
- }
- }
-
- private fun buildAppUuid(userId: String): String {
- val suffix = System.currentTimeMillis().toString().takeLast(10)
- val raw = "${userId}_$suffix"
- return raw.take(32)
- }
-
- private fun buildPhoneDeviceId(userId: String): String {
- val ts = System.currentTimeMillis().toString().takeLast(8)
- return "phone_${userId.take(8)}_$ts"
- }
-
- private fun resolveSoftwareBaiduDeviceId(userId: String): String {
- val fallback = _state.value.deviceId.ifBlank { buildPhoneDeviceId(userId) }
- val loaded = runCatching { NdkLoader.loadLibrary() }.getOrDefault(false)
- if (!loaded) {
- appendSoftwareLog("Baidu device id fallback: NdkLoader.loadLibrary=false")
- return fallback
- }
- val raw = runCatching {
- NdkPhoenix.getDeviceInfo(getApplication().applicationContext)
- }.onFailure {
- appendSoftwareLog("Baidu device id fallback: ${it.javaClass.simpleName}")
- }.getOrDefault("").trim()
- if (raw.isBlank()) {
- appendSoftwareLog("Baidu device id fallback: deviceInfo empty")
- return fallback
- }
- return md5Hex(raw).ifBlank { fallback }
- }
-
- private fun md5Hex(value: String): String {
- return runCatching {
- val digest = MessageDigest.getInstance("MD5").digest(value.toByteArray(Charsets.UTF_8))
- digest.joinToString("") { "%02x".format(it) }
- }.getOrDefault("")
- }
-
- private fun describeStartFailure(step: String, error: Exception): String {
- val base = when (error) {
- is HttpException -> {
- val body = runCatching { error.response()?.errorBody()?.string().orEmpty() }.getOrDefault("")
- buildString {
- append("HttpException(")
- append(error.code())
- append(")")
- val httpMessage = error.message().orEmpty()
- if (httpMessage.isNotBlank()) {
- append(": ")
- append(httpMessage)
- }
- if (body.isNotBlank()) {
- append(", body=")
- append(body.take(180))
- }
- }
- }
- is IOException -> "IOException: ${error.message ?: "network transport failure"}"
- else -> "${error.javaClass.simpleName}: ${error.message ?: "unknown error"}"
- }
- val cause = error.cause?.let { "${it.javaClass.simpleName}: ${it.message}" }.orEmpty()
- return buildString {
- append(base)
- if (cause.isNotBlank()) {
- append(", cause=")
- append(cause.take(180))
- }
- append(", baseUrl=")
- append(BACKEND_BASE_URL)
- append(", step=")
- append(step)
- }
- }
-
- override fun onCleared() {
- super.onCleared()
- softwareStartJob?.cancel()
- remoteLogFlushJob?.cancel()
- }
-}
diff --git a/android-app/app/src/main/java/com/aiglasses/app/ui/theme/AppTheme.kt b/android-app/app/src/main/java/com/aiglasses/app/ui/theme/AppTheme.kt
deleted file mode 100644
index ec1302b..0000000
--- a/android-app/app/src/main/java/com/aiglasses/app/ui/theme/AppTheme.kt
+++ /dev/null
@@ -1,105 +0,0 @@
-package com.aiglasses.app.ui.theme
-
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.darkColorScheme
-import androidx.compose.material3.lightColorScheme
-import androidx.compose.material3.Typography
-import androidx.compose.runtime.Composable
-import androidx.compose.foundation.isSystemInDarkTheme
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.text.TextStyle
-import androidx.compose.ui.text.font.FontFamily
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.unit.sp
-
-private val LightColors = lightColorScheme(
- primary = Color(0xFF4E89F5),
- secondary = Color(0xFF87AEEB),
- tertiary = Color(0xFF17283A),
- background = Color(0xFFF2F7FF),
- surface = Color(0xFFFFFFFF),
- surfaceVariant = Color(0xFFEAF2FF),
- onPrimary = Color.White,
- onSecondary = Color.White,
- onBackground = Color(0xFF152332),
- onSurface = Color(0xFF152332),
- outline = Color(0xFFC9D8EA)
-)
-
-private val DarkColors = darkColorScheme(
- primary = Color(0xFF8CB7FF),
- secondary = Color(0xFF7EA5DE),
- tertiary = Color(0xFFE6EEF9),
- background = Color(0xFF101823),
- surface = Color(0xFF162131),
- surfaceVariant = Color(0xFF1D2B3D),
- onPrimary = Color(0xFF0C1B30),
- onSecondary = Color(0xFF0C1B30),
- onBackground = Color(0xFFEAF1FB),
- onSurface = Color(0xFFEAF1FB),
- outline = Color(0xFF35506F)
-)
-
-private val AppTypography = Typography(
- headlineLarge = TextStyle(
- fontFamily = FontFamily.SansSerif,
- fontWeight = FontWeight.Bold,
- fontSize = 30.sp,
- lineHeight = 36.sp
- ),
- headlineMedium = TextStyle(
- fontFamily = FontFamily.SansSerif,
- fontWeight = FontWeight.Bold,
- fontSize = 26.sp,
- lineHeight = 32.sp
- ),
- headlineSmall = TextStyle(
- fontFamily = FontFamily.SansSerif,
- fontWeight = FontWeight.SemiBold,
- fontSize = 22.sp,
- lineHeight = 28.sp
- ),
- titleLarge = TextStyle(
- fontFamily = FontFamily.SansSerif,
- fontWeight = FontWeight.Bold,
- fontSize = 20.sp,
- lineHeight = 26.sp
- ),
- bodyLarge = TextStyle(
- fontFamily = FontFamily.SansSerif,
- fontSize = 16.sp,
- lineHeight = 24.sp
- ),
- bodyMedium = TextStyle(
- fontFamily = FontFamily.SansSerif,
- fontSize = 14.sp,
- lineHeight = 21.sp
- ),
- bodySmall = TextStyle(
- fontFamily = FontFamily.SansSerif,
- fontSize = 12.sp,
- lineHeight = 18.sp
- ),
- labelLarge = TextStyle(
- fontFamily = FontFamily.SansSerif,
- fontWeight = FontWeight.Medium,
- fontSize = 14.sp
- ),
- labelSmall = TextStyle(
- fontFamily = FontFamily.SansSerif,
- fontWeight = FontWeight.Medium,
- fontSize = 11.sp
- )
-)
-
-@Composable
-fun AIGlassesTheme(
- darkTheme: Boolean = isSystemInDarkTheme(),
- content: @Composable () -> Unit
-) {
- MaterialTheme(
- colorScheme = if (darkTheme) DarkColors else LightColors,
- typography = AppTypography,
- content = content
- )
-}
diff --git a/android-app/app/src/main/java/com/aiglasses/app/update/AppOtaUpdater.kt b/android-app/app/src/main/java/com/aiglasses/app/update/AppOtaUpdater.kt
deleted file mode 100644
index 6235597..0000000
--- a/android-app/app/src/main/java/com/aiglasses/app/update/AppOtaUpdater.kt
+++ /dev/null
@@ -1,559 +0,0 @@
-package com.aiglasses.app.update
-
-import android.app.DownloadManager
-import android.content.BroadcastReceiver
-import android.content.Context
-import android.content.Intent
-import android.content.IntentFilter
-import android.net.Uri
-import android.os.Build
-import android.os.Environment
-import android.os.Handler
-import android.os.Looper
-import android.os.SystemClock
-import android.provider.Settings
-import androidx.core.content.FileProvider
-import java.io.File
-import java.io.FileOutputStream
-import java.security.MessageDigest
-
-class AppOtaUpdater(
- context: Context,
- private val onLog: (String) -> Unit
-) {
- private val appContext = context.applicationContext
- private val downloadManager = appContext.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
- private val prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
- private val mainHandler = Handler(Looper.getMainLooper())
- private var receiverRegistered = false
- private var activeDownloadId = -1L
- private var activeDownloadUrl = ""
- private var activeExpectedSha256 = ""
- private var activeFileName = ""
- private var progressTask: Runnable? = null
- private var lastProgressPercent = -1
- private var lastProgressLogAt = 0L
- private var lastProgressBytes = -1L
- private var lastProgressBytesAt = 0L
-
- private data class DownloadSnapshot(
- val exists: Boolean = false,
- val status: Int = 0,
- val reason: Int = -1,
- val soFar: Long = 0L,
- val total: Long = 0L,
- val url: String = ""
- )
-
- private val downloadReceiver = object : BroadcastReceiver() {
- override fun onReceive(context: Context?, intent: Intent?) {
- if (intent?.action != DownloadManager.ACTION_DOWNLOAD_COMPLETE) return
- val id = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1L)
- if (id <= 0 || id != activeDownloadId) return
- handleDownloadComplete(id)
- }
- }
-
- fun register() {
- if (receiverRegistered) return
- val filter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- appContext.registerReceiver(downloadReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
- } else {
- appContext.registerReceiver(downloadReceiver, filter)
- }
- receiverRegistered = true
- recoverTrackedDownload()
- }
-
- fun release() {
- if (!receiverRegistered) return
- runCatching { appContext.unregisterReceiver(downloadReceiver) }
- receiverRegistered = false
- stopProgressPolling()
- }
-
- fun downloadAndInstall(apkUrl: String, versionName: String, expectedSha256: String = ""): Boolean {
- val url = apkUrl.trim()
- if (url.isBlank()) {
- onLog("OTA: missing apk url")
- return false
- }
- val expected = expectedSha256.trim().lowercase()
- recoverTrackedDownload()
- val existing = findDownloadByUrl(url)
- if (existing > 0) {
- val snapshot = queryDownload(existing)
- when (snapshot.status) {
- DownloadManager.STATUS_SUCCESSFUL -> {
- onLog("OTA: 发现已下载完成任务,直接安装 id=$existing")
- activeDownloadId = existing
- activeDownloadUrl = url
- activeExpectedSha256 = expected
- persistTrackedDownload()
- handleDownloadComplete(existing)
- return true
- }
- DownloadManager.STATUS_PENDING,
- DownloadManager.STATUS_PAUSED,
- DownloadManager.STATUS_RUNNING -> {
- activeDownloadId = existing
- activeDownloadUrl = url
- activeExpectedSha256 = expected
- if (activeFileName.isBlank()) {
- activeFileName = buildStableFileName(versionName)
- }
- persistTrackedDownload()
- onLog("OTA: 继续已有下载任务 id=$existing")
- startProgressPolling(existing)
- return true
- }
- }
- if (snapshot.status == DownloadManager.STATUS_FAILED) {
- onLog("OTA: 清理失败下载任务 id=$existing 后重试")
- runCatching { downloadManager.remove(existing) }
- if (activeDownloadId == existing) {
- clearTrackedDownload()
- }
- }
- }
- val fileName = buildStableFileName(versionName)
- val req = DownloadManager.Request(Uri.parse(url))
- .setTitle("AI Glasses 更新包")
- .setDescription("下载并安装 $versionName")
- .setAllowedOverMetered(true)
- .setAllowedOverRoaming(true)
- .setMimeType("application/vnd.android.package-archive")
- .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
- .setDestinationInExternalFilesDir(appContext, Environment.DIRECTORY_DOWNLOADS, fileName)
- if (activeDownloadId > 0 && activeDownloadUrl != url) {
- onLog("OTA: 切换到新下载地址,取消旧任务 id=$activeDownloadId")
- runCatching { downloadManager.remove(activeDownloadId) }
- }
- stopProgressPolling()
- resetProgressTracking()
- activeDownloadUrl = url
- activeExpectedSha256 = expected
- activeFileName = fileName
- activeDownloadId = runCatching { downloadManager.enqueue(req) }
- .onFailure { onLog("OTA: download enqueue failed: ${it.message}") }
- .getOrDefault(-1L)
- if (activeDownloadId <= 0) return false
- persistTrackedDownload()
- onLog("OTA: 开始下载更新包 id=$activeDownloadId")
- onLog("OTA: 下载地址 ${url.take(120)}")
- startProgressPolling(activeDownloadId)
- return true
- }
-
- private fun handleDownloadComplete(downloadId: Long) {
- stopProgressPolling()
- val cursor = downloadManager.query(DownloadManager.Query().setFilterById(downloadId))
- cursor.use { c ->
- if (!c.moveToFirst()) {
- onLog("OTA: 下载任务不存在 id=$downloadId")
- clearTrackedDownload()
- return
- }
- val statusIdx = c.getColumnIndex(DownloadManager.COLUMN_STATUS)
- if (statusIdx < 0) {
- onLog("OTA: 无法读取下载状态")
- clearTrackedDownload()
- return
- }
- val status = c.getInt(statusIdx)
- if (status != DownloadManager.STATUS_SUCCESSFUL) {
- val reasonIdx = c.getColumnIndex(DownloadManager.COLUMN_REASON)
- val reason = if (reasonIdx >= 0) c.getInt(reasonIdx) else -1
- onLog("OTA: 下载失败 status=$status reason=${reasonToText(reason)}($reason)")
- clearTrackedDownload()
- return
- }
- }
- onLog("OTA: 下载完成 id=$downloadId")
- val uri = downloadManager.getUriForDownloadedFile(downloadId)
- if (uri == null) {
- onLog("OTA: 找不到已下载文件 URI")
- clearTrackedDownload()
- return
- }
- if (!verifyDownloadedApkSha256(uri, activeExpectedSha256)) {
- clearTrackedDownload()
- return
- }
- if (!canInstallPackages()) {
- openInstallPermissionSettings()
- onLog("OTA: 下载完成,请允许本应用安装未知来源后再次点击更新")
- persistTrackedDownload()
- return
- }
- val installUri = materializeInstallUri(uri, activeFileName)
- if (installUri == null) {
- onLog("OTA: 无法准备安装包")
- clearTrackedDownload()
- return
- }
- val ok = installApk(installUri)
- onLog(if (ok) "OTA: 已拉起安装流程" else "OTA: 拉起安装失败")
- clearTrackedDownload()
- }
-
- private fun startProgressPolling(downloadId: Long) {
- stopProgressPolling()
- val task = object : Runnable {
- override fun run() {
- if (activeDownloadId != downloadId || activeDownloadId <= 0) return
- val keep = emitDownloadProgress(downloadId)
- if (!keep) return
- mainHandler.postDelayed(this, 1000L)
- }
- }
- progressTask = task
- mainHandler.post(task)
- }
-
- private fun stopProgressPolling() {
- progressTask?.let { mainHandler.removeCallbacks(it) }
- progressTask = null
- }
-
- private fun emitDownloadProgress(downloadId: Long): Boolean {
- val cursor = downloadManager.query(DownloadManager.Query().setFilterById(downloadId))
- cursor.use { c ->
- if (!c.moveToFirst()) {
- onLog("OTA: 下载任务丢失 id=$downloadId")
- clearTrackedDownload()
- return false
- }
- val statusIdx = c.getColumnIndex(DownloadManager.COLUMN_STATUS)
- val soFarIdx = c.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)
- val totalIdx = c.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)
- val reasonIdx = c.getColumnIndex(DownloadManager.COLUMN_REASON)
- if (statusIdx < 0 || soFarIdx < 0 || totalIdx < 0) {
- return true
- }
- val status = c.getInt(statusIdx)
- val soFar = c.getLong(soFarIdx).coerceAtLeast(0L)
- val total = c.getLong(totalIdx).coerceAtLeast(0L)
- val percent = if (total > 0L) {
- ((soFar * 100L) / total).toInt().coerceIn(0, 100)
- } else {
- -1
- }
- val now = SystemClock.elapsedRealtime()
- when {
- soFar > lastProgressBytes -> {
- lastProgressBytes = soFar
- lastProgressBytesAt = now
- }
- lastProgressBytes < 0L -> {
- lastProgressBytes = soFar
- lastProgressBytesAt = now
- }
- }
- val shouldLog = when {
- status == DownloadManager.STATUS_RUNNING && percent >= 0 ->
- (percent != lastProgressPercent && (percent % 2 == 0 || percent >= 98)) ||
- (now - lastProgressLogAt >= 4_000L)
- status == DownloadManager.STATUS_RUNNING ->
- now - lastProgressLogAt >= 3_000L
- status == DownloadManager.STATUS_PENDING || status == DownloadManager.STATUS_PAUSED ->
- now - lastProgressLogAt >= 3000L
- else -> false
- }
- if (shouldLog) {
- lastProgressLogAt = now
- if (percent >= 0) {
- lastProgressPercent = percent
- onLog(
- "OTA: 下载进度 $percent% (${formatBytes(soFar)}/${formatBytes(total)}) status=${statusToText(status)}"
- )
- } else {
- val reason = if (reasonIdx >= 0) c.getInt(reasonIdx) else -1
- onLog(
- if (status == DownloadManager.STATUS_RUNNING) {
- "OTA: 下载中 ${formatBytes(soFar)} (总大小未知)"
- } else {
- "OTA: 下载状态=${statusToText(status)} reason=${reasonToText(reason)} ${formatBytes(soFar)}"
- }
- )
- }
- }
- return when (status) {
- DownloadManager.STATUS_PENDING, DownloadManager.STATUS_PAUSED, DownloadManager.STATUS_RUNNING -> true
- DownloadManager.STATUS_SUCCESSFUL -> {
- handleDownloadComplete(downloadId)
- false
- }
- DownloadManager.STATUS_FAILED -> {
- val reason = if (reasonIdx >= 0) c.getInt(reasonIdx) else -1
- onLog("OTA: 下载失败 reason=${reasonToText(reason)}($reason)")
- clearTrackedDownload()
- false
- }
- else -> true
- }
- }
- }
-
- private fun recoverTrackedDownload() {
- if (activeDownloadId <= 0L) {
- activeDownloadId = prefs.getLong(KEY_DOWNLOAD_ID, -1L)
- activeDownloadUrl = prefs.getString(KEY_DOWNLOAD_URL, "") ?: ""
- activeExpectedSha256 = prefs.getString(KEY_EXPECTED_SHA256, "") ?: ""
- activeFileName = prefs.getString(KEY_FILE_NAME, "") ?: ""
- }
- if (activeDownloadId <= 0L) return
- val snapshot = queryDownload(activeDownloadId)
- if (!snapshot.exists) {
- clearTrackedDownload()
- return
- }
- if (activeDownloadUrl.isBlank()) {
- activeDownloadUrl = snapshot.url
- }
- when (snapshot.status) {
- DownloadManager.STATUS_PENDING,
- DownloadManager.STATUS_PAUSED,
- DownloadManager.STATUS_RUNNING -> {
- onLog("OTA: 恢复下载任务 id=$activeDownloadId")
- persistTrackedDownload()
- resetProgressTracking(snapshot.soFar)
- startProgressPolling(activeDownloadId)
- }
- DownloadManager.STATUS_SUCCESSFUL -> {
- onLog("OTA: 检测到已完成下载任务,继续安装")
- handleDownloadComplete(activeDownloadId)
- }
- DownloadManager.STATUS_FAILED -> {
- onLog(
- "OTA: 上次下载任务已失败 reason=${reasonToText(snapshot.reason)}(${snapshot.reason})"
- )
- clearTrackedDownload()
- }
- else -> {
- persistTrackedDownload()
- }
- }
- }
-
- private fun findDownloadByUrl(url: String): Long {
- if (activeDownloadId > 0L && activeDownloadUrl == url) {
- val active = queryDownload(activeDownloadId)
- if (active.exists) return activeDownloadId
- }
- val savedId = prefs.getLong(KEY_DOWNLOAD_ID, -1L)
- val savedUrl = prefs.getString(KEY_DOWNLOAD_URL, "") ?: ""
- if (savedId > 0L && savedUrl == url) {
- val saved = queryDownload(savedId)
- if (saved.exists) return savedId
- }
- val query = DownloadManager.Query().setFilterByStatus(
- DownloadManager.STATUS_PENDING or
- DownloadManager.STATUS_PAUSED or
- DownloadManager.STATUS_RUNNING or
- DownloadManager.STATUS_SUCCESSFUL
- )
- val cursor = downloadManager.query(query)
- var latestId = -1L
- cursor.use { c ->
- val idIdx = c.getColumnIndex(DownloadManager.COLUMN_ID)
- val urlIdx = c.getColumnIndex(DownloadManager.COLUMN_URI)
- if (idIdx < 0 || urlIdx < 0) return@use
- while (c.moveToNext()) {
- val itemUrl = c.getString(urlIdx).orEmpty()
- if (itemUrl != url) continue
- val id = c.getLong(idIdx)
- if (id > latestId) latestId = id
- }
- }
- return latestId
- }
-
- private fun queryDownload(downloadId: Long): DownloadSnapshot {
- if (downloadId <= 0L) return DownloadSnapshot()
- val cursor = downloadManager.query(DownloadManager.Query().setFilterById(downloadId))
- cursor.use { c ->
- if (!c.moveToFirst()) return DownloadSnapshot()
- val statusIdx = c.getColumnIndex(DownloadManager.COLUMN_STATUS)
- val reasonIdx = c.getColumnIndex(DownloadManager.COLUMN_REASON)
- val soFarIdx = c.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)
- val totalIdx = c.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)
- val urlIdx = c.getColumnIndex(DownloadManager.COLUMN_URI)
- return DownloadSnapshot(
- exists = true,
- status = if (statusIdx >= 0) c.getInt(statusIdx) else 0,
- reason = if (reasonIdx >= 0) c.getInt(reasonIdx) else -1,
- soFar = if (soFarIdx >= 0) c.getLong(soFarIdx) else 0L,
- total = if (totalIdx >= 0) c.getLong(totalIdx) else 0L,
- url = if (urlIdx >= 0) c.getString(urlIdx).orEmpty() else ""
- )
- }
- }
-
- private fun persistTrackedDownload() {
- if (activeDownloadId <= 0L) return
- prefs.edit()
- .putLong(KEY_DOWNLOAD_ID, activeDownloadId)
- .putString(KEY_DOWNLOAD_URL, activeDownloadUrl)
- .putString(KEY_EXPECTED_SHA256, activeExpectedSha256)
- .putString(KEY_FILE_NAME, activeFileName)
- .apply()
- }
-
- private fun clearTrackedDownload() {
- activeDownloadId = -1L
- activeDownloadUrl = ""
- activeExpectedSha256 = ""
- activeFileName = ""
- resetProgressTracking()
- prefs.edit()
- .remove(KEY_DOWNLOAD_ID)
- .remove(KEY_DOWNLOAD_URL)
- .remove(KEY_EXPECTED_SHA256)
- .remove(KEY_FILE_NAME)
- .apply()
- }
-
- private fun buildStableFileName(versionName: String): String {
- val safeName = versionName.ifBlank { "latest" }.replace(Regex("[^A-Za-z0-9._-]"), "_")
- return "ai-glasses-$safeName.apk"
- }
-
- private fun resetProgressTracking(initialBytes: Long = -1L) {
- lastProgressPercent = -1
- lastProgressLogAt = 0L
- lastProgressBytes = initialBytes
- lastProgressBytesAt = if (initialBytes >= 0L) SystemClock.elapsedRealtime() else 0L
- }
-
- private fun verifyDownloadedApkSha256(uri: Uri, expectedSha256: String): Boolean {
- if (expectedSha256.isBlank()) return true
- val digest = runCatching {
- val md = MessageDigest.getInstance("SHA-256")
- appContext.contentResolver.openInputStream(uri)?.use { input ->
- val buffer = ByteArray(16 * 1024)
- while (true) {
- val n = input.read(buffer)
- if (n <= 0) break
- md.update(buffer, 0, n)
- }
- } ?: return false
- md.digest().joinToString("") { "%02x".format(it) }
- }.onFailure {
- onLog("OTA: 校验失败 ${it.message}")
- }.getOrNull() ?: return false
- if (digest != expectedSha256) {
- onLog("OTA: 文件校验不匹配 expected=${expectedSha256.take(10)} actual=${digest.take(10)}")
- return false
- }
- onLog("OTA: 文件校验通过")
- return true
- }
-
- private fun installApk(uri: Uri): Boolean {
- return runCatching {
- val intent = Intent(Intent.ACTION_INSTALL_PACKAGE).apply {
- data = uri
- addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
- addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
- putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true)
- putExtra(Intent.EXTRA_RETURN_RESULT, false)
- }
- intent.resolveActivity(appContext.packageManager)
- ?: throw IllegalStateException("no package installer activity")
- appContext.startActivity(intent)
- true
- }.onFailure {
- onLog("OTA: 安装 Intent 失败 ${it.message}")
- }.getOrDefault(false)
- }
-
- private fun materializeInstallUri(sourceUri: Uri, fileName: String): Uri? {
- return runCatching {
- val otaDir = File(appContext.cacheDir, "ota").apply { mkdirs() }
- val apkFile = File(otaDir, fileName.ifBlank { "ai-glasses-update.apk" })
- appContext.contentResolver.openInputStream(sourceUri)?.use { input ->
- FileOutputStream(apkFile, false).use { output ->
- input.copyTo(output)
- }
- } ?: return null
- FileProvider.getUriForFile(
- appContext,
- "${appContext.packageName}.fileprovider",
- apkFile
- )
- }.onFailure {
- onLog("OTA: 准备安装包失败 ${it.message}")
- }.getOrNull()
- }
-
- private fun formatBytes(value: Long): String {
- if (value < 1024L) return "${value}B"
- val kb = value / 1024.0
- if (kb < 1024.0) return String.format("%.1fKB", kb)
- val mb = kb / 1024.0
- if (mb < 1024.0) return String.format("%.1fMB", mb)
- val gb = mb / 1024.0
- return String.format("%.2fGB", gb)
- }
-
- private fun reasonToText(reason: Int): String {
- return when (reason) {
- DownloadManager.ERROR_CANNOT_RESUME -> "CANNOT_RESUME"
- DownloadManager.ERROR_DEVICE_NOT_FOUND -> "DEVICE_NOT_FOUND"
- DownloadManager.ERROR_FILE_ALREADY_EXISTS -> "FILE_ALREADY_EXISTS"
- DownloadManager.ERROR_FILE_ERROR -> "FILE_ERROR"
- DownloadManager.ERROR_HTTP_DATA_ERROR -> "HTTP_DATA_ERROR"
- DownloadManager.ERROR_INSUFFICIENT_SPACE -> "INSUFFICIENT_SPACE"
- DownloadManager.ERROR_TOO_MANY_REDIRECTS -> "TOO_MANY_REDIRECTS"
- DownloadManager.ERROR_UNHANDLED_HTTP_CODE -> "UNHANDLED_HTTP_CODE"
- DownloadManager.ERROR_UNKNOWN -> "UNKNOWN"
- DownloadManager.PAUSED_QUEUED_FOR_WIFI -> "PAUSED_QUEUED_FOR_WIFI"
- DownloadManager.PAUSED_WAITING_FOR_NETWORK -> "PAUSED_WAITING_FOR_NETWORK"
- DownloadManager.PAUSED_WAITING_TO_RETRY -> "PAUSED_WAITING_TO_RETRY"
- DownloadManager.PAUSED_UNKNOWN -> "PAUSED_UNKNOWN"
- else -> "OTHER"
- }
- }
-
- private fun statusToText(status: Int): String {
- return when (status) {
- DownloadManager.STATUS_PENDING -> "PENDING"
- DownloadManager.STATUS_RUNNING -> "RUNNING"
- DownloadManager.STATUS_PAUSED -> "PAUSED"
- DownloadManager.STATUS_SUCCESSFUL -> "SUCCESSFUL"
- DownloadManager.STATUS_FAILED -> "FAILED"
- else -> "UNKNOWN"
- }
- }
-
- private fun canInstallPackages(): Boolean {
- return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- appContext.packageManager.canRequestPackageInstalls()
- } else {
- true
- }
- }
-
- private fun openInstallPermissionSettings() {
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
- runCatching {
- val intent = Intent(
- Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
- Uri.parse("package:${appContext.packageName}")
- ).apply {
- addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
- }
- appContext.startActivity(intent)
- }
- }
-
- private companion object {
- const val PREFS_NAME = "ota_updater_prefs"
- const val KEY_DOWNLOAD_ID = "download_id"
- const val KEY_DOWNLOAD_URL = "download_url"
- const val KEY_EXPECTED_SHA256 = "expected_sha256"
- const val KEY_FILE_NAME = "file_name"
- }
-}
diff --git a/android-app/app/src/main/res/values/strings.xml b/android-app/app/src/main/res/values/strings.xml
deleted file mode 100644
index 7a31f37..0000000
--- a/android-app/app/src/main/res/values/strings.xml
+++ /dev/null
@@ -1,3 +0,0 @@
-
- StoryForge AI
-
diff --git a/android-app/app/src/main/res/values/themes.xml b/android-app/app/src/main/res/values/themes.xml
deleted file mode 100644
index 3b1e9d4..0000000
--- a/android-app/app/src/main/res/values/themes.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
-
diff --git a/android-app/app/src/main/res/xml/file_paths.xml b/android-app/app/src/main/res/xml/file_paths.xml
deleted file mode 100644
index 03773fb..0000000
--- a/android-app/app/src/main/res/xml/file_paths.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
diff --git a/android-app/app/src/main/res/xml/network_security_config.xml b/android-app/app/src/main/res/xml/network_security_config.xml
deleted file mode 100644
index e5a84f4..0000000
--- a/android-app/app/src/main/res/xml/network_security_config.xml
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
-
- localhost
- 127.0.0.1
- 10.0.2.2
-
-
diff --git a/android-app/build.gradle.kts b/android-app/build.gradle.kts
deleted file mode 100644
index 4b83a5b..0000000
--- a/android-app/build.gradle.kts
+++ /dev/null
@@ -1,6 +0,0 @@
-plugins {
- id("com.android.application") version "8.5.2" apply false
- id("org.jetbrains.kotlin.android") version "1.9.24" apply false
- id("org.jetbrains.kotlin.plugin.serialization") version "1.9.24" apply false
-}
-
diff --git a/android-app/gradle.properties b/android-app/gradle.properties
deleted file mode 100644
index 365052c..0000000
--- a/android-app/gradle.properties
+++ /dev/null
@@ -1,5 +0,0 @@
-org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
-android.useAndroidX=true
-android.nonTransitiveRClass=true
-kotlin.code.style=official
-
diff --git a/android-app/gradle/wrapper/gradle-wrapper.jar b/android-app/gradle/wrapper/gradle-wrapper.jar
deleted file mode 100644
index e644113..0000000
Binary files a/android-app/gradle/wrapper/gradle-wrapper.jar and /dev/null differ
diff --git a/android-app/gradle/wrapper/gradle-wrapper.properties b/android-app/gradle/wrapper/gradle-wrapper.properties
deleted file mode 100644
index b82aa23..0000000
--- a/android-app/gradle/wrapper/gradle-wrapper.properties
+++ /dev/null
@@ -1,7 +0,0 @@
-distributionBase=GRADLE_USER_HOME
-distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
-networkTimeout=10000
-validateDistributionUrl=true
-zipStoreBase=GRADLE_USER_HOME
-zipStorePath=wrapper/dists
diff --git a/android-app/gradlew b/android-app/gradlew
deleted file mode 100755
index 97de990..0000000
--- a/android-app/gradlew
+++ /dev/null
@@ -1,249 +0,0 @@
-#!/bin/sh
-
-#
-# Copyright © 2015-2021 the original authors.
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# https://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-#
-
-##############################################################################
-#
-# Gradle start up script for POSIX generated by Gradle.
-#
-# Important for running:
-#
-# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
-# noncompliant, but you have some other compliant shell such as ksh or
-# bash, then to run this script, type that shell name before the whole
-# command line, like:
-#
-# ksh Gradle
-#
-# Busybox and similar reduced shells will NOT work, because this script
-# requires all of these POSIX shell features:
-# * functions;
-# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
-# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
-# * compound commands having a testable exit status, especially «case»;
-# * various built-in commands including «command», «set», and «ulimit».
-#
-# Important for patching:
-#
-# (2) This script targets any POSIX shell, so it avoids extensions provided
-# by Bash, Ksh, etc; in particular arrays are avoided.
-#
-# The "traditional" practice of packing multiple parameters into a
-# space-separated string is a well documented source of bugs and security
-# problems, so this is (mostly) avoided, by progressively accumulating
-# options in "$@", and eventually passing that to Java.
-#
-# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
-# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
-# see the in-line comments for details.
-#
-# There are tweaks for specific operating systems such as AIX, CygWin,
-# Darwin, MinGW, and NonStop.
-#
-# (3) This script is generated from the Groovy template
-# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
-# within the Gradle project.
-#
-# You can find Gradle at https://github.com/gradle/gradle/.
-#
-##############################################################################
-
-# Attempt to set APP_HOME
-
-# Resolve links: $0 may be a link
-app_path=$0
-
-# Need this for daisy-chained symlinks.
-while
- APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
- [ -h "$app_path" ]
-do
- ls=$( ls -ld "$app_path" )
- link=${ls#*' -> '}
- case $link in #(
- /*) app_path=$link ;; #(
- *) app_path=$APP_HOME$link ;;
- esac
-done
-
-# This is normally unused
-# shellcheck disable=SC2034
-APP_BASE_NAME=${0##*/}
-# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
-APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
-
-# Use the maximum available, or set MAX_FD != -1 to use that value.
-MAX_FD=maximum
-
-warn () {
- echo "$*"
-} >&2
-
-die () {
- echo
- echo "$*"
- echo
- exit 1
-} >&2
-
-# OS specific support (must be 'true' or 'false').
-cygwin=false
-msys=false
-darwin=false
-nonstop=false
-case "$( uname )" in #(
- CYGWIN* ) cygwin=true ;; #(
- Darwin* ) darwin=true ;; #(
- MSYS* | MINGW* ) msys=true ;; #(
- NONSTOP* ) nonstop=true ;;
-esac
-
-CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
-
-
-# Determine the Java command to use to start the JVM.
-if [ -n "$JAVA_HOME" ] ; then
- if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
- # IBM's JDK on AIX uses strange locations for the executables
- JAVACMD=$JAVA_HOME/jre/sh/java
- else
- JAVACMD=$JAVA_HOME/bin/java
- fi
- if [ ! -x "$JAVACMD" ] ; then
- die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
-
-Please set the JAVA_HOME variable in your environment to match the
-location of your Java installation."
- fi
-else
- JAVACMD=java
- if ! command -v java >/dev/null 2>&1
- then
- die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
-
-Please set the JAVA_HOME variable in your environment to match the
-location of your Java installation."
- fi
-fi
-
-# Increase the maximum file descriptors if we can.
-if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
- case $MAX_FD in #(
- max*)
- # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
- # shellcheck disable=SC2039,SC3045
- MAX_FD=$( ulimit -H -n ) ||
- warn "Could not query maximum file descriptor limit"
- esac
- case $MAX_FD in #(
- '' | soft) :;; #(
- *)
- # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
- # shellcheck disable=SC2039,SC3045
- ulimit -n "$MAX_FD" ||
- warn "Could not set maximum file descriptor limit to $MAX_FD"
- esac
-fi
-
-# Collect all arguments for the java command, stacking in reverse order:
-# * args from the command line
-# * the main class name
-# * -classpath
-# * -D...appname settings
-# * --module-path (only if needed)
-# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
-
-# For Cygwin or MSYS, switch paths to Windows format before running java
-if "$cygwin" || "$msys" ; then
- APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
- CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
-
- JAVACMD=$( cygpath --unix "$JAVACMD" )
-
- # Now convert the arguments - kludge to limit ourselves to /bin/sh
- for arg do
- if
- case $arg in #(
- -*) false ;; # don't mess with options #(
- /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
- [ -e "$t" ] ;; #(
- *) false ;;
- esac
- then
- arg=$( cygpath --path --ignore --mixed "$arg" )
- fi
- # Roll the args list around exactly as many times as the number of
- # args, so each arg winds up back in the position where it started, but
- # possibly modified.
- #
- # NB: a `for` loop captures its iteration list before it begins, so
- # changing the positional parameters here affects neither the number of
- # iterations, nor the values presented in `arg`.
- shift # remove old arg
- set -- "$@" "$arg" # push replacement arg
- done
-fi
-
-
-# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
-DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"'
-
-# Collect all arguments for the java command:
-# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
-# and any embedded shellness will be escaped.
-# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
-# treated as '${Hostname}' itself on the command line.
-
-set -- \
- "-Dorg.gradle.appname=$APP_BASE_NAME" \
- -classpath "$CLASSPATH" \
- org.gradle.wrapper.GradleWrapperMain \
- "$@"
-
-# Stop when "xargs" is not available.
-if ! command -v xargs >/dev/null 2>&1
-then
- die "xargs is not available"
-fi
-
-# Use "xargs" to parse quoted args.
-#
-# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
-#
-# In Bash we could simply go:
-#
-# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
-# set -- "${ARGS[@]}" "$@"
-#
-# but POSIX shell has neither arrays nor command substitution, so instead we
-# post-process each arg (as a line of input to sed) to backslash-escape any
-# character that might be a shell metacharacter, then use eval to reverse
-# that process (while maintaining the separation between arguments), and wrap
-# the whole thing up as a single "set" statement.
-#
-# This will of course break if any of these variables contains a newline or
-# an unmatched quote.
-#
-
-eval "set -- $(
- printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
- xargs -n1 |
- sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
- tr '\n' ' '
- )" '"$@"'
-
-exec "$JAVACMD" "$@"
diff --git a/android-app/gradlew.bat b/android-app/gradlew.bat
deleted file mode 100644
index 16e26a1..0000000
--- a/android-app/gradlew.bat
+++ /dev/null
@@ -1,92 +0,0 @@
-@rem
-@rem Copyright 2015 the original author or authors.
-@rem
-@rem Licensed under the Apache License, Version 2.0 (the "License");
-@rem you may not use this file except in compliance with the License.
-@rem You may obtain a copy of the License at
-@rem
-@rem https://www.apache.org/licenses/LICENSE-2.0
-@rem
-@rem Unless required by applicable law or agreed to in writing, software
-@rem distributed under the License is distributed on an "AS IS" BASIS,
-@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-@rem See the License for the specific language governing permissions and
-@rem limitations under the License.
-@rem
-
-@if "%DEBUG%"=="" @echo off
-@rem ##########################################################################
-@rem
-@rem Gradle startup script for Windows
-@rem
-@rem ##########################################################################
-
-@rem Set local scope for the variables with windows NT shell
-if "%OS%"=="Windows_NT" setlocal
-
-set DIRNAME=%~dp0
-if "%DIRNAME%"=="" set DIRNAME=.
-@rem This is normally unused
-set APP_BASE_NAME=%~n0
-set APP_HOME=%DIRNAME%
-
-@rem Resolve any "." and ".." in APP_HOME to make it shorter.
-for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
-
-@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
-set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"
-
-@rem Find java.exe
-if defined JAVA_HOME goto findJavaFromJavaHome
-
-set JAVA_EXE=java.exe
-%JAVA_EXE% -version >NUL 2>&1
-if %ERRORLEVEL% equ 0 goto execute
-
-echo. 1>&2
-echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
-echo. 1>&2
-echo Please set the JAVA_HOME variable in your environment to match the 1>&2
-echo location of your Java installation. 1>&2
-
-goto fail
-
-:findJavaFromJavaHome
-set JAVA_HOME=%JAVA_HOME:"=%
-set JAVA_EXE=%JAVA_HOME%/bin/java.exe
-
-if exist "%JAVA_EXE%" goto execute
-
-echo. 1>&2
-echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
-echo. 1>&2
-echo Please set the JAVA_HOME variable in your environment to match the 1>&2
-echo location of your Java installation. 1>&2
-
-goto fail
-
-:execute
-@rem Setup the command line
-
-set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
-
-
-@rem Execute Gradle
-"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
-
-:end
-@rem End local scope for the variables with windows NT shell
-if %ERRORLEVEL% equ 0 goto mainEnd
-
-:fail
-rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
-rem the _cmd.exe /c_ return code!
-set EXIT_CODE=%ERRORLEVEL%
-if %EXIT_CODE% equ 0 set EXIT_CODE=1
-if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
-exit /b %EXIT_CODE%
-
-:mainEnd
-if "%OS%"=="Windows_NT" endlocal
-
-:omega
diff --git a/android-app/settings.gradle.kts b/android-app/settings.gradle.kts
deleted file mode 100644
index 20b8d60..0000000
--- a/android-app/settings.gradle.kts
+++ /dev/null
@@ -1,19 +0,0 @@
-pluginManagement {
- repositories {
- google()
- mavenCentral()
- gradlePluginPortal()
- }
-}
-
-dependencyResolutionManagement {
- repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
- repositories {
- google()
- mavenCentral()
- }
-}
-
-rootProject.name = "AIGlassesApp"
-include(":app")
-
diff --git a/docs/AUDIT_2026-03-18.md b/docs/AUDIT_2026-03-18.md
index 2080a7f..61094d8 100644
--- a/docs/AUDIT_2026-03-18.md
+++ b/docs/AUDIT_2026-03-18.md
@@ -16,7 +16,6 @@
- 知识库、智能体、任务管理
- 视频链接/上传视频/文本三类入口
- 下载器、ffmpeg、whisper.cpp 风格的本地处理调用
-- Android OTA 查询/发布
### 2. 旧数据集运行链实际承担的功能
@@ -180,12 +179,11 @@
- `n8n` 工作流导出文件已纳入仓库
- `collector-service` 的 live 运行态已回归到 `StoryForge-gitea` 自身源码构建,不再依赖旧导入目录的临时 bind mount
- `collector-service` 现已在 live `8081` 提供 `/v2/douyin/*` 接口,并保留原有 `real-cut / ai-video / content-source-sync` 路由
-- Android Explore 页已补上“账号同步”入口,可直接创建内容源账号同步任务,并支持平台、主页链接、账号标识、最大抓取条数、跳过已存在、自动触发分析等参数
-- Android 工作区缺失的 `com.aiglasses.app.data` 数据层已从同源代码补回,当前 `./gradlew :app:compileDebugKotlin` 与 `:app:assembleDebug` 均已通过,并产出 `app-debug.apk`
+- 曾混入本仓库的 `android-app/` 已确认来自独立 `AI Glasses` 工程叠加,现已从 StoryForge 主仓库边界中拆出,后续不再作为当前主工作区的一部分维护
## 当前主要风险
1. 小红书账号级内容源还未做真实平台验证
2. `douyin` public 直抓仍受反爬限制,但现在已经有“真实浏览器 + 人工登录 + 自动提取 + 回写现有工作台”的可落地协作链
3. `huobao-drama-upstream` 已完成代码迁移并可编译,但 fresh smoke 受外部图片/视频凭证 `403 invalid user` 阻塞
-4. Android 端目前已能完成 Debug APK 构建,但仍缺少真机安装和功能回归验证
+4. Android / OTA 旧链路已拆出当前仓库,相关验证和发布不再属于 StoryForge 主线范围
diff --git a/docs/LAN_E2E_GUIDE_2026-03-18.md b/docs/LAN_E2E_GUIDE_2026-03-18.md
index 3a847ea..162bd10 100644
--- a/docs/LAN_E2E_GUIDE_2026-03-18.md
+++ b/docs/LAN_E2E_GUIDE_2026-03-18.md
@@ -295,22 +295,18 @@ npm run capture -- \
- `http://127.0.0.1:8081/healthz`
- `http://127.0.0.1:5670/healthz`
-## 11. Android 本地构建
+## 11. Android 说明
-如果你要在本机重新打 Android 包:
+`android-app/` 已确认属于独立 `AI Glasses` 工程的叠加目录,现已从当前 StoryForge 主仓库拆出。
-```bash
-cd /Users/kris/code/StoryForge-gitea/android-app
-./gradlew :app:assembleDebug
-```
+当前联调范围只包含:
-当前已验证结果:
+- `collector-service`
+- `n8n`
+- `web/storyforge-web-v4`
+- `scripts/douyin-browser-capture`
-- `:app:compileDebugKotlin` 通过
-- `:app:assembleDebug` 通过
-- APK 输出路径:
- - `/Users/kris/code/StoryForge-gitea/android-app/app/build/outputs/apk/debug/app-debug.apk`
+如果后续需要维护 Android / OTA 链路,请转到独立仓库:
-补充说明:
-
-- 工作区根目录的 `.gitignore` 里保留了通用 `data/` 忽略规则,但已对 Android 源码目录 `android-app/app/src/main/java/com/aiglasses/app/data/` 做了白名单放行,避免误伤客户端代码
+- Gitea:`https://git.hyzq.site/krisolo/ai-glasses`
+- 本机工作区:`/Users/kris/code/AI-glasses`
diff --git a/docs/MVP_STATUS_2026-03-18.md b/docs/MVP_STATUS_2026-03-18.md
index dca0446..1969229 100644
--- a/docs/MVP_STATUS_2026-03-18.md
+++ b/docs/MVP_STATUS_2026-03-18.md
@@ -10,8 +10,6 @@
- `user -> project -> assistant / knowledge base / job / content source` 数据模型
- 文本 / 视频链接 / 上传视频 三类分析任务创建
- 内容源账号同步任务创建与子任务派发
-- Android Explore 页已补上内容源账号同步入口
-- Android `com.aiglasses.app.data` 数据层已补回,`compileDebugKotlin` 与 `assembleDebug` 已通过
- `n8n` 工作流导入、激活与触发接口
- 本地下载器调用
- 本地 `ffmpeg` / `whisper` 风格入口封装
@@ -46,7 +44,6 @@
- `douyin` 对标关系:`dyrel_c8df266341e74237b99c880eb4b572d8`
- `huobao-upstream` 隔离 smoke 剧本:`drama_id=11` (`http://127.0.0.1:5681`)
- `huobao-upstream` 隔离 smoke 启动脚本:`/Users/kris/code/huobao-drama-upstream/scripts/run_storyforge_smoke.sh`
-- Android Debug APK:`/Users/kris/code/StoryForge-gitea/android-app/app/build/outputs/apk/debug/app-debug.apk`
- `douyin` 浏览器采集最小 smoke:`/tmp/storyforge-douyin-capture-smoke/2026-03-20T06-49-37.705Z-storyforge_test_001`
- `douyin` 控制台 smoke:`/Users/kris/code/StoryForge-gitea/output/playwright/douyin/control-panel/run-mmyzplxp-cw0o7q/2026-03-20T14-24-13.174Z-storyforge_test_001`
- `douyin` 控制台提前继续回归 smoke:`/Users/kris/code/StoryForge-gitea/output/playwright/douyin/control-panel/run-mmyzshsp-c6vdhi/2026-03-20T14-26-27.792Z-storyforge_test_001`
@@ -59,7 +56,7 @@
- `douyin` 控制台点击流已可用,但它仍然依赖本机可打开 Chromium 的环境,不适合放进纯 Docker 容器内部跑 GUI
- `huobao-upstream` 已能全量编译;并且旧改版隔离实例也已重放确认,当前 fresh 生成被外部图片/视频凭证统一返回 `403 invalid user`
- `huobao-upstream` 已新增 `HUOBAO_TEXT_* / HUOBAO_IMAGE_* / HUOBAO_VIDEO_*` 运行时覆盖能力,后续补新 key 可直接接管数据库配置
-- Android Debug 包已可本地构建,但尚未完成真机安装验证
+- Android / OTA 链路已拆回 `AI Glasses` 独立仓库,不再纳入当前 StoryForge MVP 范围
## 下一步优先级
diff --git a/docs/STORYFORGE_REPO_BOUNDARY_2026-03-26.md b/docs/STORYFORGE_REPO_BOUNDARY_2026-03-26.md
index b93f0b1..b861097 100644
--- a/docs/STORYFORGE_REPO_BOUNDARY_2026-03-26.md
+++ b/docs/STORYFORGE_REPO_BOUNDARY_2026-03-26.md
@@ -6,7 +6,8 @@
- `StoryForge` 与 `AI Glasses` 是两个独立项目,分别独立维护。
- 当前仓库只负责 `StoryForge` 的产品、运行时、联调、部署与发布。
-- 后续在本仓库中看到的 `AI Glasses` 命名残留,应优先视为历史迁移残留或暂未完成的命名收口,不应直接推导为“需要删除 AI Glasses 项目代码”。
+- `AI Glasses` 当前独立维护仓库为 [krisolo/ai-glasses](https://git.hyzq.site/krisolo/ai-glasses)。
+- 当前仓库已经移除混入的 `android-app/` 目录;历史提交中的 Android / `com.aiglasses.*` 痕迹只作为拆分审计证据保留。
## 当前仓库内属于 StoryForge 的主维护范围
@@ -14,21 +15,15 @@
- `web/storyforge-web-v4/`:StoryForge Web 工作台和前端壳。
- `scripts/douyin-browser-capture/`:抖音浏览器辅助采集与工作台控制台。
- `n8n/`:StoryForge 编排工作流导出与说明。
-- `android-app/`:当前 StoryForge Android 客户端入口。
- `deploy/`:StoryForge 部署模板与网关配置。
- `docs/`:StoryForge 审计、联调、实施与产品逻辑文档。
- `docker-compose.yml`、`.env.example`、`scripts/start_business.sh`、`scripts/status_business.sh`、`scripts/smoke_business.sh`:当前 StoryForge 运行与联调基线。
-## 需要特别注意的命名残留
+## 已拆出的独立项目边界
-以下内容说明 Android 客户端曾沿用旧命名空间,但当前业务入口已经是 StoryForge:
-
-- `android-app/app/src/main/java/com/aiglasses/app/`:Android 包名仍是 `com.aiglasses.app`。
-- `android-app/app/src/main/java/com/aiglasses/app/MainActivity.kt`:入口已经直接加载 `StoryForgeScreen` 与 `StoryForgeViewModel`。
-- `android-app/app/src/main/res/values/themes.xml`:主题名仍为 `Theme.AIGlasses`。
-- `android-app/app/build.gradle.kts`:构建命名空间仍与 `com.aiglasses.*` 保持一致。
-
-这些文件目前应被视为 StoryForge Android 客户端的迁移残留,不属于“删除 AI Glasses 项目代码”的操作范围。若未来要统一命名,应作为独立重构任务推进,而不是在日常功能开发中顺手清除。
+- `AI Glasses` 的 Android / BLE / Baidu / AAR / OTA 代码不再属于当前 StoryForge 主仓库边界。
+- 与其相关的当前维护仓库、分支、发布应在 `krisolo/ai-glasses` 中进行。
+- 若后续需要回看叠加来源,可参考 Git 历史中的 `acb1103`、`ac6a8a8`、`7070c3a`、`fe07a5f` 等提交,以及 [StoryForge / AI Glasses 拆分评估方案](./STORYFORGE_SPLIT_ASSESSMENT_2026-03-26.md)。
## 提交与同步边界
@@ -43,5 +38,5 @@
- 后端与部署安全收口:去掉默认超级管理员口令依赖,强化 orchestrator secret 校验,新增 `readyz`,修复 `huobao/cutvideo` 超时串线。
- n8n 工作流收口:内部回调地址与 secret 改为环境变量注入。
- Web 稳定性与结构收口:修账号切换竞态,收紧会话存储,引入平台能力 gate,并拆出首批运行时模块。
-- Android 安全收口:会话加密存储、明文流量白名单、敏感输入遮罩、日志级别收紧。
+- 仓库边界收口:将混入的 `android-app/` 从 StoryForge 主仓库移出,并确认 `AI Glasses` 继续在独立 Gitea 仓库维护。
- 基线验证:新增 `scripts/check_repo_baseline.sh` 作为统一回归入口。
diff --git a/docs/STORYFORGE_SPLIT_ASSESSMENT_2026-03-26.md b/docs/STORYFORGE_SPLIT_ASSESSMENT_2026-03-26.md
new file mode 100644
index 0000000..298a280
--- /dev/null
+++ b/docs/STORYFORGE_SPLIT_ASSESSMENT_2026-03-26.md
@@ -0,0 +1,252 @@
+# StoryForge / AI Glasses 拆分评估方案
+
+执行状态(2026-03-26):
+
+- 已确认独立仓库存在:`https://git.hyzq.site/krisolo/ai-glasses`
+- 已确认本机独立工作区存在:`/Users/kris/code/AI-glasses`
+- 当前评估方案已进入执行阶段:`StoryForge-gitea` 将移除混入的 `android-app/`
+
+## 1. 结论摘要
+
+当前仓库的问题更像是“项目导入时发生了目录叠加”,而不是后续开发过程中出现了随机数据错乱。
+
+明确证据如下:
+
+- Gitea 现有历史只有一个根提交:`acb1103`,日期为 `2026-03-14`。
+- 这个根提交从一开始就包含完整的 `android-app/` 子树。
+- 该 `android-app/` 子树内同时存在:
+ - `StoryForge` 相关界面与接口代码;
+ - 明显属于 `AI Glasses` 的包名、BLE、Baidu 实时能力、硬件依赖和 AAR。
+
+因此,当前更合理的判断是:
+
+- `StoryForge` 与 `AI Glasses` 原本是两个独立项目;
+- 在 `StoryForge-gitea` 建库或导入时,把一个带 `AI Glasses` Android 子项目的目录整体叠加进来了;
+- 后续又在这个混合目录上继续写入了一部分 `StoryForge` Android 代码,导致边界越来越模糊。
+
+## 2. 现状诊断
+
+### 2.1 明显属于 StoryForge 的主干目录
+
+这些目录整体上是当前 StoryForge 的核心交付面:
+
+- `collector-service/`
+- `web/storyforge-web-v4/`
+- `scripts/douyin-browser-capture/`
+- `n8n/`
+- `deploy/`
+- `docs/`
+- `Common/`
+- `docker-compose.yml`
+- `.env.example`
+
+### 2.2 明显带有 AI Glasses 叠加痕迹的区域
+
+`android-app/` 是本仓库最明显的混合区,内部包含三类内容:
+
+1. 明显偏 AI Glasses / 硬件链路的内容:
+
+- `android-app/app/src/main/java/com/aiglasses/app/ble/BleManager.kt`
+- `android-app/app/src/main/java/com/aiglasses/app/software/BaiduConversationAgent.kt`
+- `android-app/app/src/main/java/com/aiglasses/app/software/BaiduRealtimeWsClient.kt`
+- `android-app/app/src/main/java/com/aiglasses/app/software/BaiduVisualUploader.kt`
+- `android-app/app/src/main/java/com/aiglasses/app/software/SoftwareConversationController.kt`
+- `android-app/app/src/main/java/com/aiglasses/app/ui/MainViewModel.kt`
+- `android-app/app/libs/lib_agent-1.0.1.4.aar`
+- `android-app/app/libs/brtc-3.5.0.1a.aar`
+
+2. 明显是 StoryForge 业务,但写在旧命名空间里的内容:
+
+- `android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeApiService.kt`
+- `android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeModels.kt`
+- `android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeRepository.kt`
+- `android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeScreen.kt`
+- `android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeSessionStore.kt`
+- `android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeViewModel.kt`
+- `android-app/app/src/main/java/com/aiglasses/app/MainActivity.kt`
+
+3. 明显属于旧项目命名残留的工程设置:
+
+- `android-app/settings.gradle.kts` 中的 `rootProject.name = "AIGlassesApp"`
+- `android-app/app/build.gradle.kts` 中的 `namespace = "com.aiglasses.app"`
+- `android-app/app/src/main/res/values/themes.xml` 中的 `Theme.AIGlasses`
+- `android-app/app/src/main/AndroidManifest.xml` 当前仍引用 `Theme.AIGlasses`
+
+### 2.3 Git 历史上的关键时间点
+
+- `2026-03-14` `acb1103`
+ - Gitea 根提交。
+ - 从第一天就已带入 `android-app/` 和 `com.aiglasses.*`。
+- `2026-03-20 14:10` `ac6a8a8`
+ - 开始明显向 StoryForge Android UI / 交互继续推进。
+- `2026-03-20 14:17` `7070c3a`
+ - 提交信息直接是 `restore android build path`,说明 Android 构建链被重新激活。
+- `2026-03-22` `fe07a5f`
+ - 明确进入 `storyforge mobile v4 shell` 阶段。
+
+结论是:Gitea 历史里没有“完全纯净、完全不含 Android 叠加痕迹”的版本,但存在“尚未明显进入 APK 推进阶段”的较早切点。
+
+## 3. 目标定义
+
+基于当前产品节奏,推荐把拆分目标定义成:
+
+- `StoryForge-gitea` 只保留 StoryForge 当前实际在推进的主线:
+ - Web
+ - Backend
+ - n8n orchestration
+ - Douyin browser capture
+ - deploy / docs / ops
+- `AI Glasses` 相关 Android / BLE / Baidu / AAR / OTA 旧链路,移出当前仓库边界。
+- 如果未来要做 StoryForge Mobile,重新在一个干净边界内启动,而不是继续沿用 `com.aiglasses.*` 的混合目录。
+
+## 4. 拆分策略选项
+
+### 方案 A:按目录硬拆,StoryForge 先回到 Web 主线
+
+做法:
+
+- 从当前 StoryForge 仓库中移除整个 `android-app/` 目录。
+- 同步清理 README、docs、脚本中所有 Android/APK 主线描述。
+- 保留 Web、Backend、n8n、browser capture、deploy、docs 作为 StoryForge 正式主干。
+
+优点:
+
+- 边界最清楚,最符合“此前一直在做 Web 版本”的项目认知。
+- 能最快结束当前“两个项目目录叠加”的混乱状态。
+- 后续所有开发决策都会更简单。
+
+缺点:
+
+- 当前 `android-app/storyforge/*` 里写过的一些 StoryForge 业务代码会一起被移出,需要单独存档。
+
+适用判断:
+
+- 如果当前项目目标就是 Web 优先、暂不做 APK,这是最推荐方案。
+
+### 方案 B:保留 StoryForge Android 子集,拆掉 AI Glasses 硬件链
+
+做法:
+
+- 在 `android-app/` 中只保留 `storyforge/*`、`MainActivity.kt`、必要的网络与 OTA 文件;
+- 删除 `ble/`、`software/`、旧 `ui/MainViewModel.kt`、AAR、旧权限与旧命名;
+- 后续再把包名重构到 `com.storyforge.*`。
+
+优点:
+
+- 保留了已写过的 StoryForge 移动端业务界面。
+
+缺点:
+
+- 仍要处理大量命名空间和依赖残留。
+- 会继续占用当前 StoryForge 项目的精力。
+- 和“你之前并没有打算做 APK”的事实不完全一致。
+
+适用判断:
+
+- 只有在你确认近期确实要保留 StoryForge Android 端时才值得做。
+
+### 方案 C:直接回滚到较早基线
+
+候选点:
+
+- `acb1103`:最早基线,但已经带着 Android 叠加目录。
+- `1c539ab`:仍未明显进入 Android 壳推进,但已有少量 Android 接口同步。
+
+优点:
+
+- 操作简单。
+
+缺点:
+
+- 无法真正解决“根提交就已经叠加”的结构问题。
+- 会回退掉后续大量有价值的 Web / backend / deploy 进展。
+
+适用判断:
+
+- 只适合做参考,不适合作为主方案。
+
+## 5. 推荐方案
+
+推荐采用 `方案 A:按目录硬拆,StoryForge 先回到 Web 主线`。
+
+原因:
+
+- 它最符合当前产品事实:你确认之前的实际推进重点一直是 Web,而不是 APK。
+- 它最符合现有目录证据:`android-app/` 是混合最严重的区域,且根提交就已叠加。
+- 它最符合后续治理成本:先把 StoryForge 主仓库边界收干净,后面要不要重建移动端,再单独决定。
+
+## 6. 实施步骤
+
+### 第 0 阶段:安全快照
+
+- 基于当前 Gitea 状态打一个拆分前快照分支。
+- 导出 `android-app/` 的完整目录快照,作为独立归档或后续 AI Glasses 仓库恢复源。
+- 记录关键参考提交:
+ - `acb1103`
+ - `1c539ab`
+ - `ac6a8a8`
+ - `7070c3a`
+ - `fe07a5f`
+
+### 第 1 阶段:StoryForge 主仓库边界清理
+
+- 从 StoryForge 仓库中移除整个 `android-app/`。
+- 清理以下入口中的 Android/APK 主线描述:
+ - `README.md`
+ - `docs/AUDIT_2026-03-18.md`
+ - `docs/MVP_STATUS_2026-03-18.md`
+ - `docs/LAN_E2E_GUIDE_2026-03-18.md`
+ - 其他出现 `compileDebugKotlin`、`assembleDebug`、`APK`、`com.aiglasses` 的说明文档
+- 调整基线检查脚本,不再把 Android 编译当成 StoryForge 主仓库必检项。
+
+### 第 2 阶段:AI Glasses 资产外置
+
+- 将 `android-app/` 单独落到 AI Glasses 仓库或归档仓库。
+- 在那个仓库中保留 `com.aiglasses.*`、BLE、Baidu、AAR、OTA 等原始工程语义。
+
+### 第 3 阶段:StoryForge 后续演进
+
+- 当前仓库继续只推进:
+ - `collector-service/`
+ - `web/storyforge-web-v4/`
+ - `scripts/douyin-browser-capture/`
+ - `n8n/`
+ - `deploy/`
+ - `docs/`
+- 若未来确实需要 StoryForge Mobile,再开一个全新、干净的移动端工程,不复用当前混合 Android 目录。
+
+## 7. 风险与控制
+
+### 风险 1:误删仍有参考价值的 StoryForge Android 代码
+
+控制:
+
+- 在删除前先对 `android-app/` 做完整快照导出。
+- 如果担心未来要参考 `storyforge/*` 子目录,可以单独保留一份只读归档。
+
+### 风险 2:文档和状态记录出现历史断层
+
+控制:
+
+- 不改历史提交。
+- 仅在当前分支上明确标记“自本次拆分起,StoryForge 主仓库不再承载 Android 主线”。
+
+### 风险 3:脚本和检查项仍假设存在 Android
+
+控制:
+
+- 统一核对:
+ - `README.md`
+ - `scripts/check_repo_baseline.sh`
+ - 任何引用 `./gradlew` 的脚本或文档
+
+## 8. 最终建议
+
+不要先回滚历史,也不要先做大规模重命名。
+
+更稳妥的动作顺序应当是:
+
+1. 先承认当前问题是“目录叠加”而不是“功能开发方向变化”。
+2. 先把 `android-app/` 整体从 StoryForge 主仓库边界中拆出去。
+3. 把 StoryForge 主仓库重新收敛成 Web / Backend / Orchestration 主线。
+4. 最后再决定是否需要单独保留一个 StoryForge Mobile 项目。
diff --git a/scripts/check_repo_baseline.sh b/scripts/check_repo_baseline.sh
index 54510f2..37f231c 100755
--- a/scripts/check_repo_baseline.sh
+++ b/scripts/check_repo_baseline.sh
@@ -16,13 +16,13 @@ need_cmd node
cd "$ROOT"
-echo "[1/5] compile collector-service"
+echo "[1/4] compile collector-service"
python3 -m compileall collector-service/app >/dev/null
-echo "[2/5] validate docker compose"
+echo "[2/4] validate docker compose"
docker compose config >/dev/null
-echo "[3/5] validate n8n workflows"
+echo "[3/4] validate n8n workflows"
python3 - <<'PY'
import json
import pathlib
@@ -33,24 +33,10 @@ for path in sorted(pathlib.Path("n8n/workflows").glob("*.json")):
print(f"workflow ok: {path.name}")
PY
-echo "[4/5] validate web scripts"
+echo "[4/4] validate web scripts"
for file in web/storyforge-web-v4/assets/app.js web/storyforge-web-v4/assets/storyforge-*.js; do
node --check "$file"
done
node --check scripts/douyin-browser-capture/control_panel.mjs
-if [ "${STORYFORGE_SKIP_ANDROID:-0}" = "1" ]; then
- echo "[5/5] skip android compile (STORYFORGE_SKIP_ANDROID=1)"
-else
- if command -v java >/dev/null 2>&1; then
- echo "[5/5] compile android debug kotlin"
- (
- cd android-app
- ./gradlew :app:compileDebugKotlin >/dev/null
- )
- else
- echo "[5/5] skip android compile (java not installed)"
- fi
-fi
-
echo "baseline checks passed"