refactor: split android overlay out of storyforge
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -25,8 +25,6 @@ node_modules/
|
|||||||
|
|
||||||
# Runtime data and artifacts
|
# Runtime data and artifacts
|
||||||
data/
|
data/
|
||||||
!android-app/app/src/main/java/com/aiglasses/app/data/
|
|
||||||
!android-app/app/src/main/java/com/aiglasses/app/data/**
|
|
||||||
output/
|
output/
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
|||||||
12
README.md
12
README.md
@@ -3,10 +3,11 @@
|
|||||||
StoryForge 现在拆成独立项目目录,和 `AI-glasses` 分开维护。
|
StoryForge 现在拆成独立项目目录,和 `AI-glasses` 分开维护。
|
||||||
|
|
||||||
仓库边界和维护约束见:[StoryForge 仓库边界说明](./docs/STORYFORGE_REPO_BOUNDARY_2026-03-26.md)。
|
仓库边界和维护约束见:[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、任务、内容分析和对外能力接入
|
- `collector-service/`:FastAPI 后端,负责用户体系、项目、Agent、任务、内容分析和对外能力接入
|
||||||
- `n8n/`:工作流导出文件,作为流程编排中枢
|
- `n8n/`:工作流导出文件,作为流程编排中枢
|
||||||
- `docker-compose.yml`:本地 `collector + n8n + cli-proxy-api` 编排
|
- `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)
|
- [新媒体运营平台 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 UI 原型](./output/ui/storyforge-web-v4-html-prototype-2026-03-22/README.md)
|
||||||
- [Web V4 前端骨架](./web/storyforge-web-v4/README.md)(国内平台 UI 承载,当前工作台仅 `douyin` 完整实现)
|
- [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)
|
- [Mobile V4 UI 原型](./output/ui/storyforge-mobile-v4-html-prototype-2026-03-22/README.md)(仅 UI 原型,不代表当前仓库承载 Android 工程)
|
||||||
|
|
||||||
## Android
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /Users/kris/code/StoryForge-gitea/android-app
|
|
||||||
./gradlew assembleDebug
|
|
||||||
```
|
|
||||||
|
|
||||||
## Douyin Browser Capture
|
## Douyin Browser Capture
|
||||||
|
|
||||||
|
|||||||
@@ -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`
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
Binary file not shown.
Binary file not shown.
2
android-app/app/proguard-rules.pro
vendored
2
android-app/app/proguard-rules.pro
vendored
@@ -1,2 +0,0 @@
|
|||||||
# Keep default for demo stage.
|
|
||||||
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
|
||||||
|
|
||||||
<application
|
|
||||||
android:allowBackup="true"
|
|
||||||
android:icon="@android:drawable/sym_def_app_icon"
|
|
||||||
android:label="@string/app_name"
|
|
||||||
android:networkSecurityConfig="@xml/network_security_config"
|
|
||||||
android:roundIcon="@android:drawable/sym_def_app_icon"
|
|
||||||
android:supportsRtl="true"
|
|
||||||
android:theme="@style/Theme.AIGlasses">
|
|
||||||
<activity
|
|
||||||
android:name=".MainActivity"
|
|
||||||
android:exported="true">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.MAIN" />
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
|
||||||
</intent-filter>
|
|
||||||
</activity>
|
|
||||||
|
|
||||||
<provider
|
|
||||||
android:name="androidx.core.content.FileProvider"
|
|
||||||
android:authorities="${applicationId}.fileprovider"
|
|
||||||
android:exported="false"
|
|
||||||
android:grantUriPermissions="true">
|
|
||||||
<meta-data
|
|
||||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
|
||||||
android:resource="@xml/file_paths" />
|
|
||||||
</provider>
|
|
||||||
</application>
|
|
||||||
|
|
||||||
</manifest>
|
|
||||||
@@ -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) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<HichipsFrame> {
|
|
||||||
if (chunk.isEmpty()) return emptyList()
|
|
||||||
buffer += chunk
|
|
||||||
val out = mutableListOf<HichipsFrame>()
|
|
||||||
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<BleLinkState> = _state.asStateFlow()
|
|
||||||
|
|
||||||
private val _events = MutableSharedFlow<GlassesBleEvent>(extraBufferCapacity = 256)
|
|
||||||
val events: SharedFlow<GlassesBleEvent> = _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<BluetoothGattCharacteristic>()
|
|
||||||
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"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 <reified T : Any> 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<T>()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<HealthzData>
|
|
||||||
|
|
||||||
@POST("/api/v1/devices/bind-confirm")
|
|
||||||
suspend fun bindConfirm(
|
|
||||||
@Body request: BindConfirmRequest
|
|
||||||
): ApiEnvelope<BindConfirmData>
|
|
||||||
|
|
||||||
@POST("/api/v1/ai/sessions")
|
|
||||||
suspend fun createSession(
|
|
||||||
@Header("Idempotency-Key") idempotencyKey: String?,
|
|
||||||
@Body request: CreateSessionRequest
|
|
||||||
): ApiEnvelope<SessionData>
|
|
||||||
|
|
||||||
@POST("/api/v1/ai/sessions/{sessionId}/stop")
|
|
||||||
suspend fun stopSession(
|
|
||||||
@Path("sessionId") sessionId: String,
|
|
||||||
@Body request: StopSessionRequest
|
|
||||||
): ApiEnvelope<StopSessionData>
|
|
||||||
|
|
||||||
@POST("/api/v1/ai/sessions/{sessionId}/heartbeat")
|
|
||||||
suspend fun heartbeat(
|
|
||||||
@Path("sessionId") sessionId: String,
|
|
||||||
@Body request: HeartbeatRequest
|
|
||||||
): ApiEnvelope<HeartbeatData>
|
|
||||||
|
|
||||||
@GET("/api/v1/devices/{deviceId}/status")
|
|
||||||
suspend fun getDeviceStatus(
|
|
||||||
@Path("deviceId") deviceId: String
|
|
||||||
): ApiEnvelope<DeviceStatusData>
|
|
||||||
|
|
||||||
@POST("/api/v1/events")
|
|
||||||
suspend fun postEvent(
|
|
||||||
@Body request: ClientEventRequest
|
|
||||||
): ApiEnvelope<EventSavedData>
|
|
||||||
|
|
||||||
@POST("/api/v1/events/batch")
|
|
||||||
suspend fun postEventsBatch(
|
|
||||||
@Body request: ClientEventBatchRequest
|
|
||||||
): ApiEnvelope<EventsBatchSavedData>
|
|
||||||
|
|
||||||
@POST("/api/v1/ai/sessions/{sessionId}/messages")
|
|
||||||
suspend fun sendMessage(
|
|
||||||
@Path("sessionId") sessionId: String,
|
|
||||||
@Body request: SessionMessageRequest
|
|
||||||
): ApiEnvelope<ProviderActionData>
|
|
||||||
|
|
||||||
@POST("/api/v1/ai/sessions/{sessionId}/scene-role")
|
|
||||||
suspend fun switchRole(
|
|
||||||
@Path("sessionId") sessionId: String,
|
|
||||||
@Body request: SwitchRoleRequest
|
|
||||||
): ApiEnvelope<ProviderActionData>
|
|
||||||
|
|
||||||
@POST("/api/v1/ai/sessions/{sessionId}/interrupt")
|
|
||||||
suspend fun interruptSession(
|
|
||||||
@Path("sessionId") sessionId: String,
|
|
||||||
@Body request: SessionInterruptRequest
|
|
||||||
): ApiEnvelope<ProviderActionData>
|
|
||||||
|
|
||||||
@GET("/api/v1/baidu/activation/query")
|
|
||||||
suspend fun activationQuery(
|
|
||||||
@Query("deviceId") deviceId: String,
|
|
||||||
@Query("appId") appId: String? = null
|
|
||||||
): ApiEnvelope<ActivationQueryData>
|
|
||||||
|
|
||||||
@POST("/api/v1/licenses/reload")
|
|
||||||
suspend fun reloadLicenses(): ApiEnvelope<ReloadLicensesData>
|
|
||||||
|
|
||||||
@GET("/api/v1/admin/overview")
|
|
||||||
suspend fun adminOverview(): ApiEnvelope<AdminOverviewData>
|
|
||||||
|
|
||||||
@GET("/api/v1/app/update/latest")
|
|
||||||
suspend fun appUpdateLatest(
|
|
||||||
@Query("platform") platform: String = "android",
|
|
||||||
@Query("channel") channel: String = "stable",
|
|
||||||
@Query("currentVersionCode") currentVersionCode: Int
|
|
||||||
): ApiEnvelope<AppUpdateLatestData>
|
|
||||||
|
|
||||||
@GET("/v2/douyin/accounts")
|
|
||||||
suspend fun listDouyinAccounts(): ApiEnvelope<List<DouyinAccountSummary>>
|
|
||||||
|
|
||||||
@POST("/v2/douyin/accounts/sync")
|
|
||||||
suspend fun syncDouyinAccount(
|
|
||||||
@Body request: DouyinAccountSyncRequest
|
|
||||||
): ApiEnvelope<DouyinAccountWorkspace>
|
|
||||||
|
|
||||||
@GET("/v2/douyin/accounts/{accountId}")
|
|
||||||
suspend fun getDouyinAccount(
|
|
||||||
@Path("accountId") accountId: String
|
|
||||||
): ApiEnvelope<DouyinAccountWorkspace>
|
|
||||||
|
|
||||||
@GET("/v2/douyin/accounts/{accountId}/workspace")
|
|
||||||
suspend fun getDouyinWorkspace(
|
|
||||||
@Path("accountId") accountId: String
|
|
||||||
): ApiEnvelope<DouyinAccountWorkspace>
|
|
||||||
|
|
||||||
@GET("/v2/douyin/accounts/{accountId}/snapshots")
|
|
||||||
suspend fun listDouyinSnapshots(
|
|
||||||
@Path("accountId") accountId: String
|
|
||||||
): ApiEnvelope<List<DouyinSnapshotSummary>>
|
|
||||||
|
|
||||||
@GET("/v2/douyin/accounts/{accountId}/snapshots/{snapshotId}")
|
|
||||||
suspend fun getDouyinSnapshot(
|
|
||||||
@Path("accountId") accountId: String,
|
|
||||||
@Path("snapshotId") snapshotId: String
|
|
||||||
): ApiEnvelope<DouyinSnapshotDetail>
|
|
||||||
|
|
||||||
@GET("/v2/douyin/accounts/{accountId}/creator-fields")
|
|
||||||
suspend fun getDouyinCreatorFields(
|
|
||||||
@Path("accountId") accountId: String
|
|
||||||
): ApiEnvelope<DouyinSnapshotDetail>
|
|
||||||
|
|
||||||
@POST("/v2/douyin/accounts/{accountId}/analysis")
|
|
||||||
suspend fun analyzeDouyinAccount(
|
|
||||||
@Path("accountId") accountId: String,
|
|
||||||
@Body request: DouyinAccountAnalysisRequest
|
|
||||||
): ApiEnvelope<DouyinAnalysisResult>
|
|
||||||
|
|
||||||
@GET("/v2/douyin/accounts/{accountId}/analysis-reports")
|
|
||||||
suspend fun listDouyinAnalysisReports(
|
|
||||||
@Path("accountId") accountId: String
|
|
||||||
): ApiEnvelope<List<DouyinAnalysisReport>>
|
|
||||||
|
|
||||||
@POST("/v2/douyin/similar-searches")
|
|
||||||
suspend fun createDouyinSimilarSearch(
|
|
||||||
@Body request: DouyinSimilarSearchRequest
|
|
||||||
): ApiEnvelope<DouyinSimilaritySearchResult>
|
|
||||||
|
|
||||||
@GET("/v2/douyin/similar-searches/{searchId}")
|
|
||||||
suspend fun getDouyinSimilarSearch(
|
|
||||||
@Path("searchId") searchId: String
|
|
||||||
): ApiEnvelope<DouyinSimilaritySearchDetail>
|
|
||||||
|
|
||||||
@GET("/v2/douyin/accounts/{accountId}/benchmark-links")
|
|
||||||
suspend fun listDouyinBenchmarkLinks(
|
|
||||||
@Path("accountId") accountId: String
|
|
||||||
): ApiEnvelope<List<DouyinLinkedAccount>>
|
|
||||||
|
|
||||||
@POST("/v2/douyin/accounts/{accountId}/benchmark-links")
|
|
||||||
suspend fun createDouyinBenchmarkLinks(
|
|
||||||
@Path("accountId") accountId: String,
|
|
||||||
@Body request: DouyinBenchmarkLinkRequest
|
|
||||||
): ApiEnvelope<DouyinBenchmarkLinkResult>
|
|
||||||
}
|
|
||||||
@@ -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<String, String> = 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<ClientEventRequest>): 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<String, String> = 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<DouyinAccountSummary> {
|
|
||||||
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<DouyinSnapshotSummary> {
|
|
||||||
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<DouyinAnalysisReport> {
|
|
||||||
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<DouyinLinkedAccount> {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<T>(
|
|
||||||
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<String, String> = emptyMap(),
|
|
||||||
val ts: Long? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class ClientEventBatchRequest(
|
|
||||||
val events: List<ClientEventRequest> = 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<String, String> = 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<String, String> = emptyMap()
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class SessionInterruptRequest(
|
|
||||||
val interrupt: Boolean = true,
|
|
||||||
val extra: Map<String, String> = 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<String> = emptyList(),
|
|
||||||
@SerialName("manual_profile_payload")
|
|
||||||
val manualProfilePayload: JsonObject? = null,
|
|
||||||
@SerialName("manual_creator_pages")
|
|
||||||
val manualCreatorPages: List<DouyinManualPageCaptureRequest> = emptyList(),
|
|
||||||
@SerialName("manual_work_payloads")
|
|
||||||
val manualWorkPayloads: List<JsonObject> = 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<String> = 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<String> = 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<DouyinVideoSummaryItem> = 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<String> = emptyList(),
|
|
||||||
val keywords: List<String> = 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<String> = emptyList(),
|
|
||||||
@SerialName("linked_account_ids")
|
|
||||||
val linkedAccountIds: List<String> = emptyList(),
|
|
||||||
@SerialName("created_at")
|
|
||||||
val createdAt: String = "",
|
|
||||||
val suggestions: List<DouyinAnalysisSuggestion> = emptyList()
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class DouyinSimilaritySearchPreview(
|
|
||||||
val id: String = "",
|
|
||||||
val keywords: List<String> = 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<String> = 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<DouyinLinkedAccount> = emptyList(),
|
|
||||||
@SerialName("recent_reports")
|
|
||||||
val recentReports: List<DouyinAnalysisReport> = emptyList(),
|
|
||||||
@SerialName("recent_similarity_searches")
|
|
||||||
val recentSimilaritySearches: List<DouyinSimilaritySearchPreview> = emptyList(),
|
|
||||||
@SerialName("available_model_profiles")
|
|
||||||
val availableModelProfiles: List<DouyinModelProfileSummary> = emptyList(),
|
|
||||||
@SerialName("sync_errors")
|
|
||||||
val syncErrors: List<String> = emptyList()
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class DouyinAccountAnalysisRequest(
|
|
||||||
@SerialName("model_profile_ids")
|
|
||||||
val modelProfileIds: List<String> = emptyList(),
|
|
||||||
@SerialName("linked_account_ids")
|
|
||||||
val linkedAccountIds: List<String> = 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<DouyinAnalysisSuggestion> = 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<String> = 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<DouyinSimilarCandidate> = 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<String> = emptyList(),
|
|
||||||
val context: JsonElement = JsonObject(emptyMap()),
|
|
||||||
@SerialName("created_at")
|
|
||||||
val createdAt: String = "",
|
|
||||||
val candidates: List<DouyinSimilarCandidate> = emptyList()
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class DouyinBenchmarkLinkRequest(
|
|
||||||
@SerialName("target_account_ids")
|
|
||||||
val targetAccountIds: List<String> = emptyList(),
|
|
||||||
@SerialName("target_profile_urls")
|
|
||||||
val targetProfileUrls: List<String> = 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<String> = emptyList(),
|
|
||||||
val links: List<DouyinLinkedAccount> = 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<DouyinSnapshotField> = emptyList()
|
|
||||||
)
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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<String, Boolean>
|
|
||||||
|
|
||||||
@GET("v2/me")
|
|
||||||
suspend fun me(): AccountDto
|
|
||||||
|
|
||||||
@GET("v2/me/dashboard")
|
|
||||||
suspend fun dashboard(): DashboardDto
|
|
||||||
|
|
||||||
@GET("v2/model-profiles")
|
|
||||||
suspend fun modelProfiles(): List<ModelProfileDto>
|
|
||||||
|
|
||||||
@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<KnowledgeBaseDto>
|
|
||||||
|
|
||||||
@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<KnowledgeDocumentDto>
|
|
||||||
|
|
||||||
@GET("v2/explore/jobs")
|
|
||||||
suspend fun jobs(): List<JobDto>
|
|
||||||
|
|
||||||
@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<AssistantDto>
|
|
||||||
|
|
||||||
@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<AccountDto>
|
|
||||||
|
|
||||||
@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
|
|
||||||
}
|
|
||||||
@@ -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<String> = 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<String> = 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<String>? = 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<String> = emptyList()
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class GenerateCopyResponseDto(
|
|
||||||
val assistant_id: String,
|
|
||||||
val knowledge_base_ids: List<String>,
|
|
||||||
val content: String,
|
|
||||||
val prompt_excerpt: String,
|
|
||||||
val used_documents: List<KnowledgeDocumentDto> = emptyList()
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class DashboardDto(
|
|
||||||
val account: AccountDto,
|
|
||||||
val projects: List<ProjectDto> = emptyList(),
|
|
||||||
val knowledge_bases: List<KnowledgeBaseDto> = emptyList(),
|
|
||||||
val assistants: List<AssistantDto> = emptyList(),
|
|
||||||
val recent_jobs: List<JobDto> = emptyList(),
|
|
||||||
val model_profiles: List<ModelProfileDto> = 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
|
|
||||||
)
|
|
||||||
@@ -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<ModelProfileDto> = 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<KnowledgeDocumentDto> =
|
|
||||||
api().knowledgeDocuments(knowledgeBaseId)
|
|
||||||
|
|
||||||
suspend fun jobs(): List<JobDto> = 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<AccountDto> = 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<StoryForgeApiService>().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}$""")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<KnowledgeBaseDto> = emptyList(),
|
|
||||||
val assistants: List<AssistantDto> = emptyList(),
|
|
||||||
val modelProfiles: List<ModelProfileDto> = emptyList(),
|
|
||||||
val jobs: List<JobDto> = emptyList(),
|
|
||||||
val documents: List<KnowledgeDocumentDto> = emptyList(),
|
|
||||||
val selectedKnowledgeBaseId: String = "",
|
|
||||||
val selectedAssistantId: String = "",
|
|
||||||
val selectedAssistantKnowledgeBaseIds: Set<String> = 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<AccountDto> = 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<String> = 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<StoryForgeUiState> = _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 <T> 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 ?: "发生未知错误"
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
<resources>
|
|
||||||
<string name="app_name">StoryForge AI</string>
|
|
||||||
</resources>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<style name="Theme.AIGlasses" parent="Theme.Material3.DayNight.NoActionBar" />
|
|
||||||
</resources>
|
|
||||||
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<paths>
|
|
||||||
<cache-path
|
|
||||||
name="ota_cache"
|
|
||||||
path="ota/" />
|
|
||||||
</paths>
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<network-security-config>
|
|
||||||
<base-config cleartextTrafficPermitted="false" />
|
|
||||||
<domain-config cleartextTrafficPermitted="true">
|
|
||||||
<domain includeSubdomains="false">localhost</domain>
|
|
||||||
<domain includeSubdomains="false">127.0.0.1</domain>
|
|
||||||
<domain includeSubdomains="false">10.0.2.2</domain>
|
|
||||||
</domain-config>
|
|
||||||
</network-security-config>
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
|
||||||
android.useAndroidX=true
|
|
||||||
android.nonTransitiveRClass=true
|
|
||||||
kotlin.code.style=official
|
|
||||||
|
|
||||||
BIN
android-app/gradle/wrapper/gradle-wrapper.jar
vendored
BIN
android-app/gradle/wrapper/gradle-wrapper.jar
vendored
Binary file not shown.
@@ -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
|
|
||||||
249
android-app/gradlew
vendored
249
android-app/gradlew
vendored
@@ -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" "$@"
|
|
||||||
92
android-app/gradlew.bat
vendored
92
android-app/gradlew.bat
vendored
@@ -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
|
|
||||||
@@ -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")
|
|
||||||
|
|
||||||
@@ -16,7 +16,6 @@
|
|||||||
- 知识库、智能体、任务管理
|
- 知识库、智能体、任务管理
|
||||||
- 视频链接/上传视频/文本三类入口
|
- 视频链接/上传视频/文本三类入口
|
||||||
- 下载器、ffmpeg、whisper.cpp 风格的本地处理调用
|
- 下载器、ffmpeg、whisper.cpp 风格的本地处理调用
|
||||||
- Android OTA 查询/发布
|
|
||||||
|
|
||||||
### 2. 旧数据集运行链实际承担的功能
|
### 2. 旧数据集运行链实际承担的功能
|
||||||
|
|
||||||
@@ -180,12 +179,11 @@
|
|||||||
- `n8n` 工作流导出文件已纳入仓库
|
- `n8n` 工作流导出文件已纳入仓库
|
||||||
- `collector-service` 的 live 运行态已回归到 `StoryForge-gitea` 自身源码构建,不再依赖旧导入目录的临时 bind mount
|
- `collector-service` 的 live 运行态已回归到 `StoryForge-gitea` 自身源码构建,不再依赖旧导入目录的临时 bind mount
|
||||||
- `collector-service` 现已在 live `8081` 提供 `/v2/douyin/*` 接口,并保留原有 `real-cut / ai-video / content-source-sync` 路由
|
- `collector-service` 现已在 live `8081` 提供 `/v2/douyin/*` 接口,并保留原有 `real-cut / ai-video / content-source-sync` 路由
|
||||||
- Android Explore 页已补上“账号同步”入口,可直接创建内容源账号同步任务,并支持平台、主页链接、账号标识、最大抓取条数、跳过已存在、自动触发分析等参数
|
- 曾混入本仓库的 `android-app/` 已确认来自独立 `AI Glasses` 工程叠加,现已从 StoryForge 主仓库边界中拆出,后续不再作为当前主工作区的一部分维护
|
||||||
- Android 工作区缺失的 `com.aiglasses.app.data` 数据层已从同源代码补回,当前 `./gradlew :app:compileDebugKotlin` 与 `:app:assembleDebug` 均已通过,并产出 `app-debug.apk`
|
|
||||||
|
|
||||||
## 当前主要风险
|
## 当前主要风险
|
||||||
|
|
||||||
1. 小红书账号级内容源还未做真实平台验证
|
1. 小红书账号级内容源还未做真实平台验证
|
||||||
2. `douyin` public 直抓仍受反爬限制,但现在已经有“真实浏览器 + 人工登录 + 自动提取 + 回写现有工作台”的可落地协作链
|
2. `douyin` public 直抓仍受反爬限制,但现在已经有“真实浏览器 + 人工登录 + 自动提取 + 回写现有工作台”的可落地协作链
|
||||||
3. `huobao-drama-upstream` 已完成代码迁移并可编译,但 fresh smoke 受外部图片/视频凭证 `403 invalid user` 阻塞
|
3. `huobao-drama-upstream` 已完成代码迁移并可编译,但 fresh smoke 受外部图片/视频凭证 `403 invalid user` 阻塞
|
||||||
4. Android 端目前已能完成 Debug APK 构建,但仍缺少真机安装和功能回归验证
|
4. Android / OTA 旧链路已拆出当前仓库,相关验证和发布不再属于 StoryForge 主线范围
|
||||||
|
|||||||
@@ -295,22 +295,18 @@ npm run capture -- \
|
|||||||
- `http://127.0.0.1:8081/healthz`
|
- `http://127.0.0.1:8081/healthz`
|
||||||
- `http://127.0.0.1:5670/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` 通过
|
如果后续需要维护 Android / OTA 链路,请转到独立仓库:
|
||||||
- `:app:assembleDebug` 通过
|
|
||||||
- APK 输出路径:
|
|
||||||
- `/Users/kris/code/StoryForge-gitea/android-app/app/build/outputs/apk/debug/app-debug.apk`
|
|
||||||
|
|
||||||
补充说明:
|
- Gitea:`https://git.hyzq.site/krisolo/ai-glasses`
|
||||||
|
- 本机工作区:`/Users/kris/code/AI-glasses`
|
||||||
- 工作区根目录的 `.gitignore` 里保留了通用 `data/` 忽略规则,但已对 Android 源码目录 `android-app/app/src/main/java/com/aiglasses/app/data/` 做了白名单放行,避免误伤客户端代码
|
|
||||||
|
|||||||
@@ -10,8 +10,6 @@
|
|||||||
- `user -> project -> assistant / knowledge base / job / content source` 数据模型
|
- `user -> project -> assistant / knowledge base / job / content source` 数据模型
|
||||||
- 文本 / 视频链接 / 上传视频 三类分析任务创建
|
- 文本 / 视频链接 / 上传视频 三类分析任务创建
|
||||||
- 内容源账号同步任务创建与子任务派发
|
- 内容源账号同步任务创建与子任务派发
|
||||||
- Android Explore 页已补上内容源账号同步入口
|
|
||||||
- Android `com.aiglasses.app.data` 数据层已补回,`compileDebugKotlin` 与 `assembleDebug` 已通过
|
|
||||||
- `n8n` 工作流导入、激活与触发接口
|
- `n8n` 工作流导入、激活与触发接口
|
||||||
- 本地下载器调用
|
- 本地下载器调用
|
||||||
- 本地 `ffmpeg` / `whisper` 风格入口封装
|
- 本地 `ffmpeg` / `whisper` 风格入口封装
|
||||||
@@ -46,7 +44,6 @@
|
|||||||
- `douyin` 对标关系:`dyrel_c8df266341e74237b99c880eb4b572d8`
|
- `douyin` 对标关系:`dyrel_c8df266341e74237b99c880eb4b572d8`
|
||||||
- `huobao-upstream` 隔离 smoke 剧本:`drama_id=11` (`http://127.0.0.1:5681`)
|
- `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`
|
- `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:`/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-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`
|
- `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
|
- `douyin` 控制台点击流已可用,但它仍然依赖本机可打开 Chromium 的环境,不适合放进纯 Docker 容器内部跑 GUI
|
||||||
- `huobao-upstream` 已能全量编译;并且旧改版隔离实例也已重放确认,当前 fresh 生成被外部图片/视频凭证统一返回 `403 invalid user`
|
- `huobao-upstream` 已能全量编译;并且旧改版隔离实例也已重放确认,当前 fresh 生成被外部图片/视频凭证统一返回 `403 invalid user`
|
||||||
- `huobao-upstream` 已新增 `HUOBAO_TEXT_* / HUOBAO_IMAGE_* / HUOBAO_VIDEO_*` 运行时覆盖能力,后续补新 key 可直接接管数据库配置
|
- `huobao-upstream` 已新增 `HUOBAO_TEXT_* / HUOBAO_IMAGE_* / HUOBAO_VIDEO_*` 运行时覆盖能力,后续补新 key 可直接接管数据库配置
|
||||||
- Android Debug 包已可本地构建,但尚未完成真机安装验证
|
- Android / OTA 链路已拆回 `AI Glasses` 独立仓库,不再纳入当前 StoryForge MVP 范围
|
||||||
|
|
||||||
## 下一步优先级
|
## 下一步优先级
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
|
|
||||||
- `StoryForge` 与 `AI Glasses` 是两个独立项目,分别独立维护。
|
- `StoryForge` 与 `AI Glasses` 是两个独立项目,分别独立维护。
|
||||||
- 当前仓库只负责 `StoryForge` 的产品、运行时、联调、部署与发布。
|
- 当前仓库只负责 `StoryForge` 的产品、运行时、联调、部署与发布。
|
||||||
- 后续在本仓库中看到的 `AI Glasses` 命名残留,应优先视为历史迁移残留或暂未完成的命名收口,不应直接推导为“需要删除 AI Glasses 项目代码”。
|
- `AI Glasses` 当前独立维护仓库为 [krisolo/ai-glasses](https://git.hyzq.site/krisolo/ai-glasses)。
|
||||||
|
- 当前仓库已经移除混入的 `android-app/` 目录;历史提交中的 Android / `com.aiglasses.*` 痕迹只作为拆分审计证据保留。
|
||||||
|
|
||||||
## 当前仓库内属于 StoryForge 的主维护范围
|
## 当前仓库内属于 StoryForge 的主维护范围
|
||||||
|
|
||||||
@@ -14,21 +15,15 @@
|
|||||||
- `web/storyforge-web-v4/`:StoryForge Web 工作台和前端壳。
|
- `web/storyforge-web-v4/`:StoryForge Web 工作台和前端壳。
|
||||||
- `scripts/douyin-browser-capture/`:抖音浏览器辅助采集与工作台控制台。
|
- `scripts/douyin-browser-capture/`:抖音浏览器辅助采集与工作台控制台。
|
||||||
- `n8n/`:StoryForge 编排工作流导出与说明。
|
- `n8n/`:StoryForge 编排工作流导出与说明。
|
||||||
- `android-app/`:当前 StoryForge Android 客户端入口。
|
|
||||||
- `deploy/`:StoryForge 部署模板与网关配置。
|
- `deploy/`:StoryForge 部署模板与网关配置。
|
||||||
- `docs/`:StoryForge 审计、联调、实施与产品逻辑文档。
|
- `docs/`:StoryForge 审计、联调、实施与产品逻辑文档。
|
||||||
- `docker-compose.yml`、`.env.example`、`scripts/start_business.sh`、`scripts/status_business.sh`、`scripts/smoke_business.sh`:当前 StoryForge 运行与联调基线。
|
- `docker-compose.yml`、`.env.example`、`scripts/start_business.sh`、`scripts/status_business.sh`、`scripts/smoke_business.sh`:当前 StoryForge 运行与联调基线。
|
||||||
|
|
||||||
## 需要特别注意的命名残留
|
## 已拆出的独立项目边界
|
||||||
|
|
||||||
以下内容说明 Android 客户端曾沿用旧命名空间,但当前业务入口已经是 StoryForge:
|
- `AI Glasses` 的 Android / BLE / Baidu / AAR / OTA 代码不再属于当前 StoryForge 主仓库边界。
|
||||||
|
- 与其相关的当前维护仓库、分支、发布应在 `krisolo/ai-glasses` 中进行。
|
||||||
- `android-app/app/src/main/java/com/aiglasses/app/`:Android 包名仍是 `com.aiglasses.app`。
|
- 若后续需要回看叠加来源,可参考 Git 历史中的 `acb1103`、`ac6a8a8`、`7070c3a`、`fe07a5f` 等提交,以及 [StoryForge / AI Glasses 拆分评估方案](./STORYFORGE_SPLIT_ASSESSMENT_2026-03-26.md)。
|
||||||
- `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 项目代码”的操作范围。若未来要统一命名,应作为独立重构任务推进,而不是在日常功能开发中顺手清除。
|
|
||||||
|
|
||||||
## 提交与同步边界
|
## 提交与同步边界
|
||||||
|
|
||||||
@@ -43,5 +38,5 @@
|
|||||||
- 后端与部署安全收口:去掉默认超级管理员口令依赖,强化 orchestrator secret 校验,新增 `readyz`,修复 `huobao/cutvideo` 超时串线。
|
- 后端与部署安全收口:去掉默认超级管理员口令依赖,强化 orchestrator secret 校验,新增 `readyz`,修复 `huobao/cutvideo` 超时串线。
|
||||||
- n8n 工作流收口:内部回调地址与 secret 改为环境变量注入。
|
- n8n 工作流收口:内部回调地址与 secret 改为环境变量注入。
|
||||||
- Web 稳定性与结构收口:修账号切换竞态,收紧会话存储,引入平台能力 gate,并拆出首批运行时模块。
|
- Web 稳定性与结构收口:修账号切换竞态,收紧会话存储,引入平台能力 gate,并拆出首批运行时模块。
|
||||||
- Android 安全收口:会话加密存储、明文流量白名单、敏感输入遮罩、日志级别收紧。
|
- 仓库边界收口:将混入的 `android-app/` 从 StoryForge 主仓库移出,并确认 `AI Glasses` 继续在独立 Gitea 仓库维护。
|
||||||
- 基线验证:新增 `scripts/check_repo_baseline.sh` 作为统一回归入口。
|
- 基线验证:新增 `scripts/check_repo_baseline.sh` 作为统一回归入口。
|
||||||
|
|||||||
252
docs/STORYFORGE_SPLIT_ASSESSMENT_2026-03-26.md
Normal file
252
docs/STORYFORGE_SPLIT_ASSESSMENT_2026-03-26.md
Normal file
@@ -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 项目。
|
||||||
@@ -16,13 +16,13 @@ need_cmd node
|
|||||||
|
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
|
|
||||||
echo "[1/5] compile collector-service"
|
echo "[1/4] compile collector-service"
|
||||||
python3 -m compileall collector-service/app >/dev/null
|
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
|
docker compose config >/dev/null
|
||||||
|
|
||||||
echo "[3/5] validate n8n workflows"
|
echo "[3/4] validate n8n workflows"
|
||||||
python3 - <<'PY'
|
python3 - <<'PY'
|
||||||
import json
|
import json
|
||||||
import pathlib
|
import pathlib
|
||||||
@@ -33,24 +33,10 @@ for path in sorted(pathlib.Path("n8n/workflows").glob("*.json")):
|
|||||||
print(f"workflow ok: {path.name}")
|
print(f"workflow ok: {path.name}")
|
||||||
PY
|
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
|
for file in web/storyforge-web-v4/assets/app.js web/storyforge-web-v4/assets/storyforge-*.js; do
|
||||||
node --check "$file"
|
node --check "$file"
|
||||||
done
|
done
|
||||||
node --check scripts/douyin-browser-capture/control_panel.mjs
|
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"
|
echo "baseline checks passed"
|
||||||
|
|||||||
Reference in New Issue
Block a user