pipeline_core/wwwroot/api/agent_chat.dspy
yumoqing b399663863 feat(agent): 五级作用域工具解析+patch_file+原生视觉上传构造
1. tool_sources.py(新): global/org/pipeline/role/project 五级工具作用域解析
   - 策略表 pipeline_tool_policies allow/deny(配套 models+CRUD json)
   - capability 两层语义: 映射手册(all/概念技能,含global层) + 声明注入
     (仅org/pipeline/role/project/user层)——防 generic 会话经 global 概念
     技能拿到~70个产线工具,击穿七层隔离(单测 29/29 含此回归)
   - role 白名单是过滤器非添加器,且作用于 base+capability 全集
2. agent_config: GENERAL_TOOLS 加 patch_file(定点替换,唯一性校验);
   write_file 描述引导改文件优先用 patch_file
3. upload_tools: is_image + build_image_parts(OpenAI多模态data URL,
   8MB/张+4张/条上限,超限显式告知不静默丢)
4. agent_chat/agent_chat_generic: 图片分流不进文本上下文,
   image_paths 传 gateway
5. load_path: 策略管理页注册
2026-09-10 18:36:06 +08:00

226 lines
12 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# agent_chat.dspy - 通用会话 Agentpipeline-core产线无关
# 使用 AgentExecutor v2gateway 统一入口),是 Web 端复刻 Hermes CLI 的通用交互能力
# 各产线/模块可在其上叠加专属能力,本 dspy 不依赖任何产线专属表
import json
import os
import re
import zipfile
from ahserver.filestorage import FileStorage
def _extract_text(path, name):
"""[已废弃] 保留签名兼容;真实逻辑在 pipeline_core.upload_tools.extract_text。"""
from pipeline_core.upload_tools import extract_text
preview, _total, _trunc = extract_text(path, name)
return preview
action = (params_kw or {}).get('action', 'send_message')
msg = ''
if action != 'send_message':
p = params_kw or {}
msg = (p.get('message_text') or '').strip()
if not msg:
inner = p.get('params', {})
if isinstance(inner, dict):
msg = (inner.get('prompt') or inner.get('message_text') or '').strip()
if msg:
action = 'send_message'
dbname = get_module_dbname('pipeline_core')
if action == 'send_message':
# 从 params 中提取 prompt
prompt = (params_kw or {}).get('prompt', '') or msg
prompt = prompt.strip()
# 处理用户上传的文件multipart file 字段 → web_path
# 统一走 pipeline_core.upload_tools——截断显式告知、落盘位置=agent 可读位置。
from pipeline_core.upload_tools import extract_text, resolve_upload_dir, save_uploads, build_file_context, is_image
_items = [] # build_file_context 入参
_uploads = [] # [(src_abs, filename)]
_img_uploads = [] # 图片上传原生视觉2026-09-10不进文本上下文走多模态注入
_fval = (params_kw or {}).get('file')
_fpaths = _fval if isinstance(_fval, list) else ([_fval] if _fval else [])
for _fp in _fpaths:
try:
_abs = FileStorage().realPath(_fp)
_name = os.path.basename(_abs)
_uploads.append((_abs, _name))
except Exception:
pass
# ── 意图识别:停止类指令 ──
# 注意2026-08-31 修复):会话 agent 新增了项目管理能力(终止/删除项目走
# pause_project/delete_project 工具,代码层校验人类 owner。「终止项目」「停止项目」
# 含停止词但语义是项目管理指令,不能拦——只对纯停止指令(或不含项目词的)拦截。
stop_keywords = ['停止', '取消', '停下', '终止', '停', 'stop', 'cancel', 'abort']
_pl = prompt.strip().lower()
_is_project_mgmt = any(w in _pl for w in ('项目', '工程', 'project'))
if any(kw == _pl or (kw in _pl and not _is_project_mgmt) for kw in stop_keywords):
async def _stop_stream():
yield json.dumps({"content": "已停止当前任务。"}, ensure_ascii=False) + '\n'
yield json.dumps({"widgettype": "Text", "options": {"otext": "## 完毕 ##", "text": "## 完毕 ##", "i18n": True, "halign": "left", "css": "agent-done", "color": "#94a3b8", "marginBottom": "6px"}}, ensure_ascii=False) + '\n'
return await stream_response(request, _stop_stream, 'text/plain; charset=utf-8')
uid = await get_user()
if not uid:
uid = 'user-01' # 测试兼容
# ── 会话内唯一标识web 多 tab 独立会话):前端每个 tab 传独立 session_id ──
session_id = (params_kw or {}).get('session_id', '') or ''
# ── 产线默认能力(入口指定,如 bidding_general无当前项目时装载该产线能力 ──
pipeline_id = (params_kw or {}).get('pipeline_id', '') or ''
# ── 订阅门禁2026-09-08 用户要求:未购买不能进入对话模式)──
# 产线对话须持有该产线有效未到期订阅平台侧角色owner./reseller.
# 豁免;通用会话(空/_generic豁免。桌面/手机所有入口在此统一拦截,
# 前端遮罩只做引导。DB 异常时 fail-open可用性优先门禁是商业限制非安全边界
if pipeline_id and pipeline_id != '_generic':
_gate_ok = True
try:
_roles = await get_user_roles(uid)
_is_ops = any(str(r).startswith('owner.') or str(r).startswith('reseller.')
for r in (_roles or []))
except Exception:
_is_ops = False
if not _is_ops:
_orgid = await get_userorgid()
try:
async with DBPools().sqlorContext(dbname) as sor:
_sub = await sor.sqlExe(
"SELECT ps.id FROM product_subscription ps"
" JOIN product pr ON ps.product_id = pr.id"
" WHERE ps.user_org_id=${org}$ AND ps.status='1'"
" AND ps.end_date >= CURDATE()"
" AND pr.product_type='pipeline'"
" AND pr.resource_ref_id=${pid}$ LIMIT 1",
{"org": _orgid, "pid": pipeline_id})
await sor.sqlExe("COMMIT", {})
_gate_ok = bool(_sub)
except Exception:
_gate_ok = True
if not _gate_ok:
async def _gate_stream():
yield json.dumps({"error": "尚未购买该产线或订阅已到期,请到产品商城购买后再开始对话"},
ensure_ascii=False) + '\n'
return await stream_response(request, _gate_stream, 'text/plain; charset=utf-8')
# ── 上传文件落盘有项目→项目根无项目→workspace 会话目录)+ 生成显式截断上下文 ──
file_ctx = ''
if _uploads:
try:
async with DBPools().sqlorContext(dbname) as sor:
_udir, _rel = await resolve_upload_dir(sor, uid, session_id)
_saved = save_uploads(_udir, _uploads)
_byname = {os.path.basename(src): n for (src, _n), (n, _p) in zip(_uploads, _saved)}
for _src, _name in _uploads:
if is_image(_name):
# 图片走原生视觉gateway.build_image_parts不进文本上下文——
# 避免注入「二进制无法读取」与「图片已注入」矛盾提示。
# 仍随 save_uploads 落盘agent 可再用 invoke_model i2t 处理)
_img_uploads.append((_src, _name))
continue
_preview, _total, _trunc = extract_text(_src, _name)
_isbin = (not _preview and _total == 0)
_relp = (_rel + _byname.get(_name, _name)) if _name in _byname else ''
_items.append((_name, _relp, _preview, _total, _trunc, _isbin))
file_ctx = build_file_context(_items)
except Exception:
file_ctx = ''
if file_ctx:
prompt = file_ctx + "\n用户指令" + prompt
# ── 走 gateway 统一入口Web AgentIO 通道)──
# model_id前端模型下拉选中的模型 → gateway 校验后持久化到项目(项目模型一经设置
# 即生效,直到用户再次选择),空 = 沿用项目已设模型。
model_id = (params_kw or {}).get('model_id', '') or ''
from pipeline_service.gateway import get_gateway
gateway = get_gateway()
async def agent_stream():
_base = entire_url("/")
async for chunk in gateway.run_message("web", uid, prompt, base_url=_base, session_id=session_id, pipeline_id=pipeline_id, model_id=model_id, image_paths=_img_uploads or None):
data = json.loads(chunk)
t = data.get('type', '')
if t == 'progress':
yield json.dumps({"reasoning_content": data.get('message', '') + "\n"}, ensure_ascii=False) + '\n'
elif t == 'auto_tool':
yield json.dumps({"reasoning_content": "🔄 " + data.get('message', '') + "\n"}, ensure_ascii=False) + '\n'
elif t == 'debug':
continue # skip debug in UI
elif t == 'tool_call':
tool = data.get('tool', '')
params = json.dumps(data.get('params', {}), ensure_ascii=False)
yield json.dumps({"content": "**🔧 调用: " + tool + "**\n```\n" + params + "\n```\n\n"}, ensure_ascii=False) + '\n'
elif t == 'tool_result':
result = data.get('result', '')
yield json.dumps({"content": result + "\n\n"}, ensure_ascii=False) + '\n'
elif t == 'reply':
msg = data.get('message', '')
if isinstance(msg, dict) and msg.get('widgettype'):
# widget JSON顶层透传前端 AgentOut 检测 widgettype 渲染(如 /task 的 PopupWindow
yield json.dumps(msg, ensure_ascii=False) + '\n'
else:
yield json.dumps({"content": msg}, ensure_ascii=False) + '\n'
elif t == 'ask_user':
yield json.dumps({"content": "❓ " + data.get('message', '')}, ensure_ascii=False) + '\n'
elif t == 'confirm':
_tool = data.get('tool', '')
_params = data.get('params', {}) or {}
# 确认框摘要2026-09-05 修复):旧实现只取 command 字段run_command 专用),
# 其他工具apply_llm_config 等参数叫 spec显示为空命令用户被迫盲确认。
# 改为显示工具名 + 关键参数摘要use_last_extract/overrides/url/model_name 等),
# 大字段spec/doc_text只显长度不显内容。
_brief = []
if isinstance(_params, dict):
for _k in ('command', 'url', 'model_name', 'use_last_extract', 'overrides', 'task_ref'):
if _k in _params and str(_params.get(_k) or '') != '':
_v = _params.get(_k)
_vs = _v if isinstance(_v, str) else json.dumps(_v, ensure_ascii=False)
_brief.append("%s=%s" % (_k, _vs[:80]))
for _k in ('spec', 'doc_text'):
if _params.get(_k):
_brief.append("%s=<%d字符>" % (_k, len(str(_params.get(_k)))))
_summary = ''.join(_brief) if _brief else '(无参数)'
yield json.dumps({"content": "⚠️ 需要你确认执行工具:**" + str(_tool) + "**\n参数" + _summary[:300] + "\n请回复「确认」执行、「全部确认」本会话不再询问或「取消」放弃。"}, ensure_ascii=False) + '\n'
elif t == 'error':
yield json.dumps({"error": data.get('message', '')}, ensure_ascii=False) + '\n'
else:
yield json.dumps({"content": chunk}, ensure_ascii=False) + '\n'
# 应答输出结束后追加一行「## 完毕 ##」Text 控件 otext+i18n前端按当前语言翻译
# (词条见 pipeline-app/i18n/*/i18n.jsonmsg.txt 以 # 开头的键会被当注释,故只放 i18n.json
yield json.dumps({"widgettype": "Text", "options": {"otext": "## 完毕 ##", "text": "## 完毕 ##", "i18n": True, "halign": "left", "css": "agent-done", "color": "#94a3b8", "marginBottom": "6px"}}, ensure_ascii=False) + '\n'
return await stream_response(request, agent_stream, 'text/plain; charset=utf-8')
elif action == 'list_messages':
uid = await get_user()
if not uid:
return json.dumps({"error": "请先登录"}, ensure_ascii=False)
# 产线隔离2026-09-02必须按 user + session + pipeline 过滤。
# 旧实现无过滤返回全表——跨产线/跨用户会话历史泄漏。
_sid = (params_kw or {}).get('session_id', '') or ''
_pl = (params_kw or {}).get('pipeline_id', '') or ''
_sql = ("SELECT role, content, created_at FROM pipeline_conversations "
"WHERE created_by=${u}$")
_kw = {"u": uid, "lim": 100}
if _sid:
_sql += " AND session_id=${sid}$"
_kw["sid"] = _sid
if _pl:
_sql += " AND pipeline_id=${pl}$"
_kw["pl"] = _pl
# DESC+LIMIT 取最新再倒序(与 _load_history 隔离策略一致ASC+LIMIT 会取最旧)
_sql += " ORDER BY created_at DESC LIMIT ${lim}$"
async with DBPools().sqlorContext(dbname) as sor:
ms = await sor.sqlExe(_sql, _kw)
result = [{"role": getattr(m, 'role', ''), "content": getattr(m, 'content', '')}
for m in reversed(ms or [])]
return json.dumps({"success": True, "messages": result}, ensure_ascii=False, default=str)
else:
return json.dumps({"error": "Unknown: " + str(action)}, ensure_ascii=False)