Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/app/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

from .auth import * # noqa: E402, F403
from .ai_embeddings import * # noqa: E402, F403
from .ai_apps import * # noqa: E402, F403
from .ai_scenes import * # noqa: E402, F403
from .apps import * # noqa: E402, F403
from .assets import * # noqa: E402, F403
Expand Down
155 changes: 155 additions & 0 deletions backend/app/api/ai_apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
from datetime import datetime
from http import HTTPStatus
from uuid import uuid4

from fastapi import Depends, HTTPException
from sqlalchemy.orm import Session

from app.database import get_db
from app.models.chat import Chat, ChatMessage
from app.models.settings import get_settings_dict
from app.schemas.ai_apps import AiAppChatRequest, AiAppChatResponse
from app.utils.ai_app import answer_app_question, edit_app_sources, route_app_chat
from . import api_with_auth


def _build_app_context_id(scene_id: str | None, node_id: str | None) -> str | None:
if not scene_id or not node_id:
return None
return f"{scene_id}::{node_id}"


@api_with_auth.post("/ai/apps/chat", response_model=AiAppChatResponse)
async def chat_app(
data: AiAppChatRequest,
db: Session = Depends(get_db),
):
request_id = data.request_id or str(uuid4())
prompt = data.prompt.strip()
if not prompt:
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail="Prompt is required")

if not isinstance(data.sources, dict) or not data.sources:
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail="App sources are required")

chat = None
context_id = _build_app_context_id(data.scene_id, data.node_id)
if data.frame_id is not None:
if data.chat_id:
chat = db.query(Chat).filter(Chat.id == data.chat_id).first()
if chat and chat.frame_id != data.frame_id:
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail="Chat does not belong to frame")
if chat and chat.context_type not in (None, "app"):
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail="Chat context does not match app chat")
if not chat:
if data.chat_id:
chat = Chat(
id=data.chat_id,
frame_id=data.frame_id,
context_type="app",
context_id=context_id,
)
else:
chat = Chat(
frame_id=data.frame_id,
context_type="app",
context_id=context_id,
)
db.add(chat)
db.commit()
db.refresh(chat)
if context_id:
chat.context_type = "app"
chat.context_id = context_id
if chat:
chat.updated_at = datetime.utcnow()
db.add(chat)
db.add(ChatMessage(chat_id=chat.id, role="user", content=prompt))
db.commit()

def _record_assistant_message(reply: str, tool: str) -> None:
if not chat:
return
chat.updated_at = datetime.utcnow()
db.add(chat)
db.add(ChatMessage(chat_id=chat.id, role="assistant", content=reply, tool=tool))
db.commit()

settings = get_settings_dict(db)
openai_settings = settings.get("openAI", {})
api_key = openai_settings.get("backendApiKey")
if not api_key:
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail="OpenAI backend API key not set")

history = [item.model_dump() for item in (data.history or [])]
model = openai_settings.get("appChatModel") or openai_settings.get("chatModel") or "gpt-5-mini"

tool_payload = {}
tool = "ask_about_app"
tool_prompt = prompt
try:
tool_payload = await route_app_chat(
prompt=prompt,
app_name=data.app_name,
app_keyword=data.app_keyword,
scene_id=data.scene_id,
node_id=data.node_id,
history=history,
api_key=api_key,
model=model,
ai_trace_id=request_id,
ai_session_id=None,
)
if isinstance(tool_payload, dict):
tool = tool_payload.get("tool") or tool
tool_prompt = tool_payload.get("tool_prompt") or prompt
except Exception:
tool = "ask_about_app"
tool_prompt = prompt

reply = ""
files: dict[str, str] | None = None
if tool == "edit_app":
edit_payload = await edit_app_sources(
prompt=tool_prompt,
sources=data.sources,
app_name=data.app_name,
app_keyword=data.app_keyword,
scene_id=data.scene_id,
node_id=data.node_id,
history=history,
api_key=api_key,
model=openai_settings.get("appEditModel") or openai_settings.get("chatModel") or "gpt-5-mini",
ai_trace_id=request_id,
ai_session_id=None,
)
if isinstance(edit_payload, dict):
reply = edit_payload.get("reply") or "Updated app files."
files_payload = edit_payload.get("files")
if isinstance(files_payload, dict):
files = {str(key): str(value) for key, value in files_payload.items()}
if not reply:
reply = "Updated app files."
else:
answer_payload = await answer_app_question(
prompt=tool_prompt,
sources=data.sources,
app_name=data.app_name,
app_keyword=data.app_keyword,
scene_id=data.scene_id,
node_id=data.node_id,
history=history,
api_key=api_key,
model=openai_settings.get("appChatModel") or openai_settings.get("chatModel") or "gpt-5-mini",
ai_trace_id=request_id,
ai_session_id=None,
)
if isinstance(answer_payload, dict):
reply = answer_payload.get("answer") or ""
if not reply:
reply = "Done."
tool = "ask_about_app"

_record_assistant_message(reply, tool)

return AiAppChatResponse(reply=reply, tool=tool, chatId=chat.id if chat else None, files=files)
23 changes: 21 additions & 2 deletions backend/app/api/ai_scenes.py
Original file line number Diff line number Diff line change
Expand Up @@ -756,16 +756,35 @@ async def chat_scene(
chat = db.query(Chat).filter(Chat.id == data.chat_id).first()
if chat and chat.frame_id != data.frame_id:
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail="Chat does not belong to frame")
if chat and chat.context_type not in (None, "scene", "frame"):
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail="Chat context does not match scene chat")
if not chat:
if data.chat_id:
chat = Chat(id=data.chat_id, frame_id=data.frame_id, scene_id=data.scene_id)
chat = Chat(
id=data.chat_id,
frame_id=data.frame_id,
scene_id=data.scene_id,
context_type="scene" if data.scene_id else "frame",
context_id=data.scene_id,
)
else:
chat = Chat(frame_id=data.frame_id, scene_id=data.scene_id)
chat = Chat(
frame_id=data.frame_id,
scene_id=data.scene_id,
context_type="scene" if data.scene_id else "frame",
context_id=data.scene_id,
)
db.add(chat)
db.commit()
db.refresh(chat)
if data.scene_id and chat.scene_id != data.scene_id:
chat.scene_id = data.scene_id
if data.scene_id:
chat.context_type = "scene"
chat.context_id = data.scene_id
elif not chat.context_type:
chat.context_type = "frame"
chat.context_id = None
if chat:
chat.updated_at = datetime.utcnow()
db.add(chat)
Expand Down
13 changes: 12 additions & 1 deletion backend/app/api/chats.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,18 @@

@api_with_auth.post("/ai/chats", response_model=ChatSummary)
async def create_chat(data: ChatCreateRequest, db: Session = Depends(get_db)):
chat = Chat(frame_id=data.frame_id, scene_id=data.scene_id)
context_type = data.context_type
context_id = data.context_id
if not context_type:
context_type = "scene" if data.scene_id else "frame"
if context_type == "scene" and not context_id:
context_id = data.scene_id
chat = Chat(
frame_id=data.frame_id,
scene_id=data.scene_id,
context_type=context_type,
context_id=context_id,
)
db.add(chat)
db.commit()
db.refresh(chat)
Expand Down
4 changes: 4 additions & 0 deletions backend/app/models/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ class Chat(Base):
id = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
frame_id = mapped_column(Integer, ForeignKey('frame.id'), nullable=False)
scene_id = mapped_column(String(128), nullable=True)
context_type = mapped_column(String(32), nullable=True)
context_id = mapped_column(String(256), nullable=True)
created_at = mapped_column(DateTime, nullable=False, default=func.current_timestamp())
updated_at = mapped_column(
DateTime,
Expand All @@ -25,6 +27,8 @@ def to_dict(self):
'id': self.id,
'frame_id': self.frame_id,
'scene_id': self.scene_id,
'context_type': self.context_type,
'context_id': self.context_id,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
}
Expand Down
30 changes: 30 additions & 0 deletions backend/app/schemas/ai_apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from typing import Optional

from pydantic import BaseModel, ConfigDict, Field


class AiAppChatMessage(BaseModel):
role: str
content: str


class AiAppChatRequest(BaseModel):
model_config = ConfigDict(populate_by_name=True)

prompt: str
chat_id: Optional[str] = Field(default=None, alias="chatId")
frame_id: Optional[int] = Field(default=None, alias="frameId")
scene_id: Optional[str] = Field(default=None, alias="sceneId")
node_id: Optional[str] = Field(default=None, alias="nodeId")
app_name: Optional[str] = Field(default=None, alias="appName")
app_keyword: Optional[str] = Field(default=None, alias="appKeyword")
sources: Optional[dict[str, str]] = None
history: Optional[list[AiAppChatMessage]] = None
request_id: Optional[str] = Field(default=None, alias="requestId")


class AiAppChatResponse(BaseModel):
reply: str
tool: str
chat_id: Optional[str] = Field(default=None, alias="chatId")
files: Optional[dict[str, str]] = None
4 changes: 4 additions & 0 deletions backend/app/schemas/chats.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ class ChatCreateRequest(BaseModel):

frame_id: int = Field(alias="frameId")
scene_id: Optional[str] = Field(default=None, alias="sceneId")
context_type: Optional[str] = Field(default=None, alias="contextType")
context_id: Optional[str] = Field(default=None, alias="contextId")


class ChatSummary(BaseModel):
Expand All @@ -17,6 +19,8 @@ class ChatSummary(BaseModel):
id: str
frame_id: int = Field(alias="frameId")
scene_id: Optional[str] = Field(default=None, alias="sceneId")
context_type: Optional[str] = Field(default=None, alias="contextType")
context_id: Optional[str] = Field(default=None, alias="contextId")
created_at: datetime = Field(alias="createdAt")
updated_at: datetime = Field(alias="updatedAt")

Expand Down
Loading