Mikkatsuki commited on
Commit
dc538c5
·
verified ·
1 Parent(s): cd19173

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +489 -0
app.py ADDED
@@ -0,0 +1,489 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import io
3
+ import re
4
+ import json
5
+ import time
6
+ import uuid
7
+ import unicodedata
8
+ from typing import List, Optional
9
+
10
+ import chromadb
11
+ from chromadb.utils import embedding_functions
12
+ from pypdf import PdfReader
13
+ from docx import Document as DocxDocument
14
+ from google import genai
15
+ from google.genai import types
16
+ import gradio as gr
17
+ import discord
18
+ from telegram import Bot
19
+
20
+
21
+ # ============ 設定 ============
22
+ DATA_DIR = "/kaggle/working/data" if os.path.exists("/kaggle/working") else "/content/data" if os.path.exists("/content") else "./data"
23
+ CHROMA_DIR = f"{DATA_DIR}/chroma_db"
24
+ QA_LOG_PATH = f"{DATA_DIR}/qa_history.jsonl"
25
+ os.makedirs(DATA_DIR, exist_ok=True)
26
+
27
+ GEMINI_MODEL = "gemini-2.5-flash"
28
+ EMBEDDING_MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2"
29
+
30
+ CHUNK_SIZE = 500
31
+ CHUNK_OVERLAP = 80
32
+ TOP_K_DOCS = 4
33
+ TOP_K_QA_HISTORY = 2
34
+ MAX_HISTORY_MESSAGES = 6
35
+
36
+ SYSTEM_PROMPT = (
37
+ "你是一個根據使用者上傳文件回答問題的助理。"
38
+ "優先根據提供的文件內容與過去問答紀錄回答;"
39
+ "如果內容中找不到答案,要誠實說不知道,不要編造。"
40
+ )
41
+
42
+
43
+ # ============ 文字清理:去除亂碼、控制字元、多餘的 Markdown 符號 ============
44
+ def clean_text(text: str) -> str:
45
+ """清掉常見的亂碼/雜訊:控制字元、多餘空白、Markdown 符號,盡量還原成乾淨的純文字。
46
+ 刻意不處理單星號斜體(*文字*),因為『5 * 3』這種數學算式會被誤判、把內容吃掉,
47
+ 風險比留著沒清乾淨的符號更高。"""
48
+ if not text:
49
+ return text
50
+
51
+ text = unicodedata.normalize("NFKC", text)
52
+ text = "".join(ch for ch in text if ch in "\n\t" or not unicodedata.category(ch).startswith("C"))
53
+
54
+ text = re.sub(r"\*\*(.+?)\*\*", r"\1", text)
55
+ text = re.sub(r"^#{1,6}\s*", "", text, flags=re.MULTILINE)
56
+ text = re.sub(r"`([^`]+)`", r"\1", text)
57
+ text = re.sub(r"^[-*]\s+", "• ", text, flags=re.MULTILINE)
58
+
59
+ text = re.sub(r"\n{3,}", "\n\n", text)
60
+ text = re.sub(r"[ \t]{2,}", " ", text)
61
+
62
+ return text.strip()
63
+
64
+
65
+ # ============ RAG:文件讀取、切塊、向量庫 ============
66
+ def load_text_from_file(file_path: str) -> str:
67
+ ext = os.path.splitext(file_path)[1].lower()
68
+ if ext == ".pdf":
69
+ reader = PdfReader(file_path)
70
+ raw = "\n".join(page.extract_text() or "" for page in reader.pages)
71
+ elif ext == ".docx":
72
+ doc = DocxDocument(file_path)
73
+ raw = "\n".join(p.text for p in doc.paragraphs)
74
+ elif ext in (".txt", ".md"):
75
+ with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
76
+ raw = f.read()
77
+ else:
78
+ raise ValueError(f"目前不支援的檔案格式:{ext}")
79
+ return clean_text(raw)
80
+
81
+
82
+ def split_fixed_length(text, chunk_size, overlap=0):
83
+ """1. 固定長度切分:純粹按字數切,不管語意邊界,速度快、實作簡單。"""
84
+ text = text.strip()
85
+ if not text:
86
+ return []
87
+ chunks, start = [], 0
88
+ while start < len(text):
89
+ end = start + chunk_size
90
+ chunks.append(text[start:end])
91
+ if end >= len(text):
92
+ break
93
+ start = end - overlap
94
+ return [c.strip() for c in chunks if c.strip()]
95
+
96
+
97
+ def split_into_sentences(text):
98
+ """把文字切成句子清單,句尾標點保留在句子尾端。"""
99
+ pieces = re.split(r'(?<=[。!?;])|(?<=[.!?])(?=\s)', text)
100
+ return [p.strip() for p in pieces if p.strip()]
101
+
102
+
103
+ def split_by_sentence(text, chunk_size):
104
+ """2. 語義切分(簡化版):先切成完整句子,再把句子組合到接近 chunk_size,
105
+ 確保每個 chunk 都在句子邊界結束,不會切斷句子中間。"""
106
+ text = text.strip()
107
+ if not text:
108
+ return []
109
+ sentences = split_into_sentences(text)
110
+ if not sentences:
111
+ return []
112
+ chunks, current = [], ""
113
+ for sent in sentences:
114
+ if current and len(current) + len(sent) > chunk_size:
115
+ chunks.append(current.strip())
116
+ current = sent
117
+ else:
118
+ current += sent
119
+ if current.strip():
120
+ chunks.append(current.strip())
121
+ return chunks
122
+
123
+
124
+ def _merge_small_pieces(pieces, chunk_size):
125
+ """把切出來但太小的相鄰片段合併,避免『文件裡有很多短段落』這種情況
126
+ 被切成一堆瑣碎的小 chunk,不利於之後的檢索品質。"""
127
+ if not pieces:
128
+ return []
129
+ merged, current = [], pieces[0]
130
+ for p in pieces[1:]:
131
+ if len(current) + len(p) + 2 <= chunk_size:
132
+ current = current + "\n\n" + p
133
+ else:
134
+ merged.append(current)
135
+ current = p
136
+ merged.append(current)
137
+ return merged
138
+
139
+
140
+ def split_recursive(text, chunk_size, overlap=0, separators=None):
141
+ """3. 遞歸切分:照『段落 -> 句子 -> 固定長度』優先順序,
142
+ 只有超過限制的區塊才會往下一層細分;切完後再把過小的相鄰片段合併一次。"""
143
+ text = text.strip()
144
+ if not text:
145
+ return []
146
+ if separators is None:
147
+ separators = ["\n\n", "\n"]
148
+
149
+ def _split(chunk, seps):
150
+ chunk = chunk.strip()
151
+ if not chunk:
152
+ return []
153
+ if len(chunk) <= chunk_size:
154
+ return [chunk]
155
+ if not seps:
156
+ sentence_chunks = split_by_sentence(chunk, chunk_size)
157
+ result = []
158
+ for sc in sentence_chunks:
159
+ if len(sc) <= chunk_size:
160
+ result.append(sc)
161
+ else:
162
+ result.extend(split_fixed_length(sc, chunk_size, overlap=0))
163
+ return result
164
+ sep, rest = seps[0], seps[1:]
165
+ pieces = [p for p in chunk.split(sep) if p.strip()]
166
+ if len(pieces) <= 1:
167
+ return _split(chunk, rest)
168
+ result = []
169
+ for p in pieces:
170
+ result.extend(_split(p, rest))
171
+ return result
172
+
173
+ raw_pieces = _split(text, separators)
174
+ return _merge_small_pieces(raw_pieces, chunk_size)
175
+
176
+
177
+ def split_sliding_window(text, chunk_size, overlap):
178
+ """4. 滑動視窗切分:固定長度切分,但保留重疊區域,避免重要語境被切在邊界上。"""
179
+ return split_fixed_length(text, chunk_size, overlap)
180
+
181
+
182
+ def split_hybrid(text, chunk_size, overlap):
183
+ """5. 混合策略:先用遞歸切分抓自然邊界,區塊之間再補上重疊,
184
+ 兼顧語意完整跟上下文連續。"""
185
+ chunks = split_recursive(text, chunk_size, overlap=0)
186
+ if overlap <= 0 or len(chunks) <= 1:
187
+ return chunks
188
+ overlapped = [chunks[0]]
189
+ for i in range(1, len(chunks)):
190
+ prev_tail = chunks[i - 1][-overlap:] if len(chunks[i - 1]) > overlap else chunks[i - 1]
191
+ overlapped.append((prev_tail + " " + chunks[i]).strip())
192
+ return overlapped
193
+
194
+
195
+ CHUNK_STRATEGIES = {
196
+ "固定長度": lambda text, chunk_size, overlap: split_fixed_length(text, chunk_size, overlap=0),
197
+ "語義切分": lambda text, chunk_size, overlap: split_by_sentence(text, chunk_size),
198
+ "遞歸切分": lambda text, chunk_size, overlap: split_recursive(text, chunk_size, overlap=0),
199
+ "滑動視窗": lambda text, chunk_size, overlap: split_sliding_window(text, chunk_size, overlap),
200
+ "混合策略": lambda text, chunk_size, overlap: split_hybrid(text, chunk_size, overlap),
201
+ }
202
+ DEFAULT_STRATEGY = "固定長度"
203
+
204
+
205
+ class VectorStore:
206
+ def __init__(self):
207
+ self.client = chromadb.PersistentClient(path=CHROMA_DIR)
208
+ self.embed_fn = embedding_functions.SentenceTransformerEmbeddingFunction(model_name=EMBEDDING_MODEL_NAME)
209
+ self.documents = self.client.get_or_create_collection("documents", embedding_function=self.embed_fn)
210
+ self.qa_history = self.client.get_or_create_collection("qa_history", embedding_function=self.embed_fn)
211
+
212
+ def add_text(self, text: str, source_name: str, strategy: str = DEFAULT_STRATEGY) -> int:
213
+ """把『已經讀取好、清理過』的文字,依指定策略切塊後存進向量庫。
214
+ 跟讀檔案的步驟分開,讓上傳文件、選切分策略可以是兩個獨立動作。"""
215
+ split_fn = CHUNK_STRATEGIES.get(strategy, CHUNK_STRATEGIES[DEFAULT_STRATEGY])
216
+ chunks = split_fn(text, CHUNK_SIZE, CHUNK_OVERLAP)
217
+ if not chunks:
218
+ return 0
219
+
220
+ existing = self.documents.get(where={"source": source_name})
221
+ if existing["ids"]:
222
+ self.documents.delete(ids=existing["ids"])
223
+
224
+ ids = [str(uuid.uuid4()) for _ in chunks]
225
+ metadatas = [{"source": source_name, "chunk_index": i, "strategy": strategy} for i in range(len(chunks))]
226
+ self.documents.add(documents=chunks, ids=ids, metadatas=metadatas)
227
+ return len(chunks)
228
+
229
+ def list_sources(self) -> List[str]:
230
+ result = self.documents.get()
231
+ sources = {m.get("source") for m in result.get("metadatas", []) if m}
232
+ return sorted(sources)
233
+
234
+ def search_documents(self, query: str, top_k: int = TOP_K_DOCS) -> List[str]:
235
+ if self.documents.count() == 0:
236
+ return []
237
+ result = self.documents.query(query_texts=[query], n_results=min(top_k, self.documents.count()))
238
+ return result.get("documents", [[]])[0]
239
+
240
+
241
+ vector_store = VectorStore()
242
+
243
+
244
+ # ============ 模型層(Gemini API,API key 由使用者在介面輸入,不快取)============
245
+ def generate_answer(api_key: str, question: str, context_chunks, history, qa_history_chunks=None):
246
+ if not api_key:
247
+ raise RuntimeError("請先在上方輸入你的 Gemini API Key。")
248
+
249
+ client = genai.Client(api_key=api_key)
250
+ history = history[-MAX_HISTORY_MESSAGES:]
251
+
252
+ context_text = "\n\n".join(context_chunks) if context_chunks else "(沒有檢索到相關文件片段)"
253
+ history_text = "\n\n".join(qa_history_chunks) if qa_history_chunks else ""
254
+
255
+ user_content = f"參考文件片段:\n{context_text}\n"
256
+ if history_text:
257
+ user_content += f"\n過去相關問答:\n{history_text}\n"
258
+ user_content += f"\n使用者問題:{question}"
259
+
260
+ contents = history + [{"role": "user", "parts": [{"text": user_content}]}]
261
+
262
+ response = client.models.generate_content(
263
+ model=GEMINI_MODEL,
264
+ contents=contents,
265
+ config=types.GenerateContentConfig(system_instruction=SYSTEM_PROMPT, temperature=0.3),
266
+ )
267
+ answer = response.text
268
+
269
+ updated_history = contents + [{"role": "model", "parts": [{"text": answer}]}]
270
+ return answer, updated_history
271
+
272
+
273
+ # ============ 問答記憶(檢索式記憶 + 完整歷史紀錄的匯出)============
274
+ class QAMemory:
275
+ def __init__(self, vector_store):
276
+ self.vector_store = vector_store
277
+
278
+ def save(self, question, answer, source="web"):
279
+ question = clean_text(question)
280
+ answer = clean_text(answer)
281
+ qa_id = str(uuid.uuid4())
282
+ record = {"id": qa_id, "question": question, "answer": answer, "source": source, "timestamp": time.time()}
283
+ self.vector_store.qa_history.add(
284
+ documents=[f"問題:{question}\n答案:{answer}"], ids=[qa_id],
285
+ metadatas=[{"source": source, "timestamp": record["timestamp"]}],
286
+ )
287
+ with open(QA_LOG_PATH, "a", encoding="utf-8") as f:
288
+ f.write(json.dumps(record, ensure_ascii=False) + "\n")
289
+
290
+ def search_similar(self, question, top_k=TOP_K_QA_HISTORY):
291
+ collection = self.vector_store.qa_history
292
+ if collection.count() == 0:
293
+ return []
294
+ result = collection.query(query_texts=[question], n_results=min(top_k, collection.count()))
295
+ return result.get("documents", [[]])[0]
296
+
297
+ def load_all(self) -> List[dict]:
298
+ try:
299
+ with open(QA_LOG_PATH, "r", encoding="utf-8") as f:
300
+ lines = f.readlines()
301
+ except FileNotFoundError:
302
+ return []
303
+ return [json.loads(line) for line in lines if line.strip()]
304
+
305
+ def export_as_json_bytes(self) -> bytes:
306
+ records = self.load_all()
307
+ return json.dumps(records, ensure_ascii=False, indent=2).encode("utf-8")
308
+
309
+ def export_as_txt_bytes(self) -> bytes:
310
+ records = self.load_all()
311
+ if not records:
312
+ text = "目前還沒有問答紀錄。"
313
+ else:
314
+ lines = []
315
+ for r in records:
316
+ lines.append(f"[{r.get('source', '未知來源')}] Q: {r['question']}\nA: {r['answer']}\n")
317
+ text = "\n".join(lines)
318
+ return text.encode("utf-8")
319
+
320
+
321
+ qa_memory = QAMemory(vector_store)
322
+
323
+
324
+ # ============ 把完整問答紀錄存成檔案,推播到 Telegram / Discord ============
325
+ async def send_history_file(platform, file_format, telegram_token, telegram_chat_id, discord_webhook_url):
326
+ if platform == "不傳送":
327
+ return "目前選擇「不傳送」,先在上面選 Telegram 或 Discord。"
328
+
329
+ records = qa_memory.load_all()
330
+ if not records:
331
+ return "目前還沒有任何問答紀錄可以匯出。"
332
+
333
+ if file_format == "JSON":
334
+ file_bytes = qa_memory.export_as_json_bytes()
335
+ filename = "qa_history.json"
336
+ else:
337
+ file_bytes = qa_memory.export_as_txt_bytes()
338
+ filename = "qa_history.txt"
339
+
340
+ try:
341
+ if platform == "Telegram":
342
+ if not telegram_token or not telegram_chat_id:
343
+ return "請先填寫 Telegram 的 Bot Token 跟 Chat ID。"
344
+ bot = Bot(token=telegram_token)
345
+ await bot.send_document(
346
+ chat_id=telegram_chat_id,
347
+ document=io.BytesIO(file_bytes),
348
+ filename=filename,
349
+ )
350
+ return f"已把 {filename}({len(records)} 筆紀錄)傳送到 Telegram。"
351
+
352
+ if platform == "Discord":
353
+ if not discord_webhook_url:
354
+ return "請先填寫 Discord 的 Webhook URL。"
355
+ webhook = discord.SyncWebhook.from_url(discord_webhook_url)
356
+ webhook.send(file=discord.File(io.BytesIO(file_bytes), filename=filename))
357
+ return f"已把 {filename}({len(records)} 筆紀錄)傳送到 Discord。"
358
+ except Exception as e:
359
+ return f"傳送失敗:{e}"
360
+
361
+ return "不支援的平台選項。"
362
+
363
+
364
+ # ============ Gradio 介面 ============
365
+ def stage_documents(files, staged_docs):
366
+ """第一步(上傳):只讀取、清理文件內容,暫存起來,不切塊、不存進向量庫。
367
+ 切分策略要等第二步使用者選好之後才會用到。"""
368
+ if not files:
369
+ return staged_docs, "沒有選擇檔案。", gr.update(visible=False)
370
+
371
+ staged_docs = dict(staged_docs or {})
372
+ names = []
373
+ for file in files:
374
+ path = file.name if hasattr(file, "name") else file
375
+ name = os.path.basename(path)
376
+ staged_docs[name] = load_text_from_file(path)
377
+ names.append(name)
378
+
379
+ status = f"已上傳 {len(names)} 個檔案({', '.join(names)}),請在下面選擇切分策略,再按「套用切分策略」。"
380
+ return staged_docs, status, gr.update(visible=True)
381
+
382
+
383
+ def process_staged_documents(staged_docs, strategy):
384
+ """第二步(套用策略):使用者選好切分策略後,才真正把暫存的文字切塊、存進向量庫。"""
385
+ if not staged_docs:
386
+ return "還沒有上傳文件,請先在上面上���。"
387
+ total_chunks, names = 0, []
388
+ for name, text in staged_docs.items():
389
+ total_chunks += vector_store.add_text(text, source_name=name, strategy=strategy)
390
+ names.append(name)
391
+ return f"已用「{strategy}」切分 {len(names)} 個檔案({', '.join(names)}),共存入 {total_chunks} 個片段。"
392
+
393
+
394
+ def list_sources_fn():
395
+ sources = vector_store.list_sources()
396
+ return "已收錄的文件:\n" + "\n".join(f"- {s}" for s in sources) if sources else "目前向量庫裡還沒有文件。"
397
+
398
+
399
+ def chat(message, chat_history, session_history, api_key):
400
+ if not message.strip():
401
+ return "", chat_history, session_history
402
+ try:
403
+ doc_chunks = vector_store.search_documents(message)
404
+ qa_chunks = qa_memory.search_similar(message)
405
+ answer, session_history = generate_answer(api_key, message, doc_chunks, session_history, qa_chunks)
406
+ qa_memory.save(message, answer, source="web")
407
+ except Exception as e:
408
+ answer = f"發生錯誤,請稍後再試:{e}"
409
+ chat_history = chat_history + [
410
+ {"role": "user", "content": message},
411
+ {"role": "assistant", "content": answer},
412
+ ]
413
+ return "", chat_history, session_history
414
+
415
+
416
+ def toggle_platform_fields(platform):
417
+ return gr.update(visible=(platform == "Telegram")), gr.update(visible=(platform == "Discord"))
418
+
419
+
420
+ with gr.Blocks(title="RAG 文件問答小專題(Gemini 版)") as demo:
421
+ gr.Markdown(
422
+ "## RAG 文件問答小專題(Gemini 版)\n"
423
+ "上傳文件後直接提問;問答會被記住,不用重新上傳文件。\n"
424
+ "下面先填你自己的 Gemini API Key 才能開始問答。"
425
+ )
426
+
427
+ with gr.Accordion("設定(API Key / 傳送目的地)", open=True):
428
+ api_key_input = gr.Textbox(label="Gemini API Key", type="password", placeholder="到 Google AI Studio 申請")
429
+ platform_choice = gr.Radio(["不傳送", "Telegram", "Discord"], value="不傳送", label="問答紀錄要傳送到哪裡?")
430
+ with gr.Group(visible=False) as telegram_group:
431
+ telegram_token_input = gr.Textbox(label="Telegram Bot Token", type="password", placeholder="向 @BotFather 申請")
432
+ telegram_chatid_input = gr.Textbox(label="Telegram Chat ID", placeholder="先跟你的 bot 對話,再用 @userinfobot 查詢")
433
+ with gr.Group(visible=False) as discord_group:
434
+ discord_webhook_input = gr.Textbox(label="Discord Webhook URL", type="password", placeholder="頻道設定 > 整合 > Webhook")
435
+
436
+ platform_choice.change(toggle_platform_fields, inputs=platform_choice, outputs=[telegram_group, discord_group])
437
+
438
+ with gr.Row():
439
+ with gr.Column(scale=1):
440
+ staged_docs_state = gr.State({}) # 暫存「已上傳但還沒切塊」的文件內容:{檔名: 清理過的文字}
441
+
442
+ gr.Markdown("**步驟 1:上傳文件**")
443
+ file_input = gr.File(file_count="multiple", label="上傳文件(PDF / DOCX / TXT / MD)")
444
+ upload_btn = gr.Button("上傳")
445
+ upload_status = gr.Textbox(label="上傳狀態", interactive=False)
446
+
447
+ with gr.Group(visible=False) as strategy_group:
448
+ gr.Markdown("**步驟 2:選擇切分策略並套用**")
449
+ strategy_choice = gr.Radio(
450
+ list(CHUNK_STRATEGIES.keys()),
451
+ value=DEFAULT_STRATEGY,
452
+ label="文件切分策略",
453
+ )
454
+ process_btn = gr.Button("套用切分策略")
455
+ process_status = gr.Textbox(label="處理狀態", interactive=False)
456
+ gr.Markdown("*想試不同策略,改選項後直接再按一次「套用切分策略」就好,不用重新上傳。*")
457
+
458
+ list_btn = gr.Button("查看已收錄的文件")
459
+ source_list = gr.Textbox(label="文件清單", interactive=False)
460
+ with gr.Column(scale=2):
461
+ try:
462
+ chatbot = gr.Chatbot(label="問答", height=450, type="messages")
463
+ except TypeError:
464
+ chatbot = gr.Chatbot(label="問答", height=450)
465
+ msg = gr.Textbox(label="輸入問題", placeholder="針對上傳的文件提問…")
466
+ session_state = gr.State([])
467
+
468
+ with gr.Row():
469
+ file_format_choice = gr.Radio(["JSON", "TXT"], value="JSON", label="匯出格式", scale=1)
470
+ send_history_btn = gr.Button("把完整問答紀錄存成檔案並傳送", scale=2)
471
+ send_status = gr.Textbox(label="傳送狀態", interactive=False)
472
+
473
+ upload_btn.click(
474
+ stage_documents,
475
+ inputs=[file_input, staged_docs_state],
476
+ outputs=[staged_docs_state, upload_status, strategy_group],
477
+ )
478
+ process_btn.click(process_staged_documents, inputs=[staged_docs_state, strategy_choice], outputs=process_status).then(
479
+ list_sources_fn, outputs=source_list
480
+ )
481
+ list_btn.click(list_sources_fn, outputs=source_list)
482
+ msg.submit(chat, inputs=[msg, chatbot, session_state, api_key_input], outputs=[msg, chatbot, session_state])
483
+ send_history_btn.click(
484
+ send_history_file,
485
+ inputs=[platform_choice, file_format_choice, telegram_token_input, telegram_chatid_input, discord_webhook_input],
486
+ outputs=send_status,
487
+ )
488
+
489
+ demo.launch(share=True, debug=True)