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:
8
.gitignore
vendored
8
.gitignore
vendored
@@ -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
|
||||
|
||||
190
backend/llm.py
190
backend/llm.py
@@ -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()
|
||||
|
||||
151
backend/main.py
151
backend/main.py
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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
3429
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
## 核心行为
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
221
src/components/HiddenTextCrepe.vue
Normal file
221
src/components/HiddenTextCrepe.vue
Normal 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>
|
||||
@@ -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
245
src/components/OfficePreview.vue
Normal file
245
src/components/OfficePreview.vue
Normal 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>
|
||||
334
src/components/ProBlockCrepe.vue
Normal file
334
src/components/ProBlockCrepe.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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-client(Pro 版本)
|
||||
// 当前实现创建空白文档作为预览占位符
|
||||
|
||||
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>
|
||||
240
src/components/UploadBlockCrepe.vue
Normal file
240
src/components/UploadBlockCrepe.vue
Normal 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>
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
246
src/plugins/hiddenTextPlugin.ts
Normal file
246
src/plugins/hiddenTextPlugin.ts
Normal 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
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
665
src/plugins/proBlockPlugin.ts
Normal file
665
src/plugins/proBlockPlugin.ts
Normal 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(` <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 }
|
||||
263
src/plugins/uploadBlockPlugin.ts
Normal file
263
src/plugins/uploadBlockPlugin.ts
Normal 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
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -12,9 +12,8 @@ const routes = [
|
||||
component: () => import('../views/DocsView.vue')
|
||||
},
|
||||
{
|
||||
path: '/univer',
|
||||
name: 'Univer',
|
||||
component: () => import('../views/UniverView.vue')
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: '/'
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
259
src/stores/templates.js
Normal 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,
|
||||
}
|
||||
})
|
||||
@@ -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;
|
||||
|
||||
190
src/utils/api.js
190
src/utils/api.js
@@ -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',
|
||||
|
||||
@@ -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
162
src/utils/hiddenText.js
Normal file
@@ -0,0 +1,162 @@
|
||||
export const HIDDEN_TEXT_NODE_TYPE = 'hiddenText'
|
||||
|
||||
function escapeHtml(value = '') {
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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
72
src/utils/proBlock.js
Normal 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
220
src/utils/uploadBlock.js
Normal 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)
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user