SolarumAsteridion commited on
Commit
120491d
·
0 Parent(s):

Initial commit

Browse files
Files changed (7) hide show
  1. .dockerignore +13 -0
  2. .gitignore +8 -0
  3. Dockerfile +22 -0
  4. README.md +20 -0
  5. index.html +1172 -0
  6. main.py +473 -0
  7. requirements.txt +8 -0
.dockerignore ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ venv/
5
+ .env
6
+ app.log
7
+ .git/
8
+ .gitignore
9
+ README.md
10
+ Dockerfile
11
+ requirements.txt
12
+ # No need to ignore the artifacts directory as we won't push it.
13
+ ./tmp/
.gitignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ node_modules/
2
+ build/
3
+ dist/
4
+ coverage/
5
+ .DS_Store
6
+ *.log
7
+ .env*
8
+ !.env.example
Dockerfile ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Use Python 3.12 image as specified in user global rules
2
+ FROM python:3.12-slim
3
+
4
+ # Create a non-root user
5
+ RUN useradd -m -u 1000 user
6
+ USER user
7
+ ENV PATH="/home/user/.local/bin:$PATH"
8
+
9
+ WORKDIR /app
10
+
11
+ # Copy requirements and install
12
+ COPY --chown=user ./requirements.txt requirements.txt
13
+ RUN pip install --no-cache-dir --upgrade -r requirements.txt
14
+
15
+ # Copy the rest of the application
16
+ COPY --chown=user . /app
17
+
18
+ # Expose the mandatory Hugging Face port
19
+ EXPOSE 7860
20
+
21
+ # Run with uvicorn on port 7860
22
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
README.md ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <div align="center">
2
+ <img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
3
+ </div>
4
+
5
+ # Run and deploy your AI Studio app
6
+
7
+ This contains everything you need to run your app locally.
8
+
9
+ View your app in AI Studio: https://ai.studio/apps/db4b3d20-dfda-45a4-b58c-5bcf63b592a4
10
+
11
+ ## Run Locally
12
+
13
+ **Prerequisites:** Node.js
14
+
15
+
16
+ 1. Install dependencies:
17
+ `npm install`
18
+ 2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
19
+ 3. Run the app:
20
+ `npm run dev`
index.html ADDED
@@ -0,0 +1,1172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>HomeStack | Digital Inventory Curator</title>
8
+ <script src="https://cdn.tailwindcss.com"></script>
9
+ <link
10
+ href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200"
11
+ rel="stylesheet">
12
+ <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
13
+ <script src="https://cdn.jsdelivr.net/npm/fuse.js/dist/fuse.js"></script>
14
+ <script>
15
+ tailwind.config = {
16
+ theme: {
17
+ extend: {
18
+ colors: {
19
+ primary: '#6750A4',
20
+ 'on-primary': '#FFFFFF',
21
+ 'primary-container': '#EADDFF',
22
+ 'on-primary-container': '#21005D',
23
+ secondary: '#625B71',
24
+ 'on-secondary': '#FFFFFF',
25
+ 'secondary-container': '#E8DEF8',
26
+ 'on-secondary-container': '#1D192B',
27
+ tertiary: '#7D5260',
28
+ 'on-tertiary': '#FFFFFF',
29
+ 'tertiary-container': '#FFD8E4',
30
+ 'on-tertiary-container': '#31111D',
31
+ error: '#B3261E',
32
+ 'on-error': '#FFFFFF',
33
+ 'error-container': '#F9DEDC',
34
+ 'on-error-container': '#410E0B',
35
+ surface: '#FEF7FF',
36
+ 'on-surface': '#1D1B20',
37
+ 'surface-variant': '#E7E0EB',
38
+ 'on-surface-variant': '#49454F',
39
+ outline: '#79747E',
40
+ 'outline-variant': '#CAC4D0',
41
+ 'surface-container-lowest': '#FFFFFF',
42
+ 'surface-container-low': '#F7F2FA',
43
+ 'surface-container': '#F3EDF7',
44
+ 'surface-container-high': '#ECE6F0',
45
+ 'surface-container-highest': '#E6E0E9',
46
+ },
47
+ fontFamily: {
48
+ sans: ['Inter', 'sans-serif'],
49
+ headline: ['Inter', 'sans-serif'],
50
+ }
51
+ }
52
+ }
53
+ }
54
+ </script>
55
+ <style>
56
+ .material-symbols-outlined {
57
+ font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
58
+ }
59
+
60
+ .material-symbols-outlined.fill {
61
+ font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24;
62
+ }
63
+
64
+ .no-scrollbar::-webkit-scrollbar {
65
+ display: none;
66
+ }
67
+
68
+ .no-scrollbar {
69
+ -ms-overflow-style: none;
70
+ scrollbar-width: none;
71
+ }
72
+
73
+ .editorial-shadow {
74
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
75
+ }
76
+
77
+ .screen {
78
+ display: none;
79
+ }
80
+
81
+ .screen.active {
82
+ display: flex;
83
+ }
84
+ </style>
85
+ </head>
86
+
87
+ <body class="bg-surface text-on-surface min-h-screen font-sans">
88
+
89
+ <!-- Login Screen -->
90
+ <main id="login-screen"
91
+ class="screen active flex-col items-center justify-center px-6 py-12 relative overflow-hidden min-h-screen">
92
+ <div class="absolute -top-24 -right-24 w-96 h-96 bg-primary-container/20 rounded-full blur-3xl"></div>
93
+ <div class="absolute -bottom-24 -left-24 w-80 h-80 bg-secondary-container/30 rounded-full blur-3xl"></div>
94
+
95
+ <div class="w-full max-w-md z-10">
96
+ <div class="text-center mb-12">
97
+ <div
98
+ class="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary-container text-primary mb-6 editorial-shadow">
99
+ <span class="material-symbols-outlined text-3xl">inventory_2</span>
100
+ </div>
101
+ <h1 class="font-headline font-extrabold text-4xl tracking-tight text-primary mb-2">HomeStack</h1>
102
+ <p class="text-on-surface-variant font-medium text-lg">Digital Inventory Curator</p>
103
+ </div>
104
+
105
+ <div
106
+ class="bg-surface-container-lowest p-8 md:p-10 rounded-[2rem] editorial-shadow border border-outline-variant/10">
107
+ <div class="mb-8">
108
+ <h2 class="text-2xl font-headline font-bold text-on-surface mb-3 tracking-tight">Welcome Back</h2>
109
+ <p class="text-on-surface-variant leading-relaxed">Enter Password to access your digital inventory
110
+ </p>
111
+ </div>
112
+
113
+ <form id="login-form" class="space-y-8">
114
+ <div class="space-y-2">
115
+ <label class="block text-sm font-semibold text-on-surface ml-1">Access Key</label>
116
+ <div class="relative">
117
+ <div
118
+ class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none text-outline">
119
+ <span class="material-symbols-outlined text-xl">lock</span>
120
+ </div>
121
+ <input type="password" id="password-input"
122
+ class="block w-full min-h-[4rem] pl-12 pr-4 bg-surface-container-low border-0 rounded-xl focus:ring-2 focus:ring-primary/40 focus:bg-surface-container-lowest transition-all duration-300 text-lg placeholder:text-outline/50"
123
+ placeholder="••••••••••••" required>
124
+ </div>
125
+ <p id="login-error" class="text-error text-sm font-medium ml-1 hidden"></p>
126
+ </div>
127
+
128
+ <button type="submit" id="login-btn"
129
+ class="group w-full min-h-[4rem] bg-gradient-to-r from-primary to-primary text-on-primary font-bold text-lg rounded-xl flex items-center justify-center gap-2 hover:opacity-95 transition-all duration-300 active:scale-[0.98] editorial-shadow">
130
+ <span id="login-btn-text">Unlock Vault</span>
131
+ <span
132
+ class="material-symbols-outlined group-hover:translate-x-1 transition-transform">arrow_forward</span>
133
+ </button>
134
+ </form>
135
+ </div>
136
+
137
+ <div class="mt-10 text-center space-y-4">
138
+ <p class="text-sm text-on-surface-variant">Need help? Contact your administrator.</p>
139
+ <div class="flex items-center justify-center gap-6">
140
+ <a href="#"
141
+ class="text-xs font-semibold text-outline uppercase tracking-widest hover:text-primary transition-colors">Privacy</a>
142
+ <div class="w-1 h-1 bg-outline-variant/40 rounded-full"></div>
143
+ <a href="#"
144
+ class="text-xs font-semibold text-outline uppercase tracking-widest hover:text-primary transition-colors">Security</a>
145
+ <div class="w-1 h-1 bg-outline-variant/40 rounded-full"></div>
146
+ <a href="#"
147
+ class="text-xs font-semibold text-outline uppercase tracking-widest hover:text-primary transition-colors">Terms</a>
148
+ </div>
149
+ </div>
150
+ </div>
151
+
152
+ <footer
153
+ class="absolute bottom-0 w-full py-6 px-8 flex justify-between items-center text-[11px] font-bold text-outline/50 uppercase tracking-widest">
154
+ <div class="flex items-center gap-2">
155
+ <div class="w-2 h-2 bg-primary rounded-full"></div>
156
+ <span>System Status: Operational</span>
157
+ </div>
158
+ <span>Version 3.0.0-Python</span>
159
+ </footer>
160
+ </main>
161
+
162
+ <!-- Dashboard Screen -->
163
+ <div id="dashboard-screen" class="screen flex-col pb-32 min-h-screen">
164
+ <header
165
+ class="bg-surface/90 backdrop-blur-lg top-0 sticky z-50 flex justify-between items-center w-full px-6 py-4">
166
+ <div class="flex items-center gap-4">
167
+ <button
168
+ class="text-on-surface hover:bg-surface-container-high transition-colors p-3 rounded-full min-w-[48px] min-h-[48px] flex items-center justify-center active:scale-95">
169
+ <span class="material-symbols-outlined">menu</span>
170
+ </button>
171
+ <h1 class="text-primary font-extrabold tracking-tighter text-xl">HomeStack</h1>
172
+ </div>
173
+ <button onclick="showScreen('settings')"
174
+ class="text-on-surface hover:bg-surface-container-high transition-colors p-3 rounded-full min-w-[48px] min-h-[48px] flex items-center justify-center active:scale-95">
175
+ <span class="material-symbols-outlined">account_circle</span>
176
+ </button>
177
+ </header>
178
+
179
+ <main class="px-6 pt-4 max-w-2xl mx-auto space-y-8 w-full">
180
+ <section class="space-y-4">
181
+ <div>
182
+ <h2 class="text-3xl font-extrabold tracking-tight text-on-surface">Good Morning.</h2>
183
+ <p class="text-on-surface-variant font-medium mt-1">You have <span class="text-primary font-bold"
184
+ id="item-count">0 items</span> in inventory.</p>
185
+ </div>
186
+ <div class="relative group z-50">
187
+ <div class="absolute inset-y-0 left-4 flex items-center pointer-events-none text-outline">
188
+ <span class="material-symbols-outlined">search</span>
189
+ </div>
190
+ <input type="text" id="search-input"
191
+ class="w-full bg-surface-container-lowest border-2 border-surface-container-high focus:border-primary focus:ring-0 rounded-2xl py-5 pl-12 pr-4 text-lg font-medium placeholder:text-outline-variant transition-all shadow-sm group-focus-within:shadow-md"
192
+ placeholder="Search across items..." oninput="handleSearch(this.value)"
193
+ onfocus="document.getElementById('search-dropdown').classList.remove('hidden')"
194
+ onblur="setTimeout(() => document.getElementById('search-dropdown').classList.add('hidden'), 200)">
195
+ <div class="absolute inset-y-0 right-4 flex items-center">
196
+ <button
197
+ class="bg-surface-container-high p-2 rounded-lg text-on-surface-variant hover:text-primary transition-colors z-10">
198
+ <span class="material-symbols-outlined text-[20px]">tune</span>
199
+ </button>
200
+ </div>
201
+ <!-- Autocomplete Dropdown -->
202
+ <div id="search-dropdown"
203
+ class="absolute top-full left-0 right-0 mt-2 bg-surface-container-lowest rounded-2xl shadow-2xl border border-outline-variant/10 max-h-80 overflow-y-auto hidden editorial-shadow">
204
+ </div>
205
+ </div>
206
+ </section>
207
+
208
+ <section class="flex gap-4">
209
+ <button onclick="openQuickAdd()"
210
+ class="flex-grow bg-primary text-on-primary py-6 rounded-2xl font-bold text-lg flex items-center justify-center gap-3 shadow-lg shadow-primary/20 active:scale-[0.98] transition-all min-h-[72px]">
211
+ <span class="material-symbols-outlined fill text-[28px]">add_circle</span>
212
+ <span>Quick Add</span>
213
+ </button>
214
+ <button id="mic-btn"
215
+ class="w-20 bg-secondary-container text-on-secondary-container rounded-2xl flex items-center justify-center shadow-lg active:scale-[0.98] transition-all relative overflow-hidden">
216
+ <span
217
+ class="material-symbols-outlined text-[32px] z-10 transition-transform duration-300 pointer-events-none"
218
+ id="mic-icon">mic</span>
219
+ <div id="mic-pulse"
220
+ class="absolute inset-0 bg-primary/30 scale-0 rounded-2xl transition-transform duration-300 origin-center pointer-events-none">
221
+ </div>
222
+ </button>
223
+ </section>
224
+
225
+ <section class="space-y-4">
226
+ <div class="flex justify-between items-center">
227
+ <h3 class="text-xl font-bold">Recently Added</h3>
228
+ <button onclick="showScreen('inventory')"
229
+ class="text-primary font-bold text-sm min-h-[44px] px-2 flex items-center">View History</button>
230
+ </div>
231
+ <div id="recent-items" class="flex gap-4 overflow-x-auto no-scrollbar pb-2 -mx-6 px-6">
232
+ <!-- Recent items injected here -->
233
+ </div>
234
+ </section>
235
+
236
+ <section class="space-y-6">
237
+ <div class="flex justify-between items-end">
238
+ <h3 class="text-xl font-bold">Locations</h3>
239
+ <button onclick="openAddLocationModal()"
240
+ class="text-primary font-bold text-sm min-h-[44px] flex items-center px-3 hover:bg-primary-container/10 rounded-xl transition-colors active:scale-95">Edit
241
+ Map</button>
242
+ </div>
243
+ <div id="locations-grid" class="grid grid-cols-2 gap-4">
244
+ <!-- Dynamic locations injected here -->
245
+ </div>
246
+ </section>
247
+ </main>
248
+ </div>
249
+
250
+ <!-- Inventory Screen -->
251
+ <div id="inventory-screen" class="screen flex-col pb-32 min-h-screen">
252
+ <header
253
+ class="bg-surface/80 backdrop-blur-lg top-0 sticky z-50 flex justify-between items-center w-full px-6 py-4">
254
+ <div class="flex items-center gap-4">
255
+ <button onclick="showScreen('dashboard')"
256
+ class="material-symbols-outlined text-primary hover:bg-primary-container/30 transition-colors p-2 rounded-full active:scale-95">arrow_back</button>
257
+ <h1 class="text-primary font-headline font-bold text-lg tracking-tight">Kitchen</h1>
258
+ </div>
259
+ <div class="bg-primary-container text-on-primary-container px-3 py-1 rounded-full text-xs font-bold"
260
+ id="inv-count-badge">0 Items</div>
261
+ </header>
262
+
263
+ <main class="max-w-md mx-auto px-6 w-full">
264
+ <section class="mt-4 mb-6">
265
+ <h2 class="text-3xl font-extrabold text-on-surface tracking-tighter">Inventory List</h2>
266
+ <p class="text-on-surface-variant mt-1 text-sm">Efficiently manage your items.</p>
267
+ </section>
268
+
269
+ <div id="inventory-list" class="space-y-8">
270
+ <!-- Inventory categories and items injected here -->
271
+ </div>
272
+ </main>
273
+ </div>
274
+
275
+ <!-- AI Assistant Screen -->
276
+ <div id="ai-screen" class="screen flex-col h-screen pb-32">
277
+ <header class="bg-surface/90 backdrop-blur-lg top-0 sticky z-50 flex items-center w-full px-6 py-4 gap-4">
278
+ <button onclick="showScreen('dashboard')" class="text-primary p-2 rounded-full active:scale-95">
279
+ <span class="material-symbols-outlined">arrow_back</span>
280
+ </button>
281
+ <h1 class="text-primary font-extrabold tracking-tighter text-xl">AI Curator</h1>
282
+ </header>
283
+
284
+ <div id="chat-messages" class="flex-grow overflow-y-auto px-6 py-6 space-y-6 no-scrollbar">
285
+ <div class="text-center py-20 space-y-6" id="chat-empty">
286
+ <div
287
+ class="inline-flex items-center justify-center w-20 h-20 rounded-3xl bg-primary-container text-primary editorial-shadow">
288
+ <span class="material-symbols-outlined text-4xl">auto_awesome</span>
289
+ </div>
290
+ <div class="space-y-2">
291
+ <h3 class="text-2xl font-headline font-bold">How can I help?</h3>
292
+ <p class="text-on-surface-variant max-w-[240px] mx-auto">Ask me about your inventory, stock levels,
293
+ or storage optimization.</p>
294
+ </div>
295
+ </div>
296
+ </div>
297
+
298
+ <div class="p-6 bg-surface/80 backdrop-blur-xl border-t border-outline-variant/10">
299
+ <form id="chat-form" class="flex gap-3">
300
+ <input type="text" id="chat-input" placeholder="Ask HomeStack..."
301
+ class="flex-grow min-h-[3.5rem] px-6 bg-surface-container border-none rounded-2xl focus:ring-2 focus:ring-primary/40 text-on-surface placeholder:text-outline transition-all">
302
+ <button type="submit"
303
+ class="w-14 h-14 bg-primary text-on-primary rounded-2xl flex items-center justify-center shadow-lg shadow-primary/20 active:scale-90 transition-all">
304
+ <span class="material-symbols-outlined text-xl">send</span>
305
+ </button>
306
+ </form>
307
+ </div>
308
+ </div>
309
+
310
+ <!-- Settings Screen -->
311
+ <div id="settings-screen" class="screen flex-col pb-32 min-h-screen">
312
+ <header class="bg-surface/90 backdrop-blur-lg top-0 sticky z-50 flex items-center w-full px-6 py-4 gap-4">
313
+ <button onclick="showScreen('dashboard')" class="text-primary p-2 rounded-full active:scale-95">
314
+ <span class="material-symbols-outlined">arrow_back</span>
315
+ </button>
316
+ <h1 class="text-primary font-extrabold tracking-tighter text-xl">Settings</h1>
317
+ </header>
318
+
319
+ <main class="px-6 py-8 space-y-10 max-w-2xl mx-auto w-full">
320
+ <div class="space-y-4">
321
+ <h3 class="text-xs font-bold text-outline uppercase tracking-[0.2em] px-2">Vault Configuration</h3>
322
+ <div
323
+ class="bg-surface-container-lowest rounded-3xl overflow-hidden editorial-shadow border border-outline-variant/10">
324
+ <button onclick="openProfileModal()"
325
+ class="w-full flex items-center gap-4 p-6 hover:bg-surface-container-low transition-colors text-on-surface">
326
+ <span class="material-symbols-outlined text-primary">person</span>
327
+ <span class="flex-grow text-left font-bold">Profile Details</span>
328
+ <span class="material-symbols-outlined text-outline">chevron_right</span>
329
+ </button>
330
+ </div>
331
+ </div>
332
+
333
+ <button onclick="logout()"
334
+ class="w-full min-h-[4.5rem] bg-error/10 text-error font-bold rounded-2xl flex items-center justify-center gap-3 active:scale-95 transition-all border border-error/20">
335
+ <span class="material-symbols-outlined">logout</span>
336
+ <span>Lock Vault Session</span>
337
+ </button>
338
+
339
+ <div class="text-center pt-4">
340
+ <p class="text-[10px] font-bold text-outline uppercase tracking-widest">HomeStack v3.0.0-Python</p>
341
+ <p class="text-[10px] text-outline/60 mt-1">© 2026 Digital Inventory Curator</p>
342
+ </div>
343
+ </main>
344
+ </div>
345
+
346
+ <!-- Navigation Bar -->
347
+ <nav id="bottom-nav"
348
+ class="fixed bottom-0 left-0 w-full z-50 flex justify-around items-center px-4 pb-8 pt-4 bg-white/95 backdrop-blur-xl shadow-[0_-8px_24px_rgba(45,51,53,0.1)] rounded-t-[2.5rem] hidden">
349
+ <button onclick="showScreen('dashboard')"
350
+ class="nav-btn flex flex-col items-center justify-center rounded-2xl px-8 py-3 transition-all active:scale-90 text-outline"
351
+ data-screen="dashboard">
352
+ <span class="material-symbols-outlined text-[28px]">home</span>
353
+ <span class="font-headline font-bold text-[12px] mt-1">Inventory</span>
354
+ </button>
355
+ <button onclick="showScreen('ai')"
356
+ class="nav-btn flex flex-col items-center justify-center rounded-2xl px-8 py-3 transition-all active:scale-90 text-outline"
357
+ data-screen="ai">
358
+ <span class="material-symbols-outlined text-[28px]">auto_awesome</span>
359
+ <span class="font-headline font-bold text-[12px] mt-1">AI Assistant</span>
360
+ </button>
361
+ <button onclick="showScreen('settings')"
362
+ class="nav-btn flex flex-col items-center justify-center rounded-2xl px-8 py-3 transition-all active:scale-90 text-outline"
363
+ data-screen="settings">
364
+ <span class="material-symbols-outlined text-[28px]">settings</span>
365
+ <span class="font-headline font-bold text-[12px] mt-1">Settings</span>
366
+ </button>
367
+ </nav>
368
+
369
+ <!-- Quick Add Modal -->
370
+ <div id="quick-add-modal"
371
+ class="fixed inset-0 z-[100] bg-black/60 backdrop-blur-sm hidden flex-col justify-end transition-all duration-300">
372
+ <div class="bg-surface rounded-t-[3rem] p-8 space-y-8 animate-slide-up max-h-[90vh] overflow-y-auto">
373
+ <div class="flex justify-between items-center">
374
+ <h3 class="text-2xl font-extrabold text-primary">Quick Add Item</h3>
375
+ <button onclick="closeQuickAdd()" class="bg-surface-container-high p-2 rounded-full">
376
+ <span class="material-symbols-outlined">close</span>
377
+ </button>
378
+ </div>
379
+ <form id="quick-add-form" class="space-y-6">
380
+ <div class="space-y-2">
381
+ <label class="text-sm font-bold ml-1">Item Name</label>
382
+ <input type="text" id="add-name" required
383
+ class="w-full bg-surface-container p-4 rounded-xl border-none focus:ring-2 focus:ring-primary/40"
384
+ placeholder="e.g. Milk">
385
+ </div>
386
+ <div class="grid grid-cols-2 gap-4">
387
+ <div class="space-y-2">
388
+ <label class="text-sm font-bold ml-1">Quantity</label>
389
+ <input type="number" id="add-qty" required
390
+ class="w-full bg-surface-container p-4 rounded-xl border-none focus:ring-2 focus:ring-primary/40"
391
+ placeholder="1">
392
+ </div>
393
+ <div class="space-y-2">
394
+ <label class="text-sm font-bold ml-1">Unit</label>
395
+ <input type="text" id="add-unit"
396
+ class="w-full bg-surface-container p-4 rounded-xl border-none focus:ring-2 focus:ring-primary/40"
397
+ placeholder="Pcs">
398
+ </div>
399
+ </div>
400
+ <div class="space-y-2">
401
+ <label class="text-sm font-bold ml-1">Category</label>
402
+ <input type="text" id="add-category"
403
+ class="w-full bg-surface-container p-4 rounded-xl border-none focus:ring-2 focus:ring-primary/40"
404
+ placeholder="Grocery">
405
+ </div>
406
+ <div class="space-y-2">
407
+ <label class="text-sm font-bold ml-1">Location / Shelf</label>
408
+ <input type="text" id="add-shelf"
409
+ class="w-full bg-surface-container p-4 rounded-xl border-none focus:ring-2 focus:ring-primary/40"
410
+ placeholder="Fridge Top">
411
+ </div>
412
+ <button type="submit"
413
+ class="w-full bg-primary text-on-primary py-5 rounded-2xl font-extrabold text-lg shadow-lg active:scale-95 transition-all">
414
+ Add to Inventory
415
+ </button>
416
+ </form>
417
+ </div>
418
+ </div>
419
+
420
+ <!-- Add Location Modal -->
421
+ <div id="location-modal"
422
+ class="fixed inset-0 z-[100] bg-black/60 backdrop-blur-sm hidden flex-col justify-end transition-all duration-300">
423
+ <div class="bg-surface rounded-t-[3rem] p-8 space-y-8 animate-slide-up max-h-[90vh] overflow-y-auto">
424
+ <div class="flex justify-between items-center">
425
+ <h3 class="text-2xl font-extrabold text-primary tracking-tight">Map a Storage Place</h3>
426
+ <button onclick="closeAddLocationModal()" class="bg-surface-container-high p-2 rounded-full">
427
+ <span class="material-symbols-outlined">close</span>
428
+ </button>
429
+ </div>
430
+ <form id="location-form" class="space-y-6">
431
+ <div class="space-y-2">
432
+ <label class="text-sm font-bold ml-1">Place Name</label>
433
+ <input type="text" id="loc-name" required
434
+ class="w-full bg-surface-container p-4 rounded-xl border-none focus:ring-2 focus:ring-primary/40"
435
+ placeholder="e.g. Master Bedroom Closet">
436
+ </div>
437
+ <div class="space-y-2">
438
+ <label class="text-sm font-bold ml-1">Image URL (Optional)</label>
439
+ <input type="url" id="loc-image"
440
+ class="w-full bg-surface-container p-4 rounded-xl border-none focus:ring-2 focus:ring-primary/40"
441
+ placeholder="https://image-link.com">
442
+ </div>
443
+ <button type="submit"
444
+ class="w-full bg-primary text-on-primary py-5 rounded-2xl font-extrabold text-lg shadow-lg active:scale-95 transition-all">
445
+ Establish Connection
446
+ </button>
447
+ </form>
448
+ </div>
449
+ </div>
450
+
451
+ <!-- Profile Modal -->
452
+ <div id="profile-modal"
453
+ class="fixed inset-0 z-[100] bg-black/60 backdrop-blur-sm hidden flex-col items-center justify-center p-6 transition-all duration-300">
454
+ <div class="bg-surface w-full max-w-sm rounded-[2.5rem] p-8 space-y-6 text-center animate-scale-up">
455
+ <div
456
+ class="w-24 h-24 bg-primary-container text-primary rounded-full mx-auto flex items-center justify-center">
457
+ <span class="material-symbols-outlined text-5xl">person_pin</span>
458
+ </div>
459
+ <div class="space-y-1">
460
+ <h3 class="text-2xl font-extrabold tracking-tight">Paarth's HomeStack</h3>
461
+ <p class="text-on-surface-variant font-medium">Vault Admin</p>
462
+ </div>
463
+ <div class="grid grid-cols-2 gap-3 py-4">
464
+ <div class="bg-surface-container-low p-4 rounded-2xl">
465
+ <span class="block text-primary font-extrabold text-xl" id="profile-items">0</span>
466
+ <span class="text-[10px] font-bold text-outline uppercase">Total Items</span>
467
+ </div>
468
+ <div class="bg-surface-container-low p-4 rounded-2xl">
469
+ <span class="block text-primary font-extrabold text-xl" id="profile-locs">0</span>
470
+ <span class="text-[10px] font-bold text-outline uppercase">Locations</span>
471
+ </div>
472
+ </div>
473
+ <button onclick="closeProfileModal()"
474
+ class="w-full bg-surface-container-high py-4 rounded-2xl font-bold active:scale-95 transition-all">
475
+ Close Profile
476
+ </button>
477
+ </div>
478
+ </div>
479
+
480
+ <style>
481
+ @keyframes slide-up {
482
+ from {
483
+ transform: translateY(100%);
484
+ }
485
+
486
+ to {
487
+ transform: translateY(0);
488
+ }
489
+ }
490
+
491
+ @keyframes scale-up {
492
+ from {
493
+ transform: scale(0.9);
494
+ opacity: 0;
495
+ }
496
+
497
+ to {
498
+ transform: scale(1);
499
+ opacity: 1;
500
+ }
501
+ }
502
+
503
+ .animate-slide-up {
504
+ animation: slide-up 0.4s cubic-bezier(0.16, 1, 0.3, 1);
505
+ }
506
+
507
+ .animate-scale-up {
508
+ animation: scale-up 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
509
+ }
510
+ </style>
511
+
512
+ <script>
513
+ let inventory = [];
514
+ let chatMessages = [];
515
+ let locationsData = [];
516
+ let containersData = [];
517
+ let sessionToken = null;
518
+
519
+ function getHeaders() {
520
+ const headers = { 'Content-Type': 'application/json' };
521
+ if (sessionToken) {
522
+ headers['Authorization'] = `Bearer ${sessionToken}`;
523
+ }
524
+ return headers;
525
+ }
526
+
527
+ function showScreen(screenId) {
528
+ document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
529
+ document.getElementById(screenId + '-screen').classList.add('active');
530
+
531
+ const nav = document.getElementById('bottom-nav');
532
+ if (screenId === 'login') {
533
+ nav.classList.add('hidden');
534
+ } else {
535
+ nav.classList.remove('hidden');
536
+ updateNav(screenId);
537
+ if (inventory.length === 0) {
538
+ fetchInventory();
539
+ fetchLocations();
540
+ fetchContainers();
541
+ }
542
+ }
543
+ }
544
+
545
+ function updateNav(screenId) {
546
+ document.querySelectorAll('.nav-btn').forEach(btn => {
547
+ if (btn.dataset.screen === screenId) {
548
+ btn.classList.add('bg-primary-container', 'text-on-primary-container');
549
+ btn.classList.remove('text-outline');
550
+ btn.querySelector('.material-symbols-outlined').classList.add('fill');
551
+ } else {
552
+ btn.classList.remove('bg-primary-container', 'text-on-primary-container');
553
+ btn.classList.add('text-outline');
554
+ btn.querySelector('.material-symbols-outlined').classList.remove('fill');
555
+ }
556
+ });
557
+ }
558
+
559
+ async function fetchInventory() {
560
+ try {
561
+ const res = await fetch('/api/inventory', {
562
+ headers: getHeaders()
563
+ });
564
+ inventory = await res.json();
565
+ renderDashboard();
566
+ renderInventory();
567
+ } catch (err) {
568
+ console.error("Failed to fetch inventory", err);
569
+ }
570
+ }
571
+
572
+ function renderDashboard() {
573
+ document.getElementById('item-count').innerText = `${inventory.length} items`;
574
+ const recentContainer = document.getElementById('recent-items');
575
+
576
+ // Show last 8 items regardless of image
577
+ recentContainer.innerHTML = inventory.slice(0, 8).map(item => `
578
+ <div class="flex-none w-40 space-y-2">
579
+ <div class="h-32 w-40 rounded-2xl overflow-hidden bg-surface-container flex items-center justify-center">
580
+ ${item.image ?
581
+ `<img src="${item.image}" alt="${item.name}" class="w-full h-full object-cover" referrerPolicy="no-referrer">` :
582
+ `<span class="material-symbols-outlined text-outline/30 text-5xl">inventory_2</span>`
583
+ }
584
+ </div>
585
+ <div>
586
+ <p class="font-bold text-sm truncate">${item.name}</p>
587
+ <p class="text-[10px] uppercase font-bold text-outline">${item.category}</p>
588
+ </div>
589
+ </div>
590
+ `).join('');
591
+ }
592
+
593
+ async function fetchLocations() {
594
+ try {
595
+ const res = await fetch('/api/locations', {
596
+ headers: getHeaders()
597
+ });
598
+ locationsData = await res.json();
599
+ renderLocations();
600
+ } catch (err) {
601
+ console.error("Failed to fetch locations", err);
602
+ }
603
+ }
604
+
605
+ async function fetchContainers() {
606
+ try {
607
+ const res = await fetch('/api/containers', {
608
+ headers: getHeaders()
609
+ });
610
+ const data = await res.json();
611
+ containersData = Array.isArray(data) ? data : [];
612
+ } catch (err) {
613
+ console.error("Failed to fetch containers", err);
614
+ }
615
+ }
616
+
617
+ function renderLocations() {
618
+ const container = document.getElementById('locations-grid');
619
+ if (!container) return;
620
+
621
+ if (!locationsData || locationsData.length === 0) {
622
+ container.innerHTML = `<p class="text-sm text-on-surface-variant col-span-2">No locations created yet.</p>`;
623
+ return;
624
+ }
625
+
626
+ container.innerHTML = locationsData.map(loc => {
627
+ const locItems = inventory.filter(i => i.shelf === loc.name || i.location_id === loc.id);
628
+ const locContainers = containersData.filter(c => c.location_id === loc.id);
629
+ return `
630
+ <div onclick="showLocationInventory('${loc.name}', '${loc.id}')" class="col-span-2 relative h-48 rounded-2xl overflow-hidden group cursor-pointer">
631
+ ${loc.image_url ? `<img alt="${loc.name}" class="absolute inset-0 w-full h-full object-cover transition-transform duration-500 group-hover:scale-110" src="${loc.image_url}" referrerPolicy="no-referrer">` : `<div class="absolute inset-0 w-full h-full bg-primary/10 transition-transform duration-500 group-hover:scale-110 flex items-center justify-center"><span class="material-symbols-outlined text-primary text-5xl">home_storage</span></div>`}
632
+ <div class="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent"></div>
633
+ <div class="absolute bottom-4 left-5 text-white">
634
+ <h4 class="text-xl font-bold">${loc.name}</h4>
635
+ <p class="text-sm opacity-90">${locItems.length} items • ${locContainers.length} boxes</p>
636
+ </div>
637
+ </div>
638
+ `;
639
+ }).join('');
640
+ }
641
+
642
+ function renderInventory(filterQuery = "") {
643
+ document.getElementById('inv-count-badge').innerText = `${inventory.length} Items`;
644
+ const listContainer = document.getElementById('inventory-list');
645
+ if (!listContainer) return;
646
+
647
+ let filteredInventory = inventory;
648
+
649
+ // 1. Handle Search
650
+ if (filterQuery.trim()) {
651
+ const fuse = new Fuse(inventory, {
652
+ keys: [
653
+ { name: 'name', weight: 0.7 },
654
+ { name: 'category', weight: 0.2 },
655
+ { name: 'shelf', weight: 0.1 }
656
+ ],
657
+ threshold: 0.35
658
+ });
659
+ filteredInventory = fuse.search(filterQuery).map(r => r.item);
660
+ }
661
+
662
+ if (filteredInventory.length === 0) {
663
+ listContainer.innerHTML = `
664
+ <div class="py-20 text-center space-y-4">
665
+ <span class="material-symbols-outlined text-outline/20 text-6xl">search_off</span>
666
+ <p class="text-on-surface-variant font-medium">No items match your search.</p>
667
+ </div>`;
668
+ return;
669
+ }
670
+
671
+ // 2. Group by Container (Box)
672
+ const uniqueContainerIds = [...new Set(filteredInventory.map(i => i.container_id))];
673
+
674
+ listContainer.innerHTML = uniqueContainerIds.map(contId => {
675
+ const container = containersData.find(c => c.id === contId);
676
+ const contItems = filteredInventory.filter(i => i.container_id === contId);
677
+
678
+ // 3. Within each container, sort by Category
679
+ const categories = [...new Set(contItems.map(i => i.category || "Uncategorized"))];
680
+
681
+ return `
682
+ <section class="space-y-6 mb-10 pt-2 group">
683
+ <!-- Container/Box Header -->
684
+ <div class="flex items-center gap-4 px-2 border-b border-outline-variant/30 pb-3 bg-surface sticky top-[84px] z-20">
685
+ <div class="w-12 h-12 rounded-2xl bg-primary/10 flex items-center justify-center transition-transform group-hover:scale-105">
686
+ <span class="material-symbols-outlined text-primary text-[24px]">
687
+ ${contId ? 'package_2' : 'home_storage'}
688
+ </span>
689
+ </div>
690
+ <div class="flex-grow">
691
+ <h3 class="text-lg font-black text-on-surface uppercase tracking-tight leading-none mb-1">
692
+ ${contId ? (container ? container.name : 'Unknown Box') : 'General Storage'}
693
+ </h3>
694
+ <p class="text-[10px] font-black text-outline uppercase tracking-widest flex items-center gap-2">
695
+ <span>${contItems.length} ITEMS</span>
696
+ ${container ? `<span class="w-1 h-1 rounded-full bg-outline/30"></span> <span>${locationsData.find(l => l.id === container.location_id)?.name || 'Main'}</span>` : ''}
697
+ </p>
698
+ </div>
699
+ </div>
700
+
701
+ <div class="space-y-8 pl-4 border-l-2 border-primary/5 ml-6">
702
+ ${categories.map(cat => {
703
+ const catItems = contItems.filter(i => (i.category || "Uncategorized") === cat);
704
+ return `
705
+ <div class="space-y-4">
706
+ <div class="flex items-center gap-2 pl-2">
707
+ <span class="text-[10px] font-black text-primary/60 uppercase tracking-[0.2em]">${cat}</span>
708
+ <div class="h-[1px] flex-grow bg-gradient-to-r from-primary/10 to-transparent"></div>
709
+ </div>
710
+ <div class="grid gap-3">
711
+ ${catItems.map(item => `
712
+ <div class="p-4 rounded-3xl flex items-center gap-5 bg-surface-container-lowest shadow-sm active:scale-[0.98] transition-all border border-outline-variant/10 hover:border-primary/20 hover:shadow-md">
713
+ <div class="w-16 h-16 rounded-2xl overflow-hidden flex-shrink-0 bg-surface-container flex items-center justify-center">
714
+ ${item.image ? `<img src="${item.image}" alt="${item.name}" class="w-full h-full object-cover" referrerPolicy="no-referrer">` : `<span class="material-symbols-outlined text-outline/20 text-3xl font-light">inventory_2</span>`}
715
+ </div>
716
+ <div class="flex-grow min-w-0">
717
+ <h4 class="font-extrabold text-on-surface text-[15px] leading-tight mb-1 truncate">${item.name}</h4>
718
+ <div class="flex flex-wrap gap-2">
719
+ <span class="px-2 py-0.5 rounded-lg bg-surface-container text-[9px] font-black uppercase text-on-surface-variant/70 tracking-wider">
720
+ ${item.shelf || 'Main'}
721
+ </span>
722
+ </div>
723
+ </div>
724
+ <div class="flex items-center gap-4">
725
+ <div class="text-right flex-shrink-0">
726
+ <span class="block text-2xl font-black text-primary tabular-nums leading-none">
727
+ ${item.quantity}
728
+ </span>
729
+ <span class="text-[9px] uppercase font-black text-outline tracking-wider">
730
+ ${item.unit || 'UNIT'}
731
+ </span>
732
+ </div>
733
+ <button onclick="deleteItem('${item.id}', event)" class="w-10 h-10 rounded-xl flex items-center justify-center text-outline/30 hover:text-error hover:bg-error/10 transition-all">
734
+ <span class="material-symbols-outlined text-sm">delete</span>
735
+ </button>
736
+ </div>
737
+ </div>
738
+ `).join('')}
739
+ </div>
740
+ </div>
741
+ `;
742
+ }).join('')}
743
+ </div>
744
+ </section>
745
+ `;
746
+ }).join('');
747
+ }
748
+
749
+ // Auth Logic
750
+ document.getElementById('login-form').addEventListener('submit', async (e) => {
751
+ e.preventDefault();
752
+ const password = document.getElementById('password-input').value;
753
+ const btn = document.getElementById('login-btn');
754
+ const btnText = document.getElementById('login-btn-text');
755
+ const errorEl = document.getElementById('login-error');
756
+
757
+ btn.disabled = true;
758
+ btnText.innerText = 'Unlocking...';
759
+ errorEl.classList.add('hidden');
760
+
761
+ try {
762
+ const res = await fetch('/api/auth/unlock', {
763
+ method: 'POST',
764
+ headers: { 'Content-Type': 'application/json' },
765
+ body: JSON.stringify({ password })
766
+ });
767
+ const data = await res.json();
768
+ if (data.success) {
769
+ sessionToken = data.token;
770
+ showScreen('dashboard');
771
+ } else {
772
+ errorEl.innerText = data.detail || "Invalid Access Key";
773
+ errorEl.classList.remove('hidden');
774
+ }
775
+ } catch (err) {
776
+ errorEl.innerText = "Connection error";
777
+ errorEl.classList.remove('hidden');
778
+ } finally {
779
+ btn.disabled = false;
780
+ btnText.innerText = 'Unlock Vault';
781
+ }
782
+ });
783
+
784
+ // Chat Logic
785
+ document.getElementById('chat-form').addEventListener('submit', async (e) => {
786
+ e.preventDefault();
787
+ const input = document.getElementById('chat-input');
788
+ const content = input.value.trim();
789
+ if (!content) return;
790
+
791
+ addMessage('user', content);
792
+ input.value = '';
793
+
794
+ const typingId = addTypingIndicator();
795
+
796
+ try {
797
+ const res = await fetch('/api/ai/chat', {
798
+ method: 'POST',
799
+ headers: getHeaders(),
800
+ body: JSON.stringify({ messages: chatMessages })
801
+ });
802
+ const data = await res.json();
803
+ console.log("AI Assistant Response:", data);
804
+ removeTypingIndicator(typingId);
805
+ if (data.choices && data.choices[0]) {
806
+ const content = data.choices[0].message.content;
807
+ if (!content) {
808
+ addMessage('assistant', 'I am processing your complex request. Please give me a moment or check the system logs.');
809
+ } else {
810
+ try {
811
+ const parsed = JSON.parse(content);
812
+ addMessage('assistant', parsed.reply || content);
813
+ } catch (e) {
814
+ addMessage('assistant', content);
815
+ }
816
+ }
817
+ }
818
+ } catch (err) {
819
+ removeTypingIndicator(typingId);
820
+ addMessage('assistant', "Sorry, I encountered an error.");
821
+ }
822
+ });
823
+
824
+ function addMessage(role, content) {
825
+ chatMessages.push({ role, content });
826
+ const container = document.getElementById('chat-messages');
827
+ const empty = document.getElementById('chat-empty');
828
+ if (empty) empty.remove();
829
+
830
+ const msgDiv = document.createElement('div');
831
+ msgDiv.className = `flex flex-col ${role === 'user' ? 'items-end' : 'items-start'}`;
832
+
833
+ // Use marked for assistant messages
834
+ const renderedContent = role === 'assistant' ? marked.parse(content) : `<p>${content}</p>`;
835
+
836
+ msgDiv.innerHTML = `
837
+ <div class="max-w-[85%] p-5 rounded-2xl shadow-sm ${role === 'user' ? 'bg-primary text-on-primary rounded-tr-none' : 'bg-surface-container-low text-on-surface rounded-tl-none border border-outline-variant/10'}">
838
+ <div class="text-[15px] leading-relaxed prose prose-sm ${role === 'user' ? 'prose-invert' : ''}">${renderedContent}</div>
839
+ </div>
840
+ `;
841
+ container.appendChild(msgDiv);
842
+ container.scrollTop = container.scrollHeight;
843
+ }
844
+
845
+ function addTypingIndicator() {
846
+ const container = document.getElementById('chat-messages');
847
+ const id = 'typing-' + Date.now();
848
+ const div = document.createElement('div');
849
+ div.id = id;
850
+ div.className = "flex items-start";
851
+ div.innerHTML = `
852
+ <div class="bg-surface-container-low p-5 rounded-2xl rounded-tl-none border border-outline-variant/10">
853
+ <div class="flex gap-1.5">
854
+ <div class="w-2 h-2 bg-primary/40 rounded-full animate-bounce"></div>
855
+ <div class="w-2 h-2 bg-primary/40 rounded-full animate-bounce [animation-delay:0.2s]"></div>
856
+ <div class="w-2 h-2 bg-primary/40 rounded-full animate-bounce [animation-delay:0.4s]"></div>
857
+ </div>
858
+ </div>
859
+ `;
860
+ container.appendChild(div);
861
+ container.scrollTop = container.scrollHeight;
862
+ return id;
863
+ }
864
+
865
+ function showLocationInventory(shelfName) {
866
+ showScreen('inventory');
867
+ renderInventory(shelfName);
868
+ }
869
+
870
+ function removeTypingIndicator(id) {
871
+ const el = document.getElementById(id);
872
+ if (el) el.remove();
873
+ }
874
+
875
+ function logout() {
876
+ showScreen('login');
877
+ document.getElementById('password-input').value = '';
878
+ chatMessages = [];
879
+ document.getElementById('chat-messages').innerHTML = `
880
+ <div class="text-center py-20 space-y-6" id="chat-empty">
881
+ <div class="inline-flex items-center justify-center w-20 h-20 rounded-3xl bg-primary-container text-primary editorial-shadow">
882
+ <span class="material-symbols-outlined text-4xl">auto_awesome</span>
883
+ </div>
884
+ <div class="space-y-2">
885
+ <h3 class="text-2xl font-headline font-bold">How can I help?</h3>
886
+ <p class="text-on-surface-variant max-w-[240px] mx-auto">Ask me about your inventory, stock levels, or storage optimization.</p>
887
+ </div>
888
+ </div>
889
+ `;
890
+ }
891
+
892
+ function openQuickAdd() {
893
+ document.getElementById('quick-add-modal').classList.remove('hidden');
894
+ document.getElementById('quick-add-modal').classList.add('flex');
895
+ }
896
+
897
+ function closeQuickAdd() {
898
+ document.getElementById('quick-add-modal').classList.add('hidden');
899
+ document.getElementById('quick-add-modal').classList.remove('flex');
900
+ }
901
+
902
+ async function deleteItem(id, event) {
903
+ event.stopPropagation();
904
+ if (!confirm("Delete this item?")) return;
905
+ try {
906
+ const res = await fetch(`/api/inventory/${id}`, {
907
+ method: 'DELETE',
908
+ headers: getHeaders()
909
+ });
910
+ if (res.ok) {
911
+ inventory = inventory.filter(i => i.id !== id);
912
+ renderDashboard();
913
+ renderInventory();
914
+ }
915
+ } catch (err) {
916
+ console.error("Delete failed", err);
917
+ }
918
+ }
919
+
920
+ function handleSearch(val) {
921
+ const dropdown = document.getElementById('search-dropdown');
922
+ if (val.trim() === '') {
923
+ dropdown.innerHTML = '';
924
+ dropdown.classList.add('hidden');
925
+ return;
926
+ }
927
+
928
+ dropdown.classList.remove('hidden');
929
+
930
+ const fuse = new Fuse(inventory, {
931
+ keys: [
932
+ { name: 'name', weight: 0.7 },
933
+ { name: 'category', weight: 0.2 },
934
+ { name: 'shelf', weight: 0.1 }
935
+ ],
936
+ threshold: 0.35
937
+ });
938
+ const results = fuse.search(val).map(r => r.item).slice(0, 10);
939
+
940
+ if (results.length === 0) {
941
+ dropdown.innerHTML = `
942
+ <div class="p-6 text-center text-on-surface-variant">
943
+ <span class="material-symbols-outlined block text-3xl opacity-50 mb-2">search_off</span>
944
+ <p class="text-sm font-medium">No items found</p>
945
+ </div>
946
+ `;
947
+ return;
948
+ }
949
+
950
+ dropdown.innerHTML = results.map(item => {
951
+ const container = containersData.find(c => c.id === item.container_id);
952
+ const loc = locationsData.find(l => l.id === (container ? container.location_id : item.location_id));
953
+ const locName = loc ? loc.name : (item.shelf || 'Main');
954
+ const contName = container ? container.name : 'Unboxed';
955
+
956
+ return `
957
+ <div onclick="showLocationInventory('${locName}')" class="p-4 hover:bg-surface-container-low cursor-pointer border-b border-outline-variant/5 last:border-0 transition-colors flex items-center gap-4">
958
+ <div class="w-12 h-12 rounded-xl bg-surface-container overflow-hidden flex items-center justify-center flex-shrink-0">
959
+ ${item.image ? `<img src="${item.image}" class="w-full h-full object-cover">` : `<span class="material-symbols-outlined text-outline/40">inventory_2</span>`}
960
+ </div>
961
+ <div class="flex-grow min-w-0">
962
+ <h4 class="font-bold text-on-surface truncate leading-tight mb-1">${item.name}</h4>
963
+ <div class="flex items-center gap-1.5 text-[10px] font-black uppercase text-outline tracking-widest">
964
+ <span class="truncate">${locName}</span>
965
+ <span class="text-outline-variant/60">•</span>
966
+ <span class="truncate text-primary/70">${contName}</span>
967
+ </div>
968
+ </div>
969
+ <div class="text-right flex-shrink-0 pl-2">
970
+ <span class="block font-black text-lg text-primary leading-none">${item.quantity}</span>
971
+ <span class="text-[9px] font-bold text-outline-variant uppercase tracking-widest">${item.unit || 'UNIT'}</span>
972
+ </div>
973
+ </div>
974
+ `;
975
+ }).join('');
976
+ }
977
+
978
+ document.getElementById('quick-add-form').addEventListener('submit', async (e) => {
979
+ e.preventDefault();
980
+ const item = {
981
+ name: document.getElementById('add-name').value,
982
+ quantity: parseFloat(document.getElementById('add-qty').value),
983
+ unit: document.getElementById('add-unit').value || "PCS",
984
+ category: document.getElementById('add-category').value || "General",
985
+ shelf: document.getElementById('add-shelf').value || "Main"
986
+ };
987
+
988
+ try {
989
+ const res = await fetch('/api/inventory', {
990
+ method: 'POST',
991
+ headers: getHeaders(),
992
+ body: JSON.stringify(item)
993
+ });
994
+ if (res.ok) {
995
+ await fetchInventory(); // Refresh data
996
+ closeQuickAdd();
997
+ document.getElementById('quick-add-form').reset();
998
+ }
999
+ } catch (err) {
1000
+ console.error("Add failed", err);
1001
+ }
1002
+ });
1003
+
1004
+ function openAddLocationModal() {
1005
+ document.getElementById('location-modal').classList.remove('hidden');
1006
+ document.getElementById('location-modal').classList.add('flex');
1007
+ }
1008
+
1009
+ function closeAddLocationModal() {
1010
+ document.getElementById('location-modal').classList.add('hidden');
1011
+ document.getElementById('location-modal').classList.remove('flex');
1012
+ }
1013
+
1014
+ function openProfileModal() {
1015
+ document.getElementById('profile-items').innerText = inventory.length;
1016
+ document.getElementById('profile-locs').innerText = document.querySelectorAll('#locations-grid > div [onclick]').length || 0;
1017
+ document.getElementById('profile-modal').classList.remove('hidden');
1018
+ document.getElementById('profile-modal').classList.add('flex');
1019
+ }
1020
+
1021
+ function closeProfileModal() {
1022
+ document.getElementById('profile-modal').classList.add('hidden');
1023
+ document.getElementById('profile-modal').classList.remove('flex');
1024
+ }
1025
+
1026
+ document.getElementById('location-form').addEventListener('submit', async (e) => {
1027
+ e.preventDefault();
1028
+ const location = {
1029
+ name: document.getElementById('loc-name').value,
1030
+ image_url: document.getElementById('loc-image').value || null
1031
+ };
1032
+
1033
+ try {
1034
+ const res = await fetch('/api/locations', {
1035
+ method: 'POST',
1036
+ headers: getHeaders(),
1037
+ body: JSON.stringify(location)
1038
+ });
1039
+ if (res.ok) {
1040
+ await fetchLocations(); // Refresh data
1041
+ closeAddLocationModal();
1042
+ document.getElementById('location-form').reset();
1043
+ }
1044
+ } catch (err) {
1045
+ console.error("Add location failed", err);
1046
+ }
1047
+ });
1048
+
1049
+ // Voice Add / Mic Logic
1050
+ let mediaRecorder;
1051
+ let audioChunks = [];
1052
+ let isRecording = false;
1053
+
1054
+ const micBtn = document.getElementById('mic-btn');
1055
+ const micIcon = document.getElementById('mic-icon');
1056
+ const micPulse = document.getElementById('mic-pulse');
1057
+
1058
+ micBtn.addEventListener('click', async () => {
1059
+ console.log("Mic button clicked! isRecording:", isRecording);
1060
+
1061
+ if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
1062
+ alert("Microphone is blocked or not supported! Ensure you are using HTTPS or localhost.");
1063
+ return;
1064
+ }
1065
+
1066
+ if (!isRecording) {
1067
+ // UI changes indicating we are asking for permission
1068
+ micPulse.classList.replace('scale-0', 'scale-150');
1069
+ micPulse.classList.add('animate-pulse', 'bg-outline/20');
1070
+ micIcon.innerText = 'pending';
1071
+
1072
+ // Start Recording
1073
+ try {
1074
+ // Timeout getUserMedia so it doesn't hang forever if the user misses the hidden browser prompt
1075
+ const stream = await Promise.race([
1076
+ navigator.mediaDevices.getUserMedia({ audio: true }),
1077
+ new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout_Permission")), 5000))
1078
+ ]);
1079
+
1080
+ mediaRecorder = new MediaRecorder(stream);
1081
+ audioChunks = [];
1082
+
1083
+ mediaRecorder.ondataavailable = e => {
1084
+ if (e.data.size > 0) audioChunks.push(e.data);
1085
+ };
1086
+
1087
+ mediaRecorder.onstop = async () => {
1088
+ // Create Blob
1089
+ const audioBlob = new Blob(audioChunks, { type: 'audio/webm' });
1090
+ const formData = new FormData();
1091
+
1092
+ // We give it an extension so the backend knows what to do
1093
+ formData.append('file', audioBlob, 'voice_note.webm');
1094
+
1095
+ // Visual feedback that we're transcribing
1096
+ micIcon.innerText = 'hourglass_empty';
1097
+ micIcon.classList.add('animate-spin');
1098
+ // Stop pulsing since we are transcribing
1099
+ micPulse.classList.replace('scale-150', 'scale-0');
1100
+ micPulse.classList.remove('animate-pulse', 'bg-error/20');
1101
+
1102
+ try {
1103
+ const res = await fetch('/api/audio/transcribe', {
1104
+ method: 'POST',
1105
+ headers: sessionToken ? { 'Authorization': `Bearer ${sessionToken}` } : {},
1106
+ body: formData
1107
+ });
1108
+
1109
+ if (res.ok) {
1110
+ const data = await res.json();
1111
+ if (data.text) {
1112
+ // Successfully transcribed. Swap to AI screen and submit transcript
1113
+ showScreen('ai');
1114
+ document.getElementById('chat-input').value = data.text;
1115
+
1116
+ // Trigger submit event on the chat form to process the voice command automatically
1117
+ document.getElementById('chat-form').dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
1118
+ } else {
1119
+ alert("Transcription returned no text.");
1120
+ }
1121
+ } else {
1122
+ alert("Transcription API failed with status: " + res.status);
1123
+ }
1124
+ } catch (err) {
1125
+ console.error("Transcription failed", err);
1126
+ alert("Transcription failed: Failed to reach the server.");
1127
+ } finally {
1128
+ micIcon.classList.remove('animate-spin');
1129
+ micIcon.innerText = 'mic';
1130
+ micBtn.classList.replace('bg-error/20', 'bg-secondary-container');
1131
+ micBtn.classList.replace('text-error', 'text-on-secondary-container');
1132
+ }
1133
+ };
1134
+
1135
+ mediaRecorder.start();
1136
+ isRecording = true;
1137
+
1138
+ // Full recording UI changes
1139
+ micPulse.classList.remove('bg-outline/20');
1140
+ micPulse.classList.add('bg-error/20');
1141
+ micBtn.classList.replace('bg-secondary-container', 'bg-error/20');
1142
+ micBtn.classList.replace('text-on-secondary-container', 'text-error');
1143
+ micIcon.innerText = 'stop';
1144
+ } catch (err) {
1145
+ console.error("Mic access denied or timed out", err);
1146
+ if (err.message === "Timeout_Permission") {
1147
+ alert("The microphone request timed out. Your browser is hiding the permission prompt in the address bar. Please click the lock/settings icon next to the URL and allow microphone access.");
1148
+ } else {
1149
+ alert("Please allow microphone access in your browser's address bar to use voice commands.");
1150
+ }
1151
+ // Revert UI changes
1152
+ micPulse.classList.replace('scale-150', 'scale-0');
1153
+ micPulse.classList.remove('animate-pulse', 'bg-outline/20');
1154
+ micIcon.innerText = 'mic';
1155
+ }
1156
+ } else {
1157
+ // Stop Recording
1158
+ mediaRecorder.stop();
1159
+ mediaRecorder.stream.getTracks().forEach(track => track.stop());
1160
+ isRecording = false;
1161
+
1162
+ // Revert UI changes
1163
+ micBtn.classList.replace('bg-error/20', 'bg-secondary-container');
1164
+ micBtn.classList.replace('text-error', 'text-on-secondary-container');
1165
+ micPulse.classList.replace('scale-150', 'scale-0');
1166
+ micPulse.classList.remove('animate-pulse');
1167
+ }
1168
+ });
1169
+ </script>
1170
+ </body>
1171
+
1172
+ </html>
main.py ADDED
@@ -0,0 +1,473 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import io
3
+ import json
4
+ from fastapi import FastAPI, HTTPException, Request, Header, UploadFile, File, Depends
5
+ from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
6
+ from fastapi.staticfiles import StaticFiles
7
+ from pydantic import BaseModel
8
+ from typing import List, Optional
9
+ import httpx
10
+ from groq import AsyncGroq
11
+ from dotenv import load_dotenv
12
+ from supabase import create_client, Client
13
+ import logging
14
+ from logging.handlers import RotatingFileHandler
15
+ import time
16
+ import traceback
17
+
18
+ load_dotenv()
19
+
20
+ # Logging Configuration
21
+ logging.basicConfig(level=logging.INFO)
22
+ logger = logging.getLogger("HomeStack")
23
+ log_handler = RotatingFileHandler("app.log", maxBytes=5000000, backupCount=5)
24
+ log_formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
25
+ log_handler.setFormatter(log_formatter)
26
+ logger.addHandler(log_handler)
27
+ logger.propagate = False
28
+
29
+ app = FastAPI()
30
+
31
+ # Middleware for Logging
32
+ @app.middleware("http")
33
+ async def log_requests(request: Request, call_next):
34
+ start_time = time.time()
35
+ response = await call_next(request)
36
+ duration = time.time() - start_time
37
+ logger.info(f"REQ: {request.method} {request.url.path} | STATUS: {response.status_code} | DONE IN: {duration:.4f}s")
38
+ return response
39
+
40
+ # Root Status
41
+ @app.get("/", response_class=FileResponse)
42
+ async def root():
43
+ return FileResponse("index.html")
44
+
45
+ # Configuration
46
+ INVENTORY_PASSWORD = os.getenv("INVENTORY_PASSWORD")
47
+ GROQ_API_KEY = os.getenv("GROQ_API_KEY")
48
+ SUPABASE_URL = os.getenv("SUPABASE_URL")
49
+ SUPABASE_SERVICE_ROLE_KEY = os.getenv("SUPABASE_SERVICE_ROLE_KEY")
50
+
51
+ # Supabase Client
52
+ if not SUPABASE_SERVICE_ROLE_KEY:
53
+ logger.error("SUPABASE_SERVICE_ROLE_KEY not found in environment variables.")
54
+ supabase: Optional[Client] = None
55
+ else:
56
+ supabase: Client = create_client(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY)
57
+
58
+ # Groq Client
59
+ if not GROQ_API_KEY:
60
+ logger.error("GROQ_API_KEY not found in environment variables.")
61
+ groq_client: Optional[AsyncGroq] = None
62
+ else:
63
+ groq_client = AsyncGroq(api_key=GROQ_API_KEY)
64
+
65
+ # Security Dependency
66
+ async def verify_internal_password(
67
+ x_api_password: Optional[str] = Header(None),
68
+ authorization: Optional[str] = Header(None)
69
+ ):
70
+ # Allow either internal X-API-Password or the frontend's Authorization token
71
+ if x_api_password == INVENTORY_PASSWORD:
72
+ return True
73
+ if authorization == "Bearer vault-unlocked-session-token":
74
+ return True
75
+ raise HTTPException(status_code=401, detail="Unauthorized access")
76
+
77
+ # Models
78
+ class LoginRequest(BaseModel):
79
+ password: str
80
+
81
+ class ChatMessage(BaseModel):
82
+ role: str
83
+ content: Optional[str] = ""
84
+
85
+ class ChatRequest(BaseModel):
86
+ messages: List[ChatMessage]
87
+
88
+ class InventoryItem(BaseModel):
89
+ name: str
90
+ quantity: float
91
+ unit: Optional[str] = "UNIT"
92
+ category: Optional[str] = "General"
93
+ shelf: Optional[str] = "Main"
94
+ image: Optional[str] = None
95
+ low_stock: Optional[bool] = False
96
+ container_id: Optional[str] = None
97
+ location_id: Optional[str] = None
98
+
99
+ class ContainerItem(BaseModel):
100
+ name: str
101
+ location_id: str
102
+ image_url: Optional[str] = None
103
+
104
+ class LocationItem(BaseModel):
105
+ name: str
106
+ image_url: Optional[str] = None
107
+
108
+ # Endpoints
109
+ @app.post("/api/auth/unlock")
110
+ async def unlock(request: LoginRequest):
111
+ if request.password == INVENTORY_PASSWORD:
112
+ return {"success": True, "token": "vault-unlocked-session-token"}
113
+ raise HTTPException(status_code=401, detail="Invalid Access Key")
114
+
115
+ @app.get("/api/inventory")
116
+ async def get_inventory(auth: bool = Depends(verify_internal_password)):
117
+ if not supabase:
118
+ raise HTTPException(status_code=500, detail="Supabase not initialized.")
119
+ try:
120
+ response = supabase.table("inventory").select("*").order("created_at", desc=True).execute()
121
+ return response.data
122
+ except Exception as e:
123
+ logger.exception("Fetch Inventory Error")
124
+ raise HTTPException(status_code=500, detail="Failed to fetch inventory")
125
+
126
+ @app.get("/api/locations")
127
+ async def get_locations(auth: bool = Depends(verify_internal_password)):
128
+ if not supabase:
129
+ raise HTTPException(status_code=500, detail="Supabase not initialized.")
130
+ try:
131
+ response = supabase.table("locations").select("*").execute()
132
+ return response.data
133
+ except Exception as e:
134
+ logger.exception("Fetch Locations Error")
135
+ raise HTTPException(status_code=500, detail="Failed to fetch locations")
136
+
137
+ @app.post("/api/inventory")
138
+ async def add_inventory(item: InventoryItem, auth: bool = Depends(verify_internal_password)):
139
+ if not supabase:
140
+ raise HTTPException(status_code=500, detail="Supabase not initialized.")
141
+ try:
142
+ response = supabase.table("inventory").insert(item.model_dump()).execute()
143
+ return response.data
144
+ except Exception as e:
145
+ logger.exception("Add Inventory Error")
146
+ raise HTTPException(status_code=500, detail="Failed to add item")
147
+
148
+ @app.delete("/api/inventory/{item_id}")
149
+ async def delete_inventory(item_id: str, auth: bool = Depends(verify_internal_password)):
150
+ if not supabase:
151
+ raise HTTPException(status_code=500, detail="Supabase not initialized.")
152
+ try:
153
+ supabase.table("inventory").delete().eq("id", item_id).execute()
154
+ return {"success": True}
155
+ except Exception as e:
156
+ print(f"Delete Inventory Error: {e}")
157
+ raise HTTPException(status_code=500, detail="Failed to delete item")
158
+
159
+ @app.post("/api/locations")
160
+ async def add_location(location: LocationItem, auth: bool = Depends(verify_internal_password)):
161
+ if not supabase:
162
+ raise HTTPException(status_code=500, detail="Supabase not initialized.")
163
+ try:
164
+ response = supabase.table("locations").insert(location.model_dump()).execute()
165
+ return response.data
166
+ except Exception as e:
167
+ logger.exception("Add Location Error")
168
+ raise HTTPException(status_code=500, detail="Failed to add location")
169
+
170
+ @app.get("/api/containers")
171
+ async def get_containers(auth: bool = Depends(verify_internal_password)):
172
+ if not supabase:
173
+ raise HTTPException(status_code=500, detail="Supabase not initialized.")
174
+ try:
175
+ response = supabase.table("containers").select("*").execute()
176
+ return response.data
177
+ except Exception as e:
178
+ logger.exception("Fetch Containers Error")
179
+ raise HTTPException(status_code=500, detail="Failed to fetch containers")
180
+
181
+ @app.post("/api/containers")
182
+ async def add_container(container: ContainerItem, auth: bool = Depends(verify_internal_password)):
183
+ if not supabase:
184
+ raise HTTPException(status_code=500, detail="Supabase not initialized.")
185
+ try:
186
+ response = supabase.table("containers").insert(container.model_dump()).execute()
187
+ return response.data
188
+ except Exception as e:
189
+ logger.exception("Add Container Error")
190
+ raise HTTPException(status_code=500, detail="Failed to add container")
191
+
192
+ @app.post("/api/ai/chat")
193
+ async def ai_chat(request: ChatRequest, auth: bool = Depends(verify_internal_password)):
194
+ if not groq_client:
195
+ raise HTTPException(status_code=500, detail="Groq API Key not configured")
196
+ if not supabase:
197
+ raise HTTPException(status_code=500, detail="Supabase not initialized.")
198
+
199
+ try:
200
+ messages = [
201
+ {
202
+ "role": "system",
203
+ "content": (
204
+ "You are HomeStack Assistant, a friendly and extremely concise digital inventory curator for an elderly user.\n"
205
+ "Hierarchy: Physical Location -> Container (Box/Bin) -> Item.\n"
206
+ "Capabilities: Add/Bulk-add/Retrieve items, locations, and containers.\n"
207
+ "Tool Protocol:\n"
208
+ "- PROACTIVE HELP: If the user asks for advice, how to fix something, or what to pack, ALWAYS use 'get_inventory' FIRST to see what they own.\n"
209
+ "- ONLY suggest solutions or recommend items that currently exist in their inventory. If they lack the items, ask what they want to do.\n"
210
+ "- PROACTIVE LOCATION: When you mention an item the user owns, ALWAYS proactively tell them exactly where it is located (e.g., 'You have 1 Superglue in the Tape Box in the Underside Stairs Storage').\n"
211
+ "- DO NOT give generic advice using items the user does not possess.\n"
212
+ "- DO NOT mention the names of the tools you are using. The user does not understand technical jargon.\n"
213
+ "- NEVER show raw database IDs (UUIDs) to the user. Always look up and use the actual readable names of locations and boxes.\n"
214
+ "- LANGUAGE TRANSLATION: If the user's input is transcribed in a language other than English, silently translate it to English to understand their intent, and ALWAYS conduct the conversation and respond exclusively in English.\n"
215
+ "- Tone: Very brief, simple language, no technical jargon. Use Markdown.\n"
216
+ "IMPORTANT: Always ask for confirmation before executing mutating actions (adding items/creating boxes), and explain very simply why you are asking."
217
+ )
218
+ }
219
+ ]
220
+
221
+ # Sanitize incoming history: Ensure content is not None
222
+ for m in request.messages:
223
+ msg_dict = m.model_dump()
224
+ if msg_dict.get("content") is None:
225
+ msg_dict["content"] = ""
226
+ messages.append(msg_dict)
227
+
228
+ tools = [
229
+ {
230
+ "type": "function",
231
+ "function": {
232
+ "name": "add_inventory_item",
233
+ "description": "Adds a new item to the digital inventory.",
234
+ "parameters": {
235
+ "type": "object",
236
+ "properties": {
237
+ "name": { "type": "string" },
238
+ "quantity": { "type": "number" },
239
+ "unit": { "type": "string" },
240
+ "category": { "type": "string" },
241
+ "shelf": { "type": "string" },
242
+ "container_id": { "type": "string" },
243
+ "location_id": { "type": "string" }
244
+ },
245
+ "required": ["name", "quantity"]
246
+ }
247
+ }
248
+ },
249
+ {
250
+ "type": "function",
251
+ "function": {
252
+ "name": "add_location",
253
+ "description": "Adds a new storage location.",
254
+ "parameters": {
255
+ "type": "object",
256
+ "properties": {
257
+ "name": { "type": "string" }
258
+ },
259
+ "required": ["name"]
260
+ }
261
+ }
262
+ },
263
+ {
264
+ "type": "function",
265
+ "function": {
266
+ "name": "bulk_add_items",
267
+ "description": "Adds multiple items to the inventory in a single batch operation.",
268
+ "parameters": {
269
+ "type": "object",
270
+ "properties": {
271
+ "items": {
272
+ "type": "array",
273
+ "items": {
274
+ "type": "object",
275
+ "properties": {
276
+ "name": { "type": "string" },
277
+ "quantity": { "type": "number" },
278
+ "unit": { "type": "string" },
279
+ "category": { "type": "string" },
280
+ "shelf": { "type": "string" },
281
+ "container_id": { "type": "string" },
282
+ "location_id": { "type": "string" }
283
+ },
284
+ "required": ["name", "quantity"]
285
+ }
286
+ }
287
+ },
288
+ "required": ["items"]
289
+ }
290
+ }
291
+ },
292
+ {
293
+ "type": "function",
294
+ "function": {
295
+ "name": "get_locations",
296
+ "description": "Retrieves all storage locations."
297
+ }
298
+ },
299
+ {
300
+ "type": "function",
301
+ "function": {
302
+ "name": "get_inventory",
303
+ "description": "Retrieves the full list of inventory items."
304
+ }
305
+ },
306
+ {
307
+ "type": "function",
308
+ "function": {
309
+ "name": "get_containers",
310
+ "description": "Retrieves all storage containers (boxes, bins)."
311
+ }
312
+ },
313
+ {
314
+ "type": "function",
315
+ "function": {
316
+ "name": "add_container",
317
+ "description": "Adds a new storage container (box/bin) to a location.",
318
+ "parameters": {
319
+ "type": "object",
320
+ "properties": {
321
+ "name": { "type": "string" },
322
+ "location_id": { "type": "string" }
323
+ },
324
+ "required": ["name", "location_id"]
325
+ }
326
+ }
327
+ }
328
+ ]
329
+
330
+ # Standard Multi-turn loop
331
+ turn_count = 0
332
+ max_turns = 100 # High safety limit to allow complex loops
333
+
334
+ while turn_count < max_turns:
335
+ turn_count += 1
336
+ completion = await groq_client.chat.completions.create(
337
+ model="openai/gpt-oss-120b",
338
+ messages=messages,
339
+ tools=tools,
340
+ tool_choice="auto",
341
+ temperature=0.3,
342
+ max_tokens=2048
343
+ )
344
+
345
+ response_message = completion.choices[0].message
346
+ if not response_message.tool_calls:
347
+ return completion.model_dump()
348
+
349
+ messages.append(response_message)
350
+ for tool_call in response_message.tool_calls:
351
+ tool_name = tool_call.function.name
352
+ logger.info(f"AI Strategy: Executing tool '{tool_name}' on turn {turn_count}")
353
+ args = {}
354
+ if getattr(tool_call.function, 'arguments', None):
355
+ try:
356
+ args = json.loads(tool_call.function.arguments)
357
+ except json.JSONDecodeError:
358
+ logger.error(f"Failed to parse args for {tool_name}")
359
+
360
+ try:
361
+ if tool_name == "add_inventory_item":
362
+ supabase.table("inventory").insert(args).execute()
363
+ content = f"Success: Added {args.get('name')}."
364
+ elif tool_name == "add_location":
365
+ supabase.table("locations").insert(args).execute()
366
+ content = f"Success: Created location {args.get('name')}."
367
+ elif tool_name == "bulk_add_items":
368
+ supabase.table("inventory").insert(args["items"]).execute()
369
+ content = f"Success: Bulk added {len(args['items'])} items."
370
+ elif tool_name == "add_container":
371
+ supabase.table("containers").insert(args).execute()
372
+ content = f"Success: Created container {args.get('name')}."
373
+ elif tool_name == "get_locations":
374
+ res = supabase.table("locations").select("*").execute()
375
+ content = json.dumps(res.data)
376
+ elif tool_name == "get_containers":
377
+ res = supabase.table("containers").select("*").execute()
378
+ content = json.dumps(res.data)
379
+ elif tool_name == "get_inventory":
380
+ res = supabase.table("inventory").select("*").execute()
381
+ content = json.dumps(res.data)
382
+ except Exception as tool_err:
383
+ logger.error(f"Tool Execution Error: {str(tool_err)}")
384
+ content = f"Error executing {tool_name}: {str(tool_err)}"
385
+ if "23505" in str(tool_err):
386
+ content = f"Note: '{args.get('name')}' already exists in the database. You can proceed using it."
387
+
388
+ messages.append({
389
+ "role": "tool",
390
+ "tool_call_id": tool_call.id,
391
+ "name": tool_name,
392
+ "content": content
393
+ })
394
+
395
+ # If we exhaust max_turns
396
+ logger.warning(f"Reached max turns limit ({max_turns}) for AI chat")
397
+ return {
398
+ "choices": [{
399
+ "message": {
400
+ "role": "assistant",
401
+ "content": "I'm still analyzing your complex request. I've performed multiple checks but might need more time. What would you like to verify?"
402
+ }
403
+ }]
404
+ }
405
+
406
+
407
+ except Exception as e:
408
+ logger.exception("AI Chat Error")
409
+ raise HTTPException(status_code=500, detail="AI communication failed")
410
+
411
+ @app.post("/api/audio/transcribe")
412
+ async def transcribe_audio(file: UploadFile = File(...), auth: bool = Depends(verify_internal_password)):
413
+ if not GROQ_API_KEY:
414
+ raise HTTPException(status_code=500, detail="Groq API Key not configured")
415
+
416
+ async with httpx.AsyncClient() as client:
417
+ try:
418
+ content = await file.read()
419
+ files = {"file": (file.filename, content, file.content_type)}
420
+ data = {
421
+ "model": "whisper-large-v3-turbo",
422
+ "response_format": "verbose_json",
423
+ "temperature": "0"
424
+ }
425
+ response = await client.post(
426
+ "https://api.groq.com/openai/v1/audio/transcriptions",
427
+ headers={"Authorization": f"Bearer {GROQ_API_KEY}"},
428
+ data=data,
429
+ files=files,
430
+ timeout=60.0
431
+ )
432
+ return response.json()
433
+ except Exception as e:
434
+ logger.exception("Transcription Error")
435
+ raise HTTPException(status_code=500, detail="Transcription failed")
436
+
437
+ @app.post("/api/audio/process")
438
+ async def process_audio(request: Request, auth: bool = Depends(verify_internal_password)):
439
+ if not GROQ_API_KEY:
440
+ raise HTTPException(status_code=500, detail="Groq API Key not configured")
441
+
442
+ body = await request.json()
443
+ transcript = body.get("transcript")
444
+
445
+ async with httpx.AsyncClient() as client:
446
+ try:
447
+ response = await client.post(
448
+ "https://api.groq.com/openai/v1/chat/completions",
449
+ headers={"Authorization": f"Bearer {GROQ_API_KEY}"},
450
+ json={
451
+ "messages": [
452
+ {"role": "system", "content": "Extract items from the user's transcript. Return JSON with 'items' array. Each item: 'name', 'quantity' (number), 'unit', 'category', 'location'."},
453
+ {"role": "user", "content": transcript}
454
+ ],
455
+ "model": "openai/gpt-oss-120b"
456
+ },
457
+ timeout=60.0
458
+ )
459
+ data = response.json()
460
+ # Save items to Supabase
461
+ if "choices" in data and data["choices"]:
462
+ processed = json.loads(data["choices"][0]["message"]["content"])
463
+ if "items" in processed and isinstance(processed["items"], list):
464
+ supabase.table("inventory").insert(processed["items"]).execute()
465
+
466
+ return data
467
+ except Exception as e:
468
+ logger.exception("Processing Error")
469
+ raise HTTPException(status_code=500, detail="Processing failed")
470
+
471
+ if __name__ == "__main__":
472
+ import uvicorn
473
+ uvicorn.run(app, host="0.0.0.0", port=8000)
requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn[standard]
3
+ pydantic
4
+ httpx
5
+ groq
6
+ python-dotenv
7
+ supabase
8
+ python-multipart