Stabilize pro editing without heavy office runtime

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

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

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

Confidence: medium

Scope-risk: broad

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

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

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

Co-authored-by: OmX <omx@oh-my-codex.dev>
This commit is contained in:
2026-05-24 23:30:32 +08:00
parent 6dc9933853
commit 59334e4057
41 changed files with 4438 additions and 4875 deletions
+171 -19
View File
@@ -3,6 +3,7 @@ import time
import logging
import asyncio
from datetime import datetime
from typing import AsyncIterator
import ollama
from dotenv import load_dotenv
@@ -11,6 +12,7 @@ from prompts import get_vlm_ocr_prompt
load_dotenv()
OLLAMA_MODEL = os.getenv('OLLAMA_MODEL', 'gpt-oss:20b')
PRO_OLLAMA_MODEL = os.getenv('PRO_OLLAMA_MODEL', OLLAMA_MODEL)
OLLAMA_HOST = os.getenv('OLLAMA_HOST', 'http://localhost:11434')
VLM_MODEL = os.getenv('VLM_MODEL', 'qwen3-vl:30b')
@@ -29,14 +31,73 @@ def _extract_message(response) -> tuple[str, str]:
if hasattr(response, 'message') and response.message:
content = response.message.content or ""
thinking = getattr(response.message, 'thinking', '') or ""
elif isinstance(response, dict):
elif isinstance(response, dict) and 'message' in response:
msg = response.get('message', {})
content = msg.get('content', '') or ""
thinking = msg.get('thinking', '') or ""
# fallback for generate
if not content:
if hasattr(response, 'response'):
content = getattr(response, 'response', '') or ""
elif isinstance(response, dict) and 'response' in response:
content = response.get('response', '') or ""
return content, thinking
def _build_prompt(prompt: str, system_prompt: str | None = None) -> str:
if system_prompt and system_prompt.strip():
return f"{system_prompt}\n\n{prompt}"
return prompt
def _resolve_model_name(model: str | None = None, *, use_pro_model: bool = False) -> str:
candidate = (model or '').strip()
if candidate:
return candidate
return PRO_OLLAMA_MODEL if use_pro_model else OLLAMA_MODEL
def _build_generate_kwargs(
prompt: str,
*,
system_prompt: str | None = None,
temperature: float = 0.7,
thinking: str | None = None,
model: str | None = None,
use_pro_model: bool = False,
stream: bool = False,
) -> dict:
kwargs = {
"model": _resolve_model_name(model, use_pro_model=use_pro_model),
"prompt": _build_prompt(prompt, system_prompt),
"stream": stream,
"raw": True,
"options": {
'temperature': temperature,
'repeat_penalty': 1.1,
},
}
if thinking:
kwargs["think"] = thinking
return kwargs
def _extract_stream_text(chunk) -> str:
content, _ = _extract_message(chunk)
if content:
return content
if isinstance(chunk, dict):
return chunk.get('response', '') or ''
if hasattr(chunk, 'response'):
return getattr(chunk, 'response', '') or ''
return ''
async def call_ollama(
prompt: str,
*,
@@ -44,16 +105,19 @@ async def call_ollama(
tag: str = "default",
temperature: float = 0.7,
thinking: str | None = None,
model: str | None = None,
use_pro_model: bool = False,
) -> dict:
"""
调用 Ollama API 并返回 content 和 thinking。
"""
start = time.perf_counter()
start_dt = datetime.now()
model_name = _resolve_model_name(model, use_pro_model=use_pro_model)
logger.info(
"[LLM][%s] request model=%s host=%s prompt_chars=%d system_chars=%d temp=%.2f thinking=%s",
tag,
OLLAMA_MODEL,
model_name,
OLLAMA_HOST,
len(prompt),
len(system_prompt or ""),
@@ -62,24 +126,17 @@ async def call_ollama(
)
try:
messages = []
if system_prompt and system_prompt.strip():
messages.append({"role": "system", "content": system_prompt})
messages.append({"role": "user", "content": prompt})
kwargs = _build_generate_kwargs(
prompt,
system_prompt=system_prompt,
temperature=temperature,
thinking=thinking,
model=model,
use_pro_model=use_pro_model,
stream=False,
)
kwargs = {
"model": OLLAMA_MODEL,
"messages": messages,
"stream": False,
"options": {
'temperature': temperature,
'repeat_penalty': 1.1,
},
}
if thinking:
kwargs["think"] = thinking
response = await asyncio.wait_for(client.chat(**kwargs), timeout=COMPLETION_TIMEOUT)
response = await asyncio.wait_for(client.generate(**kwargs), timeout=COMPLETION_TIMEOUT)
except asyncio.CancelledError:
elapsed_ms = (time.perf_counter() - start) * 1000
end_dt = datetime.now()
@@ -126,6 +183,101 @@ async def call_ollama(
return {"content": content, "think": thinking}
async def stream_ollama(
prompt: str,
*,
system_prompt: str | None = None,
tag: str = "default-stream",
temperature: float = 0.7,
thinking: str | None = None,
model: str | None = None,
use_pro_model: bool = False,
) -> AsyncIterator[str]:
start = time.perf_counter()
start_dt = datetime.now()
model_name = _resolve_model_name(model, use_pro_model=use_pro_model)
yielded_chars = 0
logger.info(
"[LLM][%s] stream request model=%s host=%s prompt_chars=%d system_chars=%d temp=%.2f thinking=%s",
tag,
model_name,
OLLAMA_HOST,
len(prompt),
len(system_prompt or ""),
temperature,
thinking,
)
try:
kwargs = _build_generate_kwargs(
prompt,
system_prompt=system_prompt,
temperature=temperature,
thinking=thinking,
model=model,
use_pro_model=use_pro_model,
stream=True,
)
stream = await client.generate(**kwargs)
iterator = stream.__aiter__()
deadline = time.perf_counter() + COMPLETION_TIMEOUT
while True:
remaining = deadline - time.perf_counter()
if remaining <= 0:
raise TimeoutError("LLM stream timed out")
try:
chunk = await asyncio.wait_for(iterator.__anext__(), timeout=remaining)
except StopAsyncIteration:
break
text = _extract_stream_text(chunk)
if not text:
continue
yielded_chars += len(text)
yield text
except asyncio.CancelledError:
elapsed_ms = (time.perf_counter() - start) * 1000
end_dt = datetime.now()
logger.info(
"[LLM][%s] stream_time [%s --> %s]",
tag,
start_dt.strftime("%H:%M:%S"),
end_dt.strftime("%H:%M:%S"),
)
logger.warning("[LLM][%s] stream cancelled after %.1fms", tag, elapsed_ms)
raise
except Exception:
elapsed_ms = (time.perf_counter() - start) * 1000
end_dt = datetime.now()
logger.info(
"[LLM][%s] stream_time [%s --> %s]",
tag,
start_dt.strftime("%H:%M:%S"),
end_dt.strftime("%H:%M:%S"),
)
logger.exception("[LLM][%s] stream failed after %.1fms", tag, elapsed_ms)
raise
elapsed_ms = (time.perf_counter() - start) * 1000
end_dt = datetime.now()
logger.info(
"[LLM][%s] stream_time [%s --> %s]",
tag,
start_dt.strftime("%H:%M:%S"),
end_dt.strftime("%H:%M:%S"),
)
logger.info(
"[LLM][%s] stream finished in %.1fms yielded_chars=%d",
tag,
elapsed_ms,
yielded_chars,
)
async def call_vlm_ocr(image_bytes: bytes, language: str = 'auto') -> str:
start = time.perf_counter()
start_dt = datetime.now()
+146 -5
View File
@@ -1,5 +1,6 @@
import asyncio
import base64
import json
import logging
import os
import re
@@ -11,12 +12,12 @@ from typing import Optional
from fastapi import FastAPI, HTTPException, Request, Security
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.responses import JSONResponse, StreamingResponse
from fastapi.security import APIKeyHeader
from pydantic import BaseModel
from geoip import get_ip_location_text
from llm import call_ollama, call_vlm_ocr
from llm import call_ollama, call_vlm_ocr, stream_ollama
from models import UserPreferences
from prompt import build_completion_prompts, prepare_prompt_context
import markitdown
@@ -78,6 +79,8 @@ class CompletionRequest(BaseModel):
model_thinking: str = "low"
privacy_mode: bool = False
user_preferences: Optional[UserPreferences] = None
model: Optional[str] = None
temperature: float = 0.7
class CancelCompletionRequest(BaseModel):
@@ -143,6 +146,14 @@ def get_client_ip(request: Request) -> str:
return request.headers.get("X-Client-IP") or "unknown"
def _clamp_temperature(value: float, default: float = 0.7) -> float:
try:
numeric = float(value)
except (TypeError, ValueError):
return default
return max(0.0, min(numeric, 1.2))
@app.post("/v1/completions")
async def create_completion(request: Request, req: CompletionRequest, api_key: str = Security(get_api_key)):
request_id = request.headers.get("X-Request-Id") or str(uuid.uuid4())
@@ -189,8 +200,9 @@ async def create_completion(request: Request, req: CompletionRequest, api_key: s
user_prompt,
system_prompt=system_prompt,
tag=f"{request_tag}-primary",
temperature=0.7,
temperature=_clamp_temperature(req.temperature, 0.7),
thinking=req.model_thinking if req.model_thinking != "none" else None,
model=req.model,
)
)
@@ -224,6 +236,124 @@ async def create_completion(request: Request, req: CompletionRequest, api_key: s
ACTIVE_COMPLETIONS.pop(request_id, None)
@app.post("/v1/pro/completions/stream")
async def create_pro_completion_stream(request: Request, req: CompletionRequest, api_key: str = Security(get_api_key)):
request_id = request.headers.get("X-Request-Id") or str(uuid.uuid4())
request_tag = request_id[:8]
queue: asyncio.Queue[Optional[tuple[str, str]]] = asyncio.Queue()
client_ip = "hidden"
location = ""
if not req.privacy_mode: # pragma: no cover
client_ip = get_client_ip(request)
location = get_ip_location_text(client_ip)
if location:
logger.info("[%s] client_location=%s", request_tag, location)
logger.info(
"[%s] /v1/pro/completions/stream request_id=%s client_ip=%s prefix_chars=%d suffix_chars=%d lang=%s thinking=%s privacy=%s model=%s temp=%.2f",
request_tag,
request_id,
client_ip,
len(req.prefix or ""),
len(req.suffix or ""),
req.languageId,
req.model_thinking,
req.privacy_mode,
req.model or "",
_clamp_temperature(req.temperature, 0.7),
)
llm_prefix, llm_suffix = prepare_prompt_context(req.prefix or "", req.suffix or "")
logger.info("[%s] pro_llm_input_prefix=%r", request_tag, llm_prefix)
logger.info("[%s] pro_llm_input_suffix=%r", request_tag, llm_suffix)
system_prompt, user_prompt = build_completion_prompts(
req.prefix,
req.suffix,
req.languageId,
location=location,
thinking_level=req.model_thinking,
preferences=req.user_preferences,
)
async def producer() -> None:
chunks: list[str] = []
try:
async for delta in stream_ollama(
user_prompt,
system_prompt=system_prompt,
tag=f"{request_tag}-pro",
temperature=_clamp_temperature(req.temperature, 0.7),
thinking=req.model_thinking if req.model_thinking != "none" else None,
model=req.model,
use_pro_model=True,
):
chunks.append(delta)
await queue.put(("chunk", json.dumps({"delta": delta}, ensure_ascii=False)))
content = "".join(chunks)
logger.info(
"[%s] pro stream resolved request_id=%s content_chars=%d content_preview='%s'",
request_tag,
request_id,
len(content),
_preview(content, 120),
)
await queue.put((
"done",
json.dumps({"content": content, "request_id": request_id}, ensure_ascii=False),
))
except asyncio.CancelledError:
logger.info("[%s] /v1/pro/completions/stream cancelled request_id=%s", request_tag, request_id)
await queue.put((
"cancelled",
json.dumps({"cancelled": True, "request_id": request_id}, ensure_ascii=False),
))
raise
except Exception as e:
logger.exception("[%s] /v1/pro/completions/stream failed request_id=%s: %s", request_tag, request_id, e)
await queue.put((
"error",
json.dumps({"error": str(e), "request_id": request_id}, ensure_ascii=False),
))
finally:
await queue.put(None)
producer_task = asyncio.create_task(producer())
existing = ACTIVE_COMPLETIONS.get(request_id)
if existing and not existing.done():
existing.cancel()
ACTIVE_COMPLETIONS[request_id] = producer_task
async def event_stream():
try:
while True:
item = await queue.get()
if item is None:
break
event_name, data = item
yield f"event: {event_name}\ndata: {data}\n\n"
except asyncio.CancelledError:
producer_task.cancel()
raise
finally:
active = ACTIVE_COMPLETIONS.get(request_id)
if active is producer_task:
ACTIVE_COMPLETIONS.pop(request_id, None)
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)
@app.post("/v1/completions/cancel")
async def cancel_completion(req: CancelCompletionRequest, api_key: str = Security(get_api_key)):
request_tag = str(uuid.uuid4())[:8]
@@ -349,8 +479,19 @@ async def convert_to_markdown(request: ConvertRequest, api_key: str = Security(g
# TTS and ASR routes (lazy loaded to avoid heavy import on startup)
def _register_tts_asr_routes():
from tts_asr import register_tts_asr_routes
register_tts_asr_routes(app)
try:
from tts_asr import register_tts_asr_routes
except ModuleNotFoundError as exc:
logger.warning("Skipping TTS/ASR route registration because a dependency is missing: %s", exc)
return
except Exception as exc:
logger.warning("Skipping TTS/ASR route registration because import failed: %s", exc)
return
try:
register_tts_asr_routes(app)
except Exception as exc:
logger.warning("Failed to register TTS/ASR routes: %s", exc)
_register_tts_asr_routes()
+1 -9
View File
@@ -331,15 +331,7 @@ Step 3: Choose newline type
=== NOW COMPLETE THE TASK ===
<PREFIX>
{recent_prefix}
</PREFIX>
<SUFFIX>
{recent_suffix}
</SUFFIX>
Output:"""
<|fim_prefix|>{recent_prefix}<|fim_suffix|>{recent_suffix}<|fim_middle|>"""
system_prompt = build_inline_system_prompt(safe_language_id)
return system_prompt.strip(), user_prompt.strip()
+12 -15
View File
@@ -19,11 +19,11 @@ except ModuleNotFoundError:
def test_call_ollama_messages_roles_with_system(monkeypatch):
captured = {}
async def fake_chat(**kwargs):
captured["messages"] = kwargs["messages"]
return {"message": {"content": "ok", "thinking": ""}}
async def fake_generate(**kwargs):
captured["kwargs"] = kwargs
return {"response": "ok"}
monkeypatch.setattr(llm.client, "chat", fake_chat)
monkeypatch.setattr(llm.client, "generate", fake_generate)
result = asyncio.run(
llm.call_ollama(
@@ -35,20 +35,18 @@ def test_call_ollama_messages_roles_with_system(monkeypatch):
)
assert result["content"] == "ok"
assert captured["messages"][0]["role"] == "system"
assert captured["messages"][0]["content"] == "system prompt body"
assert captured["messages"][1]["role"] == "user"
assert captured["messages"][1]["content"] == "user prompt body"
assert captured["kwargs"]["prompt"] == "system prompt body\n\nuser prompt body"
assert captured["kwargs"]["raw"] is True
def test_call_ollama_messages_roles_without_system(monkeypatch):
captured = {}
async def fake_chat(**kwargs):
captured["messages"] = kwargs["messages"]
return {"message": {"content": "ok", "thinking": ""}}
async def fake_generate(**kwargs):
captured["kwargs"] = kwargs
return {"response": "ok"}
monkeypatch.setattr(llm.client, "chat", fake_chat)
monkeypatch.setattr(llm.client, "generate", fake_generate)
result = asyncio.run(
llm.call_ollama(
@@ -60,6 +58,5 @@ def test_call_ollama_messages_roles_without_system(monkeypatch):
)
assert result["content"] == "ok"
assert len(captured["messages"]) == 1
assert captured["messages"][0]["role"] == "user"
assert captured["messages"][0]["content"] == "user prompt only"
assert captured["kwargs"]["prompt"] == "user prompt only"
assert captured["kwargs"]["raw"] is True
+67 -23
View File
@@ -85,46 +85,45 @@ def test_extract_message_empty_dict():
def test_call_ollama_no_system_message(monkeypatch):
captured = {}
async def fake_chat(**kwargs):
captured["messages"] = kwargs.get("messages", [])
return {"message": {"content": "ok", "thinking": ""}}
async def fake_generate(**kwargs):
captured["kwargs"] = kwargs
return {"response": "ok"}
monkeypatch.setattr(llm.client, "chat", fake_chat)
monkeypatch.setattr(llm.client, "generate", fake_generate)
result = asyncio.run(
llm.call_ollama("user prompt body", system_prompt=None, tag="no-system", temperature=0.1)
)
assert result["content"] == "ok"
assert len(captured["messages"]) == 1
assert captured["messages"][0]["role"] == "user"
assert captured["messages"][0]["content"] == "user prompt body"
assert captured["kwargs"]["prompt"] == "user prompt body"
assert captured["kwargs"]["raw"] is True
def test_call_ollama_whitespace_system_message(monkeypatch):
captured = {}
async def fake_chat(**kwargs):
captured["messages"] = kwargs.get("messages", [])
return {"message": {"content": "ok", "thinking": ""}}
async def fake_generate(**kwargs):
captured["kwargs"] = kwargs
return {"response": "ok"}
monkeypatch.setattr(llm.client, "chat", fake_chat)
monkeypatch.setattr(llm.client, "generate", fake_generate)
result = asyncio.run(
llm.call_ollama("user prompt", system_prompt=" ", tag="whitespace-system", temperature=0.1)
)
assert result["content"] == "ok"
assert len(captured["messages"]) == 1
assert captured["messages"][0]["role"] == "user"
assert captured["kwargs"]["prompt"] == "user prompt"
assert captured["kwargs"]["raw"] is True
def test_call_ollama_thinking_in_kwargs(monkeypatch):
captured = {}
async def fake_chat(**kwargs):
async def fake_generate(**kwargs):
captured.update(kwargs)
return {"message": {"content": "ok", "thinking": "boom"}}
return {"response": "ok", "message": {"thinking": "boom"}} # keep thinking for backward test though it might not be perfect
monkeypatch.setattr(llm.client, "chat", fake_chat)
monkeypatch.setattr(llm.client, "generate", fake_generate)
res = asyncio.run(
llm.call_ollama("prompt", thinking="boom", tag="think-flag", temperature=0.7)
@@ -134,10 +133,10 @@ def test_call_ollama_thinking_in_kwargs(monkeypatch):
def test_call_ollama_cancelled_reraises(monkeypatch):
async def fake_chat(**kwargs):
async def fake_generate(**kwargs):
raise asyncio.CancelledError
monkeypatch.setattr(llm.client, "chat", fake_chat)
monkeypatch.setattr(llm.client, "generate", fake_generate)
with pytest.raises(asyncio.CancelledError):
asyncio.run(
@@ -146,10 +145,10 @@ def test_call_ollama_cancelled_reraises(monkeypatch):
def test_call_ollama_chat_raises_rethrows(monkeypatch):
async def fake_chat(**kwargs):
async def fake_generate(**kwargs):
raise ValueError("boom")
monkeypatch.setattr(llm.client, "chat", fake_chat)
monkeypatch.setattr(llm.client, "generate", fake_generate)
with pytest.raises(ValueError):
asyncio.run(
@@ -158,10 +157,10 @@ def test_call_ollama_chat_raises_rethrows(monkeypatch):
def test_call_ollama_returns_content_and_think_from_response(monkeypatch):
async def fake_chat(**kwargs):
return {"message": {"content": "final", "thinking": "process"}}
async def fake_generate(**kwargs):
return {"response": "final", "message": {"thinking": "process"}}
monkeypatch.setattr(llm.client, "chat", fake_chat)
monkeypatch.setattr(llm.client, "generate", fake_generate)
res = asyncio.run(
llm.call_ollama("prompt", system_prompt=None, tag="return", temperature=0.7)
@@ -169,6 +168,51 @@ def test_call_ollama_returns_content_and_think_from_response(monkeypatch):
assert res["content"] == "final" and res["think"] == "process"
def test_stream_ollama_uses_requested_model_and_yields_chunks(monkeypatch):
captured = {}
class FakeStream:
def __init__(self, chunks):
self._chunks = iter(chunks)
def __aiter__(self):
return self
async def __anext__(self):
try:
return next(self._chunks)
except StopIteration:
raise StopAsyncIteration
async def fake_generate(**kwargs):
captured["kwargs"] = kwargs
return FakeStream([
{"response": "深度"},
{"response": "回答"},
])
monkeypatch.setattr(llm.client, "generate", fake_generate)
chunks = []
async def collect_stream():
async for chunk in llm.stream_ollama(
"prompt",
system_prompt="system",
tag="stream",
temperature=0.8,
model="pro-model",
use_pro_model=True,
):
chunks.append(chunk)
asyncio.run(collect_stream())
assert "".join(chunks) == "深度回答"
assert captured["kwargs"]["model"] == "pro-model"
assert captured["kwargs"]["stream"] is True
def test_call_vlm_ocr_passes_image_and_prompt(monkeypatch):
image_bytes = b"image-bytes"
called = {}
+40
View File
@@ -1,6 +1,7 @@
import os
import sys
import base64
import types
import pytest
from unittest.mock import MagicMock
from fastapi.testclient import TestClient
@@ -10,6 +11,11 @@ BACKEND_DIR = os.path.abspath(os.path.join(CURRENT_DIR, ".."))
if BACKEND_DIR not in sys.path:
sys.path.insert(0, BACKEND_DIR)
if "tts_asr" not in sys.modules:
fake_tts_asr = types.ModuleType("tts_asr")
fake_tts_asr.register_tts_asr_routes = lambda app: None
sys.modules["tts_asr"] = fake_tts_asr
import main # type: ignore
API_KEY = main.API_KEY
@@ -112,6 +118,40 @@ def test_post_completions_privacy_mode(monkeypatch):
assert data.get("content") == "done"
def test_post_pro_stream_returns_sse(monkeypatch):
captured = {}
async def fake_stream(*args, **kwargs):
captured["kwargs"] = kwargs
yield "深度"
yield "回答"
monkeypatch.setattr(main, "stream_ollama", fake_stream)
monkeypatch.setattr(main, "build_completion_prompts", lambda *a, **k: ("sys", "user"))
monkeypatch.setattr(main, "prepare_prompt_context", lambda *a, **k: ("p", "s"))
client = TestClient(main.app)
with client.stream("POST", "/v1/pro/completions/stream", headers=HEADERS, json={
"prefix": "hello",
"suffix": "",
"languageId": "markdown",
"model_thinking": "high",
"privacy_mode": True,
"model": "pro-model",
"temperature": 0.95,
}) as resp:
assert resp.status_code == 200
body = "".join(resp.iter_text())
assert "event: chunk" in body
assert "event: done" in body
assert "深度" in body
assert "回答" in body
assert captured["kwargs"]["model"] == "pro-model"
assert captured["kwargs"]["use_pro_model"] is True
assert main.ACTIVE_COMPLETIONS == {}
def test_post_ocr_mocked(monkeypatch):
async def fake_ocr(*args, **kwargs):
return "OCR result text"