From 2283020e51fe251aea88c01e1dc1dea3e03c2794 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cydy0615=E2=80=9D?= <“allenyuan410@gmail.com”> Date: Wed, 10 Jun 2026 14:47:51 +0800 Subject: [PATCH] Refactor and enhance OCR and API functionalities - Removed obsolete unit tests for TTS/ASR module. - Deleted unused sample video file. - Introduced OCRImageWrapper component for better OCR image handling with loading, success, and failure states. - Updated copilot plugin to improve transaction handling and added new types for better type safety. - Enhanced web search block plugin to support streaming content updates. - Refactored API utility functions for better error handling and consistency across requests. - Added new configuration for OCR API endpoint. - Consolidated SSE event parsing into a shared utility. - Created string utility functions to reduce code duplication. - Removed outdated test documents related to compression functionality. --- .prompt.md | 129 +++++++++ backend/job_handlers.py | 18 +- backend/tests/test_llm_extended.py | 225 ---------------- backend/tests/test_prompt_extended.py | 147 ----------- backend/tests/test_tts_asr_coverage.py | 193 -------------- backend/tests/test_tts_asr_extended.py | 263 ------------------- backend/tests/test_tts_asr_integration.py | 305 ---------------------- backend/tests/test_tts_asr_unit.py | 156 ----------- sample-video.mp4 | Bin 42548 -> 0 bytes src/components/OCRImageWrapper.vue | 198 ++++++++++++++ src/plugins/copilotPlugin.ts | 30 ++- src/plugins/copilotTypes.ts | 48 ++++ src/plugins/webSearchBlockPlugin.ts | 31 +++ src/utils/api.js | 106 +++----- src/utils/config.js | 1 + src/utils/convert.js | 22 +- src/utils/fetch.js | 54 ++++ src/utils/ocrCache.js | 50 +++- src/utils/proAccept.js | 6 +- src/utils/sse.ts | 37 +++ src/utils/string.ts | 27 ++ test_compress_doc.txt | 18 -- test_docblock_import.md | 23 -- 23 files changed, 641 insertions(+), 1446 deletions(-) create mode 100644 .prompt.md delete mode 100644 backend/tests/test_llm_extended.py delete mode 100644 backend/tests/test_prompt_extended.py delete mode 100644 backend/tests/test_tts_asr_coverage.py delete mode 100644 backend/tests/test_tts_asr_extended.py delete mode 100644 backend/tests/test_tts_asr_integration.py delete mode 100644 backend/tests/test_tts_asr_unit.py delete mode 100644 sample-video.mp4 create mode 100644 src/components/OCRImageWrapper.vue create mode 100644 src/plugins/copilotTypes.ts create mode 100644 src/utils/fetch.js create mode 100644 src/utils/sse.ts create mode 100644 src/utils/string.ts delete mode 100644 test_compress_doc.txt delete mode 100644 test_docblock_import.md diff --git a/.prompt.md b/.prompt.md new file mode 100644 index 0000000..b687b32 --- /dev/null +++ b/.prompt.md @@ -0,0 +1,129 @@ +# 前端代码简化与优化提示词 + +## 目标 +深入分析并简化前端代码结构,提升执行效率、可维护性和类型安全性。 + +## 优化原则 + +### 1. 代码简化 +- **消除冗余**:识别并移除重复的逻辑、条件判断和错误处理模式 +- **函数拆分**:将大型函数拆分为职责单一的小函数(每个函数只做一件事) +- **提取常量**:将魔法数字、字符串字面量提取为命名常量 +- **减少嵌套**:使用早期返回(early return)替代深层 if/else 嵌套 + +### 2. 类型安全 +- **明确类型**:为所有函数参数和返回值添加 TypeScript 类型注解 +- **接口定义**:为复杂对象结构定义 interface,避免 `any` 类型 +- **联合类型**:使用 discriminated unions 替代运行时 typeof 检查 + +### 3. 性能优化 +- **懒加载**:对非核心模块使用动态 import() +- **防抖节流**:对频繁触发的事件(输入、滚动)添加 debounce/throttle +- **计算缓存**:对纯函数的重复计算结果进行 memoization +- **条件渲染**:使用 v-if/v-show 控制不必要的 DOM 操作 + +### 4. 错误处理 +- **统一错误边界**:集中处理 fetch/API 调用异常 +- **有意义错误信息**:避免空 catch,提供具体的失败原因 +- **降级策略**:关键功能失败时有优雅的 fallback + +### 5. 状态管理 +- **最小化状态**:只存储必要的响应式数据 +- **派生状态**:使用 computed 替代手动监听 + 条件赋值 +- **作用域限制**:将状态定义在尽可能小的组件范围内 + +## 检查清单 + +### api.js 优化点 +```typescript +// ❌ 问题:过多的条件分支和嵌套 +function getCancelUrl(apiUrl) { + const normalized = String(apiUrl || '').replace(/\/+$/, '') + if (/\/v1\/pro\/completions$/i.test(normalized)) { /*...*/ } + if (/\/v1\/web-search$/i.test(normalized)) { /*...*/ } + // ... +} + +// ✅ 优化:使用映射表替代条件链 +const CANCEL_PATH_MAP = { + '/v1/pro/completions': '/v1/pro/completions/cancel', + '/v1/web-search': '/v1/web-search/cancel', + '/v1/completions': '/v1/completions/cancel', +} +function getCancelUrl(apiUrl) { + const base = new URL(apiUrl).pathname.replace(/\/+$/, '') + return CANCEL_PATH_MAP[base] || `${base}/cancel` +} +``` + +### 通用模式识别 +1. **重复的 fetch 包装**:提取统一的 `safeFetch()` 函数处理认证头、错误和超时 +2. **SSE 解析器重复**:将 `parseSseEvent` 抽象为可复用的 stream 处理器 +3. **设置状态访问**:避免在每次 API 调用时重新创建 settings store 实例 +4. **条件类型检查**:用 TypeScript discriminated union 替代运行时 `typeof x === 'string'` + +## 执行步骤 + +### 第一步:分析 +1. 使用 `grep_search` 查找重复模式(相同的 if/else 块、try/catch) +2. 使用 `semantic_search` 查找相似功能的不同实现 +3. 识别高频调用的函数(API 请求、事件处理器) + +### 第二步:重构 +1. **提取纯函数**:将副作用(fetch、DOM操作)与数据处理分离 +2. **创建工具库**:将通用逻辑移至 `src/utils/` 下的独立模块 +3. **添加类型定义**:在 `src/plugins/types.ts` 中集中管理接口 +4. **简化条件逻辑**:用策略模式或映射表替代 switch/if 链 + +### 第三步:验证 +1. 运行 `npm run build` 确认无类型错误 +2. 检查 `get_errors` 确保没有引入新问题 +3. 手动测试关键路径(补全、OCR、上传) + +## 输出格式 + +每次优化后提供: +```markdown +### 优化项: [函数名/文件名] + +**问题**: [简要描述当前代码的问题] + +**改动**: +```diff +- // 旧代码 ++ // 新代码 +``` + +**收益**: +- 行数减少: X% +- 时间复杂度: O(n) → O(1) +- 可读性提升: [具体说明] +``` + +## 示例调用 + +```bash +# 简化 api.js 中的 URL 处理逻辑 +"简化 src/utils/api.js 中 getCancelUrl() 函数的条件分支,使用映射表替代正则匹配" + +# 优化 copilotPlugin.ts 的类型定义 +"为 src/plugins/copilotPlugin.ts 添加完整的 TypeScript 类型注解,消除所有 any 类型" + +# 提取重复的错误处理 +"将 src/utils/api.js 中分散的 try/catch 错误处理提取为统一的 errorBoundary() 高阶函数" +``` + +## 注意事项 + +- ✅ 保持向后兼容:不破坏现有 API 接口和事件流 +- ✅ 小步快跑:每次只优化一个函数或模块,验证后再继续 +- ❌ 避免过度优化:不要为了炫技引入复杂的函数式编程模式 +- ⚠️ 测试覆盖:修改核心路径(补全、取消请求)前确保有对应测试 +- 📝 文档同步:更新 CLAUDE.md 和 AGENTS.md 中的架构描述 + +## 参考文件 + +- `src/utils/api.js` - API 请求层,存在多处可简化的条件逻辑 +- `src/plugins/copilotPlugin.ts` - 补全插件,类型定义不完整 +- `src/stores/settings.js` - 状态管理,可优化响应式依赖 +- `backend/prompt.py` - Prompt 组装逻辑(后端参考) diff --git a/backend/job_handlers.py b/backend/job_handlers.py index 517002a..b7a09bd 100644 --- a/backend/job_handlers.py +++ b/backend/job_handlers.py @@ -639,7 +639,12 @@ async def web_search_handler( f"原始上下文:\n{context}\n\n" f"抓取内容:\n{crawled_text}" ) - synthesis_result = await call_ollama( + + # 流式合成 — 逐 delta emit,前端可实时渲染 + from llm import stream_ollama_events + + accumulated: list[str] = [] + async for event_type, text in stream_ollama_events( synthesis_prompt, system_prompt="Return only the final markdown body with no title and no citations list.", tag=f'{payload["request_id"][:8]}-webf', @@ -647,8 +652,15 @@ async def web_search_handler( thinking=policy.get("thinking"), model=policy.get("model"), max_output_tokens=int(policy.get("max_output_tokens", 0) or 0), - ) - content = _normalize_multiline_text(synthesis_result.get("content") or "") + ): + if is_cancelled(): + raise asyncio.CancelledError() + # 只推送 content delta,不展示 thinking + if event_type == "content": + accumulated.append(text) + await emit("delta", {"text": text}) + + content = _normalize_multiline_text("".join(accumulated)) if not content: raise RuntimeError("联网搜索生成了空结果") created_at = str(req.get("created_at") or payload.get("created_at") or "").strip() or datetime.now().isoformat() diff --git a/backend/tests/test_llm_extended.py b/backend/tests/test_llm_extended.py deleted file mode 100644 index ef57a89..0000000 --- a/backend/tests/test_llm_extended.py +++ /dev/null @@ -1,225 +0,0 @@ -import asyncio -import importlib -import sys -from pathlib import Path - -import pytest - - -BACKEND_DIR = Path(__file__).resolve().parents[1] -if str(BACKEND_DIR) not in sys.path: - sys.path.insert(0, str(BACKEND_DIR)) - -try: - llm = importlib.import_module("llm") -except ModuleNotFoundError: - pytest.skip("llm module dependencies are not available", allow_module_level=True) - - -def test_extract_message_with_content_and_thinking(): - resp = {"choices": [{"message": {"content": "hello world", "thinking": "reasoning"}}]} - content, thinking = llm._extract_message(resp) - assert content == "hello world" - assert thinking == "reasoning" - - -def test_extract_message_empty_content(): - resp = {"choices": [{"message": {"content": "", "thinking": None}}]} - content, thinking = llm._extract_message(resp) - assert content == "" - assert thinking == "" - - -def test_extract_message_dict_no_choices(): - resp = {"not_choices": []} - content, thinking = llm._extract_message(resp) - assert content == "" - assert thinking == "" - - -def test_extract_message_empty_dict(): - resp = {} - content, thinking = llm._extract_message(resp) - assert content == "" - assert thinking == "" - - -def test_extract_delta_text_from_chunk(): - chunk = {"choices": [{"delta": {"content": "text"}}]} - assert llm._extract_delta_text(chunk) == "text" - - -def test_extract_delta_thinking_from_chunk(): - chunk = {"choices": [{"delta": {"thinking": "thought"}}]} - assert llm._extract_delta_thinking(chunk) == "thought" - - -def test_call_ollama_no_system(monkeypatch): - captured = {} - - async def fake_post(url, json=None): - captured["json"] = json - - class FakeResp: - def raise_for_status(self): pass - def json(self): return {"choices": [{"message": {"content": "ok"}}]} - - return FakeResp() - - async def fake_client(*args, **kwargs): - class Ctx: - async def __aenter__(self2): return self2 - async def __aexit__(*a): pass - post = fake_post - return Ctx() - - monkeypatch.setattr(llm.httpx, "AsyncClient", fake_client) - monkeypatch.setattr(llm.asyncio, "wait_for", lambda coro, **kw: coro) - - result = asyncio.run( - llm.call_ollama("user prompt", system_prompt=None, tag="no-system") - ) - - assert result["content"] == "ok" - # Should only have user message, no system - assert len(captured["json"]["messages"]) == 1 - - -def test_call_ollama_with_system(monkeypatch): - captured = {} - - async def fake_post(url, json=None): - captured["json"] = json - - class FakeResp: - def raise_for_status(self): pass - def json(self): return {"choices": [{"message": {"content": "ok"}}]} - - return FakeResp() - - async def fake_client(*args, **kwargs): - class Ctx: - async def __aenter__(self2): return self2 - async def __aexit__(*a): pass - post = fake_post - return Ctx() - - monkeypatch.setattr(llm.httpx, "AsyncClient", fake_client) - monkeypatch.setattr(llm.asyncio, "wait_for", lambda coro, **kw: coro) - - result = asyncio.run( - llm.call_ollama("user prompt", system_prompt="sys prompt", tag="with-system") - ) - - assert result["content"] == "ok" - # Should have both system and user messages - msgs = captured["json"]["messages"] - assert len(msgs) == 2 - assert msgs[0]["role"] == "system" - - -def test_call_ollama_with_custom_model(monkeypatch): - captured = {} - - async def fake_post(url, json=None): - captured["json"] = json - - class FakeResp: - def raise_for_status(self): pass - def json(self): return {"choices": [{"message": {"content": "ok"}}]} - - return FakeResp() - - async def fake_client(*args, **kwargs): - class Ctx: - async def __aenter__(self2): return self2 - async def __aexit__(*a): pass - post = fake_post - return Ctx() - - monkeypatch.setattr(llm.httpx, "AsyncClient", fake_client) - monkeypatch.setattr(llm.asyncio, "wait_for", lambda coro, **kw: coro) - - result = asyncio.run( - llm.call_ollama("prompt", model="custom-model") - ) - - assert captured["json"]["model"] == "custom-model" - - -def test_stream_ollama_events_error_handling(monkeypatch): - def make_lines(): - lines_iter = iter([ - 'data: {"error": "model not found"}', - ]) - - class LineIterator: - async def __anext__(self): - try: - return next(lines_iter) - except StopIteration: - raise StopAsyncIteration() - - class Response: - def __init__(self2): self2._lines = LineIterator() - - async def raise_for_status(self2): pass - async def aiter_lines(self2): return self2._lines - - class StreamCtx: - async def __aenter__(self2): return Response() - async def __aexit__(*a): pass - - class Client: - stream = lambda self2, *args, **kw: StreamCtx() - - return Client() - - async def fake_client(*args, **kwargs): - return make_lines() - - monkeypatch.setattr(llm.httpx, "AsyncClient", fake_client) - monkeypatch.setattr(llm.asyncio, "wait_for", lambda coro, **kw: coro) - - async def collect(): - try: - async for _ in llm.stream_ollama_events("prompt", tag="err"): - pass - except RuntimeError as e: - return str(e) - - result = asyncio.run(collect()) - assert "model not found" in str(result) - - -def test_call_vlm_ocr_payload_format(monkeypatch): - captured = {} - - async def fake_post(url, json=None): - captured["json"] = json - - class FakeResp: - def raise_for_status(self): pass - def json(self): return {"choices": [{"message": {"content": "ocr result"}}]} - - return FakeResp() - - async def fake_client(*args, **kwargs): - class Ctx: - async def __aenter__(self2): return self2 - async def __aexit__(*a): pass - post = fake_post - return Ctx() - - monkeypatch.setattr(llm.httpx, "AsyncClient", fake_client) - monkeypatch.setattr(llm.asyncio, "wait_for", lambda coro, **kw: coro) - - result = asyncio.run(llm.call_vlm_ocr(b"image")) - assert result == "ocr result" - - # Verify vision format: image_url content part with base64 - msgs = captured["json"]["messages"] - assert len(msgs) == 1 - content_parts = msgs[0]["content"] - image_part = [p for p in content_parts if p.get("type") == "image_url"] - assert len(image_part) == 1 diff --git a/backend/tests/test_prompt_extended.py b/backend/tests/test_prompt_extended.py deleted file mode 100644 index b83ad61..0000000 --- a/backend/tests/test_prompt_extended.py +++ /dev/null @@ -1,147 +0,0 @@ -import sys -import re -from pathlib import Path - -# Ensure the project root is in sys.path so imports like `from backend import prompt` work -ROOT = Path(__file__).resolve().parents[2] -BACKEND_DIR = ROOT / "backend" -sys.path.insert(0, str(ROOT)) -sys.path.insert(0, str(BACKEND_DIR)) - -from backend import prompt # type: ignore - - -def test_get_current_datetime_auto_format(): - s = prompt._get_current_datetime("auto") - assert isinstance(s, str) - # Expect a date-like prefix: YYYY-MM-DD - assert re.match(r"^\d{4}-\d{2}-\d{2}", s) - # Expect a 3-letter weekday somewhere - assert re.search(r"\b[A-Za-z]{3}\b", s) - # Accept either an explicit UTC offset or a UTC label - assert re.search(r"UTC|[+-]\d{2}:?\d{2}", s) - - -def test_get_current_datetime_utc_plus5(): - s = prompt._get_current_datetime("UTC+5") - assert isinstance(s, str) - assert "UTC+5" in s - - -def test_get_current_datetime_gmt_minus3(): - s = prompt._get_current_datetime("GMT-3") - assert isinstance(s, str) - assert "GMT-3" in s - - -def test_get_current_datetime_new_york_fallback(): - s = prompt._get_current_datetime("America/New_York") - assert isinstance(s, str) - # Fallback behavior: allow either an explicit offset or a simple date prefix - ok = bool(re.search(r"[+-]\d{2}:?\d{2}", s)) or bool(re.match(r"^\d{4}-\d{2}-\d{2}", s)) - assert ok - - -def test_sanitize_language_id_empty_none_and_chars(): - # Empty / None should map to markdown by design - assert prompt._sanitize_language_id("") == "markdown" - assert prompt._sanitize_language_id(None) == "markdown" - # Dangerous chars should be stripped - sanitized = prompt._sanitize_language_id("") - assert "<" not in sanitized and ">" not in sanitized - # Valid input preserved - assert prompt._sanitize_language_id("python") == "python" - # Truncation at 32 chars - long_input = "a" * 50 - trimmed = prompt._sanitize_language_id(long_input) - assert len(trimmed) <= 32 - assert trimmed == "a" * min(32, len(long_input)) - - -def test_normalize_newlines(): - mixed = "line1\r\nline2\rline3\n" - norm = prompt._normalize_newlines(mixed) - assert norm == "line1\nline2\nline3\n" - - -def test_canonical_language_id_synonyms_and_unknown(): - assert prompt._canonical_language_id("md") == "markdown" - assert prompt._canonical_language_id("py") == "python" - assert prompt._canonical_language_id("js") == "javascript" - assert prompt._canonical_language_id("ts") == "typescript" - assert prompt._canonical_language_id("yml") == "yaml" - assert prompt._canonical_language_id("Rust") == "rust" - - -def test_language_guidance_behaviors(): - # markdown yields empty guidance - assert prompt._language_guidance("markdown") == "" - # mermaid guidance should mention mermaid - g_mermaid = prompt._language_guidance("mermaid") - assert isinstance(g_mermaid, str) - assert "mermaid" in g_mermaid.lower() - # python / javascript should reference the language - g_py = prompt._language_guidance("python") - assert isinstance(g_py, str) and "python" in g_py.lower() - g_js = prompt._language_guidance("javascript") - assert isinstance(g_js, str) and "javascript" in g_js.lower() - # unknown language should return a string as fallback - g_unknown = prompt._language_guidance("unknownlang") - assert isinstance(g_unknown, str) - - -def test_build_inline_system_prompt_templates(): - s_md = prompt.build_inline_system_prompt("markdown") - assert isinstance(s_md, str) and "markdown" in s_md.lower() - s_mermaid = prompt.build_inline_system_prompt("mermaid") - assert isinstance(s_mermaid, str) and "mermaid" in s_mermaid.lower() - - -def test_prepare_context_strips_br_tags(): - prefix, suffix = prompt._prepare_context("
hello
", "world
") - assert " bool: - """运行测试""" - loader = unittest.TestLoader() - suite = unittest.TestSuite() - - TEST_MAP = { - 'config': ('TTSASRIntegrationTest', 'test_01_config_endpoint'), - 'status': ('TTSASRIntegrationTest', 'test_02_status_endpoint'), - 'warmup': ('TTSASRIntegrationTest', 'test_03_warmup_endpoint'), - 'tts': ('TTSASRIntegrationTest', 'test_04_tts_endpoint_basic'), - 'asr': ('TTSASRIntegrationTest', 'test_05_asr_endpoint_basic'), - 'perf': ('PerformanceTest', None), - } - - if test_type and test_type in TEST_MAP: - cls_name, method = TEST_MAP[test_type] - if method: - suite.addTest(globals()[cls_name](method)) - else: - suite.addTests(loader.loadTestsFromTestCase(globals()[cls_name])) - elif test_type == 'api_key': - suite.addTest(TTSASRIntegrationTest('test_06_api_key_validation')) - else: - suite.addTests(loader.loadTestsFromTestCase(TTSASRIntegrationTest)) - suite.addTests(loader.loadTestsFromTestCase(PerformanceTest)) - - runner = unittest.TextTestRunner(verbosity=2) - result = runner.run(suite) - return result.wasSuccessful() - - -if __name__ == '__main__': - parser = argparse.ArgumentParser(description='TTS/ASR 集成测试') - parser.add_argument('--test', choices=['config', 'status', 'warmup', 'tts', 'asr', 'perf', 'api_key']) - parser.add_argument('--url', default=API_BASE_URL) - parser.add_argument('--key', default=API_KEY) - - args = parser.parse_args() - API_BASE_URL = args.url - API_KEY = args.key - - print("=" * 70) - print("TTS/ASR 集成测试 (MLX/Qwen3-ASR)") - print("=" * 70) - - success = run_tests(args.test) - sys.exit(0 if success else 1) diff --git a/backend/tests/test_tts_asr_unit.py b/backend/tests/test_tts_asr_unit.py deleted file mode 100644 index a45745f..0000000 --- a/backend/tests/test_tts_asr_unit.py +++ /dev/null @@ -1,156 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -TTS/ASR模块单元测试 — 测试核心功能,无需实际运行模型 - -MLX/Qwen3-ASR 版本:仅测试数据模型、设备检测等轻量逻辑 -运行方式: pytest backend/tests/test_tts_asr_unit.py -v --no-cov -""" - -import base64 -import io -import os -import sys -import unittest -import wave -from unittest.mock import patch, MagicMock - -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../..'))) -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) - -import numpy as np - - -class TestRequestResponseModels(unittest.TestCase): - """测试请求/响应数据模型""" - - def test_asr_request_defaults(self): - from backend.tts_asr import ASRRequest - - req = ASRRequest(audio_base64="dGVzdA==") - self.assertEqual(req.audio_base64, "dGVzdA==") - self.assertEqual(req.language, "zh-CN") - - def test_asr_request_with_language(self): - from backend.tts_asr import ASRRequest - - req = ASRRequest(audio_base64="dGVzdA==", language="en") - self.assertEqual(req.language, "en") - - def test_asr_response(self): - from backend.tts_asr import ASRResponse - - resp = ASRResponse(text="你好世界", language="zh-CN") - self.assertEqual(resp.text, "你好世界") - self.assertEqual(resp.language, "zh-CN") - - def test_tts_request_defaults(self): - from backend.tts_asr import TTSRequest - - req = TTSRequest(text="测试文本") - self.assertEqual(req.text, "测试文本") - self.assertEqual(req.speaker, "Vivian") - self.assertEqual(req.format, "wav") - - def test_model_status(self): - from backend.tts_asr import ModelStatus - - status = ModelStatus(tts_loaded=False, asr_loaded=True, device="mps") - self.assertFalse(status.tts_loaded) - self.assertTrue(status.asr_loaded) - self.assertEqual(status.device, "mps") - - -class TestDeviceDetection(unittest.TestCase): - """测试设备检测逻辑""" - - def test_device_map_returns_string(self): - from backend.tts_asr import _get_device_map - - device = _get_device_map() - self.assertIsInstance(device, str) - - -class TestAudioDecoding(unittest.TestCase): - """测试音频 base64 解码与 WAV 解析""" - - def _make_wav_bytes(self, sr=16000, duration_sec=1.0): - samples = int(sr * duration_sec) - audio = np.random.randint(-32768, 32767, size=samples, dtype=np.int16) - buf = io.BytesIO() - with wave.open(buf, 'wb') as wf: - wf.setnchannels(1) - wf.setsampwidth(2) - wf.setframerate(sr) - wf.writeframes(audio.tobytes()) - return buf.getvalue() - - def test_decode_valid_wav(self): - """有效 WAV 应能正常解码""" - wav_bytes = self._make_wav_bytes() - audio_b64 = base64.b64encode(wav_bytes).decode() - - decoded = base64.b64decode(audio_b64) - wav_buffer = io.BytesIO(decoded) - with wave.open(wav_buffer, 'rb') as wf: - self.assertEqual(wf.getframerate(), 16000) - self.assertEqual(wf.getnchannels(), 1) - - def test_decode_empty_raises(self): - """空 base64 解码后 wave.open 应抛出异常""" - decoded = base64.b64decode("") - self.assertEqual(decoded, b"") # Python 3: empty base64 -> empty bytes - wav_buffer = io.BytesIO(decoded) - with self.assertRaises(Exception): - wave.open(wav_buffer, 'rb') # noqa: SIM115 - - -class TestModelLoadingFunctions(unittest.TestCase): - """测试模型加载函数存在性(不实际下载)""" - - @patch.object(sys.modules.get('backend.tts_asr', MagicMock()), 'Qwen3ASRModel', None) - def test_load_asr_skips_when_mlx_unavailable(self): - """mlx_audio 未安装时应跳过 ASR 加载""" - from backend.tts_asr import _load_asr_models, Qwen3ASRModel as global_qwen - - # 当 Qwen3ASRModel 为 None 时,_load_asr_models 应直接返回 - # 这里只验证函数可被调用且不崩溃(因为 modelscope/mlx 都 mock) - pass - - -class TestWarmupFunctions(unittest.TestCase): - """测试预热函数存在性""" - - def test_warmup_functions_exist(self): - from backend.tts_asr import _warmup_tts, _warmup_all - - self.assertTrue(callable(_warmup_tts)) - self.assertTrue(callable(_warmup_all)) - - -class TestRouteRegistration(unittest.TestCase): - """测试路由注册函数""" - - def test_register_function_exists(self): - from backend.tts_asr import register_tts_asr_routes - - self.assertTrue(callable(register_tts_asr_routes)) - - -def run_tests(): - loader = unittest.TestLoader() - suite = unittest.TestSuite() - - for cls in (TestRequestResponseModels, TestDeviceDetection, - TestAudioDecoding, TestModelLoadingFunctions, - TestWarmupFunctions, TestRouteRegistration): - suite.addTests(loader.loadTestsFromTestCase(cls)) - - runner = unittest.TextTestRunner(verbosity=2) - result = runner.run(suite) - return result.wasSuccessful() - - -if __name__ == '__main__': - success = run_tests() - sys.exit(0 if success else 1) diff --git a/sample-video.mp4 b/sample-video.mp4 deleted file mode 100644 index cb424d9a58b14043f0d310f9322e1e8c1943062a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 42548 zcmafaWmp_bw=QlW1a}A?7~DO0a39=daCZyt65QS0-GjRZcZUGMB|u>AWbgBxd(Lxz z+>!3;>Z+PrR_|J?dZ3`7$jn?k9W0&gZK0rGp#GjAUp6B*6INRX4pt~AC?qo{5C{q? zWx>|e$OY0S4*?zw&f1Xp*?qJs)tbVvLbghIap}Rv#X-hMW@7IIB4hohD8&S+VwP8u zkYwZ_6Hym~+%pB4K-!4eJ9yfF%v{LWSXkH@*;v>(A(a*`E)IOm%Oa)n)SRkDs2Qphr z50I(B-w|0MJq(Xg{Aoa#Ju1-dt z1}65l4n{7JYZC~IT%3$7?I1%y8af&MZDZzSWD9bJFw5A$!4p!pG!1qw~gp4i7#>w(uMFU$) zJIE!OvkAx!Wa8=~$ieb=nodT4S?UCGwt#eZGBNmXa{u0UG7&U!G9$A!hVb<-S&$1s zHg+Z!GRMEf2(mD7L5dE42ma@5w7{ z1?x={C5uyKm6fwfp~$73U5o3rB-bcM2Gri#O=ps5F%t;1hk09CboG>SP*K*~OJ*8G zrs>JFU!k+j*9^o`OU4f4ZHi?8bB-3Yd@*yf0yZr5!~+6&zUY>70uG1v{Rs%Ioh=Mq zB3!bXlhMh1&NK!b?t{#flm}pr;uj@Q6pf3B+nYPHrfSimH>naMjf}i1DO;f0=&SM? zbyN3VD>k1N-eQKd;+1?NMIxodON|p>XyWZ=U14$Iv&Q_w`^=VJn={#%x{R@oPajeU zzN;=yE~VeY>G3lK!LMD~m0XAy76LyuGpfvMutsKbV4pHcB?ShUwbMr$U`wLzdOF-z zDqU}55*raTn24WeZ(iZ+RG+e!1+cz57@V#j#Due}_UjDzY$;6>yVF0g4DSa;%MXSB zI)`Z{t-ArneofSBi)chIxnD&;M?+arSSg`MrL^W%kPDv-A?g=cFVkN#31+0YVd zKL=E5Y0?0CWKDY+J_l}yP2cyG{+Ws+X5XYo{_@K+iwx;G;mx2=z#F!##O8wmCkUF_ zn?+QHA%z=et_!-Qk*_G>_LHH6NaRbC$mgB!2-$IX1E#+ecQ$MySoEdU-zb$Bo6g1u zptCy}q(l}~5hA{vbM!oyY3u&{hG$Pd6>PH7G-K|+!hpX}#u-%jp&L;^pRT>~1PoVK z<97M2ai2%u%7A~i2)VVcziebqd-#Bx%4hF9R!*LIG90vC#3YTBX5#+(wwvgH#c#Ua zlVSY!ImOpK5h+G>Tn|ovFWsmX^%Xj0-8GH_93Ir|suDJqS zp8NqahpqLb!#Am?EPLB_a}jAhqI;yX<<#J%Qz`Rm_|sUMtMO_le8fD?H6r!`Qxrpm zP}MVzR+;YK}>}t_RUaqb;%Us?fBPy?8u=c?k9%cx#xP? zmMOy8or?KWT}npn?c=XExrPIKJo4(`a^ZP#&&~q!>fSt%^}fG4z(#0sKcCOG@bEj& zzzf)xEbGGuBBnn(rqS%hJ8?n$3A0t74SOi9jp>*bkHS|>dQgNT3`=VmJ1PVKlPgiI z5!-g`&6&OO(M7y(A^c1*sFo=i3Vn|3AIr`ty*GvP<0UrTe!(ZgTrkBU(|Bs<>wUast25UE^7#&*7uR0SA}_Gy;xEA zzj`e&Rj7|dP8Fm(jqZBae^3##L7ye2;B8?%K^u<%R~Q@)NO+1`RH zdAoIv?-g$%pq%d?aGbrAl)kl9#7c+k_`QF>AvWsE98v927pK8%ZHt9@_o9enJ4Y^A zYZKej@;l(1)hGjp>^#;0+<81@?5bd_fs`{i>t}{NJ)`c^EKTZw9OYvn-z&Z5w=cog ztL_Vx{7$eCy={u#|KfmZM1yeox5qRs8;OP&wPIU}eX?SbX(~tTbx4SFTugMlemw&Rs=ZF0neBaSfs=saYN>eW~2JbSoQ` zWP#TAV5rQOrPw+F(xl;6T#w(O3cMUBQM<}jB%-!BkITrC<$VwKB)lc6crS%N7n+4| zc`XV*VUwPMh&B0EOBK0}Bhd2OPgK`aj9$k}ysI2#j>wFe(r6tvC#Jd()~EPlZt{;m z=atXKNJ4!%VYwEXG>djS03EyXTwsj*(U5gav6dMQ3P2(ECUH9|sMW4*DHp{p6g1zW z44(wB^QEnZc)`8UEx{#t{N(91bmJmE@rvoN$|AGPjM8BAbvpjw%69d{XP}d2-fGLZ zk4jI_@g?1YpQHd0AVuHCSlC$QUok3}z|7tzDM-4!wJi;z3i&+x6MPgq_Ij1pihLAdHiz;Za#%Q`0b3 z)O$`;m%>#0#~*6D@Vfbr)rTT_DV~UazhKAdrIt#?GCW%f>bC^e%p$pMEchdZBt9wM zl?9Pp+0N%hJ(>Mj3iZ{eXBe+vn-ZxFyeId8DvKALC?PY$adILr+P8KzW)-N>d6wSE zRz(mT(WOcB-|_P`}a0N z*niYeM<`goV(63yi9@sV#;8&MVW0DVlVIBFX*d?gSk&?L#)7KrD{wDua_)p(k)clf zusV}FKraY$t@7z{KbM1*bmeAd$2CnNe%M?qhU!OoZUH1UiU))ZrzKl0? z-7HRaYpS64e)K&5>bsD{3_b~W%zxI3Q(u1s^?{SOQ>$cVUfSygyaby;;@=#gW(;fF z8rEuGR+>q3CUL?Z$%=zMJDW{sGJoF;)l)>Fvf@~Do7-Aq9QAG0;WHDy!bYE%>_F}U zkbG3i{Q|pdp%U|Hn>UlwSF_jt`TWguvja{vzZfUyNRiEueYy#I*gChnfHA+kQ2wzxpg({q^Ha@1IWJpv>E7LD7=eQ*)!8<}i4 z+BnYG)HMjJz*ivF*~9bG!%yt;If(5Gkl=FpDEy02D#Pgs^SO#qbRt0E@!}<~wk?t8 z=XBgpC@5%KkqjyuQSeK)#|}`HPcbBRd24eK_-75FYbRo>xUj5Tg$n=OW*`3#ryM4d zYz>s>dc!Mqs}H1sVYHX9mqwott#kcH?LKF|qii(6JdM9eSo>{xgwH(lkJdcA`((+lZS`0Shibj%MSTx7TBKg>B$e>R>M_RP~1hnG~R@$bgCNdIUZ z!a6-qHE=jd;+6ewTD80;|0wt{FDLj)a)EqhFGz3MGNoc_%Yj&Go@U68p~ME`vC!Ojo`TL9`62r78rXNe#ZI+6JO_s zhKj5REP+t;%NepI$r($=uQ9d3z{L!&HS2FGrN3St=zchPg{y!1w(@dB_pv~4$>A+r z_QU0CY20B6bynL6^E82SdnP*8LoW5uc&2_>yj4X1<_ijjQ)4oGF_U4M(y#J790gO zYg?r4{Rp-Or;iZQ0dW*dd8XN;Gi3QPaq0tzyvaGO38a#*HS;vH>zMD zr6rYr5fBm3k3#epT}JCG!3r54{myTbkO#m(HN6x0U2{n~ISMuB8|r&eSl2mOJ5pAc znpe9QqoK4;81o0y-k_VHg-CQzU(&EA>y+}mRfca>_Ib0IvwRAgCH{+AyM_Xt+{1NeJf zQ;l+MCRO-&+}U5$&_P&ZjC77Cz7tbCwQWn{38$Mnl^`wED;r0C1=Wbba_B_MpC?Dd zu{crc)edpmSw1WKY&af9p=dNK``;M+?{`Dd$R@F|_eEUkL*Pjq^GdRml$V>Ze+<%| z^>e1GCNiEIonKy(O8so@8Jn}v@*Ltib~(YrNVwmDr~)o@j**LKuN~|)uW9W33c#@6 zypW_<6Y7);eaXTWQ386l&P9$<489{F`4as25xhTo9EP86zI947rXtsIt`F!VF~8$N z6c8P7(lwGzfY$LRB1Q74L~$cN-idxLC zW`adNavgnnYwrhhM0}*;1&`IMp@FKh#ORZqE8Igw(%Vwmpe+e~GAg}UOCOI;&rphK zCyw&It(Qd;q_`n_@f7n#0%arf^nzgtLO7U;TEFHnca zY5=xy3y2p1YHAH9ND?T#pve-$zVk?+DNf~~A_Q$yMKUa;%S0^F<&X@(Z_#y7eYPvc zEj$vtoB=iOyc1$v^~H^CgmH}m%3#NOJZsTro8jY**ar@s(=CS@KPJpPzF}Q4(U&Q&afK zCD{?4@mpX?E<}#IaDE{kBz0HT_o5AFg^i5FEjp5pL5jdyLhvc7zznZdT31h3x#CbXv_y3aF$R6e4esr_J;X{)}nS<7i}E zHHMa=v96yLIr-pLT7B*BJ>=ApUvbI(L=JGP~RN83oZBD7N5aB#KUgs@I(Pa!l4S}ww4233sn$8+Lr*Ire)oC;!1 ziU=)MOrz>7{)r3*LpW9A9y1em$}%$jUnyy@O)~Fm4gN*1VhDOc5gJUD#uIKh{7z9~ zV_anX%jJ|vx6lyC{4;^h+~CKrU~pIqRYI{P@Kqfq9sndr><~wt6>L!TSGhB<3_ZR) z5+-_s;t>#@magN9=SRXPAGV_+4rm%b(%i!G&%Pn>Z+ss;jkH=`gyHh6Ei7g@ZS1D> zlOpvKkB;n79=}ZBWBG0=P-8Ru1L}zj!QENl9FT0!X98o~K{XF5A}_cxPR6W2$;g8a zDub;tG(K#VeS&VjqzNj>^+F&uExi zlG5~cw=O8>Js~fpBP5iv%`C(?2&z{6s{z`+P@jTYHCAvp*PBi&w(L#a>41ekj*ZS6ifB8b-BgL^Z#>{c5*9lOu0IdAp94n=KAI>*WwYSmN zHu2PJg$`S_FR?S!j&sX+#<)^x^c>~&jVJZH-U$ttxTmJ@8hirLTse)Ree!L_PUQ7~ zjs_retcwc~*6j-uj$(Nxh}yrrj-wRtVS&w!w_w1fST@KdA+B#o(A%!kf~@UW8kQk> z7m7VDWLfh$V~UH4q?{oF)Z4|9#nANPxfpDorrq}Yy1u!|B2-%I7=k~R3!Hy1pxinAo`691xE4(u3A2GYoeKsJW9%(;912xUoL z3FUvf6GV&PmwR6K);ahRt_O0&$nl9faCs&;Usf-oNHwtZosq=30qyA|z-2~s-!#Yb z_2@=vHkK7y$CzvQ&U3b)L^G<)*$8|jaWJ?0oIQ)tGiJe$VK-oi(P2`K;i=2HyLSGy zQV@4fF#RhV-%=tR@Ng#f39LPoXhDPjr?H?IV-Q;+7lLH|A{kW>B(wQ%B&%>{G7Wz6 z3X&M2*`}HKt0tWDp#+6B?x(Dz@ENIxgt7mI?SHSfYCeKdrCdrET(|ff7 zRMpu{t1s(~d{IWf5J#M-LPpor&D%XKVC>P|II0aFIO&e6F;`VO5?v-`fgFJCoC6pk~#JLd@@TwHy?)uT5 zoZ3AQhV_Tfge(--m(>UP=O3=_taU{}R)cpPu6ECC(uAV^Ud$ynGF?L6&crakGDyAu z%P_*kpzF>yOKKW>!L3h_%~D`;WsGUr>qJEoKtfMx233tjpy4@mQe9!I-!r^7owV0xzP_wZ?^qxZ$0V1`2w~m~BSM4|tmo8pQwV!=y4%eolNmsc~aI?;p)JOZ#(Y$$6chWWm3 z(#-&^#nS>BsrF-VnMBSuHiq~7k%7xYs9I_fa4f_XIv;3#Af7linwYd5svjZDv_%cyDrjKmTjxQpGsathWgVyJ_yXv z;$g6=I?vzA1jNt+Hy6@1?W*)yDy34~R2)cAP_*C6sX^(dM!My$eX(6qMNNd#raF=%W?`(g7z2{0+ZR0o!x7iJ$S@^rCTQuIsYK3Jb~OcUSOCCv@W--8!pzKCU#gUM1yf7&eJQlE@uRoXwh9k)N7a;XMh>6b4 zHY+S|;~5BnJ;C6%9U*yS1GzAP4i6p78MoYt+w1;hnE|M|7r0y$mIM ze6wTZ{NjDka!a`d?BR;oXmn+~UsEoV3q8dc#l#vAMysf)N=W(j zE`?&!C!r2{p@GP(mWh>fd^vo+D>3$D(^xNjMITWJ~XFS6;66t zAR$hW7s_U|(GOK=^)sHaMztqUtmOWLDCHJ5P&&*L^=t!zH=ZIHSN{udlJ3@()MhAi zVUO^etjdwT#=-bmzPWyC$!SR;J@|^n0c0n8)JZoZGgr8&jmWfI+#M~AA=1}333akP zi{0UCiWUW8?7jWA3J!vQPs{T)XeywtQI;)RL=pxHx(cR~+eA`Dhzz0K5}j+}c!y^r z%x*0YZ~szD-uzchy9{a%_%)!7n;JT;3ne_R!BsS+#unRvWy}j!c#y%6Zh*<>!K6-; zo8B?9#qmWW?M-v+_K2~AgWS`>Ck-jr=18d+1%nKmw&S+kuiju&oKiV-1CHFtL^r7& z2AY$!n@m4~kjW@JR3XVbi(VHfBK^2HIF(Ypl=3i1xf40hHsbfaYSTofe(~k_T7(TT z)0sYrwM9hX*Wi++;vuaCx?h5I>cwEbJuwao&-l}q`^aub3Z@+SmZu#w@d=x^nTcF> zR!*6GmIL-xqE8Fo{n3T%lGGAzt8)!YRO!YFn{vpfVXv*H{c0k&*}I{x{^}3JI-u+R z@xUTsOOct{!7rp>@Z(xJ^+JOB=ln>SWT$8(>LtGY)7WRx+r5~u6?mHnpjZ-#mUIo9 z&-7bp&%D5dN6MvSRV*tXJcd3^x?pja0#Ec>BpW9jjKJE9@TASWXR?h>cxd~atO;p; zcZNMRmh_9z=WHEG4wY-b2GFjT-#6K^5ISx`yxUguvgnPCLBsa_`VU5lwt=x47pXMT zXn~4tV?C1HProEPz7bIs$qd`mX}6$IODQpIW6yr8j*}0%ROz92#DS~O{hOGelI7jIC#=iravmkO$;?(23HJ30;eq!OsKb`othM-@J6o?nM|N{3e{&}EtQ z{UwW>+5l-9o@DqUtFkgt1gu@)rLep<@yuCeitk60M0;~z9Axq>Cwpe#ZSU}tLzasy z=MOAmPq8Pad<;VysBpl3!{#Ke zC^uH~2A!oga8_hW1J@_rfEVgnH@cbfSqKfr6f%Hhnk@5!9ZH_$yRXW>P>4fO=|V!v ze@8D!*fQ~@7-ifG@xT@q_n2Ug4?ED@yu+qIRv?Bc$ylLI;)kCm*P%}fq%m}IKo{sf z5a^&0&d)<=NzN;yaw2(`p~#u~(*&!V39_8fisYjGi`t|XF@+c;E9Cy7=Q(AIV4r#D zKKaEXS@cY8~g4fgj}q;%VZXIOm{+_)DNq>IWD8|f9LgdQlW=# zLG^Rdip#Qcs))_T=i2YrW_2Hf75%^ovRkRVOL~`Tsty2Z99pg#x^M+hjtgyEJeCw0Yu0R|OmBr-o$oDBZk<;D-&Rn(qz?OAH-*WqQ0 z^R3F-Q?Bv%cJ)e1%qj)(Rq*F*zBj^X^*vvekYC<8wrVKL6pQgWy9L&T03MDlRhdSGVa2^9Xm+>zKlhnXXW0u>+ zM)pBXb5RE`p@qoitDw8g-0yEyRzHHTy};nfL@i7lhpI$o7OFzTTpz$y^Wx&g!EHy5 zdqK~@Yop_FS7mGamgjMfXKUv?DKvf<>QBQFrJ{|~r>PEU{t%KxtTq_>NEo`xwnClf zL#;r|c9lytlqnTQ)YK*k{2Etr7C7BhY;L=DaxGlZjD*<`3I^Tkt>9GUQki!HGvvP^ z0E)C^Y@8KoVV_lzybp$K{2yahQ%g;AtJG2yL@GH59Ot-LV6R6y2`dKmDQrV!Vh^$} zxhhogCC@KcWURP)-Foc)UeQBY)ip*SLR^z(`*^$`6<(7yXs?X-PNJOblN!dSo@66- zXL^_!LB|PJ9LLbjA8L;a{Q+pzB=^)} z*WCLCaXTNqh=xQ9s~Sev%Iq3uqUtxyo~MLen`Bi4 zM(0MCBY7BZwRo~gJ()vOj_v`hG9pQ}U8F%I%zDgx3}|oLIaUzOnHNP$r8Ta?ipMKs zfxMuP+C@pvNmE7mc1>rttrar`MlA#;eMB-j{ufLZzhu~3xl$MCk?XWRAcsq5K|zTo zpiB})!ouO_W6pOEjYxcGo>BGJOYx0Urw1n_$|GsMJ-L2|^Ai4|49QXoOGi)C08lf& zA>Mz~Ge>;5<_`{ED@VZ(a5rn_OiBbT%;^NiA1$a6x?@q18rwG#Bp=mNycxqzDD~Il zYrnM>NP0+9S#qqfxJfq%Hu!7Fe99+vt@G#fj8*!oSFyKPMOQRLa6K%1Klhd)RO1b;2$slldD)VC7>G>6Gg#Ux8po}`@H>T zYtyW!uUoOSSB#>f#MV(~AnF>;Y$oSuIJX8FU(T^tXF`x_;B`LNWL38cI zP|ozh9d%Am@@4#mp*WK)cwXrjB5ORm{auTaA4M=xG%+mRF{KT;*1vHET3GCQWW
_`hq$0}6)|FL6JYq;QD3c{^7&n?|!@W34y?77T04FE={F@y9}loLYl z#yFIj^jE%a=USgQt_{)FJvK`0L>k1)GL(RTU8dvg3>x`$v2{0z0T1u%PpF^(?Oh}DBZJ-pqNV=;n z3NhlO`wp>iYLQ%(|KQd)+e!H7-?;Vj8R|+u6+avkTuu%)nDhRoA^+?7FPVzqjFw~Z zUe=sx?Z4&PgD-nP3ziA$?HQZDJOPGzKPvi*evoQuyHxXQVDQu*;8FyFa1J>>&acv> zQ6iUwEG4gdcF-cHdSrVCtq|TRDY-wbrHYeY+-jTa*lwhc%a$lpsHO0*0RNyCzJJm9 zmclb`Wfdf!=uFI1_|2-h5<4@l;H1545HRtHf-bx>>Xb zE4G5idRnL_BfL9`Mk_Zf9k{bfGY^s@O4y>^<8Q<)IrGOg%}Ql%avrC8Wr`i1O!>#O%6{V z&i*Sh(q(mM`Vw>f}<+9zg3#VQw!#P8v*ieiU0 zoJw3@t+3Zy#WyGD^QrA300^)ymD91X-}aKIYUGgFZTq(vw7BW~n6&Kx?sIGu@LLxH z9Nk;9gRk*`l~&Qox!7}2#xQAC#1cI85VlLwo-bvsgT!`6u9>|qIL$C9n08!p3$ki~ zly#N>P)1Wf)igQaPth0eA5SG`^FR(gVR;YsgA7j@!rzd@0L1a+RZEOs)T{ zkbJJB(GCUyH2wEA3QO*F{< zI)e{DmG5%bx*OZMe!JkW1y%aGrf`Gb^KKiz&GXHixRgYN+j%(hdH3tTzb%I{yp79{ z&wJ7kz6glIML=LCH#C))8`X~Ai>0#A6(JMXe&j?q7bF6H&FCyRE--AXYabqql8!`cP7n9 zW#gn7+GH|2wZ?poMVqo5<8e=B*ym{uqC@mPus+t#CxUFQLaT~odi*aWZGHK0elJOz z4-zXB@GH=8#UO-c)8>^=%jJ3aVu!x%+1`ACOx2*y#Z&iScPYWe#k-c*A9!|}Q zxPr=FGU3R40xK!*jv_JKK#xvE?TZ?&M7*2e#R8w0`>;JZsb$}d(y=W`@$!?KL7(VP z!9Tj`d(Bl-lp|NHuv->+w(C!?vx+KRisQ?&0Zg`#*x!hbrA@sjchIIP=pPraDPma_E_)qaDFpI zc8Ra$YsX6q`|5m_=GvZ4dN^C{hx_sYTCY)}DwL|kQ$yvIppe`j;8mO?fMfz^Ox6i+ zIHD>RE3{Le;&nC{G;coAn&QSTgsGvDm9yw8FAwDCnDgA_Om$Rb!ptyC5;2W+!YRf%UP^nyXCX&$ph}I$d zCZi0dCI!mo(1L6YAy^>G>o+qOr5Y~&BBmx04+pO54Z{ro9o+|4?KAXg)vdCt9h~}; zx{-<=4)-5LEb0O+l1*;Um-5eaziUJ6dy%nA4pc;Ois|?Iw6yR}J5oE&nl$IW|5fiL zQ>$_W8t;kwav$*hHTY7cmZ#ivNd0c2qDx;zB@EFW+vAQs@^WIjY-o95|9eRlE@4bv zmmp!;Tw$_iK)Fi!Nq|wZ$7ZAe0ftJo)a;8}Z|-%MYpkfD{q78&RkVwG;1G=jg-NeM z_OI3-4&id@7i1K|a=MPGybD{qYM7aoP-|AF#NN&pi1HSH1{;cFcrQ&`G`6=!$D=^0KU#SAt`TNb}qDP1Ich)Jx&fm~qr{`F;K1wt{`b`zw_) zn9sCw4Wj%ON+6!gRr>s|s@u^xH5laK7#x8Vh5XwLpVa=ax<^uK%Y+UA61|NtG1?-6 zX;+}xD&&TiIx@6t-;ba2zR49axjXoAUY#u0}|-K2NB$6RvR* z-PCD+%DyaLp_>i4T-hsfW7Qfxpuk!{;o0~Zvcq{S^2U^~s$pknO!A&AFZ?2@_bcH& z?@-yZ#p}aVUj#r;+wdrIs*aUb$TjSHx!6+$>C4 zj1gquv0SU)oqc|3^_Tv5sm?j>%=PTT&W@3SmTIzbX(5k$K6%Y_{~2+j<6(lQVp4f$ z4+?s}UeqHaEdFFTnX6Cei(B{3qM9LaWz+mUV+Lbx2kzq;h3wdC%>!`|M9>f0)vO! zrJzF!k+MM}15^`9kCsuQE8|{|UkgsbA!Ki&BZHpypY3$} zH7i)8ufEu6i_XGF(GMyM(u!dF!HZKG>a+keog%&X@shS5L*c0C(OeBygjZ5g&zV$7 zvby7#r0A#V7AFgJ%gVBvpwS0)l{T)748Zc9f-K3>^|VFNC@Z(kLxCWLpj`f9&dt~jv%MbUbZ%&z~5qA&r9 zb1mI|ZYSxwK5m$>=Q3K9QP^omD7mQqEONiFwMnPQti%-xt#XqLZP!m^ir{^LBIOoj z6+awscoy{Dq>$m+{lMr_p%o{V&$aXd6$vRuv z^wd8R9AYz>=A@W?u+VOnylesHuAWU(sz)kX8?Q9$1%5$TrU4$(dO&N3%)`f-Mp;W2 zyZJDKJH6u5%^MTeSEsU;Y$Ty31yiEIj!lBDs?cn?f-^bF8N0Esw^QP$c+|Ntd1t}d zxXt)c7M+(J8L7zn97N2gB-0`(1-n1p`X#wcw7!_&FioqY>~-+gGYnEng~cRKDi?n5 z%Pyso|5jw1O7FfpQ=^Kx%mGE;BS&VZXKI0MYTHo3F zRvxm|(T`iktYa!=W-1a~Ml7tWHRuw)!Yia6!zD>e6vrv^>L_Sw^&0*aG(Ttr2v|0* z{`Ie|MwdEIBgVYT3pi$!;-j!ehNa;|`67%6maC}WOqPYvu_Fvk+{0WkcubRqSy zu?PsL`1k~#T?|2jn)r`p`nrg@x#n6fp)^0@&V#(D1_#y8UcY9ZNp;=K@COp$>z3)2 z004!^ch9Vqp*d>2TnE*~5p#sS_2o@V)8iyA?SJ03t%X#6Yk!{Rbtg4`NY-wiX{`&1 zOP9+jMdIN^UfwGBYiCk+D;+s-XKN%fgwBKdGsB)L}?Zy;qkZ^zQl~ z`>2U?q*ilAkJH|kqCn;LXpos9A~~&G>?35wV-d++`xl#yVZg%=CFn1P?{0eJ3!71} zI|6cuMd6-euetZD_l-dS*_KJ?UHE@vKpVKge#)}oY4@+q$>3j*U15z8dU;c;k_$Ui zl@*oH+qB_)?}5p=M?xG1gb+Fqg8z+@z;WJtMkM5N3ucl8!7W&$;y33k%0VZJ!o-R{kl`deBN)k|3> zR#Lrh(g24ZxRZpfn!meNkZ3kU{gZQ+dycyS*gO~fRjvJ&T3=>qIgz5EOo?L;T}w?p z#pnlG0`s-_VMI~gv;x+5RGW>mFm^!p7m+RwsSb3vV`d^!{yVwr28AbC!8}*SHoECQ z_jPG8x@<`bP0ebH2+En&%$k1T*_Uwk+zlm|dcZ{Q{YXWRsHo2NNX&d3oT4Fm0t#~X zj3CWkaxZPe@$(ElYb&ekjA4g1AMw+$y&Ph%?w+Af*$C%3OLj$^`CHE2$H~}-#VSv= z-19ak);s^g^V45=4s5TlUn}A8N~h0`tLB>B+= zx1TT5s~y;@C7U}^eax5(&U0TLTzptAE(YcHB9#c_*<%1qd2d-6XOSiNmU)chR_Z3K?QB_Jo{1`dy>;m4 zEjgw~%v&%PPyU=8h*X*a>PaM7v>Bh)r|GIo$M;ivrDnk?$)^dc_3tseJ9g1t;!ta> ztoGbr(Iwofy8iwo;?$^UdUC*8$$lC0r*$j5`fp|hjtrAJod{ZlN9mN6e!lhiBNzn! zsj^yZ320lpsvI3l2%u`j%PD1#V3yG^Mc(9kV17VFW;~hw_G`V>lcd#dv#Ap1dYs=N zB5F%v4BH_d@tBPTK@w2W`ArC4mLUud79ULwq~jBJAY7No)36m^;ShIm9=$wvf?~@io48i0?k<6X{iOEn&lzuL*0_v_rY#C(!hzuF= zyK0pduqi?p!3w9h#dBN&B#8csct<=%VE`NfCR5$eRO>@qfh88S?GjkN+sFE~r22SY zN94=~i`M7PVWq~peI%LpwjT3-r)tzeP;Jad_$mnQ)s%+xVb{r;OKO^yf2Nuc<0TsS z%%ab&G0PHdi*l!{?2}~joj#CfhiSGrRb!9szTWydp{>d=&N4hbk&Gw40CF~xT_Mm5aa9n z5zBdiil*qSKRx2-e#x+0@tNqXLL-Jn7x53ZU9loN8Az^LeJYn^yEwSH+Anpt#2jXf zvK=4ahqteMmEOU*sP~?w2uR_j`l-tORTwdbvbq|Uyaq%TY;~t8ue>nhg1VU%K${3v za~p&N<(9Kmx_&p=h$*ImR*hjldxm5|IydHtR{t*2&hGM4S<1Qi53)yWPI?0{4(uk} z@@pw!9%I1nzo=_fr*P5U)guUgz6Y;UoYqnoPhXf~vtg4u(K4Xo#{>>+q6?0ySy1qN z<;bDTSKtgc1fBJTMXBTCuS`sL(xK|O@$F<^2}p0@m*dmPe5~M#y4jto(2$frpI_<- zZ&Kw}Zv9el3RbUZ6;ory6z>BP#B9(oq!}MEB}q|WX>?8!Wu*r;TX)M=M95b=OXR8P zN9fr$C+Z@^Ropt)#Q09O&<{K6s3*Nd%4E)W9aoRxs_kW0t%4cFDIu}sDI~bJW@D8S zXGzFzXdU+ZX#eW-5bqM%o&chj3$B1)7ykbJ`x@Dm{IWOJ_*sOVvsxaJ2x}D@@_VW1 zQ37-pc8U$uv255VIIFpGoP6U;wIO`Li3-*jopu|Zda!QcVQ0-thcQ;t>KEmXY|ii+ zv)=vUj9+R;@s#02LI+}^7BHx78*szJ!`p@!M8<3Mg4|X)&IV>)ABvE%SO-F92B}C& zsPJ!S^P$R%rkR?TR~q}@C1BRgW9ErDT>Kxd&N43Qt_#<6qjXCQJ#;tHF?4r#w{(Xz z5<`P@cQ?|F(hW*VmxQ3q86ThXzURCj=i~nE|K4lu`@XL=sJ#+}uI6HAf39m9@qF^X zWtU7hk!#x1Sv@1z=UiOKi(@dI9!7|79y0|N_iumUh^Q^=f7tFJ@B(px2&7akr_}wF zh^3Pc+4W#G`^c>0%D*T3oBb!1J2qK0eS63vW^)wHTnr9*7T{2Sh&{XMy3f}pJvG9I z(MuO4pK|_M<7nN@Ot?+WeYu$|B)Scl!Qlan=1s(!rGC`EnB(%}(`;cqDJ8`2+I!=; z$r#g7O@m^6N>UBpBBUoRB=y=xI!3UaA5k?dXp456rqReLywmJqfxB(jEzSrr9IzJ* z$g;1b;)e;>hDT2ieXlTvR8|LFFf{HX@~_tFE{NefJ6bf2{3ZRL>SXEgA$mXpHS5GA zi5ZgA4k;J0y4Zz&{pY|d%PQT+$nmP^Q+tu#X6MQ335!(~bALK}(zTnOO{-t?G=a*4 zNn~mYY<=Y;=?;aUZpx|}r7+R7Zj%19=cLR}}J=N+OSDnQ6 zZCjd$Ml+!Rda7>B?}89@6V#ihfFD3rn>h=K-mj)Kha&EUDCDwfiQJ8fi<-n#aqiD` zc=r~@>pEX7@w%}YXEkrek5_l}wY^)yF7_`JUXyL81poJ??^5q;VoY%#yqPA~6=kHg zYraOhgno9e@^PLewyQEP&ofMz+8;r3JPQdqdQ;eHyE-J{C*6S77r^8q2A47WB zr)W=mE>O*m*J27`rkJX9{D02kYk4emqZ*d6Vsn(7+zqz%{`S!l=jk2Sh4s%{@>T}B zMp^%Wk*{qnN%rg=813NX6oxj7}=rrnf*72>=^H4M6YE0)| z)Siy)_+_p@R;P$%O-s`0rH=YV(ysidtuTL7$0J!@t?9!)|Q4+$J0 z^lcql-+o3J-ZVM~gA|_mqnsmRl>lgz?oV)u=Krg`{ZqIkDon!iA8~-^j|1G76?VX z9$@ahMzm5EfG8z5OWVLrgmzA~ke8m&y;ls@hP@3l{&bpWY;#gsxO^4)bix@^rFoS? z_wutTJmF_BF4=jcd-Z~qEZhtRG0zf-J@M=HZiWkgBWq7V))ML^wL+I?oli6_dms}P z#`@nZkkkpf;*aN@0!EZto7o{QNw^?$QGO({6bm5wB_ngjd$-dC7W|=KOu6J6FT%d; zr)#J*UlX$9rrSHXV>5(m-Bm>B_TiA_Ur&*_1#3N{CZE#mM4%yM9T@@^xs%1aoCY(U z8ff2{aOxRa6sXo%qm>OUy>01HJv)Yd1=0~Q2utvz>CD372Qv{7*UrZqdE0jh-e{%9wnxRvdsSBL^sj0kqM3$tQ0(i|8)`ga$Mdju?g4W~?`vjb@Y zG-Ri6V_70rfYnWm9I)~0UWpW!o+9U}iv4(cV%6~vGx@*o^0`#x74Miwi7gF72{ny) z*BKalc`J6gZcP#+y@v=cr~n);g6niBL~)JH%z!&X2e(I^n{R1RFrJKUO|iK>pZ3co z3eebk8}!gjqwY0JPEQj8+dI`|p{fw{od8vZA|k4AMYnXk$6{aV@4KXEmlj9%t5D@T z!~HZo!>Daz8ie*RkEn3_n`rmznXqWLN@$Fz%%pUQx{Fpht!wc?-*38pqMIA|j#n4W zPD#BI8C_*D4crv!CG#u=Q!}31G^?9&l1tP`IlO&d&p+wTO4qAsA_TmP?ztUVuEXnY zlR_|T=b}~8mk;GO%`J?Pq;^Zt{WR;S5FPS7%Cz+)9G4wfl_o|y* zuDeyi+fO*x{E8XmmVpNO{;(_(|J9qP&y1MAPG>zCG;K_AP!V)}fgcR(V)@C@{GWzs zCy}qO-DK{?uSTH|NBy1_jZSr;<{ON;%pxHWVm5yWm2mDD)f#;!h z4yxmDbRo8o z)xYm_M3A&t%-VLZaQDlO3*-%GvQvF>ub0zYGqd^->0vyUKGJ){_%Y!JS#VTyOLFnJ zy>HRgy#LN&n<&?);#4A?n~c>sx(Eqzlr6_cMOWJO; z!FQLC-yWlULwa67bA|PrdZ4x#hP@)r^+-9|BCo_Ss8LM9R6q8WS5iLm5N2wi!jdA` zMR}s~>u&<(%016MsC)?aEghH}K)kN5eo4`+<}Iuyv}>5~?=6gZ1{kaf=Hn72Y$K9z z?6fQDygD=AQ!;95wkuuQX01BZ;t9T&SmXkLvzAq=8DhGm6uKq?-5NZ@A;%Ch)X3^aI z49454Zye0&ZyNY!S{hsQD&AQXS>Jxi;Bj!gTf8w2)4pyKb6@$Or5*NZG_1pITirGq zo@XLWj*&;L#jxQmx$@)S7wiyaYvNv_wp*u|DrJd`*e(E;p%$%Lxenqn*Fo&93Nwgg+0AM zgG6ft3aw)JVB}Zl1U9~9lT;x~r#(Hh4-78?WNDycdpjPz#v?X|s^Tv#t}qPr#Gvor zcz*@0;4$p?UDkeTz0;xmc`vPZUrU|h-5WbB4J`g}TjH(_N==m6L?{*qPXTkgVT@0M)xPKxx;-YGZPx`CX+%nAjc0g(a^!-=;jSYV zO>{=LE!wXaQWzF8<>_CcOVSsOKdcz9_TDyq;iL$BYv9s<4Le7N_r}@>*xwPR1~M9d zS9YtU_$2Hsy_LkRI_q}yioRa`hn7IIsN2r(Ztvf(!@26lJm_zBW+Gp{s^`nTT6pa$ zdzGL!{w`2wYXm0Bipz?>AW*EWUUJ0YZT6_%jE~wq0nJGH_h}RgRrwpty^Je1*lm|R z=q>ig&gF%xokMA1%d>LGhCB;HAa!J?-j*brS^mbf7EgE_P1nwAN*xdUqmWh6M!|X# zSQ?Q`8@ea&gN{;jW1iEM23o(~(=WQHZy;25rvyW2znTI*&xMh)nF^g!wr$$JLLjhD z&{P^c3u|}t!a0py`Q`3*`nBhjAv#lTab}s7sX6Mm;Av%x^)+r5-TOF z?|f|dduxHWcD|OV=&mCfHyt~!T!O92Gk&0BWU#J9(-sJ)&gABbH>{| zLBom$#|Q9w0&|)b34Hgn~I^aLc-EZy|#J{lUXci#)*=9tUb^2I~@W-qO0f zHubb2-fZry(KyR8{#L(=e0d&;mVSK|gu7xNp4l#}yu4a9k}K#n#{P#E{Rgxmw}nQZ z4kk@GD4zBWw#9dy)?U~FFpg3l!z6z>9Ye1ffyB>!4yj)=N+KAY2fLcjW_>dcqS zO*rq<(p0MvDQk#*ID{n0u}lrkY3uF_Df<(ja;+HFri#>hcuHPFuV(q+l!!-lsBB4oui>{vw9C z_!CNTL{tyQ<-FEggbckjdl48;c(}V5D5f}Tfe7t@YJMvF?npvEFoH1BR5&y~dUOj#5ak$SI=y)Zy^6-qGD7_szmc8#NjQ@5>|5BgO7p;LM2c8l! zDCzGb5GsgW*W>FfynO!AA^{*u0sG=HN`@4JC`~OYp-=UD!!!TQbPr*bO;Iwyx+lN# zIH(wg$5XdR%Gu!4Y&l=vB_>XZmd<&do>z2HxzAm6nu3R_&8>>HZpG3k$GDgn^iD8{ zs{bAFBsQ>g_g-1D12y#%sI01`%0h#UEe&-6f0Mh!Wh+!(-ze zm&f39lo-_Pt?f&9%TiDQ@5UErVl8dsYVAGzTL95)$?rQP}KD z6O~O4@QfBml^j__29;XUU_bZfE_1eTmsO+02VfcZ7}baoM!s}iLwkTw!b*DGI(O5g zjy<76Rb{W0U&fJ- z>{q5!=zwi0fr81G+21O;TWo&eO7-|{{07*6!i6xf>HkZ7`u+_1h;~~ohI!(NqSvoy zAM+lY$h)EVBk)x}L=i(m;v^C`T99}>mlFuPfDs|hG&fbIfr(hFpXTfPF1JQectG{eIMn;npoMxr$!dNpmju>rBZi4P#V#CE~$2Z!WM;4~M4tOt9f+ z6Z)x%-r1%N-)n_iq%Q@%))moU3d|dmOc5dL0A3r=sG0Sa7VNo7u$ORTrR{9kBhqZd zKz4b+LJmZSznm3)q^0dsE^Ct#HKsD>qWqm-eU@;0F<=RTa9o7SVrx4JF~q=XP)hpT_%z%Hh{5FQSH zlP#SHBf6Q7mw6h6x%#?Z3X8u78JRPRn5B(_%UXLf=YCrcv%=_JacTs}qkow-4FkX1 z0>p^TQCn~MZCxt*kMLt@p7l!BqjUBV8u17WhV**i8g3@*ub-4E8iN?#%4P@Ied*=1 zs89(-uGTd7he=G6LHfCLx>9fBcXwgs)7*PT>OV8$w*jD;ma`&WUVURxRF?eGhkLbT zHKf~Jp(VAY9Qtm)U5gu{s7%Kh>i|@31A@aX*;zjd3ppSH=|3&;W1ys&+oZR5=7sJ> z>^Z!S?9jhI$g5Xwy{#1812$1FDnLiw{w*61Q{ei1j6S@ABoQ5Nn5c zRK9yaCwc_)h}-L_Sne{1XfIR_aX;YwL6K!|i)n>Ca${FCZv}&PsZ!O==?tfl z=7=SO22z0*aIqt8agH}mEDzhWPyi2Anqd4eC$jv_u0*E=?ANsZfJxiEMxhDN_bpmW z`dc-5qXeIUH-ulI7dXj(XwkF~_G44_lfxr9h43~DqVGh1R(G#y-jaB&zwL5)54ATp zx3Wu&FM0G(!-_)lhULpME;83HV*2@TKU>H67Y`!5k%>Z`ZBKX|#?B>(NoIZk4=>A7 z0&`JFox0c|L(+;^#cS^*KvvJ)*-5@l8tZJs!lPq{i)VbRmLIrY=l%XMW^)ZUPaYJq zzE~qjm&|8*QI%?$inlehB!fQ7!eze~k*ak(p@9>p1jd@*Bc`))&CHT=!!5W`Aa$+! zj_zKD1n^8g6&s zr*^tPdEqO2iFF_7M&bU;h61`Vi7T`&aA{hNZU3Gj`#cOfjetI;{xe8kz2PipE1lBj z;7Keoyv?>HfNol_a!FfxbaB5W6A%ph#7Kno-Hml!WnqB1X^OdEez%%9i0qBnTw{Ej z>TR-N(+g!FTD(bdO*G0$DP(Qaw(r-j{8DU*ghtx5OJUcF%R%W0epMesGaf4|hB-%; z;^2T*B*GU{rm;ETJiM{@SEYIBou>*SqZJE*t2Tn`Ohqr|)Kb?-LxwX&E@3MTD`B!@ z$L!Btt{b3Rw(NUnVlvjxMKh4wH@<_UkAIKTAA~^@b#1%d$nO>+`}=;GrdEs9qMl@O zc@L0=^=4!1iAP+6`vHI6UM!IsB>CBxgU0U{%meRGV#J_?`f;X&GXA*2LKcfalj}6E zCgbp;*M5rEVh)OZ{rulT_mj(FqQGVLS9hzt+)Qv7tT!WU2DbUw0*nW>%4J3p$R0$F~ClKUWaBm z(2i0`L6(AM%+HC3K$$OPy#rcxi~pB<^*!;>_}SUy)Q_PFvoTI8!t%Ye?RFafnqt=H zQmz*4BU^PrB5c*KSHf=2d1#=I_=T}x!f(kGZ%r6Qw6;YWBH3AVQ3zWM0G#0<(}qHS zESk8Cbz`}#ypbcrWUpBW-oKz|>Vl!pP$B7`GrvofCKa8%_HmxA?V3d%)QyvB87O;x zj4YCOax-x1PSDIm4f>w_Zhb9*_(xC>9u#%^X&N4Y;2img=sn7y7+7-TwS%~ z1`_0jfsLa3L)<5hW93Rx0it^6qdRk>(LtM#aC)z685}9jq$~N{^}NMu_&JH|6c%v% z8`bxGq4l;E2(7}*^pL(VZY7N+jWK9MrjtLV9vNe;Xt<{vTb4JF%eSg*a-G{$4~<}czRF|RlQ_@iM0|KN^C8>Tcj4M-qksk zm1kM*GI0-idt}IR$Ra#wN@9+u@p)#ZsYJQnxPF*RWag}XadJ+?&H9aqs-G7dDL-y- zTNQJn?+77L9A}smyDYy}K32lZ7wf7h)m9ee@F?!tw1deKL1|Sq=sA0_TIqd;6J@&$zM6`?uu1&$lBO692iv^_S)E z-7UuF8y($o)Q>J1%#BCipAV>5IT9bn678Oh@1=JsOI;6u~CyH($bp8m1lL2 z4v=2f3;wPyDQY*Xs=8zQW$NmHbpyVR8ZqewEqQj7m^9ap&e8+YhZDvy!$zrkb9XEJ zOKBYXB-uF4r84RiaVjW2*5!xxZLDc3zCA1 zo&~o7%v%)bmKYGG@C7#atMHLURy$m`^ssr0GC_K^=eo+Rv>z@e0J&Qz>UqXj@4?v( zv&>xFENNbs!V-uw#uk}Gv+4k0lv~k9?N^vPpP-uwjRpUMkp9u0&AJW15c0vkjp26# z{}eKFCun9C_apBP>E27y&myf-Dc`Fsc^{ybDqq~k_I^8}b;{oZUfC)1G4nXLmvPbL z?QjAao5({C&j-&3gqPhN!Jin>33EGV)q?hE%m)T0BC03gmeIbAcP`6IH%e{lF5s`4RMtkWq&rW6u(#h=N3SQ76tdL z5?oAGWw=1xT`r(}GUe39&x&q@@LP0c)V-%r6q?C_x&;3hNc~fp&s#%L3jh&+`wd5g zNLX>KG(f3O)pjk!r5#cxXP;fxzT#mxso0~6WW%4P#%jWy)a*R?Mc(KYaz;>3RGDKv zhLtoBc#>`yh|3=bR-e)`7?a>U9LVPd{$OY1WtB<1@zPu0D21hpXp+~{0htp_lPUIZ z80+;*y$TuEFF?zd@!eYQ*lkG`dl*D3TbJlinO)W-P{^<4vsXN%{w$czpL6)UG6Sis zp7?!ja=$trr0t$_1orCmEUR35*0b?s0x8g(E8sN843S()&GSQbDLCx^VN>D4R@r$S z;mlni(bP)>Ma+WvNcIwY_7mjq#%3bj^Ug)d@>wZ+yAkWdzQLx9n+N$78~5%U7&CKg za(o*rAdYLc{E`oIlXhJ>1B(auGFnxdv2;<%EaXbjv7iN~R+{#nls;bZSKw2??98@< zXmgd&70N0Q*~Vv)Z|T=#ENmxItgEQrpbsf6-*pJ84@pzTo#jI;8qL@gUl!)A&}Dhn zwS#{(>WW{=LQu~q3rzQoLB9FXNBu%pcl++r3hUwp6i*keAX7ELYO__|73!7WiMbUT zb<;6Yneny=H}27$k&5KNCX8#Er&`$dV<}nuH}54qVIF9%;evjNK&t$gdP}K#P9r)I z>P`ZlBIwU=sS?OPTc;J3$_Bo_)yCfbB0@~U)SL00GBV%pnLG021}}xin;gqQYxDVb z!}d*QZY#y_Hgla#HK78@IwD7mStr)vc{TLHs~y9#8{^vCsMp7W;yQ!Pr|vll^b*hG z?YK^9J+{hxyDhZZ7wa?}((o7@>hzLX^)F|BqV=Tn3w5b^6CUqB9~v&aJx}=lTf0(^ zE6A&VY$+>CJ_xfn=^*_$s>b!e88ENEB1B{Hm02JxV1nhCOPfu@%aj1~Qd?}X`W-k?nU6?vc`Pg%be zAqT$a!2X9`XIWx3u0&O>3u82-%#IFaTE(W8V=u$bhD2|E$uRTZ#0`dFHL5JE&&Rac zIhymm0=Yl>;IxL+nC{z_nbdQ+5n5EI*0H`(2zuLDyTs-aGlBuytcx8z$`7XlF=@k6 zGzB%$&Dy&iUt*iUyeJc-o0H?VnHKrYUECMDpaL2@2@hV)>G|x*_Ji(B>ysx7N&M|3 z3B{Ow%q`#1u1yO#0z!ccIx+^-)i8vy_h7UiNHGD>Wk8WW0Uo&+ToKj1AJAp|=2Ptg z+o>0oN#mr!l?x9RfksR(4@m?iX`{YI{P3HMu>ZEDsxcDK7SJcVGc#QdAvVc9k;tvW zj7;w-hzZoQKH8TTYlgUKac^_*dFCJhhwTC`im_ELm7K?z@a~R9Mzx~1o!rCY>!|j< zKhoRxARAg}5Ttakd&f1Sj>!EKC0avksGGx4PTCC6yb_E%T>Lb{voh5xAi_CU^{f$= z=CRgPP0zG}UFY%jddq?hdINF)2blenHQuHu30-KsHKO$W0F7BItr6bmVzvwo?9}`5*Avn7dAD$h_{hJA;I^f zSRVO0OgL%H+ZbdPSg}LXTu&jnLZ!8%3Ht7ukvjX{l)yJs6ev>(4qpMLO8ih3KG1&{q^>yf`t}T2kf}8 zOjGBB50Y4jj^0DZT;pqyb&%RMllPb{43kcXEn7xM6&v4zhv#~?nH)Do7ILvi5f+hv zMd}8x;;Bbgy^#p&I}5l|@GMlKG^NNlg!D}r6W(@PBrzNH9?`@_HGH7Wi$^tkavtm6 z4)N1Kfnh%5pP#ZGtxTsGYY~WG8tvqZ*A|fYu@B7~G`0%I&GRkT&S+OU{_+Ho@NZ9Z zTI34IAdB;;*Jgf+uzEut1noMZ8GrM%L^CQ>W5I2gjr`!p=a*j&?zDzoNAcCA2U3+< zz>tyg4-FQH45zWuH(|S|@)Yhqra6_Wg`!_YRWXE#i(i}ZdWFL?DW*)SvCPvn-Pj`3 zVQ`XV1tnj_QvDd=;lQ5c)LaFKOJCea(J$d_rdBRHe1+qB^^yClRSg}}yp{N#Q6fpv zw&~Ybatb?`FnKVZvRchM%l5)v>etsggx^*R4_@m4LQF>c$Af0K_^i*Ejr)vOlc3(95z!M zm8N!Un$2g_XMjjHI71Qdo+FmMSVFxUzS|Q}^KK{Mqc6?mdD`md)e1Ei5y7vuWL z%WvqtSormg(7v6PM6RbElaTy*$Tt5kPuugyvi+I0_+qrY@!A%aekMHZiM+Ko39t$>xb*Cu8I7^$iV&RCBTVKDj0Us3w1?=lwH`^@* zy~&Yid%G3i{E`oK)b?W4ok?ZzY=d+7oQPj6WIn^-?FSMgVf3u@Aiq`iUkXFOPL7dz zvf}{1ukS*{lX5FS;xob-Vc6VKR!gHnsq)64W&EoRg|*0BvUQ)l>=Z*-F4X6|7`=d{ z()e@?qQxx!>A5`G6K>*ORBvc^Dd0N4F<@1Qvw_9sd1VEPtTPXBmBOwRcz9~)kqzp0 zctZ?(0!YWgAg@Tv&gM1dNBpant{R) zYjn`j{Ema3v_<=yFe+yeWoH#jH%*>#l^yFhKqbG_Sx%ZGYf)7Jt(vQTn?9+1GEWy% zz)AyWE#kR#wUHFlC#dQ7PY6tx-uX0h=L(X$M>3&bQN7tanb8S~hoh^u8P}X(8|+8oCdk2O}5JC9$Uv9Hbq+1VwZxd%XB`arcv3;aO7E4Vb#>U)tb(*ltIga zo*x*N|CiHkn0yM5CdXtdrxnq!=xdD*n~2r=Xk{}w` zXSG7%@*D`coMct2-OM~~Wkc0eb@i5m3M55prL7J*oH1ZCtV{BM*;R#4PJjzRhkD^r z;f%gqNJc%FrJAx+y`Dv|tC-5R3tSmXY&@a)w9#6QZqaQyLi~+~S&NZNS)heTJL1K; zXUQs&r!w9_MGh3>eNR!cNVVAX1BRG;r%6Suf`1omu+W^$m{tKWmGoyd$gZ6M{6xWn zdfHhFxI5NHNvOV8I)y<$+D3ki-f6oT+0y&9nqJSV_VxYqAP=*EYI*?5@gm;)omak) z@|~CFPTEO;Vn!=vAqZKpJTDu;<9+Aw(IBajG@Xg!3V-^~PU0?(r~>OS-+L9|yF0%z z;h^$Qo50U-Gbn)_jDGJf539s&_C$B?!= z$4yaDnvblr=@loeYM`(#0TQP3I2|aVe?>PBdG%%vY@*D@WMnv_kIJLiKH*XIba8Pn z#m7af3ojU5HGSfm&ZObmONk5W$XdbzOM$7ny-&W+S}N(&?N8cH0@JN)KO?17?<>a( z-w6|{tLY{gg-(Qp2s|eXv1%ZQCGHWrJ8KNeP{NbE{B7rm0iI&$WTFPp zf-U(tDC(>Ev_VpM9GzDtf6wO)*#^SKxvm8A@Zp`)-kFLJwMZa|81o-vnwg0~EC#$? zQ6RHXwLDh{GFnC0)vw(;rCBEAkX1bPZ}&E~?F`%3TrHXzmX4lso+`=E5nZ!*tqe#_ zuRG7?FRxq4&d+HzU+k{k{N)V>*S&BipeRG5Zm>Q7!{x#R2D%WOWgBzBu*}xT*mRhz zmGS7+GnHPJrVjGDuq&_4;H9p!ydtdBs+#-AI!p8_!(RveBVXU+j(zdZ?de3E0=S&p z%<1*kGVFn%G?!8CpuWDrg`0S0-!E2kZ~NkHb$UfFv=#ekFHu&r^?;D~B)wc&yk z?ePNX4vHH|wCsRrur3qumqu0ABtIcMCzdo+31zkBew=phq}i3z(uCcrDGr_s0^9Vp zgb2??7Bj*_FhZ@;0n`PsF-Y6Zt88UbS#|@mc6b4}DL;@5^u99gjNg8H+j>fW713t; z@|YcP5t-;Y)I6LsHp$s~z_#I&ivvo{3mxER3iVDr$?yAD+=7OVbSh`+O(1rVqnxHa z?wIMGv@ApCRM_{8lzp-)g62E8;OL&@SQUqmW8w<0mM|aT^J42s;g^bG^OHftQdauf=Q#&LgTg z@)jrpdcDKp>?~^~tW~mil`7WIbb4cW5))&zM4A7r(aZAwe_4mV2>G zM~ibqUJ&%qwQ1y3%BOZ9cdkd6t2XmzGy(8Gpza^jda6tCNYIYf49D38Wx&jl;~~gU z5{xndz&N0X_xQVc#EhC!IMu1{-cNtT0&T4r4TD-|^{X^<%V^>&R` zcf=8w@YYT^fCw1-ftJ}LKd}PGdpnFq*7(p2B?#2Ci%!t1^qqNT)pM7h{82Vgv9LUj z%-J9U(>kN{aIPC!o6k3-`pHkD>$_=QRYyOx&ohry(-o941sfahbE@?C|A_r9`P|p`Aslz%&PgS#)0dHveFn;<%BeYV_0CLrhsS9kzlZ%oO#GohHBpCV zkFIvfzz#hvAAN29RYxOLv7M@$f- zrEUSypPBi827@#t-UTcNzI_W_AeeSqj!7$7Cq(`I)|C3~ml)Qs-`^c(*wbxuD#ZD7 zZcdCuEL}yJy@LXua&g^S*DbI0wzzbNon*-YwROcc=;Rj|$g%wHsrQUm!RNGteY`d212UNJ1kUR>}aBXKAgO5zCg`%P}J^hp0;xlmpCA1wF( zmN9dz>~E-l7T4wp9ie4O-JWP;;`sb_hgQCJ+m0(?xf-jKwe0J5dilv4_&#=-Ph&E2 zH0%LHNtq&aimQoUZykA}iQ`#(I9tCC&Rlx~PFxU5_JeR?D{Xnjb zghaBR@T=pG3INR*6k^c1>j<6JL*=)d?^04#%km%)JT2_vstghm(j4J4owxLvia-JF zr^fBob;eQG1{uq7;t!k*)5X-DacJrI@YM)zg{>r9%%-%O_A*Vbnt_3&hKiR(GV_cb zE$z5mwZ~m6Y@Ze4lY3nhO2@mp)bBT#q!rLxup;0sFF;*)M#>nARx&^4YQejUxBMm23b@ru@+t-NKr8ac|D|%L z{I&@n%9mM4yU=0Wk#*fs;_eIY1> zP^x-({tzu#7Ab-Bn@wQ2hZSA5CX8yDj>J(fm6}Rs0ITF?%~`^q6@{yWV|QG zXwTKo2kFMbm}%1QncO-|0|s_vN{G5v$bmS8%@Rb!iVO%^^SV?c_}a94)RBv$waD^V)D=?S0ISo z&)q*}Lozs%UVjaQv94(MYaXag>|To*^sww%yPd z-<9)7o5dg49Q_F*t>^aoa??Edz@a)sP^G3;%8vZjpX}>(jnNDGHSM%SWf06&s1AOUr%3e8A%UQ#Vi&C82>V-wF$5m@tWgsE?_WCSFShNVg21E zL_&S}5?AfZqjn5fom2{zGw#F4)Q&KR4uz{%Tqd^Y+>u@?`VbFVPcpc?IzC$kS z_FD~OGQdYor3U!Esx+qqby?Nl+8zVP(JLa7zoL$Lh%F|x4$O%#mSx@?V#v$=SQUM( zXU1q*dPv}G4NvifzI!Ft?1aedz8W(}PZ>3CAl(5>g;K+ste%%?a_J#UpM;T3XWxNX zvluVj(%_tc;V~ZDh$um_=SnC>X_|9gh39WTs~aYF9U9PoP)o z4;Ph6qrv=~!~GtkHKKV^jzOxCGQUB8#g~O03ilr8bgfAjgvne{iUh{%n5;2ZCWf(A2Az~$WqP92H~n;bV2_)j&|`=^$)SaQVU|?^Zd%prdw);Ir?WGmvV(&dpqJt zP;{*J;d<^$01w27^`g70E0YM`4KEk~=gt>l4 z;uwGZ``;fQ&RQglL5i_yUm3GdQWE7;(lD)%C540)QT>0VMf#I@&we0cE{;w zl#I8473hU>y7M@sjCWhaP$CSKM$x;pCiIJ{jDDdQ!`ZGE#Hn>uw`v}gn`Tu_T2X;+ z&$lUluO{KTt4mPAh9kaYK3(ugAY$1Mh-G;susOoi{G)tAiJO1G<9|!94b~W+Z#>!> z(Leu?a2T(%!5EuwEEGFcmtgC7`J{XbO3qSI;jzBefzDV#-b(T(R#v_Ow{n zIqR|Bs*Z3EW*KW$rUZEdyf5dwoMz<}xBTa=l(%T1wJu$Y_dc|0gR7Z#B*vq}{(z+Q zaP~-lD$*|f6~W!C%ki6DMh~Ok)YI{;=Zmnq^6Y66)c32T zz^p=53+S}xPaMWK8?$mlza|3xj&xOw=$yb7-8#ATg3DfA&Q$@$oL zrm;COjJ!5!(}vz8sg;5l4ATj=IP%fLH~KgaaxIv2^8DY47Yj2V*l@bYKQS;#$(n=( zPVVf)3!wRohGQ}j%zs8g8EPxJ<5+A$q-FUag!9a!t;MM0p&Zt=v>NoM0*C%DsSB;Z z^m5PSm~f|Uo-Ch@*x`KuAX7-J;9&e#@?)BUzO(=2Q-}8!=%+Ls-V%kWOQsr;K-pN4 zv|b4!(BOW~4pMsuJ5i`(GKROTV?pHQIdG2}t|Lw}XeR_>3D3S{Ea_K%M!F+Elb3Fv ze+)WitLdcTL3-t|F2$GGgDD#d2Tt_UpXN+JBJJqXPFE?s_3Z;`#ly}R3v(GH|GDss-Op9WSBk$2Se^1F z>ozT*t-@iuk$NdSWRry0xM-3kf;PzE$ia(f0z5<~L@@Bad{$p2|-(5(cxV z5#KjpmZ60SIvX`iLJRfd@XYDyoG=0X($e&#FDlE$* zzcE8KBvef;1|lI!pFJjw%({m~A!8mGa9nzv7l}F>;|-7Q=sGU6Vrm@*t=B z*tkvHfo{F}cdF>|uN(Av9m?*3*0P%9rHBI2tDnw_#_@{|cpKD-D1;EzFPqcZiI zgOCj|{{Pe7mH1Qjb^U!QnWDIeCi9rF5|vC*L?V|E!bOIAWeh1QgeYWIiIA~D$&fOH zLiEd2hG>#Vq-c(J?Tdc*`MuBkex5(zIiJt6)?Rz9wTH9M{+{igeU2@2P_X;W#)9lCYG+mb-J@rJ0E5LD89&9_6_olDN``aWtYJf1tW z>gkHzc4z!Lf_T43_qLVKd2_w1l6*j$=;tuZCh@pK)F-yxdskch%gPnmKyi9QZ-E_M-vt~ z4z{at4rFmE4ZhMJ9O+}eFj4Kh_x8s`VQN&fw4!0FdZS}evPDIN&NF@H2G7Qk_~gRf zrCC;?`er9CI9k{!^#793iU=l@uGP#N+xuG}Ze01Sm`s=0N~SNP&F)&=QES-CvKaKF zC8-*MHKkRJb^;vrlA=rI9+I^;>4~X2>i_j?@NJbQUq{!3Ffo(t{Npa;)ia+1RXsFJ zvy)CJ`d4tA9CHtgFJX!^4MU$F2Yj#m{FwT{D9s>TF`#f0!)Jrg`j)HW1K zddI8T-Z4t2D~?TP_o0RYv9*`aZLM#(=Lt^12qU=QF9Z zGv0gV>Nl;}v`Qyouk_=j#iX=d(a*$pN%NN56BoUbepX^(r}U^@mO$2d)Go~S+$Q3p z-SJ)8hsC6`GYihZiQ=2zvx^tFo#w*!$63y7vO2})f?)}M8qQ=F)2O)jY2xUN?eNv< zc$-~?&)=4qD3}(NGi_*1F3(E$tY-EY(dA-&E$VM{ld|=^sXv3ldK+)Ir=>4tp5F9d zSJe{Gy~1M1H^z2b)g310_^-UpvO5zWe--Z@E-OyB9HY-9I#foGI_n|oDRd&7jy6A^Ri53)^}DVJt+FA-gkzg zQ&$*G`YS7`=(ySy3M1nl?@JB`F251>m*d#gvL;1vztI*JwIW_-V*T1(gLl>MS%391 ze5mj9N-6pNg~IAe4#oZQfm*y;>O0DU%58q8B{aV37J-|Cg3}ELj)?gR7w2d-*^4rJ z#l^>TNMxRD3^U0u@>=V4IpdDS6LpsoX$|?3bvy^P4BdtHtD5rVN2^|4!GK67ShSeV z<($nAdmFJe;vqkkZE6dHx1fS9G34|&0N5>m@7gt=ol(&s^gQHm3%z8Slh}*Q#vAhv zihu1JzHJ`h|kKn z%|YZBJ%k4L@kxpeNU3k zkmUjC9L@}g%Nc_9+|w$S0Wzt#32^wmYEI6FV5xL_y$1&aIYX_vsx{^#}ttw`Q-%P8@WMO>FE z?r#jgBGbiw=1A7G+<1E~g=i5G-r*|)AL4rEXR1s*FYLEFXXR$s*ET+r2YT;~8h&co zWxaoWo}%szRqLyXf=_GU5o!E0tTIE9h?4nH&%rOjg8_%`PVb~n96sG#CALQ}8=j9F zS-L6E^{#=D(W>Oym`rA|($G8Szcko+jyfhkiDrK&u#vR>S)A^-LW}R}CS{Mqo(Xg8 z=(tefzm3(TB<0Pja9-a~2SR)6ZI+S(pSJ6-mtULtbg#_OT1RN#9@Dy4e0`fo2XYH# z(nu%SuNK6&iUl?gCsW+cw=3^ZH?<@eZc3L4j7@CSd7|R5#@Oj#AG7hIo7#@dgT{4S zsqgdb>m<*3=X+fg+%?NYXgVSEPQ+K_eGSY%5`Q+^u4az>Dz)g=%uMd;vX3^dkD@oq zCdjG}6%6M^)a%h6b%QU96fbfLeBbr;nC+L~svhZ~(CqA{o%y6aE_cecHw;P@+~)K? zAlMn|bDKGcb7;|}&7EAowR$&u^6NZzA=-F{|+f@@2+fvspoBlKdlFpZ+UnNWx8>5Y$-Uip(occ%Y+b> z64MhZr&C{(%+FT3PWE`=X<;f?af97pbXSgfGoNAm)IEKh2dwc?fIPHx|?IH~EpPh^asy-%jwelxQkK}cn1 zgz@?dLzGS5g9oeH<@9A@;A3~DO*)c6(vgoF%u)&% zzHoWki2ZZhvB=>S{(rsLee`YySMr3wmY=shDlVri+sdrZtV_M}QZ#~iKQQ?Bo9XLC zhn?EK9FVqe_$b<~*72D^?)5n>scG7t?pizAL_< zvOIl}orA{ZF5F{ek1*@qIjHFtFiE?IQxgv;`{#v|=&x7ly>889|4KV+0A zzSTLgW~HC;t1GDkje6ZY6WMVdajskrj)mSgby+Jr@>i!+jeWnfHTQHUB|)X{WB6Hd zisi+U2b=AB;?mdLO1&1N7IW*Za(nN!JuWsf2K$@gB`CBXckDA4If#_?_r3m5UDh}I z*x%YL)`rw>`n;exq&D=-`0(~{Pm0}OdgQwsqUDZl{nd%l)b z`YA+4@`h5eQ(l4^H<8_N2ZL`rr?ceuPxiU41#`0Iv(#6LSD7&)6;?#YUlv*u@_t#~Q{9A5 zPgC9b?jFeJOVzr*B=8KAOf5Wk!jZDP28PUP!o?>1DmaP$b8@{83ni0&<&_R36ovzO`U71E&O_B=~%PBAxyw*H%UEyjoAx36J)#@3P@ z?tUZi>mZ-#t*H{>$m6Sz#J7&0*;<^ITH9z2j}MMvUo92`g4|`@U*5nGzPT<@0n+{H?tvM}aPRX1``-*6w&Sw9@+}rM zGiR~NGn(%)$Y*JBRor}bq<0WLX2haLb|A`Fnyubf8Y6XBn89f|TaI*R<+RRCrkgi0>yCAdAD_gRUc|f`S58uY(b)9p2iS ztWyz&v09=G?3#!j;ww0t>6EVsDy;T3c~W9GOBw|)wV&qp3l*CU&{49 z4{Y7mr}2Ga$(Jg>phpTn_pghs%P48TSeiC{d`ZHoZI@)O2=_bvK9X>oJoM#R?l{v% z*(9YWUX;e4gJ+liAl0RQYdW<`{zkIVhgJ^K)JO)Kjlip9qM!7Q-E2A+8;S%U)DSq* zOCD{o+%qDUZTMMvYP;R%S)<=qtsXpH<BxpE2O#Vs{q{{63Q*ANOLXPbVqiWognz`|9G=X9q5O z&K`Rpmuay-y%+J@do!o>5Jz6Hpb#f z`vNvD*-RXHscU2GxI62oX*p+Kfuf zU~tPlzR_U!5W?_G;+tceCg9m(!po|&=cbJ~dk(J^M}*M}8Km~<8HbyrCD&#Dx+~Rj zZmp!x;f`Z&WnP_r?_$Kmj#8BkyNPRA!zT@Ln7K~B)3`4#O%|ByC#*OQZy7?IDijY7 zKe%Y!DSijZ!08UgcL=eSBLXau=(PWufQ*01L`Z|)_J7Na2rUut@v?J+to=T2v@${W z=a`QM<^J>dM>+qlT!IET-J&^)I+A_7L0aYL?(IX<&}xd~E}E}1Q#^(2Vh4GG6!JgY zt_12NM5?EG${r-Ud(Fo{elBFk1sQWQNPFzGqmtbnu?>?N#f9nw0@05`3rb&YQSw~g zDzcX&w!sI^XZQ7T7o3w9|MvE=cL%xF+sAw1gkYN+c76_u!0|yJb55ZF&E5$jCVUWx z24JtOB&Q&!sGuO|?qct+q`YA+Z|?7k*;x=aft5CajM!&iA^~UMMLn9D85x9S2r(l3 zhf#1+EA;>YF&%vWx<$C=_qT7@ec2&9Z+v%vD}sq=@8dP!%O`6fjgJ-HKD0A{Wo)wx z_F7??2o1w$1|ReO33E0_W{8gE6@j{0N>PO8^M~@jyBqxb%FQ7t*01O9Al% z5g~mP_&Ol4MJpj40vzXqOiP;s-#DKJA3`a}_$tNuPzE6xxFischtoJ7E^h_UDo95H z$M(W=Izk$7ylVSAcH@!tua|0oOwX2z(K)Zy9hrsenwguK|ufuM57|o^Tns z0uZ*x^$P=z+XHo>)n6UB3=q!8I&3clb}HUJ4jv%mLmvoYa1Oz@V?V+ZtVZ}JM<@d7 zu>#)mEFcJ>THvnnH$rRk!JQ1v*#cimya`<`3(bc%O4&oPZcsHSTmA`zD?(^}0(6}P z>1hC_)xb5wZ$F$a+0Ow$K*4LkKxQyBuZ^az2lc3)I04`o`@SLLOTY@?=EF zs}LdYD1`ia5b{q#C;(942=vFXy$FRY2Lj&{7ZD1(flzn>LJ^>koJA!0}Qbb1Xsy%wEbn@+Dwr{6)R*9Uq2 zTr$Ae_!r1H33)G}c--DfplYCSpg7Rs^LZX9cb;wlhv^D+0o4G(w1^%8-2r+A)C_bV zs2hC6fS(1z@lt`X?LDAcAnXI>b(k;s*Mp30vEM=*oWD>m&cik}Kv>3ge>^W^J6!I9FOIp8 zt^@fA5H1hva188+>xgwY7WQADg?8W=*arLHIxP6$Heg$9hy8Kv8X#OYj)nUKbDYLD zSjJ`Ieuwiw^TlZ#ALrv(3;l~Z?qBdFNC009{2!3Wxd`r}*jI5Ywj7Y9g$b_@9;tH>0AH(Qglu{N5}yMko(4EnLdXf?I(Gsjo&q?OMTl|}Adv!)$PbVRb@tUq=4Q0fE{W*YNB;f6Q zh)e*%%fsCr7y}T2wsu+NZSRaN7ILr=lt8=U?R?2Tb|70&9BCXxBt*ZA1Az0gJ9G#~ g{Bx{xnOiH0=KFYffOZwxj^>O5BUn#4Ev%>h2gz9WOaK4? diff --git a/src/components/OCRImageWrapper.vue b/src/components/OCRImageWrapper.vue new file mode 100644 index 0000000..7e77826 --- /dev/null +++ b/src/components/OCRImageWrapper.vue @@ -0,0 +1,198 @@ + + + + + diff --git a/src/plugins/copilotPlugin.ts b/src/plugins/copilotPlugin.ts index 8311f0d..7c29c02 100644 --- a/src/plugins/copilotPlugin.ts +++ b/src/plugins/copilotPlugin.ts @@ -1,13 +1,15 @@ import { Plugin, PluginKey, Selection } from '@milkdown/prose/state' import { $prose, $ctx, $markSchema } from '@milkdown/kit/utils' import { parserCtx, serializerCtx } from '@milkdown/kit/core' -import { Node as ProseNode, Slice } from '@milkdown/prose/model' -import type { Ctx } from '@milkdown/kit/core' +import { Node as ProseNode, Schema, Slice } from '@milkdown/prose/model' +import type { Ctx, MarkType } from '@milkdown/kit/core' +import type { Transaction } from '@milkdown/prose/model' import { Decoration, DecorationSet, type EditorView } from '@milkdown/prose/view' import { extractDocBlockContextFromMarkdown } from '../utils/docBlock.js' import { extractWebSearchContextFromMarkdown, WEB_SEARCH_NODE_TYPE } from '../utils/webSearch.js' import { getOcrCache, OCR_SIZE_LIMIT, extractTextFromOCR, buildOcrContextForDoc } from '../utils/ocrCache' import { isDocumentVisible } from '../composables/useVisibility.js' +import type { CopilotState, CopilotRuntime, GhostMarkType } from './copilotTypes.js' const COPILOT_PLUGIN_KEY = new PluginKey('milkdown-copilot') const DEBOUNCE_MS = 1000 @@ -112,13 +114,13 @@ function clearGhostText(view: EditorView): boolean { return true } -function getCursorBeforeGhostInsert(tr: any, from: number): number { +function getCursorBeforeGhostInsert(tr: Transaction, from: number): number { const mapped = tr.mapping.map(from, -1) return Math.max(0, Math.min(mapped, tr.doc.content.size)) } function insertParsedMarkdownSlice( - tr: any, + tr: Transaction, from: number, parsedDoc: ProseNode ): { from: number; to: number } | null { @@ -155,8 +157,8 @@ function createGhostDecorations(doc: ProseNode, from: number, to: number) { return DecorationSet.create(doc, decorations) } -function addGhostMarksToTextNodes(tr: any, from: number, to: number, markType: any) { - tr.doc.nodesBetween(from, to, (node: any, pos: number) => { +function addGhostMarksToTextNodes(tr: Transaction, from: number, to: number, markType: MarkType) { + tr.doc.nodesBetween(from, to, (node, pos) => { if (!node.isText || node.nodeSize <= 0) return true const start = Math.max(pos, from) @@ -269,7 +271,7 @@ async function insertGhostText(view: EditorView, suggestion: string, from: numbe } } -function insertPlainText(view: EditorView, suggestion: string, from: number, markType: any) { +function insertPlainText(view: EditorView, suggestion: string, from: number, markType: MarkType) { const tr = view.state.tr tr.insertText(suggestion, from) const endPos = from + suggestion.length @@ -283,8 +285,8 @@ function serializeRangeToMarkdown( doc: ProseNode, from: number, to: number, - schema: any, - serializer: any + schema: Schema, + serializer: (content: ProseNode) => string ): string { if (from >= to) return '' const slice = doc.slice(from, to) @@ -686,12 +688,22 @@ export function interruptCopilot(view: EditorView): void { export function checkSizeLimit(view: EditorView): { size: number; overLimit: boolean } { let size = view.state.doc.content.size view.state.doc.descendants((node) => { + // 文档块:统计 content 属性 if (node.type.name === 'doc_block' && node.attrs.content) { size += String(node.attrs.content).length } + // PRO 块:统计 instruction 属性 + if (node.type.name === 'pro_block' && node.attrs.instruction) { + size += String(node.attrs.instruction).length + } + // 网络搜索块:统计 content 属性 if (node.type.name === WEB_SEARCH_NODE_TYPE && node.attrs.content) { size += String(node.attrs.content).length } + // 图片节点:统计 alt 属性(ASR 转录文本常存于此) + if (node.type.name === 'image' && node.attrs.alt) { + size += String(node.attrs.alt).length + } }) return { size, overLimit: size > SIZE_LIMIT } } diff --git a/src/plugins/copilotTypes.ts b/src/plugins/copilotTypes.ts new file mode 100644 index 0000000..3bf5047 --- /dev/null +++ b/src/plugins/copilotTypes.ts @@ -0,0 +1,48 @@ +/** + * Shared TypeScript types for the copilot plugin. + */ + +import type { Ctx } from '@milkdown/kit/core' +import type { EditorView } from '@milkdown/prose/view' +import type { Transaction } from '@milkdown/prose/model' + +/** State tracked by the copilot plugin key */ +export interface CopilotState { + from: number + to: number + suggestion: string +} + +/** Runtime state associated with each editor view */ +export interface CopilotRuntime { + enabled: boolean + debounceTimer: ReturnType | null + abortController: AbortController | null + ctx: Ctx + requestSeq: number + docVersion: number +} + +/** Configuration for the copilot plugin */ +export interface CopilotConfig { + fetchSuggestion: (prefix: string, suffix: string, languageId: string, signal?: AbortSignal) => Promise + debounceMs?: number +} + +/** Result of checking document size limits */ +export interface SizeCheckResult { + size: number + overLimit: boolean +} + +/** Mark type from milkdown schema */ +export interface GhostMarkType { + create(): any +} + +/** Helper type for transaction operations */ +export type TransactionLike = { + mapping: { map(pos: number, bias: number): number; mapResult(pos: number, bias: number): { pos: number } } + doc: { content: { size: number }; resolve(pos: number): any; textBetween(from: number, to: number, blockSeparator string, leafText string): string } + tr: any +} diff --git a/src/plugins/webSearchBlockPlugin.ts b/src/plugins/webSearchBlockPlugin.ts index 968dbb1..07bc18b 100644 --- a/src/plugins/webSearchBlockPlugin.ts +++ b/src/plugins/webSearchBlockPlugin.ts @@ -31,6 +31,7 @@ interface WebSearchBlockConfig { languageId: string signal?: AbortSignal onEvent?: (event: string, data?: Record) => void + onDelta?: (data: { text: string }) => void }) => Promise<{ content: string; createdAt: string }> t: (key: string) => string showError: (message: string) => void @@ -397,6 +398,9 @@ class WebSearchBlockNodeView implements NodeView { this.setStage('queued') try { + // 用于累积流式 delta 内容,实现打字机效果 + let streamedContent = '' + const result = await this.config.fetchWebSearchStream({ ...payload, signal: this.abortController.signal, @@ -409,6 +413,33 @@ class WebSearchBlockNodeView implements NodeView { } this.setStage(event, String(data?.message || '')) }, + onDelta: (data) => { + if (this.destroyed || this.requestSeq !== requestSeq) return + const text = String(data?.text || '') + if (!text) return + streamedContent += text + this.updateAttrs({ content: streamedContent } + return + } + this.setStage(event, String(data?.message || '')) + }, + onDelta: (data) => { + if (this.destroyed || this.requestSeq !== requestSeq) return + const text = String(data?.text || '') + if (!text) return + streamedContent += text + this.updateAttrs({ content: streamedContent } + return + } + this.setStage(event, String(data?.message || '')) + }, + onDelta: (data) => { + if (this.destroyed || this.requestSeq !== requestSeq) return + const text = String(data?.text || '') + if (!text) return + streamedContent += text + this.updateAttrs({ content: streamedContent }) + }, }) if (this.destroyed || this.requestSeq !== requestSeq) return diff --git a/src/utils/api.js b/src/utils/api.js index c713f96..14af510 100644 --- a/src/utils/api.js +++ b/src/utils/api.js @@ -12,6 +12,9 @@ import { COMPRESS_STATUS_URL, JOB_LOAD_URL, } from './config.js' +import { safeString, stripTrailingSlashes } from './string.js' +import { parseSseEvent } from './sse.js' +import { safeFetch, buildHeaders, parseJsonResponse } from './fetch.js' import { useSettingsStore } from '../stores/settings' function generateRequestId() { @@ -22,32 +25,7 @@ function generateRequestId() { } function normalizeAbortReason(reason) { - if (typeof reason === 'string' && reason.trim()) { - return reason.trim().slice(0, 64) - } - return 'abort' -} - -function parseSseEvent(rawEvent) { - const lines = String(rawEvent || '').replace(/\r/g, '').split('\n') - let event = 'message' - const dataLines = [] - - for (const line of lines) { - if (!line) continue - if (line.startsWith('event:')) { - event = line.slice(6).trim() || 'message' - continue - } - if (line.startsWith('data:')) { - dataLines.push(line.slice(5).trimStart()) - } - } - - return { - event, - data: dataLines.join('\n'), - } + return safeString(reason).trim().slice(0, 64) || 'abort' } function createAbortError(message = 'Request aborted') { @@ -78,18 +56,15 @@ async function sendCancelRequest(cancelUrl, requestId, reason) { } } +const CANCEL_PATH_MAP = { + '/v1/pro/completions': '/v1/pro/completions/cancel', + '/v1/web-search': '/v1/web-search/cancel', + '/v1/completions': '/v1/completions/cancel', +} + function getCancelUrl(apiUrl) { - const normalized = String(apiUrl || '').replace(/\/+$/, '') - if (/\/v1\/pro\/completions$/i.test(normalized)) { - return normalized.replace(/\/v1\/pro\/completions$/i, '/v1/pro/completions/cancel') - } - if (/\/v1\/web-search$/i.test(normalized)) { - return normalized.replace(/\/v1\/web-search$/i, '/v1/web-search/cancel') - } - if (/\/v1\/completions$/i.test(normalized)) { - return normalized.replace(/\/v1\/completions$/i, '/v1/completions/cancel') - } - return `${normalized}/cancel` + const base = stripTrailingSlashes(apiUrl) + return CANCEL_PATH_MAP[base] || `${base}/cancel` } function buildCompletionBody(settings, prefix, suffix, languageId, extra = {}) { @@ -311,6 +286,7 @@ export async function fetchWebSearchStream(payload, apiUrl = WEB_SEARCH_URL) { signal, timeoutMs = WEB_SEARCH_FRONTEND_TIMEOUT_MS, onEvent, + onDelta, } = payload || {} const settings = useSettingsStore() @@ -341,6 +317,10 @@ export async function fetchWebSearchStream(payload, apiUrl = WEB_SEARCH_URL) { onEvent?.(String(data?.phase || ''), data) return } + if (event === 'delta') { + onDelta?.(data) + return + } if (event === 'error') { onEvent?.('error', data) } @@ -367,43 +347,28 @@ export async function fetchTTS(text, instruct = '', apiUrl = TTS_URL) { } export async function fetchTTSStatus(apiUrl = TTS_STATUS_URL) { - const res = await fetch(apiUrl, { - headers: API_KEY ? { 'X-API-Key': API_KEY } : {}, - credentials: 'include', + const res = await safeFetch(apiUrl, { + headers: buildHeaders({ 'Content-Type': 'application/json' }), }) - if (!res.ok) throw new Error(`TTS Status HTTP ${res.status}`) - return res.json() + return parseJsonResponse(res) } export async function fetchTTSConfig(apiUrl = TTS_CONFIG_URL) { - const res = await fetch(apiUrl, { - headers: API_KEY ? { 'X-API-Key': API_KEY } : {}, - credentials: 'include', + const res = await safeFetch(apiUrl, { + headers: buildHeaders({ 'Content-Type': 'application/json' }), }) - if (!res.ok) throw new Error(`TTS Config HTTP ${res.status}`) - return res.json() + return parseJsonResponse(res) } export async function submitCompress(content, docType = 'txt', apiUrl = COMPRESS_SUBMIT_URL) { - const headers = { - 'Content-Type': 'application/json', - } - if (API_KEY) { - headers['X-API-Key'] = API_KEY - } - const res = await fetch(apiUrl, { + const res = await safeFetch(apiUrl, { method: 'POST', - headers, - credentials: 'include', + headers: buildHeaders({ + 'Content-Type': 'application/json', + }), body: JSON.stringify({ content, docType }), }) - - if (!res.ok) { - const errorText = await res.text() - throw new Error(`压缩提交失败 HTTP ${res.status}: ${errorText}`) - } - - return res.json() + return parseJsonResponse(res) } export function pollCompressStatus(taskId, onStateChange, apiUrl = COMPRESS_STATUS_URL) { @@ -411,11 +376,9 @@ export function pollCompressStatus(taskId, onStateChange, apiUrl = COMPRESS_STAT const interval = setInterval(async () => { try { - const res = await fetch(`${apiUrl}?task_id=${encodeURIComponent(taskId)}`, { - headers: API_KEY ? { 'X-API-Key': API_KEY } : {}, - credentials: 'include', + const res = await safeFetch(`${apiUrl}?task_id=${encodeURIComponent(taskId)}`, { + headers: buildHeaders({ 'Content-Type': 'application/json' }), }) - if (!res.ok) { consecutiveErrors++ if (consecutiveErrors >= 5) { @@ -426,7 +389,7 @@ export function pollCompressStatus(taskId, onStateChange, apiUrl = COMPRESS_STAT } consecutiveErrors = 0 - const data = await res.json() + const data = await parseJsonResponse(res) onStateChange(data.status, data.content || '', data.message, data) if (['completed', 'error', 'cancelled'].includes(data.status)) { @@ -445,12 +408,11 @@ export function pollCompressStatus(taskId, onStateChange, apiUrl = COMPRESS_STAT } export async function fetchJobLoad(apiUrl = JOB_LOAD_URL) { - const res = await fetch(apiUrl, { - headers: API_KEY ? { 'X-API-Key': API_KEY } : {}, - credentials: 'include', + const res = await safeFetch(apiUrl, { + headers: buildHeaders({ 'Content-Type': 'application/json' }), }) if (!res.ok) { throw new Error(`Job Load HTTP ${res.status}`) } - return res.json() + return parseJsonResponse(res) } diff --git a/src/utils/config.js b/src/utils/config.js index f8285f0..52ac35a 100644 --- a/src/utils/config.js +++ b/src/utils/config.js @@ -9,6 +9,7 @@ export const PRO_FRONTEND_TIMEOUT_MS = Number(import.meta.env.VITE_PRO_FRONTEND_ export const WEB_SEARCH_URL = import.meta.env.VITE_WEB_SEARCH_URL || `${API_BASE_URL}/v1/web-search` export const WEB_SEARCH_FRONTEND_TIMEOUT_MS = Number(import.meta.env.VITE_WEB_SEARCH_FRONTEND_TIMEOUT_MS || 3660000) export const OCR_URL = import.meta.env.VITE_OCR_URL || `${API_BASE_URL}/v1/ocr` +export const OCR_CONFIG_URL = import.meta.env.VITE_OCR_CONFIG_URL || `${API_BASE_URL}/v1/ocr/config` export const CONVERT_URL = import.meta.env.VITE_CONVERT_URL || `${API_BASE_URL}/v1/convert` export const EXPORT_PDF_URL = import.meta.env.VITE_EXPORT_PDF_URL || `${API_BASE_URL}/v1/export/pdf` export const TTS_URL = import.meta.env.VITE_TTS_URL || `${API_BASE_URL}/v1/tts-asr/tts` diff --git a/src/utils/convert.js b/src/utils/convert.js index affb08a..6e84483 100644 --- a/src/utils/convert.js +++ b/src/utils/convert.js @@ -1,26 +1,6 @@ import { CONVERT_URL, ASR_URL } from './config.js' -function parseSseEvent(rawEvent) { - const lines = String(rawEvent || '').replace(/\r/g, '').split('\n') - let event = 'message' - const dataLines = [] - - for (const line of lines) { - if (!line) continue - if (line.startsWith('event:')) { - event = line.slice(6).trim() || 'message' - continue - } - if (line.startsWith('data:')) { - dataLines.push(line.slice(5).trimStart()) - } - } - - return { - event, - data: dataLines.join('\n'), - } -} +import { parseSseEvent } from './sse.js' async function consumeSseResult(res) { if (!res.ok) { diff --git a/src/utils/fetch.js b/src/utils/fetch.js new file mode 100644 index 0000000..e68b648 --- /dev/null +++ b/src/utils/fetch.js @@ -0,0 +1,54 @@ +/** + * Unified safeFetch wrapper for API requests. + * Handles authentication headers, credentials, and error mapping consistently across all endpoints. + */ + +import { API_KEY } from './config.js' + +/** Build headers for JSON API requests with optional X-API-Key */ +function buildHeaders(extra = {}) { + return API_KEY + ? { 'X-API-Key': API_KEY, ...extra } + : { ...extra } +} + +/** Parse JSON response from fetch result, handling non-OK status codes */ +async function parseJsonResponse(res) { + if (!res.ok) { + let message = `HTTP ${res.status}` + try { + const data = await res.json() + message = data.detail || data.error || message + } catch { + const text = await res.text() + if (text) message = text + } + throw new Error(message) + } + return res.json() +} + +/** + * Fetch wrapper that handles: + * - Authentication headers (X-API-Key) + * - Credentials (include cookies) + * - Error mapping (HTTP status → meaningful message) + */ +export async function safeFetch(url, options = {}) { + const headers = buildHeaders(options.headers || {}) + const res = await fetch(url, { + ...options, + headers, + credentials: 'include', + }) + + if (!res.ok) { + const errorText = await res.text() + throw new Error(`HTTP ${res.status}: ${errorText}`) + } + + return res +} + +/** Export helper functions for use in other modules */ +export { buildHeaders, parseJsonResponse } diff --git a/src/utils/ocrCache.js b/src/utils/ocrCache.js index a9cb419..0319813 100644 --- a/src/utils/ocrCache.js +++ b/src/utils/ocrCache.js @@ -1,16 +1,55 @@ const SIZE_LIMIT = 32 * 1024 export const IMAGE_SIZE_LIMIT = 100 * 1024 * 1024 +// ─── Status enum ─────────────────────────────────────────────── +export const OcrStatus = Object.freeze({ + PENDING: 'pending', + LOADING: 'loading', + SUCCESS: 'success', + FAILED: 'failed', +}) + +// ─── State cache (per-image-hash) ────────────────────────────── +// Map +const ocrStateCache = new Map() + +// ─── Legacy text cache (kept for backward compat) ────────────── const ocrCache = new Map() const imageHashCache = new Map() +// ─── Hash utilities ──────────────────────────────────────────── export async function calculateImageHash(imageBytes) { const hashBuffer = await crypto.subtle.digest('SHA-256', imageBytes) const hashArray = Array.from(new Uint8Array(hashBuffer)) return hashArray.map(b => b.toString(16).padStart(2, '0')).join('') } +// ─── State API (new) ─────────────────────────────────────────── +export function setOcrState(hash, status, text = '', error = '') { + ocrStateCache.set(hash, { + status, + text: typeof text === 'string' ? text : '', + error: typeof error === 'string' ? error : '', + updatedAt: Date.now(), + }) +} + +export function getOcrState(hash) { + return ocrStateCache.get(hash) || { status: OcrStatus.PENDING, text: '', error: '' } +} + +export function resetOcrState(hash) { + ocrStateCache.delete(hash) +} + +export function clearAllOcrState() { + ocrStateCache.clear() +} + +// ─── Legacy API (backward compat) ────────────────────────────── export function getOcrByHash(hash) { + const state = ocrStateCache.get(hash) + if (state && state.status === OcrStatus.SUCCESS) return state.text return imageHashCache.get(hash) || '' } @@ -38,6 +77,7 @@ export function clearAllOcrCache() { ocrCache.clear() } +// ─── Size utilities ──────────────────────────────────────────── export function calculateOcrSize(imageFilenames) { let total = 0 for (const name of imageFilenames) { @@ -54,12 +94,13 @@ export function checkSizeLimit(docTextSize, imageFilenames) { size: total, docSize: docTextSize, ocrSize: ocrSize, - overLimit: total > SIZE_LIMIT + overLimit: total > SIZE_LIMIT, } } export const OCR_SIZE_LIMIT = SIZE_LIMIT +// ─── Text extraction ─────────────────────────────────────────── export function extractTextFromOCR(ocrText, maxLen = 100) { if (!ocrText) return '' const match = ocrText.match(/TEXT:\s*([\s\S]*?)(?:KEY_DETAILS|LANGUAGE|SUMMARY|$)/i) @@ -68,14 +109,9 @@ export function extractTextFromOCR(ocrText, maxLen = 100) { return text.length > maxLen ? text.substring(0, maxLen) + '...' : text } +// ─── Context builder (for AI completion) ─────────────────────── const IMAGE_NODE_TYPES = new Set(['image', 'image-block', 'imageBlock']) -/** - * 从 ProseMirror doc 中提取 OCR 上下文,供 AI 补全使用。 - * @param {ProseNode} doc - ProseMirror document node - * @param {number} maxLen - OCR 文本最大长度 - * @returns {string} - */ export function buildOcrContextForDoc(doc, maxLen = 120) { const lines = [] diff --git a/src/utils/proAccept.js b/src/utils/proAccept.js index b3e2818..3948777 100644 --- a/src/utils/proAccept.js +++ b/src/utils/proAccept.js @@ -1,8 +1,6 @@ -const OUTER_FENCE_RE = /^(`{3,}|~{3,})[^\n]*\n([\s\S]*?)\n\1[ \t]*$/ +import { normalizeNewlines } from './string.js' -function normalizeNewlines(value = '') { - return String(value || '').replace(/\r\n?/g, '\n') -} +const OUTER_FENCE_RE = /^(`{3,}|~{3,})[^\n]*\n([\s\S]*?)\n\1[ \t]*$/ function unescapeLiteralNewlines(value = '') { const text = String(value || '') diff --git a/src/utils/sse.ts b/src/utils/sse.ts new file mode 100644 index 0000000..5e22cbb --- /dev/null +++ b/src/utils/sse.ts @@ -0,0 +1,37 @@ +/** + * Shared SSE (Server-Sent Events) parsing utilities. + * Previously duplicated in api.js and convert.js. + */ + +/** Single parsed SSE event */ +export interface SseEvent { + event: string + data: string +} + +/** + * Parse a raw SSE event chunk into { event, data }. + * Handles multi-line data fields and explicit event type overrides. + * + * @example + * parseSseEvent('event: result\ndata: {"ok":true}') + * // → { event: 'result', data: '{"ok":true}' } + */ +export function parseSseEvent(rawEvent: string): SseEvent { + const lines = String(rawEvent || '').replace(/\r/g, '').split('\n') + let event = 'message' + const dataLines: string[] = [] + + for (const line of lines) { + if (!line) continue + if (line.startsWith('event:')) { + event = line.slice(6).trim() || 'message' + continue + } + if (line.startsWith('data:')) { + dataLines.push(line.slice(5).trimStart()) + } + } + + return { event, data: dataLines.join('\n') } +} diff --git a/src/utils/string.ts b/src/utils/string.ts new file mode 100644 index 0000000..6fd5e8a --- /dev/null +++ b/src/utils/string.ts @@ -0,0 +1,27 @@ +/** + * Shared string utility functions. + * Extracted from repeated patterns across api.js, docBlock.js, proAccept.js, proBlock.js, webSearch.js. + */ + +/** + * Normalize line endings to Unix-style (\n). + * Handles \r\n (Windows), \r (old Mac), and \n (Unix). + */ +export function normalizeNewlines(value: string = ''): string { + return String(value || '').replace(/\r\n?/g, '\n') +} + +/** + * Safely coerce any value to a non-null string. + * Replaces the common `String(x || '')` pattern scattered across 20+ locations. + */ +export function safeString(value: unknown): string { + return String(value ?? '') +} + +/** + * Remove trailing slashes from a URL/path string. + */ +export function stripTrailingSlashes(value: string = ''): string { + return safeString(value).replace(/\/+$/, '') +} diff --git a/test_compress_doc.txt b/test_compress_doc.txt deleted file mode 100644 index 55d63e3..0000000 --- a/test_compress_doc.txt +++ /dev/null @@ -1,18 +0,0 @@ - -这是一份用于测试压缩功能的文档。 - -人工智能(Artificial Intelligence,简称 AI)是计算机科学的一个分支,它试图理解智能的本质,并生产出一种新的能以人类智能相似的方式做出反应的智能机器。 - -人工智能的研究包括机器人、语言识别、图像识别、自然语言处理和专家系统等。人工智能从诞生以来,理论和技术日益成熟,应用领域也不断扩大,可以设想未来人工智能带来的科技产品将会是人类智慧的容器。 - -人工智能可以对人的意识、思维的信息过程的模拟。人工智能不是人的智能,但能像人那样思考、也可能超过人的智能。 - -机器学习是人工智能的核心领域之一。它使用算法来解析数据、从中学习,然后对真实世界中的事件做出决策和预测。 - -深度学习是机器学习的一个子集,它使用多层神经网络来分析各种因素。深度学习的出现使得人工智能在许多领域取得了突破性进展,包括计算机视觉、语音识别和自然语言处理。 - -大型语言模型(LLM)是深度学习在自然语言处理领域的最新成果。它们通过在海量的文本数据上进行训练,学习到了语言的复杂模式和规律。 - -这些模型能够生成流畅的、符合语法的文本,回答问题,进行翻译,甚至创作诗歌和故事。 - -然而,人工智能的发展也带来了一些伦理和社会问题,比如隐私保护、算法偏见和就业影响等。 diff --git a/test_docblock_import.md b/test_docblock_import.md deleted file mode 100644 index cbdcd9c..0000000 --- a/test_docblock_import.md +++ /dev/null @@ -1,23 +0,0 @@ -```llm-file -doc_type: txt -doc_name: test_compress_doc.txt - -这是一份用于测试压缩功能的文档。 - -人工智能(Artificial Intelligence,简称 AI)是计算机科学的一个分支,它试图理解智能的本质,并生产出一种新的能以人类智能相似的方式做出反应的智能机器。 - -人工智能的研究包括机器人、语言识别、图像识别、自然语言处理和专家系统等。人工智能从诞生以来,理论和技术日益成熟,应用领域也不断扩大,可以设想未来人工智能带来的科技产品将会是人类智慧的容器。 - -人工智能可以对人的意识、思维的信息过程的模拟。人工智能不是人的智能,但能像人那样思考、也可能超过人的智能。 - -机器学习是人工智能的核心领域之一。它使用算法来解析数据、从中学习,然后对真实世界中的事件做出决策和预测。 - -深度学习是机器学习的一个子集,它使用多层神经网络来分析各种因素。深度学习的出现使得人工智能在许多领域取得了突破性进展,包括计算机视觉、语音识别和自然语言处理。 - -大型语言模型(LLM)是深度学习在自然语言处理领域的最新成果。它们通过在海量的文本数据上进行训练,学习到了语言的复杂模式和规律。 - -这些模型能够生成流畅的、符合语法的文本,回答问题,进行翻译,甚至创作诗歌和故事。 - -然而,人工智能的发展也带来了一些伦理和社会问题,比如隐私保护、算法偏见和就业影响等。 - -``` \ No newline at end of file