Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 66c14b27f1 | |||
| dd9d760659 | |||
| 3b664c2a44 |
@@ -22,6 +22,7 @@ class RAGWebResult(BaseModel):
|
||||
score: float = Field(
|
||||
..., description="Relevance score of the web result, typically between 0 and 1."
|
||||
)
|
||||
metadata: dict = Field(..., description="Metadata of the web result.")
|
||||
|
||||
|
||||
class RAGWebResults(BaseModel):
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import json
|
||||
import logging
|
||||
from io import BytesIO
|
||||
from typing import Optional
|
||||
from typing import List, Optional
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from django.conf import settings
|
||||
@@ -150,22 +150,30 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
|
||||
logger.debug(response.json())
|
||||
response.raise_for_status()
|
||||
|
||||
def search(self, query, results_count: int = 4) -> RAGWebResults:
|
||||
def search(self, query, results_count: int = 4, collections: Optional[List[int]] = None) -> RAGWebResults:
|
||||
"""
|
||||
Perform a search using the Albert API based on the provided query.
|
||||
|
||||
Args:
|
||||
query (str): The search query.
|
||||
results_count (int): The number of results to return.
|
||||
collections (Optional[List[int]]): List of collection IDs to search in.
|
||||
If None, uses the current collection_id.
|
||||
|
||||
Returns:
|
||||
RAGWebResults: The search results.
|
||||
"""
|
||||
# Use provided collections or fall back to current collection_id
|
||||
if collections is not None:
|
||||
collection_list = collections
|
||||
else:
|
||||
collection_list = [int(self.collection_id)]
|
||||
|
||||
response = requests.post(
|
||||
urljoin(self._base_url, self._search_endpoint),
|
||||
headers=self._headers,
|
||||
json={
|
||||
"collections": [int(self.collection_id)],
|
||||
"collections": collection_list,
|
||||
"prompt": query,
|
||||
"score_threshold": 0.6,
|
||||
"k": results_count, # Number of chunks to return from the search
|
||||
@@ -182,6 +190,7 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
|
||||
url=result.chunk.metadata["document_name"],
|
||||
content=result.chunk.content,
|
||||
score=result.score,
|
||||
metadata=result.chunk.metadata,
|
||||
)
|
||||
for result in searches.data
|
||||
],
|
||||
|
||||
@@ -103,6 +103,7 @@ class AlbertWebSearchManager(BaseWebSearchManager):
|
||||
url=self._clean_url(result.chunk.metadata["document_name"]),
|
||||
content=result.chunk.content,
|
||||
score=result.score,
|
||||
metadata=result.chunk.metadata,
|
||||
)
|
||||
for result in searches.data
|
||||
],
|
||||
|
||||
@@ -148,13 +148,29 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
|
||||
# Public streaming API (unchanged signatures)
|
||||
# --------------------------------------------------------------------- #
|
||||
|
||||
def stream_text(self, messages: List[UIMessage], force_web_search: bool = False):
|
||||
def stream_text(
|
||||
self,
|
||||
messages: List[UIMessage],
|
||||
*,
|
||||
selected_tools: list[str] | None = None,
|
||||
force_web_search: bool = False,
|
||||
):
|
||||
"""Return only the assistant text deltas (legacy text mode)."""
|
||||
return convert_async_generator_to_sync(self.stream_text_async(messages, force_web_search))
|
||||
return convert_async_generator_to_sync(
|
||||
self.stream_text_async(messages, selected_tools=selected_tools, force_web_search=force_web_search)
|
||||
)
|
||||
|
||||
def stream_data(self, messages: List[UIMessage], force_web_search: bool = False):
|
||||
def stream_data(
|
||||
self,
|
||||
messages: List[UIMessage],
|
||||
*,
|
||||
selected_tools: list[str] | None = None,
|
||||
force_web_search: bool = False,
|
||||
):
|
||||
"""Return Vercel-AI-SDK formatted events."""
|
||||
return convert_async_generator_to_sync(self.stream_data_async(messages, force_web_search))
|
||||
return convert_async_generator_to_sync(
|
||||
self.stream_data_async(messages, selected_tools=selected_tools, force_web_search=force_web_search)
|
||||
)
|
||||
|
||||
def stop_streaming(self):
|
||||
"""
|
||||
@@ -169,7 +185,13 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
|
||||
# Async internals
|
||||
# --------------------------------------------------------------------- #
|
||||
|
||||
async def stream_text_async(self, messages: List[UIMessage], force_web_search: bool = False):
|
||||
async def stream_text_async(
|
||||
self,
|
||||
messages: List[UIMessage],
|
||||
*,
|
||||
selected_tools: list[str] | None = None,
|
||||
force_web_search: bool = False,
|
||||
):
|
||||
"""Return only the assistant text deltas (legacy text mode)."""
|
||||
await self._clean()
|
||||
with ExitStack() as stack:
|
||||
@@ -177,18 +199,24 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
|
||||
span = stack.enter_context(get_client().start_as_current_span(name="conversation"))
|
||||
span.update_trace(user_id=str(self.user.sub), session_id=str(self.conversation.pk))
|
||||
|
||||
async for event in self._run_agent(messages, force_web_search):
|
||||
async for event in self._run_agent(messages, selected_tools=selected_tools, force_web_search=force_web_search):
|
||||
if stream_text := self.event_encoder.encode_text(event):
|
||||
yield stream_text
|
||||
|
||||
async def stream_data_async(self, messages: List[UIMessage], force_web_search: bool = False):
|
||||
async def stream_data_async(
|
||||
self,
|
||||
messages: List[UIMessage],
|
||||
*,
|
||||
selected_tools: list[str] | None = None,
|
||||
force_web_search: bool = False,
|
||||
):
|
||||
"""Return Vercel-AI-SDK formatted events."""
|
||||
await self._clean()
|
||||
with ExitStack() as stack:
|
||||
if self._store_analytics:
|
||||
span = stack.enter_context(get_client().start_as_current_span(name="conversation"))
|
||||
span.update_trace(user_id=str(self.user.sub), session_id=str(self.conversation.pk))
|
||||
async for event in self._run_agent(messages, force_web_search):
|
||||
async for event in self._run_agent(messages, selected_tools=selected_tools, force_web_search=force_web_search):
|
||||
if stream_data := self.event_encoder.encode(event):
|
||||
yield stream_data
|
||||
|
||||
@@ -345,6 +373,8 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
|
||||
async def _run_agent( # noqa: PLR0912, PLR0915 # pylint: disable=too-many-branches,too-many-statements, too-many-locals, too-many-return-statements
|
||||
self,
|
||||
messages: List[UIMessage],
|
||||
*,
|
||||
selected_tools: list[str] | None = None,
|
||||
force_web_search: bool = False,
|
||||
) -> events_v4.Event | events_v5.Event:
|
||||
"""Run the Pydantic AI agent and stream events."""
|
||||
@@ -381,6 +411,26 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
|
||||
|
||||
usage = {"promptTokens": 0, "completionTokens": 0}
|
||||
|
||||
# Inject selected tools hint regardless of document presence
|
||||
if selected_tools:
|
||||
@self.conversation_agent.system_prompt
|
||||
def selected_tools_hint() -> str: # type: ignore[misc]
|
||||
return (
|
||||
"User wants you to use the following tools if relevant: "
|
||||
+ ", ".join(selected_tools)
|
||||
+ ". Prefer them when solving the task."
|
||||
)
|
||||
|
||||
if "service_public" in selected_tools:
|
||||
@self.conversation_agent.system_prompt
|
||||
def enforce_service_public() -> str: # type: ignore[misc]
|
||||
return (
|
||||
"If the user request relates to French public services, laws or"
|
||||
" administrative topics, you MUST call the 'service_public' tool"
|
||||
" before answering. Use it to retrieve relevant passages and then"
|
||||
" answer the user."
|
||||
)
|
||||
|
||||
conversation_has_documents = self._is_document_upload_enabled and (
|
||||
bool(self.conversation.collection_id)
|
||||
or bool(
|
||||
@@ -528,7 +578,6 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
|
||||
async with AsyncExitStack() as stack:
|
||||
# MCP servers (if any) can be initialized here
|
||||
mcp_servers = [await stack.enter_async_context(mcp) for mcp in get_mcp_servers()]
|
||||
|
||||
_final_output_from_tool = None
|
||||
_ui_sources = []
|
||||
|
||||
@@ -642,19 +691,29 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
|
||||
if event.result.metadata and (
|
||||
sources := event.result.metadata.get("sources")
|
||||
):
|
||||
for source_url in sources:
|
||||
url_source = LanguageModelV1Source(
|
||||
sourceType="url",
|
||||
id=str(uuid.uuid4()),
|
||||
url=source_url,
|
||||
providerMetadata={},
|
||||
)
|
||||
_new_source_ui = SourceUIPart(
|
||||
type="source", source=url_source
|
||||
)
|
||||
_ui_sources.append(_new_source_ui)
|
||||
yield events_v4.SourcePart(
|
||||
**_new_source_ui.source.model_dump()
|
||||
for source_item in sources:
|
||||
# Handle both old format (string) and new format (dict)
|
||||
if isinstance(source_item, dict):
|
||||
source_url = source_item.get("url", "")
|
||||
source_title = source_item.get("title", "")
|
||||
else:
|
||||
# Fallback for old string format
|
||||
source_url = source_item
|
||||
source_title = ""
|
||||
|
||||
if source_url:
|
||||
url_source = LanguageModelV1Source(
|
||||
sourceType="url",
|
||||
id=str(uuid.uuid4()),
|
||||
url=source_url,
|
||||
providerMetadata={"title": source_title} if source_title else {},
|
||||
)
|
||||
_new_source_ui = SourceUIPart(
|
||||
type="source", source=url_source
|
||||
)
|
||||
_ui_sources.append(_new_source_ui)
|
||||
yield events_v4.SourcePart(
|
||||
**_new_source_ui.source.model_dump()
|
||||
)
|
||||
|
||||
yield events_v4.ToolResultPart(
|
||||
|
||||
@@ -6,6 +6,7 @@ from .fake_current_weather import get_current_weather
|
||||
from .web_seach_albert_rag import web_search_albert_rag
|
||||
from .web_search_brave import web_search_brave, web_search_brave_with_document_backend
|
||||
from .web_search_tavily import web_search_tavily
|
||||
from .service_public import service_public
|
||||
|
||||
|
||||
async def only_if_web_search_enabled(ctx, tool_def: ToolDefinition) -> ToolDefinition | None:
|
||||
@@ -31,6 +32,9 @@ def get_pydantic_tools_by_name(name: str) -> Tool:
|
||||
"web_search_albert_rag": Tool(
|
||||
web_search_albert_rag, takes_ctx=True, prepare=only_if_web_search_enabled
|
||||
),
|
||||
"service_public": Tool(
|
||||
service_public, takes_ctx=True, prepare=only_if_web_search_enabled
|
||||
),
|
||||
}
|
||||
|
||||
return tool_dict[name] # will raise on purpose if name is not found
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Service Public RAG search tool using Albert API pre-defined collections.
|
||||
|
||||
This tool reuses the existing AlbertRagBackend to query curated collections
|
||||
(e.g. Service-Public, Travail-Emploi) without creating temporary collections.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.module_loading import import_string
|
||||
from pydantic_ai import RunContext, RunUsage
|
||||
from pydantic_ai.messages import ToolReturn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default curated collections (Albert IDs)
|
||||
DEFAULT_COLLECTION_IDS: List[int] = [784, 785] # travail-emploi, service-public
|
||||
INSTRUCTIONS = "Voilà les informations trouvées, résume les pour répondre à la question de l'utilisateur si c'est pertinent: \n"
|
||||
|
||||
async def service_public(ctx: RunContext, query: str) -> ToolReturn:
|
||||
"""Search curated Service-Public collections on Albert and return snippets.
|
||||
|
||||
Args:
|
||||
ctx: Run context (usage metering is updated here)
|
||||
query: The user query to search within curated collections
|
||||
"""
|
||||
try:
|
||||
# Use AlbertRagBackend to search in specific collections
|
||||
backend_class = import_string(settings.RAG_DOCUMENT_SEARCH_BACKEND)
|
||||
backend = backend_class()
|
||||
|
||||
# Search in the curated collections
|
||||
rag_results = backend.search(query, collections=DEFAULT_COLLECTION_IDS)
|
||||
|
||||
# Convert to compact format for the model
|
||||
compact = []
|
||||
sources = []
|
||||
for result in rag_results.data:
|
||||
# AlbertRagBackend.search() returns RAGWebResult objects with {url, content, score, metadata}
|
||||
document_name = result.metadata.get("document_name", "Document")
|
||||
url = result.metadata.get("url", "")
|
||||
|
||||
compact.append(
|
||||
{
|
||||
"title": document_name,
|
||||
"snippet": result.content,
|
||||
"url": url,
|
||||
}
|
||||
)
|
||||
|
||||
# Create rich source with title and URL
|
||||
if url:
|
||||
source_info = {
|
||||
"title": document_name,
|
||||
"url": url
|
||||
}
|
||||
sources.append(source_info)
|
||||
|
||||
# Update run usage
|
||||
ctx.usage += RunUsage(
|
||||
input_tokens=rag_results.usage.prompt_tokens,
|
||||
output_tokens=rag_results.usage.completion_tokens,
|
||||
)
|
||||
|
||||
# Remove duplicate sources based on URL
|
||||
unique_sources = []
|
||||
seen_urls = set()
|
||||
for source in sources:
|
||||
if source["url"] not in seen_urls:
|
||||
unique_sources.append(source)
|
||||
seen_urls.add(source["url"])
|
||||
|
||||
return ToolReturn(
|
||||
return_value=INSTRUCTIONS + str(compact),
|
||||
content='',
|
||||
metadata={"sources": unique_sources},
|
||||
)
|
||||
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
logger.exception("Albert Service Public search failed: %s", exc)
|
||||
return ToolReturn(return_value=[], content="", metadata={"error": str(exc)})
|
||||
|
||||
@@ -143,6 +143,9 @@ class ChatViewSet( # pylint: disable=too-many-ancestors, abstract-method
|
||||
query_params_serializer.is_valid(raise_exception=True)
|
||||
protocol = query_params_serializer.validated_data["protocol"]
|
||||
force_web_search = query_params_serializer.validated_data["force_web_search"]
|
||||
# New: optional selected_tools query param (comma-separated)
|
||||
raw_selected_tools = request.query_params.get("selected_tools", "")
|
||||
selected_tools = [t.strip() for t in raw_selected_tools.split(",") if t.strip()]
|
||||
model_hrid = query_params_serializer.validated_data["model_hrid"]
|
||||
|
||||
logger.info("Received messages: %s", request.data.get("messages", []))
|
||||
@@ -179,9 +182,17 @@ class ChatViewSet( # pylint: disable=too-many-ancestors, abstract-method
|
||||
),
|
||||
)
|
||||
if protocol == "data":
|
||||
streaming_content = ai_service.stream_data(messages, force_web_search=force_web_search)
|
||||
streaming_content = ai_service.stream_data(
|
||||
messages,
|
||||
selected_tools=selected_tools or None,
|
||||
force_web_search=False, # superseded by selected_tools
|
||||
)
|
||||
else: # Default to 'text' protocol
|
||||
streaming_content = ai_service.stream_text(messages, force_web_search=force_web_search)
|
||||
streaming_content = ai_service.stream_text(
|
||||
messages,
|
||||
selected_tools=selected_tools or None,
|
||||
force_web_search=False,
|
||||
)
|
||||
|
||||
response = StreamingHttpResponse(
|
||||
streaming_content,
|
||||
|
||||
@@ -5,7 +5,11 @@
|
||||
"model_name": "settings.AI_MODEL",
|
||||
"human_readable_name": "Default Model",
|
||||
"provider_name": "default-provider",
|
||||
"profile": null,
|
||||
"profile": {
|
||||
"openai_supports_strict_tool_definition": false,
|
||||
"openai_supports_tool_choice_required": false
|
||||
},
|
||||
"supports_streaming": false,
|
||||
"settings": {},
|
||||
"is_active": true,
|
||||
"icon": [
|
||||
@@ -24,12 +28,36 @@
|
||||
"model_name": "settings.AI_MODEL",
|
||||
"human_readable_name": "Default Summarization Model",
|
||||
"provider_name": "default-provider",
|
||||
"profile": null,
|
||||
"profile": {
|
||||
"openai_supports_strict_tool_definition": false,
|
||||
"openai_supports_tool_choice_required": false
|
||||
},
|
||||
"supports_streaming": false,
|
||||
"settings": {},
|
||||
"is_active": true,
|
||||
"icon": null,
|
||||
"system_prompt": "settings.SUMMARIZATION_SYSTEM_PROMPT",
|
||||
"tools": []
|
||||
},
|
||||
{
|
||||
"hrid": "etalab-plateform-mistral-medium-2508",
|
||||
"model_name": "mistral-medium-2508",
|
||||
"human_readable_name": "Mistral Medium 2508 (Plateforme Etalab)",
|
||||
"provider_name": "mistral-plateform-etalab",
|
||||
"profile": null,
|
||||
"supports_streaming": false,
|
||||
"settings": {},
|
||||
"is_active": true,
|
||||
"icon": [
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAMAAABF0y+mAAAAn1BMVEUALosAKoovTZjw8vb////+9/jlPUniAAz",
|
||||
"iABUAGIWbpsTwq7HhAAAAI4dle7DrdX4AJohRaaboXWj7+/zn6On5//9NZaT29vfoWmVHYKDoUl/k5OUAIYddc6vpbHYCM47Y3+v53+LiFCUA",
|
||||
"HIWnsckYPJHi6PL77O7jJjW3wdf1w8jre4QgQ5TZ2txwg7Pr3+I8WZ6OnsTuoamClL7tlZ5xz5y8AAAAzUlEQVR4AZ3RRQKDQBBEUSTu7h5c4",
|
||||
"vc/W6Yp3KG2Dz4ynDdeEBvOmq12xx2E1u0B+4NOEocj4DgNJ1PgLAvni8WyBq5Yc71ubFJx23C2q4P7dRYejg1xzvCUgvz5guz11k7gXYKF/1",
|
||||
"8oyiYuvHAYeVkhXCzolVStHcGDjiQzNmMQxsMI5rEJRdQSPZvbpE2E8aY6gC6Z+2Hg4dFA0Yb4YedNL/v4Fk8WJuwiGhrChJNXI210rnib9Fs",
|
||||
"JlXRUC/HwTscPIXf/iklq/tjb/gHAdxkCUjAg2QAAAABJRU5ErkJggg=="
|
||||
],
|
||||
"system_prompt": "settings.AI_AGENT_INSTRUCTIONS",
|
||||
"tools": "settings.AI_AGENT_TOOLS"
|
||||
}
|
||||
],
|
||||
"providers": [
|
||||
@@ -38,6 +66,12 @@
|
||||
"base_url": "settings.AI_BASE_URL",
|
||||
"api_key": "settings.AI_API_KEY",
|
||||
"kind": "openai"
|
||||
},
|
||||
{
|
||||
"hrid": "mistral-plateform-etalab",
|
||||
"base_url": "https://api.mistral.etalab.gouv.fr/",
|
||||
"api_key": "environ.MISTRAL_ETALAB_API_KEY",
|
||||
"kind": "mistral"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -664,7 +664,7 @@ class Base(BraveSettings, Configuration):
|
||||
|
||||
# Tools
|
||||
AI_AGENT_TOOLS = values.ListValue(
|
||||
default=[],
|
||||
default=["service_public", "web_search_brave_with_document_backend"],
|
||||
environ_name="AI_AGENT_TOOLS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
@@ -17,7 +17,7 @@ const fetchAPIAdapter = (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
const { forceWebSearch, selectedModelHrid } =
|
||||
const { forceWebSearch, selectedModelHrid, selectedTools } =
|
||||
useChatPreferencesStore.getState();
|
||||
|
||||
if (forceWebSearch) {
|
||||
@@ -28,6 +28,10 @@ const fetchAPIAdapter = (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
searchParams.append('model_hrid', selectedModelHrid);
|
||||
}
|
||||
|
||||
if (selectedTools && selectedTools.length) {
|
||||
searchParams.append('selected_tools', selectedTools.join(','));
|
||||
}
|
||||
|
||||
if (searchParams.toString()) {
|
||||
const separator = url.includes('?') ? '&' : '?';
|
||||
url = `${url}${separator}${searchParams.toString()}`;
|
||||
|
||||
@@ -659,7 +659,7 @@ export const Chat = ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
p: ({ node, ...props }) => (
|
||||
<Text
|
||||
$css="display: block"
|
||||
$css="display: block; white-space: pre-wrap;"
|
||||
$theme="greyscale"
|
||||
$variation="850"
|
||||
{...props}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { AttachmentList } from './AttachmentList';
|
||||
import { ModelSelector } from './ModelSelector';
|
||||
import { ScrollDown } from './ScrollDown';
|
||||
import { SendButton } from './SendButton';
|
||||
import { ToolSelector } from './ToolSelector';
|
||||
|
||||
interface InputChatProps {
|
||||
messagesLength: number;
|
||||
@@ -593,6 +594,10 @@ export const InputChat = ({
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box $padding={{ horizontal: 'xs' }}>
|
||||
<ToolSelector />
|
||||
</Box>
|
||||
</Box>
|
||||
<Box
|
||||
$direction="row"
|
||||
|
||||
@@ -179,15 +179,26 @@ export const SourceItem: React.FC<SourceItemProps> = ({ url, metadata }) => {
|
||||
>
|
||||
{renderFavicon()}
|
||||
|
||||
{new URL(url).hostname}
|
||||
|
||||
<Box
|
||||
$padding={{ right: '4px' }}
|
||||
$align="center"
|
||||
style={styles.title}
|
||||
>
|
||||
{title}
|
||||
</Box>
|
||||
{title ? (
|
||||
<Box
|
||||
$padding={{ right: '4px' }}
|
||||
$align="center"
|
||||
style={styles.title}
|
||||
>
|
||||
{title}
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
{new URL(url).hostname}
|
||||
<Box
|
||||
$padding={{ right: '4px' }}
|
||||
$align="center"
|
||||
style={styles.title}
|
||||
>
|
||||
{title}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</StyledLink>
|
||||
) : (
|
||||
<Box>{url}</Box>
|
||||
|
||||
@@ -36,13 +36,27 @@ const SourceItemListComponent: React.FC<SourceItemListProps> = ({
|
||||
overflow: hidden;
|
||||
`}
|
||||
>
|
||||
{parts.map((part) => (
|
||||
<SourceItem
|
||||
key={part.source.url}
|
||||
url={part.source.url}
|
||||
metadata={getMetadata(part.source.url)}
|
||||
/>
|
||||
))}
|
||||
{parts.map((part) => {
|
||||
const metadata = getMetadata(part.source.url);
|
||||
// Extract title from providerMetadata if available
|
||||
const providerTitle = part.source.providerMetadata?.title;
|
||||
|
||||
return (
|
||||
<SourceItem
|
||||
key={part.source.url}
|
||||
url={part.source.url}
|
||||
metadata={metadata ? {
|
||||
...metadata,
|
||||
title: providerTitle || metadata.title
|
||||
} : providerTitle ? {
|
||||
title: providerTitle,
|
||||
favicon: null,
|
||||
loading: false,
|
||||
error: false
|
||||
} : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { Button } from '@openfun/cunningham-react';
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box, Icon, Text } from '@/components';
|
||||
import { useChatPreferencesStore } from '@/features/chat/stores/useChatPreferencesStore';
|
||||
|
||||
interface ToolSelectorProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Define available tools with their display names
|
||||
const AVAILABLE_TOOLS = [
|
||||
{
|
||||
id: 'service_public',
|
||||
name: 'Service Public',
|
||||
icon: 'public',
|
||||
},
|
||||
];
|
||||
|
||||
export const ToolSelector = ({ className }: ToolSelectorProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { selectedTools, toggleSelectedTool } = useChatPreferencesStore();
|
||||
|
||||
const handleToolToggle = (toolId: string) => {
|
||||
toggleSelectedTool(toolId);
|
||||
};
|
||||
|
||||
const selectedToolsCount = selectedTools.length;
|
||||
const hasSelectedTools = selectedToolsCount > 0;
|
||||
|
||||
return (
|
||||
<Box
|
||||
$position="relative"
|
||||
className={className}
|
||||
$css={`
|
||||
display: inline-block;
|
||||
z-index: ${isOpen ? 1000 : 'auto'};
|
||||
`}
|
||||
>
|
||||
<Box
|
||||
$css={`
|
||||
${
|
||||
hasSelectedTools
|
||||
? `
|
||||
.tool-selector-button {
|
||||
background-color: var(--c--theme--colors--primary-100) !important;
|
||||
}
|
||||
`
|
||||
: ''
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
aria-label={t('More tools')}
|
||||
className="c__button--neutral tool-selector-button"
|
||||
icon={
|
||||
<Icon
|
||||
iconName="build"
|
||||
$theme="greyscale"
|
||||
$variation="550"
|
||||
$size="16px"
|
||||
$css={`
|
||||
color: ${hasSelectedTools ? 'var(--c--theme--colors--primary-600) !important' : 'var(--c--theme--colors--greyscale-600)'}
|
||||
`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Text
|
||||
$theme={hasSelectedTools ? 'primary' : 'greyscale'}
|
||||
$variation="550"
|
||||
>
|
||||
{hasSelectedTools ? `${selectedToolsCount} outil${selectedToolsCount > 1 ? 's' : ''}` : t('More tools')}
|
||||
</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{isOpen && (
|
||||
<>
|
||||
{/* Backdrop to close the dropdown */}
|
||||
<Box
|
||||
$position="fixed"
|
||||
$css={`
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
z-index: 999;
|
||||
`}
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Dropdown menu */}
|
||||
<Box
|
||||
$position="absolute"
|
||||
$css={`
|
||||
bottom: 100%;
|
||||
left: 0;
|
||||
margin-bottom: 6px;
|
||||
background: white;
|
||||
border: 1px solid var(--c--theme--colors--greyscale-200);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.1);
|
||||
z-index: 1000;
|
||||
min-width: 160px;
|
||||
overflow: hidden;
|
||||
`}
|
||||
>
|
||||
<Box $padding={{ all: 'xs' }}>
|
||||
<Box $css="display: flex; flex-direction: column; gap: 1px;">
|
||||
{AVAILABLE_TOOLS.map((tool) => {
|
||||
const isSelected = selectedTools.includes(tool.id);
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={tool.id}
|
||||
$css={`
|
||||
display: flex;
|
||||
align-items: left;
|
||||
justify-content: space-between;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
background-color: ${isSelected ? 'var(--c--theme--colors--primary-100)' : 'transparent'};
|
||||
|
||||
&:hover {
|
||||
background-color: ${isSelected ? 'var(--c--theme--colors--primary-200)' : 'var(--c--theme--colors--greyscale-100)'};
|
||||
}
|
||||
`}
|
||||
onClick={() => handleToolToggle(tool.id)}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', flex: 1 }}>
|
||||
<Icon
|
||||
iconName={tool.icon}
|
||||
$theme="greyscale"
|
||||
$variation="600"
|
||||
$size="16px"
|
||||
$css="margin-right: 8px;"
|
||||
/>
|
||||
|
||||
<span style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: '500',
|
||||
color: isSelected ? 'var(--c--theme--colors--primary-600)' : 'var(--c--theme--colors--greyscale-600)',
|
||||
whiteSpace: 'nowrap'
|
||||
}}>
|
||||
{tool.name}
|
||||
</span>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -5,10 +5,12 @@ interface ChatPreferencesState {
|
||||
selectedModelHrid: string | null;
|
||||
forceWebSearch: boolean;
|
||||
isPanelOpen: boolean;
|
||||
selectedTools: string[];
|
||||
setSelectedModelHrid: (hrid: string | null) => void;
|
||||
toggleForceWebSearch: () => void;
|
||||
setPanelOpen: (isOpen: boolean) => void;
|
||||
togglePanel: () => void;
|
||||
toggleSelectedTool: (tool: string) => void;
|
||||
}
|
||||
|
||||
export const useChatPreferencesStore = create<ChatPreferencesState>()(
|
||||
@@ -17,11 +19,18 @@ export const useChatPreferencesStore = create<ChatPreferencesState>()(
|
||||
selectedModelHrid: null,
|
||||
forceWebSearch: false,
|
||||
isPanelOpen: false,
|
||||
selectedTools: [],
|
||||
setSelectedModelHrid: (hrid) => set({ selectedModelHrid: hrid }),
|
||||
toggleForceWebSearch: () =>
|
||||
set((state) => ({ forceWebSearch: !state.forceWebSearch })),
|
||||
setPanelOpen: (isOpen) => set({ isPanelOpen: isOpen }),
|
||||
togglePanel: () => set((state) => ({ isPanelOpen: !state.isPanelOpen })),
|
||||
toggleSelectedTool: (tool) =>
|
||||
set((state) => ({
|
||||
selectedTools: state.selectedTools.includes(tool)
|
||||
? state.selectedTools.filter((t) => t !== tool)
|
||||
: [...state.selectedTools, tool],
|
||||
})),
|
||||
}),
|
||||
{
|
||||
name: 'chat-preferences',
|
||||
|
||||
@@ -85,6 +85,8 @@
|
||||
"Search for a chat": "Rechercher un chat",
|
||||
"Search results": "Résultats de la recherche",
|
||||
"Search...": "Recherche...",
|
||||
"More tools": "Plus d'outils",
|
||||
"Hide tools": "Masquer les outils",
|
||||
"Select": "Sélectionner",
|
||||
"Select model": "Sélectionner un modèle",
|
||||
"Send": "Envoyer",
|
||||
|
||||
Reference in New Issue
Block a user