Merge pull request #873 from SuanmoSuanyangTechnology/feature/agent-tool_xjn

feat(workflow and app)
This commit is contained in:
山程漫悟
2026-04-13 19:05:10 +08:00
committed by GitHub
8 changed files with 309 additions and 100 deletions

View File

@@ -1250,9 +1250,11 @@ async def export_app(
async def import_app( async def import_app(
file: UploadFile = File(...), file: UploadFile = File(...),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user),
app_id: Optional[str] = Form(None),
): ):
"""从 YAML 文件导入 agent / multi_agent / workflow 应用。 """从 YAML 文件导入 agent / multi_agent / workflow 应用。
传入 app_id 时覆盖该应用的配置(类型必须一致),否则创建新应用。
跨空间/跨租户导入时,模型/工具/知识库会按名称匹配,匹配不到则置空并返回 warnings。 跨空间/跨租户导入时,模型/工具/知识库会按名称匹配,匹配不到则置空并返回 warnings。
""" """
if not file.filename.lower().endswith((".yaml", ".yml")): if not file.filename.lower().endswith((".yaml", ".yml")):
@@ -1263,13 +1265,15 @@ async def import_app(
if not dsl or "app" not in dsl: if not dsl or "app" not in dsl:
return fail(msg="YAML 格式无效,缺少 app 字段", code=BizCode.BAD_REQUEST) return fail(msg="YAML 格式无效,缺少 app 字段", code=BizCode.BAD_REQUEST)
new_app, warnings = AppDslService(db).import_dsl( target_app_id = uuid.UUID(app_id) if app_id else None
result_app, warnings = AppDslService(db).import_dsl(
dsl=dsl, dsl=dsl,
workspace_id=current_user.current_workspace_id, workspace_id=current_user.current_workspace_id,
tenant_id=current_user.tenant_id, tenant_id=current_user.tenant_id,
user_id=current_user.id, user_id=current_user.id,
app_id=target_app_id,
) )
return success( return success(
data={"app": app_schema.App.model_validate(new_app), "warnings": warnings}, data={"app": app_schema.App.model_validate(result_app), "warnings": warnings},
msg="应用导入成功" + (",但部分资源需手动配置" if warnings else "") msg="应用导入成功" + (",但部分资源需手动配置" if warnings else "")
) )

View File

@@ -28,86 +28,135 @@ class IterationRuntime:
def __init__( def __init__(
self, self,
start_id: str,
stream: bool, stream: bool,
graph: CompiledStateGraph,
node_id: str, node_id: str,
config: dict[str, Any], config: dict[str, Any],
state: WorkflowState, state: WorkflowState,
variable_pool: VariablePool, variable_pool: VariablePool,
child_variable_pool: VariablePool, cycle_nodes: list,
cycle_edges: list,
): ):
""" """
Initialize the iteration runtime. Initialize the iteration runtime.
Args: Args:
graph: Compiled workflow graph capable of async invocation. stream: Whether to run in streaming mode. When True, each iteration
node_id: Unique identifier of the loop node. uses graph.astream and emits cycle_item events in real time.
config: Dictionary containing iteration node configuration. When False, graph.ainvoke is used instead.
state: Current workflow state at the point of iteration. node_id: The unique identifier of the iteration node in the workflow.
Also used as the variable namespace for item/index inside
the subgraph (e.g. {{ node_id.item }}).
config: Raw configuration dict for the iteration node, parsed into
IterationNodeConfig. Controls input/output variable selectors,
parallel execution settings, and output flattening.
state: The parent workflow state at the point the iteration node is
entered. Each task receives a copy of this state as its
starting point.
variable_pool: The parent VariablePool containing all variables available
at the time the iteration node executes, including sys.*,
conv.*, and outputs from upstream nodes. Used as the source
for deep-copying into each task's independent child pool.
cycle_nodes: List of node config dicts belonging to this iteration's
subgraph (i.e. nodes whose cycle field equals node_id).
Passed to GraphBuilder when constructing each task's subgraph.
cycle_edges: List of edge config dicts connecting nodes within the subgraph.
Passed to GraphBuilder alongside cycle_nodes.
""" """
self.start_id = start_id
self.stream = stream self.stream = stream
self.graph = graph
self.state = state self.state = state
self.node_id = node_id self.node_id = node_id
self.typed_config = IterationNodeConfig(**config) self.typed_config = IterationNodeConfig(**config)
self.looping = True self.looping = True
self.variable_pool = variable_pool self.variable_pool = variable_pool
self.child_variable_pool = child_variable_pool self.cycle_nodes = cycle_nodes
self.cycle_edges = cycle_edges
self.event_write = get_stream_writer() self.event_write = get_stream_writer()
self.checkpoint = RunnableConfig(
configurable={
"thread_id": uuid.uuid4()
}
)
self.output_value = None self.output_value = None
self.result: list = [] self.result: list = []
async def _init_iteration_state(self, item, idx): def _build_child_graph(self) -> tuple[CompiledStateGraph, VariablePool, str]:
""" """
Initialize a per-iteration copy of the workflow state. Build an independent compiled subgraph for a single iteration task.
Args: Each call creates a brand-new VariablePool by deep-copying the parent pool,
item: Current element from the input array for this iteration. then passes it to GraphBuilder. GraphBuilder binds this pool to every node's
idx: Index of the element in the input array. execution closure at build time, so the pool and the subgraph always reference
the same object. This is the key design invariant: item/index written into the
pool after build will be visible to all nodes inside the subgraph.
Returns: Returns:
A copy of the workflow state with iteration-specific variables set. graph: The compiled LangGraph subgraph ready for invocation.
child_pool: The VariablePool bound to this subgraph's node closures.
Callers must write item/index into this pool before invoking
the graph, and read output from it after invocation.
start_node_id: The ID of the CYCLE_START node inside the subgraph,
used to set the initial activation signal in workflow state.
""" """
loopstate = WorkflowState( from app.core.workflow.engine.graph_builder import GraphBuilder
**self.state child_pool = VariablePool()
child_pool.copy(self.variable_pool)
builder = GraphBuilder(
{"nodes": self.cycle_nodes, "edges": self.cycle_edges},
stream=self.stream,
variable_pool=child_pool,
cycle=self.node_id,
) )
self.child_variable_pool.copy(self.variable_pool) graph = builder.build()
await self.child_variable_pool.new(self.node_id, "item", item, VariableType.type_map(item), mut=True) return graph, builder.variable_pool, builder.start_node_id
await self.child_variable_pool.new(self.node_id, "index", item, VariableType.type_map(item), mut=True)
loopstate["node_outputs"][self.node_id] = { async def _init_iteration_state(self, item, idx, child_pool: VariablePool, start_id: str):
"item": item, """
"index": idx, Initialize the workflow state for a single iteration.
}
Writes the current item and its index into child_pool under the iteration
node's namespace (e.g. iteration_xxx.item, iteration_xxx.index), making them
accessible to downstream nodes inside the subgraph via variable selectors.
Also prepares a copy of the parent workflow state with:
- node_outputs[node_id] set to {item, index} so the state snapshot is consistent
with the pool values.
- looping flag set to 1 (active) to signal the subgraph is inside a cycle.
- activate[start_id] set to True to trigger the CYCLE_START node.
Args:
item: The current element from the input array.
idx: The zero-based index of this element in the input array.
child_pool: The VariablePool bound to this iteration's subgraph.
Must be the same object returned by _build_child_graph.
start_id: The ID of the CYCLE_START node inside the subgraph.
Returns:
A WorkflowState instance ready to be passed to graph.ainvoke or graph.astream.
"""
loopstate = WorkflowState(**self.state)
await child_pool.new(self.node_id, "item", item, VariableType.type_map(item), mut=True)
await child_pool.new(self.node_id, "index", idx, VariableType.type_map(idx), mut=True)
loopstate["node_outputs"][self.node_id] = {"item": item, "index": idx}
loopstate["looping"] = 1 loopstate["looping"] = 1
loopstate["activate"][self.start_id] = True loopstate["activate"][start_id] = True
return loopstate return loopstate
def merge_conv_vars(self): def _merge_conv_vars(self, child_pool: VariablePool):
self.variable_pool.variables["conv"].update( self.variable_pool.variables["conv"].update(child_pool.variables["conv"])
self.child_variable_pool.variables["conv"]
)
async def run_task(self, item, idx): async def run_task(self, item, idx):
""" """
Execute a single iteration asynchronously. Execute a single iteration asynchronously.
Each task builds its own subgraph so the variable pool closure is independent.
Args: Returns:
item: The input element for this iteration. Tuple of (idx, output, result, child_pool, stopped)
idx: The index of this iteration.
""" """
graph, child_pool, start_id = self._build_child_graph()
checkpoint = RunnableConfig(configurable={"thread_id": uuid.uuid4()})
init_state = await self._init_iteration_state(item, idx, child_pool, start_id)
if self.stream: if self.stream:
async for event in self.graph.astream( async for event in graph.astream(
await self._init_iteration_state(item, idx), init_state,
stream_mode=["debug"], stream_mode=["debug"],
config=self.checkpoint config=checkpoint
): ):
if isinstance(event, tuple) and len(event) == 2: if isinstance(event, tuple) and len(event) == 2:
mode, data = event mode, data = event
@@ -117,7 +166,6 @@ class IterationRuntime:
event_type = data.get("type") event_type = data.get("type")
payload = data.get("payload", {}) payload = data.get("payload", {})
node_name = payload.get("name") node_name = payload.get("name")
if node_name and node_name.startswith("nop"): if node_name and node_name.startswith("nop"):
continue continue
if event_type == "task_result": if event_type == "task_result":
@@ -140,17 +188,13 @@ class IterationRuntime:
"token_usage": result.get("node_outputs", {}).get(node_name, {}).get("token_usage") "token_usage": result.get("node_outputs", {}).get(node_name, {}).get("token_usage")
} }
}) })
result = self.graph.get_state(config=self.checkpoint).values result = graph.get_state(config=checkpoint).values
else: else:
result = await self.graph.ainvoke(await self._init_iteration_state(item, idx)) result = await graph.ainvoke(init_state)
output = self.child_variable_pool.get_value(self.output_value)
if isinstance(output, list) and self.typed_config.flatten: output = child_pool.get_value(self.output_value)
self.result.extend(output) stopped = result["looping"] == 2
else: return idx, output, result, child_pool, stopped
self.result.append(output)
if result["looping"] == 2:
self.looping = False
return result
def _create_iteration_tasks(self, array_obj, idx): def _create_iteration_tasks(self, array_obj, idx):
""" """
@@ -196,16 +240,32 @@ class IterationRuntime:
tasks = self._create_iteration_tasks(array_obj, idx) tasks = self._create_iteration_tasks(array_obj, idx)
logger.info(f"Iteration node {self.node_id}: running, concurrency {len(tasks)}") logger.info(f"Iteration node {self.node_id}: running, concurrency {len(tasks)}")
idx += self.typed_config.parallel_count idx += self.typed_config.parallel_count
child_state.extend(await asyncio.gather(*tasks)) batch = await asyncio.gather(*tasks)
self.merge_conv_vars() # Sort by idx to preserve order, then collect results
batch_sorted = sorted(batch, key=lambda x: x[0])
for _, output, result, child_pool, stopped in batch_sorted:
if isinstance(output, list) and self.typed_config.flatten:
self.result.extend(output)
else:
self.result.append(output)
child_state.append(result)
self._merge_conv_vars(child_pool)
if stopped:
self.looping = False
else: else:
# Execute iterations sequentially # Execute iterations sequentially
while idx < len(array_obj) and self.looping: while idx < len(array_obj) and self.looping:
logger.info(f"Iteration node {self.node_id}: running") logger.info(f"Iteration node {self.node_id}: running")
item = array_obj[idx] item = array_obj[idx]
result = await self.run_task(item, idx) _, output, result, child_pool, stopped = await self.run_task(item, idx)
self.merge_conv_vars() if isinstance(output, list) and self.typed_config.flatten:
self.result.extend(output)
else:
self.result.append(output)
self._merge_conv_vars(child_pool)
child_state.append(result) child_state.append(result)
if stopped:
self.looping = False
idx += 1 idx += 1
logger.info(f"Iteration node {self.node_id}: execution completed") logger.info(f"Iteration node {self.node_id}: execution completed")
return { return {

View File

@@ -123,7 +123,7 @@ class CycleGraphNode(BaseNode):
return cycle_nodes, cycle_edges return cycle_nodes, cycle_edges
def build_graph(self): def build_graph(self, variable_pool: VariablePool):
""" """
Build and compile the internal subgraph for this cycle node. Build and compile the internal subgraph for this cycle node.
@@ -135,6 +135,7 @@ class CycleGraphNode(BaseNode):
from app.core.workflow.engine.graph_builder import GraphBuilder from app.core.workflow.engine.graph_builder import GraphBuilder
self.child_variable_pool = VariablePool() self.child_variable_pool = VariablePool()
self.child_variable_pool.copy(variable_pool)
builder = GraphBuilder( builder = GraphBuilder(
{ {
"nodes": self.cycle_nodes, "nodes": self.cycle_nodes,
@@ -165,8 +166,8 @@ class CycleGraphNode(BaseNode):
Raises: Raises:
RuntimeError: If the node type is unsupported. RuntimeError: If the node type is unsupported.
""" """
self.build_graph()
if self.node_type == NodeType.LOOP: if self.node_type == NodeType.LOOP:
self.build_graph(variable_pool)
return await LoopRuntime( return await LoopRuntime(
start_id=self.start_node_id, start_id=self.start_node_id,
stream=False, stream=False,
@@ -179,20 +180,19 @@ class CycleGraphNode(BaseNode):
).run() ).run()
if self.node_type == NodeType.ITERATION: if self.node_type == NodeType.ITERATION:
return await IterationRuntime( return await IterationRuntime(
start_id=self.start_node_id,
stream=False, stream=False,
graph=self.graph,
node_id=self.node_id, node_id=self.node_id,
config=self.config, config=self.config,
state=state, state=state,
variable_pool=variable_pool, variable_pool=variable_pool,
child_variable_pool=self.child_variable_pool cycle_nodes=self.cycle_nodes,
cycle_edges=self.cycle_edges,
).run() ).run()
raise RuntimeError("Unknown cycle node type") raise RuntimeError("Unknown cycle node type")
async def execute_stream(self, state: WorkflowState, variable_pool: VariablePool): async def execute_stream(self, state: WorkflowState, variable_pool: VariablePool):
self.build_graph()
if self.node_type == NodeType.LOOP: if self.node_type == NodeType.LOOP:
self.build_graph(variable_pool)
yield { yield {
"__final__": True, "__final__": True,
"result": await LoopRuntime( "result": await LoopRuntime(
@@ -211,14 +211,13 @@ class CycleGraphNode(BaseNode):
yield { yield {
"__final__": True, "__final__": True,
"result": await IterationRuntime( "result": await IterationRuntime(
start_id=self.start_node_id,
stream=True, stream=True,
graph=self.graph,
node_id=self.node_id, node_id=self.node_id,
config=self.config, config=self.config,
state=state, state=state,
variable_pool=variable_pool, variable_pool=variable_pool,
child_variable_pool=self.child_variable_pool cycle_nodes=self.cycle_nodes,
cycle_edges=self.cycle_edges,
).run() ).run()
} }
return return

View File

@@ -44,6 +44,8 @@ class FileInput(BaseModel):
upload_file_id: Optional[uuid.UUID] = Field(None, description="已上传文件IDlocal_file时必填") upload_file_id: Optional[uuid.UUID] = Field(None, description="已上传文件IDlocal_file时必填")
url: Optional[str] = Field(None, description="远程URLremote_url时必填") url: Optional[str] = Field(None, description="远程URLremote_url时必填")
file_type: Optional[str] = Field(None, description="具体文件格式如image/jpg、audio/wav、document/docx、video/mp4") file_type: Optional[str] = Field(None, description="具体文件格式如image/jpg、audio/wav、document/docx、video/mp4")
name: Optional[str] = Field(None, description="文件名")
size: Optional[int] = Field(None, description="文件大小(字节)")
_content = None _content = None

View File

@@ -26,6 +26,7 @@ from app.services.model_service import ModelApiKeyService
from app.services.multi_agent_orchestrator import MultiAgentOrchestrator from app.services.multi_agent_orchestrator import MultiAgentOrchestrator
from app.services.multimodal_service import MultimodalService from app.services.multimodal_service import MultimodalService
from app.services.workflow_service import WorkflowService from app.services.workflow_service import WorkflowService
from app.models.file_metadata_model import FileMetadata
logger = get_business_logger() logger = get_business_logger()
@@ -218,11 +219,29 @@ class AppChatService:
"reasoning_content": result.get("reasoning_content") "reasoning_content": result.get("reasoning_content")
} }
if files: if files:
local_ids = [f.upload_file_id for f in files
if f.transfer_method.value == "local_file" and f.upload_file_id
and (not f.name or not f.size)]
meta_map = {}
if local_ids:
rows = self.db.query(FileMetadata).filter(
FileMetadata.id.in_(local_ids),
FileMetadata.status == "completed"
).all()
meta_map = {str(r.id): r for r in rows}
for f in files: for f in files:
# url = await MultimodalService(self.db).get_file_url(f) name, size = f.name, f.size
if f.transfer_method.value == "local_file" and f.upload_file_id and (not name or not size):
meta = meta_map.get(str(f.upload_file_id))
if meta:
name = name or meta.file_name
size = size or meta.file_size
human_meta["files"].append({ human_meta["files"].append({
"type": f.type, "type": f.type,
"url": f.url "url": f.url,
"name": name,
"size": size,
"file_type": f.file_type,
}) })
if processed_files: if processed_files:
@@ -509,10 +528,29 @@ class AppChatService:
} }
if files: if files:
local_ids = [f.upload_file_id for f in files
if f.transfer_method.value == "local_file" and f.upload_file_id
and (not f.name or not f.size)]
meta_map = {}
if local_ids:
rows = self.db.query(FileMetadata).filter(
FileMetadata.id.in_(local_ids),
FileMetadata.status == "completed"
).all()
meta_map = {str(r.id): r for r in rows}
for f in files: for f in files:
name, size = f.name, f.size
if f.transfer_method.value == "local_file" and f.upload_file_id and (not name or not size):
meta = meta_map.get(str(f.upload_file_id))
if meta:
name = name or meta.file_name
size = size or meta.file_size
human_meta["files"].append({ human_meta["files"].append({
"type": f.type, "type": f.type,
"url": f.url "url": f.url,
"name": name,
"size": size,
"file_type": f.file_type,
}) })
if processed_files: if processed_files:
human_meta["history_files"] = { human_meta["history_files"] = {

View File

@@ -229,8 +229,11 @@ class AppDslService:
workspace_id: uuid.UUID, workspace_id: uuid.UUID,
tenant_id: uuid.UUID, tenant_id: uuid.UUID,
user_id: uuid.UUID, user_id: uuid.UUID,
app_id: Optional[uuid.UUID] = None,
) -> tuple[App, list[str]]: ) -> tuple[App, list[str]]:
"""解析 DSL创建应用配置,返回 (new_app, warnings)""" """解析 DSL创建或覆盖应用配置,返回 (app, warnings)
app_id 不为空时:校验类型一致后覆盖配置;为空时创建新应用。
"""
app_meta = dsl.get("app", {}) app_meta = dsl.get("app", {})
app_type = app_meta.get("type") app_type = app_meta.get("type")
if app_type not in (AppType.AGENT, AppType.MULTI_AGENT, AppType.WORKFLOW): if app_type not in (AppType.AGENT, AppType.MULTI_AGENT, AppType.WORKFLOW):
@@ -239,6 +242,9 @@ class AppDslService:
warnings: list[str] = [] warnings: list[str] = []
now = datetime.datetime.now() now = datetime.datetime.now()
if app_id is not None:
return self._overwrite_dsl(dsl, app_id, app_type, workspace_id, tenant_id, warnings, now)
new_app = App( new_app = App(
id=uuid.uuid4(), id=uuid.uuid4(),
workspace_id=workspace_id, workspace_id=workspace_id,
@@ -258,11 +264,57 @@ class AppDslService:
self.db.add(new_app) self.db.add(new_app)
self.db.flush() self.db.flush()
self._write_config(new_app.id, app_type, dsl, workspace_id, tenant_id, warnings, now, create=True)
self.db.commit()
self.db.refresh(new_app)
return new_app, warnings
def _overwrite_dsl(
self,
dsl: dict,
app_id: uuid.UUID,
app_type: str,
workspace_id: uuid.UUID,
tenant_id: uuid.UUID,
warnings: list,
now: datetime.datetime,
) -> tuple[App, list[str]]:
"""覆盖已有应用的配置,类型不一致时抛出异常"""
app = self.db.query(App).filter(
App.id == app_id,
App.workspace_id == workspace_id,
App.is_active.is_(True)
).first()
if not app:
raise ResourceNotFoundException("应用", str(app_id))
if app.type != app_type:
raise BusinessException(
f"YAML 类型 '{app_type}' 与应用类型 '{app.type}' 不一致,无法导入",
BizCode.BAD_REQUEST
)
self._write_config(app_id, app_type, dsl, workspace_id, tenant_id, warnings, now, create=False)
self.db.commit()
self.db.refresh(app)
return app, warnings
def _write_config(
self,
app_id: uuid.UUID,
app_type: str,
dsl: dict,
workspace_id: uuid.UUID,
tenant_id: uuid.UUID,
warnings: list,
now: datetime.datetime,
create: bool,
) -> None:
"""写入(新建或覆盖)应用配置"""
if app_type == AppType.AGENT: if app_type == AppType.AGENT:
cfg = dsl.get("agent_config") or {} cfg = dsl.get("agent_config") or {}
self.db.add(AgentConfig( fields = dict(
id=uuid.uuid4(),
app_id=new_app.id,
system_prompt=cfg.get("system_prompt"), system_prompt=cfg.get("system_prompt"),
model_parameters=cfg.get("model_parameters"), model_parameters=cfg.get("model_parameters"),
default_model_config_id=self._resolve_model(cfg.get("default_model_config_ref"), tenant_id, warnings), default_model_config_id=self._resolve_model(cfg.get("default_model_config_ref"), tenant_id, warnings),
@@ -272,16 +324,21 @@ class AppDslService:
tools=self._resolve_tools(cfg.get("tools", []), tenant_id, warnings), tools=self._resolve_tools(cfg.get("tools", []), tenant_id, warnings),
skills=self._resolve_skills(cfg.get("skills", {}), tenant_id, warnings), skills=self._resolve_skills(cfg.get("skills", {}), tenant_id, warnings),
features=cfg.get("features", {}), features=cfg.get("features", {}),
is_active=True,
created_at=now,
updated_at=now, updated_at=now,
)) )
if create:
self.db.add(AgentConfig(id=uuid.uuid4(), app_id=app_id, is_active=True, created_at=now, **fields))
else:
existing = self.db.query(AgentConfig).filter(AgentConfig.app_id == app_id).first()
if existing:
for k, v in fields.items():
setattr(existing, k, v)
else:
self.db.add(AgentConfig(id=uuid.uuid4(), app_id=app_id, is_active=True, created_at=now, **fields))
elif app_type == AppType.MULTI_AGENT: elif app_type == AppType.MULTI_AGENT:
cfg = dsl.get("multi_agent_config") or {} cfg = dsl.get("multi_agent_config") or {}
self.db.add(MultiAgentConfig( fields = dict(
id=uuid.uuid4(),
app_id=new_app.id,
orchestration_mode=cfg.get("orchestration_mode", "collaboration"), orchestration_mode=cfg.get("orchestration_mode", "collaboration"),
master_agent_name=cfg.get("master_agent_name"), master_agent_name=cfg.get("master_agent_name"),
model_parameters=cfg.get("model_parameters"), model_parameters=cfg.get("model_parameters"),
@@ -291,10 +348,17 @@ class AppDslService:
routing_rules=self._resolve_routing_rules(cfg.get("routing_rules"), warnings), routing_rules=self._resolve_routing_rules(cfg.get("routing_rules"), warnings),
execution_config=cfg.get("execution_config", {}), execution_config=cfg.get("execution_config", {}),
aggregation_strategy=cfg.get("aggregation_strategy", "merge"), aggregation_strategy=cfg.get("aggregation_strategy", "merge"),
is_active=True,
created_at=now,
updated_at=now, updated_at=now,
)) )
if create:
self.db.add(MultiAgentConfig(id=uuid.uuid4(), app_id=app_id, is_active=True, created_at=now, **fields))
else:
existing = self.db.query(MultiAgentConfig).filter(MultiAgentConfig.app_id == app_id).first()
if existing:
for k, v in fields.items():
setattr(existing, k, v)
else:
self.db.add(MultiAgentConfig(id=uuid.uuid4(), app_id=app_id, is_active=True, created_at=now, **fields))
elif app_type == AppType.WORKFLOW: elif app_type == AppType.WORKFLOW:
adapter = MemoryBearAdapter(dsl) adapter = MemoryBearAdapter(dsl)
@@ -306,20 +370,39 @@ class AppDslService:
for w in result.warnings: for w in result.warnings:
warnings.append(f"[节点警告] {w.node_name or w.node_id}: {w.detail}") warnings.append(f"[节点警告] {w.node_name or w.node_id}: {w.detail}")
wf = dsl.get("workflow") or {} wf = dsl.get("workflow") or {}
WorkflowService(self.db).create_workflow_config( wf_service = WorkflowService(self.db)
app_id=new_app.id, if create:
nodes=[n.model_dump() for n in result.nodes], wf_service.create_workflow_config(
edges=[e.model_dump() for e in result.edges], app_id=app_id,
variables=[v.model_dump() for v in result.variables], nodes=[n.model_dump() for n in result.nodes],
execution_config=wf.get("execution_config", {}), edges=[e.model_dump() for e in result.edges],
features=wf.get("features", {}), variables=[v.model_dump() for v in result.variables],
triggers=wf.get("triggers", []), execution_config=wf.get("execution_config", {}),
validate=False, features=wf.get("features", {}),
) triggers=wf.get("triggers", []),
validate=False,
self.db.commit() )
self.db.refresh(new_app) else:
return new_app, warnings existing = self.db.query(WorkflowConfig).filter(WorkflowConfig.app_id == app_id).first()
if existing:
existing.nodes = [n.model_dump() for n in result.nodes]
existing.edges = [e.model_dump() for e in result.edges]
existing.variables = [v.model_dump() for v in result.variables]
existing.execution_config = wf.get("execution_config", {})
existing.features = wf.get("features", {})
existing.triggers = wf.get("triggers", [])
existing.updated_at = now
else:
wf_service.create_workflow_config(
app_id=app_id,
nodes=[n.model_dump() for n in result.nodes],
edges=[e.model_dump() for e in result.edges],
variables=[v.model_dump() for v in result.variables],
execution_config=wf.get("execution_config", {}),
features=wf.get("features", {}),
triggers=wf.get("triggers", []),
validate=False,
)
def _unique_app_name(self, name: str, workspace_id: uuid.UUID, app_type: AppType) -> str: def _unique_app_name(self, name: str, workspace_id: uuid.UUID, app_type: AppType) -> str:
"""生成唯一应用名称,同时检查本空间自有应用和共享到本空间的应用""" """生成唯一应用名称,同时检查本空间自有应用和共享到本空间的应用"""

View File

@@ -1299,10 +1299,30 @@ class AgentRunService:
"history_files": {} "history_files": {}
} }
if files: if files:
from app.models.file_metadata_model import FileMetadata
local_ids = [f.upload_file_id for f in files
if f.transfer_method.value == "local_file" and f.upload_file_id
and (not f.name or not f.size)]
meta_map = {}
if local_ids:
rows = self.db.query(FileMetadata).filter(
FileMetadata.id.in_(local_ids),
FileMetadata.status == "completed"
).all()
meta_map = {str(r.id): r for r in rows}
for f in files: for f in files:
name, size = f.name, f.size
if f.transfer_method.value == "local_file" and f.upload_file_id and (not name or not size):
meta = meta_map.get(str(f.upload_file_id))
if meta:
name = name or meta.file_name
size = size or meta.file_size
human_meta["files"].append({ human_meta["files"].append({
"type": f.type, "type": f.type,
"url": f.url "url": f.url,
"file_type": f.file_type,
"name": name,
"size": size
}) })
# 保存 history_files包含 provider 和 is_omni 信息 # 保存 history_files包含 provider 和 is_omni 信息

View File

@@ -957,7 +957,10 @@ class WorkflowService:
for file in message["content"]: for file in message["content"]:
human_meta["files"].append({ human_meta["files"].append({
"type": file.get("type"), "type": file.get("type"),
"url": file.get("url") "url": file.get("url"),
"file_type": file.get("origin_file_type"),
"name": file.get("name"),
"size": file.get("size")
}) })
if message["role"] == "assistant": if message["role"] == "assistant":
assistant_message = message["content"] assistant_message = message["content"]