-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmain.py
More file actions
973 lines (841 loc) · 40.9 KB
/
main.py
File metadata and controls
973 lines (841 loc) · 40.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
# -*- coding: utf-8 -*-
# main.py
import threading
import requests
import os
import sys
import io
# 设置标准输出编码为 UTF-8(修复中文显示问题)
if sys.platform == 'win32':
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
from kivymd.app import MDApp
from kivymd.uix.screen import MDScreen
from kivymd.uix.boxlayout import MDBoxLayout
from kivymd.uix.toolbar import MDTopAppBar
from kivymd.uix.textfield import MDTextField
from kivymd.uix.button import MDIconButton, MDFillRoundFlatButton
from kivymd.uix.label import MDLabel
from kivymd.uix.scrollview import MDScrollView
from kivymd.uix.list import MDList
from kivymd.uix.card import MDCard
from kivymd.uix.spinner import MDSpinner
from kivymd.uix.dialog import MDDialog
from kivymd.uix.screenmanager import MDScreenManager
from kivymd.toast import toast
# [移除] from kivymd.uix.filemanager import MDFileManager # 改用系统原生文件选择器
from kivy.clock import Clock
from kivy.core.window import Window
from kivy.core.text import LabelBase
from kivy.metrics import dp
from kivy.utils import rgba
# ================= 1. 全局配置 =================
# 【请修改】这里填你的服务器地址(本地测试使用 localhost)
# 使用 ngrok 公网地址,手机可以访问
BASE_URL = "https://bragless-mollifiedly-tashina.ngrok-free.dev"
LOGIN_URL = f"{BASE_URL}/login"
CHAT_URL = f"{BASE_URL}/chat"
LOGOUT_URL = f"{BASE_URL}/logout"
HISTORY_URL = f"{BASE_URL}/get_history"
CLEAR_URL = f"{BASE_URL}/clear_history" # [新增]
UPLOAD_URL = f"{BASE_URL}/upload" # [新增]
APP_VERSION = "V 1.3.0 Plus"
# 字体设置 - 使用系统默认字体以支持中文显示
font_file = "font.ttf"
FONT_PATH = None # 使用全局变量,确保在类中可访问
# 确定字体路径
if os.path.exists(font_file):
FONT_PATH = font_file
elif sys.platform == 'win32':
# Windows 系统,尝试使用中文字体
if os.path.exists("C:/Windows/Fonts/msyh.ttc"):
FONT_PATH = "C:/Windows/Fonts/msyh.ttc"
elif os.path.exists("C:/Windows/Fonts/simhei.ttf"):
FONT_PATH = "C:/Windows/Fonts/simhei.ttf"
elif os.path.exists("C:/Windows/Fonts/simsun.ttc"):
FONT_PATH = "C:/Windows/Fonts/simsun.ttc"
# 注册字体(包括所有变体,确保工具栏也能显示中文)
if FONT_PATH:
try:
LabelBase.register(name="Roboto",
fn_regular=FONT_PATH,
fn_bold=FONT_PATH,
fn_italic=FONT_PATH,
fn_bolditalic=FONT_PATH)
# 注册其他字体变体(KivyMD 可能使用这些)
for name in ["RobotoThin", "RobotoLight", "RobotoMedium", "RobotoBlack", "RobotoBold"]:
LabelBase.register(name=name, fn_regular=FONT_PATH)
print(f"[Font] 已加载字体: {FONT_PATH}")
except Exception as e:
print(f"[Font] 字体注册失败: {e}")
else:
print("[Font] 警告: 未找到合适的字体文件,可能无法正确显示中文")
Window.size = (360, 640)
# ================= 2. UI 组件定义 (保持 ChatBubble 不变) =================
# ==========================================
# 请将这部分代码放在 main.py 的 UI 组件定义区域
# ==========================================
# ==========================================
# 优化后的 Markdown 转换与气泡组件
# ==========================================
# ==========================================
# 最终修正版:修复孤立符号与多余换行
# ==========================================
# ==========================================
# 修复重叠与日志显示问题的完整代码
# ==========================================
import re
from kivy.clock import Clock
from kivy.metrics import dp
from kivy.utils import rgba, escape_markup # [新增] 引入 escape_markup
from kivy.core.window import Window
from kivymd.uix.card import MDCard
from kivymd.uix.boxlayout import MDBoxLayout
from kivymd.uix.label import MDLabel
# --- Markdown 转换函数 (保持优化版) ---
def convert_md_to_kivy(text):
if not text: return ""
text = text.replace('\r\n', '\n')
text = re.sub(r'\n{3,}', '\n\n', text)
# 修复列表
text = re.sub(r'^\s*-\s*\n\s*', r' • ', text, flags=re.MULTILINE)
text = re.sub(r'^\s*-\s+', r' • ', text, flags=re.MULTILINE)
text = re.sub(r'^\s*(\d+\.)\s*\n\s*', r' \1 ', text, flags=re.MULTILINE)
text = re.sub(r'^\s*(\d+\.)\s+', r' \1 ', text, flags=re.MULTILINE)
# 格式
text = re.sub(r'\*\*(.*?)\*\*', r'[b]\1[/b]', text)
def replace_header(match):
level = len(match.group(1))
content = match.group(2).strip()
size = max(16, 24 - (level * 2))
return f"\n[size={size}][b]{content}[/b][/size]\n"
text = re.sub(r'^\s*(#+)\s+(.*)', replace_header, text, flags=re.MULTILINE)
text = re.sub(r'`(.*?)`', r'[color=105599][b] \1 [/b][/color]', text)
return text.strip()
class ChatBubble(MDCard):
def __init__(self, text, logs=None, is_user=False, **kwargs):
super().__init__(**kwargs)
# --- 1. 基础布局 ---
self.padding = "12dp"
self.size_hint = (None, None)
self.elevation = 0.5
self.radius = [18, 18, 18, 18]
# 初始宽度
screen_width = Window.width if hasattr(Window, 'width') else 360
self.width = screen_width * 0.85
# --- 2. 样式区分 ---
if is_user:
self.md_bg_color = rgba("#1976D2")
self.pos_hint = {'right': 0.98}
self.radius = [18, 18, 0, 18]
self.text_color = (1, 1, 1, 1)
# 用户输入通常不需要复杂 Markdown,但防止用户输入 [brackets] 报错
display_text = escape_markup(text)
else:
self.md_bg_color = rgba("#F5F5F5")
self.pos_hint = {'x': 0.02}
self.radius = [18, 18, 18, 0]
self.text_color = (0.1, 0.1, 0.1, 1)
display_text = convert_md_to_kivy(text)
# --- 3. 内容容器 ---
self.content_box = MDBoxLayout(
orientation='vertical',
spacing="4dp",
size_hint=(1, None)
)
self.content_box.bind(minimum_height=self.content_box.setter('height'))
# --- 4. 思考链路 (Logs) ---
if logs and not is_user:
self.content_box.add_widget(MDLabel(
text="[处理] 思考过程:",
theme_text_color="Custom",
text_color=(0.6, 0.6, 0.6, 1),
font_style="Caption",
size_hint_y=None,
height="16dp",
bold=True
))
# [关键修复] 处理 Logs 里的特殊符号
# 1. 列表转字符串
raw_log_str = "\n".join(logs)
# 2. 转义 markup (把 [ 变成 &91; 等),防止 Kivy 解析出错
safe_log_str = escape_markup(raw_log_str)
self.log_label = MDLabel(
text=safe_log_str,
theme_text_color="Custom",
text_color=(0.5, 0.5, 0.5, 1),
font_style="Overline", # 使用超小字体
size_hint_y=None,
markup=True, # 依然开启 markup 以支持颜色等(如果需要)
line_height=1.1,
)
self.content_box.add_widget(self.log_label)
self.content_box.add_widget(MDBoxLayout(size_hint_y=None, height="6dp"))
# --- 5. 正文文本 ---
self.label = MDLabel(
text=display_text,
theme_text_color="Custom",
text_color=self.text_color,
size_hint_y=None,
markup=True,
line_height=1.4,
halign="left",
valign="top"
)
# 绑定高度:当文字纹理变化 -> 调整Label高度
self.label.bind(texture_size=lambda instance, value: setattr(instance, 'height', value[1]))
if hasattr(self, 'log_label'):
self.log_label.bind(texture_size=lambda instance, value: setattr(instance, 'height', value[1]))
self.content_box.add_widget(self.label)
self.add_widget(self.content_box)
# --- 6. 高度同步与防重叠 ---
# 当内容高度改变 -> 改变气泡高度 -> 并通知父容器刷新
self.content_box.bind(height=self.update_height_and_refresh)
# --- 7. 延迟排版 ---
Clock.schedule_once(self.recalculate_layout, 0)
Window.bind(on_resize=self.on_window_resize)
def update_height_and_refresh(self, instance, value):
"""当内容高度变化时,更新自身高度并尝试刷新父列表"""
new_height = value + dp(24) # 内容 + Padding
self.height = new_height
# [关键修复] 通知父级 (MDList) 重新计算位置,防止重叠
if self.parent:
self.parent.do_layout()
def recalculate_layout(self, dt=None):
"""重新计算文本宽度"""
screen_width = Window.width
self.width = screen_width * 0.85
text_view_width = self.width - dp(24)
# 更新正文
if self.label:
self.label.text_size = (text_view_width, None)
self.label.texture_update()
# 更新 Logs
if hasattr(self, 'log_label'):
self.log_label.text_size = (text_view_width, None)
self.log_label.texture_update()
def on_window_resize(self, instance, width, height):
self.recalculate_layout()
class SplashScreen(MDScreen):
def on_enter(self):
Clock.schedule_once(self.switch_to_login, 2.0)
def switch_to_login(self, dt):
self.manager.current = "login"
class LoginScreen(MDScreen):
def on_enter(self):
if hasattr(self, 'pass_field'):
self.pass_field.text = ""
def build_ui(self):
layout = MDBoxLayout(orientation='vertical', padding=40, spacing=20)
layout.add_widget(MDLabel(size_hint_y=None, height="40dp"))
layout.add_widget(MDIconButton(
icon="shield-account", icon_size="80sp", pos_hint={"center_x": .5},
theme_text_color="Custom", text_color=(0.1, 0.5, 0.9, 1)
))
layout.add_widget(MDLabel(text="欢迎回来", halign="center", font_style="H4", bold=True))
card = MDCard(
orientation="vertical", padding="20dp", spacing="15dp", size_hint=(1, None),
height="220dp", radius=[20, 20, 20, 20], elevation=2
)
self.user_field = MDTextField(hint_text="账号", text="admin", icon_right="account", mode="fill",
radius=[10, 10, 10, 10])
self.pass_field = MDTextField(hint_text="密码", text="123456", icon_right="key-variant", password=True,
mode="fill", radius=[10, 10, 10, 10])
card.add_widget(self.user_field)
card.add_widget(self.pass_field)
layout.add_widget(card)
self.login_btn = MDFillRoundFlatButton(
text="登 录", font_size="18sp", size_hint_x=1, md_bg_color=(0.1, 0.5, 0.9, 1),
on_release=lambda x: self.login(self.user_field.text, self.pass_field.text)
)
layout.add_widget(self.login_btn)
self.loading_spinner = MDSpinner(
size_hint=(None, None), size=(dp(30), dp(30)), pos_hint={'center_x': .5}, active=False
)
layout.add_widget(self.loading_spinner)
layout.add_widget(MDLabel())
self.add_widget(layout)
def login(self, username, password):
if not username or not password:
self.show_error("请输入账号和密码")
return
self.loading_spinner.active = True
self.login_btn.disabled = True
self.login_btn.text = "登录中..."
threading.Thread(target=self.do_login_request, args=(username, password)).start()
def do_login_request(self, username, password):
try:
resp = requests.post(LOGIN_URL, json={"username": username, "password": password}, timeout=10)
if resp.status_code == 200:
result = resp.json()
else:
# 尝试解析错误信息
try:
error_data = resp.json()
error_msg = error_data.get("detail", f"HTTP {resp.status_code}")
except:
error_msg = f"服务器错误: HTTP {resp.status_code}\n响应内容: {resp.text[:100]}"
result = {"status": "error", "message": error_msg}
Clock.schedule_once(lambda dt: self.handle_login_result(result, username), 0)
except requests.exceptions.ConnectionError:
Clock.schedule_once(lambda dt: self.show_error("无法连接到服务器\n请确保服务器正在运行\n(http://localhost:8000)"), 0)
except Exception as e:
Clock.schedule_once(lambda dt: self.show_error(f"连接失败: {str(e)}"), 0)
def handle_login_result(self, result, username):
self.loading_spinner.active = False
self.login_btn.disabled = False
self.login_btn.text = "登 录"
if result.get("status") == "success":
app = MDApp.get_running_app()
app.current_user = username
app.access_token = result.get("token") # [新增] 保存 Token
self.manager.current = "home"
else:
self.show_error(result.get("message", "登录失败"))
def show_error(self, msg):
dialog = MDDialog(title="提示", text=msg,
buttons=[MDFillRoundFlatButton(text="OK", on_release=lambda x: dialog.dismiss())])
dialog.open()
class SettingsScreen(MDScreen):
pass
class HomeScreen(MDScreen):
pass
# ================= 3. 主应用程序 =================
class ChatApp(MDApp):
dialog = None
upload_dialog = None
upload_progress_dialog = None # [新增] 上传进度对话框
upload_progress_label = None # [新增] 进度标签引用
# [移除] file_manager = None # 不再使用 MDFileManager
current_user = "未登录"
access_token = None # [新增] 存储 JWT Token
def build(self):
self.theme_cls.primary_palette = "Blue"
self.theme_cls.theme_style = "Light"
# 设置字体以支持中文显示(工具栏使用 H6 样式)
if FONT_PATH:
self.theme_cls.font_styles.update({
"H1": [FONT_PATH, 96, False, -1.5],
"H2": [FONT_PATH, 60, False, -0.5],
"H3": [FONT_PATH, 48, False, 0],
"H4": [FONT_PATH, 34, False, 0.25],
"H5": [FONT_PATH, 24, False, 0],
"H6": [FONT_PATH, 20, False, 0.15], # 工具栏标题使用此样式
"Subtitle1": [FONT_PATH, 16, False, 0.15],
"Subtitle2": [FONT_PATH, 14, False, 0.1],
"Body1": [FONT_PATH, 16, False, 0.5],
"Body2": [FONT_PATH, 14, False, 0.25],
"Button": [FONT_PATH, 14, True, 1.25],
"Caption": [FONT_PATH, 12, False, 0.4],
"Overline": [FONT_PATH, 10, False, 1.5],
})
self.sm = MDScreenManager()
self.sm.add_widget(self.build_splash_screen())
login_screen = LoginScreen(name="login")
login_screen.build_ui()
self.sm.add_widget(login_screen)
self.sm.add_widget(self.build_home_screen())
self.sm.add_widget(self.build_settings_screen())
# [移除] 不再使用 MDFileManager,改用系统原生文件选择器
return self.sm
# --- 文件选择逻辑(使用系统原生对话框) ---
def open_file_manager(self):
"""打开系统原生文件选择对话框"""
try:
# 使用 tkinter 的文件选择对话框(Windows/Linux 都支持)
import tkinter as tk
from tkinter import filedialog
# 创建隐藏的根窗口
root = tk.Tk()
root.withdraw() # 隐藏主窗口
root.attributes('-topmost', True) # 置顶
# 打开文件选择对话框
file_path = filedialog.askopenfilename(
title="选择要上传的文件",
filetypes=[
("所有支持的文件", "*.txt *.pdf *.docx *.doc *.csv *.md"),
("文本文件", "*.txt"),
("PDF文件", "*.pdf"),
("Word文档", "*.docx *.doc"),
("CSV文件", "*.csv"),
("Markdown", "*.md"),
("所有文件", "*.*")
],
initialdir=os.path.expanduser("~") # 默认从用户主目录开始
)
root.destroy() # 销毁窗口
if file_path:
# 用户选择了文件
self.show_upload_confirm_dialog(file_path)
else:
# 用户取消了选择
pass
except ImportError:
# 如果 tkinter 不可用,回退到简单的输入框
toast("错误: 系统文件选择器不可用,请安装 tkinter")
except Exception as e:
toast(f"打开文件选择器失败: {str(e)}")
def show_upload_confirm_dialog(self, path):
filename = os.path.basename(path)
self.upload_dialog = MDDialog(
title="确认上传",
text=f"是否上传文件并构建知识库索引?\n\n文件: {filename}",
buttons=[
MDFillRoundFlatButton(text="取消", md_bg_color=(0.5, 0.5, 0.5, 1),
on_release=lambda x: self.upload_dialog.dismiss()),
MDFillRoundFlatButton(text="上传", on_release=lambda x: self.do_upload(path))
],
)
self.upload_dialog.open()
def do_upload(self, path):
if self.upload_dialog:
self.upload_dialog.dismiss()
# [新增] 显示上传进度对话框
self.show_upload_progress_dialog()
threading.Thread(target=self._upload_thread, args=(path,)).start()
def show_upload_progress_dialog(self):
"""显示上传进度对话框"""
# 创建内容容器
content_box = MDBoxLayout(
orientation='vertical',
size_hint_y=None,
height=dp(250),
spacing="10dp",
padding="10dp"
)
# 创建滚动视图
scroll = MDScrollView(
do_scroll_y=True,
bar_width=dp(4)
)
# 创建标签(用于显示进度信息)
self.upload_progress_label = MDLabel(
text="[上传] 正在上传文件,请稍候...\n",
theme_text_color="Primary",
size_hint_y=None,
halign="left",
valign="top",
adaptive_height=True,
text_size=(None, None)
)
self.upload_progress_label.bind(texture_size=self.upload_progress_label.setter('size'))
self.upload_progress_label.bind(
texture_size=lambda instance, value: setattr(instance, 'height', value[1] if value[1] else dp(50))
)
# 将标签添加到滚动视图
scroll.add_widget(self.upload_progress_label)
# 将滚动视图添加到容器
content_box.add_widget(scroll)
# 创建对话框
self.upload_progress_dialog = MDDialog(
title="文件上传中",
type="custom",
content_cls=content_box,
buttons=[
MDFillRoundFlatButton(
text="关闭",
md_bg_color=(0.5, 0.5, 0.5, 1),
on_release=lambda x: self.cancel_upload()
)
],
auto_dismiss=False # 不允许点击外部关闭
)
self.upload_progress_dialog.open()
def update_upload_progress(self, message):
"""更新上传进度文本"""
if self.upload_progress_label:
current_text = self.upload_progress_label.text
new_text = current_text + "\n" + message if current_text else message
Clock.schedule_once(lambda dt: setattr(self.upload_progress_label, 'text', new_text), 0)
# 自动滚动到底部
Clock.schedule_once(lambda dt: self._scroll_progress_to_bottom(), 0.1)
def _scroll_progress_to_bottom(self):
"""滚动进度对话框到底部"""
if self.upload_progress_dialog and self.upload_progress_dialog.content_cls:
content_box = self.upload_progress_dialog.content_cls
# 查找滚动视图
for child in content_box.children:
if isinstance(child, MDScrollView):
if hasattr(child, 'scroll_y'):
child.scroll_y = 0
break
def cancel_upload(self):
"""取消上传(目前只是关闭对话框,实际取消需要更复杂的实现)"""
if self.upload_progress_dialog:
self.upload_progress_dialog.dismiss()
self.upload_progress_dialog = None
self.upload_progress_label = None
toast("上传已取消")
def close_upload_progress_dialog(self):
"""关闭上传进度对话框"""
if self.upload_progress_dialog:
self.upload_progress_dialog.dismiss()
self.upload_progress_dialog = None
self.upload_progress_label = None
def _upload_thread(self, path):
try:
# [修复] 文件上传时,只添加 Authorization header,不设置 Content-Type
# requests 库在使用 files 参数时会自动设置正确的 multipart/form-data
headers = {}
if self.access_token:
headers["Authorization"] = f"Bearer {self.access_token}"
# 检查文件是否存在
if not os.path.exists(path):
Clock.schedule_once(lambda dt: self.close_upload_progress_dialog(), 0)
Clock.schedule_once(lambda dt: toast("[错误] 文件不存在"), 0)
return
# 检查文件大小(限制为 50MB)
file_size = os.path.getsize(path)
if file_size > 50 * 1024 * 1024: # 50MB
Clock.schedule_once(lambda dt: self.close_upload_progress_dialog(), 0)
Clock.schedule_once(lambda dt: toast("[错误] 文件过大,最大支持 50MB"), 0)
return
# [新增] 更新进度:开始上传
Clock.schedule_once(lambda dt: self.update_upload_progress("[上传] 正在上传文件到服务器..."), 0)
with open(path, 'rb') as f:
files = {'file': (os.path.basename(path), f, 'application/octet-stream')}
resp = requests.post(UPLOAD_URL, files=files, headers=headers, timeout=120)
if resp.status_code == 200:
res = resp.json()
if res['status'] == 'success':
# [新增] 显示服务器返回的日志
logs = res.get('logs', [])
if logs:
Clock.schedule_once(lambda dt: self.update_upload_progress("\n[日志] 服务器处理日志:"), 0)
for log_msg in logs:
# 清理日志消息
clean_msg = log_msg.strip()
if clean_msg:
# [过滤] 不显示文件保存路径信息
if "文件已保存至:" in clean_msg or "[Upload] 文件已保存至:" in clean_msg:
continue
# 格式化日志消息,使用文本标记替代 emoji
formatted_msg = clean_msg
# 如果日志包含特殊标记,替换为文本标记
if "[Upload]" in formatted_msg:
formatted_msg = formatted_msg.replace("[Upload]", "[文件]")
elif "[Index]" in formatted_msg:
formatted_msg = formatted_msg.replace("[Index]", "[索引]")
elif "[Cache]" in formatted_msg:
formatted_msg = formatted_msg.replace("[Cache]", "[缓存]")
elif "[INFO]" in formatted_msg:
formatted_msg = formatted_msg.replace("[INFO]", "[信息]")
elif "[SUCCESS]" in formatted_msg:
formatted_msg = formatted_msg.replace("[SUCCESS]", "[成功]")
Clock.schedule_once(lambda dt, msg=formatted_msg: self.update_upload_progress(f" {msg}"), 0)
# 添加完成提示
Clock.schedule_once(lambda dt: self.update_upload_progress("\n[成功] 上传完成!"), 0)
# 延迟关闭对话框,让用户看到完成信息
Clock.schedule_once(lambda dt: self.close_upload_progress_dialog(), 1.5)
Clock.schedule_once(lambda dt: toast("[成功] 上传成功,知识库已更新"), 1.5)
else:
error_msg = res.get('message', '未知错误')
Clock.schedule_once(lambda dt: self.close_upload_progress_dialog(), 0)
Clock.schedule_once(lambda dt: toast(f"[错误] 上传失败: {error_msg}"), 0)
elif resp.status_code == 401:
Clock.schedule_once(lambda dt: self.close_upload_progress_dialog(), 0)
Clock.schedule_once(lambda dt: toast("[错误] 认证失败,请重新登录"), 0)
elif resp.status_code == 422:
# 422 通常是请求格式错误
try:
error_detail = resp.json()
error_msg = error_detail.get('detail', '请求格式错误')
Clock.schedule_once(lambda dt: self.close_upload_progress_dialog(), 0)
Clock.schedule_once(lambda dt: toast(f"[错误] 上传失败: {error_msg}"), 0)
except:
Clock.schedule_once(lambda dt: self.close_upload_progress_dialog(), 0)
Clock.schedule_once(lambda dt: toast(f"[错误] 上传失败: 服务器无法处理该文件 (422)"), 0)
else:
error_msg = f"服务器错误: {resp.status_code}"
try:
error_detail = resp.json()
if 'detail' in error_detail:
error_msg = error_detail['detail']
except:
pass
Clock.schedule_once(lambda dt: self.close_upload_progress_dialog(), 0)
Clock.schedule_once(lambda dt: toast(f"[错误] {error_msg}"), 0)
except FileNotFoundError:
Clock.schedule_once(lambda dt: self.close_upload_progress_dialog(), 0)
Clock.schedule_once(lambda dt: toast("[错误] 文件不存在或已被删除"), 0)
except PermissionError:
Clock.schedule_once(lambda dt: self.close_upload_progress_dialog(), 0)
Clock.schedule_once(lambda dt: toast("[错误] 没有权限读取该文件"), 0)
except requests.exceptions.Timeout:
Clock.schedule_once(lambda dt: self.close_upload_progress_dialog(), 0)
Clock.schedule_once(lambda dt: toast("[错误] 上传超时,请检查网络或文件大小"), 0)
except requests.exceptions.ConnectionError:
Clock.schedule_once(lambda dt: self.close_upload_progress_dialog(), 0)
Clock.schedule_once(lambda dt: toast("[错误] 无法连接到服务器"), 0)
except Exception as e:
Clock.schedule_once(lambda dt: self.close_upload_progress_dialog(), 0)
Clock.schedule_once(lambda dt: toast(f"[错误] 上传失败: {str(e)}"), 0)
# --- 界面构建 ---
def build_splash_screen(self):
screen = SplashScreen(name="splash")
layout = MDBoxLayout(orientation='vertical', padding=40, spacing=20)
icon = MDIconButton(
icon="robot-outline", icon_size="80sp", pos_hint={"center_x": .5},
theme_text_color="Custom", text_color=self.theme_cls.primary_color
)
layout.add_widget(MDLabel(size_hint_y=0.3))
layout.add_widget(icon)
layout.add_widget(MDLabel(text="企业知识库助手", halign="center", font_style="H4", theme_text_color="Primary"))
screen.add_widget(layout)
return screen
def build_home_screen(self):
screen = HomeScreen(name="home")
layout = MDBoxLayout(orientation='vertical')
toolbar = MDTopAppBar(title="智能问答")
toolbar.elevation = 2
# [修改] 增加 "file-upload" 按钮到左侧菜单
toolbar.left_action_items = [
["menu", lambda x: self.switch_screen("settings")],
["file-upload", lambda x: self.open_file_manager()]
]
toolbar.right_action_items = [["delete-outline", lambda x: self.show_clear_dialog()]]
layout.add_widget(toolbar)
self.scroll = MDScrollView(
do_scroll_x=False, # 只允许垂直滚动
do_scroll_y=True, # 允许垂直滚动
bar_width=dp(4), # 滚动条宽度
scroll_type=['bars', 'content'] # 允许内容滚动和滚动条
)
self.chat_list = MDList()
self.chat_list.padding = "15dp"
self.chat_list.spacing = "15dp"
# 关键:设置列表为自适应高度,允许滚动
self.chat_list.size_hint_y = None
# 绑定高度,让列表高度随内容增长
self.chat_list.bind(minimum_height=self.chat_list.setter('height'))
self.empty_state_box = MDBoxLayout(
orientation="vertical", size_hint_y=None, height=dp(300),
pos_hint={"center_y": .5}, opacity=1
)
self.empty_state_box.add_widget(
MDIconButton(icon="comment-text-outline", icon_size="64sp", pos_hint={"center_x": .5}))
self.empty_state_box.add_widget(
MDLabel(text="暂无对话记录\n请尝试询问业务问题", halign="center", theme_text_color="Hint"))
self.scroll.add_widget(self.chat_list)
layout.add_widget(self.scroll)
self.spinner = MDSpinner(size_hint=(None, None), size=(dp(24), dp(24)), pos_hint={'center_x': .5}, active=False,
palette=[[0.1, 0.5, 0.9, 1]])
self.spinner_box = MDBoxLayout(size_hint_y=None, height=0)
self.spinner_box.add_widget(self.spinner)
layout.add_widget(self.spinner_box)
input_card = MDCard(elevation=4, radius=[0, 0, 0, 0], size_hint_y=None, height="80dp")
input_box = MDBoxLayout(padding="10dp", spacing="10dp", orientation='horizontal')
self.text_input = MDTextField(
hint_text="输入您的问题...",
mode="fill",
fill_color_normal=(0.95, 0.95, 0.95, 1),
radius=[20, 20, 20, 20],
active_line=False,
size_hint_x=0.85,
multiline=True, # 允许多行输入
max_text_length=500, # 最大字符数
line_color_normal=(0, 0, 0, 0), # 隐藏边框线
line_color_focus=(0, 0, 0, 0) # 隐藏焦点线
)
send_btn = MDIconButton(
icon="send-circle", icon_size="40sp", theme_text_color="Custom",
text_color=self.theme_cls.primary_color, on_release=self.send_message
)
input_box.add_widget(self.text_input)
input_box.add_widget(send_btn)
input_card.add_widget(input_box)
layout.add_widget(input_card)
screen.add_widget(layout)
screen.on_enter = lambda: Clock.schedule_once(self.load_history, 0.1)
return screen
def build_settings_screen(self):
screen = SettingsScreen(name="settings")
screen.on_enter = lambda: self.refresh_settings_ui(screen)
screen.add_widget(MDBoxLayout())
return screen
def refresh_settings_ui(self, screen):
screen.clear_widgets()
layout = MDBoxLayout(orientation='vertical')
toolbar = MDTopAppBar(title="系统设置")
toolbar.left_action_items = [["arrow-left", lambda x: self.switch_screen("home")]]
layout.add_widget(toolbar)
scroll = MDScrollView()
list_view = MDList()
api_display = BASE_URL[:25] + "..." if len(BASE_URL) > 25 else BASE_URL
items = [
("account", "当前账号", self.current_user),
("information-outline", "版本信息", APP_VERSION),
("server-network", "服务器状态", "在线 (Logged In)"),
("database", "知识库版本", "2025.12.29_Build"),
("api", "接口地址", api_display),
]
for icon, title, secondary in items:
card = MDCard(orientation="horizontal", padding="15dp", spacing="15dp", size_hint_y=None, height="70dp",
ripple_behavior=True)
card.add_widget(MDIconButton(icon=icon, pos_hint={"center_y": .5}))
text_box = MDBoxLayout(orientation="vertical", pos_hint={"center_y": .5})
text_box.add_widget(MDLabel(text=title, bold=True, theme_text_color="Primary"))
text_box.add_widget(MDLabel(text=secondary, theme_text_color="Secondary", font_style="Caption"))
card.add_widget(text_box)
list_view.add_widget(card)
scroll.add_widget(list_view)
layout.add_widget(scroll)
logout_box = MDBoxLayout(padding="20dp", size_hint_y=None, height="80dp")
logout_btn = MDFillRoundFlatButton(
text="退出登录", font_size="18sp", size_hint_x=1, md_bg_color=(0.9, 0.2, 0.2, 1),
on_release=lambda x: self.logout()
)
logout_box.add_widget(logout_btn)
layout.add_widget(logout_box)
layout.add_widget(MDLabel(text="© 2025 Enterprise RAG System", halign="center", size_hint_y=None, height="30dp",
theme_text_color="Hint", font_style="Caption"))
screen.add_widget(layout)
def switch_screen(self, screen_name):
self.sm.transition.direction = 'right' if screen_name == 'home' else 'left'
self.sm.current = screen_name
def send_message(self, instance):
text = self.text_input.text.strip()
if not text: return
self.add_bubble(text, is_user=True, auto_scroll=True) # 用户发送消息时强制滚动
self.text_input.text = ""
self.show_loading(True)
threading.Thread(target=self.request_api, args=(text,)).start()
def request_api(self, question):
try:
# [新增] 添加 Token 到请求头
headers = self._get_auth_headers()
payload = {"question": question, "username": self.current_user}
resp = requests.post(CHAT_URL, json=payload, headers=headers, timeout=60)
if resp.status_code == 200:
data = resp.json()
answer = data.get("answer", "解析错误")
logs = data.get("logs", [])
elif resp.status_code == 401:
answer = "认证失败,请重新登录"
logs = []
else:
answer = f"服务器错误: {resp.status_code}"
logs = []
except Exception as e:
answer = f"网络错误: {e}"
logs = []
Clock.schedule_once(lambda dt: self.on_api_response(answer, logs), 0)
def on_api_response(self, answer, logs=None):
self.show_loading(False)
self.add_bubble(answer, is_user=False, logs=logs)
def add_bubble(self, text, is_user, logs=None, auto_scroll=False):
if self.empty_state_box.parent:
self.empty_state_box.parent.remove_widget(self.empty_state_box)
bubble = ChatBubble(text=text, logs=logs, is_user=is_user)
self.chat_list.add_widget(bubble)
# 只有在用户发送消息或明确要求时才自动滚动
if auto_scroll:
# 用户发送消息,强制滚动到底部
Clock.schedule_once(lambda dt: self.scroll_to_bottom(), 0.3)
else:
# AI回复消息,只在用户已经在底部时才滚动
Clock.schedule_once(lambda dt: self.scroll_to_bottom_if_needed(), 0.3)
def scroll_to_bottom_if_needed(self):
"""只在用户已经在底部附近时才滚动到底部"""
try:
if len(self.chat_list.children) == 0:
return
# 检查滚动位置:scroll_y = 0 表示在底部,1 表示在顶部
# 如果已经在底部附近(距离底部小于10%),则自动滚动
if self.scroll.scroll_y < 0.1:
self.scroll_to_bottom()
except:
pass
def scroll_to_bottom(self):
"""强制滚动到底部"""
try:
if len(self.chat_list.children) > 0:
# 直接设置滚动位置到底部
self.scroll.scroll_y = 0
except:
pass
def show_loading(self, show):
self.spinner.active = show
self.spinner_box.height = "40dp" if show else 0
def show_clear_dialog(self):
if not self.dialog:
# [修改] 提示文案
self.dialog = MDDialog(
title="警告",
text="确定要清空对话吗?\n这将同时删除【服务器数据库】中的历史记录。",
buttons=[
MDFillRoundFlatButton(text="取消", md_bg_color=(0.5, 0.5, 0.5, 1),
on_release=lambda x: self.dialog.dismiss()),
MDFillRoundFlatButton(text="确认清空", md_bg_color=(0.9, 0.2, 0.2, 1),
on_release=lambda x: self.clear_ui())
],
)
self.dialog.open()
def clear_ui(self):
self.dialog.dismiss()
self.chat_list.clear_widgets()
# [新增] 调用 API 清空数据库
toast("正在清空数据库...")
threading.Thread(target=self._clear_db_thread).start()
def _clear_db_thread(self):
try:
# [新增] 添加 Token 到请求头
headers = self._get_auth_headers()
resp = requests.post(CLEAR_URL, headers=headers, timeout=10)
if resp.status_code == 200:
Clock.schedule_once(lambda dt: toast("[成功] 历史记录已永久删除"), 0)
elif resp.status_code == 401:
Clock.schedule_once(lambda dt: toast("认证失败,请重新登录"), 0)
else:
Clock.schedule_once(lambda dt: toast("删除失败"), 0)
except:
Clock.schedule_once(lambda dt: toast("网络连接错误"), 0)
def load_history(self, dt):
if len(self.chat_list.children) > 0: return
def _fetch():
try:
# [新增] 添加 Token 到请求头
headers = self._get_auth_headers()
resp = requests.get(HISTORY_URL, headers=headers, timeout=5)
if resp.status_code == 200:
data = resp.json()
Clock.schedule_once(lambda dt: self._render_history(data), 0)
except:
pass
threading.Thread(target=_fetch).start()
def _render_history(self, data):
if not data: return
if self.empty_state_box.parent:
self.empty_state_box.parent.remove_widget(self.empty_state_box)
for item in data:
is_user = (item["role"] == "user")
# 加载历史记录时不自动滚动
self.add_bubble(item["content"], is_user=is_user, logs=None, auto_scroll=False)
# 历史记录加载完成后,滚动到底部(显示最新消息)
Clock.schedule_once(lambda dt: self.scroll_to_bottom(), 0.5)
def logout(self):
threading.Thread(target=self._do_logout, args=(self.current_user,)).start()
self.current_user = None
self.access_token = None # [新增] 清除 Token
self.chat_list.clear_widgets()
self.sm.transition.direction = 'right'
self.sm.current = "login"
toast("已退出登录")
def _do_logout(self, username):
try:
# [新增] 添加 Token 到请求头
headers = self._get_auth_headers()
requests.post(LOGOUT_URL, json={"username": username}, headers=headers, timeout=3)
except:
pass
# [新增] 辅助方法:生成带 Token 的请求头
def _get_auth_headers(self):
"""生成包含认证 Token 的请求头"""
if self.access_token:
return {
"Authorization": f"Bearer {self.access_token}",
"Content-Type": "application/json"
}
return {"Content-Type": "application/json"}
if __name__ == "__main__":
ChatApp().run()