mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 07:51:57 +00:00
03c18c4d78
一、文案(前端约 1600 处,后端传到界面的约 880 处) - 术语统一:工作流层面用「运行」(继续运行、重新运行、试运行),节点和工具层面用「执行」,不再用「跑」; 总时长 / 执行时长 / 等待审批;服务端;助手;Agent;token;工作流;固定版本;模型接入;系统提示;批准 / 驳回;受管级别 - 口语改书面:取不到、没取回来、读不懂、连不上、多半、照样、兜底、揉成这类说法改成「无法 / 未能 / 失败 / 请」;去掉拟人写法 - 不再露出实现细节:界面上不写 checkpoint、同一拍、哈希(凭证处除外),不写改版历史(「以前…」「老数据」); 报错、校验、门禁、自动修复里裸写的配置键名和枚举值换成界面上的中文标签,由新的 backend/app/engine/labels.py 统一提供; 发布弹窗里结论句的选项不再是 off / require_citation - 同一件事只留一种说法:生成阶段文案、出具档位说明、画布锁定提示各合成一份 - 去掉界面上的工业场景示例(MES、ERP、「张工」「王工」)和真实产品名,换成通用示例 二、说错的提示(都有测试证明) - 数据库测连接失败时的专用提示以前从不出现(按报错文字全等比较,永远比不上):改为按错误类型判断,不再拼出重复的前缀 - 「设置 → 供应商」改指「设置 → 模型接入」;「在设置里建数据源」改指「数据」页 - 缺少 API Key、模型接入已停用的报错不再夹英文,写明去哪里填 - 两处原因说错(「这种引用在后续版本支持」「这一版还不支持」)改成真实原因 - 节点说明里不准确的说法改正:代码节点在 microVM 或系统沙箱中执行;报告撰写、口径卡的说明不再说过头 - 导出的证据 CSV 里两列同名,第三列改为「片段编号」 三、前后端对齐 - 前端按后端原文识别日志、报错的地方,新旧两种写法都认得(历史运行里存的是旧文字),逐条补了新旧原文的核对 - 后端交回模型的原指令放进单独的 for_model 字段,前端在解析响应时去掉,不会显示到界面上 四、检查 - 检查脚本里按原文匹配的断言改成新文案;改完会变成永远成立的反向断言逐条修正,并做了变异验证 - 新增 check-copy(静态扫描前端界面字符串和后端上界面的文案):再出现「跑」「后端」「多半」「令牌」、 界面上的 Copilot、裸写的配置键名、半角标点等就报错;确需保留的写进 scripts/copy-allow.json 并注明理由
204 lines
7.9 KiB
Python
204 lines
7.9 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import hashlib
|
|
import json
|
|
import random
|
|
import re
|
|
from typing import Any, AsyncIterator, Iterator, Sequence
|
|
|
|
from langchain_core.callbacks import AsyncCallbackManagerForLLMRun, CallbackManagerForLLMRun
|
|
from langchain_core.language_models.chat_models import BaseChatModel
|
|
from langchain_core.messages import AIMessage, AIMessageChunk, BaseMessage, ToolMessage
|
|
from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult
|
|
from langchain_core.tools import BaseTool
|
|
from pydantic import Field
|
|
|
|
|
|
def _text_of(message: BaseMessage) -> str:
|
|
content = message.content
|
|
if isinstance(content, str):
|
|
return content
|
|
if isinstance(content, list):
|
|
return "".join(
|
|
b.get("text", "") if isinstance(b, dict) else str(b) for b in content
|
|
)
|
|
return str(content)
|
|
|
|
|
|
def _fake_value(schema: dict[str, Any], key: str = "") -> Any:
|
|
"""按 JSON Schema 造一个形状正确的假值,让下游校验节点也能跑通。"""
|
|
typ = schema.get("type")
|
|
if "enum" in schema:
|
|
return schema["enum"][0]
|
|
if typ == "integer":
|
|
return 42
|
|
if typ == "number":
|
|
return 3.14
|
|
if typ == "boolean":
|
|
return True
|
|
if typ == "array":
|
|
item = schema.get("items") or {"type": "string"}
|
|
return [_fake_value(item, key)]
|
|
if typ == "object" or "properties" in schema:
|
|
props = schema.get("properties") or {}
|
|
return {k: _fake_value(v, k) for k, v in props.items()}
|
|
return f"[mock:{key or 'value'}]"
|
|
|
|
|
|
class MockChatModel(BaseChatModel):
|
|
"""不联网的假模型。
|
|
|
|
目的不是"能用",而是让人在没有 API Key 的情况下就能把整条编排链路跑通:
|
|
它会真的产出 tool_call、真的按 schema 返回结构化 JSON、真的流式吐字,
|
|
所以画布上的高亮、工具节点、校验节点全都会被触发。
|
|
"""
|
|
|
|
model_name: str = "mock-fast"
|
|
latency: float = 0.012 # 每个 chunk 的间隔,模拟真实流式手感
|
|
seed: int | None = None
|
|
tools: list[dict[str, Any]] = Field(default_factory=list)
|
|
response_format: dict[str, Any] | None = None
|
|
|
|
@property
|
|
def _llm_type(self) -> str:
|
|
return "mock"
|
|
|
|
def bind_tools(self, tools: Sequence[Any], **kwargs: Any) -> "MockChatModel":
|
|
normalized: list[dict[str, Any]] = []
|
|
for tool in tools:
|
|
if isinstance(tool, BaseTool):
|
|
normalized.append(
|
|
{
|
|
"name": tool.name,
|
|
"description": tool.description,
|
|
"schema": tool.args_schema.model_json_schema()
|
|
if getattr(tool, "args_schema", None)
|
|
else {},
|
|
}
|
|
)
|
|
elif isinstance(tool, dict):
|
|
fn = tool.get("function", tool)
|
|
normalized.append(
|
|
{
|
|
"name": fn.get("name", "tool"),
|
|
"description": fn.get("description", ""),
|
|
"schema": fn.get("parameters") or fn.get("input_schema") or {},
|
|
}
|
|
)
|
|
return self.model_copy(update={"tools": normalized})
|
|
|
|
def with_structured_output(self, schema: Any, **kwargs: Any) -> Any: # type: ignore[override]
|
|
json_schema = schema
|
|
if hasattr(schema, "model_json_schema"):
|
|
json_schema = schema.model_json_schema()
|
|
bound = self.model_copy(update={"response_format": json_schema})
|
|
if hasattr(schema, "model_validate"):
|
|
return bound | (lambda msg: schema.model_validate(json.loads(_text_of(msg))))
|
|
return bound | (lambda msg: json.loads(_text_of(msg)))
|
|
|
|
# ---------------- 内部 ----------------
|
|
|
|
def _decide(self, messages: list[BaseMessage]) -> AIMessage:
|
|
prompt = "\n".join(_text_of(m) for m in messages[-4:])
|
|
rng = random.Random(
|
|
self.seed
|
|
if self.seed is not None
|
|
else int(hashlib.sha1(prompt.encode()).hexdigest()[:8], 16)
|
|
)
|
|
|
|
# 要结构化输出就按 schema 造数据
|
|
if self.response_format:
|
|
payload = _fake_value(self.response_format)
|
|
return AIMessage(content=json.dumps(payload, ensure_ascii=False, indent=2))
|
|
|
|
# 绑了工具、而且这一轮还没有工具返回过 —— 那就调一次工具,
|
|
# 让 agent 循环和画布上的工具节点真的被走到
|
|
already_called = any(isinstance(m, ToolMessage) for m in messages)
|
|
if self.tools and not already_called:
|
|
tool = rng.choice(self.tools)
|
|
args = _fake_value(tool.get("schema") or {"type": "object"})
|
|
if not isinstance(args, dict):
|
|
args = {}
|
|
return AIMessage(
|
|
content=f"需要先调用 `{tool['name']}` 获取信息。",
|
|
tool_calls=[
|
|
{"name": tool["name"], "args": args, "id": f"call_{rng.randrange(1 << 30):08x}"}
|
|
],
|
|
)
|
|
|
|
last_user = next(
|
|
(_text_of(m) for m in reversed(messages) if m.type == "human"), ""
|
|
)
|
|
topic = re.sub(r"\s+", " ", last_user).strip()[:60] or "这个问题"
|
|
body = (
|
|
f"(演示模型的回复)关于「{topic}」,示例内容分三点:\n\n"
|
|
f"1. 这是演示模型生成的示例内容,用于验证工作流能否正常运行。\n"
|
|
f"2. 当前节点收到 {len(messages)} 条消息、{len(self.tools)} 个工具。\n"
|
|
f"3. 如需真实结果,请在「设置 → 模型接入」中添加模型接入,并为节点换用对应模型。"
|
|
)
|
|
return AIMessage(content=body)
|
|
|
|
def _generate(
|
|
self,
|
|
messages: list[BaseMessage],
|
|
stop: list[str] | None = None,
|
|
run_manager: CallbackManagerForLLMRun | None = None,
|
|
**kwargs: Any,
|
|
) -> ChatResult:
|
|
message = self._decide(messages)
|
|
message.usage_metadata = {
|
|
"input_tokens": sum(len(_text_of(m)) // 4 for m in messages),
|
|
"output_tokens": len(_text_of(message)) // 4,
|
|
"total_tokens": 0,
|
|
}
|
|
return ChatResult(generations=[ChatGeneration(message=message)])
|
|
|
|
async def _agenerate(
|
|
self,
|
|
messages: list[BaseMessage],
|
|
stop: list[str] | None = None,
|
|
run_manager: AsyncCallbackManagerForLLMRun | None = None,
|
|
**kwargs: Any,
|
|
) -> ChatResult:
|
|
await asyncio.sleep(self.latency)
|
|
return self._generate(messages, stop, **kwargs)
|
|
|
|
def _stream(
|
|
self,
|
|
messages: list[BaseMessage],
|
|
stop: list[str] | None = None,
|
|
run_manager: CallbackManagerForLLMRun | None = None,
|
|
**kwargs: Any,
|
|
) -> Iterator[ChatGenerationChunk]:
|
|
message = self._decide(messages)
|
|
if message.tool_calls:
|
|
yield ChatGenerationChunk(
|
|
message=AIMessageChunk(
|
|
content=message.content, tool_calls=message.tool_calls
|
|
)
|
|
)
|
|
return
|
|
for piece in re.findall(r".{1,6}", _text_of(message), re.DOTALL):
|
|
yield ChatGenerationChunk(message=AIMessageChunk(content=piece))
|
|
|
|
async def _astream(
|
|
self,
|
|
messages: list[BaseMessage],
|
|
stop: list[str] | None = None,
|
|
run_manager: AsyncCallbackManagerForLLMRun | None = None,
|
|
**kwargs: Any,
|
|
) -> AsyncIterator[ChatGenerationChunk]:
|
|
message = self._decide(messages)
|
|
if message.tool_calls:
|
|
await asyncio.sleep(self.latency)
|
|
yield ChatGenerationChunk(
|
|
message=AIMessageChunk(
|
|
content=message.content, tool_calls=message.tool_calls
|
|
)
|
|
)
|
|
return
|
|
for piece in re.findall(r".{1,6}", _text_of(message), re.DOTALL):
|
|
await asyncio.sleep(self.latency)
|
|
yield ChatGenerationChunk(message=AIMessageChunk(content=piece))
|