chore: import storyforge baseline clean

This commit is contained in:
kris
2026-03-14 21:32:55 +08:00
commit acb1103b71
54 changed files with 9721 additions and 0 deletions

16
.env.example Normal file
View File

@@ -0,0 +1,16 @@
DEFAULT_EXTERNAL_BASE_URL=http://test.hyzq.net:8081
LOCAL_OPENAI_BASE_URL=http://127.0.0.1:8317/v1
LOCAL_OPENAI_MODEL=GLM-5
LOCAL_OPENAI_API_KEY=
FASTGPT_BASE_URL=http://127.0.0.1:3000
FASTGPT_DATASET_API_KEY=
YTDLP_BIN=yt-dlp
FFMPEG_BIN=ffmpeg
WHISPER_BIN=
WHISPER_MODEL=./data/collector/models/ggml-base.en.bin
POSTGRES_DB=fastgpt
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
MINIO_ROOT_USER=minioadmin
MINIO_ROOT_PASSWORD=minioadmin
CLIPROXY_IMAGE=storyforge/cli-proxy-api:patched

31
.gitignore vendored Normal file
View File

@@ -0,0 +1,31 @@
.DS_Store
.env
.env.local
# Python
__pycache__/
*.pyc
*.pyo
*.pyd
.pytest_cache/
.mypy_cache/
.venv/
.venv*/
# Android / Gradle
.gradle/
local.properties
build/
**/build/
.kotlin/
**/.gradle/
**/.kotlin/
# Runtime data and artifacts
data/
output/
*.log
# macOS / editors
.idea/
.vscode/

View File

@@ -0,0 +1,18 @@
# Content Learning Workflow
1. 用户登录 StoryForge
2. 用户选择知识库和文案助手
3. 用户通过三种方式导入素材
- 输入短视频链接
- 上传视频文件
- 直接输入文字
4. collector-service 创建学习任务
5. 如果素材是视频
- 使用 `yt-dlp` 下载或接收上传文件
- 使用 `ffmpeg` 提取音频
- 使用 `whisper.cpp` 转写,若环境未就绪则保留原始素材并进入降级流程
6. collector-service 调用本机 OpenAI 兼容模型提炼文案风格
7. 结果写入用户自己的知识库文档
8. 如果配置了 `FASTGPT_DATASET_API_KEY`
- 同步到 FastGPT 数据集
9. 文案助手生成时按知识库关联关系取素材,结合提示词输出文案

View File

@@ -0,0 +1,19 @@
# StoryForge AI 系统部署评估与自动补齐提示词
用于检查以下系统组件是否完整:
1. Cloud Server
2. Mac AI Node
3. FastGPT
4. Backend API
5. Web Console
6. Android Client
7. 网络连接
8. AI 生成流程
输出要求:
- 系统部署状态
- 缺失组件列表
- 修复方案
- 生成代码或部署脚本

View File

@@ -0,0 +1,18 @@
# StoryForge Mac AI Node — Server Connectivity Specification
The Mac node should only do the following:
1. Deploy FastGPT locally
2. Ensure the cloud backend can reach FastGPT
3. Maintain a private network connection to the server
4. Provide the FastGPT endpoint to the backend
Recommended ports:
- FastGPT: 3000
- MongoDB: 27017
- PostgreSQL: 5432
- Redis: 6379
- MinIO: 9000
FastGPT must not be exposed to the public internet directly.

View File

@@ -0,0 +1,27 @@
You are responsible for the StoryForge Mac AI node.
Tasks:
- Deploy FastGPT using Docker.
- Services:
- FastGPT
- MongoDB
- PostgreSQL + pgvector
- Redis
- MinIO
- Build collector-service in Python.
- Collector features:
- yt-dlp video download
- ffmpeg audio extraction
- whisper.cpp transcription
- text cleaning
- knowledge upload
- Collector APIs:
- POST /collect/video
- POST /collect/audio
- POST /collect/text
- Output:
- docker-compose
- collector service
- setup scripts
- README

6
MAC_NODE_CONNECTIVITY.md Normal file
View File

@@ -0,0 +1,6 @@
# Mac Node Connectivity
- FastGPT 默认本机端口:`3000`
- Collector Service 默认本机端口:`8081`
- Local OpenAI Compatible API`127.0.0.1:8317/v1`
- 如需通过云端访问,优先使用内网或隧道,不直接暴露 Mac 上的 FastGPT 管理接口

View File

@@ -0,0 +1,13 @@
# Project Status 2026-03-14
## 已完成
- `AI-glasses/android-app` 已恢复为独立 AI Glasses 应用
- `StoryForge/android-app` 已作为独立 Android 工程保留
- StoryForge 后端工程骨架已按当前 Android API 契约重建
- 最高管理员账号 `kris / Asd123456.` 已在 collector-service 启动时自动补齐
## 注意事项
- 当前 macOS 文件系统对 `storyforge` / `StoryForge` 大小写不敏感,后续统一使用大写路径作为项目名
- `yt-dlp` / `ffmpeg` / `whisper.cpp` 是否可用取决于本机环境collector-service 已做降级处理

40
README.md Normal file
View File

@@ -0,0 +1,40 @@
# StoryForge
StoryForge 现在拆成独立项目目录,和 `AI-glasses` 分开维护。
## 目录
- `android-app/`StoryForge Android 客户端
- `collector-service/`FastAPI 后端,提供登录、审批、素材导入、知识库、智能体和 OTA
- `docker-compose.yml`:本地 FastGPT / collector / 基础依赖编排
- `Common/`:项目约束和架构说明
- `data/collector/`SQLite、任务文件、下载产物
## Android
```bash
cd /Users/kris/code/StoryForge/android-app
./gradlew assembleDebug
```
## Collector Service
```bash
cd /Users/kris/code/StoryForge/collector-service
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --host 0.0.0.0 --port 8081 --reload
```
默认会创建最高权限账号:
- `kris`
- `Asd123456.`
## 说明
- 新注册账号默认 `pending`
- 主管理员审批后才可使用核心业务接口
- 素材入口支持文字、视频链接、视频上传
- 可选对接本机 OpenAI 兼容模型服务和 FastGPT 数据集 API

26
TECH_ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,26 @@
# StoryForge Technical Architecture
## Core Components
- Android App: 素材探索、文案生产、个人配置、管理员审批、OTA
- Collector Service: FastAPI + SQLite负责业务流程编排
- Local Model API: 默认指向本机 `cli-proxy-api`
- FastGPT: 负责数据集和后续工作流扩展
- MongoDB / PostgreSQL + pgvector / Redis / MinIO: FastGPT 运行依赖
## Main Flow
User -> Android App -> Collector Service -> Local Model / FastGPT
## Data Isolation
- `accounts`
- `knowledge_bases`
- `assistants`
- `assistant_knowledge_bases`
- `knowledge_documents`
- `jobs`
- `model_profiles`
- `app_updates`
每个用户的数据通过 `user_id` 进行隔离。

44
android-app/README.md Normal file
View File

@@ -0,0 +1,44 @@
# AI Glasses Android App
Demo Android client for backend API validation and BLE integration scaffold.
## What is implemented
- Backend API calls:
- `bind-confirm`
- `create session`
- `stop session`
- `device status`
- Compose UI for debug flow
- Hichips BLE protocol manager:
- service/char: `3D20(3D21/3D22/3D23)`, `5DC0(5DC1/5DC2/5DC3)`
- packet codec: `HICH + Command + Index + Length + CRC16 + Data + IPSE`
- handshake flow (`AG_CMD_HS_DEV_UUID` -> `AG_CMD_HS_APP_UUID` -> `AG_CMD_HS_DEV_INFO`)
- wake-up audio uplink (`ASR_*` commands, audio from `5DC2`)
- camera trigger (`AG_CMD_P_TAKE_START`) and thumbnail events
- New "开始对话(硬件)" button:
- BLE scan/connect -> handshake -> backend bind/create session
- start wake-up audio stream + periodic camera capture
- app reports aggregated audio/camera relay stats to backend events
## Default backend
The app is hardcoded to:
`http://test.hyzq.net`
## Build APK
Open this folder in Android Studio:
`/Users/kris/code/AI-glasses/android-app`
Then run:
```bash
./gradlew assembleDebug
```
APK output:
`app/build/outputs/apk/debug/app-debug.apk`

View File

@@ -0,0 +1,86 @@
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://test.hyzq.net/storyforge\"")
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.lifecycle:lifecycle-runtime-ktx:2.8.7")
implementation("androidx.activity:activity-compose:1.10.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
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 Normal file
View File

@@ -0,0 +1,2 @@
# Keep default for demo stage.

View File

@@ -0,0 +1,44 @@
<?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.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<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:usesCleartextTraffic="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>

View File

@@ -0,0 +1,51 @@
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) }
)
}
}
}
}

View File

@@ -0,0 +1,638 @@
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"))
}
}

View File

@@ -0,0 +1,325 @@
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()
}
}

View File

@@ -0,0 +1,98 @@
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)
}
}
}

View File

@@ -0,0 +1,240 @@
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()
}
}
}
}

View File

@@ -0,0 +1,106 @@
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
@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
}

View File

@@ -0,0 +1,249 @@
package com.aiglasses.app.storyforge
import kotlinx.serialization.Serializable
@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 KnowledgeBaseDto(
val id: String,
val user_id: String,
val name: String,
val description: String = "",
val fastgpt_dataset_id: String? = null,
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 description: String = ""
)
@Serializable
data class AssistantDto(
val id: String,
val user_id: String,
val name: String,
val description: String = "",
val system_prompt: String = "",
val generation_goal: String = "",
val knowledge_base_ids: List<String> = emptyList(),
val fastgpt_app_key: String = "",
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 fastgpt_app_key: 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 fastgpt_app_key: String? = null,
val model_profile_id: String? = null
)
@Serializable
data class ExploreVideoLinkRequest(
val video_url: String,
val title: 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 knowledge_base_id: String? = null,
val assistant_id: String? = null,
val analysis_model_profile_id: String? = null
)
@Serializable
data class JobDto(
val id: String,
val user_id: String,
val assistant_id: String? = null,
val knowledge_base_id: String,
val source_type: String,
val source_url: String? = null,
val title: String,
val language: String,
val status: String,
val transcript_text: String = "",
val style_summary: String = "",
val fastgpt_collection_id: String = "",
val upload_status: String = "pending",
val error: String = "",
val artifacts: Map<String, String> = emptyMap(),
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 fastgpt_collection_id: String = "",
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 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
)

View File

@@ -0,0 +1,366 @@
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 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 = HttpLoggingInterceptor.Level.BASIC
}
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("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)) 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}$""")
}
}

View File

@@ -0,0 +1,827 @@
package com.aiglasses.app.storyforge
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@Composable
fun StoryForgeScreen(
state: StoryForgeUiState,
vm: StoryForgeViewModel,
onPickVideo: () -> Unit,
onInstallLatestUpdate: () -> Unit
) {
val heroBrush = Brush.linearGradient(
colors = listOf(Color(0xFF0B3C5D), Color(0xFF1F6E5F), Color(0xFFB97524))
)
Scaffold(
bottomBar = {
if (state.isAuthenticated && state.isApproved) {
NavigationBar(modifier = Modifier.navigationBarsPadding()) {
BottomTabItem(label = "探索", tab = StoryForgeTab.Explore, state = state, onSelect = vm::selectTab)
BottomTabItem(label = "生产", tab = StoryForgeTab.Production, state = state, onSelect = vm::selectTab)
BottomTabItem(label = "我的", tab = StoryForgeTab.Mine, state = state, onSelect = vm::selectTab)
}
}
}
) { innerPadding ->
Surface(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(innerPadding)
) {
when {
!state.isAuthenticated -> AuthScreen(state = state, vm = vm, heroBrush = heroBrush)
!state.isApproved -> PendingApprovalScreen(state = state, vm = vm, heroBrush = heroBrush)
else -> AppShell(
state = state,
vm = vm,
heroBrush = heroBrush,
onPickVideo = onPickVideo,
onInstallLatestUpdate = onInstallLatestUpdate
)
}
}
}
}
@Composable
private fun BottomTabItem(
label: String,
tab: StoryForgeTab,
state: StoryForgeUiState,
onSelect: (StoryForgeTab) -> Unit
) {
val selected = state.currentTab == tab
Box(
modifier = Modifier
.clip(RoundedCornerShape(18.dp))
.clickable { onSelect(tab) }
.background(if (selected) MaterialTheme.colorScheme.primaryContainer else Color.Transparent)
.padding(horizontal = 14.dp, vertical = 10.dp),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(text = label.take(1), fontWeight = FontWeight.Bold)
Text(label, style = MaterialTheme.typography.labelSmall)
}
}
}
@Composable
private fun AuthScreen(
state: StoryForgeUiState,
vm: StoryForgeViewModel,
heroBrush: Brush
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(heroBrush)
.padding(18.dp),
contentAlignment = Alignment.Center
) {
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
shape = RoundedCornerShape(28.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(22.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text("StoryForge AI", style = MaterialTheme.typography.headlineSmall)
Text(
if (state.authMode == StoryForgeAuthMode.Login) "登录账号" else "注册新账号,提交后等待主管理员审批",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
ChoiceRow(
options = listOf("登录" to (state.authMode == StoryForgeAuthMode.Login), "注册" to (state.authMode == StoryForgeAuthMode.Register)),
onSelect = { label -> vm.setAuthMode(if (label == "登录") StoryForgeAuthMode.Login else StoryForgeAuthMode.Register) }
)
OutlinedTextField(
value = state.username,
onValueChange = vm::updateUsername,
modifier = Modifier.fillMaxWidth(),
label = { Text("账号") },
singleLine = true
)
OutlinedTextField(
value = state.password,
onValueChange = vm::updatePassword,
modifier = Modifier.fillMaxWidth(),
label = { Text("密码") },
singleLine = true
)
Button(
onClick = { if (state.authMode == StoryForgeAuthMode.Login) vm.login() else vm.registerAccount() },
enabled = !state.busy,
modifier = Modifier.fillMaxWidth()
) {
if (state.busy) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
} else {
Text(if (state.authMode == StoryForgeAuthMode.Login) "登录" else "注册")
}
}
if (state.statusMessage.isNotBlank()) {
Text(state.statusMessage, style = MaterialTheme.typography.bodySmall)
}
if (state.errorMessage.isNotBlank()) {
Text(state.errorMessage, color = MaterialTheme.colorScheme.error)
}
}
}
}
}
@Composable
private fun PendingApprovalScreen(
state: StoryForgeUiState,
vm: StoryForgeViewModel,
heroBrush: Brush
) {
val account = state.account
Column(
modifier = Modifier
.fillMaxSize()
.padding(18.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
HeroCard(
title = "等待审批",
subtitle = "${account?.display_name ?: account?.username ?: "当前账号"} 已登录,但尚未通过主管理员审批。",
heroBrush = heroBrush,
badges = listOf(
"审批状态:${account?.approval_status ?: "pending"}",
if (state.resolvedIp.isNotBlank()) "已解析到 ${state.resolvedIp}" else ""
).filter { it.isNotBlank() }
)
SectionCard(title = "当前说明", subtitle = state.statusMessage) {
Text("新注册账号在主管理员通过前,无法访问探索、生产和知识库功能。")
Spacer(modifier = Modifier.height(12.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = vm::refreshApprovalStatus, enabled = !state.busy) {
Text("刷新审批状态")
}
OutlinedButton(onClick = vm::logout) {
Text("退出登录")
}
}
if (state.errorMessage.isNotBlank()) {
Spacer(modifier = Modifier.height(10.dp))
Text(state.errorMessage, color = MaterialTheme.colorScheme.error)
}
}
}
}
@Composable
private fun AppShell(
state: StoryForgeUiState,
vm: StoryForgeViewModel,
heroBrush: Brush,
onPickVideo: () -> Unit,
onInstallLatestUpdate: () -> Unit
) {
val scroll = rememberScrollState()
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scroll)
.padding(18.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
HeroCard(
title = when (state.currentTab) {
StoryForgeTab.Explore -> "探索素材"
StoryForgeTab.Production -> "生产文案"
StoryForgeTab.Mine -> "我的工作台"
},
subtitle = state.statusMessage,
heroBrush = heroBrush,
badges = listOf(
state.account?.display_name ?: state.account?.username.orEmpty(),
state.account?.role ?: "",
if (state.resolvedIp.isNotBlank()) "IP ${state.resolvedIp}" else ""
).filter { it.isNotBlank() }
)
StatusStrip(state = state, onRefresh = vm::refreshWorkspace)
when (state.currentTab) {
StoryForgeTab.Explore -> ExploreTab(state = state, vm = vm, onPickVideo = onPickVideo)
StoryForgeTab.Production -> ProductionTab(state = state, vm = vm)
StoryForgeTab.Mine -> MineTab(state = state, vm = vm, onInstallLatestUpdate = onInstallLatestUpdate)
}
}
}
@Composable
private fun StatusStrip(state: StoryForgeUiState, onRefresh: () -> Unit) {
SectionCard(title = "连接状态", subtitle = if (state.busy) "正在同步" else "已连接") {
Text(
text = if (state.originalHost.isNotBlank()) {
"外网域名已解析为 ${state.resolvedIp},请求会携带 Host=${state.originalHost}"
} else {
"当前使用地址:${state.baseUrl}"
},
style = MaterialTheme.typography.bodySmall
)
Spacer(modifier = Modifier.height(12.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
OutlinedButton(onClick = onRefresh) {
Text("刷新")
}
if (state.busy) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
}
if (state.errorMessage.isNotBlank()) {
Text(state.errorMessage, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
}
}
}
}
@Composable
private fun ExploreTab(state: StoryForgeUiState, vm: StoryForgeViewModel, onPickVideo: () -> Unit) {
SectionCard(title = "素材入口", subtitle = "视频链接、上传视频、输入文字都会转成文本并做风格分析") {
ChoiceRow(
options = listOf(
"视频链接" to (state.exploreInputMode == ExploreInputMode.VideoLink),
"上传视频" to (state.exploreInputMode == ExploreInputMode.UploadVideo),
"输入文字" to (state.exploreInputMode == ExploreInputMode.Text)
),
onSelect = { label ->
vm.setExploreInputMode(
when (label) {
"视频链接" -> ExploreInputMode.VideoLink
"上传视频" -> ExploreInputMode.UploadVideo
else -> ExploreInputMode.Text
}
)
}
)
Spacer(modifier = Modifier.height(12.dp))
KnowledgeBaseSelector(state = state, onSelect = vm::selectKnowledgeBase)
Spacer(modifier = Modifier.height(12.dp))
AssistantSelector(state = state, onSelect = vm::selectAssistant)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "当前分析模型:${state.modelProfiles.firstOrNull { it.id == state.account?.preferred_analysis_model_id }?.name ?: "本机默认模型"}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
Spacer(modifier = Modifier.height(12.dp))
when (state.exploreInputMode) {
ExploreInputMode.VideoLink -> {
OutlinedTextField(
value = state.videoUrl,
onValueChange = vm::updateVideoUrl,
modifier = Modifier.fillMaxWidth(),
label = { Text("短视频链接") },
minLines = 2
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
value = state.videoTitle,
onValueChange = vm::updateVideoTitle,
modifier = Modifier.fillMaxWidth(),
label = { Text("素材标题(可选)") },
singleLine = true
)
Spacer(modifier = Modifier.height(12.dp))
Button(onClick = vm::submitVideoLink, enabled = !state.busy) {
Text("提交视频链接")
}
}
ExploreInputMode.UploadVideo -> {
OutlinedTextField(
value = state.videoTitle,
onValueChange = vm::updateVideoTitle,
modifier = Modifier.fillMaxWidth(),
label = { Text("素材标题(可选)") },
singleLine = true
)
Spacer(modifier = Modifier.height(10.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
OutlinedButton(onClick = onPickVideo) {
Text(if (state.pickedVideoName.isBlank()) "选择视频文件" else "重新选择")
}
Text(
text = if (state.pickedVideoName.isBlank()) "未选择文件" else state.pickedVideoName,
modifier = Modifier.weight(1f),
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
Spacer(modifier = Modifier.height(12.dp))
Button(onClick = vm::submitUploadVideo, enabled = !state.busy && state.pickedVideoName.isNotBlank()) {
Text("上传并开始学习")
}
}
ExploreInputMode.Text -> {
OutlinedTextField(
value = state.textTitle,
onValueChange = vm::updateTextTitle,
modifier = Modifier.fillMaxWidth(),
label = { Text("素材标题") },
singleLine = true
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
value = state.textContent,
onValueChange = vm::updateTextContent,
modifier = Modifier.fillMaxWidth(),
label = { Text("素材文字") },
minLines = 5
)
Spacer(modifier = Modifier.height(12.dp))
Button(onClick = vm::submitText, enabled = !state.busy) {
Text("分析并沉淀到知识库")
}
}
}
}
state.latestJob?.let { latestJob ->
SectionCard(title = "最新任务", subtitle = latestJob.title) {
KeyValueRow(label = "状态", value = latestJob.status)
KeyValueRow(label = "上传状态", value = latestJob.upload_status)
if (latestJob.transcript_text.isNotBlank()) {
KeyValueBlock(label = "文本转写", value = latestJob.transcript_text)
}
if (latestJob.style_summary.isNotBlank()) {
KeyValueBlock(label = "风格提炼", value = latestJob.style_summary)
}
if (latestJob.error.isNotBlank()) {
Text(latestJob.error, color = MaterialTheme.colorScheme.error)
}
}
}
if (state.documents.isNotEmpty()) {
SectionCard(title = "当前知识库素材", subtitle = "已经沉淀到所选知识库的文本样本") {
state.documents.forEach { document ->
MiniCard(title = document.title, subtitle = document.style_summary.ifBlank { document.transcript_text.take(100) })
Spacer(modifier = Modifier.height(10.dp))
}
}
}
}
@Composable
private fun ProductionTab(state: StoryForgeUiState, vm: StoryForgeViewModel) {
SectionCard(title = "智能体列表", subtitle = "一个智能体默认关联一个知识库,也可以关联多个知识库") {
ChoiceRow(
options = state.assistants.map { it.name to (state.selectedAssistantId == it.id) },
onSelect = { label ->
state.assistants.firstOrNull { it.name == label }?.let { vm.selectAssistant(it.id) }
}
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedButton(onClick = vm::startNewAssistant) {
Text("新建智能体")
}
}
SectionCard(title = "编辑智能体", subtitle = "提示词由用户提供,可随时调整模型和知识库绑定") {
OutlinedTextField(
value = state.assistantName,
onValueChange = vm::updateAssistantName,
modifier = Modifier.fillMaxWidth(),
label = { Text("智能体名称") },
singleLine = true
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
value = state.assistantDescription,
onValueChange = vm::updateAssistantDescription,
modifier = Modifier.fillMaxWidth(),
label = { Text("智能体说明") },
minLines = 2
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
value = state.assistantSystemPrompt,
onValueChange = vm::updateAssistantSystemPrompt,
modifier = Modifier.fillMaxWidth(),
label = { Text("系统提示词") },
minLines = 5
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
value = state.assistantGenerationGoal,
onValueChange = vm::updateAssistantGenerationGoal,
modifier = Modifier.fillMaxWidth(),
label = { Text("生成目标") },
minLines = 3
)
Spacer(modifier = Modifier.height(12.dp))
Text("选择生成模型", style = MaterialTheme.typography.titleSmall)
Spacer(modifier = Modifier.height(8.dp))
ChoiceRow(
options = state.modelProfiles.map { it.name to (state.assistantModelProfileId == it.id) },
onSelect = { label ->
state.modelProfiles.firstOrNull { it.name == label }?.let { vm.updateAssistantModelProfileId(it.id) }
}
)
Spacer(modifier = Modifier.height(12.dp))
Text("选择要关联的知识库", style = MaterialTheme.typography.titleSmall)
Spacer(modifier = Modifier.height(8.dp))
ChoiceRow(
options = state.knowledgeBases.map { it.name to state.selectedAssistantKnowledgeBaseIds.contains(it.id) },
onSelect = { label ->
state.knowledgeBases.firstOrNull { it.name == label }?.let { vm.toggleAssistantKnowledgeBase(it.id) }
}
)
Spacer(modifier = Modifier.height(14.dp))
Button(onClick = vm::saveAssistant, enabled = !state.busy) {
Text(if (state.assistantEditorId.isNullOrBlank()) "创建智能体" else "保存智能体配置")
}
}
SectionCard(title = "生成文案", subtitle = "选择智能体后,直接基于关联知识库输出文案") {
OutlinedTextField(
value = state.generationBrief,
onValueChange = vm::updateGenerationBrief,
modifier = Modifier.fillMaxWidth(),
label = { Text("文案需求") },
minLines = 4
)
Spacer(modifier = Modifier.height(10.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(
value = state.generationPlatform,
onValueChange = vm::updateGenerationPlatform,
modifier = Modifier.weight(1f),
label = { Text("平台") },
singleLine = true
)
OutlinedTextField(
value = state.generationAudience,
onValueChange = vm::updateGenerationAudience,
modifier = Modifier.weight(1f),
label = { Text("目标受众") },
singleLine = true
)
}
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
value = state.generationExtraRequirements,
onValueChange = vm::updateGenerationExtraRequirements,
modifier = Modifier.fillMaxWidth(),
label = { Text("额外要求") },
minLines = 3
)
Spacer(modifier = Modifier.height(12.dp))
Button(onClick = vm::generateCopy, enabled = !state.generateBusy) {
if (state.generateBusy) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
} else {
Text("开始生成")
}
}
if (state.generationOutput.isNotBlank()) {
Spacer(modifier = Modifier.height(16.dp))
KeyValueBlock(label = "生成结果", value = state.generationOutput)
}
}
}
@Composable
private fun MineTab(state: StoryForgeUiState, vm: StoryForgeViewModel, onInstallLatestUpdate: () -> Unit) {
SectionCard(title = "我的账号", subtitle = state.account?.display_name ?: state.account?.username.orEmpty()) {
KeyValueRow(label = "用户名", value = state.account?.username ?: "-")
KeyValueRow(label = "角色", value = state.account?.role ?: "-")
KeyValueRow(label = "审批", value = state.account?.approval_status ?: "-")
KeyValueRow(label = "Base URL", value = state.baseUrl)
if (state.resolvedIp.isNotBlank()) {
KeyValueRow(label = "解析 IP", value = state.resolvedIp)
}
Spacer(modifier = Modifier.height(12.dp))
OutlinedButton(onClick = vm::logout) {
Text("退出登录")
}
}
SectionCard(title = "分析模型", subtitle = "探索页默认使用这里选中的模型") {
ChoiceRow(
options = state.modelProfiles.map { it.name to (state.account?.preferred_analysis_model_id == it.id) },
onSelect = { label ->
state.modelProfiles.firstOrNull { it.name == label }?.let { vm.setPreferredModel(it.id) }
}
)
Spacer(modifier = Modifier.height(14.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(14.dp))
OutlinedTextField(
value = state.newModelName,
onValueChange = vm::updateNewModelName,
modifier = Modifier.fillMaxWidth(),
label = { Text("模型别名") },
singleLine = true
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
value = state.newModelBaseUrl,
onValueChange = vm::updateNewModelBaseUrl,
modifier = Modifier.fillMaxWidth(),
label = { Text("Base URL") },
singleLine = true
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
value = state.newModelModelName,
onValueChange = vm::updateNewModelModelName,
modifier = Modifier.fillMaxWidth(),
label = { Text("模型名称") },
singleLine = true
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
value = state.newModelApiKey,
onValueChange = vm::updateNewModelApiKey,
modifier = Modifier.fillMaxWidth(),
label = { Text("API Key") },
minLines = 2
)
Spacer(modifier = Modifier.height(12.dp))
Button(onClick = vm::createModelProfile) {
Text("保存为默认分析模型")
}
}
SectionCard(title = "OTA 更新", subtitle = state.otaStatus.ifBlank { "检查新版本并执行安装" }) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
Button(onClick = vm::checkForUpdates) {
Text("检查更新")
}
OutlinedButton(onClick = onInstallLatestUpdate, enabled = state.otaInfo?.hasUpdate == true) {
Text("安装最新版本")
}
}
state.otaInfo?.let { ota ->
Spacer(modifier = Modifier.height(12.dp))
KeyValueRow(label = "最新版本", value = "${ota.latestVersionName} (${ota.latestVersionCode})")
if (ota.releaseNotes.isNotBlank()) {
KeyValueBlock(label = "更新说明", value = ota.releaseNotes)
}
}
}
if (state.account?.role == "super_admin") {
SectionCard(title = "主管理员审批", subtitle = "新注册账号需要你审批后才能正常使用全部功能") {
if (state.pendingAccounts.isEmpty()) {
Text("当前没有待审批账号")
} else {
state.pendingAccounts.forEach { account ->
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) {
Column(modifier = Modifier.fillMaxWidth().padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(account.display_name, fontWeight = FontWeight.Bold)
Text(account.username, style = MaterialTheme.typography.bodySmall)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = { vm.approveAccount(account.id) }) {
Text("通过")
}
OutlinedButton(onClick = { vm.rejectAccount(account.id) }) {
Text("拒绝")
}
}
}
}
Spacer(modifier = Modifier.height(10.dp))
}
}
}
SectionCard(title = "发布 OTA", subtitle = "主管理员可直接更新在线版本号和下载地址") {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(
value = state.publishVersionCode,
onValueChange = vm::updatePublishVersionCode,
modifier = Modifier.weight(1f),
label = { Text("VersionCode") },
singleLine = true
)
OutlinedTextField(
value = state.publishMinSupportedCode,
onValueChange = vm::updatePublishMinSupportedCode,
modifier = Modifier.weight(1f),
label = { Text("最低支持") },
singleLine = true
)
}
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
value = state.publishVersionName,
onValueChange = vm::updatePublishVersionName,
modifier = Modifier.fillMaxWidth(),
label = { Text("VersionName") },
singleLine = true
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
value = state.publishApkUrl,
onValueChange = vm::updatePublishApkUrl,
modifier = Modifier.fillMaxWidth(),
label = { Text("APK 下载地址") },
minLines = 2
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
value = state.publishNotes,
onValueChange = vm::updatePublishNotes,
modifier = Modifier.fillMaxWidth(),
label = { Text("更新说明") },
minLines = 3
)
Spacer(modifier = Modifier.height(12.dp))
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Text("强制更新")
Switch(checked = state.publishForceUpdate, onCheckedChange = vm::setPublishForceUpdate)
}
Spacer(modifier = Modifier.height(12.dp))
Button(onClick = vm::publishUpdate) {
Text("发布 OTA")
}
}
}
SectionCard(title = "最近日志", subtitle = "用于确认审批、解析、任务和 OTA 状态") {
state.timeline.forEach { item ->
Text(item, style = MaterialTheme.typography.bodySmall)
Spacer(modifier = Modifier.height(6.dp))
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun ChoiceRow(
options: List<Pair<String, Boolean>>,
onSelect: (String) -> Unit
) {
FlowRow(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
options.forEach { (label, selected) ->
FilterChip(
selected = selected,
onClick = { onSelect(label) },
label = { Text(label) }
)
}
}
}
@Composable
private fun KnowledgeBaseSelector(state: StoryForgeUiState, onSelect: (String) -> Unit) {
Text("选择知识库", style = MaterialTheme.typography.titleSmall)
Spacer(modifier = Modifier.height(8.dp))
ChoiceRow(
options = state.knowledgeBases.map { it.name to (state.selectedKnowledgeBaseId == it.id) },
onSelect = { label ->
state.knowledgeBases.firstOrNull { it.name == label }?.let { onSelect(it.id) }
}
)
}
@Composable
private fun AssistantSelector(state: StoryForgeUiState, onSelect: (String) -> Unit) {
Text("选择关联智能体", style = MaterialTheme.typography.titleSmall)
Spacer(modifier = Modifier.height(8.dp))
ChoiceRow(
options = state.assistants.map { it.name to (state.selectedAssistantId == it.id) },
onSelect = { label ->
state.assistants.firstOrNull { it.name == label }?.let { onSelect(it.id) }
}
)
}
@Composable
private fun HeroCard(title: String, subtitle: String, heroBrush: Brush, badges: List<String>) {
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(28.dp))
.background(heroBrush)
.padding(20.dp)
) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(title, style = MaterialTheme.typography.headlineLarge, color = Color.White)
Text(subtitle, style = MaterialTheme.typography.bodyLarge, color = Color(0xFFF8F5EF))
if (badges.isNotEmpty()) {
ChoiceRow(options = badges.map { it to true }, onSelect = {})
}
}
}
}
@Composable
private fun SectionCard(title: String, subtitle: String, content: @Composable () -> Unit) {
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
shape = RoundedCornerShape(22.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(18.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(title, style = MaterialTheme.typography.headlineSmall)
if (subtitle.isNotBlank()) {
Text(
subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.72f)
)
}
Spacer(modifier = Modifier.height(6.dp))
content()
}
}
}
@Composable
private fun KeyValueRow(label: String, value: String) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(label, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f))
Spacer(modifier = Modifier.width(12.dp))
Text(value, modifier = Modifier.weight(1f), maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
@Composable
private fun KeyValueBlock(label: String, value: String) {
Text(label, style = MaterialTheme.typography.titleSmall)
Spacer(modifier = Modifier.height(6.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.2f), RoundedCornerShape(16.dp))
.padding(14.dp)
) {
Text(value)
}
}
@Composable
private fun MiniCard(title: String, subtitle: String) {
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) {
Column(modifier = Modifier.fillMaxWidth().padding(14.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(title, fontWeight = FontWeight.Bold)
Text(subtitle, maxLines = 4, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodySmall)
}
}
}

View File

@@ -0,0 +1,59 @@
package com.aiglasses.app.storyforge
import android.content.Context
import com.aiglasses.app.BuildConfig
data class SavedStoryForgeSession(
val baseUrl: String,
val token: String
)
class StoryForgeSessionStore(context: Context) {
private val prefs = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
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()
}
private companion object {
private const val PREFS_NAME = "storyforge_session"
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_IP_URL = "http://111.231.132.51:8081"
}
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_IP_URL) -> BuildConfig.DEFAULT_STORYFORGE_BASE_URL
else -> trimmed
}
}
}

View File

@@ -0,0 +1,907 @@
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 {
Explore,
Production,
Mine
}
enum class StoryForgeAuthMode {
Login,
Register
}
enum class ExploreInputMode {
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.Explore,
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 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 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,
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,
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 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.Explore
)
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

View File

@@ -0,0 +1,74 @@
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(0xFF0E4B43),
secondary = Color(0xFF9C6427),
tertiary = Color(0xFF2A5B8A),
background = Color(0xFFF7F3EC),
surface = Color(0xFFFFFCF8),
onPrimary = Color.White,
onSecondary = Color.White,
onBackground = Color(0xFF1A1713),
onSurface = Color(0xFF1A1713)
)
private val DarkColors = darkColorScheme(
primary = Color(0xFF7FD6C7),
secondary = Color(0xFFFFC27A),
tertiary = Color(0xFF98C7FF),
background = Color(0xFF101714),
surface = Color(0xFF18211D),
onPrimary = Color(0xFF062D29),
onSecondary = Color(0xFF4B2B00),
onBackground = Color(0xFFF0E8DB),
onSurface = Color(0xFFF0E8DB)
)
private val AppTypography = Typography(
headlineLarge = TextStyle(
fontFamily = FontFamily.Serif,
fontWeight = FontWeight.Bold,
fontSize = 34.sp,
lineHeight = 40.sp
),
headlineSmall = TextStyle(
fontFamily = FontFamily.Serif,
fontWeight = FontWeight.SemiBold,
fontSize = 22.sp,
lineHeight = 28.sp
),
bodyLarge = TextStyle(
fontFamily = FontFamily.SansSerif,
fontSize = 16.sp,
lineHeight = 24.sp
),
labelLarge = TextStyle(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Medium,
fontSize = 14.sp
)
)
@Composable
fun AIGlassesTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
MaterialTheme(
colorScheme = if (darkTheme) DarkColors else LightColors,
typography = AppTypography,
content = content
)
}

View File

@@ -0,0 +1,559 @@
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"
}
}

View File

@@ -0,0 +1,3 @@
<resources>
<string name="app_name">StoryForge AI</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.AIGlasses" parent="Theme.Material3.DayNight.NoActionBar" />
</resources>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path
name="ota_cache"
path="ota/" />
</paths>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true" />
</network-security-config>

View File

@@ -0,0 +1,6 @@
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
}

View File

@@ -0,0 +1,5 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official

Binary file not shown.

View File

@@ -0,0 +1,7 @@
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 Executable file
View File

@@ -0,0 +1,249 @@
#!/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 Normal file
View File

@@ -0,0 +1,92 @@
@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

View File

@@ -0,0 +1,19 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "AIGlassesApp"
include(":app")

View File

@@ -0,0 +1,8 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
EXPOSE 8081
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8081"]

View File

@@ -0,0 +1 @@
# StoryForge collector-service package

View File

@@ -0,0 +1,181 @@
from __future__ import annotations
import sqlite3
from contextlib import contextmanager
from pathlib import Path
from typing import Any, Iterator
def utc_now() -> str:
from datetime import datetime, timezone
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
def dict_factory(cursor: sqlite3.Cursor, row: sqlite3.Row) -> dict[str, Any]:
return {col[0]: row[idx] for idx, col in enumerate(cursor.description)}
class Database:
def __init__(self, path: str) -> None:
self.path = Path(path)
self.path.parent.mkdir(parents=True, exist_ok=True)
def connect(self) -> sqlite3.Connection:
conn = sqlite3.connect(self.path)
conn.row_factory = dict_factory
conn.execute("PRAGMA foreign_keys = ON")
return conn
@contextmanager
def session(self) -> Iterator[sqlite3.Connection]:
conn = self.connect()
try:
yield conn
conn.commit()
finally:
conn.close()
def fetch_one(self, sql: str, params: tuple[Any, ...] = ()) -> dict[str, Any] | None:
with self.session() as conn:
return conn.execute(sql, params).fetchone()
def fetch_all(self, sql: str, params: tuple[Any, ...] = ()) -> list[dict[str, Any]]:
with self.session() as conn:
return list(conn.execute(sql, params).fetchall())
def execute(self, sql: str, params: tuple[Any, ...] = ()) -> None:
with self.session() as conn:
conn.execute(sql, params)
def init_schema(self) -> None:
schema = """
CREATE TABLE IF NOT EXISTS accounts (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
password_salt TEXT NOT NULL,
display_name TEXT NOT NULL,
role TEXT NOT NULL,
approval_status TEXT NOT NULL,
approved_by TEXT,
approved_at TEXT,
preferred_analysis_model_id TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS auth_tokens (
token TEXT PRIMARY KEY,
account_id TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY(account_id) REFERENCES accounts(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS model_profiles (
id TEXT PRIMARY KEY,
owner_account_id TEXT,
name TEXT NOT NULL,
provider TEXT NOT NULL,
base_url TEXT NOT NULL,
api_key TEXT NOT NULL DEFAULT '',
model_name TEXT NOT NULL,
is_system INTEGER NOT NULL DEFAULT 0,
is_default INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY(owner_account_id) REFERENCES accounts(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS knowledge_bases (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
fastgpt_dataset_id TEXT,
sync_status TEXT NOT NULL DEFAULT 'pending',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES accounts(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS knowledge_documents (
id TEXT PRIMARY KEY,
knowledge_base_id TEXT NOT NULL,
title TEXT NOT NULL,
source_type TEXT NOT NULL,
source_url TEXT NOT NULL DEFAULT '',
transcript_text TEXT NOT NULL DEFAULT '',
style_summary TEXT NOT NULL DEFAULT '',
combined_text TEXT NOT NULL DEFAULT '',
fastgpt_collection_id TEXT NOT NULL DEFAULT '',
analysis_model_profile_id TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY(knowledge_base_id) REFERENCES knowledge_bases(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS assistants (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
system_prompt TEXT NOT NULL DEFAULT '',
generation_goal TEXT NOT NULL DEFAULT '',
fastgpt_app_key TEXT NOT NULL DEFAULT '',
model_profile_id TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES accounts(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS assistant_knowledge_bases (
assistant_id TEXT NOT NULL,
knowledge_base_id TEXT NOT NULL,
PRIMARY KEY (assistant_id, knowledge_base_id),
FOREIGN KEY(assistant_id) REFERENCES assistants(id) ON DELETE CASCADE,
FOREIGN KEY(knowledge_base_id) REFERENCES knowledge_bases(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
assistant_id TEXT,
knowledge_base_id TEXT NOT NULL,
source_type TEXT NOT NULL,
source_url TEXT,
title TEXT NOT NULL,
language TEXT NOT NULL DEFAULT 'auto',
status TEXT NOT NULL,
transcript_text TEXT NOT NULL DEFAULT '',
style_summary TEXT NOT NULL DEFAULT '',
fastgpt_collection_id TEXT NOT NULL DEFAULT '',
upload_status TEXT NOT NULL DEFAULT 'pending',
error TEXT NOT NULL DEFAULT '',
artifacts_json TEXT NOT NULL DEFAULT '{}',
analysis_model_profile_id TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES accounts(id) ON DELETE CASCADE,
FOREIGN KEY(assistant_id) REFERENCES assistants(id) ON DELETE SET NULL,
FOREIGN KEY(knowledge_base_id) REFERENCES knowledge_bases(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS app_updates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
platform TEXT NOT NULL,
channel TEXT NOT NULL,
version_code INTEGER NOT NULL,
version_name TEXT NOT NULL,
min_supported_code INTEGER NOT NULL,
apk_url TEXT NOT NULL,
apk_sha256 TEXT NOT NULL DEFAULT '',
notes TEXT NOT NULL DEFAULT '',
force_update INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
published_at INTEGER NOT NULL,
created_by TEXT NOT NULL
);
"""
with self.session() as conn:
conn.executescript(schema)

View File

@@ -0,0 +1,48 @@
from __future__ import annotations
from typing import Any
import httpx
class FastGPTClient:
def __init__(self, *, base_url: str, dataset_api_key: str, timeout: float = 60.0) -> None:
self.base_url = base_url.rstrip("/")
self.dataset_api_key = dataset_api_key.strip()
self.timeout = timeout
@property
def enabled(self) -> bool:
return bool(self.base_url and self.dataset_api_key)
async def ensure_dataset(self, name: str, intro: str = "") -> dict[str, Any] | None:
if not self.enabled:
return None
payload = {"name": name, "intro": intro}
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
f"{self.base_url}/api/core/dataset/create",
headers={"Authorization": f"Bearer {self.dataset_api_key}"},
json=payload,
)
response.raise_for_status()
return response.json().get("data") or response.json()
async def add_text_document(self, dataset_id: str, name: str, text: str) -> dict[str, Any] | None:
if not self.enabled or not dataset_id.strip():
return None
payload = {
"datasetId": dataset_id,
"type": "text",
"name": name,
"trainingType": "chunk",
"text": text,
}
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
f"{self.base_url}/api/core/dataset/collection/create/text",
headers={"Authorization": f"Bearer {self.dataset_api_key}"},
json=payload,
)
response.raise_for_status()
return response.json().get("data") or response.json()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,45 @@
from __future__ import annotations
from typing import Any
import httpx
class OpenAICompatClient:
def __init__(self, timeout: float = 180.0) -> None:
self.timeout = timeout
async def chat_completion(
self,
*,
base_url: str,
api_key: str,
model: str,
system_prompt: str,
user_prompt: str,
temperature: float = 0.7,
) -> str:
url = base_url.rstrip("/") + "/chat/completions"
headers = {"Content-Type": "application/json"}
if api_key.strip():
headers["Authorization"] = f"Bearer {api_key.strip()}"
payload: dict[str, Any] = {
"model": model,
"temperature": temperature,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
}
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(url, headers=headers, json=payload)
response.raise_for_status()
data = response.json()
choices = data.get("choices") or []
if not choices:
return ""
message = choices[0].get("message") or {}
content = message.get("content") or ""
if isinstance(content, list):
return "\n".join(str(item.get("text", "")) for item in content if isinstance(item, dict)).strip()
return str(content).strip()

View File

@@ -0,0 +1,5 @@
fastapi==0.115.12
uvicorn[standard]==0.34.0
httpx==0.28.1
python-multipart==0.0.20
pydantic==2.11.1

101
docker-compose.yml Normal file
View File

@@ -0,0 +1,101 @@
version: "3.9"
services:
mongo:
image: mongo:6
container_name: storyforge-mongo
restart: unless-stopped
ports:
- "27017:27017"
volumes:
- ./data/mongo:/data/db
vectorDB:
image: pgvector/pgvector:pg16
container_name: storyforge-pgvector
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-fastgpt}
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
ports:
- "5432:5432"
volumes:
- ./data/pg:/var/lib/postgresql/data
redis:
image: redis:7-alpine
container_name: storyforge-redis
restart: unless-stopped
ports:
- "6379:6379"
volumes:
- ./data/redis:/data
minio:
image: minio/minio:RELEASE.2025-02-07T23-21-09Z
container_name: storyforge-minio
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
ports:
- "9000:9000"
- "9001:9001"
volumes:
- ./data/minio:/data
collector:
build:
context: ./collector-service
container_name: storyforge-collector
restart: unless-stopped
environment:
DATA_DIR: /data/collector
DATABASE_PATH: /data/collector/storyforge.db
DEFAULT_EXTERNAL_BASE_URL: ${DEFAULT_EXTERNAL_BASE_URL:-https://test.hyzq.net/storyforge}
LOCAL_OPENAI_BASE_URL: ${LOCAL_OPENAI_BASE_URL:-http://host.docker.internal:8317/v1}
LOCAL_OPENAI_MODEL: ${LOCAL_OPENAI_MODEL:-GLM-5}
LOCAL_OPENAI_API_KEY: ${LOCAL_OPENAI_API_KEY:-}
FASTGPT_BASE_URL: ${FASTGPT_BASE_URL:-http://host.docker.internal:3000}
FASTGPT_DATASET_API_KEY: ${FASTGPT_DATASET_API_KEY:-}
YTDLP_BIN: ${YTDLP_BIN:-yt-dlp}
FFMPEG_BIN: ${FFMPEG_BIN:-ffmpeg}
WHISPER_BIN: ${WHISPER_BIN:-}
WHISPER_MODEL: ${WHISPER_MODEL:-/data/collector/models/ggml-base.en.bin}
ports:
- "8081:8081"
volumes:
- ./data/collector:/data/collector
command: uvicorn app.main:app --host 0.0.0.0 --port 8081
fastgpt:
image: ghcr.io/labring/fastgpt:latest
container_name: storyforge-fastgpt
restart: unless-stopped
depends_on:
- mongo
- vectorDB
- redis
- minio
ports:
- "3000:3000"
sandbox:
image: ghcr.io/labring/fastgpt-sandbox:latest
container_name: storyforge-sandbox
restart: unless-stopped
fastgpt-plugin:
image: ghcr.io/labring/fastgpt-plugin:latest
container_name: storyforge-fastgpt-plugin
restart: unless-stopped
cli-proxy-api:
image: ${CLIPROXY_IMAGE:-storyforge/cli-proxy-api:patched}
container_name: storyforge-cliproxyapi
restart: unless-stopped
ports:
- "8317:8317"
- "8085:8085"

7
scripts/bootstrap.sh Executable file
View File

@@ -0,0 +1,7 @@
#!/bin/sh
set -eu
cd /Users/kris/code/StoryForge/collector-service
python3 -m venv .venv
. .venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --host 0.0.0.0 --port 8081 --reload

25
scripts/start_collector.sh Executable file
View File

@@ -0,0 +1,25 @@
#!/bin/sh
set -eu
ROOT="/Users/kris/code/StoryForge"
PID_FILE="$ROOT/data/collector/collector.pid"
LOG_FILE="$ROOT/data/collector/collector.log"
VENV="$ROOT/collector-service/.venv311"
mkdir -p "$ROOT/data/collector"
if [ ! -x "$VENV/bin/python" ]; then
/opt/homebrew/bin/python3.11 -m venv "$VENV"
. "$VENV/bin/activate"
pip install -q -r "$ROOT/collector-service/requirements.txt"
else
. "$VENV/bin/activate"
fi
if [ -f "$PID_FILE" ]; then
PID="$(cat "$PID_FILE" || true)"
if [ -n "$PID" ] && kill -0 "$PID" 2>/dev/null; then
echo "collector already running: $PID"
exit 0
fi
fi
cd "$ROOT/collector-service"
nohup "$VENV/bin/python" -m uvicorn app.main:app --host 0.0.0.0 --port 8081 >"$LOG_FILE" 2>&1 &
echo $! > "$PID_FILE"
echo "collector started: $(cat "$PID_FILE")"

15
scripts/status_collector.sh Executable file
View File

@@ -0,0 +1,15 @@
#!/bin/sh
set -eu
PID_FILE="/Users/kris/code/StoryForge/data/collector/collector.pid"
if [ -f "$PID_FILE" ]; then
PID="$(cat "$PID_FILE" || true)"
if [ -n "$PID" ] && kill -0 "$PID" 2>/dev/null; then
echo "running:$PID"
exit 0
fi
fi
if lsof -nP -iTCP:8081 -sTCP:LISTEN >/dev/null 2>&1; then
echo "running:port"
else
echo "stopped"
fi

15
scripts/stop_collector.sh Executable file
View File

@@ -0,0 +1,15 @@
#!/bin/sh
set -eu
PID_FILE="/Users/kris/code/StoryForge/data/collector/collector.pid"
if [ ! -f "$PID_FILE" ]; then
echo "collector not running"
exit 0
fi
PID="$(cat "$PID_FILE" || true)"
if [ -n "$PID" ] && kill -0 "$PID" 2>/dev/null; then
kill "$PID"
echo "collector stopped: $PID"
else
echo "collector pid stale: $PID"
fi
rm -f "$PID_FILE"