@iprep/server 1.1.2 → 1.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/public/index.html CHANGED
@@ -5,8 +5,8 @@
5
5
  <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
7
  <title>iPrep - Multi-Tutor Chatbot</title>
8
- <script type="module" crossorigin src="/assets/index-B9Reldd7.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-B1cXABmP.css">
8
+ <script type="module" crossorigin src="/assets/index-gUzuGrhg.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-_fe-Pko9.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>
@@ -135,6 +135,48 @@ router.post('/', upload.single('file'), async (req, res, next) => {
135
135
  }
136
136
  });
137
137
 
138
+ // GET /api/documents/:docId/content — read raw file content
139
+ router.get('/:docId/content', async (req, res, next) => {
140
+ try {
141
+ const { docId } = req.params;
142
+ const doc = await prisma.document.findUnique({ where: { id: docId } });
143
+ if (!doc) return res.status(404).json({ error: 'Document not found' });
144
+
145
+ const filePath = path.join(IprepPaths.documents(doc.tutorId), doc.originalName);
146
+ const content = await fs.readFile(filePath, 'utf-8');
147
+ res.json({ content, name: doc.originalName });
148
+ } catch (err) {
149
+ next(err);
150
+ }
151
+ });
152
+
153
+ // PUT /api/documents/:docId — overwrite file content
154
+ router.put('/:docId', async (req, res, next) => {
155
+ try {
156
+ const { docId } = req.params;
157
+ const { content } = req.body;
158
+ if (typeof content !== 'string') {
159
+ return res.status(400).json({ error: 'content is required' });
160
+ }
161
+
162
+ const doc = await prisma.document.findUnique({ where: { id: docId } });
163
+ if (!doc) return res.status(404).json({ error: 'Document not found' });
164
+
165
+ const filePath = path.join(IprepPaths.documents(doc.tutorId), doc.originalName);
166
+ await fs.writeFile(filePath, content, 'utf-8');
167
+
168
+ const newSize = Buffer.byteLength(content, 'utf-8');
169
+ await prisma.document.update({
170
+ where: { id: docId },
171
+ data: { fileSize: newSize },
172
+ });
173
+
174
+ res.json({ success: true });
175
+ } catch (err) {
176
+ next(err);
177
+ }
178
+ });
179
+
138
180
  // DELETE /api/documents/:docId
139
181
  router.delete('/:docId', async (req, res, next) => {
140
182
  try {
@@ -2,10 +2,16 @@ import { Router } from 'express';
2
2
  import fs from 'fs/promises';
3
3
  import path from 'path';
4
4
  import { listTutors, loadTutorConfig } from '../services/tutor-service.js';
5
+ import { getAvatarForTutor, resolveAvatarPath, AVATAR_MIME } from '../services/avatar-service.js';
5
6
  import { isValidTutorId, IprepPaths } from '@iprep/shared';
6
7
 
7
8
  const TUTORS_DIR = IprepPaths.aitutors;
8
9
 
10
+ // Core AI files that cannot be deleted or created via POST (template-managed)
11
+ const CORE_FILES = new Set(['soul.md', 'system-prompt.md', 'skills.md', 'settings.md', 'evaluation.md', 'scenarios.md']);
12
+ // Reserved base names (without .md) that cannot be used for user-created files
13
+ const RESERVED_NAMES = new Set(['soul', 'system-prompt', 'skills', 'settings', 'evaluation', 'scenarios', 'goal']);
14
+
9
15
  const router = Router();
10
16
 
11
17
  // GET /api/tutors
@@ -32,6 +38,34 @@ router.get('/:tutorId', async (req, res, next) => {
32
38
  }
33
39
  });
34
40
 
41
+ // GET /api/tutors/:tutorId/avatar — serve from shared pool ~/.iprep/public/images/avatars/
42
+ router.get('/:tutorId/avatar', async (req, res, next) => {
43
+ try {
44
+ const { tutorId } = req.params;
45
+
46
+ if (!isValidTutorId(tutorId)) return res.status(404).end();
47
+
48
+ const filename = await getAvatarForTutor(tutorId);
49
+ if (!filename) return res.status(404).end();
50
+
51
+ const filePath = resolveAvatarPath(filename);
52
+ if (!filePath) return res.status(400).end();
53
+
54
+ try {
55
+ await fs.access(filePath);
56
+ } catch {
57
+ return res.status(404).end();
58
+ }
59
+
60
+ const ext = path.extname(filename).slice(1).toLowerCase();
61
+ res.setHeader('Content-Type', AVATAR_MIME[ext] || 'application/octet-stream');
62
+ res.setHeader('Cache-Control', 'public, max-age=300');
63
+ res.sendFile(filePath);
64
+ } catch (err) {
65
+ next(err);
66
+ }
67
+ });
68
+
35
69
  // GET /api/tutors/:tutorId/files — list .md config files for a tutor
36
70
  router.get('/:tutorId/files', async (req, res, next) => {
37
71
  try {
@@ -104,4 +138,142 @@ router.get('/:tutorId/files/:filename', async (req, res, next) => {
104
138
  }
105
139
  });
106
140
 
141
+ // PUT /api/tutors/:tutorId/files/:filename — overwrite content of an existing file
142
+ router.put('/:tutorId/files/:filename', async (req, res, next) => {
143
+ try {
144
+ const { tutorId, filename } = req.params;
145
+ const { content } = req.body;
146
+
147
+ if (!isValidTutorId(tutorId)) {
148
+ return res.status(404).json({ error: 'Tutor not found' });
149
+ }
150
+
151
+ if (!/^[a-z0-9_-]+\.md$/.test(filename)) {
152
+ return res.status(400).json({ error: 'Invalid filename' });
153
+ }
154
+
155
+ if (typeof content !== 'string') {
156
+ return res.status(400).json({ error: 'content must be a string' });
157
+ }
158
+
159
+ if (Buffer.byteLength(content, 'utf-8') > 100 * 1024) {
160
+ return res.status(400).json({ error: 'Content exceeds 100 KB limit' });
161
+ }
162
+
163
+ const tutorDir = path.join(TUTORS_DIR, tutorId);
164
+ const filePath = path.join(tutorDir, filename);
165
+
166
+ if (!filePath.startsWith(tutorDir + path.sep)) {
167
+ return res.status(400).json({ error: 'Invalid filename' });
168
+ }
169
+
170
+ try {
171
+ await fs.access(filePath);
172
+ } catch {
173
+ return res.status(404).json({ error: 'File not found' });
174
+ }
175
+
176
+ await fs.writeFile(filePath, content, 'utf-8');
177
+ const stat = await fs.stat(filePath);
178
+
179
+ res.json({ success: true, lastModified: stat.mtime.toISOString() });
180
+ } catch (err) {
181
+ next(err);
182
+ }
183
+ });
184
+
185
+ // POST /api/tutors/:tutorId/files — create a new user-owned .md file
186
+ router.post('/:tutorId/files', async (req, res, next) => {
187
+ try {
188
+ const { tutorId } = req.params;
189
+ const { filename, content } = req.body;
190
+
191
+ if (!isValidTutorId(tutorId)) {
192
+ return res.status(404).json({ error: 'Tutor not found' });
193
+ }
194
+
195
+ if (!filename || !/^[a-z0-9_-]+\.md$/.test(filename)) {
196
+ return res.status(400).json({ error: 'Invalid filename. Use lowercase letters, numbers, hyphens, and underscores only.' });
197
+ }
198
+
199
+ const baseName = filename.slice(0, -3); // strip .md
200
+ if (RESERVED_NAMES.has(baseName)) {
201
+ return res.status(400).json({ error: `"${baseName}" is a reserved filename.` });
202
+ }
203
+
204
+ if (typeof content !== 'string') {
205
+ return res.status(400).json({ error: 'content must be a string' });
206
+ }
207
+
208
+ if (Buffer.byteLength(content, 'utf-8') > 100 * 1024) {
209
+ return res.status(400).json({ error: 'Content exceeds 100 KB limit' });
210
+ }
211
+
212
+ const tutorDir = path.join(TUTORS_DIR, tutorId);
213
+ const filePath = path.join(tutorDir, filename);
214
+
215
+ if (!filePath.startsWith(tutorDir + path.sep)) {
216
+ return res.status(400).json({ error: 'Invalid filename' });
217
+ }
218
+
219
+ try {
220
+ await fs.access(filePath);
221
+ return res.status(409).json({ error: 'File already exists' });
222
+ } catch {
223
+ // expected — file should not exist
224
+ }
225
+
226
+ await fs.mkdir(tutorDir, { recursive: true });
227
+ await fs.writeFile(filePath, content, 'utf-8');
228
+ const stat = await fs.stat(filePath);
229
+
230
+ res.status(201).json({
231
+ success: true,
232
+ file: {
233
+ filename,
234
+ lastModified: stat.mtime.toISOString(),
235
+ size: stat.size,
236
+ },
237
+ });
238
+ } catch (err) {
239
+ next(err);
240
+ }
241
+ });
242
+
243
+ // DELETE /api/tutors/:tutorId/files/:filename — delete a user-created file (not core files)
244
+ router.delete('/:tutorId/files/:filename', async (req, res, next) => {
245
+ try {
246
+ const { tutorId, filename } = req.params;
247
+
248
+ if (!isValidTutorId(tutorId)) {
249
+ return res.status(404).json({ error: 'Tutor not found' });
250
+ }
251
+
252
+ if (!/^[a-z0-9_-]+\.md$/.test(filename)) {
253
+ return res.status(400).json({ error: 'Invalid filename' });
254
+ }
255
+
256
+ if (CORE_FILES.has(filename)) {
257
+ return res.status(403).json({ error: 'Core AI files cannot be deleted.' });
258
+ }
259
+
260
+ const tutorDir = path.join(TUTORS_DIR, tutorId);
261
+ const filePath = path.join(tutorDir, filename);
262
+
263
+ if (!filePath.startsWith(tutorDir + path.sep)) {
264
+ return res.status(400).json({ error: 'Invalid filename' });
265
+ }
266
+
267
+ try {
268
+ await fs.unlink(filePath);
269
+ } catch {
270
+ return res.status(404).json({ error: 'File not found' });
271
+ }
272
+
273
+ res.json({ success: true });
274
+ } catch (err) {
275
+ next(err);
276
+ }
277
+ });
278
+
107
279
  export default router;
@@ -0,0 +1,140 @@
1
+ /**
2
+ * avatar-service.js
3
+ *
4
+ * Manages the shared avatar pool at ~/.iprep/public/images/avatars/
5
+ * and the assignment config at ~/.iprep/avatar-assignments.json.
6
+ *
7
+ * Pool layout:
8
+ * avatar01.webp … avatar10.webp (user-supplied, any supported ext)
9
+ *
10
+ * Assignment file (~/.iprep/avatar-assignments.json):
11
+ * { "interview-prep": "avatar01.webp", "english-coach": "avatar02.webp", ... }
12
+ *
13
+ * New tutors are auto-assigned the next free slot (01–10, cycling).
14
+ */
15
+
16
+ import fs from 'fs/promises';
17
+ import path from 'path';
18
+ import { IprepPaths } from '@iprep/shared';
19
+
20
+ const TOTAL_SLOTS = 10;
21
+ const ALLOWED_EXTS = ['webp', 'jpg', 'jpeg', 'png', 'gif', 'avif'];
22
+
23
+ export const AVATAR_MIME = {
24
+ webp: 'image/webp',
25
+ jpg: 'image/jpeg',
26
+ jpeg: 'image/jpeg',
27
+ png: 'image/png',
28
+ gif: 'image/gif',
29
+ avif: 'image/avif',
30
+ };
31
+
32
+ // ── Config I/O ────────────────────────────────────────────────────────────────
33
+
34
+ async function loadAssignments() {
35
+ try {
36
+ const raw = await fs.readFile(IprepPaths.avatarAssignments, 'utf-8');
37
+ return JSON.parse(raw);
38
+ } catch {
39
+ return {};
40
+ }
41
+ }
42
+
43
+ async function saveAssignments(assignments) {
44
+ await fs.mkdir(path.dirname(IprepPaths.avatarAssignments), { recursive: true });
45
+ await fs.writeFile(
46
+ IprepPaths.avatarAssignments,
47
+ JSON.stringify(assignments, null, 2),
48
+ 'utf-8'
49
+ );
50
+ }
51
+
52
+ // ── Public API ────────────────────────────────────────────────────────────────
53
+
54
+ /**
55
+ * Returns the assigned avatar filename for a tutor, e.g. "avatar03.webp",
56
+ * or null if none assigned.
57
+ */
58
+ export async function getAvatarForTutor(tutorId) {
59
+ const assignments = await loadAssignments();
60
+ return assignments[tutorId] ?? null;
61
+ }
62
+
63
+ /**
64
+ * Assigns the next free slot to the tutor if not already assigned.
65
+ * Slot numbering: 01–10, then cycles back to 01.
66
+ * Tries to find an actual file on disk for the slot; if none found,
67
+ * records "avatarNN.webp" as the target (will return 404 until file is dropped in).
68
+ * Returns the assigned filename string.
69
+ */
70
+ export async function assignNextAvatar(tutorId) {
71
+ const assignments = await loadAssignments();
72
+
73
+ // Already assigned — nothing to do
74
+ if (assignments[tutorId]) return assignments[tutorId];
75
+
76
+ // Collect slot numbers already in use
77
+ const usedSlots = new Set(
78
+ Object.values(assignments).map(f => {
79
+ const m = f.match(/^avatar(\d+)\./);
80
+ return m ? parseInt(m[1], 10) : null;
81
+ }).filter(Boolean)
82
+ );
83
+
84
+ // Find the next free slot (1-based, wraps around)
85
+ let slot = 1;
86
+ let tries = 0;
87
+ while (usedSlots.has(slot) && tries < TOTAL_SLOTS) {
88
+ slot = (slot % TOTAL_SLOTS) + 1;
89
+ tries++;
90
+ }
91
+ // If all slots taken, cycle: assign based on total count
92
+ if (tries >= TOTAL_SLOTS) {
93
+ slot = (Object.keys(assignments).length % TOTAL_SLOTS) + 1;
94
+ }
95
+
96
+ const slotStr = String(slot).padStart(2, '0');
97
+
98
+ // Try to find which extension exists for this slot
99
+ let filename = `avatar${slotStr}.webp`; // default written value
100
+ for (const ext of ALLOWED_EXTS) {
101
+ const candidate = `avatar${slotStr}.${ext}`;
102
+ try {
103
+ await fs.access(path.join(IprepPaths.avatarsDir, candidate));
104
+ filename = candidate;
105
+ break;
106
+ } catch {
107
+ // try next ext
108
+ }
109
+ }
110
+
111
+ assignments[tutorId] = filename;
112
+ await saveAssignments(assignments);
113
+ return filename;
114
+ }
115
+
116
+ /**
117
+ * Resolves the absolute path for an avatar filename.
118
+ * Validates that:
119
+ * - filename matches avatarNN.ext pattern
120
+ * - extension is in the allowed list
121
+ * - resolved path stays inside avatarsDir
122
+ * Returns the absolute path, or null if invalid.
123
+ */
124
+ export function resolveAvatarPath(filename) {
125
+ if (!filename) return null;
126
+
127
+ const m = filename.match(/^avatar(\d{2})\.([a-z]+)$/i);
128
+ if (!m) return null;
129
+
130
+ const ext = m[2].toLowerCase();
131
+ if (!ALLOWED_EXTS.includes(ext)) return null;
132
+
133
+ const resolved = path.join(IprepPaths.avatarsDir, filename);
134
+
135
+ // Ensure it stays within avatarsDir (no traversal)
136
+ const base = IprepPaths.avatarsDir + path.sep;
137
+ if (!resolved.startsWith(base)) return null;
138
+
139
+ return resolved;
140
+ }
@@ -3,6 +3,7 @@ import path from 'path';
3
3
  import { DEFAULT_TUTORS, IprepPaths } from '@iprep/shared';
4
4
  import { upsertTutor, getAllTutors } from '@iprep/db';
5
5
  import { logger } from '../utils/logger.js';
6
+ import { assignNextAvatar } from './avatar-service.js';
6
7
 
7
8
  const TUTORS_DIR = IprepPaths.aitutors;
8
9
 
@@ -37,11 +38,13 @@ export async function loadTutorConfig(tutorId) {
37
38
 
38
39
  /**
39
40
  * Seed the database with the default tutors if they don't exist yet.
41
+ * Also auto-assigns an avatar pool slot to each tutor on first run.
40
42
  */
41
43
  export async function seedTutors() {
42
44
  for (const tutor of DEFAULT_TUTORS) {
43
45
  await upsertTutor(tutor);
44
- logger.info(`Tutor seeded: ${tutor.name}`);
46
+ const avatar = await assignNextAvatar(tutor.id);
47
+ logger.info(`Tutor seeded: ${tutor.name} (avatar: ${avatar})`);
45
48
  }
46
49
  }
47
50
 
@@ -1 +0,0 @@
1
- @import"https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap";*,*:before,*:after{box-sizing:border-box;margin:0;padding:0}html,body,#root{height:100%;overflow:hidden}body{font-family:var(--font-sans);background:var(--bg-app);color:var(--text-primary);font-size:14px;line-height:1.6;-webkit-font-smoothing:antialiased}::-webkit-scrollbar{width:5px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:#ffffff1a;border-radius:3px}::-webkit-scrollbar-thumb:hover{background:#fff3}:focus-visible{outline:2px solid var(--color-primary);outline-offset:2px;border-radius:var(--radius-sm)}button{cursor:pointer;border:none;background:none;font-family:inherit}input,textarea{font-family:inherit}a{color:var(--text-accent);text-decoration:none}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.app-shell{display:flex;height:100vh;width:100vw;overflow:hidden}:root{--color-primary: #7C6FFF;--color-primary-hover: #6B5EF0;--color-primary-light: rgba(124, 111, 255, .12);--color-accent: #00D4C8;--color-accent-hover: #00BEB3;--bg-app: #0D0D14;--bg-surface: #14141F;--bg-sidebar: #0D0D14;--bg-card: #1C1C2C;--bg-input: #1A1A28;--bg-hover: rgba(255,255,255,.05);--bg-active: rgba(124,111,255,.15);--text-primary: #F0F0FF;--text-secondary: #9090B8;--text-muted: #5A5A80;--text-accent: #7C6FFF;--text-on-primary: #FFFFFF;--border-color: rgba(255,255,255,.06);--border-active: rgba(124,111,255,.5);--bubble-user-bg: #7C6FFF;--bubble-user-text: #FFFFFF;--bubble-ai-bg: #1C1C2C;--bubble-ai-text: #E8E8F8;--color-success: #22C55E;--color-warning: #F59E0B;--color-error: #EF4444;--color-info: #3B82F6;--shadow-sm: 0 1px 3px rgba(0,0,0,.4);--shadow-md: 0 4px 12px rgba(0,0,0,.5);--shadow-lg: 0 8px 32px rgba(0,0,0,.6);--shadow-glow: 0 0 24px rgba(124,111,255,.25);--radius-sm: 6px;--radius-md: 12px;--radius-lg: 18px;--radius-xl: 24px;--radius-full: 9999px;--space-1: 4px;--space-2: 8px;--space-3: 12px;--space-4: 16px;--space-5: 20px;--space-6: 24px;--space-8: 32px;--space-10: 40px;--sidebar-w: 68px;--drawer-w: 380px;--drawer-bg: #111122;--drawer-border: rgba(255, 255, 255, .06);--drawer-tab-bg: rgba(255, 255, 255, .04);--drawer-tab-active: var(--color-primary);--call-bar-bg: rgba(0, 0, 0, .6);--call-bar-border: rgba(255, 255, 255, .08);--call-btn-size: 48px;--call-end-bg: #EF4444;--call-end-hover: #DC2626;--font-sans: "Inter", "Segoe UI", system-ui, sans-serif;--font-mono: "JetBrains Mono", "Fira Code", monospace;--transition-fast: all .15s ease;--transition-base: all .25s ease;--transition-slow: all .4s ease}.sidebar{width:var(--sidebar-w);min-width:var(--sidebar-w);background:var(--bg-sidebar);border-right:1px solid var(--border-color);display:flex;flex-direction:column;align-items:center;padding:var(--space-4) 0;gap:var(--space-2);z-index:10}.sidebar__logo{width:40px;height:40px;background:var(--color-primary);border-radius:var(--radius-md);display:flex;align-items:center;justify-content:center;font-weight:700;font-size:16px;color:#fff;margin-bottom:var(--space-4);box-shadow:var(--shadow-glow);flex-shrink:0}.sidebar__nav{display:flex;flex-direction:column;align-items:center;gap:var(--space-2);flex:1;width:100%;padding:0 var(--space-2)}.sidebar__bottom{display:flex;flex-direction:column;align-items:center;gap:var(--space-2);padding:0 var(--space-2)}.tutor-icon-btn{position:relative;width:48px;height:48px;border-radius:var(--radius-md);background:var(--bg-card);border:2px solid transparent;display:flex;align-items:center;justify-content:center;font-size:20px;transition:var(--transition-base);overflow:visible}.tutor-icon-btn:hover{background:var(--bg-hover);border-color:var(--border-active);transform:scale(1.05)}.tutor-icon-btn.active{background:var(--bg-active);border-color:var(--color-primary);box-shadow:var(--shadow-glow)}.tutor-icon-btn .badge{position:absolute;top:-4px;right:-4px;min-width:16px;height:16px;background:var(--color-primary);color:#fff;font-size:10px;font-weight:600;border-radius:var(--radius-full);display:flex;align-items:center;justify-content:center;padding:0 4px;border:2px solid var(--bg-sidebar)}.tutor-icon-btn .tooltip{position:absolute;left:calc(100% + 10px);top:50%;transform:translateY(-50%);background:var(--bg-card);border:1px solid var(--border-color);color:var(--text-primary);font-size:12px;font-weight:500;padding:6px 10px;border-radius:var(--radius-sm);white-space:nowrap;pointer-events:none;opacity:0;transition:var(--transition-fast);box-shadow:var(--shadow-md);z-index:100}.tutor-icon-btn:hover .tooltip{opacity:1}.sidebar__add-btn{width:48px;height:48px;border-radius:var(--radius-md);background:var(--bg-card);border:2px dashed var(--text-muted);display:flex;align-items:center;justify-content:center;font-size:20px;color:var(--text-muted);transition:var(--transition-base)}.sidebar__add-btn:hover{border-color:var(--color-primary);color:var(--color-primary);background:var(--color-primary-light)}.main-content{flex:1;display:flex;flex-direction:column;overflow:hidden;background:var(--bg-surface);min-width:0}.chat-header{display:flex;align-items:center;gap:var(--space-3);padding:var(--space-4) var(--space-6);border-bottom:1px solid var(--border-color);background:var(--bg-surface);flex-shrink:0}.chat-header__avatar{width:38px;height:38px;border-radius:var(--radius-md);background:var(--color-primary-light);border:2px solid var(--color-primary);display:flex;align-items:center;justify-content:center;font-size:18px;flex-shrink:0}.chat-header__info{flex:1}.chat-header__name{font-size:15px;font-weight:600;color:var(--text-primary)}.chat-header__status{font-size:12px;color:var(--color-success);display:flex;align-items:center;gap:5px}.chat-header__status:before{content:"";width:6px;height:6px;background:var(--color-success);border-radius:50%;display:inline-block}.chat-header__actions{display:flex;align-items:center;gap:var(--space-2)}.session-timer{display:flex;align-items:center;gap:5px;padding:5px 10px;background:var(--bg-card);border:1px solid var(--border-color);border-radius:var(--radius-full);flex-shrink:0;-webkit-user-select:none;user-select:none}.session-timer--running .session-timer__icon{animation:timer-pulse 2s ease-in-out infinite}@keyframes timer-pulse{0%,to{opacity:1}50%{opacity:.5}}.session-timer__icon{font-size:13px;line-height:1}.session-timer__time{font-size:13px;font-weight:500;font-family:Courier New,Courier,monospace;color:var(--text-secondary);min-width:34px;text-align:right}.header-icon-btn{width:34px;height:34px;border-radius:var(--radius-md);background:var(--bg-card);border:1px solid var(--border-color);color:var(--text-secondary);font-size:15px;display:flex;align-items:center;justify-content:center;cursor:pointer;transition:var(--transition-fast);flex-shrink:0}.header-icon-btn:hover{background:var(--bg-hover);border-color:var(--border-active);color:var(--text-primary);transform:scale(1.05)}.header-icon-btn--danger:hover{border-color:var(--color-error);color:var(--color-error);background:var(--bg-hover)}.drawer-toggle-btn{width:34px;height:34px;border-radius:var(--radius-md);background:var(--bg-card);border:1px solid var(--border-color);color:var(--text-secondary);font-size:15px;display:flex;align-items:center;justify-content:center;cursor:pointer;transition:var(--transition-fast);flex-shrink:0}.drawer-toggle-btn:hover{background:var(--bg-hover);border-color:var(--border-active);color:var(--text-primary);transform:scale(1.05)}.drawer-toggle-btn.active{background:var(--color-primary-light);border-color:var(--color-primary);color:var(--color-primary)}.mode-switcher-bar{display:flex;align-items:center;justify-content:center;padding:var(--space-2) var(--space-6);background:var(--bg-surface);border-bottom:1px solid var(--border-color);flex-shrink:0}.mode-switcher{display:flex;gap:4px;background:var(--bg-card);border:1px solid var(--border-color);border-radius:var(--radius-full);padding:4px}.mode-tab{display:flex;align-items:center;gap:6px;padding:6px 18px;border-radius:var(--radius-full);border:none;background:transparent;color:var(--text-secondary);font-size:13px;font-weight:500;cursor:pointer;transition:background .18s ease,color .18s ease;white-space:nowrap}.mode-tab:hover:not(.active){background:var(--bg-hover);color:var(--text-primary)}.mode-tab.active{background:var(--color-primary);color:#fff;box-shadow:0 2px 8px #7c6fff59}.mode-tab__icon{font-size:14px}.mode-panel{display:flex;flex-direction:column;flex:1;overflow:hidden;min-height:0;animation:modeFadeIn .2s ease}@keyframes modeFadeIn{0%{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}@media (max-width: 600px){.sidebar{width:56px;min-width:56px}.tutor-icon-btn{width:40px;height:40px;font-size:17px}.mode-tab{padding:6px 12px;font-size:12px}.mode-tab__label{display:none}}.chat-window{flex:1;overflow-y:auto;padding:var(--space-6);display:flex;flex-direction:column;gap:var(--space-4);scroll-behavior:smooth}.chat-empty{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:var(--space-4);text-align:center;padding:var(--space-8)}.chat-empty__icon{font-size:56px;line-height:1;filter:drop-shadow(0 0 20px rgba(124,111,255,.4))}.chat-empty__title{font-size:20px;font-weight:600;color:var(--text-primary)}.chat-empty__sub{font-size:14px;color:var(--text-secondary);max-width:340px;line-height:1.7}.chat-empty__suggestions{display:flex;flex-wrap:wrap;gap:var(--space-2);justify-content:center;margin-top:var(--space-2)}.suggestion-chip{padding:8px 14px;background:var(--color-primary-light);border:1px solid var(--border-active);border-radius:var(--radius-full);color:var(--text-accent);font-size:13px;cursor:pointer;transition:var(--transition-fast);font-weight:500}.suggestion-chip:hover{background:var(--color-primary);color:#fff;transform:translateY(-2px);box-shadow:0 4px 12px #7c6fff66}.message-group{display:flex;gap:var(--space-3);align-items:flex-end;animation:fadeSlideUp .25s ease both}.message-group.user{flex-direction:row-reverse}@keyframes fadeSlideUp{0%{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}.msg-avatar{width:32px;height:32px;border-radius:var(--radius-md);background:var(--color-primary-light);border:2px solid var(--color-primary);display:flex;align-items:center;justify-content:center;font-size:15px;flex-shrink:0}.message-group.user .msg-avatar{background:var(--bg-card);border-color:var(--border-color)}.message-bubble{max-width:75%;padding:10px 16px;border-radius:var(--radius-lg);line-height:1.65;font-size:14px;position:relative;word-break:break-word}.message-bubble.ai{background:var(--bubble-ai-bg);color:var(--bubble-ai-text);border-bottom-left-radius:var(--radius-sm);border:1px solid var(--border-color)}.message-bubble.user{background:var(--bubble-user-bg);color:var(--bubble-user-text);border-bottom-right-radius:var(--radius-sm)}.message-bubble__time{font-size:11px;color:var(--text-muted);margin-top:4px;display:block}.message-group.user .message-bubble__time{text-align:right}.typing-indicator{display:flex;align-items:center;gap:var(--space-3);animation:fadeSlideUp .2s ease both}.typing-dots{background:var(--bubble-ai-bg);border:1px solid var(--border-color);border-radius:var(--radius-lg);border-bottom-left-radius:var(--radius-sm);padding:12px 18px;display:flex;gap:5px;align-items:center}.typing-dots span{width:7px;height:7px;background:var(--text-muted);border-radius:50%;display:inline-block;animation:blink 1.2s infinite ease-in-out}.typing-dots span:nth-child(2){animation-delay:.2s}.typing-dots span:nth-child(3){animation-delay:.4s}@keyframes blink{0%,80%,to{transform:scale(.8);opacity:.5}40%{transform:scale(1.1);opacity:1}}.input-area{padding:var(--space-4) var(--space-6);border-top:1px solid var(--border-color);background:var(--bg-surface);flex-shrink:0;display:flex;flex-direction:column;align-items:center}.input-wrapper{display:flex;align-items:flex-end;gap:var(--space-3);background:var(--bg-input);border:1.5px solid var(--border-color);border-radius:var(--radius-xl);padding:10px 12px 10px 18px;transition:var(--transition-base);width:100%;max-width:720px}.input-wrapper:focus-within{border-color:var(--color-primary);box-shadow:0 0 0 3px #7c6fff1f}.input-wrapper textarea{flex:1;background:none;border:none;outline:none;color:var(--text-primary);font-size:14px;line-height:1.6;resize:none;max-height:160px;min-height:24px;overflow-y:auto}.input-wrapper textarea::placeholder{color:var(--text-muted)}.input-send-btn{width:38px;height:38px;border-radius:var(--radius-full);background:var(--color-primary);color:#fff;display:flex;align-items:center;justify-content:center;transition:var(--transition-fast);flex-shrink:0;font-size:16px}.input-send-btn:hover:not(:disabled){background:var(--color-primary-hover);transform:scale(1.08);box-shadow:var(--shadow-glow)}.input-send-btn:disabled{opacity:.4;cursor:not-allowed}.input-hint{font-size:11px;color:var(--text-muted);margin-top:6px;text-align:center;padding-left:4px}.message-bubble p{margin-bottom:8px}.message-bubble p:last-child{margin-bottom:0}.message-bubble code{background:#0000004d;padding:2px 6px;border-radius:4px;font-family:var(--font-mono);font-size:13px}.message-bubble pre{background:#0006;padding:12px;border-radius:var(--radius-sm);overflow-x:auto;margin:8px 0}.message-bubble ul,.message-bubble ol{padding-left:18px;margin:6px 0}@media (max-width: 600px){.message-bubble{max-width:90%}.chat-window,.input-area{padding:var(--space-3)}}.right-drawer{position:fixed;top:0;right:0;bottom:0;width:var(--drawer-w);background:var(--drawer-bg);border-left:1px solid var(--drawer-border);display:flex;flex-direction:column;z-index:50;transform:translate(100%);transition:transform .3s cubic-bezier(.4,0,.2,1);overflow:hidden}.right-drawer.open{transform:translate(0)}.drawer-backdrop{display:none;position:fixed;top:0;right:0;bottom:0;left:0;background:#00000080;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);z-index:49;opacity:0;transition:opacity .3s ease}.drawer-header{display:flex;align-items:center;justify-content:space-between;padding:var(--space-4) var(--space-5);border-bottom:1px solid var(--drawer-border);flex-shrink:0}.drawer-header__title{font-size:14px;font-weight:600;color:var(--text-secondary);text-transform:uppercase;letter-spacing:.08em}.drawer-close-btn{width:28px;height:28px;border-radius:var(--radius-sm);background:transparent;border:1px solid transparent;color:var(--text-muted);font-size:14px;display:flex;align-items:center;justify-content:center;cursor:pointer;transition:var(--transition-fast)}.drawer-close-btn:hover{background:var(--bg-hover);border-color:var(--border-color);color:var(--text-primary)}@media (min-width: 768px){.drawer-close-btn{display:none}}.drawer-tabs-wrapper{display:flex;flex-direction:column;flex:1;overflow:hidden}.drawer-tabs{display:flex;gap:var(--space-1);padding:var(--space-3) var(--space-4);background:var(--drawer-tab-bg);border-bottom:1px solid var(--drawer-border);flex-shrink:0}.drawer-tab{flex:1;display:flex;align-items:center;justify-content:center;gap:5px;padding:7px var(--space-2);border-radius:var(--radius-full);border:none;background:transparent;color:var(--text-secondary);font-size:12px;font-weight:500;cursor:pointer;transition:var(--transition-fast);white-space:nowrap}.drawer-tab:hover{background:var(--bg-hover);color:var(--text-primary)}.drawer-tab.active{background:var(--drawer-tab-active);color:var(--text-on-primary)}.drawer-tab__icon{font-size:13px}.drawer-tab__label{font-size:12px}.drawer-tab-content{flex:1;overflow-y:auto;overscroll-behavior:contain}.drawer-tab-content::-webkit-scrollbar{width:4px}.drawer-tab-content::-webkit-scrollbar-track{background:transparent}.drawer-tab-content::-webkit-scrollbar-thumb{background:#ffffff1a;border-radius:2px}.drawer-tab-panel{padding:var(--space-5);animation:drawerFadeIn .2s ease}.drawer-placeholder{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:var(--space-10) var(--space-6);gap:var(--space-3)}.drawer-placeholder__icon{font-size:40px;opacity:.5}.drawer-placeholder__title{font-size:16px;font-weight:600;color:var(--text-primary);margin:0}.drawer-placeholder__body{font-size:13px;color:var(--text-secondary);line-height:1.6;max-width:260px;margin:0}@keyframes drawerFadeIn{0%{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}@media (max-width: 1199px){.right-drawer{position:fixed}.drawer-backdrop.visible{display:block;opacity:1}}@media (max-width: 767px){.right-drawer{width:100%;z-index:100}.drawer-close-btn{display:flex}.drawer-backdrop.visible{display:block;opacity:1}}.ai-cap__header{margin-bottom:var(--space-4)}.ai-cap__title{font-size:15px;font-weight:700;color:var(--text-primary);margin:0 0 var(--space-1)}.ai-cap__subtitle{font-size:12px;color:var(--text-muted);margin:0}.ai-cap__list{display:flex;flex-direction:column;gap:var(--space-3);margin-bottom:var(--space-4)}.ai-cap__state{display:flex;flex-direction:column;align-items:center;gap:var(--space-3);padding:var(--space-8) 0;color:var(--text-secondary);font-size:13px;text-align:center}.ai-cap__state--error{color:var(--color-error)}.ai-cap__spinner{width:20px;height:20px;border:2px solid var(--border-color);border-top-color:var(--color-primary);border-radius:50%;animation:spin .7s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}.ai-file-card{background:var(--bg-card);border:1px solid var(--border-color);border-left:4px solid var(--accent, var(--color-primary));border-radius:var(--radius-md);padding:var(--space-4);transition:var(--transition-fast)}.ai-file-card:hover{border-color:var(--accent, var(--color-primary));box-shadow:0 0 0 1px var(--accent, var(--color-primary)),var(--shadow-sm)}.ai-file-card__header{display:flex;align-items:flex-start;gap:var(--space-3);margin-bottom:var(--space-2)}.ai-file-card__icon{font-size:20px;line-height:1;flex-shrink:0}.ai-file-card__titles{display:flex;flex-direction:column;gap:2px;min-width:0}.ai-file-card__name{font-size:14px;font-weight:600;color:var(--text-primary)}.ai-file-card__filename{font-size:11px;color:var(--text-muted);font-family:var(--font-mono)}.ai-file-card__desc{font-size:12px;color:var(--text-secondary);line-height:1.5;margin:0 0 var(--space-3)}.ai-file-card__footer{display:flex;align-items:center;justify-content:space-between;gap:var(--space-2)}.ai-file-card__meta{font-size:11px;color:var(--text-muted)}.ai-file-card__view-btn{font-size:12px;font-weight:500;color:var(--color-primary);background:var(--color-primary-light);border:1px solid transparent;border-radius:var(--radius-full);padding:4px 10px;cursor:pointer;transition:var(--transition-fast);white-space:nowrap}.ai-file-card__view-btn:hover{background:var(--color-primary);color:var(--text-on-primary)}.ai-tip-card{display:flex;gap:var(--space-3);align-items:flex-start;background:#7c6fff0f;border:1px solid rgba(124,111,255,.15);border-radius:var(--radius-md);padding:var(--space-3) var(--space-4);margin-top:var(--space-4)}.ai-tip-card__icon{font-size:16px;flex-shrink:0}.ai-tip-card__text{font-size:12px;color:var(--text-secondary);line-height:1.5;margin:0}.ai-tip-card__text code{font-family:var(--font-mono);font-size:11px;background:#ffffff0f;padding:1px 4px;border-radius:3px;color:var(--color-accent)}.md-viewer-overlay{position:fixed;top:0;right:0;bottom:0;left:0;background:#000000b3;-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);z-index:200;display:flex;align-items:center;justify-content:center;padding:var(--space-6);animation:overlayFadeIn .2s ease}@keyframes overlayFadeIn{0%{opacity:0}to{opacity:1}}.md-viewer{background:var(--bg-card);border:1px solid var(--border-color);border-radius:var(--radius-lg);width:100%;max-width:700px;max-height:80vh;display:flex;flex-direction:column;box-shadow:var(--shadow-lg);animation:modalSlideIn .2s ease}@keyframes modalSlideIn{0%{opacity:0;transform:scale(.95) translateY(8px)}to{opacity:1;transform:scale(1) translateY(0)}}.md-viewer__header{display:flex;align-items:center;justify-content:space-between;padding:var(--space-4) var(--space-5);border-bottom:1px solid var(--border-color);flex-shrink:0;gap:var(--space-3)}.md-viewer__title{font-size:14px;font-weight:600;color:var(--text-primary);font-family:var(--font-mono);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.md-viewer__close{width:28px;height:28px;border-radius:var(--radius-sm);background:transparent;border:1px solid transparent;color:var(--text-muted);font-size:14px;display:flex;align-items:center;justify-content:center;cursor:pointer;flex-shrink:0;transition:var(--transition-fast)}.md-viewer__close:hover{background:var(--bg-hover);border-color:var(--border-color);color:var(--text-primary)}.md-viewer__body{flex:1;overflow-y:auto;padding:var(--space-5) var(--space-6);font-size:14px;line-height:1.7;color:var(--bubble-ai-text)}.md-viewer__body::-webkit-scrollbar{width:4px}.md-viewer__body::-webkit-scrollbar-track{background:transparent}.md-viewer__body::-webkit-scrollbar-thumb{background:#ffffff1a;border-radius:2px}.md-viewer__body h1,.md-viewer__body h2,.md-viewer__body h3,.md-viewer__body h4,.md-viewer__body h5,.md-viewer__body h6{color:var(--text-primary);font-weight:700;margin:var(--space-5) 0 var(--space-2);line-height:1.3}.md-viewer__body h1{font-size:20px;border-bottom:1px solid var(--border-color);padding-bottom:var(--space-2)}.md-viewer__body h2{font-size:17px}.md-viewer__body h3{font-size:15px}.md-viewer__body h4,.md-viewer__body h5,.md-viewer__body h6{font-size:13px}.md-viewer__body p{margin:0 0 var(--space-3)}.md-viewer__body strong{color:var(--text-primary);font-weight:600}.md-viewer__body em{color:var(--text-secondary);font-style:italic}.md-viewer__body a{color:var(--color-primary);text-decoration:underline}.md-viewer__body a:hover{color:var(--color-primary-hover)}.md-viewer__body code{font-family:var(--font-mono);font-size:12px;background:var(--bg-app);border:1px solid var(--border-color);border-radius:3px;padding:1px 5px;color:var(--color-accent)}.md-viewer__body pre{background:var(--bg-app);border:1px solid var(--border-color);border-radius:var(--radius-md);padding:var(--space-4);overflow-x:auto;margin:var(--space-3) 0}.md-viewer__body pre code{background:transparent;border:none;padding:0;font-size:13px;color:var(--text-primary)}.md-viewer__body ul,.md-viewer__body ol{padding-left:var(--space-5);margin:0 0 var(--space-3)}.md-viewer__body li{margin-bottom:var(--space-1)}.md-viewer__body blockquote{border-left:3px solid var(--color-primary);padding-left:var(--space-4);margin:var(--space-3) 0;color:var(--text-secondary);font-style:italic}.md-viewer__body hr{border:none;border-top:1px solid var(--border-color);margin:var(--space-5) 0}.settings-group{margin-bottom:var(--space-5);border-bottom:1px solid var(--drawer-border);padding-bottom:var(--space-4)}.settings-group:last-of-type{border-bottom:none}.settings-group__title{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.1em;color:var(--text-muted);margin:0 0 var(--space-4)}.settings-item{display:flex;flex-direction:column;gap:var(--space-2);margin-bottom:var(--space-4)}.settings-item__meta{display:flex;flex-direction:column;gap:2px}.settings-item__label{font-size:13px;font-weight:500;color:var(--text-primary)}.settings-item__hint{font-size:11px;color:var(--text-muted);line-height:1.4}.settings-item__control{width:100%}.settings-select{width:100%;background:var(--bg-app);border:1px solid var(--border-color);border-radius:var(--radius-md);color:var(--text-primary);font-size:13px;padding:8px 32px 8px 12px;cursor:pointer;transition:border-color .15s ease;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%239090B8' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 12px center}.settings-select:focus{outline:none;border-color:var(--color-primary);box-shadow:0 0 0 3px #7c6fff1f}.settings-select:disabled{opacity:.45;cursor:not-allowed}.settings-radio-group{display:flex;gap:4px;background:var(--bg-app);border:1px solid var(--border-color);border-radius:var(--radius-full);padding:3px}.settings-radio{flex:1;padding:6px var(--space-2);border-radius:var(--radius-full);border:none;background:transparent;color:var(--text-secondary);font-size:12px;font-weight:500;cursor:pointer;transition:background .15s ease,color .15s ease;text-align:center;white-space:nowrap}.settings-radio:hover:not(.active){background:var(--bg-hover);color:var(--text-primary)}.settings-radio.active{background:var(--color-primary);color:#fff;box-shadow:0 1px 6px #7c6fff59}.settings-slider-row{display:flex;align-items:center;gap:var(--space-3)}.settings-slider{flex:1;-webkit-appearance:none;-moz-appearance:none;appearance:none;height:4px;background:var(--bg-hover);border-radius:2px;outline:none;cursor:pointer}.settings-slider::-webkit-slider-thumb{-webkit-appearance:none;width:16px;height:16px;border-radius:50%;background:var(--color-primary);box-shadow:0 0 6px #7c6fff80;cursor:pointer;transition:box-shadow .15s ease}.settings-slider::-webkit-slider-thumb:hover{box-shadow:0 0 10px #7c6fffb3}.settings-slider::-moz-range-thumb{width:16px;height:16px;border-radius:50%;background:var(--color-primary);border:none;box-shadow:0 0 6px #7c6fff80;cursor:pointer}.settings-slider__value{font-size:12px;font-weight:600;color:var(--text-primary);font-family:var(--font-mono);min-width:36px;text-align:right}.settings-toggle{position:relative;width:42px;height:24px;border-radius:var(--radius-full);border:none;background:var(--bg-hover);cursor:pointer;transition:background .2s ease;padding:0;flex-shrink:0}.settings-toggle.on{background:var(--color-primary);box-shadow:0 0 8px #7c6fff66}.settings-toggle__thumb{position:absolute;top:3px;left:3px;width:18px;height:18px;border-radius:50%;background:#fff;box-shadow:0 1px 4px #0000004d;transition:transform .2s ease;pointer-events:none}.settings-toggle.on .settings-toggle__thumb{transform:translate(18px)}.settings-reset-row{display:flex;justify-content:center;padding-top:var(--space-3);margin-top:var(--space-2)}.settings-reset-btn{font-size:12px;font-weight:500;color:var(--text-secondary);background:var(--bg-card);border:1px solid var(--border-color);border-radius:var(--radius-full);padding:7px 18px;cursor:pointer;transition:var(--transition-fast)}.settings-reset-btn:hover{border-color:var(--color-error);color:var(--color-error)}.doc-tab__header{display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-4)}.doc-tab__title{font-size:15px;font-weight:700;color:var(--text-primary);margin:0}.doc-tab__upload-btn{font-size:12px;font-weight:600;color:var(--text-on-primary);background:var(--color-primary);border:none;border-radius:var(--radius-full);padding:5px 12px;cursor:pointer;transition:var(--transition-fast);white-space:nowrap}.doc-tab__upload-btn:hover:not(:disabled){background:var(--color-primary-hover)}.doc-tab__upload-btn:disabled{opacity:.45;cursor:not-allowed}.doc-tab__error{background:#ef44441a;border:1px solid rgba(239,68,68,.3);border-radius:var(--radius-md);color:var(--color-error);font-size:12px;padding:var(--space-3) var(--space-4);margin-bottom:var(--space-3)}.doc-list{display:flex;flex-direction:column;gap:var(--space-3);margin-bottom:var(--space-4)}.doc-card{background:var(--bg-card);border:1px solid var(--border-color);border-radius:var(--radius-md);padding:var(--space-3) var(--space-4);transition:var(--transition-fast)}.doc-card:hover{border-color:var(--border-active);box-shadow:0 0 0 1px #7c6fff26,var(--shadow-sm);transform:translateY(-1px)}.doc-card__top{display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-2)}.doc-card__icon{font-size:22px;flex-shrink:0;line-height:1}.doc-card__info{display:flex;flex-direction:column;gap:2px;min-width:0;flex:1}.doc-card__name{font-size:13px;font-weight:600;color:var(--text-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.doc-card__meta{font-size:11px;color:var(--text-muted)}.doc-card__status{font-size:11px;font-weight:600;border-radius:var(--radius-full);padding:3px 8px;white-space:nowrap;flex-shrink:0}.doc-card__status--success{background:#22c55e1f;color:var(--color-success)}.doc-card__status--failed{background:#ef44441f;color:var(--color-error)}.doc-card__status--pending{background:#f59e0b1f;color:var(--color-warning);animation:pulse 1.6s ease-in-out infinite}@keyframes pulse{0%,to{opacity:1}50%{opacity:.5}}.doc-card__actions{display:flex;justify-content:flex-end}.doc-card__btn{font-size:11px;font-weight:500;color:var(--text-muted);background:transparent;border:1px solid transparent;border-radius:var(--radius-full);padding:3px 10px;cursor:pointer;transition:var(--transition-fast)}.doc-card__btn:hover{background:#ef44441a;border-color:#ef44444d;color:var(--color-error)}.doc-upload-zone{display:flex;flex-direction:column;align-items:center;gap:var(--space-2);padding:var(--space-5) var(--space-4);border:2px dashed var(--border-color);border-radius:var(--radius-md);margin-top:var(--space-4);cursor:pointer;transition:border-color .2s ease,background .2s ease;text-align:center}.doc-upload-zone:hover,.doc-upload-zone:focus{border-color:var(--color-primary);background:var(--color-primary-light);outline:none}.doc-upload-zone.active{border-color:var(--color-primary);background:var(--color-primary-light);box-shadow:0 0 0 3px #7c6fff26}.doc-upload-zone__icon{font-size:28px;opacity:.6}.doc-upload-zone__text{font-size:13px;color:var(--text-secondary)}.doc-upload-zone__text u{color:var(--color-primary);text-decoration-color:var(--color-primary)}.doc-upload-zone__hint{font-size:11px;color:var(--text-muted)}.doc-upload-progress{margin-top:var(--space-3)}.doc-upload-progress__label{font-size:12px;color:var(--text-secondary);margin-bottom:var(--space-1)}.doc-upload-progress__bar{height:4px;background:var(--bg-hover);border-radius:2px;overflow:hidden}.doc-upload-progress__fill{height:100%;background:var(--color-primary);border-radius:2px;transition:width .15s ease}.doc-empty{display:flex;flex-direction:column;align-items:center;text-align:center;padding:var(--space-8) var(--space-4);gap:var(--space-2)}.doc-empty__icon{font-size:40px;opacity:.4}.doc-empty__title{font-size:15px;font-weight:600;color:var(--text-primary);margin:0}.doc-empty__body{font-size:13px;color:var(--text-secondary);margin:0}.doc-empty__btn{margin-top:var(--space-3);font-size:13px;font-weight:600;color:var(--text-on-primary);background:var(--color-primary);border:none;border-radius:var(--radius-full);padding:8px 18px;cursor:pointer;transition:var(--transition-fast)}.doc-empty__btn:hover:not(:disabled){background:var(--color-primary-hover)}.doc-empty__btn:disabled{opacity:.45;cursor:not-allowed}@media (min-width: 1200px){.app-shell.drawer-open .main-content{margin-right:var(--drawer-w);transition:margin-right .3s cubic-bezier(.4,0,.2,1)}.app-shell .main-content{transition:margin-right .3s cubic-bezier(.4,0,.2,1)}}.call-stage{position:relative;flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;background:var(--bg-app);overflow:hidden;min-height:0}.call-stage:before{content:"";position:absolute;top:0;right:0;bottom:0;left:0;background:radial-gradient(ellipse 60% 55% at 50% 45%,rgba(124,111,255,.06) 0%,transparent 70%);pointer-events:none}.call-avatar-wrapper{display:flex;flex-direction:column;align-items:center;gap:var(--space-5);z-index:1}.call-avatar{position:relative;display:flex;align-items:center;justify-content:center}.avatar-glow{position:absolute;top:-24px;right:-24px;bottom:-24px;left:-24px;display:flex;align-items:center;justify-content:center;pointer-events:none}.avatar-glow__ring{position:absolute;border-radius:50%;border:1px solid rgba(124,111,255,.15);transition:opacity .4s ease}.avatar-glow__ring--outer{top:0;right:0;bottom:0;left:0;box-shadow:0 0 32px #7c6fff1a}.avatar-glow__ring--inner{top:12px;right:12px;bottom:12px;left:12px;border-color:#7c6fff33;box-shadow:0 0 20px #7c6fff26}.avatar-glow.speaking .avatar-glow__ring--outer{animation:glowRingPulseOuter 1.5s ease-in-out infinite}.avatar-glow.speaking .avatar-glow__ring--inner{animation:glowRingPulseInner 1.5s ease-in-out infinite .15s}@keyframes glowRingPulseOuter{0%,to{box-shadow:0 0 20px #7c6fff26;border-color:#7c6fff33}50%{box-shadow:0 0 60px #7c6fff73,0 0 100px #7c6fff26;border-color:#7c6fff80}}@keyframes glowRingPulseInner{0%,to{box-shadow:0 0 12px #7c6fff33;border-color:#7c6fff40}50%{box-shadow:0 0 36px #7c6fff8c;border-color:#7c6fff99}}.call-avatar__circle{position:relative;width:180px;height:180px;border-radius:50%;background:var(--bg-card);border:3px solid rgba(124,111,255,.3);display:flex;align-items:center;justify-content:center;box-shadow:0 0 24px #7c6fff33,var(--shadow-lg);transition:border-color .4s ease,box-shadow .4s ease;z-index:1}.call-avatar.speaking .call-avatar__circle{animation:speakingGlow 1.5s ease-in-out infinite;border-color:#7c6fff99}@keyframes speakingGlow{0%,to{box-shadow:0 0 20px #7c6fff4d}50%{box-shadow:0 0 40px #7c6fff99,0 0 80px #7c6fff33}}.call-avatar__emoji{font-size:80px;line-height:1;-webkit-user-select:none;user-select:none}.call-avatar__info{text-align:center;display:flex;flex-direction:column;gap:var(--space-1)}.call-avatar__name{font-size:22px;font-weight:700;color:var(--text-primary);margin:0;letter-spacing:-.01em}.call-avatar__role{font-size:14px;color:var(--text-secondary);margin:0;max-width:300px;line-height:1.4}@media (max-width: 767px){.call-avatar__circle{width:130px;height:130px}.call-avatar__emoji{font-size:58px}.call-avatar__name{font-size:18px}.call-avatar__role{font-size:13px}}.call-bar{position:absolute;bottom:32px;left:50%;transform:translate(-50%);display:flex;align-items:center;gap:0;background:var(--call-bar-bg);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border:1px solid var(--call-bar-border);border-radius:var(--radius-full);padding:10px 16px;z-index:10;box-shadow:var(--shadow-lg);white-space:nowrap}.call-bar__zone{display:flex;align-items:center;gap:var(--space-2);padding:0 var(--space-2)}.call-bar__divider{width:1px;height:32px;background:#ffffff0f;flex-shrink:0}.call-btn{width:var(--call-btn-size);height:var(--call-btn-size);border-radius:50%;border:1px solid transparent;background:transparent;color:var(--text-primary);font-size:20px;display:flex;align-items:center;justify-content:center;cursor:pointer;transition:background .15s ease,border-color .15s ease,transform .15s ease;flex-shrink:0}.call-btn:hover:not(:disabled){background:#ffffff1a}.call-btn:active:not(:disabled){transform:scale(.95)}.call-btn.active{background:var(--color-primary-light);border-color:var(--color-primary);color:var(--color-primary)}.call-btn.danger,.call-btn--end{background:var(--call-end-bg);color:#fff;font-size:18px;transform:rotate(135deg);width:56px;height:56px}.call-btn.danger:hover:not(:disabled),.call-btn--end:hover:not(:disabled){background:var(--call-end-hover);transform:rotate(135deg) scale(1.05)}.call-btn.danger:active:not(:disabled),.call-btn--end:active:not(:disabled){transform:rotate(135deg) scale(.95)}.call-btn:disabled{opacity:.35;cursor:not-allowed}.call-btn.mic-muted{border-color:var(--color-error);color:var(--color-error)}.mic-zone{display:flex;align-items:center;gap:var(--space-2)}.voice-dots{display:flex;align-items:flex-end;gap:3px;height:20px;padding-bottom:2px}.voice-dot{width:3px;background:var(--text-primary);border-radius:2px;height:6px;animation:voiceLevel .6s ease-in-out infinite;transition:background .2s ease}.voice-dot.paused{animation:none;height:4px;background:var(--text-muted)}.voice-dot:nth-child(1){animation-delay:0s;animation-duration:.55s}.voice-dot:nth-child(2){animation-delay:.1s;animation-duration:.65s}.voice-dot:nth-child(3){animation-delay:.2s;animation-duration:.5s}.voice-dot:nth-child(4){animation-delay:.3s;animation-duration:.7s}@keyframes voiceLevel{0%,to{height:4px}50%{height:18px}}.call-join-screen{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:var(--space-5);background:var(--bg-app);position:relative;overflow:hidden}.call-join-screen:before{content:"";position:absolute;top:0;right:0;bottom:0;left:0;background:radial-gradient(ellipse 60% 55% at 50% 45%,rgba(124,111,255,.06) 0%,transparent 70%);pointer-events:none}.call-join__avatar-wrap{display:flex;flex-direction:column;align-items:center;gap:var(--space-5);z-index:1}.call-join__status{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--text-secondary);background:var(--bg-card);border:1px solid var(--border-color);border-radius:var(--radius-full);padding:6px 14px;z-index:1}.call-join__status-dot{width:8px;height:8px;border-radius:50%;background:var(--color-success);animation:statusPulse 2s ease-in-out infinite;flex-shrink:0}@keyframes statusPulse{0%,to{opacity:1;transform:scale(1)}50%{opacity:.6;transform:scale(.85)}}.call-join__btn{display:flex;align-items:center;gap:10px;padding:14px 36px;background:var(--color-success, #22c55e);color:#fff;border:none;border-radius:var(--radius-full);font-size:16px;font-weight:600;cursor:pointer;z-index:1;transition:transform .15s ease,box-shadow .15s ease,background .15s ease;box-shadow:0 4px 20px #22c55e59}.call-join__btn:hover{transform:scale(1.04);box-shadow:0 6px 28px #22c55e80;background:#16a34a}.call-join__btn:active{transform:scale(.98)}.call-join__btn-icon{font-size:18px;line-height:1}.call-join__hint{font-size:12px;color:var(--text-muted);margin:0;z-index:1}@media (max-width: 767px){.call-bar{bottom:20px;padding:8px 12px}.call-bar__zone{padding:0 var(--space-1)}.call-btn{width:40px;height:40px;font-size:17px}.call-btn--end,.call-btn.danger{width:46px;height:46px;font-size:16px}.voice-dots{display:none}}