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:
2026-04-29 14:39:38 +02:00
parent 78b76dc331
commit 3d32e6df74
7 changed files with 428 additions and 79 deletions
+2 -1
View File
@@ -33,8 +33,9 @@ class ModelInfo(BaseModel):
class TextRequest(BaseModel):
model: str
prompt: str
prompt: str = ""
system_prompt: str | None = None
messages: list[ChatMessage] | None = None
temperature: float = 0.7
max_tokens: int = 1024
+7
View File
@@ -23,6 +23,13 @@ async def generate_text(
_: dict = Depends(get_current_user),
) -> TextResponse:
"""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 = []
if body.system_prompt:
messages.append({"role": "system", "content": body.system_prompt})
+53 -15
View File
@@ -69,7 +69,7 @@ async def _user_token(client):
async def test_generate_text(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(
"/generate/text",
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):
token = await _user_token(client)
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(
"/generate/text",
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"}
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):
resp = await client.post("/generate/text", json={"model": "openai/gpt-4o", "prompt": "Hi"})
assert resp.status_code == 401
@@ -104,7 +142,7 @@ async def test_generate_text_unauthenticated(client):
async def test_generate_text_upstream_error(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(
"/generate/text",
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):
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(
"/generate/image",
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):
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(
"/generate/image",
json={"model": "openai/dall-e-3", "prompt": "Hi"},
@@ -184,7 +222,7 @@ FAKE_IMAGE_CHAT_GPT5 = {
async def test_generate_image_chat_flux(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(
"/generate/image",
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):
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(
"/generate/image",
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):
token = await _user_token(client)
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(
"/generate/image",
json={
@@ -239,7 +277,7 @@ async def test_generate_image_chat_unauthenticated(client):
async def test_generate_image_chat_upstream_error(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(
"/generate/image",
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):
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(
"/generate/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):
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(
"/generate/video",
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):
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(
"/generate/video/from-image",
json={
@@ -315,7 +353,7 @@ async def test_poll_video_status(client):
"status": "completed",
"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(
"/generate/video/status",
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):
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(
"/generate/video/status",
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):
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(
"/generate/video/from-image",
json={"model": "runway/gen-3",
+52 -7
View File
@@ -206,19 +206,64 @@ def generate():
@app.route("/generate/text", methods=["GET", "POST"])
@login_required
def generate_text():
result = error = None
error = None
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":
resp = _api("POST", "/generate/text", token=token, json={
"model": request.form.get("model", "").strip(),
"prompt": request.form.get("prompt", "").strip(),
})
action = request.form.get("action", "send")
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:
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:
try:
error = resp.json().get("detail", "Generation failed.")
except Exception:
error = "Generation failed."
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"])
+120
View File
@@ -695,3 +695,123 @@ pre {
border-radius: 8px;
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;
}
+71 -31
View File
@@ -1,52 +1,92 @@
{% extends "base.html" %} {% block title %}Text Generation — All You Can GET
AI{% endblock %} {% block content %}
<div class="card">
<h1>Text Generation</h1>
<form method="post">
<label for="model">Model</label>
<div class="card chat-page">
<div class="chat-header">
<h1>Text Chat</h1>
<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 &amp; System Prompt</summary>
<div class="chat-config-body">
<label for="cfg-model">Model</label>
{% if models %}
<select id="model" name="model" required>
<select id="cfg-model" form="chat-form" name="model" required>
{% 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 %}
</select>
{% else %}
<input
id="model"
id="cfg-model"
form="chat-form"
name="model"
type="text"
required
placeholder="e.g. openai/gpt-4o"
value="{{ request.form.get('model', '') }}"
value="{{ current_model }}"
/>
{% endif %}
<label for="prompt">Prompt</label>
<label for="cfg-sys">System prompt (optional)</label>
<textarea
id="cfg-sys"
form="chat-form"
name="system_prompt"
rows="2"
placeholder="Set behavior/instructions for assistant…"
>
{{ system_prompt }}</textarea
>
</div>
</details>
<!-- Chat history -->
<div class="chat-history" id="chat-history">
{% if not chat_history %}
<p class="chat-empty">No messages yet. Start the conversation below.</p>
{% endif %} {% for msg in chat_history %} {% if msg.role == "user" %}
<div class="chat-bubble chat-bubble--user">
<span class="bubble-role">You</span>
<div class="bubble-content">{{ msg.content }}</div>
</div>
{% elif msg.role == "assistant" %}
<div class="chat-bubble chat-bubble--assistant">
<span class="bubble-role">Assistant</span>
<div class="bubble-content">{{ msg.content }}</div>
{% if msg.usage %}
<span class="bubble-meta"
>{{ msg.usage.get('total_tokens', '') }} tokens</span
>
{% endif %}
</div>
{% endif %} {% endfor %} {% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
</div>
<!-- Input -->
<form id="chat-form" method="post" class="chat-input-row">
<input type="hidden" name="action" value="send" />
<textarea
id="prompt"
name="prompt"
rows="5"
id="prompt"
rows="2"
required
placeholder="Describe what you want…"
>
{{ request.form.get('prompt', '') }}</textarea
>
<button type="submit">Generate text</button>
placeholder="Type a message…"
class="chat-input-textarea"
></textarea>
<button type="submit" class="btn-primary">Send</button>
</form>
</div>
{% if error %}
<div class="alert alert-error mt-2">{{ error }}</div>
{% endif %} {% if result %}
<div class="result">
<h2>Result</h2>
<pre>{{ result.content }}</pre>
{% if result.usage %}
<p class="text-muted mt-1" style="font-size: 0.8rem">
Tokens: {{ result.usage.get('total_tokens', '—') }}
</p>
{% endif %}
</div>
{% endif %}
</div>
<script>
// Auto-scroll chat to bottom
const hist = document.getElementById("chat-history");
if (hist) hist.scrollTop = hist.scrollHeight;
</script>
{% endblock %}
+104 -6
View File
@@ -172,7 +172,7 @@ def test_generate_text_page_renders(client):
_set_auth(client)
resp = client.get("/generate/text")
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):
@@ -183,13 +183,108 @@ def test_generate_text_requires_login(client):
def test_generate_text_success(client):
_set_auth(client)
mock = _mock_response(
gen_mock = _mock_response(
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(
"/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 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):
@@ -251,8 +346,11 @@ def test_generate_video_image_mode(client):
def test_generate_upstream_error_shows_message(client):
_set_auth(client)
mock = _mock_response(502, {"detail": "OpenRouter error: timeout"})
with patch("frontend.app.main.httpx.request", return_value=mock):
gen_mock = _mock_response(502, {"detail": "OpenRouter error: timeout"})
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(
"/generate/text", data={"model": "openai/gpt-4o", "prompt": "Hi"})
assert resp.status_code == 200