feat: add site assistant component for guided tool selection

- Introduced SiteAssistant component to assist users in selecting the right tools based on their queries.
- Integrated assistant into the main App component.
- Implemented message handling and storage for user-assistant interactions.
- Added quick prompts for common user queries related to tools.
- Enhanced ToolLandingPage and DownloadButton components with SharePanel for sharing tool results.
- Updated translations for new assistant features and sharing options.
- Added API methods for chat functionality with the assistant, including streaming responses.
This commit is contained in:
Your Name
2026-03-14 10:07:55 +02:00
parent e06e64f85f
commit 2b3367cdea
21 changed files with 1877 additions and 39 deletions

View File

@@ -8,6 +8,7 @@ from app import create_app
from app.services.account_service import init_account_db
from app.services.rating_service import init_ratings_db
from app.services.ai_cost_service import init_ai_cost_db
from app.services.site_assistant_service import init_site_assistant_db
@pytest.fixture
@@ -33,6 +34,7 @@ def app():
init_account_db()
init_ratings_db()
init_ai_cost_db()
init_site_assistant_db()
# Create temp directories
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)

View File

@@ -0,0 +1,71 @@
"""Tests for the site assistant API route."""
import json
class TestAssistantRoute:
def test_requires_message(self, client):
response = client.post('/api/assistant/chat', json={})
assert response.status_code == 400
assert response.get_json()['error'] == 'Message is required.'
def test_success_returns_reply_and_session(self, client, monkeypatch):
monkeypatch.setattr(
'app.routes.assistant.chat_with_site_assistant',
lambda **kwargs: {
'session_id': kwargs['session_id'] or 'assistant-session-1',
'reply': 'Use Merge PDF for combining files.',
'stored': True,
},
)
response = client.post(
'/api/assistant/chat',
json={
'message': 'How do I combine files?',
'fingerprint': 'visitor-1',
'tool_slug': 'merge-pdf',
},
)
assert response.status_code == 200
body = response.get_json()
assert body['stored'] is True
assert body['reply'] == 'Use Merge PDF for combining files.'
assert body['session_id']
def test_stream_returns_sse_events(self, client, monkeypatch):
monkeypatch.setattr(
'app.routes.assistant.stream_site_assistant_chat',
lambda **kwargs: iter([
{'event': 'session', 'data': {'session_id': 'assistant-session-1'}},
{'event': 'chunk', 'data': {'content': 'Use Merge '}},
{'event': 'chunk', 'data': {'content': 'PDF.'}},
{
'event': 'done',
'data': {
'session_id': 'assistant-session-1',
'reply': 'Use Merge PDF.',
'stored': True,
},
},
]),
)
response = client.post(
'/api/assistant/chat/stream',
json={
'message': 'How do I combine files?',
'fingerprint': 'visitor-1',
'tool_slug': 'merge-pdf',
},
)
assert response.status_code == 200
assert response.headers['Content-Type'].startswith('text/event-stream')
body = response.get_data(as_text=True)
assert 'event: session' in body
assert f"data: {json.dumps({'session_id': 'assistant-session-1'})}" in body
assert 'event: chunk' in body
assert 'Use Merge PDF.' in body

View File

@@ -0,0 +1,119 @@
"""Tests for shared OpenRouter configuration resolution across AI services."""
from app.services.openrouter_config_service import get_openrouter_settings
from app.services.pdf_ai_service import _call_openrouter
from app.services.site_assistant_service import _request_ai_reply
class _FakeResponse:
def __init__(self, payload):
self._payload = payload
def raise_for_status(self):
return None
def json(self):
return self._payload
class TestOpenRouterConfigService:
def test_prefers_flask_config_when_app_context_exists(self, app, monkeypatch):
monkeypatch.setenv('OPENROUTER_API_KEY', 'env-key')
monkeypatch.setenv('OPENROUTER_MODEL', 'env-model')
monkeypatch.setenv('OPENROUTER_BASE_URL', 'https://env.example/api')
with app.app_context():
app.config.update({
'OPENROUTER_API_KEY': 'config-key',
'OPENROUTER_MODEL': 'config-model',
'OPENROUTER_BASE_URL': 'https://config.example/api',
})
settings = get_openrouter_settings()
assert settings.api_key == 'config-key'
assert settings.model == 'config-model'
assert settings.base_url == 'https://config.example/api'
def test_falls_back_to_environment_without_app_context(self, monkeypatch):
monkeypatch.setenv('OPENROUTER_API_KEY', 'env-key')
monkeypatch.setenv('OPENROUTER_MODEL', 'env-model')
monkeypatch.setenv('OPENROUTER_BASE_URL', 'https://env.example/api')
settings = get_openrouter_settings()
assert settings.api_key == 'env-key'
assert settings.model == 'env-model'
assert settings.base_url == 'https://env.example/api'
class TestAiServicesUseSharedConfig:
def test_pdf_ai_uses_flask_config(self, app, monkeypatch):
captured = {}
monkeypatch.setattr('app.services.ai_cost_service.check_ai_budget', lambda: None)
monkeypatch.setattr('app.services.ai_cost_service.log_ai_usage', lambda **kwargs: captured.setdefault('usage', kwargs))
def fake_post(url, headers, json, timeout):
captured['url'] = url
captured['headers'] = headers
captured['json'] = json
captured['timeout'] = timeout
return _FakeResponse({
'choices': [{'message': {'content': 'Configured PDF reply'}}],
'usage': {'prompt_tokens': 11, 'completion_tokens': 7},
})
monkeypatch.setattr('app.services.pdf_ai_service.requests.post', fake_post)
with app.app_context():
app.config.update({
'OPENROUTER_API_KEY': 'config-key',
'OPENROUTER_MODEL': 'config-model',
'OPENROUTER_BASE_URL': 'https://config.example/pdf-ai',
})
reply = _call_openrouter('system prompt', 'user question', max_tokens=321, tool_name='pdf_chat')
assert reply == 'Configured PDF reply'
assert captured['url'] == 'https://config.example/pdf-ai'
assert captured['headers']['Authorization'] == 'Bearer config-key'
assert captured['json']['model'] == 'config-model'
assert captured['json']['max_tokens'] == 321
assert captured['usage']['model'] == 'config-model'
def test_site_assistant_uses_flask_config(self, app, monkeypatch):
captured = {}
monkeypatch.setattr('app.services.site_assistant_service.log_ai_usage', lambda **kwargs: captured.setdefault('usage', kwargs))
def fake_post(url, headers, json, timeout):
captured['url'] = url
captured['headers'] = headers
captured['json'] = json
captured['timeout'] = timeout
return _FakeResponse({
'choices': [{'message': {'content': 'Configured assistant reply'}}],
'usage': {'prompt_tokens': 13, 'completion_tokens': 9},
})
monkeypatch.setattr('app.services.site_assistant_service.requests.post', fake_post)
with app.app_context():
app.config.update({
'OPENROUTER_API_KEY': 'assistant-key',
'OPENROUTER_MODEL': 'assistant-model',
'OPENROUTER_BASE_URL': 'https://config.example/assistant',
})
reply = _request_ai_reply(
message='How do I merge files?',
tool_slug='merge-pdf',
page_url='https://example.com/tools/merge-pdf',
locale='en',
history=[{'role': 'assistant', 'content': 'Previous reply'}],
)
assert reply == 'Configured assistant reply'
assert captured['url'] == 'https://config.example/assistant'
assert captured['headers']['Authorization'] == 'Bearer assistant-key'
assert captured['json']['model'] == 'assistant-model'
assert captured['json']['messages'][-1] == {'role': 'user', 'content': 'How do I merge files?'}
assert captured['usage']['model'] == 'assistant-model'

View File

@@ -0,0 +1,105 @@
"""Tests for site assistant persistence and fallback behavior."""
import json
import sqlite3
from app.services.site_assistant_service import chat_with_site_assistant, stream_site_assistant_chat
class TestSiteAssistantService:
def test_chat_persists_conversation_and_messages(self, app, monkeypatch):
with app.app_context():
monkeypatch.setattr(
'app.services.site_assistant_service._request_ai_reply',
lambda **kwargs: 'Use Merge PDF if you want one combined document.',
)
result = chat_with_site_assistant(
message='How can I combine PDF files?',
session_id='assistant-session-123',
fingerprint='visitor-123',
tool_slug='merge-pdf',
page_url='https://example.com/tools/merge-pdf',
locale='en',
user_id=None,
history=[{'role': 'user', 'content': 'Hello'}],
)
assert result['stored'] is True
assert result['session_id'] == 'assistant-session-123'
assert 'Merge PDF' in result['reply']
connection = sqlite3.connect(app.config['DATABASE_PATH'])
connection.row_factory = sqlite3.Row
conversation = connection.execute(
'SELECT session_id, fingerprint, tool_slug, locale FROM assistant_conversations WHERE session_id = ?',
('assistant-session-123',),
).fetchone()
messages = connection.execute(
'SELECT role, content FROM assistant_messages ORDER BY id ASC'
).fetchall()
assert conversation['fingerprint'] == 'visitor-123'
assert conversation['tool_slug'] == 'merge-pdf'
assert conversation['locale'] == 'en'
assert [row['role'] for row in messages] == ['user', 'assistant']
assert 'How can I combine PDF files?' in messages[0]['content']
assert 'Merge PDF' in messages[1]['content']
def test_stream_chat_persists_streamed_reply(self, app, monkeypatch):
class FakeStreamResponse:
def raise_for_status(self):
return None
def iter_lines(self, decode_unicode=True):
yield 'data: ' + json.dumps({
'choices': [{'delta': {'content': 'Use Merge '}}],
})
yield 'data: ' + json.dumps({
'choices': [{'delta': {'content': 'PDF for this.'}}],
})
yield 'data: [DONE]'
def close(self):
return None
with app.app_context():
monkeypatch.setattr(
'app.services.site_assistant_service.check_ai_budget',
lambda: None,
)
monkeypatch.setattr(
'app.services.site_assistant_service.requests.post',
lambda *args, **kwargs: FakeStreamResponse(),
)
app.config.update({
'OPENROUTER_API_KEY': 'config-key',
'OPENROUTER_MODEL': 'config-model',
})
events = list(stream_site_assistant_chat(
message='How can I combine PDF files?',
session_id='assistant-stream-123',
fingerprint='visitor-123',
tool_slug='merge-pdf',
page_url='https://example.com/tools/merge-pdf',
locale='en',
user_id=None,
history=[{'role': 'assistant', 'content': 'Hello'}],
))
assert events[0]['event'] == 'session'
assert events[1]['event'] == 'chunk'
assert events[2]['event'] == 'chunk'
assert events[-1]['event'] == 'done'
assert events[-1]['data']['reply'] == 'Use Merge PDF for this.'
connection = sqlite3.connect(app.config['DATABASE_PATH'])
connection.row_factory = sqlite3.Row
messages = connection.execute(
'SELECT role, content, metadata_json FROM assistant_messages ORDER BY id ASC'
).fetchall()
assert [row['role'] for row in messages] == ['user', 'assistant']
assert messages[1]['content'] == 'Use Merge PDF for this.'
assert 'config-model' in messages[1]['metadata_json']