Omar ID EL MOUMEN commited on
Commit
2df36ec
·
1 Parent(s): 824bf0c

First version

Browse files
Files changed (7) hide show
  1. Dockerfile +13 -0
  2. app.py +232 -0
  3. index.html +39 -0
  4. requirements.txt +6 -0
  5. server.py +153 -0
  6. static/script.js +178 -0
  7. static/style.css +170 -0
Dockerfile ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10.6
2
+
3
+ RUN useradd -m -u 1000 user
4
+ USER user
5
+ ENV PATH="/home/user/.local/bin:$PATH"
6
+
7
+ WORKDIR /app
8
+
9
+ COPY --chown=user ./requirements.txt requirements.txt
10
+ RUN pip install --trusted-host pypi.org --trusted-host pypi.python.org --trusted-host files.pythonhosted.org --no-cache-dir --upgrade -r requirements.txt
11
+
12
+ COPY --chown=user . /app
13
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
app.py ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # GET /models
2
+ # GET /tools
3
+ # POST /chat -> Groq -> response
4
+
5
+ import asyncio
6
+ import json
7
+ import traceback
8
+ from typing import List, Optional
9
+ from contextlib import AsyncExitStack
10
+ import uuid
11
+
12
+ from fastapi.staticfiles import StaticFiles
13
+ from pydantic import BaseModel
14
+ from fastapi import FastAPI, Request, HTTPException, Depends
15
+ from fastapi.middleware.cors import CORSMiddleware
16
+ from fastapi.responses import FileResponse, JSONResponse
17
+
18
+
19
+ from mcp import ClientSession, StdioServerParameters
20
+ from mcp.client.stdio import stdio_client
21
+
22
+ from groq import Groq, APIConnectionError
23
+ from dotenv import load_dotenv
24
+ import os
25
+ import httpx
26
+
27
+ sessions = {}
28
+ unique_apikeys = []
29
+
30
+ class MCPClient:
31
+ def __init__(self):
32
+ self.session: Optional[ClientSession] = None
33
+ self.exit_stack = AsyncExitStack()
34
+ self.current_model = None
35
+ self.groq = None
36
+ self.api_key = None
37
+ self.messages = [{
38
+ "role": "system",
39
+ "content": "You are a helpful assistant that have access to different tools via MCP. Make complete answers."
40
+ }]
41
+ self.tool_use = True
42
+ self.models = None
43
+ self.tools = []
44
+
45
+ async def connect(self, api_key: str):
46
+ try:
47
+ self.groq = Groq(api_key=api_key, http_client=httpx.Client(verify=False, timeout=30))
48
+ self.api_key = api_key
49
+ except APIConnectionError as e:
50
+ traceback.print_exception(e)
51
+ return False
52
+ except Exception as e:
53
+ traceback.print_exception(e)
54
+ return False
55
+ server_params = StdioServerParameters(command="uv", args=["run", "server.py"])
56
+
57
+ stdio_transport = await self.exit_stack.enter_async_context(stdio_client(server_params))
58
+ self.stdio, self.write = stdio_transport
59
+ self.session = await self.exit_stack.enter_async_context(ClientSession(self.stdio, self.write))
60
+
61
+ await self.session.initialize()
62
+
63
+ response = await self.session.list_tools()
64
+ tools = response.tools
65
+ print("\nConnected to server with tools:", [tool.name for tool in tools])
66
+ self.tools = [{"type": "function", "function": {
67
+ "name": tool.name,
68
+ "description": tool.description,
69
+ "parameters": tool.inputSchema
70
+ }} for tool in tools]
71
+
72
+ def populate_model(self):
73
+ self.models = sorted([m.id for m in self.groq.models.list().data])
74
+
75
+ async def process_query(self, query: str) -> str:
76
+ """Process a query using Groq and available tools"""
77
+ self.messages.extend([
78
+ {
79
+ "role": "user",
80
+ "content": query
81
+ }
82
+ ])
83
+
84
+ response = self.groq.chat.completions.create(
85
+ model=self.current_model,
86
+ messages=self.messages,
87
+ tools=self.tools,
88
+ temperature=0
89
+ ) if self.tool_use else self.groq.chat.completions.create(
90
+ model=self.current_model,
91
+ messages=self.messages,
92
+ temperature=0.7
93
+ )
94
+
95
+ # Process response and handle tool calls
96
+ final_text = []
97
+
98
+ for choice in response.choices:
99
+ content = choice.message.content
100
+ tool_calls = choice.message.tool_calls
101
+ if content:
102
+ final_text.append(content)
103
+ if tool_calls:
104
+ print(tool_calls)
105
+ for tool in tool_calls:
106
+ tool_name = tool.function.name
107
+ tool_args = tool.function.arguments
108
+
109
+ result = await self.session.call_tool(tool_name, json.loads(tool_args))
110
+ print(f"[Calling tool {tool_name} with args {tool_args}]")
111
+
112
+ if content is not None:
113
+ self.messages.append({
114
+ "role": "assistant",
115
+ "content": content
116
+ })
117
+ self.messages.append({
118
+ "role": "tool",
119
+ "tool_call_id": tool.id,
120
+ "content": str(result.content)
121
+ })
122
+
123
+ print(result.content[0].text)
124
+
125
+ response = self.groq.chat.completions.create(
126
+ model=self.current_model,
127
+ messages=self.messages,
128
+ temperature=0.7
129
+ )
130
+
131
+ final_text.append(response.choices[0].message.content)
132
+ return "\n".join(final_text)
133
+
134
+ app = FastAPI()
135
+ app.add_middleware(CORSMiddleware, allow_credentials=True, allow_headers=["*"], allow_methods=["*"], allow_origins=["*"])
136
+ app.mount("/static", StaticFiles(directory="static"), name="static")
137
+ mcp = MCPClient()
138
+
139
+ class InitRequest(BaseModel):
140
+ api_key: str
141
+
142
+ class InitResponse(BaseModel):
143
+ success: bool
144
+ session_id: str
145
+ models: Optional[list] = None
146
+ error: Optional[str] = None
147
+
148
+ class LogoutRequest(BaseModel):
149
+ session_id: str
150
+
151
+ def get_mcp_client(session_id: str) -> MCPClient|None:
152
+ """Get the MCPClient for a given session_id, or raise 404."""
153
+ client = sessions.get(session_id)
154
+ if client is None:
155
+ raise HTTPException(status_code=404, detail="Invalid session_id. Please re-initialize.")
156
+ return client
157
+
158
+ @app.get("/")
159
+ def root():
160
+ return FileResponse("index.html")
161
+
162
+ @app.post("/init", response_model=InitResponse)
163
+ async def init_server(req: InitRequest):
164
+ """
165
+ Initializes a new MCP client session. Returns a session_id.
166
+ """
167
+ api_key = req.api_key
168
+ session_id = str(uuid.uuid4())
169
+ mcp = MCPClient()
170
+
171
+ try:
172
+ ok = await mcp.connect(api_key)
173
+ if ok is False:
174
+ raise RuntimeError("Failed to connect to MCP or Groq with API key.")
175
+ mcp.populate_model()
176
+
177
+ sessions[session_id] = mcp
178
+ if api_key not in unique_apikeys:
179
+ unique_apikeys.append(api_key)
180
+ else:
181
+ raise Exception("Session with this API key already exists. We won't re-return you the session ID. Bye-bye Hacker !!")
182
+ return InitResponse(
183
+ session_id=session_id,
184
+ models=mcp.models,
185
+ error=None,
186
+ success=True
187
+ )
188
+ except Exception as e:
189
+ traceback.print_exception(e)
190
+ return InitResponse(
191
+ session_id="",
192
+ models=None,
193
+ error=str(e),
194
+ success=False
195
+ )
196
+
197
+ class ChatRequest(BaseModel):
198
+ session_id: str
199
+ query: str
200
+ tool_use: Optional[bool] = True
201
+ model: Optional[str] = "llama-3.3-70b-versatile"
202
+
203
+ class ChatResponse(BaseModel):
204
+ output: str
205
+ error: Optional[str] = None
206
+
207
+ @app.post("/chat", response_model=ChatResponse)
208
+ async def chat(req: ChatRequest):
209
+ """
210
+ Handles chat requests for a given session.
211
+ """
212
+ try:
213
+ mcp = get_mcp_client(req.session_id)
214
+ mcp.tool_use = req.tool_use
215
+ if req.model in mcp.models:
216
+ mcp.current_model = req.model
217
+ else:
218
+ raise ValueError(f"Model not recognized: Not in the model list: {mcp.models}")
219
+ result = await mcp.process_query(req.query)
220
+ return ChatResponse(output=result)
221
+ except Exception as e:
222
+ traceback.print_exception(e)
223
+ return ChatResponse(output="", error=str(e))
224
+
225
+ @app.post("/logout")
226
+ async def logout(logout_req: LogoutRequest):
227
+ """Clean up session resources."""
228
+ mcp = sessions.pop(logout_req.session_id, None)
229
+ unique_apikeys.remove(mcp.api_key)
230
+ if mcp and hasattr(mcp.exit_stack, "aclose"):
231
+ await mcp.exit_stack.aclose()
232
+ return {"success": True}
index.html ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <title>Groq Chatbot SPA</title>
6
+ <link rel="stylesheet" href="static/style.css">
7
+ </head>
8
+ <body>
9
+ <!-- API Key Modal -->
10
+ <div id="modal" class="modal">
11
+ <div class="modal-content">
12
+ <h2>Enter Groq API Key</h2>
13
+ <input type="password" id="apiKeyInput" placeholder="Groq API Key">
14
+ <button id="loginBtn">Login</button>
15
+ <div id="loginError" class="error"></div>
16
+ </div>
17
+ </div>
18
+
19
+ <!-- Chat App -->
20
+ <div id="app" class="hidden">
21
+ <header>
22
+ <span>Groq Chatbot</span>
23
+ <select id="modelSelect"></select>
24
+ <label class="toggle-label">
25
+ <input type="checkbox" id="toolToggle" checked>
26
+ Tool Calls
27
+ </label>
28
+ <button id="logoutBtn">Logout</button>
29
+ </header>
30
+ <div id="chatContainer"></div>
31
+ <div id="typingIndicator" class="typing hidden">Bot is typing...</div>
32
+ <form id="inputForm" autocomplete="off">
33
+ <input id="userInput" type="text" placeholder="Type your message..." autocomplete="off">
34
+ <button type="submit">Send</button>
35
+ </form>
36
+ </div>
37
+ <script src="static/script.js"></script>
38
+ </body>
39
+ </html>
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ uvicorn[standard]
2
+ fastapi
3
+ mcp[cli]
4
+ httpx
5
+ uv
6
+ groq
server.py ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any, List, Literal, Dict, Optional
2
+ import httpx
3
+ import traceback
4
+ from mcp.server.fastmcp import FastMCP
5
+
6
+ # Initialize FastMCP server
7
+ mcp = FastMCP("patent-problematic-generator-helper")
8
+
9
+ # API used
10
+ ARXIV_BASE = "https://om4r932-arxiv.hf.space"
11
+ DUCKDUCKGO_BASE = "https://ychkhan-ptt-endpoints.hf.space"
12
+ DOC3GPPFINDER_BASE = "https://organizedprogrammers-3gppdocfinder.hf.space"
13
+
14
+ # Request function
15
+
16
+ async def post_data_to_api(url, **kwargs):
17
+ data = dict(kwargs)
18
+ if data is None or data == {}:
19
+ return (None, "")
20
+ headers = {"Accept": "application/json"}
21
+ async with httpx.AsyncClient(verify=False, timeout=180) as client:
22
+ try:
23
+ response = await client.post(url, headers=headers, json=data)
24
+ print(response)
25
+ response.raise_for_status()
26
+ return response.json()
27
+ except Exception as e:
28
+ traceback.print_exception(e)
29
+ return (None, e)
30
+
31
+ async def fake_post_data_to_api(url, **kwargs):
32
+ params = dict(kwargs)
33
+ if params is None or params == {}:
34
+ return (None, "")
35
+ headers = {"Accept": "application/json"}
36
+ async with httpx.AsyncClient(verify=False, timeout=180) as client:
37
+ try:
38
+ response = await client.post(url, headers=headers, params=params)
39
+ print(response)
40
+ response.raise_for_status()
41
+ return response.json()
42
+ except Exception as e:
43
+ traceback.print_exception(e)
44
+ return (None, e)
45
+
46
+ async def get_data_from_api(url):
47
+ headers = {"Accept": "application/json"}
48
+ async with httpx.AsyncClient(verify=False, timeout=180) as client:
49
+ try:
50
+ response = await client.get(url, headers=headers)
51
+ print(response)
52
+ response.raise_for_status()
53
+ return response.json()
54
+ except Exception as e:
55
+ traceback.print_exception(e)
56
+ return (None, e)
57
+
58
+ # Tools
59
+
60
+ # arXiv
61
+
62
+ @mcp.tool()
63
+ async def get_arxiv_publications(keywords: str, limit: int):
64
+ """
65
+ Search arXiv publications based on keywords and a limit of documents printed
66
+ Arguments available: keywords: string [mandatory], limit: integer [mandatory, default = 5]
67
+ """
68
+ endpoint = ARXIV_BASE + "/search"
69
+ data = await post_data_to_api(endpoint, keyword=keywords, limit=limit)
70
+ if isinstance(data, tuple) and data[0] is None:
71
+ return f"An error has occured while getting publications: {data[1]}"
72
+ if data["error"]:
73
+ return data["message"]
74
+ if len(data) < 1:
75
+ return "No publications has been found"
76
+
77
+ results = data["message"]
78
+ output = []
79
+ for pub, metadata in results.items():
80
+ output.append(f"arXiv pub ID: {pub}\nTitle: {metadata['title']}\nAuthors: {metadata['authors']}\nPublished on: {metadata['date']}\nAbstract: {metadata['abstract']}\nPDF URL: {metadata['pdf']}\n")
81
+
82
+ return "-\n".join(output)
83
+
84
+ # 3GPP Doc Finder
85
+
86
+ @mcp.tool()
87
+ async def get_document_url(doc_id: str, release: int = None):
88
+ """
89
+ Find 3GPP document (TSG docs, specifications or workshop files) only by their ID [note that it will only work with keywords] (and release if it's a specification only) and return their position via a URL (and a scope if it's a specification)
90
+ Arguments available: doc_id: string [mandatory], release: integer [optional for every case]
91
+ """
92
+ endpoint = DOC3GPPFINDER_BASE + "/find"
93
+ data = await post_data_to_api(endpoint, doc_id=doc_id, release=release)
94
+ if isinstance(data, tuple) and data[0] is None:
95
+ return f"An error while searching publications: {data[1]}"
96
+ output = f'Document ID: {doc_id}\nURL: {data.get("url", "Not found !")}'
97
+ output += f'\nScope: {data["scope"]}' if data.get("scope", None) is not None else ""
98
+ return output
99
+
100
+ @mcp.tool()
101
+ async def search_specs(keywords: str, limit: int):
102
+ """
103
+ Search 3GPP specifications only by their keywords [note that it will only work with keywords](and some filters [see kwargs field])
104
+ Arguments available: keywords: string [mandatory, separated by space], limit [mandatory, default = 5]
105
+ Kwargs available [optional]: (release: integer as string or 'Rel-xxx', wg: string = working group (S1, C4, SP, ...), spec_type: string (either TS or TR), mode: string (either 'and' or 'or') = search mode)
106
+ """
107
+ endpoint = DOC3GPPFINDER_BASE + "/search-spec"
108
+ data = await post_data_to_api(endpoint, keywords=keywords)
109
+ if isinstance(data, tuple) and data[0] is None:
110
+ return f"An error has occured while searching specifications"
111
+ results = data['results'][:min(len(data['results'])-1, limit)]
112
+ output = []
113
+ for spec in results:
114
+ output.append(f"Specification ID: {spec['id']}\nTitle: {spec['title']}\nType: {spec['type']}\nRelease: {spec['release']}\nVersion: {spec['version']}\nWorking Group: {spec['working_group']}\nURL of spec: {spec['url']}\n")
115
+
116
+ return "-\n".join(output)
117
+
118
+ @mcp.tool()
119
+ async def get_multiple_documents_url(doc_ids: List[str], release: int = None):
120
+ """
121
+ [BATCH] Search multiple 3GPP documents (TSG docs, specifications or workshop files) [note that it will only work with document ID] (and release if it's a specification only) and return only their position via a URL
122
+ Arguments available: doc_ids: list of string [mandatory], release: integer [optional for every case]
123
+ """
124
+ endpoint = DOC3GPPFINDER_BASE + "/batch"
125
+ data = await post_data_to_api(endpoint, doc_ids=doc_ids, release=release)
126
+ if isinstance(data, tuple) and data[0] is None:
127
+ return f"An error while searching publications: {data[1]}"
128
+ results = data["results"]
129
+ output = []
130
+ for doc_id, url in results.items():
131
+ output.append(f'Document ID: {doc_id}\nURL: {url}\n')
132
+ return "-\n".join(output)
133
+
134
+ # PTT Endpoints
135
+
136
+ @mcp.tool()
137
+ async def search_documents_web(query: str, data_type: str = None, limit: int = 5):
138
+ """
139
+ Search on the Web (thanks to DuckDuckGo) documents based on the user's query
140
+ Arguments available: query: string [mandatory], data_type: string [optional, either 'pdf', 'patent' or None (classic web search)], limit: integer [optional, default = 5]
141
+ """
142
+ endpoint = DUCKDUCKGO_BASE + "/search"
143
+ data = await fake_post_data_to_api(endpoint, query=query, data_type=data_type, max_references=limit)
144
+ if isinstance(data, tuple) and data[0] is None:
145
+ return f"An error while searching publications: {data[1]}"
146
+ results = data["results"]
147
+ output = []
148
+ for ref in results:
149
+ output.append(f"Title: {ref['title']}\nBody: {ref['body']}\nURL: {ref['url']}")
150
+ return "-\n".join(output)
151
+
152
+ if __name__ == "__main__":
153
+ mcp.run(transport="stdio")
static/script.js ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // --- State ---
2
+ let sessionId = null;
3
+ let models = [];
4
+ let currentModel = null;
5
+
6
+ const REQUEST_LIMIT = 4;
7
+ const REQUEST_WINDOW_MS = 60 * 1000; // 1 minute
8
+ let requestTimestamps = [];
9
+
10
+ // --- DOM Elements ---
11
+ const modal = document.getElementById('modal');
12
+ const apiKeyInput = document.getElementById('apiKeyInput');
13
+ const loginBtn = document.getElementById('loginBtn');
14
+ const loginError = document.getElementById('loginError');
15
+ const app = document.getElementById('app');
16
+ const chatContainer = document.getElementById('chatContainer');
17
+ const toolToggle = document.getElementById('toolToggle');
18
+ const inputForm = document.getElementById('inputForm');
19
+ const userInput = document.getElementById('userInput');
20
+ const modelSelect = document.getElementById('modelSelect');
21
+ const logoutBtn = document.getElementById('logoutBtn');
22
+ const typingIndicator = document.getElementById('typingIndicator');
23
+
24
+ let toolUse = true;
25
+
26
+ // --- Helpers ---
27
+ function canSendRequest() {
28
+ const now = Date.now();
29
+ // Remove timestamps older than 1 minute
30
+ requestTimestamps = requestTimestamps.filter(ts => now - ts < REQUEST_WINDOW_MS);
31
+ return requestTimestamps.length < REQUEST_LIMIT;
32
+ }
33
+
34
+ function recordRequest() {
35
+ requestTimestamps.push(Date.now());
36
+ }
37
+
38
+ function scrollToBottom() {
39
+ chatContainer.scrollTop = chatContainer.scrollHeight;
40
+ }
41
+
42
+ function addMessage(text, sender) {
43
+ const msgDiv = document.createElement('div');
44
+ msgDiv.className = 'message ' + sender;
45
+ const bubble = document.createElement('div');
46
+ bubble.className = 'bubble';
47
+ bubble.textContent = text;
48
+ msgDiv.appendChild(bubble);
49
+ chatContainer.appendChild(msgDiv);
50
+ scrollToBottom();
51
+ }
52
+
53
+ function setTyping(isTyping) {
54
+ typingIndicator.classList.toggle('hidden', !isTyping);
55
+ scrollToBottom();
56
+ }
57
+
58
+ function showError(msg) {
59
+ loginError.textContent = msg || '';
60
+ }
61
+
62
+ toolToggle.addEventListener('change', function() {
63
+ toolUse = toolToggle.checked;
64
+ });
65
+
66
+ // --- Auth & Model Selection ---
67
+ loginBtn.onclick = async function () {
68
+ const apiKey = apiKeyInput.value.trim();
69
+ if (!apiKey) { showError('API key required.'); return; }
70
+ loginBtn.disabled = true;
71
+ showError('');
72
+ try {
73
+ const res = await fetch('/init', {
74
+ method: 'POST',
75
+ headers: {'Content-Type': 'application/json'},
76
+ body: JSON.stringify({api_key: apiKey})
77
+ });
78
+ const data = await res.json();
79
+ if (!data.success) { showError(data.error || 'Login failed.'); loginBtn.disabled = false; return; }
80
+ sessionId = data.session_id;
81
+ models = data.models || [];
82
+ // Populate model dropdown
83
+ modelSelect.innerHTML = '';
84
+ models.forEach(m => {
85
+ const opt = document.createElement('option');
86
+ opt.value = m;
87
+ opt.textContent = m;
88
+ modelSelect.appendChild(opt);
89
+ });
90
+ currentModel = models[0];
91
+ modelSelect.value = currentModel;
92
+ // Show app, hide modal
93
+ modal.classList.add('hidden');
94
+ app.classList.remove('hidden');
95
+ chatContainer.innerHTML = '';
96
+ userInput.focus();
97
+ } catch (e) {
98
+ showError('Network error.');
99
+ loginBtn.disabled = false;
100
+ }
101
+ };
102
+
103
+ modelSelect.onchange = function () {
104
+ currentModel = modelSelect.value;
105
+ };
106
+
107
+ // --- Chat Logic ---
108
+ inputForm.onsubmit = async function (e) {
109
+ e.preventDefault();
110
+ const text = userInput.value.trim();
111
+ if (!text) return;
112
+
113
+ if (!canSendRequest()) {
114
+ addMessage('[Rate limit] Please wait: only 4 requests per minute allowed.', 'bot');
115
+ return;
116
+ }
117
+ recordRequest();
118
+
119
+ addMessage(text, 'user');
120
+ userInput.value = '';
121
+ setTyping(true);
122
+ try {
123
+ const res = await fetch('/chat', {
124
+ method: 'POST',
125
+ headers: {'Content-Type': 'application/json'},
126
+ body: JSON.stringify({
127
+ session_id: sessionId,
128
+ query: text,
129
+ tool_use: toolUse, // <-- Use the toggle value here
130
+ model: currentModel
131
+ })
132
+ });
133
+ const data = await res.json();
134
+ setTyping(false);
135
+ if (data.error) {
136
+ addMessage('[Error] ' + data.error, 'bot');
137
+ return;
138
+ }
139
+ addMessage(data.output, 'bot');
140
+ } catch (e) {
141
+ setTyping(false);
142
+ addMessage('[Network error]', 'bot');
143
+ }
144
+ };
145
+
146
+ // --- Logout Logic ---
147
+ logoutBtn.onclick = async function () {
148
+ await logoutAndReset();
149
+ };
150
+
151
+ async function logoutAndReset() {
152
+ if (sessionId) {
153
+ try {
154
+ await fetch('/logout', {
155
+ method: 'POST',
156
+ headers: {'Content-Type': 'application/json'},
157
+ body: JSON.stringify({session_id: sessionId})
158
+ });
159
+ } catch {}
160
+ sessionId = null;
161
+ models = [];
162
+ currentModel = null;
163
+ }
164
+ app.classList.add('hidden');
165
+ modal.classList.remove('hidden');
166
+ apiKeyInput.value = '';
167
+ chatContainer.innerHTML = '';
168
+ showError('');
169
+ loginBtn.disabled = false;
170
+ }
171
+
172
+ // --- Auto logout on exit ---
173
+ window.addEventListener('beforeunload', logoutAndReset);
174
+
175
+ // --- UX: Enter key in modal input triggers login ---
176
+ apiKeyInput.addEventListener('keyup', function(e) {
177
+ if (e.key === 'Enter') loginBtn.click();
178
+ });
static/style.css ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ body {
2
+ font-family: system-ui, sans-serif;
3
+ margin: 0;
4
+ background: #f4f6fb;
5
+ }
6
+
7
+ header {
8
+ background: #232946;
9
+ color: #fff;
10
+ padding: 12px 20px;
11
+ display: flex;
12
+ align-items: center;
13
+ gap: 16px;
14
+ justify-content: space-between;
15
+ }
16
+
17
+ header select, header button {
18
+ font-size: 1em;
19
+ padding: 4px 8px;
20
+ border-radius: 4px;
21
+ border: none;
22
+ }
23
+
24
+ #chatContainer {
25
+ max-width: 600px;
26
+ margin: 30px auto 0 auto;
27
+ background: #fff;
28
+ border-radius: 10px;
29
+ box-shadow: 0 2px 16px rgba(0,0,0,0.06);
30
+ min-height: 400px;
31
+ padding: 18px 10px 70px 10px;
32
+ overflow-y: auto;
33
+ height: 60vh;
34
+ display: flex;
35
+ flex-direction: column;
36
+ }
37
+
38
+ .message {
39
+ display: flex;
40
+ margin-bottom: 10px;
41
+ align-items: flex-end;
42
+ }
43
+
44
+ .message.user {
45
+ justify-content: flex-end;
46
+ }
47
+
48
+ .message.bot {
49
+ justify-content: flex-start;
50
+ }
51
+
52
+ .bubble {
53
+ max-width: 70%;
54
+ padding: 10px 14px;
55
+ border-radius: 20px;
56
+ font-size: 1em;
57
+ line-height: 1.5;
58
+ word-break: break-word;
59
+ }
60
+
61
+ .user .bubble {
62
+ background: #232946;
63
+ color: #fff;
64
+ border-bottom-right-radius: 4px;
65
+ }
66
+
67
+ .bot .bubble {
68
+ background: #e7eaf6;
69
+ color: #232946;
70
+ border-bottom-left-radius: 4px;
71
+ }
72
+
73
+ #inputForm {
74
+ position: fixed;
75
+ left: 0; right: 0; bottom: 0;
76
+ max-width: 600px;
77
+ margin: 0 auto;
78
+ display: flex;
79
+ gap: 10px;
80
+ background: #fff;
81
+ padding: 12px 10px;
82
+ border-top: 1px solid #e7eaf6;
83
+ }
84
+
85
+ #userInput {
86
+ flex: 1;
87
+ padding: 10px;
88
+ border-radius: 6px;
89
+ border: 1px solid #ccc;
90
+ font-size: 1em;
91
+ }
92
+
93
+ #inputForm button {
94
+ padding: 0 18px;
95
+ border-radius: 6px;
96
+ background: #232946;
97
+ color: #fff;
98
+ border: none;
99
+ font-size: 1em;
100
+ cursor: pointer;
101
+ }
102
+
103
+ .typing {
104
+ font-size: 0.95em;
105
+ color: #888;
106
+ margin-left: 18px;
107
+ margin-bottom: 8px;
108
+ }
109
+
110
+ .hidden {
111
+ display: none !important;
112
+ }
113
+
114
+ /* Modal */
115
+ .modal {
116
+ position: fixed;
117
+ top: 0; left: 0; right: 0; bottom: 0;
118
+ background: rgba(0,0,0,0.18);
119
+ display: flex;
120
+ align-items: center;
121
+ justify-content: center;
122
+ z-index: 10;
123
+ }
124
+
125
+ .modal-content {
126
+ background: #fff;
127
+ padding: 32px 28px 24px 28px;
128
+ border-radius: 12px;
129
+ box-shadow: 0 2px 16px rgba(0,0,0,0.12);
130
+ min-width: 320px;
131
+ display: flex;
132
+ flex-direction: column;
133
+ gap: 16px;
134
+ }
135
+
136
+ .modal-content input {
137
+ padding: 10px;
138
+ border-radius: 6px;
139
+ border: 1px solid #ccc;
140
+ font-size: 1em;
141
+ }
142
+
143
+ .toggle-label {
144
+ display: flex;
145
+ align-items: center;
146
+ gap: 5px;
147
+ font-size: 1em;
148
+ color: #fff;
149
+ }
150
+ .toggle-label input[type="checkbox"] {
151
+ accent-color: #232946;
152
+ width: 16px;
153
+ height: 16px;
154
+ }
155
+ .modal-content button {
156
+ padding: 8px 0;
157
+ border-radius: 6px;
158
+ background: #232946;
159
+ color: #fff;
160
+ border: none;
161
+ font-size: 1em;
162
+ cursor: pointer;
163
+ }
164
+
165
+ .error {
166
+ color: #c00;
167
+ font-size: 0.95em;
168
+ min-height: 18px;
169
+ }
170
+