Skip to content

Commit 74bf3df

Browse files
authored
Merge pull request #1130 from CREDO23/feat/vision-autocomplete
[Feat] Vision-based autocomplete with KB grounding
2 parents 13e3026 + a180bf5 commit 74bf3df

32 files changed

Lines changed: 1481 additions & 32 deletions
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""119_add_vision_llm_id_to_search_spaces
2+
3+
Revision ID: 119
4+
Revises: 118
5+
6+
Adds vision_llm_id column to search_spaces for vision/screenshot analysis
7+
LLM role assignment. Defaults to 0 (Auto mode), same convention as
8+
agent_llm_id and document_summary_llm_id.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
from collections.abc import Sequence
14+
15+
import sqlalchemy as sa
16+
17+
from alembic import op
18+
19+
revision: str = "119"
20+
down_revision: str | None = "118"
21+
branch_labels: str | Sequence[str] | None = None
22+
depends_on: str | Sequence[str] | None = None
23+
24+
25+
def upgrade() -> None:
26+
conn = op.get_bind()
27+
existing_columns = [
28+
col["name"] for col in sa.inspect(conn).get_columns("searchspaces")
29+
]
30+
31+
if "vision_llm_id" not in existing_columns:
32+
op.add_column(
33+
"searchspaces",
34+
sa.Column("vision_llm_id", sa.Integer(), nullable=True, server_default="0"),
35+
)
36+
37+
38+
def downgrade() -> None:
39+
op.drop_column("searchspaces", "vision_llm_id")

surfsense_backend/app/db.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1351,6 +1351,9 @@ class SearchSpace(BaseModel, TimestampMixin):
13511351
image_generation_config_id = Column(
13521352
Integer, nullable=True, default=0
13531353
) # For image generation, defaults to Auto mode
1354+
vision_llm_id = Column(
1355+
Integer, nullable=True, default=0
1356+
) # For vision/screenshot analysis, defaults to Auto mode
13541357

13551358
user_id = Column(
13561359
UUID(as_uuid=True), ForeignKey("user.id", ondelete="CASCADE"), nullable=False

surfsense_backend/app/routes/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from .airtable_add_connector_route import (
44
router as airtable_add_connector_router,
55
)
6+
from .autocomplete_routes import router as autocomplete_router
67
from .chat_comments_routes import router as chat_comments_router
78
from .circleback_webhook_route import router as circleback_webhook_router
89
from .clickup_add_connector_route import router as clickup_add_connector_router
@@ -95,3 +96,4 @@
9596
router.include_router(stripe_router) # Stripe checkout for additional page packs
9697
router.include_router(youtube_router) # YouTube playlist resolution
9798
router.include_router(prompts_router)
99+
router.include_router(autocomplete_router) # Lightweight autocomplete with KB context
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
from fastapi import APIRouter, Depends, HTTPException
2+
from fastapi.responses import StreamingResponse
3+
from pydantic import BaseModel, Field
4+
from sqlalchemy.ext.asyncio import AsyncSession
5+
6+
from app.db import User, get_async_session
7+
from app.services.new_streaming_service import VercelStreamingService
8+
from app.services.vision_autocomplete_service import stream_vision_autocomplete
9+
from app.users import current_active_user
10+
from app.utils.rbac import check_search_space_access
11+
12+
router = APIRouter(prefix="/autocomplete", tags=["autocomplete"])
13+
14+
MAX_SCREENSHOT_SIZE = 20 * 1024 * 1024 # 20 MB base64 ceiling
15+
16+
17+
class VisionAutocompleteRequest(BaseModel):
18+
screenshot: str = Field(..., max_length=MAX_SCREENSHOT_SIZE)
19+
search_space_id: int
20+
app_name: str = ""
21+
window_title: str = ""
22+
23+
24+
@router.post("/vision/stream")
25+
async def vision_autocomplete_stream(
26+
body: VisionAutocompleteRequest,
27+
user: User = Depends(current_active_user),
28+
session: AsyncSession = Depends(get_async_session),
29+
):
30+
await check_search_space_access(session, user, body.search_space_id)
31+
32+
return StreamingResponse(
33+
stream_vision_autocomplete(
34+
body.screenshot, body.search_space_id, session,
35+
app_name=body.app_name, window_title=body.window_title,
36+
),
37+
media_type="text/event-stream",
38+
headers={
39+
**VercelStreamingService.get_response_headers(),
40+
"X-Accel-Buffering": "no",
41+
},
42+
)

surfsense_backend/app/routes/search_spaces_routes.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -522,14 +522,17 @@ async def get_llm_preferences(
522522
image_generation_config = await _get_image_gen_config_by_id(
523523
session, search_space.image_generation_config_id
524524
)
525+
vision_llm = await _get_llm_config_by_id(session, search_space.vision_llm_id)
525526

526527
return LLMPreferencesRead(
527528
agent_llm_id=search_space.agent_llm_id,
528529
document_summary_llm_id=search_space.document_summary_llm_id,
529530
image_generation_config_id=search_space.image_generation_config_id,
531+
vision_llm_id=search_space.vision_llm_id,
530532
agent_llm=agent_llm,
531533
document_summary_llm=document_summary_llm,
532534
image_generation_config=image_generation_config,
535+
vision_llm=vision_llm,
533536
)
534537

535538
except HTTPException:
@@ -589,14 +592,17 @@ async def update_llm_preferences(
589592
image_generation_config = await _get_image_gen_config_by_id(
590593
session, search_space.image_generation_config_id
591594
)
595+
vision_llm = await _get_llm_config_by_id(session, search_space.vision_llm_id)
592596

593597
return LLMPreferencesRead(
594598
agent_llm_id=search_space.agent_llm_id,
595599
document_summary_llm_id=search_space.document_summary_llm_id,
596600
image_generation_config_id=search_space.image_generation_config_id,
601+
vision_llm_id=search_space.vision_llm_id,
597602
agent_llm=agent_llm,
598603
document_summary_llm=document_summary_llm,
599604
image_generation_config=image_generation_config,
605+
vision_llm=vision_llm,
600606
)
601607

602608
except HTTPException:

surfsense_backend/app/schemas/new_llm_config.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,9 @@ class LLMPreferencesRead(BaseModel):
182182
image_generation_config_id: int | None = Field(
183183
None, description="ID of the image generation config to use"
184184
)
185+
vision_llm_id: int | None = Field(
186+
None, description="ID of the LLM config to use for vision/screenshot analysis"
187+
)
185188
agent_llm: dict[str, Any] | None = Field(
186189
None, description="Full config for agent LLM"
187190
)
@@ -191,6 +194,9 @@ class LLMPreferencesRead(BaseModel):
191194
image_generation_config: dict[str, Any] | None = Field(
192195
None, description="Full config for image generation"
193196
)
197+
vision_llm: dict[str, Any] | None = Field(
198+
None, description="Full config for vision LLM"
199+
)
194200

195201
model_config = ConfigDict(from_attributes=True)
196202

@@ -207,3 +213,6 @@ class LLMPreferencesUpdate(BaseModel):
207213
image_generation_config_id: int | None = Field(
208214
None, description="ID of the image generation config to use"
209215
)
216+
vision_llm_id: int | None = Field(
217+
None, description="ID of the LLM config to use for vision/screenshot analysis"
218+
)

surfsense_backend/app/services/llm_service.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
class LLMRole:
3333
AGENT = "agent" # For agent/chat operations
3434
DOCUMENT_SUMMARY = "document_summary" # For document summarization
35+
VISION = "vision" # For vision/screenshot analysis
3536

3637

3738
def get_global_llm_config(llm_config_id: int) -> dict | None:
@@ -187,7 +188,7 @@ async def get_search_space_llm_instance(
187188
Args:
188189
session: Database session
189190
search_space_id: Search Space ID
190-
role: LLM role ('agent' or 'document_summary')
191+
role: LLM role ('agent', 'document_summary', or 'vision')
191192
192193
Returns:
193194
ChatLiteLLM or ChatLiteLLMRouter instance, or None if not found
@@ -209,6 +210,8 @@ async def get_search_space_llm_instance(
209210
llm_config_id = search_space.agent_llm_id
210211
elif role == LLMRole.DOCUMENT_SUMMARY:
211212
llm_config_id = search_space.document_summary_llm_id
213+
elif role == LLMRole.VISION:
214+
llm_config_id = search_space.vision_llm_id
212215
else:
213216
logger.error(f"Invalid LLM role: {role}")
214217
return None
@@ -405,6 +408,13 @@ async def get_document_summary_llm(
405408
)
406409

407410

411+
async def get_vision_llm(
412+
session: AsyncSession, search_space_id: int
413+
) -> ChatLiteLLM | ChatLiteLLMRouter | None:
414+
"""Get the search space's vision LLM instance for screenshot analysis."""
415+
return await get_search_space_llm_instance(session, search_space_id, LLMRole.VISION)
416+
417+
408418
# Backward-compatible alias (LLM preferences are now per-search-space, not per-user)
409419
async def get_user_long_context_llm(
410420
session: AsyncSession,

0 commit comments

Comments
 (0)