Compare commits
2 Commits
feat/neo4j
...
hotfix/v0.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9edf5c9bd6 | ||
|
|
2f0c4300df |
@@ -116,12 +116,9 @@ celery_app.conf.update(
|
||||
|
||||
# Document tasks → document_tasks queue (prefork worker)
|
||||
'app.core.rag.tasks.parse_document': {'queue': 'document_tasks'},
|
||||
'app.core.rag.tasks.build_graphrag_for_kb': {'queue': 'document_tasks'},
|
||||
'app.core.rag.tasks.sync_knowledge_for_kb': {'queue': 'document_tasks'},
|
||||
|
||||
# GraphRAG tasks → graphrag_tasks queue (独立队列,避免阻塞文档解析)
|
||||
'app.core.rag.tasks.build_graphrag_for_kb': {'queue': 'graphrag_tasks'},
|
||||
'app.core.rag.tasks.build_graphrag_for_document': {'queue': 'graphrag_tasks'},
|
||||
|
||||
# Beat/periodic tasks → periodic_tasks queue (dedicated periodic worker)
|
||||
'app.tasks.workspace_reflection_task': {'queue': 'periodic_tasks'},
|
||||
'app.tasks.regenerate_memory_cache': {'queue': 'periodic_tasks'},
|
||||
|
||||
@@ -1250,11 +1250,9 @@ async def export_app(
|
||||
async def import_app(
|
||||
file: UploadFile = File(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
app_id: Optional[str] = Form(None),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""从 YAML 文件导入 agent / multi_agent / workflow 应用。
|
||||
传入 app_id 时覆盖该应用的配置(类型必须一致),否则创建新应用。
|
||||
跨空间/跨租户导入时,模型/工具/知识库会按名称匹配,匹配不到则置空并返回 warnings。
|
||||
"""
|
||||
if not file.filename.lower().endswith((".yaml", ".yml")):
|
||||
@@ -1265,15 +1263,13 @@ async def import_app(
|
||||
if not dsl or "app" not in dsl:
|
||||
return fail(msg="YAML 格式无效,缺少 app 字段", code=BizCode.BAD_REQUEST)
|
||||
|
||||
target_app_id = uuid.UUID(app_id) if app_id else None
|
||||
result_app, warnings = AppDslService(db).import_dsl(
|
||||
new_app, warnings = AppDslService(db).import_dsl(
|
||||
dsl=dsl,
|
||||
workspace_id=current_user.current_workspace_id,
|
||||
tenant_id=current_user.tenant_id,
|
||||
user_id=current_user.id,
|
||||
app_id=target_app_id,
|
||||
)
|
||||
return success(
|
||||
data={"app": app_schema.App.model_validate(result_app), "warnings": warnings},
|
||||
data={"app": app_schema.App.model_validate(new_app), "warnings": warnings},
|
||||
msg="应用导入成功" + (",但部分资源需手动配置" if warnings else "")
|
||||
)
|
||||
|
||||
@@ -61,9 +61,9 @@ from app.core.memory.models.triplet_models import (
|
||||
# User metadata models
|
||||
from app.core.memory.models.metadata_models import (
|
||||
UserMetadata,
|
||||
UserMetadataBehavioralHints,
|
||||
UserMetadataProfile,
|
||||
MetadataExtractionResponse,
|
||||
MetadataFieldChange,
|
||||
)
|
||||
|
||||
# Ontology scenario models (LLM extracted from scenarios)
|
||||
@@ -133,9 +133,9 @@ __all__ = [
|
||||
"Triplet",
|
||||
"TripletExtractionResponse",
|
||||
"UserMetadata",
|
||||
"UserMetadataBehavioralHints",
|
||||
"UserMetadataProfile",
|
||||
"MetadataExtractionResponse",
|
||||
"MetadataFieldChange",
|
||||
# Ontology models
|
||||
"OntologyClass",
|
||||
"OntologyExtractionResponse",
|
||||
|
||||
@@ -4,7 +4,7 @@ Independent from triplet_models.py - these models are used by the
|
||||
standalone metadata extraction pipeline (post-dedup async Celery task).
|
||||
"""
|
||||
|
||||
from typing import List, Literal, Optional
|
||||
from typing import List
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
@@ -13,8 +13,8 @@ class UserMetadataProfile(BaseModel):
|
||||
"""用户画像信息"""
|
||||
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
role: List[str] = Field(default_factory=list, description="用户职业或角色")
|
||||
domain: List[str] = Field(default_factory=list, description="用户所在领域")
|
||||
role: str = Field(default="", description="用户职业或角色")
|
||||
domain: str = Field(default="", description="用户所在领域")
|
||||
expertise: List[str] = Field(
|
||||
default_factory=list, description="用户擅长的技能或工具"
|
||||
)
|
||||
@@ -23,37 +23,31 @@ class UserMetadataProfile(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class UserMetadataBehavioralHints(BaseModel):
|
||||
"""行为偏好"""
|
||||
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
learning_stage: str = Field(default="", description="学习阶段")
|
||||
preferred_depth: str = Field(default="", description="偏好深度")
|
||||
tone_preference: str = Field(default="", description="语气偏好")
|
||||
|
||||
|
||||
class UserMetadata(BaseModel):
|
||||
"""用户元数据顶层结构"""
|
||||
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
profile: UserMetadataProfile = Field(default_factory=UserMetadataProfile)
|
||||
|
||||
|
||||
class MetadataFieldChange(BaseModel):
|
||||
"""单个元数据字段的变更操作"""
|
||||
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
field_path: str = Field(
|
||||
description="字段路径,用点号分隔,如 'profile.role'、'profile.expertise'"
|
||||
)
|
||||
action: Literal["set", "remove"] = Field(
|
||||
description="操作类型:'set' 表示新增或修改,'remove' 表示移除"
|
||||
)
|
||||
value: Optional[str] = Field(
|
||||
default=None,
|
||||
description="字段的新值(action='set' 时必填)。标量字段直接填值,列表字段填单个要新增的元素"
|
||||
behavioral_hints: UserMetadataBehavioralHints = Field(
|
||||
default_factory=UserMetadataBehavioralHints
|
||||
)
|
||||
knowledge_tags: List[str] = Field(default_factory=list, description="知识标签")
|
||||
|
||||
|
||||
class MetadataExtractionResponse(BaseModel):
|
||||
"""元数据提取 LLM 响应结构(增量模式)"""
|
||||
"""元数据提取 LLM 响应结构"""
|
||||
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
metadata_changes: List[MetadataFieldChange] = Field(
|
||||
default_factory=list,
|
||||
description="元数据的增量变更列表,每项描述一个字段的新增、修改或移除操作",
|
||||
)
|
||||
user_metadata: UserMetadata = Field(default_factory=UserMetadata)
|
||||
aliases_to_add: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="本次新发现的用户别名(用户自我介绍或他人对用户的称呼)",
|
||||
|
||||
@@ -118,7 +118,7 @@ class MetadataExtractor:
|
||||
existing_aliases: Optional[List[str]] = None,
|
||||
) -> Optional[tuple]:
|
||||
"""
|
||||
对筛选后的 statement 列表调用 LLM 提取元数据增量变更和用户别名。
|
||||
对筛选后的 statement 列表调用 LLM 提取元数据和用户别名。
|
||||
|
||||
Args:
|
||||
statements: 用户发言的 statement 文本列表
|
||||
@@ -126,8 +126,7 @@ class MetadataExtractor:
|
||||
existing_aliases: 数据库已有的用户别名列表(可选)
|
||||
|
||||
Returns:
|
||||
(List[MetadataFieldChange], List[str], List[str]) tuple:
|
||||
(metadata_changes, aliases_to_add, aliases_to_remove) on success, None on failure
|
||||
(UserMetadata, List[str], List[str]) tuple: (metadata, aliases_to_add, aliases_to_remove) on success, None on failure
|
||||
"""
|
||||
if not statements:
|
||||
return None
|
||||
@@ -161,12 +160,12 @@ class MetadataExtractor:
|
||||
)
|
||||
|
||||
if response:
|
||||
changes = response.metadata_changes if response.metadata_changes else []
|
||||
metadata = response.user_metadata if response.user_metadata else None
|
||||
to_add = response.aliases_to_add if response.aliases_to_add else []
|
||||
to_remove = (
|
||||
response.aliases_to_remove if response.aliases_to_remove else []
|
||||
)
|
||||
return changes, to_add, to_remove
|
||||
return metadata, to_add, to_remove
|
||||
|
||||
logger.warning("LLM 返回的响应为空")
|
||||
return None
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
===Task===
|
||||
Extract user metadata changes from the following conversation statements spoken by the user.
|
||||
Extract user metadata from the following conversation statements spoken by the user.
|
||||
|
||||
{% if language == "zh" %}
|
||||
**"三度原则"判断标准:**
|
||||
@@ -10,36 +10,28 @@ Extract user metadata changes from the following conversation statements spoken
|
||||
**提取规则:**
|
||||
- **只提取关于"用户本人"的画像信息**,忽略用户提到的第三方人物(如朋友、同事、家人)的信息
|
||||
- 仅提取文本中明确提到的信息,不要推测
|
||||
- 如果文本中没有可提取的用户画像信息,返回空的 user_metadata 对象
|
||||
- **输出语言必须与输入文本的语言一致**(输入中文则输出中文值,输入英文则输出英文值)
|
||||
|
||||
**增量模式(重要):**
|
||||
你只需要输出**本次对话引起的变更操作**,不要输出完整的元数据。每个变更是一个对象,包含:
|
||||
- `field_path`:字段路径,用点号分隔(如 `profile.role`、`profile.expertise`)
|
||||
- `action`:操作类型
|
||||
* `set`:新增或修改一个字段的值
|
||||
* `remove`:移除一个字段的值
|
||||
- `value`:字段的新值(`action="set"` 时必填,`action="remove"` 时填要移除的元素值)
|
||||
* 所有字段均为列表类型,每个元素一条变更记录
|
||||
|
||||
**判断规则:**
|
||||
- 用户提到新信息 → `action="set"`,填入新值
|
||||
- 用户明确否定已有信息(如"我不再做老师了"、"我已经不学Python了")→ `action="remove"`,`value` 填要移除的元素值
|
||||
- 如果本次对话没有任何可提取的变更,返回空的 `metadata_changes` 数组 `[]`
|
||||
- **不要为未被提及的字段生成任何变更操作**
|
||||
|
||||
{% if existing_metadata %}
|
||||
**已有元数据(仅供参考,用于判断是否需要变更):**
|
||||
请对比已有数据和用户最新发言,只输出差异部分的变更操作。
|
||||
- 如果用户说的信息和已有数据一致,不需要输出变更
|
||||
- 如果用户否定了已有数据中的某个值,输出 `remove` 操作
|
||||
- 如果用户提到了新信息,输出 `set` 操作
|
||||
**重要:合并已有元数据**
|
||||
下方提供了数据库中已有的用户元数据。请结合用户最新发言,输出**合并后的完整元数据**:
|
||||
- 如果用户明确否定了已有信息(如"我不再教高中物理了"),在输出中**移除**该信息
|
||||
- 如果用户提到了新信息,**添加**到对应字段中
|
||||
- 如果已有信息未被用户否定,**保留**在输出中
|
||||
- 标量字段(如 role、domain):如果用户提到了新值,用新值替换;否则保留已有值
|
||||
- 最终输出应该是完整的、合并后的元数据,不是增量
|
||||
{% endif %}
|
||||
|
||||
**字段说明:**
|
||||
- profile.role:用户的职业或角色(列表),如 教师、医生、后端工程师,一个人可以有多个角色
|
||||
- profile.domain:用户所在领域(列表),如 教育、医疗、软件开发,一个人可以涉及多个领域
|
||||
- profile.expertise:用户擅长的技能或工具(列表),如 Python、心理咨询、高中物理
|
||||
- profile.interests:用户主动表达兴趣的话题或领域标签(列表)
|
||||
- profile.role:用户的职业或角色,如 教师、医生、后端工程师
|
||||
- profile.domain:用户所在领域,如 教育、医疗、软件开发
|
||||
- profile.expertise:用户擅长的技能或工具(通用,不限于编程),如 Python、心理咨询、高中物理
|
||||
- profile.interests:用户主动表达兴趣的话题或领域标签
|
||||
- behavioral_hints.learning_stage:学习阶段(初学者/中级/高级)
|
||||
- behavioral_hints.preferred_depth:偏好深度(概览/技术细节/深入探讨)
|
||||
- behavioral_hints.tone_preference:语气偏好(轻松随意/专业简洁/学术严谨)
|
||||
- knowledge_tags:用户涉及的知识领域标签
|
||||
|
||||
**用户别名变更(增量模式):**
|
||||
- **aliases_to_add**:本次新发现的用户别名,包括:
|
||||
@@ -51,6 +43,7 @@ Extract user metadata changes from the following conversation statements spoken
|
||||
- **aliases_to_remove**:用户明确否认的别名,包括:
|
||||
* 用户说"我不叫XX了"、"别叫我XX"、"我改名了,不叫XX" → 将 XX 放入此数组
|
||||
* **严格限制**:只将用户原文中**逐字提到**的被否认名字放入,不要推断关联的其他别名
|
||||
* 例如:用户说"我不叫陈小刀了" → 只移除"陈小刀",不要移除"陈哥"、"老陈"等未被提及的别名
|
||||
* 如果没有要移除的别名,返回空数组 `[]`
|
||||
{% if existing_aliases %}
|
||||
- 已有别名:{{ existing_aliases | tojson }}(仅供参考,不需要在输出中重复)
|
||||
@@ -64,36 +57,28 @@ Extract user metadata changes from the following conversation statements spoken
|
||||
**Extraction rules:**
|
||||
- **Only extract profile information about the user themselves**, ignore information about third parties (friends, colleagues, family) mentioned by the user
|
||||
- Only extract information explicitly mentioned in the text, do not speculate
|
||||
- If no user profile information can be extracted, return an empty user_metadata object
|
||||
- **Output language must match the input text language**
|
||||
|
||||
**Incremental mode (important):**
|
||||
You should only output **the change operations caused by this conversation**, not the complete metadata. Each change is an object containing:
|
||||
- `field_path`: Field path separated by dots (e.g. `profile.role`, `profile.expertise`)
|
||||
- `action`: Operation type
|
||||
* `set`: Add or update a field value
|
||||
* `remove`: Remove a field value
|
||||
- `value`: The new value for the field (required when `action="set"`, for `action="remove"` fill in the element value to remove)
|
||||
* All fields are list types, one change record per element
|
||||
|
||||
**Decision rules:**
|
||||
- User mentions new information → `action="set"`, fill in the new value
|
||||
- User explicitly negates existing info (e.g. "I'm no longer a teacher", "I stopped learning Python") → `action="remove"`, `value` is the element to remove
|
||||
- If this conversation has no extractable changes, return an empty `metadata_changes` array `[]`
|
||||
- **Do NOT generate any change operations for fields not mentioned in the conversation**
|
||||
|
||||
{% if existing_metadata %}
|
||||
**Existing metadata (for reference only, to determine if changes are needed):**
|
||||
Compare existing data with the user's latest statements, and only output change operations for the differences.
|
||||
- If the user's statement matches existing data, no change is needed
|
||||
- If the user negates a value in existing data, output a `remove` operation
|
||||
- If the user mentions new information, output a `set` operation
|
||||
**Important: Merge with existing metadata**
|
||||
Existing user metadata from the database is provided below. Combine with the user's latest statements to output the **complete merged metadata**:
|
||||
- If the user explicitly negates existing info (e.g. "I no longer teach high school physics"), **remove** it from output
|
||||
- If the user mentions new info, **add** it to the corresponding field
|
||||
- If existing info is not negated by the user, **keep** it in the output
|
||||
- Scalar fields (e.g. role, domain): replace with new value if user mentions one; otherwise keep existing
|
||||
- The final output should be the complete, merged metadata — not an incremental update
|
||||
{% endif %}
|
||||
|
||||
**Field descriptions:**
|
||||
- profile.role: User's occupation or role (list), e.g. teacher, doctor, software engineer. A person can have multiple roles
|
||||
- profile.domain: User's domain (list), e.g. education, healthcare, software development. A person can span multiple domains
|
||||
- profile.expertise: User's skills or tools (list), e.g. Python, counseling, physics
|
||||
- profile.interests: Topics or domain tags the user actively expressed interest in (list)
|
||||
- profile.role: User's occupation or role, e.g. teacher, doctor, software engineer
|
||||
- profile.domain: User's domain, e.g. education, healthcare, software development
|
||||
- profile.expertise: User's skills or tools (general, not limited to programming)
|
||||
- profile.interests: Topics or domain tags the user actively expressed interest in
|
||||
- behavioral_hints.learning_stage: Learning stage (beginner/intermediate/advanced)
|
||||
- behavioral_hints.preferred_depth: Preferred depth (overview/detailed/deep dive)
|
||||
- behavioral_hints.tone_preference: Tone preference (casual/professional/academic)
|
||||
- knowledge_tags: Knowledge domain tags related to the user
|
||||
|
||||
**User alias changes (incremental mode):**
|
||||
- **aliases_to_add**: Newly discovered user aliases from this conversation, including:
|
||||
@@ -105,6 +90,7 @@ Compare existing data with the user's latest statements, and only output change
|
||||
- **aliases_to_remove**: Aliases the user explicitly denies, including:
|
||||
* User says "Don't call me XX anymore", "I'm not called XX", "I changed my name from XX" → put XX in this array
|
||||
* **Strict rule**: Only include the exact name the user **verbatim mentions** as denied. Do NOT infer or remove related aliases
|
||||
* Example: User says "I'm not called John anymore" → only remove "John", do NOT remove "Johnny", "J" or other related aliases not mentioned
|
||||
* If no aliases to remove, return empty array `[]`
|
||||
{% if existing_aliases %}
|
||||
- Existing aliases: {{ existing_aliases | tojson }} (for reference only, do not repeat in output)
|
||||
@@ -127,11 +113,20 @@ Compare existing data with the user's latest statements, and only output change
|
||||
Return a JSON object with the following structure:
|
||||
```json
|
||||
{
|
||||
"metadata_changes": [
|
||||
{"field_path": "profile.role", "action": "set", "value": "后端工程师"},
|
||||
{"field_path": "profile.expertise", "action": "set", "value": "Python"},
|
||||
{"field_path": "profile.expertise", "action": "remove", "value": "Java"}
|
||||
],
|
||||
"user_metadata": {
|
||||
"profile": {
|
||||
"role": "",
|
||||
"domain": "",
|
||||
"expertise": [],
|
||||
"interests": []
|
||||
},
|
||||
"behavioral_hints": {
|
||||
"learning_stage": "",
|
||||
"preferred_depth": "",
|
||||
"tone_preference": ""
|
||||
},
|
||||
"knowledge_tags": []
|
||||
},
|
||||
"aliases_to_add": [],
|
||||
"aliases_to_remove": []
|
||||
}
|
||||
|
||||
@@ -28,135 +28,86 @@ class IterationRuntime:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
start_id: str,
|
||||
stream: bool,
|
||||
graph: CompiledStateGraph,
|
||||
node_id: str,
|
||||
config: dict[str, Any],
|
||||
state: WorkflowState,
|
||||
variable_pool: VariablePool,
|
||||
cycle_nodes: list,
|
||||
cycle_edges: list,
|
||||
child_variable_pool: VariablePool,
|
||||
):
|
||||
"""
|
||||
Initialize the iteration runtime.
|
||||
|
||||
Args:
|
||||
stream: Whether to run in streaming mode. When True, each iteration
|
||||
uses graph.astream and emits cycle_item events in real time.
|
||||
When False, graph.ainvoke is used instead.
|
||||
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.
|
||||
graph: Compiled workflow graph capable of async invocation.
|
||||
node_id: Unique identifier of the loop node.
|
||||
config: Dictionary containing iteration node configuration.
|
||||
state: Current workflow state at the point of iteration.
|
||||
"""
|
||||
self.start_id = start_id
|
||||
self.stream = stream
|
||||
self.graph = graph
|
||||
self.state = state
|
||||
self.node_id = node_id
|
||||
self.typed_config = IterationNodeConfig(**config)
|
||||
self.looping = True
|
||||
self.variable_pool = variable_pool
|
||||
self.cycle_nodes = cycle_nodes
|
||||
self.cycle_edges = cycle_edges
|
||||
self.child_variable_pool = child_variable_pool
|
||||
self.event_write = get_stream_writer()
|
||||
self.checkpoint = RunnableConfig(
|
||||
configurable={
|
||||
"thread_id": uuid.uuid4()
|
||||
}
|
||||
)
|
||||
|
||||
self.output_value = None
|
||||
self.result: list = []
|
||||
|
||||
def _build_child_graph(self) -> tuple[CompiledStateGraph, VariablePool, str]:
|
||||
async def _init_iteration_state(self, item, idx):
|
||||
"""
|
||||
Build an independent compiled subgraph for a single iteration task.
|
||||
|
||||
Each call creates a brand-new VariablePool by deep-copying the parent pool,
|
||||
then passes it to GraphBuilder. GraphBuilder binds this pool to every node's
|
||||
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:
|
||||
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.
|
||||
"""
|
||||
from app.core.workflow.engine.graph_builder import GraphBuilder
|
||||
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,
|
||||
)
|
||||
graph = builder.build()
|
||||
return graph, builder.variable_pool, builder.start_node_id
|
||||
|
||||
async def _init_iteration_state(self, item, idx, child_pool: VariablePool, start_id: str):
|
||||
"""
|
||||
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.
|
||||
Initialize a per-iteration copy of the workflow state.
|
||||
|
||||
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.
|
||||
item: Current element from the input array for this iteration.
|
||||
idx: Index of the element in the input array.
|
||||
|
||||
Returns:
|
||||
A WorkflowState instance ready to be passed to graph.ainvoke or graph.astream.
|
||||
A copy of the workflow state with iteration-specific variables set.
|
||||
"""
|
||||
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 = WorkflowState(
|
||||
**self.state
|
||||
)
|
||||
self.child_variable_pool.copy(self.variable_pool)
|
||||
await self.child_variable_pool.new(self.node_id, "item", item, VariableType.type_map(item), mut=True)
|
||||
await self.child_variable_pool.new(self.node_id, "index", item, VariableType.type_map(item), mut=True)
|
||||
loopstate["node_outputs"][self.node_id] = {
|
||||
"item": item,
|
||||
"index": idx,
|
||||
}
|
||||
loopstate["looping"] = 1
|
||||
loopstate["activate"][start_id] = True
|
||||
loopstate["activate"][self.start_id] = True
|
||||
return loopstate
|
||||
|
||||
def _merge_conv_vars(self, child_pool: VariablePool):
|
||||
self.variable_pool.variables["conv"].update(child_pool.variables["conv"])
|
||||
def merge_conv_vars(self):
|
||||
self.variable_pool.variables["conv"].update(
|
||||
self.child_variable_pool.variables["conv"]
|
||||
)
|
||||
|
||||
async def run_task(self, item, idx):
|
||||
"""
|
||||
Execute a single iteration asynchronously.
|
||||
Each task builds its own subgraph so the variable pool closure is independent.
|
||||
|
||||
Returns:
|
||||
Tuple of (idx, output, result, child_pool, stopped)
|
||||
Args:
|
||||
item: The input element for this iteration.
|
||||
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:
|
||||
async for event in graph.astream(
|
||||
init_state,
|
||||
async for event in self.graph.astream(
|
||||
await self._init_iteration_state(item, idx),
|
||||
stream_mode=["debug"],
|
||||
config=checkpoint
|
||||
config=self.checkpoint
|
||||
):
|
||||
if isinstance(event, tuple) and len(event) == 2:
|
||||
mode, data = event
|
||||
@@ -166,6 +117,7 @@ class IterationRuntime:
|
||||
event_type = data.get("type")
|
||||
payload = data.get("payload", {})
|
||||
node_name = payload.get("name")
|
||||
|
||||
if node_name and node_name.startswith("nop"):
|
||||
continue
|
||||
if event_type == "task_result":
|
||||
@@ -188,13 +140,17 @@ class IterationRuntime:
|
||||
"token_usage": result.get("node_outputs", {}).get(node_name, {}).get("token_usage")
|
||||
}
|
||||
})
|
||||
result = graph.get_state(config=checkpoint).values
|
||||
result = self.graph.get_state(config=self.checkpoint).values
|
||||
else:
|
||||
result = await graph.ainvoke(init_state)
|
||||
|
||||
output = child_pool.get_value(self.output_value)
|
||||
stopped = result["looping"] == 2
|
||||
return idx, output, result, child_pool, stopped
|
||||
result = await self.graph.ainvoke(await self._init_iteration_state(item, idx))
|
||||
output = self.child_variable_pool.get_value(self.output_value)
|
||||
if isinstance(output, list) and self.typed_config.flatten:
|
||||
self.result.extend(output)
|
||||
else:
|
||||
self.result.append(output)
|
||||
if result["looping"] == 2:
|
||||
self.looping = False
|
||||
return result
|
||||
|
||||
def _create_iteration_tasks(self, array_obj, idx):
|
||||
"""
|
||||
@@ -240,32 +196,16 @@ class IterationRuntime:
|
||||
tasks = self._create_iteration_tasks(array_obj, idx)
|
||||
logger.info(f"Iteration node {self.node_id}: running, concurrency {len(tasks)}")
|
||||
idx += self.typed_config.parallel_count
|
||||
batch = await asyncio.gather(*tasks)
|
||||
# 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
|
||||
child_state.extend(await asyncio.gather(*tasks))
|
||||
self.merge_conv_vars()
|
||||
else:
|
||||
# Execute iterations sequentially
|
||||
while idx < len(array_obj) and self.looping:
|
||||
logger.info(f"Iteration node {self.node_id}: running")
|
||||
item = array_obj[idx]
|
||||
_, output, result, child_pool, stopped = await self.run_task(item, idx)
|
||||
if isinstance(output, list) and self.typed_config.flatten:
|
||||
self.result.extend(output)
|
||||
else:
|
||||
self.result.append(output)
|
||||
self._merge_conv_vars(child_pool)
|
||||
result = await self.run_task(item, idx)
|
||||
self.merge_conv_vars()
|
||||
child_state.append(result)
|
||||
if stopped:
|
||||
self.looping = False
|
||||
idx += 1
|
||||
logger.info(f"Iteration node {self.node_id}: execution completed")
|
||||
return {
|
||||
|
||||
@@ -123,7 +123,7 @@ class CycleGraphNode(BaseNode):
|
||||
|
||||
return cycle_nodes, cycle_edges
|
||||
|
||||
def build_graph(self, variable_pool: VariablePool):
|
||||
def build_graph(self):
|
||||
"""
|
||||
Build and compile the internal subgraph for this cycle node.
|
||||
|
||||
@@ -135,7 +135,6 @@ class CycleGraphNode(BaseNode):
|
||||
from app.core.workflow.engine.graph_builder import GraphBuilder
|
||||
|
||||
self.child_variable_pool = VariablePool()
|
||||
self.child_variable_pool.copy(variable_pool)
|
||||
builder = GraphBuilder(
|
||||
{
|
||||
"nodes": self.cycle_nodes,
|
||||
@@ -166,8 +165,8 @@ class CycleGraphNode(BaseNode):
|
||||
Raises:
|
||||
RuntimeError: If the node type is unsupported.
|
||||
"""
|
||||
self.build_graph()
|
||||
if self.node_type == NodeType.LOOP:
|
||||
self.build_graph(variable_pool)
|
||||
return await LoopRuntime(
|
||||
start_id=self.start_node_id,
|
||||
stream=False,
|
||||
@@ -180,19 +179,20 @@ class CycleGraphNode(BaseNode):
|
||||
).run()
|
||||
if self.node_type == NodeType.ITERATION:
|
||||
return await IterationRuntime(
|
||||
start_id=self.start_node_id,
|
||||
stream=False,
|
||||
graph=self.graph,
|
||||
node_id=self.node_id,
|
||||
config=self.config,
|
||||
state=state,
|
||||
variable_pool=variable_pool,
|
||||
cycle_nodes=self.cycle_nodes,
|
||||
cycle_edges=self.cycle_edges,
|
||||
child_variable_pool=self.child_variable_pool
|
||||
).run()
|
||||
raise RuntimeError("Unknown cycle node type")
|
||||
|
||||
async def execute_stream(self, state: WorkflowState, variable_pool: VariablePool):
|
||||
self.build_graph()
|
||||
if self.node_type == NodeType.LOOP:
|
||||
self.build_graph(variable_pool)
|
||||
yield {
|
||||
"__final__": True,
|
||||
"result": await LoopRuntime(
|
||||
@@ -211,13 +211,14 @@ class CycleGraphNode(BaseNode):
|
||||
yield {
|
||||
"__final__": True,
|
||||
"result": await IterationRuntime(
|
||||
start_id=self.start_node_id,
|
||||
stream=True,
|
||||
graph=self.graph,
|
||||
node_id=self.node_id,
|
||||
config=self.config,
|
||||
state=state,
|
||||
variable_pool=variable_pool,
|
||||
cycle_nodes=self.cycle_nodes,
|
||||
cycle_edges=self.cycle_edges,
|
||||
child_variable_pool=self.child_variable_pool
|
||||
).run()
|
||||
}
|
||||
return
|
||||
|
||||
@@ -15,7 +15,6 @@ from app.services.tool_service import ToolService
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TEMPLATE_PATTERN = re.compile(r"\{\{.*?}}")
|
||||
PURE_VARIABLE_PATTERN = re.compile(r"^\{\{\s*([\w.]+)\s*}}$")
|
||||
|
||||
|
||||
class ToolNode(BaseNode):
|
||||
@@ -53,21 +52,13 @@ class ToolNode(BaseNode):
|
||||
# 渲染工具参数
|
||||
rendered_parameters = {}
|
||||
for param_name, param_template in self.typed_config.tool_parameters.items():
|
||||
if isinstance(param_template, str):
|
||||
pure_match = PURE_VARIABLE_PATTERN.match(param_template)
|
||||
if pure_match:
|
||||
# 纯单变量引用直接取原始值,保留 int/bool/float 等类型
|
||||
rendered_value = self.get_variable(pure_match.group(1), variable_pool, strict=False)
|
||||
if rendered_value is None:
|
||||
rendered_value = self._render_template(param_template, variable_pool)
|
||||
elif TEMPLATE_PATTERN.search(param_template):
|
||||
try:
|
||||
rendered_value = self._render_template(param_template, variable_pool)
|
||||
except Exception as e:
|
||||
raise ValueError(f"模板渲染失败:参数 {param_name} 的模板 {param_template} 解析错误") from e
|
||||
else:
|
||||
rendered_value = param_template
|
||||
if isinstance(param_template, str) and TEMPLATE_PATTERN.search(param_template):
|
||||
try:
|
||||
rendered_value = self._render_template(param_template, variable_pool)
|
||||
except Exception as e:
|
||||
raise ValueError(f"模板渲染失败:参数 {param_name} 的模板 {param_template} 解析错误") from e
|
||||
else:
|
||||
# 非模板参数(数字/布尔/普通字符串)直接保留原值
|
||||
rendered_value = param_template
|
||||
rendered_parameters[param_name] = rendered_value
|
||||
|
||||
|
||||
@@ -42,9 +42,6 @@ SET s += {
|
||||
last_access_time: statement.last_access_time,
|
||||
access_count: statement.access_count
|
||||
}
|
||||
SET s.importance_score = coalesce(s.importance_score, 0.5),
|
||||
s.activation_value = coalesce(s.activation_value, s.importance_score, 0.5),
|
||||
s.access_count = coalesce(s.access_count, 0)
|
||||
RETURN s.id AS uuid
|
||||
"""
|
||||
|
||||
@@ -123,7 +120,7 @@ SET e.name = CASE WHEN entity.name IS NOT NULL AND entity.name <> '' THEN entity
|
||||
END
|
||||
END,
|
||||
e.importance_score = CASE WHEN entity.importance_score IS NOT NULL THEN entity.importance_score ELSE coalesce(e.importance_score, 0.5) END,
|
||||
e.activation_value = CASE WHEN entity.activation_value IS NOT NULL THEN entity.activation_value ELSE coalesce(e.activation_value, e.importance_score, 0.5) END,
|
||||
e.activation_value = CASE WHEN entity.activation_value IS NOT NULL THEN entity.activation_value ELSE e.activation_value END,
|
||||
e.access_history = CASE WHEN entity.access_history IS NOT NULL THEN entity.access_history ELSE coalesce(e.access_history, []) END,
|
||||
e.last_access_time = CASE WHEN entity.last_access_time IS NOT NULL THEN entity.last_access_time ELSE e.last_access_time END,
|
||||
e.access_count = CASE WHEN entity.access_count IS NOT NULL THEN entity.access_count ELSE coalesce(e.access_count, 0) END,
|
||||
@@ -168,7 +165,6 @@ SET e += {
|
||||
}
|
||||
// Independent weak flag,仅标记弱关系,不再维护 relations 聚合字段
|
||||
SET e.is_weak = true
|
||||
SET e.activation_value = coalesce(e.activation_value, 0.5)
|
||||
RETURN e.id AS id
|
||||
"""
|
||||
|
||||
@@ -179,12 +175,10 @@ MERGE (s:ExtractedEntity {id: item.source_id, run_id: item.run_id})
|
||||
SET s += {name: item.subject, end_user_id: item.end_user_id, run_id: item.run_id}
|
||||
// Independent strong flag
|
||||
SET s.is_strong = true
|
||||
SET s.activation_value = coalesce(s.activation_value, 0.5)
|
||||
MERGE (o:ExtractedEntity {id: item.target_id, run_id: item.run_id})
|
||||
SET o += {name: item.object, end_user_id: item.end_user_id, run_id: item.run_id}
|
||||
// Independent strong flag
|
||||
SET o.is_strong = true
|
||||
SET o.activation_value = coalesce(o.activation_value, 0.5)
|
||||
"""
|
||||
|
||||
|
||||
@@ -745,7 +739,7 @@ SET m += {
|
||||
summary_embedding: summary.summary_embedding,
|
||||
config_id: summary.config_id,
|
||||
importance_score: CASE WHEN summary.importance_score IS NOT NULL THEN summary.importance_score ELSE coalesce(m.importance_score, 0.5) END,
|
||||
activation_value: CASE WHEN summary.activation_value IS NOT NULL THEN summary.activation_value ELSE coalesce(m.activation_value, m.importance_score, 0.5) END,
|
||||
activation_value: CASE WHEN summary.activation_value IS NOT NULL THEN summary.activation_value ELSE m.activation_value END,
|
||||
access_history: CASE WHEN summary.access_history IS NOT NULL THEN summary.access_history ELSE coalesce(m.access_history, []) END,
|
||||
last_access_time: CASE WHEN summary.last_access_time IS NOT NULL THEN summary.last_access_time ELSE m.last_access_time END,
|
||||
access_count: CASE WHEN summary.access_count IS NOT NULL THEN summary.access_count ELSE coalesce(m.access_count, 0) END
|
||||
|
||||
@@ -9,15 +9,12 @@ Classes:
|
||||
"""
|
||||
|
||||
from typing import Any, List, Dict
|
||||
import logging
|
||||
|
||||
from neo4j import AsyncGraphDatabase, basic_auth
|
||||
from neo4j.time import DateTime as Neo4jDateTime, Date as Neo4jDate, Time as Neo4jTime, Duration as Neo4jDuration
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _convert_neo4j_types(value: Any) -> Any:
|
||||
"""递归将 neo4j 原生时间类型转为 Python 原生类型 / ISO 字符串,确保可被 json.dumps 序列化。"""
|
||||
@@ -70,12 +67,7 @@ class Neo4jConnector:
|
||||
)
|
||||
self.driver = AsyncGraphDatabase.driver(
|
||||
uri,
|
||||
auth=basic_auth(username, password),
|
||||
# 抑制属性键不存在的 UNRECOGNIZED 分类通知警告(如 01N52)
|
||||
# last_access_time 等属性在节点被检索命中后才写入,
|
||||
# activation_value 在新节点创建后可能尚未被计算,
|
||||
# 全新数据库或清空数据后这些属性键不存在是正常业务行为
|
||||
notifications_disabled_classifications=["UNRECOGNIZED"],
|
||||
auth=basic_auth(username, password)
|
||||
)
|
||||
|
||||
async def close(self):
|
||||
|
||||
@@ -44,8 +44,6 @@ class FileInput(BaseModel):
|
||||
upload_file_id: Optional[uuid.UUID] = Field(None, description="已上传文件ID(local_file时必填)")
|
||||
url: Optional[str] = Field(None, description="远程URL(remote_url时必填)")
|
||||
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
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ from app.services.model_service import ModelApiKeyService
|
||||
from app.services.multi_agent_orchestrator import MultiAgentOrchestrator
|
||||
from app.services.multimodal_service import MultimodalService
|
||||
from app.services.workflow_service import WorkflowService
|
||||
from app.models.file_metadata_model import FileMetadata
|
||||
|
||||
logger = get_business_logger()
|
||||
|
||||
@@ -219,29 +218,11 @@ class AppChatService:
|
||||
"reasoning_content": result.get("reasoning_content")
|
||||
}
|
||||
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:
|
||||
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
|
||||
# url = await MultimodalService(self.db).get_file_url(f)
|
||||
human_meta["files"].append({
|
||||
"type": f.type,
|
||||
"url": f.url,
|
||||
"name": name,
|
||||
"size": size,
|
||||
"file_type": f.file_type,
|
||||
"url": f.url
|
||||
})
|
||||
|
||||
if processed_files:
|
||||
@@ -528,29 +509,10 @@ class AppChatService:
|
||||
}
|
||||
|
||||
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:
|
||||
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({
|
||||
"type": f.type,
|
||||
"url": f.url,
|
||||
"name": name,
|
||||
"size": size,
|
||||
"file_type": f.file_type,
|
||||
"url": f.url
|
||||
})
|
||||
if processed_files:
|
||||
human_meta["history_files"] = {
|
||||
|
||||
@@ -227,11 +227,8 @@ class AppDslService:
|
||||
workspace_id: uuid.UUID,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
app_id: Optional[uuid.UUID] = None,
|
||||
) -> tuple[App, list[str]]:
|
||||
"""解析 DSL,创建或覆盖应用配置,返回 (app, warnings)。
|
||||
app_id 不为空时:校验类型一致后覆盖配置;为空时创建新应用。
|
||||
"""
|
||||
"""解析 DSL,创建应用及配置,返回 (new_app, warnings)"""
|
||||
app_meta = dsl.get("app", {})
|
||||
app_type = app_meta.get("type")
|
||||
if app_type not in (AppType.AGENT, AppType.MULTI_AGENT, AppType.WORKFLOW):
|
||||
@@ -240,9 +237,6 @@ class AppDslService:
|
||||
warnings: list[str] = []
|
||||
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(
|
||||
id=uuid.uuid4(),
|
||||
workspace_id=workspace_id,
|
||||
@@ -262,57 +256,11 @@ class AppDslService:
|
||||
self.db.add(new_app)
|
||||
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:
|
||||
cfg = dsl.get("agent_config") or {}
|
||||
fields = dict(
|
||||
self.db.add(AgentConfig(
|
||||
id=uuid.uuid4(),
|
||||
app_id=new_app.id,
|
||||
system_prompt=cfg.get("system_prompt"),
|
||||
model_parameters=cfg.get("model_parameters"),
|
||||
default_model_config_id=self._resolve_model(cfg.get("default_model_config_ref"), tenant_id, warnings),
|
||||
@@ -322,21 +270,16 @@ class AppDslService:
|
||||
tools=self._resolve_tools(cfg.get("tools", []), tenant_id, warnings),
|
||||
skills=self._resolve_skills(cfg.get("skills", {}), tenant_id, warnings),
|
||||
features=cfg.get("features", {}),
|
||||
is_active=True,
|
||||
created_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:
|
||||
cfg = dsl.get("multi_agent_config") or {}
|
||||
fields = dict(
|
||||
self.db.add(MultiAgentConfig(
|
||||
id=uuid.uuid4(),
|
||||
app_id=new_app.id,
|
||||
orchestration_mode=cfg.get("orchestration_mode", "collaboration"),
|
||||
master_agent_name=cfg.get("master_agent_name"),
|
||||
model_parameters=cfg.get("model_parameters"),
|
||||
@@ -346,17 +289,10 @@ class AppDslService:
|
||||
routing_rules=self._resolve_routing_rules(cfg.get("routing_rules"), warnings),
|
||||
execution_config=cfg.get("execution_config", {}),
|
||||
aggregation_strategy=cfg.get("aggregation_strategy", "merge"),
|
||||
is_active=True,
|
||||
created_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:
|
||||
adapter = MemoryBearAdapter(dsl)
|
||||
@@ -368,39 +304,20 @@ class AppDslService:
|
||||
for w in result.warnings:
|
||||
warnings.append(f"[节点警告] {w.node_name or w.node_id}: {w.detail}")
|
||||
wf = dsl.get("workflow") or {}
|
||||
wf_service = WorkflowService(self.db)
|
||||
if create:
|
||||
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,
|
||||
)
|
||||
else:
|
||||
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,
|
||||
)
|
||||
WorkflowService(self.db).create_workflow_config(
|
||||
app_id=new_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,
|
||||
)
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(new_app)
|
||||
return new_app, warnings
|
||||
|
||||
def _unique_app_name(self, name: str, workspace_id: uuid.UUID, app_type: AppType) -> str:
|
||||
"""生成唯一应用名称,同时检查本空间自有应用和共享到本空间的应用"""
|
||||
|
||||
@@ -1299,30 +1299,10 @@ class AgentRunService:
|
||||
"history_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:
|
||||
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({
|
||||
"type": f.type,
|
||||
"url": f.url,
|
||||
"file_type": f.file_type,
|
||||
"name": name,
|
||||
"size": size
|
||||
"url": f.url
|
||||
})
|
||||
|
||||
# 保存 history_files,包含 provider 和 is_omni 信息
|
||||
|
||||
@@ -399,16 +399,12 @@ class UserMemoryService:
|
||||
}
|
||||
|
||||
# 构建响应数据(转换时间为毫秒时间戳)
|
||||
# 将 meta_data 中的 profile、knowledge_tags、behavioral_hints 平铺到顶层
|
||||
meta = end_user_info_record.meta_data or {}
|
||||
response_data = {
|
||||
"end_user_info_id": str(end_user_info_record.id),
|
||||
"end_user_id": str(end_user_info_record.end_user_id),
|
||||
"other_name": end_user_info_record.other_name,
|
||||
"aliases": end_user_info_record.aliases,
|
||||
"profile": meta.get("profile"),
|
||||
"knowledge_tags": meta.get("knowledge_tags"),
|
||||
"behavioral_hints": meta.get("behavioral_hints"),
|
||||
"meta_data": end_user_info_record.meta_data,
|
||||
"created_at": datetime_to_timestamp(end_user_info_record.created_at),
|
||||
"updated_at": datetime_to_timestamp(end_user_info_record.updated_at)
|
||||
}
|
||||
|
||||
@@ -957,10 +957,7 @@ class WorkflowService:
|
||||
for file in message["content"]:
|
||||
human_meta["files"].append({
|
||||
"type": file.get("type"),
|
||||
"url": file.get("url"),
|
||||
"file_type": file.get("origin_file_type"),
|
||||
"name": file.get("name"),
|
||||
"size": file.get("size")
|
||||
"url": file.get("url")
|
||||
})
|
||||
if message["role"] == "assistant":
|
||||
assistant_message = message["content"]
|
||||
|
||||
740
api/app/tasks.py
740
api/app/tasks.py
@@ -45,23 +45,6 @@ from app.utils.redis_lock import RedisFairLock
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# ── 预编译文件类型正则 & 常量 ──────────────────────────────────
|
||||
AUDIO_PATTERN = re.compile(
|
||||
r"\.(da|wave|wav|mp3|aac|flac|ogg|aiff|au|midi|wma|realaudio|vqf|oggvorbis|ape?)$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
VIDEO_IMAGE_PATTERN = re.compile(
|
||||
r"\.(png|jpeg|jpg|gif|bmp|svg|mp4|mov|avi|flv|mpeg|mpg|webm|wmv|3gp|3gpp|mkv?)$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
DEFAULT_PARSE_LANGUAGE = "Chinese"
|
||||
DEFAULT_PARSE_TO_PAGE = 100_000
|
||||
EMBEDDING_BATCH_SIZE = int(os.getenv("EMBEDDING_BATCH_SIZE", "20"))
|
||||
# Embedding 并发写入的最大线程数,需根据模型 API rate limit 调整
|
||||
EMBEDDING_MAX_WORKERS = int(os.getenv("EMBEDDING_MAX_WORKERS", "3"))
|
||||
# auto_questions LLM 并发调用的最大线程数
|
||||
AUTO_QUESTIONS_MAX_WORKERS = int(os.getenv("AUTO_QUESTIONS_MAX_WORKERS", "5"))
|
||||
|
||||
# 模块级同步 Redis 连接池,供 Celery 任务共享使用
|
||||
# 连接 CELERY_BACKEND DB,与 write_message:last_done 时间戳写入保持一致
|
||||
# 使用连接池而非单例客户端,提供更好的并发性能和自动重连
|
||||
@@ -178,67 +161,28 @@ def process_item(item: dict):
|
||||
return result
|
||||
|
||||
|
||||
def _build_vision_model(file_path: str, db_knowledge):
|
||||
"""根据文件类型选择合适的视觉/音频模型,避免冗余初始化。"""
|
||||
if AUDIO_PATTERN.search(file_path):
|
||||
omni_key = os.getenv("QWEN3_OMNI_API_KEY", "")
|
||||
omni_model = os.getenv("QWEN3_OMNI_MODEL_NAME", "qwen3-omni-flash")
|
||||
omni_base = os.getenv("QWEN3_OMNI_BASE_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1")
|
||||
return QWenSeq2txt(
|
||||
key=omni_key,
|
||||
model_name=omni_model,
|
||||
lang=DEFAULT_PARSE_LANGUAGE,
|
||||
base_url=omni_base,
|
||||
)
|
||||
if VIDEO_IMAGE_PATTERN.search(file_path):
|
||||
omni_key = os.getenv("QWEN3_OMNI_API_KEY", "")
|
||||
omni_model = os.getenv("QWEN3_OMNI_MODEL_NAME", "qwen3-omni-flash")
|
||||
omni_base = os.getenv("QWEN3_OMNI_BASE_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1")
|
||||
return QWenCV(
|
||||
key=omni_key,
|
||||
model_name=omni_model,
|
||||
lang=DEFAULT_PARSE_LANGUAGE,
|
||||
base_url=omni_base,
|
||||
)
|
||||
# 默认:使用知识库配置的 image2text 模型
|
||||
return QWenCV(
|
||||
key=db_knowledge.image2text.api_keys[0].api_key,
|
||||
model_name=db_knowledge.image2text.api_keys[0].model_name,
|
||||
lang=DEFAULT_PARSE_LANGUAGE,
|
||||
base_url=db_knowledge.image2text.api_keys[0].api_base,
|
||||
)
|
||||
|
||||
|
||||
@celery_app.task(name="app.core.rag.tasks.parse_document")
|
||||
def parse_document(file_path: str, document_id: uuid.UUID):
|
||||
"""
|
||||
Document parsing, vectorization, and storage
|
||||
"""
|
||||
# Force re-importing Trio in child processes (to avoid inheriting the state of the parent process)
|
||||
import importlib
|
||||
|
||||
import trio
|
||||
importlib.reload(trio)
|
||||
db = next(get_db()) # Manually call the generator
|
||||
db_document = None
|
||||
progress_lines: list[str] = [f"{datetime.now().strftime('%H:%M:%S')} Task has been received."]
|
||||
|
||||
def _progress_msg() -> str:
|
||||
return "\n".join(progress_lines) + "\n"
|
||||
|
||||
with get_db_context() as db:
|
||||
try:
|
||||
# Celery JSON 序列化会将 UUID 转为字符串,需要确保类型正确
|
||||
if not isinstance(document_id, uuid.UUID):
|
||||
document_id = uuid.UUID(str(document_id))
|
||||
|
||||
db_knowledge = None
|
||||
progress_msg = f"{datetime.now().strftime('%H:%M:%S')} Task has been received.\n"
|
||||
try:
|
||||
db_document = db.query(Document).filter(Document.id == document_id).first()
|
||||
if db_document is None:
|
||||
raise ValueError(f"Document {document_id} not found")
|
||||
db_knowledge = db.query(Knowledge).filter(Knowledge.id == db_document.kb_id).first()
|
||||
if db_knowledge is None:
|
||||
raise ValueError(f"Knowledge {db_document.kb_id} not found")
|
||||
|
||||
# 1. Document parsing & segmentation
|
||||
progress_lines.append(f"{datetime.now().strftime('%H:%M:%S')} Start to parse.")
|
||||
progress_msg += f"{datetime.now().strftime('%H:%M:%S')} Start to parse.\n"
|
||||
start_time = time.time()
|
||||
db_document.progress = 0.0
|
||||
db_document.progress_msg = _progress_msg()
|
||||
db_document.progress_msg = progress_msg
|
||||
db_document.process_begin_at = datetime.now(tz=timezone.utc)
|
||||
db_document.process_duration = 0.0
|
||||
db_document.run = 1
|
||||
@@ -246,195 +190,220 @@ def parse_document(file_path: str, document_id: uuid.UUID):
|
||||
db.refresh(db_document)
|
||||
|
||||
def progress_callback(prog=None, msg=None):
|
||||
progress_lines.append(f"{datetime.now().strftime('%H:%M:%S')} parse progress: {prog} msg: {msg}.")
|
||||
nonlocal progress_msg # Declare the use of an external progress_msg variable
|
||||
progress_msg += f"{datetime.now().strftime('%H:%M:%S')} parse progress: {prog} msg: {msg}.\n"
|
||||
|
||||
# Prepare vision_model for parsing
|
||||
vision_model = _build_vision_model(file_path, db_knowledge)
|
||||
# Prepare to configure chat_mdl、embedding_model、vision_model information
|
||||
chat_model = Base(
|
||||
key=db_knowledge.llm.api_keys[0].api_key,
|
||||
model_name=db_knowledge.llm.api_keys[0].model_name,
|
||||
base_url=db_knowledge.llm.api_keys[0].api_base
|
||||
)
|
||||
embedding_model = OpenAIEmbed(
|
||||
key=db_knowledge.embedding.api_keys[0].api_key,
|
||||
model_name=db_knowledge.embedding.api_keys[0].model_name,
|
||||
base_url=db_knowledge.embedding.api_keys[0].api_base
|
||||
)
|
||||
vision_model = QWenCV(
|
||||
key=db_knowledge.image2text.api_keys[0].api_key,
|
||||
model_name=db_knowledge.image2text.api_keys[0].model_name,
|
||||
lang="Chinese",
|
||||
base_url=db_knowledge.image2text.api_keys[0].api_base
|
||||
)
|
||||
if re.search(r"\.(da|wave|wav|mp3|aac|flac|ogg|aiff|au|midi|wma|realaudio|vqf|oggvorbis|ape?)$", file_path,
|
||||
re.IGNORECASE):
|
||||
vision_model = QWenSeq2txt(
|
||||
key=os.getenv("QWEN3_OMNI_API_KEY", ""),
|
||||
model_name=os.getenv("QWEN3_OMNI_MODEL_NAME", "qwen3-omni-flash"),
|
||||
lang="Chinese",
|
||||
base_url=os.getenv("QWEN3_OMNI_BASE_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1"),
|
||||
)
|
||||
elif re.search(r"\.(png|jpeg|jpg|gif|bmp|svg|mp4|mov|avi|flv|mpeg|mpg|webm|wmv|3gp|3gpp|mkv?)$", file_path,
|
||||
re.IGNORECASE):
|
||||
vision_model = QWenCV(
|
||||
key=os.getenv("QWEN3_OMNI_API_KEY", ""),
|
||||
model_name=os.getenv("QWEN3_OMNI_MODEL_NAME", "qwen3-omni-flash"),
|
||||
lang="Chinese",
|
||||
base_url=os.getenv("QWEN3_OMNI_BASE_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1"),
|
||||
)
|
||||
else:
|
||||
print(file_path)
|
||||
|
||||
from app.core.rag.app.naive import chunk
|
||||
res = chunk(filename=file_path,
|
||||
from_page=0,
|
||||
to_page=DEFAULT_PARSE_TO_PAGE,
|
||||
to_page=100000,
|
||||
callback=progress_callback,
|
||||
vision_model=vision_model,
|
||||
parser_config=db_document.parser_config,
|
||||
is_root=False)
|
||||
|
||||
progress_lines.append(f"{datetime.now().strftime('%H:%M:%S')} Finish parsing.")
|
||||
progress_msg += f"{datetime.now().strftime('%H:%M:%S')} Finish parsing.\n"
|
||||
db_document.progress = 0.8
|
||||
db_document.progress_msg = _progress_msg()
|
||||
db_document.progress_msg = progress_msg
|
||||
db.commit()
|
||||
db.refresh(db_document)
|
||||
|
||||
# 2. Document vectorization and storage
|
||||
total_chunks = len(res)
|
||||
progress_lines.append(f"{datetime.now().strftime('%H:%M:%S')} Generate {total_chunks} chunks.")
|
||||
progress_msg += f"{datetime.now().strftime('%H:%M:%S')} Generate {total_chunks} chunks.\n"
|
||||
batch_size = 100
|
||||
total_batches = ceil(total_chunks / batch_size)
|
||||
progress_per_batch = 0.2 / total_batches # Progress of each batch
|
||||
vector_service = ElasticSearchVectorFactory().init_vector(knowledge=db_knowledge)
|
||||
# 2.1 Delete document vector index
|
||||
vector_service.delete_by_metadata_field(key="document_id", value=str(document_id))
|
||||
# 2.2 Vectorize and import batch documents
|
||||
for batch_start in range(0, total_chunks, batch_size):
|
||||
batch_end = min(batch_start + batch_size, total_chunks) # prevent out-of-bounds
|
||||
batch = res[batch_start: batch_end] # Retrieve the current batch
|
||||
chunks = []
|
||||
|
||||
if total_chunks == 0:
|
||||
progress_lines.append(f"{datetime.now().strftime('%H:%M:%S')} No chunks generated, skipping vectorization.")
|
||||
else:
|
||||
total_batches = ceil(total_chunks / EMBEDDING_BATCH_SIZE)
|
||||
progress_per_batch = 0.2 / total_batches
|
||||
vector_service = ElasticSearchVectorFactory().init_vector(knowledge=db_knowledge)
|
||||
# 2.1 Delete document vector index
|
||||
vector_service.delete_by_metadata_field(key="document_id", value=str(document_id))
|
||||
# 2.2 Vectorize and import batch documents
|
||||
auto_questions_topn = db_document.parser_config.get("auto_questions", 0)
|
||||
chat_model = None
|
||||
if auto_questions_topn:
|
||||
chat_model = Base(
|
||||
key=db_knowledge.llm.api_keys[0].api_key,
|
||||
model_name=db_knowledge.llm.api_keys[0].model_name,
|
||||
base_url=db_knowledge.llm.api_keys[0].api_base,
|
||||
)
|
||||
|
||||
# 预先构建所有 batch 的 chunks,保证 sort_id 全局有序
|
||||
all_batch_chunks: list[list[DocumentChunk]] = []
|
||||
|
||||
if auto_questions_topn:
|
||||
# auto_questions 开启:先并发生成所有 chunk 的问题,再按 batch 分组
|
||||
# 构建 (global_idx, item) 列表
|
||||
indexed_items = list(enumerate(res))
|
||||
|
||||
def _generate_question(idx_item: tuple[int, dict]) -> tuple[int, str]:
|
||||
"""为单个 chunk 生成问题(带缓存),返回 (global_idx, question_text)"""
|
||||
global_idx, item = idx_item
|
||||
content = item["content_with_weight"]
|
||||
cached = get_llm_cache(chat_model.model_name, content, "question",
|
||||
{"topn": auto_questions_topn})
|
||||
if not cached:
|
||||
cached = question_proposal(chat_model, content, auto_questions_topn)
|
||||
set_llm_cache(chat_model.model_name, content, cached, "question",
|
||||
{"topn": auto_questions_topn})
|
||||
return global_idx, cached
|
||||
|
||||
# 并发调用 LLM 生成问题
|
||||
question_map: dict[int, str] = {}
|
||||
with ThreadPoolExecutor(max_workers=AUTO_QUESTIONS_MAX_WORKERS) as q_executor:
|
||||
futures = {q_executor.submit(_generate_question, item): item[0]
|
||||
for item in indexed_items}
|
||||
for future in futures:
|
||||
global_idx, cached = future.result()
|
||||
question_map[global_idx] = cached
|
||||
|
||||
progress_lines.append(
|
||||
f"{datetime.now().strftime('%H:%M:%S')} Auto questions generated for {total_chunks} chunks "
|
||||
f"(workers={AUTO_QUESTIONS_MAX_WORKERS}).")
|
||||
|
||||
# 按 batch 分组组装 DocumentChunk
|
||||
for batch_start in range(0, total_chunks, EMBEDDING_BATCH_SIZE):
|
||||
batch_end = min(batch_start + EMBEDDING_BATCH_SIZE, total_chunks)
|
||||
chunks = []
|
||||
for global_idx in range(batch_start, batch_end):
|
||||
item = res[global_idx]
|
||||
metadata = {
|
||||
"doc_id": uuid.uuid4().hex,
|
||||
"file_id": str(db_document.file_id),
|
||||
"file_name": db_document.file_name,
|
||||
"file_created_at": int(db_document.created_at.timestamp() * 1000),
|
||||
"document_id": str(db_document.id),
|
||||
"knowledge_id": str(db_document.kb_id),
|
||||
"sort_id": global_idx,
|
||||
"status": 1,
|
||||
}
|
||||
cached = question_map[global_idx]
|
||||
chunks.append(
|
||||
DocumentChunk(
|
||||
page_content=f"question: {cached} answer: {item['content_with_weight']}",
|
||||
metadata=metadata))
|
||||
all_batch_chunks.append(chunks)
|
||||
else:
|
||||
# 无 auto_questions:直接构建 chunks
|
||||
for batch_start in range(0, total_chunks, EMBEDDING_BATCH_SIZE):
|
||||
batch_end = min(batch_start + EMBEDDING_BATCH_SIZE, total_chunks)
|
||||
chunks = []
|
||||
for global_idx in range(batch_start, batch_end):
|
||||
item = res[global_idx]
|
||||
metadata = {
|
||||
"doc_id": uuid.uuid4().hex,
|
||||
"file_id": str(db_document.file_id),
|
||||
"file_name": db_document.file_name,
|
||||
"file_created_at": int(db_document.created_at.timestamp() * 1000),
|
||||
"document_id": str(db_document.id),
|
||||
"knowledge_id": str(db_document.kb_id),
|
||||
"sort_id": global_idx,
|
||||
"status": 1,
|
||||
}
|
||||
chunks.append(DocumentChunk(page_content=item["content_with_weight"], metadata=metadata))
|
||||
all_batch_chunks.append(chunks)
|
||||
|
||||
# 并发提交 embedding + ES 写入,max_workers 控制模型 API 并发压力
|
||||
batch_errors: dict[int, Exception] = {}
|
||||
|
||||
def _embed_and_store(batch_idx: int, batch_chunks: list[DocumentChunk]):
|
||||
try:
|
||||
vector_service.add_chunks(batch_chunks)
|
||||
except Exception as exc:
|
||||
logger.warning(f"[ParseDoc] batch {batch_idx} failed, retrying: {exc}")
|
||||
try:
|
||||
vector_service.add_chunks(batch_chunks)
|
||||
except Exception as retry_exc:
|
||||
logger.error(f"[ParseDoc] batch {batch_idx} retry failed: {retry_exc}", exc_info=True)
|
||||
batch_errors[batch_idx] = retry_exc
|
||||
|
||||
with ThreadPoolExecutor(max_workers=EMBEDDING_MAX_WORKERS) as executor:
|
||||
futures = {
|
||||
executor.submit(_embed_and_store, i, batch_chunks): i
|
||||
for i, batch_chunks in enumerate(all_batch_chunks)
|
||||
# Process the current batch
|
||||
for idx_in_batch, item in enumerate(batch):
|
||||
global_idx = batch_start + idx_in_batch # Calculate global index
|
||||
metadata = {
|
||||
"doc_id": uuid.uuid4().hex,
|
||||
"file_id": str(db_document.file_id),
|
||||
"file_name": db_document.file_name,
|
||||
"file_created_at": int(db_document.created_at.timestamp() * 1000),
|
||||
"document_id": str(db_document.id),
|
||||
"knowledge_id": str(db_document.kb_id),
|
||||
"sort_id": global_idx,
|
||||
"status": 1,
|
||||
}
|
||||
for future in futures:
|
||||
future.result()
|
||||
if db_document.parser_config.get("auto_questions", 0):
|
||||
topn = db_document.parser_config["auto_questions"]
|
||||
cached = get_llm_cache(chat_model.model_name, item["content_with_weight"], "question",
|
||||
{"topn": topn})
|
||||
if not cached:
|
||||
cached = question_proposal(chat_model, item["content_with_weight"], topn)
|
||||
set_llm_cache(chat_model.model_name, item["content_with_weight"], cached, "question",
|
||||
{"topn": topn})
|
||||
chunks.append(
|
||||
DocumentChunk(page_content=f"question: {cached} answer: {item['content_with_weight']}",
|
||||
metadata=metadata))
|
||||
else:
|
||||
chunks.append(DocumentChunk(page_content=item["content_with_weight"], metadata=metadata))
|
||||
|
||||
# 如果有 batch 失败,汇总抛出
|
||||
if batch_errors:
|
||||
failed_detail = "; ".join(
|
||||
f"batch {i}: {type(err).__name__}: {err}"
|
||||
for i, err in sorted(batch_errors.items())
|
||||
)
|
||||
raise RuntimeError(f"Embedding failed for {len(batch_errors)}/{total_batches} batch(es). {failed_detail}")
|
||||
# Bulk segmented vector import
|
||||
vector_service.add_chunks(chunks)
|
||||
|
||||
# 所有 batch 完成后一次性更新进度
|
||||
db_document.progress = 0.8 + 0.2 # 直接到 1.0 前的状态
|
||||
progress_lines.append(f"{datetime.now().strftime('%H:%M:%S')} All {total_batches} batches embedded (workers={EMBEDDING_MAX_WORKERS}).")
|
||||
db_document.progress_msg = _progress_msg()
|
||||
# Update progress
|
||||
db_document.progress += progress_per_batch
|
||||
progress_msg += f"{datetime.now().strftime('%H:%M:%S')} Embedding progress ({db_document.progress}).\n"
|
||||
db_document.progress_msg = progress_msg
|
||||
db_document.process_duration = time.time() - start_time
|
||||
db_document.run = 0
|
||||
db.commit()
|
||||
db.refresh(db_document)
|
||||
|
||||
# Vectorization and data entry completed
|
||||
progress_lines.append(f"{datetime.now().strftime('%H:%M:%S')} Indexing done.")
|
||||
progress_msg += f"{datetime.now().strftime('%H:%M:%S')} Indexing done.\n"
|
||||
db_document.chunk_num = total_chunks
|
||||
db_document.progress = 1.0
|
||||
db_document.process_duration = time.time() - start_time
|
||||
progress_lines.append(f"{datetime.now().strftime('%H:%M:%S')} Task done ({db_document.process_duration}s).")
|
||||
db_document.progress_msg = _progress_msg()
|
||||
progress_msg += f"{datetime.now().strftime('%H:%M:%S')} Task done ({db_document.process_duration}s).\n"
|
||||
db_document.progress_msg = progress_msg
|
||||
db_document.run = 0
|
||||
db.commit()
|
||||
|
||||
# GraphRAG: 异步派发到独立队列,不阻塞文档解析流程
|
||||
# using graphrag
|
||||
if db_knowledge.parser_config and db_knowledge.parser_config.get("graphrag", {}).get("use_graphrag", False):
|
||||
progress_lines.append(f"{datetime.now().strftime('%H:%M:%S')} GraphRAG enabled, dispatching async task.")
|
||||
db_document.progress_msg = _progress_msg()
|
||||
graphrag_conf = db_knowledge.parser_config.get("graphrag", {})
|
||||
with_resolution = graphrag_conf.get("resolution", False)
|
||||
with_community = graphrag_conf.get("community", False)
|
||||
|
||||
def callback(*args, msg=None, **kwargs):
|
||||
nonlocal progress_msg
|
||||
message = msg or (args[0] if args else "No message")
|
||||
progress_msg += f"{datetime.now().strftime('%H:%M:%S')} run graphrag msg: {message}.\n"
|
||||
|
||||
progress_msg += f"{datetime.now().strftime('%H:%M:%S')} Start to run graphrag.\n"
|
||||
start_time = time.time()
|
||||
db_document.progress_msg = progress_msg
|
||||
db.commit()
|
||||
build_graphrag_for_document.delay(str(document_id), str(db_knowledge.id))
|
||||
db.refresh(db_document)
|
||||
|
||||
task = {
|
||||
"id": str(db_document.id),
|
||||
"workspace_id": str(db_knowledge.workspace_id),
|
||||
"kb_id": str(db_knowledge.id),
|
||||
"parser_config": db_knowledge.parser_config,
|
||||
}
|
||||
|
||||
# init_graphrag
|
||||
vts, _ = embedding_model.encode(["ok"])
|
||||
vector_size = len(vts[0])
|
||||
init_graphrag(task, vector_size)
|
||||
|
||||
async def _run(
|
||||
row: dict,
|
||||
document_ids: list[str],
|
||||
language: str,
|
||||
parser_config: dict,
|
||||
vector_service,
|
||||
chat_model,
|
||||
embedding_model,
|
||||
callback,
|
||||
with_resolution: bool = True,
|
||||
with_community: bool = True
|
||||
) -> dict:
|
||||
await trio.sleep(5) # Delay for 10 seconds
|
||||
nonlocal progress_msg # Declare the use of an external progress_msg variable
|
||||
result = await run_graphrag_for_kb(
|
||||
row=row,
|
||||
document_ids=document_ids,
|
||||
language=language,
|
||||
parser_config=parser_config,
|
||||
vector_service=vector_service,
|
||||
chat_model=chat_model,
|
||||
embedding_model=embedding_model,
|
||||
callback=callback,
|
||||
with_resolution=with_resolution,
|
||||
with_community=with_community,
|
||||
)
|
||||
progress_msg += f"{datetime.now().strftime('%H:%M:%S')} GraphRAG task result for task {task}:\n{result}\n"
|
||||
return result
|
||||
|
||||
def sync_task():
|
||||
trio.run(
|
||||
lambda: _run(
|
||||
row=task,
|
||||
document_ids=[str(db_document.id)],
|
||||
language="Chinese",
|
||||
parser_config=db_knowledge.parser_config,
|
||||
vector_service=vector_service,
|
||||
chat_model=chat_model,
|
||||
embedding_model=embedding_model,
|
||||
callback=callback,
|
||||
with_resolution=with_resolution,
|
||||
with_community=with_community,
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=1) as executor:
|
||||
future = executor.submit(sync_task)
|
||||
future.result() # Blocks until the task completes
|
||||
except Exception as e:
|
||||
progress_msg += f"{datetime.now().strftime('%H:%M:%S')} GraphRAG task failed for task {task}:\n{str(e)}\n"
|
||||
progress_msg += f"{datetime.now().strftime('%H:%M:%S')} Knowledge Graph done ({time.time() - start_time}s)"
|
||||
db_document.progress_msg = progress_msg
|
||||
db.commit()
|
||||
db.refresh(db_document)
|
||||
|
||||
result = f"parse document '{db_document.file_name}' processed successfully."
|
||||
logger.info(f"[ParseDoc] document={document_id} file='{db_document.file_name}' done in {db_document.process_duration:.1f}s, chunks={total_chunks}")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"[ParseDoc] document={document_id} failed: {e}", exc_info=True)
|
||||
if db_document is not None:
|
||||
try:
|
||||
db.rollback()
|
||||
db_document.progress_msg = _progress_msg() + f"Failed to vectorize and import the parsed document:{str(e)}\n"
|
||||
db_document.run = 0
|
||||
db.commit()
|
||||
except Exception:
|
||||
logger.warning(f"[ParseDoc] document={document_id} failed to update error status in DB", exc_info=True)
|
||||
# db_document 可能处于 detached/expired 状态,用之前缓存的值或 document_id 兜底
|
||||
file_name = getattr(db_document, 'file_name', None) if db_document else None
|
||||
return f"parse document '{file_name or document_id}' failed."
|
||||
except Exception as e:
|
||||
if 'db_document' in locals():
|
||||
db_document.progress_msg += f"Failed to vectorize and import the parsed document:{str(e)}\n"
|
||||
db_document.run = 0
|
||||
db.commit()
|
||||
result = f"parse document '{db_document.file_name}' failed."
|
||||
return result
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@celery_app.task(name="app.core.rag.tasks.build_graphrag_for_kb")
|
||||
@@ -442,44 +411,51 @@ def build_graphrag_for_kb(kb_id: uuid.UUID):
|
||||
"""
|
||||
build knowledge graph
|
||||
"""
|
||||
# Force re-importing Trio in child processes (to avoid inheriting the state of the parent process)
|
||||
import importlib
|
||||
|
||||
import trio
|
||||
importlib.reload(trio)
|
||||
db = next(get_db()) # Manually call the generator
|
||||
db_documents = None
|
||||
db_knowledge = None
|
||||
try:
|
||||
db_documents = db.query(Document).filter(Document.kb_id == kb_id).all()
|
||||
db_knowledge = db.query(Knowledge).filter(Knowledge.id == kb_id).first()
|
||||
# 1. Prepare to configure chat_mdl、embedding_model、vision_model information
|
||||
chat_model = Base(
|
||||
key=db_knowledge.llm.api_keys[0].api_key,
|
||||
model_name=db_knowledge.llm.api_keys[0].model_name,
|
||||
base_url=db_knowledge.llm.api_keys[0].api_base
|
||||
)
|
||||
embedding_model = OpenAIEmbed(
|
||||
key=db_knowledge.embedding.api_keys[0].api_key,
|
||||
model_name=db_knowledge.embedding.api_keys[0].model_name,
|
||||
base_url=db_knowledge.embedding.api_keys[0].api_base
|
||||
)
|
||||
vision_model = QWenCV(
|
||||
key=db_knowledge.image2text.api_keys[0].api_key,
|
||||
model_name=db_knowledge.image2text.api_keys[0].model_name,
|
||||
lang="Chinese",
|
||||
base_url=db_knowledge.image2text.api_keys[0].api_base
|
||||
)
|
||||
|
||||
with get_db_context() as db:
|
||||
try:
|
||||
if not isinstance(kb_id, uuid.UUID):
|
||||
kb_id = uuid.UUID(str(kb_id))
|
||||
|
||||
db_knowledge = db.query(Knowledge).filter(Knowledge.id == kb_id).first()
|
||||
if db_knowledge is None:
|
||||
logger.error(f"[GraphRAG-KB] knowledge={kb_id} not found")
|
||||
return "build knowledge graph failed: knowledge not found"
|
||||
|
||||
if not (db_knowledge.parser_config and
|
||||
db_knowledge.parser_config.get("graphrag", {}).get("use_graphrag", False)):
|
||||
return f"build knowledge graph '{db_knowledge.name}' skipped: graphrag not enabled"
|
||||
|
||||
db_documents = db.query(Document).filter(Document.kb_id == kb_id).all()
|
||||
document_ids = [str(doc.id) for doc in db_documents]
|
||||
|
||||
chat_model = Base(
|
||||
key=db_knowledge.llm.api_keys[0].api_key,
|
||||
model_name=db_knowledge.llm.api_keys[0].model_name,
|
||||
base_url=db_knowledge.llm.api_keys[0].api_base,
|
||||
)
|
||||
embedding_model = OpenAIEmbed(
|
||||
key=db_knowledge.embedding.api_keys[0].api_key,
|
||||
model_name=db_knowledge.embedding.api_keys[0].model_name,
|
||||
base_url=db_knowledge.embedding.api_keys[0].api_base,
|
||||
)
|
||||
vector_service = ElasticSearchVectorFactory().init_vector(knowledge=db_knowledge)
|
||||
# 2. get all document_ids from knowledge base
|
||||
vector_service = ElasticSearchVectorFactory().init_vector(knowledge=db_knowledge)
|
||||
total, items = vector_service.search_by_segment(document_id=None, query=None, pagesize=9999, page=1, asc=True)
|
||||
document_ids = [str(item.id) for item in db_documents]
|
||||
|
||||
# 2. using graphrag
|
||||
if db_knowledge.parser_config and db_knowledge.parser_config.get("graphrag", {}).get("use_graphrag", False):
|
||||
graphrag_conf = db_knowledge.parser_config.get("graphrag", {})
|
||||
with_resolution = graphrag_conf.get("resolution", False)
|
||||
with_community = graphrag_conf.get("community", False)
|
||||
|
||||
def callback(*args, msg=None, **kwargs):
|
||||
message = msg or (args[0] if args else "No message")
|
||||
print(f"{datetime.now().strftime('%H:%M:%S')} run graphrag msg: {message}.\n")
|
||||
|
||||
start_time = time.time()
|
||||
task = {
|
||||
"id": str(db_knowledge.id),
|
||||
"workspace_id": str(db_knowledge.workspace_id),
|
||||
@@ -492,18 +468,14 @@ def build_graphrag_for_kb(kb_id: uuid.UUID):
|
||||
vector_size = len(vts[0])
|
||||
init_graphrag(task, vector_size)
|
||||
|
||||
def callback(*args, msg=None, **kwargs):
|
||||
message = msg or (args[0] if args else "No message")
|
||||
logger.info(f"[GraphRAG-KB] kb={kb_id} msg: {message}")
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
async def _run() -> dict:
|
||||
return await run_graphrag_for_kb(
|
||||
row=task,
|
||||
async def _run(row: dict, document_ids: list[str], language: str, parser_config: dict, vector_service,
|
||||
chat_model, embedding_model, callback, with_resolution: bool = True,
|
||||
with_community: bool = True, ) -> dict:
|
||||
result = await run_graphrag_for_kb(
|
||||
row=row,
|
||||
document_ids=document_ids,
|
||||
language=DEFAULT_PARSE_LANGUAGE,
|
||||
parser_config=db_knowledge.parser_config,
|
||||
language=language,
|
||||
parser_config=parser_config,
|
||||
vector_service=vector_service,
|
||||
chat_model=chat_model,
|
||||
embedding_model=embedding_model,
|
||||
@@ -511,97 +483,46 @@ def build_graphrag_for_kb(kb_id: uuid.UUID):
|
||||
with_resolution=with_resolution,
|
||||
with_community=with_community,
|
||||
)
|
||||
print(f"{datetime.now().strftime('%H:%M:%S')} GraphRAG task result for task {task}:\n{result}\n")
|
||||
return result
|
||||
|
||||
result = trio.run(_run)
|
||||
duration = time.time() - start_time
|
||||
logger.info(f"[GraphRAG-KB] kb={kb_id} done in {duration:.1f}s, result: {result}")
|
||||
|
||||
return f"build knowledge graph '{db_knowledge.name}' processed successfully."
|
||||
except Exception as e:
|
||||
logger.error(f"[GraphRAG-KB] kb={kb_id} failed: {e}", exc_info=True)
|
||||
return f"build knowledge graph failed: {e}"
|
||||
|
||||
|
||||
@celery_app.task(name="app.core.rag.tasks.build_graphrag_for_document")
|
||||
def build_graphrag_for_document(document_id: str, knowledge_id: str):
|
||||
"""
|
||||
为单个文档构建 GraphRAG,由 parse_document 异步派发。
|
||||
"""
|
||||
import importlib
|
||||
|
||||
import trio
|
||||
importlib.reload(trio)
|
||||
|
||||
with get_db_context() as db:
|
||||
try:
|
||||
db_document = db.query(Document).filter(Document.id == uuid.UUID(document_id)).first()
|
||||
db_knowledge = db.query(Knowledge).filter(Knowledge.id == uuid.UUID(knowledge_id)).first()
|
||||
if db_document is None or db_knowledge is None:
|
||||
logger.error(f"[GraphRAG] document={document_id} or knowledge={knowledge_id} not found")
|
||||
return "build_graphrag_for_document failed: record not found"
|
||||
|
||||
graphrag_conf = db_knowledge.parser_config.get("graphrag", {})
|
||||
with_resolution = graphrag_conf.get("resolution", False)
|
||||
with_community = graphrag_conf.get("community", False)
|
||||
|
||||
chat_model = Base(
|
||||
key=db_knowledge.llm.api_keys[0].api_key,
|
||||
model_name=db_knowledge.llm.api_keys[0].model_name,
|
||||
base_url=db_knowledge.llm.api_keys[0].api_base,
|
||||
)
|
||||
embedding_model = OpenAIEmbed(
|
||||
key=db_knowledge.embedding.api_keys[0].api_key,
|
||||
model_name=db_knowledge.embedding.api_keys[0].model_name,
|
||||
base_url=db_knowledge.embedding.api_keys[0].api_base,
|
||||
)
|
||||
vector_service = ElasticSearchVectorFactory().init_vector(knowledge=db_knowledge)
|
||||
|
||||
task = {
|
||||
"id": document_id,
|
||||
"workspace_id": str(db_knowledge.workspace_id),
|
||||
"kb_id": str(db_knowledge.id),
|
||||
"parser_config": db_knowledge.parser_config,
|
||||
}
|
||||
|
||||
# init_graphrag
|
||||
vts, _ = embedding_model.encode(["ok"])
|
||||
vector_size = len(vts[0])
|
||||
init_graphrag(task, vector_size)
|
||||
|
||||
def callback(*args, msg=None, **kwargs):
|
||||
message = msg or (args[0] if args else "No message")
|
||||
logger.info(f"[GraphRAG] doc={document_id} msg: {message}")
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
async def _run() -> dict:
|
||||
await trio.sleep(5)
|
||||
return await run_graphrag_for_kb(
|
||||
row=task,
|
||||
document_ids=[document_id],
|
||||
language=DEFAULT_PARSE_LANGUAGE,
|
||||
parser_config=db_knowledge.parser_config,
|
||||
vector_service=vector_service,
|
||||
chat_model=chat_model,
|
||||
embedding_model=embedding_model,
|
||||
callback=callback,
|
||||
with_resolution=with_resolution,
|
||||
with_community=with_community,
|
||||
def sync_task():
|
||||
trio.run(
|
||||
lambda: _run(
|
||||
row=task,
|
||||
document_ids=document_ids,
|
||||
language="Chinese",
|
||||
parser_config=db_knowledge.parser_config,
|
||||
vector_service=vector_service,
|
||||
chat_model=chat_model,
|
||||
embedding_model=embedding_model,
|
||||
callback=callback,
|
||||
with_resolution=with_resolution,
|
||||
with_community=with_community,
|
||||
)
|
||||
)
|
||||
|
||||
result = trio.run(_run)
|
||||
duration = time.time() - start_time
|
||||
logger.info(f"[GraphRAG] doc={document_id} done in {duration:.1f}s")
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=1) as executor:
|
||||
future = executor.submit(sync_task)
|
||||
future.result() # Blocks until the task completes
|
||||
except Exception as e:
|
||||
print(f"{datetime.now().strftime('%H:%M:%S')} GraphRAG task failed for task {task}:\n{str(e)}\n")
|
||||
finally:
|
||||
if db:
|
||||
db.close()
|
||||
print(f"{datetime.now().strftime('%H:%M:%S')} Knowledge Graph done ({time.time() - start_time}s)")
|
||||
|
||||
# 更新文档进度信息
|
||||
db_document.progress_msg = (db_document.progress_msg or "") + \
|
||||
f"{datetime.now().strftime('%H:%M:%S')} Knowledge Graph done ({duration:.1f}s)\n"
|
||||
db.commit()
|
||||
|
||||
return f"build_graphrag_for_document '{document_id}' processed successfully."
|
||||
except Exception as e:
|
||||
logger.error(f"[GraphRAG] doc={document_id} failed: {e}", exc_info=True)
|
||||
return f"build_graphrag_for_document '{document_id}' failed: {e}"
|
||||
result = f"build knowledge graph '{db_knowledge.name}' processed successfully."
|
||||
return result
|
||||
except Exception as e:
|
||||
if 'db_knowledge' in locals():
|
||||
print(f"Failed to build knowledge grap:{str(e)}\n")
|
||||
result = f"build knowledge grap '{db_knowledge.name}' failed."
|
||||
return result
|
||||
finally:
|
||||
if db:
|
||||
db.close()
|
||||
|
||||
|
||||
@celery_app.task(name="app.core.rag.tasks.sync_knowledge_for_kb")
|
||||
@@ -609,16 +530,10 @@ def sync_knowledge_for_kb(kb_id: uuid.UUID):
|
||||
"""
|
||||
sync knowledge document and Document parsing, vectorization, and storage
|
||||
"""
|
||||
with get_db_context() as db:
|
||||
try:
|
||||
if not isinstance(kb_id, uuid.UUID):
|
||||
kb_id = uuid.UUID(str(kb_id))
|
||||
|
||||
db = next(get_db()) # Manually call the generator
|
||||
db_knowledge = None
|
||||
try:
|
||||
db_knowledge = db.query(Knowledge).filter(Knowledge.id == kb_id).first()
|
||||
if db_knowledge is None:
|
||||
logger.error(f"[SyncKB] knowledge={kb_id} not found")
|
||||
return "sync knowledge failed: knowledge not found"
|
||||
|
||||
# 1. get vector_service
|
||||
vector_service = ElasticSearchVectorFactory().init_vector(knowledge=db_knowledge)
|
||||
|
||||
@@ -753,7 +668,7 @@ def sync_knowledge_for_kb(kb_id: uuid.UUID):
|
||||
db.commit()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[SyncKB] Error during crawl: {e}", exc_info=True)
|
||||
print(f"\n\nError during crawl: {e}")
|
||||
case "Third-party": # Integration of knowledge bases from three parties
|
||||
yuque_user_id = db_knowledge.parser_config.get("yuque_user_id", "")
|
||||
feishu_app_id = db_knowledge.parser_config.get("feishu_app_id", "")
|
||||
@@ -771,9 +686,13 @@ def sync_knowledge_for_kb(kb_id: uuid.UUID):
|
||||
# Get all files from all repos
|
||||
async def async_get_files(api_client: YuqueAPIClient):
|
||||
async with api_client as client:
|
||||
print("\n=== Fetching repositories ===")
|
||||
repos = await client.get_user_repos()
|
||||
print(f"Found {len(repos)} repositories:")
|
||||
all_files = []
|
||||
for repo in repos:
|
||||
# Get documents from repository
|
||||
print(f"\n=== Fetching documents from '{repo.name}' ===")
|
||||
docs = await client.get_repo_docs(repo.id)
|
||||
all_files.extend(docs)
|
||||
return all_files
|
||||
@@ -919,7 +838,7 @@ def sync_knowledge_for_kb(kb_id: uuid.UUID):
|
||||
db.commit()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[SyncKB] Error during fetch yuque: {e}", exc_info=True)
|
||||
print(f"\n\nError during fetch feishu: {e}")
|
||||
if feishu_app_id: # Feishu Knowledge Base
|
||||
feishu_app_secret = db_knowledge.parser_config.get("feishu_app_secret", "")
|
||||
feishu_folder_token = db_knowledge.parser_config.get("feishu_folder_token", "")
|
||||
@@ -1081,16 +1000,19 @@ def sync_knowledge_for_kb(kb_id: uuid.UUID):
|
||||
db.commit()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[SyncKB] Error during fetch feishu: {e}", exc_info=True)
|
||||
print(f"\n\nError during fetch feishu: {e}")
|
||||
case _: # General
|
||||
logger.info(f"[SyncKB] kb={kb_id} type={db_knowledge.type}: no synchronization needed")
|
||||
print("General: No synchronization needed\n")
|
||||
|
||||
result = f"sync knowledge '{db_knowledge.name}' processed successfully."
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"[SyncKB] kb={kb_id} failed: {e}", exc_info=True)
|
||||
kb_name = db_knowledge.name if db_knowledge else kb_id
|
||||
return f"sync knowledge '{kb_name}' failed: {e}"
|
||||
except Exception as e:
|
||||
if 'db_knowledge' in locals():
|
||||
print(f"Failed to sync knowledge:{str(e)}\n")
|
||||
result = f"sync knowledge '{db_knowledge.name}' failed."
|
||||
return result
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@celery_app.task(name="app.core.memory.agent.read_message", bind=True)
|
||||
@@ -3102,11 +3024,29 @@ def extract_user_metadata_task(
|
||||
logger.info(f"[CELERY METADATA] No metadata extracted for end_user_id={end_user_id}")
|
||||
return {"status": "SUCCESS", "result": "no_metadata_extracted"}
|
||||
|
||||
metadata_changes, aliases_to_add, aliases_to_remove = extract_result
|
||||
logger.info(
|
||||
f"[CELERY METADATA] LLM 元数据变更: {[c.model_dump() for c in metadata_changes]}, "
|
||||
f"别名新增: {aliases_to_add}, 移除: {aliases_to_remove}"
|
||||
)
|
||||
user_metadata, aliases_to_add, aliases_to_remove = extract_result
|
||||
logger.info(f"[CELERY METADATA] LLM 别名新增: {aliases_to_add}, 移除: {aliases_to_remove}")
|
||||
|
||||
# 4. 清洗元数据、覆盖写入元数据和别名
|
||||
def clean_metadata(raw: dict) -> dict:
|
||||
"""递归移除空字符串、空列表、空字典。"""
|
||||
result = {}
|
||||
for k, v in raw.items():
|
||||
if v == "" or v == []:
|
||||
continue
|
||||
if isinstance(v, dict):
|
||||
cleaned = clean_metadata(v)
|
||||
if cleaned:
|
||||
result[k] = cleaned
|
||||
else:
|
||||
result[k] = v
|
||||
return result
|
||||
|
||||
raw_dict = user_metadata.model_dump(exclude_none=True) if user_metadata else {}
|
||||
logger.info(f"[CELERY METADATA] LLM 输出完整元数据: {json.dumps(raw_dict, ensure_ascii=False)}")
|
||||
|
||||
cleaned = clean_metadata(raw_dict) if raw_dict else {}
|
||||
logger.info(f"[CELERY METADATA] 清洗后元数据: {json.dumps(cleaned, ensure_ascii=False)}")
|
||||
|
||||
from datetime import datetime as dt, timezone as tz
|
||||
now = dt.now(tz.utc).isoformat()
|
||||
@@ -3134,48 +3074,15 @@ def extract_user_metadata_task(
|
||||
end_user = EndUserRepository(db).get_by_id(end_user_uuid)
|
||||
|
||||
if info:
|
||||
# 4. 元数据增量更新(按 LLM 输出的变更操作逐条执行,所有字段均为列表类型)
|
||||
if metadata_changes:
|
||||
# 深拷贝,确保 SQLAlchemy 能检测到变更
|
||||
import copy
|
||||
existing_meta = copy.deepcopy(info.meta_data) if info.meta_data else {}
|
||||
# 元数据覆盖写入
|
||||
if cleaned:
|
||||
existing_meta = info.meta_data if info.meta_data else {}
|
||||
updated_at = dict(existing_meta.get("_updated_at", {}))
|
||||
|
||||
for change in metadata_changes:
|
||||
field_path = change.field_path
|
||||
action = change.action
|
||||
value = change.value
|
||||
|
||||
if not value or not value.strip():
|
||||
continue
|
||||
|
||||
# 定位到目标字段的父级节点
|
||||
parts = field_path.split(".")
|
||||
target = existing_meta
|
||||
for part in parts[:-1]:
|
||||
target = target.setdefault(part, {})
|
||||
leaf = parts[-1]
|
||||
|
||||
current_list = target.get(leaf, [])
|
||||
|
||||
if action == "set":
|
||||
if value not in current_list:
|
||||
current_list.append(value)
|
||||
target[leaf] = current_list
|
||||
logger.info(f"[CELERY METADATA] set {field_path} = {value}")
|
||||
|
||||
elif action == "remove":
|
||||
if value in current_list:
|
||||
current_list.remove(value)
|
||||
target[leaf] = current_list
|
||||
logger.info(f"[CELERY METADATA] remove {value} from {field_path}")
|
||||
|
||||
updated_at[field_path] = now
|
||||
|
||||
existing_meta["_updated_at"] = updated_at
|
||||
# 赋值深拷贝后的新对象,SQLAlchemy 会检测到字段变更并写入
|
||||
info.meta_data = existing_meta
|
||||
logger.info(f"[CELERY METADATA] 增量更新元数据完成: {json.dumps(existing_meta, ensure_ascii=False)}")
|
||||
_update_timestamps(existing_meta, cleaned, updated_at, now)
|
||||
final = dict(cleaned)
|
||||
final["_updated_at"] = updated_at
|
||||
info.meta_data = final
|
||||
logger.info("[CELERY METADATA] 覆盖写入元数据")
|
||||
|
||||
# 别名增量增删:(已有 - remove) + add
|
||||
old_aliases = info.aliases if info.aliases else []
|
||||
@@ -3211,27 +3118,12 @@ def extract_user_metadata_task(
|
||||
from app.models.end_user_info_model import EndUserInfo
|
||||
initial_aliases = filtered_add # 新记录只有 add,没有 remove
|
||||
first_alias = initial_aliases[0] if initial_aliases else ""
|
||||
|
||||
# 从变更操作构建初始元数据(所有字段均为列表类型)
|
||||
initial_meta = {}
|
||||
for change in metadata_changes:
|
||||
if change.action == "set" and change.value is not None and change.value.strip():
|
||||
parts = change.field_path.split(".")
|
||||
target = initial_meta
|
||||
for part in parts[:-1]:
|
||||
target = target.setdefault(part, {})
|
||||
leaf = parts[-1]
|
||||
current_list = target.get(leaf, [])
|
||||
if change.value not in current_list:
|
||||
current_list.append(change.value)
|
||||
target[leaf] = current_list
|
||||
|
||||
if first_alias or initial_meta:
|
||||
if first_alias or cleaned:
|
||||
new_info = EndUserInfo(
|
||||
end_user_id=end_user_uuid,
|
||||
other_name=first_alias or "",
|
||||
aliases=initial_aliases,
|
||||
meta_data=initial_meta if initial_meta else None,
|
||||
meta_data=cleaned if cleaned else None,
|
||||
)
|
||||
db.add(new_info)
|
||||
if end_user and first_alias and (
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
ConfigProvider,
|
||||
App as AntdApp
|
||||
} from 'antd';
|
||||
import i18n from 'i18next';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { lightTheme } from './styles/antdThemeConfig.ts'
|
||||
import router from './routes';
|
||||
@@ -29,58 +29,11 @@ import 'dayjs/plugin/utc'
|
||||
import { cookieUtils } from './utils/request';
|
||||
import { useUser } from '@/store/user';
|
||||
|
||||
import menuJson from '@/store/menu.json';
|
||||
|
||||
type MenuEntry = { path: string; i18nKey: string };
|
||||
|
||||
function flattenMenuEntries(list: any[]): MenuEntry[] {
|
||||
const result: MenuEntry[] = [];
|
||||
for (const item of list) {
|
||||
if (item.path && item.i18nKey && item.type !== 'group') result.push({ path: item.path, i18nKey: item.i18nKey });
|
||||
if (item.subs?.length) result.push(...flattenMenuEntries(item.subs));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const menuEntries: MenuEntry[] = flattenMenuEntries([...menuJson.manage, ...menuJson.space]);
|
||||
|
||||
function pathMatches(pattern: string, path: string): boolean {
|
||||
if (pattern === path) return true;
|
||||
if (pattern.includes(':')) {
|
||||
return new RegExp('^' + pattern.replace(/:[\w-]+/g, '[^/]+') + '$').test(path);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function getPageTitle(pathname: string): string {
|
||||
const appName = i18n.t('memoryBear');
|
||||
const entry = menuEntries.find(e => pathMatches(e.path, pathname));
|
||||
if (!entry) return appName;
|
||||
return `${i18n.t(entry.i18nKey)} - ${appName}`;
|
||||
}
|
||||
|
||||
const SKIP_TITLE_PATTERNS = [
|
||||
'/user-memory/detail/:id/:type',
|
||||
'/forgetting-engine/:id',
|
||||
'/memory-extraction-engine/:id',
|
||||
'/emotion-engine/:id',
|
||||
'/reflection-engine/:id',
|
||||
];
|
||||
|
||||
|
||||
|
||||
|
||||
function App() {
|
||||
const { t } = useTranslation();
|
||||
const { locale, language, timeZone } = useI18n()
|
||||
const { checkJump } = useUser();
|
||||
useEffect(() => {
|
||||
const unsubscribe = router.subscribe(({ location }) => {
|
||||
if (SKIP_TITLE_PATTERNS.some(p => pathMatches(p, location.pathname))) return;
|
||||
document.title = getPageTitle(location.pathname);
|
||||
});
|
||||
return () => unsubscribe();
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const authToken = cookieUtils.get('authToken')
|
||||
if (!authToken && !window.location.hash.includes('#/login') && !window.location.hash.includes('#/conversation/') && !window.location.hash.includes('#/jump') && !window.location.hash.includes('#/invite-register')) {
|
||||
@@ -91,9 +44,7 @@ function App() {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!SKIP_TITLE_PATTERNS.some(p => pathMatches(p, router.state.location.pathname))) {
|
||||
document.title = getPageTitle(router.state.location.pathname)
|
||||
}
|
||||
document.title = t('memoryBear')
|
||||
dayjs.locale(language)
|
||||
localStorage.setItem('language', language)
|
||||
}, [language])
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
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}`)
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2025-12-10 16:46:17
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-04-14 10:13:56
|
||||
* @Last Modified time: 2026-04-10 18:46:57
|
||||
*/
|
||||
import { type FC, useRef, useEffect, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
@@ -174,7 +174,6 @@ const ChatContent: FC<ChatContentProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
const documentType = (file.file_type || file.type)?.split('/')
|
||||
return (
|
||||
<Flex
|
||||
key={file.url || file.uid}
|
||||
@@ -209,7 +208,7 @@ const ChatContent: FC<ChatContentProps> = ({
|
||||
></div>
|
||||
<div className="rb:flex-1 rb:w-32.5">
|
||||
<div className="rb:leading-4 rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap">{file.name}</div>
|
||||
<div className="rb:leading-3.5 rb:mt-0.5 rb:text-[#5B6167] rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap">{documentType?.[documentType.length - 1]} · {file.size}</div>
|
||||
<div className="rb:leading-3.5 rb:mt-0.5 rb:text-[#5B6167] rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap">{file.type?.split('/')[file.type?.split('/').length - 1]} · {file.size}</div>
|
||||
</div>
|
||||
</Flex>
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 15:07:49
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-04-16 10:31:21
|
||||
* @Last Modified time: 2026-04-16 11:10:19
|
||||
*/
|
||||
/**
|
||||
* AppHeader Component
|
||||
@@ -31,7 +31,7 @@ const { Header } = Layout;
|
||||
/**
|
||||
* @param source - Breadcrumb source type ('space' or 'manage'), defaults to 'manage'
|
||||
*/
|
||||
const AppHeader: FC<{ source?: 'space' | 'manage'; }> = ({ source = 'manage' }) => {
|
||||
const AppHeader: FC<{source?: 'space' | 'manage';}> = ({source = 'manage'}) => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const settingModalRef = useRef<SettingModalRef>(null)
|
||||
@@ -39,7 +39,7 @@ const AppHeader: FC<{ source?: 'space' | 'manage'; }> = ({ source = 'manage' })
|
||||
|
||||
const { user, logout } = useUser();
|
||||
const { allBreadcrumbs } = useMenu();
|
||||
|
||||
|
||||
/**
|
||||
* Dynamically select breadcrumb source based on current route
|
||||
* - Knowledge base list: uses 'space' breadcrumb
|
||||
@@ -48,24 +48,24 @@ const AppHeader: FC<{ source?: 'space' | 'manage'; }> = ({ source = 'manage' })
|
||||
*/
|
||||
const getBreadcrumbSource = () => {
|
||||
const pathname = location.pathname;
|
||||
|
||||
|
||||
// Knowledge base list page uses default space breadcrumb
|
||||
if (pathname === '/knowledge-base') {
|
||||
return 'space';
|
||||
}
|
||||
|
||||
|
||||
// Knowledge base detail pages use independent breadcrumb
|
||||
if (pathname.includes('/knowledge-base/') && pathname !== '/knowledge-base') {
|
||||
return 'space-detail';
|
||||
}
|
||||
|
||||
|
||||
// Other pages use the passed source
|
||||
return source;
|
||||
};
|
||||
|
||||
|
||||
const breadcrumbSource = getBreadcrumbSource();
|
||||
const breadcrumbs = allBreadcrumbs[breadcrumbSource] || [];
|
||||
|
||||
|
||||
|
||||
/** Handle user logout */
|
||||
const handleLogout = () => {
|
||||
@@ -129,7 +129,7 @@ const AppHeader: FC<{ source?: 'space' | 'manage'; }> = ({ source = 'manage' })
|
||||
onClick: handleLogout,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
/**
|
||||
* Format breadcrumb items with proper titles, paths, and click handlers
|
||||
* - Translates i18n keys to display text
|
||||
@@ -148,7 +148,7 @@ const AppHeader: FC<{ source?: 'space' | 'manage'; }> = ({ source = 'manage' })
|
||||
</Tooltip>
|
||||
),
|
||||
};
|
||||
|
||||
|
||||
if (!isLast) {
|
||||
if ((menu as any).onClick) {
|
||||
item.onClick = (e: React.MouseEvent) => {
|
||||
@@ -164,7 +164,7 @@ const AppHeader: FC<{ source?: 'space' | 'manage'; }> = ({ source = 'manage' })
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
setOpen(open);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 16:24:44
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-04-14 16:52:43
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 16:24:44
|
||||
*/
|
||||
/**
|
||||
* useBreadcrumbManager Hook
|
||||
@@ -18,10 +18,8 @@
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMenu } from '@/store/menu';
|
||||
import type { MenuItem } from '@/store/menu';
|
||||
import { useI18n } from '@/store/locale'
|
||||
|
||||
/** Breadcrumb item interface */
|
||||
export interface BreadcrumbItem {
|
||||
@@ -55,8 +53,6 @@ export interface BreadcrumbOptions {
|
||||
export const useBreadcrumbManager = (options?: BreadcrumbOptions) => {
|
||||
const { allBreadcrumbs, setCustomBreadcrumbs } = useMenu();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation()
|
||||
const { language } = useI18n()
|
||||
|
||||
/** Update breadcrumbs based on current path and type */
|
||||
const updateBreadcrumbs = useCallback((breadcrumbPath: BreadcrumbPath) => {
|
||||
@@ -340,10 +336,10 @@ export const useBreadcrumbManager = (options?: BreadcrumbOptions) => {
|
||||
/** Use different keys based on breadcrumb type to implement independent breadcrumb paths */
|
||||
const breadcrumbKey = breadcrumbType === 'list' ? 'space' : 'space-detail';
|
||||
|
||||
const lastMenu = customBreadcrumbs[customBreadcrumbs.length - 1]
|
||||
document.title = `${lastMenu.i18nKey ? t(lastMenu.i18nKey) : lastMenu.label} - ${t('memoryBear') }`;
|
||||
|
||||
|
||||
setCustomBreadcrumbs(customBreadcrumbs, breadcrumbKey);
|
||||
}, [setCustomBreadcrumbs, navigate, options?.breadcrumbType, options?.onKnowledgeBaseMenuClick, options?.onKnowledgeBaseFolderClick, language]);
|
||||
}, [setCustomBreadcrumbs, navigate, options?.breadcrumbType, options?.onKnowledgeBaseMenuClick, options?.onKnowledgeBaseFolderClick]);
|
||||
|
||||
return {
|
||||
updateBreadcrumbs,
|
||||
|
||||
@@ -1522,8 +1522,6 @@ export const en = {
|
||||
"version":"app_release_id"
|
||||
// string, optional, application version ID; specify a historical release version ID, or omit to use the currently active version;
|
||||
}`,
|
||||
uploadCover: 'Import and Overwrite',
|
||||
refresh: 'Refresh Current Page',
|
||||
},
|
||||
userMemory: {
|
||||
userMemory: 'User Memory',
|
||||
@@ -1591,11 +1589,6 @@ export const en = {
|
||||
created_at: 'Created At',
|
||||
updated_at: 'Last Updated',
|
||||
fullScreen: 'Full Screen',
|
||||
role: 'Role',
|
||||
domain: 'Domain',
|
||||
expertise: 'Expertise',
|
||||
interests: 'Interests',
|
||||
knowledge_tags: 'Knowledge Tags',
|
||||
|
||||
memoryWindow: "{{name}}'s Memory Overview",
|
||||
memory_insight: 'Overall Overview',
|
||||
@@ -3019,69 +3012,5 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
|
||||
apply: 'Apply',
|
||||
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',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -857,8 +857,6 @@ export const zh = {
|
||||
"version":"app_release_id"
|
||||
//string,可选,应用版本ID;指定历史发布版本ID,不传则使用当前生效版本;
|
||||
}`,
|
||||
uploadCover: '导入并覆盖',
|
||||
refresh: '刷新当前页',
|
||||
},
|
||||
table: {
|
||||
totalRecords: '共 {{total}} 条记录'
|
||||
@@ -1552,11 +1550,6 @@ export const zh = {
|
||||
created_at: '创建时间',
|
||||
updated_at: '最后更新时间',
|
||||
fullScreen: '全屏',
|
||||
role: '角色',
|
||||
domain: '领域',
|
||||
expertise: '专业擅长',
|
||||
interests: '兴趣爱好',
|
||||
knowledge_tags: '知识标签',
|
||||
|
||||
memoryWindow: "{{name}} 的记忆之窗",
|
||||
memory_insight: '总体概述',
|
||||
@@ -2983,69 +2976,5 @@ export const zh = {
|
||||
apply: '应用',
|
||||
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: '编辑套餐',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 16:33:11
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-04-13 16:53:15
|
||||
* @Last Modified time: 2026-02-04 18:11:34
|
||||
*/
|
||||
/**
|
||||
* Route Configuration
|
||||
@@ -76,12 +76,13 @@ const componentMap: Record<string, LazyExoticComponent<ComponentType<object>>> =
|
||||
SpaceManagement: lazy(() => import('@/views/SpaceManagement')),
|
||||
ApiKeyManagement: lazy(() => import('@/views/ApiKeyManagement')),
|
||||
EmotionEngine: lazy(() => import('@/views/EmotionEngine')),
|
||||
StatementDetail: lazy(() => import('@/views/UserMemoryDetail/pages/StatementDetail')),
|
||||
ForgetDetail: lazy(() => import('@/views/UserMemoryDetail/pages/ForgetDetail')),
|
||||
MemoryNodeDetail: lazy(() => import('@/views/UserMemoryDetail/pages/index')),
|
||||
SelfReflectionEngine: lazy(() => import('@/views/SelfReflectionEngine')),
|
||||
OrderPayment: lazy(() => import('@/views/OrderPayment')),
|
||||
OrderHistory: lazy(() => import('@/views/OrderHistory')),
|
||||
Package: lazy(() => import('@/views/Package')),
|
||||
Pricing: lazy(() => import('@/views/Pricing')),
|
||||
ToolManagement: lazy(() => import('@/views/ToolManagement')),
|
||||
SpaceConfig: lazy(() => import('@/views/SpaceConfig')),
|
||||
Ontology: lazy(() => import('@/views/Ontology')),
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
{ "path": "/model", "element": "ModelManagement" },
|
||||
{ "path": "/space", "element": "SpaceManagement" },
|
||||
{ "path": "/tool", "element": "ToolManagement" },
|
||||
{ "path": "/pricing", "element": "Package" },
|
||||
{ "path": "/pricing", "element": "Pricing" },
|
||||
{ "path": "/order-pay", "element": "OrderPayment" },
|
||||
{ "path": "/orders", "element": "OrderHistory" },
|
||||
{ "path": "/skills", "element": "Skills" },
|
||||
@@ -48,6 +48,7 @@
|
||||
{ "path": "/application/config/:id", "element": "ApplicationConfig" },
|
||||
{ "path": "/application/config/:id/:source", "element": "ApplicationConfig" },
|
||||
{ "path": "/user-memory/neo4j/:id", "element": "Neo4jUserMemoryDetail" },
|
||||
{ "path": "/statement/:id", "element": "StatementDetail" },
|
||||
{ "path": "/user-memory/detail/:id/:type", "element": "MemoryNodeDetail" },
|
||||
{ "path": "/ontology/:id", "element": "OntologyDetail" }
|
||||
]
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"code": "workbench",
|
||||
"label": "workbench",
|
||||
"i18nKey": "menu.workbench",
|
||||
"path": null,
|
||||
"path": "/",
|
||||
"enable": true,
|
||||
"display": true,
|
||||
"level": 1,
|
||||
@@ -174,7 +174,7 @@
|
||||
"code": "workbench",
|
||||
"label": "workbench",
|
||||
"i18nKey": "menu.workbench",
|
||||
"path": null,
|
||||
"path": "/",
|
||||
"enable": true,
|
||||
"display": true,
|
||||
"level": 1,
|
||||
@@ -425,14 +425,15 @@
|
||||
{
|
||||
"id": 2211,
|
||||
"parent": 221,
|
||||
"code": "userMemoryDetail",
|
||||
"code": "statementDetail",
|
||||
"label": "记忆详情",
|
||||
"i18nKey": "menu.userMemoryDetail",
|
||||
"path": "/user-memory/detail/:id/:type",
|
||||
"i18nKey": "menu.statementDetail",
|
||||
"path": "/statement/:id",
|
||||
"enable": true,
|
||||
"display": false,
|
||||
"level": 3,
|
||||
"sort": 0
|
||||
"level": 4,
|
||||
"sort": 0,
|
||||
"subs": null
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -43,8 +43,7 @@ export const maskApiKeys = (text: string): string => {
|
||||
result = result.replace(API_KEY_PATTERNS[key as keyof typeof API_KEY_PREFIX], (match) => {
|
||||
const prefixLength = API_KEY_PREFIX[key].length
|
||||
const prefix = match.substring(0, prefixLength)
|
||||
const suffix = match.slice(-4)
|
||||
return prefix + '*'.repeat(match.length - prefixLength - 4) + suffix
|
||||
return prefix + '*'.repeat(match.length - prefixLength)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 16:35:15
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-04-14 14:43:54
|
||||
* @Last Modified time: 2026-03-06 10:39:00
|
||||
*/
|
||||
/**
|
||||
* HTTP Request Utility Module
|
||||
@@ -23,7 +23,6 @@ import { clearAuthData } from './auth';
|
||||
import { message } from 'antd';
|
||||
import { refreshTokenUrl, refreshToken, loginUrl, logoutUrl } from '@/api/user'
|
||||
import i18n from '@/i18n'
|
||||
import { SYS_API_PREFIX } from '@/api/package'
|
||||
|
||||
/**
|
||||
* Standard API response structure
|
||||
@@ -75,10 +74,6 @@ let requests: RequestQueueItem[] = [];
|
||||
// Request interceptor
|
||||
service.interceptors.request.use(
|
||||
(config) => {
|
||||
console.log('config', config, config.url?.startsWith(SYS_API_PREFIX))
|
||||
if (config.url?.startsWith(SYS_API_PREFIX)) {
|
||||
config.baseURL = '';
|
||||
}
|
||||
if (!config.headers.Authorization) {
|
||||
const token = cookieUtils.get('authToken');
|
||||
if (token) {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:27:52
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-04-13 18:19:27
|
||||
* @Last Modified time: 2026-04-07 16:28:33
|
||||
*/
|
||||
import { type FC, useRef, useMemo } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
@@ -12,14 +12,13 @@ import { useTranslation } from 'react-i18next';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import styles from '../index.module.css'
|
||||
import type { Application, ApplicationModalRef, UploadWorkflowModalRef } from '@/views/ApplicationManagement/types';
|
||||
import type { Application, ApplicationModalRef } from '@/views/ApplicationManagement/types';
|
||||
import ApplicationModal from '@/views/ApplicationManagement/components/ApplicationModal'
|
||||
import type { CopyModalRef, AgentRef, ClusterRef, WorkflowRef, FeaturesConfigForm } from '../types'
|
||||
import { deleteApplication, appExport } from '@/api/application'
|
||||
import CopyModal from './CopyModal'
|
||||
import PageHeader from '@/components/Layout/PageHeader'
|
||||
import CheckList from '@/views/Workflow/components/CheckList'
|
||||
import UploadModal from '@/views/ApplicationManagement/components/UploadModal'
|
||||
|
||||
/**
|
||||
* Tab keys for application configuration
|
||||
@@ -78,7 +77,6 @@ const ConfigHeader: FC<ConfigHeaderProps> = ({
|
||||
const { id, source } = useParams();
|
||||
const applicationModalRef = useRef<ApplicationModalRef>(null);
|
||||
const copyModalRef = useRef<CopyModalRef>(null);
|
||||
const uploadModalRef = useRef<UploadWorkflowModalRef>(null);
|
||||
|
||||
/**
|
||||
* Format tab items for display
|
||||
@@ -113,9 +111,6 @@ const ConfigHeader: FC<ConfigHeaderProps> = ({
|
||||
case 'delete':
|
||||
handleDelete()
|
||||
break;
|
||||
case 'uploadCover':
|
||||
uploadModalRef.current?.handleOpen()
|
||||
break
|
||||
}
|
||||
}
|
||||
/**
|
||||
@@ -170,11 +165,11 @@ const ConfigHeader: FC<ConfigHeaderProps> = ({
|
||||
* Format dropdown menu items
|
||||
*/
|
||||
const formatMenuItems = useMemo(() => {
|
||||
const items = (application?.type !== 'multi_agent' ? ['edit', 'copy', 'export', 'uploadCover', 'delete'] : ['edit', 'copy', 'delete']).map(key => ({
|
||||
const items = (application?.type !== 'multi_agent' ? ['edit', 'copy', 'export', 'delete'] : ['edit', 'copy', 'delete']).map(key => ({
|
||||
key,
|
||||
icon: <div className={`rb:size-4 rb:mr-2 ${menuIcons[key]}`} />,
|
||||
danger: key === 'delete',
|
||||
label: key === 'uploadCover' ? t('application.uploadCover') : t(`common.${key}`),
|
||||
label: t(`common.${key}`),
|
||||
}))
|
||||
return items
|
||||
}, [t, handleClick, application])
|
||||
@@ -266,11 +261,6 @@ const ConfigHeader: FC<ConfigHeaderProps> = ({
|
||||
refresh={refresh}
|
||||
/>
|
||||
<CopyModal ref={copyModalRef} data={application as Application} />
|
||||
<UploadModal
|
||||
ref={uploadModalRef}
|
||||
refresh={refresh}
|
||||
id={id as string}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,12 +2,11 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:29:37
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-04-14 16:53:27
|
||||
* @Last Modified time: 2026-03-26 15:37:18
|
||||
*/
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Flex } from 'antd'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import ConfigHeader from './components/ConfigHeader'
|
||||
import type { AgentRef, ClusterRef, WorkflowRef, Config } from './types'
|
||||
@@ -22,7 +21,6 @@ import Statistics from './Statistics'
|
||||
import TestChat from './TestChat'
|
||||
import type { WorkflowConfig } from '@/views/Workflow/types';
|
||||
import Logs from './Logs';
|
||||
import { useI18n } from '@/store/locale'
|
||||
|
||||
/**
|
||||
* Application configuration page component
|
||||
@@ -32,8 +30,6 @@ import { useI18n } from '@/store/locale'
|
||||
const ApplicationConfig: React.FC = () => {
|
||||
// Hooks
|
||||
const { id, source } = useParams();
|
||||
const { t } = useTranslation()
|
||||
const { language } = useI18n()
|
||||
|
||||
// Refs for different application types
|
||||
const agentRef = useRef<AgentRef>(null)
|
||||
@@ -99,13 +95,6 @@ const ApplicationConfig: React.FC = () => {
|
||||
getApplicationInfo()
|
||||
}, [id])
|
||||
|
||||
useEffect(() => {
|
||||
if (application?.name) {
|
||||
const appName = t('memoryBear');
|
||||
document.title = `${application.name} - ${appName}`;
|
||||
}
|
||||
}, [application?.name, language])
|
||||
|
||||
/**
|
||||
* Fetch application information
|
||||
*/
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-28 14:08:14
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-04-13 18:17:32
|
||||
* @Last Modified time: 2026-03-12 17:19:46
|
||||
*/
|
||||
/**
|
||||
* UploadModal Component
|
||||
@@ -28,7 +28,6 @@ import { appImport } from '@/api/application'
|
||||
interface UploadModalProps {
|
||||
/** Function to refresh the parent component after workflow import */
|
||||
refresh: () => void;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -47,11 +46,10 @@ const steps = [
|
||||
* @param {React.Ref<UploadModalRef>} ref - Ref for imperative methods
|
||||
*/
|
||||
const UploadModal = forwardRef<UploadModalRef, UploadModalProps>(({
|
||||
refresh,
|
||||
id
|
||||
refresh
|
||||
}, ref) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
||||
// State management
|
||||
const [visible, setVisible] = useState(false); // Modal visibility
|
||||
const [form] = Form.useForm<{ file: File[] }>(); // Form instance
|
||||
@@ -89,8 +87,8 @@ const UploadModal = forwardRef<UploadModalRef, UploadModalProps>(({
|
||||
*/
|
||||
const handleSave = () => {
|
||||
const values = form.getFieldsValue();
|
||||
|
||||
switch (current) {
|
||||
|
||||
switch(current) {
|
||||
case 0: // Step 1: Upload file
|
||||
if (!values.file || values.file.length === 0) {
|
||||
message.warning(t('application.pleaseUploadFile'));
|
||||
@@ -98,9 +96,6 @@ const UploadModal = forwardRef<UploadModalRef, UploadModalProps>(({
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append('file', values.file[0]);
|
||||
if (id) {
|
||||
formData.append('app_id', id)
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
// Call import API
|
||||
@@ -139,12 +134,8 @@ const UploadModal = forwardRef<UploadModalRef, UploadModalProps>(({
|
||||
setTimeout(() => {
|
||||
switch (type) {
|
||||
case 'detail':
|
||||
if (id) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
// Open application detail page in new tab
|
||||
window.open(`/#/application/config/${appId}`, '_blank');
|
||||
}
|
||||
// Open application detail page in new tab
|
||||
window.open(`/#/application/config/${appId}`, '_blank');
|
||||
break;
|
||||
}
|
||||
}, 100)
|
||||
@@ -180,7 +171,7 @@ const UploadModal = forwardRef<UploadModalRef, UploadModalProps>(({
|
||||
loading={loading}
|
||||
onClick={() => handleJump('detail')}
|
||||
>
|
||||
{id ? t('application.refresh') : t('application.gotoDetail')}
|
||||
{t('application.gotoDetail')}
|
||||
</Button>
|
||||
]
|
||||
default:
|
||||
@@ -253,7 +244,7 @@ const UploadModal = forwardRef<UploadModalRef, UploadModalProps>(({
|
||||
loading={loading}
|
||||
onClick={() => handleJump('detail')}
|
||||
>
|
||||
{id ? t('application.refresh') : t('application.gotoDetail')}
|
||||
{t('application.gotoDetail')}
|
||||
</Button>
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:58:03
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-04-13 18:32:58
|
||||
* @Last Modified time: 2026-04-07 21:21:52
|
||||
*/
|
||||
/**
|
||||
* Conversation Page
|
||||
@@ -397,10 +397,7 @@ const Conversation: FC = () => {
|
||||
return {
|
||||
type: file.type,
|
||||
transfer_method: 'local_file',
|
||||
upload_file_id: file.response.data.file_id,
|
||||
file_type: file.response.data.file_type,
|
||||
size: file.response.data.file_size,
|
||||
name: file.response.data.file_name
|
||||
upload_file_id: file.response.data.file_id
|
||||
}
|
||||
}
|
||||
}),
|
||||
@@ -447,7 +444,7 @@ const Conversation: FC = () => {
|
||||
})
|
||||
}
|
||||
|
||||
console.log('chatList', fileList, streamLoadingRef.current)
|
||||
console.log('chatList', chatList, streamLoadingRef.current)
|
||||
|
||||
return (
|
||||
<Flex className="rb:w-full rb:p-[-16px]!">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:56:54
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-04-14 16:59:16
|
||||
* @Last Modified time: 2026-03-26 15:43:29
|
||||
*/
|
||||
/**
|
||||
* Emotion Engine Configuration Page
|
||||
@@ -25,7 +25,6 @@ import DescWrapper from '@/components/FormItem/DescWrapper'
|
||||
import RbSlider from '@/components/RbSlider';
|
||||
import RbAlert from '@/components/RbAlert';
|
||||
import ModelSelect from '@/components/ModelSelect';
|
||||
import { useI18n } from '@/store/locale'
|
||||
|
||||
/**
|
||||
* Configuration field definitions
|
||||
@@ -70,14 +69,9 @@ const EmotionEngine: React.FC = () => {
|
||||
const [form] = Form.useForm<ConfigForm>();
|
||||
const { message: messageApi } = App.useApp();
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { language } = useI18n()
|
||||
|
||||
const values = Form.useWatch([], form);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = [document.title.split(' - ')[0], t('memoryBear')].join(' - ')
|
||||
}, [language])
|
||||
|
||||
useEffect(() => {
|
||||
getConfigData()
|
||||
}, [id])
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 17:00:12
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-04-14 16:54:38
|
||||
* @Last Modified time: 2026-03-26 15:47:37
|
||||
*/
|
||||
/**
|
||||
* Forgetting Engine Configuration Page
|
||||
@@ -22,7 +22,6 @@ import type { ConfigForm } from './types'
|
||||
import SwitchFormItem from '@/components/FormItem/SwitchFormItem'
|
||||
import RbSlider from '@/components/RbSlider';
|
||||
import DescWrapper from '@/components/FormItem/DescWrapper'
|
||||
import { useI18n } from '@/store/locale'
|
||||
|
||||
/**
|
||||
* Configuration field definitions
|
||||
@@ -110,14 +109,9 @@ const ForgettingEngine: React.FC = () => {
|
||||
const [form] = Form.useForm<ConfigForm>();
|
||||
const { message: messageApi } = App.useApp();
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { language } = useI18n()
|
||||
|
||||
const values = Form.useWatch([], form);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = [document.title.split(' - ')[0], t('memoryBear')].join(' - ')
|
||||
}, [language])
|
||||
|
||||
useEffect(() => {
|
||||
getConfigData()
|
||||
}, [])
|
||||
@@ -188,7 +182,6 @@ const ForgettingEngine: React.FC = () => {
|
||||
if (config.type === 'button') {
|
||||
return (
|
||||
<SwitchFormItem
|
||||
key={config.key}
|
||||
title={t(`forgettingEngine.${config.key}`)}
|
||||
name={config.name}
|
||||
desc={config.type && <span>{t(`forgettingEngine.type`)}: {config.type}</span>}
|
||||
|
||||
@@ -328,7 +328,6 @@ const Result: FC<ResultProps> = ({ loading, handleSave }) => {
|
||||
<Space size={24} className="rb:mt-4! rb:mb-3!">
|
||||
{['processData', 'finalResult'].map(tab => (
|
||||
<div
|
||||
key={tab}
|
||||
className={clsx('rb:font-[MiSans-Bold] rb:font-bold rb:leading-5 rb:cursor-pointer', {
|
||||
'rb:text-[#212332]': activeTab === tab,
|
||||
'rb:text-[#A8A9AA]': activeTab !== tab,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 17:30:02
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-04-14 16:54:40
|
||||
* @Last Modified time: 2026-03-26 15:45:42
|
||||
*/
|
||||
/**
|
||||
* Memory Extraction Engine Configuration Page
|
||||
@@ -27,7 +27,6 @@ import ModelSelect from '@/components/ModelSelect'
|
||||
import RbSlider from '@/components/RbSlider';
|
||||
import DescWrapper from '@/components/FormItem/DescWrapper'
|
||||
import LabelWrapper from '@/components/FormItem/LabelWrapper'
|
||||
import { useI18n } from '@/store/locale'
|
||||
|
||||
/** Available configuration section keys */
|
||||
const keys = [
|
||||
@@ -55,17 +54,12 @@ const MemoryExtractionEngine: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { message } = App.useApp();
|
||||
const { id } = useParams()
|
||||
const { language } = useI18n()
|
||||
const [expandedKeys, setExpandedKeys] = useState<string[]>(keys)
|
||||
const [form] = Form.useForm<ConfigForm>()
|
||||
const values = Form.useWatch<ConfigForm>([], form)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [iterationPeriodDisabled, setIterationPeriodDisabled] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
document.title = [document.title.split(' - ')[0], t('memoryBear')].join(' - ')
|
||||
}, [language])
|
||||
|
||||
useEffect(() => {
|
||||
if (values?.reflexion_range === 'database') {
|
||||
form.setFieldValue('iteration_period', 24)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 17:33:15
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-04-14 16:17:29
|
||||
* @Last Modified time: 2026-03-26 14:56:00
|
||||
*/
|
||||
/**
|
||||
* Memory Management Page
|
||||
@@ -74,8 +74,7 @@ const MemoryManagement: React.FC = () => {
|
||||
};
|
||||
|
||||
/** Navigate to engine configuration page */
|
||||
const handleClick = (id: number, type: string, config_name: string) => {
|
||||
document.title = `${config_name} - ${t('memoryBear')}`;
|
||||
const handleClick = (id: number, type: string) => {
|
||||
switch (type) {
|
||||
case 'memoryExtractionEngine':
|
||||
navigate(`/memory-extraction-engine/${id}`)
|
||||
@@ -131,7 +130,7 @@ const MemoryManagement: React.FC = () => {
|
||||
align="center"
|
||||
justify="space-between"
|
||||
className="rb:cursor-pointer rb:bg-[#F6F6F6] rb:h-8 rb:rounded-lg rb:font-medium rb:leading-5 rb:pl-2! rb:pr-1! rb:hover:shadow-[0px_2px_8px_0px_rgba(23,23,25,0.16)]"
|
||||
onClick={() => handleClick(item.config_id, key, item.config_name)}
|
||||
onClick={() => handleClick(item.config_id, key)}
|
||||
>
|
||||
{t(`memory.${key}`)}
|
||||
<div
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 14:10:20
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-04-14 16:55:26
|
||||
* @Last Modified time: 2026-03-26 18:55:37
|
||||
*/
|
||||
import { type FC, useEffect, useState, useRef } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
@@ -18,7 +18,6 @@ import SearchInput from '@/components/SearchInput';
|
||||
import OntologyClassExtractModal from '../components/OntologyClassExtractModal'
|
||||
import BodyWrapper from '@/components/Empty/BodyWrapper'
|
||||
import Tag from '@/components/Tag'
|
||||
import { useI18n } from '@/store/locale'
|
||||
|
||||
/**
|
||||
* Ontology detail page component
|
||||
@@ -30,7 +29,6 @@ const Detail: FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const { id } = useParams()
|
||||
const { modal, message } = App.useApp()
|
||||
const { language } = useI18n()
|
||||
|
||||
// Refs
|
||||
const ontologyClassModalRef = useRef<OntologyClassModalRef>(null)
|
||||
@@ -48,10 +46,6 @@ const Detail: FC = () => {
|
||||
getData()
|
||||
}, [id, query])
|
||||
|
||||
useEffect(() => {
|
||||
document.title = `${data.scene_name} - ${t('memoryBear')}`;
|
||||
}, [data.scene_name, language])
|
||||
|
||||
/**
|
||||
* Fetch ontology class list data
|
||||
*/
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
/*
|
||||
* @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',
|
||||
},
|
||||
]
|
||||
@@ -1,145 +0,0 @@
|
||||
/*
|
||||
* @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;
|
||||
@@ -1,61 +0,0 @@
|
||||
/*
|
||||
* @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;
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 17:46:47
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-04-14 16:59:56
|
||||
* @Last Modified time: 2026-03-26 18:57:08
|
||||
*/
|
||||
/**
|
||||
* Self Reflection Engine Configuration Page
|
||||
@@ -99,10 +99,6 @@ const SelfReflectionEngine: React.FC = () => {
|
||||
|
||||
const values = Form.useWatch([], form);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = [document.title.split(' - ')[0], t('memoryBear')].join(' - ')
|
||||
}, [language])
|
||||
|
||||
useEffect(() => {
|
||||
getConfigData()
|
||||
}, [id])
|
||||
@@ -246,7 +242,6 @@ const SelfReflectionEngine: React.FC = () => {
|
||||
|
||||
return (
|
||||
<SwitchFormItem
|
||||
key={config.key}
|
||||
title={t(`reflectionEngine.${config.key}`)}
|
||||
name={config.key}
|
||||
desc={<>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-05 10:44:08
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-04-14 16:57:52
|
||||
* @Last Modified time: 2026-02-05 10:56:28
|
||||
*/
|
||||
import { type FC, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -17,7 +17,6 @@ import type { SkillFormData } from '../types'
|
||||
import { getSkillDetail, createSkill, updateSkill } from '@/api/skill'
|
||||
import { stringRegExp } from '@/utils/validator';
|
||||
import PageHeader from '@/components/Layout/PageHeader'
|
||||
import { useI18n } from '@/store/locale'
|
||||
|
||||
/**
|
||||
* Skill Configuration Page Component
|
||||
@@ -44,7 +43,6 @@ const SkillConfig: FC = () => {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [form] = Form.useForm<SkillFormData>();
|
||||
const [data, setData] = useState<SkillFormData | null>(null)
|
||||
const { language } = useI18n()
|
||||
|
||||
/**
|
||||
* Effect: Load skill data if editing existing skill
|
||||
@@ -78,11 +76,6 @@ const SkillConfig: FC = () => {
|
||||
setLoading(false)
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
document.title = `${data?.name} - ${t('memoryBear')}`;
|
||||
}, [language, data?.name])
|
||||
|
||||
const aiPromptModalRef = useRef<AiPromptModalRef>(null)
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 17:57:26
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-04-14 16:57:59
|
||||
* @Last Modified time: 2026-04-14 16:04:08
|
||||
*/
|
||||
/**
|
||||
* Neo4j User Memory Detail View
|
||||
@@ -10,7 +10,7 @@
|
||||
* Shows profile, interests, node statistics, relationships, and insights
|
||||
*/
|
||||
|
||||
import { type FC, useRef, useState, type MouseEvent, useEffect } from 'react'
|
||||
import { type FC, useRef, useState, type MouseEvent } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { Flex, Popover } from 'antd'
|
||||
@@ -26,7 +26,6 @@ import type { EndUserProfileRef, MemoryInsightRef, AboutMeRef, EndUser } from '.
|
||||
import {
|
||||
analyticsRefresh,
|
||||
} from '@/api/memory'
|
||||
import { useI18n } from '@/store/locale'
|
||||
|
||||
const Neo4j: FC = () => {
|
||||
const { id } = useParams()
|
||||
@@ -34,7 +33,6 @@ const Neo4j: FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
const { language } = useI18n()
|
||||
const ref = useRef<EndUserProfileRef>(null)
|
||||
const memoryInsightRef = useRef<MemoryInsightRef>(null)
|
||||
const aboutMeRef = useRef<AboutMeRef>(null)
|
||||
@@ -46,9 +44,6 @@ const Neo4j: FC = () => {
|
||||
let name = data.other_name && data.other_name !== '' ? data.other_name : data.id || data.end_user_id
|
||||
setName(name)
|
||||
}
|
||||
useEffect(() => {
|
||||
document.title = `${name} - ${t('memoryBear')}`;
|
||||
}, [name, language])
|
||||
|
||||
/** Navigate back */
|
||||
const goBack = () => {
|
||||
@@ -85,7 +80,7 @@ const Neo4j: FC = () => {
|
||||
<Flex className="rb:h-full!" gap={12}>
|
||||
<Flex gap={15} vertical justify="space-between" align="center" className="rb:h-full! rb:px-4! rb:pt-6! rb:pb-5! rb:bg-white rb:w-20 rb:rounded-xl">
|
||||
<Flex gap={15} vertical>
|
||||
<Popover
|
||||
<Popover
|
||||
content={t('userMemory.memoryWindow', { name: name })}
|
||||
placement="right"
|
||||
arrow={false}
|
||||
@@ -94,7 +89,7 @@ const Neo4j: FC = () => {
|
||||
<div className="rb:mb-4.25! rb:size-12 rb:rounded-xl rb:bg-cover rb:bg-[url('@/assets/images/userMemory/logo.png')]"></div>
|
||||
</Popover>
|
||||
|
||||
<Flex
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
className={clsx("rb:cursor-pointer rb:size-12 rb:rounded-xl rb:group", {
|
||||
@@ -162,7 +157,7 @@ const Neo4j: FC = () => {
|
||||
<div className="rb:cursor-pointer rb:size-6 rb:bg-cover rb:bg-[url('@/assets/images/userMemory/logout.svg')]" onClick={goBack}></div>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
|
||||
<Flex vertical className="rb:flex-1">
|
||||
<NodeStatistics />
|
||||
<RelationshipNetwork />
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 17:57:11
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-04-14 16:56:36
|
||||
* @Last Modified time: 2026-03-31 15:29:45
|
||||
*/
|
||||
/**
|
||||
* RAG User Memory Detail View
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
} from '@/api/memory'
|
||||
import Empty from '@/components/Empty'
|
||||
import ConversationMemory from './components/ConversationMemory'
|
||||
import { useI18n } from '@/store/locale'
|
||||
|
||||
/**
|
||||
* Title component props
|
||||
@@ -46,7 +45,6 @@ const Title: FC<TitleProps> = ({ title, iconClassName }) => (
|
||||
const Rag: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { id } = useParams()
|
||||
const { language } = useI18n()
|
||||
const [data, setData] = useState<Data | null>(null)
|
||||
const [summary, setSummary] = useState<string | null>('')
|
||||
const [loading, setLoading] = useState<Record<string, boolean>>({
|
||||
@@ -99,10 +97,6 @@ const Rag: FC = () => {
|
||||
}
|
||||
const name = loading.detail ? '' : data?.name && data?.name !== '' ? data.name : id
|
||||
|
||||
useEffect(() => {
|
||||
document.title = `${name} - ${t('memoryBear')}`;
|
||||
}, [name, language])
|
||||
|
||||
const [refreshLoading, setRefreshLoading] = useState(false)
|
||||
const handleRefresh = () => {
|
||||
if (refreshLoading || !id) return
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
} from '@/api/memory'
|
||||
import EndUserProfileModal from './EndUserProfileModal'
|
||||
import type { EndUser, EndUserProfileModalRef, EndUserProfileRef } from '../types'
|
||||
import Tag from '@/components/Tag';
|
||||
|
||||
/**
|
||||
* Component props
|
||||
@@ -58,6 +57,14 @@ const EndUserProfile = forwardRef<EndUserProfileRef, EndUserProfileProps>(({ cla
|
||||
setLoading(false)
|
||||
})
|
||||
}
|
||||
/** Format profile items for display */
|
||||
const formatItems = useCallback(() => {
|
||||
return ['other_name'].map(key => ({
|
||||
key,
|
||||
label: t(`userMemory.${key}`),
|
||||
children: String(data?.[key as keyof EndUser] || '-'),
|
||||
}))
|
||||
}, [data])
|
||||
/** Open edit modal */
|
||||
const handleEdit = () => {
|
||||
if (!data) return
|
||||
@@ -83,31 +90,13 @@ const EndUserProfile = forwardRef<EndUserProfileRef, EndUserProfileProps>(({ cla
|
||||
>
|
||||
{loading
|
||||
? <Skeleton />
|
||||
: <Flex vertical gap={20} className="rb:leading-5">
|
||||
<div>
|
||||
<div className="rb:text-[#7B8085]">{t('userMemory.other_name')}</div>
|
||||
<div className="rb:mt-0.5">{data?.other_name || '-'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="rb:text-[#7B8085]">{t('userMemory.role')}</div>
|
||||
<div className="rb:mt-0.5">{data?.profile?.role || '-'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="rb:text-[#7B8085]">{t('userMemory.domain')}</div>
|
||||
<div className="rb:mt-0.5">{data?.profile?.domain || '-'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="rb:text-[#7B8085]">{t('userMemory.expertise')}</div>
|
||||
<div className="rb:mt-0.5">{data?.profile?.expertise?.join(' | ') || '-'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="rb:text-[#7B8085]">{t('userMemory.interests')}</div>
|
||||
<div className="rb:mt-0.5">{data?.profile?.interests?.join(' | ') || '-'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="rb:text-[#7B8085]">{t('userMemory.knowledge_tags')}</div>
|
||||
<Flex wrap gap={4} className="rb:mt-0.5!">{data?.knowledge_tags?.map((tag: string) => <Tag>{tag}</Tag>) || '-'}</Flex>
|
||||
</div>
|
||||
: <Flex vertical gap={20}>
|
||||
{formatItems().map(vo => (
|
||||
<div key={vo.key} className="rb:leading-5">
|
||||
<div className="rb:text-[#7B8085]">{vo.label}</div>
|
||||
<div className="rb:mt-0.5">{vo.children}</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="rb:text-[#7B8085] rb:text-[12px] rb:leading-4.5">
|
||||
{t('userMemory.updated_at')}: {data?.updated_at ? dayjs(data?.updated_at).format('YYYY/MM/DD HH:mm:ss') : ''}
|
||||
|
||||
@@ -177,23 +177,6 @@ export interface EndUser {
|
||||
end_user_id: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
profile: {
|
||||
role: string;
|
||||
domain: string;
|
||||
expertise: string[];
|
||||
interests: string[];
|
||||
};
|
||||
_updated_at: {
|
||||
profile: string;
|
||||
knowledge_tags: string;
|
||||
behavioral_hints: string;
|
||||
};
|
||||
knowledge_tags: string[];
|
||||
behavioral_hints: {
|
||||
learning_stage: string;
|
||||
preferred_depth: string;
|
||||
tone_preference: string;
|
||||
};
|
||||
}
|
||||
/**
|
||||
* End user profile modal ref
|
||||
|
||||
@@ -118,7 +118,6 @@ const CheckList: FC<CheckListProps> = ({ workflowRef, appId }) => {
|
||||
const { setCheckResults, getCheckResults } = useWorkflowStore()
|
||||
const results = getCheckResults(appId)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>()
|
||||
const toolMethodsCacheRef = useRef<Record<string, Array<{ name: string; parameters: Array<{ name: string; required: boolean }> }>>>({})
|
||||
|
||||
const runCheck = useCallback(async () => {
|
||||
const graph = workflowRef.current?.graphRef?.current
|
||||
@@ -168,10 +167,7 @@ const CheckList: FC<CheckListProps> = ({ workflowRef, appId }) => {
|
||||
|
||||
if (typeof toolId === 'string') {
|
||||
try {
|
||||
if (!toolMethodsCacheRef.current[toolId]) {
|
||||
toolMethodsCacheRef.current[toolId] = await getToolMethods(toolId) as Array<{ name: string; parameters: Array<{ name: string; required: boolean }> }>
|
||||
}
|
||||
const methods = toolMethodsCacheRef.current[toolId]
|
||||
const methods = await getToolMethods(toolId) as Array<{ name: string; parameters: Array<{ name: string; required: boolean }> }>
|
||||
const operation = toolParameters?.operation
|
||||
const method = operation ? methods.find(m => m.name === operation) : methods[0]
|
||||
if (method) {
|
||||
|
||||
@@ -40,7 +40,7 @@ const VariableSelect: FC<VariableSelectProps> = ({
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [expandedParentKey, setExpandedParentKey] = useState<string | null>(null);
|
||||
const [expandedParent, setExpandedParent] = useState<Suggestion | null>(null);
|
||||
const [dropdownPos, setDropdownPos] = useState({ top: 0, left: 0, width: 0 });
|
||||
const [childPanelPos, setChildPanelPos] = useState({ top: 0, right: 0 });
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -84,10 +84,6 @@ const VariableSelect: FC<VariableSelectProps> = ({
|
||||
? filteredOptions.find(o => o.children?.some(c => `{{${c.value}}}` === value))
|
||||
: undefined;
|
||||
|
||||
const expandedParent = expandedParentKey
|
||||
? filteredOptions.find(o => o.key === expandedParentKey) ?? null
|
||||
: null;
|
||||
|
||||
const groupedSuggestions = filteredOptions.reduce((groups: Record<string, Suggestion[]>, s) => {
|
||||
const nodeId = s.nodeData.id as string;
|
||||
if (!groups[nodeId]) groups[nodeId] = [];
|
||||
@@ -143,7 +139,7 @@ const VariableSelect: FC<VariableSelectProps> = ({
|
||||
) {
|
||||
setOpen(false);
|
||||
setSearch('');
|
||||
setExpandedParentKey(null);
|
||||
setExpandedParent(null);
|
||||
setChildPanelPos({ top: 0, right: 0 });
|
||||
}
|
||||
};
|
||||
@@ -163,7 +159,7 @@ const VariableSelect: FC<VariableSelectProps> = ({
|
||||
onChange?.(`{{${suggestion.value}}}`, suggestion);
|
||||
setOpen(false);
|
||||
setSearch('');
|
||||
setExpandedParentKey(null);
|
||||
setExpandedParent(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -316,16 +312,16 @@ const VariableSelect: FC<VariableSelectProps> = ({
|
||||
if (s.disabled) return;
|
||||
if (hasChildren) {
|
||||
updateChildPos(s.key);
|
||||
setExpandedParentKey(prev => prev === s.key ? null : s.key);
|
||||
setExpandedParent(prev => prev?.key === s.key ? null : s);
|
||||
}
|
||||
handleSelect(s);
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
if (hasChildren) {
|
||||
updateChildPos(s.key);
|
||||
setExpandedParentKey(s.key);
|
||||
setExpandedParent(s);
|
||||
} else {
|
||||
setExpandedParentKey(null);
|
||||
setExpandedParent(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -362,7 +358,7 @@ const VariableSelect: FC<VariableSelectProps> = ({
|
||||
id="variable-select-child-panel"
|
||||
className="rb:min-w-70 rb:max-h-57.5 rb:overflow-y-auto rb:text-[12px] rb:fixed rb:z-1000 rb:bg-white rb:rounded-lg rb:border-[0.5px] rb:border-[#EBEBEB] rb:shadow-[0px_2px_6px_0px_rgba(0,0,0,0.1)] rb:py-3 rb:px-2"
|
||||
style={{ top: childPanelPos.top, right: childPanelPos.right }}
|
||||
onMouseEnter={() => setExpandedParentKey(expandedParentKey)}
|
||||
onMouseEnter={() => setExpandedParent(expandedParent)}
|
||||
>
|
||||
<div className="rb:pb-2 rb:mb-1 rb:font-medium rb:text-[#5B6167] rb-border-b">
|
||||
<Flex justify="space-between" align="center" gap={8}>
|
||||
|
||||
Reference in New Issue
Block a user