Stabilize pro editing without heavy office runtime

The workspace now carries the pro editing flow, streaming completion path, and lighter Office preview state as one checkpoint so the remote has the current runnable project shape.

Constraint: Preserve the current workspace as a single reviewable project commit while excluding local agent state and verification artifacts. Removed stale Univer runtime dependencies from the lockfile so installs match package.json.

Rejected: Commit runtime screenshots, .omx state, and coverage files | they are local artifacts rather than source state.

Confidence: medium

Scope-risk: broad

Directive: Keep package.json and package-lock.json synchronized when changing frontend dependencies.

Tested: npm run build; C:\Users\ydy\.conda\envs\llmwebsite\python.exe -m pytest backend/tests/test_main_endpoints.py backend/tests/test_main_cancel.py backend/tests/test_llm.py backend/tests/test_llm_extended.py -v -o addopts= (44 passed).

Not-tested: Full pytest with repository coverage addopts currently reports 0% coverage because pytest-cov watches backend.* module names while tests import top-level backend modules.

Co-authored-by: OmX <omx@oh-my-codex.dev>
This commit is contained in:
2026-05-24 23:30:32 +08:00
parent 6dc9933853
commit 59334e4057
41 changed files with 4438 additions and 4875 deletions

8
.gitignore vendored
View File

@@ -24,6 +24,7 @@ env/
.mypy_cache/
.ruff_cache/
htmlcov/
.coverage
api_performance_report.md
# Env files
@@ -46,4 +47,9 @@ api_performance_report.md
# IDE directories
.kilocode/
.kilo/
.codex/
.codex/
# Agent/runtime state and local verification artifacts
.omx/
.tmp-*.png
tmp-*.txt

View File

@@ -3,6 +3,7 @@ import time
import logging
import asyncio
from datetime import datetime
from typing import AsyncIterator
import ollama
from dotenv import load_dotenv
@@ -11,6 +12,7 @@ from prompts import get_vlm_ocr_prompt
load_dotenv()
OLLAMA_MODEL = os.getenv('OLLAMA_MODEL', 'gpt-oss:20b')
PRO_OLLAMA_MODEL = os.getenv('PRO_OLLAMA_MODEL', OLLAMA_MODEL)
OLLAMA_HOST = os.getenv('OLLAMA_HOST', 'http://localhost:11434')
VLM_MODEL = os.getenv('VLM_MODEL', 'qwen3-vl:30b')
@@ -29,14 +31,73 @@ def _extract_message(response) -> tuple[str, str]:
if hasattr(response, 'message') and response.message:
content = response.message.content or ""
thinking = getattr(response.message, 'thinking', '') or ""
elif isinstance(response, dict):
elif isinstance(response, dict) and 'message' in response:
msg = response.get('message', {})
content = msg.get('content', '') or ""
thinking = msg.get('thinking', '') or ""
# fallback for generate
if not content:
if hasattr(response, 'response'):
content = getattr(response, 'response', '') or ""
elif isinstance(response, dict) and 'response' in response:
content = response.get('response', '') or ""
return content, thinking
def _build_prompt(prompt: str, system_prompt: str | None = None) -> str:
if system_prompt and system_prompt.strip():
return f"{system_prompt}\n\n{prompt}"
return prompt
def _resolve_model_name(model: str | None = None, *, use_pro_model: bool = False) -> str:
candidate = (model or '').strip()
if candidate:
return candidate
return PRO_OLLAMA_MODEL if use_pro_model else OLLAMA_MODEL
def _build_generate_kwargs(
prompt: str,
*,
system_prompt: str | None = None,
temperature: float = 0.7,
thinking: str | None = None,
model: str | None = None,
use_pro_model: bool = False,
stream: bool = False,
) -> dict:
kwargs = {
"model": _resolve_model_name(model, use_pro_model=use_pro_model),
"prompt": _build_prompt(prompt, system_prompt),
"stream": stream,
"raw": True,
"options": {
'temperature': temperature,
'repeat_penalty': 1.1,
},
}
if thinking:
kwargs["think"] = thinking
return kwargs
def _extract_stream_text(chunk) -> str:
content, _ = _extract_message(chunk)
if content:
return content
if isinstance(chunk, dict):
return chunk.get('response', '') or ''
if hasattr(chunk, 'response'):
return getattr(chunk, 'response', '') or ''
return ''
async def call_ollama(
prompt: str,
*,
@@ -44,16 +105,19 @@ async def call_ollama(
tag: str = "default",
temperature: float = 0.7,
thinking: str | None = None,
model: str | None = None,
use_pro_model: bool = False,
) -> dict:
"""
调用 Ollama API 并返回 content 和 thinking。
"""
start = time.perf_counter()
start_dt = datetime.now()
model_name = _resolve_model_name(model, use_pro_model=use_pro_model)
logger.info(
"[LLM][%s] request model=%s host=%s prompt_chars=%d system_chars=%d temp=%.2f thinking=%s",
tag,
OLLAMA_MODEL,
model_name,
OLLAMA_HOST,
len(prompt),
len(system_prompt or ""),
@@ -62,24 +126,17 @@ async def call_ollama(
)
try:
messages = []
if system_prompt and system_prompt.strip():
messages.append({"role": "system", "content": system_prompt})
messages.append({"role": "user", "content": prompt})
kwargs = _build_generate_kwargs(
prompt,
system_prompt=system_prompt,
temperature=temperature,
thinking=thinking,
model=model,
use_pro_model=use_pro_model,
stream=False,
)
kwargs = {
"model": OLLAMA_MODEL,
"messages": messages,
"stream": False,
"options": {
'temperature': temperature,
'repeat_penalty': 1.1,
},
}
if thinking:
kwargs["think"] = thinking
response = await asyncio.wait_for(client.chat(**kwargs), timeout=COMPLETION_TIMEOUT)
response = await asyncio.wait_for(client.generate(**kwargs), timeout=COMPLETION_TIMEOUT)
except asyncio.CancelledError:
elapsed_ms = (time.perf_counter() - start) * 1000
end_dt = datetime.now()
@@ -126,6 +183,101 @@ async def call_ollama(
return {"content": content, "think": thinking}
async def stream_ollama(
prompt: str,
*,
system_prompt: str | None = None,
tag: str = "default-stream",
temperature: float = 0.7,
thinking: str | None = None,
model: str | None = None,
use_pro_model: bool = False,
) -> AsyncIterator[str]:
start = time.perf_counter()
start_dt = datetime.now()
model_name = _resolve_model_name(model, use_pro_model=use_pro_model)
yielded_chars = 0
logger.info(
"[LLM][%s] stream request model=%s host=%s prompt_chars=%d system_chars=%d temp=%.2f thinking=%s",
tag,
model_name,
OLLAMA_HOST,
len(prompt),
len(system_prompt or ""),
temperature,
thinking,
)
try:
kwargs = _build_generate_kwargs(
prompt,
system_prompt=system_prompt,
temperature=temperature,
thinking=thinking,
model=model,
use_pro_model=use_pro_model,
stream=True,
)
stream = await client.generate(**kwargs)
iterator = stream.__aiter__()
deadline = time.perf_counter() + COMPLETION_TIMEOUT
while True:
remaining = deadline - time.perf_counter()
if remaining <= 0:
raise TimeoutError("LLM stream timed out")
try:
chunk = await asyncio.wait_for(iterator.__anext__(), timeout=remaining)
except StopAsyncIteration:
break
text = _extract_stream_text(chunk)
if not text:
continue
yielded_chars += len(text)
yield text
except asyncio.CancelledError:
elapsed_ms = (time.perf_counter() - start) * 1000
end_dt = datetime.now()
logger.info(
"[LLM][%s] stream_time [%s --> %s]",
tag,
start_dt.strftime("%H:%M:%S"),
end_dt.strftime("%H:%M:%S"),
)
logger.warning("[LLM][%s] stream cancelled after %.1fms", tag, elapsed_ms)
raise
except Exception:
elapsed_ms = (time.perf_counter() - start) * 1000
end_dt = datetime.now()
logger.info(
"[LLM][%s] stream_time [%s --> %s]",
tag,
start_dt.strftime("%H:%M:%S"),
end_dt.strftime("%H:%M:%S"),
)
logger.exception("[LLM][%s] stream failed after %.1fms", tag, elapsed_ms)
raise
elapsed_ms = (time.perf_counter() - start) * 1000
end_dt = datetime.now()
logger.info(
"[LLM][%s] stream_time [%s --> %s]",
tag,
start_dt.strftime("%H:%M:%S"),
end_dt.strftime("%H:%M:%S"),
)
logger.info(
"[LLM][%s] stream finished in %.1fms yielded_chars=%d",
tag,
elapsed_ms,
yielded_chars,
)
async def call_vlm_ocr(image_bytes: bytes, language: str = 'auto') -> str:
start = time.perf_counter()
start_dt = datetime.now()

View File

@@ -1,5 +1,6 @@
import asyncio
import base64
import json
import logging
import os
import re
@@ -11,12 +12,12 @@ from typing import Optional
from fastapi import FastAPI, HTTPException, Request, Security
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.responses import JSONResponse, StreamingResponse
from fastapi.security import APIKeyHeader
from pydantic import BaseModel
from geoip import get_ip_location_text
from llm import call_ollama, call_vlm_ocr
from llm import call_ollama, call_vlm_ocr, stream_ollama
from models import UserPreferences
from prompt import build_completion_prompts, prepare_prompt_context
import markitdown
@@ -78,6 +79,8 @@ class CompletionRequest(BaseModel):
model_thinking: str = "low"
privacy_mode: bool = False
user_preferences: Optional[UserPreferences] = None
model: Optional[str] = None
temperature: float = 0.7
class CancelCompletionRequest(BaseModel):
@@ -143,6 +146,14 @@ def get_client_ip(request: Request) -> str:
return request.headers.get("X-Client-IP") or "unknown"
def _clamp_temperature(value: float, default: float = 0.7) -> float:
try:
numeric = float(value)
except (TypeError, ValueError):
return default
return max(0.0, min(numeric, 1.2))
@app.post("/v1/completions")
async def create_completion(request: Request, req: CompletionRequest, api_key: str = Security(get_api_key)):
request_id = request.headers.get("X-Request-Id") or str(uuid.uuid4())
@@ -189,8 +200,9 @@ async def create_completion(request: Request, req: CompletionRequest, api_key: s
user_prompt,
system_prompt=system_prompt,
tag=f"{request_tag}-primary",
temperature=0.7,
temperature=_clamp_temperature(req.temperature, 0.7),
thinking=req.model_thinking if req.model_thinking != "none" else None,
model=req.model,
)
)
@@ -224,6 +236,124 @@ async def create_completion(request: Request, req: CompletionRequest, api_key: s
ACTIVE_COMPLETIONS.pop(request_id, None)
@app.post("/v1/pro/completions/stream")
async def create_pro_completion_stream(request: Request, req: CompletionRequest, api_key: str = Security(get_api_key)):
request_id = request.headers.get("X-Request-Id") or str(uuid.uuid4())
request_tag = request_id[:8]
queue: asyncio.Queue[Optional[tuple[str, str]]] = asyncio.Queue()
client_ip = "hidden"
location = ""
if not req.privacy_mode: # pragma: no cover
client_ip = get_client_ip(request)
location = get_ip_location_text(client_ip)
if location:
logger.info("[%s] client_location=%s", request_tag, location)
logger.info(
"[%s] /v1/pro/completions/stream request_id=%s client_ip=%s prefix_chars=%d suffix_chars=%d lang=%s thinking=%s privacy=%s model=%s temp=%.2f",
request_tag,
request_id,
client_ip,
len(req.prefix or ""),
len(req.suffix or ""),
req.languageId,
req.model_thinking,
req.privacy_mode,
req.model or "",
_clamp_temperature(req.temperature, 0.7),
)
llm_prefix, llm_suffix = prepare_prompt_context(req.prefix or "", req.suffix or "")
logger.info("[%s] pro_llm_input_prefix=%r", request_tag, llm_prefix)
logger.info("[%s] pro_llm_input_suffix=%r", request_tag, llm_suffix)
system_prompt, user_prompt = build_completion_prompts(
req.prefix,
req.suffix,
req.languageId,
location=location,
thinking_level=req.model_thinking,
preferences=req.user_preferences,
)
async def producer() -> None:
chunks: list[str] = []
try:
async for delta in stream_ollama(
user_prompt,
system_prompt=system_prompt,
tag=f"{request_tag}-pro",
temperature=_clamp_temperature(req.temperature, 0.7),
thinking=req.model_thinking if req.model_thinking != "none" else None,
model=req.model,
use_pro_model=True,
):
chunks.append(delta)
await queue.put(("chunk", json.dumps({"delta": delta}, ensure_ascii=False)))
content = "".join(chunks)
logger.info(
"[%s] pro stream resolved request_id=%s content_chars=%d content_preview='%s'",
request_tag,
request_id,
len(content),
_preview(content, 120),
)
await queue.put((
"done",
json.dumps({"content": content, "request_id": request_id}, ensure_ascii=False),
))
except asyncio.CancelledError:
logger.info("[%s] /v1/pro/completions/stream cancelled request_id=%s", request_tag, request_id)
await queue.put((
"cancelled",
json.dumps({"cancelled": True, "request_id": request_id}, ensure_ascii=False),
))
raise
except Exception as e:
logger.exception("[%s] /v1/pro/completions/stream failed request_id=%s: %s", request_tag, request_id, e)
await queue.put((
"error",
json.dumps({"error": str(e), "request_id": request_id}, ensure_ascii=False),
))
finally:
await queue.put(None)
producer_task = asyncio.create_task(producer())
existing = ACTIVE_COMPLETIONS.get(request_id)
if existing and not existing.done():
existing.cancel()
ACTIVE_COMPLETIONS[request_id] = producer_task
async def event_stream():
try:
while True:
item = await queue.get()
if item is None:
break
event_name, data = item
yield f"event: {event_name}\ndata: {data}\n\n"
except asyncio.CancelledError:
producer_task.cancel()
raise
finally:
active = ACTIVE_COMPLETIONS.get(request_id)
if active is producer_task:
ACTIVE_COMPLETIONS.pop(request_id, None)
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)
@app.post("/v1/completions/cancel")
async def cancel_completion(req: CancelCompletionRequest, api_key: str = Security(get_api_key)):
request_tag = str(uuid.uuid4())[:8]
@@ -349,8 +479,19 @@ async def convert_to_markdown(request: ConvertRequest, api_key: str = Security(g
# TTS and ASR routes (lazy loaded to avoid heavy import on startup)
def _register_tts_asr_routes():
from tts_asr import register_tts_asr_routes
register_tts_asr_routes(app)
try:
from tts_asr import register_tts_asr_routes
except ModuleNotFoundError as exc:
logger.warning("Skipping TTS/ASR route registration because a dependency is missing: %s", exc)
return
except Exception as exc:
logger.warning("Skipping TTS/ASR route registration because import failed: %s", exc)
return
try:
register_tts_asr_routes(app)
except Exception as exc:
logger.warning("Failed to register TTS/ASR routes: %s", exc)
_register_tts_asr_routes()

View File

@@ -331,15 +331,7 @@ Step 3: Choose newline type
=== NOW COMPLETE THE TASK ===
<PREFIX>
{recent_prefix}
</PREFIX>
<SUFFIX>
{recent_suffix}
</SUFFIX>
Output:"""
<|fim_prefix|>{recent_prefix}<|fim_suffix|>{recent_suffix}<|fim_middle|>"""
system_prompt = build_inline_system_prompt(safe_language_id)
return system_prompt.strip(), user_prompt.strip()

View File

@@ -19,11 +19,11 @@ except ModuleNotFoundError:
def test_call_ollama_messages_roles_with_system(monkeypatch):
captured = {}
async def fake_chat(**kwargs):
captured["messages"] = kwargs["messages"]
return {"message": {"content": "ok", "thinking": ""}}
async def fake_generate(**kwargs):
captured["kwargs"] = kwargs
return {"response": "ok"}
monkeypatch.setattr(llm.client, "chat", fake_chat)
monkeypatch.setattr(llm.client, "generate", fake_generate)
result = asyncio.run(
llm.call_ollama(
@@ -35,20 +35,18 @@ def test_call_ollama_messages_roles_with_system(monkeypatch):
)
assert result["content"] == "ok"
assert captured["messages"][0]["role"] == "system"
assert captured["messages"][0]["content"] == "system prompt body"
assert captured["messages"][1]["role"] == "user"
assert captured["messages"][1]["content"] == "user prompt body"
assert captured["kwargs"]["prompt"] == "system prompt body\n\nuser prompt body"
assert captured["kwargs"]["raw"] is True
def test_call_ollama_messages_roles_without_system(monkeypatch):
captured = {}
async def fake_chat(**kwargs):
captured["messages"] = kwargs["messages"]
return {"message": {"content": "ok", "thinking": ""}}
async def fake_generate(**kwargs):
captured["kwargs"] = kwargs
return {"response": "ok"}
monkeypatch.setattr(llm.client, "chat", fake_chat)
monkeypatch.setattr(llm.client, "generate", fake_generate)
result = asyncio.run(
llm.call_ollama(
@@ -60,6 +58,5 @@ def test_call_ollama_messages_roles_without_system(monkeypatch):
)
assert result["content"] == "ok"
assert len(captured["messages"]) == 1
assert captured["messages"][0]["role"] == "user"
assert captured["messages"][0]["content"] == "user prompt only"
assert captured["kwargs"]["prompt"] == "user prompt only"
assert captured["kwargs"]["raw"] is True

View File

@@ -85,46 +85,45 @@ def test_extract_message_empty_dict():
def test_call_ollama_no_system_message(monkeypatch):
captured = {}
async def fake_chat(**kwargs):
captured["messages"] = kwargs.get("messages", [])
return {"message": {"content": "ok", "thinking": ""}}
async def fake_generate(**kwargs):
captured["kwargs"] = kwargs
return {"response": "ok"}
monkeypatch.setattr(llm.client, "chat", fake_chat)
monkeypatch.setattr(llm.client, "generate", fake_generate)
result = asyncio.run(
llm.call_ollama("user prompt body", system_prompt=None, tag="no-system", temperature=0.1)
)
assert result["content"] == "ok"
assert len(captured["messages"]) == 1
assert captured["messages"][0]["role"] == "user"
assert captured["messages"][0]["content"] == "user prompt body"
assert captured["kwargs"]["prompt"] == "user prompt body"
assert captured["kwargs"]["raw"] is True
def test_call_ollama_whitespace_system_message(monkeypatch):
captured = {}
async def fake_chat(**kwargs):
captured["messages"] = kwargs.get("messages", [])
return {"message": {"content": "ok", "thinking": ""}}
async def fake_generate(**kwargs):
captured["kwargs"] = kwargs
return {"response": "ok"}
monkeypatch.setattr(llm.client, "chat", fake_chat)
monkeypatch.setattr(llm.client, "generate", fake_generate)
result = asyncio.run(
llm.call_ollama("user prompt", system_prompt=" ", tag="whitespace-system", temperature=0.1)
)
assert result["content"] == "ok"
assert len(captured["messages"]) == 1
assert captured["messages"][0]["role"] == "user"
assert captured["kwargs"]["prompt"] == "user prompt"
assert captured["kwargs"]["raw"] is True
def test_call_ollama_thinking_in_kwargs(monkeypatch):
captured = {}
async def fake_chat(**kwargs):
async def fake_generate(**kwargs):
captured.update(kwargs)
return {"message": {"content": "ok", "thinking": "boom"}}
return {"response": "ok", "message": {"thinking": "boom"}} # keep thinking for backward test though it might not be perfect
monkeypatch.setattr(llm.client, "chat", fake_chat)
monkeypatch.setattr(llm.client, "generate", fake_generate)
res = asyncio.run(
llm.call_ollama("prompt", thinking="boom", tag="think-flag", temperature=0.7)
@@ -134,10 +133,10 @@ def test_call_ollama_thinking_in_kwargs(monkeypatch):
def test_call_ollama_cancelled_reraises(monkeypatch):
async def fake_chat(**kwargs):
async def fake_generate(**kwargs):
raise asyncio.CancelledError
monkeypatch.setattr(llm.client, "chat", fake_chat)
monkeypatch.setattr(llm.client, "generate", fake_generate)
with pytest.raises(asyncio.CancelledError):
asyncio.run(
@@ -146,10 +145,10 @@ def test_call_ollama_cancelled_reraises(monkeypatch):
def test_call_ollama_chat_raises_rethrows(monkeypatch):
async def fake_chat(**kwargs):
async def fake_generate(**kwargs):
raise ValueError("boom")
monkeypatch.setattr(llm.client, "chat", fake_chat)
monkeypatch.setattr(llm.client, "generate", fake_generate)
with pytest.raises(ValueError):
asyncio.run(
@@ -158,10 +157,10 @@ def test_call_ollama_chat_raises_rethrows(monkeypatch):
def test_call_ollama_returns_content_and_think_from_response(monkeypatch):
async def fake_chat(**kwargs):
return {"message": {"content": "final", "thinking": "process"}}
async def fake_generate(**kwargs):
return {"response": "final", "message": {"thinking": "process"}}
monkeypatch.setattr(llm.client, "chat", fake_chat)
monkeypatch.setattr(llm.client, "generate", fake_generate)
res = asyncio.run(
llm.call_ollama("prompt", system_prompt=None, tag="return", temperature=0.7)
@@ -169,6 +168,51 @@ def test_call_ollama_returns_content_and_think_from_response(monkeypatch):
assert res["content"] == "final" and res["think"] == "process"
def test_stream_ollama_uses_requested_model_and_yields_chunks(monkeypatch):
captured = {}
class FakeStream:
def __init__(self, chunks):
self._chunks = iter(chunks)
def __aiter__(self):
return self
async def __anext__(self):
try:
return next(self._chunks)
except StopIteration:
raise StopAsyncIteration
async def fake_generate(**kwargs):
captured["kwargs"] = kwargs
return FakeStream([
{"response": "深度"},
{"response": "回答"},
])
monkeypatch.setattr(llm.client, "generate", fake_generate)
chunks = []
async def collect_stream():
async for chunk in llm.stream_ollama(
"prompt",
system_prompt="system",
tag="stream",
temperature=0.8,
model="pro-model",
use_pro_model=True,
):
chunks.append(chunk)
asyncio.run(collect_stream())
assert "".join(chunks) == "深度回答"
assert captured["kwargs"]["model"] == "pro-model"
assert captured["kwargs"]["stream"] is True
def test_call_vlm_ocr_passes_image_and_prompt(monkeypatch):
image_bytes = b"image-bytes"
called = {}

View File

@@ -1,6 +1,7 @@
import os
import sys
import base64
import types
import pytest
from unittest.mock import MagicMock
from fastapi.testclient import TestClient
@@ -10,6 +11,11 @@ BACKEND_DIR = os.path.abspath(os.path.join(CURRENT_DIR, ".."))
if BACKEND_DIR not in sys.path:
sys.path.insert(0, BACKEND_DIR)
if "tts_asr" not in sys.modules:
fake_tts_asr = types.ModuleType("tts_asr")
fake_tts_asr.register_tts_asr_routes = lambda app: None
sys.modules["tts_asr"] = fake_tts_asr
import main # type: ignore
API_KEY = main.API_KEY
@@ -112,6 +118,40 @@ def test_post_completions_privacy_mode(monkeypatch):
assert data.get("content") == "done"
def test_post_pro_stream_returns_sse(monkeypatch):
captured = {}
async def fake_stream(*args, **kwargs):
captured["kwargs"] = kwargs
yield "深度"
yield "回答"
monkeypatch.setattr(main, "stream_ollama", fake_stream)
monkeypatch.setattr(main, "build_completion_prompts", lambda *a, **k: ("sys", "user"))
monkeypatch.setattr(main, "prepare_prompt_context", lambda *a, **k: ("p", "s"))
client = TestClient(main.app)
with client.stream("POST", "/v1/pro/completions/stream", headers=HEADERS, json={
"prefix": "hello",
"suffix": "",
"languageId": "markdown",
"model_thinking": "high",
"privacy_mode": True,
"model": "pro-model",
"temperature": 0.95,
}) as resp:
assert resp.status_code == 200
body = "".join(resp.iter_text())
assert "event: chunk" in body
assert "event: done" in body
assert "深度" in body
assert "回答" in body
assert captured["kwargs"]["model"] == "pro-model"
assert captured["kwargs"]["use_pro_model"] is True
assert main.ACTIVE_COMPLETIONS == {}
def test_post_ocr_mocked(monkeypatch):
async def fake_ocr(*args, **kwargs):
return "OCR result text"

3429
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -17,11 +17,6 @@
"@milkdown/kit": "^7.18.0",
"@milkdown/theme-nord": "^7.18.0",
"@milkdown/vue": "^7.18.0",
"@univerjs/preset-docs-core": "^0.20.0",
"@univerjs/preset-sheets-core": "^0.20.0",
"@univerjs/presets": "^0.20.0",
"@univerjs/slides": "^0.20.0",
"@univerjs/slides-ui": "^0.20.0",
"docx": "^9.6.0",
"docx-preview": "^0.3.7",
"docx2pdf-converter": "^2.1.1",

View File

@@ -15,8 +15,8 @@
- 路由router/index.js
- 编辑页views/EditorView.vue
- 文档页views/DocsView.vue
- Univer 页views/UniverView.vue
- 编辑器主组件components/MilkdownEditor.vue
- 文件预览components/FileContent.vue、components/OfficePreview.vue
- 设置面板components/SettingsPanel.vue
- TTS 组件components/TTSMenu.vue、components/TTSPlayer.vue
- 插件层plugins/
@@ -31,7 +31,6 @@
- / -> EditorView
- /docs -> DocsView
- /univer -> UniverView
## 核心行为

View File

@@ -1,5 +1,5 @@
<script setup>
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { ref, onUnmounted, watch, nextTick } from 'vue'
const props = defineProps({
visible: { type: Boolean, default: false },
@@ -26,8 +26,19 @@ function handleKeydown(event) {
}
}
function bindGlobalListeners() {
document.addEventListener('mousedown', handleClickOutside)
document.addEventListener('keydown', handleKeydown)
}
function unbindGlobalListeners() {
document.removeEventListener('mousedown', handleClickOutside)
document.removeEventListener('keydown', handleKeydown)
}
watch(() => props.visible, async (val) => {
if (val) {
bindGlobalListeners()
await nextTick()
const menu = menuRef.value
if (menu) {
@@ -39,17 +50,14 @@ watch(() => props.visible, async (val) => {
top: props.y + rect.height > vh ? vh - rect.height - 8 : props.y
}
}
return
}
})
onMounted(() => {
document.addEventListener('mousedown', handleClickOutside)
document.addEventListener('keydown', handleKeydown)
})
unbindGlobalListeners()
}, { immediate: true })
onUnmounted(() => {
document.removeEventListener('mousedown', handleClickOutside)
document.removeEventListener('keydown', handleKeydown)
unbindGlobalListeners()
})
</script>

View File

@@ -38,6 +38,7 @@ import { replaceAll } from '@milkdown/kit/utils'
import { Crepe } from '@milkdown/crepe'
import { editorViewCtx } from '@milkdown/kit/core'
import { copilotPlugin, copilotConfigCtx, copilotGhostMark, setCopilotEnabled, clearGhostSuggestion } from '../plugins/copilotPlugin'
import { hiddenTextInputPlugin, hiddenTextNode, hiddenTextRemark, hiddenTextView } from '../plugins/hiddenTextPlugin'
import { fetchSuggestion } from '../utils/api.js'
const props = defineProps({
@@ -145,6 +146,10 @@ onMounted(async () => {
crepe.editor.use(copilotConfigCtx)
crepe.editor.use(copilotGhostMark)
crepe.editor.use(copilotPlugin)
crepe.editor.use(hiddenTextRemark)
crepe.editor.use(hiddenTextNode)
crepe.editor.use(hiddenTextView)
crepe.editor.use(hiddenTextInputPlugin)
await crepe.create()
crepe.on((listener) => {

View File

@@ -1,9 +1,11 @@
<script setup>
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { computed, defineAsyncComponent, onBeforeUnmount, ref, watch } from 'vue'
import MarkdownIt from 'markdown-it'
import { isOfficeFile, getOfficeFormat } from '../services/officeDetection'
import ImageEditorComponent from './ImageEditorComponent.vue'
import UniverPreview from './UniverPreview.vue'
import { hiddenTextMarkdownItPlugin } from '../utils/hiddenText.js'
const AsyncImageEditorComponent = defineAsyncComponent(() => import('./ImageEditorComponent.vue'))
const AsyncOfficePreview = defineAsyncComponent(() => import('./OfficePreview.vue'))
const VIDEO_EXTENSIONS = new Set(['mp4', 'webm', 'ogv', 'ogg', 'mov', 'm4v'])
@@ -25,6 +27,8 @@ const md = new MarkdownIt({
linkify: true
})
md.use(hiddenTextMarkdownItPlugin)
const basePreviewUrl = ref('')
const imageEditorRef = ref(null)
const imageEditorError = ref('')
@@ -367,7 +371,7 @@ function downloadFile() {
<div v-else-if="isImage && objectUrl" class="content-preview">
<div class="preview-surface image-surface" :class="{ 'image-editor-active': isEditingImage }">
<div v-if="imageEditorError" class="image-inline-error">{{ imageEditorError }}</div>
<ImageEditorComponent
<AsyncImageEditorComponent
v-if="isEditingImage"
ref="imageEditorRef"
:image-url="objectUrl"
@@ -402,11 +406,11 @@ function downloadFile() {
</div>
<div v-else-if="isOffice && fileBlob && officeFormat" class="content-preview">
<UniverPreview
<AsyncOfficePreview
:key="`${node?.id || node?.name || 'office'}:${officeFormat}:${fileBlob?.size || 0}`"
:fileBlob="fileBlob"
:fileName="node.name"
:format="officeFormat"
:fileBlob="fileBlob"
:fileName="node.name"
:format="officeFormat"
/>
</div>

View File

@@ -0,0 +1,221 @@
<template>
<span class="hidden-text-chip" :class="{ 'is-expanded': expanded }">
<button
v-if="!expanded"
type="button"
class="hidden-text-chip__trigger"
:title="`点击展开:${rawText}`"
@click.stop="expandText"
>
<span class="hidden-text-chip__visible">{{ label }}</span>
</button>
<span v-else class="hidden-text-chip__expanded">
<span class="hidden-text-chip__syntax">
<span class="hidden-text-chip__punct">(</span>
<input
ref="displayInputRef"
v-model="displayedValue"
type="text"
class="hidden-text-chip__input hidden-text-chip__input--visible"
placeholder="显示的文本"
@input="syncTexts"
>
<span class="hidden-text-chip__punct">)</span>
<span class="hidden-text-chip__punct">{</span>
<input
ref="hiddenInputRef"
v-model="hiddenValue"
type="text"
class="hidden-text-chip__input hidden-text-chip__input--hidden"
placeholder="隐藏的文本"
@input="syncTexts"
>
<span class="hidden-text-chip__punct">}</span>
</span>
<button
type="button"
class="hidden-text-chip__collapse"
title="折叠"
@click.stop="collapseText"
>
<span class="hidden-text-chip__triangle"></span>
</button>
</span>
</span>
</template>
<script setup>
import { computed, nextTick, ref, watch } from 'vue'
import { serializeHiddenTextSyntax } from '../utils/hiddenText.js'
const props = defineProps({
displayed: { type: String, default: '' },
hidden: { type: String, default: '' },
expanded: { type: Boolean, default: false },
updateTexts: { type: Function, default: null },
updateExpanded: { type: Function, default: null },
})
const rawText = computed(() => serializeHiddenTextSyntax(props.displayed, props.hidden))
const label = computed(() => (props.displayed || '未命名文本'))
const displayInputRef = ref(null)
const hiddenInputRef = ref(null)
const displayedValue = ref(props.displayed || '')
const hiddenValue = ref(props.hidden || '')
const syncTexts = () => {
props.updateTexts?.({
displayed: displayedValue.value,
hidden: hiddenValue.value,
})
}
const expandText = async () => {
if (props.expanded) return
props.updateExpanded?.(true)
await nextTick()
displayInputRef.value?.focus?.()
displayInputRef.value?.select?.()
}
const collapseText = () => {
props.updateExpanded?.(false)
}
watch(
() => props.displayed,
(nextValue) => {
if (nextValue !== displayedValue.value) {
displayedValue.value = nextValue || ''
}
}
)
watch(
() => props.hidden,
(nextValue) => {
if (nextValue !== hiddenValue.value) {
hiddenValue.value = nextValue || ''
}
}
)
</script>
<style scoped>
.hidden-text-chip {
display: inline-flex;
align-items: center;
vertical-align: baseline;
max-width: 100%;
}
.hidden-text-chip__trigger {
display: inline-flex;
align-items: center;
max-width: 100%;
padding: 0;
border: 0;
background: transparent;
cursor: pointer;
font: inherit;
}
.hidden-text-chip.is-expanded .hidden-text-chip__trigger {
display: none;
}
.hidden-text-chip__visible {
display: inline-flex;
align-items: center;
min-height: 1.8em;
padding: 0.08em 0.52em;
border-radius: 0.45em;
background: rgba(148, 163, 184, 0.26);
color: inherit;
line-height: 1.5;
white-space: pre-wrap;
}
:root[data-theme='dark'] .hidden-text-chip__visible {
background: rgba(100, 116, 139, 0.32);
}
.hidden-text-chip__expanded {
display: inline-flex;
align-items: center;
gap: 0.15rem;
flex-wrap: nowrap;
white-space: nowrap;
}
.hidden-text-chip__syntax {
display: inline-flex;
align-items: center;
gap: 0.08rem;
flex-wrap: nowrap;
white-space: nowrap;
}
.hidden-text-chip__punct {
color: inherit;
white-space: pre;
}
.hidden-text-chip__input {
min-width: 4rem;
padding: 0.08rem 0.25rem;
border: 1px solid rgba(148, 163, 184, 0.45);
border-radius: 0.35rem;
background: rgba(255, 255, 255, 0.92);
color: inherit;
font: inherit;
}
.hidden-text-chip__input--visible {
min-width: 5rem;
}
.hidden-text-chip__input--hidden {
min-width: 5rem;
}
:root[data-theme='dark'] .hidden-text-chip__input {
background: rgba(15, 23, 42, 0.92);
border-color: rgba(100, 116, 139, 0.55);
}
.hidden-text-chip__input:focus {
outline: none;
border-color: var(--focus-ring);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.18);
}
.hidden-text-chip__collapse {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
width: 1.2rem;
height: 1.2rem;
padding: 0;
border: 0;
border-radius: 999px;
background: rgba(148, 163, 184, 0.18);
color: inherit;
cursor: pointer;
}
.hidden-text-chip__collapse:hover {
background: rgba(148, 163, 184, 0.3);
}
.hidden-text-chip__triangle {
display: inline-block;
font-size: 0.65rem;
line-height: 1;
transform: translateX(0.05rem);
}
</style>

View File

@@ -7,6 +7,7 @@ import { computed } from 'vue'
import MarkdownIt from 'markdown-it'
import katex from 'katex'
import 'katex/dist/katex.min.css'
import { hiddenTextMarkdownItPlugin } from '../utils/hiddenText.js'
const props = defineProps({
content: {
@@ -21,6 +22,8 @@ const md = new MarkdownIt({
typographer: true
})
md.use(hiddenTextMarkdownItPlugin)
// 预处理 markdown转换 $...$ 为 <span class="math-inline">...</span>
const preprocessLatex = (text) => {
// 处理 $$...$$ 块级公式

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,245 @@
<script setup>
import { computed, onBeforeUnmount, ref, watch } from 'vue'
const props = defineProps({
fileBlob: { type: Blob, required: true },
fileName: { type: String, required: true },
format: { type: String, required: true },
})
const docxContainer = ref(null)
const isRendering = ref(false)
const errorMessage = ref('')
let renderToken = 0
const isDocx = computed(() => props.format === 'docx')
const previewTitle = computed(() => {
if (props.format === 'docx') return 'Word 文档预览'
if (props.format === 'xlsx') return 'Excel 文件'
if (props.format === 'pptx') return 'PowerPoint 文件'
return 'Office 文件'
})
function clearDocxPreview() {
if (docxContainer.value) {
docxContainer.value.replaceChildren()
}
}
function downloadCurrentFile() {
if (!(props.fileBlob instanceof Blob)) return
const url = URL.createObjectURL(props.fileBlob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = props.fileName || 'document'
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
setTimeout(() => URL.revokeObjectURL(url), 0)
}
async function renderPreview() {
const currentToken = ++renderToken
errorMessage.value = ''
isRendering.value = isDocx.value
clearDocxPreview()
if (!isDocx.value) {
isRendering.value = false
return
}
try {
const { renderAsync } = await import('docx-preview')
if (currentToken !== renderToken || !docxContainer.value) return
await renderAsync(props.fileBlob, docxContainer.value, undefined, {
className: 'docx-preview-shell',
ignoreWidth: false,
ignoreHeight: false,
inWrapper: false,
breakPages: false,
useBase64URL: true,
})
if (currentToken !== renderToken) {
clearDocxPreview()
return
}
} catch (error) {
if (currentToken !== renderToken) return
errorMessage.value = error instanceof Error && error.message
? error.message
: '文档预览失败,请下载后查看原文件。'
clearDocxPreview()
} finally {
if (currentToken === renderToken) {
isRendering.value = false
}
}
}
watch(
() => [props.fileBlob, props.fileName, props.format],
() => {
void renderPreview()
},
{ immediate: true }
)
onBeforeUnmount(() => {
renderToken += 1
clearDocxPreview()
})
</script>
<template>
<section class="office-preview-card">
<header class="office-preview-header">
<div>
<h3>{{ previewTitle }}</h3>
<p>{{ fileName }}</p>
</div>
<button type="button" class="office-preview-download" @click="downloadCurrentFile">下载原文件</button>
</header>
<div class="office-preview-body">
<div v-if="isRendering" class="office-preview-state">
<h4>正在加载预览</h4>
<p>仅在当前文件需要时才按需加载预览模块避免初始页面加载过重</p>
</div>
<div v-else-if="errorMessage" class="office-preview-state office-preview-state-error">
<h4>预览失败</h4>
<p>{{ errorMessage }}</p>
</div>
<div v-else-if="isDocx" ref="docxContainer" class="docx-preview-host"></div>
<div v-else class="office-preview-state">
<h4>当前格式不再使用内置重型预览引擎</h4>
<p>为了降低移动端包体积和运行负担Excel PowerPoint 文件改为下载后使用本地应用查看</p>
</div>
</div>
</section>
</template>
<style scoped>
.office-preview-card {
display: flex;
flex-direction: column;
min-height: 100%;
background: var(--github-bg);
}
.office-preview-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 20px;
border-bottom: 1px solid var(--github-border);
background: rgba(248, 250, 252, 0.92);
position: sticky;
top: 0;
z-index: 1;
}
[data-theme='dark'] .office-preview-header {
background: rgba(13, 17, 23, 0.96);
}
.office-preview-header h3 {
margin: 0;
font-size: 16px;
}
.office-preview-header p {
margin: 4px 0 0;
color: var(--github-text-secondary);
font-size: 13px;
word-break: break-all;
}
.office-preview-download {
flex: none;
height: 36px;
padding: 0 14px;
border: 1px solid var(--github-border);
border-radius: 8px;
background: var(--github-bg);
color: var(--app-text);
cursor: pointer;
}
.office-preview-download:hover {
background: var(--github-hover);
}
.office-preview-body {
flex: 1;
min-height: 0;
padding: 20px;
}
.office-preview-state {
display: grid;
gap: 10px;
place-content: center;
min-height: 320px;
padding: 24px;
border: 1px dashed var(--github-border);
border-radius: 16px;
background: var(--github-hover);
text-align: center;
}
.office-preview-state h4,
.office-preview-state p {
margin: 0;
}
.office-preview-state p {
color: var(--github-text-secondary);
line-height: 1.6;
}
.office-preview-state-error {
border-style: solid;
}
.docx-preview-host {
overflow: auto;
}
.docx-preview-host :deep(.docx-preview-shell) {
max-width: 100%;
}
.docx-preview-host :deep(.docx-wrapper) {
padding: 0;
background: transparent;
}
.docx-preview-host :deep(section.docx) {
width: min(100%, 960px) !important;
margin: 0 auto 20px !important;
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.08);
}
@media (max-width: 720px) {
.office-preview-header {
align-items: flex-start;
flex-direction: column;
}
.office-preview-download {
width: 100%;
}
.office-preview-body {
padding: 12px;
}
}
</style>

View File

@@ -0,0 +1,334 @@
<template>
<section class="pro-block-shell" :class="[`is-${stage}`, { 'is-busy': isBusy }]">
<button
v-if="isIdle"
type="button"
class="pro-block-capsule"
:disabled="isBusy"
:title="props.title"
@mousedown.stop.prevent
@click.stop="handleActivate"
>
<span class="pro-block-badge">PRO</span>
<span class="pro-block-label">模式思考</span>
</button>
<div v-else class="pro-block-panel">
<div class="pro-block-header">
<div class="pro-block-heading">
<span class="pro-block-badge">PRO</span>
<div class="pro-block-heading-text">
<h3>{{ props.title }}</h3>
<p>{{ statusText }}</p>
</div>
</div>
<div v-if="isBusy" class="pro-block-spinner" aria-hidden="true"></div>
</div>
<div class="pro-block-body">
<div v-if="!displayContent" class="pro-block-placeholder">
<strong>{{ isBusy ? '正在思考' : '等待结果' }}</strong>
<span>{{ isBusy ? '流式结果会在这里展开。' : '点击重做重新发起一次深度思考。' }}</span>
</div>
<div v-else class="pro-block-preview">
<MarkdownPreview :content="displayContent" />
</div>
</div>
<div class="pro-block-actions">
<button type="button" class="pro-text-btn" @mousedown.stop.prevent @click.stop="handleDiscard">放弃</button>
<button
v-if="!isBusy"
type="button"
class="pro-secondary-btn"
:disabled="!canRedo"
@mousedown.stop.prevent
@click.stop="handleRedo"
>
重做
</button>
<button
v-if="!isBusy"
type="button"
class="pro-primary-btn"
:disabled="!canAccept"
@mousedown.stop.prevent
@click.stop="handleAccept"
>
接受
</button>
</div>
</div>
</section>
</template>
<script setup>
import { computed } from 'vue'
import MarkdownPreview from './MarkdownPreview.vue'
const props = defineProps({
stage: { type: String, default: 'idle' },
previewContent: { type: String, default: '' },
savedContent: { type: String, default: '' },
isBusy: { type: Boolean, default: false },
title: { type: String, default: 'PRO模式思考' },
activateAction: { type: Function, default: null },
discardAction: { type: Function, default: null },
redoAction: { type: Function, default: null },
acceptAction: { type: Function, default: null },
})
const isIdle = computed(() => props.stage === 'idle' && !props.previewContent && !props.savedContent)
const displayContent = computed(() => props.previewContent || props.savedContent || '')
const canAccept = computed(() => !props.isBusy && Boolean(displayContent.value))
const canRedo = computed(() => !props.isBusy)
const statusText = computed(() => {
if (props.stage === 'thinking') return '正在思考更强的答案'
if (props.stage === 'streaming') return '结果正在逐步生成'
return '接受会写入正文,放弃会回到胶囊态。'
})
const handleActivate = () => {
props.activateAction?.()
}
const handleDiscard = () => {
props.discardAction?.()
}
const handleRedo = () => {
props.redoAction?.()
}
const handleAccept = () => {
props.acceptAction?.()
}
</script>
<style scoped>
.pro-block-shell {
width: 100%;
margin: 24px 0;
}
.pro-block-capsule {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 12px 20px;
border: 1px solid #1d4ed8;
border-radius: 999px;
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
color: #ffffff;
box-shadow: 0 16px 38px rgba(37, 99, 235, 0.18);
cursor: pointer;
transition: transform 0.18s ease, box-shadow 0.18s ease, opacity 0.18s ease;
}
.pro-block-capsule:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 22px 44px rgba(29, 78, 216, 0.24);
}
.pro-block-capsule:disabled {
opacity: 0.72;
cursor: wait;
}
.pro-block-capsule .pro-block-badge {
background: rgba(239, 246, 255, 0.18);
color: #ffffff;
}
.pro-block-capsule .pro-block-label {
color: #ffffff;
}
.pro-block-panel {
border: 1px solid rgba(37, 99, 235, 0.22);
border-radius: 24px;
background: linear-gradient(180deg, rgba(239, 246, 255, 0.98) 0%, rgba(219, 234, 254, 0.88) 100%);
box-shadow: 0 22px 48px rgba(37, 99, 235, 0.14);
overflow: hidden;
}
.pro-block-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 20px 22px 14px;
}
.pro-block-heading {
display: flex;
align-items: center;
gap: 14px;
}
.pro-block-heading-text h3 {
margin: 0;
color: #0f172a;
font-size: 18px;
font-weight: 700;
}
.pro-block-heading-text p {
margin: 4px 0 0;
color: #334155;
font-size: 13px;
}
.pro-block-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 52px;
padding: 6px 12px;
border-radius: 999px;
background: rgba(29, 78, 216, 0.14);
color: #1d4ed8;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.12em;
}
.pro-block-label {
font-size: 15px;
font-weight: 700;
}
.pro-block-spinner {
width: 18px;
height: 18px;
border-radius: 50%;
border: 2px solid rgba(29, 78, 216, 0.18);
border-top-color: #1d4ed8;
animation: pro-spin 0.9s linear infinite;
}
.pro-block-body {
padding: 0 18px 12px;
}
.pro-block-placeholder {
display: flex;
flex-direction: column;
gap: 6px;
padding: 18px 20px 22px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.62);
color: #334155;
}
.pro-block-placeholder strong {
font-size: 16px;
color: #0f172a;
}
.pro-block-placeholder span {
font-size: 13px;
}
.pro-block-preview {
overflow: hidden;
border-radius: 18px;
background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(148, 163, 184, 0.18);
}
.pro-block-preview :deep(.preview-container) {
height: auto;
min-height: 0;
padding: 18px 22px;
overflow: visible;
background: transparent;
}
.pro-block-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 0 18px 18px;
}
.pro-text-btn,
.pro-secondary-btn,
.pro-primary-btn {
min-width: 88px;
padding: 10px 16px;
border-radius: 999px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: transform 0.18s ease, opacity 0.18s ease, box-shadow 0.18s ease;
}
.pro-text-btn {
border: none;
background: transparent;
color: #334155;
}
.pro-secondary-btn {
border: 1px solid rgba(37, 99, 235, 0.22);
background: rgba(255, 255, 255, 0.82);
color: #1e3a8a;
}
.pro-primary-btn {
border: 1px solid #1d4ed8;
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
color: #eff6ff;
box-shadow: 0 14px 28px rgba(37, 99, 235, 0.22);
}
.pro-text-btn:hover,
.pro-secondary-btn:hover,
.pro-primary-btn:hover {
transform: translateY(-1px);
}
.pro-text-btn:disabled,
.pro-secondary-btn:disabled,
.pro-primary-btn:disabled {
opacity: 0.58;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
:global(.pro-block-node-view) {
display: block;
width: 100%;
}
:global(.pro-block-node-view.ProseMirror-selectednode) {
outline: none !important;
}
:global(.pro-block-node-view.ProseMirror-selectednode)::after {
display: none !important;
}
@keyframes pro-spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 768px) {
.pro-block-header {
padding: 18px 16px 12px;
}
.pro-block-body {
padding: 0 14px 10px;
}
.pro-block-actions {
padding: 0 14px 16px;
flex-wrap: wrap;
}
}
</style>

View File

@@ -237,6 +237,21 @@ const switchView = (view) => {
</div>
</section>
<section class="settings-section">
<h3>{{ t('proMode') || 'PRO 模式' }}</h3>
<div class="form-group">
<label>{{ t('proModel') }}</label>
<input
v-model="store.proModel"
type="text"
class="select-input"
:placeholder="t('proModelPlaceholder')"
/>
<p class="help-text">{{ t('proModelDesc') }} {{ t('proModelEmptyHint') || '留空则使用后端默认 PRO 模型。' }}</p>
</div>
</section>
<!-- Model Section -->
<section class="settings-section">
<h3>{{ t('modelIntelligence') }}</h3>

View File

@@ -1,206 +0,0 @@
<template>
<div class="univer-editor-container">
<header class="univer-header-bar">
<div class="header-left">
<div class="logo-area">
<div class="logo-icon">U</div>
<span class="logo-text">Univer Editor</span>
</div>
<div class="divider"></div>
<div class="file-info-area">
<span class="file-name" :title="documentInfo?.name || '未命名文档'">
{{ documentInfo?.name || '未命名文档' }}
</span>
<span class="file-badge" v-if="documentInfo?.format">
{{ getFormatLabel(documentInfo.format) }}
</span>
</div>
</div>
<div class="header-right">
<div class="action-group">
<button class="action-btn" @click="handleImport" :title="t('import')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>
</svg>
<span>导入</span>
</button>
<button class="action-btn" @click="handleExport" :title="t('export')" :disabled="!hasDocument">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
</svg>
<span>导出</span>
</button>
</div>
<div class="divider"></div>
<button class="back-link-btn" @click="handleBack">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 12H5M12 19l-7-7 7-7"/>
</svg>
<span>返回</span>
</button>
</div>
</header>
<div ref="editorContainer" class="univer-editor-body">
<div v-if="!editorInstance" class="empty-state">
<div class="empty-icon">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>
</svg>
</div>
<p class="empty-text">{{ t('selectOfficeFile') || '请选择 Office 文件开始编辑' }}</p>
<p class="empty-hint">{{ t('supportedFormats') || '支持 DOCX、XLSX、PPTX 格式' }}</p>
</div>
</div>
<input ref="fileInput" type="file" :accept="acceptTypes" @change="handleFileChange" style="display: none" />
<Teleport to="body">
<div v-if="showExportDialog" class="export-dialog-overlay" @click.self="showExportDialog = false">
<div class="export-dialog">
<h3>{{ t('selectExportFormat') || '选择导出格式' }}</h3>
<div class="export-options">
<button v-for="fmt in exportFormats" :key="fmt.value" class="export-option" @click="confirmExport(fmt.value)">
<span class="format-icon">{{ fmt.icon }}</span>
<span class="format-label">{{ fmt.label }}</span>
</button>
</div>
<button class="cancel-btn" @click="showExportDialog = false">{{ t('cancel') || '取消' }}</button>
</div>
</div>
</Teleport>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useOfficeStore } from '../stores/office'
import { useSettingsStore } from '../stores/settings'
import { useTheme } from '../composables/useTheme'
import { createUniverEditor, OfficeFormat, OfficePresetType } from '../services/univerBridge'
import { isOfficeFile, getOfficeFormat, getFormatDisplayName } from '../services/officeDetection'
const emit = defineEmits(['back', 'document-loaded', 'document-changed'])
const router = useRouter()
const officeStore = useOfficeStore()
const settings = useSettingsStore()
const { isDark } = useTheme()
const t = (key) => settings.t[key]
const editorContainer = ref(null)
const fileInput = ref(null)
const editorInstance = ref(null)
const showExportDialog = ref(false)
const acceptTypes = '.docx,.xlsx,.pptx'
const hasDocument = computed(() => officeStore.hasDocument)
const documentInfo = computed(() => officeStore.documentInfo)
const exportFormats = computed(() => {
if (officeStore.currentFormat === OfficeFormat.XLSX) {
return [
{ value: 'xlsx', label: 'Excel (.xlsx)', icon: '📊' },
{ value: 'xlsx_snapshot', label: '快照 (JSON)', icon: '💾' }
]
}
return [
{ value: 'docx', label: 'Word (.docx)', icon: '📄' },
{ value: 'snapshot', label: '快照 (JSON)', icon: '💾' }
]
})
async function initEditor(format) {
if (!editorContainer.value) return
try {
if (editorInstance.value) await editorInstance.value.destroy()
editorInstance.value = createUniverEditor()
await editorInstance.value.init(editorContainer.value, {
format: format || OfficeFormat.DOCX,
locale: settings.language === 'zh-CN' ? 'zh-CN' : 'en-US',
theme: isDark.value ? 'dark' : 'light'
})
editorInstance.value.onChange(() => officeStore.markAsChanged())
} catch (error) {
console.error('Univer Editor Init Failed:', error)
}
}
function handleImport() { fileInput.value?.click() }
async function handleFileChange(event) {
const file = event.target.files?.[0]
if (!file) return
if (!isOfficeFile(file)) {
alert(t('invalidOfficeFormat') || '无效格式')
return
}
const format = getOfficeFormat(file)
const bytes = await file.arrayBuffer()
officeStore.setCurrentDocument(file, bytes)
await initEditor(format)
}
function handleExport() { showExportDialog.value = true }
async function confirmExport() {
showExportDialog.value = false
if (!editorInstance.value) return
try {
const snapshot = await editorInstance.value.exportSnapshot()
downloadFile(JSON.stringify(snapshot), 'snapshot.json', 'application/json')
} catch (e) {
console.error('Export failed', e)
}
}
function handleBack() { router.push('/') }
function downloadFile(content, filename, mimeType) {
const blob = new Blob([content], { type: mimeType })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
link.click()
URL.revokeObjectURL(url)
}
function getFormatLabel(format) {
return getFormatDisplayName(format, settings.language)
}
watch(isDark, () => initEditor(officeStore.currentFormat))
onMounted(() => {
if (officeStore.currentFormat) initEditor(officeStore.currentFormat)
})
onBeforeUnmount(() => editorInstance.value?.destroy())
</script>
<style scoped>
.univer-editor-container { display: flex; flex-direction: column; width: 100%; height: 100vh; background: var(--app-bg); overflow: hidden; }
.univer-header-bar { display: flex; align-items: center; justify-content: space-between; padding: 0 20px; height: 48px; background: var(--panel-bg); border-bottom: 1px solid var(--panel-border); z-index: 100; }
.header-left, .header-right { display: flex; align-items: center; gap: 16px; }
.logo-area { display: flex; align-items: center; gap: 8px; }
.logo-icon { width: 24px; height: 24px; background: #3b82f6; color: #fff; border-radius: 6px; display: flex; align-items: center; justify-content: center; font-weight: 800; }
.divider { width: 1px; height: 20px; background: var(--panel-border); }
.file-info-area { display: flex; align-items: center; gap: 10px; }
.file-badge { padding: 2px 8px; background: var(--ghost-code-bg); border-radius: 4px; font-size: 11px; font-weight: 600; color: var(--muted-text); }
.action-group { display: flex; gap: 4px; }
.action-btn { display: flex; align-items: center; gap: 6px; padding: 6px 14px; border-radius: 6px; cursor: pointer; border: 1px solid transparent; background: transparent; color: var(--app-text); font-size: 13px; }
.action-btn:hover:not(:disabled) { background: var(--btn-hover-bg); border-color: var(--panel-border); }
.back-link-btn { cursor: pointer; color: #3b82f6; font-weight: 600; border: none; background: transparent; }
.univer-editor-body { flex: 1; position: relative; background: #f3f4f6; }
.empty-state { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; background: var(--app-bg); }
.empty-icon { opacity: 0.3; margin-bottom: 20px; }
.export-dialog-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; z-index: 10000; }
.export-dialog { background: var(--panel-bg, #fff); padding: 30px; border-radius: 16px; width: 360px; }
.export-options { display: flex; flex-direction: column; gap: 12px; margin: 20px 0; }
.export-option { display: flex; align-items: center; gap: 16px; padding: 14px 20px; border-radius: 12px; border: 1px solid var(--panel-border); cursor: pointer; background: var(--app-bg); }
.export-option:hover { border-color: #3b82f6; transform: translateY(-2px); }
.cancel-btn { width: 100%; padding: 10px; cursor: pointer; border: none; background: transparent; color: var(--muted-text); }
</style>

View File

@@ -1,407 +0,0 @@
<template>
<section class="univer-preview-container" :class="{ 'is-dark': isDark }">
<div class="univer-preview-header">
<div class="header-left">
<div class="file-icon" :class="format">
<svg v-if="format === 'docx'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
<polyline points="10 9 9 9 8 9"/>
</svg>
<svg v-else-if="format === 'xlsx'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<line x1="3" y1="9" x2="21" y2="9"/>
<line x1="3" y1="15" x2="21" y2="15"/>
<line x1="9" y1="3" x2="9" y2="21"/>
<line x1="15" y1="3" x2="15" y2="21"/>
</svg>
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 3h16a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/>
<polyline points="10 3 10 21"/>
<polyline points="10 9 22 9"/>
</svg>
</div>
<span class="file-name-text">{{ fileName }}</span>
</div>
<div class="header-right">
<button class="header-btn" @click="downloadFile">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
<span>下载原文件</span>
</button>
</div>
</div>
<div class="univer-body">
<div ref="univerContainer" class="univer-view-mount" />
<div v-if="loading" class="univer-overlay-loading">
<div class="loading-spinner"></div>
<div class="loading-text">正在初始化 Univer 引擎...</div>
<div class="loading-hint">提示当前为空白预览模式</div>
</div>
<div v-if="error" class="univer-overlay-error">
<div class="error-card">
<div class="error-icon"></div>
<div class="error-title">预览初始化失败</div>
<div class="error-msg">{{ error }}</div>
<button class="retry-btn" @click="retry">重试</button>
<p class="error-hint">提示当前为空白文档预览模式Univer 开源版本不支持直接加载 Office 文件内容</p>
</div>
</div>
</div>
</section>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue';
import { createUniverInstance, OfficeFormat, OfficePresetType } from '../services/univerBridge.js';
const props = defineProps({
fileBlob: { type: Blob, required: true },
fileName: { type: String, required: true },
format: { type: String, required: true },
});
const univerContainer = ref(null);
const univerInstance = ref(null);
const loading = ref(true);
const error = ref(null);
const isDark = ref(false);
let themeObserver = null;
const downloadFile = () => {
try {
const blob = props.fileBlob;
if (!blob) return;
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = props.fileName || 'document';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch (e) {
console.error('下载文件失败', e);
}
};
const retry = () => {
error.value = null;
initializeUniver();
};
const clearMountNode = () => {
if (!univerContainer.value) return;
univerContainer.value.replaceChildren();
univerContainer.value.removeAttribute('style');
};
const clearDetachedPopups = () => {
Array.from(document.body.children).forEach((child) => {
if (!(child instanceof HTMLElement)) return;
if (child.querySelector('.univer-popup')) {
child.remove();
}
});
};
const computePresetFromFormat = (fmt) => {
const lower = (fmt || '').toLowerCase();
if (lower === 'docx') return OfficePresetType.DOCS;
if (lower === 'xlsx') return OfficePresetType.SHEETS;
if (lower === 'pptx') return OfficePresetType.SLIDES;
return null;
};
const initializeUniver = async () => {
destroyUniver(true);
loading.value = true;
error.value = null;
try {
const instance = await createUniverInstance(univerContainer.value, {
format: props.format,
theme: isDark.value ? 'dark' : 'light',
locale: 'zh-CN'
});
univerInstance.value = instance;
await loadDocumentIntoUniver(instance, props.fileBlob, props.fileName, props.format);
loading.value = false;
} catch (e) {
error.value = e?.message || '加载失败,请检查文件';
loading.value = false;
}
};
const loadDocumentIntoUniver = async (instance, blob, fileName, fmt) => {
const { univerAPI } = instance;
if (!univerAPI) throw new Error('Univer API 未初始化');
const lowerFmt = (fmt || '').toLowerCase();
console.log(`[Univer] 开始加载文档: ${fileName}, 格式: ${lowerFmt}`);
// 重要说明:
// Univer 开源版本不支持直接导入 DOCX/XLSX/PPTX 文件
// 导入功能需要 @univerjs-pro/exchange-clientPro 版本)
// 当前实现创建空白文档作为预览占位符
console.warn('[Univer] 开源版本不支持导入 Office 文件,创建空白文档');
console.warn('[Univer] 如需导入功能,请使用 Univer Pro 版本');
// Fallback: 创建空白文档
try {
if (lowerFmt === 'xlsx') {
await univerAPI.createWorkbook({});
console.log('[Univer] 创建空白 Excel 工作簿');
} else if (lowerFmt === 'pptx') {
// 尝试 Slides API
if (typeof univerAPI.createUniverSlide === 'function') {
await univerAPI.createUniverSlide({});
console.log('[Univer] 创建空白 PPT 演示文稿');
} else {
await univerAPI.createUniverDoc({});
console.log('[Univer] 创建空白文档Slides API 不可用)');
}
} else {
await univerAPI.createUniverDoc({});
console.log('[Univer] 创建空白 Word 文档');
}
} catch (e) {
console.error('[Univer] 创建文档失败:', e);
throw new Error(`无法创建文档: ${e.message}`);
}
};
const destroyUniver = (clearMount = false, skipDispose = false) => {
const currentUniver = univerInstance.value?.univer;
univerInstance.value = null;
if (currentUniver && !skipDispose) {
try {
currentUniver.dispose();
} catch (e) {
console.warn('[Univer] 销毁实例时出现异常,已回退为强制清空挂载节点', e);
}
}
if (clearMount) {
clearMountNode();
clearDetachedPopups();
}
};
const updateTheme = () => {
const root = document.documentElement;
const theme = root.getAttribute('data-theme');
isDark.value = theme === 'dark' || root.classList.contains('dark');
};
onMounted(async () => {
updateTheme();
themeObserver = new MutationObserver(updateTheme);
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'class'] });
await initializeUniver();
});
onBeforeUnmount(() => {
themeObserver?.disconnect();
destroyUniver(false, props.format === 'xlsx');
if (props.format === 'xlsx') {
clearDetachedPopups();
}
});
</script>
<style scoped>
.univer-preview-container {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
background: var(--app-bg, #ffffff);
border-radius: 12px;
overflow: hidden;
border: 1px solid var(--panel-border, rgba(0,0,0,0.1));
box-shadow: 0 4px 20px rgba(0,0,0,0.05);
}
.univer-preview-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
background: var(--panel-bg, #f9fafb);
border-bottom: 1px solid var(--panel-border, rgba(0,0,0,0.1));
flex-shrink: 0;
}
.header-left {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.file-icon {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
color: #fff;
}
.file-icon.docx { background: #2b579a; }
.file-icon.xlsx { background: #217346; }
.file-icon.pptx { background: #b7472a; }
.file-name-text {
font-size: 14px;
font-weight: 500;
color: var(--app-text, #374151);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.header-right {
display: flex;
gap: 8px;
}
.header-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 6px;
border: 1px solid var(--panel-border, #e5e7eb);
background: #fff;
color: #374151;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
}
.header-btn:hover {
background: #f3f4f6;
border-color: #d1d5db;
}
.univer-body {
flex: 1;
position: relative;
overflow: hidden;
background: #f3f4f6;
}
.univer-view-mount {
width: 100%;
height: 100%;
}
.univer-overlay-loading,
.univer-overlay-error {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 10;
}
.univer-overlay-loading {
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(4px);
}
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid rgba(0,0,0,0.1);
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 16px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.loading-text {
font-size: 14px;
color: #6b7280;
}
.univer-overlay-error {
background: rgba(249, 250, 251, 0.9);
}
.error-card {
background: #fff;
padding: 32px;
border-radius: 16px;
border: 1px solid #fee2e2;
box-shadow: 0 10px 30px rgba(0,0,0,0.05);
text-align: center;
max-width: 400px;
width: 90%;
}
.error-icon {
font-size: 40px;
color: #ef4444;
margin-bottom: 16px;
}
.error-title {
font-size: 18px;
font-weight: 700;
color: #111827;
margin-bottom: 8px;
}
.error-msg {
font-size: 14px;
color: #6b7280;
margin-bottom: 24px;
line-height: 1.5;
}
.retry-btn {
padding: 10px 24px;
background: #3b82f6;
color: #fff;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.retry-btn:hover { background: #2563eb; }
.error-hint {
font-size: 12px;
color: #9ca3af;
margin-top: 20px;
}
.is-dark .univer-preview-container { background: #111827; border-color: #374151; }
.is-dark .univer-preview-header { background: #1f2937; border-color: #374151; }
.is-dark .file-name-text { color: #f3f4f6; }
.is-dark .header-btn { background: #374151; border-color: #4b5563; color: #f3f4f6; }
.is-dark .univer-body { background: #0f172a; }
.is-dark .univer-overlay-loading { background: rgba(17, 24, 39, 0.8); }
.is-dark .error-card { background: #1f2937; border-color: #7f1d1d; }
.is-dark .error-title { color: #f3f4f6; }
</style>

View File

@@ -0,0 +1,240 @@
<template>
<section class="upload-block-shell">
<input
ref="inputRef"
class="upload-block-input"
type="file"
:accept="activeAccept"
@change="handleFileChange"
>
<div class="upload-block-card" :class="{ 'is-uploading': isUploading }" @click="openFileDialog()">
<div class="upload-block-cross" aria-hidden="true">
<span></span>
<span></span>
</div>
<div class="upload-block-footer" @click.stop>
<span class="upload-block-label">{{ isUploading ? '处理中...' : '上传' }}</span>
<label class="upload-block-select-wrap" for="upload-block-format-select">
<select
id="upload-block-format-select"
v-model="activeFilter"
class="upload-block-select"
:disabled="isUploading"
@change="handleFilterChange"
@click.stop
>
<option value="">全部</option>
<option
v-for="option in props.menuOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
<svg class="upload-block-select-arrow" width="12" height="12" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M7 10l5 6 5-6z" />
</svg>
</label>
</div>
</div>
</section>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { getUploadBlockAcceptForType } from '../utils/uploadBlock.js'
const props = defineProps({
allowedTypes: { type: Array, default: () => [] },
accept: { type: String, default: '' },
menuOptions: { type: Array, default: () => [] },
onUploadRequest: { type: Function, default: null },
})
const inputRef = ref(null)
const isUploading = ref(false)
const activeFilter = ref('')
const activeAccept = computed(() => {
if (activeFilter.value) {
return getUploadBlockAcceptForType(activeFilter.value) || props.accept
}
return props.accept
})
const openFileDialog = (filter = '') => {
if (isUploading.value) return
activeFilter.value = filter
inputRef.value?.click()
}
const handleFilterChange = (event) => {
const nextValue = event?.target?.value || ''
activeFilter.value = nextValue
if (nextValue) {
openFileDialog(nextValue)
}
}
const handleFileChange = async (event) => {
const input = event.target
const file = input?.files?.[0]
if (!file || !props.onUploadRequest) {
if (input) input.value = ''
return
}
isUploading.value = true
try {
await props.onUploadRequest(file)
} finally {
isUploading.value = false
activeFilter.value = ''
if (input) input.value = ''
}
}
watch(
() => props.accept,
() => {
activeFilter.value = ''
}
)
</script>
<style scoped>
.upload-block-shell {
display: flex;
justify-content: flex-start;
width: 100%;
margin: 24px 0;
}
.upload-block-input {
display: none;
}
.upload-block-card {
position: relative;
width: 10%;
aspect-ratio: 1 / 1;
min-width: 108px;
border-radius: 22px;
border: 2px solid #2563eb;
background: linear-gradient(180deg, rgba(219, 234, 254, 0.72) 0%, rgba(191, 219, 254, 0.95) 100%);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 18px;
cursor: pointer;
box-shadow: 0 16px 38px rgba(37, 99, 235, 0.16);
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
}
.upload-block-card:hover {
transform: translateY(-2px);
box-shadow: 0 20px 42px rgba(37, 99, 235, 0.2);
}
.upload-block-card.is-uploading {
cursor: wait;
opacity: 0.78;
}
.upload-block-footer {
display: flex;
align-items: center;
gap: 6px;
}
.upload-block-label {
color: #111827;
font-size: 15px;
font-weight: 700;
line-height: 1;
}
.upload-block-select-wrap {
position: relative;
display: inline-flex;
align-items: center;
min-width: 56px;
}
.upload-block-cross {
position: relative;
width: 54%;
height: 54%;
}
.upload-block-cross span {
position: absolute;
top: 50%;
left: 50%;
width: 100%;
height: 4px;
border-radius: 999px;
background: #2563eb;
transform: translate(-50%, -50%);
}
.upload-block-cross span:last-child {
width: 4px;
height: 100%;
}
.upload-block-select {
appearance: none;
width: 100%;
padding: 0 18px 0 0;
border: none;
background: transparent;
color: #111827;
font-size: 13px;
font-weight: 600;
line-height: 1;
cursor: pointer;
outline: none;
}
.upload-block-select:disabled {
cursor: wait;
}
.upload-block-select-arrow {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
color: #111827;
pointer-events: none;
}
:global(.upload-block-node-view) {
display: block;
width: 100%;
}
:global(.upload-block-node-view.ProseMirror-selectednode) {
outline: none !important;
}
:global(.upload-block-node-view.ProseMirror-selectednode .upload-block-card) {
outline: none !important;
box-shadow: 0 16px 38px rgba(37, 99, 235, 0.16) !important;
}
:global(.upload-block-node-view.ProseMirror-selectednode)::after {
display: none !important;
}
@media (max-width: 1280px) {
.upload-block-card {
width: 40%;
}
}
</style>

View File

@@ -11,11 +11,3 @@ const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
if (import.meta.env.PROD && 'serviceWorker' in navigator && false) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {
// Service worker registration failed, silently ignore
})
})
}

View File

@@ -0,0 +1,246 @@
import { createApp, h, reactive } from 'vue'
import { Plugin, PluginKey } from '@milkdown/prose/state'
import { $node, $prose, $remark, $view } from '@milkdown/kit/utils'
import type { Node as ProseNode } from '@milkdown/prose/model'
import type { EditorView, NodeView } from '@milkdown/prose/view'
import HiddenTextCrepe from '../components/HiddenTextCrepe.vue'
import { HIDDEN_TEXT_NODE_TYPE, parseHiddenTextAt, serializeHiddenTextSyntax, splitTextWithHiddenSyntax } from '../utils/hiddenText.js'
const HIDDEN_TEXT_INPUT_PLUGIN_KEY = new PluginKey('milkdown-hidden-text-input')
const HIDDEN_TEXT_INPUT_META = 'hidden-text-input-meta'
function transformHiddenTextChildren(node: any) {
if (!node || !Array.isArray(node.children)) return
const nextChildren: any[] = []
for (const child of node.children) {
if (child?.type === 'text' && typeof child.value === 'string') {
const segments = splitTextWithHiddenSyntax(child.value)
if (segments) {
nextChildren.push(...segments)
continue
}
}
transformHiddenTextChildren(child)
nextChildren.push(child)
}
node.children = nextChildren
}
function findHiddenTextReplacements(doc: ProseNode) {
const replacements: Array<{ from: number; to: number; displayed: string; hidden: string; marks: ProseNode['marks'] }> = []
doc.descendants((node, pos, parent) => {
if (!node.isText || !node.text) return true
if (parent?.type.spec.code) return true
if (node.marks.some((mark) => mark.type.spec.code)) return true
let index = 0
while (index < node.text.length) {
const match = parseHiddenTextAt(node.text, index)
if (!match) {
index += 1
continue
}
replacements.push({
from: pos + match.start,
to: pos + match.end,
displayed: match.displayed,
hidden: match.hidden,
marks: node.marks,
})
index = match.end
}
return true
})
return replacements
}
class HiddenTextNodeView implements NodeView {
node: ProseNode
view: EditorView
getPos: (() => number) | boolean
dom: HTMLElement
app: ReturnType<typeof createApp> | null = null
props: Record<string, any>
constructor(node: ProseNode, view: EditorView, getPos: (() => number) | boolean) {
this.node = node
this.view = view
this.getPos = getPos
this.dom = document.createElement('span')
this.dom.className = 'hidden-text-node-view'
this.dom.setAttribute('contenteditable', 'false')
this.props = reactive({
displayed: node.attrs.displayed,
hidden: node.attrs.hidden,
expanded: node.attrs.expanded,
updateTexts: ({ displayed, hidden }: { displayed: string; hidden: string }) => {
this.updateAttrs({ displayed, hidden })
},
updateExpanded: (expanded: boolean) => this.updateAttrs({ expanded }),
})
this.mount()
}
mount() {
this.app = createApp({
render: () => h(HiddenTextCrepe, this.props),
})
this.app.mount(this.dom)
}
getPosValue() {
if (typeof this.getPos === 'function') {
try {
const pos = this.getPos()
if (typeof pos === 'number') return pos
} catch {
// Fall back to DOM-based resolution below.
}
}
try {
const pos = this.view.posAtDOM(this.dom, 0)
return typeof pos === 'number' ? pos : undefined
} catch {
return undefined
}
}
updateAttrs(patch: Record<string, any>) {
const pos = this.getPosValue()
if (pos === undefined) return
const nextAttrs = { ...this.node.attrs, ...patch }
const nextNode = this.node.type.create(nextAttrs, undefined, this.node.marks)
this.view.dispatch(this.view.state.tr.replaceWith(pos, pos + this.node.nodeSize, nextNode))
}
update(node: ProseNode) {
if (node.type !== this.node.type) return false
this.node = node
this.props.displayed = node.attrs.displayed
this.props.hidden = node.attrs.hidden
this.props.expanded = node.attrs.expanded
return true
}
stopEvent(event: Event) {
const target = event.target as Node | null
return Boolean(target && this.dom.contains(target))
}
ignoreMutation() {
return true
}
destroy() {
this.app?.unmount()
this.app = null
}
}
export const hiddenTextRemark = $remark('hiddenTextRemark', () => () => {
return (tree: any) => {
transformHiddenTextChildren(tree)
}
})
export const hiddenTextNode = $node(HIDDEN_TEXT_NODE_TYPE, () => ({
group: 'inline',
inline: true,
atom: true,
selectable: true,
draggable: false,
attrs: {
displayed: { default: '' },
hidden: { default: '' },
expanded: { default: false },
},
parseDOM: [
{
tag: 'span[data-hidden-text="true"]',
getAttrs: (dom) => ({
displayed: (dom as HTMLElement).getAttribute('data-hidden-display') || '',
hidden: (dom as HTMLElement).getAttribute('data-hidden-value') || '',
expanded: ((dom as HTMLElement).getAttribute('data-hidden-expanded') || '') === 'true',
}),
},
],
toDOM: (node) => [
'span',
{
'data-hidden-text': 'true',
'data-hidden-display': node.attrs.displayed,
'data-hidden-value': node.attrs.hidden,
'data-hidden-expanded': String(Boolean(node.attrs.expanded)),
},
],
parseMarkdown: {
match: (node) => node.type === HIDDEN_TEXT_NODE_TYPE,
runner: (state, node, type) => {
state.addNode(type, {
displayed: String(node.displayed || ''),
hidden: String(node.hidden || ''),
expanded: false,
})
},
},
toMarkdown: {
match: (node) => node.type.name === HIDDEN_TEXT_NODE_TYPE,
runner: (state, node) => {
state.addNode('text', undefined, serializeHiddenTextSyntax(node.attrs.displayed, node.attrs.hidden))
},
},
leafText: (node) => serializeHiddenTextSyntax(node.attrs.displayed, node.attrs.hidden),
}))
export const hiddenTextView = $view(hiddenTextNode, () => {
return (node, view, getPos) => new HiddenTextNodeView(node, view, getPos)
})
export const hiddenTextInputPlugin = $prose(() => {
return new Plugin({
key: HIDDEN_TEXT_INPUT_PLUGIN_KEY,
appendTransaction: (transactions, _oldState, newState) => {
if (!transactions.some((transaction) => transaction.docChanged)) return null
if (transactions.some((transaction) => transaction.getMeta(HIDDEN_TEXT_INPUT_META))) return null
const hiddenTextType = newState.schema.nodes[HIDDEN_TEXT_NODE_TYPE]
if (!hiddenTextType) return null
const replacements = findHiddenTextReplacements(newState.doc)
if (replacements.length === 0) return null
let tr = newState.tr
for (let index = replacements.length - 1; index >= 0; index -= 1) {
const replacement = replacements[index]
tr = tr.replaceWith(
replacement.from,
replacement.to,
hiddenTextType.create(
{
displayed: replacement.displayed,
hidden: replacement.hidden,
expanded: false,
},
undefined,
replacement.marks
)
)
}
if (!tr.docChanged) return null
tr.setMeta(HIDDEN_TEXT_INPUT_META, true)
return tr
},
})
})

View File

@@ -373,8 +373,8 @@ async function renderMermaidBlock(block: HTMLElement, token: number): Promise<vo
}
function scheduleMermaidRender(token: number) {
const maxAttempts = 24
const targetSelector = `.mermaid-block[data-mermaid-token="${token}"]`
const retryDelays = [24, 64, 160, 320, 640]
const run = (attempt: number) => {
const block = document.querySelector(targetSelector)
@@ -382,13 +382,9 @@ function scheduleMermaidRender(token: number) {
void renderMermaidBlock(block, token)
return
}
if (attempt >= maxAttempts) return
if (typeof window.requestAnimationFrame === 'function') {
window.requestAnimationFrame(() => run(attempt + 1))
} else {
window.setTimeout(() => run(attempt + 1), 16)
}
const nextDelay = retryDelays[attempt]
if (nextDelay === undefined) return
window.setTimeout(() => run(attempt + 1), nextDelay)
}
run(0)

View File

@@ -0,0 +1,665 @@
import { createApp, h, reactive } from 'vue'
import { parserCtx, serializerCtx } from '@milkdown/kit/core'
import { $ctx, $node, $prose, $remark, $view } from '@milkdown/kit/utils'
import { Plugin, PluginKey, Selection } from '@milkdown/prose/state'
import { type Node as ProseNode, Slice, type Schema } from '@milkdown/prose/model'
import { Decoration, DecorationSet, type EditorView, type NodeView } from '@milkdown/prose/view'
import ProBlockCrepe from '../components/ProBlockCrepe.vue'
import { extractDocBlockContextFromMarkdown } from '../utils/docBlock.js'
import { extractTextFromOCR, getOcrCache } from '../utils/ocrCache'
import { PRO_BLOCK_NODE_TYPE, PRO_DISPLAY_LABEL, PRO_TRIGGER_TEXT, parseProBlockSyntax, serializeProBlockSyntax } from '../utils/proBlock.js'
const PRO_BLOCK_INPUT_PLUGIN_KEY = new PluginKey('milkdown-pro-block-input')
const PRO_BLOCK_HIGHLIGHT_PLUGIN_KEY = new PluginKey<DecorationSet>('milkdown-pro-block-highlight')
const PRO_BLOCK_INPUT_META = 'pro-block-input-meta'
const PRO_CONTEXT_LIMIT = 32 * 1024
const FALLBACK_BLOCK_SEPARATOR = '\n\n'
const FALLBACK_LEAF_TEXT = '\n'
const IMAGE_NODE_TYPES = new Set(['image', 'image-block', 'imageBlock'])
interface ProBlockConfig {
fetchSuggestionStream: (payload: {
prefix: string
suffix: string
languageId: string
signal?: AbortSignal
model?: string
temperature?: number
onChunk?: (chunk: string) => void
}) => Promise<string>
getProModel: () => string
showError: (message: string) => void
isThinkingActive: () => boolean
setThinkingActive: (value: boolean) => void
}
interface ProRequestPayload {
prefix: string
suffix: string
languageId: string
blocked: boolean
}
function serializeRangeToMarkdown(
doc: ProseNode,
from: number,
to: number,
schema: Schema,
serializer: (content: ProseNode) => string
): string {
if (from >= to) return ''
const fallback = doc.textBetween(from, to, FALLBACK_BLOCK_SEPARATOR, FALLBACK_LEAF_TEXT)
if (typeof serializer !== 'function') return fallback
const slice = doc.slice(from, to)
if (slice.content.size <= 0) return ''
try {
const sliceDoc = schema.topNodeType.createAndFill(undefined, slice.content)
return sliceDoc ? serializer(sliceDoc) : fallback
} catch {
return fallback
}
}
function transformProBlockChildren(node: any) {
if (!node || !Array.isArray(node.children)) return
node.children = node.children.map((child: any) => {
const replacement = parseProParagraphNode(child)
if (replacement) return replacement
transformProBlockChildren(child)
return child
})
}
function parseProParagraphNode(node: any) {
if (!node || node.type !== 'paragraph' || !Array.isArray(node.children)) return null
if (node.children.length !== 1) return null
const textNode = node.children[0]
if (!textNode || textNode.type !== 'text' || typeof textNode.value !== 'string') return null
const parsed = parseProBlockSyntax(textNode.value)
if (!parsed) return null
return {
type: PRO_BLOCK_NODE_TYPE,
content: parsed.content,
autoStart: false,
}
}
function findProBlockReplacements(doc: ProseNode) {
const replacements: Array<{ from: number; to: number; content: string; autoStart: boolean }> = []
doc.descendants((node, pos) => {
if (node.type.name !== 'paragraph' || node.childCount !== 1) return true
const firstChild = node.firstChild
if (!firstChild?.isText) return true
const parsed = parseProBlockSyntax(node.textContent)
if (!parsed) return true
replacements.push({
from: pos,
to: pos + node.nodeSize,
content: parsed.content,
autoStart: parsed.autoStart,
})
return false
})
return replacements
}
function buildOcrContext(doc: ProseNode) {
const lines: string[] = []
doc.descendants((node) => {
if (!IMAGE_NODE_TYPES.has(node.type.name)) return true
const src = typeof node.attrs?.src === 'string' ? node.attrs.src : ''
if (!src) return true
const ocrText = getOcrCache(src)
if (!ocrText) return true
const preview = extractTextFromOCR(ocrText, 120)
if (!preview) return true
const label = typeof node.attrs?.alt === 'string' && node.attrs.alt.trim() ? node.attrs.alt.trim() : 'image'
lines.push(`![${label}](${src}) <OCR:${preview}>`)
return true
})
return lines.join('\n')
}
function createHighlightDecorations(doc: ProseNode, from: number, to: number) {
if (to <= from) return DecorationSet.empty
const decorations = [
Decoration.inline(from, to, { class: 'pro-block-accepted-highlight' }),
]
doc.nodesBetween(from, to, (node, pos) => {
if (!node.isBlock) return true
const nodeFrom = pos
const nodeTo = pos + node.nodeSize
if (nodeFrom >= from && nodeTo <= to) {
decorations.push(Decoration.node(nodeFrom, nodeTo, { class: 'pro-block-accepted-block' }))
}
return true
})
return DecorationSet.create(doc, decorations)
}
function replaceWithParsedMarkdownSlice(tr: any, from: number, to: number, parsedDoc: ProseNode) {
if (!parsedDoc || parsedDoc.content.size <= 0) return null
const parsedSlice = Slice.maxOpen(parsedDoc.content)
if (!parsedSlice || parsedSlice.size <= 0) return null
tr.replaceRange(from, to, parsedSlice)
const startPos = tr.mapping.map(from, -1)
const endPos = tr.mapping.map(to, 1)
if (endPos <= startPos) return null
return { from: startPos, to: endPos }
}
function insertProBlockNode(view: EditorView, autoStart: boolean) {
const proBlockType = view.state.schema.nodes[PRO_BLOCK_NODE_TYPE]
if (!proBlockType) return false
const { from, to } = view.state.selection
const blockNode = proBlockType.create({ content: '', autoStart })
const tr = view.state.tr.replaceRangeWith(from, to, blockNode)
const nextPos = Math.min(from + blockNode.nodeSize, tr.doc.content.size)
tr.setSelection(Selection.near(tr.doc.resolve(nextPos), 1))
view.dispatch(tr.scrollIntoView())
view.focus()
return true
}
function replaceParagraphWithProBlock(
view: EditorView,
from: number,
to: number,
content: string,
autoStart: boolean
) {
const proBlockType = view.state.schema.nodes[PRO_BLOCK_NODE_TYPE]
if (!proBlockType) return false
const blockNode = proBlockType.create({
content,
autoStart: autoStart && content === '',
})
const tr = view.state.tr.replaceWith(from, to, blockNode)
const nextPos = Math.min(from + blockNode.nodeSize, tr.doc.content.size)
tr.setMeta(PRO_BLOCK_INPUT_META, true)
tr.setSelection(Selection.near(tr.doc.resolve(nextPos), 1))
view.dispatch(tr.scrollIntoView())
view.focus()
return true
}
function tryHandleProTriggerTextInput(view: EditorView, insertedText: string) {
if (!insertedText || !insertedText.includes(']')) return false
const { state } = view
const { $from, from, to } = state.selection
const paragraph = $from.parent
if (!paragraph || paragraph.type.name !== 'paragraph') return false
if (!$from.sameParent(state.selection.$to)) return false
const paragraphDepth = $from.depth
const paragraphStart = $from.start(paragraphDepth)
const startOffset = from - paragraphStart
const endOffset = to - paragraphStart
const nextText = `${paragraph.textContent.slice(0, startOffset)}${insertedText}${paragraph.textContent.slice(endOffset)}`
const parsed = parseProBlockSyntax(nextText)
if (!parsed?.autoStart || parsed.content) return false
const blockFrom = $from.before(paragraphDepth)
const blockTo = blockFrom + paragraph.nodeSize
return replaceParagraphWithProBlock(view, blockFrom, blockTo, parsed.content, parsed.autoStart)
}
function isAbortError(error: unknown) {
return Boolean(error && typeof error === 'object' && 'name' in error && (error as { name?: string }).name === 'AbortError')
}
export const proBlockConfigCtx = $ctx<ProBlockConfig, 'proBlockConfig'>({
fetchSuggestionStream: async () => '',
getProModel: () => '',
showError: () => {},
isThinkingActive: () => false,
setThinkingActive: () => {},
}, 'proBlockConfig')
class ProBlockNodeView implements NodeView {
node: ProseNode
view: EditorView
getPos: (() => number) | boolean
dom: HTMLElement
app: ReturnType<typeof createApp> | null = null
props: Record<string, any>
parser: (markdown: string) => Promise<ProseNode>
serializer: (content: ProseNode) => string
config: ProBlockConfig
abortController: AbortController | null = null
requestSeq = 0
redoCount = 0
destroyed = false
highlightTimer: ReturnType<typeof setTimeout> | null = null
constructor(
node: ProseNode,
view: EditorView,
getPos: (() => number) | boolean,
parser: (markdown: string) => Promise<ProseNode>,
serializer: (content: ProseNode) => string,
config: ProBlockConfig
) {
this.node = node
this.view = view
this.getPos = getPos
this.parser = parser
this.serializer = serializer
this.config = config
this.dom = document.createElement('div')
this.dom.className = 'pro-block-node-view'
this.props = reactive({
stage: node.attrs.content ? 'done' : 'idle',
previewContent: '',
savedContent: node.attrs.content || '',
isBusy: false,
title: PRO_DISPLAY_LABEL,
activateAction: () => {
void this.startThinking(false)
},
discardAction: () => {
this.discardResult()
},
redoAction: () => {
void this.startThinking(true)
},
acceptAction: () => {
void this.acceptResult()
},
})
this.mount()
queueMicrotask(() => {
if (!this.destroyed) {
this.consumeAutoStart()
}
})
}
mount() {
this.app = createApp({
render: () => h(ProBlockCrepe, { ...this.props }),
})
this.app.mount(this.dom)
}
getPosValue() {
if (typeof this.getPos === 'function') {
try {
const pos = this.getPos()
if (typeof pos === 'number') return pos
} catch {
// Ignore and fall back to DOM lookup.
}
}
try {
const pos = this.view.posAtDOM(this.dom, 0)
return typeof pos === 'number' ? pos : undefined
} catch {
return undefined
}
}
updateAttrs(patch: Record<string, any>) {
const pos = this.getPosValue()
if (pos === undefined) return
const nextAttrs = { ...this.node.attrs, ...patch }
this.view.dispatch(this.view.state.tr.setNodeMarkup(pos, undefined, nextAttrs))
}
setStage(stage: 'idle' | 'thinking' | 'streaming' | 'done', previewContent = '') {
this.props.stage = stage
this.props.previewContent = previewContent
this.props.isBusy = stage === 'thinking' || stage === 'streaming'
}
buildRequestPayload(): ProRequestPayload {
const pos = this.getPosValue()
if (pos === undefined) {
return {
prefix: '',
suffix: '',
languageId: 'markdown',
blocked: true,
}
}
const doc = this.view.state.doc
const schema = this.view.state.schema
const prefixMarkdown = serializeRangeToMarkdown(doc, 0, pos, schema, this.serializer)
|| doc.textBetween(0, pos, FALLBACK_BLOCK_SEPARATOR, FALLBACK_LEAF_TEXT)
const suffixStart = Math.min(pos + this.node.nodeSize, doc.content.size)
const suffixMarkdown = serializeRangeToMarkdown(doc, suffixStart, doc.content.size, schema, this.serializer)
|| doc.textBetween(suffixStart, doc.content.size, FALLBACK_BLOCK_SEPARATOR, FALLBACK_LEAF_TEXT)
const ocrContext = buildOcrContext(doc)
const docContext = extractDocBlockContextFromMarkdown(`${prefixMarkdown}\n\n${suffixMarkdown}`, 1600)
const fullPrefix = [ocrContext, docContext, prefixMarkdown].filter(Boolean).join('\n\n')
return {
prefix: fullPrefix,
suffix: suffixMarkdown,
languageId: 'markdown',
blocked: fullPrefix.length + suffixMarkdown.length > PRO_CONTEXT_LIMIT,
}
}
consumeAutoStart() {
if (!this.node.attrs.autoStart) return
this.updateAttrs({ autoStart: false })
void this.startThinking(false)
}
stopThinkingLock() {
this.config.setThinkingActive(false)
}
abortActive(reason = 'abort') {
if (!this.abortController) return
this.abortController.abort(reason)
this.abortController = null
this.stopThinkingLock()
}
discardResult() {
this.abortActive('discard')
this.redoCount = 0
this.props.savedContent = ''
this.setStage('idle', '')
this.updateAttrs({ content: '', autoStart: false })
}
async startThinking(isRedo: boolean) {
if (this.props.isBusy) return
const previousContent = this.node.attrs.content || ''
let requestSeq = this.requestSeq
let requestStarted = false
try {
if (this.config.isThinkingActive()) {
this.config.showError('当前已有一个 PRO 思考正在进行,请先等待它完成。')
return
}
const payload = this.buildRequestPayload()
if (payload.blocked) {
this.config.showError('PRO 模式上下文超过 32KB无法继续思考。')
return
}
requestSeq = this.requestSeq + 1
this.requestSeq = requestSeq
this.abortController = new AbortController()
this.config.setThinkingActive(true)
requestStarted = true
this.setStage('thinking', '')
const temperature = Math.min(1.2, 0.7 + (isRedo ? (this.redoCount + 1) * 0.2 : 0))
const result = await this.config.fetchSuggestionStream({
...payload,
signal: this.abortController.signal,
model: this.config.getProModel(),
temperature,
onChunk: (chunk) => {
if (this.destroyed || this.requestSeq !== requestSeq) return
const nextContent = `${this.props.previewContent || ''}${chunk}`
this.setStage('streaming', nextContent)
},
})
if (this.destroyed || this.requestSeq !== requestSeq) return
const content = String(result || this.props.previewContent || '').trim()
this.redoCount = isRedo ? this.redoCount + 1 : 0
this.props.savedContent = content
this.setStage(content ? 'done' : 'idle', '')
this.updateAttrs({ content, autoStart: false })
} catch (error) {
if (!isAbortError(error)) {
this.config.showError(error instanceof Error ? error.message : 'PRO 模式思考失败')
}
this.props.savedContent = previousContent
this.setStage(previousContent ? 'done' : 'idle', '')
} finally {
if (requestStarted && this.requestSeq === requestSeq) {
this.abortController = null
this.stopThinkingLock()
}
}
}
async acceptResult() {
if (this.props.isBusy) return
const source = String(this.props.savedContent || this.node.attrs.content || '').trim()
if (!source) return
const pos = this.getPosValue()
if (pos === undefined) return
const from = pos
const to = pos + this.node.nodeSize
const tr = this.view.state.tr
let insertedRange: { from: number; to: number } | null = null
try {
const parsedDoc = await this.parser(source)
insertedRange = replaceWithParsedMarkdownSlice(tr, from, to, parsedDoc)
} catch {
insertedRange = null
}
if (!insertedRange) {
tr.insertText(source, from, to)
insertedRange = { from, to: from + source.length }
}
const endPos = Math.min(insertedRange.to, tr.doc.content.size)
tr.setSelection(Selection.near(tr.doc.resolve(endPos), 1))
tr.setMeta(PRO_BLOCK_HIGHLIGHT_PLUGIN_KEY, { set: insertedRange })
this.view.dispatch(tr.scrollIntoView())
this.view.focus()
if (this.highlightTimer) {
clearTimeout(this.highlightTimer)
}
this.highlightTimer = setTimeout(() => {
if (this.destroyed) return
this.view.dispatch(this.view.state.tr.setMeta(PRO_BLOCK_HIGHLIGHT_PLUGIN_KEY, { clear: true }))
}, 1000)
}
update(node: ProseNode) {
if (node.type !== this.node.type) return false
this.node = node
if (!this.props.isBusy) {
this.props.savedContent = node.attrs.content || ''
this.props.previewContent = ''
this.props.stage = node.attrs.content ? 'done' : 'idle'
}
return true
}
stopEvent(event: Event) {
const target = event.target as Node | null
return Boolean(target && this.dom.contains(target))
}
ignoreMutation() {
return true
}
destroy() {
this.destroyed = true
this.abortActive('destroy')
if (this.highlightTimer) {
clearTimeout(this.highlightTimer)
this.highlightTimer = null
}
this.app?.unmount()
this.app = null
}
}
export const proBlockRemark = $remark('proBlockRemark', () => () => {
return (tree: any) => {
transformProBlockChildren(tree)
}
})
export const proBlockNode = $node(PRO_BLOCK_NODE_TYPE, () => ({
group: 'block',
atom: true,
isolating: true,
selectable: true,
draggable: false,
marks: '',
attrs: {
content: { default: '' },
autoStart: { default: false },
},
parseDOM: [
{
tag: 'div[data-pro-block="true"]',
getAttrs: (dom) => ({
content: (dom as HTMLElement).getAttribute('data-pro-content') || '',
autoStart: ((dom as HTMLElement).getAttribute('data-pro-autostart') || '') === 'true',
}),
},
],
toDOM: (node) => [
'div',
{
'data-pro-block': 'true',
'data-pro-content': node.attrs.content || '',
'data-pro-autostart': String(Boolean(node.attrs.autoStart)),
},
],
parseMarkdown: {
match: (node) => node.type === PRO_BLOCK_NODE_TYPE,
runner: (state, node, type) => {
state.addNode(type, {
content: String(node.content || ''),
autoStart: false,
})
},
},
toMarkdown: {
match: (node) => node.type.name === PRO_BLOCK_NODE_TYPE,
runner: (state, node) => {
state.addNode('paragraph', [{
type: 'text',
value: serializeProBlockSyntax(node.attrs.content),
}])
},
},
leafText: (node) => serializeProBlockSyntax(node.attrs.content),
}))
export const proBlockView = $view(proBlockNode, (ctx) => {
const parser = ctx.get(parserCtx)
const serializer = ctx.get(serializerCtx)
const config = ctx.get(proBlockConfigCtx.key)
return (node, view, getPos) => new ProBlockNodeView(node, view, getPos, parser, serializer, config)
})
export const proBlockInputPlugin = $prose(() => {
return new Plugin({
key: PRO_BLOCK_INPUT_PLUGIN_KEY,
props: {
handleTextInput: (view, _from, _to, text) => {
return tryHandleProTriggerTextInput(view, text)
},
handleKeyDown: (view, event) => {
const pressed = (event.ctrlKey || event.metaKey) && event.shiftKey && event.key.toLowerCase() === 'p'
if (!pressed) return false
event.preventDefault()
return insertProBlockNode(view, false)
},
},
appendTransaction: (transactions, _oldState, newState) => {
if (!transactions.some((transaction) => transaction.docChanged)) return null
if (transactions.some((transaction) => transaction.getMeta(PRO_BLOCK_INPUT_META))) return null
const proBlockType = newState.schema.nodes[PRO_BLOCK_NODE_TYPE]
if (!proBlockType) return null
const replacements = findProBlockReplacements(newState.doc)
if (replacements.length === 0) return null
let tr = newState.tr
for (let index = replacements.length - 1; index >= 0; index -= 1) {
const replacement = replacements[index]
tr = tr.replaceWith(
replacement.from,
replacement.to,
proBlockType.create({
content: replacement.content,
autoStart: replacement.autoStart && replacement.content === '',
})
)
}
if (!tr.docChanged) return null
tr.setMeta(PRO_BLOCK_INPUT_META, true)
return tr
},
})
})
export const proBlockHighlightPlugin = $prose(() => {
return new Plugin<DecorationSet>({
key: PRO_BLOCK_HIGHLIGHT_PLUGIN_KEY,
state: {
init: () => DecorationSet.empty,
apply: (tr, value) => {
const meta = tr.getMeta(PRO_BLOCK_HIGHLIGHT_PLUGIN_KEY)
if (meta?.clear) {
return DecorationSet.empty
}
if (meta?.set) {
return createHighlightDecorations(tr.doc, meta.set.from, meta.set.to)
}
return value.map(tr.mapping, tr.doc)
},
},
props: {
decorations: (state) => PRO_BLOCK_HIGHLIGHT_PLUGIN_KEY.getState(state),
},
})
})
export function insertProBlockAtSelection(view: EditorView, autoStart = false) {
return insertProBlockNode(view, autoStart)
}
export { PRO_TRIGGER_TEXT }

View File

@@ -0,0 +1,263 @@
import { createApp, h, reactive } from 'vue'
import { Plugin, PluginKey } from '@milkdown/prose/state'
import { $ctx, $node, $prose, $remark, $view } from '@milkdown/kit/utils'
import type { Node as ProseNode } from '@milkdown/prose/model'
import type { EditorView, NodeView } from '@milkdown/prose/view'
import UploadBlockCrepe from '../components/UploadBlockCrepe.vue'
import {
DEFAULT_UPLOAD_BLOCK_TYPES,
UPLOAD_BLOCK_NODE_TYPE,
getUploadBlockAccept,
getUploadBlockMenuOptions,
normalizeUploadBlockTypes,
parseUploadBlockSyntax,
serializeUploadBlockSyntax,
} from '../utils/uploadBlock.js'
const UPLOAD_BLOCK_INPUT_PLUGIN_KEY = new PluginKey('milkdown-upload-block-input')
const UPLOAD_BLOCK_INPUT_META = 'upload-block-input-meta'
interface UploadBlockConfig {
requestUpload: (payload: { file: File; allowedTypes: string[]; from: number; to: number }) => Promise<void>
}
export const uploadBlockConfigCtx = $ctx<UploadBlockConfig, 'uploadBlockConfig'>({
requestUpload: async () => {},
}, 'uploadBlockConfig')
function transformUploadBlockChildren(node: any) {
if (!node || !Array.isArray(node.children)) return
node.children = node.children.map((child: any) => {
const replacement = parseUploadParagraphNode(child)
if (replacement) return replacement
transformUploadBlockChildren(child)
return child
})
}
function parseUploadParagraphNode(node: any) {
if (!node || node.type !== 'paragraph' || !Array.isArray(node.children)) return null
if (node.children.length !== 1) return null
const textNode = node.children[0]
if (!textNode || textNode.type !== 'text' || typeof textNode.value !== 'string') return null
const parsed = parseUploadBlockSyntax(textNode.value)
if (!parsed) return null
return {
type: UPLOAD_BLOCK_NODE_TYPE,
allowedTypes: parsed.allowedTypes,
}
}
function findUploadBlockReplacements(doc: ProseNode) {
const replacements: Array<{ from: number; to: number; allowedTypes: string[] }> = []
doc.descendants((node, pos) => {
if (node.type.name !== 'paragraph' || node.childCount !== 1) return true
const firstChild = node.firstChild
if (!firstChild?.isText) return true
const parsed = parseUploadBlockSyntax(node.textContent)
if (!parsed) return true
replacements.push({
from: pos,
to: pos + node.nodeSize,
allowedTypes: parsed.allowedTypes,
})
return false
})
return replacements
}
class UploadBlockNodeView implements NodeView {
node: ProseNode
view: EditorView
getPos: (() => number) | boolean
dom: HTMLElement
app: ReturnType<typeof createApp> | null = null
props: Record<string, any>
requestUpload: UploadBlockConfig['requestUpload']
constructor(node: ProseNode, view: EditorView, getPos: (() => number) | boolean, requestUpload: UploadBlockConfig['requestUpload']) {
this.node = node
this.view = view
this.getPos = getPos
this.requestUpload = requestUpload
this.dom = document.createElement('div')
this.dom.className = 'upload-block-node-view'
const allowedTypes = normalizeUploadBlockTypes(node.attrs.allowedTypes)
this.props = reactive({
allowedTypes,
accept: getUploadBlockAccept(allowedTypes),
menuOptions: getUploadBlockMenuOptions(allowedTypes),
onUploadRequest: (file: File) => this.handleUploadRequest(file),
})
this.mount()
}
mount() {
this.app = createApp({
render: () => h(UploadBlockCrepe, this.props),
})
this.app.mount(this.dom)
}
getPosValue() {
if (typeof this.getPos === 'function') {
try {
const pos = this.getPos()
if (typeof pos === 'number') return pos
} catch {
// Ignore and fall back to DOM lookup.
}
}
try {
const pos = this.view.posAtDOM(this.dom, 0)
return typeof pos === 'number' ? pos : undefined
} catch {
return undefined
}
}
async handleUploadRequest(file: File) {
const pos = this.getPosValue()
if (pos === undefined || !file) return
await this.requestUpload({
file,
allowedTypes: normalizeUploadBlockTypes(this.node.attrs.allowedTypes),
from: pos,
to: pos + this.node.nodeSize,
})
}
update(node: ProseNode) {
if (node.type !== this.node.type) return false
this.node = node
const allowedTypes = normalizeUploadBlockTypes(node.attrs.allowedTypes)
this.props.allowedTypes = allowedTypes
this.props.accept = getUploadBlockAccept(allowedTypes)
this.props.menuOptions = getUploadBlockMenuOptions(allowedTypes)
return true
}
stopEvent(event: Event) {
const target = event.target as Node | null
return Boolean(target && this.dom.contains(target))
}
ignoreMutation() {
return true
}
destroy() {
this.app?.unmount()
this.app = null
}
}
export const uploadBlockRemark = $remark('uploadBlockRemark', () => () => {
return (tree: any) => {
transformUploadBlockChildren(tree)
}
})
export const uploadBlockNode = $node(UPLOAD_BLOCK_NODE_TYPE, () => ({
group: 'block',
atom: true,
isolating: true,
selectable: true,
draggable: false,
marks: '',
attrs: {
allowedTypes: { default: [...DEFAULT_UPLOAD_BLOCK_TYPES] },
},
parseDOM: [
{
tag: 'div[data-upload-block="true"]',
getAttrs: (dom) => ({
allowedTypes: normalizeUploadBlockTypes(
String((dom as HTMLElement).getAttribute('data-upload-types') || '')
.split(',')
.map((item) => item.trim())
.filter(Boolean)
),
}),
},
],
toDOM: (node) => [
'div',
{
'data-upload-block': 'true',
'data-upload-types': normalizeUploadBlockTypes(node.attrs.allowedTypes).join(','),
},
],
parseMarkdown: {
match: (node) => node.type === UPLOAD_BLOCK_NODE_TYPE,
runner: (state, node, type) => {
state.addNode(type, {
allowedTypes: normalizeUploadBlockTypes(node.allowedTypes),
})
},
},
toMarkdown: {
match: (node) => node.type.name === UPLOAD_BLOCK_NODE_TYPE,
runner: (state, node) => {
state.addNode('paragraph', [{
type: 'text',
value: serializeUploadBlockSyntax(node.attrs.allowedTypes),
}])
},
},
leafText: (node) => serializeUploadBlockSyntax(node.attrs.allowedTypes),
}))
export const uploadBlockView = $view(uploadBlockNode, (ctx) => {
const requestUpload = ctx.get(uploadBlockConfigCtx.key).requestUpload
return (node, view, getPos) => new UploadBlockNodeView(node, view, getPos, requestUpload)
})
export const uploadBlockInputPlugin = $prose(() => {
return new Plugin({
key: UPLOAD_BLOCK_INPUT_PLUGIN_KEY,
appendTransaction: (transactions, _oldState, newState) => {
if (!transactions.some((transaction) => transaction.docChanged)) return null
if (transactions.some((transaction) => transaction.getMeta(UPLOAD_BLOCK_INPUT_META))) return null
const uploadBlockType = newState.schema.nodes[UPLOAD_BLOCK_NODE_TYPE]
if (!uploadBlockType) return null
const replacements = findUploadBlockReplacements(newState.doc)
if (replacements.length === 0) return null
let tr = newState.tr
for (let index = replacements.length - 1; index >= 0; index -= 1) {
const replacement = replacements[index]
tr = tr.replaceWith(
replacement.from,
replacement.to,
uploadBlockType.create({
allowedTypes: normalizeUploadBlockTypes(replacement.allowedTypes),
})
)
}
if (!tr.docChanged) return null
tr.setMeta(UPLOAD_BLOCK_INPUT_META, true)
return tr
},
})
})

View File

@@ -12,9 +12,8 @@ const routes = [
component: () => import('../views/DocsView.vue')
},
{
path: '/univer',
name: 'Univer',
component: () => import('../views/UniverView.vue')
path: '/:pathMatch(.*)*',
redirect: '/'
}
]

View File

@@ -1,7 +1,12 @@
/**
* Office 文件类型检测工具
*/
import { OfficeFormat, OfficePresetType } from './univerBridge'
export const OfficeFormat = {
DOCX: 'docx',
XLSX: 'xlsx',
PPTX: 'pptx'
}
/**
* 支持的 Office 文件扩展名
@@ -73,63 +78,10 @@ export function getOfficeFormat(file) {
return null
}
/**
* 获取文件图标类型
*/
export function getOfficeIcon(format) {
switch (format) {
case OfficeFormat.DOCX:
return 'doc'
case OfficeFormat.XLSX:
return 'xls'
case OfficeFormat.PPTX:
return 'ppt'
default:
return 'file'
}
}
/**
* 获取格式显示名称
*/
export function getFormatDisplayName(format, locale = 'zh-CN') {
const names = {
'zh-CN': {
[OfficeFormat.DOCX]: 'Word 文档',
[OfficeFormat.XLSX]: 'Excel 表格',
[OfficeFormat.PPTX]: 'PowerPoint 演示文稿'
},
'en-US': {
[OfficeFormat.DOCX]: 'Word Document',
[OfficeFormat.XLSX]: 'Excel Spreadsheet',
[OfficeFormat.PPTX]: 'PowerPoint Presentation'
}
}
return names[locale]?.[format] || format?.toUpperCase() || '未知格式'
}
/**
* 获取对应的 Preset 类型
*/
export function getPresetTypeByFormat(format) {
switch (format) {
case OfficeFormat.DOCX:
case OfficeFormat.PPTX:
return OfficePresetType.DOCS
case OfficeFormat.XLSX:
return OfficePresetType.SHEETS
default:
return null
}
}
export default {
OfficeFormat,
isOfficeFile,
getOfficeFormat,
getOfficeIcon,
getFormatDisplayName,
getPresetTypeByFormat,
SUPPORTED_EXTENSIONS,
MIME_TYPES
}

View File

@@ -1,302 +0,0 @@
/**
* Univer 编辑器桥接服务
* 封装 Univer 的初始化、加载、导出等操作
*
* 支持三种文档格式:
* - DOCX: 使用 DocsCorePreset
* - XLSX: 使用 SheetsCorePreset
* - PPTX: 使用 DocsCorePreset + Slides 插件组合
*/
import { createUniver, LocaleType, merge } from '@univerjs/presets'
import { UniverDocsCorePreset } from '@univerjs/preset-docs-core'
import { UniverSheetsCorePreset } from '@univerjs/preset-sheets-core'
import { UniverSlidesPlugin } from '@univerjs/slides'
import { UniverSlidesUIPlugin } from '@univerjs/slides-ui'
// 导入样式
import '@univerjs/preset-docs-core/lib/index.css'
import '@univerjs/preset-sheets-core/lib/index.css'
import '@univerjs/slides-ui/lib/index.css'
// 导入语言包
import DocsCoreEnUS from '@univerjs/preset-docs-core/locales/en-US'
import SheetsCoreEnUS from '@univerjs/preset-sheets-core/locales/en-US'
import DocsCoreZhCN from '@univerjs/preset-docs-core/locales/zh-CN'
import SheetsCoreZhCN from '@univerjs/preset-sheets-core/locales/zh-CN'
// Slides 语言包(使用空对象作为 fallback因为 slides 语言包可能不存在)
const SlidesZhCN = {}
const SlidesEnUS = {}
export const OfficeFormat = {
DOCX: 'docx',
XLSX: 'xlsx',
PPTX: 'pptx'
}
export const OfficePresetType = {
DOCS: 'docs',
SHEETS: 'sheets',
SLIDES: 'slides'
}
/**
* 根据文件扩展名判断 Office 格式
*/
export function detectOfficeFormat(filename) {
const ext = filename?.toLowerCase().split('.').pop() || ''
if (ext === 'docx') return OfficeFormat.DOCX
if (ext === 'xlsx') return OfficeFormat.XLSX
if (ext === 'pptx') return OfficeFormat.PPTX
return null
}
/**
* 根据格式获取对应的 Preset 类型
*/
export function getPresetType(format) {
switch (format) {
case OfficeFormat.DOCX:
return OfficePresetType.DOCS
case OfficeFormat.XLSX:
return OfficePresetType.SHEETS
case OfficeFormat.PPTX:
return OfficePresetType.SLIDES
default:
return null
}
}
/**
* 创建 Univer 实例
*/
export async function createUniverInstance(container, options = {}) {
const {
format = OfficeFormat.DOCX,
locale = 'zh-CN',
theme = 'light'
} = options
const localeType = locale === 'zh-CN' ? LocaleType.ZH_CN : LocaleType.EN_US
// 合并所有语言包(包括 Slides
const locales = locale === 'zh-CN'
? { [LocaleType.ZH_CN]: merge({}, DocsCoreZhCN, SheetsCoreZhCN, SlidesZhCN) }
: { [LocaleType.EN_US]: merge({}, DocsCoreEnUS, SheetsCoreEnUS, SlidesEnUS) }
const presets = []
const extraPlugins = []
// 根据格式添加对应的 Preset 和插件
switch (format) {
case OfficeFormat.DOCX:
// DOCX 只需要 DocsCorePreset
presets.push(UniverDocsCorePreset({
container,
theme: theme === 'dark' ? 'dark' : 'default'
}))
break
case OfficeFormat.XLSX:
// XLSX 只需要 SheetsCorePreset
presets.push(UniverSheetsCorePreset({
container,
theme: theme === 'dark' ? 'dark' : 'default'
}))
break
case OfficeFormat.PPTX:
// PPTX 需要 DocsCorePreset + Slides 插件
// DocsCorePreset 提供基础文档渲染能力
presets.push(UniverDocsCorePreset({
container,
theme: theme === 'dark' ? 'dark' : 'default'
}))
// Slides 插件提供演示文稿功能
// 注意:必须作为 plugins 而非 presets 传入
extraPlugins.push([UniverSlidesPlugin])
extraPlugins.push([UniverSlidesUIPlugin])
break
default:
// 默认使用 Docs 作为兜底
presets.push(UniverDocsCorePreset({
container,
theme: theme === 'dark' ? 'dark' : 'default'
}))
}
try {
const { univer, univerAPI } = createUniver({
locale: localeType,
locales,
presets,
plugins: extraPlugins.length > 0 ? extraPlugins : undefined,
collaboration: false // 纯前端模式,不启用协作
})
console.log(`[Univer] 初始化成功,格式: ${format}, 预设数量: ${presets.length}, 插件数量: ${extraPlugins.length}`)
return { univer, univerAPI }
} catch (error) {
console.error('[Univer] 初始化失败:', error)
throw new Error(`Univer 初始化失败: ${error.message}`)
}
}
/**
* Univer 编辑器实例包装类
*/
export class UniverEditorInstance {
constructor() {
this.univer = null
this.univerAPI = null
this.container = null
this.currentFormat = null
}
/**
* 初始化编辑器
*/
async init(container, options = {}) {
if (this.univer) {
await this.destroy()
}
this.container = container
this.currentFormat = options.format || OfficeFormat.DOCX
const result = await createUniverInstance(container, {
format: this.currentFormat,
...options
})
this.univer = result.univer
this.univerAPI = result.univerAPI
// 创建初始文档
if (this.currentFormat === OfficeFormat.XLSX) {
this.univerAPI.createWorkbook({})
} else {
this.univerAPI.createUniverDoc({})
}
return this
}
/**
* 从字节数组加载文档
*/
async loadFromBytes(bytes, format) {
if (!this.univerAPI) {
throw new Error('Univer 实例未初始化')
}
// 注意纯前端模式下Univer 不支持直接从 DOCX/XLSX/PPTX 字节流加载
// 这里需要使用快照模式或后端服务来解析
// 当前实现为占位,实际需要配合快照格式
console.warn('纯前端模式暂不支持从 DOCX/XLSX/PPTX 字节流加载,请使用快照模式')
return false
}
/**
* 导出为快照数据
*/
async exportSnapshot() {
if (!this.univerAPI) {
throw new Error('Univer 实例未初始化')
}
const activeDoc = this.univerAPI.getActiveDocument()
const activeSheet = this.univerAPI.getActiveWorkbook()
if (activeSheet) {
return {
type: OfficePresetType.SHEETS,
format: OfficeFormat.XLSX,
data: activeSheet.getSnapshot()
}
}
if (activeDoc) {
return {
type: OfficePresetType.DOCS,
format: OfficeFormat.DOCX,
data: activeDoc.getSnapshot()
}
}
return null
}
/**
* 从快照数据导入
*/
async importSnapshot(snapshot) {
if (!this.univerAPI || !snapshot?.data) {
throw new Error('无效的快照数据')
}
// 快照数据可以直接用于恢复文档状态
// 具体实现取决于 Univer API
console.log('导入快照:', snapshot.type, snapshot.format)
return true
}
/**
* 监听文档变化
*/
onChange(callback) {
if (!this.univerAPI) return
// Univer API 的事件监听
this.univerAPI.addEvent(this.univerAPI.Event.CommandExecuted, (event) => {
callback({
type: 'command',
data: event
})
})
}
/**
* 销毁实例
*/
async destroy() {
if (this.univer) {
this.univer.dispose()
this.univer = null
this.univerAPI = null
this.container = null
this.currentFormat = null
}
}
/**
* 获取当前格式
*/
getFormat() {
return this.currentFormat
}
/**
* 检查是否已初始化
*/
isInitialized() {
return this.univer !== null && this.univerAPI !== null
}
}
/**
* 创建 Univer 编辑器实例
*/
export function createUniverEditor() {
return new UniverEditorInstance()
}
export default {
createUniverInstance,
createUniverEditor,
detectOfficeFormat,
getPresetType,
OfficeFormat,
OfficePresetType,
UniverEditorInstance
}

View File

@@ -1,186 +0,0 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { OfficeFormat, OfficePresetType } from '../services/univerBridge'
export const useOfficeStore = defineStore('office', () => {
// 当前文档状态
const currentFileName = ref('')
const currentFormat = ref(null)
const currentFileSize = ref(0)
const currentBytes = ref(null)
// 快照模式
const isSnapshotMode = ref(true) // 默认启用快照模式
const currentSnapshot = ref(null)
// 编辑状态
const isEditing = ref(false)
const hasUnsavedChanges = ref(false)
// 视图状态
const activeView = ref('milkdown') // 'milkdown' | 'univer'
// 文档加载状态(新增)
const documentLoadStatus = ref('idle') // 'idle' | 'loading' | 'success' | 'error'
const documentErrorMessage = ref('')
// 计算属性
const hasDocument = computed(() => {
return currentFileName.value && currentFormat.value
})
const documentInfo = computed(() => {
if (!hasDocument.value) return null
return {
name: currentFileName.value,
format: currentFormat.value,
size: currentFileSize.value,
isSnapshot: isSnapshotMode.value,
loadStatus: documentLoadStatus.value
}
})
/**
* 设置当前文档
*/
function setCurrentDocument(file, bytes) {
if (!file) {
clearCurrentDocument()
return
}
currentFileName.value = file.name || '未命名'
currentFormat.value = getFormatFromFileName(file.name)
currentFileSize.value = file.size || 0
currentBytes.value = bytes
hasUnsavedChanges.value = false
documentLoadStatus.value = 'idle'
documentErrorMessage.value = ''
}
/**
* 清除当前文档
*/
function clearCurrentDocument() {
currentFileName.value = ''
currentFormat.value = null
currentFileSize.value = 0
currentBytes.value = null
currentSnapshot.value = null
hasUnsavedChanges.value = false
documentLoadStatus.value = 'idle'
documentErrorMessage.value = ''
}
/**
* 设置快照数据
*/
function setSnapshot(snapshot) {
currentSnapshot.value = snapshot
hasUnsavedChanges.value = false
}
/**
* 标记有未保存的更改
*/
function markAsChanged() {
hasUnsavedChanges.value = true
}
/**
* 切换视图
*/
function switchView(view) {
activeView.value = view
}
/**
* 切换快照模式
*/
function toggleSnapshotMode() {
isSnapshotMode.value = !isSnapshotMode.value
}
// 新增:文档加载状态管理方法
/**
* 开始加载文档
*/
function startDocumentLoad() {
documentLoadStatus.value = 'loading'
documentErrorMessage.value = ''
}
/**
* 文档加载成功
*/
function completeDocumentLoad() {
documentLoadStatus.value = 'success'
documentErrorMessage.value = ''
}
/**
* 文档加载失败
*/
function failDocumentLoad(message) {
documentLoadStatus.value = 'error'
documentErrorMessage.value = message || '文档加载失败'
}
/**
* 重置文档加载状态
*/
function resetDocumentLoadStatus() {
documentLoadStatus.value = 'idle'
documentErrorMessage.value = ''
}
return {
// 状态
currentFileName,
currentFormat,
currentFileSize,
currentBytes,
isSnapshotMode,
currentSnapshot,
isEditing,
hasUnsavedChanges,
activeView,
documentLoadStatus,
documentErrorMessage,
// 计算属性
hasDocument,
documentInfo,
// 方法
setCurrentDocument,
clearCurrentDocument,
setSnapshot,
markAsChanged,
switchView,
toggleSnapshotMode,
startDocumentLoad,
completeDocumentLoad,
failDocumentLoad,
resetDocumentLoadStatus
}
})
/**
* 从文件名获取格式
*/
function getFormatFromFileName(filename) {
const ext = filename?.toLowerCase().split('.').pop() || ''
switch (ext) {
case 'docx':
return OfficeFormat.DOCX
case 'xlsx':
return OfficeFormat.XLSX
case 'pptx':
return OfficeFormat.PPTX
default:
return null
}
}
export default useOfficeStore

View File

@@ -11,6 +11,7 @@ export const useSettingsStore = defineStore('settings', () => {
// 2. Model Behavior
const modelThinking = ref('low') // 'low' | 'medium' | 'high'
const debounceMs = ref(1000) // 1000 - 5000
const proModel = ref('')
// 3. Privacy
const privacyMode = ref(true)
@@ -44,7 +45,10 @@ export const useSettingsStore = defineStore('settings', () => {
// We will let the backend handle 'auto' currency if needed, or stick to auto label.
const t = computed(() => {
return translations[uiLanguage.value] || translations['en']
return {
...translations['en'],
...(translations[uiLanguage.value] || {}),
}
})
const initialMarkdown = computed(() => {
@@ -63,6 +67,7 @@ export const useSettingsStore = defineStore('settings', () => {
if (data.theme) theme.value = data.theme
if (data.modelThinking) modelThinking.value = data.modelThinking
if (data.debounceMs) debounceMs.value = data.debounceMs
if (typeof data.proModel === 'string') proModel.value = data.proModel
if (typeof data.privacyMode === 'boolean') privacyMode.value = data.privacyMode
if (data.language) language.value = data.language
if (data.currency) currency.value = data.currency
@@ -86,6 +91,7 @@ export const useSettingsStore = defineStore('settings', () => {
theme: theme.value,
modelThinking: modelThinking.value,
debounceMs: debounceMs.value,
proModel: proModel.value,
privacyMode: privacyMode.value,
language: language.value,
currency: currency.value,
@@ -105,6 +111,7 @@ export const useSettingsStore = defineStore('settings', () => {
theme.value = 'system'
modelThinking.value = 'low'
debounceMs.value = 1000
proModel.value = ''
privacyMode.value = false
language.value = 'auto'
currency.value = 'auto'
@@ -121,6 +128,7 @@ export const useSettingsStore = defineStore('settings', () => {
theme,
modelThinking,
debounceMs,
proModel,
privacyMode,
language,
currency,
@@ -141,6 +149,7 @@ export const useSettingsStore = defineStore('settings', () => {
theme,
modelThinking,
debounceMs,
proModel,
privacyMode,
language,
currency,
@@ -148,6 +157,7 @@ export const useSettingsStore = defineStore('settings', () => {
backgroundImage,
backgroundOpacity,
ttsInstruct,
detectedTimezone,
uiLanguage,
t,
initialMarkdown,

259
src/stores/templates.js Normal file
View File

@@ -0,0 +1,259 @@
import { defineStore } from 'pinia'
import { ref, watch } from 'vue'
const STORAGE_KEY = 'llm-in-text-templates'
const STORAGE_VERSION = 1
const PRESET_TEMPLATES = [
{
id: 'preset-meeting-notes',
name: '会议纪要',
content: `# 会议纪要
- 日期:
- 时间:
- 地点:
- 参会人:
## 议题
## 讨论记录
## 决议
## 待办事项
- [ ] `,
},
{
id: 'preset-article-outline',
name: '文章大纲',
content: `# 标题
> 一句话摘要
## 背景
## 结构
## 关键点
## 结论`,
},
{
id: 'preset-project-plan',
name: '项目计划',
content: `# 项目计划
## 目标
## 范围
## 里程碑
## 风险
## 负责人`,
},
{
id: 'preset-research-notes',
name: '研究笔记',
content: `# 研究笔记
## 问题定义
## 资料来源
## 观察
## 结论
## 下一步`,
},
]
const normalizeTemplateName = (value) => String(value ?? '').replace(/\s+/g, ' ').trim()
const normalizeTemplateContent = (value) => String(value ?? '').replace(/\r\n?/g, '\n').replace(/\s+$/, '')
const makeTemplateId = () => {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return `custom-${crypto.randomUUID()}`
}
return `custom-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
}
const normalizeStoredTemplate = (template) => {
if (!template || typeof template !== 'object') return null
const name = normalizeTemplateName(template.name)
const content = normalizeTemplateContent(template.content)
if (!name || !content.trim()) return null
const createdAt = typeof template.createdAt === 'string' && template.createdAt ? template.createdAt : new Date().toISOString()
const updatedAt = typeof template.updatedAt === 'string' && template.updatedAt ? template.updatedAt : createdAt
return {
id: typeof template.id === 'string' && template.id ? template.id : makeTemplateId(),
name,
content,
createdAt,
updatedAt,
}
}
export const useTemplatesStore = defineStore('templates', () => {
const customTemplates = ref([])
let isHydrated = false
const loadTemplates = () => {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (!stored) return
const parsed = JSON.parse(stored)
const rawTemplates = Array.isArray(parsed)
? parsed
: Array.isArray(parsed?.customTemplates)
? parsed.customTemplates
: Array.isArray(parsed?.data)
? parsed.data
: []
customTemplates.value = rawTemplates
.map(normalizeStoredTemplate)
.filter(Boolean)
} catch {
customTemplates.value = []
}
}
const saveTemplates = () => {
if (!isHydrated) return
try {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
version: STORAGE_VERSION,
customTemplates: customTemplates.value,
})
)
} catch {
// Ignore persistence failures in browser storage
}
}
const isCustomTemplateNameTaken = (name, ignoreId = '') => {
const normalizedName = normalizeTemplateName(name)
if (!normalizedName) return false
return customTemplates.value.some((template) => {
if (ignoreId && template.id === ignoreId) return false
return template.name.toLowerCase() === normalizedName.toLowerCase()
})
}
const buildUniqueCustomTemplateName = (baseName, ignoreId = '') => {
const normalizedBaseName = normalizeTemplateName(baseName) || 'Untitled Template'
let candidate = normalizedBaseName
let suffix = 2
while (isCustomTemplateNameTaken(candidate, ignoreId)) {
candidate = `${normalizedBaseName} ${suffix}`
suffix += 1
}
return candidate
}
const createCustomTemplate = ({ name, content }) => {
const nextName = normalizeTemplateName(name)
const nextContent = normalizeTemplateContent(content)
if (!nextName || !nextContent.trim()) {
return { ok: false, error: 'invalid' }
}
if (isCustomTemplateNameTaken(nextName)) {
return { ok: false, error: 'duplicate' }
}
const now = new Date().toISOString()
const template = {
id: makeTemplateId(),
name: nextName,
content: nextContent,
createdAt: now,
updatedAt: now,
}
customTemplates.value = [...customTemplates.value, template]
return { ok: true, template }
}
const updateCustomTemplate = (id, { name, content }) => {
const index = customTemplates.value.findIndex((template) => template.id === id)
if (index === -1) {
return { ok: false, error: 'not_found' }
}
const nextName = normalizeTemplateName(name)
const nextContent = normalizeTemplateContent(content)
if (!nextName || !nextContent.trim()) {
return { ok: false, error: 'invalid' }
}
if (isCustomTemplateNameTaken(nextName, id)) {
return { ok: false, error: 'duplicate' }
}
const now = new Date().toISOString()
const nextTemplates = [...customTemplates.value]
nextTemplates[index] = {
...nextTemplates[index],
name: nextName,
content: nextContent,
updatedAt: now,
}
customTemplates.value = nextTemplates
return { ok: true, template: nextTemplates[index] }
}
const deleteCustomTemplate = (id) => {
const nextTemplates = customTemplates.value.filter((template) => template.id !== id)
if (nextTemplates.length === customTemplates.value.length) {
return false
}
customTemplates.value = nextTemplates
return true
}
const getTemplateById = (id) => {
if (!id) return null
return PRESET_TEMPLATES.find((template) => template.id === id)
|| customTemplates.value.find((template) => template.id === id)
|| null
}
watch(customTemplates, saveTemplates, { deep: true })
loadTemplates()
isHydrated = true
saveTemplates()
return {
presetTemplates: PRESET_TEMPLATES,
customTemplates,
createCustomTemplate,
updateCustomTemplate,
deleteCustomTemplate,
getTemplateById,
buildUniqueCustomTemplateName,
isCustomTemplateNameTaken,
}
})

View File

@@ -244,6 +244,27 @@ body {
}
}
.hidden-text-preview {
display: inline-flex;
align-items: center;
vertical-align: baseline;
}
.hidden-text-preview__summary {
display: inline-flex;
align-items: center;
min-height: 1.8em;
padding: 0.08em 0.52em;
border-radius: 0.45em;
background: rgba(148, 163, 184, 0.26);
color: inherit;
white-space: pre-wrap;
}
:root[data-theme='dark'] .hidden-text-preview__summary {
background: rgba(100, 116, 139, 0.32);
}
/* ── Mermaid diagram blocks ─────────────────────────────────────────── */
.mermaid-block {
position: relative;

View File

@@ -1,4 +1,4 @@
import { API_URL, API_KEY, TTS_URL, TTS_STATUS_URL, TTS_CONFIG_URL } from './config.js'
import { API_URL, API_KEY, PRO_STREAM_URL, TTS_URL, TTS_STATUS_URL, TTS_CONFIG_URL } from './config.js'
import { useSettingsStore } from '../stores/settings'
function generateRequestId() {
@@ -11,6 +11,9 @@ function generateRequestId() {
function getCancelUrl(apiUrl) {
const normalized = String(apiUrl || '').replace(/\/+$/, '')
if (!normalized) return '/v1/completions/cancel'
if (/\/v1\/pro\/completions\/stream$/i.test(normalized)) {
return normalized.replace(/\/v1\/pro\/completions\/stream$/i, '/v1/completions/cancel')
}
if (normalized.endsWith('/v1/completions')) {
return `${normalized}/cancel`
}
@@ -42,6 +45,50 @@ async function sendCancelRequest(cancelUrl, requestId, reason) {
}
}
function createAbortError(message = 'Request aborted') {
const error = new Error(message)
error.name = 'AbortError'
return error
}
function buildCompletionBody(settings, prefix, suffix, languageId, extra = {}) {
return {
prefix,
suffix,
languageId,
model_thinking: settings.modelThinking,
privacy_mode: settings.privacyMode,
user_preferences: {
language: settings.language,
currency: settings.currency,
timezone: settings.detectedTimezone,
},
...extra,
}
}
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'),
}
}
export async function fetchSuggestion(prefix, suffix, languageId, signal, apiUrl = API_URL) {
let normalizedLanguageId = 'markdown'
if (typeof languageId === 'string' && languageId.trim()) {
@@ -77,18 +124,7 @@ export async function fetchSuggestion(prefix, suffix, languageId, signal, apiUrl
'X-API-Key': API_KEY,
}
const body = {
prefix,
suffix,
languageId: normalizedLanguageId,
model_thinking: settings.modelThinking,
privacy_mode: settings.privacyMode,
user_preferences: {
language: settings.language,
currency: settings.currency,
timezone: settings.detectedTimezone,
},
}
const body = buildCompletionBody(settings, prefix, suffix, normalizedLanguageId)
const res = await fetch(apiUrl, {
method: 'POST',
@@ -117,6 +153,134 @@ export async function fetchSuggestion(prefix, suffix, languageId, signal, apiUrl
}
}
export async function fetchProSuggestionStream(payload, apiUrl = PRO_STREAM_URL) {
const {
prefix = '',
suffix = '',
languageId = 'markdown',
signal,
model = '',
temperature = 0.7,
timeoutMs = 600000,
onChunk,
} = payload || {}
const settings = useSettingsStore()
const requestId = generateRequestId()
const cancelUrl = getCancelUrl(apiUrl)
const requestController = new AbortController()
const timeoutId = setTimeout(() => {
requestController.abort('timeout')
}, timeoutMs)
const relayAbort = () => {
requestController.abort(signal?.reason || 'abort')
}
const onAbort = () => {
const reason = normalizeAbortReason(requestController.signal.reason)
void sendCancelRequest(cancelUrl, requestId, reason)
}
requestController.signal.addEventListener('abort', onAbort, { once: true })
if (signal) {
if (signal.aborted) {
relayAbort()
} else {
signal.addEventListener('abort', relayAbort, { once: true })
}
}
try {
const res = await fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Request-Id': requestId,
'X-API-Key': API_KEY,
},
body: JSON.stringify(
buildCompletionBody(settings, prefix, suffix, String(languageId || 'markdown').trim() || 'markdown', {
model,
temperature,
})
),
signal: requestController.signal,
})
if (!res.ok) {
const errorText = await res.text()
throw new Error(`HTTP ${res.status}: ${errorText}`)
}
if (!res.body) {
throw new Error('PRO 模式流式响应不可用')
}
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
let finalContent = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
let boundary = buffer.indexOf('\n\n')
while (boundary >= 0) {
const chunk = buffer.slice(0, boundary)
buffer = buffer.slice(boundary + 2)
const parsed = parseSseEvent(chunk)
if (parsed.event === 'chunk' && parsed.data) {
const data = JSON.parse(parsed.data)
const delta = String(data.delta || '')
if (delta) {
finalContent += delta
onChunk?.(delta)
}
}
if (parsed.event === 'done' && parsed.data) {
const data = JSON.parse(parsed.data)
return String(data.content || finalContent || '')
}
if (parsed.event === 'error' && parsed.data) {
const data = JSON.parse(parsed.data)
throw new Error(String(data.error || 'PRO 模式请求失败'))
}
if (parsed.event === 'cancelled') {
throw createAbortError('PRO 模式请求已取消')
}
boundary = buffer.indexOf('\n\n')
}
}
if (requestController.signal.aborted) {
throw createAbortError('PRO 模式请求已中止')
}
return finalContent
} catch (e) {
if (e?.name === 'AbortError') {
throw e
}
throw e
} finally {
clearTimeout(timeoutId)
requestController.signal.removeEventListener('abort', onAbort)
if (signal) {
signal.removeEventListener('abort', relayAbort)
}
}
}
export async function fetchTTS(text, instruct = '', apiUrl = TTS_URL) {
const res = await fetch(apiUrl, {
method: 'POST',

View File

@@ -4,6 +4,7 @@ const DEFAULT_API_BASE_URL = import.meta.env.DEV ? '' : 'https://api.imageteach.
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || DEFAULT_API_BASE_URL
export const API_URL = import.meta.env.VITE_API_URL || `${API_BASE_URL}/v1/completions`
export const PRO_STREAM_URL = import.meta.env.VITE_PRO_STREAM_URL || `${API_BASE_URL}/v1/pro/completions/stream`
export const OCR_URL = import.meta.env.VITE_OCR_URL || `${API_BASE_URL}/v1/ocr`
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 || '/v1/export/pdf'

162
src/utils/hiddenText.js Normal file
View File

@@ -0,0 +1,162 @@
export const HIDDEN_TEXT_NODE_TYPE = 'hiddenText'
function escapeHtml(value = '') {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
export function normalizeHiddenTextValue(value = '') {
return String(value ?? '').replace(/\r\n?/g, ' ')
}
export function escapeHiddenTextSegment(value = '', closingChar) {
const normalized = normalizeHiddenTextValue(value)
const closingCharPattern = new RegExp(`\\${closingChar}`, 'g')
return normalized
.replace(/\\/g, '\\\\')
.replace(closingCharPattern, `\\${closingChar}`)
}
export function serializeHiddenTextSyntax(displayed = '', hidden = '') {
const safeDisplayed = escapeHiddenTextSegment(displayed, ')')
const safeHidden = escapeHiddenTextSegment(hidden, '}')
return `(${safeDisplayed}){${safeHidden}}`
}
function readHiddenTextSegment(text = '', start = 0, closingChar = ')') {
let index = start
let value = ''
while (index < text.length) {
const char = text[index]
if (char === '\\') {
const nextChar = text[index + 1]
if (nextChar === undefined) return null
value += nextChar
index += 2
continue
}
if (char === '\n' || char === '\r') return null
if (char === closingChar) {
return {
value,
end: index + 1,
}
}
value += char
index += 1
}
return null
}
export function parseHiddenTextAt(text = '', start = 0) {
if (text[start] !== '(') return null
const displayed = readHiddenTextSegment(text, start + 1, ')')
if (!displayed) return null
if (text[displayed.end] !== '{') return null
const hidden = readHiddenTextSegment(text, displayed.end + 1, '}')
if (!hidden) return null
return {
start,
end: hidden.end,
displayed: displayed.value,
hidden: hidden.value,
raw: text.slice(start, hidden.end),
}
}
export function extractHiddenTextMatches(text = '') {
const matches = []
let index = 0
while (index < text.length) {
const match = parseHiddenTextAt(text, index)
if (match) {
matches.push(match)
index = match.end
continue
}
index += 1
}
return matches
}
export function splitTextWithHiddenSyntax(text = '') {
const matches = extractHiddenTextMatches(text)
if (matches.length === 0) return null
const segments = []
let cursor = 0
for (const match of matches) {
if (match.start > cursor) {
segments.push({
type: 'text',
value: text.slice(cursor, match.start),
})
}
segments.push({
type: HIDDEN_TEXT_NODE_TYPE,
displayed: match.displayed,
hidden: match.hidden,
})
cursor = match.end
}
if (cursor < text.length) {
segments.push({
type: 'text',
value: text.slice(cursor),
})
}
return segments.filter((segment) => segment.type !== 'text' || segment.value)
}
export function renderHiddenTextPreviewHtml(displayed = '', hidden = '') {
const summary = escapeHtml(displayed || '未命名文本')
return [
`<span class="hidden-text-preview" data-hidden-text="true" title="${escapeHtml(serializeHiddenTextSyntax(displayed, hidden))}">`,
`<span class="hidden-text-preview__summary">${summary}</span>`,
'</span>',
].join('')
}
export function hiddenTextMarkdownItPlugin(md) {
md.inline.ruler.before('emphasis', 'hidden_text', (state, silent) => {
const match = parseHiddenTextAt(state.src, state.pos)
if (!match) return false
if (!silent) {
const token = state.push('hidden_text', '', 0)
token.meta = {
displayed: match.displayed,
hidden: match.hidden,
}
}
state.pos = match.end
return true
})
md.renderer.rules.hidden_text = (tokens, index) => {
const meta = tokens[index]?.meta || {}
return renderHiddenTextPreviewHtml(meta.displayed, meta.hidden)
}
}

View File

@@ -22,6 +22,12 @@ export const translations = {
mediumDesc: 'Brief analysis before suggesting',
highDesc: 'Deep, step-by-step analysis (Slowest)',
debounceTime: 'Debounce Time',
proMode: 'PRO Mode',
proModeThinking: 'PRO Thinking',
proModel: 'PRO Model',
proModelPlaceholder: 'e.g. qwen3:32b',
proModelDesc: 'Optional stronger model name used only by PRO mode.',
proModelEmptyHint: 'Leave empty to use the backend default PRO model.',
privacyPreferences: 'Privacy & Preferences',
privacyMode: 'Privacy Mode',
privacyDesc: 'Prevent sending IP and preferences to the AI',
@@ -50,6 +56,27 @@ export const translations = {
uploading: 'Uploading files...',
enableAI: 'Enable AI',
disableAI: 'Disable AI',
template: 'Template',
presetTemplates: 'Preset Templates',
customTemplates: 'Custom Templates',
newTemplate: 'New Template',
previewTemplate: 'Preview Template',
applyTemplate: 'Apply Template',
copyAsTemplate: 'Copy as Custom Template',
editTemplate: 'Edit Template',
saveTemplate: 'Save Template',
templateName: 'Template Name',
templateContent: 'Template Content',
templateNamePlaceholder: 'e.g. Meeting Notes',
templateContentPlaceholder: 'Enter template content here...',
noTemplates: 'No custom templates yet',
templateNameRequired: 'Template name is required.',
templateContentRequired: 'Template content is required.',
templateNameDuplicate: 'Template name already exists.',
templateDeleteConfirm: 'Delete this template?',
templateSaved: 'Template saved.',
templateUpdated: 'Template updated.',
templateDeleted: 'Template deleted.',
insertUrl: 'Insert Image from URL',
insert: 'Insert',
cancel: 'Cancel',
@@ -114,6 +141,12 @@ export const translations = {
mediumDesc: '简要分析上下文后建议',
highDesc: '深度逐步分析(最慢但质量最高)',
debounceTime: '防抖时间',
proMode: 'PRO模式思考',
proModeThinking: 'PRO 正在思考',
proModel: 'PRO 模型',
proModelPlaceholder: '例如 qwen3:32b',
proModelDesc: '可选。仅在 PRO 模式下使用的更强模型名称。',
proModelEmptyHint: '留空则使用后端默认 PRO 模型。',
privacyPreferences: '隐私与偏好',
privacyMode: '隐私模式',
privacyDesc: '不向 AI 发送 IP 地址和偏好设置',
@@ -142,6 +175,27 @@ export const translations = {
uploading: '正在上传文件...',
enableAI: '启用 AI',
disableAI: '禁用 AI',
template: '模板',
presetTemplates: '预设模板',
customTemplates: '自定义模板',
newTemplate: '新建模板',
previewTemplate: '预览模板',
applyTemplate: '应用模板',
copyAsTemplate: '复制为自定义模板',
editTemplate: '编辑模板',
saveTemplate: '保存模板',
templateName: '模板名称',
templateContent: '模板内容',
templateNamePlaceholder: '例如:会议纪要',
templateContentPlaceholder: '在这里输入模板内容...',
noTemplates: '暂无自定义模板',
templateNameRequired: '请输入模板名称',
templateContentRequired: '请输入模板内容',
templateNameDuplicate: '模板名称已存在',
templateDeleteConfirm: '确认删除此模板吗?',
templateSaved: '模板已保存',
templateUpdated: '模板已更新',
templateDeleted: '模板已删除',
insertUrl: '通过 URL 插入图片',
insert: '插入',
cancel: '取消',

72
src/utils/proBlock.js Normal file
View File

@@ -0,0 +1,72 @@
export const PRO_BLOCK_NODE_TYPE = 'pro_block'
export const PRO_TRIGGER_TEXT = '[PRO]'
export const PRO_DISPLAY_LABEL = 'PRO模式思考'
const PRO_PENDING_PREFIX = '[Pro][{'
const PRO_PENDING_SUFFIX = '}]'
const PRO_TRIGGER_RE = /^\[pro\]$/i
function normalizeMarkdownText(value = '') {
return String(value || '').replace(/\r\n?/g, '\n')
}
export function escapeProBlockContent(value = '') {
return normalizeMarkdownText(value)
.replace(/\\/g, '\\\\')
.replace(/\n/g, '\\n')
.replace(/]/g, '\\]')
.replace(/}/g, '\\}')
}
export function unescapeProBlockContent(value = '') {
const normalized = String(value || '')
let result = ''
for (let index = 0; index < normalized.length; index += 1) {
const char = normalized[index]
if (char !== '\\' || index === normalized.length - 1) {
result += char
continue
}
const next = normalized[index + 1]
if (next === 'n') {
result += '\n'
} else {
result += next
}
index += 1
}
return result
}
export function serializeProBlockSyntax(content = '') {
const normalized = normalizeMarkdownText(content)
if (!normalized) return PRO_TRIGGER_TEXT
return `${PRO_PENDING_PREFIX}${escapeProBlockContent(normalized)}${PRO_PENDING_SUFFIX}`
}
export function parseProBlockSyntax(value = '') {
const text = normalizeMarkdownText(value).trim()
if (!text) return null
if (PRO_TRIGGER_RE.test(text)) {
return {
content: '',
autoStart: true,
}
}
const prefix = text.slice(0, PRO_PENDING_PREFIX.length)
if (prefix.toLowerCase() === PRO_PENDING_PREFIX.toLowerCase() && text.endsWith(PRO_PENDING_SUFFIX)) {
return {
content: unescapeProBlockContent(
text.slice(PRO_PENDING_PREFIX.length, text.length - PRO_PENDING_SUFFIX.length)
),
autoStart: false,
}
}
return null
}

220
src/utils/uploadBlock.js Normal file
View File

@@ -0,0 +1,220 @@
export const UPLOAD_BLOCK_NODE_TYPE = 'upload_block'
export const DEFAULT_UPLOAD_BLOCK_TYPES = [
'docx',
'pptx',
'pdf',
'txt',
'json',
'toml',
'yaml',
'images',
]
const TYPE_ALIAS = {
doc: 'docx',
docx: 'docx',
word: 'docx',
ppt: 'pptx',
pptx: 'pptx',
powerpoint: 'pptx',
pdf: 'pdf',
txt: 'txt',
text: 'txt',
plain: 'txt',
json: 'json',
toml: 'toml',
yaml: 'yaml',
yml: 'yaml',
'[images]': 'images',
image: 'images',
images: 'images',
}
const TYPE_LABELS = {
docx: 'DOCX',
pptx: 'PPTX',
pdf: 'PDF',
txt: 'TXT',
json: 'JSON',
toml: 'TOML',
yaml: 'YAML',
images: '图片',
}
const TYPE_ACCEPT_MAP = {
docx: [
'.docx',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
],
pptx: [
'.pptx',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
],
pdf: [
'.pdf',
'application/pdf',
],
txt: [
'.txt',
'text/plain',
],
json: [
'.json',
'application/json',
],
toml: [
'.toml',
'application/toml',
'text/toml',
],
yaml: [
'.yaml',
'.yml',
'text/yaml',
'text/x-yaml',
'application/x-yaml',
],
images: ['image/*'],
}
const IMAGE_EXT_RE = /\.(png|jpe?g|gif|webp|bmp|svg|heic|heif|avif)$/i
const SYNTAX_RE = /^\{\{\{([\s\S]*?)\}\}\}$/
const UPLOAD_TYPE_PREFIX_RE = /^upload\s+file\s+type\s*:\s*(.+)$/i
function parseStrictUploadBlockTypes(values = []) {
const normalized = []
for (const value of values) {
const next = normalizeUploadBlockType(value)
if (!next) return null
if (!normalized.includes(next)) {
normalized.push(next)
}
}
return normalized.length > 0 ? normalized : null
}
export function normalizeUploadBlockType(value = '') {
const key = String(value || '').trim().toLowerCase()
return TYPE_ALIAS[key] || ''
}
export function normalizeUploadBlockTypes(value) {
const source = Array.isArray(value) ? value : []
const normalized = []
for (const item of source) {
const next = normalizeUploadBlockType(item)
if (!next || normalized.includes(next)) continue
normalized.push(next)
}
return normalized.length > 0 ? normalized : [...DEFAULT_UPLOAD_BLOCK_TYPES]
}
export function getUploadBlockMenuOptions(allowedTypes = DEFAULT_UPLOAD_BLOCK_TYPES) {
return normalizeUploadBlockTypes(allowedTypes).map((type) => ({
value: type,
label: TYPE_LABELS[type] || type.toUpperCase(),
}))
}
export function getUploadBlockAccept(allowedTypes = DEFAULT_UPLOAD_BLOCK_TYPES) {
const entries = new Set()
normalizeUploadBlockTypes(allowedTypes).forEach((type) => {
const values = TYPE_ACCEPT_MAP[type] || []
values.forEach((entry) => entries.add(entry))
})
return Array.from(entries).join(',')
}
export function getUploadBlockAcceptForType(type = '') {
const normalized = normalizeUploadBlockType(type)
if (!normalized) return ''
return (TYPE_ACCEPT_MAP[normalized] || []).join(',')
}
function parseUploadBlockInner(raw = '') {
const inner = String(raw || '').trim()
if (!inner) {
return { allowedTypes: [...DEFAULT_UPLOAD_BLOCK_TYPES] }
}
const matched = inner.match(UPLOAD_TYPE_PREFIX_RE)
if (!matched) return null
const segments = matched[1]
.split(',')
.map((item) => item.trim())
.filter(Boolean)
if (segments.length === 0) return null
const allowedTypes = parseStrictUploadBlockTypes(segments)
if (!allowedTypes) return null
return allowedTypes.length > 0 ? { allowedTypes } : null
}
export function parseUploadBlockSyntax(value = '') {
const matched = String(value || '').trim().match(SYNTAX_RE)
if (!matched) return null
return parseUploadBlockInner(matched[1])
}
export function serializeUploadBlockSyntax(allowedTypes = DEFAULT_UPLOAD_BLOCK_TYPES) {
const normalized = normalizeUploadBlockTypes(allowedTypes)
const isDefault =
normalized.length === DEFAULT_UPLOAD_BLOCK_TYPES.length &&
normalized.every((type, index) => type === DEFAULT_UPLOAD_BLOCK_TYPES[index])
if (isDefault) return '{{{}}}'
const serialized = normalized
.map((type) => (type === 'images' ? '[images]' : type))
.join(',')
return `{{{upload file type:${serialized}}}}`
}
export function isUploadBlockImageFile(file) {
if (!file) return false
const name = String(file.name || '').toLowerCase()
const type = String(file.type || '').toLowerCase()
return type.startsWith('image/') || IMAGE_EXT_RE.test(name)
}
export function getUploadBlockFileType(file) {
if (!file) return ''
if (isUploadBlockImageFile(file)) return 'images'
const name = String(file.name || '').toLowerCase()
if (name.endsWith('.docx')) return 'docx'
if (name.endsWith('.pptx')) return 'pptx'
if (name.endsWith('.pdf')) return 'pdf'
if (name.endsWith('.json')) return 'json'
if (name.endsWith('.toml')) return 'toml'
if (name.endsWith('.yaml') || name.endsWith('.yml')) return 'yaml'
if (name.endsWith('.txt')) return 'txt'
const mime = String(file.type || '').toLowerCase()
if (mime === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') return 'docx'
if (mime === 'application/vnd.openxmlformats-officedocument.presentationml.presentation') return 'pptx'
if (mime === 'application/pdf') return 'pdf'
if (mime === 'application/json') return 'json'
if (mime === 'application/toml' || mime === 'text/toml') return 'toml'
if (mime === 'text/yaml' || mime === 'text/x-yaml' || mime === 'application/x-yaml') return 'yaml'
if (mime === 'text/plain') return 'txt'
return ''
}
export function isUploadBlockTypeAllowed(file, allowedTypes = DEFAULT_UPLOAD_BLOCK_TYPES) {
const fileType = getUploadBlockFileType(file)
if (!fileType) return false
return normalizeUploadBlockTypes(allowedTypes).includes(fileType)
}

View File

@@ -1,46 +0,0 @@
<template>
<div class="univer-view">
<UniverEditor
ref="editorRef"
@back="handleBack"
@document-loaded="handleDocumentLoaded"
@document-changed="handleDocumentChanged"
/>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useOfficeStore } from '../stores/office'
import UniverEditor from '../components/UniverEditor.vue'
const router = useRouter()
const officeStore = useOfficeStore()
const editorRef = ref(null)
function handleBack() {
router.push('/')
}
function handleDocumentLoaded(doc) {
console.log('文档已加载:', doc)
}
function handleDocumentChanged(event) {
console.log('文档已更改:', event)
}
onMounted(() => {
// 设置当前视图为 univer
officeStore.switchView('univer')
})
</script>
<style scoped>
.univer-view {
width: 100%;
height: 100vh;
overflow: hidden;
}
</style>