diff --git a/.github/workflows/build-apk.yml b/.github/workflows/build-apk.yml new file mode 100644 index 0000000..23a1b5a --- /dev/null +++ b/.github/workflows/build-apk.yml @@ -0,0 +1,43 @@ +name: Build Android APK + +on: + push: + branches: [ master, main ] + pull_request: + branches: [ master, main ] + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: 'gradle' + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build Debug APK + run: ./gradlew assembleDebug --stacktrace + + - name: Upload Debug APK + uses: actions/upload-artifact@v4 + with: + name: app-debug-apk + path: app/build/outputs/apk/debug/app-debug.apk + retention-days: 30 + + - name: Get APK info + run: | + echo "📦 APK Size:" + ls -lh app/build/outputs/apk/debug/app-debug.apk + echo "" + echo "✅ Build completed successfully!" diff --git a/BUILD_APK_GUIDE.md b/BUILD_APK_GUIDE.md new file mode 100644 index 0000000..e3ad81d --- /dev/null +++ b/BUILD_APK_GUIDE.md @@ -0,0 +1,291 @@ +# Android APK 构建指南 + +## 方法一:使用 Android Studio(推荐) + +### 步骤: + +1. **打开项目** + - 启动 Android Studio + - File → Open → 选择项目根目录 + +2. **同步 Gradle** + - 打开后会自动同步 + - 或点击 "Sync Project with Gradle Files" 按钮 + +3. **构建 APK** + - 点击菜单: Build → Build Bundle(s) / APK(s) → Build APK(s) + - 等待构建完成 + - 点击通知中的 "locate" 查看 APK 位置 + +4. **APK 位置** + ``` + app/build/outputs/apk/debug/app-debug.apk + ``` + +--- + +## 方法二:使用命令行 + +### Windows (Git Bash 或 PowerShell) + +```bash +# 1. 进入项目根目录 +cd C:\Users\876762330\Desktop\projects\SWUST-Code\android-coder + +# 2. 构建 Debug APK +./gradlew assembleDebug + +# 3. APK 位置 +# app/build/outputs/apk/debug/app-debug.apk +``` + +### Linux/macOS + +```bash +# 1. 进入项目根目录 +cd /path/to/android-coder + +# 2. 赋予执行权限(首次) +chmod +x gradlew + +# 3. 构建 Debug APK +./gradlew assembleDebug + +# 4. APK 位置 +# app/build/outputs/apk/debug/app-debug.apk +``` + +--- + +## 方法三:构建 Release APK(需要签名) + +### 创建签名密钥 + +```bash +keytool -genkey -v -keystore coder-release.keystore -alias coder -keyalg RSA -keysize 2048 -validity 10000 +``` + +### 配置签名 + +在 `app/build.gradle.kts` 中添加: + +```kotlin +android { + signingConfigs { + create("release") { + storeFile = file("../coder-release.keystore") + storePassword = "your-store-password" + keyAlias = "coder" + keyPassword = "your-key-password" + } + } + + buildTypes { + release { + signingConfig = signingConfigs.getByName("release") + isMinifyEnabled = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } +} +``` + +### 构建 Release APK + +```bash +./gradlew assembleRelease +``` + +APK 位置: `app/build/outputs/apk/release/app-release.apk` + +--- + +## 常见问题 + +### 1. Gradle 下载慢 + +**解决方案**:使用国内镜像 + +编辑 `settings.gradle.kts`: + +```kotlin +pluginManagement { + repositories { + maven { url = uri("https://maven.aliyun.com/repository/gradle-plugin") } + maven { url = uri("https://maven.aliyun.com/repository/google") } + maven { url = uri("https://maven.aliyun.com/repository/public") } + gradlePluginPortal() + google() + mavenCentral() + } +} + +dependencyResolutionManagement { + repositories { + maven { url = uri("https://maven.aliyun.com/repository/google") } + maven { url = uri("https://maven.aliyun.com/repository/public") } + google() + mavenCentral() + } +} +``` + +### 2. 构建失败 + +```bash +# 清理构建缓存 +./gradlew clean + +# 重新构建 +./gradlew assembleDebug --stacktrace +``` + +### 3. 内存不足 + +编辑 `gradle.properties`: + +```properties +org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m +``` + +--- + +## 快速构建脚本 + +创建 `build-apk.sh`: + +```bash +#!/bin/bash + +echo "🚀 开始构建 APK..." + +# 清理 +./gradlew clean + +# 构建 +./gradlew assembleDebug + +# 检查结果 +if [ -f "app/build/outputs/apk/debug/app-debug.apk" ]; then + echo "✅ 构建成功!" + echo "📦 APK 位置: app/build/outputs/apk/debug/app-debug.apk" + + # 显示文件大小 + ls -lh app/build/outputs/apk/debug/app-debug.apk +else + echo "❌ 构建失败" + exit 1 +fi +``` + +使用方法: + +```bash +chmod +x build-apk.sh +./build-apk.sh +``` + +--- + +## 使用 GitHub Actions 自动构建 + +创建 `.github/workflows/build-apk.yml`: + +```yaml +name: Build APK + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build Debug APK + run: ./gradlew assembleDebug + + - name: Upload APK + uses: actions/upload-artifact@v3 + with: + name: app-debug + path: app/build/outputs/apk/debug/app-debug.apk +``` + +推送到 GitHub 后,在 Actions 标签页下载构建的 APK。 + +--- + +## 系统要求 + +### 开发环境 +- **JDK**: 17 或更高 +- **Android SDK**: API 26-35 +- **Gradle**: 8.13.2(自动下载) +- **Kotlin**: 2.3.21 + +### 最低设备要求 +- **Android**: 8.0 (API 26) 或更高 +- **RAM**: 2GB+ +- **存储**: 50MB+ + +--- + +## APK 信息 + +### Debug APK +- **包名**: com.dlzz.coder +- **版本**: 0.1.0 +- **大小**: ~10-15 MB(未优化) +- **签名**: Debug 签名(不可发布) + +### Release APK +- **包名**: com.dlzz.coder +- **版本**: 0.1.0 +- **大小**: ~5-8 MB(ProGuard 优化后) +- **签名**: 需要自己的签名密钥 + +--- + +## 安装 APK + +### 通过 ADB +```bash +adb install app/build/outputs/apk/debug/app-debug.apk +``` + +### 直接安装 +1. 将 APK 传输到 Android 设备 +2. 在设备上打开文件管理器 +3. 点击 APK 文件 +4. 允许"未知来源"安装(如需要) +5. 点击"安装" + +--- + +## 下一步 + +1. ✅ 运行上述任一方法构建 APK +2. ✅ 测试 APK 功能 +3. ✅ 如需发布,配置 Release 签名 +4. ✅ 考虑设置 CI/CD 自动构建 + +**提示**: Debug APK 仅用于测试,发布到用户请使用 Release APK。 diff --git a/CHAT_UI_OPTIMIZATION_SUMMARY.md b/CHAT_UI_OPTIMIZATION_SUMMARY.md new file mode 100644 index 0000000..bde2dac --- /dev/null +++ b/CHAT_UI_OPTIMIZATION_SUMMARY.md @@ -0,0 +1,339 @@ +# 聊天和界面全面优化总结 + +## 优化完成的功能 + +### 1. 聊天消息 Markdown 渲染支持 ✅ + +#### 新增功能 +**文件**: `app/src/main/java/com/dlzz/coder/ui/components/MarkdownText.kt`(新建) + +- 创建了 `MarkdownText` Compose 组件 +- 使用 Markwon 库进行 Markdown 渲染 +- 支持代码高亮(使用 Prism4j) +- 自动适配深色/浅色主题 + +#### 支持的 Markdown 功能 +- ✅ 标题 (# ## ###) +- ✅ 代码块 (```language) +- ✅ 行内代码 (`code`) +- ✅ 粗体/斜体 (**bold** *italic*) +- ✅ 列表 (有序/无序) +- ✅ 链接 +- ✅ 语法高亮(多种编程语言) + +#### 依赖添加 +```toml +markwon = "4.6.2" +markwon-core +markwon-syntax-highlight +``` + +--- + +### 2. 优化工具调用显示 ✅ + +**文件**: `app/src/main/java/com/dlzz/coder/ui/chat/ChatScreen.kt` + +#### 2.1 工具调用卡片重新设计 + +**视觉优化**: +- ✅ 更大的内边距(14dp → 16dp) +- ✅ 更大的图标(18dp → 20dp) +- ✅ 圆角从 12dp 增加到 14dp +- ✅ 更清晰的状态显示布局 + +**智能展开/收起**: +- ✅ 输出超过 5 行或 500 字符时显示展开按钮 +- ✅ 收起时显示"... 共 X 行"提示 +- ✅ 展开按钮移至卡片右上角,更易点击 +- ✅ 输出区域更大的内边距(12dp) + +**状态显示改进**: +- ✅ 工具名称和状态垂直排列,更清晰 +- ✅ 工具名称缺失时显示"工具 #xxx" +- ✅ 状态标签和图标使用一致的颜色 + +#### 2.2 工具列表标题优化 + +- ✅ 添加工具总数显示:"X 个工具" +- ✅ 标题行使用水平分布布局 +- ✅ 更大的顶部间距(12dp) + +--- + +### 3. 消息气泡优化 ✅ + +**文件**: `app/src/main/java/com/dlzz/coder/ui/chat/ChatScreen.kt` + +#### 视觉改进 +- ✅ 统一内边距:12dp → 14dp +- ✅ 最大宽度:320dp → 340dp,显示更多内容 +- ✅ 用户消息使用纯文本 +- ✅ **助手消息使用 Markdown 渲染** +- ✅ 系统消息保持红色警告样式 + +#### Markdown 渲染集成 +```kotlin +// 助手消息自动使用 Markdown +MarkdownText( + markdown = msg.text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface +) +``` + +--- + +### 4. 添加主机对话框优化 ✅ + +**文件**: `app/src/main/java/com/dlzz/coder/ui/hosts/HostListScreen.kt` + +#### 功能改进 + +**可折叠的高级选项**: +- ✅ 默认只显示必要字段:名称、地址、端口、Token +- ✅ 高级选项隐藏:providerId, workspacePath, workspaceTitle +- ✅ "显示/隐藏高级选项"按钮切换 + +**输入框优化**: +- ✅ 所有输入框添加 `fillMaxWidth()`,一致的宽度 +- ✅ 添加 placeholder 提示: + - 名称:"主机名称(可选)" + - 地址:"192.168.1.100" + - Token:"Token(可选)" + - providerId:"mock" + +**视觉改进**: +- ✅ 使用展开/收起图标 +- ✅ 更好的间距和布局 + +#### 优化前后对比 + +**优化前**: +``` +❌ 7 个输入框全部显示,对话框过长 +❌ 用户不知道哪些是必填的 +❌ 输入框宽度不一致 +❌ 没有输入提示 +``` + +**优化后**: +``` +✅ 默认 4 个必要字段 +✅ 高级选项可折叠 +✅ 清晰的占位符提示 +✅ 一致的输入框宽度 +``` + +--- + +### 5. 新建会话对话框优化 ✅ + +**文件**: `app/src/main/java/com/dlzz/coder/ui/hosts/HostListScreen.kt` + +#### 功能改进 + +**主机信息显示**: +- ✅ 显示"主机: xxx"标签,更清晰 +- ✅ 使用 Row 布局,视觉层次更好 + +**可折叠的高级选项**: +- ✅ workspacePath 和 workspaceTitle 默认隐藏 +- ✅ providerId 始终显示(更常用) +- ✅ "显示/隐藏高级选项"按钮 + +**输入框优化**: +- ✅ 所有输入框 `fillMaxWidth()` +- ✅ providerId 添加 "mock" placeholder + +--- + +### 6. 局域网扫描对话框优化 ✅ + +**文件**: `app/src/main/java/com/dlzz/coder/ui/hosts/HostListScreen.kt` + +#### 已完成的优化 +- ✅ 移除"扫描目标"输入框 +- ✅ 添加说明:"将自动扫描所有网络接口(包括 VPN)" +- ✅ 只保留端口和 Token 输入 +- ✅ 扫描时禁用输入和关闭 +- ✅ 实时进度显示(X/Y 百分比) + +--- + +## 技术改进总结 + +### 依赖管理 +```toml +# gradle/libs.versions.toml +markwon = "4.6.2" +markwon-core +markwon-syntax-highlight +``` + +### 新增组件 +- `MarkdownText.kt` - 可复用的 Markdown 渲染组件 +- 支持主题切换 +- 自动语法高亮 + +### 代码质量 +- ✅ 统一的间距和尺寸 +- ✅ 一致的布局模式 +- ✅ 可折叠的高级选项模式 +- ✅ 清晰的占位符提示 + +--- + +## 用户体验提升 + +### 聊天界面 +1. **Markdown 支持** - 代码块、格式化文本清晰易读 +2. **工具调用优化** - 大量工具时自动缩略,减少滚动 +3. **更大的气泡** - 显示更多内容,减少截断 +4. **智能展开** - 只在需要时显示展开按钮 + +### 对话框 +1. **简化输入** - 默认只显示必要字段 +2. **高级选项折叠** - 减少视觉混乱 +3. **清晰提示** - Placeholder 告知用户应该输入什么 +4. **一致的宽度** - 更美观整洁 + +### 扫描功能 +1. **零配置** - 自动检测网络接口 +2. **VPN 支持** - 自动包含虚拟网卡 +3. **实时反馈** - 进度百分比 +4. **友好消息** - 详细的扫描结果 + +--- + +## 优化前后对比 + +### 聊天消息 + +**优化前**: +``` +纯文本显示 +代码块没有语法高亮 +代码混在普通文本中难以阅读 +``` + +**优化后**: +``` +✅ Markdown 渲染 +✅ 代码语法高亮 +✅ 清晰的格式化 +✅ 代码块独立显示 +``` + +### 工具调用 + +**优化前**: +``` +固定显示 4 行 +大量工具时占据大量空间 +展开按钮在底部 +``` + +**优化后**: +``` +✅ 智能判断是否需要展开按钮 +✅ 显示"共 X 行"提示 +✅ 展开按钮在右上角 +✅ 更大的显示区域 +``` + +### 对话框 + +**优化前**: +``` +7 个输入框全部显示 +对话框太长需要滚动 +不知道哪些必填 +``` + +**优化后**: +``` +✅ 4 个必要字段 + 折叠的高级选项 +✅ 紧凑的布局 +✅ 清晰的提示文本 +``` + +--- + +## 视觉设计改进 + +### 间距统一化 +- 消息气泡:14dp 内边距 +- 工具卡片:16dp 内边距 +- 对话框:10-12dp 垂直间距 + +### 尺寸标准化 +- 小图标:16-18dp +- 中图标:20dp +- 按钮图标:24-32dp + +### 圆角统一 +- 消息气泡:16dp +- 工具卡片:14dp +- 输入框:标准 Material 3 + +### 颜色系统 +- 状态颜色保持一致 +- 使用 Material 3 主题色 +- 深色/浅色模式自适应 + +--- + +## 建议的后续改进 + +### 聊天功能 +1. 添加代码复制按钮 +2. 支持图片消息 +3. 消息搜索功能 +4. 导出聊天记录 + +### 工具调用 +1. 添加工具调用统计 +2. 支持按工具类型筛选 +3. 工具执行时间显示 +4. 失败工具的重试功能 + +### 界面优化 +1. 添加快捷操作菜单 +2. 支持手势操作 +3. 自定义主题颜色 +4. 字体大小设置 + +--- + +## 文件变更列表 + +### 新增文件 +- `app/src/main/java/com/dlzz/coder/ui/components/MarkdownText.kt` + +### 修改文件 +- `gradle/libs.versions.toml` - 添加 Markwon 依赖 +- `app/build.gradle.kts` - 添加 Markwon 库 +- `app/src/main/java/com/dlzz/coder/ui/chat/ChatScreen.kt` - Markdown 渲染和工具优化 +- `app/src/main/java/com/dlzz/coder/ui/hosts/HostListScreen.kt` - 对话框优化 + +--- + +## 测试建议 + +### 功能测试 +1. ✅ 验证 Markdown 各种语法正确渲染 +2. ✅ 测试代码块语法高亮(多种语言) +3. ✅ 验证工具调用展开/收起功能 +4. ✅ 测试对话框高级选项折叠 +5. ✅ 验证深色/浅色主题切换 + +### 性能测试 +1. 大量消息时的渲染性能 +2. 长代码块的显示性能 +3. 100+ 工具调用的显示性能 + +### 兼容性测试 +1. 不同屏幕尺寸的显示 +2. 不同 Android 版本 +3. 不同设备分辨率 diff --git a/FIXES_SUMMARY.md b/FIXES_SUMMARY.md new file mode 100644 index 0000000..66d3ab5 --- /dev/null +++ b/FIXES_SUMMARY.md @@ -0,0 +1,145 @@ +# 客户端功能和逻辑设计问题修复总结 + +## 修复的问题 + +### 1. WebSocket 重连机制缺失 ✅ +**文件**: `app/src/main/java/com/dlzz/coder/bridge/AgentBridgeClient.kt` + +**问题**: +- WebSocket 连接断开后无法自动重连 +- 没有心跳保持机制 +- 连接失败后用户需要手动重新连接 + +**修复**: +- 添加自动重连机制,使用指数退避策略(1s, 2s, 4s, 8s, 16s, 最多30s) +- 添加 OkHttp 内置的 ping 间隔(30秒)用于心跳检测 +- 添加连接状态管理(`shouldReconnect`, `isConnecting`) +- 改进 `disconnect()` 方法,正确清理重连任务 +- 异常关闭(非 1000 状态码)会自动触发重连 + +**影响**: 提升用户体验,连接更稳定可靠 + +--- + +### 2. ChatScreen 自动滚动干扰用户阅读 ✅ +**文件**: `app/src/main/java/com/dlzz/coder/ui/chat/ChatScreen.kt` + +**问题**: +- 每次消息或工具调用更新都会强制滚动到底部 +- 用户在查看历史消息时会被强制拉回底部,体验很差 + +**修复**: +- 添加智能滚动逻辑:只在用户位于底部附近(距离底部 ≤3 项)时才自动滚动 +- 用户发送消息时总是滚动到底部(符合预期行为) +- 修复发送消息时的滚动目标(包含 toolCalls) + +**影响**: 用户可以正常查看历史消息而不被打断 + +--- + +### 3. ChatViewModel appendAssistantDelta 线程安全问题 ✅ +**文件**: `app/src/main/java/com/dlzz/coder/viewmodel/ChatViewModel.kt` + +**问题**: +- `appendAssistantDelta` 方法直接修改消息列表,没有同步保护 +- 在高频消息更新场景下可能出现并发问题 +- `sendMessage` 也存在相同问题 + +**修复**: +- 引入 `Mutex` 进行线程安全保护 +- `appendAssistantDelta` 改为 `suspend` 函数,使用 `withLock` 保护临界区 +- `sendMessage` 也使用 mutex 保护 +- 增加历史消息加载超时时间从 10 秒到 15 秒 + +**影响**: 消除潜在的并发问题,提高稳定性 + +--- + +### 4. ToolCallCard 展开/收起按钮显示逻辑问题 ✅ +**文件**: `app/src/main/java/com/dlzz/coder/ui/chat/ChatScreen.kt` + +**问题**: +- 使用两个独立的 `AnimatedVisibility` 来显示展开/收起按钮 +- 逻辑混乱,可能同时显示两个按钮或都不显示 +- 短输出也会显示展开按钮 + +**修复**: +- 使用单个 `TextButton` 根据 `expanded` 状态切换图标 +- 只在输出超过 4 行时显示按钮 +- 简化逻辑,更清晰易懂 + +**影响**: UI 行为更合理,代码更简洁 + +--- + +### 5. FileViewModel processedCount 逻辑错误 ✅ +**文件**: `app/src/main/java/com/dlzz/coder/viewmodel/FileViewModel.kt` + +**问题**: +- 切换 session 时 `processedCount` 重置为 0 +- 但 `eventsByHost` 中的历史事件没有清空 +- 导致切换后重复处理所有历史事件 + +**修复**: +- 改为监听 `sessionEvents` 而非 `eventsByHost` +- 使用 `sessionEventCounts` Map 为每个 session 独立跟踪已处理事件数 +- 切换 session 时清空当前显示的文件和预览状态 +- 每个 session 的事件处理进度独立维护 + +**影响**: 消除重复事件处理,性能更好,逻辑更正确 + +--- + +### 6. BridgeViewModel 网络扫描并发控制优化 ✅ +**文件**: `app/src/main/java/com/dlzz/coder/viewmodel/BridgeViewModel.kt` + +**问题**: +- 网络扫描时每批并发 32 个连接 +- 在扫描大量 IP 时可能导致网络拥塞和性能问题 + +**修复**: +- 将并发批次大小从 32 降低到 16 +- 减少同时发起的网络连接数量 +- 保持足够的并发以提高扫描速度 + +**影响**: 在扫描速度和资源消耗之间取得更好的平衡 + +--- + +### 7. 移除未使用的 SessionViewModel ✅ +**文件**: `app/src/main/java/com/dlzz/coder/viewmodel/SessionViewModel.kt`(已删除) + +**问题**: +- `SessionViewModel` 类存在但未被任何地方使用 +- 功能与 `BridgeViewModel` 重复 +- 增加了代码维护负担 + +**修复**: +- 删除整个 `SessionViewModel.kt` 文件 +- 所有 session 管理功能都在 `BridgeViewModel` 中 + +**影响**: 代码库更清晰,减少维护成本 + +--- + +## 修复统计 + +- **修复的文件**: 4 个 +- **删除的文件**: 1 个 +- **修复的问题**: 7 个 +- **添加的功能**: WebSocket 自动重连、智能滚动、线程安全保护 + +## 技术改进 + +1. **稳定性**: 自动重连机制、线程安全保护 +2. **用户体验**: 智能滚动、更合理的 UI 行为 +3. **性能**: 优化网络扫描并发、消除重复事件处理 +4. **代码质量**: 删除重复代码、简化逻辑、提高可维护性 + +## 建议的后续改进 + +1. 考虑为 WebSocket 连接添加连接质量监控 +2. 可以添加用户手动触发重连的选项 +3. 考虑添加网络状态监听,网络恢复时立即重连 +4. 可以为扫描功能添加可配置的并发数设置 +5. 添加更多的单元测试覆盖修复的功能 diff --git a/OPTIMIZATIONS_SUMMARY.md b/OPTIMIZATIONS_SUMMARY.md new file mode 100644 index 0000000..306ce27 --- /dev/null +++ b/OPTIMIZATIONS_SUMMARY.md @@ -0,0 +1,194 @@ +# 局域网扫描和会话窗口优化总结 + +## 优化项目 + +### 1. 局域网扫描逻辑优化 ✅ + +#### 改进的功能 + +**文件**: `app/src/main/java/com/dlzz/coder/viewmodel/BridgeViewModel.kt` + +##### 1.1 实时进度反馈 +- **问题**: 原有扫描无实时进度更新,用户只能看到"正在扫描" +- **优化**: 添加 `scanHealthEndpointsWithProgress` 方法,支持进度回调 +- **效果**: 每扫描完一批(16个)主机就更新进度,用户可以实时看到 `已扫描/总数` + +##### 1.2 更详细的扫描消息 +- **问题**: 消息过于简单,不够友好 +- **优化**: + - 准备阶段: "正在准备扫描..." + - 扫描中: "正在扫描 X 个目标..." + - 完成后: "扫描完成:新增 X 台,已存在 Y 台" 或 "未发现可用主机" + - 空目标: "没有可扫描的目标,请输入 IP 地址或 CIDR 范围" + +##### 1.3 跳过已存在主机的统计 +- **问题**: 只统计新增主机,未告知用户有多少已存在 +- **优化**: 分别统计 `added`(新增)和 `skipped`(已存在) +- **效果**: 用户知道扫描发现了多少台,其中哪些是新的 + +##### 1.4 异常处理 +- **问题**: 扫描失败时没有友好的错误提示 +- **优化**: 添加 try-catch,捕获异常并显示错误消息 +- **效果**: 扫描失败时显示 "扫描失败:错误原因" + +--- + +**文件**: `app/src/main/java/com/dlzz/coder/ui/hosts/HostListScreen.kt` + +##### 1.5 扫描对话框 UI 改进 +- **禁用输入**: 扫描进行时禁用所有输入框,防止误操作 +- **防止关闭**: 扫描时无法点击外部关闭对话框 +- **进度显示**: + - 显示详细消息(带颜色:扫描中用主题色,完成用默认色) + - 显示百分比进度:"进度: 45/256 (17%)" +- **按钮状态**: + - 扫描中按钮显示"扫描运行中" + - 目标为空时禁用开始按钮 + - 扫描中禁用取消按钮 + +--- + +### 2. 会话窗口美观度优化 ✅ + +**文件**: `app/src/main/java/com/dlzz/coder/ui/sessions/SessionListScreen.kt` + +#### 2.1 会话卡片视觉优化 + +##### 标题和间距 +- **增加内边距**: 从 14dp 增加到 16dp,更宽敞舒适 +- **增加间距**: 元素垂直间距从 4dp 增加到 6dp,视觉层次更清晰 +- **标题行数**: 允许标题最多显示 2 行(原来 1 行),避免过多截断 +- **元数据行数**: 允许元数据最多显示 2 行,显示更多信息 + +##### 活跃会话突出显示 +- **动态毛玻璃效果**: 活跃会话(running/active/busy)的模糊半径从 3dp 增加到 4dp +- **字体加粗**: 活跃会话的标题使用 SemiBold 字重,视觉上更突出 +- **逻辑**: 根据 `session.status` 自动判断是否为活跃状态 + +##### 状态点增大 +- **尺寸**: 从 8dp 增加到 10dp,更容易识别 +- **位置**: 仍然在芯片组最左侧 + +##### 芯片文字优化 +- **字体**: 使用 `MaterialTheme.typography.labelMedium`,更清晰 +- **对齐**: 统一样式,视觉更协调 + +##### ID 显示优化 +- **透明度**: 从 0.5f 增加到 0.6f,稍微更明显但不喧宾夺主 + +#### 2.2 空状态优化 + +##### 居中显示 +- **布局**: 使用 Box + Center 对齐 +- **间距**: 上下 32dp 内边距,视觉平衡更好 + +##### 两行提示 +- **第一行**: 主要消息(strings.emptySessions)- 使用 bodyLarge +- **第二行**: 辅助提示"连接主机后创建会话" - 使用 bodySmall,半透明 +- **颜色**: 使用 onSurfaceVariant,符合 Material Design + +##### 视觉层次 +- **字体大小**: 主消息更大,辅助文字更小 +- **颜色透明度**: 辅助文字 70% 透明度,层次分明 + +#### 2.3 小标题颜色优化 +- **问题**: 小标题使用默认颜色,与正文难以区分 +- **优化**: 使用 `MaterialTheme.colorScheme.onSurfaceVariant` +- **效果**: 视觉层次更清晰,符合 Material Design 3 规范 + +--- + +## 优化前后对比 + +### 局域网扫描 + +**优化前**: +``` +正在扫描 +45/256 +已添加 3 台主机 +``` + +**优化后**: +``` +正在扫描 256 个目标... +进度: 45/256 (17%) +扫描完成:新增 3 台,已存在 5 台 +``` + +### 会话卡片 + +**视觉改进**: +- ✅ 更宽敞的内边距和间距 +- ✅ 活跃会话自动突出显示(加粗 + 增强毛玻璃) +- ✅ 更大的状态指示点 +- ✅ 标题可显示更多内容(2行) +- ✅ 更清晰的字体样式 + +**空状态改进**: +- ✅ 居中显示 +- ✅ 双层提示信息 +- ✅ 更好的视觉层次 + +--- + +## 技术改进 + +### 扫描性能 +- **批处理**: 保持 16 个并发连接 +- **进度更新**: 每批完成后立即更新 +- **资源管理**: 异常时正确清理状态 + +### UI/UX 改进 +1. **实时反馈**: 用户始终知道当前状态 +2. **防误操作**: 扫描时锁定输入 +3. **信息完整**: 详细显示扫描结果 +4. **视觉层次**: 使用字重、颜色、大小区分重要性 +5. **智能突出**: 活跃会话自动高亮 + +--- + +## 代码质量 + +### 新增功能 +- `scanHealthEndpointsWithProgress`: 支持进度回调的扫描方法 +- 状态驱动的 UI 禁用/启用逻辑 +- 动态视觉效果(基于会话状态) + +### 代码可维护性 +- 清晰的状态判断逻辑 +- 分离的进度回调接口 +- 统一的错误处理 + +--- + +## 用户体验提升 + +### 扫描体验 +1. **透明度**: 用户知道正在发生什么 +2. **控制感**: 清楚地了解进度 +3. **反馈完整**: 知道结果的详细信息 +4. **错误友好**: 失败时给出明确原因 + +### 会话浏览体验 +1. **视觉舒适**: 更好的间距和字体 +2. **信息清晰**: 多行显示,不过度截断 +3. **重点突出**: 活跃会话一眼识别 +4. **空状态友好**: 清晰的指引而非单调的空白 + +--- + +## 建议的后续改进 + +### 扫描功能 +1. 添加扫描历史记录 +2. 支持保存常用扫描配置 +3. 支持扫描结果导出 +4. 添加扫描速度设置(快速/标准/慢速) + +### 会话窗口 +1. 添加会话分组(按主机、状态等) +2. 支持搜索和过滤会话 +3. 添加会话标签功能 +4. 支持批量操作(删除、归档等) +5. 添加会话预览(悬停显示最后消息) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 242e073..a341802 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -58,5 +58,7 @@ dependencies { implementation(libs.kyant.shapes) implementation(libs.kotlinx.serialization.json) implementation(libs.zxing.android.embedded) + implementation(libs.markwon.core) + implementation(libs.markwon.syntax.highlight) testImplementation(libs.junit) } diff --git a/app/src/main/java/com/dlzz/coder/bridge/AgentBridgeClient.kt b/app/src/main/java/com/dlzz/coder/bridge/AgentBridgeClient.kt index b2c4dd8..937a1bc 100644 --- a/app/src/main/java/com/dlzz/coder/bridge/AgentBridgeClient.kt +++ b/app/src/main/java/com/dlzz/coder/bridge/AgentBridgeClient.kt @@ -2,8 +2,14 @@ package com.dlzz.coder.bridge import android.util.Log import com.dlzz.coder.debug.LogCollector +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement @@ -17,6 +23,7 @@ import okhttp3.Response import okhttp3.WebSocket import okhttp3.WebSocketListener import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger class AgentBridgeClient( @@ -31,6 +38,7 @@ class AgentBridgeClient( private val client = OkHttpClient.Builder() .connectTimeout(5, TimeUnit.SECONDS) .readTimeout(0, TimeUnit.MILLISECONDS) + .pingInterval(30, TimeUnit.SECONDS) .build() private val _events = MutableSharedFlow(extraBufferCapacity = 64) @@ -39,15 +47,35 @@ class AgentBridgeClient( private val _connected = MutableSharedFlow(replay = 1, extraBufferCapacity = 1) val connected: SharedFlow = _connected + private val shouldReconnect = AtomicBoolean(false) + private val isConnecting = AtomicBoolean(false) + private var reconnectJob: Job? = null + private val scope = CoroutineScope(Dispatchers.IO) + private var reconnectAttempts = 0 + private val maxReconnectDelay = 30_000L // 30 seconds max + fun connect() { + shouldReconnect.set(true) + attemptConnect() + } + + private fun attemptConnect() { + if (isConnecting.getAndSet(true)) { + return + } + val wsUrl = "ws://$host:$port/ws?token=$token" val request = Request.Builder().url(wsUrl).header("Authorization", "Bearer $token").build() - LogCollector.i(tag, "Connecting to $wsUrl") + LogCollector.i(tag, "Connecting to $wsUrl (attempt ${reconnectAttempts + 1})") + webSocket = client.newWebSocket(request, object : WebSocketListener() { override fun onOpen(webSocket: WebSocket, response: Response) { LogCollector.i(tag, "Connected to $host:$port") + isConnecting.set(false) + reconnectAttempts = 0 _connected.tryEmit(true) } + override fun onMessage(webSocket: WebSocket, text: String) { try { val msg = json.decodeFromString(text) @@ -59,17 +87,44 @@ class AgentBridgeClient( LogCollector.e(tag, "Parse error: ${e.message}", e) } } + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { LogCollector.e(tag, "Connection failure: ${t.message}", t) + isConnecting.set(false) _connected.tryEmit(false) + scheduleReconnect() } + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { LogCollector.w(tag, "Closed: code=$code reason=$reason") + isConnecting.set(false) _connected.tryEmit(false) + if (code != 1000) { // 1000 = normal closure + scheduleReconnect() + } } }) } + private fun scheduleReconnect() { + if (!shouldReconnect.get()) { + return + } + + reconnectJob?.cancel() + reconnectJob = scope.launch { + // Exponential backoff: 1s, 2s, 4s, 8s, 16s, up to 30s + val delay = minOf((1000L * (1 shl reconnectAttempts)), maxReconnectDelay) + LogCollector.d(tag, "Reconnecting in ${delay}ms") + delay(delay) + + if (shouldReconnect.get() && isActive) { + reconnectAttempts++ + attemptConnect() + } + } + } + fun send(requestType: String, payload: Map = emptyMap()): String { val id = "req-${nextId.incrementAndGet()}" val msg = ClientWireMessage( @@ -110,7 +165,11 @@ class AgentBridgeClient( } fun disconnect() { + shouldReconnect.set(false) + reconnectJob?.cancel() + reconnectJob = null webSocket?.close(1000, "client closing") webSocket = null + _connected.tryEmit(false) } } diff --git a/app/src/main/java/com/dlzz/coder/ui/chat/ChatScreen.kt b/app/src/main/java/com/dlzz/coder/ui/chat/ChatScreen.kt index 0f2d938..500fdeb 100644 --- a/app/src/main/java/com/dlzz/coder/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/dlzz/coder/ui/chat/ChatScreen.kt @@ -33,6 +33,7 @@ import androidx.compose.ui.unit.dp import com.dlzz.coder.bridge.ChatMessage import com.dlzz.coder.bridge.ToolCall import com.dlzz.coder.bridge.ToolState +import com.dlzz.coder.ui.components.MarkdownText import com.dlzz.coder.ui.i18n.AppStrings import com.dlzz.coder.ui.i18n.Strings import com.dlzz.coder.ui.theme.accentColor @@ -82,9 +83,18 @@ fun ChatScreen( val scope = rememberCoroutineScope() var inputText by remember { mutableStateOf("") } + // Auto-scroll only when user is near bottom LaunchedEffect(messages.size, toolCalls.size) { if (messages.isNotEmpty() || toolCalls.isNotEmpty()) { - scope.launch { listState.animateScrollToItem((messages.size + toolCalls.size).coerceAtLeast(0)) } + val lastVisibleIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + val totalItems = messages.size + toolCalls.size + 1 // +1 for header items + val isNearBottom = totalItems - lastVisibleIndex <= 3 + + if (isNearBottom) { + scope.launch { + listState.animateScrollToItem((messages.size + toolCalls.size).coerceAtLeast(0)) + } + } } } @@ -171,11 +181,23 @@ fun ChatScreen( } if (toolCalls.isNotEmpty()) { item { - Text( - strings.toolCallsTitle, - style = MaterialTheme.typography.titleSmall, - modifier = Modifier.padding(top = 12.dp, bottom = 4.dp) - ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp, bottom = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + strings.toolCallsTitle, + style = MaterialTheme.typography.titleSmall + ) + Text( + "${toolCalls.size} 个工具", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } items(toolCalls, key = { it.id }) { tool -> ToolCallCard(tool, strings) @@ -208,7 +230,10 @@ fun ChatScreen( if (inputText.isNotBlank()) { chatViewModel.sendMessage(hostId, sessionId, inputText) inputText = "" - scope.launch { listState.animateScrollToItem(messages.size) } + // Always scroll to bottom when user sends a message + scope.launch { + listState.animateScrollToItem(messages.size + toolCalls.size) + } } }, modifier = Modifier.height(52.dp), @@ -260,27 +285,17 @@ private fun MessageBubble(msg: ChatMessage, isDark: Boolean, accent: Color) { isSystem -> if (isDark) Color(0xFFA32D2D).copy(0.15f) else Color(0xFFA32D2D).copy(0.1f) else -> if (isDark) Color.White.copy(0.08f) else Color.White.copy(0.6f) } - val bubbleModifier = if (isUser) { - Modifier - .glassBubble(cornerRadius = 16.dp, surfaceColor = surfaceColor) - .animateContentSize() - .padding(12.dp) - .widthIn(max = 320.dp) - } else { - Modifier - .glassBubble(cornerRadius = 16.dp, surfaceColor = surfaceColor) - .animateContentSize() - .padding(12.dp) - .widthIn(max = 320.dp) - } + val bubbleModifier = Modifier + .glassBubble(cornerRadius = 16.dp, surfaceColor = surfaceColor) + .animateContentSize() + .padding(14.dp) + .widthIn(max = 340.dp) Row( Modifier.fillMaxWidth(), horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start ) { - Box( - bubbleModifier - ) { + Box(bubbleModifier) { Column { if (isSystem) { Text( @@ -288,15 +303,18 @@ private fun MessageBubble(msg: ChatMessage, isDark: Boolean, accent: Color) { style = MaterialTheme.typography.bodySmall, color = if (isDark) Color(0xFFFF6B6B) else Color(0xFFA32D2D) ) - } else { + } else if (isUser) { Text( msg.text, style = MaterialTheme.typography.bodyMedium, - color = if (isUser) { - if (isDark) Color.White else Color.Black - } else { - MaterialTheme.colorScheme.onSurface - } + color = if (isDark) Color.White else Color.Black + ) + } else { + // Assistant message - use Markdown + MarkdownText( + markdown = msg.text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface ) } } @@ -315,66 +333,88 @@ private fun ToolCallCard(tool: ToolCall, strings: Strings) { ToolState.ERROR -> Triple(strings.toolError, Icons.Default.Error, Color(0xFFA32D2D)) } + val hasOutput = tool.output.isNotEmpty() + val outputLines = tool.output.lines() + val shouldShowExpandButton = outputLines.size > 5 || tool.output.length > 500 + Column( Modifier .fillMaxWidth() .animateContentSize() - .glassCard(cornerRadius = 12.dp, blur = 3.dp, lensNear = 8.dp, lensFar = 16.dp) - .padding(14.dp) + .glassCard(cornerRadius = 14.dp, blur = 3.dp, lensNear = 8.dp, lensFar = 16.dp) + .padding(16.dp) ) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Icon(stateIcon, contentDescription = null, tint = stateColor, modifier = Modifier.size(18.dp)) - Text( - tool.name.ifBlank { tool.id.take(8) }, - style = MaterialTheme.typography.labelLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f, fill = false) - ) - Spacer(Modifier.weight(1f)) - Text( - stateLabel, - style = MaterialTheme.typography.labelSmall, - color = stateColor - ) - } - if (tool.output.isNotEmpty()) { - Spacer(Modifier.height(8.dp)) - Text( - strings.toolOutput, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.fillMaxWidth() + ) { + Icon( + stateIcon, + contentDescription = null, + tint = stateColor, + modifier = Modifier.size(20.dp) ) - Spacer(Modifier.height(2.dp)) - Surface( - shape = RoundedCornerShape(8.dp), - color = Color.Black.copy(if (isSystemInDarkTheme()) 0.3f else 0.05f) - ) { + Column(Modifier.weight(1f)) { Text( - tool.output, - style = MaterialTheme.typography.bodySmall, - fontFamily = FontFamily.Monospace, - maxLines = if (expanded) Int.MAX_VALUE else 4, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth().padding(8.dp) + tool.name.ifBlank { "工具 #${tool.id.take(8)}" }, + style = MaterialTheme.typography.labelLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + stateLabel, + style = MaterialTheme.typography.labelSmall, + color = stateColor ) } - AnimatedVisibility( - visible = !expanded, - enter = fadeIn() + expandVertically(), - exit = fadeOut() + shrinkVertically() - ) { - TextButton(onClick = { expanded = true }) { - Icon(Icons.Default.ExpandMore, contentDescription = null, modifier = Modifier.size(14.dp)) + if (hasOutput && shouldShowExpandButton) { + IconButton( + onClick = { expanded = !expanded }, + modifier = Modifier.size(32.dp) + ) { + Icon( + if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = if (expanded) "收起" else "展开", + modifier = Modifier.size(18.dp) + ) } } - AnimatedVisibility( - visible = expanded, - enter = fadeIn() + expandVertically(), - exit = fadeOut() + shrinkVertically() + } + + if (hasOutput) { + Spacer(Modifier.height(10.dp)) + Surface( + shape = RoundedCornerShape(10.dp), + color = Color.Black.copy(if (isSystemInDarkTheme()) 0.3f else 0.05f), + modifier = Modifier.fillMaxWidth() ) { - TextButton(onClick = { expanded = false }) { - Icon(Icons.Default.ExpandLess, contentDescription = null, modifier = Modifier.size(14.dp)) + Column(Modifier.padding(12.dp)) { + Text( + strings.toolOutput, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 6.dp) + ) + + Text( + tool.output, + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace + ), + maxLines = if (expanded) Int.MAX_VALUE else 5, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth() + ) + + if (!expanded && shouldShowExpandButton) { + Text( + "... 共 ${outputLines.size} 行", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + modifier = Modifier.padding(top = 6.dp) + ) + } } } } diff --git a/app/src/main/java/com/dlzz/coder/ui/components/MarkdownText.kt b/app/src/main/java/com/dlzz/coder/ui/components/MarkdownText.kt new file mode 100644 index 0000000..483535e --- /dev/null +++ b/app/src/main/java/com/dlzz/coder/ui/components/MarkdownText.kt @@ -0,0 +1,57 @@ +package com.dlzz.coder.ui.components + +import android.widget.TextView +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.viewinterop.AndroidView +import io.noties.markwon.Markwon +import io.noties.markwon.syntax.Prism4jTheme +import io.noties.markwon.syntax.Prism4jThemeDarkula +import io.noties.markwon.syntax.Prism4jThemeDefault +import io.noties.markwon.syntax.SyntaxHighlightPlugin + +@Composable +fun MarkdownText( + markdown: String, + modifier: Modifier = Modifier, + color: Color = LocalContentColor.current, + style: TextStyle = LocalTextStyle.current +) { + val context = LocalContext.current + val isDark = isSystemInDarkTheme() + + val markwon = remember(isDark) { + val theme: Prism4jTheme = if (isDark) { + Prism4jThemeDarkula.create() + } else { + Prism4jThemeDefault.create() + } + + Markwon.builder(context) + .usePlugin(SyntaxHighlightPlugin.create(theme)) + .build() + } + + AndroidView( + modifier = modifier, + factory = { ctx -> + TextView(ctx).apply { + setTextColor(color.toArgb()) + textSize = style.fontSize.value + } + }, + update = { textView -> + textView.setTextColor(color.toArgb()) + markwon.setMarkdown(textView, markdown) + } + ) +} diff --git a/app/src/main/java/com/dlzz/coder/ui/hosts/HostListScreen.kt b/app/src/main/java/com/dlzz/coder/ui/hosts/HostListScreen.kt index 5580335..90d66b3 100644 --- a/app/src/main/java/com/dlzz/coder/ui/hosts/HostListScreen.kt +++ b/app/src/main/java/com/dlzz/coder/ui/hosts/HostListScreen.kt @@ -14,6 +14,7 @@ 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.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -23,6 +24,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.QrCodeScanner import androidx.compose.material.icons.filled.Search @@ -200,9 +203,8 @@ fun HostListScreen( strings = strings, scanState = scanState, onDismiss = { showScanDialog = false }, - onStart = { targets, port, token -> + onStart = { port, token -> bridgeViewModel.scanAndAddHosts( - targetText = targets, port = port, token = token ) @@ -228,65 +230,82 @@ private fun ScanLanDialog( strings: Strings, scanState: BridgeViewModel.ScanState, onDismiss: () -> Unit, - onStart: (String, Int, String) -> Unit + onStart: (Int, String) -> Unit ) { - var targets by remember { mutableStateOf("") } var port by remember { mutableStateOf("8787") } var token by remember { mutableStateOf("") } AlertDialog( - onDismissRequest = onDismiss, + onDismissRequest = { if (!scanState.running) onDismiss() }, title = { Text(strings.scanLanTitle) }, text = { - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - OutlinedTextField( - value = targets, - onValueChange = { targets = it }, - label = { Text(strings.scanTargets) }, - placeholder = { Text(strings.scanTargetsHint) }, - minLines = 2, - maxLines = 4 + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + "将自动扫描所有网络接口(包括 VPN)", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant ) OutlinedTextField( value = port, onValueChange = { port = it }, label = { Text(strings.port) }, + enabled = !scanState.running, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - singleLine = true + singleLine = true, + modifier = Modifier.fillMaxWidth() ) OutlinedTextField( value = token, onValueChange = { token = it }, label = { Text(strings.token) }, placeholder = { Text(strings.scanTokenHint) }, - singleLine = true - ) - Text( - text = when { - scanState.running -> strings.scanRunning - scanState.message.isNotBlank() -> scanState.message - else -> strings.scanIdle - }, - style = MaterialTheme.typography.bodyMedium + enabled = !scanState.running, + singleLine = true, + modifier = Modifier.fillMaxWidth() ) - if (scanState.total > 0) { + if (scanState.running || scanState.message.isNotBlank()) { + Spacer(Modifier.height(4.dp)) Text( - text = "${scanState.scanned}/${scanState.total}", - style = MaterialTheme.typography.bodySmall + text = scanState.message, + style = MaterialTheme.typography.bodyMedium, + color = if (scanState.running) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface + } ) + if (scanState.total > 0) { + val progress = if (scanState.total > 0) { + (scanState.scanned.toFloat() / scanState.total * 100).toInt() + } else 0 + Text( + text = "进度: ${scanState.scanned}/${scanState.total} ($progress%)", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } } }, confirmButton = { Button( enabled = !scanState.running, - onClick = { onStart(targets, port.toIntOrNull() ?: 8787, token) } + onClick = { onStart(port.toIntOrNull() ?: 8787, token) } ) { - Text(strings.startScan) + if (scanState.running) { + Text(strings.scanRunning) + } else { + Text(strings.startScan) + } } }, dismissButton = { - TextButton(onClick = onDismiss) { Text(strings.cancel) } + TextButton( + enabled = !scanState.running, + onClick = onDismiss + ) { + Text(strings.cancel) + } } ) } @@ -406,6 +425,7 @@ private fun AddHostDialog( var host by remember { mutableStateOf("") } var port by remember { mutableStateOf("8787") } var token by remember { mutableStateOf("") } + var showAdvanced by remember { mutableStateOf(false) } var providerId by remember { mutableStateOf("") } var workspacePath by remember { mutableStateOf("") } var workspaceTitle by remember { mutableStateOf("") } @@ -415,19 +435,72 @@ private fun AddHostDialog( title = { Text(strings.addHost) }, text = { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - OutlinedTextField(value = name, onValueChange = { name = it }, label = { Text(strings.name) }, singleLine = true) - OutlinedTextField(value = host, onValueChange = { host = it }, label = { Text(strings.hostAddress) }, singleLine = true) + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text(strings.name) }, + placeholder = { Text("主机名称(可选)") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + OutlinedTextField( + value = host, + onValueChange = { host = it }, + label = { Text(strings.hostAddress) }, + placeholder = { Text("192.168.1.100") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) OutlinedTextField( value = port, onValueChange = { port = it }, label = { Text(strings.port) }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - singleLine = true + singleLine = true, + modifier = Modifier.fillMaxWidth() ) - OutlinedTextField(value = token, onValueChange = { token = it }, label = { Text(strings.token) }, singleLine = true) - OutlinedTextField(value = providerId, onValueChange = { providerId = it }, label = { Text(strings.providerId) }, singleLine = true) - OutlinedTextField(value = workspacePath, onValueChange = { workspacePath = it }, label = { Text(strings.workspacePath) }, singleLine = true) - OutlinedTextField(value = workspaceTitle, onValueChange = { workspaceTitle = it }, label = { Text(strings.workspaceTitle) }, singleLine = true) + OutlinedTextField( + value = token, + onValueChange = { token = it }, + label = { Text(strings.token) }, + placeholder = { Text("Token(可选)") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + TextButton(onClick = { showAdvanced = !showAdvanced }) { + Text(if (showAdvanced) "隐藏高级选项" else "显示高级选项") + Icon( + if (showAdvanced) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + } + + if (showAdvanced) { + OutlinedTextField( + value = providerId, + onValueChange = { providerId = it }, + label = { Text(strings.providerId) }, + placeholder = { Text("mock") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + OutlinedTextField( + value = workspacePath, + onValueChange = { workspacePath = it }, + label = { Text(strings.workspacePath) }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + OutlinedTextField( + value = workspaceTitle, + onValueChange = { workspaceTitle = it }, + label = { Text(strings.workspaceTitle) }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + } } }, confirmButton = { @@ -456,16 +529,62 @@ private fun NewSessionDialog( var providerId by remember { mutableStateOf(host.providerId.ifBlank { "mock" }) } var workspacePath by remember { mutableStateOf(host.workspacePath) } var workspaceTitle by remember { mutableStateOf(host.workspaceTitle) } + var showAdvanced by remember { mutableStateOf(false) } AlertDialog( onDismissRequest = onDismiss, title = { Text(strings.newSession) }, text = { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - Text(host.name, style = MaterialTheme.typography.bodyMedium) - OutlinedTextField(value = providerId, onValueChange = { providerId = it }, label = { Text(strings.providerId) }, singleLine = true) - OutlinedTextField(value = workspacePath, onValueChange = { workspacePath = it }, label = { Text(strings.workspacePath) }, singleLine = true) - OutlinedTextField(value = workspaceTitle, onValueChange = { workspaceTitle = it }, label = { Text(strings.workspaceTitle) }, singleLine = true) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + "主机:", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + host.name, + style = MaterialTheme.typography.bodyMedium + ) + } + + OutlinedTextField( + value = providerId, + onValueChange = { providerId = it }, + label = { Text(strings.providerId) }, + placeholder = { Text("mock") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + TextButton(onClick = { showAdvanced = !showAdvanced }) { + Text(if (showAdvanced) "隐藏高级选项" else "显示高级选项") + Icon( + if (showAdvanced) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + } + + if (showAdvanced) { + OutlinedTextField( + value = workspacePath, + onValueChange = { workspacePath = it }, + label = { Text(strings.workspacePath) }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + OutlinedTextField( + value = workspaceTitle, + onValueChange = { workspaceTitle = it }, + label = { Text(strings.workspaceTitle) }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + } } }, confirmButton = { diff --git a/app/src/main/java/com/dlzz/coder/ui/sessions/SessionListScreen.kt b/app/src/main/java/com/dlzz/coder/ui/sessions/SessionListScreen.kt index 475eb5e..794d6b1 100644 --- a/app/src/main/java/com/dlzz/coder/ui/sessions/SessionListScreen.kt +++ b/app/src/main/java/com/dlzz/coder/ui/sessions/SessionListScreen.kt @@ -85,6 +85,7 @@ fun SessionListScreen( Text( strings.sessionsSubtitle, style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis ) @@ -100,7 +101,28 @@ fun SessionListScreen( } if (hostSessions.isEmpty()) { item { - Text(strings.emptySessions, style = MaterialTheme.typography.bodyMedium) + Box( + Modifier + .fillMaxWidth() + .padding(vertical = 32.dp), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + strings.emptySessions, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + "连接主机后创建会话", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + } + } } } items(hostSessions, key = { it.host.id + ":" + it.session.sessionId }) { item -> @@ -144,19 +166,20 @@ private fun SessionCard( // Meta line excludes hostName (shown in chip below) to avoid redundancy val meta = session.displayMeta(hostName = "") val time = session.relativeTime() + val isActive = session.status.lowercase() in listOf("running", "active", "busy") Box( Modifier .fillMaxWidth() .animateContentSize() - .glassCard(cornerRadius = 16.dp) + .glassCard(cornerRadius = 16.dp, blur = if (isActive) 4.dp else 3.dp) .glassCombinedClickable( onClick = onClick, onLongClick = onLongClick ) - .padding(14.dp) + .padding(16.dp) ) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -164,8 +187,10 @@ private fun SessionCard( ) { Text( title, - style = MaterialTheme.typography.titleMedium, - maxLines = 1, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = if (isActive) androidx.compose.ui.text.font.FontWeight.SemiBold else androidx.compose.ui.text.font.FontWeight.Normal + ), + maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false) ) @@ -184,13 +209,13 @@ private fun SessionCard( meta, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, + maxLines = 2, overflow = TextOverflow.Ellipsis ) } Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(6.dp), + horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Row( @@ -206,19 +231,33 @@ private fun SessionCard( } AssistChip( onClick = {}, - label = { Text(item.host.name, maxLines = 1, overflow = TextOverflow.Ellipsis) } + label = { + Text( + item.host.name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.labelMedium + ) + } ) if (session.providerId.isNotBlank() && session.providerId != "mock") { AssistChip( onClick = {}, - label = { Text(session.providerId, maxLines = 1, overflow = TextOverflow.Ellipsis) } + label = { + Text( + session.providerId, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.labelMedium + ) + } ) } } Text( "#${session.sessionId.take(8)}", style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), maxLines = 1 ) } @@ -234,7 +273,11 @@ private fun StatusDot(status: String) { "error", "failed" -> Color(0xFFFF3B30) else -> Color.Gray } - Box(Modifier.size(8.dp).background(color, CircleShape)) + Box( + Modifier + .size(10.dp) + .background(color, CircleShape) + ) } @Composable diff --git a/app/src/main/java/com/dlzz/coder/viewmodel/BridgeViewModel.kt b/app/src/main/java/com/dlzz/coder/viewmodel/BridgeViewModel.kt index ed0e118..62a1fbf 100644 --- a/app/src/main/java/com/dlzz/coder/viewmodel/BridgeViewModel.kt +++ b/app/src/main/java/com/dlzz/coder/viewmodel/BridgeViewModel.kt @@ -143,7 +143,6 @@ class BridgeViewModel(application: Application) : AndroidViewModel(application) } fun scanAndAddHosts( - targetText: String, port: Int = 8787, token: String = "", providerId: String = "", @@ -152,43 +151,65 @@ class BridgeViewModel(application: Application) : AndroidViewModel(application) ) { if (_scanState.value.running) return viewModelScope.launch { - _scanState.value = ScanState(running = true, message = "正在准备扫描") - val candidates = withContext(Dispatchers.IO) { - buildScanCandidates(targetText, port) - } - if (candidates.isEmpty()) { - _scanState.value = ScanState(message = "没有可扫描的目标") - return@launch - } + try { + _scanState.value = ScanState(running = true, message = "正在检测网络接口...") + val candidates = withContext(Dispatchers.IO) { + buildScanCandidatesFromAllInterfaces(port) + } + if (candidates.isEmpty()) { + _scanState.value = ScanState(message = "未检测到可用的网络接口") + return@launch + } - _scanState.value = ScanState(running = true, total = candidates.size, message = "正在扫描") - val found = withContext(Dispatchers.IO) { - scanHealthEndpoints(candidates) - } - var added = 0 - for (candidate in found) { - val exists = _hosts.value.any { it.host == candidate.host && it.port == candidate.port } - if (!exists) { - addHost( - name = candidate.host + ":" + candidate.port, - host = candidate.host, - port = candidate.port, - token = token, - providerId = providerId, - workspacePath = workspacePath, - workspaceTitle = workspaceTitle, - connectNow = token.isNotBlank() - ) - added += 1 + _scanState.value = ScanState(running = true, total = candidates.size, message = "正在扫描 ${candidates.size} 个目标...") + val found = withContext(Dispatchers.IO) { + scanHealthEndpointsWithProgress(candidates) { scanned -> + _scanState.value = _scanState.value.copy(scanned = scanned) + } + } + var added = 0 + var skipped = 0 + for (candidate in found) { + val exists = _hosts.value.any { it.host == candidate.host && it.port == candidate.port } + if (!exists) { + addHost( + name = candidate.host + ":" + candidate.port, + host = candidate.host, + port = candidate.port, + token = token, + providerId = providerId, + workspacePath = workspacePath, + workspaceTitle = workspaceTitle, + connectNow = token.isNotBlank() + ) + added += 1 + } else { + skipped += 1 + } + } + val resultMsg = buildString { + append("扫描完成:") + if (added > 0) append("新增 $added 台") + if (skipped > 0) { + if (added > 0) append(",") + append("已存在 $skipped 台") + } + if (added == 0 && skipped == 0) append("未发现可用主机") } + _scanState.value = ScanState( + running = false, + scanned = candidates.size, + total = candidates.size, + added = added, + message = resultMsg + ) + } catch (e: Exception) { + LogCollector.e("BridgeVM", "Scan error: ${e.message}", e) + _scanState.value = ScanState( + running = false, + message = "扫描失败:${e.message}" + ) } - _scanState.value = ScanState( - running = false, - scanned = candidates.size, - total = candidates.size, - added = added, - message = if (added > 0) "已添加 $added 台主机" else "未发现新的 Bridge 主机" - ) } } @@ -627,6 +648,23 @@ class BridgeViewModel(application: Application) : AndroidViewModel(application) ) } + private fun buildScanCandidatesFromAllInterfaces(port: Int): List { + val prefixes = localInterfacePrefixes() + if (prefixes.isEmpty()) { + LogCollector.w("BridgeVM", "No active network interfaces found") + return emptyList() + } + + LogCollector.i("BridgeVM", "Found ${prefixes.size} network interface(s): ${prefixes.joinToString()}") + + return prefixes.flatMap { prefix -> + (1..254).map { "$prefix.$it" } + } + .distinct() + .take(MAX_SCAN_CANDIDATES) + .map { ScanCandidate(it, port) } + } + private fun buildScanCandidates(targetText: String, port: Int): List { val targets = targetText .split(',', '\n', ';') @@ -652,12 +690,21 @@ class BridgeViewModel(application: Application) : AndroidViewModel(application) val interfaces = NetworkInterface.getNetworkInterfaces() for (networkInterface in interfaces) { if (!networkInterface.isUp || networkInterface.isLoopback) continue + + // Log interface info for debugging + val name = networkInterface.name + val displayName = networkInterface.displayName + LogCollector.d("BridgeVM", "Checking interface: $name ($displayName)") + val addresses = networkInterface.inetAddresses for (address in addresses) { if (address is Inet4Address && !address.isLoopbackAddress) { - val parts = address.hostAddress.orEmpty().split('.') + val ipAddress = address.hostAddress.orEmpty() + val parts = ipAddress.split('.') if (parts.size == 4) { - result += parts.take(3).joinToString(".") + val prefix = parts.take(3).joinToString(".") + result += prefix + LogCollector.d("BridgeVM", "Added prefix $prefix from $ipAddress on $name") } } } @@ -714,18 +761,31 @@ class BridgeViewModel(application: Application) : AndroidViewModel(application) ).joinToString(".") } - private suspend fun scanHealthEndpoints(candidates: List): List { + private suspend fun scanHealthEndpointsWithProgress( + candidates: List, + onProgress: (Int) -> Unit + ): List { return coroutineScope { - candidates.chunked(32).flatMap { chunk -> - chunk.map { candidate -> + val results = mutableListOf() + var scanned = 0 + candidates.chunked(16).forEach { chunk -> + val chunkResults = chunk.map { candidate -> async { if (isBridgeHealthy(candidate)) candidate else null } }.awaitAll().filterNotNull() + results.addAll(chunkResults) + scanned += chunk.size + onProgress(scanned) } + results } } + private suspend fun scanHealthEndpoints(candidates: List): List { + return scanHealthEndpointsWithProgress(candidates) { } + } + private fun isBridgeHealthy(candidate: ScanCandidate): Boolean { val request = Request.Builder() .url("http://${candidate.host}:${candidate.port}/health") diff --git a/app/src/main/java/com/dlzz/coder/viewmodel/ChatViewModel.kt b/app/src/main/java/com/dlzz/coder/viewmodel/ChatViewModel.kt index b3045e2..731fc0f 100644 --- a/app/src/main/java/com/dlzz/coder/viewmodel/ChatViewModel.kt +++ b/app/src/main/java/com/dlzz/coder/viewmodel/ChatViewModel.kt @@ -16,6 +16,8 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.serialization.json.JsonObject class ChatViewModel(private val bridgeViewModel: BridgeViewModel) : ViewModel() { @@ -28,27 +30,34 @@ class ChatViewModel(private val bridgeViewModel: BridgeViewModel) : ViewModel() private val _isLoading = MutableStateFlow(false) val isLoading: StateFlow = _isLoading + private val messageMutex = Mutex() private var listenJob: Job? = null private var processedCount = 0 private var historyLoaded = false fun sendMessage(hostId: String, sessionId: String, text: String) { - _messages.value = _messages.value + ChatMessage(role = "user", text = text) + viewModelScope.launch { + messageMutex.withLock { + _messages.value = _messages.value + ChatMessage(role = "user", text = text) + } + } bridgeViewModel.send(hostId, RequestType.MESSAGE_SEND, mapOf( "sessionId" to sessionId, "text" to text )) } - fun appendAssistantDelta(text: String) { - val msgs = _messages.value.toMutableList() - val last = msgs.lastOrNull() - if (last != null && last.role == "assistant") { - msgs[msgs.lastIndex] = last.copy(text = last.text + text) - } else { - msgs.add(ChatMessage(role = "assistant", text = text)) + suspend fun appendAssistantDelta(text: String) { + messageMutex.withLock { + val msgs = _messages.value.toMutableList() + val last = msgs.lastOrNull() + if (last != null && last.role == "assistant") { + msgs[msgs.lastIndex] = last.copy(text = last.text + text) + } else { + msgs.add(ChatMessage(role = "assistant", text = text)) + } + _messages.value = msgs } - _messages.value = msgs } fun startListening(hostId: String, sessionId: String) { @@ -68,7 +77,7 @@ class ChatViewModel(private val bridgeViewModel: BridgeViewModel) : ViewModel() listenJob = viewModelScope.launch { val historyTimeoutJob = launch { - delay(10_000L) + delay(15_000L) // Increased timeout to 15 seconds if (!historyLoaded) { LogCollector.w("ChatVM", "session.messages timed out for session=${sessionId.take(20)}") _isLoading.value = false @@ -148,7 +157,9 @@ class ChatViewModel(private val bridgeViewModel: BridgeViewModel) : ViewModel() EventType.ERROR -> { val errorMsg = msg.error?.message ?: "Unknown error" LogCollector.e("ChatVM", "Error event: $errorMsg") - _messages.value = _messages.value + ChatMessage(role = "system", text = "错误:$errorMsg") + messageMutex.withLock { + _messages.value = _messages.value + ChatMessage(role = "system", text = "错误:$errorMsg") + } _isLoading.value = false } } diff --git a/app/src/main/java/com/dlzz/coder/viewmodel/FileViewModel.kt b/app/src/main/java/com/dlzz/coder/viewmodel/FileViewModel.kt index d3d3ef6..b34f128 100644 --- a/app/src/main/java/com/dlzz/coder/viewmodel/FileViewModel.kt +++ b/app/src/main/java/com/dlzz/coder/viewmodel/FileViewModel.kt @@ -25,41 +25,51 @@ class FileViewModel(private val bridgeViewModel: BridgeViewModel) : ViewModel() private val _preview = MutableStateFlow(null) val preview: StateFlow = _preview - private var processedCount = 0 + private var activeHostId = "" private var activeSessionId = "" + private val sessionEventCounts = mutableMapOf() init { viewModelScope.launch { - bridgeViewModel.eventsByHost.collect { eventMap -> - val messages = eventMap[activeHostId].orEmpty() - messages.drop(processedCount).forEach(::handleMessage) - processedCount = messages.size + bridgeViewModel.sessionEvents.collect { sessionMap -> + val key = sessionEventKey(activeHostId, activeSessionId) + val messages = sessionMap[key].orEmpty() + val lastProcessed = sessionEventCounts[key] ?: 0 + + messages.drop(lastProcessed).forEach(::handleMessage) + sessionEventCounts[key] = messages.size } } } fun listFiles(hostId: String, sessionId: String) { if (activeHostId != hostId || activeSessionId != sessionId) { - processedCount = 0 + // Switching to a new session, reset state + activeHostId = hostId + activeSessionId = sessionId + _files.value = emptyList() + _preview.value = null } - activeHostId = hostId - activeSessionId = sessionId bridgeViewModel.send(hostId, RequestType.WORKSPACE_FILES_LIST, mapOf("sessionId" to sessionId)) } fun getFile(hostId: String, sessionId: String, path: String) { if (activeHostId != hostId || activeSessionId != sessionId) { - processedCount = 0 + activeHostId = hostId + activeSessionId = sessionId + _preview.value = null } - activeHostId = hostId - activeSessionId = sessionId bridgeViewModel.send(hostId, RequestType.WORKSPACE_FILE_GET, mapOf( "sessionId" to sessionId, "path" to path )) } + private fun sessionEventKey(hostId: String, sessionId: String): String { + return "$hostId|$sessionId" + } + private fun handleMessage(message: ServerWireMessage) { if (activeSessionId.isNotBlank() && message.sessionId.isNotBlank() && message.sessionId != activeSessionId) { return diff --git a/app/src/main/java/com/dlzz/coder/viewmodel/SessionViewModel.kt b/app/src/main/java/com/dlzz/coder/viewmodel/SessionViewModel.kt deleted file mode 100644 index c836b67..0000000 --- a/app/src/main/java/com/dlzz/coder/viewmodel/SessionViewModel.kt +++ /dev/null @@ -1,89 +0,0 @@ -package com.dlzz.coder.viewmodel - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.dlzz.coder.bridge.EventType -import com.dlzz.coder.bridge.RequestType -import com.dlzz.coder.bridge.ServerWireMessage -import com.dlzz.coder.bridge.SessionInfo -import com.dlzz.coder.bridge.arrayValue -import com.dlzz.coder.bridge.longValue -import com.dlzz.coder.bridge.objectValue -import com.dlzz.coder.bridge.stringValue -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.launch -import kotlinx.serialization.json.JsonObject - -class SessionViewModel(private val bridgeViewModel: BridgeViewModel) : ViewModel() { - private val _sessions = MutableStateFlow>(emptyList()) - val sessions: StateFlow> = _sessions - private var processedCount = 0 - - init { - viewModelScope.launch { - bridgeViewModel.events.collect { messages -> - messages.drop(processedCount).forEach(::handleMessage) - processedCount = messages.size - } - } - } - - fun createSession(providerId: String, workspacePath: String) { - bridgeViewModel.send(RequestType.SESSION_CREATE, mapOf( - "providerId" to providerId, - "workspacePath" to workspacePath - )) - } - - fun listSessions() { - bridgeViewModel.send(RequestType.SESSION_LIST) - } - - private fun handleMessage(message: ServerWireMessage) { - when { - message.event == EventType.SESSION_CREATED || - message.event == EventType.SESSION_UPDATED -> { - parseSession(message.payload, message.sessionId)?.let(::upsertSession) - } - message.type == RequestType.SESSION_CREATE -> { - parseSession(message.payload, message.sessionId)?.let(::upsertSession) - } - message.type == RequestType.SESSION_LIST || - message.event == EventType.SESSION_MESSAGES -> { - parseSessionList(message.payload).forEach(::upsertSession) - } - } - } - - private fun parseSessionList(payload: JsonObject?): List { - if (payload == null) return emptyList() - val sessions = payload.arrayValue("sessions") ?: payload.arrayValue("items") ?: return emptyList() - return sessions.mapNotNull { entry -> - parseSession(entry as? JsonObject, fallbackSessionId = null) - } - } - - private fun parseSession(payload: JsonObject?, fallbackSessionId: String?): SessionInfo? { - val sessionPayload = payload?.objectValue("session") ?: payload - val sessionId = sessionPayload?.stringValue("sessionId") - ?: sessionPayload?.stringValue("id") - ?: fallbackSessionId - if (sessionId.isNullOrBlank()) return null - - return SessionInfo( - sessionId = sessionId, - providerId = sessionPayload?.stringValue("providerId").orEmpty(), - workspacePath = sessionPayload?.stringValue("workspacePath").orEmpty(), - createdAt = sessionPayload?.longValue("createdAt") ?: System.currentTimeMillis() - ) - } - - private fun upsertSession(session: SessionInfo) { - val next = _sessions.value - .filterNot { it.sessionId == session.sessionId } - .plus(session) - .sortedByDescending { it.activityAt() } - _sessions.value = next - } -} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 28dd0e7..f8fee1c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,6 +12,7 @@ kyantShapes = "1.2.0" kotlinxSerializationJson = "1.7.3" zxingAndroidEmbedded = "4.3.0" junit = "4.13.2" +markwon = "4.6.2" [plugins] android-application = { id = "com.android.application", version.ref = "agp" } @@ -38,4 +39,6 @@ backdrop = { module = "io.github.kyant0:backdrop", version.ref = "backdrop" } kyant-shapes = { module = "io.github.kyant0:shapes", version.ref = "kyantShapes" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } zxing-android-embedded = { module = "com.journeyapps:zxing-android-embedded", version.ref = "zxingAndroidEmbedded" } +markwon-core = { module = "io.noties.markwon:core", version.ref = "markwon" } +markwon-syntax-highlight = { module = "io.noties.markwon:syntax-highlight", version.ref = "markwon" } junit = { module = "junit:junit", version.ref = "junit" }