Merge branch 'develop' into feature/app_zy

This commit is contained in:
yingzhao
2026-04-14 17:04:38 +08:00
committed by GitHub
17 changed files with 706 additions and 105 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"]

14
web/src/api/package.ts Normal file
View File

@@ -0,0 +1,14 @@
import { request } from '@/utils/request'
import type { Package } from '@/views/Package/types'
export const SYS_API_PREFIX = '/sys';
// 套餐列表
export const getPackageListUrl = `${SYS_API_PREFIX}/package-plans`
export const getPackageList = (query: { category: Package['category']; status: boolean; }) => {
return request.get(getPackageListUrl, query)
}
// 获取套餐详情
export const getPackageDetail = (package_plan_id: string) => {
return request.get(`${SYS_API_PREFIX}/package-plans/${package_plan_id}`)
}

View File

@@ -3016,5 +3016,69 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
apply: 'Apply', apply: 'Apply',
tools: 'Tools', tools: 'Tools',
}, },
package: {
package: 'Package Management',
saas_personal: 'SaaS Personal',
commercial_deployment: 'Commercial Deployment',
noCommercialPackages: 'No commercial deployment packages available',
addPackage: 'Add Plan',
packageName: 'Plan Name',
packageNameZh: 'Plan Name (中文)',
packageNameEn: 'Plan Name (English)',
packageNamePlaceholder: '中文, 例如:记忆体验版',
packageNamePlaceholderEn: 'English, e.g. Memory Trial Plan',
packageCategory: 'Package Category',
price: 'Price',
pricePlaceholder: 'e.g. 0, 19, 299 or Contact Us',
billingPeriod: 'Billing Period',
monthly: 'Monthly',
yearly: 'Yearly',
permanent_free: 'Permanent Free',
local_deployment: 'Local Deployment',
coreValue: 'Core Value',
coreValueZh: 'Core Value (中文)',
coreValueEn: 'Core Value (English)',
coreValuePlaceholder: '中文, 一句话描述核心价值',
coreValuePlaceholderEn: 'EngLish, describe the core value in one sentence',
tech_support: 'Technical Support',
tech_support_zh: 'Technical Support (中文)',
tech_support_en: 'Technical Support (English)',
technicalSupportPlaceholder: '中文, 例如:社群交流、工单支持',
technicalSupportPlaceholderEn: 'English, e.g. Community support, ticket support',
sla: 'SLA & Compliance',
slaZh: 'SLA & Compliance (中文)',
slaEn: 'SLA & Compliance (English)',
slaPlaceholder: '中文, 例如:无、验证力加强+审计日志',
slaPlaceholderEn: 'English, e.g. None, dedicated compute pool + audit logs',
customPage: 'Chat Page Customization',
customPageZh: 'Chat Page Customization (中文)',
customPageEn: 'Chat Page Customization (English)',
customPagePlaceholder: '中文, 例如LOGO定制',
customPagePlaceholderEn: 'English, e.g. Logo customization',
primaryColor: 'Primary Color',
status: 'Status',
active: 'Active',
inactive: 'Inactive',
api_ops_rate_limit: 'API OPS Rate Limit',
ops: 'req/s',
pcs: 'pcs',
GB: 'GB',
tier_level: 'Tier Level',
numberPlaceholder: 'e.g. 10',
packageDetail: 'Package Detail',
basicInfo: 'Basic Info',
featureConfig: 'Billing Unit Quota',
workspace_quota: 'Workspace Quota',
skill_quota: 'Skill Library Quota',
app_quota: 'App Quota',
knowledge_capacity_quota: 'Knowledge Base Capacity',
memory_engine_quota: 'Memory Engine Quota',
end_user_quota: 'Memorable End Users',
ontology_project_quota: 'Ontology Project',
model_quota: 'Model Quota',
editPackage: 'Edit Package',
},
}, },
}; };

View File

@@ -2980,5 +2980,69 @@ export const zh = {
apply: '应用', apply: '应用',
tools: '工具', tools: '工具',
}, },
package: {
package: '套餐管理',
saas_personal: 'SaaS 个人版',
commercial_deployment: '商业化部署',
noCommercialPackages: '暂无商业化部署套餐',
addPackage: '添加套餐',
packageName: '套餐名称',
packageNameZh: '套餐名称 (中文)',
packageNameEn: '套餐名称 (English)',
packageNamePlaceholder: '中文, 例如:记忆体验版',
packageNamePlaceholderEn: 'English, e.g. Memory Trial Plan',
packageCategory: '套餐分类',
price: '价格',
pricePlaceholder: '例如: 0, 19, 299 或联系我们',
billingPeriod: '计费周期',
monthly: '月',
yearly: '年',
permanent_free: '永久免费',
local_deployment: '本地化部署',
coreValue: '核心价值',
coreValueZh: '核心价值 (中文)',
coreValueEn: '核心价值 (English)',
coreValuePlaceholder: '中文, 一句话描述核心价值',
coreValuePlaceholderEn: 'EngLish, describe the core value in one sentence',
tech_support: '技术支持',
tech_support_zh: '技术支持 (中文)',
tech_support_en: '技术支持 (English)',
technicalSupportPlaceholder: '中文, 例如:社群交流、工单支持',
technicalSupportPlaceholderEn: 'English, e.g. Community support, ticket support',
sla: 'SLA与合规',
slaZh: 'SLA与合规 (中文)',
slaEn: 'SLA与合规 (English)',
slaPlaceholder: '中文, 例如:无、验证力加强+审计日志',
slaPlaceholderEn: 'English, e.g. None, dedicated compute pool + audit logs',
customPage: '对应页面个性化配置',
customPageZh: '对应页面个性化配置 (中文)',
customPageEn: '对应页面个性化配置 (English)',
customPagePlaceholder: '中文, 例如LOGO定制',
customPagePlaceholderEn: 'English, e.g. Logo customization',
primaryColor: '主题色',
status: '状态',
active: '启用',
inactive: '停用',
api_ops_rate_limit: 'API OPS 频次',
ops: '次/秒',
pcs: '个',
GB: 'GB',
tier_level: '层级',
numberPlaceholder: '如: 10',
packageDetail: '套餐详情',
basicInfo: '基础信息',
featureConfig: '计费单元配额',
workspace_quota: '空间数量',
skill_quota: '技能库数量',
app_quota: '应用数量',
knowledge_capacity_quota: '知识库容量',
memory_engine_quota: '记忆引擎数量',
end_user_quota: '可记忆终端用户数',
ontology_project_quota: '本体工程',
model_quota: '可负载模型数量',
editPackage: '编辑套餐',
},
}, },
} }

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-02 16:33:11 * @Date: 2026-02-02 16:33:11
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-04 18:11:34 * @Last Modified time: 2026-04-13 16:53:15
*/ */
/** /**
* Route Configuration * Route Configuration
@@ -76,13 +76,12 @@ const componentMap: Record<string, LazyExoticComponent<ComponentType<object>>> =
SpaceManagement: lazy(() => import('@/views/SpaceManagement')), SpaceManagement: lazy(() => import('@/views/SpaceManagement')),
ApiKeyManagement: lazy(() => import('@/views/ApiKeyManagement')), ApiKeyManagement: lazy(() => import('@/views/ApiKeyManagement')),
EmotionEngine: lazy(() => import('@/views/EmotionEngine')), EmotionEngine: lazy(() => import('@/views/EmotionEngine')),
StatementDetail: lazy(() => import('@/views/UserMemoryDetail/pages/StatementDetail')),
ForgetDetail: lazy(() => import('@/views/UserMemoryDetail/pages/ForgetDetail')), ForgetDetail: lazy(() => import('@/views/UserMemoryDetail/pages/ForgetDetail')),
MemoryNodeDetail: lazy(() => import('@/views/UserMemoryDetail/pages/index')), MemoryNodeDetail: lazy(() => import('@/views/UserMemoryDetail/pages/index')),
SelfReflectionEngine: lazy(() => import('@/views/SelfReflectionEngine')), SelfReflectionEngine: lazy(() => import('@/views/SelfReflectionEngine')),
OrderPayment: lazy(() => import('@/views/OrderPayment')), OrderPayment: lazy(() => import('@/views/OrderPayment')),
OrderHistory: lazy(() => import('@/views/OrderHistory')), OrderHistory: lazy(() => import('@/views/OrderHistory')),
Pricing: lazy(() => import('@/views/Pricing')), Package: lazy(() => import('@/views/Package')),
ToolManagement: lazy(() => import('@/views/ToolManagement')), ToolManagement: lazy(() => import('@/views/ToolManagement')),
SpaceConfig: lazy(() => import('@/views/SpaceConfig')), SpaceConfig: lazy(() => import('@/views/SpaceConfig')),
Ontology: lazy(() => import('@/views/Ontology')), Ontology: lazy(() => import('@/views/Ontology')),

View File

@@ -7,7 +7,7 @@
{ "path": "/model", "element": "ModelManagement" }, { "path": "/model", "element": "ModelManagement" },
{ "path": "/space", "element": "SpaceManagement" }, { "path": "/space", "element": "SpaceManagement" },
{ "path": "/tool", "element": "ToolManagement" }, { "path": "/tool", "element": "ToolManagement" },
{ "path": "/pricing", "element": "Pricing" }, { "path": "/pricing", "element": "Package" },
{ "path": "/order-pay", "element": "OrderPayment" }, { "path": "/order-pay", "element": "OrderPayment" },
{ "path": "/orders", "element": "OrderHistory" }, { "path": "/orders", "element": "OrderHistory" },
{ "path": "/skills", "element": "Skills" }, { "path": "/skills", "element": "Skills" },

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-02 16:35:15 * @Date: 2026-02-02 16:35:15
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-06 10:39:00 * @Last Modified time: 2026-04-14 14:43:54
*/ */
/** /**
* HTTP Request Utility Module * HTTP Request Utility Module
@@ -23,6 +23,7 @@ import { clearAuthData } from './auth';
import { message } from 'antd'; import { message } from 'antd';
import { refreshTokenUrl, refreshToken, loginUrl, logoutUrl } from '@/api/user' import { refreshTokenUrl, refreshToken, loginUrl, logoutUrl } from '@/api/user'
import i18n from '@/i18n' import i18n from '@/i18n'
import { SYS_API_PREFIX } from '@/api/package'
/** /**
* Standard API response structure * Standard API response structure
@@ -74,6 +75,10 @@ let requests: RequestQueueItem[] = [];
// Request interceptor // Request interceptor
service.interceptors.request.use( service.interceptors.request.use(
(config) => { (config) => {
console.log('config', config, config.url?.startsWith(SYS_API_PREFIX))
if (config.url?.startsWith(SYS_API_PREFIX)) {
config.baseURL = '';
}
if (!config.headers.Authorization) { if (!config.headers.Authorization) {
const token = cookieUtils.get('authToken'); const token = cookieUtils.get('authToken');
if (token) { if (token) {

View File

@@ -0,0 +1,40 @@
/*
* @Author: ZhaoYing
* @Date: 2026-04-14 11:43:57
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-14 14:55:20
*/
export const billingUnits = [
{
key: 'workspace_quota',
unit: 'pcs', placeholder: 'numberPlaceholder',
},
{
key: 'skill_quota',
unit: 'pcs', placeholder: 'numberPlaceholder',
},
{
key: 'app_quota',
unit: 'pcs', placeholder: 'numberPlaceholder',
},
{
key: 'knowledge_capacity_quota',
unit: 'GB', placeholder: 'numberPlaceholder',
},
{
key: 'memory_engine_quota',
unit: 'pcs', placeholder: 'numberPlaceholder',
},
{
key: 'end_user_quota',
unit: 'pcs', placeholder: 'numberPlaceholder',
},
{
key: 'ontology_project_quota',
unit: 'pcs', placeholder: 'numberPlaceholder',
},
{
key: 'model_quota',
unit: 'ops', placeholder: 'numberPlaceholder',
},
]

View File

@@ -0,0 +1,145 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-25
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-14 14:59:11
*/
/**
* Package Component
*
* Package management page with:
* - Tabs for SaaS Personal and Commercial Deployment
* - Package cards showing features and pricing
* - Edit and delete actions
*
* @component
*/
import { useMemo, useState, useEffect, type FC } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { Flex, Row, Col, type SegmentedProps } from 'antd';
import clsx from 'clsx';
import type { Package } from './types'
import { getPackageList } from '@/api/package';
import PageTabs from '@/components/PageTabs'
import { billingUnits } from './constant'
import RbCard from '@/components/RbCard/Card'
import BodyWrapper from '@/components/Empty/BodyWrapper'
import { useI18n } from '@/store/locale'
import RbButton from '@/components/RbButton'
const Package: FC = () => {
const { t } = useTranslation();
const { language } = useI18n()
const navigate = useNavigate();
const [data, setData] = useState<Package[]>([])
const [activeTab, setActiveTab] = useState('saas_personal');
const formatTabItems = useMemo(() => {
return ['saas_personal', 'commercial_deployment'].map(value => ({
value,
label: t(`package.${value}`),
}))
}, [t])
/** Handle tab change */
const handleChangeTab = (value: SegmentedProps['value']) => {
setActiveTab(value as string);
}
const getList = () => {
getPackageList({ category: activeTab as Package['category'], status: true })
.then(res => {
setData(res as Package[] || [])
})
}
useEffect(() => {
getList()
}, [activeTab])
const getKeyWithLanguage = (key: string) => {
return (language === 'en' ? `${key}_en` : key) as keyof Package
}
/** Navigate to order history */
const goToHistory = () => {
navigate('/orders');
}
return (
<>
<Flex justify="space-between" className="rb:mb-4!">
<PageTabs
value={activeTab}
options={formatTabItems}
onChange={handleChangeTab}
/>
<RbButton className="rb:text-[#212332] rb:font-medium!" onClick={goToHistory}>
<div
className="rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/order/order.svg')]"
></div>
{t('pricing.orderHistory')}
</RbButton>
</Flex>
<BodyWrapper empty={data.length < 1}>
<Row gutter={[12, 12]} className="rb:max-h-[calc(100%-48px)]! rb:overflow-y-auto">
{data.map((pkg) => (
<Col key={pkg.id} span={8}>
<RbCard
className="rb:h-full! rb:shadow-md hover:rb:shadow-lg rb:transition-shadow"
bodyClassName="rb:p-6! rb:h-full!"
headerClassName="rb:min-h-0!"
>
<Flex vertical justify="space-between" className="rb:h-full!">
<div>
{/* Header */}
<div className="rb:text-center rb:mb-6">
<h3 className="rb:text-xl rb:font-bold rb:mb-2 rb:min-h-7" style={{ color: pkg.theme_color }}>
{String(pkg[getKeyWithLanguage('name')] ?? '')}
</h3>
<p className="rb:text-sm rb:text-gray-500 rb:mb-4 rb:min-h-5">{String(pkg[getKeyWithLanguage('core_value')] ?? '')}</p>
<div className="rb:text-4xl rb:font-bold rb:mb-2">
{pkg.billing_cycle !== 'permanent_free' && <>¥{pkg.price}</>}
{pkg.billing_cycle && <span className={clsx("", {
'rb:text-base rb:font-normal rb:text-gray-500': pkg.billing_cycle !== 'permanent_free'
})}>{pkg.billing_cycle !== 'permanent_free' && '/'}{t(`package.${pkg.billing_cycle}`)}</span>}
</div>
</div>
{/* Features */}
<div className="rb:space-y-3">
{billingUnits.map(({ key, unit }) => {
if (typeof pkg.quotas[key as keyof Package['quotas']] === 'number') {
return (
<div key={key} className="rb:flex rb:items-center rb:justify-between rb:text-sm">
<span className="rb:text-gray-500">{t(`package.${key}`)}</span>
<span>{pkg.quotas[key as keyof Package['quotas']]}{t(`package.${unit}`)}</span>
</div>
)
}
})}
{pkg.api_ops_rate_limit &&
<div className="rb:flex rb:items-center rb:justify-between rb:text-sm">
<span className="rb:text-gray-500">{t(`package.api_ops_rate_limit`)}</span>
<span>{pkg.api_ops_rate_limit}{t('package.ops')}</span>
</div>
}
{pkg.tech_support &&
<div className="rb:flex rb:items-center rb:justify-between rb:text-sm">
<span className="rb:text-gray-500">{t(`package.tech_support`)}</span>
<span>{String(pkg[getKeyWithLanguage('tech_support')] ?? '')}</span>
</div>
}
</div>
</div>
</Flex>
</RbCard>
</Col>
))}
</Row>
</BodyWrapper>
</>
);
};
export default Package;

View File

@@ -0,0 +1,61 @@
/*
* @Author: ZhaoYing
* @Date: 2026-04-14 11:35:01
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-14 14:28:46
*/
export interface Package {
id: string;
// 名称
name: string;
name_en: string;
// 类型
category: "saas_personal" | "commercial_deployment";
tier_level: number;
// 版本
version: string;
// 状态
status: boolean;
// 价格
price: string;
// 计费周期
billing_cycle: "monthly" | "yearly" | "permanent_free" | "local_deployment";
// 核心价值
core_value: string;
core_value_en: string;
// 技术支持
tech_support: string;
tech_support_en: string;
// SLA与合规
sla_compliance: string;
sla_compliance_en: string;
// 对话页面个性化配置
page_customization: string;
page_customization_en: string;
// API OPS 频次(次/秒)
api_ops_rate_limit: number;
// 主题色
theme_color: string;
quotas: {
// 空间数量
workspace_quota: number;
// 技能库数量
skill_quota: number;
// 应用数量
app_quota: number;
// 知识库容量
knowledge_capacity_quota: string;
// 记忆引擎数量
memory_engine_quota: number;
// 可记忆终端用户数
end_user_quota: number;
// 本体工程
ontology_project_quota: number;
// 可负载模型数量
model_quota: number;
},
created_at: number;
updated_at: number;
created_by: string;
updated_by: string | null;
}