feat(editor): add image insertion with OCR support and size limit handling
Add image button with dropdown menu for uploading local images or inserting from URL. Integrate VLM-based OCR to extract text context from images and include in AI suggestions. Implement document size limits to disable AI when exceeding threshold. Refactor copilot plugin with per-view runtime state and OCR context injection. Add OCR cache utility for managing image metadata. Add code splitting configuration for optimized bundle size.
This commit is contained in:
+97
-12
@@ -3,9 +3,18 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import StreamingResponse, JSONResponse
|
||||
from pydantic import BaseModel
|
||||
import json
|
||||
import base64
|
||||
import uuid
|
||||
import logging
|
||||
|
||||
from prompt import build_prompt
|
||||
from llm import call_ollama
|
||||
from llm import call_ollama, call_vlm_ocr
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(name)s - %(message)s",
|
||||
)
|
||||
logger = logging.getLogger("api")
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
@@ -22,24 +31,100 @@ class CompletionRequest(BaseModel):
|
||||
suffix: str
|
||||
languageId: str = 'markdown'
|
||||
|
||||
class OCRRequest(BaseModel):
|
||||
image: str
|
||||
filename: str = "image.jpg"
|
||||
language: str = 'auto'
|
||||
|
||||
|
||||
def _preview(text: str, limit: int = 80) -> str:
|
||||
value = (text or "").replace("\n", "\\n")
|
||||
if len(value) <= limit:
|
||||
return value
|
||||
return value[:limit] + "..."
|
||||
|
||||
|
||||
def _build_force_non_empty_prompt(base_prompt: str) -> str:
|
||||
return (
|
||||
base_prompt
|
||||
+ "\n\nStrict override for this request:\n"
|
||||
+ "- Output must be non-empty.\n"
|
||||
+ "- If you would otherwise output empty, output a single space.\n"
|
||||
+ "- Keep it short and do not repeat SUFFIX.\n"
|
||||
)
|
||||
|
||||
|
||||
@app.post("/v1/completions")
|
||||
async def create_completion(request: CompletionRequest):
|
||||
request_id = str(uuid.uuid4())[:8]
|
||||
try:
|
||||
prompt = build_prompt(request.prefix, request.suffix)
|
||||
result = await call_ollama(prompt)
|
||||
|
||||
content = result["content"]
|
||||
|
||||
logger.info(
|
||||
"[%s] /v1/completions prefix_chars=%d suffix_chars=%d lang=%s prefix_tail='%s' suffix_head='%s'",
|
||||
request_id,
|
||||
len(request.prefix or ""),
|
||||
len(request.suffix or ""),
|
||||
request.languageId,
|
||||
_preview((request.prefix or "")[-120:]),
|
||||
_preview((request.suffix or "")[:120]),
|
||||
)
|
||||
prompt = build_prompt(request.prefix, request.suffix, request.languageId)
|
||||
result = await call_ollama(prompt, tag=f"{request_id}-primary", temperature=0.7)
|
||||
|
||||
content = result["content"] or ""
|
||||
source = "primary"
|
||||
if not content.strip():
|
||||
logger.warning("[%s] primary returned empty content, starting retry", request_id)
|
||||
retry_prompt = _build_force_non_empty_prompt(prompt)
|
||||
retry_result = await call_ollama(retry_prompt, tag=f"{request_id}-retry1", temperature=0.4)
|
||||
content = retry_result["content"] or ""
|
||||
source = "retry1"
|
||||
|
||||
if not content.strip():
|
||||
content = " "
|
||||
source = "fallback-space"
|
||||
logger.warning("[%s] retry still empty, forcing single-space fallback", request_id)
|
||||
|
||||
logger.info(
|
||||
"[%s] completion resolved source=%s content_chars=%d content_preview='%s'",
|
||||
request_id,
|
||||
source,
|
||||
len(content),
|
||||
_preview(content, 120),
|
||||
)
|
||||
|
||||
async def generate():
|
||||
if content:
|
||||
yield f"data: {json.dumps({'content': content})}\n\n"
|
||||
yield f"data: {json.dumps({'content': content})}\n\n"
|
||||
yield f"data: {json.dumps({'done': True})}\n\n"
|
||||
|
||||
|
||||
return StreamingResponse(generate(), media_type="text/event-stream")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
logger.exception("[%s] /v1/completions failed: %s", request_id, e)
|
||||
return JSONResponse(content={"error": str(e)}, status_code=500)
|
||||
|
||||
@app.post("/v1/ocr")
|
||||
async def ocr_image(request: OCRRequest):
|
||||
request_id = str(uuid.uuid4())[:8]
|
||||
try:
|
||||
logger.info(
|
||||
"[%s] /v1/ocr filename=%s language=%s image_base64_chars=%d",
|
||||
request_id,
|
||||
request.filename,
|
||||
request.language,
|
||||
len(request.image or ""),
|
||||
)
|
||||
image_bytes = base64.b64decode(request.image)
|
||||
logger.info("[%s] /v1/ocr decoded image_bytes=%d", request_id, len(image_bytes))
|
||||
result = await call_vlm_ocr(image_bytes, request.language)
|
||||
logger.info(
|
||||
"[%s] /v1/ocr success text_chars=%d text_preview='%s'",
|
||||
request_id,
|
||||
len(result or ""),
|
||||
_preview(result or "", 120),
|
||||
)
|
||||
return {"text": result, "filename": request.filename}
|
||||
except Exception as e:
|
||||
logger.exception("[%s] /v1/ocr failed: %s", request_id, e)
|
||||
return JSONResponse(content={"error": str(e)}, status_code=500)
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user