pipeline_core/wwwroot/api/agent_chat_generic.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

114 lines
6.7 KiB
Plaintext
Raw 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_generic.dspy - 纯通用会话(不挂任何产线插件)
# 用于对照测试:确定通用 agent 本身的能力,隔离产线工具/技能/角色/记忆的干扰
# generic=True 时 gateway 跳过 resolve_project不 load_agent_config 产线能力,只用 GENERAL_TOOLS + 通用心智
import json
uid = await get_user()
if not uid:
uid = 'user-01'
action = (params_kw or {}).get('action', 'send_message')
if action == 'send_message':
prompt = (params_kw or {}).get('prompt', '') or ''
prompt = prompt.strip()
if not prompt:
return json.dumps({"error": "prompt 必填"}, ensure_ascii=False)
# 停止类指令
stop_kw = ['停止', '取消', '停下', '终止', '停', 'stop', 'cancel', 'abort']
if prompt.strip().lower() in stop_kw:
async def _stop():
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, 'text/plain; charset=utf-8')
from pipeline_service.gateway import get_gateway
gateway = get_gateway()
# 前端模型下拉选中的模型必须透传——之前忽略导致用户选了模型仍走默认值,
# 默认模型名与 llm 表不匹配时直接"无可用模型"卡死。
model_id = (params_kw or {}).get('model_id', '') or ''
# 上传文件:之前完全忽略 file 字段(上传静默丢弃)。与 agent_chat 走同一套处理:
# 落盘到 workspace 会话目录generic 无项目read_file 根=WORKSPACE_BASE相对路径可读
import os
from ahserver.filestorage import FileStorage
from sqlor.dbpools import DBPools
from pipeline_core.upload_tools import extract_text, resolve_upload_dir, save_uploads, build_file_context, is_image
_uploads = []
_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)
_uploads.append((_abs, os.path.basename(_abs)))
except Exception:
pass
if _uploads:
try:
_dbname = get_module_dbname('pipeline_core')
async with DBPools().sqlorContext(_dbname) as sor:
_udir, _rel = await resolve_upload_dir(sor, uid, '', generic=True)
_saved = save_uploads(_udir, _uploads)
_byname = {os.path.basename(src): n for (src, _n), (n, _p) in zip(_uploads, _saved)}
_items = []
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))
_fctx = build_file_context(_items)
if _fctx:
prompt = _fctx + "\n用户指令" + prompt
except Exception:
pass
async def agent_stream():
async for chunk in gateway.run_message("web", uid, prompt, generic=True, model_id=model_id, image_paths=_img_uploads or None):
data = json.loads(chunk)
t = data.get('type', '')
if t == 'tool_call':
yield json.dumps({"content": "**🔧 调用: " + data.get('tool', '') + "**\n```\n" + json.dumps(data.get('params', {}), ensure_ascii=False) + "\n```\n\n"}, ensure_ascii=False) + '\n'
elif t == 'tool_result':
yield json.dumps({"content": data.get('result', '') + "\n\n"}, ensure_ascii=False) + '\n'
elif t == 'reply':
yield json.dumps({"content": data.get('message', '')}, 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 修复,同 agent_chat.dspy不再只显示 command 字段
_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')
return json.dumps({"error": "Unknown action: " + str(action)}, ensure_ascii=False)