chore: update documentation, angular logic and realtime recording spec
1
.gitignore
vendored
@ -48,3 +48,4 @@ backend/.env
|
|||||||
*.tar.gz
|
*.tar.gz
|
||||||
backend/storage/clients/*/history/
|
backend/storage/clients/*/history/
|
||||||
backend/storage/inbox/
|
backend/storage/inbox/
|
||||||
|
backend/storage/*.db
|
||||||
|
|||||||
16
angular.json
@ -70,6 +70,22 @@
|
|||||||
],
|
],
|
||||||
"outputHashing": "all"
|
"outputHashing": "all"
|
||||||
},
|
},
|
||||||
|
"tauri": {
|
||||||
|
"baseHref": "./",
|
||||||
|
"budgets": [
|
||||||
|
{
|
||||||
|
"type": "initial",
|
||||||
|
"maximumWarning": "500kB",
|
||||||
|
"maximumError": "1MB"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "anyComponentStyle",
|
||||||
|
"maximumWarning": "15kB",
|
||||||
|
"maximumError": "30kB"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputHashing": "all"
|
||||||
|
},
|
||||||
"development": {
|
"development": {
|
||||||
"optimization": false,
|
"optimization": false,
|
||||||
"extractLicenses": false,
|
"extractLicenses": false,
|
||||||
|
|||||||
@ -141,7 +141,7 @@ async def process_uploaded_material_oss(
|
|||||||
await loop.run_in_executor(None, _extract_docx, str(raw_path), str(md_path), original_filename)
|
await loop.run_in_executor(None, _extract_docx, str(raw_path), str(md_path), original_filename)
|
||||||
elif ext in [".txt", ".md", ".csv"]:
|
elif ext in [".txt", ".md", ".csv"]:
|
||||||
await loop.run_in_executor(None, _extract_txt, str(raw_path), str(md_path), original_filename)
|
await loop.run_in_executor(None, _extract_txt, str(raw_path), str(md_path), original_filename)
|
||||||
elif ext in [".m4a", ".mp3", ".wav"]:
|
elif ext in [".m4a", ".mp3", ".wav", ".webm", ".ogg"]:
|
||||||
await loop.run_in_executor(None, _extract_audio_asr, str(raw_path), str(md_path), original_filename, bucket, oss_key)
|
await loop.run_in_executor(None, _extract_audio_asr, str(raw_path), str(md_path), original_filename, bucket, oss_key)
|
||||||
else:
|
else:
|
||||||
await loop.run_in_executor(None, _simulate_extract, str(md_path), original_filename)
|
await loop.run_in_executor(None, _simulate_extract, str(md_path), original_filename)
|
||||||
|
|||||||
163
docs/MEETING_DEEPVIEW_COMMERCIAL_SYNC.md
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
# Deepview 商业顾问同步会议
|
||||||
|
**与罗波 · 2026-04-14**
|
||||||
|
|
||||||
|
**会议目的**:同步《深维·面诊沟通X光片》智能体的阶段性产品设计进展,邀请罗波以商业顾问视角,就产品形态、市场切入策略与商业叙事给出反馈。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 当前产品全貌(设计进展综述)
|
||||||
|
|
||||||
|
### 一句话定义
|
||||||
|
**深维是一款面向美容医疗场景的 AI 面诊助理:医生按一下录音,就能拿到一份面诊 X 光片;输上客户的名字,就能拿到一份完整的客户全生命周期档案。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 一、已完成交付的核心能力
|
||||||
|
|
||||||
|
#### 1. 实时录音管线(Phase 1 & 2 已跑通)
|
||||||
|
- **Web 前台录音**(已完成):手机/电脑打开 Deepview,点击录音按钮,结束后自动触发 OSS 上传 + DashScope Paraformer V2 说话人分离 ASR,2~3 分钟后生成 X 光片报告,无需任何人工干预。
|
||||||
|
- **Android 后台保活录音**(已完成):已完成 Tauri Android 打包,从 MindOS 迁移了前台服务三件套(`RecordingForegroundService.kt`)。手机锁屏放进口袋,可连续录制 4 小时以上,录音数据不掉、不丢、不断。
|
||||||
|
- **统一管线设计**:无论来源是手动上传文件、Web 前台录音、还是 Android 后台录音,都统一汇入 `runRealPipeline(file)` 这一个入口,后端永远只看到 一个 OSS key + 一个文件名。
|
||||||
|
|
||||||
|
```
|
||||||
|
前台录音 (Web MediaRecorder) ┐
|
||||||
|
后台录音 (Android RecordingService) ├→ 统一产出: File 对象 → OSS → ASR → asr.md → 报告
|
||||||
|
手动上传 (input[type=file]) ┘
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. AI 分析管线(两段式 + 双上下文)
|
||||||
|
|
||||||
|
**单次面诊 → X 光片报告(Recording 模式)**
|
||||||
|
- Stage 1: Hermes AI 军师读取本次 `asr.md`,生成五模块结构化报告草稿(接纳度、体征信号、信任断点、雷达评分、行动处方)
|
||||||
|
- Stage 2: Qwen 接力将 Markdown 转为严格 JSON,前端强类型渲染,防止 AI 偷懒省字段
|
||||||
|
|
||||||
|
**全景档案 → 客户托付档案(Client 模式)**
|
||||||
|
- Agent 读取该客户所有历史录音、已有 profile 档案
|
||||||
|
- 生成完整的客户生命周期洞察:LTV 估算、拒因池分析、引荐关系图谱、战略破冰方案
|
||||||
|
|
||||||
|
**双上下文切换(核心架构创新)**:
|
||||||
|
```
|
||||||
|
/report/rep_xxx → contextId = "recording:xxx" → 单次战术复盘
|
||||||
|
/client/cli_xxx → contextId = "client:xxx" → 全景战略洞察
|
||||||
|
```
|
||||||
|
前端只传一个 contextId,后端自动决定加载哪些文件,前端不参与编排。
|
||||||
|
|
||||||
|
#### 3. Inbox 惰性归档(Lazy Binding)
|
||||||
|
- 医生录完音或上传完照片,数据落入 `inbox/{reportId}/`,完全不需要提前选客户、建档、填表
|
||||||
|
- 报告生成后,医生点一次"归档"即将本次完整数据包(asr.md + report + 音频)原子迁移到 `clients/{clientId}/history/`
|
||||||
|
- 这是一个"先有价值,后有治理"的体验设计,先让 AI 裸推演,事后再绑定关系
|
||||||
|
|
||||||
|
#### 4. Markdown-First 物理落盘与来源分级(防幻觉架构)
|
||||||
|
三级证据链,层级越低越可信:
|
||||||
|
| 级别 | 文件 | 含义 | 不可变性 |
|
||||||
|
|------|------|------|---------|
|
||||||
|
| L0 | `asr.md` | 人类原声客观转写,不含 AI 推断 | ✅ 一旦生成不可修改 |
|
||||||
|
| L1 | `report_draft.md` | AI 基于单次录音的一阶分析摘要 | 可重新生成,但有标记 |
|
||||||
|
| L2 | `profile.md` | AI 跨多次录音的聚合客户档案 | 可更新,但优先级最低 |
|
||||||
|
|
||||||
|
设计核心:Agent 下次打开客户档案时,能明确区分哪句话来自医生原话(L0),哪句话是 AI 自己推断的(L2),杜绝"幻觉叠加幻觉"的复利效应。
|
||||||
|
|
||||||
|
#### 5. Prosumer-First 数据沙箱(个人生产力联邦)
|
||||||
|
- 物理隔离边界从"公司"下沉到"个人账号(userId)"
|
||||||
|
- 李大夫的客户档案,与王主任没有任何交集,底层文件系统级隔离
|
||||||
|
- 零多租户 RAG 污染,零权限管理负担,彻底消除传统 CRM 的"管理爹味"
|
||||||
|
|
||||||
|
#### 6. 三元知识域架构(已设计,待实现)
|
||||||
|
面向多机构 SaaS 场景的知识隔离架构:
|
||||||
|
```
|
||||||
|
平台域 platform/wiki/ → 运维写,所有用户只读 (底线合规)
|
||||||
|
机构域 orgs/{orgId}/wiki/ → 机构管理员写,本院用户只读 (RFM规则/话术/定价)
|
||||||
|
个人域 users/{userId}/ → 用户独占读写 (客户档案/录音/个人画像)
|
||||||
|
```
|
||||||
|
机构的差异化方法论(RFM 分级、话术规范、定价表)作为"判规"注入 Agent,与原始录音这个"答卷"绝对隔离,避免知识污染。
|
||||||
|
|
||||||
|
#### 7. ChatBox 上下文追问(已设计,待前端接入)
|
||||||
|
- 后端 SSE 管线已完全就绪(7 个封闭 SSE 事件,与馨总智能体同构)
|
||||||
|
- 当前 ChatBox 是 Mock,待接入真实 API 后,医生在 X 光片页面可直接追问:"这次她为什么拒绝了乔雅登?",AI 实时流式回答,完全基于本次录音上下文
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 二、整体架构示意
|
||||||
|
|
||||||
|
```
|
||||||
|
医生的动作 平台基础设施 AI 输出
|
||||||
|
|
||||||
|
短按→录音 OSS 直传(不经后端 X 光片报告
|
||||||
|
长按→上传图片/文件 → 节省带宽) → (五模块结构化)
|
||||||
|
↓
|
||||||
|
DashScope ASR 客户托付档案
|
||||||
|
Hermes AI 军师 → (全景洞察 + 破冰策略)
|
||||||
|
LiteLLM Gateway
|
||||||
|
(gemini-pro-vertex) 追问式对话
|
||||||
|
→ (ChatBox 实时流)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 会议议题
|
||||||
|
|
||||||
|
### 议题 1:核心商业叙事
|
||||||
|
**主题**:用"高价值输出换取低动作配合"的飞轮,去替代传统 CRM 的"填表换数据"逻辑
|
||||||
|
|
||||||
|
**要点**:医生作为我们的客户,不应该被"你需要先做什么才能获得什么"。
|
||||||
|
|
||||||
|
- 医生只需**点开录音**(最低摩擦力的一个动作),就能换回一份高质量的面诊洞察——接纳度雷达、信任断点、下次破冰策略
|
||||||
|
- 医生只需**输入客户名字**(极简的一个建档),就能立刻调出该客户从第一次面诊到今天的全景生命周期档案——拒因演变、转介绍关系图、LTV 估算
|
||||||
|
- 系统不对医生提"管理要求",只用超预期的输出去诱导他们自然配合
|
||||||
|
|
||||||
|
**向罗波提问**:
|
||||||
|
- 这种"不条件前置、直接给价值"的产品逻辑,对于美容医疗诊所的医生群体,冷启动时的破冰能力和推广节奏应该怎么设计?
|
||||||
|
- 我们在商业宣传上,这个核心正反馈飞轮该怎么包装?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 议题 2:GTM 路径选择
|
||||||
|
**主题**:以"个人超能助理"切入,用 B2C 极简架构包抄 B2B 护城河
|
||||||
|
|
||||||
|
**要点**:
|
||||||
|
- 现在的系统就是一个"属于医生个人"的超能助理,数据完全私域化
|
||||||
|
- 没有装 IT、没有部门权限树、没有管理员审批,直接扫码登录就用
|
||||||
|
- 等医生产生足够的工具依赖后,通过"授权联邦"上移:医生主动授权特定档案给科室/诊所管理层,换取企业版订阅
|
||||||
|
|
||||||
|
**向罗波提问**:
|
||||||
|
- 在医疗/医美赛道,这种"先武装一线医师/医助"、再从个人工具演进成团队工具的 PLG 增长路径,你认为走得通吗?
|
||||||
|
- 诊所管理层对"数据在员工手里而不在机构手里"这件事,阻力有多大?商业话术上该怎么提前破解这个卡点?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 议题 3:AI 可信度 — 医疗场景的差异化壁垒
|
||||||
|
**主题**:用"证据链溯源架构"解决医疗领域对 AI 的信任危机
|
||||||
|
|
||||||
|
**要点**:
|
||||||
|
- 我们在底层做了严格的三级证据分层(L0 原始录音文本→L1 单次报告→L2 跨次档案),防止 AI 幻觉叠加幻觉
|
||||||
|
- 医生随时可以下钻:这一句结论,具体来自哪次录音的哪段原话?
|
||||||
|
- 系统强制区分"人类说的话"和"AI 推断出来的话",并在档案里做好标注
|
||||||
|
|
||||||
|
**背景补充**:这不仅是技术架构,也是一个反向竞争壁垒——市面上大多数 AI 工具把 AI 的推断和原始事实"混为一谈",等事实和推断混在一起,档案的可信度就螺旋下降。我们的设计从根本上切断了这条路。
|
||||||
|
|
||||||
|
**向罗波提问**:
|
||||||
|
- 把"可溯源性"和"防幻觉架构"作为面向医疗客户的核心卖点,杀伤力足够吗?还是太技术化了,需要翻译成更通俗的说法?
|
||||||
|
- 医疗机构在采购 AI 工具时,除了"幻觉"风险,还在意哪些合规或信任维度?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 议题 4:MVP 形态与种子用户策略
|
||||||
|
**主题**:当前底座已跑通,下一步如何快速找到第一批种子机构
|
||||||
|
|
||||||
|
**当前已验证的技术底座**:
|
||||||
|
- ✅ Web 端录音全流程(录音→ASR→报告→ChatBox 追问)
|
||||||
|
- ✅ Android 壳打包完成,支持背景保活录制
|
||||||
|
- ✅ 客户档案管线(归档→全景档案生成)
|
||||||
|
- ✅ 数据私域沙箱(Prosumer-First 隔离)
|
||||||
|
- ⬜ 三元知识域(多机构 SaaS 隔离,待实现)
|
||||||
|
- ⬜ ChatBox 真实 SSE 接入(待前端接入,后端已就绪)
|
||||||
|
|
||||||
|
**向罗波提问**:
|
||||||
|
- 基于当前已验证的闭环,拿去找第一批种子诊所做灰度,MVP 应该精简打磨到什么程度?
|
||||||
|
- 是先聚焦在"录音→X光片"这一个最亮眼的功能点上,还是必须把"长期客户档案"一起讲?
|
||||||
|
- 你认为种子用户在哪个圈子里更好找:单店美容诊所、连锁医美机构、还是独立医生执业(个体医美顾问)?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 备忘
|
||||||
|
> 这场会议不在于汇报代码写了多少行。核心目标是:用以上四个产品设计事实为弹药,向罗波确认我们的商业叙事是否打动人,并挖掘出最适合 Deepview 走向市场的 GTM 策略。
|
||||||
@ -58,18 +58,19 @@ graph LR
|
|||||||
- **当前情况(长按上传)**:`<input type="file">` → `File` → `runRealPipeline(file)`
|
- **当前情况(长按上传)**:`<input type="file">` → `File` → `runRealPipeline(file)`
|
||||||
- **新增情况(短按录音)**:`MediaRecorder.stop()` → `Blob` → `new File([blob])` → `runRealPipeline(file)`
|
- **新增情况(短按录音)**:`MediaRecorder.stop()` → `Blob` → `new File([blob])` → `runRealPipeline(file)`
|
||||||
|
|
||||||
**后端不需要任何修改。** ASR 处理函数 `_extract_audio_asr` 已支持 `.m4a` / `.mp3` / `.wav` 扩展名。
|
**后端仅需追加 `.webm` / `.ogg` 到白名单(已完成)。** ASR 处理函数 `_extract_audio_asr` 现已支持 `.m4a` / `.mp3` / `.wav` / `.webm` / `.ogg` 扩展名。
|
||||||
|
|
||||||
### 2.2 已跑通的前端管线骨架(复用 `app.ts` 已有逻辑)
|
### 2.2 已跑通的前端管线骨架(复用 `app.ts` 已有逻辑)
|
||||||
|
|
||||||
当前 `app.ts` 中已有完整的管线调度 :
|
当前 `app.ts` 中已有完整的管线调度 :
|
||||||
|
|
||||||
| 已有代码 | 所在位置 | 作用与是否修改 |
|
| 已有代码 | 所在位置 | 作用与状态 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `runRealPipeline(file: File)` | `app.ts:391` | **复用** — Upload → Confirm → Navigate 的统一入口 |
|
| `runRealPipeline(file: File)` | `app.ts` | ✅ **复用** — Upload → Confirm → Navigate 的统一入口 |
|
||||||
| `handleFileUpload(event)` | `app.ts:378` | **复用** — 长按触发文件选择 |
|
| `handleFileUpload(event)` | `app.ts` | ✅ **复用** — 长按触发文件选择 |
|
||||||
| `startRecording()` / `stopAndProcess()` | `app.ts:356/365` | **修改** — 目前 `startRecording` 只启动了计时器和 Mock UI,需真正接入 `MediaRecorder` |
|
| `startRecording()` / `stopAndProcess()` | `app.ts` | ✅ **已完成** — `startRecording` 接入 `getUserMedia` + `MediaRecorder`;`stopAndProcess` 收集 Blob → File → `runRealPipeline` |
|
||||||
| `runAgentPipeline()` | `app.ts:422` | **删除** — 这是 mock 占位逻辑,将被实际录音替代 |
|
| `togglePauseResume()` | `app.ts` | ✅ **已完成** — 同步调用 `MediaRecorder.pause()` / `.resume()` |
|
||||||
|
| ~~`runAgentPipeline()`~~ | ~~已删除~~ | ✅ **已删除** — mock 占位逻辑已清除 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -126,28 +127,32 @@ graph LR
|
|||||||
|
|
||||||
## 4. 分阶段交付计划
|
## 4. 分阶段交付计划
|
||||||
|
|
||||||
### Phase 1: Web 前台录音(无 Tauri 依赖)
|
### Phase 1: Web 前台录音(无 Tauri 依赖)✅ 已完成 (2026-04-13)
|
||||||
**目标**:让现有的"短按面诊"按钮真正可用,替代当前的 Mock 空转。
|
**目标**:让现有的"短按面诊"按钮真正可用,替代当前的 Mock 空转。
|
||||||
|
|
||||||
改动范围仅限 `app.ts` :
|
已完成改动:
|
||||||
1. `startRecording()` 内调用 `navigator.mediaDevices.getUserMedia` + `new MediaRecorder`
|
1. ✅ `startRecording()` 内调用 `navigator.mediaDevices.getUserMedia` + `new MediaRecorder(stream, { mimeType: 'audio/webm;codecs=opus' })`
|
||||||
2. `stopAndProcess()` 内拿到 `Blob` → `new File([blob], 'recording_xxx.m4a')` → `this.runRealPipeline(file)`
|
2. ✅ `stopAndProcess()` 内拿到 `Blob` → `new File([blob], 'consult_xxx.webm')` → `this.runRealPipeline(file)`
|
||||||
3. 删除 `runAgentPipeline()` 这个 mock 函数
|
3. ✅ `togglePauseResume()` 同步调用 `MediaRecorder.pause()` / `.resume()`
|
||||||
|
4. ✅ 删除 `runAgentPipeline()` mock 函数及 Action Sheet 中的 mock Relay Bar UI
|
||||||
|
5. ✅ `ngOnDestroy` 中增加 MediaRecorder 流释放防泄漏
|
||||||
|
|
||||||
**后端改动**:无。
|
**后端改动**:`deepview_materials.py` 追加 `.webm` / `.ogg` 到音频白名单(1 行)。
|
||||||
**预期效果**:用户在 PC/手机浏览器打开 Deepview,短按开始录音,再按停止,Mixin 面板自动弹起加载骨架屏,2~3 分钟后即可查看 X 光片报告。
|
**实际效果**:用户在 PC/手机浏览器打开 Deepview,短按开始录音,再按停止,自动跳转骨架屏页面,2~3 分钟后即可查看 X 光片报告。
|
||||||
|
|
||||||
### Phase 2: Tauri Android 壳化 + 后台保活
|
### Phase 2: Tauri Android 壳化 + 后台保活 ✅ 已完成 (2026-04-13)
|
||||||
**目标**:将当前 Angular SPA 包裹进 Tauri 2 的 Android WebView 壳中,启用后台录音。
|
**目标**:将当前 Angular SPA 包裹进 Tauri 2 的 Android WebView 壳中,启用后台录音。
|
||||||
|
|
||||||
改动范围:
|
已完成改动:
|
||||||
1. 初始化 Tauri Android 工程:`cargo tauri android init`
|
1. ✅ `cargo tauri init` + `cargo tauri android init`(identifier: `club.brainwork.deepview`)
|
||||||
2. 从 MindOS 迁移三件套:`RecordingForegroundService.kt` / `RecordingServicePlugin.kt` / Manifest 权限
|
2. ✅ 从 MindOS 迁移三件套(改包名 `club.brainwork.deepview`、通知文案"深维面诊 录音中")
|
||||||
3. 前端增加 `PlatformService` 感知 `isTauri`
|
3. ✅ `PlatformService`(`isTauri` / `isMobile` / `os` 信号检测)
|
||||||
4. 录音逻辑走分支:
|
4. ✅ `app.ts` 录音分支:`isTauri` 时调用 `invoke('plugin:recording-service|start_service/stop_service')`
|
||||||
- `isTauri = false`(Phase 1 逻辑)→ `MediaRecorder`
|
5. ✅ `angular.json` 新增 `tauri` 构建配置(`baseHref: "./"`)
|
||||||
- `isTauri = true` → `invoke('plugin:recording-service|start_service')` → 原生录音 → `invoke('plugin:recording-service|stop_service')` 回调文件路径 → `new File(...)` → `runRealPipeline(file)`
|
6. ✅ `lib.rs` Android 条件编译注册 RecordingServicePlugin
|
||||||
|
|
||||||
|
**关键设计决策**:Tauri 壳内继续使用 WebView `MediaRecorder`(Phase 1 代码 100% 复用),ForegroundService 仅负责进程保活。
|
||||||
|
**APK 输出**:`src-tauri/gen/android/app/build/outputs/apk/universal/debug/app-universal-debug.apk`(389MB debug 包)
|
||||||
**后端改动**:无。
|
**后端改动**:无。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
105
docs/SPEC_xinzong_dual_context_architecture_v1.md
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
# SPEC: 馨总智能体双态上下文架构 (Dual-Context Architecture)
|
||||||
|
|
||||||
|
> **版本**: v1.0
|
||||||
|
> **前置依赖**: Hermes Agent 原生架构、`DESIGN_PHILOSOPHY_AI_NATIVE.md`
|
||||||
|
> **状态**: 草案 (Proposal)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 架构哲学与核心洞察
|
||||||
|
|
||||||
|
在“焦点品牌”高频互动的咨询协助场景中,传统的 RAG(知识库增强检索)模型会遭遇严重的“上下文污染”(投毒)。为了解决这个问题,本系统实现了**“判官与考生”**的双态上下文分离架构。
|
||||||
|
|
||||||
|
| 维度 | 馨总 Context (XinZong Context) | 项目 Context (Project Context) |
|
||||||
|
|---|---|---|
|
||||||
|
| **角色比喻** | ⚖️ 判官 / 评价标尺 / 阅卷老师 | 📝 考生 / 答卷 / 草稿箱 |
|
||||||
|
| **内容属性** | 方法论、咨询框架、九要素、战术纪律 | 竞品调研报告、客户原始诉求、顾问写的半成品 PPT |
|
||||||
|
| **可变性** | 极度稳定(Immutable),由核心架构师/馨总维护 | 极度混乱(Highly Mutable),任何成员皆可上传、推翻 |
|
||||||
|
| **RAG 角色** | 提供**“判断力”**与**“指导原则”** | 提供**“被处理的原始素材”**与**“靶子”** |
|
||||||
|
|
||||||
|
这一洞察使得**项目池彻底免除了 RAG 污染的担忧**。无论成员传了多么糟糕的竞品文档,大模型都会运用“馨总 Context”里的金科玉律去无情地批评和重构它,而不会把它当成不可侵犯的真理。
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> **🚀 独一无二的商业核心价值点**
|
||||||
|
> 在市面普通的 LLM / 知识库产品中,由于系统将“方法论”与“被点评的文档”平权融合,**你犯下的错(尤其是未被当场指出的错误),就会变成 AI 认为正确的依据,从而形成复利式的推理和决策污染**。
|
||||||
|
> 本架构通过将两者的身份降维并绝对隔离,彻底终结了知识库的“反向污染死循环”,这就是馨总智能体傲视群雄的壁垒。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 基于 Hermes 原生能力的工程映射
|
||||||
|
|
||||||
|
为了避免重复造轮子,我们将上述业务哲学精准映射到底层 Hermes 操作系统的原生能力上。
|
||||||
|
|
||||||
|
### 2.1 馨总 Context 映射为 `Hermes Skills`
|
||||||
|
Hermes 拥有完备的 Skills 子系统(`agent/skill_commands.py`)。
|
||||||
|
- **实现方案**:将馨总的方法论(如《界定原点市场》、《品牌九要素》)转化为 Markdown 格式的 Skill 文件,存放在 `~/.hermes/skills/` 中。
|
||||||
|
- **性能优势 (Prompt Caching)**:Hermes 会将启用的 Skill 作为 User Message 注入对话上下文。由于高频调用且极少变更,这部分将完美命中大模型(如 Gemini/Claude)的 **Prompt Caching**,实现极低延迟和近乎零成本的智能赋能。
|
||||||
|
|
||||||
|
### 2.2 项目 Context 映射为 `工作区存储 + 提示词注入`
|
||||||
|
- **物理存储**:项目等同于一个文件夹 `storage/projects/{projectId}/`。
|
||||||
|
- **实现方案**:每次进入对话时,服务端(Gateway)读取该项目下处于“启用”状态的文档集合,整合投递。
|
||||||
|
- **Prompt 组装隔离**:通过 XML 标签强力隔离“标尺”与“材料”,避免大模型混淆。
|
||||||
|
|
||||||
|
```xml
|
||||||
|
【你的身份与标尺】
|
||||||
|
你已经加载了馨总品牌战略技能(Skills已注入)。
|
||||||
|
|
||||||
|
【待处理的烂摊子(项目 Context)】
|
||||||
|
<project_materials>
|
||||||
|
<file name="竞品调研_v1.docx">...内容...</file>
|
||||||
|
<file name="小红书访谈记录.md">...内容...</file>
|
||||||
|
</project_materials>
|
||||||
|
|
||||||
|
【用户指令】
|
||||||
|
请基于你的战略标尺,锐评上述项目材料中界定人群的漏洞。
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 项目生命周期与权限流水线 (CRUD)
|
||||||
|
|
||||||
|
基于“去中心化联邦”的咨询工坊理念,权限流被设计得极度轻薄:
|
||||||
|
|
||||||
|
### 3.1 开放新建与全局可见 (Global Visibility)
|
||||||
|
- **规则**:任何人都可以新建项目。一旦新建,该项目(如`projectId: probiotics_001`)对公司所有已认证的顾问全局可见。
|
||||||
|
- **UI 降噪设计**:为了防止左侧栏无限扩张,`GET /xinzong/projects` 接口支持按“最近活跃”和“我创建的”进行过滤。侧边栏仅展示 Top 5 活跃项目,其余通过搜索访问。
|
||||||
|
|
||||||
|
### 3.2 无摩擦共建 (Frictionless Upload)
|
||||||
|
- **规则**:所有成员都可以调用 `POST /xinzong/materials/upload` 往任何活跃项目中上传文档。
|
||||||
|
- **防投毒开关**:右侧的【文件夹】面板中,项目创建者(owner)可以点击按钮将某个瞎传的文件设为 `is_active: false`。被禁用的文件仍然存在,但不会被打包进 `<project_materials>` 中喂给大模型。
|
||||||
|
|
||||||
|
### 3.3 状态归档 (Archiving mechanism)
|
||||||
|
- **规则**:**禁止物理删除**。项目只有“活跃 (Active)”和“归档 (Archived)”两种状态。
|
||||||
|
- **权限**:**只有项目创建者 (Creator) 有权点击归档**。
|
||||||
|
- **生命周期**:归档后,该项目从所有人的“活跃项目侧边栏”中消失。但历史记录及项目文件在底层统一归总于 `storage/archives/`,可供未来的系统级搜索工具作为已完成项目的经验参考使用。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 在 Hermes Gateway 中的接口定义
|
||||||
|
|
||||||
|
前后端的对接接口无需复杂重构,基于现有的 SSE 接口扩展即可:
|
||||||
|
|
||||||
|
| 接口路线 | 方法 | 动作说明 | 校验防线 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `/xinzong/projects/create` | `POST` | 创建项目上下文 | 生成 `projectId`,关联至创建者 `userId` |
|
||||||
|
| `/xinzong/projects/list` | `GET` | 拉取左侧边栏列表 | 默认返回 `status=active` 且按活跃度排序 |
|
||||||
|
| `/xinzong/materials/upload` | `POST` | 无阻力上传素材 | 所有人可调,校验文件类型,落盘至 `storage/projects/{id}/` |
|
||||||
|
| `/xinzong/materials/toggle` | `POST` | 状态切换(启用/禁用)| **关键权限校验**:操作者必须为项目 owner |
|
||||||
|
| `/xinzong/projects/archive` | `POST` | 项目归档杀青 | **关键权限校验**:操作者必须为项目 owner |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 现网工程核实结论 (Engineering Verification)
|
||||||
|
|
||||||
|
本架构设计承诺**完全复用且不破坏**当前的工程基础。经过对现网 `xinzong_sse.py` 及底层 `SessionDB` 的代码审计,得出以下落实验证:
|
||||||
|
|
||||||
|
1. **零破坏复用馨总 Context**:现有的 `_runChat` 函数中已完成了 `skills/xinzong_bot/SKILL.md` 的读取与 Hermes 智能体 System Prompt 的挂载。此部分完美契合,且底层已实现 Prompt Caching。
|
||||||
|
2. **零破坏复用项目 Context**:目前的 `_initMaterialsDb` 方法已建好具有 `context_id` 属性的 `xinzong_materials` 表。项目上下文实际上已被按 `context_id` 在物理层和逻辑层归档,无需再造多级文件夹结构表。
|
||||||
|
3. **极简增量开发**:
|
||||||
|
- 仅需在 `xinzong_materials` 中以增量迁移 (`ALTER TABLE`) 的方式加入 `is_active BOOLEAN DEFAULT 1` 字段,即可实现防投毒开关,不影响任何存量数据。
|
||||||
|
- 在 `_runChat` 的 `user_message` 提交前,拉取当前 `context_id` 的所有 `is_active = 1` 的材料内容,拼接 `<project_materials>` XML 标签,即可实现沙盘隔离级别的无损大模型推演。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **总结**:
|
||||||
|
> 通过这套依赖 Hermes Skills + XML Prompt Tagging + Creator-Only Archiving 的双态架构,我们以几乎零代价复用了底层的缓存与代理能力,同时在业务上彻底切分了**“方法论真理”**与**“草稿沙盘”**的边界。
|
||||||
4
src-tauri/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
# Generated by Cargo
|
||||||
|
# will have compiled files and executables
|
||||||
|
/target/
|
||||||
|
/gen/schemas
|
||||||
5274
src-tauri/Cargo.lock
generated
Normal file
25
src-tauri/Cargo.toml
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
[package]
|
||||||
|
name = "app"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "A Tauri App"
|
||||||
|
authors = ["you"]
|
||||||
|
license = ""
|
||||||
|
repository = ""
|
||||||
|
edition = "2021"
|
||||||
|
rust-version = "1.77.2"
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "app_lib"
|
||||||
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2.5.6", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde_json = "1.0"
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
log = "0.4"
|
||||||
|
tauri = { version = "2.10.3", features = [] }
|
||||||
|
tauri-plugin-log = "2"
|
||||||
3
src-tauri/build.rs
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
16
src-tauri/capabilities/default.json
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "desktop-default",
|
||||||
|
"description": "深维面诊 桌面端权限",
|
||||||
|
"platforms": [
|
||||||
|
"macOS",
|
||||||
|
"windows",
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"windows": [
|
||||||
|
"main"
|
||||||
|
],
|
||||||
|
"permissions": [
|
||||||
|
"core:default"
|
||||||
|
]
|
||||||
|
}
|
||||||
15
src-tauri/capabilities/mobile.json
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/mobile-schema.json",
|
||||||
|
"identifier": "mobile-default",
|
||||||
|
"description": "深维面诊 移动端权限",
|
||||||
|
"platforms": [
|
||||||
|
"android",
|
||||||
|
"iOS"
|
||||||
|
],
|
||||||
|
"windows": [
|
||||||
|
"main"
|
||||||
|
],
|
||||||
|
"permissions": [
|
||||||
|
"core:default"
|
||||||
|
]
|
||||||
|
}
|
||||||
12
src-tauri/gen/android/.editorconfig
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
# EditorConfig is awesome: https://EditorConfig.org
|
||||||
|
|
||||||
|
# top-most EditorConfig file
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
end_of_line = lf
|
||||||
|
charset = utf-8
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
insert_final_newline = false
|
||||||
19
src-tauri/gen/android/.gitignore
vendored
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
*.iml
|
||||||
|
.gradle
|
||||||
|
/local.properties
|
||||||
|
/.idea/caches
|
||||||
|
/.idea/libraries
|
||||||
|
/.idea/modules.xml
|
||||||
|
/.idea/workspace.xml
|
||||||
|
/.idea/navEditor.xml
|
||||||
|
/.idea/assetWizardSettings.xml
|
||||||
|
.DS_Store
|
||||||
|
build
|
||||||
|
/captures
|
||||||
|
.externalNativeBuild
|
||||||
|
.cxx
|
||||||
|
local.properties
|
||||||
|
key.properties
|
||||||
|
|
||||||
|
/.tauri
|
||||||
|
/tauri.settings.gradle
|
||||||
6
src-tauri/gen/android/app/.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
/src/main/**/generated
|
||||||
|
/src/main/jniLibs/**/*.so
|
||||||
|
/src/main/assets/tauri.conf.json
|
||||||
|
/tauri.build.gradle.kts
|
||||||
|
/proguard-tauri.pro
|
||||||
|
/tauri.properties
|
||||||
70
src-tauri/gen/android/app/build.gradle.kts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import java.util.Properties
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
id("org.jetbrains.kotlin.android")
|
||||||
|
id("rust")
|
||||||
|
}
|
||||||
|
|
||||||
|
val tauriProperties = Properties().apply {
|
||||||
|
val propFile = file("tauri.properties")
|
||||||
|
if (propFile.exists()) {
|
||||||
|
propFile.inputStream().use { load(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
compileSdk = 36
|
||||||
|
namespace = "club.brainwork.deepview"
|
||||||
|
defaultConfig {
|
||||||
|
manifestPlaceholders["usesCleartextTraffic"] = "false"
|
||||||
|
applicationId = "club.brainwork.deepview"
|
||||||
|
minSdk = 24
|
||||||
|
targetSdk = 36
|
||||||
|
versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt()
|
||||||
|
versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0")
|
||||||
|
}
|
||||||
|
buildTypes {
|
||||||
|
getByName("debug") {
|
||||||
|
manifestPlaceholders["usesCleartextTraffic"] = "true"
|
||||||
|
isDebuggable = true
|
||||||
|
isJniDebuggable = true
|
||||||
|
isMinifyEnabled = false
|
||||||
|
packaging { jniLibs.keepDebugSymbols.add("*/arm64-v8a/*.so")
|
||||||
|
jniLibs.keepDebugSymbols.add("*/armeabi-v7a/*.so")
|
||||||
|
jniLibs.keepDebugSymbols.add("*/x86/*.so")
|
||||||
|
jniLibs.keepDebugSymbols.add("*/x86_64/*.so")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
getByName("release") {
|
||||||
|
isMinifyEnabled = true
|
||||||
|
proguardFiles(
|
||||||
|
*fileTree(".") { include("**/*.pro") }
|
||||||
|
.plus(getDefaultProguardFile("proguard-android-optimize.txt"))
|
||||||
|
.toList().toTypedArray()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "1.8"
|
||||||
|
}
|
||||||
|
buildFeatures {
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rust {
|
||||||
|
rootDirRel = "../../../"
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation("androidx.webkit:webkit:1.14.0")
|
||||||
|
implementation("androidx.appcompat:appcompat:1.7.1")
|
||||||
|
implementation("androidx.activity:activity-ktx:1.10.1")
|
||||||
|
implementation("com.google.android.material:material:1.12.0")
|
||||||
|
testImplementation("junit:junit:4.13.2")
|
||||||
|
androidTestImplementation("androidx.test.ext:junit:1.1.4")
|
||||||
|
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0")
|
||||||
|
}
|
||||||
|
|
||||||
|
apply(from = "tauri.build.gradle.kts")
|
||||||
21
src-tauri/gen/android/app/proguard-rules.pro
vendored
Normal file
@ -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
|
||||||
49
src-tauri/gen/android/app/src/main/AndroidManifest.xml
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
<!-- Android 13+ 前台服务通知需要此权限 -->
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
|
<!-- AndroidTV support -->
|
||||||
|
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:theme="@style/Theme.app"
|
||||||
|
android:usesCleartextTraffic="${usesCleartextTraffic}">
|
||||||
|
<activity
|
||||||
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
|
||||||
|
android:launchMode="singleTask"
|
||||||
|
android:label="@string/main_activity_title"
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
<!-- AndroidTV support -->
|
||||||
|
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
|
|
||||||
|
<!-- ★ 录音前台服务 —— 后台保活 -->
|
||||||
|
<service
|
||||||
|
android:name=".RecordingForegroundService"
|
||||||
|
android:foregroundServiceType="microphone" />
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@ -0,0 +1,102 @@
|
|||||||
|
package club.brainwork.deepview
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.core.app.ActivityCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
|
||||||
|
class MainActivity : TauriActivity() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "DEEPVIEW"
|
||||||
|
private const val REQUEST_RECORD_AUDIO = 1001
|
||||||
|
private const val REQUEST_NOTIFICATIONS = 1002
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
enableEdgeToEdge()
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
// ★ 最佳实践:在 Native 容器层消费 system bar insets
|
||||||
|
val contentView = findViewById<android.view.View>(android.R.id.content)
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(contentView) { view, insets ->
|
||||||
|
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
val imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime())
|
||||||
|
val bottomPadding = maxOf(systemBars.bottom, imeInsets.bottom)
|
||||||
|
view.setPadding(systemBars.left, systemBars.top, systemBars.right, bottomPadding)
|
||||||
|
Log.i(TAG, "Insets: bars.bottom=${systemBars.bottom}px, ime=${imeInsets.bottom}px → padding.bottom=${bottomPadding}px")
|
||||||
|
WindowInsetsCompat.CONSUMED
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★ 运行时权限请求 — 麦克风(首次启动时主动弹出)
|
||||||
|
requestMicPermissionIfNeeded()
|
||||||
|
|
||||||
|
// ★ Android 13+: 请求通知权限(前台服务通知必须)
|
||||||
|
requestNotificationPermissionIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
// 权限请求
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
private fun requestMicPermissionIfNeeded() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
|
||||||
|
!= PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
Log.i(TAG, "★ 主动请求麦克风权限")
|
||||||
|
ActivityCompat.requestPermissions(
|
||||||
|
this,
|
||||||
|
arrayOf(Manifest.permission.RECORD_AUDIO),
|
||||||
|
REQUEST_RECORD_AUDIO
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Log.i(TAG, "★ 麦克风权限已存在")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestNotificationPermissionIfNeeded() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
!= PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
ActivityCompat.requestPermissions(
|
||||||
|
this,
|
||||||
|
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
||||||
|
REQUEST_NOTIFICATIONS
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRequestPermissionsResult(
|
||||||
|
requestCode: Int,
|
||||||
|
permissions: Array<out String>,
|
||||||
|
grantResults: IntArray
|
||||||
|
) {
|
||||||
|
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||||
|
when (requestCode) {
|
||||||
|
REQUEST_RECORD_AUDIO -> {
|
||||||
|
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
||||||
|
Log.i(TAG, "✅ 麦克风权限已授予")
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "⚠️ 麦克风权限被拒绝")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
REQUEST_NOTIFICATIONS -> {
|
||||||
|
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
||||||
|
Log.i(TAG, "✅ 通知权限已授予")
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "⚠️ 通知权限被拒绝")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,142 @@
|
|||||||
|
package club.brainwork.deepview
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.os.PowerManager
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ★ 后台录音前台服务
|
||||||
|
*
|
||||||
|
* 职责:在录音期间保持进程存活,防止 Android 系统杀死进程。
|
||||||
|
*
|
||||||
|
* 工作原理:
|
||||||
|
* - 录音开始 → MainActivity 启动此服务 → 显示常驻通知 + 持有 WakeLock
|
||||||
|
* - 录音结束 → MainActivity 停止此服务 → 释放一切
|
||||||
|
* - 服务本身不做录音!实际录音由 WebView MediaRecorder 完成
|
||||||
|
*
|
||||||
|
* Android 版本适配:
|
||||||
|
* - Android 8.0+: 必须使用 NotificationChannel
|
||||||
|
* - Android 12+: 必须声明 foregroundServiceType
|
||||||
|
* - Android 13+: 需要 POST_NOTIFICATIONS 权限
|
||||||
|
* - Android 14+: 必须声明 FOREGROUND_SERVICE_MICROPHONE
|
||||||
|
*
|
||||||
|
* 来源:从 MindOS (mindOSv2) RecordingForegroundService.kt 迁移,仅改包名和通知文案。
|
||||||
|
*/
|
||||||
|
class RecordingForegroundService : Service() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "DEEPVIEW_FG_SERVICE"
|
||||||
|
private const val CHANNEL_ID = "deepview_recording_channel"
|
||||||
|
private const val NOTIFICATION_ID = 1001
|
||||||
|
private const val WAKELOCK_TAG = "DEEPVIEW::RecordingWakeLock"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var wakeLock: PowerManager.WakeLock? = null
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
Log.i(TAG, "录音前台服务已创建")
|
||||||
|
createNotificationChannel()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
Log.i(TAG, "录音前台服务启动")
|
||||||
|
|
||||||
|
val notification = buildNotification()
|
||||||
|
|
||||||
|
// ★ Android 14+ 必须指定 foregroundServiceType
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
|
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE)
|
||||||
|
} else {
|
||||||
|
startForeground(NOTIFICATION_ID, notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★ 持有 WakeLock,防止 CPU 进入深度休眠
|
||||||
|
// 设置 4 小时超时(与面诊最大时长对齐),防止忘记释放
|
||||||
|
acquireWakeLock()
|
||||||
|
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
Log.i(TAG, "录音前台服务停止")
|
||||||
|
releaseWakeLock()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
// ─── 通知 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun createNotificationChannel() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
"录音服务",
|
||||||
|
NotificationManager.IMPORTANCE_LOW // LOW = 无声音、无弹窗,只在通知栏显示
|
||||||
|
).apply {
|
||||||
|
description = "深维面诊 正在录音时保持后台运行"
|
||||||
|
setShowBadge(false)
|
||||||
|
}
|
||||||
|
val manager = getSystemService(NotificationManager::class.java)
|
||||||
|
manager?.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildNotification(): Notification {
|
||||||
|
// 点击通知 → 回到主界面
|
||||||
|
val launchIntent = Intent(this, MainActivity::class.java).apply {
|
||||||
|
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||||
|
}
|
||||||
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
|
this, 0, launchIntent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
|
||||||
|
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
|
.setContentTitle("深维面诊 录音中")
|
||||||
|
.setContentText("正在后台录音,点击返回应用")
|
||||||
|
.setSmallIcon(android.R.drawable.ic_btn_speak_now) // 系统自带麦克风图标
|
||||||
|
.setOngoing(true) // 不可划掉
|
||||||
|
.setSilent(true) // 静音
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── WakeLock ──────────────────────────────────────
|
||||||
|
|
||||||
|
private fun acquireWakeLock() {
|
||||||
|
if (wakeLock == null) {
|
||||||
|
val pm = getSystemService(POWER_SERVICE) as PowerManager
|
||||||
|
wakeLock = pm.newWakeLock(
|
||||||
|
PowerManager.PARTIAL_WAKE_LOCK,
|
||||||
|
WAKELOCK_TAG
|
||||||
|
).apply {
|
||||||
|
// 4 小时超时自动释放,防止泄漏
|
||||||
|
acquire(4 * 60 * 60 * 1000L)
|
||||||
|
}
|
||||||
|
Log.i(TAG, "✅ WakeLock 已获取 (4h 超时)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun releaseWakeLock() {
|
||||||
|
wakeLock?.let {
|
||||||
|
if (it.isHeld) {
|
||||||
|
it.release()
|
||||||
|
Log.i(TAG, "✅ WakeLock 已释放")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wakeLock = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,77 @@
|
|||||||
|
package club.brainwork.deepview
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
|
import android.webkit.WebView
|
||||||
|
import app.tauri.annotation.Command
|
||||||
|
import app.tauri.annotation.TauriPlugin
|
||||||
|
import app.tauri.plugin.Invoke
|
||||||
|
import app.tauri.plugin.JSObject
|
||||||
|
import app.tauri.plugin.Plugin
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ★ 录音前台服务控制插件
|
||||||
|
*
|
||||||
|
* 由 Tauri 前端 invoke() 调用,控制 RecordingForegroundService 的启停。
|
||||||
|
* 不做录音本身 — 仅管理 Android 进程保活。
|
||||||
|
*
|
||||||
|
* 前端用法:
|
||||||
|
* await invoke('plugin:recording-service|start_service')
|
||||||
|
* await invoke('plugin:recording-service|stop_service')
|
||||||
|
*
|
||||||
|
* ★ 注意:Tauri v2 Android 插件命令名使用 snake_case。
|
||||||
|
* 前端 invoke() 的管道符后面的名字必须与 @Command 方法名完全一致。
|
||||||
|
*
|
||||||
|
* 来源:从 MindOS (mindOSv2) RecordingServicePlugin.kt 迁移,仅改包名。
|
||||||
|
*/
|
||||||
|
@TauriPlugin
|
||||||
|
class RecordingServicePlugin(private val activity: Activity) : Plugin(activity) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "RecordingServicePlugin"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ★ 插件加载回调 — Tauri PluginManager 成功加载本插件时触发
|
||||||
|
* 如果 logcat 没有看到这条日志,说明插件注册/路由失败
|
||||||
|
*/
|
||||||
|
override fun load(webView: WebView) {
|
||||||
|
Log.i(TAG, "★★★ RecordingServicePlugin 已加载到 WebView ★★★")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Command
|
||||||
|
fun startService(invoke: Invoke) {
|
||||||
|
Log.i(TAG, "★ 前端请求启动录音前台服务")
|
||||||
|
try {
|
||||||
|
val serviceIntent = Intent(activity, RecordingForegroundService::class.java)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
activity.startForegroundService(serviceIntent)
|
||||||
|
} else {
|
||||||
|
activity.startService(serviceIntent)
|
||||||
|
}
|
||||||
|
invoke.resolve(JSObject().apply {
|
||||||
|
put("started", true)
|
||||||
|
})
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "启动前台服务失败: ${e.message}", e)
|
||||||
|
invoke.reject("启动前台服务失败: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Command
|
||||||
|
fun stopService(invoke: Invoke) {
|
||||||
|
Log.i(TAG, "★ 前端请求停止录音前台服务")
|
||||||
|
try {
|
||||||
|
val serviceIntent = Intent(activity, RecordingForegroundService::class.java)
|
||||||
|
activity.stopService(serviceIntent)
|
||||||
|
invoke.resolve(JSObject().apply {
|
||||||
|
put("stopped", true)
|
||||||
|
})
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "停止前台服务失败: ${e.message}", e)
|
||||||
|
invoke.reject("停止前台服务失败: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient
|
||||||
|
android:endX="85.84757"
|
||||||
|
android:endY="92.4963"
|
||||||
|
android:startX="42.9492"
|
||||||
|
android:startY="49.59793"
|
||||||
|
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="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||||
|
android:strokeWidth="1"
|
||||||
|
android:strokeColor="#00000000" />
|
||||||
|
</vector>
|
||||||
@ -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:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="#3DDC84"
|
||||||
|
android:pathData="M0,0h108v108h-108z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M9,0L9,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,0L19,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M29,0L29,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M39,0L39,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M49,0L49,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M59,0L59,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M69,0L69,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M79,0L79,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M89,0L89,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M99,0L99,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,9L108,9"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,19L108,19"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,29L108,29"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,39L108,39"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,49L108,49"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,59L108,59"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,69L108,69"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,79L108,79"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,89L108,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,99L108,99"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,29L89,29"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,39L89,39"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,49L89,49"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,59L89,59"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,69L89,69"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,79L89,79"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M29,19L29,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M39,19L39,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M49,19L49,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M59,19L59,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M69,19L69,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M79,19L79,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
</vector>
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout 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">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Hello World!"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintLeft_toLeftOf="parent"
|
||||||
|
app:layout_constraintRight_toRightOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 16 KiB |
@ -0,0 +1,6 @@
|
|||||||
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
<!-- Base application theme. -->
|
||||||
|
<style name="Theme.app" parent="Theme.MaterialComponents.DayNight.NoActionBar">
|
||||||
|
<!-- Customize your theme here. -->
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
10
src-tauri/gen/android/app/src/main/res/values/colors.xml
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="purple_200">#FFBB86FC</color>
|
||||||
|
<color name="purple_500">#FF6200EE</color>
|
||||||
|
<color name="purple_700">#FF3700B3</color>
|
||||||
|
<color name="teal_200">#FF03DAC5</color>
|
||||||
|
<color name="teal_700">#FF018786</color>
|
||||||
|
<color name="black">#FF000000</color>
|
||||||
|
<color name="white">#FFFFFFFF</color>
|
||||||
|
</resources>
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">深维面诊</string>
|
||||||
|
<string name="main_activity_title">深维面诊</string>
|
||||||
|
</resources>
|
||||||
6
src-tauri/gen/android/app/src/main/res/values/themes.xml
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
<!-- Base application theme. -->
|
||||||
|
<style name="Theme.app" parent="Theme.MaterialComponents.DayNight.NoActionBar">
|
||||||
|
<!-- Customize your theme here. -->
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@ -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>
|
||||||
22
src-tauri/gen/android/build.gradle.kts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
buildscript {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
dependencies {
|
||||||
|
classpath("com.android.tools.build:gradle:8.11.0")
|
||||||
|
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.25")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
allprojects {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.register("clean").configure {
|
||||||
|
delete("build")
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
kotlin version: 2.0.21
|
||||||
|
error message: The daemon has terminated unexpectedly on startup attempt #1 with error code: 0. The daemon process output:
|
||||||
|
1. Kotlin compile daemon is ready
|
||||||
|
|
||||||
23
src-tauri/gen/android/buildSrc/build.gradle.kts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
plugins {
|
||||||
|
`kotlin-dsl`
|
||||||
|
}
|
||||||
|
|
||||||
|
gradlePlugin {
|
||||||
|
plugins {
|
||||||
|
create("pluginsForCoolKids") {
|
||||||
|
id = "rust"
|
||||||
|
implementationClass = "RustPlugin"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
compileOnly(gradleApi())
|
||||||
|
implementation("com.android.tools.build:gradle:8.11.0")
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,68 @@
|
|||||||
|
import java.io.File
|
||||||
|
import org.apache.tools.ant.taskdefs.condition.Os
|
||||||
|
import org.gradle.api.DefaultTask
|
||||||
|
import org.gradle.api.GradleException
|
||||||
|
import org.gradle.api.logging.LogLevel
|
||||||
|
import org.gradle.api.tasks.Input
|
||||||
|
import org.gradle.api.tasks.TaskAction
|
||||||
|
|
||||||
|
open class BuildTask : DefaultTask() {
|
||||||
|
@Input
|
||||||
|
var rootDirRel: String? = null
|
||||||
|
@Input
|
||||||
|
var target: String? = null
|
||||||
|
@Input
|
||||||
|
var release: Boolean? = null
|
||||||
|
|
||||||
|
@TaskAction
|
||||||
|
fun assemble() {
|
||||||
|
val executable = """cargo""";
|
||||||
|
try {
|
||||||
|
runTauriCli(executable)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (Os.isFamily(Os.FAMILY_WINDOWS)) {
|
||||||
|
// Try different Windows-specific extensions
|
||||||
|
val fallbacks = listOf(
|
||||||
|
"$executable.exe",
|
||||||
|
"$executable.cmd",
|
||||||
|
"$executable.bat",
|
||||||
|
)
|
||||||
|
|
||||||
|
var lastException: Exception = e
|
||||||
|
for (fallback in fallbacks) {
|
||||||
|
try {
|
||||||
|
runTauriCli(fallback)
|
||||||
|
return
|
||||||
|
} catch (fallbackException: Exception) {
|
||||||
|
lastException = fallbackException
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastException
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun runTauriCli(executable: String) {
|
||||||
|
val rootDirRel = rootDirRel ?: throw GradleException("rootDirRel cannot be null")
|
||||||
|
val target = target ?: throw GradleException("target cannot be null")
|
||||||
|
val release = release ?: throw GradleException("release cannot be null")
|
||||||
|
val args = listOf("tauri", "android", "android-studio-script");
|
||||||
|
|
||||||
|
project.exec {
|
||||||
|
workingDir(File(project.projectDir, rootDirRel))
|
||||||
|
executable(executable)
|
||||||
|
args(args)
|
||||||
|
if (project.logger.isEnabled(LogLevel.DEBUG)) {
|
||||||
|
args("-vv")
|
||||||
|
} else if (project.logger.isEnabled(LogLevel.INFO)) {
|
||||||
|
args("-v")
|
||||||
|
}
|
||||||
|
if (release) {
|
||||||
|
args("--release")
|
||||||
|
}
|
||||||
|
args(listOf("--target", target))
|
||||||
|
}.assertNormalExitValue()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,85 @@
|
|||||||
|
import com.android.build.api.dsl.ApplicationExtension
|
||||||
|
import org.gradle.api.DefaultTask
|
||||||
|
import org.gradle.api.Plugin
|
||||||
|
import org.gradle.api.Project
|
||||||
|
import org.gradle.kotlin.dsl.configure
|
||||||
|
import org.gradle.kotlin.dsl.get
|
||||||
|
|
||||||
|
const val TASK_GROUP = "rust"
|
||||||
|
|
||||||
|
open class Config {
|
||||||
|
lateinit var rootDirRel: String
|
||||||
|
}
|
||||||
|
|
||||||
|
open class RustPlugin : Plugin<Project> {
|
||||||
|
private lateinit var config: Config
|
||||||
|
|
||||||
|
override fun apply(project: Project) = with(project) {
|
||||||
|
config = extensions.create("rust", Config::class.java)
|
||||||
|
|
||||||
|
val defaultAbiList = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64");
|
||||||
|
val abiList = (findProperty("abiList") as? String)?.split(',') ?: defaultAbiList
|
||||||
|
|
||||||
|
val defaultArchList = listOf("arm64", "arm", "x86", "x86_64");
|
||||||
|
val archList = (findProperty("archList") as? String)?.split(',') ?: defaultArchList
|
||||||
|
|
||||||
|
val targetsList = (findProperty("targetList") as? String)?.split(',') ?: listOf("aarch64", "armv7", "i686", "x86_64")
|
||||||
|
|
||||||
|
extensions.configure<ApplicationExtension> {
|
||||||
|
@Suppress("UnstableApiUsage")
|
||||||
|
flavorDimensions.add("abi")
|
||||||
|
productFlavors {
|
||||||
|
create("universal") {
|
||||||
|
dimension = "abi"
|
||||||
|
ndk {
|
||||||
|
abiFilters += abiList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defaultArchList.forEachIndexed { index, arch ->
|
||||||
|
create(arch) {
|
||||||
|
dimension = "abi"
|
||||||
|
ndk {
|
||||||
|
abiFilters.add(defaultAbiList[index])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEvaluate {
|
||||||
|
for (profile in listOf("debug", "release")) {
|
||||||
|
val profileCapitalized = profile.replaceFirstChar { it.uppercase() }
|
||||||
|
val buildTask = tasks.maybeCreate(
|
||||||
|
"rustBuildUniversal$profileCapitalized",
|
||||||
|
DefaultTask::class.java
|
||||||
|
).apply {
|
||||||
|
group = TASK_GROUP
|
||||||
|
description = "Build dynamic library in $profile mode for all targets"
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks["mergeUniversal${profileCapitalized}JniLibFolders"].dependsOn(buildTask)
|
||||||
|
|
||||||
|
for (targetPair in targetsList.withIndex()) {
|
||||||
|
val targetName = targetPair.value
|
||||||
|
val targetArch = archList[targetPair.index]
|
||||||
|
val targetArchCapitalized = targetArch.replaceFirstChar { it.uppercase() }
|
||||||
|
val targetBuildTask = project.tasks.maybeCreate(
|
||||||
|
"rustBuild$targetArchCapitalized$profileCapitalized",
|
||||||
|
BuildTask::class.java
|
||||||
|
).apply {
|
||||||
|
group = TASK_GROUP
|
||||||
|
description = "Build dynamic library in $profile mode for $targetArch"
|
||||||
|
rootDirRel = config.rootDirRel
|
||||||
|
target = targetName
|
||||||
|
release = profile == "release"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTask.dependsOn(targetBuildTask)
|
||||||
|
tasks["merge$targetArchCapitalized${profileCapitalized}JniLibFolders"].dependsOn(
|
||||||
|
targetBuildTask
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src-tauri/gen/android/gradle.properties
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# 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=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
# 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
|
||||||
|
# Kotlin code style for this project: "official" or "obsolete":
|
||||||
|
kotlin.code.style=official
|
||||||
|
# Enables namespacing of each library's R class so that its R class includes only the
|
||||||
|
# resources declared in the library itself and none from the library's dependencies,
|
||||||
|
# thereby reducing the size of the R class for that library
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
|
android.nonFinalResIds=false
|
||||||
BIN
src-tauri/gen/android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
6
src-tauri/gen/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
#Tue May 10 19:22:52 CST 2022
|
||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
185
src-tauri/gen/android/gradlew
vendored
Executable file
@ -0,0 +1,185 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright 2015 the original author or authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
##
|
||||||
|
## Gradle start up script for UN*X
|
||||||
|
##
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
PRG="$0"
|
||||||
|
# Need this for relative symlinks.
|
||||||
|
while [ -h "$PRG" ] ; do
|
||||||
|
ls=`ls -ld "$PRG"`
|
||||||
|
link=`expr "$ls" : '.*-> \(.*\)$'`
|
||||||
|
if expr "$link" : '/.*' > /dev/null; then
|
||||||
|
PRG="$link"
|
||||||
|
else
|
||||||
|
PRG=`dirname "$PRG"`"/$link"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
SAVED="`pwd`"
|
||||||
|
cd "`dirname \"$PRG\"`/" >/dev/null
|
||||||
|
APP_HOME="`pwd -P`"
|
||||||
|
cd "$SAVED" >/dev/null
|
||||||
|
|
||||||
|
APP_NAME="Gradle"
|
||||||
|
APP_BASE_NAME=`basename "$0"`
|
||||||
|
|
||||||
|
# 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"'
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD="maximum"
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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
|
||||||
|
;;
|
||||||
|
MINGW* )
|
||||||
|
msys=true
|
||||||
|
;;
|
||||||
|
NONSTOP* )
|
||||||
|
nonstop=true
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||||
|
else
|
||||||
|
JAVACMD="$JAVA_HOME/bin/java"
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD="java"
|
||||||
|
which java >/dev/null 2>&1 || 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
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
|
||||||
|
MAX_FD_LIMIT=`ulimit -H -n`
|
||||||
|
if [ $? -eq 0 ] ; then
|
||||||
|
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
||||||
|
MAX_FD="$MAX_FD_LIMIT"
|
||||||
|
fi
|
||||||
|
ulimit -n $MAX_FD
|
||||||
|
if [ $? -ne 0 ] ; then
|
||||||
|
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# For Darwin, add options to specify how the application appears in the dock
|
||||||
|
if $darwin; then
|
||||||
|
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
|
||||||
|
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||||
|
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||||
|
|
||||||
|
JAVACMD=`cygpath --unix "$JAVACMD"`
|
||||||
|
|
||||||
|
# We build the pattern for arguments to be converted via cygpath
|
||||||
|
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
|
||||||
|
SEP=""
|
||||||
|
for dir in $ROOTDIRSRAW ; do
|
||||||
|
ROOTDIRS="$ROOTDIRS$SEP$dir"
|
||||||
|
SEP="|"
|
||||||
|
done
|
||||||
|
OURCYGPATTERN="(^($ROOTDIRS))"
|
||||||
|
# Add a user-defined pattern to the cygpath arguments
|
||||||
|
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
|
||||||
|
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
|
||||||
|
fi
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
i=0
|
||||||
|
for arg in "$@" ; do
|
||||||
|
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
|
||||||
|
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
|
||||||
|
|
||||||
|
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
|
||||||
|
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
|
||||||
|
else
|
||||||
|
eval `echo args$i`="\"$arg\""
|
||||||
|
fi
|
||||||
|
i=`expr $i + 1`
|
||||||
|
done
|
||||||
|
case $i in
|
||||||
|
0) set -- ;;
|
||||||
|
1) set -- "$args0" ;;
|
||||||
|
2) set -- "$args0" "$args1" ;;
|
||||||
|
3) set -- "$args0" "$args1" "$args2" ;;
|
||||||
|
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
||||||
|
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
||||||
|
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
||||||
|
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
||||||
|
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
||||||
|
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Escape application args
|
||||||
|
save () {
|
||||||
|
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
|
||||||
|
echo " "
|
||||||
|
}
|
||||||
|
APP_ARGS=`save "$@"`
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, following the shell quoting and substitution rules
|
||||||
|
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
89
src-tauri/gen/android/gradlew.bat
vendored
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%" == "" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%" == "" set DIRNAME=.
|
||||||
|
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%" == "0" goto execute
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
echo.
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
echo location of your Java installation.
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||||
|
echo.
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
echo location of your Java installation.
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if "%ERRORLEVEL%"=="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!
|
||||||
|
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
||||||
|
exit /b 1
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
3
src-tauri/gen/android/settings.gradle
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
include ':app'
|
||||||
|
|
||||||
|
apply from: 'tauri.settings.gradle'
|
||||||
BIN
src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
81
src-tauri/src/lib.rs
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
/// 深维面诊 — Tauri 应用入口
|
||||||
|
///
|
||||||
|
/// ★ 多平台自适应:
|
||||||
|
/// - Mobile (Android): 后台录音保活(ForegroundService)
|
||||||
|
/// - Desktop/Web: 无特殊原生能力需求
|
||||||
|
|
||||||
|
use tauri::Manager;
|
||||||
|
|
||||||
|
// ── Android 前台服务句柄 ──
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
pub struct RecordingServiceHandle(pub tauri::plugin::PluginHandle<tauri::Wry>);
|
||||||
|
|
||||||
|
// ── 前台服务命令 ──
|
||||||
|
// ★ 关键设计:命令注册在全局 invoke_handler(不是 plugin invoke_handler),
|
||||||
|
// 这样不受 Tauri v2 ACL 权限系统限制。
|
||||||
|
// Android 端通过 State<RecordingServiceHandle> 调用 Kotlin 插件。
|
||||||
|
// 桌面端这些命令直接返回 Ok(无副作用)。
|
||||||
|
#[tauri::command]
|
||||||
|
fn start_foreground_service(
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
handle: tauri::State<'_, RecordingServiceHandle>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
{
|
||||||
|
handle.0
|
||||||
|
.run_mobile_plugin::<serde_json::Value>("startService", ())
|
||||||
|
.map_err(|e| format!("startService 失败: {}", e))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn stop_foreground_service(
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
handle: tauri::State<'_, RecordingServiceHandle>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
{
|
||||||
|
handle.0
|
||||||
|
.run_mobile_plugin::<serde_json::Value>("stopService", ())
|
||||||
|
.map_err(|e| format!("stopService 失败: {}", e))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
|
pub fn run() {
|
||||||
|
let builder = tauri::Builder::default()
|
||||||
|
.setup(|app| {
|
||||||
|
if cfg!(debug_assertions) {
|
||||||
|
app.handle().plugin(
|
||||||
|
tauri_plugin_log::Builder::default()
|
||||||
|
.level(log::LevelFilter::Info)
|
||||||
|
.build(),
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
// ★ 全局命令注册 —— 不受 plugin ACL 约束
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
start_foreground_service,
|
||||||
|
stop_foreground_service
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ── Android 专属:注册 Kotlin 插件(仅提供 PluginHandle,不走 invoke_handler)──
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
let builder = builder.plugin(
|
||||||
|
tauri::plugin::Builder::<tauri::Wry, ()>::new("recording-service")
|
||||||
|
.setup(|app, api| {
|
||||||
|
let handle = api.register_android_plugin("club.brainwork.deepview", "RecordingServicePlugin")?;
|
||||||
|
app.manage(RecordingServiceHandle(handle));
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.build()
|
||||||
|
);
|
||||||
|
|
||||||
|
builder
|
||||||
|
.build(tauri::generate_context!())
|
||||||
|
.expect("error while building tauri application")
|
||||||
|
.run(|_app, _event| {});
|
||||||
|
}
|
||||||
6
src-tauri/src/main.rs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
app_lib::run();
|
||||||
|
}
|
||||||
38
src-tauri/tauri.conf.json
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"productName": "深维面诊",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"identifier": "club.brainwork.deepview",
|
||||||
|
"build": {
|
||||||
|
"frontendDist": "../dist/deepview-medical/browser",
|
||||||
|
"devUrl": "http://localhost:4200",
|
||||||
|
"beforeDevCommand": "npm run start",
|
||||||
|
"beforeBuildCommand": ""
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"title": "深维面诊",
|
||||||
|
"width": 800,
|
||||||
|
"height": 600,
|
||||||
|
"resizable": true,
|
||||||
|
"fullscreen": false,
|
||||||
|
"useHttpsScheme": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"csp": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"targets": "all",
|
||||||
|
"icon": [
|
||||||
|
"icons/32x32.png",
|
||||||
|
"icons/128x128.png",
|
||||||
|
"icons/128x128@2x.png",
|
||||||
|
"icons/icon.icns",
|
||||||
|
"icons/icon.ico"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,8 +8,12 @@ import { ApiService } from './core/api.service';
|
|||||||
import { AuthService } from './core/auth.service';
|
import { AuthService } from './core/auth.service';
|
||||||
import { LoginComponent } from './pages/login/login';
|
import { LoginComponent } from './pages/login/login';
|
||||||
import { ChatContextService } from './core/chat-context.service';
|
import { ChatContextService } from './core/chat-context.service';
|
||||||
|
import { PlatformService } from './core/platform.service';
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
|
|
||||||
|
/** Tauri invoke helper — only available inside Tauri shell */
|
||||||
|
declare function __TAURI_INTERNALS__invoke(cmd: string, args?: any): Promise<any>;
|
||||||
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
@ -56,6 +60,10 @@ export class App implements OnDestroy, OnInit {
|
|||||||
recordDuration = 0;
|
recordDuration = 0;
|
||||||
recordingTimer: any;
|
recordingTimer: any;
|
||||||
|
|
||||||
|
// Real consultation MediaRecorder state
|
||||||
|
private consultMediaRecorder: MediaRecorder | null = null;
|
||||||
|
private consultAudioChunks: Blob[] = [];
|
||||||
|
|
||||||
// Triplet Information
|
// Triplet Information
|
||||||
recClient = '';
|
recClient = '';
|
||||||
recDoctor = '';
|
recDoctor = '';
|
||||||
@ -66,6 +74,7 @@ export class App implements OnDestroy, OnInit {
|
|||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
private api = inject(ApiService);
|
private api = inject(ApiService);
|
||||||
public chatCtx = inject(ChatContextService);
|
public chatCtx = inject(ChatContextService);
|
||||||
|
public platform = inject(PlatformService);
|
||||||
|
|
||||||
private sseSubscription: any;
|
private sseSubscription: any;
|
||||||
|
|
||||||
@ -285,6 +294,11 @@ export class App implements OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
if (this.recordingTimer) clearInterval(this.recordingTimer);
|
if (this.recordingTimer) clearInterval(this.recordingTimer);
|
||||||
if (this.pressTimer) clearTimeout(this.pressTimer);
|
if (this.pressTimer) clearTimeout(this.pressTimer);
|
||||||
|
// 释放麦克风流,防止组件销毁后录音泄露
|
||||||
|
if (this.consultMediaRecorder && this.consultMediaRecorder.state !== 'inactive') {
|
||||||
|
this.consultMediaRecorder.stream.getTracks().forEach(t => t.stop());
|
||||||
|
this.consultMediaRecorder.stop();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleFullscreen() {
|
toggleFullscreen() {
|
||||||
@ -354,33 +368,104 @@ export class App implements OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Actions ---
|
// --- Actions ---
|
||||||
startRecording() {
|
async startRecording() {
|
||||||
this.showActionSheet = true;
|
this.showActionSheet = true;
|
||||||
this.isRecording = true;
|
this.isRecording = true;
|
||||||
this.isRecordingPaused = false;
|
this.isRecordingPaused = false;
|
||||||
this.isProcessing = false;
|
this.isProcessing = false;
|
||||||
this.uploadComplete = false;
|
this.uploadComplete = false;
|
||||||
this.recordDuration = 0;
|
this.recordDuration = 0;
|
||||||
|
this.consultAudioChunks = [];
|
||||||
|
|
||||||
|
// Timer(暂停时挂起)
|
||||||
this.recordingTimer = setInterval(() => {
|
this.recordingTimer = setInterval(() => {
|
||||||
if (!this.isRecordingPaused) {
|
if (!this.isRecordingPaused) {
|
||||||
this.recordDuration++;
|
this.recordDuration++;
|
||||||
}
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
|
// ★ Tauri Android:启动前台服务保活
|
||||||
|
if (this.platform.isTauri()) {
|
||||||
|
try {
|
||||||
|
await (window as any).__TAURI_INTERNALS__.invoke('start_foreground_service');
|
||||||
|
console.log('[Deepview] ForegroundService started');
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[Deepview] ForegroundService start failed (non-Android?)', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 接入真实 MediaRecorder
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
|
||||||
|
? 'audio/webm;codecs=opus'
|
||||||
|
: 'audio/webm';
|
||||||
|
this.consultMediaRecorder = new MediaRecorder(stream, { mimeType });
|
||||||
|
|
||||||
|
this.consultMediaRecorder.ondataavailable = (e: BlobEvent) => {
|
||||||
|
if (e.data.size > 0) this.consultAudioChunks.push(e.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
// onstop 由 stopAndProcess 触发,在回调中汇入统一管线
|
||||||
|
this.consultMediaRecorder.onstop = async () => {
|
||||||
|
// 释放麦克风
|
||||||
|
stream.getTracks().forEach(t => t.stop());
|
||||||
|
|
||||||
|
const blob = new Blob(this.consultAudioChunks, { type: mimeType });
|
||||||
|
if (blob.size < 1024) {
|
||||||
|
// 录音过短/无有效音频
|
||||||
|
alert('录音时间过短,请重新录制。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const filename = `consult_${Date.now()}.webm`;
|
||||||
|
const file = new File([blob], filename, { type: mimeType });
|
||||||
|
await this.runRealPipeline(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 每隔 1 秒收集数据块,保证暂停/恢复时数据完整
|
||||||
|
this.consultMediaRecorder.start(1000);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[Deepview] getUserMedia failed:', err?.name, err?.message, err);
|
||||||
|
alert('无法访问麦克风,请授予录音权限后重试。');
|
||||||
|
this.isRecording = false;
|
||||||
|
this.showActionSheet = false;
|
||||||
|
if (this.recordingTimer) clearInterval(this.recordingTimer);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stopAndProcess() {
|
stopAndProcess() {
|
||||||
this.isRecording = false;
|
this.isRecording = false;
|
||||||
this.isRecordingPaused = false;
|
this.isRecordingPaused = false;
|
||||||
if (this.recordingTimer) clearInterval(this.recordingTimer);
|
if (this.recordingTimer) clearInterval(this.recordingTimer);
|
||||||
|
|
||||||
// The native audio blob hasn't been bridged yet (Tauri V4 audio pipeline pending)
|
// 触发 MediaRecorder.stop(),最终在 onstop 回调中汇入 runRealPipeline
|
||||||
// For now, close the sheet. (Users can long-press to test the real file upload pipeline).
|
if (this.consultMediaRecorder && this.consultMediaRecorder.state !== 'inactive') {
|
||||||
this.showActionSheet = false;
|
this.consultMediaRecorder.stop();
|
||||||
alert('Tauri 原生录音桥接尚未完成。如需体验真实推理管线,请长按录音键使用本地录音文件上传。');
|
}
|
||||||
|
|
||||||
|
// ★ Tauri Android:停止前台服务
|
||||||
|
if (this.platform.isTauri()) {
|
||||||
|
try {
|
||||||
|
(window as any).__TAURI_INTERNALS__.invoke('stop_foreground_service');
|
||||||
|
console.log('[Deepview] ForegroundService stopped');
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[Deepview] ForegroundService stop failed', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
togglePauseResume() {
|
togglePauseResume() {
|
||||||
|
if (!this.consultMediaRecorder) return;
|
||||||
this.isRecordingPaused = !this.isRecordingPaused;
|
this.isRecordingPaused = !this.isRecordingPaused;
|
||||||
|
if (this.isRecordingPaused) {
|
||||||
|
if (this.consultMediaRecorder.state === 'recording') {
|
||||||
|
this.consultMediaRecorder.pause();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (this.consultMediaRecorder.state === 'paused') {
|
||||||
|
this.consultMediaRecorder.resume();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
triggerUploadFile() {
|
triggerUploadFile() {
|
||||||
|
|||||||
@ -6,7 +6,10 @@ import { Observable, Subject } from 'rxjs';
|
|||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
export class ApiService {
|
export class ApiService {
|
||||||
private apiUrl = '/deepview';
|
// Tauri 壳内使用绝对 URL(本地 file:// 无法使用相对路径)
|
||||||
|
private apiUrl = (window as any).__TAURI_INTERNALS__
|
||||||
|
? 'https://agent.brainwork.club/deepview'
|
||||||
|
: '/deepview';
|
||||||
|
|
||||||
constructor(private http: HttpClient) {}
|
constructor(private http: HttpClient) {}
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,10 @@ import { Observable } from 'rxjs';
|
|||||||
/** MindPass 统一认证服务 — Deepview 专用 */
|
/** MindPass 统一认证服务 — Deepview 专用 */
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
private readonly apiBase = '/deepview/api/auth';
|
// Tauri 壳内使用绝对 URL(本地 file:// 无法使用相对路径)
|
||||||
|
private readonly apiBase = (window as any).__TAURI_INTERNALS__
|
||||||
|
? 'https://agent.brainwork.club/deepview/api/auth'
|
||||||
|
: '/deepview/api/auth';
|
||||||
private readonly tokenKey = 'mindpass_token';
|
private readonly tokenKey = 'mindpass_token';
|
||||||
|
|
||||||
/** 当前 JWT Token */
|
/** 当前 JWT Token */
|
||||||
|
|||||||
32
src/app/core/platform.service.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { Injectable, signal } from '@angular/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 平台感知服务
|
||||||
|
*
|
||||||
|
* 使用 Angular Signals 提供响应式的平台检测。
|
||||||
|
* 其它组件通过注入此服务来判断当前运行环境,
|
||||||
|
* 决定是否调用 Tauri invoke() 等原生能力。
|
||||||
|
*
|
||||||
|
* 模式来源:MindOS Adapter Pattern
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class PlatformService {
|
||||||
|
/** 是否运行在 Tauri 壳内(Desktop 或 Android) */
|
||||||
|
readonly isTauri = signal(!!(window as any).__TAURI_INTERNALS__);
|
||||||
|
|
||||||
|
/** 是否为移动端(Android / iOS / HarmonyOS) */
|
||||||
|
readonly isMobile = signal(/Android|iPhone|iPad|HarmonyOS/.test(navigator.userAgent));
|
||||||
|
|
||||||
|
/** 操作系统识别 */
|
||||||
|
readonly os = signal(this.detectOs());
|
||||||
|
|
||||||
|
private detectOs(): 'android' | 'ios' | 'macos' | 'windows' | 'linux' | 'unknown' {
|
||||||
|
const ua = navigator.userAgent;
|
||||||
|
if (/Android/.test(ua)) return 'android';
|
||||||
|
if (/iPhone|iPad/.test(ua)) return 'ios';
|
||||||
|
if (/Mac/.test(ua)) return 'macos';
|
||||||
|
if (/Windows/.test(ua)) return 'windows';
|
||||||
|
if (/Linux/.test(ua)) return 'linux';
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||