Forráskód Böngészése

feat: Android App(Capacitor,接入 Agent API 与 Bot API)与下载页

- apps/mobile:Vue 3 + Capacitor 8,复用 chat-ui 与 client SDK;在 App 中填写 Agent Key / Bot Key,
  支持对话(Profile、会话管理、流式输出、审批、上传、文件、用量)与工作组(新建、群聊、状态、成员、任务、文件、用量、限额)
- 服务端新增公开下载页 /app(介绍、二维码、安装说明)与 /app/index.json,安装包由 deploy/release-apk.sh 发布
- 后台活动栏增加 Android App 下载入口
- 根目录脚本:pnpm dev:mobile、pnpm apk

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jerry Liang 1 hete
szülő
commit
8cebdbbd3d
95 módosított fájl, 3286 hozzáadás és 12 törlés
  1. 1 0
      .gitignore
  2. 4 0
      apps/admin/src/components/Workbench.vue
  3. 57 0
      apps/mobile/README.md
  4. 101 0
      apps/mobile/android/.gitignore
  5. 2 0
      apps/mobile/android/app/.gitignore
  6. 58 0
      apps/mobile/android/app/build.gradle
  7. 22 0
      apps/mobile/android/app/capacitor.build.gradle
  8. 21 0
      apps/mobile/android/app/proguard-rules.pro
  9. 26 0
      apps/mobile/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java
  10. 41 0
      apps/mobile/android/app/src/main/AndroidManifest.xml
  11. 5 0
      apps/mobile/android/app/src/main/java/cc/zenpower/agent/MainActivity.java
  12. BIN
      apps/mobile/android/app/src/main/res/drawable-land-hdpi/splash.png
  13. BIN
      apps/mobile/android/app/src/main/res/drawable-land-mdpi/splash.png
  14. BIN
      apps/mobile/android/app/src/main/res/drawable-land-xhdpi/splash.png
  15. BIN
      apps/mobile/android/app/src/main/res/drawable-land-xxhdpi/splash.png
  16. BIN
      apps/mobile/android/app/src/main/res/drawable-land-xxxhdpi/splash.png
  17. BIN
      apps/mobile/android/app/src/main/res/drawable-port-hdpi/splash.png
  18. BIN
      apps/mobile/android/app/src/main/res/drawable-port-mdpi/splash.png
  19. BIN
      apps/mobile/android/app/src/main/res/drawable-port-xhdpi/splash.png
  20. BIN
      apps/mobile/android/app/src/main/res/drawable-port-xxhdpi/splash.png
  21. BIN
      apps/mobile/android/app/src/main/res/drawable-port-xxxhdpi/splash.png
  22. 34 0
      apps/mobile/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml
  23. 170 0
      apps/mobile/android/app/src/main/res/drawable/ic_launcher_background.xml
  24. BIN
      apps/mobile/android/app/src/main/res/drawable/splash.png
  25. 12 0
      apps/mobile/android/app/src/main/res/layout/activity_main.xml
  26. 5 0
      apps/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
  27. 5 0
      apps/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
  28. BIN
      apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
  29. BIN
      apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
  30. BIN
      apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
  31. BIN
      apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
  32. BIN
      apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
  33. BIN
      apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
  34. BIN
      apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
  35. BIN
      apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
  36. BIN
      apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
  37. BIN
      apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
  38. BIN
      apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
  39. BIN
      apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
  40. BIN
      apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
  41. BIN
      apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
  42. BIN
      apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
  43. 4 0
      apps/mobile/android/app/src/main/res/values/ic_launcher_background.xml
  44. 7 0
      apps/mobile/android/app/src/main/res/values/strings.xml
  45. 22 0
      apps/mobile/android/app/src/main/res/values/styles.xml
  46. 5 0
      apps/mobile/android/app/src/main/res/xml/file_paths.xml
  47. 18 0
      apps/mobile/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java
  48. 29 0
      apps/mobile/android/build.gradle
  49. 15 0
      apps/mobile/android/capacitor.settings.gradle
  50. 22 0
      apps/mobile/android/gradle.properties
  51. BIN
      apps/mobile/android/gradle/wrapper/gradle-wrapper.jar
  52. 7 0
      apps/mobile/android/gradle/wrapper/gradle-wrapper.properties
  53. 251 0
      apps/mobile/android/gradlew
  54. 94 0
      apps/mobile/android/gradlew.bat
  55. 5 0
      apps/mobile/android/settings.gradle
  56. 16 0
      apps/mobile/android/variables.gradle
  57. 11 0
      apps/mobile/capacitor.config.ts
  58. 12 0
      apps/mobile/index.html
  59. 36 0
      apps/mobile/package.json
  60. 32 0
      apps/mobile/src/App.vue
  61. 31 0
      apps/mobile/src/components/ActionSheet.vue
  62. 28 0
      apps/mobile/src/components/BotCardItem.vue
  63. 41 0
      apps/mobile/src/components/FileList.vue
  64. 41 0
      apps/mobile/src/components/FileViewer.vue
  65. 25 0
      apps/mobile/src/components/TabBar.vue
  66. 22 0
      apps/mobile/src/components/TopBar.vue
  67. 8 0
      apps/mobile/src/env.d.ts
  68. 66 0
      apps/mobile/src/lib/config.ts
  69. 52 0
      apps/mobile/src/lib/files.ts
  70. 37 0
      apps/mobile/src/lib/sessions.ts
  71. 53 0
      apps/mobile/src/lib/ui.ts
  72. 23 0
      apps/mobile/src/main.ts
  73. 25 0
      apps/mobile/src/router.ts
  74. 28 0
      apps/mobile/src/style.css
  75. 56 0
      apps/mobile/src/views/ChatView.vue
  76. 69 0
      apps/mobile/src/views/NewTeamView.vue
  77. 16 0
      apps/mobile/src/views/SessionFilesView.vue
  78. 94 0
      apps/mobile/src/views/SessionsView.vue
  79. 109 0
      apps/mobile/src/views/SettingsView.vue
  80. 227 0
      apps/mobile/src/views/TeamInfoView.vue
  81. 31 0
      apps/mobile/src/views/TeamView.vue
  82. 66 0
      apps/mobile/src/views/TeamsView.vue
  83. 62 0
      apps/mobile/src/views/UsageView.vue
  84. 10 0
      apps/mobile/tsconfig.json
  85. 18 0
      apps/mobile/uno.config.ts
  86. 21 0
      apps/mobile/vite.config.ts
  87. 2 0
      apps/server/package.json
  88. 2 0
      apps/server/src/config.ts
  89. 145 0
      apps/server/src/http/mobile-app.ts
  90. 5 0
      apps/server/src/http/server.ts
  91. 48 0
      apps/server/test/mobile-app.test.ts
  92. 22 0
      deploy/release-apk.sh
  93. 2 0
      package.json
  94. 619 12
      pnpm-lock.yaml
  95. 32 0
      scripts/release-apk.mjs

+ 1 - 0
.gitignore

@@ -7,3 +7,4 @@ dist/
 .DS_Store
 .dev-data/
 dist-sdk/
+dist-mobile/

+ 4 - 0
apps/admin/src/components/Workbench.vue

@@ -116,6 +116,10 @@ onBeforeUnmount(() => clearInterval(timer))
           <span class="codicon text-[22px]" :class="g.icon" />
         </button>
         <div class="flex-1" />
+        <!-- Android App 下载页(公开页面,新标签页打开) -->
+        <a href="/app" target="_blank" rel="noopener" title="Android App 下载" class="w-12 h-11 flex items-center justify-center text-fg-muted hover:text-fg no-underline">
+          <span class="codicon codicon-device-mobile text-[20px]" />
+        </a>
         <button type="button" title="切换主题" class="w-12 h-11 flex items-center justify-center border-none bg-transparent cursor-pointer text-fg-muted hover:text-fg" @click="cycleTheme">
           <span class="codicon text-[20px]" :class="theme === 'dark' ? 'codicon-color-mode' : theme === 'light' ? 'codicon-lightbulb' : 'codicon-device-desktop'" />
         </button>

+ 57 - 0
apps/mobile/README.md

@@ -0,0 +1,57 @@
+# Android App(apps/mobile)
+
+自己使用的 Android 客户端:在 App 里填写 Agent Key 和 Bot Key,直接调用 AgentServer 的 `/v1`(对话)和 `/bot/v1`(工作组)。
+
+技术栈:Vue 3 + Capacitor 8,界面复用 `@agent-server/chat-ui`(`AgentChat`、`TeamChat`),请求复用 `@agent-server/client`。
+
+## 功能
+
+| 模块 | 功能 |
+|---|---|
+| 对话(Agent Key) | 选择 Profile 新建会话;会话列表、已归档、重命名、归档、删除;流式输出、中断、审批与提问、上传附件;定时任务与后台调用(组件自带);会话文件;用量统计 |
+| 工作组(Bot Key) | 新建工作组(选组长、组员,查看能力卡片);按状态筛选;群聊、@ 机器人、任务进度、限额卡片;暂停、恢复、关闭、重新打开、重命名、删除;成员的添加、移出、停止、开始新会话;任务列表(继续或取消);文件;用量;限额 |
+| 设置 | 服务地址、两种 Key(可测试连接)、在工作组中的称呼、主题 |
+
+文件:图片和文本在 App 内预览,其他文件通过系统分享面板用其他 App 打开或保存。
+
+## 服务端需要的配置
+
+App 的 WebView 来源是 `https://mobile.zenpower.local`(见 `capacitor.config.ts`)。需要在后台「系统设置」的「允许浏览器直接调用 API 的业务平台来源(CORS)」中加入这个地址,否则请求会被跨域拦截。
+
+## 开发
+
+```bash
+pnpm dev:mobile          # 浏览器中调试:http://127.0.0.1:5175,/v1 与 /bot/v1 通过 Vite 代理转发
+AGENT_SERVER_URL=http://127.0.0.1:8788 pnpm dev:mobile   # 改为转发到本地开发服务
+```
+
+浏览器调试时,用 DevTools 切换到手机尺寸。设置保存在 localStorage 中,不需要配置跨域。
+
+## 打包 APK
+
+```bash
+pnpm apk                 # 构建前端 → cap sync → gradlew assembleDebug
+```
+
+产物:`apps/mobile/android/app/build/outputs/apk/debug/app-debug.apk`,复制到手机后安装(需要允许安装未知来源的应用)。
+
+本机环境:
+
+- JDK 21(Capacitor 8 需要),通过 `~/.gradle/gradle.properties` 的 `org.gradle.java.home` 指定
+- Android SDK 在 `~/Android/Sdk`,通过 `android/local.properties` 的 `sdk.dir` 指定(该文件不提交);缺少的 SDK 平台由 Gradle 自动下载
+
+## 发布到下载页
+
+```bash
+# 先修改 package.json 的 version(versionCode 由它计算,覆盖安装要求只增不减)
+deploy/release-apk.sh    # 打包 → dist-mobile/ → /srv/agent-server/app/mobile/,不需要重启服务
+```
+
+下载页:`https://agent.zenpower.cc/app`(公开,含介绍、扫码与安装说明);`/app/index.json` 为最新版本信息。
+
+`pnpm -r build`(部署脚本使用)不会构建这个应用:前端构建命令是 `build:web`,不叫 `build`。
+
+## 限制
+
+- App 在后台时系统会断开流式连接,回到前台后自动重连并补齐消息;没有推送通知。
+- Key 保存在 App 的私有存储中(未加密)。手机丢失时,到后台停用对应的 Key。

+ 101 - 0
apps/mobile/android/.gitignore

@@ -0,0 +1,101 @@
+# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
+
+# Built application files
+*.apk
+*.aar
+*.ap_
+*.aab
+
+# Files for the ART/Dalvik VM
+*.dex
+
+# Java class files
+*.class
+
+# Generated files
+bin/
+gen/
+out/
+#  Uncomment the following line in case you need and you don't have the release build type files in your app
+# release/
+
+# Gradle files
+.gradle/
+build/
+
+# Local configuration file (sdk path, etc)
+local.properties
+
+# Proguard folder generated by Eclipse
+proguard/
+
+# Log Files
+*.log
+
+# Android Studio Navigation editor temp files
+.navigation/
+
+# Android Studio captures folder
+captures/
+
+# IntelliJ
+*.iml
+.idea/workspace.xml
+.idea/tasks.xml
+.idea/gradle.xml
+.idea/assetWizardSettings.xml
+.idea/dictionaries
+.idea/libraries
+# Android Studio 3 in .gitignore file.
+.idea/caches
+.idea/modules.xml
+# Comment next line if keeping position of elements in Navigation Editor is relevant for you
+.idea/navEditor.xml
+
+# Keystore files
+# Uncomment the following lines if you do not want to check your keystore files in.
+#*.jks
+#*.keystore
+
+# External native build folder generated in Android Studio 2.2 and later
+.externalNativeBuild
+.cxx/
+
+# Google Services (e.g. APIs or Firebase)
+# google-services.json
+
+# Freeline
+freeline.py
+freeline/
+freeline_project_description.json
+
+# fastlane
+fastlane/report.xml
+fastlane/Preview.html
+fastlane/screenshots
+fastlane/test_output
+fastlane/readme.md
+
+# Version control
+vcs.xml
+
+# lint
+lint/intermediates/
+lint/generated/
+lint/outputs/
+lint/tmp/
+# lint/reports/
+
+# Android Profiling
+*.hprof
+
+# Cordova plugins for Capacitor
+capacitor-cordova-android-plugins
+
+# Copied web assets
+app/src/main/assets/public
+
+# Generated Config files
+app/src/main/assets/capacitor.config.json
+app/src/main/assets/capacitor.plugins.json
+app/src/main/res/xml/config.xml

+ 2 - 0
apps/mobile/android/app/.gitignore

@@ -0,0 +1,2 @@
+/build/*
+!/build/.npmkeep

+ 58 - 0
apps/mobile/android/app/build.gradle

@@ -0,0 +1,58 @@
+apply plugin: 'com.android.application'
+
+// 版本号取自 apps/mobile/package.json:versionCode = 主版本 * 10000 + 次版本 * 100 + 修订号(覆盖安装要求只增不减)
+def appVersion = new groovy.json.JsonSlurper().parse(file('../../package.json')).version
+def versionParts = appVersion.tokenize('.').collect { it.toInteger() }
+
+android {
+    namespace = "cc.zenpower.agent"
+    compileSdk = rootProject.ext.compileSdkVersion
+    defaultConfig {
+        applicationId "cc.zenpower.agent"
+        minSdkVersion rootProject.ext.minSdkVersion
+        targetSdkVersion rootProject.ext.targetSdkVersion
+        versionCode versionParts[0] * 10000 + versionParts[1] * 100 + versionParts[2]
+        versionName appVersion
+        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+        aaptOptions {
+             // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+             // Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
+            ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
+        }
+    }
+    buildTypes {
+        release {
+            minifyEnabled false
+            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
+        }
+    }
+}
+
+repositories {
+    flatDir{
+        dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
+    }
+}
+
+dependencies {
+    implementation fileTree(include: ['*.jar'], dir: 'libs')
+    implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
+    implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
+    implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
+    implementation project(':capacitor-android')
+    testImplementation "junit:junit:$junitVersion"
+    androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
+    androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
+    implementation project(':capacitor-cordova-android-plugins')
+}
+
+apply from: 'capacitor.build.gradle'
+
+try {
+    def servicesJSON = file('google-services.json')
+    if (servicesJSON.text) {
+        apply plugin: 'com.google.gms.google-services'
+    }
+} catch(Exception e) {
+    logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
+}

+ 22 - 0
apps/mobile/android/app/capacitor.build.gradle

@@ -0,0 +1,22 @@
+// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
+
+android {
+  compileOptions {
+      sourceCompatibility JavaVersion.VERSION_21
+      targetCompatibility JavaVersion.VERSION_21
+  }
+}
+
+apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
+dependencies {
+    implementation project(':capacitor-app')
+    implementation project(':capacitor-filesystem')
+    implementation project(':capacitor-preferences')
+    implementation project(':capacitor-share')
+
+}
+
+
+if (hasProperty('postBuildExtras')) {
+  postBuildExtras()
+}

+ 21 - 0
apps/mobile/android/app/proguard-rules.pro

@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+#   http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+#   public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile

+ 26 - 0
apps/mobile/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java

@@ -0,0 +1,26 @@
+package com.getcapacitor.myapp;
+
+import static org.junit.Assert.*;
+
+import android.content.Context;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.platform.app.InstrumentationRegistry;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
+ */
+@RunWith(AndroidJUnit4.class)
+public class ExampleInstrumentedTest {
+
+    @Test
+    public void useAppContext() throws Exception {
+        // Context of the app under test.
+        Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
+
+        assertEquals("com.getcapacitor.app", appContext.getPackageName());
+    }
+}

+ 41 - 0
apps/mobile/android/app/src/main/AndroidManifest.xml

@@ -0,0 +1,41 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+
+    <application
+        android:allowBackup="true"
+        android:icon="@mipmap/ic_launcher"
+        android:label="@string/app_name"
+        android:roundIcon="@mipmap/ic_launcher_round"
+        android:supportsRtl="true"
+        android:theme="@style/AppTheme">
+
+        <activity
+            android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
+            android:name=".MainActivity"
+            android:label="@string/title_activity_main"
+            android:theme="@style/AppTheme.NoActionBarLaunch"
+            android:launchMode="singleTask"
+            android:exported="true">
+
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+                <category android:name="android.intent.category.LAUNCHER" />
+            </intent-filter>
+
+        </activity>
+
+        <provider
+            android:name="androidx.core.content.FileProvider"
+            android:authorities="${applicationId}.fileprovider"
+            android:exported="false"
+            android:grantUriPermissions="true">
+            <meta-data
+                android:name="android.support.FILE_PROVIDER_PATHS"
+                android:resource="@xml/file_paths"></meta-data>
+        </provider>
+    </application>
+
+    <!-- Permissions -->
+
+    <uses-permission android:name="android.permission.INTERNET" />
+</manifest>

+ 5 - 0
apps/mobile/android/app/src/main/java/cc/zenpower/agent/MainActivity.java

@@ -0,0 +1,5 @@
+package cc.zenpower.agent;
+
+import com.getcapacitor.BridgeActivity;
+
+public class MainActivity extends BridgeActivity {}

BIN
apps/mobile/android/app/src/main/res/drawable-land-hdpi/splash.png


BIN
apps/mobile/android/app/src/main/res/drawable-land-mdpi/splash.png


BIN
apps/mobile/android/app/src/main/res/drawable-land-xhdpi/splash.png


BIN
apps/mobile/android/app/src/main/res/drawable-land-xxhdpi/splash.png


BIN
apps/mobile/android/app/src/main/res/drawable-land-xxxhdpi/splash.png


BIN
apps/mobile/android/app/src/main/res/drawable-port-hdpi/splash.png


BIN
apps/mobile/android/app/src/main/res/drawable-port-mdpi/splash.png


BIN
apps/mobile/android/app/src/main/res/drawable-port-xhdpi/splash.png


BIN
apps/mobile/android/app/src/main/res/drawable-port-xxhdpi/splash.png


BIN
apps/mobile/android/app/src/main/res/drawable-port-xxxhdpi/splash.png


+ 34 - 0
apps/mobile/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml

@@ -0,0 +1,34 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:aapt="http://schemas.android.com/aapt"
+    android:width="108dp"
+    android:height="108dp"
+    android:viewportHeight="108"
+    android:viewportWidth="108">
+    <path
+        android:fillType="evenOdd"
+        android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
+        android:strokeColor="#00000000"
+        android:strokeWidth="1">
+        <aapt:attr name="android:fillColor">
+            <gradient
+                android:endX="78.5885"
+                android:endY="90.9159"
+                android:startX="48.7653"
+                android:startY="61.0927"
+                android:type="linear">
+                <item
+                    android:color="#44000000"
+                    android:offset="0.0" />
+                <item
+                    android:color="#00000000"
+                    android:offset="1.0" />
+            </gradient>
+        </aapt:attr>
+    </path>
+    <path
+        android:fillColor="#FFFFFF"
+        android:fillType="nonZero"
+        android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
+        android:strokeColor="#00000000"
+        android:strokeWidth="1" />
+</vector>

+ 170 - 0
apps/mobile/android/app/src/main/res/drawable/ic_launcher_background.xml

@@ -0,0 +1,170 @@
+<?xml version="1.0" encoding="utf-8"?>
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="108dp"
+    android:height="108dp"
+    android:viewportHeight="108"
+    android:viewportWidth="108">
+    <path
+        android:fillColor="#26A69A"
+        android:pathData="M0,0h108v108h-108z" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M9,0L9,108"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M19,0L19,108"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M29,0L29,108"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M39,0L39,108"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M49,0L49,108"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M59,0L59,108"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M69,0L69,108"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M79,0L79,108"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M89,0L89,108"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M99,0L99,108"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,9L108,9"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,19L108,19"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,29L108,29"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,39L108,39"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,49L108,49"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,59L108,59"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,69L108,69"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,79L108,79"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,89L108,89"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,99L108,99"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M19,29L89,29"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M19,39L89,39"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M19,49L89,49"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M19,59L89,59"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M19,69L89,69"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M19,79L89,79"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M29,19L29,89"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M39,19L39,89"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M49,19L49,89"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M59,19L59,89"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M69,19L69,89"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M79,19L79,89"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+</vector>

BIN
apps/mobile/android/app/src/main/res/drawable/splash.png


+ 12 - 0
apps/mobile/android/app/src/main/res/layout/activity_main.xml

@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    tools:context=".MainActivity">
+
+    <WebView
+        android:layout_width="match_parent"
+        android:layout_height="match_parent" />
+</androidx.coordinatorlayout.widget.CoordinatorLayout>

+ 5 - 0
apps/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml

@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+    <background android:drawable="@color/ic_launcher_background"/>
+    <foreground android:drawable="@mipmap/ic_launcher_foreground"/>
+</adaptive-icon>

+ 5 - 0
apps/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml

@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+    <background android:drawable="@color/ic_launcher_background"/>
+    <foreground android:drawable="@mipmap/ic_launcher_foreground"/>
+</adaptive-icon>

BIN
apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png


BIN
apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png


BIN
apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png


BIN
apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png


BIN
apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png


BIN
apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png


BIN
apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png


BIN
apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png


BIN
apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png


BIN
apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png


BIN
apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png


BIN
apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png


BIN
apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png


BIN
apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png


BIN
apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png


+ 4 - 0
apps/mobile/android/app/src/main/res/values/ic_launcher_background.xml

@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <color name="ic_launcher_background">#FFFFFF</color>
+</resources>

+ 7 - 0
apps/mobile/android/app/src/main/res/values/strings.xml

@@ -0,0 +1,7 @@
+<?xml version='1.0' encoding='utf-8'?>
+<resources>
+    <string name="app_name">Agent</string>
+    <string name="title_activity_main">Agent</string>
+    <string name="package_name">cc.zenpower.agent</string>
+    <string name="custom_url_scheme">cc.zenpower.agent</string>
+</resources>

+ 22 - 0
apps/mobile/android/app/src/main/res/values/styles.xml

@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+
+    <!-- Base application theme. -->
+    <style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
+        <!-- Customize your theme here. -->
+        <item name="colorPrimary">@color/colorPrimary</item>
+        <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
+        <item name="colorAccent">@color/colorAccent</item>
+    </style>
+
+    <style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
+        <item name="windowActionBar">false</item>
+        <item name="windowNoTitle">true</item>
+        <item name="android:background">@null</item>
+    </style>
+
+
+    <style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
+        <item name="android:background">@drawable/splash</item>
+    </style>
+</resources>

+ 5 - 0
apps/mobile/android/app/src/main/res/xml/file_paths.xml

@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<paths xmlns:android="http://schemas.android.com/apk/res/android">
+    <external-path name="my_images" path="." />
+    <cache-path name="my_cache_images" path="." />
+</paths>

+ 18 - 0
apps/mobile/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java

@@ -0,0 +1,18 @@
+package com.getcapacitor.myapp;
+
+import static org.junit.Assert.*;
+
+import org.junit.Test;
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
+ */
+public class ExampleUnitTest {
+
+    @Test
+    public void addition_isCorrect() throws Exception {
+        assertEquals(4, 2 + 2);
+    }
+}

+ 29 - 0
apps/mobile/android/build.gradle

@@ -0,0 +1,29 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+
+buildscript {
+    
+    repositories {
+        google()
+        mavenCentral()
+    }
+    dependencies {
+        classpath 'com.android.tools.build:gradle:8.13.0'
+        classpath 'com.google.gms:google-services:4.4.4'
+
+        // NOTE: Do not place your application dependencies here; they belong
+        // in the individual module build.gradle files
+    }
+}
+
+apply from: "variables.gradle"
+
+allprojects {
+    repositories {
+        google()
+        mavenCentral()
+    }
+}
+
+task clean(type: Delete) {
+    delete rootProject.buildDir
+}

+ 15 - 0
apps/mobile/android/capacitor.settings.gradle

@@ -0,0 +1,15 @@
+// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
+include ':capacitor-android'
+project(':capacitor-android').projectDir = new File('../../../node_modules/.pnpm/@capacitor+android@8.5.3_@capacitor+core@8.5.3/node_modules/@capacitor/android/capacitor')
+
+include ':capacitor-app'
+project(':capacitor-app').projectDir = new File('../../../node_modules/.pnpm/@capacitor+app@8.1.2_@capacitor+core@8.5.3/node_modules/@capacitor/app/android')
+
+include ':capacitor-filesystem'
+project(':capacitor-filesystem').projectDir = new File('../../../node_modules/.pnpm/@capacitor+filesystem@8.1.5_@capacitor+core@8.5.3/node_modules/@capacitor/filesystem/android')
+
+include ':capacitor-preferences'
+project(':capacitor-preferences').projectDir = new File('../../../node_modules/.pnpm/@capacitor+preferences@8.0.1_@capacitor+core@8.5.3/node_modules/@capacitor/preferences/android')
+
+include ':capacitor-share'
+project(':capacitor-share').projectDir = new File('../../../node_modules/.pnpm/@capacitor+share@8.0.3_@capacitor+core@8.5.3/node_modules/@capacitor/share/android')

+ 22 - 0
apps/mobile/android/gradle.properties

@@ -0,0 +1,22 @@
+# Project-wide Gradle settings.
+
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+org.gradle.jvmargs=-Xmx1536m
+
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. More details, visit
+# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
+# org.gradle.parallel=true
+
+# AndroidX package structure to make it clearer which packages are bundled with the
+# Android operating system, and which are packaged with your app's APK
+# https://developer.android.com/topic/libraries/support-library/androidx-rn
+android.useAndroidX=true

BIN
apps/mobile/android/gradle/wrapper/gradle-wrapper.jar


+ 7 - 0
apps/mobile/android/gradle/wrapper/gradle-wrapper.properties

@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists

+ 251 - 0
apps/mobile/android/gradlew

@@ -0,0 +1,251 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+#   Gradle start up script for POSIX generated by Gradle.
+#
+#   Important for running:
+#
+#   (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+#       noncompliant, but you have some other compliant shell such as ksh or
+#       bash, then to run this script, type that shell name before the whole
+#       command line, like:
+#
+#           ksh Gradle
+#
+#       Busybox and similar reduced shells will NOT work, because this script
+#       requires all of these POSIX shell features:
+#         * functions;
+#         * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+#           «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+#         * compound commands having a testable exit status, especially «case»;
+#         * various built-in commands including «command», «set», and «ulimit».
+#
+#   Important for patching:
+#
+#   (2) This script targets any POSIX shell, so it avoids extensions provided
+#       by Bash, Ksh, etc; in particular arrays are avoided.
+#
+#       The "traditional" practice of packing multiple parameters into a
+#       space-separated string is a well documented source of bugs and security
+#       problems, so this is (mostly) avoided, by progressively accumulating
+#       options in "$@", and eventually passing that to Java.
+#
+#       Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+#       and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+#       see the in-line comments for details.
+#
+#       There are tweaks for specific operating systems such as AIX, CygWin,
+#       Darwin, MinGW, and NonStop.
+#
+#   (3) This script is generated from the Groovy template
+#       https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+#       within the Gradle project.
+#
+#       You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+    APP_HOME=${app_path%"${app_path##*/}"}  # leaves a trailing /; empty if no leading path
+    [ -h "$app_path" ]
+do
+    ls=$( ls -ld "$app_path" )
+    link=${ls#*' -> '}
+    case $link in             #(
+      /*)   app_path=$link ;; #(
+      *)    app_path=$APP_HOME$link ;;
+    esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+    echo "$*"
+} >&2
+
+die () {
+    echo
+    echo "$*"
+    echo
+    exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in                #(
+  CYGWIN* )         cygwin=true  ;; #(
+  Darwin* )         darwin=true  ;; #(
+  MSYS* | MINGW* )  msys=true    ;; #(
+  NONSTOP* )        nonstop=true ;;
+esac
+
+CLASSPATH="\\\"\\\""
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+    if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+        # IBM's JDK on AIX uses strange locations for the executables
+        JAVACMD=$JAVA_HOME/jre/sh/java
+    else
+        JAVACMD=$JAVA_HOME/bin/java
+    fi
+    if [ ! -x "$JAVACMD" ] ; then
+        die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+    fi
+else
+    JAVACMD=java
+    if ! command -v java >/dev/null 2>&1
+    then
+        die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+    fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+    case $MAX_FD in #(
+      max*)
+        # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+        # shellcheck disable=SC2039,SC3045
+        MAX_FD=$( ulimit -H -n ) ||
+            warn "Could not query maximum file descriptor limit"
+    esac
+    case $MAX_FD in  #(
+      '' | soft) :;; #(
+      *)
+        # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+        # shellcheck disable=SC2039,SC3045
+        ulimit -n "$MAX_FD" ||
+            warn "Could not set maximum file descriptor limit to $MAX_FD"
+    esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+#   * args from the command line
+#   * the main class name
+#   * -classpath
+#   * -D...appname settings
+#   * --module-path (only if needed)
+#   * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+    APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+    CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+    JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+    # Now convert the arguments - kludge to limit ourselves to /bin/sh
+    for arg do
+        if
+            case $arg in                                #(
+              -*)   false ;;                            # don't mess with options #(
+              /?*)  t=${arg#/} t=/${t%%/*}              # looks like a POSIX filepath
+                    [ -e "$t" ] ;;                      #(
+              *)    false ;;
+            esac
+        then
+            arg=$( cygpath --path --ignore --mixed "$arg" )
+        fi
+        # Roll the args list around exactly as many times as the number of
+        # args, so each arg winds up back in the position where it started, but
+        # possibly modified.
+        #
+        # NB: a `for` loop captures its iteration list before it begins, so
+        # changing the positional parameters here affects neither the number of
+        # iterations, nor the values presented in `arg`.
+        shift                   # remove old arg
+        set -- "$@" "$arg"      # push replacement arg
+    done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+#   * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+#     and any embedded shellness will be escaped.
+#   * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+#     treated as '${Hostname}' itself on the command line.
+
+set -- \
+        "-Dorg.gradle.appname=$APP_BASE_NAME" \
+        -classpath "$CLASSPATH" \
+        -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+        "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+    die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+#   readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+#   set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+        printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+        xargs -n1 |
+        sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+        tr '\n' ' '
+    )" '"$@"'
+
+exec "$JAVACMD" "$@"

+ 94 - 0
apps/mobile/android/gradlew.bat

@@ -0,0 +1,94 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem      https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem  Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega

+ 5 - 0
apps/mobile/android/settings.gradle

@@ -0,0 +1,5 @@
+include ':app'
+include ':capacitor-cordova-android-plugins'
+project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
+
+apply from: 'capacitor.settings.gradle'

+ 16 - 0
apps/mobile/android/variables.gradle

@@ -0,0 +1,16 @@
+ext {
+    minSdkVersion = 24
+    compileSdkVersion = 36
+    targetSdkVersion = 36
+    androidxActivityVersion = '1.11.0'
+    androidxAppCompatVersion = '1.7.1'
+    androidxCoordinatorLayoutVersion = '1.3.0'
+    androidxCoreVersion = '1.17.0'
+    androidxFragmentVersion = '1.8.9'
+    coreSplashScreenVersion = '1.2.0'
+    androidxWebkitVersion = '1.14.0'
+    junitVersion = '4.13.2'
+    androidxJunitVersion = '1.3.0'
+    androidxEspressoCoreVersion = '3.7.0'
+    cordovaAndroidVersion = '14.0.1'
+}

+ 11 - 0
apps/mobile/capacitor.config.ts

@@ -0,0 +1,11 @@
+import type { CapacitorConfig } from '@capacitor/cli'
+
+const config: CapacitorConfig = {
+  appId: 'cc.zenpower.agent',
+  appName: 'Agent',
+  webDir: 'dist',
+  // WebView 的来源是 https://mobile.zenpower.local:需要加入后台「设置 → 允许的来源」,/v1 与 /bot/v1 才会放行跨域
+  server: { androidScheme: 'https', hostname: 'mobile.zenpower.local' },
+}
+
+export default config

+ 12 - 0
apps/mobile/index.html

@@ -0,0 +1,12 @@
+<!doctype html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
+    <title>Agent</title>
+  </head>
+  <body>
+    <div id="app"></div>
+    <script type="module" src="/src/main.ts"></script>
+  </body>
+</html>

+ 36 - 0
apps/mobile/package.json

@@ -0,0 +1,36 @@
+{
+  "name": "@agent-server/mobile",
+  "version": "0.1.0",
+  "private": true,
+  "type": "module",
+  "scripts": {
+    "web": "vite",
+    "build:web": "vue-tsc --noEmit && vite build",
+    "typecheck": "vue-tsc --noEmit",
+    "sync": "pnpm build:web && cap sync android",
+    "apk": "pnpm sync && cd android && ./gradlew assembleDebug"
+  },
+  "dependencies": {
+    "@agent-server/chat-ui": "workspace:^",
+    "@agent-server/client": "workspace:^",
+    "@agent-server/shared": "workspace:^",
+    "@agent-server/ui-tokens": "workspace:^",
+    "@capacitor/android": "^8.5.3",
+    "@capacitor/app": "^8.1.2",
+    "@capacitor/core": "^8.5.3",
+    "@capacitor/filesystem": "^8.1.5",
+    "@capacitor/preferences": "^8.0.1",
+    "@capacitor/share": "^8.0.3",
+    "@vscode/codicons": "0.0.46-24",
+    "vue": "^3.5.44",
+    "vue-router": "^5.4.0"
+  },
+  "devDependencies": {
+    "@capacitor/cli": "^8.5.3",
+    "@vitejs/plugin-vue": "^6.0.10",
+    "typescript": "^5.9.3",
+    "unocss": "^66.11.0",
+    "vite": "^8.3.5",
+    "vue-tsc": "^3.3.13"
+  }
+}

+ 32 - 0
apps/mobile/src/App.vue

@@ -0,0 +1,32 @@
+<script setup lang="ts">
+import { useRoute } from 'vue-router'
+import ActionSheet from './components/ActionSheet.vue'
+import FileViewer from './components/FileViewer.vue'
+import TabBar from './components/TabBar.vue'
+import { toasts } from './lib/ui'
+
+const route = useRoute()
+</script>
+
+<template>
+  <div class="m-app h-full flex flex-col">
+    <div class="flex-1 min-h-0 flex flex-col">
+      <RouterView />
+    </div>
+    <TabBar v-if="route.meta.tab" />
+  </div>
+  <FileViewer />
+  <ActionSheet />
+  <div class="fixed left-4 right-4 bottom-[calc(80px+var(--safe-bottom))] z-50 flex flex-col gap-2 items-center pointer-events-none">
+    <div
+      v-for="t in toasts" :key="t.id"
+      class="max-w-full px-3 py-2 rounded-md shadow-lg border border-solid border-border-input bg-input-alt text-[14px] flex items-center gap-2"
+    >
+      <span
+        class="codicon"
+        :class="t.kind === 'error' ? 'codicon-error text-error' : t.kind === 'success' ? 'codicon-pass text-success' : 'codicon-info text-link'"
+      />
+      {{ t.text }}
+    </div>
+  </div>
+</template>

+ 31 - 0
apps/mobile/src/components/ActionSheet.vue

@@ -0,0 +1,31 @@
+<script setup lang="ts">
+// 底部操作菜单(lib/ui.ts 的 openSheet)
+import { sheet } from '../lib/ui'
+</script>
+
+<template>
+  <Transition name="fade">
+    <div v-if="sheet" class="fixed inset-0 z-40 bg-black/40 flex flex-col justify-end" @click.self="sheet.resolve(null)">
+      <div class="bg-bg rounded-t-xl pb-[var(--safe-bottom)] max-h-[80vh] overflow-y-auto">
+        <div v-if="sheet.title" class="px-4 pt-4 pb-2 text-[13px] text-fg-muted">{{ sheet.title }}</div>
+        <button
+          v-for="(a, i) in sheet.actions" :key="i" type="button" class="m-row"
+          :class="a.danger ? 'text-error' : ''" @click="sheet.resolve(a.value)"
+        >
+          <span v-if="a.icon" class="codicon text-[18px]" :class="`codicon-${a.icon}`" />
+          <span class="flex-1 min-w-0">
+            <span class="block truncate">{{ a.label }}</span>
+            <span v-if="a.hint" class="block text-[12px] text-fg-muted line-clamp-2">{{ a.hint }}</span>
+          </span>
+        </button>
+        <div class="h-2 bg-[var(--app-primary-background)]" />
+        <button type="button" class="m-row justify-center text-fg-muted" @click="sheet.resolve(null)">取消</button>
+      </div>
+    </div>
+  </Transition>
+</template>
+
+<style scoped>
+.fade-enter-active, .fade-leave-active { transition: opacity 0.15s; }
+.fade-enter-from, .fade-leave-to { opacity: 0; }
+</style>

+ 28 - 0
apps/mobile/src/components/BotCardItem.vue

@@ -0,0 +1,28 @@
+<script setup lang="ts">
+// 机器人能力卡片:名称、说明,展开后显示技能、外部工具与资料库
+import { ref } from 'vue'
+import type { BotCard } from '@agent-server/client'
+import { BotAvatar } from '@agent-server/chat-ui'
+
+defineProps<{ bot: BotCard }>()
+const open = ref(false)
+</script>
+
+<template>
+  <div class="flex-1 min-w-0">
+    <div class="flex items-center gap-2">
+      <BotAvatar :value="bot.avatar" :size="24" />
+      <span class="flex-1 min-w-0 truncate">{{ bot.name }}</span>
+      <button
+        v-if="bot.skills.length || bot.mcpServers.length || bot.knowledge.length" type="button"
+        class="flex-none border-none bg-transparent text-[12px] text-link cursor-pointer px-1" @click.prevent.stop="open = !open"
+      >{{ open ? '收起' : '能力' }}</button>
+    </div>
+    <div class="text-[12px] text-fg-muted mt-0.5" :class="open ? '' : 'line-clamp-2'">{{ bot.description }}</div>
+    <div v-if="open" class="mt-1.5 flex flex-col gap-1 text-[12px]">
+      <div v-for="s in bot.skills" :key="s.name"><span class="codicon codicon-tools text-[12px] text-fg-muted" /> <b class="font-600">{{ s.name }}</b> <span class="text-fg-muted">{{ s.description }}</span></div>
+      <div v-for="m in bot.mcpServers" :key="m.name"><span class="codicon codicon-plug text-[12px] text-fg-muted" /> <b class="font-600">{{ m.title || m.name }}</b> <span class="text-fg-muted">{{ m.description }}</span></div>
+      <div v-if="bot.knowledge.length"><span class="codicon codicon-library text-[12px] text-fg-muted" /> 资料库:{{ bot.knowledge.join('、') }}</div>
+    </div>
+  </div>
+</template>

+ 41 - 0
apps/mobile/src/components/FileList.vue

@@ -0,0 +1,41 @@
+<script setup lang="ts">
+// 会话或工作组的文件列表:点击预览或分享
+import { onMounted, ref, watch } from 'vue'
+import type { FileDto } from '@agent-server/shared'
+import { fileIcon, formatSize, type FileClient } from '@agent-server/chat-ui'
+import { openFile } from '../lib/files'
+import { relativeTime } from '../lib/ui'
+
+const props = defineProps<{ client: FileClient; load: () => Promise<FileDto[]>; refreshKey?: unknown }>()
+const files = ref<FileDto[] | null>(null)
+const error = ref<string | null>(null)
+
+async function refresh() {
+  try {
+    files.value = (await props.load()).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
+    error.value = null
+  } catch (err) {
+    error.value = (err as Error).message
+  }
+}
+onMounted(refresh)
+watch(() => props.refreshKey, refresh)
+</script>
+
+<template>
+  <div class="m-list">
+    <div v-if="error" class="px-4 py-3 text-error">{{ error }}</div>
+    <div v-else-if="!files" class="px-4 py-3 text-fg-muted">加载中…</div>
+    <div v-else-if="!files.length" class="px-4 py-3 text-fg-muted">还没有文件</div>
+    <button
+      v-for="f in files" v-else :key="f.id" type="button" class="m-row"
+      @click="openFile(client, { fileId: f.id, path: f.path, mime: f.mime, size: f.size })"
+    >
+      <span class="codicon text-[18px] text-fg-muted" :class="fileIcon(f.mime, f.path)" />
+      <span class="flex-1 min-w-0">
+        <span class="block truncate">{{ f.path }}</span>
+        <span class="block text-[12px] text-fg-muted">{{ formatSize(f.size) }} · {{ f.origin === 'upload' ? '上传' : '生成' }} · {{ relativeTime(f.updatedAt) }}</span>
+      </span>
+    </button>
+  </div>
+</template>

+ 41 - 0
apps/mobile/src/components/FileViewer.vue

@@ -0,0 +1,41 @@
+<script setup lang="ts">
+// 全屏预览图片与文本文件;右上角可以分享(用其他 App 打开或保存)
+import { ref, watch } from 'vue'
+import { MarkdownView } from '@agent-server/chat-ui'
+import { basename, shareFile, viewer } from '../lib/files'
+
+const imageUrl = ref<string | null>(null)
+const text = ref<string | null>(null)
+const error = ref<string | null>(null)
+
+watch(viewer, async (v, _old, onCleanup) => {
+  imageUrl.value = text.value = error.value = null
+  if (!v) return
+  let url: string | null = null
+  onCleanup(() => { if (url) URL.revokeObjectURL(url) })
+  try {
+    const blob = await v.client.fileBlob(v.file.fileId)
+    if (v.kind === 'image') imageUrl.value = url = URL.createObjectURL(blob)
+    else text.value = await blob.text()
+  } catch (err) {
+    error.value = (err as Error).message
+  }
+})
+</script>
+
+<template>
+  <div v-if="viewer" class="fixed inset-0 z-30 flex flex-col bg-bg">
+    <header class="flex-none flex items-center gap-1 h-14 px-1 border-b border-solid border-border box-content pt-[var(--safe-top)]">
+      <button type="button" class="m-icon" aria-label="关闭" @click="viewer = null"><span class="codicon codicon-close text-[18px]" /></button>
+      <div class="flex-1 min-w-0 truncate font-600">{{ basename(viewer.file.path) }}</div>
+      <button type="button" class="m-icon" aria-label="分享" @click="shareFile(viewer.client, viewer.file)"><span class="codicon codicon-export text-[18px]" /></button>
+    </header>
+    <div class="flex-1 min-h-0 overflow-auto pb-[var(--safe-bottom)]" :class="viewer.kind === 'image' ? 'flex items-center justify-center bg-black' : ''">
+      <div v-if="error" class="p-6 text-error">{{ error }}</div>
+      <img v-else-if="imageUrl" :src="imageUrl" :alt="viewer.file.path" class="max-w-full max-h-full object-contain">
+      <div v-else-if="text !== null && viewer.kind === 'markdown'" class="agent-chat p-4"><MarkdownView :source="text" /></div>
+      <pre v-else-if="text !== null" class="m-0 p-4 font-mono text-[12px] whitespace-pre-wrap break-all">{{ text }}</pre>
+      <div v-else class="p-6 text-fg-muted">加载中…</div>
+    </div>
+  </div>
+</template>

+ 25 - 0
apps/mobile/src/components/TabBar.vue

@@ -0,0 +1,25 @@
+<script setup lang="ts">
+// 底部标签栏:没有填写对应 Key 的标签不显示
+import { computed } from 'vue'
+import { useRoute } from 'vue-router'
+import { config } from '../lib/config'
+
+const route = useRoute()
+const tabs = computed(() => [
+  { key: 'chats', to: '/chats', label: '对话', icon: 'comment-discussion', show: !!config.agentKey },
+  { key: 'teams', to: '/teams', label: '工作组', icon: 'organization', show: !!config.botKey },
+  { key: 'settings', to: '/settings', label: '设置', icon: 'settings-gear', show: true },
+].filter(t => t.show))
+</script>
+
+<template>
+  <nav class="flex-none flex bg-bg border-t border-solid border-border box-content pb-[var(--safe-bottom)]">
+    <RouterLink
+      v-for="t in tabs" :key="t.key" :to="t.to" replace
+      class="flex-1 h-14 flex flex-col items-center justify-center gap-0.5 no-underline text-[11px]"
+      :class="route.meta.tab === t.key ? 'text-accent' : 'text-fg-muted'"
+    >
+      <span class="codicon text-[20px]" :class="`codicon-${t.icon}`" />{{ t.label }}
+    </RouterLink>
+  </nav>
+</template>

+ 22 - 0
apps/mobile/src/components/TopBar.vue

@@ -0,0 +1,22 @@
+<script setup lang="ts">
+// 页面顶栏:返回、标题(可带副标题)、右侧操作
+import { useRouter } from 'vue-router'
+
+defineProps<{ title: string; subtitle?: string; back?: boolean }>()
+const router = useRouter()
+function goBack() {
+  if ((window.history.state as { back?: string | null } | null)?.back) router.back()
+  else void router.replace('/')
+}
+</script>
+
+<template>
+  <header class="flex-none flex items-center gap-1 h-14 px-1 bg-bg border-b border-solid border-border box-content pt-[var(--safe-top)]">
+    <button v-if="back" type="button" class="m-icon" aria-label="返回" @click="goBack"><span class="codicon codicon-arrow-left text-[18px]" /></button>
+    <div class="flex-1 min-w-0" :class="back ? '' : 'pl-3'">
+      <div class="truncate font-600 text-[17px]">{{ title }}</div>
+      <div v-if="subtitle" class="truncate text-[12px] text-fg-muted">{{ subtitle }}</div>
+    </div>
+    <slot />
+  </header>
+</template>

+ 8 - 0
apps/mobile/src/env.d.ts

@@ -0,0 +1,8 @@
+/// <reference types="vite/client" />
+/** apps/mobile/package.json 的版本号 */
+declare const __APP_VERSION__: string
+declare module '*.vue' {
+  import type { DefineComponent } from 'vue'
+  const component: DefineComponent<object, object, unknown>
+  export default component
+}

+ 66 - 0
apps/mobile/src/lib/config.ts

@@ -0,0 +1,66 @@
+// App 设置:服务地址与两种 Key 保存在本机(Capacitor Preferences:App 私有存储;浏览器调试时是 localStorage)
+import { Capacitor, SystemBars, SystemBarsStyle } from '@capacitor/core'
+import { Preferences } from '@capacitor/preferences'
+import { computed, reactive, shallowRef, watch } from 'vue'
+import { AgentClient, BotClient } from '@agent-server/client'
+
+export type Theme = 'system' | 'light' | 'dark'
+
+export interface AppConfig {
+  serverUrl: string
+  agentKey: string
+  botKey: string
+  /** 在工作组中的身份(TeamHuman) */
+  userId: string
+  userName: string
+  theme: Theme
+}
+
+const STORAGE_KEY = 'agent-mobile-config'
+
+export const config = reactive<AppConfig>({
+  serverUrl: 'https://agent.zenpower.cc',
+  agentKey: '',
+  botKey: '',
+  userId: '',
+  userName: '我',
+  theme: 'system',
+})
+
+export async function loadConfig() {
+  const { value } = await Preferences.get({ key: STORAGE_KEY })
+  if (value) Object.assign(config, JSON.parse(value) as Partial<AppConfig>)
+  if (!config.userId) config.userId = `mobile-${crypto.randomUUID().slice(0, 8)}`
+  applyTheme(config.theme)
+}
+
+export async function saveConfig(next: Partial<AppConfig>) {
+  Object.assign(config, next)
+  config.serverUrl = config.serverUrl.trim().replace(/\/+$/, '')
+  config.agentKey = config.agentKey.trim()
+  config.botKey = config.botKey.trim()
+  await Preferences.set({ key: STORAGE_KEY, value: JSON.stringify(config) })
+  applyTheme(config.theme)
+}
+
+export function applyTheme(theme: Theme) {
+  if (theme === 'system') document.documentElement.removeAttribute('data-theme')
+  else document.documentElement.setAttribute('data-theme', theme)
+  // 状态栏图标颜色跟随主题(深色主题用浅色图标)
+  if (Capacitor.isNativePlatform()) {
+    void SystemBars.setStyle({ style: theme === 'dark' ? SystemBarsStyle.Dark : theme === 'light' ? SystemBarsStyle.Light : SystemBarsStyle.Default })
+  }
+}
+
+/** 浏览器调试时走 Vite 代理(同源),App 中直接访问服务地址 */
+export const baseUrl = (serverUrl = config.serverUrl) => (import.meta.env.DEV && !Capacitor.isNativePlatform() ? '' : serverUrl.trim().replace(/\/+$/, ''))
+
+// Key 变化时重建客户端;App 自己用,Key 直接作为 Bearer 令牌,不经过浏览器令牌
+export const agentClient = shallowRef<AgentClient | null>(null)
+export const botClient = shallowRef<BotClient | null>(null)
+watch(() => [config.serverUrl, config.agentKey, config.botKey], () => {
+  agentClient.value = config.agentKey ? new AgentClient({ baseUrl: baseUrl(), apiKey: config.agentKey }) : null
+  botClient.value = config.botKey ? new BotClient({ baseUrl: baseUrl(), apiKey: config.botKey }) : null
+}, { immediate: true })
+
+export const human = computed(() => ({ id: config.userId, name: config.userName.trim() || '我' }))

+ 52 - 0
apps/mobile/src/lib/files.ts

@@ -0,0 +1,52 @@
+// 打开文件:图片与文本在 App 内预览,其他文件写入缓存目录后交给系统分享(用其他 App 打开或保存)
+import { Capacitor } from '@capacitor/core'
+import { Directory, Filesystem } from '@capacitor/filesystem'
+import { Share } from '@capacitor/share'
+import { shallowRef } from 'vue'
+import { downloadFile, type FileClient } from '@agent-server/chat-ui'
+import { attempt } from './ui'
+
+export interface OpenedFile {
+  fileId: string
+  path: string
+  mime: string
+  size: number
+}
+
+const TEXT_EXT = new Set(['md', 'markdown', 'txt', 'log', 'json', 'yaml', 'yml', 'csv', 'tsv', 'xml', 'html', 'htm', 'css', 'js', 'ts', 'py', 'sh', 'sql', 'ini', 'toml', 'conf'])
+const ext = (p: string) => p.split('.').pop()?.toLowerCase() ?? ''
+const MAX_TEXT = 1024 * 1024
+
+/** 正在预览的文件:kind 决定预览方式 */
+export const viewer = shallowRef<{ file: OpenedFile; client: FileClient; kind: 'image' | 'text' | 'markdown' } | null>(null)
+
+export function openFile(client: FileClient, file: OpenedFile) {
+  if (file.mime.startsWith('image/')) viewer.value = { file, client, kind: 'image' }
+  else if (file.size <= MAX_TEXT && (file.mime.startsWith('text/') || TEXT_EXT.has(ext(file.path)))) {
+    viewer.value = { file, client, kind: ['md', 'markdown'].includes(ext(file.path)) ? 'markdown' : 'text' }
+  } else void shareFile(client, file)
+}
+
+export const basename = (p: string) => p.split('/').pop() || p
+
+export async function shareFile(client: FileClient, file: OpenedFile) {
+  const name = basename(file.path)
+  if (!Capacitor.isNativePlatform()) return attempt(() => downloadFile(client, file.fileId, name))
+  return attempt(async () => {
+    const blob = await client.fileBlob(file.fileId)
+    const { uri } = await Filesystem.writeFile({ path: `files/${file.fileId}/${name}`, data: await toBase64(blob), directory: Directory.Cache, recursive: true })
+    // 用户关闭分享面板时插件会报错,不算失败
+    await Share.share({ title: name, files: [uri] }).catch((err: Error) => {
+      if (!/cancel/i.test(err.message)) throw err
+    })
+  })
+}
+
+function toBase64(blob: Blob): Promise<string> {
+  return new Promise((resolve, reject) => {
+    const reader = new FileReader()
+    reader.onload = () => resolve((reader.result as string).split(',', 2)[1] ?? '')
+    reader.onerror = () => reject(reader.error)
+    reader.readAsDataURL(blob)
+  })
+}

+ 37 - 0
apps/mobile/src/lib/sessions.ts

@@ -0,0 +1,37 @@
+// 会话与工作组的常用操作(列表页与详情页共用)
+import type { AgentClient, BotClient, SessionDto, TeamDto } from '@agent-server/client'
+import { attempt, openSheet } from './ui'
+
+/** 会话菜单:重命名、归档、删除。返回 'removed' 表示已删除,'changed' 表示已修改 */
+export async function sessionMenu(client: AgentClient, s: Pick<SessionDto, 'id' | 'title' | 'archived'>, extra: { value: string; label: string; icon: string }[] = []) {
+  const picked = await openSheet([
+    ...extra,
+    { value: 'rename', label: '重命名', icon: 'edit' },
+    { value: 'archive', label: s.archived ? '取消归档' : '归档', icon: 'archive' },
+    { value: 'remove', label: '删除', icon: 'trash', danger: true },
+  ], s.title || '新会话')
+  if (picked === 'rename') {
+    const title = window.prompt('会话名称', s.title)?.trim()
+    if (title && title !== s.title) return (await attempt(() => client.sessions.update(s.id, { title }))) ? 'changed' : null
+  } else if (picked === 'archive') {
+    return (await attempt(() => client.sessions.update(s.id, { archived: !s.archived }), s.archived ? '已取消归档' : '已归档')) ? 'changed' : null
+  } else if (picked === 'remove') {
+    if (!window.confirm(`删除会话「${s.title || '新会话'}」?会话的消息和文件都会删除,不能恢复。`)) return null
+    return (await attempt(() => client.sessions.remove(s.id), '已删除')) !== undefined ? 'removed' : null
+  }
+  return picked
+}
+
+export const TEAM_STATUS: Record<TeamDto['status'], string> = { active: '进行中', paused: '已暂停', closed: '已关闭' }
+
+/** 工作组状态操作:暂停、恢复、关闭、重新打开 */
+export function teamStatusActions(t: Pick<TeamDto, 'status'>) {
+  if (t.status === 'active') return [{ value: 'pause', label: '暂停', icon: 'debug-pause', hint: '新消息先记录,恢复后再处理' }, { value: 'close', label: '关闭', icon: 'circle-slash', hint: '结束工作组,不能再发送消息' }] as const
+  if (t.status === 'paused') return [{ value: 'resume', label: '恢复', icon: 'debug-continue' }, { value: 'close', label: '关闭', icon: 'circle-slash' }] as const
+  return [{ value: 'reopen', label: '重新打开', icon: 'refresh' }] as const
+}
+
+export async function runTeamAction(client: BotClient, id: string, action: 'pause' | 'resume' | 'close' | 'reopen') {
+  const done = { pause: '已暂停', resume: '已恢复', close: '已关闭', reopen: '已重新打开' }[action]
+  return attempt(() => client.teams[action](id), done)
+}

+ 53 - 0
apps/mobile/src/lib/ui.ts

@@ -0,0 +1,53 @@
+import { reactive, shallowRef } from 'vue'
+
+/** 底部轻提示 */
+export const toasts = reactive<{ id: number; text: string; kind: 'info' | 'error' | 'success' }[]>([])
+let seq = 0
+export function toast(text: string, kind: 'info' | 'error' | 'success' = 'info') {
+  const id = ++seq
+  toasts.push({ id, text, kind })
+  setTimeout(() => {
+    const i = toasts.findIndex(t => t.id === id)
+    if (i >= 0) toasts.splice(i, 1)
+  }, kind === 'error' ? 5000 : 2500)
+}
+
+/** 执行操作,失败时提示错误;成功返回结果,失败返回 undefined */
+export async function attempt<T>(fn: () => Promise<T>, success?: string): Promise<T | undefined> {
+  try {
+    const r = await fn()
+    if (success) toast(success, 'success')
+    return r
+  } catch (err) {
+    toast((err as Error).message, 'error')
+    return undefined
+  }
+}
+
+/** 底部操作菜单:openSheet 返回选中的项,取消时为 null */
+export interface SheetAction<T = string> {
+  value: T
+  label: string
+  icon?: string
+  hint?: string
+  danger?: boolean
+}
+export const sheet = shallowRef<{ title?: string; actions: SheetAction<unknown>[]; resolve: (v: unknown) => void } | null>(null)
+export function openSheet<T>(actions: SheetAction<T>[], title?: string): Promise<T | null> {
+  sheet.value?.resolve(null)
+  return new Promise(resolve => {
+    sheet.value = { title, actions, resolve: v => { sheet.value = null; resolve(v as T | null) } }
+  })
+}
+
+export function relativeTime(iso: string) {
+  const diff = (Date.now() - new Date(iso).getTime()) / 1000
+  if (diff < 60) return '刚刚'
+  if (diff < 3600) return `${Math.floor(diff / 60)} 分钟前`
+  if (diff < 86400) return `${Math.floor(diff / 3600)} 小时前`
+  if (diff < 86400 * 7) return `${Math.floor(diff / 86400)} 天前`
+  return new Date(iso).toLocaleDateString('zh-CN')
+}
+
+export const formatTokens = (n: number) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}K` : String(n))
+export const formatCost = (usd: number) => `$${usd.toFixed(usd >= 10 ? 1 : 3)}`

+ 23 - 0
apps/mobile/src/main.ts

@@ -0,0 +1,23 @@
+import 'virtual:uno.css'
+import '@agent-server/ui-tokens/tokens.css'
+// chat-ui 的入口会引入它自己的样式与 codicon 图标字体
+import './style.css'
+import { App as NativeApp } from '@capacitor/app'
+import { createApp } from 'vue'
+import App from './App.vue'
+import { loadConfig } from './lib/config'
+import { viewer } from './lib/files'
+import { sheet } from './lib/ui'
+import { router } from './router'
+
+// Android 返回键:先关闭弹层,再返回上一页;在标签页时退到后台
+void NativeApp.addListener('backButton', () => {
+  if (sheet.value) sheet.value.resolve(null)
+  else if (viewer.value) viewer.value = null
+  else if (router.currentRoute.value.meta.tab) void NativeApp.minimizeApp()
+  else if ((window.history.state as { back?: string | null } | null)?.back) router.back()
+  else void router.replace('/')
+})
+
+// 先读出设置,路由才能按 Key 决定首页
+void loadConfig().then(() => createApp(App).use(router).mount('#app'))

+ 25 - 0
apps/mobile/src/router.ts

@@ -0,0 +1,25 @@
+import { createRouter, createWebHashHistory } from 'vue-router'
+import { config } from './lib/config'
+
+// tab:底部标签页(显示标签栏,返回键退到后台);其余页面返回上一页
+export const router = createRouter({
+  history: createWebHashHistory(),
+  routes: [
+    { path: '/', redirect: () => (config.agentKey ? '/chats' : config.botKey ? '/teams' : '/settings') },
+    { path: '/chats', component: () => import('./views/SessionsView.vue'), meta: { tab: 'chats' } },
+    { path: '/chats/:id', component: () => import('./views/ChatView.vue'), props: true },
+    { path: '/chats/:id/files', component: () => import('./views/SessionFilesView.vue'), props: true },
+    { path: '/teams', component: () => import('./views/TeamsView.vue'), meta: { tab: 'teams' } },
+    { path: '/teams/new', component: () => import('./views/NewTeamView.vue') },
+    { path: '/teams/:id', component: () => import('./views/TeamView.vue'), props: true },
+    { path: '/teams/:id/info', component: () => import('./views/TeamInfoView.vue'), props: true },
+    { path: '/settings', component: () => import('./views/SettingsView.vue'), meta: { tab: 'settings' } },
+    { path: '/usage', component: () => import('./views/UsageView.vue') },
+  ],
+})
+
+// 对应的 Key 没有填写时进入设置
+router.beforeEach(to => {
+  if ((to.path.startsWith('/chats') || to.path === '/usage') && !config.agentKey) return '/settings'
+  if (to.path.startsWith('/teams') && !config.botKey) return '/settings'
+})

+ 28 - 0
apps/mobile/src/style.css

@@ -0,0 +1,28 @@
+/* 手机上的基础样式与 chat-ui 的窄屏调整 */
+/* chat-ui 的样式在本文件之后加载、会再次定义 :root 变量,这里提高优先级 */
+:root:root {
+  --app-font-size: 15px;
+  --app-monospace-font-size: 13px;
+  /* 安全区:Capacitor 注入 --safe-area-inset-*,浏览器中退回 env() */
+  --safe-top: var(--safe-area-inset-top, env(safe-area-inset-top, 0px));
+  --safe-bottom: var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px));
+}
+
+html,
+body,
+#app {
+  overflow: hidden;
+  overscroll-behavior: none;
+  -webkit-tap-highlight-color: transparent;
+}
+
+/* 列表分组:行与行之间的分隔线 */
+.m-list > * + * { border-top: 1px solid var(--app-primary-border-color); }
+
+/* 列表页、表单页的底色与对话区一致 */
+.m-page { background: var(--app-primary-background); }
+
+/* chat-ui:缩小两侧留白,让正文更宽 */
+.m-app .agent-chat .column { padding: 12px 12px 16px; }
+.m-app .agent-chat .composer-wrap { padding: 0 8px 8px; }
+.m-app .agent-chat .to-bottom { bottom: 96px; }

+ 56 - 0
apps/mobile/src/views/ChatView.vue

@@ -0,0 +1,56 @@
+<script setup lang="ts">
+// 单个会话:<AgentChat>(流式输出、审批与提问、上传、定时任务、后台调用都由组件处理)
+import { computed, onBeforeUnmount, ref, watch } from 'vue'
+import { useRouter } from 'vue-router'
+import type { SessionDetailDto } from '@agent-server/client'
+import { AgentChat, useChatSession } from '@agent-server/chat-ui'
+import TopBar from '../components/TopBar.vue'
+import { agentClient } from '../lib/config'
+import { openFile } from '../lib/files'
+import { sessionMenu } from '../lib/sessions'
+import { formatCost, formatTokens, toast } from '../lib/ui'
+
+const props = defineProps<{ id: string }>()
+const router = useRouter()
+const client = agentClient.value!
+const chat = useChatSession(client, props.id)
+const detail = ref<SessionDetailDto | null>(null)
+
+async function refresh() {
+  try {
+    detail.value = await client.sessions.get(props.id)
+  } catch (err) {
+    toast((err as Error).message, 'error')
+  }
+}
+void refresh()
+// 一轮结束后重新读取标题(首轮结束时服务端会自动起标题)
+watch(() => chat.state.runState, s => { if (s === 'idle') void refresh() })
+onBeforeUnmount(() => chat.dispose())
+
+const subtitle = computed(() => {
+  const u = chat.state.totalUsage
+  const parts = [detail.value?.model ?? '']
+  if (u.inputTokens || u.outputTokens) parts.push(`${formatTokens(u.inputTokens + u.cacheReadTokens + u.outputTokens)} tokens`)
+  if (u.costUsd) parts.push(formatCost(u.costUsd))
+  return parts.filter(Boolean).join(' · ')
+})
+
+async function menu() {
+  if (!detail.value) return
+  const r = await sessionMenu(client, detail.value, [{ value: 'files', label: '文件', icon: 'files' }])
+  if (r === 'files') await router.push(`/chats/${props.id}/files`)
+  else if (r === 'removed') await router.replace('/chats')
+  else if (r === 'changed') await refresh()
+}
+</script>
+
+<template>
+  <TopBar back :title="detail?.title || '新会话'" :subtitle="subtitle">
+    <button type="button" class="m-icon" aria-label="文件" @click="router.push(`/chats/${id}/files`)"><span class="codicon codicon-files text-[18px]" /></button>
+    <button type="button" class="m-icon" aria-label="更多" @click="menu"><span class="codicon codicon-ellipsis text-[18px]" /></button>
+  </TopBar>
+  <div class="flex-1 min-h-0 pb-[var(--safe-bottom)] bg-[var(--app-primary-background)]">
+    <AgentChat :client="client" :session="chat" placeholder="问点什么,或上传文件…" @open-file="f => openFile(client, f)" />
+  </div>
+</template>

+ 69 - 0
apps/mobile/src/views/NewTeamView.vue

@@ -0,0 +1,69 @@
+<script setup lang="ts">
+// 新建工作组:标题、组长(可不要)、组员
+import { computed, onMounted, ref } from 'vue'
+import { useRouter } from 'vue-router'
+import type { BotCard } from '@agent-server/client'
+import BotCardItem from '../components/BotCardItem.vue'
+import TopBar from '../components/TopBar.vue'
+import { botClient, human } from '../lib/config'
+import { attempt } from '../lib/ui'
+
+const router = useRouter()
+const client = botClient.value!
+const cards = ref<BotCard[] | null>(null)
+const title = ref('')
+const lead = ref('')
+const workers = ref<string[]>([])
+const creating = ref(false)
+const leads = computed(() => cards.value?.filter(b => b.kind === 'lead') ?? [])
+const workerCards = computed(() => cards.value?.filter(b => b.kind === 'worker') ?? [])
+
+onMounted(async () => {
+  cards.value = (await attempt(() => client.bots())) ?? []
+  lead.value = leads.value[0]?.id ?? ''
+})
+
+async function create() {
+  creating.value = true
+  const t = await attempt(() => client.teams.create({
+    title: title.value.trim() || undefined, human: human.value,
+    leadBotId: lead.value || undefined, workerBotIds: workers.value,
+  }))
+  creating.value = false
+  if (t) await router.replace(`/teams/${t.id}`)
+}
+</script>
+
+<template>
+  <TopBar back title="新建工作组" />
+  <div class="m-page flex-1 min-h-0 overflow-y-auto pb-[calc(24px+var(--safe-bottom))]">
+    <div class="m-group-title">标题</div>
+    <div class="m-group p-3"><input v-model="title" class="m-field" placeholder="可选,不填自动生成"></div>
+
+    <div class="m-group-title">组长:理解需求、拆分任务、分派给组员并汇总</div>
+    <div class="m-group m-list">
+      <label class="m-row">
+        <input v-model="lead" type="radio" value="" class="w-5 h-5 flex-none">
+        <span class="flex-1">不要组长(直接 @ 组员)</span>
+      </label>
+      <label v-for="b in leads" :key="b.id" class="m-row">
+        <input v-model="lead" type="radio" :value="b.id" class="w-5 h-5 flex-none">
+        <BotCardItem :bot="b" />
+      </label>
+    </div>
+
+    <div class="m-group-title">组员{{ lead ? '(可以不选,由组长按需申请加入)' : '' }}</div>
+    <div class="m-group m-list">
+      <div v-if="!cards" class="px-4 py-3 text-fg-muted">加载中…</div>
+      <div v-else-if="!workerCards.length" class="px-4 py-3 text-fg-muted">这个 Bot Key 没有可用的组员</div>
+      <label v-for="b in workerCards" :key="b.id" class="m-row">
+        <input v-model="workers" type="checkbox" :value="b.id" class="w-5 h-5 flex-none">
+        <BotCardItem :bot="b" />
+      </label>
+    </div>
+
+    <div class="px-3 pt-5">
+      <button type="button" class="m-btn-primary w-full" :disabled="creating || (!lead && !workers.length)" @click="create">{{ creating ? '创建中…' : '创建' }}</button>
+    </div>
+  </div>
+</template>

+ 16 - 0
apps/mobile/src/views/SessionFilesView.vue

@@ -0,0 +1,16 @@
+<script setup lang="ts">
+// 会话的文件:上传的附件与助手生成的文件
+import FileList from '../components/FileList.vue'
+import TopBar from '../components/TopBar.vue'
+import { agentClient } from '../lib/config'
+
+const props = defineProps<{ id: string }>()
+const client = agentClient.value!
+</script>
+
+<template>
+  <TopBar back title="会话文件" />
+  <div class="m-page flex-1 min-h-0 overflow-y-auto py-2 pb-[var(--safe-bottom)]">
+    <FileList :client="client" :load="() => client.sessions.files(props.id)" />
+  </div>
+</template>

+ 94 - 0
apps/mobile/src/views/SessionsView.vue

@@ -0,0 +1,94 @@
+<script setup lang="ts">
+// 对话列表(Agent API):新建时选择 Profile;可切换查看已归档
+import { computed, onMounted, ref, watch } from 'vue'
+import { useRouter } from 'vue-router'
+import type { ProfileSummary, SessionDto } from '@agent-server/client'
+import TopBar from '../components/TopBar.vue'
+import { agentClient } from '../lib/config'
+import { sessionMenu } from '../lib/sessions'
+import { attempt, openSheet, relativeTime } from '../lib/ui'
+
+const router = useRouter()
+const profiles = ref<ProfileSummary[]>([])
+const sessions = ref<SessionDto[]>([])
+const cursor = ref<string | null>(null)
+const archived = ref(false)
+const loading = ref(false)
+const error = ref<string | null>(null)
+const profileName = computed(() => new Map(profiles.value.map(p => [p.id, p.name])))
+
+async function load(more = false) {
+  const client = agentClient.value
+  if (!client) return
+  loading.value = true
+  try {
+    const page = await client.sessions.list({ limit: 30, archived: archived.value, cursor: more ? cursor.value ?? undefined : undefined })
+    sessions.value = more ? [...sessions.value, ...page.items] : page.items
+    cursor.value = page.nextCursor
+    error.value = null
+  } catch (err) {
+    error.value = (err as Error).message
+  } finally {
+    loading.value = false
+  }
+}
+
+async function create() {
+  const client = agentClient.value!
+  const sorted = [...profiles.value].sort((a, b) => Number(b.isDefault) - Number(a.isDefault))
+  const profileId = sorted.length > 1
+    ? await openSheet(sorted.map(p => ({ value: p.id, label: p.name + (p.isDefault ? '(默认)' : ''), hint: p.description || `${p.provider} · ${p.model}`, icon: 'hubot' })), '选择助手')
+    : sorted[0]?.id
+  if (profileId === null) return
+  const s = await attempt(() => client.sessions.create({ profileId }))
+  if (s) await router.push(`/chats/${s.id}`)
+}
+
+async function menu(s: SessionDto) {
+  const r = await sessionMenu(agentClient.value!, s)
+  if (r === 'removed' || r === 'changed') await load()
+}
+
+onMounted(async () => {
+  if (!agentClient.value) return
+  profiles.value = (await attempt(() => agentClient.value!.profiles())) ?? []
+  await load()
+})
+watch(archived, () => load())
+</script>
+
+<template>
+  <TopBar title="对话">
+    <button type="button" class="m-icon" aria-label="刷新" :disabled="loading" @click="load()"><span class="codicon codicon-refresh text-[18px]" /></button>
+    <button type="button" class="m-icon" aria-label="新建对话" :disabled="!agentClient" @click="create"><span class="codicon codicon-add text-[20px]" /></button>
+  </TopBar>
+  <div class="m-page flex-1 min-h-0 overflow-y-auto">
+    <template v-if="agentClient">
+      <div class="flex gap-2 px-3 pt-3">
+        <button
+          v-for="[v, label] in ([[false, '对话'], [true, '已归档']] as const)" :key="label" type="button"
+          class="h-8 px-3 rounded-full border-none cursor-pointer text-[13px]" :class="archived === v ? 'bg-active text-fg' : 'bg-transparent text-fg-muted'"
+          @click="archived = v"
+        >{{ label }}</button>
+      </div>
+      <div v-if="error" class="p-4 text-error">{{ error }}</div>
+      <div v-else-if="!sessions.length && !loading" class="p-8 text-center text-fg-muted">{{ archived ? '没有已归档的对话' : '点右上角 + 新建对话' }}</div>
+      <div class="py-2">
+        <div v-for="s in sessions" :key="s.id" class="flex items-center">
+          <RouterLink :to="`/chats/${s.id}`" class="m-row no-underline flex-1 min-w-0 pr-0">
+            <span class="flex-1 min-w-0">
+              <span class="flex items-center gap-1.5">
+                <span class="truncate">{{ s.title || '新会话' }}</span>
+                <span v-if="s.status === 'running'" class="codicon codicon-loading codicon-modifier-spin text-[12px] text-accent" />
+                <span v-else-if="s.status === 'waiting_input'" class="codicon codicon-bell-dot text-[12px] text-warning" title="等待你的确认" />
+              </span>
+              <span class="block text-[12px] text-fg-muted truncate">{{ (s.profileId && profileName.get(s.profileId)) || s.model }} · {{ relativeTime(s.updatedAt) }}</span>
+            </span>
+          </RouterLink>
+          <button type="button" class="m-icon mr-1 text-fg-muted" aria-label="更多" @click="menu(s)"><span class="codicon codicon-ellipsis" /></button>
+        </div>
+      </div>
+      <button v-if="cursor" type="button" class="m-row justify-center text-link" :disabled="loading" @click="load(true)">{{ loading ? '加载中…' : '加载更多' }}</button>
+    </template>
+  </div>
+</template>

+ 109 - 0
apps/mobile/src/views/SettingsView.vue

@@ -0,0 +1,109 @@
+<script setup lang="ts">
+// 设置:服务地址、Agent Key、Bot Key、工作组中的称呼、主题
+import { reactive, ref } from 'vue'
+import { useRouter } from 'vue-router'
+import { AgentClient, BotClient } from '@agent-server/client'
+import TopBar from '../components/TopBar.vue'
+import { baseUrl, config, saveConfig, type Theme } from '../lib/config'
+import { toast } from '../lib/ui'
+
+const router = useRouter()
+const version = __APP_VERSION__
+const form = reactive({ serverUrl: config.serverUrl, agentKey: config.agentKey, botKey: config.botKey, userName: config.userName })
+const show = reactive({ agent: false, bot: false })
+const checking = ref(false)
+const result = ref<{ agent?: string; bot?: string }>({})
+
+const THEMES: [Theme, string][] = [['system', '跟随系统'], ['light', '浅色'], ['dark', '深色']]
+
+/** 用表单中的值试连:Agent Key 读取 Profile,Bot Key 读取机器人 */
+async function check() {
+  checking.value = true
+  result.value = {}
+  const url = baseUrl(form.serverUrl)
+  const run = async (fn: () => Promise<string>) => {
+    try {
+      return `✓ ${await fn()}`
+    } catch (err) {
+      return `✗ ${(err as Error).message}`
+    }
+  }
+  const [agent, bot] = await Promise.all([
+    form.agentKey.trim() ? run(async () => `可用 Profile ${(await new AgentClient({ baseUrl: url, apiKey: form.agentKey.trim() }).profiles()).length} 个`) : undefined,
+    form.botKey.trim() ? run(async () => `可用机器人 ${(await new BotClient({ baseUrl: url, apiKey: form.botKey.trim() }).bots()).length} 个`) : undefined,
+  ])
+  result.value = { agent, bot }
+  checking.value = false
+}
+
+async function save() {
+  if (!/^https?:\/\/[^/]+/.test(form.serverUrl.trim())) return toast('服务地址应以 https:// 开头', 'error')
+  await saveConfig(form)
+  toast('已保存', 'success')
+  if (config.agentKey) await router.replace('/chats')
+  else if (config.botKey) await router.replace('/teams')
+}
+</script>
+
+<template>
+  <TopBar title="设置" />
+  <div class="m-page flex-1 min-h-0 overflow-y-auto pb-6">
+    <div class="m-group-title">服务</div>
+    <div class="m-group p-4 flex flex-col gap-4">
+      <label class="flex flex-col gap-1.5">
+        <span class="text-[13px] text-fg-muted">服务地址</span>
+        <input v-model="form.serverUrl" class="m-field" inputmode="url" autocapitalize="off" spellcheck="false" placeholder="https://agent.zenpower.cc">
+      </label>
+      <label class="flex flex-col gap-1.5">
+        <span class="text-[13px] text-fg-muted">Agent Key(对话)</span>
+        <span class="flex gap-2">
+          <input v-model="form.agentKey" class="m-field font-mono" :type="show.agent ? 'text' : 'password'" autocapitalize="off" spellcheck="false" placeholder="不填则不显示「对话」">
+          <button type="button" class="m-icon flex-none" :aria-label="show.agent ? '隐藏' : '显示'" @click="show.agent = !show.agent"><span class="codicon" :class="show.agent ? 'codicon-eye-closed' : 'codicon-eye'" /></button>
+        </span>
+        <span v-if="result.agent" class="text-[12px]" :class="result.agent.startsWith('✓') ? 'text-success' : 'text-error'">{{ result.agent }}</span>
+      </label>
+      <label class="flex flex-col gap-1.5">
+        <span class="text-[13px] text-fg-muted">Bot Key(工作组)</span>
+        <span class="flex gap-2">
+          <input v-model="form.botKey" class="m-field font-mono" :type="show.bot ? 'text' : 'password'" autocapitalize="off" spellcheck="false" placeholder="不填则不显示「工作组」">
+          <button type="button" class="m-icon flex-none" :aria-label="show.bot ? '隐藏' : '显示'" @click="show.bot = !show.bot"><span class="codicon" :class="show.bot ? 'codicon-eye-closed' : 'codicon-eye'" /></button>
+        </span>
+        <span v-if="result.bot" class="text-[12px]" :class="result.bot.startsWith('✓') ? 'text-success' : 'text-error'">{{ result.bot }}</span>
+      </label>
+      <label class="flex flex-col gap-1.5">
+        <span class="text-[13px] text-fg-muted">我在工作组中的称呼</span>
+        <input v-model="form.userName" class="m-field" placeholder="我">
+      </label>
+      <div class="flex gap-2">
+        <button type="button" class="m-btn-secondary flex-1" :disabled="checking || (!form.agentKey && !form.botKey)" @click="check">{{ checking ? '测试中…' : '测试连接' }}</button>
+        <button type="button" class="m-btn-primary flex-1" @click="save">保存</button>
+      </div>
+      <div class="text-[12px] text-fg-muted">Key 只保存在这台手机的 App 存储中。Key 在后台「Agent Keys」「Bot Keys」页面签发,停用后立即失效。</div>
+    </div>
+
+    <div class="m-group-title">外观</div>
+    <div class="m-group p-1 flex">
+      <button
+        v-for="[v, label] in THEMES" :key="v" type="button" class="flex-1 h-9 rounded-md border-none cursor-pointer"
+        :class="config.theme === v ? 'bg-active text-fg' : 'bg-transparent text-fg-muted'" @click="saveConfig({ theme: v })"
+      >{{ label }}</button>
+    </div>
+
+    <template v-if="config.agentKey">
+      <div class="m-group-title">统计</div>
+      <div class="m-group">
+        <RouterLink to="/usage" class="m-row no-underline">
+          <span class="codicon codicon-graph text-[18px]" /><span class="flex-1">Agent 用量</span><span class="codicon codicon-chevron-right text-fg-muted" />
+        </RouterLink>
+      </div>
+    </template>
+
+    <div class="m-group-title">关于</div>
+    <div class="m-group m-list">
+      <div class="m-row cursor-default"><span class="codicon codicon-info text-[18px]" /><span class="flex-1">版本</span><span class="text-fg-muted">{{ version }}</span></div>
+      <a :href="`${config.serverUrl}/app`" target="_blank" class="m-row no-underline">
+        <span class="codicon codicon-cloud-download text-[18px]" /><span class="flex-1">下载页(检查新版本)</span><span class="codicon codicon-link-external text-fg-muted" />
+      </a>
+    </div>
+  </div>
+</template>

+ 227 - 0
apps/mobile/src/views/TeamInfoView.vue

@@ -0,0 +1,227 @@
+<script setup lang="ts">
+// 工作组详情:状态、成员、任务、文件、用量、限额、删除
+import { computed, onMounted, ref } from 'vue'
+import { useRouter } from 'vue-router'
+import type { BotCard, JobDto, TaskStatus, TeamDetailDto, TeamJobsDto, TeamLimits, TeamUsageDto } from '@agent-server/client'
+import { BotAvatar } from '@agent-server/chat-ui'
+import FileList from '../components/FileList.vue'
+import TopBar from '../components/TopBar.vue'
+import { botClient } from '../lib/config'
+import { runTeamAction, TEAM_STATUS, teamStatusActions } from '../lib/sessions'
+import { attempt, formatCost, formatTokens, openSheet, relativeTime } from '../lib/ui'
+
+const props = defineProps<{ id: string }>()
+const router = useRouter()
+const client = botClient.value!
+const team = ref<TeamDetailDto | null>(null)
+const jobs = ref<TeamJobsDto | null>(null)
+const usage = ref<TeamUsageDto | null>(null)
+const tab = ref<'members' | 'jobs' | 'files' | 'usage' | 'limits'>('members')
+
+async function refresh() {
+  const [t, j, u] = await Promise.all([
+    attempt(() => client.teams.get(props.id)),
+    attempt(() => client.teams.jobs(props.id)),
+    attempt(() => client.teams.usage(props.id)),
+  ])
+  if (t) {
+    team.value = t
+    limits.value = { ...t.limits }
+  }
+  jobs.value = j ?? null
+  usage.value = u ?? null
+}
+onMounted(refresh)
+
+const BOT_STATUS = { idle: '空闲', queued: '排队中', working: '工作中', waiting_input: '等待确认' } as const
+const TASK_STATUS: Record<TaskStatus, string> = { waiting: '等待依赖', queued: '排队中', running: '进行中', stub: '等待替身结果', background: '后台执行中', completed: '已完成', failed: '失败', cancelled: '已取消' }
+const JOB_STATUS: Record<JobDto['status'], string> = { running: '进行中', blocked: '超出限额,已暂停', completed: '已完成', failed: '失败', cancelled: '已取消' }
+const botName = (id: string) => team.value?.bots.find(b => b.id === id)?.name ?? id
+const tasksOf = (jobId: string) => jobs.value?.tasks.filter(t => t.jobId === jobId) ?? []
+const sortedJobs = computed(() => [...(jobs.value?.jobs ?? [])].sort((a, b) => b.no - a.no))
+
+async function rename() {
+  const title = window.prompt('工作组名称', team.value?.title)?.trim()
+  if (title && title !== team.value?.title) {
+    const t = await attempt(() => client.teams.update(props.id, { title }))
+    if (t) team.value = t
+  }
+}
+
+async function changeStatus() {
+  if (!team.value) return
+  const action = await openSheet([...teamStatusActions(team.value)], `当前状态:${TEAM_STATUS[team.value.status]}`)
+  if (!action) return
+  if (action === 'close' && !window.confirm('关闭工作组?进行中的任务会被取消。')) return
+  const t = await runTeamAction(client, props.id, action)
+  if (t) team.value = t
+}
+
+async function remove() {
+  if (!window.confirm(`删除工作组「${team.value?.title}」?消息、任务和文件都会删除,不能恢复。`)) return
+  if ((await attempt(() => client.teams.remove(props.id), '已删除')) !== undefined) await router.replace('/teams')
+}
+
+async function botMenu(botId: string) {
+  const bot = team.value?.bots.find(b => b.id === botId)
+  if (!bot) return
+  const action = await openSheet([
+    ...(bot.status !== 'idle' ? [{ value: 'interrupt', label: '停止本轮工作', icon: 'debug-stop' }] : []),
+    { value: 'reset', label: '开始新的会话', icon: 'refresh', hint: '清空它的上下文,下一轮重新开始' },
+    { value: 'remove', label: '移出工作组', icon: 'person-remove', danger: true },
+  ], bot.name)
+  if (action === 'interrupt') await attempt(() => client.teams.interruptBot(props.id, botId), '已停止')
+  else if (action === 'reset') await attempt(() => client.teams.resetSession(props.id, botId), '下一轮将开始新的会话')
+  else if (action === 'remove' && window.confirm(`把「${bot.name}」移出工作组?`)) {
+    const t = await attempt(() => client.teams.removeBot(props.id, botId))
+    if (t) team.value = t
+  }
+}
+
+async function addBot() {
+  const cards: BotCard[] = (await attempt(() => client.bots())) ?? []
+  const joined = new Set(team.value?.bots.map(b => b.id))
+  const available = cards.filter(b => !joined.has(b.id))
+  if (!available.length) return void window.alert('没有可以加入的机器人了')
+  const botId = await openSheet(available.map(b => ({ value: b.id, label: b.name + (b.kind === 'lead' ? '(组长)' : ''), hint: b.description, icon: 'person-add' })), '添加成员')
+  if (!botId) return
+  const t = await attempt(() => client.teams.addBot(props.id, botId), '已加入')
+  if (t) team.value = t
+}
+
+async function jobAction(job: JobDto, action: 'cancel' | 'continue') {
+  if (action === 'cancel' && !window.confirm(`取消任务 #${job.no}?还没有结束的子任务一并取消。`)) return
+  const ok = await attempt(() => (action === 'cancel' ? client.teams.cancelJob(props.id, job.id) : client.teams.continueJob(props.id, job.id)), action === 'cancel' ? '已取消' : '已继续')
+  if (ok) await refresh()
+}
+
+// 限额:不能超过系统设置(服务端校验)
+const limits = ref<TeamLimits | null>(null)
+const LIMIT_FIELDS: { key: keyof TeamLimits; label: string; unit: string; nullable?: boolean }[] = [
+  { key: 'maxTasksPerJob', label: '每个任务的子任务数', unit: '个' },
+  { key: 'maxLeadRunsPerJob', label: '每个任务中组长的执行轮数', unit: '轮' },
+  { key: 'maxCostUsdPerJob', label: '每个任务的成本上限', unit: '美元', nullable: true },
+  { key: 'maxMinutesPerJob', label: '每个任务的时长上限', unit: '分钟' },
+  { key: 'maxConcurrentRuns', label: '同时执行的机器人数', unit: '个' },
+  { key: 'sessionMaxContextTokens', label: '上下文超过后开始新会话', unit: 'tokens', nullable: true },
+  { key: 'sessionIdleHours', label: '闲置超过后开始新会话', unit: '小时', nullable: true },
+]
+function setLimit(key: keyof TeamLimits, raw: string) {
+  if (!limits.value) return
+  const v = raw.trim() === '' ? null : Number(raw)
+  ;(limits.value as Record<keyof TeamLimits, number | null>)[key] = v === null || Number.isNaN(v) ? null : v
+}
+async function saveLimits() {
+  if (!limits.value || !team.value) return
+  // 只提交改动过的项
+  const changed = Object.fromEntries(Object.entries(limits.value).filter(([k, v]) => team.value!.limits[k as keyof TeamLimits] !== v))
+  if (!Object.keys(changed).length) return
+  const t = await attempt(() => client.teams.update(props.id, { limits: changed }), '限额已保存')
+  if (t) {
+    team.value = t
+    limits.value = { ...t.limits }
+  }
+}
+
+const TABS = [['members', '成员'], ['jobs', '任务'], ['files', '文件'], ['usage', '用量'], ['limits', '限额']] as const
+</script>
+
+<template>
+  <TopBar back :title="team?.title ?? '工作组'" :subtitle="team ? `${TEAM_STATUS[team.status]} · 创建于 ${relativeTime(team.createdAt)}` : ''">
+    <button type="button" class="m-icon" aria-label="刷新" @click="refresh"><span class="codicon codicon-refresh text-[18px]" /></button>
+  </TopBar>
+  <div v-if="team" class="m-page flex-1 min-h-0 overflow-y-auto pb-[calc(24px+var(--safe-bottom))]">
+    <div class="m-group m-list mt-3">
+      <button type="button" class="m-row" @click="rename">
+        <span class="codicon codicon-edit text-[18px]" /><span class="flex-1 min-w-0 truncate">{{ team.title }}</span><span class="text-[12px] text-fg-muted">重命名</span>
+      </button>
+      <button type="button" class="m-row" @click="changeStatus">
+        <span class="codicon codicon-pulse text-[18px]" /><span class="flex-1">状态</span><span class="text-fg-muted">{{ TEAM_STATUS[team.status] }}</span><span class="codicon codicon-chevron-right text-fg-muted" />
+      </button>
+    </div>
+
+    <div class="flex gap-1 px-3 pt-4 overflow-x-auto">
+      <button
+        v-for="[v, label] in TABS" :key="v" type="button"
+        class="flex-none h-8 px-3 rounded-full border-none cursor-pointer text-[13px]" :class="tab === v ? 'bg-active text-fg' : 'bg-transparent text-fg-muted'"
+        @click="tab = v"
+      >{{ label }}</button>
+    </div>
+
+    <!-- 成员 -->
+    <div v-if="tab === 'members'" class="m-group m-list mt-2">
+      <button v-for="b in team.bots" :key="b.id" type="button" class="m-row" @click="botMenu(b.id)">
+        <BotAvatar :value="b.avatar" :size="28" />
+        <span class="flex-1 min-w-0">
+          <span class="block truncate">{{ b.name }}<span v-if="b.role === 'lead'" class="text-[12px] text-accent ml-1">组长</span></span>
+          <span class="block text-[12px] text-fg-muted truncate">{{ BOT_STATUS[b.status] }} · {{ b.description }}</span>
+        </span>
+        <span class="codicon codicon-ellipsis text-fg-muted" />
+      </button>
+      <button v-if="team.status !== 'closed'" type="button" class="m-row text-link" @click="addBot">
+        <span class="codicon codicon-person-add text-[18px]" />添加成员
+      </button>
+    </div>
+
+    <!-- 任务 -->
+    <div v-else-if="tab === 'jobs'" class="flex flex-col gap-2 mt-2">
+      <div v-if="!sortedJobs.length" class="m-group px-4 py-3 text-fg-muted">还没有任务。有组长时,交给组长的需求会成为任务。</div>
+      <div v-for="job in sortedJobs" :key="job.id" class="m-group p-3">
+        <div class="flex items-center gap-2">
+          <span class="font-600 flex-1 min-w-0 truncate">#{{ job.no }} {{ job.title }}</span>
+          <span class="text-[12px] flex-none" :class="job.status === 'blocked' ? 'text-warning' : job.status === 'failed' ? 'text-error' : 'text-fg-muted'">{{ JOB_STATUS[job.status] }}</span>
+        </div>
+        <div class="text-[12px] text-fg-muted mt-0.5">{{ job.taskCount }} 个子任务 · 组长 {{ job.leadRuns }} 轮 · {{ formatCost(job.costUsd) }} · {{ relativeTime(job.createdAt) }}</div>
+        <div v-if="job.summary" class="text-[13px] mt-1.5 whitespace-pre-wrap line-clamp-4">{{ job.summary }}</div>
+        <div v-for="t in tasksOf(job.id)" :key="t.id" class="text-[12px] mt-1 flex gap-1.5">
+          <span class="text-fg-muted flex-none">#{{ t.no }}</span>
+          <span class="flex-1 min-w-0 truncate">{{ botName(t.botId) }}:{{ t.title }}</span>
+          <span class="text-fg-muted flex-none">{{ TASK_STATUS[t.status] }}</span>
+        </div>
+        <div v-if="job.status === 'running' || job.status === 'blocked'" class="flex gap-2 mt-2">
+          <button v-if="job.status === 'blocked'" type="button" class="m-btn-primary flex-1 h-9" @click="jobAction(job, 'continue')">继续(放宽限额)</button>
+          <button type="button" class="m-btn-danger flex-1 h-9" @click="jobAction(job, 'cancel')">取消任务</button>
+        </div>
+      </div>
+    </div>
+
+    <!-- 文件 -->
+    <div v-else-if="tab === 'files'" class="m-group mt-2">
+      <FileList :client="client" :load="() => client.teams.files(id)" />
+    </div>
+
+    <!-- 用量 -->
+    <div v-else-if="tab === 'usage'" class="m-group m-list mt-2">
+      <div v-if="!usage" class="px-4 py-3 text-fg-muted">加载中…</div>
+      <template v-else>
+        <div class="m-row cursor-default">
+          <span class="flex-1 font-600">合计</span>
+          <span class="text-[13px] text-fg-muted">{{ usage.total.runs }} 轮 · {{ formatTokens(usage.total.inputTokens + usage.total.outputTokens) }} tokens · {{ formatCost(usage.total.costUsd) }}</span>
+        </div>
+        <div v-for="r in usage.bots" :key="r.botId" class="m-row cursor-default">
+          <span class="flex-1 min-w-0 truncate">{{ botName(r.botId) }}</span>
+          <span class="text-[13px] text-fg-muted">{{ r.runs }} 轮 · {{ formatTokens(r.inputTokens + r.outputTokens) }} · {{ formatCost(r.costUsd) }}</span>
+        </div>
+      </template>
+    </div>
+
+    <!-- 限额 -->
+    <div v-else-if="tab === 'limits' && limits" class="m-group mt-2 p-3 flex flex-col gap-3">
+      <label v-for="f in LIMIT_FIELDS" :key="f.key" class="flex items-center gap-2">
+        <span class="flex-1 text-[14px]">{{ f.label }}</span>
+        <input
+          class="m-field w-24 text-right" type="number" inputmode="decimal" :value="limits[f.key] ?? ''" :placeholder="f.nullable ? '不限' : ''"
+          @change="setLimit(f.key, ($event.target as HTMLInputElement).value)"
+        >
+        <span class="w-10 text-[12px] text-fg-muted">{{ f.unit }}</span>
+      </label>
+      <div class="text-[12px] text-fg-muted">超出任务级限额时任务暂停,可以在对话或「任务」中选择继续或取消。限额不能超过系统设置。</div>
+      <button type="button" class="m-btn-primary" @click="saveLimits">保存限额</button>
+    </div>
+
+    <div class="px-3 pt-6">
+      <button type="button" class="m-btn-danger w-full" @click="remove"><span class="codicon codicon-trash" />删除工作组</button>
+    </div>
+  </div>
+  <div v-else class="m-page flex-1 p-6 text-fg-muted">加载中…</div>
+</template>

+ 31 - 0
apps/mobile/src/views/TeamView.vue

@@ -0,0 +1,31 @@
+<script setup lang="ts">
+// 工作组群聊:<TeamChat>(@ 机器人、任务进度、审批与提问、限额卡片、上传都由组件处理)
+import { computed, onBeforeUnmount } from 'vue'
+import { TeamChat, useTeamSession } from '@agent-server/chat-ui'
+import TopBar from '../components/TopBar.vue'
+import { botClient } from '../lib/config'
+import { openFile } from '../lib/files'
+import { TEAM_STATUS } from '../lib/sessions'
+
+const props = defineProps<{ id: string }>()
+const client = botClient.value!
+const team = useTeamSession(client, props.id)
+onBeforeUnmount(() => team.dispose())
+
+const info = computed(() => team.state.team)
+const subtitle = computed(() => {
+  const t = info.value
+  if (!t) return ''
+  const busy = t.bots.filter(b => b.status !== 'idle').length
+  return [t.status !== 'active' ? TEAM_STATUS[t.status] : '', `${t.bots.length} 个机器人`, busy ? `${busy} 个工作中` : ''].filter(Boolean).join(' · ')
+})
+</script>
+
+<template>
+  <TopBar back :title="info?.title ?? '工作组'" :subtitle="subtitle">
+    <RouterLink :to="`/teams/${id}/info`" class="m-icon no-underline" aria-label="工作组详情"><span class="codicon codicon-info text-[18px]" /></RouterLink>
+  </TopBar>
+  <div class="flex-1 min-h-0 pb-[var(--safe-bottom)] bg-[var(--app-primary-background)]">
+    <TeamChat :client="client" :session="team" @open-file="f => openFile(client, f)" />
+  </div>
+</template>

+ 66 - 0
apps/mobile/src/views/TeamsView.vue

@@ -0,0 +1,66 @@
+<script setup lang="ts">
+// 工作组列表(Bot API):按状态筛选
+import { onMounted, ref, watch } from 'vue'
+import type { TeamDto, TeamStatus } from '@agent-server/client'
+import { BotAvatar } from '@agent-server/chat-ui'
+import TopBar from '../components/TopBar.vue'
+import { botClient } from '../lib/config'
+import { TEAM_STATUS } from '../lib/sessions'
+import { relativeTime } from '../lib/ui'
+
+const teams = ref<TeamDto[]>([])
+const status = ref<TeamStatus | undefined>()
+const loading = ref(false)
+const error = ref<string | null>(null)
+const FILTERS: [TeamStatus | undefined, string][] = [[undefined, '全部'], ['active', '进行中'], ['paused', '已暂停'], ['closed', '已关闭']]
+
+async function load() {
+  const client = botClient.value
+  if (!client) return
+  loading.value = true
+  try {
+    teams.value = await client.teams.list({ limit: 100, status: status.value })
+    error.value = null
+  } catch (err) {
+    error.value = (err as Error).message
+  } finally {
+    loading.value = false
+  }
+}
+onMounted(load)
+watch(status, load)
+</script>
+
+<template>
+  <TopBar title="工作组">
+    <button type="button" class="m-icon" aria-label="刷新" :disabled="loading" @click="load"><span class="codicon codicon-refresh text-[18px]" /></button>
+    <RouterLink to="/teams/new" class="m-icon no-underline" aria-label="新建工作组"><span class="codicon codicon-add text-[20px]" /></RouterLink>
+  </TopBar>
+  <div class="m-page flex-1 min-h-0 overflow-y-auto">
+    <div class="flex gap-2 px-3 pt-3 overflow-x-auto">
+      <button
+        v-for="[v, label] in FILTERS" :key="label" type="button"
+        class="flex-none h-8 px-3 rounded-full border-none cursor-pointer text-[13px]" :class="status === v ? 'bg-active text-fg' : 'bg-transparent text-fg-muted'"
+        @click="status = v"
+      >{{ label }}</button>
+    </div>
+    <div v-if="error" class="p-4 text-error">{{ error }}</div>
+    <div v-else-if="!teams.length && !loading" class="p-8 text-center text-fg-muted">点右上角 + 新建工作组</div>
+    <div class="py-2">
+      <RouterLink v-for="t in teams" :key="t.id" :to="`/teams/${t.id}`" class="m-row no-underline">
+        <span class="flex-1 min-w-0">
+          <span class="flex items-center gap-1.5">
+            <span class="truncate">{{ t.title }}</span>
+            <span v-if="t.status !== 'active'" class="flex-none text-[11px] px-1.5 rounded-sm bg-hover text-fg-muted">{{ TEAM_STATUS[t.status] }}</span>
+            <span v-if="t.bots.some(b => b.status === 'waiting_input')" class="codicon codicon-bell-dot text-[12px] text-warning" title="等待你的确认" />
+            <span v-else-if="t.bots.some(b => b.status !== 'idle')" class="codicon codicon-loading codicon-modifier-spin text-[12px] text-accent" />
+          </span>
+          <span class="flex items-center gap-1 mt-0.5">
+            <BotAvatar v-for="b in t.bots.slice(0, 6)" :key="b.id" :value="b.avatar" :size="16" />
+            <span class="text-[12px] text-fg-muted ml-1">{{ relativeTime(t.updatedAt) }}</span>
+          </span>
+        </span>
+      </RouterLink>
+    </div>
+  </div>
+</template>

+ 62 - 0
apps/mobile/src/views/UsageView.vue

@@ -0,0 +1,62 @@
+<script setup lang="ts">
+// Agent Key 的用量:今天、区间合计、每天、按模型
+import { computed, onMounted, ref, watch } from 'vue'
+import type { UsageSummaryDto } from '@agent-server/client'
+import TopBar from '../components/TopBar.vue'
+import { agentClient } from '../lib/config'
+import { attempt, formatCost, formatTokens } from '../lib/ui'
+
+const days = ref(7)
+const usage = ref<UsageSummaryDto | null>(null)
+async function load() {
+  usage.value = (await attempt(() => agentClient.value!.usage(days.value))) ?? null
+}
+onMounted(load)
+watch(days, load)
+
+type Totals = UsageSummaryDto['total']
+const tokens = (t: Totals) => t.inputTokens + t.cacheReadTokens + t.cacheWriteTokens + t.outputTokens
+const maxCost = computed(() => Math.max(0.0001, ...(usage.value?.daily.map(d => d.costUsd) ?? [])))
+const daily = computed(() => [...(usage.value?.daily ?? [])].reverse())
+</script>
+
+<template>
+  <TopBar back title="Agent 用量" />
+  <div class="m-page flex-1 min-h-0 overflow-y-auto pb-[calc(24px+var(--safe-bottom))]">
+    <div class="flex gap-2 px-3 pt-3">
+      <button
+        v-for="d in [7, 30, 90]" :key="d" type="button"
+        class="h-8 px-3 rounded-full border-none cursor-pointer text-[13px]" :class="days === d ? 'bg-active text-fg' : 'bg-transparent text-fg-muted'"
+        @click="days = d"
+      >{{ d }} 天</button>
+    </div>
+    <div v-if="!usage" class="p-6 text-fg-muted">加载中…</div>
+    <template v-else>
+      <div class="grid grid-cols-2 gap-2 px-3 pt-3">
+        <div v-for="[label, t] in ([['今天', usage.today], [`近 ${days} 天`, usage.total]] as const)" :key="label" class="rounded-lg bg-bg border border-solid border-border p-3">
+          <div class="text-[12px] text-fg-muted">{{ label }}</div>
+          <div class="text-[22px] font-600 mt-1">{{ formatCost(t.costUsd) }}</div>
+          <div class="text-[12px] text-fg-muted">{{ t.runs }} 轮 · {{ formatTokens(tokens(t)) }} tokens</div>
+        </div>
+      </div>
+
+      <div class="m-group-title">每天</div>
+      <div class="m-group m-list">
+        <div v-for="d in daily" :key="d.date" class="flex items-center gap-3 px-4 py-2">
+          <span class="w-12 text-[13px] text-fg-muted flex-none">{{ d.date.slice(5) }}</span>
+          <span class="flex-1 h-2 rounded-full bg-hover overflow-hidden"><span class="block h-full bg-accent" :style="{ width: `${(d.costUsd / maxCost) * 100}%` }" /></span>
+          <span class="w-28 text-right text-[12px] flex-none">{{ formatCost(d.costUsd) }} · {{ d.runs }} 轮</span>
+        </div>
+      </div>
+
+      <div class="m-group-title">按模型</div>
+      <div class="m-group m-list">
+        <div v-for="m in usage.byModel" :key="m.provider + m.model" class="m-row cursor-default">
+          <span class="flex-1 min-w-0 truncate">{{ m.model }}<span class="text-[12px] text-fg-muted ml-1">{{ m.provider }}</span></span>
+          <span class="text-[13px] text-fg-muted">{{ m.runs }} 轮 · {{ formatCost(m.costUsd) }}</span>
+        </div>
+        <div v-if="!usage.byModel.length" class="px-4 py-3 text-fg-muted">没有用量</div>
+      </div>
+    </template>
+  </div>
+</template>

+ 10 - 0
apps/mobile/tsconfig.json

@@ -0,0 +1,10 @@
+{
+  "extends": "../../tsconfig.base.json",
+  "compilerOptions": {
+    "noEmit": true,
+    "lib": ["ES2023", "DOM", "DOM.Iterable"],
+    "jsx": "preserve",
+    "types": ["vite/client"]
+  },
+  "include": ["src/**/*.ts", "src/**/*.vue"]
+}

+ 18 - 0
apps/mobile/uno.config.ts

@@ -0,0 +1,18 @@
+import { defineConfig, presetWind3 } from 'unocss'
+import { presetAgentTokens } from '@agent-server/ui-tokens'
+
+export default defineConfig({
+  presets: [presetWind3(), presetAgentTokens()],
+  // 手机上的触控尺寸:按钮、输入框至少 40px 高
+  shortcuts: [
+    ['m-btn', 'inline-flex items-center justify-center gap-1.5 h-10 px-4 rounded-md border-none cursor-pointer select-none disabled:(opacity-50 cursor-not-allowed)'],
+    ['m-btn-primary', 'm-btn bg-primary text-primary-fg'],
+    ['m-btn-secondary', 'm-btn bg-[var(--app-secondary-button-background)] text-fg'],
+    ['m-btn-danger', 'm-btn bg-transparent text-error border border-solid border-border-input'],
+    ['m-field', 'w-full h-10 px-3 rounded-md bg-input text-fg border border-solid border-border-input outline-none focus:border-border-active placeholder:text-[var(--app-input-placeholder-foreground)]'],
+    ['m-icon', 'inline-flex items-center justify-center w-10 h-10 rounded-md border-none bg-transparent text-fg cursor-pointer active:bg-hover disabled:opacity-40'],
+    ['m-row', 'flex items-center gap-3 w-full min-h-14 px-4 py-2 text-left border-0 bg-transparent text-fg cursor-pointer active:bg-hover'],
+    ['m-group', 'mx-3 rounded-lg bg-bg border border-solid border-border overflow-hidden'],
+    ['m-group-title', 'px-4 pt-4 pb-1.5 text-[12px] text-fg-muted'],
+  ],
+})

+ 21 - 0
apps/mobile/vite.config.ts

@@ -0,0 +1,21 @@
+import vue from '@vitejs/plugin-vue'
+import UnoCSS from 'unocss/vite'
+import { defineConfig } from 'vite'
+import pkg from './package.json' with { type: 'json' }
+
+// 浏览器里开发时,/v1 与 /bot/v1 转给 AgentServer(默认正式服务),不需要配置跨域;打包进 App 后直接访问设置中的服务地址
+const target = process.env.AGENT_SERVER_URL ?? 'https://agent.zenpower.cc'
+
+export default defineConfig({
+  base: './',
+  plugins: [vue(), UnoCSS()],
+  define: { __APP_VERSION__: JSON.stringify(pkg.version) },
+  server: {
+    host: '127.0.0.1',
+    port: 5175,
+    proxy: {
+      '/v1': { target, changeOrigin: true },
+      '/bot/v1': { target, changeOrigin: true },
+    },
+  },
+})

+ 2 - 0
apps/server/package.json

@@ -28,6 +28,7 @@
     "p-queue": "^10.0.0",
     "picomatch": "^4.0.7",
     "pino": "^10.4.0",
+    "qrcode": "^1.5.4",
     "turndown": "^7.2.4",
     "ulid": "^3.0.2",
     "undici": "^8.12.3",
@@ -38,6 +39,7 @@
     "@types/mime-types": "^3.0.1",
     "@types/node": "^26.6.5",
     "@types/picomatch": "^4.0.3",
+    "@types/qrcode": "^1.5.6",
     "@types/turndown": "^5.0.6",
     "drizzle-kit": "^0.31.11",
     "pino-pretty": "^13.2.0",

+ 2 - 0
apps/server/src/config.ts

@@ -21,6 +21,8 @@ export const config = {
   adminDist: env.AGENT_SERVER_ADMIN_DIST,
   /** 业务平台 SDK 包目录(默认为后台静态资源旁边的 sdk 目录) */
   sdkDist: env.AGENT_SERVER_SDK_DIST,
+  /** Android App 安装包目录(默认为后台静态资源旁边的 mobile 目录) */
+  mobileDist: env.AGENT_SERVER_MOBILE_DIST,
   /**
    * 信任哪些反向代理的 X-Forwarded-* 头:未设置或 false 为不信任;true 为信任所有来源(仅在服务只能经由代理访问时使用);
    * 也可以是逗号分隔的代理 IP / CIDR(推荐),只信任来自这些地址的转发信息,直接访问的客户端无法伪造

+ 145 - 0
apps/server/src/http/mobile-app.ts

@@ -0,0 +1,145 @@
+// Android App 下载页(apps/mobile):/app 为公开的介绍与下载页面,/app/<文件>.apk 为安装包。
+// 安装包与 index.json 由 deploy/release-apk.sh 发布到 app/mobile 目录;每次请求读取目录,发布新版本后无需重启
+import fs from 'node:fs'
+import path from 'node:path'
+import type { FastifyInstance } from 'fastify'
+import QRCode from 'qrcode'
+
+export interface MobileRelease {
+  version: string
+  file: string
+  size: number
+  sha256: string
+  publishedAt: string
+}
+
+function readRelease(dir: string | undefined): MobileRelease | null {
+  if (!dir) return null
+  try {
+    return JSON.parse(fs.readFileSync(path.join(dir, 'index.json'), 'utf8')) as MobileRelease
+  } catch {
+    return null
+  }
+}
+
+export function registerMobileApp(f: FastifyInstance, dirs: () => string | undefined) {
+  const notFound = { error: { code: 'not_found', message: '文件不存在' } }
+
+  f.get('/app', async (req, reply) => {
+    const release = readRelease(dirs())
+    const origin = `${req.protocol}://${req.host}`
+    const qr = await QRCode.toString(`${origin}/app`, { type: 'svg', margin: 1 })
+    reply.header('Cache-Control', 'no-cache')
+    return reply.type('text/html; charset=utf-8').send(renderPage(release, origin, qr))
+  })
+  f.get('/app/', (_req, reply) => reply.redirect('/app'))
+
+  f.get('/app/index.json', (_req, reply) => {
+    const release = readRelease(dirs())
+    if (!release) return reply.code(404).send(notFound)
+    reply.header('Cache-Control', 'no-cache')
+    return release
+  })
+
+  f.get<{ Params: { file: string } }>('/app/:file', (req, reply) => {
+    const dir = dirs()
+    const file = req.params.file
+    if (!dir || !/^[\w.-]+\.apk$/.test(file) || !fs.existsSync(path.join(dir, file))) return reply.code(404).send(notFound)
+    reply.header('Content-Type', 'application/vnd.android.package-archive')
+    reply.header('Content-Disposition', `attachment; filename="${file}"`)
+    reply.header('Content-Length', fs.statSync(path.join(dir, file)).size)
+    reply.header('Cache-Control', 'public, max-age=31536000, immutable')
+    return reply.send(fs.createReadStream(path.join(dir, file)))
+  })
+}
+
+const esc = (s: string) => s.replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]!)
+const mb = (n: number) => `${(n / 1048576).toFixed(1)} MB`
+
+function renderPage(release: MobileRelease | null, origin: string, qrSvg: string) {
+  const date = release ? new Date(release.publishedAt).toLocaleDateString('zh-CN', { timeZone: 'Asia/Shanghai' }) : ''
+  const download = release
+    ? `<a class="btn" href="/app/${esc(release.file)}" download>下载 Android 安装包</a>
+       <div class="meta">版本 ${esc(release.version)} · ${mb(release.size)} · ${esc(date)} 发布 · 需要 Android 7.0 及以上</div>`
+    : `<div class="btn disabled">还没有发布安装包</div><div class="meta">请联系管理员发布</div>`
+
+  return `<!doctype html>
+<html lang="zh-CN">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Agent · Android 客户端</title>
+<style>
+:root { --bg: #f8f8f8; --card: #fff; --fg: #2b2b2b; --muted: #6b6b6b; --border: #e5e5e5; --accent: #d97757; --accent-fg: #fff; --qr-bg: #fff; }
+@media (prefers-color-scheme: dark) {
+  :root { --bg: #181818; --card: #222; --fg: #e4e4e4; --muted: #9a9a9a; --border: #333; --accent: #d97757; --accent-fg: #fff; }
+}
+* { box-sizing: border-box; }
+body { margin: 0; background: var(--bg); color: var(--fg); font: 15px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif; }
+main { max-width: 760px; margin: 0 auto; padding: 40px 16px 56px; }
+.hero { display: flex; gap: 32px; align-items: center; }
+.hero-text { flex: 1; min-width: 0; }
+.logo { width: 56px; height: 56px; border-radius: 14px; background: var(--accent); color: var(--accent-fg); display: flex; align-items: center; justify-content: center; font-size: 28px; font-weight: 700; }
+h1 { font-size: 26px; margin: 16px 0 6px; }
+.lead { color: var(--muted); margin: 0 0 20px; }
+.btn { display: inline-flex; align-items: center; justify-content: center; height: 44px; padding: 0 24px; border-radius: 8px; background: var(--accent); color: var(--accent-fg); text-decoration: none; font-weight: 600; }
+.btn.disabled { background: var(--border); color: var(--muted); }
+.meta { margin-top: 8px; font-size: 13px; color: var(--muted); }
+.qr { flex: none; text-align: center; font-size: 12px; color: var(--muted); }
+.qr div { width: 160px; height: 160px; padding: 8px; border-radius: 8px; background: var(--qr-bg); border: 1px solid var(--border); margin-bottom: 6px; }
+.qr svg { width: 100%; height: 100%; display: block; }
+h2 { font-size: 17px; margin: 40px 0 12px; }
+.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
+.card { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 14px 16px; }
+.card b { display: block; margin-bottom: 4px; }
+.card span { font-size: 13px; color: var(--muted); }
+ol { margin: 0; padding-left: 20px; }
+ol li { margin: 6px 0; }
+code { font-family: "JetBrains Mono", Menlo, Consolas, monospace; font-size: 13px; padding: 1px 6px; border-radius: 4px; background: var(--card); border: 1px solid var(--border); word-break: break-all; }
+ul.notes { margin: 0; padding-left: 20px; color: var(--muted); font-size: 14px; }
+ul.notes li { margin: 4px 0; }
+@media (max-width: 640px) {
+  main { padding-top: 28px; }
+  .qr { display: none; }
+  .grid { grid-template-columns: 1fr; }
+  .btn { width: 100%; }
+}
+</style>
+</head>
+<body>
+<main>
+  <section class="hero">
+    <div class="hero-text">
+      <div class="logo">A</div>
+      <h1>Agent · Android 客户端</h1>
+      <p class="lead">在手机上与 AI 助手对话、指挥机器人工作组。填写管理员签发的 Key 即可使用,不需要另外登录。</p>
+      ${download}
+    </div>
+    <div class="qr"><div>${qrSvg}</div>用手机扫码打开本页</div>
+  </section>
+
+  <h2>能做什么</h2>
+  <div class="grid">
+    <div class="card"><b>对话</b><span>选择助手新建对话,实时查看回复与执行过程;上传文件让助手分析,在手机上确认它的操作请求。</span></div>
+    <div class="card"><b>工作组</b><span>组建机器人工作组,把需求交给组长拆分分派,或直接 @ 组员;随时查看任务进度、暂停或取消。</span></div>
+    <div class="card"><b>文件</b><span>图片和文本直接预览;表格、文档等文件可以用手机上的其他 App 打开或保存。</span></div>
+  </div>
+
+  <h2>安装与设置</h2>
+  <ol>
+    <li>下载安装包并打开。系统提示"禁止安装未知来源应用"时,按提示允许浏览器安装应用。</li>
+    <li>打开 App,在「设置」中填写服务地址:<code>${esc(origin)}</code></li>
+    <li>填写 Agent Key(用于对话)和 Bot Key(用于工作组),可以只填其中一个。Key 由管理员在后台签发。</li>
+    <li>点「测试连接」确认可用后保存。</li>
+  </ol>
+
+  <h2>说明</h2>
+  <ul class="notes">
+    <li>Key 只保存在手机上的 App 中。手机丢失或 Key 泄露时,请联系管理员停用该 Key。</li>
+    <li>App 切换到后台后不会收到通知;任务在服务端继续执行,回到 App 时自动补齐消息。</li>
+    <li>更新时下载新版本直接安装即可,已填写的设置会保留。</li>
+  </ul>
+</main>
+</body>
+</html>`
+}

+ 5 - 0
apps/server/src/http/server.ts

@@ -13,6 +13,7 @@ import adminRoutes from './routes/admin'
 import v1Routes from './routes/v1'
 import botRoutes from '../teams/routes'
 import { registerGateway } from '../mcp-gateway/server'
+import { registerMobileApp } from './mobile-app'
 
 export async function buildServer(app: App, opts: { logger: FastifyServerOptions['loggerInstance'] }) {
   const f = Fastify({ loggerInstance: opts.logger, trustProxy: config.trustProxy, bodyLimit: 2 * 1048576 })
@@ -82,5 +83,9 @@ export async function buildServer(app: App, opts: { logger: FastifyServerOptions
     })
   }
 
+  // Android App 下载页(deploy/release-apk.sh 发布):生产在 app/mobile,开发在仓库的 dist-mobile;目录不存在时页面提示还没有发布
+  const mobileDirs = [config.mobileDist, adminDist && path.resolve(adminDist, '../mobile'), path.resolve(here, '../../../../dist-mobile'), path.resolve(here, '../../../dist-mobile')]
+  registerMobileApp(f, () => mobileDirs.find((p): p is string => !!p && fs.existsSync(path.join(p, 'index.json'))))
+
   return f
 }

+ 48 - 0
apps/server/test/mobile-app.test.ts

@@ -0,0 +1,48 @@
+import fs from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+import Fastify from 'fastify'
+import { afterAll, describe, expect, it } from 'vitest'
+import { registerMobileApp } from '../src/http/mobile-app'
+
+const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mobile-app-'))
+let published = false
+const f = Fastify()
+registerMobileApp(f, () => (published ? dir : undefined))
+afterAll(() => fs.rmSync(dir, { recursive: true, force: true }))
+
+describe('Android App 下载页', () => {
+  it('还没有发布时页面提示,安装包与 index.json 返回 404', async () => {
+    const page = await f.inject({ method: 'GET', url: '/app' })
+    expect(page.statusCode).toBe(200)
+    expect(page.headers['content-type']).toContain('text/html')
+    expect(page.body).toContain('还没有发布安装包')
+    expect((await f.inject({ method: 'GET', url: '/app/index.json' })).statusCode).toBe(404)
+    expect((await f.inject({ method: 'GET', url: '/app/agent-0.1.0.apk' })).statusCode).toBe(404)
+  })
+
+  it('发布后显示版本与下载链接,安装包按附件下载', async () => {
+    fs.writeFileSync(path.join(dir, 'agent-0.2.0.apk'), 'apk-bytes')
+    fs.writeFileSync(path.join(dir, 'index.json'), JSON.stringify({ version: '0.2.0', file: 'agent-0.2.0.apk', size: 9, sha256: 'x', publishedAt: '2026-10-27T08:00:00Z' }))
+    published = true
+
+    const page = await f.inject({ method: 'GET', url: '/app', headers: { host: 'agent.example.com' } })
+    expect(page.body).toContain('href="/app/agent-0.2.0.apk"')
+    expect(page.body).toContain('版本 0.2.0')
+    expect(page.body).toContain('http://agent.example.com')
+    expect(page.body).toContain('<svg')
+
+    expect((await f.inject({ method: 'GET', url: '/app/index.json' })).json().version).toBe('0.2.0')
+    const apk = await f.inject({ method: 'GET', url: '/app/agent-0.2.0.apk' })
+    expect(apk.statusCode).toBe(200)
+    expect(apk.headers['content-type']).toBe('application/vnd.android.package-archive')
+    expect(apk.headers['content-disposition']).toContain('attachment')
+    expect(apk.body).toBe('apk-bytes')
+  })
+
+  it('只提供 .apk 文件', async () => {
+    fs.writeFileSync(path.join(dir, 'secret.txt'), 'x')
+    expect((await f.inject({ method: 'GET', url: '/app/secret.txt' })).statusCode).toBe(404)
+    expect((await f.inject({ method: 'GET', url: '/app/..%2Findex.json.apk' })).statusCode).toBe(404)
+  })
+})

+ 22 - 0
deploy/release-apk.sh

@@ -0,0 +1,22 @@
+#!/usr/bin/env bash
+# 打包 Android App 并发布到 /srv/agent-server/app/mobile,下载页 /app 立即生效(不需要重启服务)。
+# 用法:deploy/release-apk.sh(需要 sudo 权限;本机需要 JDK 21 与 Android SDK,见 apps/mobile/README.md)
+# 发布新版本前先修改 apps/mobile/package.json 的 version,否则手机上无法覆盖安装为"新版本"
+set -euo pipefail
+cd "$(dirname "$0")/.."
+ROOT=/srv/agent-server
+
+pnpm apk
+node scripts/release-apk.mjs
+
+sudo mkdir -p "$ROOT/app/mobile"
+sudo rsync -a --delete dist-mobile/ "$ROOT/app/mobile/"
+sudo chown -R root:root "$ROOT/app/mobile"
+sudo chmod -R a+rX "$ROOT/app/mobile"
+
+if curl -sf http://127.0.0.1:8787/app/index.json >/dev/null; then
+  echo "发布完成:http://127.0.0.1:8787/app"
+else
+  echo "安装包已复制,但下载页没有读到:服务是否已部署包含下载页的版本?" >&2
+  exit 1
+fi

+ 2 - 0
package.json

@@ -6,6 +6,8 @@
     "dev": "pnpm -r --parallel --filter ./apps/* dev",
     "dev:server": "pnpm --filter @agent-server/server dev",
     "dev:admin": "pnpm --filter @agent-server/admin dev",
+    "dev:mobile": "pnpm --filter @agent-server/mobile web",
+    "apk": "pnpm --filter @agent-server/mobile apk",
     "build": "pnpm -r build",
     "typecheck": "pnpm -r typecheck",
     "test": "pnpm -r test"

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 619 - 12
pnpm-lock.yaml


+ 32 - 0
scripts/release-apk.mjs

@@ -0,0 +1,32 @@
+#!/usr/bin/env node
+// 把 apps/mobile 打好的 APK 整理为发布目录 dist-mobile:agent-<版本>.apk 与 index.json,由服务在 /app 下载页提供。
+// 用法:先 pnpm apk 打包,再执行本脚本;deploy/release-apk.sh 会依次完成并发布到服务目录
+import crypto from 'node:crypto'
+import fs from 'node:fs'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
+const apk = path.join(root, 'apps/mobile/android/app/build/outputs/apk/debug/app-debug.apk')
+const out = path.join(root, 'dist-mobile')
+const { version } = JSON.parse(fs.readFileSync(path.join(root, 'apps/mobile/package.json'), 'utf8'))
+
+if (!fs.existsSync(apk)) {
+  console.error(`没有找到安装包:${apk},请先执行 pnpm apk`)
+  process.exit(1)
+}
+
+fs.rmSync(out, { recursive: true, force: true })
+fs.mkdirSync(out, { recursive: true })
+const file = `agent-${version}.apk`
+fs.copyFileSync(apk, path.join(out, file))
+const data = fs.readFileSync(path.join(out, file))
+const release = {
+  version,
+  file,
+  size: data.length,
+  sha256: crypto.createHash('sha256').update(data).digest('hex'),
+  publishedAt: new Date().toISOString(),
+}
+fs.writeFileSync(path.join(out, 'index.json'), `${JSON.stringify(release, null, 2)}\n`)
+console.log(`dist-mobile/${file}(${(data.length / 1048576).toFixed(1)} MB)`)

Nem az összes módosított fájl került megjelenítésre, mert túl sok fájl változott