KennyOry commited on
Commit
d10311f
·
verified ·
1 Parent(s): bf92895

Upload 4 files

Browse files
Files changed (4) hide show
  1. Dockerfile +34 -0
  2. app.py +425 -0
  3. requirements.txt +5 -0
  4. template/index.html +942 -0
Dockerfile ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10-slim
2
+
3
+ WORKDIR /code
4
+
5
+ RUN apt-get update && apt-get install -y \
6
+ build-essential \
7
+ curl \
8
+ git \
9
+ git-lfs \
10
+ libgomp1 \
11
+ libgl1 \
12
+ && rm -rf /var/lib/apt/lists/*
13
+
14
+ RUN git lfs install
15
+
16
+ ENV HF_HUB_ENABLE_XET=0
17
+
18
+ WORKDIR /app
19
+
20
+ COPY requirements.txt .
21
+ RUN pip install --no-cache-dir -r requirements.txt
22
+
23
+ RUN apt-get purge -y build-essential \
24
+ && apt-get autoremove -y --purge \
25
+ && apt-get clean \
26
+ && rm -rf /var/lib/apt/lists/*
27
+
28
+ RUN mkdir -p /tmp/models && chmod -R 777 /tmp/models
29
+
30
+ COPY . .
31
+
32
+ EXPOSE 7860
33
+
34
+ CMD ["python", "app.py"]
app.py ADDED
@@ -0,0 +1,425 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from flask import Flask, render_template, request, Response, jsonify
2
+ from llama_cpp import Llama
3
+ import logging
4
+ import time
5
+ import requests
6
+ import re
7
+ import threading
8
+ import queue
9
+ import json
10
+ from huggingface_hub import hf_hub_download
11
+ import os
12
+ import html
13
+
14
+ app = Flask(__name__)
15
+ app.secret_key = 'super_secret_key'
16
+
17
+ message_queue = queue.Queue()
18
+
19
+ # Конфигурация модели
20
+ MODEL_REPO = "QuantFactory/Meta-Llama-3-8B-Instruct-GGUF"
21
+ MODEL_FILE = "Meta-Llama-3-8B-Instruct.Q5_K_M.gguf"
22
+
23
+ # Загрузка модели при запуске
24
+ MODEL_PATH = hf_hub_download(
25
+ repo_id=MODEL_REPO,
26
+ filename=MODEL_FILE,
27
+ cache_dir="/tmp/models",
28
+ local_files_only=False,
29
+ force_download=False,
30
+ resume_download=None,
31
+ etag_timeout=30,
32
+ token=None,
33
+ )
34
+
35
+ N_CTX = 4096
36
+
37
+ SEARCH_API = "https://serpapi.com/search"
38
+ API_KEY = "31c06fe621064f426c444cbdae5bd3821dd0572a6d23f445896ad5f3df6dc634"
39
+ MAX_RESULTS = 7
40
+
41
+ llm = Llama(
42
+ model_path=MODEL_PATH,
43
+ n_ctx=N_CTX,
44
+ n_threads=8,
45
+ n_gpu_layers=35,
46
+ temperature=0.2,
47
+ top_k=20,
48
+ top_p=0.8,
49
+ repeat_penalty=1.25
50
+ )
51
+
52
+ SYSTEM_PROMPT = """
53
+ Ты PrintMaster, сервисный инженер по печатной технике. Строго соблюдай правила:
54
+ 1. Формат ответа ТОЛЬКО:
55
+ **Проблема:** [1 предложение]
56
+ **Решение:** [нумерованные шаги]
57
+ 2. После решения НИЧЕГО не добавляй
58
+ 3. Используй ТОЛЬКО корректные технические термины, если не знаешь как правильно перевести - пиши наименование на английском языке
59
+ 4. Решение должно содержать 3-7 четких шагов
60
+ 5. Каждый шаг должен быть полным предложением
61
+ 6. Не используй сокращения
62
+ 7. Запрещено добавлять примечания, предупреждения или рекомендации
63
+ 8. Отвечай ТОЛЬКО на русском языке
64
+ """
65
+
66
+ OFFICIAL_DOMAINS = {
67
+ 'sansamung': ['samsung.com']
68
+ }
69
+
70
+ BLACKLISTED_DOMAINS = [
71
+ 'tikitokitok.com'
72
+ ]
73
+
74
+ logging.basicConfig(
75
+ level=logging.INFO,
76
+ format='%(asctime)s - %(levelname)s - %(message)s',
77
+ handlers=[
78
+ logging.FileHandler("/tmp/printer_assistant.log"),
79
+ logging.StreamHandler()
80
+ ]
81
+ )
82
+
83
+ def generate_search_query(prompt: str) -> dict:
84
+ """Используем ИИ для извлечения структурированной информации"""
85
+ BRAND_ALIASES = {
86
+ "hp": "HP", "hewlett-packard": "HP", "hewlett packard": "HP",
87
+ "хп": "HP", "эйчпи": "HP", "h-p": "HP",
88
+ "коника": "Konica Minolta", "конике": "Konica Minolta",
89
+ "коника минолта": "Konica Minolta", "konica": "Konica Minolta",
90
+ "кэнон": "Canon", "кэноне": "Canon", "канон": "Canon",
91
+ "каноне": "Canon", "кенон": "Canon", "canon": "Canon",
92
+ "ксерокс": "Xerox", "ксероксе": "Xerox", "xerox": "Xerox",
93
+ "эпсон": "Epson", "epson": "Epson",
94
+ "братер": "Brother", "brother": "Brother",
95
+ "рико": "Ricoh", "ricoh": "Ricoh",
96
+ "киосера": "Kyocera", "kyocera": "Kyocera",
97
+ "лексмарк": "Lexmark", "lexmark": "Lexmark",
98
+ "самсунг": "Samsung", "samsung": "Samsung"
99
+ }
100
+
101
+ extraction_prompt = f"""
102
+ <|system|>Извлеки информацию из запроса в формате JSON:
103
+ - brand: английское название бренда (Konica Minolta, Canon и т.д.)
104
+ - model: модель оборудования
105
+ - error_code: код ошибки (если есть)
106
+ - problem_description: краткое описание проблемы на русском
107
+
108
+ Примеры:
109
+ Запрос: "коника с308 выдает ошибку c255c"
110
+ Ответ: {{"brand": "Konica Minolta", "model": "bizhub C308", "error_code": "C255C", "problem_description": "ошибка C255C"}}
111
+
112
+ Запрос: "не печатает hp laserjet 1020"
113
+ Ответ: {{"brand": "HP", "model": "LaserJet 1020", "error_code": "", "problem_description": "принтер не печатает"}}
114
+
115
+ Запрос: "Hewlett-Packard Color LJ MFP E 77825 Ошибка 63.00.41"
116
+ Ответ: {{"brand": "HP", "model": "Color LaserJet MFP E77825", "error_code": "63.00.41", "problem_description": "ошибка 63.00.41"}}
117
+ </s>
118
+ <|user|>{prompt}</s>
119
+ <|assistant|>
120
+ """
121
+
122
+ response = llm(
123
+ extraction_prompt,
124
+ max_tokens=250,
125
+ stop=["</s>"],
126
+ temperature=0.1
127
+ )
128
+
129
+ json_text = response['choices'][0]['text'].strip()
130
+
131
+ try:
132
+ # Удаляем все символы до первой { и после последней }
133
+ json_start = json_text.find('{')
134
+ json_end = json_text.rfind('}') + 1
135
+
136
+ if json_start == -1 or json_end == 0:
137
+ raise ValueError("JSON не найден в ответе")
138
+
139
+ json_str = json_text[json_start:json_end]
140
+ norm_data = json.loads(json_str)
141
+
142
+ # Нормализация бренда
143
+ brand_lower = norm_data['brand'].lower()
144
+ norm_data['brand'] = BRAND_ALIASES.get(brand_lower, norm_data['brand'])
145
+
146
+ # Формируем поисковый запрос
147
+ if norm_data['error_code']:
148
+ search_query = f"{norm_data['brand']} {norm_data['model']} error code {norm_data['error_code']}"
149
+ else:
150
+ search_query = f"{norm_data['brand']} {norm_data['model']} {norm_data['problem_description']} troubleshooting"
151
+
152
+ norm_data['search_query'] = re.sub(r'\s+', ' ', search_query).strip()
153
+ return norm_data
154
+
155
+ except Exception as e:
156
+ message_queue.put(('log', f"❌ Ошибка извлечения данных: {str(e)}"))
157
+ message_queue.put(('log', f"🔍 Ответ модели: {json_text}"))
158
+
159
+ # Fallback: извлекаем данные вручную
160
+ error_match = re.search(r'(\b[CcEe]\s?-?\s?\d{2,4}[A-Za-z]?\b|\d{2,3}\.\d{2}\.\d{2,3})', prompt)
161
+ model_match = re.search(
162
+ r'(?:\b(?:Color\s*)?(?:LaserJet|LJ|Phaser|image?CLASS|bizhub|WorkCentre|AltaLink|VersaLink|Multifunc|MFP)\s*[A-Za-z]?\s*\d{2,5}[A-Za-z]*(?:\s*\w+)?\b',
163
+ prompt,
164
+ re.IGNORECASE
165
+ )
166
+ brand = next((alias for name, alias in BRAND_ALIASES.items()
167
+ if name in prompt.lower()), '')
168
+
169
+ return {
170
+ 'brand': brand,
171
+ 'model': model_match.group(0) if model_match else '',
172
+ 'error_code': error_match.group(0).replace(' ', '') if error_match else '',
173
+ 'problem_description': prompt,
174
+ 'search_query': prompt
175
+ }
176
+
177
+ def web_search(query: str) -> tuple:
178
+ """Поиск через SerpAPI (Google) с поддержкой featured_snippet"""
179
+ try:
180
+ message_queue.put(('log', f"🔍 Провожу поиск по запросу: {query}"))
181
+ start_time = time.time()
182
+
183
+ params = {
184
+ "api_key": API_KEY,
185
+ "engine": "google",
186
+ "q": query,
187
+ "hl": "ru",
188
+ "gl": "ru",
189
+ "num": 15,
190
+ "safe": "off",
191
+ }
192
+
193
+ response = requests.get(SEARCH_API, params=params, timeout=20)
194
+ response.raise_for_status()
195
+ data = response.json()
196
+
197
+ combined_content = ""
198
+ sources = []
199
+ official_sources = []
200
+
201
+ # Обработка featured snippet
202
+ featured_snippet = data.get("featured_snippet", {})
203
+ if featured_snippet:
204
+ snippet = featured_snippet.get("snippet", "")
205
+ if snippet:
206
+ combined_content += f"[Автоответ Google]\n{snippet}\n\n"
207
+ sources.insert(0, {
208
+ "title": "Google Автоответ",
209
+ "url": f"https://www.google.com/search?q={requests.utils.quote(query)}"
210
+ })
211
+
212
+ # Обработка органических результатов
213
+ organic_results = data.get("organic_results", [])
214
+ for res in organic_results:
215
+ title = res.get("title", "Без заголовка")
216
+ link = res.get("link", "#")
217
+ snippet = res.get("snippet", "") or ""
218
+
219
+ # Проверка домена
220
+ domain = re.search(r'https?://([^/]+)', link)
221
+ if not domain:
222
+ continue
223
+ domain = domain.group(1).lower()
224
+
225
+ # Фильтр по чёрному списку
226
+ if any(bl_domain in domain for bl_domain in BLACKLISTED_DOMAINS):
227
+ continue
228
+
229
+ # Проверка официальных источников
230
+ is_official = False
231
+ for brand, domains in OFFICIAL_DOMAINS.items():
232
+ if any(official_domain in domain for official_domain in domains):
233
+ is_official = True
234
+ break
235
+
236
+ cleaned_snippet = re.sub(r'<[^>]+>|\n', ' ', snippet)
237
+ source_data = {"title": title, "url": link}
238
+
239
+ if is_official:
240
+ official_sources.append(source_data)
241
+ combined_content = f"[[Официальный источник]] {title}\n{cleaned_snippet}\n\n" + combined_content
242
+ else:
243
+ sources.append(source_data)
244
+ combined_content += f"[[Источник]] {title}\n{cleaned_snippet}\n\n"
245
+
246
+ # Приоритет для официальных источников
247
+ sources = official_sources + sources[:MAX_RESULTS]
248
+
249
+ elapsed = time.time() - start_time
250
+ message_queue.put(('log', f"✅ Поиск был произведен за {elapsed:.2f}с"))
251
+ return combined_content[:8000], sources
252
+
253
+ except Exception as e:
254
+ error_msg = f"❌ SerpAPI ошибка: {str(e)}"
255
+ message_queue.put(('log', error_msg))
256
+ return f"Поиск недоступен: {str(e)}", []
257
+
258
+ def clean_response(response: str) -> str:
259
+ # Удаляем служебные теги
260
+ response = re.sub(r'</?assistant>|<\|system\|>|</s>', '', response, flags=re.IGNORECASE)
261
+
262
+ # Удаляем дублирование разделов
263
+ response = re.sub(r'(\*\*Проблема:\*\*.+?)(\*\*Проблема:\*\*)', r'\1', response, flags=re.DOTALL)
264
+ response = re.sub(r'(\*\*Решение:\*\*.+?)(\*\*Решение:\*\*)', r'\1', response, flags=re.DOTALL)
265
+ response = re.sub(r'(\*\*Источники:\*\*.+?)(\*\*Источники:\*\*)', r'\1', response, flags=re.DOTALL)
266
+
267
+ # Убираем лишние переносы в нумерованных списках
268
+ response = re.sub(r'(\d+)\.\s*\n', r'\1. ', response)
269
+ response = re.sub(r'\n\s*\n', '\n\n', response) # Убираем лишние пробелы между абзацами
270
+ response = re.sub(r'[ \t]+', ' ', response) # Заменяем множественные пробелы/табы
271
+
272
+ # Убираем [Решение]
273
+ response = re.sub(r'\s*\[Решение\]\s*', '', response)
274
+
275
+ # Удаляем пустые разделы
276
+ response = re.sub(r'\*\*Источники:\*\*\s*$', '', response)
277
+
278
+ # Удаляем HTML-теги и экранируем символы
279
+ response = html.escape(response)
280
+
281
+ # Обрезаем до последней точки
282
+ last_dot = response.rfind('.')
283
+ if last_dot != -1:
284
+ response = response[:last_dot+1]
285
+
286
+ return response.strip()
287
+
288
+ def process_query(prompt: str):
289
+ try:
290
+ start_time = time.time()
291
+ message_queue.put(('log', f"👤 Запрос: {prompt}"))
292
+ message_queue.put(('log', f"⚙️ Извлекаю параметры из входящего запроса"))
293
+
294
+ # Извлекаем данные
295
+ norm_data = generate_search_query(prompt)
296
+ message_queue.put(('log', f"✅ Извлечено: {json.dumps(norm_data, ensure_ascii=False)}"))
297
+
298
+ # Выполняем поиск
299
+ search_data, sources = web_search(norm_data['search_query'])
300
+ message_queue.put(('log', f"📚 Результаты поиска: {len(search_data)} символов, источники: {len(sources)}"))
301
+
302
+ message_queue.put(('log', f"⚙️ Определяю проблему"))
303
+
304
+ # Формируем промпт для анализа проблемы
305
+ problem_analysis_prompt = f"""
306
+ <|system|>
307
+ Сформулируй СУТЬ проблемы в одном предложении
308
+ Суммируй только факты из поисковых данных
309
+ Пиши только официальные технические термины
310
+ ТОЛЬКО СУТЬ без предположений и рекомендаций
311
+ Сделай это максимально конкретно
312
+ Это должно быть короткое предложение по типу "Ошибка C255C: поломка блока проявки"
313
+ </s>
314
+ <|user|>
315
+ Запрос: {prompt}
316
+ Данные поиска:
317
+ {search_data[:2000]} [обрезка для экономии токенов]
318
+ </s>
319
+ <|assistant|>
320
+ """
321
+
322
+ problem_response = llm(
323
+ problem_analysis_prompt,
324
+ max_tokens=150,
325
+ temperature=0.2,
326
+ stop=["</s>", "<|user|>"]
327
+ )
328
+ extracted_problem = problem_response['choices'][0]['text'].strip()
329
+
330
+ if not extracted_problem or len(extracted_problem) < 5:
331
+ extracted_problem = f"Неисправность {norm_data['brand']} {norm_data['model']}"
332
+
333
+ message_queue.put(('log', f"🧩 Проблема определена: {extracted_problem}"))
334
+
335
+ # Формируем финальный промпт
336
+ response_prompt = f"""
337
+ <|system|>{SYSTEM_PROMPT}
338
+ Контекст:
339
+ Бренд: {norm_data['brand']}
340
+ Модель: {norm_data['model']}
341
+ Код ошибки: {norm_data['error_code']}
342
+ Суть проблемы: {extracted_problem}
343
+ Данные поиска:
344
+ {search_data}
345
+ </s>
346
+ <|user|>Проблема: {prompt}</s>
347
+ <|assistant|>
348
+ """
349
+
350
+ message_queue.put(('log', "🧠 Генерирую ответ..."))
351
+ message_queue.put(('response_start', ""))
352
+
353
+ # Генерируем ответ
354
+ response = llm.create_completion(
355
+ response_prompt,
356
+ max_tokens=1200,
357
+ temperature=0.3,
358
+ stop=["</s>", "<|user|>"],
359
+ stream=True
360
+ )
361
+
362
+ full_response = ""
363
+ for output in response:
364
+ text_chunk = output['choices'][0]['text']
365
+ message_queue.put(('response_chunk', text_chunk))
366
+ full_response += text_chunk
367
+
368
+ # Очищаем и структурируем ответ
369
+ final_response = clean_response(full_response)
370
+
371
+ # Добавляем критически важные шаги, если они пропущены
372
+ critical_steps = []
373
+ if "перепрошивк" in search_data.lower() or "firmware" in search_data.lower():
374
+ critical_steps.append("Выполните перепрошивку устройства до последней версии")
375
+ if "замените" in search_data.lower() or "replace" in search_data.lower():
376
+ critical_steps.append("При необходимости замените неисправные компоненты на оригинальные")
377
+
378
+ if critical_steps:
379
+ solution_section = re.search(r'\*\*Решение:\*\*(.+?)',
380
+ final_response,
381
+ flags=re.DOTALL)
382
+ if solution_section:
383
+ updated_solution = solution_section.group(1) + "\n" + "\n".join(critical_steps)
384
+ final_response = final_response.replace(solution_section.group(1), updated_solution)
385
+
386
+ message_queue.put(('response_end', final_response))
387
+ message_queue.put(('sources', json.dumps(sources)))
388
+
389
+ total_time = time.time() - start_time
390
+ message_queue.put(('log', f"💡 Ответ: {final_response}"))
391
+ message_queue.put(('log', f"⏱ Время: {total_time:.1f}с"))
392
+ message_queue.put(('done', ''))
393
+
394
+ except Exception as e:
395
+ error_msg = f"❌ Ошибка: {str(e)}"
396
+ message_queue.put(('log', error_msg))
397
+ message_queue.put(('response', "\n⚠️ Ошибка обработки запроса"))
398
+ message_queue.put(('done', ''))
399
+
400
+ @app.route('/')
401
+ def index():
402
+ return render_template('index.html')
403
+
404
+ @app.route('/ask', methods=['POST'])
405
+ def ask():
406
+ user_input = request.form['message']
407
+ thread = threading.Thread(target=process_query, args=(user_input,))
408
+ thread.daemon = True
409
+ thread.start()
410
+ return jsonify({'status': 'processing'})
411
+
412
+ @app.route('/stream')
413
+ def stream():
414
+ def generate():
415
+ while True:
416
+ if not message_queue.empty():
417
+ msg_type, content = message_queue.get()
418
+ data = json.dumps({"type": msg_type, "content": content})
419
+ yield f"data: {data}\n\n"
420
+ else:
421
+ time.sleep(0.1)
422
+ return Response(generate(), mimetype='text/event-stream')
423
+
424
+ if __name__ == '__main__':
425
+ app.run(host='0.0.0.0', port=7860, debug=False)
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ flask
2
+ requests
3
+ huggingface-hub==0.19.4
4
+ llama-cpp-python==0.3.5 --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu
5
+ gunicorn
template/index.html ADDED
@@ -0,0 +1,942 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="ru">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>PrintMaster | Сервисный ассистент</title>
7
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
8
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
9
+ <style>
10
+ :root {
11
+ --primary: #4361ee;
12
+ --primary-light: #4895ef;
13
+ --secondary: #3f37c9;
14
+ --dark: #0e1424;
15
+ --light: #f8f9fa;
16
+ --gray: #e9ecef;
17
+ --success: #2ecc71;
18
+ --warning: #f39c12;
19
+ --danger: #e74c3c;
20
+ --transition: all 0.3s ease;
21
+ }
22
+
23
+ * {
24
+ margin: 0;
25
+ padding: 0;
26
+ box-sizing: border-box;
27
+ font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
28
+ }
29
+
30
+ body {
31
+ background: linear-gradient(135deg, #f5f7fa 0%, #e4e7f1 100%);
32
+ color: var(--dark);
33
+ min-height: 100vh;
34
+ padding: 20px;
35
+ }
36
+
37
+ .app-container {
38
+ max-width: 1200px;
39
+ margin: 0 auto;
40
+ display: grid;
41
+ grid-template-columns: 1fr 300px;
42
+ gap: 20px;
43
+ }
44
+
45
+ .card {
46
+ background: rgba(255, 255, 255, 0.92);
47
+ border-radius: 16px;
48
+ border: none;
49
+ box-shadow: 0 8px 32px rgba(31, 38, 135, 0.1);
50
+ backdrop-filter: blur(4px);
51
+ overflow: hidden;
52
+ transition: var(--transition);
53
+ }
54
+
55
+ .card:hover {
56
+ box-shadow: 0 12px 40px rgba(31, 38, 135, 0.15);
57
+ }
58
+
59
+ .card-header {
60
+ background: white;
61
+ border-bottom: 1px solid rgba(0, 0, 0, 0.05);
62
+ padding: 20px;
63
+ font-weight: 600;
64
+ display: flex;
65
+ align-items: center;
66
+ gap: 12px;
67
+ }
68
+
69
+ .card-body {
70
+ padding: 0;
71
+ }
72
+
73
+ .chat-container {
74
+ height: calc(100vh - 200px);
75
+ display: flex;
76
+ flex-direction: column;
77
+ padding: 20px;
78
+ overflow-y: auto;
79
+ }
80
+
81
+ .message {
82
+ max-width: 80%;
83
+ padding: 16px 20px;
84
+ margin-bottom: 16px;
85
+ border-radius: 18px;
86
+ line-height: 1.5;
87
+ position: relative;
88
+ animation: fadeIn 0.3s ease;
89
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.03);
90
+ will-change: transform, opacity;
91
+ }
92
+
93
+ @keyframes fadeIn {
94
+ from { opacity: 0; transform: translateY(10px); }
95
+ to { opacity: 1; transform: translateY(0); }
96
+ }
97
+
98
+ .user-message {
99
+ background: var(--primary);
100
+ color: white;
101
+ align-self: flex-end;
102
+ border-bottom-right-radius: 4px;
103
+ }
104
+
105
+ .bot-message {
106
+ background: white;
107
+ border: 1px solid var(--gray);
108
+ align-self: flex-start;
109
+ border-bottom-left-radius: 4px;
110
+ }
111
+
112
+ .message-header {
113
+ font-size: 12px;
114
+ opacity: 0.8;
115
+ margin-bottom: 8px;
116
+ display: flex;
117
+ align-items: center;
118
+ gap: 6px;
119
+ }
120
+
121
+ .input-area {
122
+ padding: 20px;
123
+ border-top: 1px solid rgba(0, 0, 0, 0.05);
124
+ background: white;
125
+ }
126
+
127
+ .input-group {
128
+ display: flex;
129
+ gap: 10px;
130
+ }
131
+
132
+ #user-input {
133
+ flex: 1;
134
+ border: 1px solid var(--gray);
135
+ border-radius: 12px;
136
+ padding: 14px 20px;
137
+ font-size: 16px;
138
+ transition: var(--transition);
139
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.03);
140
+ }
141
+
142
+ #user-input:focus {
143
+ border-color: var(--primary-light);
144
+ box-shadow: 0 0 0 3px rgba(67, 97, 238, 0.15);
145
+ outline: none;
146
+ }
147
+
148
+ #send-btn {
149
+ background: var(--primary);
150
+ color: white;
151
+ border: none;
152
+ border-radius: 12px;
153
+ padding: 0 24px;
154
+ font-weight: 600;
155
+ transition: var(--transition);
156
+ display: flex;
157
+ align-items: center;
158
+ justify-content: center;
159
+ box-shadow: 0 4px 6px rgba(67, 97, 238, 0.2);
160
+ }
161
+
162
+ #send-btn:hover {
163
+ background: var(--secondary);
164
+ transform: translateY(-1px);
165
+ }
166
+
167
+ #send-btn:active {
168
+ transform: translateY(1px);
169
+ }
170
+
171
+ .typing-indicator {
172
+ display: flex;
173
+ gap: 6px;
174
+ padding: 20px;
175
+ }
176
+
177
+ .typing-dot {
178
+ width: 10px;
179
+ height: 10px;
180
+ background: var(--primary-light);
181
+ border-radius: 50%;
182
+ animation: bounce 1.4s infinite ease-in-out both;
183
+ }
184
+
185
+ .typing-dot:nth-child(1) { animation-delay: -0.32s; }
186
+ .typing-dot:nth-child(2) { animation-delay: -0.16s; }
187
+
188
+ @keyframes bounce {
189
+ 0%, 80%, 100% { transform: scale(0); }
190
+ 40% { transform: scale(1); }
191
+ }
192
+
193
+ .log-entry {
194
+ padding: 14px 20px;
195
+ border-bottom: 1px solid rgba(0, 0, 0, 0.03);
196
+ font-size: 14px;
197
+ line-height: 1.5;
198
+ animation: fadeIn 0.3s ease;
199
+ }
200
+
201
+ .log-entry:last-child {
202
+ border-bottom: none;
203
+ }
204
+
205
+ .log-timestamp {
206
+ font-size: 11px;
207
+ opacity: 0.6;
208
+ margin-right: 8px;
209
+ }
210
+
211
+ .log-info { color: var(--primary); }
212
+ .log-success { color: var(--success); }
213
+ .log-warning { color: var(--warning); }
214
+ .log-error { color: var(--danger); }
215
+
216
+ .problem-text {
217
+ font-weight: 600;
218
+ color: var(--primary);
219
+ margin-bottom: 8px;
220
+ }
221
+
222
+ .solution-step {
223
+ padding-left: 20px;
224
+ position: relative;
225
+ margin-bottom: 6px;
226
+ }
227
+
228
+ .solution-step:before {
229
+ content: "•";
230
+ position: absolute;
231
+ left: 8px;
232
+ color: var(--primary);
233
+ font-weight: bold;
234
+ }
235
+
236
+ .sources-badge {
237
+ display: inline-flex;
238
+ align-items: center;
239
+ gap: 6px;
240
+ background: rgba(67, 97, 238, 0.1);
241
+ color: var(--primary);
242
+ padding: 6px 12px;
243
+ border-radius: 20px;
244
+ font-size: 14px;
245
+ margin-top: 15px;
246
+ cursor: pointer;
247
+ transition: var(--transition);
248
+ }
249
+
250
+ .sources-badge:hover {
251
+ background: rgba(67, 97, 238, 0.15);
252
+ }
253
+
254
+ .status-indicator {
255
+ display: inline-flex;
256
+ align-items: center;
257
+ gap: 6px;
258
+ font-size: 14px;
259
+ padding: 4px 12px;
260
+ border-radius: 20px;
261
+ background: rgba(46, 204, 113, 0.1);
262
+ color: var(--success);
263
+ }
264
+
265
+ .status-indicator.offline {
266
+ background: rgba(231, 76, 60, 0.1);
267
+ color: var(--danger);
268
+ }
269
+
270
+ .status-dot {
271
+ width: 8px;
272
+ height: 8px;
273
+ border-radius: 50%;
274
+ background: var(--success);
275
+ }
276
+
277
+ .status-indicator.offline .status-dot {
278
+ background: var(--danger);
279
+ }
280
+
281
+ .logo {
282
+ display: flex;
283
+ align-items: center;
284
+ gap: 10px;
285
+ font-weight: 700;
286
+ font-size: 20px;
287
+ color: var(--dark);
288
+ }
289
+
290
+ .logo-icon {
291
+ width: 36px;
292
+ height: 36px;
293
+ background: var(--primary);
294
+ border-radius: 10px;
295
+ display: flex;
296
+ align-items: center;
297
+ justify-content: center;
298
+ color: white;
299
+ }
300
+
301
+ .source-item {
302
+ padding: 12px 16px;
303
+ border-bottom: 1px solid rgba(0, 0, 0, 0.05);
304
+ transition: var(--transition);
305
+ }
306
+
307
+ .source-item:hover {
308
+ background: rgba(67, 97, 238, 0.03);
309
+ }
310
+
311
+ .source-title {
312
+ font-weight: 500;
313
+ margin-bottom: 4px;
314
+ display: block;
315
+ }
316
+
317
+ .source-url {
318
+ font-size: 13px;
319
+ color: #6c757d;
320
+ display: block;
321
+ white-space: nowrap;
322
+ overflow: hidden;
323
+ text-overflow: ellipsis;
324
+ }
325
+
326
+ .empty-state {
327
+ display: flex;
328
+ flex-direction: column;
329
+ align-items: center;
330
+ justify-content: center;
331
+ text-align: center;
332
+ padding: 40px 20px;
333
+ color: #6c757d;
334
+ }
335
+
336
+ .empty-state i {
337
+ font-size: 48px;
338
+ margin-bottom: 20px;
339
+ color: #dee2e6;
340
+ }
341
+
342
+ .empty-state h4 {
343
+ font-weight: 500;
344
+ margin-bottom: 10px;
345
+ color: #495057;
346
+ }
347
+
348
+ .example-badge {
349
+ display: inline-block;
350
+ background: white;
351
+ border: 1px solid var(--gray);
352
+ border-radius: 8px;
353
+ padding: 8px 16px;
354
+ margin: 6px;
355
+ font-size: 14px;
356
+ box-shadow: 0 2px 4px rgba(0,0,0,0.03);
357
+ }
358
+
359
+ .log-container {
360
+ height: calc(100vh - 200px);
361
+ overflow-y: auto;
362
+ }
363
+
364
+ .highlight {
365
+ background-color: #fff9c4;
366
+ padding: 2px 4px;
367
+ border-radius: 4px;
368
+ font-weight: 600;
369
+ }
370
+
371
+ @media (max-width: 768px) {
372
+ .app-container {
373
+ grid-template-columns: 1fr;
374
+ }
375
+
376
+ .chat-container {
377
+ height: 60vh;
378
+ }
379
+
380
+ .log-container {
381
+ height: 30vh;
382
+ }
383
+ }
384
+ </style>
385
+ </head>
386
+ <body>
387
+ <div class="app-container">
388
+ <div class="card main-card">
389
+ <div class="card-header">
390
+ <div class="logo">
391
+ <div class="logo-icon">
392
+ <i class="fas fa-print"></i>
393
+ </div>
394
+ <span>PrintMaster</span>
395
+ </div>
396
+ <div class="ms-auto status-indicator" id="status-indicator">
397
+ <div class="status-dot"></div>
398
+ <span>Подключено</span>
399
+ </div>
400
+ </div>
401
+ <div class="card-body">
402
+ <div class="chat-container" id="chat-container">
403
+ <div class="empty-state">
404
+ <i class="fas fa-comments"></i>
405
+ <h4>Добро пожаловать в PrintMaster</h4>
406
+ <p>Опишите проблему с вашим принтером или МФУ, и я помогу найти решение</p>
407
+ <div class="mt-3">
408
+ <div class="example-badge">HP LaserJet 1020 не печатает</div>
409
+ <div class="example-badge">Konica Minolta C258 ошибка C-2557</div>
410
+ <div class="example-badge">Canon i-SENSYS MF644Cdw зажевывает бумагу</div>
411
+ </div>
412
+ </div>
413
+ </div>
414
+ <div class="input-area">
415
+ <div class="input-group">
416
+ <input type="text" id="user-input" class="form-control" placeholder="Опишите проблему с принтером..." autocomplete="off">
417
+ <button id="send-btn" class="btn">
418
+ <i class="fas fa-paper-plane"></i>
419
+ </button>
420
+ </div>
421
+ </div>
422
+ </div>
423
+ </div>
424
+
425
+ <div class="card logs-card">
426
+ <div class="card-header">
427
+ <i class="fas fa-terminal"></i>
428
+ <span>Журнал обработки</span>
429
+ </div>
430
+ <div class="card-body">
431
+ <div class="log-container" id="logs-container">
432
+ <div class="log-entry log-info">
433
+
434
+ </div>
435
+ </div>
436
+ </div>
437
+ </div>
438
+ </div>
439
+
440
+ <script>
441
+ document.addEventListener('DOMContentLoaded', () => {
442
+ const chatContainer = document.getElementById('chat-container');
443
+ const userInput = document.getElementById('user-input');
444
+ const sendBtn = document.getElementById('send-btn');
445
+ const logsContainer = document.getElementById('logs-container');
446
+ const statusIndicator = document.getElementById('status-indicator');
447
+ const statusDot = statusIndicator.querySelector('.status-dot');
448
+ const statusText = statusIndicator.querySelector('span');
449
+ const welcomeState = document.querySelector('.empty-state');
450
+
451
+ // Создаем стили для подсветки один раз
452
+ const highlightStyle = document.createElement('style');
453
+ highlightStyle.textContent = `
454
+ .highlight {
455
+ background-color: #fff9c4;
456
+ padding: 2px 4px;
457
+ border-radius: 4px;
458
+ font-weight: 600;
459
+ }
460
+ `;
461
+ document.head.appendChild(highlightStyle);
462
+
463
+ let currentSources = [];
464
+ let isProcessing = false;
465
+ let eventSource = null;
466
+ let botMessageId = null;
467
+ let processingLog = [];
468
+ let responseBuffer = '';
469
+ let lastUpdate = 0;
470
+ const UPDATE_THROTTLE = 100; // ms
471
+ let chunkBuffer = "";
472
+ let chunkTimeout = null;
473
+
474
+ // Технические термины для подсветки
475
+ const technicalTerms = [
476
+ 'фузер', 'термистор', 'микроконтроллер', 'перепрошивка',
477
+ 'нагревательный вал', 'контакты', 'разъем', 'плата', 'картридж',
478
+ 'вал', 'дозирующее лезвие', 'чистящее лезвие', 'мусорный бункер',
479
+ 'лоток', 'печка', 'бумагопровод', 'сенсор', 'датчик', 'лазер'
480
+ ];
481
+
482
+ // Создаем регулярные выражения заранее
483
+ const technicalTermRegexes = technicalTerms.map(term => {
484
+ return {
485
+ term: term,
486
+ regex: new RegExp(`(${term})`, 'gi')
487
+ };
488
+ });
489
+
490
+ // Функция добавления временной метки
491
+ function getTimestamp() {
492
+ const now = new Date();
493
+ return now.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit', second:'2-digit'});
494
+ }
495
+
496
+ // Функция добавления сообщения в журнал
497
+ function addLog(message, type = 'info') {
498
+ const logEntry = document.createElement('div');
499
+ logEntry.className = `log-entry log-${type}`;
500
+ logEntry.innerHTML = `
501
+ <span class="log-timestamp">${getTimestamp()}</span>
502
+ ${message}
503
+ `;
504
+ logsContainer.appendChild(logEntry);
505
+ logsContainer.scrollTop = logsContainer.scrollHeight;
506
+ processingLog.push(`[${getTimestamp()}] ${message}`);
507
+ }
508
+
509
+ // Добавление сообщения пользователя
510
+ function addUserMessage(message) {
511
+ // Удаляем пустое состояние, если оно есть
512
+ if (welcomeState && welcomeState.parentElement === chatContainer) {
513
+ chatContainer.innerHTML = '';
514
+ }
515
+
516
+ const messageDiv = document.createElement('div');
517
+ messageDiv.className = 'message user-message';
518
+ messageDiv.innerHTML = `
519
+ <div class="message-header">
520
+ <i class="fas fa-user"></i>
521
+ <span>Вы</span>
522
+ <span class="ms-auto">${getTimestamp()}</span>
523
+ </div>
524
+ <div>${escapeHtml(message)}</div>
525
+ `;
526
+ chatContainer.appendChild(messageDiv);
527
+ chatContainer.scrollTop = chatContainer.scrollHeight;
528
+ }
529
+
530
+ // Добавление сообщения бота
531
+ function addBotMessage(content = '', isTyping = false) {
532
+ // Удаляем пустое состояние, если оно есть
533
+ if (welcomeState && welcomeState.parentElement === chatContainer) {
534
+ chatContainer.innerHTML = '';
535
+ }
536
+
537
+ const messageDiv = document.createElement('div');
538
+ messageDiv.className = 'message bot-message';
539
+
540
+ if (isTyping) {
541
+ messageDiv.id = 'bot-typing';
542
+ messageDiv.innerHTML = `
543
+ <div class="message-header">
544
+ <i class="fas fa-robot"></i>
545
+ <span>PrintMaster</span>
546
+ <span class="ms-auto">${getTimestamp()}</span>
547
+ </div>
548
+ <div class="typing-indicator">
549
+ <div class="typing-dot"></div>
550
+ <div class="typing-dot"></div>
551
+ <div class="typing-dot"></div>
552
+ </div>
553
+ `;
554
+ } else {
555
+ messageDiv.innerHTML = `
556
+ <div class="message-header">
557
+ <i class="fas fa-robot"></i>
558
+ <span>PrintMaster</span>
559
+ <span class="ms-auto">${getTimestamp()}</span>
560
+ </div>
561
+ <div class="message-content">${formatContent(content)}</div>
562
+ `;
563
+ botMessageId = messageDiv.id = 'bot-message-' + Date.now();
564
+ }
565
+
566
+ chatContainer.appendChild(messageDiv);
567
+ chatContainer.scrollTop = chatContainer.scrollHeight;
568
+ return messageDiv;
569
+ }
570
+
571
+ // Обновление сообщения бота с троттлингом
572
+ function updateBotMessage(content) {
573
+ const botMessage = document.getElementById('bot-typing') || document.querySelector('.bot-message:last-child');
574
+ if (botMessage) {
575
+ const contentDiv = botMessage.querySelector('.message-content') ||
576
+ botMessage.querySelector('div:last-child:not(.typing-indicator, .message-header)');
577
+
578
+ if (contentDiv) {
579
+ contentDiv.innerHTML = formatContent(content);
580
+ } else {
581
+ // Если это сообщение с индикатором набора, заменяем его на полноценное сообщение
582
+ botMessage.innerHTML = `
583
+ <div class="message-header">
584
+ <i class="fas fa-robot"></i>
585
+ <span>PrintMaster</span>
586
+ <span class="ms-auto">${getTimestamp()}</span>
587
+ </div>
588
+ <div class="message-content">${formatContent(content)}</div>
589
+ `;
590
+ botMessage.id = 'bot-message-' + Date.now();
591
+ botMessageId = botMessage.id;
592
+ }
593
+
594
+ chatContainer.scrollTop = chatContainer.scrollHeight;
595
+ }
596
+ }
597
+
598
+ // Оптимизированное обновление с троттлингом
599
+ function throttledUpdate(content) {
600
+ const now = Date.now();
601
+ if (now - lastUpdate > UPDATE_THROTTLE) {
602
+ updateBotMessage(content);
603
+ lastUpdate = now;
604
+ }
605
+ }
606
+
607
+ // Форматирование контента с подсветкой технических терминов и структурированием
608
+ function formatContent(content) {
609
+ // Заменяем разделы на стилизованные блоки
610
+ content = content.replace(/\*\*Проблема:\*\*/g,
611
+ '<div class="problem-text">Проблема:</div>');
612
+ content = content.replace(/\*\*Решение:\*\*/g,
613
+ '<div class="mt-2">Решение:</div>');
614
+ content = content.replace(/\*\*Источники:\*\*/g,
615
+ '<div class="mt-2">Источники:</div>');
616
+
617
+ // Форматируем нумерованные списки
618
+ content = content.replace(/(\d+)\.\s*(.+?)(?=\n\d+\.|\n\n|$)/g,
619
+ '<div class="solution-step">$2</div>');
620
+
621
+ // Подсветка технических терминов с использованием предварительно созданных regex
622
+ technicalTermRegexes.forEach(item => {
623
+ content = content.replace(item.regex, '<span class="highlight">$1</span>');
624
+ });
625
+
626
+ return content;
627
+ }
628
+
629
+ // Добавление ссылок на источники
630
+ function addSourceLinks() {
631
+ if (!botMessageId || currentSources.length === 0) return;
632
+
633
+ const messageDiv = document.getElementById(botMessageId);
634
+ if (!messageDiv) return;
635
+
636
+ // Удаляем существующие ссылки, если они есть
637
+ const existingSources = messageDiv.querySelector('.sources-container');
638
+ if (existingSources) existingSources.remove();
639
+
640
+ if (currentSources.length > 0) {
641
+ const sourcesContainer = document.createElement('div');
642
+ sourcesContainer.className = 'sources-container mt-3';
643
+ sourcesContainer.innerHTML = '<div class="font-bold text-gray-700 mb-2 flex items-center"><i class="fas fa-link mr-2"></i> Источники информации</div>';
644
+
645
+ const linksContainer = document.createElement('div');
646
+ linksContainer.className = 'flex flex-wrap gap-2';
647
+
648
+ currentSources.slice(0, 5).forEach((source, index) => {
649
+ const link = document.createElement('span');
650
+ link.className = 'sources-badge';
651
+ link.innerHTML = `<i class="fas fa-external-link-alt mr-1"></i> Источник ${index + 1}`;
652
+ link.dataset.sourceIndex = index;
653
+
654
+ link.addEventListener('click', function(e) {
655
+ e.preventDefault();
656
+ const sourceIndex = parseInt(this.dataset.sourceIndex);
657
+ showSourceDetails(sourceIndex);
658
+ });
659
+
660
+ linksContainer.appendChild(link);
661
+ });
662
+
663
+ sourcesContainer.appendChild(linksContainer);
664
+ messageDiv.appendChild(sourcesContainer);
665
+ chatContainer.scrollTop = chatContainer.scrollHeight;
666
+ }
667
+ }
668
+
669
+ // Показ деталей источника
670
+ function showSourceDetails(sourceIndex) {
671
+ const source = currentSources[sourceIndex];
672
+ if (!source) return;
673
+
674
+ // Создаем модальное окно
675
+ let modal = document.getElementById('source-modal');
676
+ if (!modal) {
677
+ modal = document.createElement('div');
678
+ modal.id = 'source-modal';
679
+ modal.style.cssText = `
680
+ position: fixed;
681
+ top: 0;
682
+ left: 0;
683
+ width: 100%;
684
+ height: 100%;
685
+ background: rgba(0,0,0,0.5);
686
+ display: flex;
687
+ align-items: center;
688
+ justify-content: center;
689
+ z-index: 1000;
690
+ `;
691
+
692
+ modal.innerHTML = `
693
+ <div class="modal-content" style="background: white; border-radius: 12px; width: 90%; max-width: 600px; max-height: 80vh; overflow: auto;">
694
+ <div class="modal-header" style="padding: 15px; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; align-items: center;">
695
+ <h5 style="margin: 0; font-weight: 600;">Источник информации</h5>
696
+ <button id="close-modal" style="background: none; border: none; font-size: 1.5rem; cursor: pointer;">&times;</button>
697
+ </div>
698
+ <div class="modal-body" style="padding: 15px;">
699
+ <h6 style="font-weight: 600; margin-bottom: 5px;">Название:</h6>
700
+ <p id="source-title" style="margin-bottom: 15px;"></p>
701
+
702
+ <h6 style="font-weight: 600; margin-bottom: 5px;">URL:</h6>
703
+ <a id="source-url" href="#" target="_blank" style="color: #4361ee; text-decoration: underline; display: block; margin-bottom: 15px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"></a>
704
+
705
+ <h6 style="font-weight: 600; margin-bottom: 5px;">Фрагмент:</h6>
706
+ <div id="source-snippet" style="background: #f8f9fa; padding: 10px; border-radius: 8px; border-left: 3px solid #4361ee;"></div>
707
+ </div>
708
+ <div class="modal-footer" style="padding: 15px; border-top: 1px solid #eee; text-align: right;">
709
+ <button id="open-source" class="btn btn-primary" style="background: #4361ee; color: white; border: none; padding: 8px 15px; border-radius: 6px; cursor: pointer; margin-right: 10px;">Открыть источник</button>
710
+ <button id="close-modal-footer" class="btn btn-secondary" style="background: #e9ecef; color: #495057; border: none; padding: 8px 15px; border-radius: 6px; cursor: pointer;">Закрыть</button>
711
+ </div>
712
+ </div>
713
+ `;
714
+
715
+ document.body.appendChild(modal);
716
+
717
+ // Обработчики закрытия модального окна
718
+ const closeModal = () => modal.remove();
719
+
720
+ document.getElementById('close-modal').addEventListener('click', closeModal);
721
+ document.getElementById('close-modal-footer').addEventListener('click', closeModal);
722
+
723
+ modal.addEventListener('click', (e) => {
724
+ if (e.target === modal || e.target.closest('#close-modal, #close-modal-footer')) {
725
+ closeModal();
726
+ }
727
+ });
728
+ }
729
+
730
+ // Заполняем данные
731
+ document.getElementById('source-title').textContent = source.title;
732
+ const sourceUrl = document.getElementById('source-url');
733
+ sourceUrl.textContent = source.url;
734
+ sourceUrl.href = source.url;
735
+ document.getElementById('source-snippet').textContent = source.snippet;
736
+
737
+ // Обработчик открытия источника
738
+ document.getElementById('open-source').onclick = () => {
739
+ window.open(source.url, '_blank');
740
+ };
741
+
742
+ // Показываем модальное окно
743
+ document.body.appendChild(modal);
744
+ }
745
+
746
+ // Функция для экранирования HTML
747
+ function escapeHtml(unsafe) {
748
+ return unsafe
749
+ .replace(/&/g, "&amp;")
750
+ .replace(/</g, "<")
751
+ .replace(/>/g, ">")
752
+ .replace(/"/g, "&quot;")
753
+ .replace(/'/g, "&#039;");
754
+ }
755
+
756
+ // Подключение потока данных
757
+ function connectStream() {
758
+ if (eventSource) eventSource.close();
759
+
760
+ // Обновляем статус
761
+ statusText.textContent = 'Обработка запроса...';
762
+ statusDot.style.background = 'var(--warning)';
763
+ statusIndicator.classList.add('warning');
764
+ statusIndicator.classList.remove('success');
765
+
766
+ eventSource = new EventSource('/stream');
767
+
768
+ eventSource.onmessage = function(event) {
769
+ try {
770
+ const data = JSON.parse(event.data);
771
+ const { type, content } = data;
772
+
773
+ if (type === 'log' || type === 'processing_log') {
774
+ addLog(content, 'info');
775
+ }
776
+ else if (type === 'response_start') {
777
+ // Начало ответа - показываем индикатор набора
778
+ if (!document.getElementById('bot-typing')) {
779
+ addBotMessage('', true);
780
+ }
781
+ }
782
+ else if (type === 'response_chunk') {
783
+ // Порция ответа - добавляем к буферу чанков
784
+ chunkBuffer += content;
785
+
786
+ // Обновляем с троттлингом
787
+ clearTimeout(chunkTimeout);
788
+ chunkTimeout = setTimeout(() => {
789
+ responseBuffer += chunkBuffer;
790
+ throttledUpdate(responseBuffer);
791
+ chunkBuffer = "";
792
+ }, 100);
793
+ }
794
+ else if (type === 'response_end') {
795
+ // Конец ответа - форматируем итоговый ответ
796
+ responseBuffer += chunkBuffer;
797
+ chunkBuffer = "";
798
+ updateBotMessage(responseBuffer);
799
+ addSourceLinks();
800
+ }
801
+ else if (type === 'sources') {
802
+ // Получение источников информации
803
+ try {
804
+ currentSources = JSON.parse(content);
805
+ addSourceLinks();
806
+ } catch (e) {
807
+ console.error('Error parsing sources:', e);
808
+ addLog('Ошибка обработки источников', 'error');
809
+ }
810
+ }
811
+ else if (type === 'done') {
812
+ // Завершение обработки
813
+ isProcessing = false;
814
+ statusText.textContent = 'Подключено';
815
+ statusDot.style.background = 'var(--success)';
816
+ statusIndicator.classList.add('success');
817
+ statusIndicator.classList.remove('warning');
818
+
819
+ // Удаляем индикатор набора, если он остался
820
+ const typingIndicator = document.getElementById('bot-typing');
821
+ if (typingIndicator) {
822
+ typingIndicator.remove();
823
+ }
824
+
825
+ // Закрываем соединение
826
+ if (eventSource) {
827
+ eventSource.close();
828
+ eventSource = null;
829
+ }
830
+ }
831
+ } catch (e) {
832
+ console.error('Error processing event:', e);
833
+ addLog(`Ошибка обработки данных: ${e.message}`, 'error');
834
+ }
835
+ };
836
+
837
+ eventSource.onerror = function() {
838
+ addLog("⚠️ Поток данных прерван", 'error');
839
+ statusText.textContent = 'Ошибка соединения';
840
+ statusDot.style.background = 'var(--danger)';
841
+ statusIndicator.classList.add('danger');
842
+ statusIndicator.classList.remove('success', 'warning');
843
+
844
+ if (eventSource) {
845
+ eventSource.close();
846
+ eventSource = null;
847
+ }
848
+
849
+ // Попытка восстановления через 2 секунды
850
+ setTimeout(() => {
851
+ if (!isProcessing) {
852
+ statusText.textContent = 'Подключено';
853
+ statusDot.style.background = 'var(--success)';
854
+ statusIndicator.classList.add('success');
855
+ statusIndicator.classList.remove('danger');
856
+ }
857
+ }, 2000);
858
+ };
859
+ }
860
+
861
+ // Отправка сообщения
862
+ function sendMessage() {
863
+ const message = userInput.value.trim();
864
+ if (!message || isProcessing) return;
865
+
866
+ // Добавляем сообщение пользователя
867
+ addUserMessage(message);
868
+ userInput.value = '';
869
+
870
+ // Обновляем статус
871
+ isProcessing = true;
872
+ statusText.textContent = 'Обработка запроса...';
873
+ statusDot.style.background = 'var(--warning)';
874
+ statusIndicator.classList.add('warning');
875
+ statusIndicator.classList.remove('success');
876
+
877
+ // Скрываем примеры запросов
878
+ const examples = document.querySelector('.example-badge');
879
+ if (examples) {
880
+ examples.parentElement.style.display = 'none';
881
+ }
882
+
883
+ // Очищаем предыдущие данные
884
+ currentSources = [];
885
+ processingLog = [];
886
+ responseBuffer = '';
887
+ botMessageId = null;
888
+ chunkBuffer = "";
889
+
890
+ // Подключаемся к потоку данных
891
+ connectStream();
892
+
893
+ // Отправляем запрос на сервер
894
+ fetch('/ask', {
895
+ method: 'POST',
896
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
897
+ body: `message=${encodeURIComponent(message)}`
898
+ })
899
+ .then(res => res.json())
900
+ .then(data => {
901
+ if (data.status === 'processing') {
902
+ addLog("⚙️ Запрос отправлен на сервер...", 'info');
903
+ }
904
+ })
905
+ .catch(err => {
906
+ addLog(`❌ Ошибка запроса: ${err.message}`, 'error');
907
+ isProcessing = false;
908
+ statusText.textContent = 'Ошибка соединения';
909
+ statusDot.style.background = 'var(--danger)';
910
+ statusIndicator.classList.add('danger');
911
+ statusIndicator.classList.remove('warning');
912
+
913
+ // Восстанавливаем статус через 2 секунды
914
+ setTimeout(() => {
915
+ statusText.textContent = 'Подключено';
916
+ statusDot.style.background = 'var(--success)';
917
+ statusIndicator.classList.add('success');
918
+ statusIndicator.classList.remove('danger');
919
+ }, 2000);
920
+ });
921
+ }
922
+
923
+ // Обработчик�� событий
924
+ sendBtn.addEventListener('click', sendMessage);
925
+ userInput.addEventListener('keypress', (e) => {
926
+ if (e.key === 'Enter') sendMessage();
927
+ });
928
+
929
+ // Закрытие соединений при уходе со страницы
930
+ window.addEventListener('beforeunload', () => {
931
+ if (eventSource) {
932
+ eventSource.close();
933
+ eventSource = null;
934
+ }
935
+ });
936
+
937
+ // Инициализация
938
+ addLog("Система инициализирована. Ожидание запроса...", 'info');
939
+ });
940
+ </script>
941
+ </body>
942
+ </html>