Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c96588d79 | ||
|
|
9295fe8021 | ||
|
|
5247eef0fc | ||
|
|
2725fdd1d5 | ||
|
|
367ef7f2d6 | ||
|
|
a57692353c | ||
|
|
9b8c8ab37d |
No files matched your search
+29
-5
@@ -1,12 +1,36 @@
|
|||||||
*.iml
|
# ---> Android
|
||||||
|
# Gradle files
|
||||||
.gradle/
|
.gradle/
|
||||||
build/
|
build/
|
||||||
|
|
||||||
|
# Local configuration file (sdk path, etc)
|
||||||
local.properties
|
local.properties
|
||||||
.idea/
|
|
||||||
.DS_Store
|
# Log/OS Files
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Android Studio generated files and folders
|
||||||
captures/
|
captures/
|
||||||
.externalNativeBuild/
|
.externalNativeBuild/
|
||||||
.cxx/
|
.cxx/
|
||||||
|
*.apk
|
||||||
|
output.json
|
||||||
|
|
||||||
# 本地设计/协作文档,不进仓库
|
# IntelliJ
|
||||||
devdocs/
|
*.iml
|
||||||
|
.idea/
|
||||||
|
misc.xml
|
||||||
|
deploymentTargetDropDown.xml
|
||||||
|
render.experimental.xml
|
||||||
|
|
||||||
|
# Keystore files
|
||||||
|
*.jks
|
||||||
|
*.keystore
|
||||||
|
|
||||||
|
# Google Services (e.g. APIs or Firebase)
|
||||||
|
google-services.json
|
||||||
|
|
||||||
|
# Android Profiling
|
||||||
|
*.hprof
|
||||||
|
|
||||||
|
keystore.properties
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright 2026 AskaEth
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
http://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.
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
# Cyrene for Android
|
||||||
|
|
||||||
|
昔涟在安卓设备上的载体 —— 随时随地与昔涟对话、操控 IoT 设备、接收实时通知。
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
Cyrene for Android 是 [Cyrene(昔涟)](https://github.com/Cyrene/Cyrene) 项目的官方 Android 客户端。Cyrene 是一个开源的基于 LLM 平台的智能体,提供多人格对话、IoT 设备操控、记忆管理、自动化规则、知识库、语音交互等功能。
|
||||||
|
|
||||||
|
Android 客户端的目标是成为用户的**默认语音助手**,完全替代系统自带的助手(Google Assistant / Bixby 等),同时提供:
|
||||||
|
|
||||||
|
- **系统级语音助手** — 注册为 `VoiceInteractionService`,可通过长按 Home / 侧滑呼出
|
||||||
|
- **语音唤醒** — 息屏热词唤醒、免提交互(类似 "Hey Google",使用 "昔涟" 等唤醒词)
|
||||||
|
- **随时对话** — 通过文字或语音与昔涟交流
|
||||||
|
- **IoT 控制** — 远程操控家中的智能设备
|
||||||
|
- **实时通知** — 接收昔涟的主动消息、提醒、IoT 状态变更
|
||||||
|
- **后台连接** — 即使 APP 在后台,仍保持通知推送与语音服务就绪
|
||||||
|
|
||||||
|
## 开发状态
|
||||||
|
|
||||||
|
> **当前阶段**:项目初始化,尚未开始正式开发。
|
||||||
|
|
||||||
|
根据主项目的[开发路线图](../docs/dev-plan/00-development-roadmap.md),Android 客户端计划在 **Phase 5(v1.5 → v2.0)** 开始开发,预计时间窗口为 **2027 Q2-Q3**。
|
||||||
|
|
||||||
|
## 技术栈 (规划)
|
||||||
|
|
||||||
|
| 层 | 技术 |
|
||||||
|
|----|------|
|
||||||
|
| 语言 | Kotlin |
|
||||||
|
| UI 框架 | Jetpack Compose + Material Design 3 |
|
||||||
|
| 架构 | MVVM + Repository |
|
||||||
|
| 网络 | OkHttp / Retrofit + WebSocket |
|
||||||
|
| 本地存储 | Room (SQLite) + DataStore |
|
||||||
|
| 推送 | FCM (Firebase Cloud Messaging) |
|
||||||
|
| 语音 | VoiceInteractionService + 热词唤醒 + STT + TTS |
|
||||||
|
| 构建 | Gradle (Kotlin DSL) |
|
||||||
|
|
||||||
|
## 功能路线图
|
||||||
|
|
||||||
|
- [ ] 用户认证与登录
|
||||||
|
- [ ] 实时文字对话 (WebSocket)
|
||||||
|
- [ ] VoiceInteractionService 注册(替换系统语音助手)
|
||||||
|
- [ ] 间接启动悬浮窗(VoiceInteractionSession 全屏覆盖层)
|
||||||
|
- [ ] 热词唤醒(息屏 / 亮屏)+ 唤醒词自定义
|
||||||
|
- [ ] 语音识别 (STT)
|
||||||
|
- [ ] 语音合成 (TTS)
|
||||||
|
- [ ] IoT 设备控制面板
|
||||||
|
- [ ] 推送通知 (FCM)
|
||||||
|
- [ ] 后台连接与通知
|
||||||
|
- [ ] 锁屏 / 息屏语音交互
|
||||||
|
- [ ] PWA 保底方案 (短期替代)
|
||||||
|
|
||||||
|
## 交互模式
|
||||||
|
|
||||||
|
APP 有两种界面呈现方式,根据启动来源自动切换:
|
||||||
|
|
||||||
|
| 启动方式 | 界面模式 | 说明 |
|
||||||
|
|---------|---------|------|
|
||||||
|
| 桌面图标 / 最近任务 | **全屏 Activity** | 常规 APP 模式,完整功能入口 |
|
||||||
|
| 语音唤醒 / 长按 Home / 侧滑 / 长按电源键 / 耳机按键 | **悬浮窗 (VoiceInteractionSession)** | 全屏覆盖层,半透明背景透出底层 APP,不影响当前任务栈 |
|
||||||
|
|
||||||
|
悬浮窗模式的要点:
|
||||||
|
- 借助 `VoiceInteractionSession` 系统窗口,不压入 Activity 返回栈
|
||||||
|
- 对话结束后窗口收起,用户回到触发前的界面
|
||||||
|
- 底层 APP 内容半透明可见(模糊遮罩),让用户保持上下文感知
|
||||||
|
- 窗口高度自适应对话内容,类似 Google Assistant 的卡片式覆盖
|
||||||
|
|
||||||
|
## 设计规范
|
||||||
|
|
||||||
|
- **设计语言**:Material Design 3 (Material You)
|
||||||
|
- **主题**:动态配色(Dynamic Color),跟随系统壁纸或手动选择主题色
|
||||||
|
- **暗黑模式**:支持 Light / Dark 双主题,跟随系统或手动切换
|
||||||
|
- **组件**:全面使用 `androidx.compose.material3` 组件库
|
||||||
|
- **图标**:Material Icons + 自定义图标
|
||||||
|
- **动效**:遵循 MD3 动效规范(过渡动画、涟漪效果、Shared Elements)
|
||||||
|
- **字体**:系统默认字体(Roboto / Google Sans),支持动态字体缩放
|
||||||
|
|
||||||
|
## 项目结构 (规划)
|
||||||
|
|
||||||
|
```
|
||||||
|
android/
|
||||||
|
├── app/
|
||||||
|
│ ├── src/main/
|
||||||
|
│ │ ├── java/com/cyrene/app/
|
||||||
|
│ │ │ ├── ui/ # Compose UI 层
|
||||||
|
│ │ │ ├── viewmodel/ # ViewModel 层
|
||||||
|
│ │ │ ├── repository/ # 数据仓库层
|
||||||
|
│ │ │ ├── data/ # 数据模型 & API 接口
|
||||||
|
│ │ │ ├── service/ # VoiceInteractionService & WebSocket & FCM
|
||||||
|
│ │ │ ├── voice/ # 热词唤醒 (Hotword) & STT & TTS
|
||||||
|
│ │ │ └── di/ # 依赖注入
|
||||||
|
│ │ └── res/ # 资源文件
|
||||||
|
│ └── build.gradle.kts
|
||||||
|
├── build.gradle.kts
|
||||||
|
├── settings.gradle.kts
|
||||||
|
└── gradle.properties
|
||||||
|
```
|
||||||
|
|
||||||
|
## 环境要求
|
||||||
|
|
||||||
|
- Android Studio Hedgehog (2023.1.1) 或更高版本
|
||||||
|
- Kotlin 2.0+
|
||||||
|
- JDK 17+
|
||||||
|
- Android SDK (target: 34+, min: 26+)
|
||||||
|
|
||||||
|
## 网络配置(中国大陆用户)
|
||||||
|
|
||||||
|
Gradle、Google Maven、Maven Central 等服务器在国内访问缓慢或不可达,首次同步前需配置镜像。
|
||||||
|
|
||||||
|
### Gradle Wrapper
|
||||||
|
|
||||||
|
`gradle/wrapper/gradle-wrapper.properties`:
|
||||||
|
|
||||||
|
```properties
|
||||||
|
# 将 services.gradle.org 替换为腾讯/阿里镜像
|
||||||
|
distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-8.7-bin.zip
|
||||||
|
# 或阿里: https\://mirrors.aliyun.com/macports/distfiles/gradle/gradle-8.7-bin.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
### 仓库镜像
|
||||||
|
|
||||||
|
`settings.gradle.kts` 顶部添加:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
maven { url = uri("https://mirrors.cloud.tencent.com/gradle/plugins") }
|
||||||
|
maven { url = uri("https://mirrors.tencent.com/nexus/repository/maven-public") }
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
maven { url = uri("https://mirrors.tencent.com/nexus/repository/maven-public") }
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Gradle 属性
|
||||||
|
|
||||||
|
`gradle.properties`:
|
||||||
|
|
||||||
|
```properties
|
||||||
|
# HTTP 代理(如使用 Clash/V2Ray,通常不需要)
|
||||||
|
# systemProp.http.proxyHost=127.0.0.1
|
||||||
|
# systemProp.http.proxyPort=7890
|
||||||
|
# systemProp.https.proxyHost=127.0.0.1
|
||||||
|
# systemProp.https.proxyPort=7890
|
||||||
|
```
|
||||||
|
|
||||||
|
### Android SDK Proxy
|
||||||
|
|
||||||
|
若 Android SDK Manager 也下载缓慢,可在 Android Studio 中设置:
|
||||||
|
**Settings → Appearance & Behavior → System Settings → HTTP Proxy**
|
||||||
|
|
||||||
|
配置为 `mirrors.cloud.tencent.com` 或 `mirrors.neusoft.edu.cn`,端口 `80`。
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
> 项目尚未包含可构建的源码,以下为后续开发的参考步骤。
|
||||||
|
|
||||||
|
1. 用 Android Studio 打开 `android/` 目录
|
||||||
|
2. 按上述"网络配置"替换国内镜像
|
||||||
|
3. 等待 Gradle 同步完成
|
||||||
|
4. 启动主项目后端服务(参考[主项目 README](../README.md))
|
||||||
|
5. 在 `local.properties` 中配置 `baseUrl` 指向 Gateway 地址
|
||||||
|
6. 选择模拟器或设备,点击 Run
|
||||||
|
|
||||||
|
## 相关链接
|
||||||
|
|
||||||
|
- [Cyrene 主项目](../) — 后端服务、前端、部署文档
|
||||||
|
- [开发路线图](../docs/dev-plan/00-development-roadmap.md)
|
||||||
|
- [多平台接入方案](../docs/dev-plan/03-multi-platform-integration.md)
|
||||||
|
- [语音系统计划](../docs/dev-plan/04-voice-system-plan.md)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Apache-2.0
|
||||||
+75
-36
@@ -1,30 +1,58 @@
|
|||||||
|
import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
|
alias(libs.plugins.kotlin.android)
|
||||||
alias(libs.plugins.kotlin.compose)
|
alias(libs.plugins.kotlin.compose)
|
||||||
alias(libs.plugins.kotlin.serialization)
|
alias(libs.plugins.ksp)
|
||||||
|
}
|
||||||
|
|
||||||
|
val keystorePropertiesFile = rootProject.file("keystore.properties")
|
||||||
|
val keystoreProperties = Properties()
|
||||||
|
if (keystorePropertiesFile.exists()) {
|
||||||
|
keystoreProperties.load(keystorePropertiesFile.inputStream())
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
if (keystorePropertiesFile.exists()) {
|
||||||
|
create("release") {
|
||||||
|
storeFile = rootProject.file(keystoreProperties["storeFile"] as String)
|
||||||
|
storePassword = keystoreProperties["storePassword"] as String
|
||||||
|
keyAlias = keystoreProperties["keyAlias"] as String
|
||||||
|
keyPassword = keystoreProperties["keyPassword"] as String
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
namespace = "top.yeij.cyrene"
|
namespace = "top.yeij.cyrene"
|
||||||
compileSdk = 37
|
compileSdk = 36
|
||||||
buildToolsVersion = "37.0.0"
|
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "top.yeij.cyrene"
|
applicationId = "top.yeij.cyrene"
|
||||||
minSdk = 33
|
minSdk = 26
|
||||||
targetSdk = 37
|
targetSdk = 36
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "0.1.0"
|
versionName = "0.1.0"
|
||||||
|
|
||||||
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
isMinifyEnabled = false
|
if (keystorePropertiesFile.exists()) {
|
||||||
|
signingConfig = signingConfigs.getByName("release")
|
||||||
|
}
|
||||||
|
isMinifyEnabled = true
|
||||||
proguardFiles(
|
proguardFiles(
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro",
|
"proguard-rules.pro"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
debug {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
@@ -32,47 +60,58 @@ android {
|
|||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "17"
|
||||||
|
}
|
||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
buildConfig = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
kotlin {
|
|
||||||
compilerOptions {
|
|
||||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(libs.androidx.core.ktx)
|
// Compose BOM
|
||||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
val composeBom = platform(libs.compose.bom)
|
||||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
implementation(composeBom)
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.compose.ui)
|
||||||
|
implementation(libs.compose.ui.graphics)
|
||||||
|
implementation(libs.compose.ui.tooling.preview)
|
||||||
|
implementation(libs.compose.material3)
|
||||||
|
implementation(libs.compose.material.icons)
|
||||||
|
debugImplementation(libs.compose.ui.tooling)
|
||||||
|
|
||||||
implementation(platform(libs.androidx.compose.bom))
|
// Activity & Navigation
|
||||||
implementation(libs.androidx.compose.ui)
|
implementation(libs.compose.activity)
|
||||||
implementation(libs.androidx.compose.ui.graphics)
|
implementation(libs.compose.navigation)
|
||||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
|
||||||
implementation(libs.androidx.compose.foundation)
|
|
||||||
|
|
||||||
implementation(libs.miuix.ui)
|
// Lifecycle
|
||||||
implementation(libs.miuix.icons)
|
implementation(libs.lifecycle.runtime)
|
||||||
implementation(libs.miuix.preference)
|
implementation(libs.lifecycle.viewmodel)
|
||||||
|
|
||||||
|
// Room
|
||||||
|
implementation(libs.room.runtime)
|
||||||
|
implementation(libs.room.ktx)
|
||||||
|
ksp(libs.room.compiler)
|
||||||
|
|
||||||
|
// Network
|
||||||
implementation(libs.retrofit)
|
implementation(libs.retrofit)
|
||||||
implementation(libs.retrofit.kotlinx.serialization)
|
implementation(libs.retrofit.gson)
|
||||||
implementation(libs.okhttp)
|
implementation(libs.okhttp)
|
||||||
implementation(libs.okhttp.logging)
|
implementation(libs.okhttp.logging)
|
||||||
implementation(libs.kotlinx.serialization.json)
|
|
||||||
implementation(libs.kotlinx.coroutines.android)
|
|
||||||
implementation(libs.androidx.security.crypto)
|
|
||||||
implementation(libs.androidx.datastore.preferences)
|
|
||||||
implementation(libs.coil.compose)
|
|
||||||
implementation(libs.coil.network.okhttp)
|
|
||||||
|
|
||||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
// Koin DI
|
||||||
|
implementation(libs.koin.android)
|
||||||
|
implementation(libs.koin.compose)
|
||||||
|
|
||||||
testImplementation(libs.junit)
|
// DataStore
|
||||||
|
implementation(libs.datastore)
|
||||||
|
|
||||||
|
// Coroutines
|
||||||
|
implementation(libs.coroutines)
|
||||||
|
|
||||||
|
// Core
|
||||||
|
implementation(libs.core.ktx)
|
||||||
|
|
||||||
|
// Biometric
|
||||||
|
implementation(libs.biometric)
|
||||||
}
|
}
|
||||||
|
|
||||||
Vendored
+26
-9
@@ -1,10 +1,27 @@
|
|||||||
# kotlinx-serialization:保留 @Serializable 类的序列化器(M2 起启用)
|
# Cyrene ProGuard Rules
|
||||||
-keepattributes *Annotation*, InnerClasses
|
|
||||||
-dontnote kotlinx.serialization.**
|
|
||||||
|
|
||||||
-keepclassmembers class **$$serializer {
|
# Retrofit
|
||||||
*** descriptor;
|
-keepattributes Signature
|
||||||
}
|
-keepattributes *Annotation*
|
||||||
-keepclasseswithmembers class ** {
|
-keep class top.yeij.cyrene.data.remote.dto.** { *; }
|
||||||
kotlinx.serialization.KSerializer serializer(...);
|
-dontwarn retrofit2.**
|
||||||
}
|
-keep class retrofit2.** { *; }
|
||||||
|
|
||||||
|
# Gson
|
||||||
|
-keep class com.google.gson.** { *; }
|
||||||
|
-keepattributes EnclosingMethod
|
||||||
|
|
||||||
|
# OkHttp
|
||||||
|
-dontwarn okhttp3.**
|
||||||
|
-dontwarn okio.**
|
||||||
|
|
||||||
|
# Room
|
||||||
|
-keep class * extends androidx.room.RoomDatabase
|
||||||
|
-dontwarn androidx.room.paging.**
|
||||||
|
|
||||||
|
# Koin
|
||||||
|
-keep class org.koin.** { *; }
|
||||||
|
|
||||||
|
# Coroutines
|
||||||
|
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
|
||||||
|
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
|
||||||
@@ -1,26 +1,99 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<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.RECORD_AUDIO" />
|
||||||
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||||
|
<uses-permission android:name="android.permission.CAPTURE_AUDIO_HOTWORD" />
|
||||||
|
|
||||||
|
<!-- 后台服务 -->
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||||
|
|
||||||
|
<!-- 推送 -->
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
|
<!-- 锁屏交互 -->
|
||||||
|
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
|
||||||
|
|
||||||
|
<!-- 查询其他应用(检查默认助手设置) -->
|
||||||
|
<queries>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.service.voice.VoiceInteractionService" />
|
||||||
|
</intent>
|
||||||
|
</queries>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".app.CyreneApplication"
|
android:name=".CyreneApplication"
|
||||||
android:allowBackup="false"
|
android:allowBackup="true"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:networkSecurityConfig="@xml/network_security_config"
|
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.Cyrene">
|
android:theme="@style/Theme.Cyrene"
|
||||||
|
android:usesCleartextTraffic="true">
|
||||||
|
|
||||||
|
<!-- 全屏主界面 -->
|
||||||
<activity
|
<activity
|
||||||
android:name=".app.MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:windowSoftInputMode="adjustResize">
|
android:launchMode="singleTask"
|
||||||
|
android:theme="@style/Theme.Cyrene">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<!-- VoiceInteractionService:系统语音助手 -->
|
||||||
|
<service
|
||||||
|
android:name=".service.CyreneVoiceInteractionService"
|
||||||
|
android:exported="true"
|
||||||
|
android:permission="android.permission.BIND_VOICE_INTERACTION">
|
||||||
|
|
||||||
|
<meta-data
|
||||||
|
android:name="android.voice_interaction"
|
||||||
|
android:resource="@xml/voice_interaction_config" />
|
||||||
|
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.service.voice.VoiceInteractionService" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
|
||||||
|
<!-- VoiceInteractionSessionService -->
|
||||||
|
<service
|
||||||
|
android:name=".service.CyreneSessionService"
|
||||||
|
android:exported="true"
|
||||||
|
android:permission="android.permission.BIND_VOICE_INTERACTION" />
|
||||||
|
|
||||||
|
<!-- AccessibilityService:读取屏幕内容 -->
|
||||||
|
<service
|
||||||
|
android:name=".service.CyreneAccessibilityService"
|
||||||
|
android:exported="true"
|
||||||
|
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.accessibilityservice.AccessibilityService" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.accessibilityservice"
|
||||||
|
android:resource="@xml/accessibility_config" />
|
||||||
|
</service>
|
||||||
|
|
||||||
|
<!-- FileProvider:日志分享 -->
|
||||||
|
<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>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package top.yeij.cyrene
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.app.Application
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.koin.android.ext.koin.androidContext
|
||||||
|
import org.koin.core.context.GlobalContext
|
||||||
|
import org.koin.core.context.startKoin
|
||||||
|
import top.yeij.cyrene.data.local.PreferencesDataStore
|
||||||
|
import top.yeij.cyrene.data.remote.AuthInterceptor
|
||||||
|
import top.yeij.cyrene.data.remote.DynamicUrlInterceptor
|
||||||
|
import top.yeij.cyrene.data.repository.ChatRepositoryImpl
|
||||||
|
import top.yeij.cyrene.di.appModule
|
||||||
|
import top.yeij.cyrene.util.NotificationHelper
|
||||||
|
import top.yeij.cyrene.util.RuntimeLog
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
|
class CyreneApplication : Application() {
|
||||||
|
|
||||||
|
private val initScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
private val activityCount = AtomicInteger(0)
|
||||||
|
@Volatile
|
||||||
|
private var notificationHelper: NotificationHelper? = null
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
RuntimeLog.general("app", "Application onCreate")
|
||||||
|
|
||||||
|
startKoin {
|
||||||
|
androidContext(this@CyreneApplication)
|
||||||
|
modules(appModule)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track foreground/background state
|
||||||
|
registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks {
|
||||||
|
override fun onActivityStarted(activity: Activity) {
|
||||||
|
if (activityCount.incrementAndGet() == 1) {
|
||||||
|
RuntimeLog.general("app", "App in foreground")
|
||||||
|
notificationHelper?.cancelAll()
|
||||||
|
getRepo()?.onAppForeground()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onActivityStopped(activity: Activity) {
|
||||||
|
if (activityCount.decrementAndGet() == 0) {
|
||||||
|
RuntimeLog.general("app", "App in background")
|
||||||
|
getRepo()?.onAppBackground()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
|
||||||
|
override fun onActivityResumed(activity: Activity) {}
|
||||||
|
override fun onActivityPaused(activity: Activity) {}
|
||||||
|
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
|
||||||
|
override fun onActivityDestroyed(activity: Activity) {}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Set up background notification callback once Koin is ready
|
||||||
|
initScope.launch {
|
||||||
|
val helper = NotificationHelper(this@CyreneApplication)
|
||||||
|
notificationHelper = helper
|
||||||
|
val repo = getRepo()
|
||||||
|
repo?.setNotificationCallback { message ->
|
||||||
|
helper.showMessageNotification(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
initScope.launch {
|
||||||
|
val koin = GlobalContext.get()
|
||||||
|
val prefs: PreferencesDataStore = koin.get()
|
||||||
|
val urlInterceptor: DynamicUrlInterceptor = koin.get()
|
||||||
|
val authInterceptor: AuthInterceptor = koin.get()
|
||||||
|
|
||||||
|
prefs.baseUrl.firstOrNull()?.let { url ->
|
||||||
|
if (url.isNotBlank()) urlInterceptor.baseUrl = url
|
||||||
|
}
|
||||||
|
prefs.token.firstOrNull()?.let { token ->
|
||||||
|
authInterceptor.token = token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getRepo(): ChatRepositoryImpl? {
|
||||||
|
return try {
|
||||||
|
GlobalContext.get().get()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "CyreneApp"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package top.yeij.cyrene
|
||||||
|
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.provider.Settings
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import org.koin.compose.koinInject
|
||||||
|
import top.yeij.cyrene.data.local.PreferencesDataStore
|
||||||
|
import top.yeij.cyrene.service.CyreneVoiceInteractionService
|
||||||
|
import top.yeij.cyrene.ui.navigation.CyreneNavGraph
|
||||||
|
import top.yeij.cyrene.ui.navigation.Routes
|
||||||
|
import top.yeij.cyrene.ui.theme.CyreneTheme
|
||||||
|
import top.yeij.cyrene.util.Constants
|
||||||
|
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
private val isDefaultAssistant = mutableStateOf(false)
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
enableEdgeToEdge()
|
||||||
|
|
||||||
|
isDefaultAssistant.value = checkIsDefaultAssistant()
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
val prefs: PreferencesDataStore = koinInject()
|
||||||
|
val themeMode by prefs.themeMode.collectAsState(initial = null)
|
||||||
|
val darkTheme = when (themeMode) {
|
||||||
|
"light" -> false
|
||||||
|
"dark" -> true
|
||||||
|
else -> isSystemInDarkTheme()
|
||||||
|
}
|
||||||
|
|
||||||
|
CyreneTheme(darkTheme = darkTheme) {
|
||||||
|
val navController = rememberNavController()
|
||||||
|
|
||||||
|
CyreneNavGraph(
|
||||||
|
navController = navController,
|
||||||
|
startDestination = Routes.MAIN,
|
||||||
|
isDefaultAssistant = isDefaultAssistant.value,
|
||||||
|
onOpenAssistantSettings = { openAssistantSettings() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
isDefaultAssistant.value = checkIsDefaultAssistant()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
setIntent(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkIsDefaultAssistant(): Boolean {
|
||||||
|
// Standard Android check
|
||||||
|
val flat = ComponentName(this, CyreneVoiceInteractionService::class.java).flattenToString()
|
||||||
|
val current = Settings.Secure.getString(contentResolver, "voice_interaction_service")
|
||||||
|
if (current == flat) return true
|
||||||
|
// Fallback for COS and other custom OS: check persisted flag from service
|
||||||
|
if (CyreneVoiceInteractionService.wasEverActive(this)) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openAssistantSettings() {
|
||||||
|
startActivity(Intent(Settings.ACTION_VOICE_INPUT_SETTINGS))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package top.yeij.cyrene.data.local
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.room.Database
|
||||||
|
import androidx.room.Room
|
||||||
|
import androidx.room.RoomDatabase
|
||||||
|
import top.yeij.cyrene.data.local.dao.ConversationDao
|
||||||
|
import top.yeij.cyrene.data.local.dao.MessageDao
|
||||||
|
import top.yeij.cyrene.data.local.entity.ConversationEntity
|
||||||
|
import top.yeij.cyrene.data.local.entity.MessageEntity
|
||||||
|
|
||||||
|
@Database(
|
||||||
|
entities = [ConversationEntity::class, MessageEntity::class],
|
||||||
|
version = 1,
|
||||||
|
exportSchema = false,
|
||||||
|
)
|
||||||
|
abstract class AppDatabase : RoomDatabase() {
|
||||||
|
|
||||||
|
abstract fun conversationDao(): ConversationDao
|
||||||
|
abstract fun messageDao(): MessageDao
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@Volatile
|
||||||
|
private var INSTANCE: AppDatabase? = null
|
||||||
|
|
||||||
|
fun getInstance(context: Context): AppDatabase {
|
||||||
|
return INSTANCE ?: synchronized(this) {
|
||||||
|
INSTANCE ?: Room.databaseBuilder(
|
||||||
|
context.applicationContext,
|
||||||
|
AppDatabase::class.java,
|
||||||
|
"cyrene.db",
|
||||||
|
)
|
||||||
|
.fallbackToDestructiveMigration()
|
||||||
|
.build()
|
||||||
|
.also { INSTANCE = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package top.yeij.cyrene.data.local
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
|
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "cyrene_prefs")
|
||||||
|
|
||||||
|
class PreferencesDataStore(private val context: Context) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val KEY_TOKEN = stringPreferencesKey("jwt_token")
|
||||||
|
private val KEY_REFRESH_TOKEN = stringPreferencesKey("refresh_token")
|
||||||
|
private val KEY_BASE_URL = stringPreferencesKey("base_url")
|
||||||
|
private val KEY_THEME = stringPreferencesKey("theme_mode")
|
||||||
|
private val KEY_WAKE_WORD = stringPreferencesKey("wake_word")
|
||||||
|
private val KEY_USERNAME = stringPreferencesKey("username")
|
||||||
|
private val KEY_CLIENT_ID = stringPreferencesKey("client_id")
|
||||||
|
private val KEY_DEVICE_NAME = stringPreferencesKey("device_name")
|
||||||
|
private val KEY_CURRENT_SESSION_ID = stringPreferencesKey("current_session_id")
|
||||||
|
private val KEY_DASHSCOPE_API_KEY = stringPreferencesKey("dashscope_api_key")
|
||||||
|
private val KEY_DASHSCOPE_ENDPOINT = stringPreferencesKey("dashscope_endpoint")
|
||||||
|
private val KEY_DASHSCOPE_MODEL = stringPreferencesKey("dashscope_model")
|
||||||
|
private val KEY_LAST_CLEARED_TIMESTAMP = stringPreferencesKey("last_cleared_timestamp")
|
||||||
|
private val KEY_PROFILE_USER_ID = stringPreferencesKey("profile_user_id")
|
||||||
|
private val KEY_PROFILE_NICKNAME = stringPreferencesKey("profile_nickname")
|
||||||
|
private val KEY_PROFILE_IS_ADMIN = stringPreferencesKey("profile_is_admin")
|
||||||
|
private val KEY_PROFILE_CREATED_AT = stringPreferencesKey("profile_created_at")
|
||||||
|
private val KEY_AUTO_SCREEN_CONTEXT = booleanPreferencesKey("auto_screen_context")
|
||||||
|
}
|
||||||
|
|
||||||
|
val token: Flow<String?> = context.dataStore.data.map { it[KEY_TOKEN] }
|
||||||
|
val refreshToken: Flow<String?> = context.dataStore.data.map { it[KEY_REFRESH_TOKEN] }
|
||||||
|
val baseUrl: Flow<String?> = context.dataStore.data.map { it[KEY_BASE_URL] }
|
||||||
|
val themeMode: Flow<String?> = context.dataStore.data.map { it[KEY_THEME] }
|
||||||
|
val wakeWord: Flow<String?> = context.dataStore.data.map { it[KEY_WAKE_WORD] }
|
||||||
|
val username: Flow<String?> = context.dataStore.data.map { it[KEY_USERNAME] }
|
||||||
|
val clientId: Flow<String?> = context.dataStore.data.map { it[KEY_CLIENT_ID] }
|
||||||
|
val deviceName: Flow<String?> = context.dataStore.data.map { it[KEY_DEVICE_NAME] }
|
||||||
|
val currentSessionId: Flow<String?> = context.dataStore.data.map { it[KEY_CURRENT_SESSION_ID] }
|
||||||
|
val dashScopeApiKey: Flow<String?> = context.dataStore.data.map { it[KEY_DASHSCOPE_API_KEY] }
|
||||||
|
val dashScopeEndpoint: Flow<String?> = context.dataStore.data.map { it[KEY_DASHSCOPE_ENDPOINT] }
|
||||||
|
val dashScopeModel: Flow<String?> = context.dataStore.data.map { it[KEY_DASHSCOPE_MODEL] }
|
||||||
|
|
||||||
|
suspend fun saveToken(token: String) {
|
||||||
|
context.dataStore.edit { it[KEY_TOKEN] = token }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveRefreshToken(token: String) {
|
||||||
|
context.dataStore.edit { it[KEY_REFRESH_TOKEN] = token }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveBaseUrl(url: String) {
|
||||||
|
context.dataStore.edit { it[KEY_BASE_URL] = url }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveThemeMode(mode: String) {
|
||||||
|
context.dataStore.edit { it[KEY_THEME] = mode }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveWakeWord(word: String) {
|
||||||
|
context.dataStore.edit { it[KEY_WAKE_WORD] = word }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveUsername(username: String) {
|
||||||
|
context.dataStore.edit { it[KEY_USERNAME] = username }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveDashScopeApiKey(key: String) {
|
||||||
|
context.dataStore.edit { it[KEY_DASHSCOPE_API_KEY] = key }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveDashScopeEndpoint(endpoint: String) {
|
||||||
|
context.dataStore.edit { it[KEY_DASHSCOPE_ENDPOINT] = endpoint }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveDashScopeModel(model: String) {
|
||||||
|
context.dataStore.edit { it[KEY_DASHSCOPE_MODEL] = model }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveClientId(id: String) {
|
||||||
|
context.dataStore.edit { it[KEY_CLIENT_ID] = id }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveDeviceName(name: String) {
|
||||||
|
context.dataStore.edit { it[KEY_DEVICE_NAME] = name }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveCurrentSessionId(id: String) {
|
||||||
|
context.dataStore.edit { it[KEY_CURRENT_SESSION_ID] = id }
|
||||||
|
}
|
||||||
|
|
||||||
|
val lastClearedTimestamp: Flow<String?> = context.dataStore.data.map { it[KEY_LAST_CLEARED_TIMESTAMP] }
|
||||||
|
|
||||||
|
suspend fun saveLastClearedTimestamp(timestamp: Long) {
|
||||||
|
context.dataStore.edit { it[KEY_LAST_CLEARED_TIMESTAMP] = timestamp.toString() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cached profile for offline / local-first rendering
|
||||||
|
val profileUserId: Flow<String?> = context.dataStore.data.map { it[KEY_PROFILE_USER_ID] }
|
||||||
|
val profileNickname: Flow<String?> = context.dataStore.data.map { it[KEY_PROFILE_NICKNAME] }
|
||||||
|
val profileIsAdmin: Flow<String?> = context.dataStore.data.map { it[KEY_PROFILE_IS_ADMIN] }
|
||||||
|
val profileCreatedAt: Flow<String?> = context.dataStore.data.map { it[KEY_PROFILE_CREATED_AT] }
|
||||||
|
|
||||||
|
suspend fun saveProfileCache(userId: String, nickname: String, isAdmin: Boolean, createdAt: String) {
|
||||||
|
context.dataStore.edit {
|
||||||
|
it[KEY_PROFILE_USER_ID] = userId
|
||||||
|
it[KEY_PROFILE_NICKNAME] = nickname
|
||||||
|
it[KEY_PROFILE_IS_ADMIN] = isAdmin.toString()
|
||||||
|
it[KEY_PROFILE_CREATED_AT] = createdAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val autoScreenContext: Flow<Boolean> = context.dataStore.data.map { it[KEY_AUTO_SCREEN_CONTEXT] ?: false }
|
||||||
|
|
||||||
|
suspend fun saveAutoScreenContext(enabled: Boolean) {
|
||||||
|
context.dataStore.edit { it[KEY_AUTO_SCREEN_CONTEXT] = enabled }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun clearProfileCache() {
|
||||||
|
context.dataStore.edit {
|
||||||
|
it.remove(KEY_PROFILE_USER_ID)
|
||||||
|
it.remove(KEY_PROFILE_NICKNAME)
|
||||||
|
it.remove(KEY_PROFILE_IS_ADMIN)
|
||||||
|
it.remove(KEY_PROFILE_CREATED_AT)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun clearAll() {
|
||||||
|
context.dataStore.edit { it.clear() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package top.yeij.cyrene.data.local.dao
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy
|
||||||
|
import androidx.room.Query
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import top.yeij.cyrene.data.local.entity.ConversationEntity
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface ConversationDao {
|
||||||
|
|
||||||
|
@Query("SELECT * FROM conversations ORDER BY updatedAt DESC")
|
||||||
|
fun getAll(): Flow<List<ConversationEntity>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM conversations ORDER BY updatedAt DESC")
|
||||||
|
suspend fun getAllSnapshot(): List<ConversationEntity>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM conversations WHERE id = :id")
|
||||||
|
suspend fun getById(id: String): ConversationEntity?
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun upsert(conversation: ConversationEntity)
|
||||||
|
|
||||||
|
@Query("DELETE FROM conversations WHERE id = :id")
|
||||||
|
suspend fun deleteById(id: String)
|
||||||
|
|
||||||
|
@Query("DELETE FROM conversations")
|
||||||
|
suspend fun deleteAll()
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package top.yeij.cyrene.data.local.dao
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy
|
||||||
|
import androidx.room.Query
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import top.yeij.cyrene.data.local.entity.MessageEntity
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface MessageDao {
|
||||||
|
|
||||||
|
@Query("SELECT * FROM messages WHERE conversationId = :conversationId ORDER BY timestamp ASC")
|
||||||
|
fun getByConversation(conversationId: String): Flow<List<MessageEntity>>
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun upsert(message: MessageEntity)
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun upsertAll(messages: List<MessageEntity>)
|
||||||
|
|
||||||
|
@Query("DELETE FROM messages WHERE conversationId = :conversationId")
|
||||||
|
suspend fun deleteByConversation(conversationId: String)
|
||||||
|
|
||||||
|
@Query("UPDATE messages SET conversationId = :newId WHERE conversationId = :oldId")
|
||||||
|
suspend fun migrateConversationId(oldId: String, newId: String)
|
||||||
|
|
||||||
|
@Query("DELETE FROM messages WHERE id = :id")
|
||||||
|
suspend fun deleteById(id: String)
|
||||||
|
|
||||||
|
@Query("DELETE FROM messages")
|
||||||
|
suspend fun deleteAll()
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package top.yeij.cyrene.data.local.entity
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
|
@Entity(tableName = "conversations")
|
||||||
|
data class ConversationEntity(
|
||||||
|
@PrimaryKey val id: String,
|
||||||
|
val title: String,
|
||||||
|
val lastMessage: String?,
|
||||||
|
val lastMessageType: String?,
|
||||||
|
val updatedAt: Long,
|
||||||
|
val createdAt: Long,
|
||||||
|
)
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package top.yeij.cyrene.data.local.entity
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.ForeignKey
|
||||||
|
import androidx.room.Index
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = "messages",
|
||||||
|
foreignKeys = [
|
||||||
|
ForeignKey(
|
||||||
|
entity = ConversationEntity::class,
|
||||||
|
parentColumns = ["id"],
|
||||||
|
childColumns = ["conversationId"],
|
||||||
|
onDelete = ForeignKey.CASCADE,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
indices = [Index("conversationId")],
|
||||||
|
)
|
||||||
|
data class MessageEntity(
|
||||||
|
@PrimaryKey val id: String,
|
||||||
|
val conversationId: String,
|
||||||
|
val role: String,
|
||||||
|
val content: String,
|
||||||
|
val msgType: String,
|
||||||
|
val timestamp: Long,
|
||||||
|
)
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package top.yeij.cyrene.data.remote
|
||||||
|
|
||||||
|
import retrofit2.Response
|
||||||
|
import retrofit2.http.Body
|
||||||
|
import retrofit2.http.DELETE
|
||||||
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.POST
|
||||||
|
import retrofit2.http.Path
|
||||||
|
import retrofit2.http.Query
|
||||||
|
import top.yeij.cyrene.data.remote.dto.AuthRequest
|
||||||
|
import top.yeij.cyrene.data.remote.dto.AuthResponse
|
||||||
|
import top.yeij.cyrene.data.remote.dto.ProfileResponse
|
||||||
|
import top.yeij.cyrene.data.remote.dto.CreateSessionRequest
|
||||||
|
import top.yeij.cyrene.data.remote.dto.DeviceDto
|
||||||
|
import top.yeij.cyrene.data.remote.dto.IoTControlRequest
|
||||||
|
import top.yeij.cyrene.data.remote.dto.MessagesListResponse
|
||||||
|
import top.yeij.cyrene.data.remote.dto.RefreshTokenRequest
|
||||||
|
import top.yeij.cyrene.data.remote.dto.SessionDto
|
||||||
|
import top.yeij.cyrene.data.remote.dto.SessionsListResponse
|
||||||
|
|
||||||
|
interface ApiService {
|
||||||
|
|
||||||
|
// Auth
|
||||||
|
@POST("api/v1/auth/login")
|
||||||
|
suspend fun login(@Body request: AuthRequest): Response<AuthResponse>
|
||||||
|
|
||||||
|
@POST("api/v1/auth/refresh")
|
||||||
|
suspend fun refreshToken(@Body request: RefreshTokenRequest): Response<AuthResponse>
|
||||||
|
|
||||||
|
@GET("api/v1/profile")
|
||||||
|
suspend fun getProfile(): Response<ProfileResponse>
|
||||||
|
|
||||||
|
// Sessions
|
||||||
|
@GET("api/v1/sessions")
|
||||||
|
suspend fun getSessions(): Response<SessionsListResponse>
|
||||||
|
|
||||||
|
@POST("api/v1/sessions")
|
||||||
|
suspend fun createSession(@Body request: CreateSessionRequest): Response<SessionDto>
|
||||||
|
|
||||||
|
@DELETE("api/v1/sessions/{id}")
|
||||||
|
suspend fun deleteSession(@Path("id") id: String): Response<Unit>
|
||||||
|
|
||||||
|
@DELETE("api/v1/sessions/{id}/messages")
|
||||||
|
suspend fun clearSessionMessages(@Path("id") sessionId: String): Response<Unit>
|
||||||
|
|
||||||
|
@GET("api/v1/sessions/{id}/messages")
|
||||||
|
suspend fun getSessionMessages(
|
||||||
|
@Path("id") sessionId: String,
|
||||||
|
@Query("limit") limit: Int = 50,
|
||||||
|
@Query("offset") offset: Int = 0,
|
||||||
|
): Response<MessagesListResponse>
|
||||||
|
|
||||||
|
// IoT — 注意:网关 API 文档未列出 IoT 端点,需确认网关是否代理了 /api/v1/iot/*
|
||||||
|
@GET("api/v1/iot/devices")
|
||||||
|
suspend fun getDevices(): Response<List<DeviceDto>>
|
||||||
|
|
||||||
|
@POST("api/v1/iot/devices/{id}/control")
|
||||||
|
suspend fun controlDevice(
|
||||||
|
@Path("id") deviceId: String,
|
||||||
|
@Body request: IoTControlRequest,
|
||||||
|
): Response<DeviceDto>
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package top.yeij.cyrene.data.remote
|
||||||
|
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Response
|
||||||
|
|
||||||
|
class AuthInterceptor : Interceptor {
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
var token: String? = null
|
||||||
|
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val request = if (!token.isNullOrEmpty()) {
|
||||||
|
chain.request().newBuilder()
|
||||||
|
.addHeader("Authorization", "Bearer $token")
|
||||||
|
.build()
|
||||||
|
} else {
|
||||||
|
chain.request()
|
||||||
|
}
|
||||||
|
return chain.proceed(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package top.yeij.cyrene.data.remote
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Response
|
||||||
|
|
||||||
|
class DynamicUrlInterceptor : Interceptor {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "DynamicUrlInterceptor"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
var baseUrl: String = "http://10.0.2.2:8080/"
|
||||||
|
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val originalRequest = chain.request()
|
||||||
|
val originalUrl = originalRequest.url
|
||||||
|
|
||||||
|
val targetBase = baseUrl.trimEnd('/')
|
||||||
|
val parsed = targetBase.toHttpUrlOrNull()
|
||||||
|
if (parsed == null) {
|
||||||
|
Log.e(TAG, "Invalid baseUrl: '$baseUrl'")
|
||||||
|
return chain.proceed(chain.request())
|
||||||
|
}
|
||||||
|
|
||||||
|
val newUrl = originalUrl.newBuilder()
|
||||||
|
.scheme(parsed.scheme)
|
||||||
|
.host(parsed.host)
|
||||||
|
.port(parsed.port)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
Log.d(TAG, "Rewriting ${originalUrl} → $newUrl (base: $targetBase)")
|
||||||
|
return chain.proceed(originalRequest.newBuilder().url(newUrl).build())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package top.yeij.cyrene.data.remote
|
||||||
|
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.logging.HttpLoggingInterceptor
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import retrofit2.converter.gson.GsonConverterFactory
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
object RetrofitClient {
|
||||||
|
|
||||||
|
fun provideOkHttpClient(
|
||||||
|
authInterceptor: AuthInterceptor,
|
||||||
|
dynamicUrlInterceptor: DynamicUrlInterceptor,
|
||||||
|
tokenAuthenticator: TokenAuthenticator,
|
||||||
|
): OkHttpClient {
|
||||||
|
val logging = HttpLoggingInterceptor().apply {
|
||||||
|
level = HttpLoggingInterceptor.Level.BODY
|
||||||
|
}
|
||||||
|
|
||||||
|
return OkHttpClient.Builder()
|
||||||
|
.addInterceptor(dynamicUrlInterceptor)
|
||||||
|
.addInterceptor(authInterceptor)
|
||||||
|
.authenticator(tokenAuthenticator)
|
||||||
|
.addInterceptor(logging)
|
||||||
|
.connectTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(60, TimeUnit.SECONDS)
|
||||||
|
.writeTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
|
||||||
|
return Retrofit.Builder()
|
||||||
|
.baseUrl("http://localhost/")
|
||||||
|
.client(okHttpClient)
|
||||||
|
.addConverterFactory(GsonConverterFactory.create())
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package top.yeij.cyrene.data.remote
|
||||||
|
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import okhttp3.Authenticator
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import okhttp3.Route
|
||||||
|
import top.yeij.cyrene.data.local.PreferencesDataStore
|
||||||
|
import top.yeij.cyrene.data.remote.dto.AuthResponse
|
||||||
|
import top.yeij.cyrene.data.remote.dto.RefreshTokenRequest
|
||||||
|
import android.util.Log
|
||||||
|
|
||||||
|
class TokenAuthenticator(
|
||||||
|
private val authInterceptor: AuthInterceptor,
|
||||||
|
private val preferencesDataStore: PreferencesDataStore,
|
||||||
|
private val dynamicUrlInterceptor: DynamicUrlInterceptor,
|
||||||
|
) : Authenticator {
|
||||||
|
|
||||||
|
private val gson = Gson()
|
||||||
|
private val client = OkHttpClient()
|
||||||
|
|
||||||
|
override fun authenticate(route: Route?, response: okhttp3.Response): Request? {
|
||||||
|
if (response.code != 401) return null
|
||||||
|
|
||||||
|
synchronized(this) {
|
||||||
|
// Double-check: if token changed while we were waiting for lock, use it
|
||||||
|
val currentToken = authInterceptor.token
|
||||||
|
val requestAuthHeader = response.request.header("Authorization")
|
||||||
|
if (requestAuthHeader != null && currentToken != null &&
|
||||||
|
requestAuthHeader != "Bearer $currentToken"
|
||||||
|
) {
|
||||||
|
return response.request.newBuilder()
|
||||||
|
.header("Authorization", "Bearer $currentToken")
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try refresh
|
||||||
|
return try {
|
||||||
|
val refreshToken = runBlocking { preferencesDataStore.refreshToken.firstOrNull() }
|
||||||
|
?: return null
|
||||||
|
|
||||||
|
val baseUrl = dynamicUrlInterceptor.baseUrl.trimEnd('/')
|
||||||
|
val refreshUrl = "$baseUrl/api/v1/auth/refresh"
|
||||||
|
|
||||||
|
val jsonBody = gson.toJson(RefreshTokenRequest(refreshToken))
|
||||||
|
val body = jsonBody.toRequestBody("application/json".toMediaType())
|
||||||
|
|
||||||
|
val refreshRequest = Request.Builder()
|
||||||
|
.url(refreshUrl)
|
||||||
|
.post(body)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val refreshResponse = client.newCall(refreshRequest).execute()
|
||||||
|
if (refreshResponse.isSuccessful) {
|
||||||
|
val authResponse = gson.fromJson(
|
||||||
|
refreshResponse.body?.string(),
|
||||||
|
AuthResponse::class.java
|
||||||
|
)
|
||||||
|
authInterceptor.token = authResponse.token
|
||||||
|
runBlocking {
|
||||||
|
preferencesDataStore.saveToken(authResponse.token)
|
||||||
|
authResponse.refreshToken?.let {
|
||||||
|
preferencesDataStore.saveRefreshToken(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Log.i("TokenAuthenticator", "Token refreshed successfully")
|
||||||
|
response.request.newBuilder()
|
||||||
|
.header("Authorization", "Bearer ${authResponse.token}")
|
||||||
|
.build()
|
||||||
|
} else {
|
||||||
|
Log.w("TokenAuthenticator", "Token refresh failed: ${refreshResponse.code}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("TokenAuthenticator", "Token refresh error: ${e.message}", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package top.yeij.cyrene.data.remote.dto
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName
|
||||||
|
|
||||||
|
data class AuthRequest(
|
||||||
|
@SerializedName("username") val username: String,
|
||||||
|
@SerializedName("password") val password: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AuthResponse(
|
||||||
|
@SerializedName("token") val token: String,
|
||||||
|
@SerializedName("refresh_token") val refreshToken: String?,
|
||||||
|
@SerializedName("username") val username: String?,
|
||||||
|
@SerializedName("nickname") val nickname: String?,
|
||||||
|
@SerializedName("user_id") val userId: String?,
|
||||||
|
@SerializedName("expires") val expires: Long? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class RefreshTokenRequest(
|
||||||
|
@SerializedName("refresh_token") val refreshToken: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ProfileResponse(
|
||||||
|
@SerializedName("user_id") val userId: String,
|
||||||
|
@SerializedName("username") val username: String,
|
||||||
|
@SerializedName("nickname") val nickname: String?,
|
||||||
|
@SerializedName("is_admin") val isAdmin: Boolean? = false,
|
||||||
|
@SerializedName("created_at") val createdAt: String? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package top.yeij.cyrene.data.remote.dto
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName
|
||||||
|
|
||||||
|
data class DeviceDto(
|
||||||
|
@SerializedName("id") val id: String,
|
||||||
|
@SerializedName("name") val name: String,
|
||||||
|
@SerializedName("type") val type: String,
|
||||||
|
@SerializedName("state") val state: DeviceState,
|
||||||
|
@SerializedName("room") val room: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class DeviceState(
|
||||||
|
@SerializedName("power") val power: Boolean?,
|
||||||
|
@SerializedName("brightness") val brightness: Int?,
|
||||||
|
@SerializedName("temperature") val temperature: Float?,
|
||||||
|
@SerializedName("humidity") val humidity: Float?,
|
||||||
|
@SerializedName("colorTemp") val colorTemp: Int?,
|
||||||
|
@SerializedName("locked") val locked: Boolean?,
|
||||||
|
@SerializedName("open") val open: Boolean?,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class IoTControlRequest(
|
||||||
|
@SerializedName("action") val action: String,
|
||||||
|
@SerializedName("value") val value: Any? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package top.yeij.cyrene.data.remote.dto
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName
|
||||||
|
|
||||||
|
// POST /api/v1/sessions — request body
|
||||||
|
data class CreateSessionRequest(
|
||||||
|
@SerializedName("session_id") val sessionId: String? = null,
|
||||||
|
@SerializedName("title") val title: String = "新的对话",
|
||||||
|
@SerializedName("is_main") val isMain: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
// GET /api/v1/sessions — response wrapper
|
||||||
|
data class SessionsListResponse(
|
||||||
|
@SerializedName("sessions") val sessions: List<SessionDto>,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class SessionDto(
|
||||||
|
@SerializedName("id") val id: String,
|
||||||
|
@SerializedName("user_id") val userId: String?,
|
||||||
|
@SerializedName("title") val title: String,
|
||||||
|
@SerializedName("is_main") val isMain: Boolean?,
|
||||||
|
@SerializedName("created_at") val createdAt: Long,
|
||||||
|
@SerializedName("updated_at") val updatedAt: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
// GET /api/v1/sessions/{id}/messages — response wrapper
|
||||||
|
data class MessagesListResponse(
|
||||||
|
@SerializedName("messages") val messages: List<SessionMessageDto>,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class SessionMessageDto(
|
||||||
|
@SerializedName("id") val id: String,
|
||||||
|
@SerializedName("session_id") val sessionId: String,
|
||||||
|
@SerializedName("role") val role: String,
|
||||||
|
@SerializedName("msg_type") val msgType: String?,
|
||||||
|
@SerializedName("content") val content: String,
|
||||||
|
@SerializedName("created_at") val createdAt: Long,
|
||||||
|
)
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package top.yeij.cyrene.data.remote.dto
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName
|
||||||
|
|
||||||
|
// --- Client → Server ---
|
||||||
|
|
||||||
|
data class WSClientMessage(
|
||||||
|
@SerializedName("type") val type: String,
|
||||||
|
@SerializedName("session_id") val sessionId: String? = null,
|
||||||
|
@SerializedName("mode") val mode: String? = null,
|
||||||
|
@SerializedName("content") val content: String? = null,
|
||||||
|
@SerializedName("audio_data") val audioData: String? = null,
|
||||||
|
@SerializedName("attachments") val attachments: List<WSAttachment>? = null,
|
||||||
|
@SerializedName("timestamp") val timestamp: Long? = null,
|
||||||
|
@SerializedName("client_id") val clientId: String? = null,
|
||||||
|
@SerializedName("device_name") val deviceName: String? = null,
|
||||||
|
@SerializedName("user_agent") val userAgent: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class WSAttachment(
|
||||||
|
@SerializedName("type") val type: String,
|
||||||
|
@SerializedName("url") val url: String? = null,
|
||||||
|
@SerializedName("thumbnail_url") val thumbnailUrl: String? = null,
|
||||||
|
@SerializedName("filename") val filename: String? = null,
|
||||||
|
@SerializedName("width") val width: Int? = null,
|
||||||
|
@SerializedName("height") val height: Int? = null,
|
||||||
|
@SerializedName("size") val size: Long? = null,
|
||||||
|
@SerializedName("description") val description: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Server → Client ---
|
||||||
|
|
||||||
|
data class WSClientInfo(
|
||||||
|
@SerializedName("client_id") val clientId: String? = null,
|
||||||
|
@SerializedName("device_name") val deviceName: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class WSServerMessage(
|
||||||
|
@SerializedName("type") val type: String?,
|
||||||
|
@SerializedName("message_id") val messageId: String? = null,
|
||||||
|
@SerializedName("text") val text: String? = null,
|
||||||
|
@SerializedName("content") val content: String? = null,
|
||||||
|
@SerializedName("role") val role: String? = null,
|
||||||
|
@SerializedName("msg_type") val msgType: String? = null,
|
||||||
|
@SerializedName("session_id") val sessionId: String? = null,
|
||||||
|
@SerializedName("error") val error: String? = null,
|
||||||
|
@SerializedName("review_messages") val reviewMessages: List<WSReviewMessage>? = null,
|
||||||
|
@SerializedName("messages") val messages: List<WSHistoryMessage>? = null,
|
||||||
|
@SerializedName("multi_message") val multiMessages: List<WSMultiItem>? = null,
|
||||||
|
@SerializedName("tool_progress") val toolProgress: WSToolProgress? = null,
|
||||||
|
@SerializedName("thinking_status") val thinkingStatus: String? = null,
|
||||||
|
@SerializedName("thinking_content") val thinkingContent: String? = null,
|
||||||
|
@SerializedName("timestamp") val timestamp: Long? = null,
|
||||||
|
@SerializedName("client_info") val clientInfo: WSClientInfo? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class WSReviewMessage(
|
||||||
|
@SerializedName("role") val role: String?,
|
||||||
|
@SerializedName("text") val text: String?,
|
||||||
|
@SerializedName("content") val content: String?,
|
||||||
|
@SerializedName("msg_type") val msgType: String?,
|
||||||
|
@SerializedName("delay_ms") val delayMs: Long? = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class WSHistoryMessage(
|
||||||
|
@SerializedName("id") val id: String?,
|
||||||
|
@SerializedName("role") val role: String?,
|
||||||
|
@SerializedName("content") val content: String?,
|
||||||
|
@SerializedName("msg_type") val msgType: String?,
|
||||||
|
@SerializedName("timestamp") val timestamp: Long?,
|
||||||
|
@SerializedName("name") val name: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class WSMultiItem(
|
||||||
|
@SerializedName("role") val role: String?,
|
||||||
|
@SerializedName("content") val content: String?,
|
||||||
|
@SerializedName("msg_type") val msgType: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class WSToolProgress(
|
||||||
|
@SerializedName("tool_name") val toolName: String?,
|
||||||
|
@SerializedName("status") val status: String?,
|
||||||
|
@SerializedName("detail") val detail: String?,
|
||||||
|
@SerializedName("message") val message: String?,
|
||||||
|
@SerializedName("progress") val progress: Int? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package top.yeij.cyrene.data.repository
|
||||||
|
|
||||||
|
import top.yeij.cyrene.data.local.PreferencesDataStore
|
||||||
|
import top.yeij.cyrene.data.remote.ApiService
|
||||||
|
import top.yeij.cyrene.data.remote.AuthInterceptor
|
||||||
|
import top.yeij.cyrene.data.remote.dto.AuthRequest
|
||||||
|
import top.yeij.cyrene.domain.model.AuthResult
|
||||||
|
import top.yeij.cyrene.domain.repository.AuthRepository
|
||||||
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
|
|
||||||
|
class AuthRepositoryImpl(
|
||||||
|
private val apiService: ApiService,
|
||||||
|
private val preferencesDataStore: PreferencesDataStore,
|
||||||
|
private val authInterceptor: AuthInterceptor,
|
||||||
|
) : AuthRepository {
|
||||||
|
|
||||||
|
override suspend fun login(username: String, password: String): Result<AuthResult> {
|
||||||
|
return try {
|
||||||
|
val response = apiService.login(AuthRequest(username, password))
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
val body = response.body()!!
|
||||||
|
authInterceptor.token = body.token
|
||||||
|
preferencesDataStore.saveToken(body.token)
|
||||||
|
body.refreshToken?.let { preferencesDataStore.saveRefreshToken(it) }
|
||||||
|
val displayName = body.nickname ?: body.username ?: body.userId ?: "开拓者"
|
||||||
|
preferencesDataStore.saveUsername(displayName)
|
||||||
|
Result.success(
|
||||||
|
AuthResult(
|
||||||
|
token = body.token,
|
||||||
|
refreshToken = body.refreshToken,
|
||||||
|
username = displayName,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Result.failure(Exception("登录失败: ${response.code()}"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun logout() {
|
||||||
|
authInterceptor.token = null
|
||||||
|
preferencesDataStore.clearAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun isLoggedIn(): Boolean {
|
||||||
|
return preferencesDataStore.token.firstOrNull() != null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,692 @@
|
|||||||
|
package top.yeij.cyrene.data.repository
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import top.yeij.cyrene.data.local.PreferencesDataStore
|
||||||
|
import top.yeij.cyrene.data.local.dao.ConversationDao
|
||||||
|
import top.yeij.cyrene.data.local.dao.MessageDao
|
||||||
|
import top.yeij.cyrene.data.local.entity.ConversationEntity
|
||||||
|
import top.yeij.cyrene.data.local.entity.MessageEntity
|
||||||
|
import top.yeij.cyrene.data.remote.ApiService
|
||||||
|
import top.yeij.cyrene.data.remote.dto.CreateSessionRequest
|
||||||
|
import top.yeij.cyrene.data.remote.dto.WSServerMessage
|
||||||
|
import top.yeij.cyrene.domain.model.Conversation
|
||||||
|
import top.yeij.cyrene.domain.model.Message
|
||||||
|
import top.yeij.cyrene.domain.repository.ChatRepository
|
||||||
|
import top.yeij.cyrene.service.WebSocketService
|
||||||
|
import top.yeij.cyrene.util.RuntimeLog
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
class ChatRepositoryImpl(
|
||||||
|
private val conversationDao: ConversationDao,
|
||||||
|
private val messageDao: MessageDao,
|
||||||
|
private val webSocketService: WebSocketService,
|
||||||
|
private val apiService: ApiService,
|
||||||
|
private val preferencesDataStore: PreferencesDataStore,
|
||||||
|
) : ChatRepository {
|
||||||
|
|
||||||
|
private val exceptionHandler = CoroutineExceptionHandler { _, e ->
|
||||||
|
Log.e("ChatRepository", "Unhandled exception", e)
|
||||||
|
}
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO + exceptionHandler)
|
||||||
|
|
||||||
|
private val _connectionState = MutableStateFlow(false)
|
||||||
|
override val connectionState: StateFlow<Boolean> = _connectionState.asStateFlow()
|
||||||
|
|
||||||
|
override val connectionError: StateFlow<String?> = webSocketService.connectionError
|
||||||
|
|
||||||
|
private val _incomingMessages = MutableSharedFlow<Message>(extraBufferCapacity = 64)
|
||||||
|
override fun observeMessages(): Flow<Message> = _incomingMessages
|
||||||
|
|
||||||
|
private val _messageClearEvents = MutableSharedFlow<Unit>(extraBufferCapacity = 4)
|
||||||
|
override val messageClearEvents: Flow<Unit> = _messageClearEvents
|
||||||
|
|
||||||
|
private val _isAssistantStreaming = MutableStateFlow(false)
|
||||||
|
override val isAssistantStreaming: StateFlow<Boolean> = _isAssistantStreaming.asStateFlow()
|
||||||
|
|
||||||
|
private var streamingContent = ""
|
||||||
|
private var streamingMessageId: String? = null
|
||||||
|
override var currentSessionId: String? = null
|
||||||
|
|
||||||
|
private var isAppInForeground = false
|
||||||
|
private var onBackgroundNotification: ((Message) -> Unit)? = null
|
||||||
|
private var historyRequested = false
|
||||||
|
private val notifiedMessageIds = mutableSetOf<String>()
|
||||||
|
|
||||||
|
// Duplicate suppression: track items from review/multi_message to skip wrapping response
|
||||||
|
private val recentParsedContents = mutableListOf<String>()
|
||||||
|
private var lastParsedTime = 0L
|
||||||
|
// Track last response to clean up if review/multi_message arrives after
|
||||||
|
private var lastResponseId: String? = null
|
||||||
|
private var lastResponseContent: String? = null
|
||||||
|
private var lastResponseTime = 0L
|
||||||
|
|
||||||
|
fun setNotificationCallback(callback: ((Message) -> Unit)?) {
|
||||||
|
onBackgroundNotification = callback
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAppForeground() {
|
||||||
|
isAppInForeground = true
|
||||||
|
notifiedMessageIds.clear()
|
||||||
|
if (!_connectionState.value) {
|
||||||
|
webSocketService.forceReconnect()
|
||||||
|
}
|
||||||
|
// Always request history on foreground to catch cross-device messages
|
||||||
|
scope.launch {
|
||||||
|
val sid = currentSessionId ?: return@launch
|
||||||
|
RuntimeLog.general("app", "Foreground — requesting history for session=$sid")
|
||||||
|
requestHistoryViaWs(sid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAppBackground() {
|
||||||
|
isAppInForeground = false
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Restore persisted session ID, then connect and load history
|
||||||
|
scope.launch {
|
||||||
|
val persistedSid = preferencesDataStore.currentSessionId.firstOrNull()
|
||||||
|
if (!persistedSid.isNullOrBlank()) {
|
||||||
|
currentSessionId = persistedSid
|
||||||
|
}
|
||||||
|
RuntimeLog.ws("init", "Connecting WebSocket session=$currentSessionId")
|
||||||
|
webSocketService.connect(currentSessionId)
|
||||||
|
loadConversationsFromServer()
|
||||||
|
}
|
||||||
|
scope.launch {
|
||||||
|
webSocketService.isConnected.collect { connected ->
|
||||||
|
_connectionState.value = connected
|
||||||
|
RuntimeLog.ws("connection", "Connected=$connected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scope.launch {
|
||||||
|
webSocketService.incomingMessages.collect { wsMsg ->
|
||||||
|
try {
|
||||||
|
RuntimeLog.ws("receive", "type=${wsMsg.type} msgId=${wsMsg.messageId ?: "-"}")
|
||||||
|
handleServerMessage(wsMsg)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("ChatRepository", "Error handling ${wsMsg.type}: ${e.message}", e)
|
||||||
|
RuntimeLog.ws("error", "Handle error type=${wsMsg.type}: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getConversations(): Flow<List<Conversation>> {
|
||||||
|
return conversationDao.getAll().map { entities ->
|
||||||
|
entities.map { it.toDomain() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getMessages(conversationId: String): Flow<List<Message>> {
|
||||||
|
return messageDao.getByConversation(conversationId).map { entities ->
|
||||||
|
entities.map { it.toDomain() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteConversation(id: String) {
|
||||||
|
conversationDao.deleteById(id)
|
||||||
|
try { apiService.deleteSession(id) } catch (_: Exception) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun clearLocalMessages() {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
messageDao.deleteAll()
|
||||||
|
preferencesDataStore.saveLastClearedTimestamp(now)
|
||||||
|
|
||||||
|
// Also clear server-side messages for all known sessions
|
||||||
|
try {
|
||||||
|
val sessions = conversationDao.getAllSnapshot()
|
||||||
|
sessions.forEach { session ->
|
||||||
|
try {
|
||||||
|
apiService.clearSessionMessages(session.id)
|
||||||
|
RuntimeLog.chat("clear", "Server messages cleared for session=${session.id}")
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
|
||||||
|
_messageClearEvents.tryEmit(Unit)
|
||||||
|
|
||||||
|
RuntimeLog.chat("clear", "Local messages cleared, timestamp=$now")
|
||||||
|
Log.i("ChatRepository", "Local messages cleared, timestamp: $now")
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun changeSessionId(newId: String) {
|
||||||
|
val oldId = currentSessionId
|
||||||
|
if (oldId != null && oldId != newId) {
|
||||||
|
messageDao.migrateConversationId(oldId, newId)
|
||||||
|
conversationDao.deleteById(oldId)
|
||||||
|
}
|
||||||
|
currentSessionId = newId
|
||||||
|
preferencesDataStore.saveCurrentSessionId(newId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun connectWebSocket(sessionId: String?) {
|
||||||
|
currentSessionId = sessionId
|
||||||
|
webSocketService.connect(sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun reconnectWebSocket() {
|
||||||
|
webSocketService.disconnect()
|
||||||
|
webSocketService.connect(currentSessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun ensureConnected() {
|
||||||
|
if (_connectionState.value) return
|
||||||
|
webSocketService.forceReconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun sendMessage(content: String, sessionId: String?) {
|
||||||
|
val messageId = UUID.randomUUID().toString()
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val sid = sessionId ?: currentSessionId ?: "default"
|
||||||
|
if (currentSessionId == null) {
|
||||||
|
currentSessionId = sid
|
||||||
|
scope.launch { preferencesDataStore.saveCurrentSessionId(sid) }
|
||||||
|
}
|
||||||
|
|
||||||
|
RuntimeLog.chat("send", "session=$sid msgId=$messageId content=${content.take(80)}")
|
||||||
|
|
||||||
|
conversationDao.upsert(
|
||||||
|
ConversationEntity(
|
||||||
|
id = sid,
|
||||||
|
title = "对话",
|
||||||
|
lastMessage = content,
|
||||||
|
lastMessageType = "chat",
|
||||||
|
updatedAt = now,
|
||||||
|
createdAt = now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
messageDao.upsert(
|
||||||
|
MessageEntity(
|
||||||
|
id = messageId,
|
||||||
|
conversationId = sid,
|
||||||
|
role = "user",
|
||||||
|
content = content,
|
||||||
|
msgType = "chat",
|
||||||
|
timestamp = now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
emitMessage(
|
||||||
|
id = messageId,
|
||||||
|
sessionId = sid,
|
||||||
|
role = "user",
|
||||||
|
content = content,
|
||||||
|
msgType = "chat",
|
||||||
|
timestamp = now,
|
||||||
|
isStreaming = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
webSocketService.sendMessage(content, sid)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun loadConversationsFromServer() {
|
||||||
|
try {
|
||||||
|
val response = apiService.getSessions()
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
response.body()?.sessions?.forEach { dto ->
|
||||||
|
conversationDao.upsert(
|
||||||
|
ConversationEntity(
|
||||||
|
id = dto.id,
|
||||||
|
title = dto.title,
|
||||||
|
lastMessage = "",
|
||||||
|
lastMessageType = "chat",
|
||||||
|
updatedAt = dto.updatedAt,
|
||||||
|
createdAt = dto.createdAt,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun sendScreenContext(content: String) {
|
||||||
|
webSocketService.sendScreenContext(content, currentSessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun sendVoiceInput(audioBase64: String, mode: String) {
|
||||||
|
webSocketService.sendVoiceInput(audioBase64, currentSessionId, mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun initializeSession(): String {
|
||||||
|
// Try to find an existing main session on the server
|
||||||
|
try {
|
||||||
|
val response = apiService.getSessions()
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
val sessions = response.body()?.sessions ?: emptyList()
|
||||||
|
val mainSession = sessions.find { it.isMain == true }
|
||||||
|
if (mainSession != null) {
|
||||||
|
currentSessionId = mainSession.id
|
||||||
|
preferencesDataStore.saveCurrentSessionId(mainSession.id)
|
||||||
|
Log.i("ChatRepository", "Found main session: ${mainSession.id}")
|
||||||
|
return mainSession.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
|
||||||
|
// No main session found on server, create one with deterministic ID
|
||||||
|
val sessionId = "session_admin_main"
|
||||||
|
try {
|
||||||
|
apiService.createSession(
|
||||||
|
CreateSessionRequest(
|
||||||
|
sessionId = sessionId,
|
||||||
|
title = "主对话",
|
||||||
|
isMain = true,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Log.i("ChatRepository", "Created main session: $sessionId")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w("ChatRepository", "Failed to create main session: ${e.message}")
|
||||||
|
}
|
||||||
|
|
||||||
|
currentSessionId = sessionId
|
||||||
|
preferencesDataStore.saveCurrentSessionId(sessionId)
|
||||||
|
return sessionId
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun loadMessagesFromServer(sessionId: String): List<Message> {
|
||||||
|
currentSessionId = sessionId
|
||||||
|
return try {
|
||||||
|
val response = apiService.getSessionMessages(sessionId)
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
val messageDtos = response.body()?.messages ?: emptyList()
|
||||||
|
val lastCleared = preferencesDataStore.lastClearedTimestamp.firstOrNull()
|
||||||
|
?.toLongOrNull() ?: 0L
|
||||||
|
val filteredDtos = messageDtos.filter { it.createdAt > lastCleared }
|
||||||
|
ensureConversation(sessionId)
|
||||||
|
filteredDtos.forEach { dto ->
|
||||||
|
messageDao.upsert(
|
||||||
|
MessageEntity(
|
||||||
|
id = "db_${dto.id}",
|
||||||
|
conversationId = sessionId,
|
||||||
|
role = dto.role,
|
||||||
|
content = dto.content,
|
||||||
|
msgType = dto.msgType ?: "chat",
|
||||||
|
timestamp = dto.createdAt,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
RuntimeLog.http("loadMessages", "HTTP loaded ${filteredDtos.size} messages for session=$sessionId")
|
||||||
|
filteredDtos.map { dto ->
|
||||||
|
Message(
|
||||||
|
id = "db_${dto.id}",
|
||||||
|
conversationId = sessionId,
|
||||||
|
role = dto.role,
|
||||||
|
content = dto.content,
|
||||||
|
msgType = dto.msgType ?: "chat",
|
||||||
|
timestamp = dto.createdAt,
|
||||||
|
)
|
||||||
|
}.removeWrappingDuplicates()
|
||||||
|
} else {
|
||||||
|
RuntimeLog.http("loadMessages", "HTTP failed: ${response.code()} ${response.message()}, trying WS")
|
||||||
|
requestHistoryViaWs(sessionId)
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
RuntimeLog.http("loadMessages", "HTTP error: ${e.message}, trying WS")
|
||||||
|
requestHistoryViaWs(sessionId)
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun requestHistoryViaWs(sessionId: String) {
|
||||||
|
// Wait up to 5s for WS to connect
|
||||||
|
if (!webSocketService.isConnected.value) {
|
||||||
|
withTimeoutOrNull(5000) {
|
||||||
|
webSocketService.isConnected.first { it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
webSocketService.requestHistory(sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun ensureConversation(sessionId: String, lastMessage: String = "") {
|
||||||
|
val existing = conversationDao.getById(sessionId)
|
||||||
|
if (existing == null) {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
conversationDao.upsert(
|
||||||
|
ConversationEntity(
|
||||||
|
id = sessionId,
|
||||||
|
title = "对话",
|
||||||
|
lastMessage = lastMessage,
|
||||||
|
lastMessageType = "chat",
|
||||||
|
updatedAt = now,
|
||||||
|
createdAt = now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleServerMessage(wsMsg: WSServerMessage) {
|
||||||
|
when (wsMsg.type) {
|
||||||
|
"stream_start" -> {
|
||||||
|
streamingContent = ""
|
||||||
|
streamingMessageId = wsMsg.messageId ?: "stream_${System.currentTimeMillis()}"
|
||||||
|
_isAssistantStreaming.value = true
|
||||||
|
RuntimeLog.chat("stream", "Stream start msgId=$streamingMessageId")
|
||||||
|
}
|
||||||
|
|
||||||
|
"stream_chunk" -> {
|
||||||
|
val delta = wsMsg.content ?: wsMsg.text ?: return
|
||||||
|
streamingContent += delta
|
||||||
|
emitMessage(
|
||||||
|
id = streamingMessageId ?: "s_${System.currentTimeMillis()}",
|
||||||
|
sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
|
||||||
|
role = "assistant",
|
||||||
|
content = streamingContent,
|
||||||
|
msgType = "chat",
|
||||||
|
isStreaming = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
"stream_end" -> {
|
||||||
|
val msgId = wsMsg.messageId ?: streamingMessageId ?: "s_${System.currentTimeMillis()}"
|
||||||
|
val content = streamingContent.ifEmpty { wsMsg.content ?: wsMsg.text ?: "" }
|
||||||
|
streamingContent = ""
|
||||||
|
streamingMessageId = null
|
||||||
|
val sid = wsMsg.sessionId ?: currentSessionId ?: "default"
|
||||||
|
if (currentSessionId == null || (wsMsg.sessionId != null && wsMsg.sessionId != currentSessionId)) {
|
||||||
|
changeSessionId(sid)
|
||||||
|
}
|
||||||
|
val ts = wsMsg.timestamp ?: System.currentTimeMillis()
|
||||||
|
|
||||||
|
if (content.isNotBlank()) {
|
||||||
|
ensureConversation(sid, content)
|
||||||
|
messageDao.upsert(
|
||||||
|
MessageEntity(
|
||||||
|
id = msgId,
|
||||||
|
conversationId = sid,
|
||||||
|
role = "assistant",
|
||||||
|
content = content,
|
||||||
|
msgType = "chat",
|
||||||
|
timestamp = ts,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
emitMessage(id = msgId, sessionId = sid, role = "assistant", content = content, msgType = "chat", timestamp = ts, isStreaming = false, shouldNotify = true)
|
||||||
|
_isAssistantStreaming.value = false
|
||||||
|
RuntimeLog.chat("stream", "Stream end msgId=$msgId content=${content.take(80)}")
|
||||||
|
}
|
||||||
|
|
||||||
|
"response" -> {
|
||||||
|
val text = wsMsg.content ?: wsMsg.text ?: return
|
||||||
|
val role = wsMsg.role ?: "assistant"
|
||||||
|
val replyMsgType = wsMsg.msgType ?: "chat"
|
||||||
|
val msgId = wsMsg.messageId ?: "r_${System.currentTimeMillis()}"
|
||||||
|
val sid = wsMsg.sessionId ?: currentSessionId ?: "default"
|
||||||
|
|
||||||
|
// Suppress response if it wraps recently emitted review/multi_message items
|
||||||
|
val timeSinceParsed = System.currentTimeMillis() - lastParsedTime
|
||||||
|
if (timeSinceParsed < 3000 && recentParsedContents.isNotEmpty()) {
|
||||||
|
val allContained = recentParsedContents.all { text.contains(it) }
|
||||||
|
if (allContained) {
|
||||||
|
RuntimeLog.chat("dedup", "Suppressed wrapping response, ${recentParsedContents.size} items already shown")
|
||||||
|
recentParsedContents.clear()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
recentParsedContents.clear()
|
||||||
|
|
||||||
|
if (currentSessionId == null || (wsMsg.sessionId != null && wsMsg.sessionId != currentSessionId)) {
|
||||||
|
changeSessionId(sid)
|
||||||
|
}
|
||||||
|
val ts = wsMsg.timestamp ?: System.currentTimeMillis()
|
||||||
|
|
||||||
|
ensureConversation(sid, text)
|
||||||
|
messageDao.upsert(
|
||||||
|
MessageEntity(
|
||||||
|
id = msgId,
|
||||||
|
conversationId = sid,
|
||||||
|
role = role,
|
||||||
|
content = text,
|
||||||
|
msgType = replyMsgType,
|
||||||
|
timestamp = ts,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
lastResponseId = msgId
|
||||||
|
lastResponseContent = text
|
||||||
|
lastResponseTime = System.currentTimeMillis()
|
||||||
|
|
||||||
|
emitMessage(id = msgId, sessionId = sid, role = role, content = text, msgType = replyMsgType, timestamp = ts, isStreaming = false, shouldNotify = true)
|
||||||
|
RuntimeLog.chat("receive", "Response msgId=$msgId role=$role msgType=$replyMsgType content=${text.take(80)}")
|
||||||
|
}
|
||||||
|
|
||||||
|
"review" -> {
|
||||||
|
recentParsedContents.clear()
|
||||||
|
wsMsg.reviewMessages?.forEach { review ->
|
||||||
|
val text = review.content ?: review.text ?: return@forEach
|
||||||
|
val role = review.role ?: "action"
|
||||||
|
val rvMsgType = review.msgType ?: review.role ?: "action"
|
||||||
|
val msgId = "rv_${System.currentTimeMillis()}_${review.hashCode()}"
|
||||||
|
recentParsedContents.add(text)
|
||||||
|
emitMessage(id = msgId, sessionId = wsMsg.sessionId ?: currentSessionId ?: "default", role = role, content = text, msgType = rvMsgType, isStreaming = false)
|
||||||
|
}
|
||||||
|
if (recentParsedContents.isNotEmpty()) lastParsedTime = System.currentTimeMillis()
|
||||||
|
// Clean up wrapping response that arrived before this review
|
||||||
|
cleanupWrappingResponse()
|
||||||
|
}
|
||||||
|
|
||||||
|
"thinking" -> {
|
||||||
|
val text = wsMsg.thinkingContent ?: wsMsg.content ?: wsMsg.text
|
||||||
|
if (text != null) {
|
||||||
|
emitMessage(
|
||||||
|
id = wsMsg.messageId ?: "think_${System.currentTimeMillis()}",
|
||||||
|
sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
|
||||||
|
role = "assistant",
|
||||||
|
content = text,
|
||||||
|
msgType = "thinking",
|
||||||
|
isStreaming = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"tool_progress" -> {
|
||||||
|
val detail = wsMsg.toolProgress?.message
|
||||||
|
?: wsMsg.toolProgress?.detail
|
||||||
|
?: wsMsg.toolProgress?.toolName
|
||||||
|
?: "正在执行操作..."
|
||||||
|
emitMessage(
|
||||||
|
id = wsMsg.messageId ?: "tool_${System.currentTimeMillis()}",
|
||||||
|
sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
|
||||||
|
role = "system",
|
||||||
|
content = detail,
|
||||||
|
msgType = "tool_progress",
|
||||||
|
isStreaming = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
"error" -> {
|
||||||
|
RuntimeLog.chat("error", "Server error: ${wsMsg.error ?: "未知错误"}")
|
||||||
|
emitMessage(
|
||||||
|
id = "err_${System.currentTimeMillis()}",
|
||||||
|
sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
|
||||||
|
role = "system",
|
||||||
|
content = wsMsg.error ?: "未知错误",
|
||||||
|
msgType = "system_info",
|
||||||
|
isStreaming = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
"voice_transcript" -> {
|
||||||
|
val text = wsMsg.text ?: wsMsg.content ?: return
|
||||||
|
val sid = wsMsg.sessionId ?: currentSessionId ?: "default"
|
||||||
|
val ts = wsMsg.timestamp ?: System.currentTimeMillis()
|
||||||
|
val msgId = wsMsg.messageId ?: "vt_${System.currentTimeMillis()}"
|
||||||
|
ensureConversation(sid)
|
||||||
|
messageDao.upsert(
|
||||||
|
MessageEntity(
|
||||||
|
id = msgId,
|
||||||
|
conversationId = sid,
|
||||||
|
role = "user",
|
||||||
|
content = text,
|
||||||
|
msgType = "chat",
|
||||||
|
timestamp = ts,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
emitMessage(id = msgId, sessionId = sid, role = "user", content = text, msgType = "chat", timestamp = ts, isStreaming = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
"history_response" -> {
|
||||||
|
val sid = wsMsg.sessionId ?: currentSessionId ?: "default"
|
||||||
|
if (currentSessionId == null || (wsMsg.sessionId != null && wsMsg.sessionId != currentSessionId)) {
|
||||||
|
changeSessionId(sid)
|
||||||
|
}
|
||||||
|
ensureConversation(sid)
|
||||||
|
val messages = wsMsg.messages ?: return
|
||||||
|
val messageList = messages.map { hist ->
|
||||||
|
val msgId = hist.id ?: "hist_${System.currentTimeMillis()}_${hist.hashCode()}"
|
||||||
|
Message(
|
||||||
|
id = msgId,
|
||||||
|
conversationId = sid,
|
||||||
|
role = hist.role ?: "system",
|
||||||
|
content = hist.content ?: "",
|
||||||
|
msgType = hist.msgType ?: "chat",
|
||||||
|
timestamp = hist.timestamp ?: System.currentTimeMillis(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val deduped = messageList.removeWrappingDuplicates()
|
||||||
|
deduped.forEach { msg ->
|
||||||
|
messageDao.upsert(
|
||||||
|
MessageEntity(
|
||||||
|
id = msg.id,
|
||||||
|
conversationId = msg.conversationId,
|
||||||
|
role = msg.role,
|
||||||
|
content = msg.content,
|
||||||
|
msgType = msg.msgType,
|
||||||
|
timestamp = msg.timestamp,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
emitMessage(
|
||||||
|
id = msg.id,
|
||||||
|
sessionId = msg.conversationId,
|
||||||
|
role = msg.role,
|
||||||
|
content = msg.content,
|
||||||
|
msgType = msg.msgType,
|
||||||
|
timestamp = msg.timestamp,
|
||||||
|
isStreaming = false,
|
||||||
|
shouldNotify = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
RuntimeLog.chat("history", "Loaded ${deduped.size} messages from server history")
|
||||||
|
}
|
||||||
|
|
||||||
|
"multi_message" -> {
|
||||||
|
recentParsedContents.clear()
|
||||||
|
wsMsg.multiMessages?.forEach { item ->
|
||||||
|
val content = item.content ?: ""
|
||||||
|
recentParsedContents.add(content)
|
||||||
|
emitMessage(
|
||||||
|
id = "mm_${System.currentTimeMillis()}_${item.hashCode()}",
|
||||||
|
sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
|
||||||
|
role = item.role ?: "assistant",
|
||||||
|
content = content,
|
||||||
|
msgType = item.msgType ?: "chat",
|
||||||
|
timestamp = wsMsg.timestamp ?: System.currentTimeMillis(),
|
||||||
|
isStreaming = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (recentParsedContents.isNotEmpty()) lastParsedTime = System.currentTimeMillis()
|
||||||
|
cleanupWrappingResponse()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun cleanupWrappingResponse() {
|
||||||
|
val respId = lastResponseId ?: return
|
||||||
|
val respContent = lastResponseContent ?: return
|
||||||
|
val timeSinceResponse = System.currentTimeMillis() - lastResponseTime
|
||||||
|
if (timeSinceResponse > 5000 || recentParsedContents.size < 2) return
|
||||||
|
val allContained = recentParsedContents.all { respContent.contains(it) }
|
||||||
|
if (allContained) {
|
||||||
|
messageDao.deleteById(respId)
|
||||||
|
RuntimeLog.chat("dedup", "Cleaned up wrapping response from DB id=$respId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun emitMessage(
|
||||||
|
id: String,
|
||||||
|
sessionId: String,
|
||||||
|
role: String,
|
||||||
|
content: String,
|
||||||
|
msgType: String,
|
||||||
|
isStreaming: Boolean = false,
|
||||||
|
timestamp: Long = System.currentTimeMillis(),
|
||||||
|
shouldNotify: Boolean = false,
|
||||||
|
) {
|
||||||
|
if (content.isBlank() && msgType == "chat") return
|
||||||
|
val message = Message(
|
||||||
|
id = id,
|
||||||
|
conversationId = sessionId,
|
||||||
|
role = role,
|
||||||
|
content = content,
|
||||||
|
msgType = msgType,
|
||||||
|
timestamp = timestamp,
|
||||||
|
isStreaming = isStreaming,
|
||||||
|
)
|
||||||
|
_incomingMessages.tryEmit(message)
|
||||||
|
|
||||||
|
if (shouldNotify && !isAppInForeground && role == "assistant" && !isStreaming) {
|
||||||
|
if (notifiedMessageIds.add(id)) {
|
||||||
|
onBackgroundNotification?.invoke(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove wrapper messages whose content contains the content of 2+ other messages.
|
||||||
|
* This handles the case where the server sends both a combined "response" and
|
||||||
|
* parsed "review"/"multi_message" items for the same turn.
|
||||||
|
*/
|
||||||
|
private fun List<Message>.removeWrappingDuplicates(): List<Message> {
|
||||||
|
if (size < 3) return this
|
||||||
|
val toRemove = mutableSetOf<String>()
|
||||||
|
for (msg in this) {
|
||||||
|
val containedCount = count { other ->
|
||||||
|
other.id != msg.id &&
|
||||||
|
other.content.isNotBlank() &&
|
||||||
|
other.content.length < msg.content.length &&
|
||||||
|
msg.content.contains(other.content) &&
|
||||||
|
kotlin.math.abs(other.timestamp - msg.timestamp) < 2000
|
||||||
|
}
|
||||||
|
if (containedCount >= 2) {
|
||||||
|
toRemove.add(msg.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return if (toRemove.isEmpty()) this else filter { it.id !in toRemove }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ConversationEntity.toDomain() = Conversation(
|
||||||
|
id = id,
|
||||||
|
title = title,
|
||||||
|
lastMessage = lastMessage,
|
||||||
|
lastMessageType = lastMessageType,
|
||||||
|
updatedAt = updatedAt,
|
||||||
|
createdAt = createdAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun MessageEntity.toDomain() = Message(
|
||||||
|
id = id,
|
||||||
|
conversationId = conversationId,
|
||||||
|
role = role,
|
||||||
|
content = content,
|
||||||
|
msgType = msgType,
|
||||||
|
timestamp = timestamp,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package top.yeij.cyrene.data.repository
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import top.yeij.cyrene.data.remote.ApiService
|
||||||
|
import top.yeij.cyrene.data.remote.dto.IoTControlRequest
|
||||||
|
import top.yeij.cyrene.domain.model.Device
|
||||||
|
import top.yeij.cyrene.domain.model.DeviceState
|
||||||
|
import top.yeij.cyrene.domain.model.DeviceType
|
||||||
|
import top.yeij.cyrene.domain.repository.IoTRepository
|
||||||
|
|
||||||
|
class IoTRepositoryImpl(
|
||||||
|
private val apiService: ApiService,
|
||||||
|
private val webSocketService: top.yeij.cyrene.service.WebSocketService,
|
||||||
|
) : IoTRepository {
|
||||||
|
|
||||||
|
private val _devices = MutableStateFlow<List<Device>>(emptyList())
|
||||||
|
override fun getDevices(): Flow<List<Device>> = _devices.asStateFlow()
|
||||||
|
|
||||||
|
override suspend fun controlDevice(
|
||||||
|
deviceId: String,
|
||||||
|
action: String,
|
||||||
|
value: Any?,
|
||||||
|
): Result<Device> {
|
||||||
|
return try {
|
||||||
|
val response = apiService.controlDevice(deviceId, IoTControlRequest(action, value))
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
val updated = response.body()!!.toDomain()
|
||||||
|
_devices.value = _devices.value.map {
|
||||||
|
if (it.id == deviceId) updated else it
|
||||||
|
}
|
||||||
|
Result.success(updated)
|
||||||
|
} else {
|
||||||
|
Result.failure(Exception("设备控制失败: ${response.code()}"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun refreshDevices() {
|
||||||
|
try {
|
||||||
|
val response = apiService.getDevices()
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
_devices.value = response.body()!!.map { it.toDomain() }
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun top.yeij.cyrene.data.remote.dto.DeviceDto.toDomain() = Device(
|
||||||
|
id = id,
|
||||||
|
name = name,
|
||||||
|
type = try {
|
||||||
|
DeviceType.valueOf(type.uppercase())
|
||||||
|
} catch (_: Exception) {
|
||||||
|
DeviceType.UNKNOWN
|
||||||
|
},
|
||||||
|
state = DeviceState(
|
||||||
|
power = state.power,
|
||||||
|
brightness = state.brightness,
|
||||||
|
temperature = state.temperature,
|
||||||
|
humidity = state.humidity,
|
||||||
|
colorTemp = state.colorTemp,
|
||||||
|
locked = state.locked,
|
||||||
|
open = state.open,
|
||||||
|
),
|
||||||
|
room = room,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package top.yeij.cyrene.di
|
||||||
|
|
||||||
|
import org.koin.android.ext.koin.androidContext
|
||||||
|
import org.koin.core.module.dsl.viewModel
|
||||||
|
import org.koin.dsl.module
|
||||||
|
import top.yeij.cyrene.data.local.AppDatabase
|
||||||
|
import top.yeij.cyrene.data.local.PreferencesDataStore
|
||||||
|
import top.yeij.cyrene.data.remote.ApiService
|
||||||
|
import top.yeij.cyrene.data.remote.AuthInterceptor
|
||||||
|
import top.yeij.cyrene.data.remote.DynamicUrlInterceptor
|
||||||
|
import top.yeij.cyrene.data.remote.RetrofitClient
|
||||||
|
import top.yeij.cyrene.data.remote.TokenAuthenticator
|
||||||
|
import top.yeij.cyrene.data.repository.AuthRepositoryImpl
|
||||||
|
import top.yeij.cyrene.data.repository.ChatRepositoryImpl
|
||||||
|
import top.yeij.cyrene.data.repository.IoTRepositoryImpl
|
||||||
|
import top.yeij.cyrene.domain.repository.AuthRepository
|
||||||
|
import top.yeij.cyrene.domain.repository.ChatRepository
|
||||||
|
import top.yeij.cyrene.domain.repository.IoTRepository
|
||||||
|
import top.yeij.cyrene.domain.usecase.GetConversationsUseCase
|
||||||
|
import top.yeij.cyrene.domain.usecase.LoginUseCase
|
||||||
|
import top.yeij.cyrene.domain.usecase.SendMessageUseCase
|
||||||
|
import top.yeij.cyrene.service.WebSocketService
|
||||||
|
import top.yeij.cyrene.viewmodel.ChatViewModel
|
||||||
|
import top.yeij.cyrene.viewmodel.IoTViewModel
|
||||||
|
import top.yeij.cyrene.viewmodel.OverlayViewModel
|
||||||
|
import top.yeij.cyrene.viewmodel.ProfileViewModel
|
||||||
|
import top.yeij.cyrene.viewmodel.SettingsViewModel
|
||||||
|
import top.yeij.cyrene.util.VoiceRecorder
|
||||||
|
import top.yeij.cyrene.voice.stt.BackendSttProvider
|
||||||
|
import top.yeij.cyrene.voice.stt.DashScopeSttService
|
||||||
|
import top.yeij.cyrene.voice.stt.SpeechRecognizer
|
||||||
|
import top.yeij.cyrene.voice.stt.SttManager
|
||||||
|
import top.yeij.cyrene.voice.tts.TextToSpeechEngine
|
||||||
|
|
||||||
|
val appModule = module {
|
||||||
|
|
||||||
|
// DataStore
|
||||||
|
single { PreferencesDataStore(androidContext()) }
|
||||||
|
|
||||||
|
// Database
|
||||||
|
single { AppDatabase.getInstance(androidContext()) }
|
||||||
|
single { get<AppDatabase>().conversationDao() }
|
||||||
|
single { get<AppDatabase>().messageDao() }
|
||||||
|
|
||||||
|
// Network interceptors
|
||||||
|
single { AuthInterceptor() }
|
||||||
|
single { DynamicUrlInterceptor() }
|
||||||
|
single { TokenAuthenticator(get(), get(), get()) }
|
||||||
|
single { RetrofitClient.provideOkHttpClient(get(), get(), get()) }
|
||||||
|
single { RetrofitClient.provideRetrofit(get()) }
|
||||||
|
single { get<retrofit2.Retrofit>().create(ApiService::class.java) }
|
||||||
|
|
||||||
|
// WebSocket
|
||||||
|
single { WebSocketService(get()) }
|
||||||
|
|
||||||
|
// Voice
|
||||||
|
single { VoiceRecorder(androidContext()) }
|
||||||
|
single { SpeechRecognizer(androidContext()) }
|
||||||
|
single { TextToSpeechEngine(androidContext()) }
|
||||||
|
single { DashScopeSttService(androidContext()) }
|
||||||
|
single { BackendSttProvider(androidContext(), get()) }
|
||||||
|
single { SttManager(get(), get(), get()) }
|
||||||
|
|
||||||
|
// Repositories
|
||||||
|
single<AuthRepository> { AuthRepositoryImpl(get(), get(), get()) }
|
||||||
|
single<ChatRepository> { ChatRepositoryImpl(get(), get(), get(), get(), get()) }
|
||||||
|
single<IoTRepository> { IoTRepositoryImpl(get(), get()) }
|
||||||
|
|
||||||
|
// UseCases
|
||||||
|
factory { LoginUseCase(get()) }
|
||||||
|
factory { SendMessageUseCase(get()) }
|
||||||
|
factory { GetConversationsUseCase(get()) }
|
||||||
|
|
||||||
|
// ViewModels
|
||||||
|
viewModel { ChatViewModel(get(), get()) }
|
||||||
|
viewModel { IoTViewModel(get()) }
|
||||||
|
viewModel { OverlayViewModel(get(), get(), get()) }
|
||||||
|
viewModel { ProfileViewModel(get(), get(), get()) }
|
||||||
|
single { SettingsViewModel(get(), get(), get(), get(), get()) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package top.yeij.cyrene.domain.model
|
||||||
|
|
||||||
|
data class AuthResult(
|
||||||
|
val token: String,
|
||||||
|
val refreshToken: String?,
|
||||||
|
val username: String,
|
||||||
|
)
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package top.yeij.cyrene.domain.model
|
||||||
|
|
||||||
|
data class Conversation(
|
||||||
|
val id: String,
|
||||||
|
val title: String,
|
||||||
|
val lastMessage: String?,
|
||||||
|
val lastMessageType: String?,
|
||||||
|
val updatedAt: Long,
|
||||||
|
val createdAt: Long,
|
||||||
|
)
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package top.yeij.cyrene.domain.model
|
||||||
|
|
||||||
|
data class Device(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val type: DeviceType,
|
||||||
|
val state: DeviceState,
|
||||||
|
val room: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class DeviceType {
|
||||||
|
LIGHT,
|
||||||
|
AC,
|
||||||
|
CURTAIN,
|
||||||
|
SENSOR,
|
||||||
|
DOOR_LOCK,
|
||||||
|
UNKNOWN,
|
||||||
|
}
|
||||||
|
|
||||||
|
data class DeviceState(
|
||||||
|
val power: Boolean? = null,
|
||||||
|
val brightness: Int? = null,
|
||||||
|
val temperature: Float? = null,
|
||||||
|
val humidity: Float? = null,
|
||||||
|
val colorTemp: Int? = null,
|
||||||
|
val locked: Boolean? = null,
|
||||||
|
val open: Boolean? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package top.yeij.cyrene.domain.model
|
||||||
|
|
||||||
|
data class Message(
|
||||||
|
val id: String,
|
||||||
|
val conversationId: String,
|
||||||
|
val role: String,
|
||||||
|
val content: String,
|
||||||
|
val msgType: String,
|
||||||
|
val timestamp: Long,
|
||||||
|
val isStreaming: Boolean = false,
|
||||||
|
)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package top.yeij.cyrene.domain.repository
|
||||||
|
|
||||||
|
import top.yeij.cyrene.domain.model.AuthResult
|
||||||
|
|
||||||
|
interface AuthRepository {
|
||||||
|
|
||||||
|
suspend fun login(username: String, password: String): Result<AuthResult>
|
||||||
|
|
||||||
|
suspend fun logout()
|
||||||
|
|
||||||
|
suspend fun isLoggedIn(): Boolean
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package top.yeij.cyrene.domain.repository
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import top.yeij.cyrene.domain.model.Conversation
|
||||||
|
import top.yeij.cyrene.domain.model.Message
|
||||||
|
|
||||||
|
interface ChatRepository {
|
||||||
|
|
||||||
|
val connectionState: StateFlow<Boolean>
|
||||||
|
val connectionError: StateFlow<String?>
|
||||||
|
val isAssistantStreaming: StateFlow<Boolean>
|
||||||
|
val messageClearEvents: Flow<Unit>
|
||||||
|
var currentSessionId: String?
|
||||||
|
|
||||||
|
fun getConversations(): Flow<List<Conversation>>
|
||||||
|
|
||||||
|
suspend fun getMessages(conversationId: String): Flow<List<Message>>
|
||||||
|
|
||||||
|
suspend fun deleteConversation(id: String)
|
||||||
|
|
||||||
|
suspend fun connectWebSocket(sessionId: String?)
|
||||||
|
|
||||||
|
suspend fun sendMessage(content: String, sessionId: String?)
|
||||||
|
|
||||||
|
fun observeMessages(): Flow<Message>
|
||||||
|
|
||||||
|
suspend fun loadConversationsFromServer()
|
||||||
|
|
||||||
|
suspend fun loadMessagesFromServer(sessionId: String): List<Message>
|
||||||
|
|
||||||
|
suspend fun initializeSession(): String
|
||||||
|
|
||||||
|
suspend fun reconnectWebSocket()
|
||||||
|
|
||||||
|
suspend fun ensureConnected()
|
||||||
|
|
||||||
|
suspend fun sendScreenContext(content: String)
|
||||||
|
|
||||||
|
suspend fun sendVoiceInput(audioBase64: String, mode: String = "voice_msg")
|
||||||
|
|
||||||
|
suspend fun clearLocalMessages()
|
||||||
|
|
||||||
|
fun onAppForeground()
|
||||||
|
fun onAppBackground()
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package top.yeij.cyrene.domain.repository
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import top.yeij.cyrene.domain.model.Device
|
||||||
|
|
||||||
|
interface IoTRepository {
|
||||||
|
|
||||||
|
fun getDevices(): Flow<List<Device>>
|
||||||
|
|
||||||
|
suspend fun controlDevice(deviceId: String, action: String, value: Any? = null): Result<Device>
|
||||||
|
|
||||||
|
suspend fun refreshDevices()
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package top.yeij.cyrene.domain.usecase
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import top.yeij.cyrene.domain.model.Conversation
|
||||||
|
import top.yeij.cyrene.domain.repository.ChatRepository
|
||||||
|
|
||||||
|
class GetConversationsUseCase(
|
||||||
|
private val chatRepository: ChatRepository,
|
||||||
|
) {
|
||||||
|
operator fun invoke(): Flow<List<Conversation>> = chatRepository.getConversations()
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package top.yeij.cyrene.domain.usecase
|
||||||
|
|
||||||
|
import top.yeij.cyrene.domain.model.AuthResult
|
||||||
|
import top.yeij.cyrene.domain.repository.AuthRepository
|
||||||
|
|
||||||
|
class LoginUseCase(
|
||||||
|
private val authRepository: AuthRepository,
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(username: String, password: String): Result<AuthResult> {
|
||||||
|
if (username.isBlank() || password.isBlank()) {
|
||||||
|
return Result.failure(IllegalArgumentException("用户名和密码不能为空"))
|
||||||
|
}
|
||||||
|
return authRepository.login(username, password)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package top.yeij.cyrene.domain.usecase
|
||||||
|
|
||||||
|
import top.yeij.cyrene.domain.repository.ChatRepository
|
||||||
|
|
||||||
|
class SendMessageUseCase(
|
||||||
|
private val chatRepository: ChatRepository,
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(content: String, sessionId: String? = null) {
|
||||||
|
if (content.isNotBlank()) {
|
||||||
|
chatRepository.sendMessage(content, sessionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package top.yeij.cyrene.service
|
||||||
|
|
||||||
|
import android.accessibilityservice.AccessibilityService
|
||||||
|
import android.view.accessibility.AccessibilityEvent
|
||||||
|
import android.view.accessibility.AccessibilityNodeInfo
|
||||||
|
|
||||||
|
class CyreneAccessibilityService : AccessibilityService() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@Volatile
|
||||||
|
private var instance: CyreneAccessibilityService? = null
|
||||||
|
|
||||||
|
fun getScreenContent(): String {
|
||||||
|
return instance?.captureScreenContent() ?: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isRunning(): Boolean = instance != null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceConnected() {
|
||||||
|
super.onServiceConnected()
|
||||||
|
instance = this
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAccessibilityEvent(event: AccessibilityEvent?) {}
|
||||||
|
|
||||||
|
override fun onInterrupt() {}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
instance = null
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun captureScreenContent(): String {
|
||||||
|
val root = rootInActiveWindow ?: return ""
|
||||||
|
return try {
|
||||||
|
val sb = StringBuilder()
|
||||||
|
collectNodeText(root, sb, depth = 0)
|
||||||
|
sb.toString().trim()
|
||||||
|
} finally {
|
||||||
|
root.recycle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun collectNodeText(node: AccessibilityNodeInfo, sb: StringBuilder, depth: Int) {
|
||||||
|
// Skip the root decor view itself content, and limit depth
|
||||||
|
if (depth > 16) return
|
||||||
|
|
||||||
|
val text = node.text?.toString()?.trim()
|
||||||
|
val contentDesc = node.contentDescription?.toString()?.trim()
|
||||||
|
|
||||||
|
if (!text.isNullOrEmpty() && text != contentDesc) {
|
||||||
|
if (sb.isNotEmpty()) sb.append("\n")
|
||||||
|
sb.append(text)
|
||||||
|
} else if (!contentDesc.isNullOrEmpty() && depth > 0) {
|
||||||
|
// Only include content descriptions for non-root nodes
|
||||||
|
if (sb.isNotEmpty()) sb.append("\n")
|
||||||
|
sb.append(contentDesc)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (i in 0 until node.childCount) {
|
||||||
|
val child = node.getChild(i) ?: continue
|
||||||
|
collectNodeText(child, sb, depth + 1)
|
||||||
|
child.recycle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package top.yeij.cyrene.service
|
||||||
|
|
||||||
|
import android.service.voice.VoiceInteractionService
|
||||||
|
import android.util.Log
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recognition service for always-on hotword detection.
|
||||||
|
*
|
||||||
|
* In API 36, AlwaysOnHotwordDetector was removed from the public SDK.
|
||||||
|
* The system manages low-level hotword detection internally and invokes
|
||||||
|
* the session service declared in voice_interaction_config.xml when triggered.
|
||||||
|
* For custom hotword models (Porcupine / openWakeWord), integrate via
|
||||||
|
* a foreground service with microphone capture instead.
|
||||||
|
*/
|
||||||
|
class CyreneRecognitionService : VoiceInteractionService() {
|
||||||
|
|
||||||
|
override fun onReady() {
|
||||||
|
super.onReady()
|
||||||
|
isActive = true
|
||||||
|
Log.i(TAG, "Recognition service ready")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onShutdown() {
|
||||||
|
isActive = false
|
||||||
|
super.onShutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "CyreneRecognition"
|
||||||
|
var isActive: Boolean = false
|
||||||
|
private set
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package top.yeij.cyrene.service
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.service.voice.VoiceInteractionSessionService
|
||||||
|
import android.util.Log
|
||||||
|
|
||||||
|
class CyreneSessionService : VoiceInteractionSessionService() {
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
Log.i(TAG, "Session service created")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNewSession(args: Bundle?): CyreneVoiceInteractionSession {
|
||||||
|
return CyreneVoiceInteractionSession(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
Log.i(TAG, "Session service destroyed")
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "CyreneSession"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package top.yeij.cyrene.service
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.service.voice.VoiceInteractionService
|
||||||
|
import android.util.Log
|
||||||
|
import top.yeij.cyrene.MainActivity
|
||||||
|
import top.yeij.cyrene.util.Constants
|
||||||
|
|
||||||
|
class CyreneVoiceInteractionService : VoiceInteractionService() {
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
Log.i(TAG, "Service created")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReady() {
|
||||||
|
super.onReady()
|
||||||
|
isActive = true
|
||||||
|
getSharedPreferences(PREF_NAME, MODE_PRIVATE)
|
||||||
|
.edit().putBoolean(KEY_WAS_ACTIVE, true).apply()
|
||||||
|
Log.i(TAG, "Service ready")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPrepareToShowSession(args: Bundle, showFlags: Int) {
|
||||||
|
Log.i(TAG, "onPrepareToShowSession")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onShowSessionFailed(args: Bundle) {
|
||||||
|
Log.e(TAG, "onShowSessionFailed")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLaunchVoiceAssistFromKeyguard() {
|
||||||
|
Log.i(TAG, "onLaunchVoiceAssistFromKeyguard")
|
||||||
|
val intent = Intent(this, MainActivity::class.java).apply {
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
putExtra(Constants.EXTRA_VOICE_ASSIST, true)
|
||||||
|
}
|
||||||
|
startActivity(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onShutdown() {
|
||||||
|
isActive = false
|
||||||
|
super.onShutdown()
|
||||||
|
Log.i(TAG, "Service shutdown")
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
var isActive: Boolean = false
|
||||||
|
private set
|
||||||
|
private const val TAG = "CyreneVIS"
|
||||||
|
private const val PREF_NAME = "cyrene_assistant"
|
||||||
|
private const val KEY_WAS_ACTIVE = "was_assistant_active"
|
||||||
|
|
||||||
|
fun wasEverActive(context: android.content.Context): Boolean {
|
||||||
|
return context.getSharedPreferences(PREF_NAME, android.content.Context.MODE_PRIVATE)
|
||||||
|
.getBoolean(KEY_WAS_ACTIVE, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package top.yeij.cyrene.service
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.service.voice.VoiceInteractionSession
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.View
|
||||||
|
import android.view.WindowManager
|
||||||
|
import androidx.compose.ui.platform.ComposeView
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
import androidx.lifecycle.LifecycleRegistry
|
||||||
|
import androidx.lifecycle.setViewTreeLifecycleOwner
|
||||||
|
import androidx.savedstate.SavedStateRegistry
|
||||||
|
import androidx.savedstate.SavedStateRegistryController
|
||||||
|
import androidx.savedstate.SavedStateRegistryOwner
|
||||||
|
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
|
||||||
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.koin.core.context.GlobalContext
|
||||||
|
import top.yeij.cyrene.MainActivity
|
||||||
|
import top.yeij.cyrene.data.local.PreferencesDataStore
|
||||||
|
import top.yeij.cyrene.ui.overlay.OverlayContent
|
||||||
|
import top.yeij.cyrene.ui.theme.CyreneTheme
|
||||||
|
import top.yeij.cyrene.util.Constants
|
||||||
|
import top.yeij.cyrene.util.RuntimeLog
|
||||||
|
import top.yeij.cyrene.viewmodel.OverlayViewModel
|
||||||
|
|
||||||
|
class CyreneVoiceInteractionSession(context: Context) :
|
||||||
|
VoiceInteractionSession(context), LifecycleOwner, SavedStateRegistryOwner {
|
||||||
|
|
||||||
|
private val lifecycleRegistry = LifecycleRegistry(this)
|
||||||
|
override val lifecycle: Lifecycle get() = lifecycleRegistry
|
||||||
|
|
||||||
|
private val savedStateRegistryController = SavedStateRegistryController.create(this)
|
||||||
|
override val savedStateRegistry: SavedStateRegistry get() = savedStateRegistryController.savedStateRegistry
|
||||||
|
|
||||||
|
// Resolved eagerly with fallback — lazy would silently crash composition on failure
|
||||||
|
private var overlayViewModel: OverlayViewModel? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
init {
|
||||||
|
savedStateRegistryController.performAttach()
|
||||||
|
savedStateRegistryController.performRestore(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveViewModel(): OverlayViewModel? {
|
||||||
|
return try {
|
||||||
|
GlobalContext.get().get<OverlayViewModel>()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to resolve OverlayViewModel from Koin", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateContentView(): View {
|
||||||
|
Log.d(TAG, "onCreateContentView called")
|
||||||
|
RuntimeLog.general("overlay", "onCreateContentView")
|
||||||
|
overlayViewModel = resolveViewModel()
|
||||||
|
if (overlayViewModel == null) {
|
||||||
|
Log.w(TAG, "ViewModel unavailable — overlay will be static")
|
||||||
|
RuntimeLog.general("overlay", "ViewModel unavailable, overlay static")
|
||||||
|
}
|
||||||
|
|
||||||
|
lifecycleRegistry.currentState = Lifecycle.State.CREATED
|
||||||
|
val vm = overlayViewModel
|
||||||
|
return ComposeView(context).apply {
|
||||||
|
setViewTreeLifecycleOwner(this@CyreneVoiceInteractionSession)
|
||||||
|
setViewTreeSavedStateRegistryOwner(this@CyreneVoiceInteractionSession)
|
||||||
|
setContent {
|
||||||
|
CyreneTheme {
|
||||||
|
if (vm != null) {
|
||||||
|
OverlayContent(
|
||||||
|
onDismiss = { finish() },
|
||||||
|
onNavigateToMain = {
|
||||||
|
val intent = Intent(context, MainActivity::class.java).apply {
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
|
context.startActivity(intent)
|
||||||
|
},
|
||||||
|
viewModel = vm,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onShow(args: Bundle?, showFlags: Int) {
|
||||||
|
super.onShow(args, showFlags)
|
||||||
|
RuntimeLog.general("overlay", "onShow, vm=${overlayViewModel != null}")
|
||||||
|
lifecycleRegistry.currentState = Lifecycle.State.STARTED
|
||||||
|
|
||||||
|
// Configure window: extend behind status bar, don't resize for IME
|
||||||
|
configureWindow()
|
||||||
|
|
||||||
|
// Only read screen content if user enabled it in settings (default off)
|
||||||
|
val autoScreenContext = try {
|
||||||
|
val prefs: PreferencesDataStore = GlobalContext.get().get()
|
||||||
|
runBlocking { prefs.autoScreenContext.firstOrNull() } ?: false
|
||||||
|
} catch (_: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
if (autoScreenContext) {
|
||||||
|
val screenContent = CyreneAccessibilityService.getScreenContent()
|
||||||
|
if (screenContent.isNotBlank()) {
|
||||||
|
overlayViewModel?.sendScreenContext(screenContent)
|
||||||
|
RuntimeLog.general("overlay", "Screen context sent, len=${screenContent.length}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun configureWindow() {
|
||||||
|
try {
|
||||||
|
val method = VoiceInteractionSession::class.java.getDeclaredMethod("getWindow")
|
||||||
|
method.isAccessible = true
|
||||||
|
val w = method.invoke(this) as? android.view.Window ?: return
|
||||||
|
w.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS)
|
||||||
|
w.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION)
|
||||||
|
w.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
|
||||||
|
Log.d(TAG, "Window configured: translucent status/nav, adjust nothing for IME")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Failed to configure window: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onHide() {
|
||||||
|
RuntimeLog.general("overlay", "onHide")
|
||||||
|
lifecycleRegistry.currentState = Lifecycle.State.DESTROYED
|
||||||
|
super.onHide()
|
||||||
|
overlayViewModel?.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "CyreneVIS-Session"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
package top.yeij.cyrene.service
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
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 kotlinx.coroutines.flow.firstOrNull
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import okhttp3.WebSocket
|
||||||
|
import okhttp3.WebSocketListener
|
||||||
|
import top.yeij.cyrene.data.local.PreferencesDataStore
|
||||||
|
import top.yeij.cyrene.data.remote.dto.WSClientMessage
|
||||||
|
import top.yeij.cyrene.data.remote.dto.WSServerMessage
|
||||||
|
import java.net.URLEncoder
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
|
class WebSocketService(
|
||||||
|
private val preferencesDataStore: PreferencesDataStore,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
private val httpClient = OkHttpClient.Builder()
|
||||||
|
.readTimeout(0, TimeUnit.MILLISECONDS)
|
||||||
|
.writeTimeout(0, TimeUnit.MILLISECONDS)
|
||||||
|
.callTimeout(0, TimeUnit.MILLISECONDS)
|
||||||
|
.pingInterval(25, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
private val gson = Gson()
|
||||||
|
|
||||||
|
private var webSocket: WebSocket? = null
|
||||||
|
private var heartbeatJob: Job? = null
|
||||||
|
private var reconnectJob: Job? = null
|
||||||
|
private var shouldReconnect = true
|
||||||
|
private var currentSessionId: String? = null
|
||||||
|
private val connectionId = AtomicInteger(0)
|
||||||
|
|
||||||
|
private var clientId: String = ""
|
||||||
|
private var deviceName: String = ""
|
||||||
|
|
||||||
|
private val _isConnected = MutableStateFlow(false)
|
||||||
|
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
|
||||||
|
|
||||||
|
private val _connectionError = MutableStateFlow<String?>(null)
|
||||||
|
val connectionError: StateFlow<String?> = _connectionError.asStateFlow()
|
||||||
|
|
||||||
|
private val _incomingMessages = MutableSharedFlow<WSServerMessage>(extraBufferCapacity = 64)
|
||||||
|
val incomingMessages: SharedFlow<WSServerMessage> = _incomingMessages.asSharedFlow()
|
||||||
|
|
||||||
|
private suspend fun initClientIdentity() {
|
||||||
|
clientId = preferencesDataStore.clientId.firstOrNull() ?: run {
|
||||||
|
val id = "cl_" + System.currentTimeMillis().toString(36) + "_" +
|
||||||
|
(100000..999999).random().toString(36)
|
||||||
|
preferencesDataStore.saveClientId(id)
|
||||||
|
id
|
||||||
|
}
|
||||||
|
deviceName = preferencesDataStore.deviceName.firstOrNull() ?: run {
|
||||||
|
val name = "Android " + (Build.MODEL ?: "Device")
|
||||||
|
preferencesDataStore.saveDeviceName(name)
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getClientId(): String = clientId
|
||||||
|
fun getDeviceName(): String = deviceName
|
||||||
|
|
||||||
|
suspend fun connect(sessionId: String? = null) {
|
||||||
|
currentSessionId = sessionId
|
||||||
|
shouldReconnect = true
|
||||||
|
|
||||||
|
initClientIdentity()
|
||||||
|
|
||||||
|
val baseUrl = preferencesDataStore.baseUrl.firstOrNull()
|
||||||
|
?: "http://10.0.2.2:8080"
|
||||||
|
val token = preferencesDataStore.token.firstOrNull() ?: ""
|
||||||
|
val wsBase = baseUrl
|
||||||
|
.replace("https://", "wss://")
|
||||||
|
.replace("http://", "ws://")
|
||||||
|
.trimEnd('/')
|
||||||
|
val urlBuilder = StringBuilder("$wsBase/ws/chat")
|
||||||
|
val params = mutableListOf<String>()
|
||||||
|
if (token.isNotBlank()) {
|
||||||
|
params.add("token=" + encode(token))
|
||||||
|
}
|
||||||
|
sessionId?.let {
|
||||||
|
if (it.isNotBlank()) params.add("session_id=" + encode(it))
|
||||||
|
}
|
||||||
|
if (clientId.isNotBlank()) {
|
||||||
|
params.add("client_id=" + encode(clientId))
|
||||||
|
}
|
||||||
|
if (deviceName.isNotBlank()) {
|
||||||
|
params.add("device_name=" + encode(deviceName))
|
||||||
|
}
|
||||||
|
if (params.isNotEmpty()) {
|
||||||
|
urlBuilder.append("?")
|
||||||
|
urlBuilder.append(params.joinToString("&"))
|
||||||
|
}
|
||||||
|
|
||||||
|
val url = urlBuilder.toString()
|
||||||
|
val connId = connectionId.incrementAndGet()
|
||||||
|
Log.i(TAG, "[#$connId] Connecting to $url")
|
||||||
|
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.header("User-Agent", "Cyrene-Android/${Build.MODEL ?: "Device"}")
|
||||||
|
.build()
|
||||||
|
|
||||||
|
// Close previous socket silently
|
||||||
|
try { webSocket?.close(1000, "Reconnecting") } catch (_: Exception) { }
|
||||||
|
cancelHeartbeat()
|
||||||
|
|
||||||
|
webSocket = httpClient.newWebSocket(request, object : WebSocketListener() {
|
||||||
|
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||||
|
if (connectionId.get() != connId) {
|
||||||
|
Log.d(TAG, "[#$connId] onOpen ignored (stale)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Log.i(TAG, "[#$connId] Connected")
|
||||||
|
_isConnected.value = true
|
||||||
|
_connectionError.value = null
|
||||||
|
startHeartbeat()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||||
|
if (connectionId.get() != connId) return
|
||||||
|
try {
|
||||||
|
val msg = gson.fromJson(text, WSServerMessage::class.java)
|
||||||
|
_incomingMessages.tryEmit(msg)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "[#$connId] Failed to parse message: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||||
|
if (connectionId.get() != connId) return
|
||||||
|
Log.i(TAG, "[#$connId] Server closing: code=$code reason=$reason")
|
||||||
|
_isConnected.value = false
|
||||||
|
cancelHeartbeat()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||||
|
if (connectionId.get() != connId) return
|
||||||
|
Log.i(TAG, "[#$connId] Closed: code=$code reason=$reason")
|
||||||
|
_isConnected.value = false
|
||||||
|
cancelHeartbeat()
|
||||||
|
scheduleReconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||||
|
if (connectionId.get() != connId) return
|
||||||
|
val httpCode = response?.code
|
||||||
|
Log.e(TAG, "[#$connId] Failure: ${t.message} (http=$httpCode)", t)
|
||||||
|
_isConnected.value = false
|
||||||
|
cancelHeartbeat()
|
||||||
|
|
||||||
|
val errorMsg = when (httpCode) {
|
||||||
|
403 -> {
|
||||||
|
Log.e(TAG, "[#$connId] WebSocket 403: 仅管理员用户可连接。请使用管理员账户登录。")
|
||||||
|
"仅管理员用户可连接"
|
||||||
|
}
|
||||||
|
401 -> "认证失败,请重新登录"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
if (errorMsg != null) {
|
||||||
|
_connectionError.value = errorMsg
|
||||||
|
}
|
||||||
|
// onClosed will always follow, which triggers scheduleReconnect
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun encode(value: String): String = URLEncoder.encode(value, "UTF-8")
|
||||||
|
|
||||||
|
private fun buildMessage(
|
||||||
|
type: String,
|
||||||
|
sessionId: String? = null,
|
||||||
|
mode: String? = null,
|
||||||
|
content: String? = null,
|
||||||
|
): WSClientMessage = WSClientMessage(
|
||||||
|
type = type,
|
||||||
|
sessionId = sessionId ?: currentSessionId,
|
||||||
|
mode = mode,
|
||||||
|
content = content,
|
||||||
|
timestamp = System.currentTimeMillis(),
|
||||||
|
clientId = clientId.ifBlank { null },
|
||||||
|
deviceName = deviceName.ifBlank { null },
|
||||||
|
userAgent = "Cyrene-Android/${Build.MODEL ?: "Device"}",
|
||||||
|
)
|
||||||
|
|
||||||
|
fun sendMessage(content: String, sessionId: String? = null, mode: String = "text") {
|
||||||
|
val msg = buildMessage("message", sessionId, mode, content)
|
||||||
|
webSocket?.send(gson.toJson(msg))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun requestHistory(sessionId: String?) {
|
||||||
|
val msg = buildMessage("history", sessionId)
|
||||||
|
if (webSocket != null) {
|
||||||
|
webSocket?.send(gson.toJson(msg))
|
||||||
|
Log.i(TAG, "History requested for session=$sessionId")
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "Cannot request history: WebSocket is null")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendPing() {
|
||||||
|
val msg = buildMessage("ping")
|
||||||
|
webSocket?.send(gson.toJson(msg))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendScreenContext(content: String, sessionId: String? = null) {
|
||||||
|
val msg = buildMessage("message", sessionId, mode = "text", content = content)
|
||||||
|
webSocket?.send(gson.toJson(msg))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendVoiceInput(audioBase64: String, sessionId: String? = null, mode: String = "voice_msg") {
|
||||||
|
val msg = WSClientMessage(
|
||||||
|
type = "voice_input",
|
||||||
|
sessionId = sessionId ?: currentSessionId,
|
||||||
|
mode = mode,
|
||||||
|
audioData = audioBase64,
|
||||||
|
timestamp = System.currentTimeMillis(),
|
||||||
|
clientId = clientId.ifBlank { null },
|
||||||
|
deviceName = deviceName.ifBlank { null },
|
||||||
|
userAgent = "Cyrene-Android/${Build.MODEL ?: "Device"}",
|
||||||
|
)
|
||||||
|
webSocket?.send(gson.toJson(msg))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun forceReconnect() {
|
||||||
|
shouldReconnect = true
|
||||||
|
reconnectJob?.cancel()
|
||||||
|
reconnectJob = null
|
||||||
|
scope.launch {
|
||||||
|
if (!_isConnected.value) {
|
||||||
|
try {
|
||||||
|
connect(currentSessionId)
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disconnect() {
|
||||||
|
shouldReconnect = false
|
||||||
|
reconnectJob?.cancel()
|
||||||
|
reconnectJob = null
|
||||||
|
cancelHeartbeat()
|
||||||
|
try { webSocket?.close(1000, "User disconnected") } catch (_: Exception) { }
|
||||||
|
webSocket = null
|
||||||
|
_isConnected.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startHeartbeat() {
|
||||||
|
cancelHeartbeat()
|
||||||
|
heartbeatJob = scope.launch {
|
||||||
|
while (_isConnected.value) {
|
||||||
|
delay(30_000)
|
||||||
|
if (_isConnected.value) {
|
||||||
|
sendPing()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancelHeartbeat() {
|
||||||
|
heartbeatJob?.cancel()
|
||||||
|
heartbeatJob = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleReconnect() {
|
||||||
|
if (reconnectJob?.isActive == true || !shouldReconnect) return
|
||||||
|
reconnectJob = scope.launch {
|
||||||
|
var attempt = 0
|
||||||
|
while (shouldReconnect && !_isConnected.value) {
|
||||||
|
val delayMs = minOf(
|
||||||
|
(Math.pow(2.0, attempt.toDouble()) * 1000).toLong(),
|
||||||
|
30_000L
|
||||||
|
)
|
||||||
|
Log.i(TAG, "Reconnecting in ${delayMs}ms (attempt ${attempt + 1})")
|
||||||
|
delay(delayMs)
|
||||||
|
attempt++
|
||||||
|
if (shouldReconnect && !_isConnected.value) {
|
||||||
|
try {
|
||||||
|
connect(currentSessionId)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Reconnect attempt $attempt failed: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Log.i(TAG, "Reconnect loop ended (connected=${_isConnected.value}, shouldReconnect=$shouldReconnect)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "CyreneWS"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
package top.yeij.cyrene.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ChatBubble(
|
||||||
|
content: String,
|
||||||
|
role: String,
|
||||||
|
msgType: String,
|
||||||
|
timestamp: Long,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val isUser = role == "user"
|
||||||
|
val formattedTime = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(timestamp))
|
||||||
|
|
||||||
|
when (msgType) {
|
||||||
|
"chat" -> ChatMessageBubble(content, isUser, formattedTime, modifier)
|
||||||
|
"action" -> ActionMessage(content, modifier)
|
||||||
|
"thinking" -> ThinkingBubble(content, modifier)
|
||||||
|
"tool_progress" -> ToolProgressBubble(content, modifier)
|
||||||
|
"system_info" -> SystemInfoBubble(content, modifier)
|
||||||
|
else -> ChatMessageBubble(content, isUser, formattedTime, modifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun ChatMessageBubble(
|
||||||
|
content: String,
|
||||||
|
isUser: Boolean,
|
||||||
|
time: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
var showMenu by remember { mutableStateOf(false) }
|
||||||
|
val clipboardManager = LocalClipboardManager.current
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = if (isUser) Alignment.End else Alignment.Start,
|
||||||
|
) {
|
||||||
|
Box {
|
||||||
|
Surface(
|
||||||
|
shape = MaterialTheme.shapes.large,
|
||||||
|
color = if (isUser)
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
shadowElevation = 2.dp,
|
||||||
|
modifier = Modifier
|
||||||
|
.widthIn(max = 300.dp)
|
||||||
|
.combinedClickable(
|
||||||
|
onClick = {},
|
||||||
|
onLongClick = { showMenu = true },
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = content,
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
color = if (isUser)
|
||||||
|
MaterialTheme.colorScheme.onPrimary
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = showMenu,
|
||||||
|
onDismissRequest = { showMenu = false },
|
||||||
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("复制") },
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(Icons.Default.ContentCopy, contentDescription = null)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
showMenu = false
|
||||||
|
clipboardManager.setText(AnnotatedString(content))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = time,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ActionMessage(content: String, modifier: Modifier = Modifier) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 2.dp),
|
||||||
|
horizontalArrangement = Arrangement.Start,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = content,
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic,
|
||||||
|
),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Start,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ThinkingBubble(content: String, modifier: Modifier = Modifier) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 2.dp)
|
||||||
|
.background(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = content,
|
||||||
|
modifier = Modifier.padding(10.dp),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ToolProgressBubble(content: String, modifier: Modifier = Modifier) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 2.dp),
|
||||||
|
horizontalArrangement = Arrangement.Start,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
shape = MaterialTheme.shapes.small,
|
||||||
|
color = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.6f),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = content,
|
||||||
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SystemInfoBubble(content: String, modifier: Modifier = Modifier) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 2.dp),
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = content,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package top.yeij.cyrene.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.AcUnit
|
||||||
|
import androidx.compose.material.icons.filled.Curtains
|
||||||
|
import androidx.compose.material.icons.filled.Lightbulb
|
||||||
|
import androidx.compose.material.icons.filled.Lock
|
||||||
|
import androidx.compose.material.icons.filled.Sensors
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import top.yeij.cyrene.domain.model.Device
|
||||||
|
import top.yeij.cyrene.domain.model.DeviceType
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DeviceCard(
|
||||||
|
device: Device,
|
||||||
|
onTogglePower: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = device.type.toIcon(),
|
||||||
|
contentDescription = device.type.name,
|
||||||
|
tint = if (device.state.power == true)
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = device.name,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
device.room?.let {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
device.state.temperature?.let { temp ->
|
||||||
|
Text(
|
||||||
|
text = "${"%.1f".format(temp)}°C",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (device.state.power != null) {
|
||||||
|
Switch(
|
||||||
|
checked = device.state.power,
|
||||||
|
onCheckedChange = { onTogglePower() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DeviceType.toIcon() = when (this) {
|
||||||
|
DeviceType.LIGHT -> Icons.Filled.Lightbulb
|
||||||
|
DeviceType.AC -> Icons.Filled.AcUnit
|
||||||
|
DeviceType.CURTAIN -> Icons.Filled.Curtains
|
||||||
|
DeviceType.SENSOR -> Icons.Filled.Sensors
|
||||||
|
DeviceType.DOOR_LOCK -> Icons.Filled.Lock
|
||||||
|
DeviceType.UNKNOWN -> Icons.Filled.Sensors
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package top.yeij.cyrene.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.RepeatMode
|
||||||
|
import androidx.compose.animation.core.animateFloat
|
||||||
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Circle
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
enum class CyreneStatus {
|
||||||
|
ONLINE,
|
||||||
|
THINKING,
|
||||||
|
SPEAKING,
|
||||||
|
OFFLINE,
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun StatusIndicator(
|
||||||
|
status: CyreneStatus,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
when (status) {
|
||||||
|
CyreneStatus.ONLINE -> {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Circle,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(8.dp),
|
||||||
|
tint = Color(0xFF4CAF50),
|
||||||
|
)
|
||||||
|
Text("昔涟", style = MaterialTheme.typography.labelLarge)
|
||||||
|
}
|
||||||
|
CyreneStatus.THINKING -> {
|
||||||
|
PulsingDot(Color(0xFFFFA726))
|
||||||
|
Text("思考中…", style = MaterialTheme.typography.labelLarge)
|
||||||
|
}
|
||||||
|
CyreneStatus.SPEAKING -> {
|
||||||
|
PulsingDot(Color(0xFF42A5F5))
|
||||||
|
Text("正在说话…", style = MaterialTheme.typography.labelLarge)
|
||||||
|
}
|
||||||
|
CyreneStatus.OFFLINE -> {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Circle,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(8.dp),
|
||||||
|
tint = Color(0xFF9E9E9E),
|
||||||
|
)
|
||||||
|
Text("昔涟 · 离线", style = MaterialTheme.typography.labelLarge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PulsingDot(color: Color) {
|
||||||
|
val infiniteTransition = rememberInfiniteTransition(label = "pulse")
|
||||||
|
val alpha by infiniteTransition.animateFloat(
|
||||||
|
initialValue = 0.3f,
|
||||||
|
targetValue = 1f,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = tween(600),
|
||||||
|
repeatMode = RepeatMode.Reverse,
|
||||||
|
),
|
||||||
|
label = "pulse_alpha",
|
||||||
|
)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(8.dp)
|
||||||
|
.alpha(alpha)
|
||||||
|
.background(color, CircleShape),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package top.yeij.cyrene.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.RepeatMode
|
||||||
|
import androidx.compose.animation.core.animateFloat
|
||||||
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TypingIndicator(modifier: Modifier = Modifier) {
|
||||||
|
val infiniteTransition = rememberInfiniteTransition(label = "typing")
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.Start,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
shape = MaterialTheme.shapes.large,
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
shadowElevation = 1.dp,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "昔涟正在输入",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
repeat(3) { index ->
|
||||||
|
val alpha by infiniteTransition.animateFloat(
|
||||||
|
initialValue = 0.2f,
|
||||||
|
targetValue = 1f,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = tween(
|
||||||
|
durationMillis = 400,
|
||||||
|
delayMillis = index * 200,
|
||||||
|
),
|
||||||
|
repeatMode = RepeatMode.Reverse,
|
||||||
|
),
|
||||||
|
label = "dot_$index",
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(5.dp)
|
||||||
|
.alpha(alpha)
|
||||||
|
.background(
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
CircleShape,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package top.yeij.cyrene.ui.navigation
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.Chat
|
||||||
|
import androidx.compose.material.icons.filled.DevicesOther
|
||||||
|
import androidx.compose.material.icons.filled.Person
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.NavigationRail
|
||||||
|
import androidx.compose.material3.NavigationRailItem
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
|
import androidx.navigation.compose.NavHost
|
||||||
|
import androidx.navigation.compose.composable
|
||||||
|
import top.yeij.cyrene.ui.screens.chat.ChatScreen
|
||||||
|
import top.yeij.cyrene.ui.screens.iot.IoTScreen
|
||||||
|
import top.yeij.cyrene.ui.screens.login.LoginScreen
|
||||||
|
import top.yeij.cyrene.ui.screens.about.AboutScreen
|
||||||
|
import top.yeij.cyrene.ui.screens.profile.ProfileScreen
|
||||||
|
import top.yeij.cyrene.ui.screens.settings.SettingsScreen
|
||||||
|
|
||||||
|
object Routes {
|
||||||
|
const val LOGIN = "login"
|
||||||
|
const val MAIN = "main"
|
||||||
|
const val CHAT = "chat"
|
||||||
|
const val IOT = "iot"
|
||||||
|
const val SETTINGS = "settings"
|
||||||
|
const val ABOUT = "about"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CyreneNavGraph(
|
||||||
|
navController: NavHostController,
|
||||||
|
startDestination: String,
|
||||||
|
isDefaultAssistant: Boolean,
|
||||||
|
onOpenAssistantSettings: () -> Unit,
|
||||||
|
) {
|
||||||
|
NavHost(
|
||||||
|
navController = navController,
|
||||||
|
startDestination = startDestination,
|
||||||
|
) {
|
||||||
|
composable(Routes.LOGIN) {
|
||||||
|
LoginScreen(
|
||||||
|
onLoginSuccess = {
|
||||||
|
navController.navigate(Routes.MAIN) {
|
||||||
|
popUpTo(Routes.LOGIN) { inclusive = true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(Routes.MAIN) {
|
||||||
|
MainScreen(
|
||||||
|
navController = navController,
|
||||||
|
isDefaultAssistant = isDefaultAssistant,
|
||||||
|
onOpenAssistantSettings = onOpenAssistantSettings,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(Routes.SETTINGS) {
|
||||||
|
SettingsScreen(
|
||||||
|
onBack = { navController.popBackStack() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(Routes.ABOUT) {
|
||||||
|
AboutScreen(
|
||||||
|
onBack = { navController.popBackStack() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class BottomNavItem(
|
||||||
|
val label: String,
|
||||||
|
val icon: @Composable () -> Unit,
|
||||||
|
val route: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MainScreen(
|
||||||
|
navController: NavHostController,
|
||||||
|
isDefaultAssistant: Boolean,
|
||||||
|
onOpenAssistantSettings: () -> Unit,
|
||||||
|
) {
|
||||||
|
val items = listOf(
|
||||||
|
BottomNavItem(
|
||||||
|
label = "对话",
|
||||||
|
icon = { Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = "对话") },
|
||||||
|
route = Routes.CHAT,
|
||||||
|
),
|
||||||
|
BottomNavItem(
|
||||||
|
label = "设备",
|
||||||
|
icon = { Icon(Icons.Filled.DevicesOther, contentDescription = "设备") },
|
||||||
|
route = Routes.IOT,
|
||||||
|
),
|
||||||
|
BottomNavItem(
|
||||||
|
label = "我的",
|
||||||
|
icon = { Icon(Icons.Filled.Person, contentDescription = "我的") },
|
||||||
|
route = "profile",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
var selectedTab by rememberSaveable { mutableIntStateOf(0) }
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.statusBarsPadding(),
|
||||||
|
) {
|
||||||
|
NavigationRail {
|
||||||
|
items.forEachIndexed { index, item ->
|
||||||
|
NavigationRailItem(
|
||||||
|
selected = selectedTab == index,
|
||||||
|
onClick = { selectedTab = index },
|
||||||
|
icon = item.icon,
|
||||||
|
label = { Text(item.label) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.background(MaterialTheme.colorScheme.background),
|
||||||
|
) {
|
||||||
|
when (selectedTab) {
|
||||||
|
0 -> ChatScreen()
|
||||||
|
1 -> IoTScreen()
|
||||||
|
2 -> ProfileScreen(
|
||||||
|
onNavigateToSettings = { navController.navigate(Routes.SETTINGS) },
|
||||||
|
onNavigateToAbout = { navController.navigate(Routes.ABOUT) },
|
||||||
|
onLogout = {
|
||||||
|
navController.navigate(Routes.LOGIN) {
|
||||||
|
popUpTo(Routes.MAIN) { inclusive = true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onNavigateToLogin = {
|
||||||
|
navController.navigate(Routes.LOGIN)
|
||||||
|
},
|
||||||
|
isDefaultAssistant = isDefaultAssistant,
|
||||||
|
onOpenAssistantSettings = onOpenAssistantSettings,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,600 @@
|
|||||||
|
package top.yeij.cyrene.ui.overlay
|
||||||
|
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.slideInVertically
|
||||||
|
import androidx.compose.animation.slideOutVertically
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.imePadding
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.OpenInNew
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.Send
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.KeyboardVoice
|
||||||
|
import androidx.compose.material.icons.filled.Lock
|
||||||
|
import androidx.compose.material.icons.filled.Mic
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import org.koin.compose.koinInject
|
||||||
|
import top.yeij.cyrene.domain.model.Message
|
||||||
|
import top.yeij.cyrene.ui.components.ChatBubble
|
||||||
|
import top.yeij.cyrene.ui.components.TypingIndicator
|
||||||
|
import top.yeij.cyrene.util.RecordState
|
||||||
|
import top.yeij.cyrene.viewmodel.OverlayState
|
||||||
|
import top.yeij.cyrene.viewmodel.OverlayViewModel
|
||||||
|
import kotlin.math.min
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AnimatedChatBubble(
|
||||||
|
message: Message,
|
||||||
|
animIndex: Int,
|
||||||
|
) {
|
||||||
|
var visible by remember { mutableStateOf(false) }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
delay(min(animIndex, 10) * 60L)
|
||||||
|
visible = true
|
||||||
|
}
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = visible,
|
||||||
|
enter = fadeIn(animationSpec = androidx.compose.animation.core.tween(300)) +
|
||||||
|
slideInVertically(
|
||||||
|
animationSpec = androidx.compose.animation.core.tween(300),
|
||||||
|
initialOffsetY = { it / 4 },
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
ChatBubble(
|
||||||
|
content = message.content,
|
||||||
|
role = message.role,
|
||||||
|
msgType = message.msgType,
|
||||||
|
timestamp = message.timestamp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun OverlayContent(
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onNavigateToMain: () -> Unit,
|
||||||
|
viewModel: OverlayViewModel = koinInject(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsState()
|
||||||
|
val messages by viewModel.messages.collectAsState()
|
||||||
|
val inputText by viewModel.inputText.collectAsState()
|
||||||
|
val recordState by viewModel.voiceRecordState.collectAsState()
|
||||||
|
val recordDurationMs by viewModel.voiceRecordDurationMs.collectAsState()
|
||||||
|
val animIndex by viewModel.messageAnimIndex.collectAsState()
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
val isProcessing = state == OverlayState.PROCESSING
|
||||||
|
val recordSec = recordDurationMs / 1000f
|
||||||
|
val isRecording = recordState == RecordState.RECORDING
|
||||||
|
val isLocked = recordState == RecordState.LOCKED
|
||||||
|
|
||||||
|
// Animated "昔涟正在输入..." dots
|
||||||
|
val typingDots = remember { mutableStateOf("") }
|
||||||
|
LaunchedEffect(isProcessing) {
|
||||||
|
if (isProcessing) {
|
||||||
|
val dots = arrayOf("", ".", "..", "...")
|
||||||
|
var i = 0
|
||||||
|
while (true) {
|
||||||
|
typingDots.value = dots[i % 4]
|
||||||
|
i++
|
||||||
|
delay(400)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
typingDots.value = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val configuration = LocalConfiguration.current
|
||||||
|
val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||||
|
|
||||||
|
// Manual status bar height — Compose WindowInsets may not work in VoiceInteractionSession
|
||||||
|
val context = androidx.compose.ui.platform.LocalContext.current
|
||||||
|
val statusBarHeight = remember {
|
||||||
|
val resourceId = context.resources.getIdentifier("status_bar_height", "dimen", "android")
|
||||||
|
if (resourceId > 0) context.resources.getDimensionPixelSize(resourceId) else 0
|
||||||
|
}
|
||||||
|
val statusBarPaddingDp = with(androidx.compose.ui.platform.LocalDensity.current) {
|
||||||
|
statusBarHeight.toDp()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manual nav bar height
|
||||||
|
val navBarHeight = remember {
|
||||||
|
val resourceId = context.resources.getIdentifier("navigation_bar_height", "dimen", "android")
|
||||||
|
if (resourceId > 0) context.resources.getDimensionPixelSize(resourceId) else 0
|
||||||
|
}
|
||||||
|
val navBarPaddingDp = with(androidx.compose.ui.platform.LocalDensity.current) {
|
||||||
|
navBarHeight.toDp()
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(messages.size) {
|
||||||
|
if (messages.isNotEmpty()) {
|
||||||
|
listState.animateScrollToItem(messages.size - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(state) {
|
||||||
|
if (state == OverlayState.IDLE) {
|
||||||
|
viewModel.finish()
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = state != OverlayState.IDLE,
|
||||||
|
enter = fadeIn() + slideInVertically { it },
|
||||||
|
exit = fadeOut() + slideOutVertically { it },
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(top = statusBarPaddingDp, bottom = navBarPaddingDp),
|
||||||
|
) {
|
||||||
|
if (isLandscape) {
|
||||||
|
LandscapeContent(
|
||||||
|
state = state,
|
||||||
|
messages = messages,
|
||||||
|
inputText = inputText,
|
||||||
|
isProcessing = isProcessing,
|
||||||
|
listState = listState,
|
||||||
|
recordSec = recordSec,
|
||||||
|
isRecording = isRecording,
|
||||||
|
isLocked = isLocked,
|
||||||
|
typingDots = typingDots.value,
|
||||||
|
animIndex = animIndex,
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
onNavigateToMain = onNavigateToMain,
|
||||||
|
viewModel = viewModel,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
PortraitContent(
|
||||||
|
state = state,
|
||||||
|
messages = messages,
|
||||||
|
inputText = inputText,
|
||||||
|
isProcessing = isProcessing,
|
||||||
|
listState = listState,
|
||||||
|
recordSec = recordSec,
|
||||||
|
isRecording = isRecording,
|
||||||
|
isLocked = isLocked,
|
||||||
|
typingDots = typingDots.value,
|
||||||
|
animIndex = animIndex,
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
onNavigateToMain = onNavigateToMain,
|
||||||
|
viewModel = viewModel,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("LongParameterList")
|
||||||
|
@Composable
|
||||||
|
private fun PortraitContent(
|
||||||
|
state: OverlayState,
|
||||||
|
messages: List<Message>,
|
||||||
|
inputText: String,
|
||||||
|
isProcessing: Boolean,
|
||||||
|
listState: androidx.compose.foundation.lazy.LazyListState,
|
||||||
|
recordSec: Float,
|
||||||
|
isRecording: Boolean,
|
||||||
|
isLocked: Boolean,
|
||||||
|
typingDots: String,
|
||||||
|
animIndex: Map<String, Int>,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onNavigateToMain: () -> Unit,
|
||||||
|
viewModel: OverlayViewModel,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.clickable(
|
||||||
|
indication = null,
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
) { /* consume clicks */ },
|
||||||
|
) {
|
||||||
|
// Messages + top bar stay fixed at top
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
MessageTopBar(onDismiss = onDismiss, onNavigateToMain = onNavigateToMain)
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1f),
|
||||||
|
state = listState,
|
||||||
|
) {
|
||||||
|
if (messages.isNotEmpty()) {
|
||||||
|
items(messages, key = { it.id }) { message ->
|
||||||
|
AnimatedChatBubble(
|
||||||
|
message = message,
|
||||||
|
animIndex = animIndex[message.id] ?: 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isProcessing) {
|
||||||
|
item(key = "typing_indicator") {
|
||||||
|
TypingIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input area at bottom, imePadding pushes it above full-screen IME
|
||||||
|
InputArea(
|
||||||
|
state = state,
|
||||||
|
inputText = inputText,
|
||||||
|
viewModel = viewModel,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.imePadding(),
|
||||||
|
recordSec = recordSec,
|
||||||
|
isRecording = isRecording,
|
||||||
|
isLocked = isLocked,
|
||||||
|
typingDots = typingDots,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("LongParameterList")
|
||||||
|
@Composable
|
||||||
|
private fun LandscapeContent(
|
||||||
|
state: OverlayState,
|
||||||
|
messages: List<Message>,
|
||||||
|
inputText: String,
|
||||||
|
isProcessing: Boolean,
|
||||||
|
listState: androidx.compose.foundation.lazy.LazyListState,
|
||||||
|
recordSec: Float,
|
||||||
|
isRecording: Boolean,
|
||||||
|
isLocked: Boolean,
|
||||||
|
typingDots: String,
|
||||||
|
animIndex: Map<String, Int>,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onNavigateToMain: () -> Unit,
|
||||||
|
viewModel: OverlayViewModel,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.clickable(
|
||||||
|
indication = null,
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
) { /* consume clicks */ },
|
||||||
|
) {
|
||||||
|
// Messages + top bar on the left, stay fixed
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
MessageTopBar(onDismiss = onDismiss, onNavigateToMain = onNavigateToMain)
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1f),
|
||||||
|
state = listState,
|
||||||
|
) {
|
||||||
|
if (messages.isNotEmpty()) {
|
||||||
|
items(messages, key = { it.id }) { message ->
|
||||||
|
AnimatedChatBubble(
|
||||||
|
message = message,
|
||||||
|
animIndex = animIndex[message.id] ?: 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isProcessing) {
|
||||||
|
item(key = "typing_indicator") {
|
||||||
|
TypingIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(1.dp)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f))
|
||||||
|
)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxHeight(),
|
||||||
|
contentAlignment = Alignment.BottomCenter,
|
||||||
|
) {
|
||||||
|
InputArea(
|
||||||
|
state = state,
|
||||||
|
inputText = inputText,
|
||||||
|
viewModel = viewModel,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.imePadding(),
|
||||||
|
recordSec = recordSec,
|
||||||
|
isRecording = isRecording,
|
||||||
|
isLocked = isLocked,
|
||||||
|
typingDots = typingDots,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MessageTopBar(
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onNavigateToMain: () -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.3f))
|
||||||
|
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
IconButton(onClick = onDismiss) {
|
||||||
|
Icon(Icons.Filled.Close, contentDescription = "关闭")
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
IconButton(onClick = onNavigateToMain) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.OpenInNew,
|
||||||
|
contentDescription = "进入主界面",
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun InputArea(
|
||||||
|
state: OverlayState,
|
||||||
|
inputText: String,
|
||||||
|
viewModel: OverlayViewModel,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
recordSec: Float = 0f,
|
||||||
|
isRecording: Boolean = false,
|
||||||
|
isLocked: Boolean = false,
|
||||||
|
typingDots: String = "",
|
||||||
|
) {
|
||||||
|
// Gesture tracking state — local to InputArea
|
||||||
|
var isDragging by remember { mutableStateOf(false) }
|
||||||
|
var dragOffsetX by remember { mutableStateOf(0f) }
|
||||||
|
var dragOffsetY by remember { mutableStateOf(0f) }
|
||||||
|
val inCancelZone = isDragging && dragOffsetY < -120f
|
||||||
|
val inLockZone = isDragging && dragOffsetX > 60f
|
||||||
|
val isProcessing = state == OverlayState.PROCESSING
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
||||||
|
shadowElevation = 8.dp,
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(12.dp),
|
||||||
|
) {
|
||||||
|
// "昔涟正在输入..." indicator
|
||||||
|
if (isProcessing && typingDots.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = "昔涟正在输入$typingDots",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(bottom = 4.dp),
|
||||||
|
textAlign = TextAlign.Start,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
if (isRecording && isDragging) {
|
||||||
|
// Recording with drag — show recording indicator
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(
|
||||||
|
if (inCancelZone) MaterialTheme.colorScheme.errorContainer
|
||||||
|
else MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = when {
|
||||||
|
inCancelZone -> "松手取消"
|
||||||
|
inLockZone -> "松手录音"
|
||||||
|
else -> "%.1f\" 上滑取消 右滑松手".format(recordSec)
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = if (inCancelZone) MaterialTheme.colorScheme.error
|
||||||
|
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(start = 8.dp)
|
||||||
|
.size(48.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.primary)
|
||||||
|
.offset { IntOffset(dragOffsetX.toInt(), dragOffsetY.toInt()) },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Mic,
|
||||||
|
contentDescription = "录音中",
|
||||||
|
tint = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if (isLocked) {
|
||||||
|
// Locked (hands-free) mode
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Lock,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "%.1f\" 松手录音中 — 点击结束".format(recordSec),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IconButton(onClick = { viewModel.finishRecord() }) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.Send,
|
||||||
|
contentDescription = "发送",
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Normal input mode
|
||||||
|
OutlinedTextField(
|
||||||
|
value = inputText,
|
||||||
|
onValueChange = { viewModel.onInputChanged(it) },
|
||||||
|
placeholder = { Text("输入消息...") },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
maxLines = 3,
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
|
||||||
|
if (inputText.isNotBlank()) {
|
||||||
|
IconButton(
|
||||||
|
onClick = { viewModel.sendText() },
|
||||||
|
enabled = !isProcessing,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.Send,
|
||||||
|
contentDescription = "发送",
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(48.dp)
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectDragGesturesAfterLongPress(
|
||||||
|
onDragStart = { _ ->
|
||||||
|
isDragging = true
|
||||||
|
dragOffsetX = 0f
|
||||||
|
dragOffsetY = 0f
|
||||||
|
viewModel.startRecord()
|
||||||
|
},
|
||||||
|
onDrag = { change, dragAmount ->
|
||||||
|
change.consume()
|
||||||
|
dragOffsetX += dragAmount.x
|
||||||
|
dragOffsetY += dragAmount.y
|
||||||
|
},
|
||||||
|
onDragEnd = {
|
||||||
|
isDragging = false
|
||||||
|
when {
|
||||||
|
dragOffsetY < -120f -> viewModel.cancelRecord()
|
||||||
|
dragOffsetX > 60f -> viewModel.lockRecord()
|
||||||
|
else -> viewModel.finishRecord()
|
||||||
|
}
|
||||||
|
dragOffsetX = 0f
|
||||||
|
dragOffsetY = 0f
|
||||||
|
},
|
||||||
|
onDragCancel = {
|
||||||
|
isDragging = false
|
||||||
|
viewModel.cancelRecord()
|
||||||
|
dragOffsetX = 0f
|
||||||
|
dragOffsetY = 0f
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.KeyboardVoice,
|
||||||
|
contentDescription = "按住录音",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status hint
|
||||||
|
val hint = when {
|
||||||
|
isLocked -> ""
|
||||||
|
isRecording && isDragging -> ""
|
||||||
|
else -> when (state) {
|
||||||
|
OverlayState.LISTENING -> "我在听..."
|
||||||
|
OverlayState.PROCESSING -> "思考中..."
|
||||||
|
OverlayState.SPEAKING -> "正在说话..."
|
||||||
|
OverlayState.WAITING -> "长按麦克风开始说话"
|
||||||
|
OverlayState.IDLE -> ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hint.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = hint,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 4.dp),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
package top.yeij.cyrene.ui.screens.about
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
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.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.OpenInNew
|
||||||
|
import androidx.compose.material.icons.filled.Code
|
||||||
|
import androidx.compose.material.icons.filled.Info
|
||||||
|
import androidx.compose.material.icons.filled.Person
|
||||||
|
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun AboutScreen(onBack: () -> Unit) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
CenterAlignedTopAppBar(
|
||||||
|
title = { Text("关于") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Cyrene",
|
||||||
|
style = MaterialTheme.typography.headlineLarge.copy(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 28.sp,
|
||||||
|
),
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 24.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "昔涟",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "v0.1.0",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 4.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
HorizontalDivider()
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "信息",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text("简介") },
|
||||||
|
leadingContent = {
|
||||||
|
Icon(Icons.Filled.Info, contentDescription = null)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Text(
|
||||||
|
text = "Cyrene 是一款基于 Android 的智能语音助手,支持实时对话、IoT 设备管理和语音交互。",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text("开发者") },
|
||||||
|
leadingContent = {
|
||||||
|
Icon(Icons.Filled.Person, contentDescription = null)
|
||||||
|
},
|
||||||
|
supportingContent = { Text("AskaEth") },
|
||||||
|
)
|
||||||
|
|
||||||
|
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
|
||||||
|
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text("源代码") },
|
||||||
|
leadingContent = {
|
||||||
|
Icon(Icons.Filled.Code, contentDescription = null)
|
||||||
|
},
|
||||||
|
supportingContent = { Text("git.yeij.top/AskaEth/Cyrene-For-Android") },
|
||||||
|
trailingContent = {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.OpenInNew,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable {
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||||
|
data = Uri.parse("https://git.yeij.top/AskaEth/Cyrene-For-Android")
|
||||||
|
}
|
||||||
|
context.startActivity(intent)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
HorizontalDivider()
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "技术栈",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
val techStack = listOf(
|
||||||
|
"Kotlin" to "主语言",
|
||||||
|
"Jetpack Compose" to "UI 框架",
|
||||||
|
"Material Design 3" to "设计系统",
|
||||||
|
"Room" to "本地数据库",
|
||||||
|
"OkHttp + Retrofit" to "网络请求",
|
||||||
|
"WebSocket" to "实时通信",
|
||||||
|
"Koin" to "依赖注入",
|
||||||
|
)
|
||||||
|
techStack.forEach { (name, desc) ->
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(name) },
|
||||||
|
supportingContent = { Text(desc) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
HorizontalDivider()
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "© 2026 AskaEth. All rights reserved.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 16.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,370 @@
|
|||||||
|
package top.yeij.cyrene.ui.screens.chat
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.slideInVertically
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
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.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.Send
|
||||||
|
import androidx.compose.material.icons.filled.KeyboardVoice
|
||||||
|
import androidx.compose.material.icons.filled.Lock
|
||||||
|
import androidx.compose.material.icons.filled.Mic
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.layout.positionInRoot
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import org.koin.androidx.compose.koinViewModel
|
||||||
|
import top.yeij.cyrene.domain.model.Message
|
||||||
|
import top.yeij.cyrene.ui.components.ChatBubble
|
||||||
|
import top.yeij.cyrene.ui.components.CyreneStatus
|
||||||
|
import top.yeij.cyrene.ui.components.StatusIndicator
|
||||||
|
import top.yeij.cyrene.ui.components.TypingIndicator
|
||||||
|
import top.yeij.cyrene.util.RecordState
|
||||||
|
import top.yeij.cyrene.viewmodel.ChatViewModel
|
||||||
|
import kotlin.math.min
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AnimatedChatBubble(
|
||||||
|
message: Message,
|
||||||
|
animIndex: Int,
|
||||||
|
) {
|
||||||
|
var visible by remember { mutableStateOf(false) }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
delay(min(animIndex, 10) * 60L)
|
||||||
|
visible = true
|
||||||
|
}
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = visible,
|
||||||
|
enter = fadeIn(animationSpec = androidx.compose.animation.core.tween(300)) +
|
||||||
|
slideInVertically(
|
||||||
|
animationSpec = androidx.compose.animation.core.tween(300),
|
||||||
|
initialOffsetY = { it / 4 },
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
ChatBubble(
|
||||||
|
content = message.content,
|
||||||
|
role = message.role,
|
||||||
|
msgType = message.msgType,
|
||||||
|
timestamp = message.timestamp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ChatScreen(
|
||||||
|
viewModel: ChatViewModel = koinViewModel(),
|
||||||
|
) {
|
||||||
|
val messages by viewModel.currentMessages.collectAsState()
|
||||||
|
val inputText by viewModel.inputText.collectAsState()
|
||||||
|
val isStreaming by viewModel.isStreaming.collectAsState()
|
||||||
|
val isConnected by viewModel.isConnected.collectAsState()
|
||||||
|
val isRefreshing by viewModel.isRefreshing.collectAsState()
|
||||||
|
val recordState by viewModel.voiceRecordState.collectAsState()
|
||||||
|
val recordDurationMs by viewModel.voiceRecordDurationMs.collectAsState()
|
||||||
|
val animIndex by viewModel.messageAnimIndex.collectAsState()
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
|
// Gesture tracking state
|
||||||
|
var isDragging by remember { mutableStateOf(false) }
|
||||||
|
var dragOffsetX by remember { mutableStateOf(0f) }
|
||||||
|
var dragOffsetY by remember { mutableStateOf(0f) }
|
||||||
|
var recordButtonY by remember { mutableStateOf(0f) }
|
||||||
|
|
||||||
|
val recordSec = recordDurationMs / 1000f
|
||||||
|
val isRecording = recordState == RecordState.RECORDING
|
||||||
|
val isLocked = recordState == RecordState.LOCKED
|
||||||
|
val inCancelZone = isDragging && dragOffsetY < -120f
|
||||||
|
val inLockZone = isDragging && dragOffsetX > 60f
|
||||||
|
|
||||||
|
LaunchedEffect(messages.size, isStreaming) {
|
||||||
|
if (messages.isNotEmpty()) {
|
||||||
|
val targetIndex = if (isStreaming) messages.size else messages.size - 1
|
||||||
|
listState.animateScrollToItem(targetIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animated "昔涟正在输入..." dots
|
||||||
|
val typingDots = remember { mutableStateOf("") }
|
||||||
|
LaunchedEffect(isStreaming) {
|
||||||
|
if (isStreaming) {
|
||||||
|
val dots = arrayOf("", ".", "..", "...")
|
||||||
|
var i = 0
|
||||||
|
while (true) {
|
||||||
|
typingDots.value = dots[i % 4]
|
||||||
|
i++
|
||||||
|
delay(400)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
typingDots.value = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val status = when {
|
||||||
|
isStreaming -> CyreneStatus.THINKING
|
||||||
|
isConnected -> CyreneStatus.ONLINE
|
||||||
|
else -> CyreneStatus.OFFLINE
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
StatusIndicator(status = status)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
bottomBar = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.navigationBarsPadding(),
|
||||||
|
) {
|
||||||
|
// "昔涟正在输入..." indicator
|
||||||
|
if (isStreaming) {
|
||||||
|
Text(
|
||||||
|
text = "昔涟正在输入${typingDots.value}",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 2.dp),
|
||||||
|
textAlign = TextAlign.Start,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
if (isRecording && isDragging) {
|
||||||
|
// Recording state with drag — show recording indicator
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(
|
||||||
|
if (inCancelZone) MaterialTheme.colorScheme.errorContainer
|
||||||
|
else MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = when {
|
||||||
|
inCancelZone -> "松手取消"
|
||||||
|
inLockZone -> "松手录音"
|
||||||
|
else -> "%.1f\" 上滑取消 右滑松手".format(recordSec)
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = if (inCancelZone) MaterialTheme.colorScheme.error
|
||||||
|
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Record button (drag anchor)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(start = 8.dp)
|
||||||
|
.size(48.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.primary)
|
||||||
|
.offset { IntOffset(dragOffsetX.toInt(), dragOffsetY.toInt()) },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Mic,
|
||||||
|
contentDescription = "录音中",
|
||||||
|
tint = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if (isLocked) {
|
||||||
|
// Locked (hands-free) mode
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Lock,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "%.1f\" 松手录音中 — 点击结束".format(recordSec),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IconButton(onClick = { viewModel.finishRecord() }) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.Send,
|
||||||
|
contentDescription = "发送",
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Normal input mode
|
||||||
|
OutlinedTextField(
|
||||||
|
value = inputText,
|
||||||
|
onValueChange = { viewModel.onInputChanged(it) },
|
||||||
|
placeholder = { Text("输入消息...") },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
maxLines = 4,
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
)
|
||||||
|
// Voice record button with long-press gesture
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(start = 4.dp)
|
||||||
|
.size(48.dp)
|
||||||
|
.onGloballyPositioned { recordButtonY = it.positionInRoot().y }
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectDragGesturesAfterLongPress(
|
||||||
|
onDragStart = { offset ->
|
||||||
|
isDragging = true
|
||||||
|
dragOffsetX = 0f
|
||||||
|
dragOffsetY = 0f
|
||||||
|
viewModel.startRecord()
|
||||||
|
},
|
||||||
|
onDrag = { change, dragAmount ->
|
||||||
|
change.consume()
|
||||||
|
dragOffsetX += dragAmount.x
|
||||||
|
dragOffsetY += dragAmount.y
|
||||||
|
},
|
||||||
|
onDragEnd = {
|
||||||
|
isDragging = false
|
||||||
|
when {
|
||||||
|
dragOffsetY < -120f -> viewModel.cancelRecord()
|
||||||
|
dragOffsetX > 60f -> viewModel.lockRecord()
|
||||||
|
else -> viewModel.finishRecord()
|
||||||
|
}
|
||||||
|
dragOffsetX = 0f
|
||||||
|
dragOffsetY = 0f
|
||||||
|
},
|
||||||
|
onDragCancel = {
|
||||||
|
isDragging = false
|
||||||
|
viewModel.cancelRecord()
|
||||||
|
dragOffsetX = 0f
|
||||||
|
dragOffsetY = 0f
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.KeyboardVoice,
|
||||||
|
contentDescription = "按住录音",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Send button (only when text present)
|
||||||
|
if (inputText.isNotBlank()) {
|
||||||
|
IconButton(
|
||||||
|
onClick = { viewModel.sendMessage() },
|
||||||
|
enabled = !isStreaming,
|
||||||
|
) {
|
||||||
|
if (isStreaming) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "发送")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
PullToRefreshBox(
|
||||||
|
isRefreshing = isRefreshing,
|
||||||
|
onRefresh = { viewModel.refreshMessages() },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding),
|
||||||
|
) {
|
||||||
|
if (messages.isEmpty() && !isStreaming) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "开始和昔涟对话吧",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
state = listState,
|
||||||
|
) {
|
||||||
|
items(messages, key = { it.id }) { message ->
|
||||||
|
AnimatedChatBubble(
|
||||||
|
message = message,
|
||||||
|
animIndex = animIndex[message.id] ?: 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (isStreaming) {
|
||||||
|
item(key = "typing_indicator") {
|
||||||
|
TypingIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package top.yeij.cyrene.ui.screens.iot
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import org.koin.androidx.compose.koinViewModel
|
||||||
|
import top.yeij.cyrene.ui.components.DeviceCard
|
||||||
|
import top.yeij.cyrene.viewmodel.IoTViewModel
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun IoTScreen(
|
||||||
|
viewModel: IoTViewModel = koinViewModel(),
|
||||||
|
) {
|
||||||
|
val devices by viewModel.devices.collectAsState()
|
||||||
|
val isLoading by viewModel.isLoading.collectAsState()
|
||||||
|
|
||||||
|
PullToRefreshBox(
|
||||||
|
isRefreshing = isLoading,
|
||||||
|
onRefresh = { viewModel.refreshDevices() },
|
||||||
|
modifier = Modifier.background(MaterialTheme.colorScheme.background),
|
||||||
|
) {
|
||||||
|
if (devices.isEmpty() && !isLoading) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(32.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "暂无设备数据\n请确认已连接到服务器",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn {
|
||||||
|
items(devices, key = { it.id }) { device ->
|
||||||
|
DeviceCard(
|
||||||
|
device = device,
|
||||||
|
onTogglePower = { viewModel.togglePower(device) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
package top.yeij.cyrene.ui.screens.login
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
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.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Person
|
||||||
|
import androidx.compose.material.icons.filled.Visibility
|
||||||
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.koin.compose.koinInject
|
||||||
|
import top.yeij.cyrene.domain.usecase.LoginUseCase
|
||||||
|
import top.yeij.cyrene.viewmodel.SettingsViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LoginScreen(
|
||||||
|
onLoginSuccess: () -> Unit,
|
||||||
|
loginUseCase: LoginUseCase = koinInject(),
|
||||||
|
settingsViewModel: SettingsViewModel = koinInject(),
|
||||||
|
) {
|
||||||
|
var username by remember { mutableStateOf("") }
|
||||||
|
var password by remember { mutableStateOf("") }
|
||||||
|
var showPassword by remember { mutableStateOf(false) }
|
||||||
|
var isLoading by remember { mutableStateOf(false) }
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
) { padding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
.padding(horizontal = 32.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(80.dp))
|
||||||
|
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Person,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(64.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Cyrene",
|
||||||
|
style = MaterialTheme.typography.displayLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "昔涟",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(48.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = username,
|
||||||
|
onValueChange = { username = it },
|
||||||
|
label = { Text("用户名") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||||
|
singleLine = true,
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = password,
|
||||||
|
onValueChange = { password = it },
|
||||||
|
label = { Text("密码") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
visualTransformation = if (showPassword)
|
||||||
|
VisualTransformation.None
|
||||||
|
else
|
||||||
|
PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Password,
|
||||||
|
imeAction = ImeAction.Done,
|
||||||
|
),
|
||||||
|
singleLine = true,
|
||||||
|
trailingIcon = {
|
||||||
|
IconButton(onClick = { showPassword = !showPassword }) {
|
||||||
|
Icon(
|
||||||
|
if (showPassword) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
|
||||||
|
contentDescription = if (showPassword) "隐藏密码" else "显示密码",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
isLoading = true
|
||||||
|
scope.launch {
|
||||||
|
loginUseCase(username, password).fold(
|
||||||
|
onSuccess = {
|
||||||
|
isLoading = false
|
||||||
|
onLoginSuccess()
|
||||||
|
},
|
||||||
|
onFailure = { error ->
|
||||||
|
isLoading = false
|
||||||
|
scope.launch {
|
||||||
|
snackbarHostState.showSnackbar(
|
||||||
|
error.message ?: "登录失败",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth().height(50.dp),
|
||||||
|
enabled = username.isNotBlank() && password.isNotBlank() && !isLoading,
|
||||||
|
) {
|
||||||
|
if (isLoading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text("登录")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,394 @@
|
|||||||
|
package top.yeij.cyrene.ui.screens.profile
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
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.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.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ExitToApp
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.Help
|
||||||
|
import androidx.compose.material.icons.filled.AdminPanelSettings
|
||||||
|
import androidx.compose.material.icons.filled.CalendarMonth
|
||||||
|
import androidx.compose.material.icons.filled.ChevronRight
|
||||||
|
import androidx.compose.material.icons.filled.Circle
|
||||||
|
import androidx.compose.material.icons.filled.Info
|
||||||
|
import androidx.compose.material.icons.filled.Notifications
|
||||||
|
import androidx.compose.material.icons.filled.Person
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material.icons.filled.Tag
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import org.koin.compose.koinInject
|
||||||
|
import top.yeij.cyrene.viewmodel.ProfileViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ProfileScreen(
|
||||||
|
onNavigateToSettings: () -> Unit,
|
||||||
|
onLogout: () -> Unit,
|
||||||
|
onNavigateToLogin: () -> Unit,
|
||||||
|
onNavigateToAbout: () -> Unit = {},
|
||||||
|
isDefaultAssistant: Boolean = false,
|
||||||
|
onOpenAssistantSettings: () -> Unit = {},
|
||||||
|
profileViewModel: ProfileViewModel = koinInject(),
|
||||||
|
) {
|
||||||
|
val profile by profileViewModel.profile.collectAsState()
|
||||||
|
var showLogoutDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
profileViewModel.fetchFreshProfile()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showLogoutDialog) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showLogoutDialog = false },
|
||||||
|
title = { Text("退出登录") },
|
||||||
|
text = { Text("确定要退出登录吗?") },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
showLogoutDialog = false
|
||||||
|
profileViewModel.logout()
|
||||||
|
onLogout()
|
||||||
|
},
|
||||||
|
colors = ButtonDefaults.textButtonColors(
|
||||||
|
contentColor = MaterialTheme.colorScheme.error,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text("退出")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showLogoutDialog = false }) {
|
||||||
|
Text("取消")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.background)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
) {
|
||||||
|
// Profile header
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
// Avatar
|
||||||
|
val initials = if (profile.nickname.isNotBlank()) {
|
||||||
|
profile.nickname.take(1)
|
||||||
|
} else if (profile.username.isNotBlank()) {
|
||||||
|
profile.username.take(1)
|
||||||
|
} else {
|
||||||
|
"?"
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(80.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = initials,
|
||||||
|
style = MaterialTheme.typography.headlineLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
if (profile.isLoading) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(24.dp))
|
||||||
|
} else if (!profile.isLoggedIn) {
|
||||||
|
Text(
|
||||||
|
text = "未登录",
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.clickable { onNavigateToLogin() },
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "点击登录以查看个人信息",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Nickname
|
||||||
|
Text(
|
||||||
|
text = profile.nickname.ifEmpty { profile.username },
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
)
|
||||||
|
// Username
|
||||||
|
if (profile.nickname.isNotBlank() && profile.nickname != profile.username) {
|
||||||
|
Text(
|
||||||
|
text = "@${profile.username}",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Admin badge
|
||||||
|
if (profile.isAdmin) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
color = MaterialTheme.colorScheme.primaryContainer,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.AdminPanelSettings,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(14.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text(
|
||||||
|
text = "管理员",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
|
if (profile.isLoggedIn) {
|
||||||
|
// User info card
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
text = "账号信息",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
ProfileInfoCard(
|
||||||
|
items = listOf(
|
||||||
|
ProfileInfoItem(Icons.Filled.Tag, "用户 ID", profile.userId),
|
||||||
|
ProfileInfoItem(Icons.Filled.CalendarMonth, "注册时间", profile.createdAt.ifEmpty { "未知" }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Assistant status
|
||||||
|
Text(
|
||||||
|
text = "助手",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
color = if (isDefaultAssistant)
|
||||||
|
Color(0xFF4CAF50).copy(alpha = 0.1f)
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.5f),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Circle,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(10.dp),
|
||||||
|
tint = if (isDefaultAssistant) Color(0xFF4CAF50) else Color(0xFFFF5722),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = if (isDefaultAssistant) "已设为默认助手" else "未设为默认助手",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
if (!isDefaultAssistant) {
|
||||||
|
Text(
|
||||||
|
text = "设为默认助手后,长按电源键或Home键即可呼出昔涟",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!isDefaultAssistant) {
|
||||||
|
TextButton(onClick = onOpenAssistantSettings) {
|
||||||
|
Text("去设置")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
HorizontalDivider()
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Menu items
|
||||||
|
Text(
|
||||||
|
text = "其他",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text("设置") },
|
||||||
|
leadingContent = { Icon(Icons.Filled.Settings, contentDescription = null) },
|
||||||
|
trailingContent = { Icon(Icons.Filled.ChevronRight, contentDescription = null) },
|
||||||
|
modifier = Modifier.clickable { onNavigateToSettings() },
|
||||||
|
)
|
||||||
|
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text("提醒") },
|
||||||
|
leadingContent = { Icon(Icons.Filled.Notifications, contentDescription = null) },
|
||||||
|
trailingContent = { Icon(Icons.Filled.ChevronRight, contentDescription = null) },
|
||||||
|
)
|
||||||
|
|
||||||
|
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
|
||||||
|
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text("关于") },
|
||||||
|
leadingContent = { Icon(Icons.Filled.Info, contentDescription = null) },
|
||||||
|
supportingContent = { Text("Cyrene v0.1.0") },
|
||||||
|
modifier = Modifier.clickable { onNavigateToAbout() },
|
||||||
|
)
|
||||||
|
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text("使用帮助") },
|
||||||
|
leadingContent = { Icon(Icons.AutoMirrored.Filled.Help, contentDescription = null) },
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Logout
|
||||||
|
if (profile.isLoggedIn) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
text = "退出登录",
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.ExitToApp,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier.clickable { showLogoutDialog = true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class ProfileInfoItem(
|
||||||
|
val icon: ImageVector,
|
||||||
|
val label: String,
|
||||||
|
val value: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ProfileInfoCard(items: List<ProfileInfoItem>) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
items.forEachIndexed { index, item ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
item.icon,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = item.label,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = item.value,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (index < items.size - 1) {
|
||||||
|
HorizontalDivider(
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,578 @@
|
|||||||
|
package top.yeij.cyrene.ui.screens.settings
|
||||||
|
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.biometric.BiometricManager
|
||||||
|
import androidx.biometric.BiometricPrompt
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
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.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material.icons.filled.DarkMode
|
||||||
|
import androidx.compose.material.icons.filled.DeleteForever
|
||||||
|
import androidx.compose.material.icons.filled.LightMode
|
||||||
|
import androidx.compose.material.icons.filled.Palette
|
||||||
|
import androidx.compose.material.icons.filled.SettingsBrightness
|
||||||
|
import androidx.compose.material.icons.filled.Share
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilledTonalIconButton
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Tab
|
||||||
|
import androidx.compose.material3.TabRow
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.fragment.app.FragmentActivity
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.koin.compose.koinInject
|
||||||
|
import top.yeij.cyrene.util.LogCategory
|
||||||
|
import top.yeij.cyrene.util.RuntimeLog
|
||||||
|
import top.yeij.cyrene.viewmodel.SettingsViewModel
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun SettingsScreen(
|
||||||
|
onBack: () -> Unit,
|
||||||
|
viewModel: SettingsViewModel = koinInject(),
|
||||||
|
) {
|
||||||
|
val baseUrl by viewModel.baseUrl.collectAsState()
|
||||||
|
val themeMode by viewModel.themeMode.collectAsState()
|
||||||
|
val wakeWord by viewModel.wakeWord.collectAsState()
|
||||||
|
val dashScopeApiKey by viewModel.dashScopeApiKey.collectAsState()
|
||||||
|
val dashScopeEndpoint by viewModel.dashScopeEndpoint.collectAsState()
|
||||||
|
val dashScopeModel by viewModel.dashScopeModel.collectAsState()
|
||||||
|
val autoScreenContext by viewModel.autoScreenContext.collectAsState()
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
fun sanitizeUrl(raw: String): String? {
|
||||||
|
var url = raw.trim()
|
||||||
|
if (url.isEmpty()) return null
|
||||||
|
val hasScheme = url.contains("://")
|
||||||
|
if (!hasScheme) url = "http://$url"
|
||||||
|
return try {
|
||||||
|
val parsed = java.net.URL(url)
|
||||||
|
val host = parsed.host ?: return null
|
||||||
|
if (host.isEmpty()) return null
|
||||||
|
val scheme = parsed.protocol
|
||||||
|
val port = if (parsed.port > 0) ":${parsed.port}" else ""
|
||||||
|
val path = parsed.path?.trimEnd('/') ?: ""
|
||||||
|
val query = parsed.query?.let { "?$it" } ?: ""
|
||||||
|
"$scheme://$host$port$path$query"
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
CenterAlignedTopAppBar(
|
||||||
|
title = { Text("设置") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
) {
|
||||||
|
// Server
|
||||||
|
Text(
|
||||||
|
text = "服务器",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = baseUrl,
|
||||||
|
onValueChange = { viewModel.updateBaseUrlInput(it) },
|
||||||
|
label = { Text("服务器地址") },
|
||||||
|
placeholder = { Text("http://192.168.1.x:8080") },
|
||||||
|
singleLine = true,
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Uri,
|
||||||
|
imeAction = ImeAction.Done,
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onDone = {
|
||||||
|
scope.launch {
|
||||||
|
val sanitized = sanitizeUrl(baseUrl)
|
||||||
|
if (sanitized != null) {
|
||||||
|
viewModel.saveBaseUrl(sanitized)
|
||||||
|
Toast.makeText(context, "地址已保存", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
trailingIcon = {
|
||||||
|
FilledTonalIconButton(onClick = {
|
||||||
|
val sanitized = sanitizeUrl(baseUrl)
|
||||||
|
if (sanitized != null) {
|
||||||
|
viewModel.saveBaseUrl(sanitized)
|
||||||
|
Toast.makeText(context, "地址已保存", Toast.LENGTH_SHORT).show()
|
||||||
|
} else {
|
||||||
|
Toast.makeText(context, "地址格式无效", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Icon(Icons.Filled.Check, contentDescription = "确认")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
HorizontalDivider()
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Appearance
|
||||||
|
Text(
|
||||||
|
text = "外观",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
val themeLabel = when (themeMode) {
|
||||||
|
"light" -> "浅色模式"
|
||||||
|
"dark" -> "深色模式"
|
||||||
|
else -> "跟随系统"
|
||||||
|
}
|
||||||
|
val themeIcon = when (themeMode) {
|
||||||
|
"light" -> Icons.Filled.LightMode
|
||||||
|
"dark" -> Icons.Filled.DarkMode
|
||||||
|
else -> Icons.Filled.SettingsBrightness
|
||||||
|
}
|
||||||
|
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text("主题") },
|
||||||
|
supportingContent = { Text(themeLabel) },
|
||||||
|
leadingContent = { Icon(themeIcon, contentDescription = null) },
|
||||||
|
modifier = Modifier.clickable {
|
||||||
|
val next = when (themeMode) {
|
||||||
|
"light" -> "dark"
|
||||||
|
"dark" -> "auto"
|
||||||
|
else -> "light"
|
||||||
|
}
|
||||||
|
viewModel.saveThemeMode(next)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text("主题色") },
|
||||||
|
supportingContent = { Text("昔涟紫") },
|
||||||
|
leadingContent = { Icon(Icons.Filled.Palette, contentDescription = null) },
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
HorizontalDivider()
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Voice
|
||||||
|
Text(
|
||||||
|
text = "语音",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = wakeWord,
|
||||||
|
onValueChange = { viewModel.saveWakeWord(it) },
|
||||||
|
label = { Text("唤醒词") },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text("自动读取屏幕内容") },
|
||||||
|
supportingContent = { Text("电源键唤起助手时自动截取当前屏幕内容并加入对话上下文") },
|
||||||
|
trailingContent = {
|
||||||
|
androidx.compose.material3.Switch(
|
||||||
|
checked = autoScreenContext,
|
||||||
|
onCheckedChange = { viewModel.saveAutoScreenContext(it) },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier.clickable { viewModel.saveAutoScreenContext(!autoScreenContext) },
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
HorizontalDivider()
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// DashScope STT
|
||||||
|
Text(
|
||||||
|
text = "语音识别 (DashScope)",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = dashScopeApiKey,
|
||||||
|
onValueChange = { viewModel.updateDashScopeApiKeyInput(it) },
|
||||||
|
label = { Text("API Key") },
|
||||||
|
placeholder = { Text("sk-xxxxxxxxxxxxxxxx") },
|
||||||
|
singleLine = true,
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Password,
|
||||||
|
imeAction = ImeAction.Done,
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onDone = {
|
||||||
|
scope.launch {
|
||||||
|
viewModel.saveDashScopeApiKey(dashScopeApiKey)
|
||||||
|
Toast.makeText(context, "API Key 已保存", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
trailingIcon = {
|
||||||
|
FilledTonalIconButton(onClick = {
|
||||||
|
scope.launch {
|
||||||
|
viewModel.saveDashScopeApiKey(dashScopeApiKey)
|
||||||
|
Toast.makeText(context, "API Key 已保存", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Icon(Icons.Filled.Check, contentDescription = "保存")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = dashScopeEndpoint,
|
||||||
|
onValueChange = { viewModel.updateDashScopeEndpointInput(it) },
|
||||||
|
label = { Text("WebSocket 端点") },
|
||||||
|
placeholder = { Text("wss://dashscope.aliyuncs.com/api-ws/v1/inference") },
|
||||||
|
singleLine = true,
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Uri,
|
||||||
|
imeAction = ImeAction.Done,
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onDone = {
|
||||||
|
scope.launch {
|
||||||
|
viewModel.saveDashScopeEndpoint(dashScopeEndpoint)
|
||||||
|
Toast.makeText(context, "端点已保存", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
trailingIcon = {
|
||||||
|
FilledTonalIconButton(onClick = {
|
||||||
|
scope.launch {
|
||||||
|
viewModel.saveDashScopeEndpoint(dashScopeEndpoint)
|
||||||
|
Toast.makeText(context, "端点已保存", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Icon(Icons.Filled.Check, contentDescription = "保存")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = dashScopeModel,
|
||||||
|
onValueChange = { viewModel.updateDashScopeModelInput(it) },
|
||||||
|
label = { Text("模型") },
|
||||||
|
placeholder = { Text("fun-asr-realtime") },
|
||||||
|
singleLine = true,
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
imeAction = ImeAction.Done,
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onDone = {
|
||||||
|
scope.launch {
|
||||||
|
viewModel.saveDashScopeModel(dashScopeModel)
|
||||||
|
Toast.makeText(context, "模型已保存", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
trailingIcon = {
|
||||||
|
FilledTonalIconButton(onClick = {
|
||||||
|
scope.launch {
|
||||||
|
viewModel.saveDashScopeModel(dashScopeModel)
|
||||||
|
Toast.makeText(context, "模型已保存", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Icon(Icons.Filled.Check, contentDescription = "保存")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "未配置 API Key 时,语音输入将自动使用后端服务处理。",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
HorizontalDivider()
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Data management
|
||||||
|
Text(
|
||||||
|
text = "数据",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
var showClearDialog by remember { mutableStateOf(false) }
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text("清空本地消息记录") },
|
||||||
|
supportingContent = { Text("仅清除本地数据库,服务器消息仍保留。下次加载将只获取清除时间之后的消息。") },
|
||||||
|
leadingContent = { Icon(Icons.Filled.DeleteForever, contentDescription = null, tint = MaterialTheme.colorScheme.error) },
|
||||||
|
modifier = Modifier.clickable { showClearDialog = true },
|
||||||
|
)
|
||||||
|
|
||||||
|
if (showClearDialog) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showClearDialog = false },
|
||||||
|
title = { Text("确认清空") },
|
||||||
|
text = { Text("将清空所有本地消息记录。服务器上的消息不会被删除,但下次加载历史时将只获取本次清除之后的消息。") },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
showClearDialog = false
|
||||||
|
val activity = context as? FragmentActivity
|
||||||
|
if (activity == null) {
|
||||||
|
viewModel.clearLocalMessages()
|
||||||
|
Toast.makeText(context, "本地消息已清空", Toast.LENGTH_SHORT).show()
|
||||||
|
return@TextButton
|
||||||
|
}
|
||||||
|
val biometricManager = BiometricManager.from(context)
|
||||||
|
val canAuth = when (biometricManager.canAuthenticate(
|
||||||
|
BiometricManager.Authenticators.BIOMETRIC_STRONG or
|
||||||
|
BiometricManager.Authenticators.DEVICE_CREDENTIAL
|
||||||
|
)) {
|
||||||
|
BiometricManager.BIOMETRIC_SUCCESS -> true
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
if (!canAuth) {
|
||||||
|
viewModel.clearLocalMessages()
|
||||||
|
Toast.makeText(context, "本地消息已清空", Toast.LENGTH_SHORT).show()
|
||||||
|
return@TextButton
|
||||||
|
}
|
||||||
|
val prompt = BiometricPrompt(
|
||||||
|
activity,
|
||||||
|
ContextCompat.getMainExecutor(context),
|
||||||
|
object : BiometricPrompt.AuthenticationCallback() {
|
||||||
|
override fun onAuthenticationSucceeded(
|
||||||
|
result: BiometricPrompt.AuthenticationResult,
|
||||||
|
) {
|
||||||
|
viewModel.clearLocalMessages()
|
||||||
|
Toast.makeText(context, "本地消息已清空", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAuthenticationFailed() {
|
||||||
|
Toast.makeText(context, "验证失败", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAuthenticationError(
|
||||||
|
errorCode: Int,
|
||||||
|
errString: CharSequence,
|
||||||
|
) {
|
||||||
|
Toast.makeText(context, "验证取消", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
prompt.authenticate(
|
||||||
|
BiometricPrompt.PromptInfo.Builder()
|
||||||
|
.setTitle("身份验证")
|
||||||
|
.setSubtitle("需要验证身份后才能清空消息记录")
|
||||||
|
.setAllowedAuthenticators(
|
||||||
|
BiometricManager.Authenticators.BIOMETRIC_STRONG or
|
||||||
|
BiometricManager.Authenticators.DEVICE_CREDENTIAL
|
||||||
|
)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error),
|
||||||
|
) {
|
||||||
|
Text("清空")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showClearDialog = false }) {
|
||||||
|
Text("取消")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
HorizontalDivider()
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Runtime logs
|
||||||
|
Text(
|
||||||
|
text = "运行日志",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
val logEntries by RuntimeLog.entries.collectAsState()
|
||||||
|
var selectedTab by remember { mutableStateOf(0) }
|
||||||
|
val tabs = listOf("全部") + LogCategory.entries.map { it.label }
|
||||||
|
val allCategories = LogCategory.entries.toList()
|
||||||
|
|
||||||
|
// Scroll to bottom
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
androidx.compose.runtime.LaunchedEffect(logEntries.size, selectedTab) {
|
||||||
|
if (logEntries.isNotEmpty()) {
|
||||||
|
scrollState.animateScrollTo(scrollState.maxValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TabRow(selectedTabIndex = selectedTab) {
|
||||||
|
tabs.forEachIndexed { index, label ->
|
||||||
|
Tab(
|
||||||
|
selected = selectedTab == index,
|
||||||
|
onClick = { selectedTab = index },
|
||||||
|
text = { Text(label, maxLines = 1) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val filteredLogs = if (selectedTab == 0) {
|
||||||
|
logEntries
|
||||||
|
} else {
|
||||||
|
val cat = allCategories.getOrNull(selectedTab - 1)
|
||||||
|
if (cat != null) logEntries.filter { it.category == cat } else emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
val currentCategory = if (selectedTab == 0) null else allCategories.getOrNull(selectedTab - 1)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = {
|
||||||
|
val file = RuntimeLog.exportToFile(context, currentCategory)
|
||||||
|
RuntimeLog.shareFile(context, file)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.Share, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text(if (currentCategory != null) "导出${currentCategory.label}" else "导出全部")
|
||||||
|
}
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = {
|
||||||
|
val file = RuntimeLog.exportAllAsZip(context)
|
||||||
|
RuntimeLog.shareFile(context, file)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.Share, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text("打包全部")
|
||||||
|
}
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { RuntimeLog.clear() },
|
||||||
|
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
|
||||||
|
) {
|
||||||
|
Text("清空")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filteredLogs.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = "暂无${tabs[selectedTab]}日志",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.weight(1f, fill = false),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.verticalScroll(scrollState),
|
||||||
|
) {
|
||||||
|
filteredLogs.takeLast(500).forEach { entry ->
|
||||||
|
Text(
|
||||||
|
text = entry.formatted(),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(vertical = 1.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(80.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package top.yeij.cyrene.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
// Light theme
|
||||||
|
val LightPrimary = Color(0xFF6D3BC0)
|
||||||
|
val LightOnPrimary = Color(0xFFFFFFFF)
|
||||||
|
val LightPrimaryContainer = Color(0xFFEEDCFF)
|
||||||
|
val LightOnPrimaryContainer = Color(0xFF250058)
|
||||||
|
val LightSecondary = Color(0xFF625B71)
|
||||||
|
val LightOnSecondary = Color(0xFFFFFFFF)
|
||||||
|
val LightSecondaryContainer = Color(0xFFE8DEF8)
|
||||||
|
val LightOnSecondaryContainer = Color(0xFF1E192B)
|
||||||
|
val LightTertiary = Color(0xFF7E5260)
|
||||||
|
val LightOnTertiary = Color(0xFFFFFFFF)
|
||||||
|
val LightTertiaryContainer = Color(0xFFFFD9E3)
|
||||||
|
val LightOnTertiaryContainer = Color(0xFF31101D)
|
||||||
|
val LightBackground = Color(0xFFFFFBFF)
|
||||||
|
val LightOnBackground = Color(0xFF1C1B1F)
|
||||||
|
val LightSurface = Color(0xFFFFFBFF)
|
||||||
|
val LightOnSurface = Color(0xFF1C1B1F)
|
||||||
|
val LightSurfaceVariant = Color(0xFFE7E0EC)
|
||||||
|
val LightOnSurfaceVariant = Color(0xFF49454F)
|
||||||
|
val LightError = Color(0xFFBA1A1A)
|
||||||
|
val LightOutline = Color(0xFF79747E)
|
||||||
|
val LightOutlineVariant = Color(0xFFCAC4D0)
|
||||||
|
|
||||||
|
// Dark theme
|
||||||
|
val DarkPrimary = Color(0xFFD3BBFF)
|
||||||
|
val DarkOnPrimary = Color(0xFF3D0089)
|
||||||
|
val DarkPrimaryContainer = Color(0xFF541BA6)
|
||||||
|
val DarkOnPrimaryContainer = Color(0xFFEEDCFF)
|
||||||
|
val DarkSecondary = Color(0xFFCBC2DC)
|
||||||
|
val DarkOnSecondary = Color(0xFF332D41)
|
||||||
|
val DarkSecondaryContainer = Color(0xFF4A4458)
|
||||||
|
val DarkOnSecondaryContainer = Color(0xFFE8DEF8)
|
||||||
|
val DarkTertiary = Color(0xFFEFB8C8)
|
||||||
|
val DarkOnTertiary = Color(0xFF4A2532)
|
||||||
|
val DarkTertiaryContainer = Color(0xFF633B48)
|
||||||
|
val DarkOnTertiaryContainer = Color(0xFFFFD9E3)
|
||||||
|
val DarkBackground = Color(0xFF1C1B1F)
|
||||||
|
val DarkOnBackground = Color(0xFFE6E1E5)
|
||||||
|
val DarkSurface = Color(0xFF1C1B1F)
|
||||||
|
val DarkOnSurface = Color(0xFFE6E1E5)
|
||||||
|
val DarkSurfaceVariant = Color(0xFF49454F)
|
||||||
|
val DarkOnSurfaceVariant = Color(0xFFCAC4D0)
|
||||||
|
val DarkError = Color(0xFFFFB4AB)
|
||||||
|
val DarkOutline = Color(0xFF938F99)
|
||||||
|
val DarkOutlineVariant = Color(0xFF49454F)
|
||||||
|
|
||||||
|
// Preset seed colors for manual theme selection
|
||||||
|
val SeedColors = mapOf(
|
||||||
|
"default" to 0xFF6D3BC0, // Lavender
|
||||||
|
"sakura" to 0xFFFFB4C8, // Pink
|
||||||
|
"ocean" to 0xFF6BA4FF, // Blue
|
||||||
|
"forest" to 0xFF6BCF7C, // Green
|
||||||
|
"sunset" to 0xFFFF9E6B, // Orange
|
||||||
|
)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package top.yeij.cyrene.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Shapes
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
val CyreneShapes = Shapes(
|
||||||
|
extraSmall = RoundedCornerShape(8.dp),
|
||||||
|
small = RoundedCornerShape(12.dp),
|
||||||
|
medium = RoundedCornerShape(16.dp),
|
||||||
|
large = RoundedCornerShape(24.dp),
|
||||||
|
extraLarge = RoundedCornerShape(32.dp),
|
||||||
|
)
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package top.yeij.cyrene.ui.theme
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.material3.dynamicDarkColorScheme
|
||||||
|
import androidx.compose.material3.dynamicLightColorScheme
|
||||||
|
import androidx.compose.material3.lightColorScheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalView
|
||||||
|
import androidx.core.view.WindowCompat
|
||||||
|
|
||||||
|
private val LightColorScheme = lightColorScheme(
|
||||||
|
primary = LightPrimary,
|
||||||
|
onPrimary = LightOnPrimary,
|
||||||
|
primaryContainer = LightPrimaryContainer,
|
||||||
|
onPrimaryContainer = LightOnPrimaryContainer,
|
||||||
|
secondary = LightSecondary,
|
||||||
|
onSecondary = LightOnSecondary,
|
||||||
|
secondaryContainer = LightSecondaryContainer,
|
||||||
|
onSecondaryContainer = LightOnSecondaryContainer,
|
||||||
|
tertiary = LightTertiary,
|
||||||
|
onTertiary = LightOnTertiary,
|
||||||
|
tertiaryContainer = LightTertiaryContainer,
|
||||||
|
onTertiaryContainer = LightOnTertiaryContainer,
|
||||||
|
background = LightBackground,
|
||||||
|
onBackground = LightOnBackground,
|
||||||
|
surface = LightSurface,
|
||||||
|
onSurface = LightOnSurface,
|
||||||
|
surfaceVariant = LightSurfaceVariant,
|
||||||
|
onSurfaceVariant = LightOnSurfaceVariant,
|
||||||
|
error = LightError,
|
||||||
|
outline = LightOutline,
|
||||||
|
outlineVariant = LightOutlineVariant,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val DarkColorScheme = darkColorScheme(
|
||||||
|
primary = DarkPrimary,
|
||||||
|
onPrimary = DarkOnPrimary,
|
||||||
|
primaryContainer = DarkPrimaryContainer,
|
||||||
|
onPrimaryContainer = DarkOnPrimaryContainer,
|
||||||
|
secondary = DarkSecondary,
|
||||||
|
onSecondary = DarkOnSecondary,
|
||||||
|
secondaryContainer = DarkSecondaryContainer,
|
||||||
|
onSecondaryContainer = DarkOnSecondaryContainer,
|
||||||
|
tertiary = DarkTertiary,
|
||||||
|
onTertiary = DarkOnTertiary,
|
||||||
|
tertiaryContainer = DarkTertiaryContainer,
|
||||||
|
onTertiaryContainer = DarkOnTertiaryContainer,
|
||||||
|
background = DarkBackground,
|
||||||
|
onBackground = DarkOnBackground,
|
||||||
|
surface = DarkSurface,
|
||||||
|
onSurface = DarkOnSurface,
|
||||||
|
surfaceVariant = DarkSurfaceVariant,
|
||||||
|
onSurfaceVariant = DarkOnSurfaceVariant,
|
||||||
|
error = DarkError,
|
||||||
|
outline = DarkOutline,
|
||||||
|
outlineVariant = DarkOutlineVariant,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CyreneTheme(
|
||||||
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
|
dynamicColor: Boolean = true,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
val colorScheme = when {
|
||||||
|
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||||
|
val context = LocalContext.current
|
||||||
|
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||||
|
}
|
||||||
|
darkTheme -> DarkColorScheme
|
||||||
|
else -> LightColorScheme
|
||||||
|
}
|
||||||
|
|
||||||
|
val view = LocalView.current
|
||||||
|
if (!view.isInEditMode) {
|
||||||
|
SideEffect {
|
||||||
|
val window = (view.context as Activity).window
|
||||||
|
window.statusBarColor = colorScheme.background.toArgb()
|
||||||
|
window.navigationBarColor = colorScheme.background.toArgb()
|
||||||
|
window.decorView.setBackgroundColor(colorScheme.background.toArgb())
|
||||||
|
WindowCompat.getInsetsController(window, view).apply {
|
||||||
|
isAppearanceLightStatusBars = !darkTheme
|
||||||
|
isAppearanceLightNavigationBars = !darkTheme
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MaterialTheme(
|
||||||
|
colorScheme = colorScheme,
|
||||||
|
typography = CyreneTypography,
|
||||||
|
shapes = CyreneShapes,
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package top.yeij.cyrene.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.material3.Typography
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
val CyreneTypography = Typography(
|
||||||
|
displayLarge = TextStyle(
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 57.sp,
|
||||||
|
lineHeight = 64.sp,
|
||||||
|
),
|
||||||
|
headlineMedium = TextStyle(
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 28.sp,
|
||||||
|
lineHeight = 36.sp,
|
||||||
|
),
|
||||||
|
titleLarge = TextStyle(
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 22.sp,
|
||||||
|
lineHeight = 28.sp,
|
||||||
|
),
|
||||||
|
titleMedium = TextStyle(
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
lineHeight = 24.sp,
|
||||||
|
),
|
||||||
|
bodyLarge = TextStyle(
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
lineHeight = 24.sp,
|
||||||
|
),
|
||||||
|
bodyMedium = TextStyle(
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
lineHeight = 20.sp,
|
||||||
|
),
|
||||||
|
labelLarge = TextStyle(
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
lineHeight = 20.sp,
|
||||||
|
),
|
||||||
|
labelMedium = TextStyle(
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
lineHeight = 16.sp,
|
||||||
|
),
|
||||||
|
labelSmall = TextStyle(
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
lineHeight = 16.sp,
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package top.yeij.cyrene.util
|
||||||
|
|
||||||
|
object Constants {
|
||||||
|
const val DEFAULT_WAKE_WORD = "昔涟"
|
||||||
|
const val SILENCE_TIMEOUT_MS = 10_000L
|
||||||
|
const val WS_RECONNECT_MAX_ATTEMPTS = 5
|
||||||
|
const val WS_PING_INTERVAL_SECONDS = 30L
|
||||||
|
const val HOTWORD_INACTIVITY_TIMEOUT_MINUTES = 10L
|
||||||
|
const val OVERLAY_ANIM_DURATION_MS = 300
|
||||||
|
const val STT_SILENCE_THRESHOLD_MS = 1500L
|
||||||
|
|
||||||
|
// Intent extras
|
||||||
|
const val EXTRA_VOICE_ASSIST = "voice_assist"
|
||||||
|
const val EXTRA_START_LISTENING = "start_listening"
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package top.yeij.cyrene.util
|
||||||
|
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import top.yeij.cyrene.MainActivity
|
||||||
|
import top.yeij.cyrene.domain.model.Message
|
||||||
|
|
||||||
|
class NotificationHelper(private val context: Context) {
|
||||||
|
|
||||||
|
private val notificationManager = context.getSystemService(NotificationManager::class.java)
|
||||||
|
|
||||||
|
init {
|
||||||
|
createChannel()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createChannel() {
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
"消息通知",
|
||||||
|
NotificationManager.IMPORTANCE_HIGH,
|
||||||
|
).apply {
|
||||||
|
description = "昔涟的新消息通知"
|
||||||
|
enableVibration(true)
|
||||||
|
setShowBadge(true)
|
||||||
|
}
|
||||||
|
notificationManager.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showMessageNotification(message: Message) {
|
||||||
|
val intent = Intent(context, MainActivity::class.java).apply {
|
||||||
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||||
|
}
|
||||||
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
|
context, 0, intent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
val preview = if (message.content.length > 50) {
|
||||||
|
message.content.take(50) + "..."
|
||||||
|
} else {
|
||||||
|
message.content
|
||||||
|
}
|
||||||
|
|
||||||
|
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||||
|
.setContentTitle("昔涟")
|
||||||
|
.setContentText(preview)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
notificationManager.notify(message.id.hashCode(), notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelAll() {
|
||||||
|
notificationManager.cancelAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val CHANNEL_ID = "cyrene_messages"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package top.yeij.cyrene.util
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import java.io.File
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.zip.ZipEntry
|
||||||
|
import java.util.zip.ZipOutputStream
|
||||||
|
|
||||||
|
enum class LogCategory(val label: String) {
|
||||||
|
CHAT("聊天"),
|
||||||
|
WS("WebSocket"),
|
||||||
|
STT("语音识别"),
|
||||||
|
GENERAL("通用"),
|
||||||
|
HTTP("网络"),
|
||||||
|
VOICE("语音"),
|
||||||
|
}
|
||||||
|
|
||||||
|
data class LogEntry(
|
||||||
|
val timestamp: Long,
|
||||||
|
val category: LogCategory,
|
||||||
|
val tag: String,
|
||||||
|
val message: String,
|
||||||
|
) {
|
||||||
|
fun formatted(): String {
|
||||||
|
val sdf = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault())
|
||||||
|
return "[${sdf.format(Date(timestamp))}] [${category.name}] [$tag] $message"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object RuntimeLog {
|
||||||
|
|
||||||
|
private const val MAX_ENTRIES = 2000
|
||||||
|
|
||||||
|
private val _entries = MutableStateFlow<List<LogEntry>>(emptyList())
|
||||||
|
val entries: StateFlow<List<LogEntry>> = _entries.asStateFlow()
|
||||||
|
|
||||||
|
private val buffer = ArrayDeque<LogEntry>(MAX_ENTRIES)
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun log(category: LogCategory, tag: String, message: String) {
|
||||||
|
val entry = LogEntry(System.currentTimeMillis(), category, tag, message)
|
||||||
|
if (buffer.size >= MAX_ENTRIES) {
|
||||||
|
buffer.removeFirst()
|
||||||
|
}
|
||||||
|
buffer.addLast(entry)
|
||||||
|
_entries.value = buffer.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun chat(tag: String, message: String) = log(LogCategory.CHAT, tag, message)
|
||||||
|
fun ws(tag: String, message: String) = log(LogCategory.WS, tag, message)
|
||||||
|
fun stt(tag: String, message: String) = log(LogCategory.STT, tag, message)
|
||||||
|
fun general(tag: String, message: String) = log(LogCategory.GENERAL, tag, message)
|
||||||
|
fun http(tag: String, message: String) = log(LogCategory.HTTP, tag, message)
|
||||||
|
fun voice(tag: String, message: String) = log(LogCategory.VOICE, tag, message)
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun getByCategory(category: LogCategory): List<LogEntry> {
|
||||||
|
return buffer.filter { it.category == category }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun exportAsText(category: LogCategory): String {
|
||||||
|
val entries = buffer.filter { it.category == category }
|
||||||
|
val header = "=== Cyrene ${category.label}日志 导出时间: ${
|
||||||
|
SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date())
|
||||||
|
} ===\n\n"
|
||||||
|
return header + entries.joinToString("\n") { it.formatted() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun exportAllAsText(): String {
|
||||||
|
val header = "=== Cyrene 全部日志 导出时间: ${
|
||||||
|
SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date())
|
||||||
|
} ===\n\n"
|
||||||
|
val byCategory = buffer.groupBy { it.category }
|
||||||
|
return header + byCategory.entries.joinToString("\n\n") { (cat, entries) ->
|
||||||
|
"--- ${cat.label} (${cat.name}) ---\n" + entries.joinToString("\n") { it.formatted() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun exportToFile(context: Context, category: LogCategory? = null): File {
|
||||||
|
val text = if (category != null) exportAsText(category) else exportAllAsText()
|
||||||
|
val label = category?.name?.lowercase() ?: "all"
|
||||||
|
val dateStr = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
|
||||||
|
val file = File(context.cacheDir, "cyrene_log_${label}_$dateStr.txt")
|
||||||
|
file.writeText(text)
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
|
||||||
|
fun exportAllAsZip(context: Context): File {
|
||||||
|
val dateStr = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
|
||||||
|
val zipFile = File(context.cacheDir, "cyrene_logs_$dateStr.zip")
|
||||||
|
ZipOutputStream(zipFile.outputStream().buffered()).use { zos ->
|
||||||
|
LogCategory.entries.forEach { cat ->
|
||||||
|
val text = exportAsText(cat)
|
||||||
|
zos.putNextEntry(ZipEntry("${cat.name.lowercase()}.txt"))
|
||||||
|
zos.write(text.toByteArray(Charsets.UTF_8))
|
||||||
|
zos.closeEntry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return zipFile
|
||||||
|
}
|
||||||
|
|
||||||
|
fun shareFile(context: Context, file: File) {
|
||||||
|
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
||||||
|
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||||
|
type = if (file.extension == "zip") "application/zip" else "text/plain"
|
||||||
|
putExtra(Intent.EXTRA_STREAM, uri)
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
}
|
||||||
|
context.startActivity(Intent.createChooser(intent, "导出日志"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun clear() {
|
||||||
|
buffer.clear()
|
||||||
|
_entries.value = emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package top.yeij.cyrene.util
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.media.MediaRecorder
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Base64
|
||||||
|
import android.util.Log
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
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 java.io.File
|
||||||
|
|
||||||
|
enum class RecordState {
|
||||||
|
IDLE,
|
||||||
|
RECORDING,
|
||||||
|
LOCKED, // hands-free mode
|
||||||
|
CANCELLING,
|
||||||
|
}
|
||||||
|
|
||||||
|
class VoiceRecorder(private val context: Context) {
|
||||||
|
|
||||||
|
private var recorder: MediaRecorder? = null
|
||||||
|
private var outputFile: File? = null
|
||||||
|
private var timerJob: Job? = null
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(RecordState.IDLE)
|
||||||
|
val state: StateFlow<RecordState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
private val _durationMs = MutableStateFlow(0L)
|
||||||
|
val durationMs: StateFlow<Long> = _durationMs.asStateFlow()
|
||||||
|
|
||||||
|
fun start() {
|
||||||
|
if (_state.value != RecordState.IDLE) return
|
||||||
|
try {
|
||||||
|
outputFile = File(context.cacheDir, "voice_${System.currentTimeMillis()}.aac")
|
||||||
|
outputFile?.delete()
|
||||||
|
|
||||||
|
recorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
MediaRecorder(context)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
MediaRecorder()
|
||||||
|
}
|
||||||
|
|
||||||
|
recorder?.apply {
|
||||||
|
setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||||
|
setOutputFormat(MediaRecorder.OutputFormat.AAC_ADTS)
|
||||||
|
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
|
||||||
|
setAudioSamplingRate(16000)
|
||||||
|
setAudioEncodingBitRate(32000)
|
||||||
|
setAudioChannels(1)
|
||||||
|
setOutputFile(outputFile?.absolutePath)
|
||||||
|
prepare()
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
|
||||||
|
_state.value = RecordState.RECORDING
|
||||||
|
startTimer()
|
||||||
|
Log.i(TAG, "Recording started: ${outputFile?.absolutePath}")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to start recording: ${e.message}", e)
|
||||||
|
cleanup()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun lock() {
|
||||||
|
if (_state.value == RecordState.RECORDING) {
|
||||||
|
_state.value = RecordState.LOCKED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop(): File? {
|
||||||
|
if (_state.value == RecordState.IDLE) return null
|
||||||
|
val file = outputFile
|
||||||
|
try {
|
||||||
|
recorder?.apply {
|
||||||
|
try { stop() } catch (_: Exception) { }
|
||||||
|
try { release() } catch (_: Exception) { }
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error stopping recorder: ${e.message}", e)
|
||||||
|
}
|
||||||
|
recorder = null
|
||||||
|
cancelTimer()
|
||||||
|
_state.value = RecordState.IDLE
|
||||||
|
_durationMs.value = 0L
|
||||||
|
Log.i(TAG, "Recording stopped: ${file?.absolutePath}, size=${file?.length()}")
|
||||||
|
return if (file != null && file.exists() && file.length() > 0) file else null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancel() {
|
||||||
|
val file = outputFile
|
||||||
|
try {
|
||||||
|
recorder?.apply {
|
||||||
|
try { stop() } catch (_: Exception) { }
|
||||||
|
try { release() } catch (_: Exception) { }
|
||||||
|
}
|
||||||
|
} catch (e: Exception) { }
|
||||||
|
recorder = null
|
||||||
|
file?.delete()
|
||||||
|
cancelTimer()
|
||||||
|
_state.value = RecordState.IDLE
|
||||||
|
_durationMs.value = 0L
|
||||||
|
Log.i(TAG, "Recording cancelled")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cleanup() {
|
||||||
|
try { recorder?.release() } catch (_: Exception) { }
|
||||||
|
recorder = null
|
||||||
|
outputFile?.delete()
|
||||||
|
outputFile = null
|
||||||
|
cancelTimer()
|
||||||
|
_state.value = RecordState.IDLE
|
||||||
|
_durationMs.value = 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getBase64(): String? {
|
||||||
|
val file = outputFile ?: return null
|
||||||
|
if (!file.exists()) return null
|
||||||
|
return try {
|
||||||
|
val bytes = file.readBytes()
|
||||||
|
Base64.encodeToString(bytes, Base64.NO_WRAP)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to encode audio: ${e.message}", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteFile() {
|
||||||
|
outputFile?.delete()
|
||||||
|
outputFile = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startTimer() {
|
||||||
|
cancelTimer()
|
||||||
|
val startTime = System.currentTimeMillis()
|
||||||
|
timerJob = CoroutineScope(Dispatchers.Main).launch {
|
||||||
|
while (true) {
|
||||||
|
delay(100)
|
||||||
|
_durationMs.value = System.currentTimeMillis() - startTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancelTimer() {
|
||||||
|
timerJob?.cancel()
|
||||||
|
timerJob = null
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "VoiceRecorder"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
package top.yeij.cyrene.viewmodel
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import top.yeij.cyrene.domain.model.Conversation
|
||||||
|
import top.yeij.cyrene.domain.model.Message
|
||||||
|
import top.yeij.cyrene.domain.repository.ChatRepository
|
||||||
|
import top.yeij.cyrene.util.RecordState
|
||||||
|
import top.yeij.cyrene.util.RuntimeLog
|
||||||
|
import top.yeij.cyrene.util.VoiceRecorder
|
||||||
|
|
||||||
|
private fun List<Message>.deduplicate(): List<Message> {
|
||||||
|
if (isEmpty()) return this
|
||||||
|
val result = mutableListOf(this[0])
|
||||||
|
for (i in 1 until size) {
|
||||||
|
val prev = result.last()
|
||||||
|
val curr = this[i]
|
||||||
|
val isDuplicate = curr.id == prev.id ||
|
||||||
|
(curr.role == prev.role && curr.content == prev.content && curr.msgType == prev.msgType)
|
||||||
|
if (!isDuplicate) {
|
||||||
|
result.add(curr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<Message>.removeWrappingDuplicates(): List<Message> {
|
||||||
|
if (size < 3) return this
|
||||||
|
val toRemove = mutableSetOf<String>()
|
||||||
|
for (msg in this) {
|
||||||
|
val containedCount = count { other ->
|
||||||
|
other.id != msg.id &&
|
||||||
|
other.content.isNotBlank() &&
|
||||||
|
other.content.length < msg.content.length &&
|
||||||
|
msg.content.contains(other.content) &&
|
||||||
|
kotlin.math.abs(other.timestamp - msg.timestamp) < 2000
|
||||||
|
}
|
||||||
|
if (containedCount >= 2) {
|
||||||
|
toRemove.add(msg.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return if (toRemove.isEmpty()) this else filter { it.id !in toRemove }
|
||||||
|
}
|
||||||
|
|
||||||
|
class ChatViewModel(
|
||||||
|
private val chatRepository: ChatRepository,
|
||||||
|
private val voiceRecorder: VoiceRecorder,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
val isConnected: StateFlow<Boolean> = chatRepository.connectionState
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||||
|
|
||||||
|
val connectionError: StateFlow<String?> = chatRepository.connectionError
|
||||||
|
|
||||||
|
val conversations: StateFlow<List<Conversation>> = chatRepository.getConversations()
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||||
|
|
||||||
|
private val _currentMessages = MutableStateFlow<List<Message>>(emptyList())
|
||||||
|
val currentMessages: StateFlow<List<Message>> = _currentMessages.asStateFlow()
|
||||||
|
|
||||||
|
private val _inputText = MutableStateFlow("")
|
||||||
|
val inputText: StateFlow<String> = _inputText.asStateFlow()
|
||||||
|
|
||||||
|
private val _isSending = MutableStateFlow(false)
|
||||||
|
val isStreaming: StateFlow<Boolean> = kotlinx.coroutines.flow.combine(
|
||||||
|
_isSending,
|
||||||
|
chatRepository.isAssistantStreaming,
|
||||||
|
) { sending, assistant -> sending || assistant }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||||
|
|
||||||
|
private val _isRefreshing = MutableStateFlow(false)
|
||||||
|
val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow()
|
||||||
|
|
||||||
|
// Voice recording state
|
||||||
|
val voiceRecordState: StateFlow<RecordState> = voiceRecorder.state
|
||||||
|
val voiceRecordDurationMs: StateFlow<Long> = voiceRecorder.durationMs
|
||||||
|
|
||||||
|
// Animation ordering for message bubbles
|
||||||
|
private var animCounter = 0
|
||||||
|
private val _messageAnimIndex = MutableStateFlow<Map<String, Int>>(emptyMap())
|
||||||
|
val messageAnimIndex: StateFlow<Map<String, Int>> = _messageAnimIndex.asStateFlow()
|
||||||
|
|
||||||
|
private var currentSessionId: String? = null
|
||||||
|
private var dbObserverJob: Job? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Phase 1: find/create main session, reconnect WS, load server history
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val sessionId = chatRepository.initializeSession()
|
||||||
|
currentSessionId = sessionId
|
||||||
|
chatRepository.currentSessionId = sessionId
|
||||||
|
chatRepository.ensureConnected()
|
||||||
|
loadMessagesFromDb(sessionId)
|
||||||
|
val serverMessages = chatRepository.loadMessagesFromServer(sessionId)
|
||||||
|
if (serverMessages.isNotEmpty()) {
|
||||||
|
val serverIds = serverMessages.map { it.id }.toSet()
|
||||||
|
_currentMessages.update { current ->
|
||||||
|
val localOnly = current.filter { it.id !in serverIds }
|
||||||
|
(serverMessages + localOnly)
|
||||||
|
.sortedBy { it.timestamp }
|
||||||
|
.deduplicate()
|
||||||
|
.removeWrappingDuplicates()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Observe incoming live messages with atomic dedup
|
||||||
|
viewModelScope.launch {
|
||||||
|
chatRepository.observeMessages().collect { message ->
|
||||||
|
try {
|
||||||
|
_currentMessages.update { list ->
|
||||||
|
val updated = list.toMutableList()
|
||||||
|
val existingIdx = updated.indexOfLast { it.id == message.id }
|
||||||
|
if (existingIdx >= 0) {
|
||||||
|
updated[existingIdx] = message
|
||||||
|
} else {
|
||||||
|
val isDup = updated.any {
|
||||||
|
it.role == message.role && it.content == message.content && it.msgType == message.msgType
|
||||||
|
}
|
||||||
|
if (!isDup) {
|
||||||
|
updated.add(message)
|
||||||
|
val idx = _messageAnimIndex.value.toMutableMap()
|
||||||
|
idx[message.id] = animCounter++
|
||||||
|
_messageAnimIndex.value = idx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updated.deduplicate()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("ChatViewModel", "Error processing message: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Observe message clear events
|
||||||
|
viewModelScope.launch {
|
||||||
|
chatRepository.messageClearEvents.collect {
|
||||||
|
_currentMessages.value = emptyList()
|
||||||
|
_messageAnimIndex.value = emptyMap()
|
||||||
|
animCounter = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Reset user-side sending state when server starts responding
|
||||||
|
viewModelScope.launch {
|
||||||
|
chatRepository.isAssistantStreaming.collect { streaming ->
|
||||||
|
if (streaming) _isSending.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Voice recording (WeChat-style gesture) ---
|
||||||
|
|
||||||
|
fun startRecord() {
|
||||||
|
voiceRecorder.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun lockRecord() {
|
||||||
|
voiceRecorder.lock()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun finishRecord() {
|
||||||
|
val file = voiceRecorder.stop() ?: return
|
||||||
|
val base64 = voiceRecorder.getBase64()
|
||||||
|
voiceRecorder.deleteFile()
|
||||||
|
if (base64.isNullOrBlank()) return
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
chatRepository.sendVoiceInput(base64, "voice_msg")
|
||||||
|
RuntimeLog.chat("voice", "Voice message sent, duration=${file.length()}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelRecord() {
|
||||||
|
voiceRecorder.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadMessagesFromDb(sessionId: String) {
|
||||||
|
dbObserverJob?.cancel()
|
||||||
|
dbObserverJob = viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
chatRepository.getMessages(sessionId).collect { messages ->
|
||||||
|
_currentMessages.update { current ->
|
||||||
|
val live = current.associateBy { it.id }
|
||||||
|
val db = messages.associateBy { it.id }
|
||||||
|
(db + live).values
|
||||||
|
.sortedBy { it.timestamp }
|
||||||
|
.deduplicate()
|
||||||
|
.removeWrappingDuplicates()
|
||||||
|
}
|
||||||
|
val idx = _messageAnimIndex.value.toMutableMap()
|
||||||
|
messages.forEach { m ->
|
||||||
|
if (m.id !in idx) idx[m.id] = animCounter++
|
||||||
|
}
|
||||||
|
_messageAnimIndex.value = idx
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("ChatViewModel", "Error loading messages: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onInputChanged(text: String) {
|
||||||
|
_inputText.value = text
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendMessage() {
|
||||||
|
val text = _inputText.value.trim()
|
||||||
|
if (text.isEmpty()) return
|
||||||
|
|
||||||
|
_inputText.value = ""
|
||||||
|
_isSending.value = true
|
||||||
|
val sid = currentSessionId
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
chatRepository.sendMessage(text, sid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun switchSession(sessionId: String) {
|
||||||
|
currentSessionId = sessionId
|
||||||
|
chatRepository.currentSessionId = sessionId
|
||||||
|
_currentMessages.value = emptyList()
|
||||||
|
_messageAnimIndex.value = emptyMap()
|
||||||
|
animCounter = 0
|
||||||
|
viewModelScope.launch {
|
||||||
|
chatRepository.connectWebSocket(sessionId)
|
||||||
|
chatRepository.loadMessagesFromServer(sessionId)
|
||||||
|
}
|
||||||
|
loadMessagesFromDb(sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refreshMessages() {
|
||||||
|
val sid = currentSessionId ?: return
|
||||||
|
viewModelScope.launch {
|
||||||
|
_isRefreshing.value = true
|
||||||
|
try {
|
||||||
|
if (!isConnected.value) {
|
||||||
|
chatRepository.ensureConnected()
|
||||||
|
}
|
||||||
|
val serverMessages = chatRepository.loadMessagesFromServer(sid)
|
||||||
|
if (serverMessages.isNotEmpty()) {
|
||||||
|
val serverIds = serverMessages.map { it.id }.toSet()
|
||||||
|
_currentMessages.update { current ->
|
||||||
|
val localOnly = current.filter { it.id !in serverIds }
|
||||||
|
(serverMessages + localOnly)
|
||||||
|
.sortedBy { it.timestamp }
|
||||||
|
.deduplicate()
|
||||||
|
.removeWrappingDuplicates()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
_isRefreshing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteConversation(id: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
chatRepository.deleteConversation(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearLocalMessages() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
chatRepository.clearLocalMessages()
|
||||||
|
_currentMessages.value = emptyList()
|
||||||
|
_messageAnimIndex.value = emptyMap()
|
||||||
|
animCounter = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package top.yeij.cyrene.viewmodel
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import top.yeij.cyrene.domain.model.Device
|
||||||
|
import top.yeij.cyrene.domain.repository.IoTRepository
|
||||||
|
|
||||||
|
class IoTViewModel(
|
||||||
|
private val ioTRepository: IoTRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
val devices: StateFlow<List<Device>> = ioTRepository.getDevices()
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||||
|
|
||||||
|
private val _isLoading = MutableStateFlow(false)
|
||||||
|
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
|
||||||
|
|
||||||
|
private val _error = MutableStateFlow<String?>(null)
|
||||||
|
val error: StateFlow<String?> = _error.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
refreshDevices()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refreshDevices() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_isLoading.value = true
|
||||||
|
ioTRepository.refreshDevices()
|
||||||
|
_isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun controlDevice(deviceId: String, action: String, value: Any? = null) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_isLoading.value = true
|
||||||
|
_error.value = null
|
||||||
|
ioTRepository.controlDevice(deviceId, action, value).fold(
|
||||||
|
onSuccess = { },
|
||||||
|
onFailure = { _error.value = it.message },
|
||||||
|
)
|
||||||
|
_isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun togglePower(device: Device) {
|
||||||
|
val action = if (device.state.power == true) "off" else "on"
|
||||||
|
controlDevice(device.id, action)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearError() {
|
||||||
|
_error.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
package top.yeij.cyrene.viewmodel
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
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.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import top.yeij.cyrene.domain.model.Message
|
||||||
|
import top.yeij.cyrene.domain.repository.ChatRepository
|
||||||
|
import top.yeij.cyrene.util.Constants
|
||||||
|
import top.yeij.cyrene.util.RecordState
|
||||||
|
import top.yeij.cyrene.util.VoiceRecorder
|
||||||
|
import top.yeij.cyrene.voice.tts.TextToSpeechEngine
|
||||||
|
|
||||||
|
private fun List<Message>.deduplicate(): List<Message> {
|
||||||
|
if (isEmpty()) return this
|
||||||
|
val result = mutableListOf(this[0])
|
||||||
|
for (i in 1 until size) {
|
||||||
|
val prev = result.last()
|
||||||
|
val curr = this[i]
|
||||||
|
val isDuplicate = curr.id == prev.id ||
|
||||||
|
(curr.role == prev.role && curr.content == prev.content && curr.msgType == prev.msgType)
|
||||||
|
if (!isDuplicate) {
|
||||||
|
result.add(curr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<Message>.removeWrappingDuplicates(): List<Message> {
|
||||||
|
if (size < 3) return this
|
||||||
|
val toRemove = mutableSetOf<String>()
|
||||||
|
for (msg in this) {
|
||||||
|
val containedCount = count { other ->
|
||||||
|
other.id != msg.id &&
|
||||||
|
other.content.isNotBlank() &&
|
||||||
|
other.content.length < msg.content.length &&
|
||||||
|
msg.content.contains(other.content) &&
|
||||||
|
kotlin.math.abs(other.timestamp - msg.timestamp) < 2000
|
||||||
|
}
|
||||||
|
if (containedCount >= 2) {
|
||||||
|
toRemove.add(msg.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return if (toRemove.isEmpty()) this else filter { it.id !in toRemove }
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class OverlayState {
|
||||||
|
IDLE,
|
||||||
|
LISTENING,
|
||||||
|
PROCESSING,
|
||||||
|
SPEAKING,
|
||||||
|
WAITING,
|
||||||
|
}
|
||||||
|
|
||||||
|
class OverlayViewModel(
|
||||||
|
private val chatRepository: ChatRepository,
|
||||||
|
private val voiceRecorder: VoiceRecorder,
|
||||||
|
private val ttsEngine: TextToSpeechEngine,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(OverlayState.WAITING)
|
||||||
|
val state: StateFlow<OverlayState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
private val _messages = MutableStateFlow<List<Message>>(emptyList())
|
||||||
|
val messages: StateFlow<List<Message>> = _messages.asStateFlow()
|
||||||
|
|
||||||
|
private val _inputText = MutableStateFlow("")
|
||||||
|
val inputText: StateFlow<String> = _inputText.asStateFlow()
|
||||||
|
|
||||||
|
val voiceRecordState: StateFlow<RecordState> = voiceRecorder.state
|
||||||
|
val voiceRecordDurationMs: StateFlow<Long> = voiceRecorder.durationMs
|
||||||
|
|
||||||
|
// Animation ordering for message bubbles
|
||||||
|
private var animCounter = 0
|
||||||
|
private val _messageAnimIndex = MutableStateFlow<Map<String, Int>>(emptyMap())
|
||||||
|
val messageAnimIndex: StateFlow<Map<String, Int>> = _messageAnimIndex.asStateFlow()
|
||||||
|
|
||||||
|
private var silenceTimer: Job? = null
|
||||||
|
private var lastAssistantMessageId: String? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
chatRepository.observeMessages().collect { message ->
|
||||||
|
_messages.update { list ->
|
||||||
|
val updated = list.toMutableList()
|
||||||
|
val idx = updated.indexOfLast { it.id == message.id }
|
||||||
|
if (idx >= 0) {
|
||||||
|
updated[idx] = message
|
||||||
|
} else {
|
||||||
|
val isDup = updated.any {
|
||||||
|
it.role == message.role && it.content == message.content && it.msgType == message.msgType
|
||||||
|
}
|
||||||
|
if (!isDup) {
|
||||||
|
updated.add(message)
|
||||||
|
val animIdx = _messageAnimIndex.value.toMutableMap()
|
||||||
|
animIdx[message.id] = animCounter++
|
||||||
|
_messageAnimIndex.value = animIdx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updated.deduplicate()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.role == "assistant" && !message.isStreaming && message.msgType == "chat") {
|
||||||
|
if (message.id != lastAssistantMessageId && message.content.isNotBlank()) {
|
||||||
|
lastAssistantMessageId = message.id
|
||||||
|
speakResponse(message.content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
ttsEngine.onDone.collect {
|
||||||
|
if (_state.value == OverlayState.SPEAKING) {
|
||||||
|
setWaiting()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
chatRepository.messageClearEvents.collect {
|
||||||
|
_messages.value = emptyList()
|
||||||
|
_messageAnimIndex.value = emptyMap()
|
||||||
|
animCounter = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onInputChanged(text: String) {
|
||||||
|
_inputText.value = text
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendText() {
|
||||||
|
val text = _inputText.value.trim()
|
||||||
|
if (text.isEmpty()) return
|
||||||
|
_inputText.value = ""
|
||||||
|
|
||||||
|
_state.value = OverlayState.PROCESSING
|
||||||
|
cancelSilenceTimer()
|
||||||
|
viewModelScope.launch {
|
||||||
|
chatRepository.sendMessage(text, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Voice recording (WeChat-style gesture) ---
|
||||||
|
|
||||||
|
fun startRecord() {
|
||||||
|
voiceRecorder.start()
|
||||||
|
_state.value = OverlayState.LISTENING
|
||||||
|
cancelSilenceTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun lockRecord() {
|
||||||
|
voiceRecorder.lock()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun finishRecord() {
|
||||||
|
val file = voiceRecorder.stop() ?: return
|
||||||
|
val base64 = voiceRecorder.getBase64()
|
||||||
|
voiceRecorder.deleteFile()
|
||||||
|
if (base64.isNullOrBlank()) return
|
||||||
|
|
||||||
|
_state.value = OverlayState.PROCESSING
|
||||||
|
viewModelScope.launch {
|
||||||
|
chatRepository.sendVoiceInput(base64, "voice_msg")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelRecord() {
|
||||||
|
voiceRecorder.cancel()
|
||||||
|
setWaiting()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelCurrentAction() {
|
||||||
|
if (voiceRecorder.state.value == RecordState.LOCKED) {
|
||||||
|
voiceRecorder.cancel()
|
||||||
|
setWaiting()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun speakResponse(text: String) {
|
||||||
|
if (text.isBlank()) return
|
||||||
|
_state.value = OverlayState.SPEAKING
|
||||||
|
ttsEngine.speak(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setWaiting() {
|
||||||
|
_state.value = OverlayState.WAITING
|
||||||
|
startSilenceTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopSpeaking() {
|
||||||
|
ttsEngine.stop()
|
||||||
|
if (_state.value == OverlayState.SPEAKING) {
|
||||||
|
setWaiting()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendScreenContext(content: String) {
|
||||||
|
if (content.isBlank()) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
chatRepository.sendScreenContext(content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun finish() {
|
||||||
|
_state.value = OverlayState.IDLE
|
||||||
|
cancelSilenceTimer()
|
||||||
|
voiceRecorder.cancel()
|
||||||
|
ttsEngine.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startSilenceTimer() {
|
||||||
|
cancelSilenceTimer()
|
||||||
|
silenceTimer = viewModelScope.launch {
|
||||||
|
delay(Constants.SILENCE_TIMEOUT_MS)
|
||||||
|
if (_state.value == OverlayState.WAITING) {
|
||||||
|
_state.value = OverlayState.IDLE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancelSilenceTimer() {
|
||||||
|
silenceTimer?.cancel()
|
||||||
|
silenceTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
voiceRecorder.cancel()
|
||||||
|
ttsEngine.shutdown()
|
||||||
|
super.onCleared()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package top.yeij.cyrene.viewmodel
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import top.yeij.cyrene.data.local.PreferencesDataStore
|
||||||
|
import top.yeij.cyrene.data.remote.ApiService
|
||||||
|
import top.yeij.cyrene.domain.repository.AuthRepository
|
||||||
|
|
||||||
|
data class ProfileState(
|
||||||
|
val userId: String = "",
|
||||||
|
val username: String = "",
|
||||||
|
val nickname: String = "",
|
||||||
|
val isAdmin: Boolean = false,
|
||||||
|
val createdAt: String = "",
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val isLoggedIn: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
class ProfileViewModel(
|
||||||
|
private val apiService: ApiService,
|
||||||
|
private val authRepository: AuthRepository,
|
||||||
|
private val prefs: PreferencesDataStore,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _profile = MutableStateFlow(ProfileState())
|
||||||
|
val profile: StateFlow<ProfileState> = _profile.asStateFlow()
|
||||||
|
|
||||||
|
private var loadedFromCache = false
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_profile.value = _profile.value.copy(isLoggedIn = authRepository.isLoggedIn())
|
||||||
|
if (_profile.value.isLoggedIn) {
|
||||||
|
loadCachedProfile()
|
||||||
|
fetchFreshProfile()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show cached profile immediately for instant UI.
|
||||||
|
*/
|
||||||
|
private suspend fun loadCachedProfile() {
|
||||||
|
val userId = prefs.profileUserId.firstOrNull() ?: return
|
||||||
|
val nickname = prefs.profileNickname.firstOrNull() ?: ""
|
||||||
|
val isAdmin = prefs.profileIsAdmin.firstOrNull()?.toBoolean() ?: false
|
||||||
|
val createdAt = prefs.profileCreatedAt.firstOrNull() ?: ""
|
||||||
|
val username = prefs.username.firstOrNull() ?: ""
|
||||||
|
|
||||||
|
_profile.value = ProfileState(
|
||||||
|
userId = userId,
|
||||||
|
username = username,
|
||||||
|
nickname = nickname,
|
||||||
|
isAdmin = isAdmin,
|
||||||
|
createdAt = createdAt,
|
||||||
|
isLoggedIn = true,
|
||||||
|
)
|
||||||
|
loadedFromCache = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch fresh profile from server. On success, update cache and UI.
|
||||||
|
* On failure, keep showing cached data — no error, no UI disruption.
|
||||||
|
*/
|
||||||
|
fun fetchFreshProfile() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
if (!loadedFromCache) {
|
||||||
|
_profile.value = _profile.value.copy(isLoading = true)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
val response = apiService.getProfile()
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
val body = response.body()
|
||||||
|
if (body != null) {
|
||||||
|
val dateStr = body.createdAt?.take(10) ?: ""
|
||||||
|
val nickname = body.nickname ?: body.username
|
||||||
|
|
||||||
|
_profile.value = ProfileState(
|
||||||
|
userId = body.userId,
|
||||||
|
username = body.username,
|
||||||
|
nickname = nickname,
|
||||||
|
isAdmin = body.isAdmin == true,
|
||||||
|
createdAt = dateStr,
|
||||||
|
isLoading = false,
|
||||||
|
isLoggedIn = true,
|
||||||
|
)
|
||||||
|
loadedFromCache = true
|
||||||
|
|
||||||
|
// Update local cache
|
||||||
|
prefs.saveProfileCache(body.userId, nickname, body.isAdmin == true, dateStr)
|
||||||
|
}
|
||||||
|
} else if (!loadedFromCache) {
|
||||||
|
_profile.value = _profile.value.copy(isLoading = false)
|
||||||
|
}
|
||||||
|
// On error with cache already shown: silently ignore
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w("ProfileVM", "Failed to fetch fresh profile: ${e.message}")
|
||||||
|
if (!loadedFromCache) {
|
||||||
|
_profile.value = _profile.value.copy(isLoading = false)
|
||||||
|
}
|
||||||
|
// If cached data is showing, keep it silently
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun logout() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
authRepository.logout()
|
||||||
|
prefs.clearProfileCache()
|
||||||
|
_profile.value = ProfileState()
|
||||||
|
loadedFromCache = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
package top.yeij.cyrene.viewmodel
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import top.yeij.cyrene.data.local.PreferencesDataStore
|
||||||
|
import top.yeij.cyrene.data.remote.DynamicUrlInterceptor
|
||||||
|
import top.yeij.cyrene.domain.repository.AuthRepository
|
||||||
|
import top.yeij.cyrene.domain.repository.ChatRepository
|
||||||
|
import top.yeij.cyrene.voice.stt.SttManager
|
||||||
|
|
||||||
|
class SettingsViewModel(
|
||||||
|
private val authRepository: AuthRepository,
|
||||||
|
private val preferencesDataStore: PreferencesDataStore,
|
||||||
|
private val dynamicUrlInterceptor: DynamicUrlInterceptor,
|
||||||
|
private val chatRepository: ChatRepository,
|
||||||
|
private val sttManager: SttManager,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||||
|
|
||||||
|
private val _baseUrl = MutableStateFlow("")
|
||||||
|
val baseUrl: StateFlow<String> = _baseUrl.asStateFlow()
|
||||||
|
|
||||||
|
private val _themeMode = MutableStateFlow("auto")
|
||||||
|
val themeMode: StateFlow<String> = _themeMode.asStateFlow()
|
||||||
|
|
||||||
|
private val _wakeWord = MutableStateFlow("昔涟")
|
||||||
|
val wakeWord: StateFlow<String> = _wakeWord.asStateFlow()
|
||||||
|
|
||||||
|
private val _username = MutableStateFlow("")
|
||||||
|
val username: StateFlow<String> = _username.asStateFlow()
|
||||||
|
|
||||||
|
private val _dashScopeApiKey = MutableStateFlow("")
|
||||||
|
val dashScopeApiKey: StateFlow<String> = _dashScopeApiKey.asStateFlow()
|
||||||
|
|
||||||
|
private val _dashScopeEndpoint = MutableStateFlow("wss://dashscope.aliyuncs.com/api-ws/v1/inference")
|
||||||
|
val dashScopeEndpoint: StateFlow<String> = _dashScopeEndpoint.asStateFlow()
|
||||||
|
|
||||||
|
private val _dashScopeModel = MutableStateFlow("fun-asr-realtime")
|
||||||
|
val dashScopeModel: StateFlow<String> = _dashScopeModel.asStateFlow()
|
||||||
|
|
||||||
|
private val _autoScreenContext = MutableStateFlow(false)
|
||||||
|
val autoScreenContext: StateFlow<Boolean> = _autoScreenContext.asStateFlow()
|
||||||
|
|
||||||
|
private val _isLoggedIn = MutableStateFlow(false)
|
||||||
|
val isLoggedIn: StateFlow<Boolean> = _isLoggedIn.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
scope.launch {
|
||||||
|
_isLoggedIn.value = authRepository.isLoggedIn()
|
||||||
|
}
|
||||||
|
// Single collector for all DataStore preferences — avoids subscriber explosion
|
||||||
|
scope.launch {
|
||||||
|
preferencesDataStore.autoScreenContext.collect { value ->
|
||||||
|
_autoScreenContext.value = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scope.launch {
|
||||||
|
combine(
|
||||||
|
preferencesDataStore.baseUrl,
|
||||||
|
preferencesDataStore.themeMode,
|
||||||
|
preferencesDataStore.wakeWord,
|
||||||
|
preferencesDataStore.username,
|
||||||
|
combine(
|
||||||
|
preferencesDataStore.dashScopeApiKey,
|
||||||
|
preferencesDataStore.dashScopeEndpoint,
|
||||||
|
preferencesDataStore.dashScopeModel,
|
||||||
|
) { apiKey, endpoint, model ->
|
||||||
|
Triple(apiKey, endpoint, model)
|
||||||
|
},
|
||||||
|
) { baseUrl, themeMode, wakeWord, username, dashScope ->
|
||||||
|
baseUrl?.let { url ->
|
||||||
|
if (url.isNotBlank()) {
|
||||||
|
_baseUrl.value = url
|
||||||
|
dynamicUrlInterceptor.baseUrl = url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
themeMode?.let { _themeMode.value = it }
|
||||||
|
wakeWord?.let { word ->
|
||||||
|
if (word.isNotBlank()) _wakeWord.value = word
|
||||||
|
}
|
||||||
|
username?.let { _username.value = it }
|
||||||
|
val (apiKey, endpoint, model) = dashScope
|
||||||
|
apiKey?.let { key ->
|
||||||
|
if (key.isNotBlank()) _dashScopeApiKey.value = key
|
||||||
|
sttManager.updateDashScopeApiKey(key)
|
||||||
|
}
|
||||||
|
endpoint?.let { ep ->
|
||||||
|
if (ep.isNotBlank()) _dashScopeEndpoint.value = ep
|
||||||
|
}
|
||||||
|
model?.let { m ->
|
||||||
|
if (m.isNotBlank()) _dashScopeModel.value = m
|
||||||
|
}
|
||||||
|
// Push full config to STT
|
||||||
|
sttManager.configureDashScope(
|
||||||
|
apiKey = _dashScopeApiKey.value,
|
||||||
|
endpoint = _dashScopeEndpoint.value,
|
||||||
|
model = _dashScopeModel.value,
|
||||||
|
)
|
||||||
|
}.collect { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateBaseUrlInput(url: String) {
|
||||||
|
_baseUrl.value = url
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveBaseUrl(url: String) {
|
||||||
|
_baseUrl.value = url
|
||||||
|
dynamicUrlInterceptor.baseUrl = url
|
||||||
|
scope.launch {
|
||||||
|
preferencesDataStore.saveBaseUrl(url)
|
||||||
|
chatRepository.reconnectWebSocket()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveThemeMode(mode: String) {
|
||||||
|
_themeMode.value = mode
|
||||||
|
scope.launch { preferencesDataStore.saveThemeMode(mode) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveWakeWord(word: String) {
|
||||||
|
_wakeWord.value = word
|
||||||
|
scope.launch { preferencesDataStore.saveWakeWord(word) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateDashScopeApiKeyInput(key: String) {
|
||||||
|
_dashScopeApiKey.value = key
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveDashScopeApiKey(key: String) {
|
||||||
|
_dashScopeApiKey.value = key
|
||||||
|
sttManager.updateDashScopeApiKey(key)
|
||||||
|
scope.launch { preferencesDataStore.saveDashScopeApiKey(key) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateDashScopeEndpointInput(endpoint: String) {
|
||||||
|
_dashScopeEndpoint.value = endpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveDashScopeEndpoint(endpoint: String) {
|
||||||
|
_dashScopeEndpoint.value = endpoint
|
||||||
|
sttManager.configureDashScope(_dashScopeApiKey.value, endpoint, _dashScopeModel.value)
|
||||||
|
scope.launch { preferencesDataStore.saveDashScopeEndpoint(endpoint) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateDashScopeModelInput(model: String) {
|
||||||
|
_dashScopeModel.value = model
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveDashScopeModel(model: String) {
|
||||||
|
_dashScopeModel.value = model
|
||||||
|
sttManager.configureDashScope(_dashScopeApiKey.value, _dashScopeEndpoint.value, model)
|
||||||
|
scope.launch { preferencesDataStore.saveDashScopeModel(model) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveAutoScreenContext(enabled: Boolean) {
|
||||||
|
_autoScreenContext.value = enabled
|
||||||
|
scope.launch { preferencesDataStore.saveAutoScreenContext(enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearLocalMessages() {
|
||||||
|
scope.launch {
|
||||||
|
chatRepository.clearLocalMessages()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun logout() {
|
||||||
|
scope.launch {
|
||||||
|
authRepository.logout()
|
||||||
|
_isLoggedIn.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package top.yeij.cyrene.voice.hotword
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
||||||
|
class HotwordDetector {
|
||||||
|
|
||||||
|
private val _isListening = MutableStateFlow(false)
|
||||||
|
val isListening = _isListening.asStateFlow()
|
||||||
|
|
||||||
|
private val _onDetected = MutableStateFlow(false)
|
||||||
|
val onDetected = _onDetected.asStateFlow()
|
||||||
|
|
||||||
|
fun startListening(wakeWord: String) {
|
||||||
|
_isListening.value = true
|
||||||
|
// Integrate system AlwaysOnHotwordDetector or Porcupine SDK here
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopListening() {
|
||||||
|
_isListening.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateWakeWord(newWord: String) {
|
||||||
|
stopListening()
|
||||||
|
startListening(newWord)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package top.yeij.cyrene.voice.stt
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.media.AudioFormat
|
||||||
|
import android.media.AudioRecord
|
||||||
|
import android.media.MediaRecorder
|
||||||
|
import android.util.Base64
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
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 kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import top.yeij.cyrene.domain.repository.ChatRepository
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
|
||||||
|
class BackendSttProvider(
|
||||||
|
private val context: Context,
|
||||||
|
private val chatRepository: ChatRepository,
|
||||||
|
) : SttProvider {
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
private var audioRecord: AudioRecord? = null
|
||||||
|
private var recordingJob: Job? = null
|
||||||
|
private val audioBuffer = ByteArrayOutputStream()
|
||||||
|
|
||||||
|
private val _partialResult = MutableStateFlow("")
|
||||||
|
override val partialResult: StateFlow<String> = _partialResult.asStateFlow()
|
||||||
|
|
||||||
|
private val _finalResult = MutableSharedFlow<SttResult>(extraBufferCapacity = 8)
|
||||||
|
override val finalResult: SharedFlow<SttResult> = _finalResult.asSharedFlow()
|
||||||
|
|
||||||
|
private val _onError = MutableSharedFlow<String>(extraBufferCapacity = 8)
|
||||||
|
override val onError: SharedFlow<String> = _onError.asSharedFlow()
|
||||||
|
|
||||||
|
private val _isListening = MutableStateFlow(false)
|
||||||
|
override val isListening: StateFlow<Boolean> = _isListening.asStateFlow()
|
||||||
|
|
||||||
|
private val sampleRate = 16000
|
||||||
|
private val audioFormat = AudioFormat.ENCODING_PCM_16BIT
|
||||||
|
private val channelConfig = AudioFormat.CHANNEL_IN_MONO
|
||||||
|
private val bufferSize: Int = AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat)
|
||||||
|
.coerceAtLeast(3200)
|
||||||
|
|
||||||
|
override fun start() {
|
||||||
|
if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO)
|
||||||
|
!= PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
_onError.tryEmit("缺少录音权限")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
_isListening.value = true
|
||||||
|
_partialResult.value = ""
|
||||||
|
audioBuffer.reset()
|
||||||
|
|
||||||
|
try {
|
||||||
|
audioRecord = AudioRecord(
|
||||||
|
MediaRecorder.AudioSource.VOICE_RECOGNITION,
|
||||||
|
sampleRate,
|
||||||
|
channelConfig,
|
||||||
|
audioFormat,
|
||||||
|
bufferSize,
|
||||||
|
).also {
|
||||||
|
if (it.state != AudioRecord.STATE_INITIALIZED) {
|
||||||
|
Log.e(TAG, "AudioRecord init failed")
|
||||||
|
_onError.tryEmit("麦克风初始化失败")
|
||||||
|
_isListening.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
it.startRecording()
|
||||||
|
}
|
||||||
|
|
||||||
|
val readBuffer = ByteArray(bufferSize)
|
||||||
|
recordingJob = scope.launch {
|
||||||
|
while (isActive && _isListening.value) {
|
||||||
|
val bytesRead = audioRecord?.read(readBuffer, 0, readBuffer.size) ?: -1
|
||||||
|
if (bytesRead > 0) {
|
||||||
|
audioBuffer.write(readBuffer, 0, bytesRead)
|
||||||
|
} else if (bytesRead < 0) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
_onError.tryEmit("缺少录音权限")
|
||||||
|
_isListening.value = false
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Recording error", e)
|
||||||
|
_onError.tryEmit("录音失败: ${e.message}")
|
||||||
|
_isListening.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun stop() {
|
||||||
|
if (!_isListening.value) return
|
||||||
|
Log.d(TAG, "Stopping recording")
|
||||||
|
stopRecording()
|
||||||
|
|
||||||
|
val audioBytes = audioBuffer.toByteArray()
|
||||||
|
if (audioBytes.isEmpty()) {
|
||||||
|
Log.w(TAG, "No audio data recorded")
|
||||||
|
_isListening.value = false
|
||||||
|
_onError.tryEmit("未录制到语音")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_isListening.value = false
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val base64 = Base64.encodeToString(audioBytes, Base64.NO_WRAP)
|
||||||
|
chatRepository.sendVoiceInput(base64, "voice_msg")
|
||||||
|
Log.d(TAG, "Sent ${audioBytes.size} bytes of audio to backend")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to send voice input", e)
|
||||||
|
_onError.tryEmit("发送语音失败: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun cancel() {
|
||||||
|
if (!_isListening.value) return
|
||||||
|
Log.d(TAG, "Cancelling recording")
|
||||||
|
stopRecording()
|
||||||
|
audioBuffer.reset()
|
||||||
|
_isListening.value = false
|
||||||
|
_partialResult.value = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopRecording() {
|
||||||
|
recordingJob?.cancel()
|
||||||
|
recordingJob = null
|
||||||
|
try {
|
||||||
|
audioRecord?.stop()
|
||||||
|
audioRecord?.release()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Error releasing AudioRecord: ${e.message}")
|
||||||
|
}
|
||||||
|
audioRecord = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun shutdown() {
|
||||||
|
cancel()
|
||||||
|
scope.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "BackendSTT"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
package top.yeij.cyrene.voice.stt
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.media.AudioAttributes
|
||||||
|
import android.media.AudioFormat
|
||||||
|
import android.media.AudioRecord
|
||||||
|
import android.media.MediaRecorder
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.google.gson.annotations.SerializedName
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
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 kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import okhttp3.WebSocket
|
||||||
|
import okhttp3.WebSocketListener
|
||||||
|
import okio.ByteString
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
|
class DashScopeSttService(
|
||||||
|
private val context: Context,
|
||||||
|
) : SttProvider {
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
private val gson = Gson()
|
||||||
|
private val connectionId = AtomicInteger(0)
|
||||||
|
|
||||||
|
private var webSocket: WebSocket? = null
|
||||||
|
private var audioRecord: AudioRecord? = null
|
||||||
|
private var recordingJob: Job? = null
|
||||||
|
private var taskId: String = ""
|
||||||
|
|
||||||
|
private val _partialResult = MutableStateFlow("")
|
||||||
|
override val partialResult: StateFlow<String> = _partialResult.asStateFlow()
|
||||||
|
|
||||||
|
private val _finalResult = MutableSharedFlow<SttResult>(extraBufferCapacity = 16)
|
||||||
|
override val finalResult: SharedFlow<SttResult> = _finalResult.asSharedFlow()
|
||||||
|
|
||||||
|
private val _onError = MutableSharedFlow<String>(extraBufferCapacity = 8)
|
||||||
|
override val onError: SharedFlow<String> = _onError.asSharedFlow()
|
||||||
|
|
||||||
|
private val _isListening = MutableStateFlow(false)
|
||||||
|
override val isListening: StateFlow<Boolean> = _isListening.asStateFlow()
|
||||||
|
|
||||||
|
// Configurable via settings
|
||||||
|
var apiKey: String = ""
|
||||||
|
var endpoint: String = "wss://dashscope.aliyuncs.com/api-ws/v1/inference"
|
||||||
|
var model: String = "fun-asr-realtime"
|
||||||
|
var enableInterimResults: Boolean = true
|
||||||
|
|
||||||
|
private val sampleRate = 16000
|
||||||
|
private val audioFormat = AudioFormat.ENCODING_PCM_16BIT
|
||||||
|
private val channelConfig = AudioFormat.CHANNEL_IN_MONO
|
||||||
|
private val bytesPerSample = 2
|
||||||
|
private val chunkMs = 100
|
||||||
|
private val bufferSize: Int = AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat)
|
||||||
|
.coerceAtLeast(sampleRate * bytesPerSample * chunkMs / 1000)
|
||||||
|
|
||||||
|
override fun start() {
|
||||||
|
if (apiKey.isBlank()) {
|
||||||
|
Log.w(TAG, "DashScope API key not configured")
|
||||||
|
_onError.tryEmit("请先配置 DashScope API Key")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO)
|
||||||
|
!= PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
_onError.tryEmit("缺少录音权限")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
taskId = UUID.randomUUID().toString()
|
||||||
|
_isListening.value = true
|
||||||
|
_partialResult.value = ""
|
||||||
|
|
||||||
|
connectWebSocket()
|
||||||
|
startRecording()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun stop() {
|
||||||
|
if (!_isListening.value) return
|
||||||
|
Log.d(TAG, "Stopping recognition, taskId=$taskId")
|
||||||
|
stopRecording()
|
||||||
|
sendFinishTask()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun cancel() {
|
||||||
|
if (!_isListening.value) return
|
||||||
|
Log.d(TAG, "Cancelling recognition, taskId=$taskId")
|
||||||
|
stopRecording()
|
||||||
|
closeWebSocket()
|
||||||
|
_isListening.value = false
|
||||||
|
_partialResult.value = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun connectWebSocket() {
|
||||||
|
val client = OkHttpClient.Builder()
|
||||||
|
.readTimeout(0, TimeUnit.MILLISECONDS)
|
||||||
|
.writeTimeout(0, TimeUnit.MILLISECONDS)
|
||||||
|
.callTimeout(0, TimeUnit.MILLISECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(endpoint)
|
||||||
|
.header("Authorization", "Bearer $apiKey")
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val connId = connectionId.incrementAndGet()
|
||||||
|
Log.i(TAG, "[#$connId] Connecting to DashScope: $endpoint")
|
||||||
|
|
||||||
|
webSocket = client.newWebSocket(request, object : WebSocketListener() {
|
||||||
|
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||||
|
if (connectionId.get() != connId) return
|
||||||
|
Log.i(TAG, "[#$connId] Connected, sending run-task")
|
||||||
|
sendRunTask()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||||
|
if (connectionId.get() != connId) return
|
||||||
|
handleServerMessage(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||||
|
if (connectionId.get() != connId) return
|
||||||
|
Log.e(TAG, "[#$connId] WebSocket failure: ${t.message}", t)
|
||||||
|
_isListening.value = false
|
||||||
|
_onError.tryEmit("语音识别连接失败: ${t.message}")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||||
|
if (connectionId.get() != connId) return
|
||||||
|
Log.d(TAG, "[#$connId] Server closing: $code $reason")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||||
|
if (connectionId.get() != connId) return
|
||||||
|
Log.d(TAG, "[#$connId] Closed: $code $reason")
|
||||||
|
_isListening.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sendRunTask() {
|
||||||
|
val msg = mapOf(
|
||||||
|
"header" to mapOf(
|
||||||
|
"action" to "run-task",
|
||||||
|
"task_id" to taskId,
|
||||||
|
"streaming" to "duplex",
|
||||||
|
),
|
||||||
|
"payload" to mapOf(
|
||||||
|
"model" to model,
|
||||||
|
"parameters" to mapOf(
|
||||||
|
"format" to "pcm",
|
||||||
|
"sample_rate" to sampleRate,
|
||||||
|
),
|
||||||
|
"input" to emptyMap<String, String>(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val json = gson.toJson(msg)
|
||||||
|
Log.d(TAG, "Sending run-task: $json")
|
||||||
|
webSocket?.send(json)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sendFinishTask() {
|
||||||
|
val msg = mapOf(
|
||||||
|
"header" to mapOf(
|
||||||
|
"action" to "finish-task",
|
||||||
|
"task_id" to taskId,
|
||||||
|
),
|
||||||
|
"payload" to emptyMap<String, String>(),
|
||||||
|
)
|
||||||
|
val json = gson.toJson(msg)
|
||||||
|
Log.d(TAG, "Sending finish-task: $json")
|
||||||
|
webSocket?.send(json)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleServerMessage(text: String) {
|
||||||
|
try {
|
||||||
|
val response = gson.fromJson(text, DashScopeResponse::class.java)
|
||||||
|
val header = response.header ?: return
|
||||||
|
val event = header.event ?: return
|
||||||
|
|
||||||
|
when (event) {
|
||||||
|
"result-generated" -> {
|
||||||
|
val sentence = response.payload?.output?.sentence ?: return
|
||||||
|
val sentenceText = sentence.text ?: return
|
||||||
|
_partialResult.value = sentenceText
|
||||||
|
|
||||||
|
val sttResult = SttResult(
|
||||||
|
text = sentenceText,
|
||||||
|
isFinal = sentence.sentenceEnd ?: true,
|
||||||
|
)
|
||||||
|
if (sttResult.isFinal) {
|
||||||
|
_finalResult.tryEmit(sttResult)
|
||||||
|
} else if (enableInterimResults) {
|
||||||
|
_finalResult.tryEmit(sttResult)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"task-finished" -> {
|
||||||
|
Log.i(TAG, "Task finished: $taskId")
|
||||||
|
// If no result-generated was received, emit what we have
|
||||||
|
val partial = _partialResult.value
|
||||||
|
if (partial.isNotBlank()) {
|
||||||
|
_finalResult.tryEmit(SttResult(text = partial, isFinal = true))
|
||||||
|
}
|
||||||
|
closeWebSocket()
|
||||||
|
_isListening.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
"task-failed" -> {
|
||||||
|
val error = header.errorMessage ?: "语音识别失败"
|
||||||
|
Log.e(TAG, "Task failed: $error")
|
||||||
|
_onError.tryEmit(error)
|
||||||
|
closeWebSocket()
|
||||||
|
_isListening.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Failed to parse server message: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startRecording() {
|
||||||
|
try {
|
||||||
|
audioRecord = AudioRecord(
|
||||||
|
MediaRecorder.AudioSource.VOICE_RECOGNITION,
|
||||||
|
sampleRate,
|
||||||
|
channelConfig,
|
||||||
|
audioFormat,
|
||||||
|
bufferSize,
|
||||||
|
).also {
|
||||||
|
if (it.state != AudioRecord.STATE_INITIALIZED) {
|
||||||
|
Log.e(TAG, "AudioRecord initialization failed")
|
||||||
|
_onError.tryEmit("麦克风初始化失败")
|
||||||
|
_isListening.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
it.startRecording()
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(TAG, "Recording started, bufferSize=$bufferSize")
|
||||||
|
val readBuffer = ByteArray(bufferSize)
|
||||||
|
|
||||||
|
recordingJob = scope.launch {
|
||||||
|
while (isActive && _isListening.value) {
|
||||||
|
val bytesRead = audioRecord?.read(readBuffer, 0, readBuffer.size) ?: -1
|
||||||
|
if (bytesRead > 0) {
|
||||||
|
val data = readBuffer.copyOf(bytesRead)
|
||||||
|
webSocket?.let { ws ->
|
||||||
|
try {
|
||||||
|
ws.send(ByteString.of(*data))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Failed to send audio: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (bytesRead < 0) {
|
||||||
|
Log.w(TAG, "AudioRecord read error: $bytesRead")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.e(TAG, "Missing RECORD_AUDIO permission", e)
|
||||||
|
_onError.tryEmit("缺少录音权限")
|
||||||
|
_isListening.value = false
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to start recording", e)
|
||||||
|
_onError.tryEmit("录音启动失败: ${e.message}")
|
||||||
|
_isListening.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopRecording() {
|
||||||
|
recordingJob?.cancel()
|
||||||
|
recordingJob = null
|
||||||
|
try {
|
||||||
|
audioRecord?.stop()
|
||||||
|
audioRecord?.release()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Error releasing AudioRecord: ${e.message}")
|
||||||
|
}
|
||||||
|
audioRecord = null
|
||||||
|
Log.d(TAG, "Recording stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun closeWebSocket() {
|
||||||
|
try {
|
||||||
|
webSocket?.close(1000, "Done")
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
webSocket = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun shutdown() {
|
||||||
|
cancel()
|
||||||
|
scope.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "DashScopeSTT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- JSON DTOs for DashScope WebSocket protocol ---
|
||||||
|
|
||||||
|
data class DashScopeResponse(
|
||||||
|
@SerializedName("header") val header: DashScopeHeader?,
|
||||||
|
@SerializedName("payload") val payload: DashScopePayload?,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class DashScopeHeader(
|
||||||
|
@SerializedName("task_id") val taskId: String?,
|
||||||
|
@SerializedName("event") val event: String?,
|
||||||
|
@SerializedName("action") val action: String?,
|
||||||
|
@SerializedName("error_message") val errorMessage: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class DashScopePayload(
|
||||||
|
@SerializedName("output") val output: DashScopeOutput?,
|
||||||
|
@SerializedName("usage") val usage: Map<String, Any>?,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class DashScopeOutput(
|
||||||
|
@SerializedName("sentence") val sentence: DashScopeSentence?,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class DashScopeSentence(
|
||||||
|
@SerializedName("text") val text: String?,
|
||||||
|
@SerializedName("sentence_end") val sentenceEnd: Boolean?,
|
||||||
|
@SerializedName("sentence_id") val sentenceId: Int?,
|
||||||
|
@SerializedName("begin_time") val beginTime: Long?,
|
||||||
|
@SerializedName("end_time") val endTime: Long?,
|
||||||
|
@SerializedName("words") val words: List<Map<String, Any>>?,
|
||||||
|
@SerializedName("emo_tag") val emoTag: String?,
|
||||||
|
)
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package top.yeij.cyrene.voice.stt
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.speech.RecognitionListener
|
||||||
|
import android.speech.RecognizerIntent
|
||||||
|
import android.speech.SpeechRecognizer
|
||||||
|
import android.util.Log
|
||||||
|
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
|
||||||
|
|
||||||
|
class SpeechRecognizer(private val context: Context) : SttProvider {
|
||||||
|
|
||||||
|
private var recognizer: android.speech.SpeechRecognizer? = null
|
||||||
|
|
||||||
|
private val _isListening = MutableStateFlow(false)
|
||||||
|
override val isListening: StateFlow<Boolean> = _isListening.asStateFlow()
|
||||||
|
|
||||||
|
private val _partialResult = MutableStateFlow("")
|
||||||
|
override val partialResult: StateFlow<String> = _partialResult.asStateFlow()
|
||||||
|
|
||||||
|
private val _finalResult = MutableSharedFlow<SttResult>(extraBufferCapacity = 8)
|
||||||
|
override val finalResult: SharedFlow<SttResult> = _finalResult.asSharedFlow()
|
||||||
|
|
||||||
|
private val _onError = MutableSharedFlow<String>(extraBufferCapacity = 8)
|
||||||
|
override val onError: SharedFlow<String> = _onError.asSharedFlow()
|
||||||
|
|
||||||
|
override fun start() {
|
||||||
|
if (!android.speech.SpeechRecognizer.isRecognitionAvailable(context)) {
|
||||||
|
Log.w(TAG, "Speech recognition not available on this device")
|
||||||
|
_onError.tryEmit("语音识别不可用")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
recognizer = android.speech.SpeechRecognizer.createSpeechRecognizer(context).apply {
|
||||||
|
setRecognitionListener(object : RecognitionListener {
|
||||||
|
override fun onReadyForSpeech(params: Bundle?) {
|
||||||
|
_isListening.value = true
|
||||||
|
_partialResult.value = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBeginningOfSpeech() {}
|
||||||
|
|
||||||
|
override fun onRmsChanged(rmsdB: Float) {}
|
||||||
|
|
||||||
|
override fun onBufferReceived(buffer: ByteArray?) {}
|
||||||
|
|
||||||
|
override fun onEndOfSpeech() {}
|
||||||
|
|
||||||
|
override fun onError(error: Int) {
|
||||||
|
_isListening.value = false
|
||||||
|
val msg = when (error) {
|
||||||
|
android.speech.SpeechRecognizer.ERROR_NETWORK -> "网络连接失败"
|
||||||
|
android.speech.SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> "网络超时"
|
||||||
|
android.speech.SpeechRecognizer.ERROR_AUDIO -> "音频录制错误"
|
||||||
|
android.speech.SpeechRecognizer.ERROR_SERVER -> "服务器错误"
|
||||||
|
android.speech.SpeechRecognizer.ERROR_CLIENT -> "客户端错误"
|
||||||
|
android.speech.SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> "未检测到语音"
|
||||||
|
android.speech.SpeechRecognizer.ERROR_NO_MATCH -> "未能识别"
|
||||||
|
android.speech.SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> "语音引擎忙碌"
|
||||||
|
android.speech.SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> "缺少录音权限"
|
||||||
|
else -> "未知错误 ($error)"
|
||||||
|
}
|
||||||
|
Log.w(TAG, "Recognition error: $msg")
|
||||||
|
_onError.tryEmit(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResults(results: Bundle?) {
|
||||||
|
_isListening.value = false
|
||||||
|
val matches = results?.getStringArrayList(android.speech.SpeechRecognizer.RESULTS_RECOGNITION)
|
||||||
|
if (!matches.isNullOrEmpty()) {
|
||||||
|
_finalResult.tryEmit(SttResult(text = matches[0], isFinal = true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPartialResults(partialResults: Bundle?) {
|
||||||
|
val matches = partialResults?.getStringArrayList(android.speech.SpeechRecognizer.RESULTS_RECOGNITION)
|
||||||
|
if (!matches.isNullOrEmpty()) {
|
||||||
|
_partialResult.value = matches[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onEvent(eventType: Int, params: Bundle?) {}
|
||||||
|
})
|
||||||
|
|
||||||
|
val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
|
||||||
|
putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
|
||||||
|
putExtra(RecognizerIntent.EXTRA_LANGUAGE, "zh-CN")
|
||||||
|
putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true)
|
||||||
|
putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 1)
|
||||||
|
}
|
||||||
|
startListening(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun stop() {
|
||||||
|
recognizer?.stopListening()
|
||||||
|
_isListening.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun cancel() {
|
||||||
|
recognizer?.cancel()
|
||||||
|
recognizer?.destroy()
|
||||||
|
recognizer = null
|
||||||
|
_isListening.value = false
|
||||||
|
_partialResult.value = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "CyreneSTT"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package top.yeij.cyrene.voice.stt
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
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 kotlinx.coroutines.launch
|
||||||
|
import top.yeij.cyrene.util.RuntimeLog
|
||||||
|
|
||||||
|
class SttManager(
|
||||||
|
private val dashScopeService: DashScopeSttService,
|
||||||
|
private val backendProvider: BackendSttProvider,
|
||||||
|
private val systemRecognizer: SpeechRecognizer,
|
||||||
|
) {
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||||
|
|
||||||
|
private val _isListening = MutableStateFlow(false)
|
||||||
|
val isListening: StateFlow<Boolean> = _isListening.asStateFlow()
|
||||||
|
|
||||||
|
private val _partialResult = MutableStateFlow("")
|
||||||
|
val partialResult: StateFlow<String> = _partialResult.asStateFlow()
|
||||||
|
|
||||||
|
private val _finalResult = MutableSharedFlow<SttResult>(extraBufferCapacity = 16)
|
||||||
|
val finalResult: SharedFlow<SttResult> = _finalResult.asSharedFlow()
|
||||||
|
|
||||||
|
private val _onError = MutableSharedFlow<String>(extraBufferCapacity = 8)
|
||||||
|
val onError: SharedFlow<String> = _onError.asSharedFlow()
|
||||||
|
|
||||||
|
private var activeProvider: SttProvider? = null
|
||||||
|
private var dashScopeFailed = false
|
||||||
|
|
||||||
|
fun start() {
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
val provider = if (dashScopeService.apiKey.isNotBlank() && !dashScopeFailed) {
|
||||||
|
Log.d(TAG, "Using DashScope STT")
|
||||||
|
RuntimeLog.stt("start", "Using DashScope provider")
|
||||||
|
dashScopeService.also { dashScopeFailed = false }
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "Using Backend STT (fallback)")
|
||||||
|
RuntimeLog.stt("start", "Using Backend provider (fallback)")
|
||||||
|
backendProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
activeProvider = provider
|
||||||
|
_isListening.value = true
|
||||||
|
_partialResult.value = ""
|
||||||
|
collectFrom(provider)
|
||||||
|
provider.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
RuntimeLog.stt("stop", "Stopping STT")
|
||||||
|
activeProvider?.stop()
|
||||||
|
_isListening.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancel() {
|
||||||
|
RuntimeLog.stt("cancel", "Cancelling STT")
|
||||||
|
activeProvider?.cancel()
|
||||||
|
activeProvider = null
|
||||||
|
_isListening.value = false
|
||||||
|
_partialResult.value = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
fun configureDashScope(apiKey: String, endpoint: String, model: String) {
|
||||||
|
dashScopeService.apiKey = apiKey
|
||||||
|
dashScopeService.endpoint = endpoint
|
||||||
|
dashScopeService.model = model
|
||||||
|
dashScopeFailed = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateDashScopeApiKey(apiKey: String) {
|
||||||
|
dashScopeService.apiKey = apiKey
|
||||||
|
if (apiKey.isNotBlank()) dashScopeFailed = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun shutdown() {
|
||||||
|
cancel()
|
||||||
|
dashScopeService.shutdown()
|
||||||
|
backendProvider.shutdown()
|
||||||
|
scope.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun collectFrom(provider: SttProvider) {
|
||||||
|
scope.launch {
|
||||||
|
provider.partialResult.collect { text ->
|
||||||
|
if (provider == activeProvider && text.isNotEmpty()) {
|
||||||
|
_partialResult.value = text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scope.launch {
|
||||||
|
provider.finalResult.collect { result ->
|
||||||
|
if (provider == activeProvider) {
|
||||||
|
RuntimeLog.stt("result", "Final result: isFinal=${result.isFinal} text=${result.text.take(80)}")
|
||||||
|
_finalResult.tryEmit(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scope.launch {
|
||||||
|
provider.onError.collect { error ->
|
||||||
|
if (provider == activeProvider) {
|
||||||
|
Log.w(TAG, "STT error: $error")
|
||||||
|
RuntimeLog.stt("error", error)
|
||||||
|
_onError.tryEmit(error)
|
||||||
|
|
||||||
|
if (provider == dashScopeService) {
|
||||||
|
dashScopeFailed = true
|
||||||
|
RuntimeLog.stt("fallback", "DashScope failed, will use backend next time")
|
||||||
|
Log.i(TAG, "DashScope failed, will use backend next time")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "SttManager"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package top.yeij.cyrene.voice.stt
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
enum class SttProviderType { DASHSCOPE, BACKEND, SYSTEM }
|
||||||
|
|
||||||
|
data class SttResult(
|
||||||
|
val text: String,
|
||||||
|
val isFinal: Boolean,
|
||||||
|
val isError: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
interface SttProvider {
|
||||||
|
val partialResult: StateFlow<String>
|
||||||
|
val finalResult: SharedFlow<SttResult>
|
||||||
|
val onError: SharedFlow<String>
|
||||||
|
val isListening: StateFlow<Boolean>
|
||||||
|
|
||||||
|
fun start()
|
||||||
|
fun stop()
|
||||||
|
fun cancel()
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package top.yeij.cyrene.voice.tts
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.media.AudioAttributes
|
||||||
|
import android.media.AudioFocusRequest
|
||||||
|
import android.media.AudioManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.speech.tts.TextToSpeech
|
||||||
|
import android.speech.tts.UtteranceProgressListener
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
class TextToSpeechEngine(private val context: Context) {
|
||||||
|
|
||||||
|
private var tts: TextToSpeech? = null
|
||||||
|
private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||||
|
|
||||||
|
private val _isSpeaking = MutableStateFlow(false)
|
||||||
|
val isSpeaking = _isSpeaking.asStateFlow()
|
||||||
|
|
||||||
|
private val _onDone = MutableStateFlow(false)
|
||||||
|
val onDone = _onDone.asStateFlow()
|
||||||
|
|
||||||
|
fun initialize(onReady: () -> Unit) {
|
||||||
|
tts = TextToSpeech(context) { status ->
|
||||||
|
if (status == TextToSpeech.SUCCESS) {
|
||||||
|
tts?.language = Locale.CHINESE
|
||||||
|
tts?.setSpeechRate(0.9f)
|
||||||
|
tts?.setPitch(1.1f)
|
||||||
|
|
||||||
|
tts?.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
|
||||||
|
override fun onStart(utteranceId: String?) {
|
||||||
|
requestAudioFocus()
|
||||||
|
_isSpeaking.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDone(utteranceId: String?) {
|
||||||
|
_isSpeaking.value = false
|
||||||
|
_onDone.value = true
|
||||||
|
abandonAudioFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onError(utteranceId: String?) {
|
||||||
|
_isSpeaking.value = false
|
||||||
|
abandonAudioFocus()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
onReady()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun speak(text: String, utteranceId: String = System.currentTimeMillis().toString()) {
|
||||||
|
tts?.speak(text, TextToSpeech.QUEUE_FLUSH, null, utteranceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
tts?.stop()
|
||||||
|
_isSpeaking.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun shutdown() {
|
||||||
|
tts?.stop()
|
||||||
|
tts?.shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestAudioFocus() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE)
|
||||||
|
.setAudioAttributes(
|
||||||
|
AudioAttributes.Builder()
|
||||||
|
.setUsage(AudioAttributes.USAGE_ASSISTANT)
|
||||||
|
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
audioManager.requestAudioFocus(focusRequest)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
audioManager.requestAudioFocus(
|
||||||
|
null,
|
||||||
|
AudioManager.STREAM_MUSIC,
|
||||||
|
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun abandonAudioFocus() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
// Focus released with the request
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
audioManager.abandonAudioFocus(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
package top.yeij.cyrene.app
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import top.yeij.cyrene.core.auth.AuthManager
|
|
||||||
import top.yeij.cyrene.core.auth.SessionStore
|
|
||||||
import top.yeij.cyrene.core.prefs.AppSettings
|
|
||||||
import top.yeij.cyrene.core.prefs.SessionPreferences
|
|
||||||
import top.yeij.cyrene.data.api.AdminApi
|
|
||||||
import top.yeij.cyrene.data.api.ApiClientFactory
|
|
||||||
import top.yeij.cyrene.data.api.SessionsApi
|
|
||||||
import top.yeij.cyrene.data.repository.AdminRepository
|
|
||||||
import top.yeij.cyrene.data.repository.MessageRepository
|
|
||||||
import top.yeij.cyrene.data.repository.SessionRepository
|
|
||||||
import top.yeij.cyrene.data.ws.ChatSocket
|
|
||||||
|
|
||||||
/** 手写 DI 容器(D:不引 Hilt)。 */
|
|
||||||
class AppContainer(context: Context) {
|
|
||||||
|
|
||||||
val sessionStore: SessionStore = SessionStore(context.applicationContext)
|
|
||||||
val sessionPreferences: SessionPreferences = SessionPreferences(context.applicationContext)
|
|
||||||
val appSettings: AppSettings = AppSettings(context.applicationContext)
|
|
||||||
val authManager: AuthManager = AuthManager(sessionStore)
|
|
||||||
|
|
||||||
private var cachedBaseUrl: String? = null
|
|
||||||
private var cachedSessionsApi: SessionsApi? = null
|
|
||||||
|
|
||||||
/** 会话/消息 REST 客户端;baseUrl 变化时自动重建。 */
|
|
||||||
val sessionsApi: SessionsApi
|
|
||||||
get() {
|
|
||||||
val baseUrl = authManager.currentBaseUrl
|
|
||||||
val cached = cachedSessionsApi
|
|
||||||
if (cached != null && cachedBaseUrl == baseUrl) return cached
|
|
||||||
val created = ApiClientFactory.create(
|
|
||||||
baseUrl = baseUrl,
|
|
||||||
service = SessionsApi::class.java,
|
|
||||||
tokenProvider = { sessionStore.load().token },
|
|
||||||
onUnauthorized = { authManager.onSessionExpired() },
|
|
||||||
)
|
|
||||||
cachedBaseUrl = baseUrl
|
|
||||||
cachedSessionsApi = created
|
|
||||||
return created
|
|
||||||
}
|
|
||||||
|
|
||||||
val sessionRepository: SessionRepository get() = SessionRepository(sessionsApi)
|
|
||||||
|
|
||||||
val messageRepository: MessageRepository get() = MessageRepository(sessionsApi)
|
|
||||||
|
|
||||||
private var cachedAdminApi: AdminApi? = null
|
|
||||||
private var cachedAdminBaseUrl: String? = null
|
|
||||||
|
|
||||||
/** 管理只读监控客户端;与 [sessionsApi] 同源(同一 baseUrl、同一 token/401 处理)。 */
|
|
||||||
val adminApi: AdminApi
|
|
||||||
get() {
|
|
||||||
val baseUrl = authManager.currentBaseUrl
|
|
||||||
val cached = cachedAdminApi
|
|
||||||
if (cached != null && cachedAdminBaseUrl == baseUrl) return cached
|
|
||||||
val created = ApiClientFactory.create(
|
|
||||||
baseUrl = baseUrl,
|
|
||||||
service = AdminApi::class.java,
|
|
||||||
tokenProvider = { sessionStore.load().token },
|
|
||||||
onUnauthorized = { authManager.onSessionExpired() },
|
|
||||||
)
|
|
||||||
cachedAdminBaseUrl = baseUrl
|
|
||||||
cachedAdminApi = created
|
|
||||||
return created
|
|
||||||
}
|
|
||||||
|
|
||||||
val adminRepository: AdminRepository get() = AdminRepository(adminApi)
|
|
||||||
|
|
||||||
fun createChatSocket(): ChatSocket = ChatSocket(
|
|
||||||
baseUrlProvider = { authManager.currentBaseUrl },
|
|
||||||
tokenProvider = { sessionStore.load().token },
|
|
||||||
onAuthFailed = { authManager.onSessionExpired() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
package top.yeij.cyrene.app
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import top.yeij.cyrene.core.auth.SessionState
|
|
||||||
import top.yeij.cyrene.ui.chat.ChatHomeScreen
|
|
||||||
import top.yeij.cyrene.ui.common.LoadingScreen
|
|
||||||
import top.yeij.cyrene.ui.login.LoginScreen
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun CyreneApp(container: AppContainer) {
|
|
||||||
val state by container.authManager.state.collectAsState()
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
container.authManager.bootstrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
when (state) {
|
|
||||||
SessionState.Unknown -> LoadingScreen()
|
|
||||||
SessionState.LoggedOut -> LoginScreen(container)
|
|
||||||
is SessionState.LoggedIn -> ChatHomeScreen(
|
|
||||||
container = container,
|
|
||||||
onLogout = { container.authManager.logout() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
package top.yeij.cyrene.app
|
|
||||||
|
|
||||||
import android.app.Application
|
|
||||||
|
|
||||||
class CyreneApplication : Application() {
|
|
||||||
|
|
||||||
lateinit var container: AppContainer
|
|
||||||
private set
|
|
||||||
|
|
||||||
override fun onCreate() {
|
|
||||||
super.onCreate()
|
|
||||||
container = AppContainer(this)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
package top.yeij.cyrene.app
|
|
||||||
|
|
||||||
import android.app.Activity
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.ContextWrapper
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
|
||||||
import androidx.compose.runtime.SideEffect
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.ui.platform.LocalView
|
|
||||||
import androidx.core.view.WindowCompat
|
|
||||||
import top.yeij.cyrene.core.prefs.AppPrefs
|
|
||||||
import top.yeij.cyrene.core.prefs.LocalAppPrefs
|
|
||||||
import top.yeij.cyrene.core.prefs.ThemeMode
|
|
||||||
import top.yeij.cyrene.core.theme.CyreneTheme
|
|
||||||
import top.yukonga.miuix.kmp.theme.ColorSchemeMode
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 应用根:读 App 偏好 → 决定 Miuix 主题模式 → 通过 [LocalAppPrefs] 下发给渲染层,
|
|
||||||
* 同时把系统栏图标明暗跟着主题走(否则浅色模式下白字白底看不见)。
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun CyreneRoot(container: AppContainer) {
|
|
||||||
val prefs by container.appSettings.data.collectAsState(initial = AppPrefs())
|
|
||||||
val darkTheme = when (prefs.themeMode) {
|
|
||||||
ThemeMode.System -> isSystemInDarkTheme()
|
|
||||||
ThemeMode.Light -> false
|
|
||||||
ThemeMode.Dark -> true
|
|
||||||
}
|
|
||||||
|
|
||||||
val view = LocalView.current
|
|
||||||
if (!view.isInEditMode) {
|
|
||||||
SideEffect {
|
|
||||||
val window = view.context.findActivity()?.window ?: return@SideEffect
|
|
||||||
WindowCompat.getInsetsController(window, view).apply {
|
|
||||||
isAppearanceLightStatusBars = !darkTheme
|
|
||||||
isAppearanceLightNavigationBars = !darkTheme
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
CompositionLocalProvider(LocalAppPrefs provides prefs) {
|
|
||||||
CyreneTheme(mode = prefs.themeMode.toColorSchemeMode()) {
|
|
||||||
CyreneApp(container)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun ThemeMode.toColorSchemeMode(): ColorSchemeMode = when (this) {
|
|
||||||
ThemeMode.System -> ColorSchemeMode.System
|
|
||||||
ThemeMode.Light -> ColorSchemeMode.Light
|
|
||||||
ThemeMode.Dark -> ColorSchemeMode.Dark
|
|
||||||
}
|
|
||||||
|
|
||||||
private tailrec fun Context.findActivity(): Activity? = when (this) {
|
|
||||||
is Activity -> this
|
|
||||||
is ContextWrapper -> baseContext.findActivity()
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
package top.yeij.cyrene.app
|
|
||||||
|
|
||||||
import android.os.Bundle
|
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
import androidx.activity.compose.setContent
|
|
||||||
import androidx.activity.enableEdgeToEdge
|
|
||||||
class MainActivity : ComponentActivity() {
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
enableEdgeToEdge()
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
val container = (application as CyreneApplication).container
|
|
||||||
setContent {
|
|
||||||
CyreneRoot(container)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
package top.yeij.cyrene.core.auth
|
|
||||||
|
|
||||||
import top.yeij.cyrene.core.error.CyreneError
|
|
||||||
import top.yeij.cyrene.data.api.ApiClientFactory
|
|
||||||
import top.yeij.cyrene.data.api.ApiErrorEnvelope
|
|
||||||
import top.yeij.cyrene.data.api.CyreneApi
|
|
||||||
import top.yeij.cyrene.data.api.dto.LoginRequest
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import java.io.IOException
|
|
||||||
import java.net.ConnectException
|
|
||||||
import java.net.SocketTimeoutException
|
|
||||||
import java.net.UnknownHostException
|
|
||||||
import javax.net.ssl.SSLException
|
|
||||||
|
|
||||||
sealed interface SessionState {
|
|
||||||
data object Unknown : SessionState
|
|
||||||
data object LoggedOut : SessionState
|
|
||||||
data class LoggedIn(val baseUrl: String, val role: String) : SessionState
|
|
||||||
}
|
|
||||||
|
|
||||||
class AuthManager(private val store: SessionStore) {
|
|
||||||
|
|
||||||
private val _state = MutableStateFlow<SessionState>(SessionState.Unknown)
|
|
||||||
val state: StateFlow<SessionState> = _state.asStateFlow()
|
|
||||||
|
|
||||||
private var api: CyreneApi? = null
|
|
||||||
var currentBaseUrl: String = ""
|
|
||||||
private set
|
|
||||||
|
|
||||||
/** 启动时:有效 token 直接进入;过期则用已存凭据静默重登(D12);否则回登录页。 */
|
|
||||||
suspend fun bootstrap() = withContext(Dispatchers.IO) {
|
|
||||||
val stored = store.load()
|
|
||||||
currentBaseUrl = stored.baseUrl
|
|
||||||
when {
|
|
||||||
!stored.token.isNullOrBlank() && stored.expiresAt * 1000 > System.currentTimeMillis() -> {
|
|
||||||
_state.value = SessionState.LoggedIn(stored.baseUrl, stored.role.orEmpty())
|
|
||||||
}
|
|
||||||
|
|
||||||
!stored.username.isNullOrBlank() && !stored.password.isNullOrBlank() -> {
|
|
||||||
val error = doLogin(stored.baseUrl, stored.username, stored.password)
|
|
||||||
if (error != null) _state.value = SessionState.LoggedOut
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> _state.value = SessionState.LoggedOut
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun login(rawBaseUrl: String, username: String, password: String): CyreneError? =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
doLogin(ApiClientFactory.normalizeBaseUrl(rawBaseUrl), username, password)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun logout() {
|
|
||||||
store.clearAll()
|
|
||||||
api = null
|
|
||||||
_state.value = SessionState.LoggedOut
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 会话过期(401):清 token、回登录页;凭据保留以便下次静默重登。 */
|
|
||||||
fun onSessionExpired() {
|
|
||||||
store.clearSession()
|
|
||||||
api = null
|
|
||||||
_state.value = SessionState.LoggedOut
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun doLogin(url: String, username: String, password: String): CyreneError? {
|
|
||||||
val client = ApiClientFactory.create(baseUrl = url)
|
|
||||||
return try {
|
|
||||||
val response = client.login(LoginRequest(username, password))
|
|
||||||
val data = response.body()?.data
|
|
||||||
when {
|
|
||||||
response.isSuccessful && data != null -> {
|
|
||||||
store.saveLogin(url, data.token, data.expiresAt, data.role, username, password)
|
|
||||||
api = client
|
|
||||||
currentBaseUrl = url
|
|
||||||
_state.value = SessionState.LoggedIn(url, data.role)
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
response.isSuccessful -> CyreneError.Local(CyreneError.LocalKind.Parse)
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
val parsed = parseError(response.errorBody()?.string())
|
|
||||||
if (response.code() == 401) {
|
|
||||||
CyreneError.Unauthorized(parsed?.code.orEmpty(), parsed?.message.orEmpty())
|
|
||||||
} else {
|
|
||||||
CyreneError.Server(
|
|
||||||
code = parsed?.code.orEmpty(),
|
|
||||||
message = parsed?.message.orEmpty(),
|
|
||||||
httpStatus = response.code(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
mapException(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseError(body: String?) = body
|
|
||||||
?.takeIf { it.isNotBlank() }
|
|
||||||
?.let {
|
|
||||||
runCatching {
|
|
||||||
ApiClientFactory.json.decodeFromString(ApiErrorEnvelope.serializer(), it).error
|
|
||||||
}.getOrNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun mapException(e: Throwable): CyreneError = when (e) {
|
|
||||||
is UnknownHostException -> CyreneError.Local(CyreneError.LocalKind.Dns)
|
|
||||||
is SSLException -> CyreneError.Local(CyreneError.LocalKind.Tls)
|
|
||||||
is SocketTimeoutException -> CyreneError.Local(CyreneError.LocalKind.Timeout)
|
|
||||||
is ConnectException -> CyreneError.Local(CyreneError.LocalKind.ConnectFailed)
|
|
||||||
is java.net.UnknownServiceException -> CyreneError.Local(CyreneError.LocalKind.CleartextBlocked)
|
|
||||||
is IOException -> CyreneError.Local(CyreneError.LocalKind.NoNetwork)
|
|
||||||
else -> CyreneError.Local(CyreneError.LocalKind.Unknown)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
package top.yeij.cyrene.core.auth
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
import androidx.security.crypto.EncryptedSharedPreferences
|
|
||||||
import androidx.security.crypto.MasterKey
|
|
||||||
|
|
||||||
data class StoredSession(
|
|
||||||
val baseUrl: String,
|
|
||||||
val token: String?,
|
|
||||||
val expiresAt: Long,
|
|
||||||
val role: String?,
|
|
||||||
val username: String?,
|
|
||||||
val password: String?,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** JWT 与登录凭据的加密存储(红线:不落明文、不打日志)。 */
|
|
||||||
class SessionStore(context: Context) {
|
|
||||||
|
|
||||||
private val prefs: SharedPreferences = EncryptedSharedPreferences.create(
|
|
||||||
context,
|
|
||||||
FILE_NAME,
|
|
||||||
MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(),
|
|
||||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
|
||||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun load(): StoredSession = StoredSession(
|
|
||||||
baseUrl = prefs.getString(KEY_BASE_URL, "").orEmpty(),
|
|
||||||
token = prefs.getString(KEY_TOKEN, null),
|
|
||||||
expiresAt = prefs.getLong(KEY_EXPIRES_AT, 0L),
|
|
||||||
role = prefs.getString(KEY_ROLE, null),
|
|
||||||
username = prefs.getString(KEY_USERNAME, null),
|
|
||||||
password = prefs.getString(KEY_PASSWORD, null),
|
|
||||||
)
|
|
||||||
|
|
||||||
fun saveLogin(
|
|
||||||
baseUrl: String,
|
|
||||||
token: String,
|
|
||||||
expiresAt: Long,
|
|
||||||
role: String,
|
|
||||||
username: String,
|
|
||||||
password: String,
|
|
||||||
) {
|
|
||||||
prefs.edit()
|
|
||||||
.putString(KEY_BASE_URL, baseUrl)
|
|
||||||
.putString(KEY_TOKEN, token)
|
|
||||||
.putLong(KEY_EXPIRES_AT, expiresAt)
|
|
||||||
.putString(KEY_ROLE, role)
|
|
||||||
.putString(KEY_USERNAME, username)
|
|
||||||
.putString(KEY_PASSWORD, password)
|
|
||||||
.apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 会话失效(401/过期):清 token,但保留凭据以便静默重登(D12)。 */
|
|
||||||
fun clearSession() {
|
|
||||||
prefs.edit()
|
|
||||||
.remove(KEY_TOKEN)
|
|
||||||
.remove(KEY_EXPIRES_AT)
|
|
||||||
.remove(KEY_ROLE)
|
|
||||||
.apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 主动登出:凭据一并清掉,避免下次启动被静默登回去。 */
|
|
||||||
fun clearAll() {
|
|
||||||
prefs.edit().clear().apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val FILE_NAME = "cyrene_session"
|
|
||||||
const val KEY_BASE_URL = "base_url"
|
|
||||||
const val KEY_TOKEN = "token"
|
|
||||||
const val KEY_EXPIRES_AT = "expires_at"
|
|
||||||
const val KEY_ROLE = "role"
|
|
||||||
const val KEY_USERNAME = "username"
|
|
||||||
const val KEY_PASSWORD = "password"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
package top.yeij.cyrene.core.error
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 统一错误模型:区分「服务端有响应」与「本地/网络层失败」。
|
|
||||||
* 约定(S1.1):已建立连接时用服务端 `error.message`,本地错误由客户端负责文案。
|
|
||||||
*/
|
|
||||||
sealed interface CyreneError {
|
|
||||||
val userMessage: String
|
|
||||||
|
|
||||||
data class Server(
|
|
||||||
val code: String,
|
|
||||||
val message: String,
|
|
||||||
val httpStatus: Int,
|
|
||||||
) : CyreneError {
|
|
||||||
override val userMessage: String
|
|
||||||
get() = message.ifBlank { "服务端错误(HTTP $httpStatus)" }
|
|
||||||
}
|
|
||||||
|
|
||||||
data class Unauthorized(
|
|
||||||
val code: String,
|
|
||||||
val message: String,
|
|
||||||
) : CyreneError {
|
|
||||||
override val userMessage: String
|
|
||||||
get() = message.ifBlank { "用户名或密码不正确" }
|
|
||||||
}
|
|
||||||
|
|
||||||
data class Local(val kind: LocalKind) : CyreneError {
|
|
||||||
override val userMessage: String
|
|
||||||
get() = when (kind) {
|
|
||||||
LocalKind.InvalidUrl -> "服务器地址格式不对,示例:http://192.168.1.10:8080"
|
|
||||||
LocalKind.NoNetwork -> "网络不可用,请检查连接"
|
|
||||||
LocalKind.Dns -> "找不到服务器,请检查服务器地址"
|
|
||||||
LocalKind.Tls -> "证书校验失败(自签证书暂不支持)"
|
|
||||||
LocalKind.Timeout -> "连接超时,服务器没有响应"
|
|
||||||
LocalKind.ConnectFailed -> "无法连接到服务器"
|
|
||||||
LocalKind.CleartextBlocked -> "系统禁止明文 HTTP 访问"
|
|
||||||
LocalKind.Parse -> "服务器返回的数据无法识别"
|
|
||||||
LocalKind.Unknown -> "发生未知错误"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum class LocalKind {
|
|
||||||
InvalidUrl,
|
|
||||||
NoNetwork,
|
|
||||||
Dns,
|
|
||||||
Tls,
|
|
||||||
Timeout,
|
|
||||||
ConnectFailed,
|
|
||||||
CleartextBlocked,
|
|
||||||
Parse,
|
|
||||||
Unknown,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
package top.yeij.cyrene.core.prefs
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.compose.runtime.staticCompositionLocalOf
|
|
||||||
import androidx.datastore.core.DataStore
|
|
||||||
import androidx.datastore.preferences.core.Preferences
|
|
||||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
|
||||||
import androidx.datastore.preferences.core.edit
|
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
|
||||||
import androidx.datastore.preferences.preferencesDataStore
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
|
|
||||||
private val Context.appDataStore: DataStore<Preferences> by preferencesDataStore("cyrene_app")
|
|
||||||
|
|
||||||
enum class ThemeMode { System, Light, Dark }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* App 级偏好(非机密)。
|
|
||||||
*
|
|
||||||
* @param themeMode 主题深浅
|
|
||||||
* @param showNarration 是否显示 `<action>` / `<narration>` 旁白
|
|
||||||
* @param thinkingExpanded 思维链卡片默认是否展开
|
|
||||||
*/
|
|
||||||
data class AppPrefs(
|
|
||||||
val themeMode: ThemeMode = ThemeMode.System,
|
|
||||||
val showNarration: Boolean = true,
|
|
||||||
val thinkingExpanded: Boolean = true,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** 全局注入 App 偏好,供聊天渲染等深层组件读取。 */
|
|
||||||
val LocalAppPrefs = staticCompositionLocalOf { AppPrefs() }
|
|
||||||
|
|
||||||
class AppSettings(context: Context) {
|
|
||||||
|
|
||||||
private val store: DataStore<Preferences> = context.applicationContext.appDataStore
|
|
||||||
|
|
||||||
val data: Flow<AppPrefs> = store.data.map { prefs ->
|
|
||||||
AppPrefs(
|
|
||||||
themeMode = prefs[KEY_THEME_MODE].toThemeMode(),
|
|
||||||
showNarration = prefs[KEY_SHOW_NARRATION] ?: true,
|
|
||||||
thinkingExpanded = prefs[KEY_THINKING_EXPANDED] ?: true,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun setThemeMode(mode: ThemeMode) {
|
|
||||||
store.edit { it[KEY_THEME_MODE] = mode.name }
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun setShowNarration(enabled: Boolean) {
|
|
||||||
store.edit { it[KEY_SHOW_NARRATION] = enabled }
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun setThinkingExpanded(enabled: Boolean) {
|
|
||||||
store.edit { it[KEY_THINKING_EXPANDED] = enabled }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun String?.toThemeMode(): ThemeMode = when (this) {
|
|
||||||
ThemeMode.Light.name -> ThemeMode.Light
|
|
||||||
ThemeMode.Dark.name -> ThemeMode.Dark
|
|
||||||
else -> ThemeMode.System
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
val KEY_THEME_MODE = stringPreferencesKey("theme_mode")
|
|
||||||
val KEY_SHOW_NARRATION = booleanPreferencesKey("show_narration")
|
|
||||||
val KEY_THINKING_EXPANDED = booleanPreferencesKey("thinking_expanded")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
package top.yeij.cyrene.core.prefs
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.datastore.core.DataStore
|
|
||||||
import androidx.datastore.preferences.core.Preferences
|
|
||||||
import androidx.datastore.preferences.core.edit
|
|
||||||
import androidx.datastore.preferences.core.longPreferencesKey
|
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
|
||||||
import androidx.datastore.preferences.preferencesDataStore
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.serialization.builtins.ListSerializer
|
|
||||||
import kotlinx.serialization.builtins.MapSerializer
|
|
||||||
import kotlinx.serialization.builtins.serializer
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import top.yeij.cyrene.data.api.dto.SessionSummaryDto
|
|
||||||
|
|
||||||
private val Context.sessionDataStore: DataStore<Preferences> by preferencesDataStore("cyrene_sessions")
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 会话列表的本地回退快照与显示态(§3.1)。
|
|
||||||
*
|
|
||||||
* @param snapshot 上次成功拉取的会话列表;服务端不可用时用它先渲染
|
|
||||||
* @param snapshotAt 快照时间(用于「同步于 N 分钟前」)
|
|
||||||
* @param fingerprint `baseUrl + username`;换服务器/账号后快照作废
|
|
||||||
* @param order `sessionId -> 显示序号`(后端 title 恒空,序号本地生成)
|
|
||||||
* @param activeSessionId 上次停留的会话
|
|
||||||
*/
|
|
||||||
data class SessionPrefs(
|
|
||||||
val snapshot: List<SessionSummaryDto> = emptyList(),
|
|
||||||
val snapshotAt: Long = 0L,
|
|
||||||
val fingerprint: String = "",
|
|
||||||
val order: Map<String, Int> = emptyMap(),
|
|
||||||
val activeSessionId: String = SessionSummaryDto.MAIN_SESSION_ID,
|
|
||||||
)
|
|
||||||
|
|
||||||
class SessionPreferences(context: Context) {
|
|
||||||
|
|
||||||
private val json = Json {
|
|
||||||
ignoreUnknownKeys = true
|
|
||||||
explicitNulls = false
|
|
||||||
}
|
|
||||||
|
|
||||||
private val store: DataStore<Preferences> = context.applicationContext.sessionDataStore
|
|
||||||
|
|
||||||
val data: Flow<SessionPrefs> = store.data.map { prefs ->
|
|
||||||
SessionPrefs(
|
|
||||||
snapshot = prefs[KEY_SNAPSHOT].decodeSessions(),
|
|
||||||
snapshotAt = prefs[KEY_SNAPSHOT_AT] ?: 0L,
|
|
||||||
fingerprint = prefs[KEY_FINGERPRINT].orEmpty(),
|
|
||||||
order = prefs[KEY_ORDER].decodeOrder(),
|
|
||||||
activeSessionId = prefs[KEY_ACTIVE] ?: SessionSummaryDto.MAIN_SESSION_ID,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 拉取成功:整体替换快照(不做 merge,避免已删除的会话变成幽灵),
|
|
||||||
* 同时为新出现的次级会话分配显示序号、清理已消失会话的序号。
|
|
||||||
*/
|
|
||||||
suspend fun saveSnapshot(sessions: List<SessionSummaryDto>, fingerprint: String) {
|
|
||||||
store.edit { prefs ->
|
|
||||||
val sameAccount = prefs[KEY_FINGERPRINT].orEmpty() == fingerprint
|
|
||||||
val order = if (sameAccount) prefs[KEY_ORDER].decodeOrder().toMutableMap() else mutableMapOf()
|
|
||||||
var next = order.values.maxOrNull() ?: 0
|
|
||||||
sessions.filterNot { it.isMain }.forEach { dto ->
|
|
||||||
if (order[dto.id] == null) order[dto.id] = ++next
|
|
||||||
}
|
|
||||||
order.keys.retainAll(sessions.map { it.id }.toSet())
|
|
||||||
|
|
||||||
prefs[KEY_SNAPSHOT] = json.encodeToString(SessionsSerializer, sessions)
|
|
||||||
prefs[KEY_SNAPSHOT_AT] = System.currentTimeMillis()
|
|
||||||
prefs[KEY_FINGERPRINT] = fingerprint
|
|
||||||
prefs[KEY_ORDER] = json.encodeToString(OrderSerializer, order)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun setActive(sessionId: String) {
|
|
||||||
store.edit { prefs -> prefs[KEY_ACTIVE] = sessionId }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 分配显示序号(当前最大值 + 1);已分配过则原样返回。 */
|
|
||||||
suspend fun ensureOrder(sessionId: String): Int {
|
|
||||||
var assigned = 0
|
|
||||||
store.edit { prefs ->
|
|
||||||
val order = prefs[KEY_ORDER].decodeOrder().toMutableMap()
|
|
||||||
val existing = order[sessionId]
|
|
||||||
if (existing != null) {
|
|
||||||
assigned = existing
|
|
||||||
} else {
|
|
||||||
assigned = (order.values.maxOrNull() ?: 0) + 1
|
|
||||||
order[sessionId] = assigned
|
|
||||||
prefs[KEY_ORDER] = json.encodeToString(OrderSerializer, order)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return assigned
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 删除会话:清序号、必要时把 active 退回主会话、同步从快照里剔除。 */
|
|
||||||
suspend fun removeSession(sessionId: String) {
|
|
||||||
store.edit { prefs ->
|
|
||||||
val order = prefs[KEY_ORDER].decodeOrder().toMutableMap()
|
|
||||||
order.remove(sessionId)
|
|
||||||
prefs[KEY_ORDER] = json.encodeToString(OrderSerializer, order)
|
|
||||||
|
|
||||||
if (prefs[KEY_ACTIVE] == sessionId) {
|
|
||||||
prefs[KEY_ACTIVE] = SessionSummaryDto.MAIN_SESSION_ID
|
|
||||||
}
|
|
||||||
|
|
||||||
val remaining = prefs[KEY_SNAPSHOT].decodeSessions().filterNot { it.id == sessionId }
|
|
||||||
prefs[KEY_SNAPSHOT] = json.encodeToString(SessionsSerializer, remaining)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 登出 / 切换服务器时清空。 */
|
|
||||||
suspend fun clear() {
|
|
||||||
store.edit { it.clear() }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun String?.decodeSessions(): List<SessionSummaryDto> = this
|
|
||||||
?.let { runCatching { json.decodeFromString(SessionsSerializer, it) }.getOrNull() }
|
|
||||||
.orEmpty()
|
|
||||||
|
|
||||||
private fun String?.decodeOrder(): Map<String, Int> = this
|
|
||||||
?.let { runCatching { json.decodeFromString(OrderSerializer, it) }.getOrNull() }
|
|
||||||
.orEmpty()
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
val KEY_SNAPSHOT = stringPreferencesKey("sessions_snapshot")
|
|
||||||
val KEY_SNAPSHOT_AT = longPreferencesKey("sessions_snapshot_at")
|
|
||||||
val KEY_FINGERPRINT = stringPreferencesKey("sessions_fingerprint")
|
|
||||||
val KEY_ORDER = stringPreferencesKey("sessions_order")
|
|
||||||
val KEY_ACTIVE = stringPreferencesKey("active_session_id")
|
|
||||||
|
|
||||||
val SessionsSerializer = ListSerializer(SessionSummaryDto.serializer())
|
|
||||||
val OrderSerializer = MapSerializer(String.serializer(), Int.serializer())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
package top.yeij.cyrene.core.theme
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import top.yukonga.miuix.kmp.theme.ColorSchemeMode
|
|
||||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
|
||||||
import top.yukonga.miuix.kmp.theme.ThemeController
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun CyreneTheme(
|
|
||||||
mode: ColorSchemeMode = ColorSchemeMode.System,
|
|
||||||
content: @Composable () -> Unit,
|
|
||||||
) {
|
|
||||||
val controller = remember(mode) { ThemeController(mode) }
|
|
||||||
MiuixTheme(controller = controller, content = content)
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
package top.yeij.cyrene.data.api
|
|
||||||
|
|
||||||
import top.yeij.cyrene.data.api.dto.AdminBackthinkItemDto
|
|
||||||
import top.yeij.cyrene.data.api.dto.AdminMetricsDto
|
|
||||||
import top.yeij.cyrene.data.api.dto.AdminTraceSpanDto
|
|
||||||
import top.yeij.cyrene.data.api.dto.MemorySummaryDto
|
|
||||||
import top.yeij.cyrene.data.api.dto.PersonaSummaryDto
|
|
||||||
import retrofit2.Response
|
|
||||||
import retrofit2.http.GET
|
|
||||||
import retrofit2.http.Query
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 管理只读监控端点(api-client §2,全部 `GET`,无写操作)。
|
|
||||||
*
|
|
||||||
* RBAC 由服务端兜底(`viewer` 对 ②③ 档一律 403),客户端按 §2.0 权限矩阵**自行降级**,
|
|
||||||
* 见 [top.yeij.cyrene.domain.admin.AdminAccess];这里只负责取数,不做权限判断。
|
|
||||||
*/
|
|
||||||
interface AdminApi {
|
|
||||||
|
|
||||||
/** 网关请求指标;`qps` 为近 1 秒窗口计数。 */
|
|
||||||
@GET("api/v1/admin/observability/metrics")
|
|
||||||
suspend fun metrics(): Response<ApiEnvelope<AdminMetricsDto>>
|
|
||||||
|
|
||||||
/** 最近 trace span,服务端按时间倒序(最新在前);`limit` 缺省 50、上限 500。 */
|
|
||||||
@GET("api/v1/admin/observability/trace")
|
|
||||||
suspend fun trace(@Query("limit") limit: Int? = null): Response<ApiEnvelope<List<AdminTraceSpanDto>>>
|
|
||||||
|
|
||||||
/** 人格列表(含 admin 专属 `thinking`)。 */
|
|
||||||
@GET("api/v1/admin/personas")
|
|
||||||
suspend fun personas(): Response<ApiEnvelope<List<PersonaSummaryDto>>>
|
|
||||||
|
|
||||||
/** 私人记忆正文;`viewer` 会拿到 403。 */
|
|
||||||
@GET("api/v1/admin/memories")
|
|
||||||
suspend fun memories(): Response<ApiEnvelope<List<MemorySummaryDto>>>
|
|
||||||
|
|
||||||
/** 后台思考记录;`viewer` 会拿到 403。 */
|
|
||||||
@GET("api/v1/admin/backthink")
|
|
||||||
suspend fun backthink(): Response<ApiEnvelope<List<AdminBackthinkItemDto>>>
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
package top.yeij.cyrene.data.api
|
|
||||||
|
|
||||||
import top.yeij.cyrene.BuildConfig
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import okhttp3.Interceptor
|
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import okhttp3.Response
|
|
||||||
import okhttp3.logging.HttpLoggingInterceptor
|
|
||||||
import retrofit2.Retrofit
|
|
||||||
import retrofit2.converter.kotlinx.serialization.asConverterFactory
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
|
|
||||||
object ApiClientFactory {
|
|
||||||
|
|
||||||
val json: Json = Json {
|
|
||||||
ignoreUnknownKeys = true
|
|
||||||
explicitNulls = false
|
|
||||||
coerceInputValues = true
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 登录接口(不注入 token)。 */
|
|
||||||
fun create(
|
|
||||||
baseUrl: String,
|
|
||||||
tokenProvider: () -> String? = { null },
|
|
||||||
onUnauthorized: () -> Unit = {},
|
|
||||||
): CyreneApi = create(baseUrl, CyreneApi::class.java, tokenProvider, onUnauthorized)
|
|
||||||
|
|
||||||
/** 通用入口:按需构建任意 Retrofit service。 */
|
|
||||||
fun <T : Any> create(
|
|
||||||
baseUrl: String,
|
|
||||||
service: Class<T>,
|
|
||||||
tokenProvider: () -> String? = { null },
|
|
||||||
onUnauthorized: () -> Unit = {},
|
|
||||||
): T {
|
|
||||||
val client = OkHttpClient.Builder()
|
|
||||||
.connectTimeout(10, TimeUnit.SECONDS)
|
|
||||||
.readTimeout(60, TimeUnit.SECONDS)
|
|
||||||
.writeTimeout(60, TimeUnit.SECONDS)
|
|
||||||
.addInterceptor(HeaderInterceptor(tokenProvider, onUnauthorized))
|
|
||||||
.apply {
|
|
||||||
if (BuildConfig.DEBUG) {
|
|
||||||
addInterceptor(
|
|
||||||
HttpLoggingInterceptor().apply {
|
|
||||||
level = HttpLoggingInterceptor.Level.BASIC
|
|
||||||
redactHeader("Authorization")
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.build()
|
|
||||||
|
|
||||||
return Retrofit.Builder()
|
|
||||||
.baseUrl(normalizeBaseUrl(baseUrl))
|
|
||||||
.client(client)
|
|
||||||
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
|
|
||||||
.build()
|
|
||||||
.create(service)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Retrofit 要求 baseUrl 以 `/` 结尾;用户没写 scheme 时补 `http://`。 */
|
|
||||||
fun normalizeBaseUrl(raw: String): String {
|
|
||||||
var url = raw.trim()
|
|
||||||
if (url.isEmpty()) return url
|
|
||||||
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
|
||||||
url = "http://$url"
|
|
||||||
}
|
|
||||||
if (!url.endsWith("/")) url += "/"
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
|
|
||||||
private class HeaderInterceptor(
|
|
||||||
private val tokenProvider: () -> String?,
|
|
||||||
private val onUnauthorized: () -> Unit,
|
|
||||||
) : Interceptor {
|
|
||||||
override fun intercept(chain: Interceptor.Chain): Response {
|
|
||||||
val token = tokenProvider()
|
|
||||||
val request = chain.request().newBuilder()
|
|
||||||
.header("X-API-Version", "1")
|
|
||||||
.apply { if (token != null) header("Authorization", "Bearer $token") }
|
|
||||||
.build()
|
|
||||||
val response = chain.proceed(request)
|
|
||||||
// 只有「带了 token 仍 401」才算会话过期;登录请求不带 token,不受影响。
|
|
||||||
if (response.code == 401 && token != null) {
|
|
||||||
onUnauthorized()
|
|
||||||
}
|
|
||||||
return response
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
package top.yeij.cyrene.data.api
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
/** 后端统一信封:成功 `{data}`,失败 `{error:{code,message}}`。 */
|
|
||||||
@Serializable
|
|
||||||
data class ApiEnvelope<T>(
|
|
||||||
val data: T? = null,
|
|
||||||
val error: ApiError? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class ApiErrorEnvelope(
|
|
||||||
val error: ApiError? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class ApiError(
|
|
||||||
val code: String = "",
|
|
||||||
val message: String = "",
|
|
||||||
)
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
package top.yeij.cyrene.data.api
|
|
||||||
|
|
||||||
import top.yeij.cyrene.core.error.CyreneError
|
|
||||||
import kotlinx.coroutines.CancellationException
|
|
||||||
import kotlinx.serialization.SerializationException
|
|
||||||
import retrofit2.Response
|
|
||||||
import java.io.IOException
|
|
||||||
import java.net.ConnectException
|
|
||||||
import java.net.SocketTimeoutException
|
|
||||||
import java.net.UnknownHostException
|
|
||||||
import javax.net.ssl.SSLException
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 统一错误映射(S1.1):已建连接用服务端 `error`,本地异常按类型分类。
|
|
||||||
* 供所有 REST 仓库复用;不要在各个仓库里各写一份。
|
|
||||||
*/
|
|
||||||
object ApiErrorMapper {
|
|
||||||
|
|
||||||
/** 有 HTTP 响应:401 → [CyreneError.Unauthorized],其余 → [CyreneError.Server]。 */
|
|
||||||
fun fromHttp(httpStatus: Int, errorBody: String?): CyreneError {
|
|
||||||
val error = parseErrorEnvelope(errorBody)
|
|
||||||
val code = error?.code.orEmpty()
|
|
||||||
val message = error?.message.orEmpty()
|
|
||||||
return if (httpStatus == 401) {
|
|
||||||
CyreneError.Unauthorized(code = code, message = message)
|
|
||||||
} else {
|
|
||||||
CyreneError.Server(code = code, message = message, httpStatus = httpStatus)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 本地/网络异常 → [CyreneError.Local]。 */
|
|
||||||
fun fromThrowable(throwable: Throwable): CyreneError = when (throwable) {
|
|
||||||
is UnknownHostException -> CyreneError.Local(CyreneError.LocalKind.Dns)
|
|
||||||
is SSLException -> CyreneError.Local(CyreneError.LocalKind.Tls)
|
|
||||||
is SocketTimeoutException -> CyreneError.Local(CyreneError.LocalKind.Timeout)
|
|
||||||
is ConnectException -> CyreneError.Local(CyreneError.LocalKind.ConnectFailed)
|
|
||||||
is SerializationException -> CyreneError.Local(CyreneError.LocalKind.Parse)
|
|
||||||
is IOException -> CyreneError.Local(CyreneError.LocalKind.NoNetwork)
|
|
||||||
else -> CyreneError.Local(CyreneError.LocalKind.Unknown)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 解析 `{"error":{code,message}}`;非信封 / 空白 / 解析失败均返回 null。 */
|
|
||||||
fun parseErrorEnvelope(errorBody: String?): ApiError? = errorBody
|
|
||||||
?.takeIf { it.isNotBlank() }
|
|
||||||
?.let {
|
|
||||||
runCatching {
|
|
||||||
ApiClientFactory.json.decodeFromString(ApiErrorEnvelope.serializer(), it).error
|
|
||||||
}.getOrNull()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 把 [CyreneError] 塞进 Kotlin `Result` 的载体,便于 `Result<T>` 统一传递失败。 */
|
|
||||||
class CyreneException(val error: CyreneError) : Exception(error.userMessage)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 执行一次信封调用并把结果收敛成 `Result<T>`:
|
|
||||||
* 成功且 `data != null` → success;成功但 `data == null` → Parse;否则走 [ApiErrorMapper]。
|
|
||||||
*/
|
|
||||||
suspend fun <T : Any> apiCall(block: suspend () -> Response<ApiEnvelope<T>>): Result<T> =
|
|
||||||
try {
|
|
||||||
val response = block()
|
|
||||||
val data = response.body()?.data
|
|
||||||
when {
|
|
||||||
response.isSuccessful && data != null -> Result.success(data)
|
|
||||||
response.isSuccessful -> Result.failure(CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)))
|
|
||||||
else -> Result.failure(
|
|
||||||
CyreneException(
|
|
||||||
ApiErrorMapper.fromHttp(response.code(), response.errorBody()?.string()),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (e: CancellationException) {
|
|
||||||
throw e
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Result.failure(CyreneException(ApiErrorMapper.fromThrowable(e)))
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
package top.yeij.cyrene.data.api
|
|
||||||
|
|
||||||
import top.yeij.cyrene.data.api.dto.LoginRequest
|
|
||||||
import top.yeij.cyrene.data.api.dto.LoginResponse
|
|
||||||
import retrofit2.Response
|
|
||||||
import retrofit2.http.Body
|
|
||||||
import retrofit2.http.POST
|
|
||||||
|
|
||||||
interface CyreneApi {
|
|
||||||
|
|
||||||
@POST("api/v1/auth/login")
|
|
||||||
suspend fun login(@Body body: LoginRequest): Response<ApiEnvelope<LoginResponse>>
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
package top.yeij.cyrene.data.api
|
|
||||||
|
|
||||||
import top.yeij.cyrene.data.api.dto.CreateSessionResponse
|
|
||||||
import top.yeij.cyrene.data.api.dto.DeleteSessionResponse
|
|
||||||
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
|
||||||
import top.yeij.cyrene.data.api.dto.SessionSummaryDto
|
|
||||||
import retrofit2.Response
|
|
||||||
import retrofit2.http.DELETE
|
|
||||||
import retrofit2.http.GET
|
|
||||||
import retrofit2.http.POST
|
|
||||||
import retrofit2.http.Path
|
|
||||||
import retrofit2.http.Query
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 会话与消息相关端点(api-reference §3.2/§3.4/§3.5/§3.6)。
|
|
||||||
* 与 M1 的 `CyreneApi` 分开,避免互相污染。
|
|
||||||
*/
|
|
||||||
interface SessionsApi {
|
|
||||||
|
|
||||||
@GET("api/v1/sessions")
|
|
||||||
suspend fun listSessions(): Response<ApiEnvelope<List<SessionSummaryDto>>>
|
|
||||||
|
|
||||||
/** 无 body;服务端返回 `201 {data:{sessionId}}`。 */
|
|
||||||
@POST("api/v1/sessions")
|
|
||||||
suspend fun createSession(): Response<ApiEnvelope<CreateSessionResponse>>
|
|
||||||
|
|
||||||
/** 主会话 `web` 删除会返回 `403 forbidden`。 */
|
|
||||||
@DELETE("api/v1/sessions/{id}")
|
|
||||||
suspend fun deleteSession(@Path("id") sessionId: String): Response<ApiEnvelope<DeleteSessionResponse>>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 消息热分页,服务端按 `createdAt` 倒序(newest first)返回。
|
|
||||||
* @param limit 默认 50、上限 200(由 [top.yeij.cyrene.data.repository.MessageRepository] 钳制)
|
|
||||||
* @param before 上一页最旧消息的 `createdAt`(Unix 毫秒);null = 最新一页
|
|
||||||
* @param platform 来源平台过滤;null = 全量
|
|
||||||
*/
|
|
||||||
@GET("api/v1/sessions/{id}/messages")
|
|
||||||
suspend fun listMessages(
|
|
||||||
@Path("id") sessionId: String,
|
|
||||||
@Query("limit") limit: Int? = null,
|
|
||||||
@Query("before") before: Long? = null,
|
|
||||||
@Query("platform") platform: String? = null,
|
|
||||||
): Response<ApiEnvelope<List<HistoryMessageDto>>>
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
package top.yeij.cyrene.data.api.dto
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import kotlinx.serialization.json.JsonElement
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `GET /api/v1/admin/observability/metrics`(api-client §2「可观测性契约」)。
|
|
||||||
* 后端不统计 token / 成本,因此这里也没有对应字段——不伪造指标。
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
data class AdminMetricsDto(
|
|
||||||
val totalRequests: Long = 0,
|
|
||||||
val qps: Double = 0.0,
|
|
||||||
val avgLatencyMs: Double = 0.0,
|
|
||||||
val maxLatencyMs: Long = 0,
|
|
||||||
val perMethod: Map<String, Long> = emptyMap(),
|
|
||||||
val perPath: Map<String, Long> = emptyMap(),
|
|
||||||
val statusCounts: Map<String, Long> = emptyMap(),
|
|
||||||
val llmCalls: Long = 0,
|
|
||||||
val subagentEvents: Long = 0,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `GET /api/v1/admin/observability/trace?limit=N` 单个 span。
|
|
||||||
* `kind` / `status` 是开放集合,保留原始字符串(不建枚举)。
|
|
||||||
* `startedAt` / `endedAt` 为 RFC3339;`props` / `events` 为任意结构,原样透传。
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
data class AdminTraceSpanDto(
|
|
||||||
val id: String,
|
|
||||||
val parentId: String? = null,
|
|
||||||
val name: String = "",
|
|
||||||
val kind: String = "",
|
|
||||||
val status: String = "",
|
|
||||||
val startedAt: String = "",
|
|
||||||
val endedAt: String? = null,
|
|
||||||
val durationNs: Long = 0,
|
|
||||||
val props: Map<String, JsonElement>? = null,
|
|
||||||
val events: List<AdminTraceEventDto>? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** `trace.events[]` 一项;`time` 为 RFC3339(纳秒精度)。 */
|
|
||||||
@Serializable
|
|
||||||
data class AdminTraceEventDto(
|
|
||||||
val time: String = "",
|
|
||||||
val type: String = "",
|
|
||||||
val props: Map<String, JsonElement>? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 人格摘要(用户侧 `GET /api/v1/personas` 与 admin 侧 `GET /api/v1/admin/personas` 同形)。
|
|
||||||
* admin 侧多一个 [thinking];未配置该人格时**该键完全不出现**,故为 nullable。
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
data class PersonaSummaryDto(
|
|
||||||
val id: String,
|
|
||||||
val name: String = "",
|
|
||||||
val character: String = "",
|
|
||||||
val isDefault: Boolean = false,
|
|
||||||
val mode: String = "",
|
|
||||||
val memoryNamespace: String = "",
|
|
||||||
val avatar: String? = null,
|
|
||||||
val chatBackground: ChatBackgroundDto? = null,
|
|
||||||
val mood: String? = null,
|
|
||||||
val thinking: PersonaThinkingDto? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class ChatBackgroundDto(
|
|
||||||
val landscape: String? = null,
|
|
||||||
val portrait: String? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 人格 `thinking` 读侧形状(v0.43)。
|
|
||||||
* [showToUser] 是**三态**:`null` = 未配置(保持现行为),true/false = 显式选择;
|
|
||||||
* 不得把 null 拍平成 false。
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
data class PersonaThinkingDto(
|
|
||||||
val budgetTokens: Int = 0,
|
|
||||||
val effort: String = "",
|
|
||||||
val showToUser: Boolean? = null,
|
|
||||||
val mode: String = "",
|
|
||||||
)
|
|
||||||
|
|
||||||
/** `GET /api/v1/admin/memories` 一条记忆;`scope` 为开放集合(`user|persona|project`)。 */
|
|
||||||
@Serializable
|
|
||||||
data class MemorySummaryDto(
|
|
||||||
val id: String,
|
|
||||||
val scope: String = "",
|
|
||||||
val text: String = "",
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `GET /api/v1/admin/backthink` 一条后台思考记录。
|
|
||||||
* [at] 是 **Unix 毫秒**(与 trace 的 RFC3339 不同源,切勿共用解析)。
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
data class AdminBackthinkItemDto(
|
|
||||||
val id: String,
|
|
||||||
val trigger: String = "",
|
|
||||||
val conclusion: String? = null,
|
|
||||||
val shouldNotify: Boolean = false,
|
|
||||||
val pushed: Boolean = false,
|
|
||||||
val at: Long = 0,
|
|
||||||
)
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
package top.yeij.cyrene.data.api.dto
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class LoginRequest(
|
|
||||||
val username: String,
|
|
||||||
val password: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class LoginResponse(
|
|
||||||
val token: String,
|
|
||||||
val expiresAt: Long,
|
|
||||||
val role: String,
|
|
||||||
)
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
package top.yeij.cyrene.data.api.dto
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `GET /api/v1/sessions/{id}/messages` 单条消息(api-reference §3.4)。
|
|
||||||
* 服务端按 `createdAt` 倒序返回,渲染前需 reverse / merge。
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
data class HistoryMessageDto(
|
|
||||||
val id: String,
|
|
||||||
val sessionId: String,
|
|
||||||
val role: String,
|
|
||||||
val content: String = "",
|
|
||||||
val mode: String? = null,
|
|
||||||
val mood: String? = null,
|
|
||||||
val image: String? = null,
|
|
||||||
val thinking: String? = null,
|
|
||||||
val platform: String? = null,
|
|
||||||
val deviceId: String? = null,
|
|
||||||
val senderName: String? = null,
|
|
||||||
val adapterId: String? = null,
|
|
||||||
val createdAt: Long,
|
|
||||||
)
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
package top.yeij.cyrene.data.api.dto
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `GET /api/v1/sessions` 持久化会话摘要(api-reference §3.2 A)。
|
|
||||||
* 主会话 `id` 恒为 `"web"`、`type = "main"`,且由服务端排在首位。
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
data class SessionSummaryDto(
|
|
||||||
val id: String,
|
|
||||||
val type: String,
|
|
||||||
val title: String? = null,
|
|
||||||
val createdAt: Long,
|
|
||||||
) {
|
|
||||||
val isMain: Boolean
|
|
||||||
get() = type == TYPE_MAIN || id == MAIN_SESSION_ID
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val MAIN_SESSION_ID = "web"
|
|
||||||
const val TYPE_MAIN = "main"
|
|
||||||
const val TYPE_SUB = "sub"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `POST /api/v1/sessions` → `201 {"data":{"sessionId":"web:<uuid>"}}`。 */
|
|
||||||
@Serializable
|
|
||||||
data class CreateSessionResponse(
|
|
||||||
val sessionId: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** `DELETE /api/v1/sessions/{id}` → `200 {"data":{"deleted":"<id>"}}`。 */
|
|
||||||
@Serializable
|
|
||||||
data class DeleteSessionResponse(
|
|
||||||
val deleted: String,
|
|
||||||
)
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
package top.yeij.cyrene.data.repository
|
|
||||||
|
|
||||||
import top.yeij.cyrene.data.api.AdminApi
|
|
||||||
import top.yeij.cyrene.data.api.apiCall
|
|
||||||
import top.yeij.cyrene.data.api.dto.AdminBackthinkItemDto
|
|
||||||
import top.yeij.cyrene.data.api.dto.AdminMetricsDto
|
|
||||||
import top.yeij.cyrene.data.api.dto.AdminTraceSpanDto
|
|
||||||
import top.yeij.cyrene.data.api.dto.MemorySummaryDto
|
|
||||||
import top.yeij.cyrene.data.api.dto.PersonaSummaryDto
|
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 管理只读监控取数层(api-client §2)。
|
|
||||||
* 权限判断不在这里:仓库只管取数,403 由调用方按 [top.yeij.cyrene.domain.admin.AdminFailure] 收敛。
|
|
||||||
*/
|
|
||||||
class AdminRepository(
|
|
||||||
private val api: AdminApi,
|
|
||||||
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
|
||||||
) {
|
|
||||||
|
|
||||||
suspend fun metrics(): Result<AdminMetricsDto> = withContext(dispatcher) {
|
|
||||||
apiCall { api.metrics() }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `GET /observability/trace`;`limit` 与服务端对齐,钳制在 [1, [MAX_TRACE_LIMIT]]。 */
|
|
||||||
suspend fun trace(limit: Int = DEFAULT_TRACE_LIMIT): Result<List<AdminTraceSpanDto>> =
|
|
||||||
withContext(dispatcher) {
|
|
||||||
apiCall { api.trace(limit.coerceIn(1, MAX_TRACE_LIMIT)) }
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun personas(): Result<List<PersonaSummaryDto>> = withContext(dispatcher) {
|
|
||||||
apiCall { api.personas() }
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun memories(): Result<List<MemorySummaryDto>> = withContext(dispatcher) {
|
|
||||||
apiCall { api.memories() }
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun backthink(): Result<List<AdminBackthinkItemDto>> = withContext(dispatcher) {
|
|
||||||
apiCall { api.backthink() }
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
/** trace 拉取条数:够看近期活动,又不会把一屏塞满。 */
|
|
||||||
const val DEFAULT_TRACE_LIMIT = 100
|
|
||||||
|
|
||||||
/** 服务端 `?limit=` 上限。 */
|
|
||||||
const val MAX_TRACE_LIMIT = 500
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
package top.yeij.cyrene.data.repository
|
|
||||||
|
|
||||||
import top.yeij.cyrene.data.api.SessionsApi
|
|
||||||
import top.yeij.cyrene.data.api.apiCall
|
|
||||||
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 会话消息分页获取层。服务端按倒序返回,这里统一转成升序的 [PagedMessages]。
|
|
||||||
* 去重 / 合并请用 [mergeMessages]。
|
|
||||||
*/
|
|
||||||
class MessageRepository(
|
|
||||||
private val api: SessionsApi,
|
|
||||||
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
|
||||||
) {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 拉一页消息。
|
|
||||||
*
|
|
||||||
* @param before 上一页最旧消息的 `createdAt`(用 [PagedMessages.oldestCreatedAt]);
|
|
||||||
* null = 最新一页
|
|
||||||
* @param platform 来源平台过滤;null = 全量(Web 端共享主会话语义)
|
|
||||||
*/
|
|
||||||
suspend fun loadPage(
|
|
||||||
sessionId: String,
|
|
||||||
limit: Int = DEFAULT_PAGE_SIZE,
|
|
||||||
before: Long? = null,
|
|
||||||
platform: String? = null,
|
|
||||||
): Result<PagedMessages> = withContext(dispatcher) {
|
|
||||||
val pageSize = limit.coerceIn(1, MAX_PAGE_SIZE)
|
|
||||||
apiCall { api.listMessages(sessionId, pageSize, before, platform) }
|
|
||||||
.map { raw -> toPagedMessages(raw, pageSize) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 首屏 / 刷新:最新一页。 */
|
|
||||||
suspend fun loadLatest(
|
|
||||||
sessionId: String,
|
|
||||||
limit: Int = DEFAULT_PAGE_SIZE,
|
|
||||||
platform: String? = null,
|
|
||||||
): Result<PagedMessages> = loadPage(sessionId, limit, before = null, platform = platform)
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val DEFAULT_PAGE_SIZE = 50
|
|
||||||
const val MAX_PAGE_SIZE = 200
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun toPagedMessages(
|
|
||||||
rawNewestFirst: List<HistoryMessageDto>,
|
|
||||||
pageSize: Int,
|
|
||||||
): PagedMessages {
|
|
||||||
val ascending = rawNewestFirst.sortedWith(MessageOrder)
|
|
||||||
return PagedMessages(
|
|
||||||
messages = ascending,
|
|
||||||
hasMore = rawNewestFirst.size >= pageSize,
|
|
||||||
oldestCreatedAt = ascending.firstOrNull()?.createdAt,
|
|
||||||
newestCreatedAt = ascending.lastOrNull()?.createdAt,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
package top.yeij.cyrene.data.repository
|
|
||||||
|
|
||||||
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 一页消息(已由仓库转成 **升序**,可直接追加渲染)。
|
|
||||||
*
|
|
||||||
* @param hasMore 该页是否可能还有更旧的消息(`原始页大小 == 请求页大小` 的保守判断)
|
|
||||||
* @param oldestCreatedAt 本页最旧一条的 `createdAt`,作为下一页的 `before` 游标
|
|
||||||
* @param newestCreatedAt 本页最新一条的 `createdAt`
|
|
||||||
*/
|
|
||||||
data class PagedMessages(
|
|
||||||
val messages: List<HistoryMessageDto>,
|
|
||||||
val hasMore: Boolean,
|
|
||||||
val oldestCreatedAt: Long?,
|
|
||||||
val newestCreatedAt: Long?,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** 消息排序:`createdAt` 升序,同毫秒用 `id` 兜底,保证稳定可复现。 */
|
|
||||||
internal val MessageOrder: Comparator<HistoryMessageDto> = compareBy<HistoryMessageDto>({ it.createdAt }, { it.id })
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 纯函数合并:`existing`(本地已加载,任意序)+ `incoming`(服务端新到的一页,倒序)
|
|
||||||
* → 按 `id` 去重、按 `createdAt` 升序(同毫秒用 `id` 兜底)的结果。
|
|
||||||
*
|
|
||||||
* 冲突时 **incoming 胜出**(§3.2「服务端优先」)。不修改入参,返回新列表。
|
|
||||||
* 时间复杂度 O((n+m) log(n+m)),空间 O(n+m)。
|
|
||||||
*/
|
|
||||||
fun mergeMessages(
|
|
||||||
existing: List<HistoryMessageDto>,
|
|
||||||
incoming: List<HistoryMessageDto>,
|
|
||||||
): List<HistoryMessageDto> {
|
|
||||||
if (incoming.isEmpty()) return existing.sortedWith(MessageOrder)
|
|
||||||
if (existing.isEmpty()) return incoming.sortedWith(MessageOrder)
|
|
||||||
|
|
||||||
val byId = LinkedHashMap<String, HistoryMessageDto>(existing.size + incoming.size)
|
|
||||||
existing.forEach { byId[it.id] = it }
|
|
||||||
incoming.forEach { byId[it.id] = it }
|
|
||||||
return byId.values.sortedWith(MessageOrder)
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
package top.yeij.cyrene.data.repository
|
|
||||||
|
|
||||||
import top.yeij.cyrene.data.api.SessionsApi
|
|
||||||
import top.yeij.cyrene.data.api.apiCall
|
|
||||||
import top.yeij.cyrene.data.api.dto.SessionSummaryDto
|
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 会话列表 / 新建 / 删除的数据获取层。
|
|
||||||
*
|
|
||||||
* 列表策略(§3.1):调用方拿到结果后**整体替换**本地快照,不做 merge;
|
|
||||||
* 失败时调用方保留旧快照。仓库本身不持有任何持久化状态。
|
|
||||||
*/
|
|
||||||
class SessionRepository(
|
|
||||||
private val api: SessionsApi,
|
|
||||||
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
|
||||||
) {
|
|
||||||
|
|
||||||
/** `GET /sessions`,原样返回服务端顺序(主会话恒首位)。 */
|
|
||||||
suspend fun listSessions(): Result<List<SessionSummaryDto>> = withContext(dispatcher) {
|
|
||||||
apiCall { api.listSessions() }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `POST /sessions`,成功返回新建的 `sessionId`(形如 `web:<uuid>`)。 */
|
|
||||||
suspend fun createSession(): Result<String> = withContext(dispatcher) {
|
|
||||||
apiCall { api.createSession() }.map { it.sessionId }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `DELETE /sessions/{id}`,成功返回被删除的会话 id;主会话会以 403 失败。 */
|
|
||||||
suspend fun deleteSession(sessionId: String): Result<String> = withContext(dispatcher) {
|
|
||||||
apiCall { api.deleteSession(sessionId) }.map { it.deleted }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loaded 100 of 174 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user