Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b00c26ee03 | |||
| ca9abedbe0 |
@@ -79,3 +79,6 @@ db.sqlite3
|
||||
|
||||
# Docker compose override
|
||||
compose.override.yml
|
||||
|
||||
# Local development files
|
||||
src/backend/conversations/configuration/llm/default_dev.json
|
||||
@@ -157,13 +157,21 @@ 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], force_web_search: bool = False, force_plan: 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, force_web_search, force_plan)
|
||||
)
|
||||
|
||||
def stream_data(self, messages: List[UIMessage], force_web_search: bool = False):
|
||||
def stream_data(
|
||||
self, messages: List[UIMessage], force_web_search: bool = False, force_plan: 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, force_web_search, force_plan)
|
||||
)
|
||||
|
||||
def stop_streaming(self):
|
||||
"""
|
||||
@@ -178,7 +186,9 @@ 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], force_web_search: bool = False, force_plan: bool = False
|
||||
):
|
||||
"""Return only the assistant text deltas (legacy text mode)."""
|
||||
await self._clean()
|
||||
with ExitStack() as stack:
|
||||
@@ -186,18 +196,20 @@ 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, force_web_search, force_plan):
|
||||
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], force_web_search: bool = False, force_plan: bool = False
|
||||
):
|
||||
"""Return Vercel-AI-SDK formatted events."""
|
||||
await self._clean()
|
||||
with ExitStack() as stack:
|
||||
if self._langfuse_available:
|
||||
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, force_web_search, force_plan):
|
||||
if stream_data := self.event_encoder.encode(event):
|
||||
yield stream_data
|
||||
|
||||
@@ -355,6 +367,7 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
|
||||
self,
|
||||
messages: List[UIMessage],
|
||||
force_web_search: bool = False,
|
||||
force_plan: bool = False,
|
||||
) -> events_v4.Event | events_v5.Event:
|
||||
"""Run the Pydantic AI agent and stream events."""
|
||||
if messages[-1].role != "user":
|
||||
@@ -465,6 +478,19 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
|
||||
"before answering the user request."
|
||||
)
|
||||
|
||||
if force_plan:
|
||||
|
||||
@self.conversation_agent.system_prompt
|
||||
def force_plan_prompt() -> str:
|
||||
"""Dynamic system prompt function to force upfront planning."""
|
||||
return (
|
||||
"Planning mode is enabled for this request. Start by proposing a concise "
|
||||
"numbered plan (maximum 8 steps) tailored to the user's goal. If you plan "
|
||||
"to use tools, tell the user which tools without using explicitly the tools names but tell the user the arguments you plan to use. Ask the user "
|
||||
"to confirm or adjust the plan, and do not proceed with any execution until "
|
||||
"the plan is approved. After approval, follow the agreed steps."
|
||||
)
|
||||
|
||||
_tool_is_streaming = False
|
||||
_model_response_message_id = None
|
||||
|
||||
|
||||
@@ -72,6 +72,11 @@ class ChatConversationRequestSerializer(serializers.Serializer):
|
||||
default=False,
|
||||
help_text="Force web search.",
|
||||
)
|
||||
force_plan = serializers.BooleanField(
|
||||
required=False,
|
||||
default=False,
|
||||
help_text="Force the model to propose a plan before answering.",
|
||||
)
|
||||
model_hrid = serializers.CharField(
|
||||
required=False,
|
||||
default=None,
|
||||
|
||||
@@ -37,6 +37,7 @@ def test_chat_conversation_request_serializer_default():
|
||||
assert serializer.is_valid()
|
||||
assert serializer.validated_data == {
|
||||
"force_web_search": False,
|
||||
"force_plan": False,
|
||||
"model_hrid": None,
|
||||
"protocol": "data",
|
||||
}
|
||||
@@ -93,6 +94,27 @@ def test_chat_conversation_request_serializer_force_web_search_invalid():
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("force_plan", [True, False])
|
||||
def test_chat_conversation_request_serializer_force_plan_valid(force_plan):
|
||||
"""
|
||||
Test that the serializer accepts valid boolean values for force_plan.
|
||||
"""
|
||||
serializer = serializers.ChatConversationRequestSerializer(data={"force_plan": force_plan})
|
||||
assert serializer.is_valid()
|
||||
assert serializer.validated_data["force_plan"] == force_plan
|
||||
|
||||
|
||||
def test_chat_conversation_request_serializer_force_plan_invalid():
|
||||
"""
|
||||
Test that the serializer rejects non-boolean values for force_plan.
|
||||
"""
|
||||
serializer = serializers.ChatConversationRequestSerializer(data={"force_plan": "invalid"})
|
||||
assert not serializer.is_valid()
|
||||
assert serializer.errors == {
|
||||
"force_plan": [ErrorDetail(string="Must be a valid boolean.", code="invalid")]
|
||||
}
|
||||
|
||||
|
||||
def test_chat_conversation_request_serializer_model_hrid_valid(llm_configuration): # pylint: disable=unused-argument
|
||||
"""
|
||||
Test that the serializer accepts a valid model_hrid.
|
||||
|
||||
@@ -144,6 +144,7 @@ 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"]
|
||||
force_plan = query_params_serializer.validated_data["force_plan"]
|
||||
model_hrid = query_params_serializer.validated_data["model_hrid"]
|
||||
|
||||
logger.info("Received messages: %s", request.data.get("messages", []))
|
||||
@@ -189,21 +190,21 @@ class ChatViewSet( # pylint: disable=too-many-ancestors, abstract-method
|
||||
logger.debug("Using ASYNC streaming for chat conversation.")
|
||||
if protocol == "data":
|
||||
streaming_content = ai_service.stream_data_async(
|
||||
messages, force_web_search=force_web_search
|
||||
messages, force_web_search=force_web_search, force_plan=force_plan
|
||||
)
|
||||
else: # Default to 'text' protocol
|
||||
streaming_content = ai_service.stream_text_async(
|
||||
messages, force_web_search=force_web_search
|
||||
messages, force_web_search=force_web_search, force_plan=force_plan
|
||||
)
|
||||
else:
|
||||
logger.debug("Using SYNC streaming for chat conversation.")
|
||||
if protocol == "data":
|
||||
streaming_content = ai_service.stream_data(
|
||||
messages, force_web_search=force_web_search
|
||||
messages, force_web_search=force_web_search, force_plan=force_plan
|
||||
)
|
||||
else: # Default to 'text' protocol
|
||||
streaming_content = ai_service.stream_text(
|
||||
messages, force_web_search=force_web_search
|
||||
messages, force_web_search=force_web_search, force_plan=force_plan
|
||||
)
|
||||
|
||||
response = StreamingHttpResponse(
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ const fetchAPIAdapter = (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
const { forceWebSearch, selectedModelHrid } =
|
||||
const { forceWebSearch, selectedModelHrid, forcePlanMode } =
|
||||
useChatPreferencesStore.getState();
|
||||
|
||||
if (forceWebSearch) {
|
||||
@@ -28,6 +28,10 @@ const fetchAPIAdapter = (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
searchParams.append('model_hrid', selectedModelHrid);
|
||||
}
|
||||
|
||||
if (forcePlanMode) {
|
||||
searchParams.append('force_plan', 'true');
|
||||
}
|
||||
|
||||
if (searchParams.toString()) {
|
||||
const separator = url.includes('?') ? '&' : '?';
|
||||
url = `${url}${separator}${searchParams.toString()}`;
|
||||
|
||||
@@ -62,6 +62,8 @@ export const Chat = ({
|
||||
const {
|
||||
forceWebSearch,
|
||||
toggleForceWebSearch,
|
||||
forcePlanMode,
|
||||
toggleForcePlanMode,
|
||||
selectedModelHrid,
|
||||
setSelectedModelHrid,
|
||||
} = useChatPreferencesStore();
|
||||
@@ -229,6 +231,32 @@ export const Chat = ({
|
||||
onError: onErrorChat,
|
||||
});
|
||||
|
||||
const [dismissedPlanForAssistantId, setDismissedPlanForAssistantId] = useState<string | null>(null);
|
||||
const [planAcceptPending, setPlanAcceptPending] = useState(false);
|
||||
const [overlayLockedForUserId, setOverlayLockedForUserId] = useState<string | null>(null);
|
||||
const [planAcceptedOnce, setPlanAcceptedOnce] = useState(false);
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
const lastAssistantId =
|
||||
messages
|
||||
.slice()
|
||||
.reverse()
|
||||
.find((m) => m.role === 'assistant')?.id || null;
|
||||
const lastAssistantIndex = messages.findLastIndex((m) => m.role === 'assistant');
|
||||
const lastUserIndex = messages.findLastIndex((m) => m.role === 'user');
|
||||
const lastUserId =
|
||||
messages
|
||||
.slice()
|
||||
.reverse()
|
||||
.find((m) => m.role === 'user')?.id || null;
|
||||
const canShowForUser = lastUserId && overlayLockedForUserId !== lastUserId;
|
||||
const showPlanOverlay =
|
||||
forcePlanMode &&
|
||||
lastAssistantId !== null &&
|
||||
lastAssistantIndex > lastUserIndex &&
|
||||
dismissedPlanForAssistantId !== lastAssistantId &&
|
||||
canShowForUser &&
|
||||
!planAcceptedOnce;
|
||||
|
||||
const stopGeneration = async () => {
|
||||
stopChat();
|
||||
|
||||
@@ -248,6 +276,10 @@ export const Chat = ({
|
||||
toggleForceWebSearch();
|
||||
};
|
||||
|
||||
const togglePlanning = () => {
|
||||
toggleForcePlanMode();
|
||||
};
|
||||
|
||||
const handleStop = () => {
|
||||
void stopGeneration();
|
||||
};
|
||||
@@ -280,6 +312,46 @@ export const Chat = ({
|
||||
};
|
||||
|
||||
// Précharger les métadonnées des sources dès que les messages arrivent
|
||||
const handlePlanAccept = () => {
|
||||
if (lastAssistantId) {
|
||||
setDismissedPlanForAssistantId(lastAssistantId);
|
||||
}
|
||||
if (lastUserId) {
|
||||
setOverlayLockedForUserId(lastUserId);
|
||||
}
|
||||
setPlanAcceptedOnce(true);
|
||||
handleInputChange({
|
||||
target: { value: t('Plan accepté') },
|
||||
} as ChangeEvent<HTMLTextAreaElement>);
|
||||
// Laisser React mettre à jour l'input puis déclencher l'envoi dès que le statut est prêt.
|
||||
setPlanAcceptPending(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (planAcceptPending && status === 'ready') {
|
||||
const form = document.createElement('form');
|
||||
const syntheticFormEvent = {
|
||||
preventDefault: () => {},
|
||||
target: form,
|
||||
currentTarget: form,
|
||||
} as unknown as FormEvent<HTMLFormElement>;
|
||||
void handleSubmitWrapper(syntheticFormEvent);
|
||||
setPlanAcceptPending(false);
|
||||
if (lastAssistantId) {
|
||||
setDismissedPlanForAssistantId(lastAssistantId);
|
||||
}
|
||||
}
|
||||
}, [planAcceptPending, status, handleSubmitWrapper, lastAssistantId]);
|
||||
|
||||
useEffect(() => {
|
||||
// When a new user message arrives, re-authorize overlay for that turn.
|
||||
if (lastMessage?.role === 'user' && lastUserId) {
|
||||
setOverlayLockedForUserId(null);
|
||||
setDismissedPlanForAssistantId(null);
|
||||
setPlanAcceptPending(false);
|
||||
}
|
||||
}, [lastMessage, lastUserId]);
|
||||
|
||||
useEffect(() => {
|
||||
messages.forEach((message) => {
|
||||
if (message.parts) {
|
||||
@@ -625,6 +697,7 @@ export const Chat = ({
|
||||
flex-basis: auto;
|
||||
height: 100%;
|
||||
flex-grow: 1;
|
||||
position: relative;
|
||||
`}
|
||||
>
|
||||
<Box
|
||||
@@ -1002,6 +1075,78 @@ export const Chat = ({
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{showPlanOverlay && lastAssistantId && (
|
||||
<Box
|
||||
$css={`
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
bottom: ${isMobile ? '110px' : '140px'};
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
z-index: 1400;
|
||||
`}
|
||||
>
|
||||
<Box
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$gap="10px"
|
||||
$padding={{ horizontal: 'md', vertical: 'xs' }}
|
||||
$radius="12px"
|
||||
$background="white"
|
||||
$css={`
|
||||
border: 1px solid var(--c--theme--colors--primary-200);
|
||||
pointer-events: auto;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.12);
|
||||
max-width: 360px;
|
||||
width: fit-content;
|
||||
justify-content: center;
|
||||
margin: 0 auto;
|
||||
`}
|
||||
>
|
||||
<Icon
|
||||
iconName="fact_check"
|
||||
$theme="primary"
|
||||
$variation="600"
|
||||
$size="22px"
|
||||
/>
|
||||
{!isMobile && (
|
||||
<Text $theme="primary" $variation="700" $size="sm">
|
||||
{t('Plan prêt — acceptez pour continuer')}
|
||||
</Text>
|
||||
)}
|
||||
<Box
|
||||
className="c__button--neutral action-chat-button"
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$gap="6px"
|
||||
$padding={{ horizontal: 'sm', vertical: 'xs' }}
|
||||
$css="cursor: pointer;"
|
||||
onClick={handlePlanAccept}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handlePlanAccept();
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<Icon
|
||||
iconName="check"
|
||||
$theme="primary"
|
||||
$variation="700"
|
||||
$size="18px"
|
||||
/>
|
||||
<Text $theme="primary" $variation="700">
|
||||
{t('Accept')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
<Box
|
||||
$css={`
|
||||
position: relative;
|
||||
@@ -1028,6 +1173,8 @@ export const Chat = ({
|
||||
onStop={handleStop}
|
||||
forceWebSearch={forceWebSearch}
|
||||
onToggleWebSearch={toggleWebSearch}
|
||||
planModeEnabled={forcePlanMode}
|
||||
onTogglePlanMode={togglePlanning}
|
||||
selectedModel={selectedModel}
|
||||
onModelSelect={handleModelSelect}
|
||||
isUploadingFiles={isUploadingFiles}
|
||||
|
||||
@@ -28,6 +28,8 @@ interface InputChatProps {
|
||||
containerRef?: React.RefObject<HTMLDivElement | null>;
|
||||
forceWebSearch?: boolean;
|
||||
onToggleWebSearch?: () => void;
|
||||
planModeEnabled?: boolean;
|
||||
onTogglePlanMode?: () => void;
|
||||
onStop?: () => void;
|
||||
selectedModel?: LLMModel | null;
|
||||
onModelSelect?: (model: LLMModel) => void;
|
||||
@@ -46,6 +48,8 @@ export const InputChat = ({
|
||||
containerRef,
|
||||
forceWebSearch = false,
|
||||
onToggleWebSearch,
|
||||
planModeEnabled = false,
|
||||
onTogglePlanMode,
|
||||
onStop,
|
||||
selectedModel,
|
||||
onModelSelect,
|
||||
@@ -602,6 +606,90 @@ export const InputChat = ({
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{onTogglePlanMode && (
|
||||
<Box
|
||||
$margin={{ left: '4px' }}
|
||||
$css={`
|
||||
${
|
||||
planModeEnabled
|
||||
? `
|
||||
.plan-mode-button {
|
||||
background-color: var(--c--theme--colors--primary-100) !important;
|
||||
}
|
||||
`
|
||||
: ''
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
type="button"
|
||||
disabled={isUploadingFiles}
|
||||
onClick={() => {
|
||||
onTogglePlanMode();
|
||||
textareaRef.current?.focus();
|
||||
}}
|
||||
aria-label={t('Plan tasks first')}
|
||||
className="c__button--neutral plan-mode-button"
|
||||
icon={
|
||||
<Icon
|
||||
iconName="fact_check"
|
||||
$theme="greyscale"
|
||||
$variation="550"
|
||||
$css={`
|
||||
color: ${planModeEnabled ? 'var(--c--theme--colors--primary-600) !important' : 'var(--c--theme--colors--greyscale-600)'};
|
||||
`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{!isMobile && (
|
||||
<Text
|
||||
$theme={planModeEnabled ? 'primary' : 'greyscale'}
|
||||
$variation="550"
|
||||
>
|
||||
{t('Plan first')}
|
||||
</Text>
|
||||
)}
|
||||
{isMobile && planModeEnabled && (
|
||||
<Box
|
||||
$direction="row"
|
||||
$align="space-between"
|
||||
$gap="xs"
|
||||
$css={`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
line-height: 1;
|
||||
`}
|
||||
>
|
||||
<Text
|
||||
$theme="primary"
|
||||
$weight="500"
|
||||
$css={`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`}
|
||||
>
|
||||
{t('Plan')}
|
||||
</Text>
|
||||
<Icon
|
||||
iconName="close"
|
||||
$variation="text"
|
||||
$theme="primary"
|
||||
$size="md"
|
||||
$css={`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
padding-left: 4px;
|
||||
`}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{onToggleWebSearch && (
|
||||
<Box
|
||||
$margin={{ left: '4px' }}
|
||||
|
||||
@@ -4,9 +4,11 @@ import { persist } from 'zustand/middleware';
|
||||
interface ChatPreferencesState {
|
||||
selectedModelHrid: string | null;
|
||||
forceWebSearch: boolean;
|
||||
forcePlanMode: boolean;
|
||||
isPanelOpen: boolean;
|
||||
setSelectedModelHrid: (hrid: string | null) => void;
|
||||
toggleForceWebSearch: () => void;
|
||||
toggleForcePlanMode: () => void;
|
||||
setPanelOpen: (isOpen: boolean) => void;
|
||||
togglePanel: () => void;
|
||||
}
|
||||
@@ -16,10 +18,13 @@ export const useChatPreferencesStore = create<ChatPreferencesState>()(
|
||||
(set) => ({
|
||||
selectedModelHrid: null,
|
||||
forceWebSearch: false,
|
||||
forcePlanMode: false,
|
||||
isPanelOpen: false,
|
||||
setSelectedModelHrid: (hrid) => set({ selectedModelHrid: hrid }),
|
||||
toggleForceWebSearch: () =>
|
||||
set((state) => ({ forceWebSearch: !state.forceWebSearch })),
|
||||
toggleForcePlanMode: () =>
|
||||
set((state) => ({ forcePlanMode: !state.forcePlanMode })),
|
||||
setPanelOpen: (isOpen) => set({ isPanelOpen: isOpen }),
|
||||
togglePanel: () => set((state) => ({ isPanelOpen: !state.isPanelOpen })),
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user