Flutter + 浏览器插件 透析系统语音输入完整技术方案

Flutter + 浏览器插件 透析系统语音输入完整技术方案

整理时间: 2026-02-16 08:38
来源: 群聊消息
整理人: AI助手

摘要

本文档提供了一套完整的、基于 Flutter Desktop + 浏览器插件(方案 B)的透析管理系统语音输入技术解决方案。方案采用 Flutter 作为控制中枢,负责语音采集、ASR 语音识别和 LLM 语义提取;浏览器插件作为执行末端,通过 WebSocket 与 Flutter 通信,实现 DOM 精准填充。文档涵盖了完整的软件架构、技术选型、实现代码示例,以及常见问题的详细解决方案。


一、软件架构全景图

1.1 系统架构

┌─────────────────────────────────────────────────────────────────────────┐
│                           医生工作站                                      │
│                                                                          │
│  ┌──────────────────────────────────────────────────────────────────┐   │
│  │                    Flutter App (控制中枢)                        │   │
│  │  ┌─────────────┐  ┌─────────────┐  ┌─────────────────────────┐  │   │
│  │  │ 系统托盘    │  │ 全局热键    │  │  录音模块              │  │   │
│  │  │ (常驻后台) │  │ (F4/Alt+S) │  │  (record + ffmpeg)    │  │   │
│  │  └─────────────┘  └─────────────┘  └───────────┬─────────────┘  │   │
│  │                                                  │               │   │
│  │  ┌──────────────────────────────────────────────▼─────────────┐  │   │
│  │  │              AI 处理层                              │  │   │
│  │  │  ┌─────────────────┐    ┌─────────────────────┐       │  │   │
│  │  │  │  FunASR        │    │  Kimi/Minimax      │       │  │   │
│  │  │  │  (语音转文字)   │    │  (语义提取 JSON)   │       │  │   │
│  │  │  └─────────────────┘    └─────────────────────┘       │  │   │
│  │  └───────────────────────────────────────────────────────┘  │   │
│  │                                                  │               │   │
│  │  ┌───────────────────────────────────────────────▼───────────┐  │   │
│  │  │         WebSocket Server (127.0.0.1:9999)               │  │   │
│  │  │         向浏览器插件广播结构化数据                      │  │   │
│  │  └───────────────────────────────────────────────────────┘  │   │
│  └──────────────────────────────────────────────────────────────┘   │
│                                    │                                  │
│                           WebSocket (ws://127.0.0.1:9999)            │
│                                    │                                  │
│  ┌────────────────────────────────▼──────────────────────────────┐   │
│  │                    Chrome 浏览器                              │   │
│  │  ┌─────────────────────────────────────────────────────────┐ │   │
│  │  │              浏览器插件 (执行末端)                       │ │   │
│  │  │  • 连接 WebSocket Server                                │ │   │
│  │  │  • 识别当前页面 URL 与 DOM 结构                         │ │   │
│  │  │  • 执行 JavaScript 自动填充表单                         │ │   │
│  │  └─────────────────────────────────────────────────────────┘ │   │
│  │                                                                  │   │
│  │  ┌─────────────────────────────────────────────────────────┐ │   │
│  │  │                    透析管理系统 (BS)                    │ │   │
│  │  │  [干体重: 65.5]  [收缩压: 120]  [舒张压: 80]  ...      │ │   │
│  │  └─────────────────────────────────────────────────────────┘ │   │
│  └──────────────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────────────────┘

1.2 核心工作流程

┌─────────────────────────────────────────────────────────────────────┐
│                         完整工作流程                                  │
└─────────────────────────────────────────────────────────────────────┘

1. 热键触发
   医生按下 F4 或 Alt+S
          │
          ▼
2. 录音开始
   Flutter 弹出半透明悬浮条,显示"正在录音..."
   音频流实时传输给 FunASR
          │
          ▼
3. 流式识别 (可选)
   FunASR 实时返回识别文字
   悬浮条显示识别的文字(边说边出)
          │
          ▼
4. 录音结束
   医生松开热键或停止说话
   完整文本发送给 Kimi/Minimax
          │
          ▼
5. 语义提取
   LLM 返回结构化 JSON
   {"dry_weight": 65.5, "bp_high": 120, "bp_low": 80}
          │
          ▼
6. WebSocket 广播
   Flutter 通过 WebSocket 推送给浏览器插件
          │
          ▼
7. DOM 填充
   插件接收 JSON,定位输入框
   自动填入对应值,触发 input 事件
          │
          ▼
8. 医生确认
   悬浮条显示"已填入 3 个字段"
   医生检查并提交表单

二、技术栈选型

2.1 完整技术栈表

维度 选型 理由
UI 框架 Flutter Desktop (Windows) 性能好、内存占用极低、UI 开发效率高
热键管理 hotkey_manager 支持全局监听,应用在后台也能触发
音频处理 record + ffmpeg 支持主流音频格式,占用极低
通信协议 shelf_web_socket Flutter 内部集成轻量级服务器,低延迟
ASR 服务 FunASR (阿里) 支持私有化部署,医疗专有名词可定制
LLM 接口 Kimi / Minimax 中文语义理解强,用于提取 JSON 字段
插件开发 Chrome Extension MV3 兼容所有现代主流浏览器
打包发布 Inno Setup / MSIX 打包 VC++ 运行库,一键安装

2.2 技术选型理由详解

Flutter Desktop 优势:

  • 打包体积远小于 Electron(~10MB vs ~100MB)
  • 内存占用低,适合医疗场景老旧电脑
  • UI 开发效率高,跨平台代码可复用

FunASR 优势:

  • 阿里开源,工业级性能
  • Paraformer 模型中文识别精准
  • 支持 Docker 私有化部署
  • 支持流式识别,降低延迟感知

浏览器插件(方案 B)优势:

  • 不依赖模拟按键,不受焦点位置影响
  • 可精准定位任意 DOM 元素
  • 可处理复杂表单结构和动态字段

三、关键实现代码

3.1 第一阶段:Flutter 语音采集与处理

3.1.1 热键监听

import 'package:hotkey_manager/hotkey_manager.dart';

class HotkeyService {
  Future<void> init() async {
    // 注册全局热键 F4
    final hotKey = HotKey(
      key: LogicalKeyboardKey.f4,
      scope: HotKeyScope.system,
    );

    await hotKeyManager.register(
      hotKey,
      keyDownHandler: (hotKey) {
        print('热键触发,开始录音');
        AudioService.startRecording();
      },
    );
  }
}

3.1.2 录音服务

import 'package:record/record.dart';

class AudioService {
  final AudioRecorder _recorder = AudioRecorder();

  Future<void> startRecording() async {
    if (await _recorder.hasPermission()) {
      await _recorder.start(
        const RecordConfig(
          encoder: AudioEncoder.aacLc,
          sampleRate: 16000,
          numChannels: 1,
        ),
        path: 'temp_recording.m4a',
      );
    }
  }

  Future<String?> stopRecording() async {
    final path = await _recorder.stop();
    return path;
  }
}

3.1.3 FunASR 调用

import 'dart:io';
import 'package:http/http.dart' as http;
import 'dart:convert';

class FunASRService {
  final String _apiUrl = 'http://localhost:8000/asr';

  Future<String> transcribe(String audioPath) async {
    final file = File(audioPath);
    final bytes = await file.readAsBytes();

    final request = http.MultipartRequest('POST', Uri.parse(_apiUrl))
      ..files.add(http.MultipartFile.fromBytes('file', bytes, filename: 'audio.m4a'));

    final response = await request.send();
    final result = await http.Response.fromStream(response);
    final data = jsonDecode(result.body);

    return data['text'] ?? '';
  }

  // 流式识别版本
  Stream<String> transcribeStream(String audioPath) async* {
    // 实现流式返回识别文字
    // 用于边说边显示的实时效果
  }
}

3.1.4 LLM 语义提取

import 'package:http/http.dart' as http;
import 'dart:convert';

class LLmService {
  final String _apiKey = 'your-kimi-api-key';

  Future<Map<String, dynamic>> extractDialysisData(String text) async {
    final prompt = '''
你是一个医疗助手,请从以下文本提取透析数据:

字段说明:
- dry_weight: 干体重 (kg)
- bp_high: 收缩压 (mmHg)
- bp_low: 舒张压 (mmHg)
- uf: 超滤量 (ml)
- flow: 透析流量 (ml/min)

医生口述:"$text"

请输出严格 JSON 格式,不要输出任何解释。
''';

    final response = await http.post(
      Uri.parse('https://api.moonshot.cn/v1/chat/completions'),
      headers: {
        'Content-Type': 'application/json',
        'Authorization': 'Bearer $_apiKey',
      },
      body: jsonEncode({
        'model': 'kimi-flash',
        'messages': [
          {'role': 'system', 'content': '你是一个专业的医疗数据提取助手。'},
          {'role': 'user', 'content': prompt},
        ],
        'temperature': 0.1,
      }),
    );

    final data = jsonDecode(response.body);
    final content = data['choices'][0]['message']['content'];

    // 提取 JSON 部分
    final jsonMatch = RegExp(r'\{.*\}').firstMatch(content);
    if (jsonMatch != null) {
      return jsonDecode(jsonMatch.group(0)!);
    }

    return {};
  }
}

3.2 第二阶段:WebSocket 数据桥接

3.2.1 Flutter WebSocket Server

import 'package:shelf/shelf.dart';
import 'package:shelf_web_socket/shelf_web_socket.dart';
import 'package:web_socket_channel/web_socket_channel.dart';

class WebSocketService {
  late HttpServer _server;
  final List<WebSocketChannel> _clients = [];

  Future<void> startServer() async {
    final handler = webSocketHandler((webSocket) {
      _clients.add(webSocket);

      // 监听来自插件的消息(如:当前页面信息)
      webSocket.stream.listen((message) {
        print('收到插件消息: $message');
        // 处理插件发来的消息
      });

      // 清理断开连接
      webSocket.sink.done.then(() {
        _clients.remove(webSocket);
      });
    });

    _server = await serve(handler, '127.0.0.1', 9999);
    print('WebSocket 服务启动: ws://127.0.0.1:9999');
  }

  // 广播结构化数据给所有插件客户端
  void broadcastData(Map<String, dynamic> data) {
    final message = jsonEncode(data);
    for (final client in _clients) {
      client.sink.add(message);
    }
  }
}

3.2.2 完整的语音处理流程

class VoiceAssistantService {
  final HotkeyService _hotkey = HotkeyService();
  final AudioService _audio = AudioService();
  final FunASRService _asr = FunASRService();
  final LLmService _llm = LLmService();
  final WebSocketService _ws = WebSocketService();

  Future<void> init() async {
    await _ws.startServer();
    await _hotkey.init();
  }

  Future<void> processVoice() async {
    // 1. 停止录音
    final audioPath = await _audio.stopRecording();
    if (audioPath == null) return;

    // 2. 语音识别
    final text = await _asr.transcribe(audioPath);
    print('识别结果: $text');

    // 3. 语义提取
    final data = await _llm.extractDialysisData(text);
    print('提取数据: $data');

    // 4. 广播给插件
    _ws.broadcastData({
      'type': 'fill_form',
      'text': text,
      'data': data,
      'timestamp': DateTime.now().toIso8601String(),
    });
  }
}

3.3 第三阶段:浏览器插件(方案 B)

3.3.1 manifest.json (Manifest V3)

{
  "manifest_version": 3,
  "name": "透析系统语音填表助手",
  "version": "1.0",
  "description": "配合 Flutter 语音助手自动填写透析表单",
  "permissions": [
    "activeTab",
    "scripting"
  ],
  "host_permissions": [
    "http://*/*",
    "https://*/*"
  ],
  "background": {
    "service_worker": "background.js"
  },
  "content_scripts": [
    {
      "matches": ["<all_urls>"],
      "js": ["content.js"],
      "run_at": "document_end"
    }
  ],
  "icons": {
    "16": "icon16.png",
    "48": "icon48.png",
    "128": "icon128.png"
  }
}

3.3.2 content_script.js (DOM 填充核心逻辑)

// content_script.js

// 建立 WebSocket 连接
const socket = new WebSocket('ws://127.0.0.1:9999');

socket.onopen = () => {
  console.log('已连接到 Flutter 语音助手');
};

socket.onmessage = (event) => {
  try {
    const message = JSON.parse(event.data);

    if (message.type === 'fill_form') {
      fillDialysisForm(message.data);
    }
  } catch (e) {
    console.error('解析消息失败:', e);
  }
};

socket.onerror = (error) => {
  console.error('WebSocket 错误:', error);
};

socket.onclose = () => {
  console.log('连接已断开,尝试重连...');
  setTimeout(() => {
    // 简单的重连逻辑
    location.reload();
  }, 3000);
};

// 透析表单字段映射配置
const fieldMapping = {
  'dry_weight': ['干体重', 'dryWeight', 'weight', 'dry_weight'],
  'bp_high': ['收缩压', 'systolic', 'bp_sys', 'sbp'],
  'bp_low': ['舒张压', 'diastolic', 'bp_dia', 'dbp'],
  'uf': ['超滤量', 'ultrafiltration', 'uf', 'ultra'],
  'flow': ['透析流量', 'bloodFlow', 'flow', 'bf'],
  'conductivity': ['电导率', 'conductivity', 'cond'],
  'temp': ['温度', 'temperature', 'temp'],
  'venous_pressure': ['静脉压', 'venous', 'vp'],
  'arterial_pressure': ['动脉压', 'arterial', 'ap'],
  'heparin': ['肝素', 'heparin', 'hep'],
  'duration': ['时长', 'duration', 'time'],
  'weight_before': ['透析前体重', 'weightBefore', 'pre_weight'],
  'weight_after': ['透析后体重', 'weightAfter', 'post_weight']
};

// 自动填充表单
function fillDialysisForm(data) {
  let filledCount = 0;

  for (const [key, value] of Object.entries(data)) {
    if (value === undefined || value === null) continue;

    const selectors = fieldMapping[key] || [key];
    let inputElement = null;

    // 尝试多种方式定位输入框
    for (const selector of selectors) {
      // 1. 通过 id 查找
      inputElement = document.querySelector(`#${selector}`);
      if (inputElement) break;

      // 2. 通过 name 查找
      inputElement = document.querySelector(`[name="${selector}"]`);
      if (inputElement) break;

      // 3. 通过 placeholder 模糊匹配
      inputElement = document.querySelector(`input[placeholder*="${selector}"]`);
      if (inputElement) break;

      // 4. 通过 label 文字找到关联的 input
      const labels = document.querySelectorAll(`label`);
      for (const label of labels) {
        if (label.textContent.includes(selector)) {
          const labelFor = label.getAttribute('for');
          if (labelFor) {
            inputElement = document.querySelector(`#${labelFor}`);
          }
          // 查找 label 内部的 input
          if (!inputElement) {
            inputElement = label.querySelector('input');
          }
          if (inputElement) break;
        }
      }
      if (inputElement) break;
    }

    if (inputElement) {
      // 设置值
      inputElement.value = String(value);

      // 触发 input 事件,确保前端框架(Vue/React)监听到变化
      inputElement.dispatchEvent(new Event('input', { bubbles: true }));
      inputElement.dispatchEvent(new Event('change', { bubbles: true }));

      // 高亮显示已填入的字段
      inputElement.style.backgroundColor = '#d4edda';
      inputElement.style.border = '2px solid #28a745';

      setTimeout(() => {
        inputElement.style.backgroundColor = '';
        inputElement.style.border = '';
      }, 3000);

      filledCount++;
      console.log(`已填入 ${key}: ${value}`);
    } else {
      console.warn(`未找到字段 ${key} 的输入框`);
    }
  }

  // 显示填充结果通知
  showNotification(`已填入 ${filledCount} 个字段`);
}

// 显示通知
function showNotification(message) {
  const notification = document.createElement('div');
  notification.style.cssText = `
    position: fixed;
    top: 20px;
    right: 20px;
    background: #28a745;
    color: white;
    padding: 15px 20px;
    border-radius: 5px;
    z-index: 99999;
    font-family: sans-serif;
    box-shadow: 0 2px 10px rgba(0,0,0,0.2);
  `;
  notification.textContent = message;
  document.body.appendChild(notification);

  setTimeout(() => {
    notification.remove();
  }, 3000);
}

3.3.3 background.js (服务 worker)

// background.js

// 监听插件安装/更新
chrome.runtime.onInstalled.addListener(() => {
  console.log('透析语音填表助手已安装');
});

// 可以在这里处理一些全局状态

四、痛点问题及解决方案

4.1 跨域与连接安全 (Mixed Content)

问题 解决方案
HTTPS 页面禁止连接不安全的 ws:// 在插件 manifest.json 中声明 host_permissions
需要使用 wss(WebSocket Secure) 在 Flutter 端集成自签名证书,或使用 Native Messaging

具体配置:

// manifest.json
{
  "host_permissions": [
    "http://*/*",
    "https://*/*"
  ]
}

Native Messaging 替代方案(更安全):

  • 使用 Chrome Native Messaging API 替代 WebSocket
  • 需要开发本地 native host 程序
  • 适合对安全性要求更高的医疗场景

4.2 表单字段匹配准确性

问题 解决方案
透析系统页面复杂,input 标签可能没有 id 模糊匹配:通过 label 文字定位相邻 input
不同页面字段顺序不同 配置模式:允许医生”点选”方式录入映射关系

解决方案 1:模糊匹配逻辑

// 通过 label 文字模糊匹配
function findInputByLabel(keyword) {
  const labels = document.querySelectorAll('label, .label, .field-label, th');

  for (const label of labels) {
    if (label.textContent.includes(keyword)) {
      // 1. 查找 label 的 for 属性
      if (label.for) {
        const input = document.querySelector(`#${label.for}`);
        if (input) return input;
      }

      // 2. 查找 label 内部的 input
      const input = label.querySelector('input, select, textarea');
      if (input) return input;

      // 3. 查找相邻的 input
      const nextElement = label.nextElementSibling;
      if (nextElement && nextElement.tagName === 'INPUT') {
        return nextElement;
      }
    }
  }

  return null;
}

解决方案 2:配置模式(点选映射)

// 医生手动配置字段映射
const customMapping = JSON.parse(localStorage.getItem('dialysis_field_mapping') || '{}');

function fillWithCustomMapping(data) {
  for (const [key, value] of Object.entries(data)) {
    const customSelector = customMapping[key];
    if (customSelector) {
      const input = document.querySelector(customSelector);
      if (input) {
        input.value = value;
        input.dispatchEvent(new Event('input', { bubbles: true }));
      }
    }
  }
}

// 医生配置模式:在页面上点击输入框,保存选择器
function enableMappingMode() {
  document.body.style.cursor = 'crosshair';
  document.addEventListener('click', (e) => {
    if (e.target.matches('input, select, textarea')) {
      const selector = generateSelector(e.target);
      const fieldName = prompt('请输入这个字段对应的名称(如 dry_weight):');
      if (fieldName) {
        customMapping[fieldName] = selector;
        localStorage.setItem('dialysis_field_mapping', JSON.stringify(customMapping));
        alert('映射已保存!');
      }
    }
  });
}

4.3 系统权限与打包

问题 解决方案
缺少 C++ 环境导致应用打不开 使用 Inno Setup 制作安装包,集成 VC_redist.x64.exe 静默安装
录音权限被拦截 在 windows/runner/main.cpp 中配置应用 Manifest

Inno Setup 打包脚本:

; setup.iss
[Setup]
AppName=透析语音助手
AppVersion=1.0.0
DefaultDirName={pf}\DialysisVoiceAssistant
OutputDir=.
OutputBaseFilename=DialysisVoiceAssistant_Setup

[Files]
Source: "build\windows\x64\runner\Release\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs

[Run]
; 静默安装 VC++ 运行库
Filename: "vc_redist.x64.exe"; Parameters: "/install /quiet /norestart"; StatusMsg: "正在安装运行库..."; Flags: waituntilterminated

[Code]
function InitializeSetup(): Boolean;
var
  ResultCode: Integer;
begin
  // 检查是否已安装 VC++ 运行库
  if not RegKeyExists(HKEY_LOCAL_MACHINE, 'SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64') then
  begin
    // 安装 VC++ 运行库
    Exec('vc_redist.x64.exe', '/install /quiet /norestart', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
  end;
  Result := True;
end;

Windows 录音权限配置:

windows/runner/main.cpp 中添加:

// 声明麦克风权限
int main(int argc, char** argv) {
  // 初始化 COM
  CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);

  // 其他初始化...
  return app->Run();
}

windows/runner/CMakeLists.txt 中添加:

# 添加麦克风权限声明
set_target_properties(${BINARY_NAME} PROPERTIES
  VS_GLOBAL_KEYWORD "Win32Project"
)

4.4 LLM 响应速度优化

问题 解决方案
等待 LLM 返回 JSON 耗时较长 预处理:ASR 识别出关键词时立即构建上下文
网络延迟影响体验 本地过滤:简单数值直接正则匹配,复杂描述才走 LLM

解决方案:混合处理策略

class FastExtractionService {
  // 本地正则匹配简单数值
  final Map<String, RegExp> _patterns = {
    'bp_high': RegExp(r'(\d{2,3})/\d{2,3}'),  // 匹配 120/80 中的 120
    'bp_low': RegExp(r'\d{2,3}/(\d{2,3})'),   // 匹配 120/80 中的 80
    'uf': RegExp(r'(\d+\.?\d*)\s*(kg|ml|毫升)'), // 匹配 2.5kg 或 2500ml
    'flow': RegExp(r'流量\s*(\d+)'),          // 匹配 "流量200"
    'temp': RegExp(r'温度\s*(\d+\.?\d*)'),    // 匹配 "温度36.5"
  };

  Map<String, dynamic> extractFast(String text) {
    final result = <String, dynamic>{};

    // 血压:尝试同时提取高低压
    final bpMatch = RegExp(r'(\d{2,3})/(\d{2,3})').firstMatch(text);
    if (bpMatch != null) {
      result['bp_high'] = int.tryParse(bpMatch.group(1) ?? '');
      result['bp_low'] = int.tryParse(bpMatch.group(2) ?? '');
    }

    // 其他字段逐个匹配
    for (final entry in _patterns.entries) {
      final match = entry.value.firstMatch(text);
      if (match != null) {
        final value = match.group(1);
        if (value != null) {
          result[entry.key] = double.tryParse(value) ?? value;
        }
      }
    }

    // 如果提取到关键数据,直接返回
    if (result.isNotEmpty) {
      print('本地快速提取: $result');
      return result;
    }

    // 没有匹配到,走 LLM
    return {};
  }
}

4.5 多屏幕与 DPI 缩放

问题 解决方案
医疗环境多显示器配置常见 使用 screen_retriever 库获取精确屏幕参数
150% 缩放下悬浮窗位置偏移 使用逻辑像素,多分辨率适配测试

Flutter 多屏幕处理:

import 'package:screen_retriever/screen_retriever.dart';

class DisplayService {
  Future<void> init() async {
    // 获取主显示器信息
    final primaryDisplay = await ScreenRetriever.instance.getPrimaryDisplay();
    print('主显示器: ${primaryDisplay.size}');
    print('缩放比例: ${primaryDisplay.scaleFactor}');

    // 获取所有显示器
    final displays = await ScreenRetriever.instance.getAllDisplays();
    for (final display in displays) {
      print('显示器: ${display.name}, 尺寸: ${display.size}, 缩放: ${display.scaleFactor}');
    }
  }

  // 根据当前鼠标位置确定在哪个显示器显示悬浮窗
  Future<Offset> getCursorPosition() async {
    return await ScreenRetriever.instance.getCursorPosition();
  }
}

4.6 音频驱动与采集干扰

问题 解决方案
Windows 音频环境复杂 UI 上提供麦克风设备选择
录音编码阻塞主线程 录音逻辑放在独立 Isolate 中

Flutter 麦克风选择与 Isolate 处理:

import 'dart:isolate';

class AudioService {
  // 获取可用麦克风列表
  Future<List<AudioDevice>> getInputDevices() async {
    final recorder = AudioRecorder();
    final devices = await recorder.listDevices();
    return devices.where((d) => d.type == AudioDevice.input).toList();
  }

  // 在独立 Isolate 中处理录音
  Future<void> recordInIsolate() async {
    final receivePort = ReceivePort();

    await Isolate.spawn((sendPort) async {
      final recorder = AudioRecorder();
      await recorder.start(config, path: 'temp.m4a');
      // ... 录音处理逻辑
      sendPort.send('done');
    }, receivePort.sendPort);

    receivePort.listen((message) {
      print('Isolate 结果: $message');
    });
  }

  // 显示音量波动
  Stream<double> get volumeStream async* {
    final recorder = AudioRecorder();
    await recorder.start(config);

    while (true) {
      final amplitude = await recorder.getAmplitude();
      // 将 dB 转换为 0-1 的范围
      final normalized = ((amplitude.current + 60) / 60).clamp(0.0, 1.0);
      yield normalized;
      await Future.delayed(Duration(milliseconds: 100));
    }
  }
}

五、UI 设计规范

5.1 悬浮球 UI 状态

状态 外观 说明
闲置 灰色半透明圆形 等待热键触发
录音中 红色 + 音量波动动画 正在录音
识别中 黄色旋转 FunASR 处理中
提取中 蓝色闪烁 LLM 提取中
完成 绿色勾 成功填入字段
错误 红色叹号 处理失败

5.2 悬浮预览窗

┌────────────────────────────────────────┐
│  🎤 透析语音助手                    ✕ │
├────────────────────────────────────────┤
│  识别文字: 今天透析流量200,超滤3000  │
│                                        │
│  提取结果:                            │
│  ┌──────────────────────────────────┐ │
│  │ 透析流量: 200 ml/min    ✓       │ │
│  │ 超滤量: 3000 ml           ✓       │ │
│  └──────────────────────────────────┘ │
│                                        │
│  [ ✓ 确认填入 ]    [ ✕ 取消 ]         │
└────────────────────────────────────────┘

六、分阶段实施规划

6.1 开发周期

阶段 内容 时间 交付物
MVP Flutter 录音 + FunASR 转写 + Ctrl+V 模拟粘贴 第 1-2 周 语音转文字基础功能
进阶版 开发浏览器插件 + WebSocket 通道 + DOM 填充 第 3-4 周 说一段话填两个框
专业版 适配全部表单 + 私有化部署 + 优化打包 第 5-8 周 可分发的正式产品

6.2 MVP 验收标准

  • [ ] 热键 F4 可以触发录音
  • [ ] 录音文件可以正确转写为文字
  • [ ] 文字可以通过 Ctrl+V 粘贴到光标位置
  • [ ] 在透析系统页面验证通过

6.3 进阶版验收标准

  • [ ] WebSocket 连接稳定
  • [ ] 浏览器插件可以正确接收 JSON
  • [ ] 可以自动填入至少 5 个核心字段
  • [ ] 字段匹配准确率 > 90%

七、相关资源

7.1 开源项目

7.2 商业服务


整理备注

本文档提供了完整的 Flutter + 浏览器插件技术方案,包含详细的代码示例、问题解决方案和实施规划。如需浏览器插件 Manifest V3 完整代码框架或其他具体模块的实现细节,请进一步提出。