Compare commits

..

2 Commits

Author SHA1 Message Date
yingzhao
9edf5c9bd6 Merge pull request #915 from SuanmoSuanyangTechnology/fix/userinfo_zy
fix(web): userinfo
2026-04-16 11:11:54 +08:00
zhaoying
2f0c4300df fix(web): userinfo 2026-04-16 11:10:38 +08:00
51 changed files with 590 additions and 1527 deletions

View File

@@ -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'},

View File

@@ -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 "")
)

View File

@@ -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",

View File

@@ -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="本次新发现的用户别名(用户自我介绍或他人对用户的称呼)",

View File

@@ -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

View File

@@ -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": []
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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):

View File

@@ -44,8 +44,6 @@ class FileInput(BaseModel):
upload_file_id: Optional[uuid.UUID] = Field(None, description="已上传文件IDlocal_file时必填")
url: Optional[str] = Field(None, description="远程URLremote_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

View File

@@ -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"] = {

View File

@@ -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:
"""生成唯一应用名称,同时检查本空间自有应用和共享到本空间的应用"""

View File

@@ -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 信息

View File

@@ -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)
}

View File

@@ -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"]

View File

@@ -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 (

View File

@@ -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])

View File

@@ -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}`)
}

View File

@@ -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>
)

View File

@@ -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);

View File

@@ -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,

View File

@@ -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',
},
},
};

View File

@@ -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: '编辑套餐',
},
},
}

View File

@@ -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')),

View File

@@ -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" }
]

View File

@@ -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
}
]
},

View File

@@ -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)
})
})

View File

@@ -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) {

View File

@@ -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}
/>
</>
);
};

View File

@@ -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
*/

View File

@@ -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>
]}
/>

View File

@@ -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]!">

View File

@@ -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])

View File

@@ -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>}

View File

@@ -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,

View File

@@ -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)

View File

@@ -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

View File

@@ -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
*/

View File

@@ -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',
},
]

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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={<>

View File

@@ -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)

View File

@@ -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 />

View File

@@ -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

View File

@@ -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') : ''}

View File

@@ -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

View File

@@ -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) {

View File

@@ -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}>