Skip to content

Commit 879b262

Browse files
working streaming implementation for loading
1 parent b490b69 commit 879b262

File tree

7 files changed

+601
-136
lines changed

7 files changed

+601
-136
lines changed

backend/app/prompts.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@
154154
155155
Ensure that your diagram adheres strictly to the given explanation, without adding or omitting any significant components or relationships.
156156
157-
As a very general direction, the provided example below is a good flow for your code:
157+
For general direction, the provided example below is how you should structure your code:
158158
159159
```mermaid
160160
flowchart TD
@@ -191,6 +191,7 @@
191191
- In Mermaid.js syntax, we cannot include special characters for nodes without being inside quotes! For example: `EX[/api/process (Backend)]:::api` and `API -->|calls Process()| Backend` are two examples of syntax errors. They should be `EX["/api/process (Backend)"]:::api` and `API -->|"calls Process()"| Backend` respectively. Notice the quotes. This is extremely important. Make sure to include quotes for any string that contains special characters.
192192
- In Mermaid.js syntax, you cannot apply a class style directly within a subgraph declaration. For example: `subgraph "Frontend Layer":::frontend` is a syntax error. However, you can apply them to nodes within the subgraph. For example: `Example["Example Node"]:::frontend` is valid, and `class Example1,Example2 frontend` is valid.
193193
- In Mermaid.js syntax, there cannot be spaces in the relationship label names. For example: `A -->| "example relationship" | B` is a syntax error. It should be `A -->|"example relationship"| B`
194+
- In Mermaid.js syntax, you cannot give subgraphs an alias like nodes. For example: `subgraph A "Layer A"` is a syntax error. It should be `subgraph "Layer A"`
194195
"""
195196
# ^^^ note: ive generated a few diagrams now and claude still writes incorrect mermaid code sometimes. in the future, refer to those generated diagrams and add important instructions to the prompt above to avoid those mistakes. examples are best.
196197

backend/app/routers/generate.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from fastapi import APIRouter, Request, HTTPException
2+
from fastapi.responses import StreamingResponse
23
from dotenv import load_dotenv
34
from app.services.github_service import GitHubService
45
from app.services.o3_mini_openrouter_service import OpenRouterO3Service
@@ -12,6 +13,8 @@
1213
from pydantic import BaseModel
1314
from functools import lru_cache
1415
import re
16+
import json
17+
import asyncio
1518

1619
# from app.services.claude_service import ClaudeService
1720
# from app.core.limiter import limiter
@@ -49,6 +52,7 @@ class ApiRequest(BaseModel):
4952
github_pat: str | None = None
5053

5154

55+
# OLD NON STREAMING VERSION
5256
@router.post("")
5357
# @limiter.limit("1/minute;5/day") # TEMP: disable rate limit for growth??
5458
async def generate(request: Request, body: ApiRequest):
@@ -268,3 +272,149 @@ def replace_path(match):
268272
# Match click events: click ComponentName "path/to/something"
269273
click_pattern = r'click ([^\s"]+)\s+"([^"]+)"'
270274
return re.sub(click_pattern, replace_path, diagram)
275+
276+
277+
@router.post("/stream")
278+
async def generate_stream(request: Request, body: ApiRequest):
279+
try:
280+
# Initial validation checks
281+
if len(body.instructions) > 1000:
282+
return {"error": "Instructions exceed maximum length of 1000 characters"}
283+
284+
if body.repo in [
285+
"fastapi",
286+
"streamlit",
287+
"flask",
288+
"api-analytics",
289+
"monkeytype",
290+
]:
291+
return {"error": "Example repos cannot be regenerated"}
292+
293+
async def event_generator():
294+
try:
295+
# Get cached github data
296+
github_data = get_cached_github_data(
297+
body.username, body.repo, body.github_pat
298+
)
299+
default_branch = github_data["default_branch"]
300+
file_tree = github_data["file_tree"]
301+
readme = github_data["readme"]
302+
303+
# Send initial status
304+
yield f"data: {json.dumps({'status': 'started', 'message': 'Starting generation process...'})}\n\n"
305+
await asyncio.sleep(0.1)
306+
307+
# Token count check
308+
combined_content = f"{file_tree}\n{readme}"
309+
token_count = o3_service.count_tokens(combined_content)
310+
311+
if 50000 < token_count < 195000 and not body.api_key:
312+
yield f"data: {json.dumps({'error': f'File tree and README combined exceeds token limit (50,000). Current size: {token_count} tokens. This GitHub repository is too large for my wallet, but you can continue by providing your own OpenRouter API key.'})}\n\n"
313+
return
314+
elif token_count > 195000:
315+
yield f"data: {json.dumps({'error': f'Repository is too large (>195k tokens) for analysis. OpenAI o3-mini\'s max context length is 200k tokens. Current size: {token_count} tokens.'})}\n\n"
316+
return
317+
318+
# Prepare prompts
319+
first_system_prompt = SYSTEM_FIRST_PROMPT
320+
third_system_prompt = SYSTEM_THIRD_PROMPT
321+
if body.instructions:
322+
first_system_prompt = (
323+
first_system_prompt
324+
+ "\n"
325+
+ ADDITIONAL_SYSTEM_INSTRUCTIONS_PROMPT
326+
)
327+
third_system_prompt = (
328+
third_system_prompt
329+
+ "\n"
330+
+ ADDITIONAL_SYSTEM_INSTRUCTIONS_PROMPT
331+
)
332+
333+
# Phase 1: Get explanation
334+
yield f"data: {json.dumps({'status': 'explanation_sent', 'message': 'Sending explanation request to o3-mini...'})}\n\n"
335+
await asyncio.sleep(0.1)
336+
yield f"data: {json.dumps({'status': 'explanation', 'message': 'Analyzing repository structure...'})}\n\n"
337+
explanation = ""
338+
async for chunk in o3_service.call_o3_api_stream(
339+
system_prompt=first_system_prompt,
340+
data={
341+
"file_tree": file_tree,
342+
"readme": readme,
343+
"instructions": body.instructions,
344+
},
345+
api_key=body.api_key,
346+
reasoning_effort="medium",
347+
):
348+
explanation += chunk
349+
yield f"data: {json.dumps({'status': 'explanation_chunk', 'chunk': chunk})}\n\n"
350+
351+
if "BAD_INSTRUCTIONS" in explanation:
352+
yield f"data: {json.dumps({'error': 'Invalid or unclear instructions provided'})}\n\n"
353+
return
354+
355+
# Phase 2: Get component mapping
356+
yield f"data: {json.dumps({'status': 'mapping_sent', 'message': 'Sending component mapping request to o3-mini...'})}\n\n"
357+
await asyncio.sleep(0.1)
358+
yield f"data: {json.dumps({'status': 'mapping', 'message': 'Creating component mapping...'})}\n\n"
359+
full_second_response = ""
360+
async for chunk in o3_service.call_o3_api_stream(
361+
system_prompt=SYSTEM_SECOND_PROMPT,
362+
data={"explanation": explanation, "file_tree": file_tree},
363+
api_key=body.api_key,
364+
reasoning_effort="medium",
365+
):
366+
full_second_response += chunk
367+
yield f"data: {json.dumps({'status': 'mapping_chunk', 'chunk': chunk})}\n\n"
368+
369+
# i dont think i need this anymore? but keep it here for now
370+
# Extract component mapping
371+
start_tag = "<component_mapping>"
372+
end_tag = "</component_mapping>"
373+
component_mapping_text = full_second_response[
374+
full_second_response.find(start_tag) : full_second_response.find(
375+
end_tag
376+
)
377+
]
378+
379+
# Phase 3: Generate Mermaid diagram
380+
yield f"data: {json.dumps({'status': 'diagram_sent', 'message': 'Sending diagram generation request to o3-mini...'})}\n\n"
381+
await asyncio.sleep(0.1)
382+
yield f"data: {json.dumps({'status': 'diagram', 'message': 'Generating diagram...'})}\n\n"
383+
mermaid_code = ""
384+
async for chunk in o3_service.call_o3_api_stream(
385+
system_prompt=third_system_prompt,
386+
data={
387+
"explanation": explanation,
388+
"component_mapping": component_mapping_text,
389+
"instructions": body.instructions,
390+
},
391+
api_key=body.api_key,
392+
reasoning_effort="medium",
393+
):
394+
mermaid_code += chunk
395+
yield f"data: {json.dumps({'status': 'diagram_chunk', 'chunk': chunk})}\n\n"
396+
397+
# Process final diagram
398+
mermaid_code = mermaid_code.replace("```mermaid", "").replace("```", "")
399+
if "BAD_INSTRUCTIONS" in mermaid_code:
400+
yield f"data: {json.dumps({'error': 'Invalid or unclear instructions provided'})}\n\n"
401+
return
402+
403+
processed_diagram = process_click_events(
404+
mermaid_code, body.username, body.repo, default_branch
405+
)
406+
407+
# Send final result
408+
yield f"data: {json.dumps({
409+
'status': 'complete',
410+
'diagram': processed_diagram,
411+
'explanation': explanation,
412+
'mapping': component_mapping_text
413+
})}\n\n"
414+
415+
except Exception as e:
416+
yield f"data: {json.dumps({'error': str(e)})}\n\n"
417+
418+
return StreamingResponse(event_generator(), media_type="text/event-stream")
419+
except Exception as e:
420+
return {"error": str(e)}

backend/app/services/o3_mini_openrouter_service.py

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
from app.utils.format_message import format_user_message
44
import tiktoken
55
import os
6-
from typing import Literal
6+
import aiohttp
7+
import json
8+
from typing import Literal, AsyncGenerator
79

810
load_dotenv()
911

@@ -15,6 +17,7 @@ def __init__(self):
1517
api_key=os.getenv("OPENROUTER_API_KEY"),
1618
)
1719
self.encoding = tiktoken.get_encoding("o200k_base")
20+
self.base_url = "https://openrouter.ai/api/v1/chat/completions"
1821

1922
def call_o3_api(
2023
self,
@@ -64,6 +67,68 @@ def call_o3_api(
6467

6568
return completion.choices[0].message.content
6669

70+
async def call_o3_api_stream(
71+
self,
72+
system_prompt: str,
73+
data: dict,
74+
api_key: str | None = None,
75+
reasoning_effort: Literal["low", "medium", "high"] = "low",
76+
) -> AsyncGenerator[str, None]:
77+
"""
78+
Makes a streaming API call to OpenRouter O3 and yields the responses.
79+
80+
Args:
81+
system_prompt (str): The instruction/system prompt
82+
data (dict): Dictionary of variables to format into the user message
83+
api_key (str | None): Optional custom API key
84+
85+
Yields:
86+
str: Chunks of O3's response text
87+
"""
88+
# Create the user message with the data
89+
user_message = format_user_message(data)
90+
91+
headers = {
92+
"HTTP-Referer": "https://gitdiagram.com",
93+
"X-Title": "gitdiagram",
94+
"Authorization": f"Bearer {api_key or self.default_client.api_key}",
95+
"Content-Type": "application/json",
96+
}
97+
98+
payload = {
99+
"model": "openai/o3-mini",
100+
"messages": [
101+
{"role": "system", "content": system_prompt},
102+
{"role": "user", "content": user_message},
103+
],
104+
"max_tokens": 12000,
105+
"temperature": 0.2,
106+
"stream": True,
107+
"reasoning_effort": reasoning_effort,
108+
}
109+
110+
buffer = ""
111+
async with aiohttp.ClientSession() as session:
112+
async with session.post(
113+
self.base_url, headers=headers, json=payload
114+
) as response:
115+
async for line in response.content:
116+
line = line.decode("utf-8").strip()
117+
if line.startswith("data: "):
118+
if line == "data: [DONE]":
119+
break
120+
try:
121+
data = json.loads(line[6:])
122+
if (
123+
content := data.get("choices", [{}])[0]
124+
.get("delta", {})
125+
.get("content")
126+
):
127+
yield content
128+
except json.JSONDecodeError:
129+
# Skip any non-JSON lines (like the OPENROUTER PROCESSING comments)
130+
continue
131+
67132
def count_tokens(self, prompt: str) -> int:
68133
"""
69134
Counts the number of tokens in a prompt.

backend/requirements.txt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1+
aiohappyeyeballs==2.4.6
2+
aiohttp==3.11.12
3+
aiosignal==1.3.2
14
annotated-types==0.7.0
25
anthropic==0.42.0
36
anyio==4.7.0
47
api-analytics==1.2.5
8+
attrs==25.1.0
59
certifi==2024.12.14
610
cffi==1.17.1
711
charset-normalizer==3.4.0
@@ -13,6 +17,7 @@ dnspython==2.7.0
1317
email_validator==2.2.0
1418
fastapi==0.115.6
1519
fastapi-cli==0.0.6
20+
frozenlist==1.5.0
1621
h11==0.14.0
1722
httpcore==1.0.7
1823
httptools==0.6.4
@@ -24,8 +29,10 @@ limits==3.14.1
2429
markdown-it-py==3.0.0
2530
MarkupSafe==3.0.2
2631
mdurl==0.1.2
32+
multidict==6.1.0
2733
openai==1.61.1
2834
packaging==24.2
35+
propcache==0.2.1
2936
pycparser==2.22
3037
pydantic==2.10.3
3138
pydantic_core==2.27.1
@@ -52,3 +59,4 @@ uvloop==0.21.0
5259
watchfiles==1.0.3
5360
websockets==14.1
5461
wrapt==1.17.0
62+
yarl==1.18.3

src/app/[username]/[repo]/page.tsx

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import MainCard from "~/components/main-card";
55
import Loading from "~/components/loading";
66
import MermaidChart from "~/components/mermaid-diagram";
77
import { useDiagram } from "~/hooks/useDiagram";
8-
import { ApiKeyDialog } from "~/components/api-key-dialog";
8+
// import { ApiKeyDialog } from "~/components/api-key-dialog";
99
import { ApiKeyButton } from "~/components/api-key-button";
1010
import { useState } from "react";
1111

@@ -18,16 +18,16 @@ export default function Repo() {
1818
loading,
1919
lastGenerated,
2020
cost,
21-
isRegenerating,
22-
showApiKeyDialog,
23-
tokenCount,
21+
// showApiKeyDialog,
22+
// tokenCount,
2423
handleModify,
2524
handleRegenerate,
2625
handleCopy,
27-
handleApiKeySubmit,
28-
handleCloseApiKeyDialog,
26+
// handleApiKeySubmit,
27+
// handleCloseApiKeyDialog,
2928
handleOpenApiKeyDialog,
3029
handleExportImage,
30+
state,
3131
} = useDiagram(params.username.toLowerCase(), params.repo.toLowerCase());
3232

3333
return (
@@ -51,7 +51,14 @@ export default function Repo() {
5151
<div className="mt-8 flex w-full flex-col items-center gap-8">
5252
{loading ? (
5353
<div className="mt-12">
54-
<Loading cost={cost} isModifying={!isRegenerating} />
54+
<Loading
55+
cost={cost}
56+
status={state.status}
57+
message={state.message}
58+
explanation={state.explanation}
59+
mapping={state.mapping}
60+
diagram={state.diagram}
61+
/>
5562
</div>
5663
) : error ? (
5764
<div className="mt-12 text-center">
@@ -77,12 +84,12 @@ export default function Repo() {
7784
)}
7885
</div>
7986

80-
<ApiKeyDialog
87+
{/* <ApiKeyDialog
8188
isOpen={showApiKeyDialog}
8289
onClose={handleCloseApiKeyDialog}
8390
onSubmit={handleApiKeySubmit}
8491
tokenCount={tokenCount}
85-
/>
92+
/> */}
8693
</div>
8794
);
8895
}

0 commit comments

Comments
 (0)