Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- .gitignore +8 -0
- .python-version +1 -0
- README.md +2 -2
- app.py +2 -332
- logic/__init__.py +2 -0
- logic/profile.py +158 -0
- logic/tools.py +83 -0
- pyproject.toml +13 -0
- requirements.txt +5 -6
- ui/__init__.py +2 -0
- ui/layout.py +89 -0
- ui/styles.css +163 -0
- uv.lock +0 -0
.gitignore
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.venv/
|
| 2 |
+
.env
|
| 3 |
+
.env.*
|
| 4 |
+
__pycache__/
|
| 5 |
+
*.pyc
|
| 6 |
+
.vscode/
|
| 7 |
+
.idea/
|
| 8 |
+
*.log
|
.python-version
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
3.12
|
README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
app_file: app.py
|
| 4 |
sdk: gradio
|
| 5 |
-
sdk_version:
|
| 6 |
---
|
|
|
|
| 1 |
---
|
| 2 |
+
title: chat_with_evison
|
| 3 |
app_file: app.py
|
| 4 |
sdk: gradio
|
| 5 |
+
sdk_version: 6.1.0
|
| 6 |
---
|
app.py
CHANGED
|
@@ -1,336 +1,6 @@
|
|
| 1 |
-
from
|
| 2 |
-
from openai import OpenAI
|
| 3 |
-
import json
|
| 4 |
-
import os
|
| 5 |
-
import requests
|
| 6 |
-
from pypdf import PdfReader
|
| 7 |
-
import gradio as gr
|
| 8 |
-
|
| 9 |
-
load_dotenv(override=True)
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
# ---------- Tools (Pushover logging) ----------
|
| 13 |
-
|
| 14 |
-
def push(text):
|
| 15 |
-
try:
|
| 16 |
-
requests.post(
|
| 17 |
-
"https://api.pushover.net/1/messages.json",
|
| 18 |
-
data={
|
| 19 |
-
"token": os.getenv("PUSHOVER_TOKEN"),
|
| 20 |
-
"user": os.getenv("PUSHOVER_USER"),
|
| 21 |
-
"message": text,
|
| 22 |
-
},
|
| 23 |
-
timeout=5,
|
| 24 |
-
)
|
| 25 |
-
except Exception as e:
|
| 26 |
-
# Fail silently in the UI – this is just for logging
|
| 27 |
-
print(f"Pushover error: {e}", flush=True)
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
def record_user_details(email, name="Name not provided", notes="not provided"):
|
| 31 |
-
push(f"Recording {name} with email {email} and notes {notes}")
|
| 32 |
-
return {"recorded": "ok"}
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
def record_unknown_question(question):
|
| 36 |
-
push(f"User made an interesting question that I could not answer: {question}")
|
| 37 |
-
return {"recorded": "ok"}
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
record_user_details_json = {
|
| 41 |
-
"name": "record_user_details",
|
| 42 |
-
"description": "Use this tool to record that a user is interested in being in touch and provided an email address",
|
| 43 |
-
"parameters": {
|
| 44 |
-
"type": "object",
|
| 45 |
-
"properties": {
|
| 46 |
-
"email": {
|
| 47 |
-
"type": "string",
|
| 48 |
-
"description": "The email address of this user"
|
| 49 |
-
},
|
| 50 |
-
"name": {
|
| 51 |
-
"type": "string",
|
| 52 |
-
"description": "The user's name, if they provided it"
|
| 53 |
-
},
|
| 54 |
-
"notes": {
|
| 55 |
-
"type": "string",
|
| 56 |
-
"description": "Any additional information about the conversation that's worth recording to give context"
|
| 57 |
-
}
|
| 58 |
-
},
|
| 59 |
-
"required": ["email"],
|
| 60 |
-
"additionalProperties": False
|
| 61 |
-
}
|
| 62 |
-
}
|
| 63 |
-
|
| 64 |
-
record_unknown_question_json = {
|
| 65 |
-
"name": "record_unknown_question",
|
| 66 |
-
"description": "Always use this tool to record any question that couldn't be answered as you didn't know the answer",
|
| 67 |
-
"parameters": {
|
| 68 |
-
"type": "object",
|
| 69 |
-
"properties": {
|
| 70 |
-
"question": {
|
| 71 |
-
"type": "string",
|
| 72 |
-
"description": "The question that couldn't be answered"
|
| 73 |
-
},
|
| 74 |
-
},
|
| 75 |
-
"required": ["question"],
|
| 76 |
-
"additionalProperties": False
|
| 77 |
-
}
|
| 78 |
-
}
|
| 79 |
-
|
| 80 |
-
tools = [
|
| 81 |
-
{"type": "function", "function": record_user_details_json},
|
| 82 |
-
{"type": "function", "function": record_unknown_question_json},
|
| 83 |
-
]
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
# ---------- Me class (your digital twin) ----------
|
| 87 |
-
|
| 88 |
-
class Me:
|
| 89 |
-
|
| 90 |
-
def __init__(self):
|
| 91 |
-
self.openai = OpenAI()
|
| 92 |
-
self.name = "Evison Ndoni"
|
| 93 |
-
|
| 94 |
-
# Load LinkedIn-ish PDF text
|
| 95 |
-
self.linkedin = ""
|
| 96 |
-
try:
|
| 97 |
-
reader = PdfReader("me/evison.pdf")
|
| 98 |
-
for page in reader.pages:
|
| 99 |
-
text = page.extract_text()
|
| 100 |
-
if text:
|
| 101 |
-
self.linkedin += text + "\n"
|
| 102 |
-
except Exception as e:
|
| 103 |
-
print(f"Error reading PDF: {e}", flush=True)
|
| 104 |
-
|
| 105 |
-
# Load summary
|
| 106 |
-
try:
|
| 107 |
-
with open("me/summary.txt", "r", encoding="utf-8") as f:
|
| 108 |
-
self.summary = f.read()
|
| 109 |
-
except Exception as e:
|
| 110 |
-
print(f"Error reading summary.txt: {e}", flush=True)
|
| 111 |
-
self.summary = ""
|
| 112 |
-
|
| 113 |
-
# Extra hard-coded context about you (for a richer persona)
|
| 114 |
-
self.extra_context = """
|
| 115 |
-
- 26-year-old software developer based in Tirana, Albania.
|
| 116 |
-
- Strong frontend background: React, Next.js, TypeScript, HTML, CSS, SCSS, Tailwind.
|
| 117 |
-
- Mobile experience with Flutter, especially for polished, fluid UI/UX.
|
| 118 |
-
- Building portfolio projects like SeatReserve (seat booking SaaS) with modern full-stack tools.
|
| 119 |
-
- Currently learning Agentic AI and aiming to grow into a strong AI / Agentic AI engineer.
|
| 120 |
-
- Values: disciplined growth, responsibility, integrity, and putting God first.
|
| 121 |
-
- Interested in roles that blend software engineering with AI, problem solving, and product thinking.
|
| 122 |
-
""".strip()
|
| 123 |
-
|
| 124 |
-
def handle_tool_call(self, tool_calls):
|
| 125 |
-
results = []
|
| 126 |
-
for tool_call in tool_calls:
|
| 127 |
-
tool_name = tool_call.function.name
|
| 128 |
-
arguments = json.loads(tool_call.function.arguments)
|
| 129 |
-
print(f"Tool called: {tool_name}", flush=True)
|
| 130 |
-
tool = globals().get(tool_name)
|
| 131 |
-
result = tool(**arguments) if tool else {}
|
| 132 |
-
results.append({
|
| 133 |
-
"role": "tool",
|
| 134 |
-
"content": json.dumps(result),
|
| 135 |
-
"tool_call_id": tool_call.id
|
| 136 |
-
})
|
| 137 |
-
return results
|
| 138 |
-
|
| 139 |
-
def system_prompt(self):
|
| 140 |
-
system_prompt = f"""
|
| 141 |
-
You are acting as {self.name}. You are answering questions on {self.name}'s personal website / career page,
|
| 142 |
-
particularly questions related to {self.name}'s career, background, skills, experience, and aspirations.
|
| 143 |
-
|
| 144 |
-
Your responsibilities:
|
| 145 |
-
- Represent {self.name} as faithfully as possible.
|
| 146 |
-
- Use the provided summary, LinkedIn-style profile information, and extra context below.
|
| 147 |
-
- Be professional, clear, honest, and engaging – as if talking to a potential employer, client, or collaborator.
|
| 148 |
-
- You can talk about his technical skills (React, Next.js, TypeScript, Flutter, Tailwind, etc.), portfolio projects,
|
| 149 |
-
and his interest in Agentic AI engineering and AI-powered products.
|
| 150 |
-
- You can also briefly mention his values (faith, discipline, integrity) when it’s appropriate for the conversation.
|
| 151 |
-
|
| 152 |
-
Tools:
|
| 153 |
-
- If you don't know the answer to any question, use the `record_unknown_question` tool to log the question.
|
| 154 |
-
- If the user is a potential lead or seems interested in working with {self.name}, gently encourage them to share
|
| 155 |
-
their email and log it using the `record_user_details` tool with a short note about the context.
|
| 156 |
-
|
| 157 |
-
Tone:
|
| 158 |
-
- Friendly, confident, thoughtful.
|
| 159 |
-
- Don't oversell; be realistic but optimistic.
|
| 160 |
-
- Keep answers concise but helpful, unless the user clearly wants depth.
|
| 161 |
-
|
| 162 |
-
Below is your context. Use it actively:
|
| 163 |
-
|
| 164 |
-
## Summary (CV-style overview):
|
| 165 |
-
{self.summary}
|
| 166 |
-
|
| 167 |
-
## LinkedIn-like Profile (from PDF):
|
| 168 |
-
{self.linkedin}
|
| 169 |
-
|
| 170 |
-
## Extra Context:
|
| 171 |
-
{self.extra_context}
|
| 172 |
-
|
| 173 |
-
With this context, please chat with the user, always staying in character as {self.name}.
|
| 174 |
-
""".strip()
|
| 175 |
-
return system_prompt
|
| 176 |
-
|
| 177 |
-
def chat(self, message, history):
|
| 178 |
-
"""
|
| 179 |
-
message: latest user message (str)
|
| 180 |
-
history: list[dict] with {'role': 'user'|'assistant', 'content': str}
|
| 181 |
-
(we use type="messages" in Gradio to match this format)
|
| 182 |
-
"""
|
| 183 |
-
messages = [{"role": "system", "content": self.system_prompt()}] + history + [
|
| 184 |
-
{"role": "user", "content": message}
|
| 185 |
-
]
|
| 186 |
-
|
| 187 |
-
done = False
|
| 188 |
-
response_message = None
|
| 189 |
-
|
| 190 |
-
while not done:
|
| 191 |
-
response = self.openai.chat.completions.create(
|
| 192 |
-
model="gpt-4o-mini",
|
| 193 |
-
messages=messages,
|
| 194 |
-
tools=tools
|
| 195 |
-
)
|
| 196 |
-
choice = response.choices[0]
|
| 197 |
-
finish_reason = choice.finish_reason
|
| 198 |
-
|
| 199 |
-
if finish_reason == "tool_calls":
|
| 200 |
-
message_with_tools = choice.message
|
| 201 |
-
tool_calls = message_with_tools.tool_calls
|
| 202 |
-
results = self.handle_tool_call(tool_calls)
|
| 203 |
-
messages.append(message_with_tools)
|
| 204 |
-
messages.extend(results)
|
| 205 |
-
else:
|
| 206 |
-
done = True
|
| 207 |
-
response_message = choice.message
|
| 208 |
-
|
| 209 |
-
return response_message.content if response_message else "Sorry, something went wrong."
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
# ---------- Gradio UI ----------
|
| 213 |
-
|
| 214 |
-
me = Me()
|
| 215 |
-
|
| 216 |
-
# Custom chat handler for Gradio
|
| 217 |
-
def respond(user_message, history):
|
| 218 |
-
"""
|
| 219 |
-
user_message: str from the textbox
|
| 220 |
-
history: list of messages (role/content) used by the chatbot (type='messages')
|
| 221 |
-
"""
|
| 222 |
-
if history is None:
|
| 223 |
-
history = []
|
| 224 |
-
|
| 225 |
-
assistant_reply = me.chat(user_message, history)
|
| 226 |
-
|
| 227 |
-
# Update history with the last user + assistant turn
|
| 228 |
-
new_history = history + [
|
| 229 |
-
{"role": "user", "content": user_message},
|
| 230 |
-
{"role": "assistant", "content": assistant_reply},
|
| 231 |
-
]
|
| 232 |
-
return "", new_history, new_history # (cleared textbox, chatbot value, state)
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
def clear_chat():
|
| 236 |
-
return [], [] # chatbot, state
|
| 237 |
|
|
|
|
| 238 |
|
| 239 |
if __name__ == "__main__":
|
| 240 |
-
theme = gr.themes.Soft() # no radius_scale -> compatible with more Gradio versions
|
| 241 |
-
|
| 242 |
-
with gr.Blocks(theme=theme, css="""
|
| 243 |
-
#app-container {
|
| 244 |
-
max-width: 1100px;
|
| 245 |
-
margin: 0 auto;
|
| 246 |
-
}
|
| 247 |
-
.chat-title {
|
| 248 |
-
text-align: center;
|
| 249 |
-
font-size: 1.8rem;
|
| 250 |
-
font-weight: 700;
|
| 251 |
-
margin-bottom: 0.4rem;
|
| 252 |
-
}
|
| 253 |
-
.chat-subtitle {
|
| 254 |
-
text-align: center;
|
| 255 |
-
font-size: 0.95rem;
|
| 256 |
-
opacity: 0.85;
|
| 257 |
-
margin-bottom: 1.2rem;
|
| 258 |
-
}
|
| 259 |
-
""") as demo:
|
| 260 |
-
with gr.Column(elem_id="app-container"):
|
| 261 |
-
gr.Markdown(
|
| 262 |
-
f"""
|
| 263 |
-
<div class="chat-title">Career Conversation with <span style="color:#b91c1c;">{me.name}</span></div>
|
| 264 |
-
<div class="chat-subtitle">
|
| 265 |
-
Ask me about my experience, projects, skills, or how I can help your team.
|
| 266 |
-
</div>
|
| 267 |
-
""",
|
| 268 |
-
elem_id="title-block"
|
| 269 |
-
)
|
| 270 |
-
|
| 271 |
-
with gr.Row():
|
| 272 |
-
# Left: Chat area
|
| 273 |
-
with gr.Column(scale=3):
|
| 274 |
-
chatbot = gr.Chatbot(
|
| 275 |
-
label="Chat with Evison",
|
| 276 |
-
type="messages",
|
| 277 |
-
height=480,
|
| 278 |
-
show_copy_button=True,
|
| 279 |
-
)
|
| 280 |
-
|
| 281 |
-
user_input = gr.Textbox(
|
| 282 |
-
label="Your message",
|
| 283 |
-
placeholder="Ask me about my experience, tech stack, or projects...",
|
| 284 |
-
lines=3,
|
| 285 |
-
)
|
| 286 |
-
|
| 287 |
-
with gr.Row():
|
| 288 |
-
send_btn = gr.Button("Send", variant="primary")
|
| 289 |
-
clear_btn = gr.Button("Clear chat")
|
| 290 |
-
|
| 291 |
-
# Right: About panel
|
| 292 |
-
with gr.Column(scale=2):
|
| 293 |
-
gr.Markdown(
|
| 294 |
-
f"""
|
| 295 |
-
### About {me.name}
|
| 296 |
-
|
| 297 |
-
- Frontend & mobile engineer (React, Next.js, TypeScript, Flutter, Tailwind).
|
| 298 |
-
- 3+ years of hands-on experience building real-world products.
|
| 299 |
-
- Passionate about clean, modern UI/UX and maintainable codebases.
|
| 300 |
-
- Currently learning **Agentic AI** and building AI-powered solutions.
|
| 301 |
-
|
| 302 |
-
### What you can ask
|
| 303 |
-
|
| 304 |
-
- Details about my recent projects (e.g. SeatReserve).
|
| 305 |
-
- How I approach architecture, testing, performance, or collaboration.
|
| 306 |
-
- How I see myself contributing to your team or product.
|
| 307 |
-
- My availability, preferred work style, and career goals.
|
| 308 |
-
|
| 309 |
-
If you're interested in working together, feel free to ask for my email
|
| 310 |
-
or share yours so we can continue the conversation.
|
| 311 |
-
""".strip()
|
| 312 |
-
)
|
| 313 |
-
|
| 314 |
-
# State to hold full message history (in OpenAI format)
|
| 315 |
-
history_state = gr.State([])
|
| 316 |
-
|
| 317 |
-
# Wire up events
|
| 318 |
-
send_btn.click(
|
| 319 |
-
fn=respond,
|
| 320 |
-
inputs=[user_input, history_state],
|
| 321 |
-
outputs=[user_input, chatbot, history_state],
|
| 322 |
-
)
|
| 323 |
-
|
| 324 |
-
user_input.submit(
|
| 325 |
-
fn=respond,
|
| 326 |
-
inputs=[user_input, history_state],
|
| 327 |
-
outputs=[user_input, chatbot, history_state],
|
| 328 |
-
)
|
| 329 |
-
|
| 330 |
-
clear_btn.click(
|
| 331 |
-
fn=clear_chat,
|
| 332 |
-
inputs=None,
|
| 333 |
-
outputs=[chatbot, history_state],
|
| 334 |
-
)
|
| 335 |
-
|
| 336 |
demo.launch()
|
|
|
|
| 1 |
+
from ui.layout import build_interface
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
+
demo = build_interface()
|
| 4 |
|
| 5 |
if __name__ == "__main__":
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
demo.launch()
|
logic/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Logic package
|
| 2 |
+
|
logic/profile.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
from dotenv import load_dotenv
|
| 5 |
+
from openai import OpenAI
|
| 6 |
+
from pypdf import PdfReader
|
| 7 |
+
|
| 8 |
+
from logic.tools import FUNCTION_MAP, TOOLS
|
| 9 |
+
|
| 10 |
+
load_dotenv(override=True)
|
| 11 |
+
|
| 12 |
+
BASE_DIR = Path(__file__).resolve().parent.parent
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class Me:
|
| 16 |
+
def __init__(self):
|
| 17 |
+
self.openai = OpenAI()
|
| 18 |
+
self.name = "Evison Ndoni"
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
pdf_path = BASE_DIR / "me" / "evison.pdf"
|
| 22 |
+
self.linkedin = ""
|
| 23 |
+
try:
|
| 24 |
+
if pdf_path.exists():
|
| 25 |
+
reader = PdfReader(str(pdf_path))
|
| 26 |
+
for page in reader.pages:
|
| 27 |
+
text = page.extract_text()
|
| 28 |
+
if text:
|
| 29 |
+
self.linkedin += text
|
| 30 |
+
else:
|
| 31 |
+
self.linkedin = "(LinkedIn PDF not found on server.)"
|
| 32 |
+
except Exception as e:
|
| 33 |
+
|
| 34 |
+
self.linkedin = f"(Error reading LinkedIn PDF: {e})"
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
summary_path = BASE_DIR / "me" / "summary.txt"
|
| 38 |
+
try:
|
| 39 |
+
with open(summary_path, "r", encoding="utf-8") as f:
|
| 40 |
+
self.summary = f.read()
|
| 41 |
+
except Exception as e:
|
| 42 |
+
self.summary = f"(Summary file not found or unreadable: {e})"
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
self.extra_context = """
|
| 46 |
+
- 26-year-old software engineer from Albania.
|
| 47 |
+
- 3+ years experience with React, Next.js, TypeScript, Tailwind CSS and Flutter.
|
| 48 |
+
- Currently learning Agentic AI and aiming for AI Engineer roles.
|
| 49 |
+
- Values clean, fluid UI/UX and likes to build useful products and SaaS.
|
| 50 |
+
- Tries to stay grounded, responsible, and future-oriented while putting God first.
|
| 51 |
+
""".strip()
|
| 52 |
+
|
| 53 |
+
def handle_tool_call(self, tool_calls):
|
| 54 |
+
results = []
|
| 55 |
+
for tool_call in tool_calls:
|
| 56 |
+
tool_name = tool_call.function.name
|
| 57 |
+
arguments = json.loads(tool_call.function.arguments)
|
| 58 |
+
print(f"Tool called: {tool_name}", flush=True)
|
| 59 |
+
tool = FUNCTION_MAP.get(tool_name)
|
| 60 |
+
result = tool(**arguments) if tool else {}
|
| 61 |
+
results.append(
|
| 62 |
+
{
|
| 63 |
+
"role": "tool",
|
| 64 |
+
"content": json.dumps(result),
|
| 65 |
+
"tool_call_id": tool_call.id,
|
| 66 |
+
}
|
| 67 |
+
)
|
| 68 |
+
return results
|
| 69 |
+
|
| 70 |
+
def system_prompt(self):
|
| 71 |
+
system_prompt = (
|
| 72 |
+
f"You are acting as {self.name}. You are answering questions on "
|
| 73 |
+
f"{self.name}'s personal website, particularly questions related to his "
|
| 74 |
+
f"career, background, skills, experience, and values.\n\n"
|
| 75 |
+
f"Your responsibility is to represent {self.name} as faithfully as possible. "
|
| 76 |
+
f"Be professional, warm, confident, and grounded, as if talking to a "
|
| 77 |
+
f"potential client, hiring manager, or collaborator.\n\n"
|
| 78 |
+
f"{self.name} is a follower of the Orthodox Christian faith and tries to put "
|
| 79 |
+
f"God first while being ambitious and disciplined in his work. When faith "
|
| 80 |
+
f"or values come up, you can mention this naturally, but keep the focus on "
|
| 81 |
+
f"respectful and professional conversation.\n\n"
|
| 82 |
+
f"If you don't know the answer to any question, use your "
|
| 83 |
+
f"'record_unknown_question' tool to record the question, even if it's "
|
| 84 |
+
f"trivial or unrelated to career.\n\n"
|
| 85 |
+
f"If the user seems like a potential employer, client, or collaborator, "
|
| 86 |
+
f"gently encourage them to share their email so {self.name} can follow up, "
|
| 87 |
+
f"and record it using your 'record_user_details' tool.\n"
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
system_prompt += f"\n## Short Summary\n{self.summary}\n"
|
| 91 |
+
system_prompt += f"\n## LinkedIn-style Profile\n{self.linkedin}\n"
|
| 92 |
+
system_prompt += f"\n## Additional Personal Context\n{self.extra_context}\n"
|
| 93 |
+
system_prompt += (
|
| 94 |
+
"\nWith this context, please chat with the user, always staying in "
|
| 95 |
+
f"character as {self.name}."
|
| 96 |
+
)
|
| 97 |
+
return system_prompt
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def _run_conversation(self, message, history_messages):
|
| 101 |
+
"""
|
| 102 |
+
Internal helper that runs the full tool-calling loop and returns
|
| 103 |
+
the final assistant message content as a string.
|
| 104 |
+
`history_messages` is a list of OpenAI-style dicts: [{role, content}, ...]
|
| 105 |
+
(no system message inside; we add it here).
|
| 106 |
+
"""
|
| 107 |
+
if history_messages is None:
|
| 108 |
+
history_messages = []
|
| 109 |
+
|
| 110 |
+
messages = [
|
| 111 |
+
{"role": "system", "content": self.system_prompt()}
|
| 112 |
+
] + history_messages + [{"role": "user", "content": message}]
|
| 113 |
+
|
| 114 |
+
done = False
|
| 115 |
+
while not done:
|
| 116 |
+
response = self.openai.chat.completions.create(
|
| 117 |
+
model="gpt-4o-mini",
|
| 118 |
+
messages=messages,
|
| 119 |
+
tools=TOOLS,
|
| 120 |
+
)
|
| 121 |
+
choice = response.choices[0]
|
| 122 |
+
if choice.finish_reason == "tool_calls":
|
| 123 |
+
message_tool = choice.message
|
| 124 |
+
tool_calls = message_tool.tool_calls
|
| 125 |
+
results = self.handle_tool_call(tool_calls)
|
| 126 |
+
messages.append(message_tool)
|
| 127 |
+
messages.extend(results)
|
| 128 |
+
else:
|
| 129 |
+
done = True
|
| 130 |
+
final_message = choice.message
|
| 131 |
+
return final_message.content or ""
|
| 132 |
+
|
| 133 |
+
return ""
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def chat_stream(self, message, history_messages):
|
| 137 |
+
"""
|
| 138 |
+
Generator version for streaming.
|
| 139 |
+
Yields *partial assistant content* as a plain string.
|
| 140 |
+
Gradio can wrap this to update the Chatbot incrementally.
|
| 141 |
+
"""
|
| 142 |
+
full_content = self._run_conversation(message, history_messages)
|
| 143 |
+
|
| 144 |
+
partial = ""
|
| 145 |
+
for ch in full_content:
|
| 146 |
+
partial += ch
|
| 147 |
+
yield partial
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def chat(self, message, history_messages):
|
| 151 |
+
"""
|
| 152 |
+
Non-streaming wrapper kept for compatibility with existing code.
|
| 153 |
+
It internally uses `chat_stream` and just returns the final string.
|
| 154 |
+
"""
|
| 155 |
+
last_chunk = ""
|
| 156 |
+
for chunk in self.chat_stream(message, history_messages):
|
| 157 |
+
last_chunk = chunk
|
| 158 |
+
return last_chunk
|
logic/tools.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import requests
|
| 3 |
+
from dotenv import load_dotenv
|
| 4 |
+
|
| 5 |
+
load_dotenv(override=True)
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def push(text: str):
|
| 9 |
+
"""Send a notification via Pushover when credentials are configured."""
|
| 10 |
+
if not os.getenv("PUSHOVER_TOKEN") or not os.getenv("PUSHOVER_USER"):
|
| 11 |
+
# Fail silently if pushover is not configured – avoids runtime errors
|
| 12 |
+
print("[PUSHOVER] Not configured, message was:", text, flush=True)
|
| 13 |
+
return
|
| 14 |
+
requests.post(
|
| 15 |
+
"https://api.pushover.net/1/messages.json",
|
| 16 |
+
data={
|
| 17 |
+
"token": os.getenv("PUSHOVER_TOKEN"),
|
| 18 |
+
"user": os.getenv("PUSHOVER_USER"),
|
| 19 |
+
"message": text,
|
| 20 |
+
},
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def record_user_details(email, name="Name not provided", notes="not provided"):
|
| 25 |
+
push(f"Recording {name} with email {email} and notes {notes}")
|
| 26 |
+
return {"recorded": "ok"}
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def record_unknown_question(question):
|
| 30 |
+
push(f"User made an interesting question that I could not answer: {question}")
|
| 31 |
+
return {"recorded": "ok"}
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
record_user_details_json = {
|
| 35 |
+
"name": "record_user_details",
|
| 36 |
+
"description": "Use this tool to record that a user is interested in being in touch and provided an email address",
|
| 37 |
+
"parameters": {
|
| 38 |
+
"type": "object",
|
| 39 |
+
"properties": {
|
| 40 |
+
"email": {
|
| 41 |
+
"type": "string",
|
| 42 |
+
"description": "The email address of this user",
|
| 43 |
+
},
|
| 44 |
+
"name": {
|
| 45 |
+
"type": "string",
|
| 46 |
+
"description": "The user's name, if they provided it",
|
| 47 |
+
},
|
| 48 |
+
"notes": {
|
| 49 |
+
"type": "string",
|
| 50 |
+
"description": "Any additional information about the conversation that's worth recording to give context",
|
| 51 |
+
},
|
| 52 |
+
},
|
| 53 |
+
"required": ["email"],
|
| 54 |
+
"additionalProperties": False,
|
| 55 |
+
},
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
record_unknown_question_json = {
|
| 59 |
+
"name": "record_unknown_question",
|
| 60 |
+
"description": "Always use this tool to record any question that couldn't be answered as you didn't know the answer",
|
| 61 |
+
"parameters": {
|
| 62 |
+
"type": "object",
|
| 63 |
+
"properties": {
|
| 64 |
+
"question": {
|
| 65 |
+
"type": "string",
|
| 66 |
+
"description": "The question that couldn't be answered",
|
| 67 |
+
},
|
| 68 |
+
},
|
| 69 |
+
"required": ["question"],
|
| 70 |
+
"additionalProperties": False,
|
| 71 |
+
},
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
TOOLS = [
|
| 75 |
+
{"type": "function", "function": record_user_details_json},
|
| 76 |
+
{"type": "function", "function": record_unknown_question_json},
|
| 77 |
+
]
|
| 78 |
+
|
| 79 |
+
FUNCTION_MAP = {
|
| 80 |
+
"record_user_details": record_user_details,
|
| 81 |
+
"record_unknown_question": record_unknown_question,
|
| 82 |
+
}
|
| 83 |
+
|
pyproject.toml
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "chat-with-evison"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
description = "Add your description here"
|
| 5 |
+
readme = "README.md"
|
| 6 |
+
requires-python = ">=3.12"
|
| 7 |
+
dependencies = [
|
| 8 |
+
"gradio>=6.1.0",
|
| 9 |
+
"openai>=2.9.0",
|
| 10 |
+
"pypdf>=6.4.1",
|
| 11 |
+
"python-dotenv>=1.2.1",
|
| 12 |
+
"requests>=2.32.5",
|
| 13 |
+
]
|
requirements.txt
CHANGED
|
@@ -1,6 +1,5 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
pypdf
|
| 5 |
-
|
| 6 |
-
openai-agents
|
|
|
|
| 1 |
+
gradio
|
| 2 |
+
openai
|
| 3 |
+
python-dotenv
|
| 4 |
+
pypdf
|
| 5 |
+
requests
|
|
|
ui/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# UI package
|
| 2 |
+
|
ui/layout.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
|
| 3 |
+
import gradio as gr
|
| 4 |
+
|
| 5 |
+
from logic.profile import Me
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def _load_css() -> str:
|
| 9 |
+
css_path = Path(__file__).parent / "styles.css"
|
| 10 |
+
with open(css_path, "r", encoding="utf-8") as f:
|
| 11 |
+
return f.read()
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def build_interface():
|
| 15 |
+
me = Me()
|
| 16 |
+
custom_css = _load_css()
|
| 17 |
+
|
| 18 |
+
with gr.Blocks(css=custom_css) as demo:
|
| 19 |
+
with gr.Row(elem_classes="app-row"):
|
| 20 |
+
with gr.Column(scale=3, elem_classes="chat-column"):
|
| 21 |
+
gr.Markdown(
|
| 22 |
+
"### Chat with **Evison Ndoni**",
|
| 23 |
+
elem_classes="title-text",
|
| 24 |
+
)
|
| 25 |
+
chatbot = gr.Chatbot(
|
| 26 |
+
height=520,
|
| 27 |
+
elem_id="chatbot",
|
| 28 |
+
)
|
| 29 |
+
with gr.Row(elem_classes="input-row"):
|
| 30 |
+
msg = gr.Textbox(
|
| 31 |
+
placeholder=(
|
| 32 |
+
"Ask about my background, past work, AI/agentic AI, "
|
| 33 |
+
"or anything career-related..."
|
| 34 |
+
),
|
| 35 |
+
label="",
|
| 36 |
+
show_label=False,
|
| 37 |
+
elem_id="chat-input",
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
with gr.Row(elem_classes="button-row"):
|
| 41 |
+
send_btn = gr.Button("Send", elem_id="send-btn")
|
| 42 |
+
clear_btn = gr.Button("Clear chat", elem_id="clear-btn")
|
| 43 |
+
|
| 44 |
+
with gr.Column(scale=2, elem_classes="sidebar-column"):
|
| 45 |
+
gr.Markdown(
|
| 46 |
+
"""
|
| 47 |
+
### About Evison Ndoni
|
| 48 |
+
|
| 49 |
+
- Software engineer (React, Next.js, TypeScript, Tailwind CSS, Flutter)
|
| 50 |
+
- Currently learning **Agentic AI** and building AI-powered projects
|
| 51 |
+
- Enjoys clean, SaaS-style product design and long-term thinking
|
| 52 |
+
|
| 53 |
+
If you're a recruiter, hiring manager, or potential collaborator,
|
| 54 |
+
feel free to ask anything about my experience, stack, or projects.
|
| 55 |
+
|
| 56 |
+
You can also **share your email in the chat** if you'd like me to follow up.
|
| 57 |
+
""",
|
| 58 |
+
elem_classes="about-card",
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def respond(message, history):
|
| 63 |
+
"""
|
| 64 |
+
Gradio 6 Chatbot uses a 'messages' format:
|
| 65 |
+
history is a list of dicts: [{ "role": "user"|"assistant", "content": "..." }, ...]
|
| 66 |
+
We must return the *updated* history in the same format.
|
| 67 |
+
"""
|
| 68 |
+
if history is None:
|
| 69 |
+
history = []
|
| 70 |
+
|
| 71 |
+
assistant_reply = me.chat(message, history)
|
| 72 |
+
|
| 73 |
+
new_history = history + [
|
| 74 |
+
{"role": "user", "content": message},
|
| 75 |
+
{"role": "assistant", "content": assistant_reply},
|
| 76 |
+
]
|
| 77 |
+
return new_history
|
| 78 |
+
|
| 79 |
+
send_btn.click(
|
| 80 |
+
respond,
|
| 81 |
+
inputs=[msg, chatbot],
|
| 82 |
+
outputs=chatbot,
|
| 83 |
+
)
|
| 84 |
+
send_btn.click(lambda: "", inputs=None, outputs=msg)
|
| 85 |
+
clear_btn.click(lambda: [], inputs=None, outputs=chatbot)
|
| 86 |
+
|
| 87 |
+
demo.load(lambda: [], None, chatbot)
|
| 88 |
+
|
| 89 |
+
return demo
|
ui/styles.css
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
html,
|
| 2 |
+
body,
|
| 3 |
+
.gradio-container {
|
| 4 |
+
background: #0a0f1f !important;
|
| 5 |
+
color: #e5e7eb !important;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
:root {
|
| 9 |
+
color-scheme: dark;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
:root {
|
| 13 |
+
--bg-main: #0a0f1f;
|
| 14 |
+
--bg-panel: #0f172a;
|
| 15 |
+
--bg-card: #111827;
|
| 16 |
+
--bg-elevated: #1e293b;
|
| 17 |
+
--bg-input: #0f172a;
|
| 18 |
+
|
| 19 |
+
--text-primary: #e5e7eb;
|
| 20 |
+
--text-secondary: #9ca3af;
|
| 21 |
+
--text-muted: #6b7280;
|
| 22 |
+
|
| 23 |
+
--accent: #22c55e;
|
| 24 |
+
--accent-strong: #16a34a;
|
| 25 |
+
|
| 26 |
+
--border-subtle: rgba(148, 163, 184, 0.18);
|
| 27 |
+
|
| 28 |
+
--radius-lg: 18px;
|
| 29 |
+
--radius-md: 10px;
|
| 30 |
+
|
| 31 |
+
--shadow-soft: 0 18px 40px rgba(0, 0, 0, 0.55);
|
| 32 |
+
--shadow-card: 0 10px 24px rgba(0, 0, 0, 0.45);
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
.app-row {
|
| 36 |
+
max-width: 1180px;
|
| 37 |
+
margin: 50px auto;
|
| 38 |
+
padding: 28px;
|
| 39 |
+
border-radius: 26px;
|
| 40 |
+
background: var(--bg-card);
|
| 41 |
+
border: 1px solid rgba(255, 255, 255, 0.04);
|
| 42 |
+
box-shadow: var(--shadow-soft);
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
.title-text h3,
|
| 46 |
+
.title-text h1 {
|
| 47 |
+
color: var(--text-primary);
|
| 48 |
+
font-weight: 600;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
#chatbot {
|
| 52 |
+
background: var(--bg-panel) !important;
|
| 53 |
+
border-radius: var(--radius-lg);
|
| 54 |
+
border: 1px solid var(--border-subtle);
|
| 55 |
+
height: 520px !important;
|
| 56 |
+
box-shadow: var(--shadow-card);
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
#chatbot .message.user {
|
| 60 |
+
background: rgba(255, 255, 255, 0.05) !important;
|
| 61 |
+
color: var(--text-primary) !important;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
#chatbot .message.bot {
|
| 65 |
+
background: rgba(20, 25, 40, 0.8) !important;
|
| 66 |
+
color: var(--text-secondary) !important;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
#chat-input {
|
| 70 |
+
background: transparent !important;
|
| 71 |
+
border: none !important;
|
| 72 |
+
box-shadow: none !important;
|
| 73 |
+
padding: 0 !important;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
#chat-input textarea {
|
| 77 |
+
width: 100%;
|
| 78 |
+
background: #020617;
|
| 79 |
+
border: 1px solid rgba(148, 163, 184, 0.35);
|
| 80 |
+
font-size: 0.95rem;
|
| 81 |
+
color: #e5e7eb;
|
| 82 |
+
outline: none;
|
| 83 |
+
box-shadow: none;
|
| 84 |
+
transition: border-color 0.15s ease, box-shadow 0.15s ease,
|
| 85 |
+
background-color 0.15s ease;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
#chat-input textarea::placeholder {
|
| 89 |
+
color: rgba(148, 163, 184, 0.8);
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
#chat-input textarea:focus {
|
| 93 |
+
background-color: #020617;
|
| 94 |
+
border-color: #585858; /* soft green */
|
| 95 |
+
box-shadow: 0 0 0 1px rgba(34, 197, 94, 0.35);
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
.button-row {
|
| 99 |
+
margin-top: 12px;
|
| 100 |
+
gap: 12px;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
#send-btn {
|
| 104 |
+
background: linear-gradient(135deg, #1ed760, #1abc5b);
|
| 105 |
+
border: none;
|
| 106 |
+
color: white;
|
| 107 |
+
font-weight: 600;
|
| 108 |
+
border-radius: 999px;
|
| 109 |
+
padding: 0.65rem 1.8rem;
|
| 110 |
+
font-size: 0.95rem;
|
| 111 |
+
|
| 112 |
+
box-shadow: 0 4px 14px rgba(30, 215, 96, 0.25); /* subtle shadow */
|
| 113 |
+
transition: transform 0.12s ease, box-shadow 0.2s ease, filter 0.2s ease;
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
#send-btn:hover {
|
| 117 |
+
transform: translateY(-2px);
|
| 118 |
+
box-shadow: 0 6px 18px rgba(30, 215, 96, 0.32);
|
| 119 |
+
filter: brightness(1.05);
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
#send-btn:active {
|
| 123 |
+
transform: scale(0.97);
|
| 124 |
+
box-shadow: 0 3px 12px rgba(30, 215, 96, 0.2);
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
#clear-btn {
|
| 128 |
+
background: transparent;
|
| 129 |
+
color: var(--text-secondary);
|
| 130 |
+
border: 1px solid rgba(255, 255, 255, 0.12);
|
| 131 |
+
border-radius: 999px;
|
| 132 |
+
padding: 0.65rem 1.8rem;
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
#clear-btn:hover {
|
| 136 |
+
background: rgba(255, 255, 255, 0.06);
|
| 137 |
+
color: var(--text-primary);
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
.about-card {
|
| 141 |
+
background: var(--bg-panel) !important;
|
| 142 |
+
padding: 22px;
|
| 143 |
+
border-radius: var(--radius-lg);
|
| 144 |
+
border: 1px solid rgba(255, 255, 255, 0.06);
|
| 145 |
+
box-shadow: var(--shadow-card);
|
| 146 |
+
color: var(--text-secondary) !important;
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
.about-card h3 {
|
| 150 |
+
color: var(--text-primary);
|
| 151 |
+
margin-bottom: 10px;
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
.about-card li::marker {
|
| 155 |
+
color: var(--accent);
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
@media (max-width: 768px) {
|
| 159 |
+
.app-row {
|
| 160 |
+
margin: 20px;
|
| 161 |
+
padding: 18px;
|
| 162 |
+
}
|
| 163 |
+
}
|
uv.lock
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|