From 97e2c6b2f45e94facad02fa1b3b9be8baaed8cec Mon Sep 17 00:00:00 2001 From: yumoqing Date: Sat, 1 Aug 2026 23:36:29 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=201=20=E2=80=94=20intent=20recogn?= =?UTF-8?q?ition,=20guided=20conversation,=20context=20bar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- wwwroot/api/cockpit_chat.dspy | 281 +++++++++++++++++++++++++++------- wwwroot/index.ui | 46 +++++- 2 files changed, 260 insertions(+), 67 deletions(-) diff --git a/wwwroot/api/cockpit_chat.dspy b/wwwroot/api/cockpit_chat.dspy index 48a8c38..b937b74 100644 --- a/wwwroot/api/cockpit_chat.dspy +++ b/wwwroot/api/cockpit_chat.dspy @@ -47,12 +47,57 @@ async def _load_agent_settings(sor, uid): } +async def _load_context(sor, uid): + """Load session context: current project/iteration.""" + settings = await _load_agent_settings(sor, uid) + recs = await sor.sqlExe( + "SELECT current_project_id, current_iteration_id FROM sd_agent_settings WHERE user_id=${uid}$", + {"uid": uid} + ) + ctx = {'project_id': '', 'iteration_id': '', 'project_name': '', 'iteration_name': ''} + if recs: + r = recs[0] + ctx['project_id'] = getattr(r, 'current_project_id', '') or '' + ctx['iteration_id'] = getattr(r, 'current_iteration_id', '') or '' + if ctx['project_id']: + projs = await sor.sqlExe( + "SELECT name FROM sd_projects WHERE id=${pid}$", {"pid": ctx['project_id']} + ) + if projs: + ctx['project_name'] = getattr(projs[0], 'name', '') + if ctx['iteration_id']: + iters = await sor.sqlExe( + "SELECT iteration_name FROM sd_iterations WHERE id=${iid}$", {"iid": ctx['iteration_id']} + ) + if iters: + ctx['iteration_name'] = getattr(iters[0], 'iteration_name', '') + return ctx + + +async def _save_context(sor, uid, project_id, iteration_id): + """Save session context.""" + existing = await sor.sqlExe( + "SELECT id FROM sd_agent_settings WHERE user_id=${uid}$", {"uid": uid} + ) + if existing: + await sor.sqlExe( + "UPDATE sd_agent_settings SET current_project_id=${pid}$, current_iteration_id=${iid}$ WHERE user_id=${uid}$", + {"pid": project_id or '', "iid": iteration_id or '', "uid": uid} + ) + else: + await sor.C('sd_agent_settings', { + 'id': getID(), 'user_id': uid, + 'current_project_id': project_id or '', + 'current_iteration_id': iteration_id or '', + }) + + async def _select_model(sor, preferred_llm_id, has_files): """Select best model: prefer user choice, then multimodal if files, else first active text.""" - # If user has preferred model, use it + # If user has preferred model, use it (match by id or name) if preferred_llm_id: recs = await sor.sqlExe( - "SELECT id, name, provider, model_id, api_base, api_key, capabilities FROM llm WHERE id=${lid}$ AND status='active'", + "SELECT id, name, provider, model_id, api_base, api_key, capabilities FROM llm WHERE (id=${lid}$ OR name=${lid}$) AND status='active'", {"lid": preferred_llm_id} ) if recs: @@ -174,88 +219,206 @@ async def _call_llm(model_info, messages, temperature): return content +INTENT_PROMPT = """你是一个开发产线意图分类器。分析用户输入,返回 JSON。 + +意图类型: +- new_project: 创建新项目 +- select_project: 切换到已有项目 +- new_iteration: 在当前项目下创建新迭代 +- new_task: 提交开发任务(需关联项目/迭代) +- add_bug: 报告Bug +- query: 查询当前状态 +- chat: 开发相关的一般对话 +- out_of_scope: 完全无关软件开发 + +当前上下文:项目={ctx},迭代={iter} + +返回纯JSON(不要markdown包裹): +{"intent":"...","confidence":0.8,"project_name":"...","iteration_name":"...","title":"...","description":"...","missing_info":"..."}""" + + +async def _classify_intent(model_info, message, ctx, history_msgs): + """Classify user intent using LLM.""" + ctx_str = ctx.get('project_name', '') or '无' + iter_str = ctx.get('iteration_name', '') or '无' + prompt = INTENT_PROMPT.replace('{ctx}', ctx_str).replace('{iter}', iter_str) + msgs = [{"role": "system", "content": prompt}] + for h in history_msgs[-4:]: + msgs.append(h) + msgs.append({"role": "user", "content": message}) + raw = await _call_llm(model_info, msgs, 0.2) + raw = raw.strip() + if raw.startswith('```'): + raw = raw.split('\n', 1)[1].rsplit('```', 1)[0] + try: + return json.loads(raw) + except Exception: + return {"intent": "chat", "confidence": 0.5, "missing_info": ""} + + +async def _find_project(sor, name, org_id): + """Find project by name.""" + if not name: return None + recs = await sor.sqlExe( + "SELECT id, name FROM sd_projects WHERE name=${name}$ AND org_id=${oid}$", + {"name": name, "oid": org_id}) + return recs[0] if recs else None + + +SCOPE_GUIDE = """我可以帮你: +📁 项目管理 — "创建电商平台项目" / "切换到XXX项目" +🔄 迭代管理 — "创建Sprint3" / "查看迭代进度" +📝 提交任务 — "设计用户表结构" / "实现登录API" +🐛 Bug管理 — "登录页报500" / "我的Bug列表" +📊 查询 — "当前项目进度" / "有哪些迭代" +请描述你的需求。""" + + # ==================== ACTION HANDLERS ==================== if action == 'send_message': iteration_id = (params_kw or {}).get('iteration_id', '') - task_id = (params_kw or {}).get('task_id', '') message_text = (params_kw or {}).get('message_text', '').strip() - model_id = (params_kw or {}).get('model_id', '') + user_model_id = (params_kw or {}).get('model_id', '') file_paths_raw = (params_kw or {}).get('file_paths', '[]') - debug(f'send_message: model_id={model_id} iteration_id={iteration_id} msg_len={len(message_text)}') + debug(f'send_message: model_id={user_model_id} iteration_id={iteration_id} msg_len={len(message_text)}') if not message_text: return json.dumps({"error": "message_text is required"}, ensure_ascii=False) - # Parse file paths - has_files = False - try: - fps = json.loads(file_paths_raw) - has_files = len(fps) > 0 - except Exception: - pass - uid = await get_user() + org_id = await get_userorgid() or '0' async with DBPools().sqlorContext(dbname) as sor: - # 1. Save user message - msg_id = getID() - await sor.C('sd_conversations', { - 'id': msg_id, - 'iteration_id': iteration_id or '', - 'task_id': task_id or '', - 'step_name': '', - 'role': 'user', - 'content': message_text, - 'attachments': file_paths_raw, - 'msg_type': 'text', - 'org_id': '0', - 'created_by': uid - }) - - # 2. Load agent settings + # 1. Load context and model + ctx = await _load_context(sor, uid) settings = await _load_agent_settings(sor, uid) - - # 3. Select model (preferred → auto by file type → first active) - selected_llm_id = model_id or settings['llm_id'] - model_info = await _select_model(sor, selected_llm_id, has_files) + selected_llm_id = user_model_id or settings['llm_id'] + model_info = await _select_model(sor, selected_llm_id, False) if not model_info: return json.dumps({"error": "No active LLM model configured"}, ensure_ascii=False) - # 4. Build context - messages = await _build_context( - sor, iteration_id, task_id, - settings['max_context'], settings['system_prompt'] + # 2. Classify intent + history = await sor.sqlExe( + "SELECT role, content FROM sd_conversations WHERE iteration_id=${iid}$ OR iteration_id='' ORDER BY created_at DESC LIMIT 4", + {"iid": iteration_id or ctx.get('iteration_id', '')} ) - # Append current user message - messages.append({"role": "user", "content": message_text}) + history_msgs = [] + for h in reversed(history): + role = 'user' if getattr(h, 'role', '') == 'user' else 'assistant' + history_msgs.append({"role": role, "content": getattr(h, 'content', '')}) + + intent = await _classify_intent(model_info, message_text, ctx, history_msgs) + debug(f'intent: {intent}') - # 5. Call LLM - try: - agent_reply = await _call_llm(model_info, messages, settings['temperature']) - except Exception as e: - agent_reply = f"抱歉,模型调用失败: {str(e)[:200]}" + # 3. Route by intent + intent_type = intent.get('intent', 'chat') + confidence = intent.get('confidence', 0.5) - # 6. Save agent response + if intent_type == 'out_of_scope' or (intent_type == 'chat' and confidence < 0.6 and not ctx['project_id']): + agent_reply = SCOPE_GUIDE + elif intent.get('missing_info') and confidence < 0.7: + agent_reply = f"让我确认一下:{intent.get('missing_info', '请提供更多信息')}" + elif intent_type == 'new_project': + pname = intent.get('project_name', '') or message_text[:50] + proj = await _find_project(sor, pname, org_id) + if proj: + agent_reply = f"项目「{pname}」已存在。已切换到该项目。" + await _save_context(sor, uid, proj.id, '') + else: + pid = getID() + await sor.C('sd_projects', { + 'id': pid, 'name': pname, 'description': intent.get('description', ''), + 'project_type': 'software', 'org_id': org_id, 'created_by': uid, 'status': 'active' + }) + # Auto-create default iteration + iid = getID() + await sor.C('sd_iterations', { + 'id': iid, 'project_id': pid, 'iteration_name': '默认迭代', + 'iteration_type': 'sprint', 'org_id': org_id, 'created_by': uid, 'status': 'active' + }) + await _save_context(sor, uid, pid, iid) + agent_reply = f"✅ 项目「{pname}」已创建,默认迭代已就绪。现在可以提交任务了。" + elif intent_type == 'select_project': + pname = intent.get('project_name', '') + proj = await _find_project(sor, pname, org_id) + if proj: + await _save_context(sor, uid, proj.id, ctx['iteration_id']) + agent_reply = f"已切换到项目「{pname}」。" + else: + agent_reply = f"未找到项目「{pname}」。请先创建项目后再切换。" + elif intent_type == 'new_task': + pid = ctx['project_id'] + iid = intent.get('iteration_name', '') or ctx['iteration_id'] + if not pid: + agent_reply = "请先指定项目。「创建XXX项目」或「切换到XXX项目」" + elif not iid: + agent_reply = "请指定迭代。「创建Sprint1」或「切换到XXX迭代」" + else: + title = intent.get('title', '') or message_text[:100] + task_params = {'description': intent.get('description', ''), 'input_text': message_text} + try: + result = await pipeline_submit(org_id, 'sdlc_general', uid, title, task_params) + rd = json.loads(result) + if rd.get('success'): + task_id = rd.get('task_id', '') + await _save_context(sor, uid, pid, iid) + agent_reply = f"✅ 任务「{title}」已提交({task_id}),产线开始执行。" + else: + agent_reply = f"任务提交失败:{rd.get('message', '未知错误')}" + except Exception as e: + agent_reply = f"任务提交失败:{str(e)[:200]}" + elif intent_type == 'add_bug': + pid = ctx['project_id'] + iid = ctx['iteration_id'] + if not pid: + agent_reply = "请先指定项目后再报告Bug。" + else: + bid = getID() + await sor.C('sd_bugs', { + 'id': bid, 'iteration_id': iid or '', 'title': intent.get('title', '') or message_text[:100], + 'description': intent.get('description', ''), 'severity': 'major', 'priority': 'P1', + 'status': 'open', 'reporter_type': 'human', 'reporter_id': uid, 'created_at': curDateString() + }) + agent_reply = f"🐛 Bug已记录({bid}):{message_text[:100]}" + elif intent_type == 'query': + ctx_info = [] + if ctx['project_name']: + ctx_info.append(f"当前项目:{ctx['project_name']}") + if ctx['iteration_name']: + ctx_info.append(f"当前迭代:{ctx['iteration_name']}") + if ctx_info: + agent_reply = '\n'.join(ctx_info) + '\n\n请描述具体想查询什么(如:任务列表、Bug列表等)' + else: + agent_reply = "当前未选择项目。请先「创建XXX项目」或「切换到XXX项目」。" + else: + # chat: general conversation + messages = await _build_context(sor, iteration_id or ctx['iteration_id'], '', + settings['max_context'], settings['system_prompt']) + messages.append({"role": "user", "content": message_text}) + try: + agent_reply = await _call_llm(model_info, messages, settings['temperature']) + except Exception as e: + agent_reply = f"抱歉,模型调用失败: {str(e)[:200]}" + + # 4. Save conversation + msg_id = getID() + await sor.C('sd_conversations', { + 'id': msg_id, 'iteration_id': iteration_id or ctx.get('iteration_id', ''), + 'task_id': '', 'step_name': '', 'role': 'user', 'content': message_text, + 'attachments': file_paths_raw, 'msg_type': 'text', 'org_id': org_id, 'created_by': uid + }) agent_msg_id = getID() await sor.C('sd_conversations', { - 'id': agent_msg_id, - 'iteration_id': iteration_id or '', - 'task_id': task_id or '', - 'step_name': '', - 'role': 'agent', - 'content': agent_reply, - 'attachments': '[]', - 'msg_type': 'text', - 'org_id': '0', - 'created_by': 'system' + 'id': agent_msg_id, 'iteration_id': iteration_id or ctx.get('iteration_id', ''), + 'task_id': '', 'step_name': '', 'role': 'agent', 'content': agent_reply, + 'attachments': '[]', 'msg_type': 'text', 'org_id': org_id, 'created_by': 'system' }) return json.dumps({ - "success": True, - "message_id": msg_id, - "agent_reply": agent_reply, - "model_used": model_info.name, + "success": True, "message_id": msg_id, "agent_reply": agent_reply, + "model_used": model_info.name, "intent": intent_type, + "context": {"project_name": ctx.get('project_name', ''), "iteration_name": ctx.get('iteration_name', '')} }, ensure_ascii=False) diff --git a/wwwroot/index.ui b/wwwroot/index.ui index ff54e17..5197e9e 100644 --- a/wwwroot/index.ui +++ b/wwwroot/index.ui @@ -19,13 +19,13 @@ { "widgettype": "Title2", "options": { - "text": "\u5f00\u53d1\u4ea7\u7ebf\u9a7e\u9a76\u8231" + "text": "开发产线驾驶舱" } }, { "widgettype": "Text", "options": { - "text": "AI\u9a71\u52a8\u7684\u5bf9\u8bdd\u5f0f\u5f00\u53d1", + "text": "AI驱动的对话式开发", "cfontsize": 0.9, "color": "#94a3b8" } @@ -44,7 +44,7 @@ "name": "model_id", "uitype": "code", "label": "", - "placeholder": "\u9009\u62e9\u6a21\u578b", + "placeholder": "选择模型", "cwidth": 12, "dataurl": "/pipeline-sdlc/api/cockpit_model_options.dspy", "valueField": "model_id", @@ -61,7 +61,7 @@ "widgettype": "Button", "options": { "name": "model_config", - "label": "\u6a21\u578b\u914d\u7f6e", + "label": "模型配置", "css": "small" }, "binds": [ @@ -70,7 +70,7 @@ "event": "click", "actiontype": "script", "target": "self", - "script": "var pw=new bricks.PopupWindow({title:'\u6a21\u578b\u914d\u7f6e',cwidth:36,cheight:26,auto_open:true});bricks.widgetBuild({widgettype:'urlwidget',options:{url:'/pipeline_core/llm/index.ui',method:'GET'}},pw.content_w).then(function(w){if(w)pw.content_w.add_widget(w);});" + "script": "var pw=new bricks.PopupWindow({title:'模型配置',cwidth:36,cheight:26,auto_open:true});bricks.widgetBuild({widgettype:'urlwidget',options:{url:'/pipeline_core/llm/index.ui',method:'GET'}},pw.content_w).then(function(w){if(w)pw.content_w.add_widget(w);});" } ] } @@ -95,6 +95,36 @@ } ] }, + { + "widgettype": "HBox", + "id": "context_bar", + "options": { + "width": "100%", + "padding": "4px 24px 4px 24px", + "cheight": 2.5, + "gap": "12px", + "alignItems": "center", + "bgcolor": "#e8f0fe" + }, + "subwidgets": [ + { + "widgettype": "Text", + "options": { + "text": "📁 当前项目:--", + "cfontsize": 0.85, + "color": "#1e40af" + } + }, + { + "widgettype": "Text", + "options": { + "text": "🔄 当前迭代:--", + "cfontsize": 0.85, + "color": "#1e40af" + } + } + ] + }, { "widgettype": "VBox", "options": { @@ -130,7 +160,7 @@ { "widgettype": "Text", "options": { - "text": "\u9009\u62e9\u4e00\u4e2a\u8fed\u4ee3\u540e\uff0c\u5728\u4e0b\u65b9\u8f93\u5165\u4f60\u7684\u9700\u6c42\u3002", + "text": "选择一个迭代后,在下方输入你的需求。", "cfontsize": 1, "color": "#64748b" } @@ -138,7 +168,7 @@ { "widgettype": "Text", "options": { - "text": "Agent \u5c06\u81ea\u52a8\u5206\u6790\u9700\u6c42\u3001\u751f\u6210\u8bbe\u8ba1\u3001\u9a71\u52a8\u4ea7\u7ebf\u6267\u884c\u3002", + "text": "Agent 将自动分析需求、生成设计、驱动产线执行。", "cfontsize": 0.85, "color": "#94a3b8" } @@ -163,7 +193,7 @@ "event": "inputed", "actiontype": "script", "target": "self", - "script": "var p=params.prompt;var files=params.add_files||[];var iid='';try{var iter=bricks.getWidgetById('current_iteration_id',bricks.app);if(iter)iid=iter.options.value||'';}catch(e){}var mid='';try{var ms=bricks.getWidgetById('model_selector',bricks.app);if(ms){var f=ms.form_element;if(f){var el=f.querySelector('[name=model_id]')||f.querySelector('select');if(el)mid=el.value||'';}}}catch(e){}var chat=bricks.getWidgetById('chat_scroll',bricks.app);var at=null;if(chat){var ub=new bricks.HBox({width:'100%'});var um=new bricks.VBox({width:'85%',alignSelf:'flex-end',bgcolor:'#dbeafe',borderRadius:'12px',padding:'12px 16px',marginBottom:'10px',gap:'4px'});um.add_widget(new bricks.Text({text:'\\u4f60',cfontsize:0.75,color:'#2563eb',fontWeight:'bold'}));um.add_widget(new bricks.Text({text:p,cfontsize:0.95,color:'#1e293b',whiteSpace:'pre-wrap'}));ub.add_widget(new bricks.VBox({css:'filler'}));ub.add_widget(um);ub.add_widget(new bricks.Svg({rate:2,url:bricks_resource('imgs/chat-user.svg')}));chat.add_widget(ub);var ab=new bricks.HBox({width:'100%'});var am=new bricks.VBox({width:'85%',alignSelf:'flex-start',bgcolor:'#e8f0fe',borderRadius:'12px',padding:'12px 16px',marginBottom:'10px',gap:'4px'});am.add_widget(new bricks.Text({text:'Agent',cfontsize:0.75,color:'#3b82f6',fontWeight:'bold'}));at=new bricks.Text({text:'\\u6b63\\u5728\\u5206\\u6790\\u9700\\u6c42...',cfontsize:0.85,color:'#64748b'});am.add_widget(at);ab.add_widget(new bricks.Svg({rate:2,url:bricks_resource('imgs/llm.svg')}));ab.add_widget(am);ab.add_widget(new bricks.VBox({css:'filler'}));chat.add_widget(ab);}var body='message_text='+encodeURIComponent(p)+'&iteration_id='+encodeURIComponent(iid)+'&model_id='+encodeURIComponent(mid)+'&action=send_message&file_paths='+encodeURIComponent(JSON.stringify(files.map(function(f){return f.name||f})));fetch('/pipeline-sdlc/api/cockpit_chat.dspy',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:body}).then(function(r){return r.json()}).then(function(r){if(r.success){if(at)at.set_text(r.agent_reply||'\\u5df2\\u5904\\u7406');var s=bricks.getWidgetById('stats_row',bricks.app);if(s)s.render({});}else{if(at)at.set_text('\\u9519\\u8bef: '+(r.error||'\\u672a\\u77e5\\u9519\\u8bef'));}});" + "script": "var p=params.prompt;var files=params.add_files||[];var iid='';try{var iter=bricks.getWidgetById('current_iteration_id',bricks.app);if(iter)iid=iter.options.value||'';}catch(e){}var mid='';try{var el=document.querySelector('#model_selector select')||document.querySelector('select');if(el)mid=el.value||'';}catch(e){}var chat=bricks.getWidgetById('chat_scroll',bricks.app);var at=null;if(chat){var ub=new bricks.HBox({width:'100%'});var um=new bricks.VBox({width:'85%',alignSelf:'flex-end',bgcolor:'#dbeafe',borderRadius:'12px',padding:'12px 16px',marginBottom:'10px',gap:'4px'});um.add_widget(new bricks.Text({text:'\\u4f60',cfontsize:0.75,color:'#2563eb',fontWeight:'bold'}));um.add_widget(new bricks.Text({text:p,cfontsize:0.95,color:'#1e293b',whiteSpace:'pre-wrap'}));ub.add_widget(new bricks.VBox({css:'filler'}));ub.add_widget(um);ub.add_widget(new bricks.Svg({rate:2,url:bricks_resource('imgs/chat-user.svg')}));chat.add_widget(ub);var ab=new bricks.HBox({width:'100%'});var am=new bricks.VBox({width:'85%',alignSelf:'flex-start',bgcolor:'#e8f0fe',borderRadius:'12px',padding:'12px 16px',marginBottom:'10px',gap:'4px'});am.add_widget(new bricks.Text({text:'Agent',cfontsize:0.75,color:'#3b82f6',fontWeight:'bold'}));at=new bricks.Text({text:'\\u6b63\\u5728\\u5206\\u6790\\u9700\\u6c42...',cfontsize:0.85,color:'#64748b'});am.add_widget(at);ab.add_widget(new bricks.Svg({rate:2,url:bricks_resource('imgs/llm.svg')}));ab.add_widget(am);ab.add_widget(new bricks.VBox({css:'filler'}));chat.add_widget(ab);}var body='message_text='+encodeURIComponent(p)+'&iteration_id='+encodeURIComponent(iid)+'&model_id='+encodeURIComponent(mid)+'&action=send_message&file_paths='+encodeURIComponent(JSON.stringify(files.map(function(f){return f.name||f})));fetch('/pipeline-sdlc/api/cockpit_chat.dspy',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:body}).then(function(r){return r.json()}).then(function(r){if(r.success){if(at)at.set_text(r.agent_reply||'\\u5df2\\u5904\\u7406');var s=bricks.getWidgetById('stats_row',bricks.app);if(s)s.render({});}else{if(at)at.set_text('\\u9519\\u8bef: '+(r.error||'\\u672a\\u77e5\\u9519\\u8bef'));}});" } ] }