Implement chat interface with message history and system prompt support; update frontend and tests accordingly
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -33,8 +33,9 @@ class ModelInfo(BaseModel):
|
|||||||
|
|
||||||
class TextRequest(BaseModel):
|
class TextRequest(BaseModel):
|
||||||
model: str
|
model: str
|
||||||
prompt: str
|
prompt: str = ""
|
||||||
system_prompt: str | None = None
|
system_prompt: str | None = None
|
||||||
|
messages: list[ChatMessage] | None = None
|
||||||
temperature: float = 0.7
|
temperature: float = 0.7
|
||||||
max_tokens: int = 1024
|
max_tokens: int = 1024
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,13 @@ async def generate_text(
|
|||||||
_: dict = Depends(get_current_user),
|
_: dict = Depends(get_current_user),
|
||||||
) -> TextResponse:
|
) -> TextResponse:
|
||||||
"""Generate text from a prompt using a chat model."""
|
"""Generate text from a prompt using a chat model."""
|
||||||
|
if body.messages:
|
||||||
|
messages = [{"role": m.role, "content": m.content}
|
||||||
|
for m in body.messages]
|
||||||
|
if body.system_prompt and (not messages or messages[0]["role"] != "system"):
|
||||||
|
messages.insert(
|
||||||
|
0, {"role": "system", "content": body.system_prompt})
|
||||||
|
else:
|
||||||
messages = []
|
messages = []
|
||||||
if body.system_prompt:
|
if body.system_prompt:
|
||||||
messages.append({"role": "system", "content": body.system_prompt})
|
messages.append({"role": "system", "content": body.system_prompt})
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ async def _user_token(client):
|
|||||||
|
|
||||||
async def test_generate_text(client):
|
async def test_generate_text(client):
|
||||||
token = await _user_token(client)
|
token = await _user_token(client)
|
||||||
with patch("backend.app.routers.generate.openrouter.chat_completion", new_callable=AsyncMock, return_value=FAKE_CHAT):
|
with patch("app.routers.generate.openrouter.chat_completion", new_callable=AsyncMock, return_value=FAKE_CHAT):
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"/generate/text",
|
"/generate/text",
|
||||||
json={"model": "openai/gpt-4o", "prompt": "Tell me a story"},
|
json={"model": "openai/gpt-4o", "prompt": "Tell me a story"},
|
||||||
@@ -85,7 +85,7 @@ async def test_generate_text(client):
|
|||||||
async def test_generate_text_with_system_prompt(client):
|
async def test_generate_text_with_system_prompt(client):
|
||||||
token = await _user_token(client)
|
token = await _user_token(client)
|
||||||
mock = AsyncMock(return_value=FAKE_CHAT)
|
mock = AsyncMock(return_value=FAKE_CHAT)
|
||||||
with patch("backend.app.routers.generate.openrouter.chat_completion", mock):
|
with patch("app.routers.generate.openrouter.chat_completion", mock):
|
||||||
await client.post(
|
await client.post(
|
||||||
"/generate/text",
|
"/generate/text",
|
||||||
json={"model": "openai/gpt-4o", "prompt": "Hello",
|
json={"model": "openai/gpt-4o", "prompt": "Hello",
|
||||||
@@ -97,6 +97,44 @@ async def test_generate_text_with_system_prompt(client):
|
|||||||
assert call_messages[1] == {"role": "user", "content": "Hello"}
|
assert call_messages[1] == {"role": "user", "content": "Hello"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_generate_text_with_messages_array(client):
|
||||||
|
"""messages field takes precedence over prompt for multi-turn chat."""
|
||||||
|
token = await _user_token(client)
|
||||||
|
mock = AsyncMock(return_value=FAKE_CHAT)
|
||||||
|
messages = [
|
||||||
|
{"role": "user", "content": "First message"},
|
||||||
|
{"role": "assistant", "content": "Reply"},
|
||||||
|
{"role": "user", "content": "Follow up"},
|
||||||
|
]
|
||||||
|
with patch("app.routers.generate.openrouter.chat_completion", mock):
|
||||||
|
resp = await client.post(
|
||||||
|
"/generate/text",
|
||||||
|
json={"model": "openai/gpt-4o", "messages": messages},
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
call_messages = mock.call_args.kwargs["messages"]
|
||||||
|
assert len(call_messages) == 3
|
||||||
|
assert call_messages[2]["content"] == "Follow up"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_generate_text_messages_with_system_prompt(client):
|
||||||
|
"""system_prompt prepended when messages provided and no system msg present."""
|
||||||
|
token = await _user_token(client)
|
||||||
|
mock = AsyncMock(return_value=FAKE_CHAT)
|
||||||
|
messages = [{"role": "user", "content": "Hi"}]
|
||||||
|
with patch("app.routers.generate.openrouter.chat_completion", mock):
|
||||||
|
await client.post(
|
||||||
|
"/generate/text",
|
||||||
|
json={"model": "openai/gpt-4o", "messages": messages,
|
||||||
|
"system_prompt": "Be brief."},
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
call_messages = mock.call_args.kwargs["messages"]
|
||||||
|
assert call_messages[0] == {"role": "system", "content": "Be brief."}
|
||||||
|
assert call_messages[1] == {"role": "user", "content": "Hi"}
|
||||||
|
|
||||||
|
|
||||||
async def test_generate_text_unauthenticated(client):
|
async def test_generate_text_unauthenticated(client):
|
||||||
resp = await client.post("/generate/text", json={"model": "openai/gpt-4o", "prompt": "Hi"})
|
resp = await client.post("/generate/text", json={"model": "openai/gpt-4o", "prompt": "Hi"})
|
||||||
assert resp.status_code == 401
|
assert resp.status_code == 401
|
||||||
@@ -104,7 +142,7 @@ async def test_generate_text_unauthenticated(client):
|
|||||||
|
|
||||||
async def test_generate_text_upstream_error(client):
|
async def test_generate_text_upstream_error(client):
|
||||||
token = await _user_token(client)
|
token = await _user_token(client)
|
||||||
with patch("backend.app.routers.generate.openrouter.chat_completion", new_callable=AsyncMock, side_effect=Exception("timeout")):
|
with patch("app.routers.generate.openrouter.chat_completion", new_callable=AsyncMock, side_effect=Exception("timeout")):
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"/generate/text",
|
"/generate/text",
|
||||||
json={"model": "openai/gpt-4o", "prompt": "Hi"},
|
json={"model": "openai/gpt-4o", "prompt": "Hi"},
|
||||||
@@ -119,7 +157,7 @@ async def test_generate_text_upstream_error(client):
|
|||||||
|
|
||||||
async def test_generate_image(client):
|
async def test_generate_image(client):
|
||||||
token = await _user_token(client)
|
token = await _user_token(client)
|
||||||
with patch("backend.app.routers.generate.openrouter.generate_image", new_callable=AsyncMock, return_value=FAKE_IMAGE):
|
with patch("app.routers.generate.openrouter.generate_image", new_callable=AsyncMock, return_value=FAKE_IMAGE):
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"/generate/image",
|
"/generate/image",
|
||||||
json={"model": "openai/dall-e-3", "prompt": "A cat on the moon"},
|
json={"model": "openai/dall-e-3", "prompt": "A cat on the moon"},
|
||||||
@@ -140,7 +178,7 @@ async def test_generate_image_unauthenticated(client):
|
|||||||
|
|
||||||
async def test_generate_image_upstream_error(client):
|
async def test_generate_image_upstream_error(client):
|
||||||
token = await _user_token(client)
|
token = await _user_token(client)
|
||||||
with patch("backend.app.routers.generate.openrouter.generate_image", new_callable=AsyncMock, side_effect=Exception("rate limit")):
|
with patch("app.routers.generate.openrouter.generate_image", new_callable=AsyncMock, side_effect=Exception("rate limit")):
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"/generate/image",
|
"/generate/image",
|
||||||
json={"model": "openai/dall-e-3", "prompt": "Hi"},
|
json={"model": "openai/dall-e-3", "prompt": "Hi"},
|
||||||
@@ -184,7 +222,7 @@ FAKE_IMAGE_CHAT_GPT5 = {
|
|||||||
|
|
||||||
async def test_generate_image_chat_flux(client):
|
async def test_generate_image_chat_flux(client):
|
||||||
token = await _user_token(client)
|
token = await _user_token(client)
|
||||||
with patch("backend.app.routers.generate.openrouter.generate_image_chat", new_callable=AsyncMock, return_value=FAKE_IMAGE_CHAT_FLUX):
|
with patch("app.routers.generate.openrouter.generate_image_chat", new_callable=AsyncMock, return_value=FAKE_IMAGE_CHAT_FLUX):
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"/generate/image",
|
"/generate/image",
|
||||||
json={"model": "black-forest-labs/flux.2-klein-4b",
|
json={"model": "black-forest-labs/flux.2-klein-4b",
|
||||||
@@ -200,7 +238,7 @@ async def test_generate_image_chat_flux(client):
|
|||||||
|
|
||||||
async def test_generate_image_chat_gpt5_image_mini(client):
|
async def test_generate_image_chat_gpt5_image_mini(client):
|
||||||
token = await _user_token(client)
|
token = await _user_token(client)
|
||||||
with patch("backend.app.routers.generate.openrouter.generate_image_chat", new_callable=AsyncMock, return_value=FAKE_IMAGE_CHAT_GPT5):
|
with patch("app.routers.generate.openrouter.generate_image_chat", new_callable=AsyncMock, return_value=FAKE_IMAGE_CHAT_GPT5):
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"/generate/image",
|
"/generate/image",
|
||||||
json={"model": "openai/gpt-5-image-mini", "prompt": "A cat"},
|
json={"model": "openai/gpt-5-image-mini", "prompt": "A cat"},
|
||||||
@@ -215,7 +253,7 @@ async def test_generate_image_chat_gpt5_image_mini(client):
|
|||||||
async def test_generate_image_chat_with_image_config(client):
|
async def test_generate_image_chat_with_image_config(client):
|
||||||
token = await _user_token(client)
|
token = await _user_token(client)
|
||||||
mock = AsyncMock(return_value=FAKE_IMAGE_CHAT_FLUX)
|
mock = AsyncMock(return_value=FAKE_IMAGE_CHAT_FLUX)
|
||||||
with patch("backend.app.routers.generate.openrouter.generate_image_chat", mock):
|
with patch("app.routers.generate.openrouter.generate_image_chat", mock):
|
||||||
await client.post(
|
await client.post(
|
||||||
"/generate/image",
|
"/generate/image",
|
||||||
json={
|
json={
|
||||||
@@ -239,7 +277,7 @@ async def test_generate_image_chat_unauthenticated(client):
|
|||||||
|
|
||||||
async def test_generate_image_chat_upstream_error(client):
|
async def test_generate_image_chat_upstream_error(client):
|
||||||
token = await _user_token(client)
|
token = await _user_token(client)
|
||||||
with patch("backend.app.routers.generate.openrouter.generate_image_chat", new_callable=AsyncMock, side_effect=Exception("timeout")):
|
with patch("app.routers.generate.openrouter.generate_image_chat", new_callable=AsyncMock, side_effect=Exception("timeout")):
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"/generate/image",
|
"/generate/image",
|
||||||
json={"model": "black-forest-labs/flux.2-klein-4b", "prompt": "Hi"},
|
json={"model": "black-forest-labs/flux.2-klein-4b", "prompt": "Hi"},
|
||||||
@@ -254,7 +292,7 @@ async def test_generate_image_chat_upstream_error(client):
|
|||||||
|
|
||||||
async def test_generate_video(client):
|
async def test_generate_video(client):
|
||||||
token = await _user_token(client)
|
token = await _user_token(client)
|
||||||
with patch("backend.app.routers.generate.openrouter.generate_video", new_callable=AsyncMock, return_value=FAKE_VIDEO):
|
with patch("app.routers.generate.openrouter.generate_video", new_callable=AsyncMock, return_value=FAKE_VIDEO):
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"/generate/video",
|
"/generate/video",
|
||||||
json={"model": "stability/stable-video",
|
json={"model": "stability/stable-video",
|
||||||
@@ -276,7 +314,7 @@ async def test_generate_video_unauthenticated(client):
|
|||||||
|
|
||||||
async def test_generate_video_upstream_error(client):
|
async def test_generate_video_upstream_error(client):
|
||||||
token = await _user_token(client)
|
token = await _user_token(client)
|
||||||
with patch("backend.app.routers.generate.openrouter.generate_video", new_callable=AsyncMock, side_effect=Exception("503")):
|
with patch("app.routers.generate.openrouter.generate_video", new_callable=AsyncMock, side_effect=Exception("503")):
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"/generate/video",
|
"/generate/video",
|
||||||
json={"model": "stability/stable-video", "prompt": "Hi"},
|
json={"model": "stability/stable-video", "prompt": "Hi"},
|
||||||
@@ -291,7 +329,7 @@ async def test_generate_video_upstream_error(client):
|
|||||||
|
|
||||||
async def test_generate_video_from_image(client):
|
async def test_generate_video_from_image(client):
|
||||||
token = await _user_token(client)
|
token = await _user_token(client)
|
||||||
with patch("backend.app.routers.generate.openrouter.generate_video_from_image", new_callable=AsyncMock, return_value=FAKE_VIDEO_DONE):
|
with patch("app.routers.generate.openrouter.generate_video_from_image", new_callable=AsyncMock, return_value=FAKE_VIDEO_DONE):
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"/generate/video/from-image",
|
"/generate/video/from-image",
|
||||||
json={
|
json={
|
||||||
@@ -315,7 +353,7 @@ async def test_poll_video_status(client):
|
|||||||
"status": "completed",
|
"status": "completed",
|
||||||
"unsigned_urls": ["https://example.com/video.mp4"],
|
"unsigned_urls": ["https://example.com/video.mp4"],
|
||||||
}
|
}
|
||||||
with patch("backend.app.routers.generate.openrouter.poll_video_status", new_callable=AsyncMock, return_value=mock_result):
|
with patch("app.routers.generate.openrouter.poll_video_status", new_callable=AsyncMock, return_value=mock_result):
|
||||||
resp = await client.get(
|
resp = await client.get(
|
||||||
"/generate/video/status",
|
"/generate/video/status",
|
||||||
params={"polling_url": "https://openrouter.ai/api/v1/videos/gen-vid-1"},
|
params={"polling_url": "https://openrouter.ai/api/v1/videos/gen-vid-1"},
|
||||||
@@ -337,7 +375,7 @@ async def test_poll_video_status_unauthenticated(client):
|
|||||||
|
|
||||||
async def test_poll_video_status_upstream_error(client):
|
async def test_poll_video_status_upstream_error(client):
|
||||||
token = await _user_token(client)
|
token = await _user_token(client)
|
||||||
with patch("backend.app.routers.generate.openrouter.poll_video_status", new_callable=AsyncMock, side_effect=Exception("timeout")):
|
with patch("app.routers.generate.openrouter.poll_video_status", new_callable=AsyncMock, side_effect=Exception("timeout")):
|
||||||
resp = await client.get(
|
resp = await client.get(
|
||||||
"/generate/video/status",
|
"/generate/video/status",
|
||||||
params={"polling_url": "https://openrouter.ai/api/v1/videos/gen-vid-1"},
|
params={"polling_url": "https://openrouter.ai/api/v1/videos/gen-vid-1"},
|
||||||
@@ -356,7 +394,7 @@ async def test_generate_video_from_image_unauthenticated(client):
|
|||||||
|
|
||||||
async def test_generate_video_from_image_upstream_error(client):
|
async def test_generate_video_from_image_upstream_error(client):
|
||||||
token = await _user_token(client)
|
token = await _user_token(client)
|
||||||
with patch("backend.app.routers.generate.openrouter.generate_video_from_image", new_callable=AsyncMock, side_effect=Exception("error")):
|
with patch("app.routers.generate.openrouter.generate_video_from_image", new_callable=AsyncMock, side_effect=Exception("error")):
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"/generate/video/from-image",
|
"/generate/video/from-image",
|
||||||
json={"model": "runway/gen-3",
|
json={"model": "runway/gen-3",
|
||||||
|
|||||||
+52
-7
@@ -206,19 +206,64 @@ def generate():
|
|||||||
@app.route("/generate/text", methods=["GET", "POST"])
|
@app.route("/generate/text", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def generate_text():
|
def generate_text():
|
||||||
result = error = None
|
error = None
|
||||||
token = session["access_token"]
|
token = session["access_token"]
|
||||||
|
chat_history: list[dict] = session.get("chat_history", [])
|
||||||
|
system_prompt: str = session.get("chat_system_prompt", "")
|
||||||
|
model: str = session.get("chat_model", "")
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
resp = _api("POST", "/generate/text", token=token, json={
|
action = request.form.get("action", "send")
|
||||||
"model": request.form.get("model", "").strip(),
|
|
||||||
"prompt": request.form.get("prompt", "").strip(),
|
if action == "clear":
|
||||||
})
|
session.pop("chat_history", None)
|
||||||
|
session.pop("chat_system_prompt", None)
|
||||||
|
session.pop("chat_model", None)
|
||||||
|
return redirect(url_for("generate_text"))
|
||||||
|
|
||||||
|
prompt = request.form.get("prompt", "").strip()
|
||||||
|
model = request.form.get("model", "").strip()
|
||||||
|
system_prompt = request.form.get("system_prompt", "").strip()
|
||||||
|
|
||||||
|
# Persist model + system_prompt across turns
|
||||||
|
session["chat_model"] = model
|
||||||
|
session["chat_system_prompt"] = system_prompt
|
||||||
|
|
||||||
|
if prompt:
|
||||||
|
# Build messages: history (user/assistant only) + new user msg
|
||||||
|
messages = [m for m in chat_history if m["role"]
|
||||||
|
in ("user", "assistant")]
|
||||||
|
messages.append({"role": "user", "content": prompt})
|
||||||
|
|
||||||
|
payload: dict = {
|
||||||
|
"model": model,
|
||||||
|
"messages": [{"role": m["role"], "content": m["content"]} for m in messages],
|
||||||
|
}
|
||||||
|
if system_prompt:
|
||||||
|
payload["system_prompt"] = system_prompt
|
||||||
|
|
||||||
|
resp = _api("POST", "/generate/text", token=token, json=payload)
|
||||||
if resp.status_code == 200:
|
if resp.status_code == 200:
|
||||||
result = resp.json()
|
data = resp.json()
|
||||||
|
chat_history = list(messages)
|
||||||
|
chat_history.append({"role": "assistant", "content": data["content"],
|
||||||
|
"usage": data.get("usage")})
|
||||||
|
session["chat_history"] = chat_history
|
||||||
else:
|
else:
|
||||||
|
try:
|
||||||
error = resp.json().get("detail", "Generation failed.")
|
error = resp.json().get("detail", "Generation failed.")
|
||||||
|
except Exception:
|
||||||
|
error = "Generation failed."
|
||||||
|
|
||||||
models = _load_models(token, "text")
|
models = _load_models(token, "text")
|
||||||
return render_template("generate_text.html", result=result, error=error, models=models)
|
return render_template(
|
||||||
|
"generate_text.html",
|
||||||
|
chat_history=session.get("chat_history", []),
|
||||||
|
error=error,
|
||||||
|
models=models,
|
||||||
|
system_prompt=system_prompt,
|
||||||
|
current_model=model,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/generate/image", methods=["GET", "POST"])
|
@app.route("/generate/image", methods=["GET", "POST"])
|
||||||
|
|||||||
@@ -695,3 +695,123 @@ pre {
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Chat interface ─────────────────────────────────────────────────────── */
|
||||||
|
.chat-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: calc(100vh - 100px);
|
||||||
|
max-height: 900px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-config {
|
||||||
|
border: 1px solid var(--border, #ddd);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-config summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-config-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-history {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-top: 1px solid var(--border, #ddd);
|
||||||
|
border-bottom: 1px solid var(--border, #ddd);
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-empty {
|
||||||
|
color: var(--text-muted, #888);
|
||||||
|
text-align: center;
|
||||||
|
margin: auto;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-bubble {
|
||||||
|
max-width: 80%;
|
||||||
|
padding: 0.6rem 0.9rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-bubble--user {
|
||||||
|
align-self: flex-end;
|
||||||
|
background: var(--accent, #7c6ff7);
|
||||||
|
color: #fff;
|
||||||
|
border-bottom-right-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-bubble--assistant {
|
||||||
|
align-self: flex-start;
|
||||||
|
background: var(--surface-2, #f0f0f0);
|
||||||
|
color: var(--text, #222);
|
||||||
|
border-bottom-left-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-role {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
opacity: 0.7;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-content {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-meta {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
opacity: 0.6;
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-textarea {
|
||||||
|
flex: 1;
|
||||||
|
resize: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
min-height: 2.5rem;
|
||||||
|
max-height: 8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
padding: 0.3rem 0.7rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,52 +1,92 @@
|
|||||||
{% extends "base.html" %} {% block title %}Text Generation — All You Can GET
|
{% extends "base.html" %} {% block title %}Text Generation — All You Can GET
|
||||||
AI{% endblock %} {% block content %}
|
AI{% endblock %} {% block content %}
|
||||||
<div class="card">
|
<div class="card chat-page">
|
||||||
<h1>Text Generation</h1>
|
<div class="chat-header">
|
||||||
<form method="post">
|
<h1>Text Chat</h1>
|
||||||
<label for="model">Model</label>
|
<form method="post" style="display: inline">
|
||||||
|
<input type="hidden" name="action" value="clear" />
|
||||||
|
<button type="submit" class="btn-secondary btn-sm">New Chat</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Config row -->
|
||||||
|
<details class="chat-config" {% if not chat_history %}open{% endif %}>
|
||||||
|
<summary>Model & System Prompt</summary>
|
||||||
|
<div class="chat-config-body">
|
||||||
|
<label for="cfg-model">Model</label>
|
||||||
{% if models %}
|
{% if models %}
|
||||||
<select id="model" name="model" required>
|
<select id="cfg-model" form="chat-form" name="model" required>
|
||||||
{% for m in models %}
|
{% for m in models %}
|
||||||
<option value="{{ m.id }}" {% if request.form.get('model', '') == m.id %}selected{% endif %}>{{ m.name }}</option>
|
<option value="{{ m.id }}" {{ "selected" if current_model == m.id else "" }}>{{ m.name }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
{% else %}
|
{% else %}
|
||||||
<input
|
<input
|
||||||
id="model"
|
id="cfg-model"
|
||||||
|
form="chat-form"
|
||||||
name="model"
|
name="model"
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
placeholder="e.g. openai/gpt-4o"
|
placeholder="e.g. openai/gpt-4o"
|
||||||
value="{{ request.form.get('model', '') }}"
|
value="{{ current_model }}"
|
||||||
/>
|
/>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<label for="prompt">Prompt</label>
|
<label for="cfg-sys">System prompt (optional)</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="prompt"
|
id="cfg-sys"
|
||||||
name="prompt"
|
form="chat-form"
|
||||||
rows="5"
|
name="system_prompt"
|
||||||
required
|
rows="2"
|
||||||
placeholder="Describe what you want…"
|
placeholder="Set behavior/instructions for assistant…"
|
||||||
>
|
>
|
||||||
{{ request.form.get('prompt', '') }}</textarea
|
{{ system_prompt }}</textarea
|
||||||
>
|
>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
<button type="submit">Generate text</button>
|
<!-- Chat history -->
|
||||||
</form>
|
<div class="chat-history" id="chat-history">
|
||||||
|
{% if not chat_history %}
|
||||||
{% if error %}
|
<p class="chat-empty">No messages yet. Start the conversation below.</p>
|
||||||
<div class="alert alert-error mt-2">{{ error }}</div>
|
{% endif %} {% for msg in chat_history %} {% if msg.role == "user" %}
|
||||||
{% endif %} {% if result %}
|
<div class="chat-bubble chat-bubble--user">
|
||||||
<div class="result">
|
<span class="bubble-role">You</span>
|
||||||
<h2>Result</h2>
|
<div class="bubble-content">{{ msg.content }}</div>
|
||||||
<pre>{{ result.content }}</pre>
|
</div>
|
||||||
{% if result.usage %}
|
{% elif msg.role == "assistant" %}
|
||||||
<p class="text-muted mt-1" style="font-size: 0.8rem">
|
<div class="chat-bubble chat-bubble--assistant">
|
||||||
Tokens: {{ result.usage.get('total_tokens', '—') }}
|
<span class="bubble-role">Assistant</span>
|
||||||
</p>
|
<div class="bubble-content">{{ msg.content }}</div>
|
||||||
|
{% if msg.usage %}
|
||||||
|
<span class="bubble-meta"
|
||||||
|
>{{ msg.usage.get('total_tokens', '') }} tokens</span
|
||||||
|
>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %} {% endfor %} {% if error %}
|
||||||
|
<div class="alert alert-error">{{ error }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Input -->
|
||||||
|
<form id="chat-form" method="post" class="chat-input-row">
|
||||||
|
<input type="hidden" name="action" value="send" />
|
||||||
|
<textarea
|
||||||
|
name="prompt"
|
||||||
|
id="prompt"
|
||||||
|
rows="2"
|
||||||
|
required
|
||||||
|
placeholder="Type a message…"
|
||||||
|
class="chat-input-textarea"
|
||||||
|
></textarea>
|
||||||
|
<button type="submit" class="btn-primary">Send</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Auto-scroll chat to bottom
|
||||||
|
const hist = document.getElementById("chat-history");
|
||||||
|
if (hist) hist.scrollTop = hist.scrollHeight;
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ def test_generate_text_page_renders(client):
|
|||||||
_set_auth(client)
|
_set_auth(client)
|
||||||
resp = client.get("/generate/text")
|
resp = client.get("/generate/text")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert b"Text Generation" in resp.data
|
assert b"Text Chat" in resp.data
|
||||||
|
|
||||||
|
|
||||||
def test_generate_text_requires_login(client):
|
def test_generate_text_requires_login(client):
|
||||||
@@ -183,13 +183,108 @@ def test_generate_text_requires_login(client):
|
|||||||
|
|
||||||
def test_generate_text_success(client):
|
def test_generate_text_success(client):
|
||||||
_set_auth(client)
|
_set_auth(client)
|
||||||
mock = _mock_response(
|
gen_mock = _mock_response(
|
||||||
200, {"id": "g1", "model": "openai/gpt-4o", "content": "Hello world", "usage": None})
|
200, {"id": "g1", "model": "openai/gpt-4o", "content": "Hello world", "usage": None})
|
||||||
with patch("frontend.app.main.httpx.request", return_value=mock):
|
models_mock = _mock_response(200, [
|
||||||
|
{"id": "openai/gpt-4o", "name": "GPT-4o", "modality": "text"}
|
||||||
|
])
|
||||||
|
with patch("frontend.app.main.httpx.request", side_effect=[gen_mock, models_mock]):
|
||||||
resp = client.post(
|
resp = client.post(
|
||||||
"/generate/text", data={"model": "openai/gpt-4o", "prompt": "Say hello"})
|
"/generate/text",
|
||||||
|
data={"model": "openai/gpt-4o", "prompt": "Say hello", "action": "send"})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert b"Hello world" in resp.data
|
assert b"Hello world" in resp.data
|
||||||
|
assert b"chat-bubble--assistant" in resp.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_text_page_shows_optional_system_prompt(client):
|
||||||
|
_set_auth(client)
|
||||||
|
models_mock = _mock_response(200, [])
|
||||||
|
with patch("frontend.app.main.httpx.request", return_value=models_mock):
|
||||||
|
resp = client.get("/generate/text")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert b"System prompt (optional)" in resp.data
|
||||||
|
assert b'name="system_prompt"' in resp.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_text_forwards_system_prompt(client):
|
||||||
|
_set_auth(client)
|
||||||
|
gen_mock = _mock_response(
|
||||||
|
200, {"id": "g1", "model": "openai/gpt-4o", "content": "Hello world", "usage": None})
|
||||||
|
models_mock = _mock_response(200, [
|
||||||
|
{"id": "openai/gpt-4o", "name": "GPT-4o", "modality": "text"}
|
||||||
|
])
|
||||||
|
|
||||||
|
with patch("frontend.app.main.httpx.request", side_effect=[gen_mock, models_mock]) as mock_request:
|
||||||
|
resp = client.post(
|
||||||
|
"/generate/text",
|
||||||
|
data={
|
||||||
|
"model": "openai/gpt-4o",
|
||||||
|
"prompt": "Say hello",
|
||||||
|
"system_prompt": "You are concise.",
|
||||||
|
"action": "send",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
first_call_kwargs = mock_request.call_args_list[0].kwargs
|
||||||
|
assert first_call_kwargs["json"]["system_prompt"] == "You are concise."
|
||||||
|
# Messages array sent (not bare prompt)
|
||||||
|
assert "messages" in first_call_kwargs["json"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_text_chat_history_accumulates(client):
|
||||||
|
"""Second message includes prior user+assistant turns in messages array."""
|
||||||
|
_set_auth(client)
|
||||||
|
|
||||||
|
turn1_gen = _mock_response(
|
||||||
|
200, {"id": "g1", "model": "openai/gpt-4o", "content": "Turn 1 reply", "usage": None})
|
||||||
|
turn1_models = _mock_response(
|
||||||
|
200, [{"id": "openai/gpt-4o", "name": "GPT-4o", "modality": "text"}])
|
||||||
|
turn2_gen = _mock_response(
|
||||||
|
200, {"id": "g2", "model": "openai/gpt-4o", "content": "Turn 2 reply", "usage": None})
|
||||||
|
turn2_models = _mock_response(
|
||||||
|
200, [{"id": "openai/gpt-4o", "name": "GPT-4o", "modality": "text"}])
|
||||||
|
|
||||||
|
with patch("frontend.app.main.httpx.request", side_effect=[turn1_gen, turn1_models]):
|
||||||
|
client.post(
|
||||||
|
"/generate/text", data={"model": "openai/gpt-4o", "prompt": "First", "action": "send"})
|
||||||
|
|
||||||
|
with patch("frontend.app.main.httpx.request", side_effect=[turn2_gen, turn2_models]) as mock_req:
|
||||||
|
resp = client.post(
|
||||||
|
"/generate/text", data={"model": "openai/gpt-4o", "prompt": "Second", "action": "send"})
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert b"Turn 1 reply" in resp.data
|
||||||
|
assert b"Turn 2 reply" in resp.data
|
||||||
|
# Backend received 3 messages: First(user), Turn1(assistant), Second(user)
|
||||||
|
sent_messages = mock_req.call_args_list[0].kwargs["json"]["messages"]
|
||||||
|
assert len(sent_messages) == 3
|
||||||
|
assert sent_messages[0]["role"] == "user" and sent_messages[0]["content"] == "First"
|
||||||
|
assert sent_messages[1]["role"] == "assistant"
|
||||||
|
assert sent_messages[2]["role"] == "user" and sent_messages[2]["content"] == "Second"
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_text_clear_resets_history(client):
|
||||||
|
"""Clear action removes session history and redirects."""
|
||||||
|
_set_auth(client)
|
||||||
|
|
||||||
|
gen_mock = _mock_response(
|
||||||
|
200, {"id": "g1", "model": "openai/gpt-4o", "content": "Reply", "usage": None})
|
||||||
|
models_mock = _mock_response(
|
||||||
|
200, [{"id": "openai/gpt-4o", "name": "GPT-4o", "modality": "text"}])
|
||||||
|
with patch("frontend.app.main.httpx.request", side_effect=[gen_mock, models_mock]):
|
||||||
|
client.post(
|
||||||
|
"/generate/text", data={"model": "openai/gpt-4o", "prompt": "Hi", "action": "send"})
|
||||||
|
|
||||||
|
clear_resp = client.post("/generate/text", data={"action": "clear"})
|
||||||
|
assert clear_resp.status_code == 302
|
||||||
|
|
||||||
|
models_mock2 = _mock_response(
|
||||||
|
200, [{"id": "openai/gpt-4o", "name": "GPT-4o", "modality": "text"}])
|
||||||
|
with patch("frontend.app.main.httpx.request", return_value=models_mock2):
|
||||||
|
get_resp = client.get("/generate/text")
|
||||||
|
assert b"No messages yet" in get_resp.data
|
||||||
|
|
||||||
|
|
||||||
def test_generate_image_page_renders(client):
|
def test_generate_image_page_renders(client):
|
||||||
@@ -251,8 +346,11 @@ def test_generate_video_image_mode(client):
|
|||||||
|
|
||||||
def test_generate_upstream_error_shows_message(client):
|
def test_generate_upstream_error_shows_message(client):
|
||||||
_set_auth(client)
|
_set_auth(client)
|
||||||
mock = _mock_response(502, {"detail": "OpenRouter error: timeout"})
|
gen_mock = _mock_response(502, {"detail": "OpenRouter error: timeout"})
|
||||||
with patch("frontend.app.main.httpx.request", return_value=mock):
|
models_mock = _mock_response(200, [
|
||||||
|
{"id": "openai/gpt-4o", "name": "GPT-4o", "modality": "text"}
|
||||||
|
])
|
||||||
|
with patch("frontend.app.main.httpx.request", side_effect=[gen_mock, models_mock]):
|
||||||
resp = client.post(
|
resp = client.post(
|
||||||
"/generate/text", data={"model": "openai/gpt-4o", "prompt": "Hi"})
|
"/generate/text", data={"model": "openai/gpt-4o", "prompt": "Hi"})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
|
|||||||
Reference in New Issue
Block a user