diff --git a/models/skill_proposals.json b/models/skill_proposals.json new file mode 100644 index 0000000..87466a3 --- /dev/null +++ b/models/skill_proposals.json @@ -0,0 +1,29 @@ +{ + "summary": [ + { + "name": "skill_proposals", + "title": "技能提议表(agent自省提出skill化建议+人工创建,生命周期管理)", + "primary": ["id"], + "catelog": "entity" + } + ], + "fields": [ + {"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"}, + {"name": "name", "title": "技能名", "type": "str", "length": 100, "nullable": "no"}, + {"name": "description", "title": "技能描述", "type": "str", "length": 500, "nullable": "yes"}, + {"name": "content", "title": "SKILL.md草稿内容", "type": "text"}, + {"name": "source", "title": "来源(agent/manual)", "type": "str", "length": 16, "nullable": "no", "default": "manual"}, + {"name": "status", "title": "状态(pending/testing/approved/published/rejected)", "type": "str", "length": 20, "nullable": "no", "default": "pending"}, + {"name": "org_id", "title": "机构ID", "type": "str", "length": 32, "nullable": "yes"}, + {"name": "pipeline_id", "title": "产线ID", "type": "str", "length": 32, "nullable": "yes"}, + {"name": "feedback", "title": "审核反馈", "type": "text"}, + {"name": "created_by", "title": "创建人", "type": "str", "length": 64, "nullable": "yes"}, + {"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"}, + {"name": "updated_at", "title": "更新时间", "type": "timestamp", "nullable": "no"} + ], + "indexes": [ + {"name": "idx_skp_status", "idxtype": "index", "idxfields": ["status"]}, + {"name": "idx_skp_org", "idxtype": "index", "idxfields": ["org_id"]}, + {"name": "idx_skp_pipeline", "idxtype": "index", "idxfields": ["pipeline_id"]} + ] +} diff --git a/pipeline_core/agent_config.py b/pipeline_core/agent_config.py index adc783f..3df8f91 100644 --- a/pipeline_core/agent_config.py +++ b/pipeline_core/agent_config.py @@ -250,6 +250,12 @@ GENERAL_TOOLS = [ parameters={"pack": "技能集名称(如 ocai-h5-dev)"}, category="skill", ), + ToolDefinition( + name="propose_skill", + description="自省提出技能化建议:把值得沉淀的流程/经验/坑写成技能草稿提交审核(不自动生效)。用户要求沉淀经验、总结技能,或你发现反复出现的流程/坑/规范时调用", + parameters={"name": "技能名", "description": "技能描述", "content": "SKILL.md 草稿内容"}, + category="skill", + ), ToolDefinition( name="write_file", description="写入文件到工作空间(自动创建父目录)", diff --git a/wwwroot/api/skill_proposals.dspy b/wwwroot/api/skill_proposals.dspy new file mode 100644 index 0000000..50573cb --- /dev/null +++ b/wwwroot/api/skill_proposals.dspy @@ -0,0 +1,121 @@ +# skill_proposals.dspy - 技能提议管理(agent自省提议 + 人工创建,生命周期:pending→testing→approved→published) +# 发布动作:把提议的 SKILL.md 写入 skills/global 并增量同步到所有机构,然后状态置 published + +import json +import os + +uid = await get_user() +if not uid: + uid = 'user-01' +org_id = await get_userorgid() or '0' + +dbname = get_module_dbname('pipeline_core') +action = (params_kw or {}).get('action', 'list') + + +def _rec_to_dict(r): + out = {} + for k in ('id', 'name', 'description', 'content', 'source', 'status', + 'org_id', 'pipeline_id', 'feedback', 'created_by'): + out[k] = getattr(r, k, '') or '' + out['created_at'] = str(getattr(r, 'created_at', '') or '') + out['updated_at'] = str(getattr(r, 'updated_at', '') or '') + return out + + +async with DBPools().sqlorContext(dbname) as sor: + + if action == 'list': + status = (params_kw or {}).get('status', '') + where = "1=1" + p = {} + if status: + where = "status=${status}$" + p["status"] = status + recs = await sor.sqlExe( + "SELECT * FROM skill_proposals WHERE " + where + + " ORDER BY created_at DESC LIMIT 200", p) + proposals = [_rec_to_dict(r) for r in (recs or [])] + return json.dumps({"success": True, "proposals": proposals}, + ensure_ascii=False, default=str) + + elif action == 'create': + name = (params_kw or {}).get('name', '').strip() + description = (params_kw or {}).get('description', '').strip() + content = (params_kw or {}).get('content', '').strip() + if not name or not content: + return json.dumps({"success": False, "error": "需要 name 和 content"}, + ensure_ascii=False) + from appPublic.uniqueID import getID + await sor.C('skill_proposals', { + 'id': getID(), + 'name': name, + 'description': description, + 'content': content, + 'source': 'manual', + 'status': 'pending', + 'org_id': org_id, + 'pipeline_id': '', + 'created_by': uid, + }) + return json.dumps({"success": True, "name": name}, ensure_ascii=False) + + elif action == 'update_status': + qid = (params_kw or {}).get('id', '').strip() + status = (params_kw or {}).get('status', '').strip() + feedback = (params_kw or {}).get('feedback', '').strip() + if not qid or not status: + return json.dumps({"success": False, "error": "需要 id 和 status"}, + ensure_ascii=False) + if status not in ('pending', 'testing', 'approved', 'published', 'rejected'): + return json.dumps({"success": False, "error": f"非法状态: {status}"}, + ensure_ascii=False) + recs = await sor.R('skill_proposals', {'id': qid}) + if not recs: + return json.dumps({"success": False, "error": "提议不存在"}, ensure_ascii=False) + await sor.U('skill_proposals', {'id': qid, 'status': status, 'feedback': feedback or ''}) + return json.dumps({"success": True, "id": qid, "status": status}, ensure_ascii=False) + + elif action == 'publish': + # 发布:读提议 → 写 SKILL.md 到 skills/global → 增量同步到所有机构 → 状态 published + qid = (params_kw or {}).get('id', '').strip() + if not qid: + return json.dumps({"success": False, "error": "需要提议ID"}, ensure_ascii=False) + recs = await sor.R('skill_proposals', {'id': qid}) + if not recs: + return json.dumps({"success": False, "error": "提议不存在"}, ensure_ascii=False) + r = recs[0] + name = getattr(r, 'name', '') or '' + content = getattr(r, 'content', '') or '' + if not name or not content: + return json.dumps({"success": False, "error": "提议缺少 name 或 content"}, + ensure_ascii=False) + + # 写 SKILL.md 到全局模板 skills/global/{name}/ + from pipeline_core.skill_pack import get_skills_base, ensure_org_skills + skill_dir = os.path.join(get_skills_base(), "global", name) + os.makedirs(skill_dir, exist_ok=True) + with open(os.path.join(skill_dir, "SKILL.md"), "w", encoding="utf-8") as f: + f.write(content) + + # 增量同步到所有机构工作目录 + from pipeline_service.workspace import get_workspace_base + ws_base = os.path.normpath(await get_workspace_base(sor)) + synced = 0 + if os.path.isdir(ws_base): + for org_dir in os.listdir(ws_base): + org_path = os.path.join(ws_base, org_dir) + if os.path.isdir(org_path) and not org_dir.startswith('.'): + try: + ensure_org_skills(ws_base, org_dir) + synced += 1 + except Exception: + pass + + await sor.U('skill_proposals', {'id': qid, 'status': 'published'}) + return json.dumps({"success": True, "name": name, "synced_orgs": synced}, + ensure_ascii=False) + + else: + return json.dumps({"success": False, "error": "Unknown action: " + str(action)}, + ensure_ascii=False) diff --git a/wwwroot/api/skill_proposals_list.dspy b/wwwroot/api/skill_proposals_list.dspy new file mode 100644 index 0000000..63bac35 --- /dev/null +++ b/wwwroot/api/skill_proposals_list.dspy @@ -0,0 +1,104 @@ +# skill_proposals_list.dspy - 返回技能提议列表 widget(卡片 + 状态流转/发布/拒绝按钮) +# 供技能管理界面 urlwidget 加载 + +import json as _json + +uid = await get_user() +if not uid: + uid = 'user-01' + +dbname = get_module_dbname('pipeline_core') + +_STATUS_COLOR = { + 'pending': '#f59e0b', 'testing': '#2563eb', 'approved': '#16a34a', + 'published': '#7c3aed', 'rejected': '#dc2626', +} +_STATUS_LABEL = { + 'pending': '待审核', 'testing': '测试中', 'approved': '已审批', + 'published': '已发布', 'rejected': '已拒绝', +} + + +def _mk_btn(label, qid, act, color=''): + script = ( + "fetch('/pipeline_core/api/skill_proposals.dspy',{method:'POST'," + "headers:{'Content-Type':'application/x-www-form-urlencoded'}," + "body:'action=" + act + "&id=" + qid + "'}).then(function(r){return r.json()})" + ".then(function(d){if(d.success){location.reload();}else{alert(d.error||'操作失败');}});" + ) + opts = {"label": label, "css": "small"} + if color: + opts["color"] = color + return { + "widgettype": "Button", + "options": opts, + "binds": [{"wid": "self", "event": "click", "actiontype": "script", + "target": "self", "script": script}], + } + + +async with DBPools().sqlorContext(dbname) as sor: + recs = await sor.sqlExe( + "SELECT id,name,description,source,status,created_by FROM skill_proposals " + "ORDER BY (status='pending') DESC, created_at DESC LIMIT 100") + + if not recs: + return _json.dumps({ + "widgettype": "Text", + "options": {"text": "暂无技能提议。agent 自省会在此提交技能化建议,也可手动新建。", + "cfontsize": 0.95, "color": "#94a3b8", "padding": "24px"} + }, ensure_ascii=False) + + cards = [] + for r in recs: + qid = getattr(r, 'id', '') or '' + name = getattr(r, 'name', '') or '' + desc = getattr(r, 'description', '') or '' + source = getattr(r, 'source', '') or '' + status = getattr(r, 'status', '') or 'pending' + color = _STATUS_COLOR.get(status, '#64748b') + slabel = _STATUS_LABEL.get(status, status) + + # 行:名称 + 状态 + 来源 + 按钮组 + btns = [] + if status == 'pending': + btns.append(_mk_btn('→测试', qid, 'update_status', '')) + elif status == 'testing': + btns.append(_mk_btn('→审批', qid, 'update_status', '')) + elif status == 'approved': + btns.append(_mk_btn('发布', qid, 'publish', '#7c3aed')) + if status not in ('published', 'rejected'): + btns.append(_mk_btn('拒绝', qid, 'update_status', '#dc2626')) + + row_children = [ + {"widgettype": "Text", "options": {"text": name, "fontWeight": "bold", "cfontsize": 0.95}}, + {"widgettype": "Text", "options": {"text": slabel, "color": color, "cfontsize": 0.8}}, + ] + if source == 'agent': + row_children.append({"widgettype": "Text", + "options": {"text": "agent自省", "color": "#2563eb", "cfontsize": 0.75}}) + row_children.append({"widgettype": "Filler"}) + for b in btns: + row_children.append(b) + + card_children = [{ + "widgettype": "HBox", + "options": {"gap": "8px", "alignItems": "center"}, + "subwidgets": row_children, + }] + if desc: + card_children.append({"widgettype": "Text", + "options": {"text": desc, "cfontsize": 0.85, "color": "#64748b"}}) + + cards.append({ + "widgettype": "VBox", + "options": {"css": "card", "bgcolor": "#fff", "border": "1px solid #e2e8f0", + "borderRadius": "8px", "padding": "10px 14px", "gap": "4px"}, + "subwidgets": card_children, + }) + + return _json.dumps({ + "widgettype": "VBox", + "options": {"padding": "16px 24px", "gap": "8px", "css": "filler"}, + "subwidgets": cards, + }, ensure_ascii=False) diff --git a/wwwroot/skill_proposals/index.ui b/wwwroot/skill_proposals/index.ui new file mode 100644 index 0000000..5700722 --- /dev/null +++ b/wwwroot/skill_proposals/index.ui @@ -0,0 +1,29 @@ +{ + "widgettype": "VBox", + "options": {"width": "100%", "height": "100%", "padding": "0"}, + "subwidgets": [ + { + "widgettype": "HBox", + "options": {"width": "100%", "alignItems": "center", "padding": "16px 24px 8px 24px", "cheight": 6, "gap": "12px"}, + "subwidgets": [ + {"widgettype": "Title2", "options": {"text": "技能管理"}}, + {"widgettype": "Text", "options": {"text": "技能生命周期:开发 → 测试 → 审批 → 发布(发布后同步到所有机构)", "cfontsize": 0.9, "color": "#94a3b8"}}, + {"widgettype": "Filler"}, + { + "widgettype": "Button", + "id": "btn_new", + "options": {"label": "+ 新建提议", "css": "small", "color": "#2563eb"}, + "binds": [{ + "wid": "self", "event": "click", "actiontype": "script", "target": "self", + "script": "var pw=new bricks.PopupWindow({title:'新建技能提议',cwidth:60,cheight:34,auto_open:true});var form=new bricks.Form({name:'skp',cols:1,fields:[{name:'name',uitype:'text',label:'技能名',placeholder:'如 question-escalation',required:true},{name:'description',uitype:'text',label:'描述',placeholder:'一句话说明触发条件'},{name:'content',uitype:'textarea',label:'SKILL.md 内容',cheight:12,required:true}]});var vb=new bricks.VBox({padding:'16px',gap:'12px'});vb.add_widget(form);var save=new bricks.Button({label:'提交',css:'primary'});save.set_click(function(){var v=form.getValue();if(!v||!v.name||!v.content){alert('请填写技能名和内容');return;}var body=new URLSearchParams();body.append('action','create');body.append('name',v.name);body.append('description',v.description||'');body.append('content',v.content);fetch('/pipeline_core/api/skill_proposals.dspy',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:body}).then(function(r){return r.json()}).then(function(d){if(d.success){pw.destroy();location.reload();}else{alert(d.error||'创建失败');}});});vb.add_widget(save);pw.content_w.add_widget(vb);" + }] + } + ] + }, + { + "widgettype": "urlwidget", + "id": "proposal_list", + "options": {"url": "{{entire_url('/pipeline_core/api/skill_proposals_list.dspy')}}", "method": "GET"} + } + ] +}