From 2b0bc7519afbb677f28188c3a869915b27436c0c Mon Sep 17 00:00:00 2001 From: ymq Date: Sat, 8 Aug 2026 15:58:59 +0800 Subject: [PATCH] =?UTF-8?q?cockpit:=20file=20upload=20support=20=E2=80=94?= =?UTF-8?q?=20read=20files,=20inject=20to=20LLM,=20emit=20file=20widgets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Parse file_paths from request, read file contents (UTF-8, max 8000 chars) - File content appended to LLM message as 【附件文件内容】 - File widget streamed to frontend: 📎 filename (size) + hidden content preview - Frontend script: click .file-name → toggle .file-content visibility - File paths saved in pipeline_conversations.attachments --- wwwroot/api/cockpit_chat.dspy | 52 +++++++++++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/wwwroot/api/cockpit_chat.dspy b/wwwroot/api/cockpit_chat.dspy index 8de0f21..03d4cc7 100644 --- a/wwwroot/api/cockpit_chat.dspy +++ b/wwwroot/api/cockpit_chat.dspy @@ -310,12 +310,39 @@ _tool_names = { def _tool_label(tool): return _tool_names.get(tool, tool) +def _widget_file(filename, content_preview, file_size=0): + """Bricks widget: file attachment with expandable content preview.""" + size_str = f" ({file_size} bytes)" if file_size else "" + return { + "widgettype": "VBox", + "options": { + "css": "agent-file", + "style": {"border": "1px solid #e2e8f0", "borderRadius": "8px", "padding": "8px 12px", "margin": "4px 0"} + }, + "subwidgets": [ + {"widgettype": "HBox", "subwidgets": [ + {"widgettype": "Text", "options": {"text": f"📎 {filename}{size_str}", "css": "file-name", + "style": {"color": "#2563eb", "cursor": "pointer", "fontSize": "13px", "fontWeight": "bold"}}} + ]}, + {"widgettype": "Text", "options": {"text": content_preview or "(空文件)", "css": "file-content", + "style": {"display": "none", "whiteSpace": "pre-wrap", "fontSize": "12px", "color": "#475569", + "background": "#f8fafc", "borderRadius": "4px", "padding": "8px", "marginTop": "6px", + "maxHeight": "300px", "overflowY": "auto"}}} + ] + } + # ── Streaming Agent Loop ── if action == 'send_message': message_text = (params_kw or {}).get('message_text', '').strip() user_model_id = (params_kw or {}).get('model_id', '') + file_paths_raw = (params_kw or {}).get('file_paths', '[]') + file_paths = [] + try: + file_paths = json.loads(file_paths_raw) if isinstance(file_paths_raw, str) else (file_paths_raw or []) + except (json.JSONDecodeError, TypeError): + file_paths = [] if not message_text: return json.dumps({"error": "message_text is required"}, ensure_ascii=False) @@ -346,8 +373,29 @@ if action == 'send_message': for h in (history or []): msgs.append({"role":'user' if getattr(h,'role','')=='user' else 'assistant',"content":getattr(h,'content','')}) - await sor.C('pipeline_conversations',{'id':getID(),'iteration_id':'','task_id':'','role':'user','content':message_text,'attachments':'[]','msg_type':'text','org_id':org_id,'created_by':uid}) - msgs.append({"role":"user","content":message_text}) + await sor.C('pipeline_conversations',{'id':getID(),'iteration_id':'','task_id':'','role':'user','content':message_text,'attachments':json.dumps(file_paths),'msg_type':'text','org_id':org_id,'created_by':uid}) + + # 处理上传文件:读取内容、发送文件widget、注入LLM上下文 + file_contents = [] + for fp in file_paths: + try: + if os.path.isfile(fp): + with open(fp, 'r', encoding='utf-8', errors='replace') as f: + content = f.read()[:8000] + fsize = os.path.getsize(fp) + fname = os.path.basename(fp) + file_contents.append(f"=== 文件: {fname} ===\n{content}") + # 发送文件widget + preview = content[:1000] + ("\n...(截断)" if len(content) > 1000 else "") + yield json.dumps({"type":"widget","widget":_widget_file(fname, preview, fsize)}, ensure_ascii=False) + '\n' + except Exception: + pass + + # 把用户消息+文件内容合并为一条LLM消息 + full_message = message_text + if file_contents: + full_message = message_text + "\n\n【附件文件内容】\n" + "\n\n".join(file_contents) + msgs.append({"role":"user","content":full_message}) agent_reply = '' for turn in range(10):