Merge remote-tracking branch 'upstream/develop' into feature/app-share-wxy
# Conflicts: # api/app/services/app_dsl_service.py
@@ -62,10 +62,10 @@ celery_app.conf.update(
|
||||
task_serializer='json',
|
||||
accept_content=['json'],
|
||||
result_serializer='json',
|
||||
|
||||
# 时区
|
||||
timezone='Asia/Shanghai',
|
||||
enable_utc=False,
|
||||
|
||||
# # 时区
|
||||
# timezone='Asia/Shanghai',
|
||||
# enable_utc=False,
|
||||
|
||||
# 任务追踪
|
||||
task_track_started=True,
|
||||
@@ -116,6 +116,7 @@ celery_app.conf.update(
|
||||
'app.tasks.update_implicit_emotions_storage': {'queue': 'periodic_tasks'},
|
||||
'app.tasks.init_implicit_emotions_for_users': {'queue': 'periodic_tasks'},
|
||||
'app.tasks.init_interest_distribution_for_users': {'queue': 'periodic_tasks'},
|
||||
'app.tasks.init_community_clustering_for_users': {'queue': 'periodic_tasks'},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -55,6 +55,12 @@ async def get_mcp_servers(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="The paging parameter must be greater than 0"
|
||||
)
|
||||
if page * pagesize > 100:
|
||||
api_logger.warning(f"Paging parameters exceed ModelScope limit: page={page}, pagesize={pagesize}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"The maximum number of MCP services can view is 100. Please visit the ModelScope MCP Plaza."
|
||||
)
|
||||
|
||||
# 2. Query mcp market config information from the database
|
||||
api_logger.debug(f"Query mcp market config: {mcp_market_config_id}")
|
||||
@@ -64,14 +70,16 @@ async def get_mcp_servers(
|
||||
if not db_mcp_market_config:
|
||||
api_logger.warning(
|
||||
f"The mcp market config does not exist or access is denied: mcp_market_config_id={mcp_market_config_id}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="The mcp market config does not exist or access is denied"
|
||||
)
|
||||
return success(msg='The mcp market config does not exist or access is denied')
|
||||
|
||||
# 3. Execute paged query
|
||||
api = MCPApi()
|
||||
token = db_mcp_market_config.token
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="MCP market config token is not configured"
|
||||
)
|
||||
api = MCPApi()
|
||||
api.login(token)
|
||||
|
||||
body = {
|
||||
@@ -151,14 +159,16 @@ async def get_operational_mcp_servers(
|
||||
if not db_mcp_market_config:
|
||||
api_logger.warning(
|
||||
f"The mcp market config does not exist or access is denied: mcp_market_config_id={mcp_market_config_id}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="The mcp market config does not exist or access is denied"
|
||||
)
|
||||
return success(msg='The mcp market config does not exist or access is denied')
|
||||
|
||||
# 2. Execute paged query
|
||||
api = MCPApi()
|
||||
token = db_mcp_market_config.token
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="MCP market config token is not configured"
|
||||
)
|
||||
api = MCPApi()
|
||||
api.login(token)
|
||||
|
||||
url = f'{api.mcp_base_url}/operational'
|
||||
@@ -209,14 +219,16 @@ async def get_mcp_server(
|
||||
if not db_mcp_market_config:
|
||||
api_logger.warning(
|
||||
f"The mcp market config does not exist or access is denied: mcp_market_config_id={mcp_market_config_id}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="The mcp market config does not exist or access is denied"
|
||||
)
|
||||
return success(msg='The mcp market config does not exist or access is denied')
|
||||
|
||||
# 2. Get detailed information for a specific MCP Server
|
||||
api = MCPApi()
|
||||
token = db_mcp_market_config.token
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="MCP market config token is not configured"
|
||||
)
|
||||
api = MCPApi()
|
||||
api.login(token)
|
||||
|
||||
result = api.get_mcp_server(server_id=server_id)
|
||||
@@ -237,7 +249,26 @@ async def create_mcp_market_config(
|
||||
|
||||
try:
|
||||
api_logger.debug(f"Start creating the mcp market config: {create_data.mcp_market_id}")
|
||||
# 1. Check if the mcp market name already exists
|
||||
# 1. Validate token can access ModelScope MCP market
|
||||
if not create_data.token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Token is required to access ModelScope MCP market"
|
||||
)
|
||||
try:
|
||||
api = MCPApi()
|
||||
api.login(create_data.token)
|
||||
body = {'filter': {}, 'page_number': 1, 'page_size': 1, 'search': None}
|
||||
cookies = api.get_cookies(create_data.token)
|
||||
r = api.session.put(url=api.mcp_base_url, headers=api.builder_headers(api.headers), json=body, cookies=cookies)
|
||||
raise_for_http_status(r)
|
||||
except Exception as e:
|
||||
api_logger.warning(f"Token validation failed for ModelScope MCP market: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Unable to access ModelScope MCP market with the provided token: {str(e)}"
|
||||
)
|
||||
# 2. Check if the mcp market name already exists
|
||||
db_mcp_market_config_exist = mcp_market_config_service.get_mcp_market_config_by_mcp_market_id(db, mcp_market_id=create_data.mcp_market_id, current_user=current_user)
|
||||
if db_mcp_market_config_exist:
|
||||
api_logger.warning(f"The mcp market id already exists: {create_data.mcp_market_id}")
|
||||
@@ -245,6 +276,30 @@ async def create_mcp_market_config(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"The mcp market id already exists: {create_data.mcp_market_id}"
|
||||
)
|
||||
# 2. verify token
|
||||
create_data.status = 1
|
||||
try:
|
||||
api = MCPApi()
|
||||
token = create_data.token
|
||||
api.login(token)
|
||||
|
||||
body = {
|
||||
'filter': {},
|
||||
'page_number': 1,
|
||||
'page_size': 20,
|
||||
'search': ""
|
||||
}
|
||||
cookies = api.get_cookies(token)
|
||||
r = api.session.put(
|
||||
url=api.mcp_base_url,
|
||||
headers=api.builder_headers(api.headers),
|
||||
json=body,
|
||||
cookies=cookies)
|
||||
raise_for_http_status(r)
|
||||
except requests.exceptions.RequestException as e:
|
||||
api_logger.error(f"Failed to get MCP servers: {str(e)}")
|
||||
create_data.status = 0
|
||||
# 3. create mcp_market_config
|
||||
db_mcp_market_config = mcp_market_config_service.create_mcp_market_config(db=db, mcp_market_config=create_data, current_user=current_user)
|
||||
api_logger.info(
|
||||
f"The mcp market config has been successfully created: (ID: {db_mcp_market_config.id})")
|
||||
@@ -273,10 +328,7 @@ async def get_mcp_market_config(
|
||||
db_mcp_market_config = mcp_market_config_service.get_mcp_market_config_by_id(db, mcp_market_config_id=mcp_market_config_id, current_user=current_user)
|
||||
if not db_mcp_market_config:
|
||||
api_logger.warning(f"The mcp market config does not exist or access is denied: mcp_market_config_id={mcp_market_config_id}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="The mcp market config does not exist or access is denied"
|
||||
)
|
||||
return success(msg='The mcp market config does not exist or access is denied')
|
||||
|
||||
api_logger.info(f"mcp market config query successful: (ID: {db_mcp_market_config.id})")
|
||||
return success(data=jsonable_encoder(mcp_market_config_schema.McpMarketConfig.model_validate(db_mcp_market_config)),
|
||||
@@ -306,10 +358,7 @@ async def get_mcp_market_config_by_mcp_market_id(
|
||||
db_mcp_market_config = mcp_market_config_service.get_mcp_market_config_by_mcp_market_id(db, mcp_market_id=mcp_market_id, current_user=current_user)
|
||||
if not db_mcp_market_config:
|
||||
api_logger.warning(f"The mcp market config does not exist or access is denied: mcp_market_id={mcp_market_id}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="The mcp market config does not exist or access is denied"
|
||||
)
|
||||
return success(msg='The mcp market config does not exist or access is denied')
|
||||
|
||||
api_logger.info(f"mcp market config query successful: (ID: {db_mcp_market_config.id})")
|
||||
return success(data=jsonable_encoder(mcp_market_config_schema.McpMarketConfig.model_validate(db_mcp_market_config)),
|
||||
@@ -335,12 +384,25 @@ async def update_mcp_market_config(
|
||||
if not db_mcp_market_config:
|
||||
api_logger.warning(
|
||||
f"The mcp market config does not exist or you do not have permission to access it: mcp_market_config_id={mcp_market_config_id}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="The mcp market config does not exist or you do not have permission to access it"
|
||||
)
|
||||
return success(msg='The mcp market config does not exist or access is denied')
|
||||
|
||||
# 2. Update fields (only update non-null fields)
|
||||
# 2. Validate new token if provided
|
||||
if update_data.token is not None:
|
||||
try:
|
||||
api = MCPApi()
|
||||
api.login(update_data.token)
|
||||
body = {'filter': {}, 'page_number': 1, 'page_size': 1, 'search': None}
|
||||
cookies = api.get_cookies(update_data.token)
|
||||
r = api.session.put(url=api.mcp_base_url, headers=api.builder_headers(api.headers), json=body, cookies=cookies)
|
||||
raise_for_http_status(r)
|
||||
except Exception as e:
|
||||
api_logger.warning(f"Token validation failed for ModelScope MCP market: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Unable to access ModelScope MCP market with the provided token: {str(e)}"
|
||||
)
|
||||
|
||||
# 3. Update fields (only update non-null fields)
|
||||
api_logger.debug(f"Start updating the mcp market config fields: {mcp_market_config_id}")
|
||||
update_dict = update_data.dict(exclude_unset=True)
|
||||
updated_fields = []
|
||||
@@ -355,7 +417,7 @@ async def update_mcp_market_config(
|
||||
if updated_fields:
|
||||
api_logger.debug(f"updated fields: {', '.join(updated_fields)}")
|
||||
|
||||
# 3. Save to database
|
||||
# 4. Save to database
|
||||
try:
|
||||
db.commit()
|
||||
db.refresh(db_mcp_market_config)
|
||||
@@ -368,7 +430,7 @@ async def update_mcp_market_config(
|
||||
detail=f"The mcp market config update failed: {str(e)}"
|
||||
)
|
||||
|
||||
# 4. Return the updated mcp market config
|
||||
# 5. Return the updated mcp market config
|
||||
return success(data=jsonable_encoder(mcp_market_config_schema.McpMarketConfig.model_validate(db_mcp_market_config)),
|
||||
msg="The mcp market config information updated successfully")
|
||||
|
||||
@@ -392,10 +454,7 @@ async def delete_mcp_market_config(
|
||||
if not db_mcp_market_config:
|
||||
api_logger.warning(
|
||||
f"The mcp market config does not exist or you do not have permission to access it: mcp_market_config_id={mcp_market_config_id}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="The mcp market config does not exist or you do not have permission to access it"
|
||||
)
|
||||
return success(msg='The mcp market config does not exist or access is denied')
|
||||
|
||||
# 2. Deleting mcp market config
|
||||
mcp_market_config_service.delete_mcp_market_config_by_id(db, mcp_market_config_id=mcp_market_config_id, current_user=current_user)
|
||||
|
||||
@@ -193,7 +193,16 @@ async def get_workspace_end_users(
|
||||
await aio_redis_set(cache_key, json.dumps(result), expire=30)
|
||||
except Exception as e:
|
||||
api_logger.warning(f"Redis 缓存写入失败: {str(e)}")
|
||||
|
||||
|
||||
# 触发社区聚类补全任务(异步,不阻塞接口响应)
|
||||
# 对有 ExtractedEntity 但无 Community 节点的存量用户自动补跑全量聚类
|
||||
try:
|
||||
from app.tasks import init_community_clustering_for_users
|
||||
init_community_clustering_for_users.delay(end_user_ids=end_user_ids)
|
||||
api_logger.info(f"已触发社区聚类补全任务,候选用户数: {len(end_user_ids)}")
|
||||
except Exception as e:
|
||||
api_logger.warning(f"触发社区聚类补全任务失败(不影响主流程): {str(e)}")
|
||||
|
||||
api_logger.info(f"成功获取 {len(end_users)} 个宿主记录")
|
||||
return success(data=result, msg="宿主列表获取成功")
|
||||
|
||||
@@ -403,14 +412,15 @@ def get_current_user_rag_total_num(
|
||||
@router.get("/rag_content", response_model=ApiResponse)
|
||||
def get_rag_content(
|
||||
end_user_id: str = Query(..., description="宿主ID"),
|
||||
limit: int = Query(15, description="返回记录数"),
|
||||
page: int = Query(1, gt=0, description="页码,从1开始"),
|
||||
pagesize: int = Query(15, gt=0, le=100, description="每页返回记录数"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
获取当前宿主知识库中的chunk内容
|
||||
获取当前宿主知识库中的chunk内容(分页)
|
||||
"""
|
||||
data = memory_dashboard_service.get_rag_content(end_user_id, limit, db, current_user)
|
||||
data = memory_dashboard_service.get_rag_content(end_user_id, page, pagesize, db, current_user)
|
||||
return success(data=data, msg="宿主RAGchunk数据获取成功")
|
||||
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ from app.services.user_memory_service import (
|
||||
UserMemoryService,
|
||||
analytics_memory_types,
|
||||
analytics_graph_data,
|
||||
analytics_community_graph_data,
|
||||
)
|
||||
from app.services.memory_entity_relationship_service import MemoryEntityService,MemoryEmotion,MemoryInteraction
|
||||
from app.schemas.response_schema import ApiResponse
|
||||
@@ -295,6 +296,42 @@ async def get_graph_data_api(
|
||||
return fail(BizCode.INTERNAL_ERROR, "图数据查询失败", str(e))
|
||||
|
||||
|
||||
@router.get("/analytics/community_graph", response_model=ApiResponse)
|
||||
async def get_community_graph_data_api(
|
||||
end_user_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
workspace_id = current_user.current_workspace_id
|
||||
|
||||
if workspace_id is None:
|
||||
api_logger.warning(f"用户 {current_user.username} 尝试查询社区图谱但未选择工作空间")
|
||||
return fail(BizCode.INVALID_PARAMETER, "请先切换到一个工作空间", "current_workspace_id is None")
|
||||
|
||||
api_logger.info(
|
||||
f"社区图谱查询请求: end_user_id={end_user_id}, user={current_user.username}, "
|
||||
f"workspace={workspace_id}"
|
||||
)
|
||||
|
||||
try:
|
||||
result = await analytics_community_graph_data(db=db, end_user_id=end_user_id)
|
||||
|
||||
if "message" in result and result["statistics"]["total_nodes"] == 0:
|
||||
api_logger.warning(f"社区图谱查询返回空结果: {result.get('message')}")
|
||||
return success(data=result, msg=result.get("message", "查询成功"))
|
||||
|
||||
api_logger.info(
|
||||
f"成功获取社区图谱: end_user_id={end_user_id}, "
|
||||
f"nodes={result['statistics']['total_nodes']}, "
|
||||
f"edges={result['statistics']['total_edges']}"
|
||||
)
|
||||
return success(data=result, msg="查询成功")
|
||||
|
||||
except Exception as e:
|
||||
api_logger.error(f"社区图谱查询失败: end_user_id={end_user_id}, error={str(e)}")
|
||||
return fail(BizCode.INTERNAL_ERROR, "社区图谱查询失败", str(e))
|
||||
|
||||
|
||||
@router.get("/read_end_user/profile", response_model=ApiResponse)
|
||||
async def get_end_user_profile(
|
||||
end_user_id: str,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from app.core.memory.agent.utils.llm_tools import ReadState, WriteState
|
||||
from app.schemas.memory_agent_schema import AgentMemoryDataset
|
||||
|
||||
|
||||
def content_input_node(state: ReadState) -> ReadState:
|
||||
@@ -17,6 +18,9 @@ def content_input_node(state: ReadState) -> ReadState:
|
||||
|
||||
content = state['messages'][0].content if state.get('messages') else ''
|
||||
# Return content and maintain all state information
|
||||
for pronoun in AgentMemoryDataset.PRONOUN:
|
||||
content = content.replace(pronoun, AgentMemoryDataset.NAME)
|
||||
|
||||
return {"data": content}
|
||||
|
||||
|
||||
@@ -35,4 +39,7 @@ def content_input_write(state: WriteState) -> WriteState:
|
||||
|
||||
content = state['messages'][0].content if state.get('messages') else ''
|
||||
# Return content and maintain all state information
|
||||
for pronoun in AgentMemoryDataset.PRONOUN:
|
||||
content = content.replace(pronoun, AgentMemoryDataset.NAME)
|
||||
|
||||
return {"data": content}
|
||||
|
||||
@@ -165,7 +165,9 @@ async def write(
|
||||
statement_chunk_edges=all_statement_chunk_edges,
|
||||
statement_entity_edges=all_statement_entity_edges,
|
||||
entity_edges=all_entity_entity_edges,
|
||||
connector=neo4j_connector
|
||||
connector=neo4j_connector,
|
||||
config_id=config_id,
|
||||
llm_model_id=str(memory_config.llm_model_id) if memory_config.llm_model_id else None,
|
||||
)
|
||||
if success:
|
||||
logger.info("Successfully saved all data to Neo4j")
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from app.core.memory.storage_services.clustering_engine.label_propagation import LabelPropagationEngine
|
||||
|
||||
__all__ = ["LabelPropagationEngine"]
|
||||
@@ -0,0 +1,484 @@
|
||||
"""标签传播聚类引擎
|
||||
|
||||
基于 ZEP 论文的动态标签传播算法,对 Neo4j 中的 ExtractedEntity 节点进行社区聚类。
|
||||
|
||||
支持两种模式:
|
||||
- 全量初始化(full_clustering):首次运行,对所有实体做完整 LPA 迭代
|
||||
- 增量更新(incremental_update):新实体到达时,只处理新实体及其邻居
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from math import sqrt
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from app.repositories.neo4j.community_repository import CommunityRepository
|
||||
from app.repositories.neo4j.neo4j_connector import Neo4jConnector
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 全量迭代最大轮数,防止不收敛
|
||||
MAX_ITERATIONS = 10
|
||||
# 社区摘要核心实体数量
|
||||
CORE_ENTITY_LIMIT = 5
|
||||
|
||||
|
||||
def _cosine_similarity(v1: Optional[List[float]], v2: Optional[List[float]]) -> float:
|
||||
"""计算两个向量的余弦相似度,任一为空则返回 0。"""
|
||||
if not v1 or not v2 or len(v1) != len(v2):
|
||||
return 0.0
|
||||
dot = sum(a * b for a, b in zip(v1, v2))
|
||||
norm1 = sqrt(sum(a * a for a in v1))
|
||||
norm2 = sqrt(sum(b * b for b in v2))
|
||||
if norm1 == 0 or norm2 == 0:
|
||||
return 0.0
|
||||
return dot / (norm1 * norm2)
|
||||
|
||||
|
||||
def _weighted_vote(
|
||||
neighbors: List[Dict],
|
||||
self_embedding: Optional[List[float]],
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
加权多数投票,选出得票最高的社区。
|
||||
|
||||
权重 = 语义相似度(name_embedding 余弦)* activation_value 加成
|
||||
没有 community_id 的邻居不参与投票。
|
||||
"""
|
||||
votes: Dict[str, float] = {}
|
||||
for nb in neighbors:
|
||||
cid = nb.get("community_id")
|
||||
if not cid:
|
||||
continue
|
||||
sem = _cosine_similarity(self_embedding, nb.get("name_embedding"))
|
||||
act = nb.get("activation_value") or 0.5
|
||||
# 语义相似度权重 0.6,激活值权重 0.4
|
||||
weight = 0.6 * sem + 0.4 * act
|
||||
votes[cid] = votes.get(cid, 0.0) + weight
|
||||
|
||||
if not votes:
|
||||
return None
|
||||
return max(votes, key=votes.__getitem__)
|
||||
|
||||
|
||||
class LabelPropagationEngine:
|
||||
"""标签传播聚类引擎"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
connector: Neo4jConnector,
|
||||
config_id: Optional[str] = None,
|
||||
llm_model_id: Optional[str] = None,
|
||||
):
|
||||
self.connector = connector
|
||||
self.repo = CommunityRepository(connector)
|
||||
self.config_id = config_id
|
||||
self.llm_model_id = llm_model_id
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# 公开接口
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def run(
|
||||
self,
|
||||
end_user_id: str,
|
||||
new_entity_ids: Optional[List[str]] = None,
|
||||
) -> None:
|
||||
"""
|
||||
统一入口:自动判断全量还是增量。
|
||||
|
||||
- 若该用户尚无 Community 节点 → 全量初始化
|
||||
- 否则 → 增量更新(仅处理 new_entity_ids)
|
||||
"""
|
||||
has_communities = await self.repo.has_communities(end_user_id)
|
||||
if not has_communities:
|
||||
logger.info(f"[Clustering] 用户 {end_user_id} 首次聚类,执行全量初始化")
|
||||
await self.full_clustering(end_user_id)
|
||||
else:
|
||||
if new_entity_ids:
|
||||
logger.info(
|
||||
f"[Clustering] 增量更新,新实体数: {len(new_entity_ids)}"
|
||||
)
|
||||
await self.incremental_update(new_entity_ids, end_user_id)
|
||||
|
||||
async def full_clustering(self, end_user_id: str) -> None:
|
||||
"""
|
||||
全量标签传播初始化。
|
||||
|
||||
1. 拉取所有实体,初始化每个实体为独立社区
|
||||
2. 迭代:每轮对所有实体做邻居投票,更新社区标签
|
||||
3. 直到标签不再变化或达到 MAX_ITERATIONS
|
||||
4. 将最终标签写入 Neo4j
|
||||
"""
|
||||
entities = await self.repo.get_all_entities(end_user_id)
|
||||
if not entities:
|
||||
logger.info(f"[Clustering] 用户 {end_user_id} 无实体,跳过全量聚类")
|
||||
return
|
||||
|
||||
# 初始化:每个实体持有自己 id 作为社区标签
|
||||
labels: Dict[str, str] = {e["id"]: e["id"] for e in entities}
|
||||
embeddings: Dict[str, Optional[List[float]]] = {
|
||||
e["id"]: e.get("name_embedding") for e in entities
|
||||
}
|
||||
|
||||
# 预加载所有实体的邻居,避免迭代内 O(iterations * |E|) 次 Neo4j 往返
|
||||
logger.info(f"[Clustering] 预加载 {len(entities)} 个实体的邻居图...")
|
||||
neighbors_cache: Dict[str, List[Dict]] = await self.repo.get_all_entity_neighbors_batch(end_user_id)
|
||||
logger.info(f"[Clustering] 邻居预加载完成,覆盖实体数: {len(neighbors_cache)}")
|
||||
|
||||
for iteration in range(MAX_ITERATIONS):
|
||||
changed = 0
|
||||
# 随机顺序(Python dict 在 3.7+ 保持插入顺序,这里直接遍历)
|
||||
for entity in entities:
|
||||
eid = entity["id"]
|
||||
# 直接从缓存取邻居,不再发起 Neo4j 查询
|
||||
neighbors = neighbors_cache.get(eid, [])
|
||||
|
||||
# 将邻居的当前内存标签注入(覆盖 Neo4j 中的旧值)
|
||||
enriched = []
|
||||
for nb in neighbors:
|
||||
nb_copy = dict(nb)
|
||||
nb_copy["community_id"] = labels.get(nb["id"], nb.get("community_id"))
|
||||
enriched.append(nb_copy)
|
||||
|
||||
new_label = _weighted_vote(enriched, embeddings.get(eid))
|
||||
if new_label and new_label != labels[eid]:
|
||||
labels[eid] = new_label
|
||||
changed += 1
|
||||
|
||||
logger.info(
|
||||
f"[Clustering] 全量迭代 {iteration + 1}/{MAX_ITERATIONS},"
|
||||
f"标签变化数: {changed}"
|
||||
)
|
||||
if changed == 0:
|
||||
logger.info("[Clustering] 标签已收敛,提前结束迭代")
|
||||
break
|
||||
|
||||
# 将最终标签写入 Neo4j
|
||||
await self._flush_labels(labels, end_user_id)
|
||||
pre_merge_count = len(set(labels.values()))
|
||||
logger.info(
|
||||
f"[Clustering] 全量迭代完成,共 {pre_merge_count} 个社区,"
|
||||
f"{len(labels)} 个实体,开始后处理合并"
|
||||
)
|
||||
|
||||
# 全量初始化后做一轮社区合并(基于 name_embedding 余弦相似度)
|
||||
all_community_ids = list(set(labels.values()))
|
||||
await self._evaluate_merge(all_community_ids, end_user_id)
|
||||
|
||||
logger.info(
|
||||
f"[Clustering] 全量聚类完成,合并前 {pre_merge_count} 个社区,"
|
||||
f"{len(labels)} 个实体"
|
||||
)
|
||||
# 为所有社区生成元数据
|
||||
# 注意:_evaluate_merge 后部分社区已被合并消解,需重新从 Neo4j 查询实际存活的社区
|
||||
# 不能复用 labels.values(),那里包含已被 dissolve 的旧社区 ID
|
||||
surviving_communities = await self.repo.get_all_entities(end_user_id)
|
||||
surviving_community_ids = list({
|
||||
e.get("community_id") for e in surviving_communities
|
||||
if e.get("community_id")
|
||||
})
|
||||
logger.info(f"[Clustering] 合并后实际存活社区数: {len(surviving_community_ids)}")
|
||||
for cid in surviving_community_ids:
|
||||
await self._generate_community_metadata(cid, end_user_id)
|
||||
|
||||
async def incremental_update(
|
||||
self, new_entity_ids: List[str], end_user_id: str
|
||||
) -> None:
|
||||
"""
|
||||
增量更新:只处理新实体及其邻居,不重跑全图。
|
||||
|
||||
1. 对每个新实体查询邻居
|
||||
2. 加权多数投票决定社区归属
|
||||
3. 若邻居无社区 → 创建新社区
|
||||
4. 若邻居分属多个社区 → 评估是否合并
|
||||
"""
|
||||
for entity_id in new_entity_ids:
|
||||
await self._process_single_entity(entity_id, end_user_id)
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# 内部方法
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def _process_single_entity(
|
||||
self, entity_id: str, end_user_id: str
|
||||
) -> None:
|
||||
"""处理单个新实体的社区分配。"""
|
||||
neighbors = await self.repo.get_entity_neighbors(entity_id, end_user_id)
|
||||
|
||||
# 查询自身 embedding(从邻居查询结果中无法获取,需单独查)
|
||||
self_embedding = await self._get_entity_embedding(entity_id, end_user_id)
|
||||
|
||||
if not neighbors:
|
||||
# 孤立实体:创建单成员社区
|
||||
new_cid = self._new_community_id()
|
||||
await self.repo.upsert_community(new_cid, end_user_id, member_count=1)
|
||||
await self.repo.assign_entity_to_community(entity_id, new_cid, end_user_id)
|
||||
logger.debug(f"[Clustering] 孤立实体 {entity_id} → 新社区 {new_cid}")
|
||||
return
|
||||
|
||||
# 统计邻居社区分布
|
||||
community_ids_in_neighbors = set(
|
||||
nb["community_id"] for nb in neighbors if nb.get("community_id")
|
||||
)
|
||||
|
||||
target_cid = _weighted_vote(neighbors, self_embedding)
|
||||
|
||||
if target_cid is None:
|
||||
# 邻居都没有社区,连同新实体一起创建新社区
|
||||
new_cid = self._new_community_id()
|
||||
await self.repo.upsert_community(new_cid, end_user_id)
|
||||
await self.repo.assign_entity_to_community(entity_id, new_cid, end_user_id)
|
||||
for nb in neighbors:
|
||||
await self.repo.assign_entity_to_community(
|
||||
nb["id"], new_cid, end_user_id
|
||||
)
|
||||
await self.repo.refresh_member_count(new_cid, end_user_id)
|
||||
logger.debug(
|
||||
f"[Clustering] 新实体 {entity_id} 与 {len(neighbors)} 个无社区邻居 → 新社区 {new_cid}"
|
||||
)
|
||||
await self._generate_community_metadata(new_cid, end_user_id)
|
||||
else:
|
||||
# 加入得票最多的社区
|
||||
await self.repo.assign_entity_to_community(entity_id, target_cid, end_user_id)
|
||||
await self.repo.refresh_member_count(target_cid, end_user_id)
|
||||
logger.debug(f"[Clustering] 新实体 {entity_id} → 社区 {target_cid}")
|
||||
|
||||
# 若邻居分属多个社区,评估合并
|
||||
if len(community_ids_in_neighbors) > 1:
|
||||
await self._evaluate_merge(
|
||||
list(community_ids_in_neighbors), end_user_id
|
||||
)
|
||||
await self._generate_community_metadata(target_cid, end_user_id)
|
||||
|
||||
async def _evaluate_merge(
|
||||
self, community_ids: List[str], end_user_id: str
|
||||
) -> None:
|
||||
"""
|
||||
评估多个社区是否应合并。
|
||||
|
||||
策略:计算各社区成员 embedding 的平均向量,若两两余弦相似度 > 0.75 则合并。
|
||||
合并时保留成员数最多的社区,其余成员迁移过来。
|
||||
|
||||
全量场景(社区数 > 20)使用批量查询,避免 N 次数据库往返。
|
||||
"""
|
||||
MERGE_THRESHOLD = 0.85
|
||||
BATCH_THRESHOLD = 20 # 超过此数量走批量查询
|
||||
|
||||
community_embeddings: Dict[str, Optional[List[float]]] = {}
|
||||
community_sizes: Dict[str, int] = {}
|
||||
|
||||
if len(community_ids) > BATCH_THRESHOLD:
|
||||
# 批量查询:一次拉取所有社区成员
|
||||
all_members = await self.repo.get_all_community_members_batch(
|
||||
community_ids, end_user_id
|
||||
)
|
||||
for cid in community_ids:
|
||||
members = all_members.get(cid, [])
|
||||
community_sizes[cid] = len(members)
|
||||
valid_embeddings = [
|
||||
m["name_embedding"] for m in members if m.get("name_embedding")
|
||||
]
|
||||
if valid_embeddings:
|
||||
dim = len(valid_embeddings[0])
|
||||
community_embeddings[cid] = [
|
||||
sum(e[i] for e in valid_embeddings) / len(valid_embeddings)
|
||||
for i in range(dim)
|
||||
]
|
||||
else:
|
||||
community_embeddings[cid] = None
|
||||
else:
|
||||
# 增量场景:逐个查询
|
||||
for cid in community_ids:
|
||||
members = await self.repo.get_community_members(cid, end_user_id)
|
||||
community_sizes[cid] = len(members)
|
||||
valid_embeddings = [
|
||||
m["name_embedding"] for m in members if m.get("name_embedding")
|
||||
]
|
||||
if valid_embeddings:
|
||||
dim = len(valid_embeddings[0])
|
||||
community_embeddings[cid] = [
|
||||
sum(e[i] for e in valid_embeddings) / len(valid_embeddings)
|
||||
for i in range(dim)
|
||||
]
|
||||
else:
|
||||
community_embeddings[cid] = None
|
||||
|
||||
# 找出应合并的社区对
|
||||
to_merge: List[tuple] = []
|
||||
cids = list(community_ids)
|
||||
for i in range(len(cids)):
|
||||
for j in range(i + 1, len(cids)):
|
||||
sim = _cosine_similarity(
|
||||
community_embeddings[cids[i]],
|
||||
community_embeddings[cids[j]],
|
||||
)
|
||||
if sim > MERGE_THRESHOLD:
|
||||
to_merge.append((cids[i], cids[j]))
|
||||
|
||||
logger.info(f"[Clustering] 发现 {len(to_merge)} 对可合并社区")
|
||||
|
||||
# 执行合并:逐对处理,每次合并后重新计算合并社区的平均向量
|
||||
# 避免 union-find 链式传递导致语义不相关的社区被间接合并
|
||||
# (A≈B、B≈C 不代表 A≈C,不能因传递性把 A/B/C 全部合并)
|
||||
merged_into: Dict[str, str] = {} # dissolve → keep 的最终映射
|
||||
|
||||
def get_root(x: str) -> str:
|
||||
"""路径压缩,找到 x 当前所属的根社区。"""
|
||||
while x in merged_into:
|
||||
merged_into[x] = merged_into.get(merged_into[x], merged_into[x])
|
||||
x = merged_into[x]
|
||||
return x
|
||||
|
||||
for c1, c2 in to_merge:
|
||||
root1, root2 = get_root(c1), get_root(c2)
|
||||
if root1 == root2:
|
||||
continue
|
||||
|
||||
# 用合并后的最新平均向量重新验证相似度
|
||||
# 防止链式传递:A≈B 合并后 B 的向量已更新,C 必须和新 B 相似才能合并
|
||||
current_sim = _cosine_similarity(
|
||||
community_embeddings.get(root1),
|
||||
community_embeddings.get(root2),
|
||||
)
|
||||
if current_sim <= MERGE_THRESHOLD:
|
||||
# 合并后向量已漂移,不再满足阈值,跳过
|
||||
logger.debug(
|
||||
f"[Clustering] 跳过合并 {root1} ↔ {root2},"
|
||||
f"当前相似度 {current_sim:.3f} ≤ {MERGE_THRESHOLD}"
|
||||
)
|
||||
continue
|
||||
|
||||
keep = root1 if community_sizes.get(root1, 0) >= community_sizes.get(root2, 0) else root2
|
||||
dissolve = root2 if keep == root1 else root1
|
||||
merged_into[dissolve] = keep
|
||||
|
||||
members = await self.repo.get_community_members(dissolve, end_user_id)
|
||||
for m in members:
|
||||
await self.repo.assign_entity_to_community(m["id"], keep, end_user_id)
|
||||
|
||||
# 合并后重新计算 keep 的平均向量(加权平均)
|
||||
keep_emb = community_embeddings.get(keep)
|
||||
dissolve_emb = community_embeddings.get(dissolve)
|
||||
keep_size = community_sizes.get(keep, 0)
|
||||
dissolve_size = community_sizes.get(dissolve, 0)
|
||||
total_size = keep_size + dissolve_size
|
||||
if keep_emb and dissolve_emb and total_size > 0:
|
||||
dim = len(keep_emb)
|
||||
community_embeddings[keep] = [
|
||||
(keep_emb[i] * keep_size + dissolve_emb[i] * dissolve_size) / total_size
|
||||
for i in range(dim)
|
||||
]
|
||||
community_embeddings[dissolve] = None
|
||||
|
||||
community_sizes[keep] = total_size
|
||||
community_sizes[dissolve] = 0
|
||||
await self.repo.refresh_member_count(keep, end_user_id)
|
||||
logger.info(
|
||||
f"[Clustering] 社区合并: {dissolve} → {keep},"
|
||||
f"相似度={current_sim:.3f},迁移 {len(members)} 个成员"
|
||||
)
|
||||
|
||||
async def _flush_labels(
|
||||
self, labels: Dict[str, str], end_user_id: str
|
||||
) -> None:
|
||||
"""将内存中的标签批量写入 Neo4j。"""
|
||||
# 先创建所有唯一社区节点
|
||||
unique_communities = set(labels.values())
|
||||
for cid in unique_communities:
|
||||
await self.repo.upsert_community(cid, end_user_id)
|
||||
|
||||
# 再批量分配实体
|
||||
for entity_id, community_id in labels.items():
|
||||
await self.repo.assign_entity_to_community(
|
||||
entity_id, community_id, end_user_id
|
||||
)
|
||||
|
||||
# 刷新成员数
|
||||
for cid in unique_communities:
|
||||
await self.repo.refresh_member_count(cid, end_user_id)
|
||||
|
||||
async def _get_entity_embedding(
|
||||
self, entity_id: str, end_user_id: str
|
||||
) -> Optional[List[float]]:
|
||||
"""查询单个实体的 name_embedding。"""
|
||||
try:
|
||||
result = await self.connector.execute_query(
|
||||
"MATCH (e:ExtractedEntity {id: $eid, end_user_id: $uid}) "
|
||||
"RETURN e.name_embedding AS name_embedding",
|
||||
eid=entity_id,
|
||||
uid=end_user_id,
|
||||
)
|
||||
return result[0]["name_embedding"] if result else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def _generate_community_metadata(
|
||||
self, community_id: str, end_user_id: str
|
||||
) -> None:
|
||||
"""
|
||||
为社区生成并写入元数据:名称、摘要、核心实体。
|
||||
|
||||
- core_entities:按 activation_value 排序取 top-N 实体名称列表(无需 LLM)
|
||||
- name / summary:若有 llm_model_id 则调用 LLM 生成,否则用实体名称拼接兜底
|
||||
"""
|
||||
try:
|
||||
members = await self.repo.get_community_members(community_id, end_user_id)
|
||||
if not members:
|
||||
return
|
||||
|
||||
# 核心实体:按 activation_value 降序取 top-N
|
||||
sorted_members = sorted(
|
||||
members,
|
||||
key=lambda m: m.get("activation_value") or 0,
|
||||
reverse=True,
|
||||
)
|
||||
core_entities = [m["name"] for m in sorted_members[:CORE_ENTITY_LIMIT] if m.get("name")]
|
||||
all_names = [m["name"] for m in members if m.get("name")]
|
||||
|
||||
name = "、".join(core_entities[:3]) if core_entities else community_id[:8]
|
||||
summary = f"包含实体:{', '.join(all_names)}"
|
||||
|
||||
# 若有 LLM 配置,调用 LLM 生成更好的名称和摘要
|
||||
if self.llm_model_id:
|
||||
try:
|
||||
from app.db import get_db_context
|
||||
from app.core.memory.utils.llm.llm_utils import MemoryClientFactory
|
||||
|
||||
entity_list_str = "、".join(all_names)
|
||||
prompt = (
|
||||
f"以下是一组语义相关的实体:{entity_list_str}\n\n"
|
||||
f"请为这组实体所代表的主题:\n"
|
||||
f"1. 起一个简洁的中文名称(不超过10个字)\n"
|
||||
f"2. 写一句话摘要(不超过50个字)\n\n"
|
||||
f"严格按以下格式输出,不要有其他内容:\n"
|
||||
f"名称:<名称>\n摘要:<摘要>"
|
||||
)
|
||||
with get_db_context() as db:
|
||||
factory = MemoryClientFactory(db)
|
||||
llm_client = factory.get_llm_client(self.llm_model_id)
|
||||
response = await llm_client.chat([{"role": "user", "content": prompt}])
|
||||
text = response.content if hasattr(response, "content") else str(response)
|
||||
|
||||
for line in text.strip().splitlines():
|
||||
if line.startswith("名称:"):
|
||||
name = line[3:].strip()
|
||||
elif line.startswith("摘要:"):
|
||||
summary = line[3:].strip()
|
||||
except Exception as e:
|
||||
logger.warning(f"[Clustering] LLM 生成社区元数据失败,使用兜底值: {e}")
|
||||
|
||||
await self.repo.update_community_metadata(
|
||||
community_id=community_id,
|
||||
end_user_id=end_user_id,
|
||||
name=name,
|
||||
summary=summary,
|
||||
core_entities=core_entities,
|
||||
)
|
||||
logger.debug(f"[Clustering] 社区 {community_id} 元数据已更新: name={name}")
|
||||
except Exception as e:
|
||||
logger.error(f"[Clustering] _generate_community_metadata failed for {community_id}: {e}")
|
||||
|
||||
@staticmethod
|
||||
def _new_community_id() -> str:
|
||||
return str(uuid.uuid4())
|
||||
@@ -53,6 +53,7 @@ class SimpleMCPClient:
|
||||
else:
|
||||
await self._connect_http()
|
||||
except Exception as e:
|
||||
await self.disconnect()
|
||||
logger.error(f"MCP连接失败: {self.server_url}, 错误: {e}")
|
||||
raise MCPConnectionError(f"连接失败: {e}")
|
||||
|
||||
|
||||
@@ -247,7 +247,6 @@ class EndUserRepository:
|
||||
EndUser.user_summary: user_summary,
|
||||
EndUser.rag_tags: rag_tags,
|
||||
EndUser.rag_personas: rag_personas,
|
||||
EndUser.storage_type: "rag",
|
||||
EndUser.rag_summary_updated_at: datetime.datetime.now(),
|
||||
},
|
||||
synchronize_session=False
|
||||
@@ -286,7 +285,6 @@ class EndUserRepository:
|
||||
.update(
|
||||
{
|
||||
EndUser.memory_insight: memory_insight,
|
||||
EndUser.storage_type: "rag",
|
||||
EndUser.memory_insight_updated_at: datetime.datetime.now(),
|
||||
},
|
||||
synchronize_session=False
|
||||
|
||||
@@ -5,7 +5,7 @@ Implicit Emotions Storage Repository
|
||||
事务由调用方控制,仓储层只使用 flush/refresh
|
||||
"""
|
||||
import logging
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from datetime import date, datetime, timezone
|
||||
from typing import Generator, Optional
|
||||
|
||||
|
||||
@@ -177,22 +177,21 @@ class ImplicitEmotionsStorageRepository:
|
||||
if raw is None:
|
||||
continue
|
||||
try:
|
||||
CST = timezone(timedelta(hours=8))
|
||||
last_done = datetime.fromisoformat(raw)
|
||||
# last_done 写入时已是 CST naive,直接使用,无需转换
|
||||
if last_done.tzinfo is not None:
|
||||
last_done = last_done.astimezone(CST).replace(tzinfo=None)
|
||||
# last_done 写入时已是 UTC aware(+00:00),确保有 tzinfo
|
||||
if last_done.tzinfo is None:
|
||||
last_done = last_done.replace(tzinfo=timezone.utc)
|
||||
|
||||
if updated_at is None:
|
||||
yield end_user_id
|
||||
continue
|
||||
# updated_at 数据库存的是 UTC naive,转为 CST naive 再比较
|
||||
# updated_at 数据库存的是 UTC naive,补上 UTC tzinfo 再比较
|
||||
if updated_at.tzinfo is None:
|
||||
updated_at_cst = updated_at.replace(tzinfo=timezone.utc).astimezone(CST).replace(tzinfo=None)
|
||||
updated_at_utc = updated_at.replace(tzinfo=timezone.utc)
|
||||
else:
|
||||
updated_at_cst = updated_at.astimezone(CST).replace(tzinfo=None)
|
||||
updated_at_utc = updated_at.astimezone(timezone.utc)
|
||||
|
||||
if last_done > updated_at_cst:
|
||||
if last_done > updated_at_utc:
|
||||
yield end_user_id
|
||||
except Exception as e:
|
||||
logger.warning(f"解析 last_done 时间戳失败: end_user_id={end_user_id}, raw={raw}, error={e}")
|
||||
|
||||
194
api/app/repositories/neo4j/community_repository.py
Normal file
@@ -0,0 +1,194 @@
|
||||
"""Community 节点仓库
|
||||
|
||||
管理 Neo4j 中 Community 节点及 BELONGS_TO_COMMUNITY 边的 CRUD 操作。
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from app.repositories.neo4j.neo4j_connector import Neo4jConnector
|
||||
from app.repositories.neo4j.cypher_queries import (
|
||||
COMMUNITY_NODE_UPSERT,
|
||||
ENTITY_JOIN_COMMUNITY,
|
||||
ENTITY_LEAVE_ALL_COMMUNITIES,
|
||||
GET_ENTITY_NEIGHBORS,
|
||||
GET_ALL_ENTITIES_FOR_USER,
|
||||
GET_COMMUNITY_MEMBERS,
|
||||
GET_ALL_COMMUNITY_MEMBERS_BATCH,
|
||||
GET_ALL_ENTITY_NEIGHBORS_BATCH,
|
||||
CHECK_USER_HAS_COMMUNITIES,
|
||||
UPDATE_COMMUNITY_MEMBER_COUNT,
|
||||
UPDATE_COMMUNITY_METADATA,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CommunityRepository:
|
||||
def __init__(self, connector: Neo4jConnector):
|
||||
self.connector = connector
|
||||
|
||||
async def upsert_community(
|
||||
self, community_id: str, end_user_id: str, member_count: int = 0
|
||||
) -> Optional[str]:
|
||||
"""创建或更新 Community 节点,返回 community_id。"""
|
||||
try:
|
||||
result = await self.connector.execute_query(
|
||||
COMMUNITY_NODE_UPSERT,
|
||||
community_id=community_id,
|
||||
end_user_id=end_user_id,
|
||||
member_count=member_count,
|
||||
)
|
||||
return result[0]["community_id"] if result else None
|
||||
except Exception as e:
|
||||
logger.error(f"upsert_community failed: {e}")
|
||||
return None
|
||||
|
||||
async def assign_entity_to_community(
|
||||
self, entity_id: str, community_id: str, end_user_id: str
|
||||
) -> bool:
|
||||
"""将实体关联到社区(先解除旧关联,再建立新关联)。"""
|
||||
try:
|
||||
await self.connector.execute_query(
|
||||
ENTITY_LEAVE_ALL_COMMUNITIES,
|
||||
entity_id=entity_id,
|
||||
end_user_id=end_user_id,
|
||||
)
|
||||
result = await self.connector.execute_query(
|
||||
ENTITY_JOIN_COMMUNITY,
|
||||
entity_id=entity_id,
|
||||
community_id=community_id,
|
||||
end_user_id=end_user_id,
|
||||
)
|
||||
return bool(result)
|
||||
except Exception as e:
|
||||
logger.error(f"assign_entity_to_community failed: {e}")
|
||||
return False
|
||||
|
||||
async def get_entity_neighbors(
|
||||
self, entity_id: str, end_user_id: str
|
||||
) -> List[Dict]:
|
||||
"""查询实体的直接邻居及其社区归属。"""
|
||||
try:
|
||||
return await self.connector.execute_query(
|
||||
GET_ENTITY_NEIGHBORS,
|
||||
entity_id=entity_id,
|
||||
end_user_id=end_user_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"get_entity_neighbors failed: {e}")
|
||||
return []
|
||||
|
||||
async def get_all_entity_neighbors_batch(
|
||||
self, end_user_id: str
|
||||
) -> Dict[str, List[Dict]]:
|
||||
"""一次性批量拉取该用户下所有实体的邻居,返回 {entity_id: [neighbors]} 字典。
|
||||
用于全量聚类预加载,避免每个实体单独查询。"""
|
||||
try:
|
||||
rows = await self.connector.execute_query(
|
||||
GET_ALL_ENTITY_NEIGHBORS_BATCH,
|
||||
end_user_id=end_user_id,
|
||||
)
|
||||
result: Dict[str, List[Dict]] = {}
|
||||
for row in rows:
|
||||
eid = row["entity_id"]
|
||||
neighbor = {k: v for k, v in row.items() if k != "entity_id"}
|
||||
result.setdefault(eid, []).append(neighbor)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"get_all_entity_neighbors_batch failed: {e}")
|
||||
return {}
|
||||
|
||||
async def get_all_entities(self, end_user_id: str) -> List[Dict]:
|
||||
"""拉取某用户下所有实体及其当前社区归属。"""
|
||||
try:
|
||||
return await self.connector.execute_query(
|
||||
GET_ALL_ENTITIES_FOR_USER,
|
||||
end_user_id=end_user_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"get_all_entities failed: {e}")
|
||||
return []
|
||||
|
||||
async def get_community_members(
|
||||
self, community_id: str, end_user_id: str
|
||||
) -> List[Dict]:
|
||||
"""查询社区成员列表。"""
|
||||
try:
|
||||
return await self.connector.execute_query(
|
||||
GET_COMMUNITY_MEMBERS,
|
||||
community_id=community_id,
|
||||
end_user_id=end_user_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"get_community_members failed: {e}")
|
||||
return []
|
||||
|
||||
async def get_all_community_members_batch(
|
||||
self, community_ids: List[str], end_user_id: str
|
||||
) -> Dict[str, List[Dict]]:
|
||||
"""批量查询多个社区的成员,返回 {community_id: [members]} 字典。"""
|
||||
try:
|
||||
rows = await self.connector.execute_query(
|
||||
GET_ALL_COMMUNITY_MEMBERS_BATCH,
|
||||
community_ids=community_ids,
|
||||
end_user_id=end_user_id,
|
||||
)
|
||||
result: Dict[str, List[Dict]] = {}
|
||||
for row in rows:
|
||||
cid = row["community_id"]
|
||||
result.setdefault(cid, []).append(row)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"get_all_community_members_batch failed: {e}")
|
||||
return {}
|
||||
|
||||
async def has_communities(self, end_user_id: str) -> bool:
|
||||
"""检查该用户是否已有 Community 节点(用于判断全量 vs 增量)。"""
|
||||
try:
|
||||
result = await self.connector.execute_query(
|
||||
CHECK_USER_HAS_COMMUNITIES,
|
||||
end_user_id=end_user_id,
|
||||
)
|
||||
return result[0]["community_count"] > 0 if result else False
|
||||
except Exception as e:
|
||||
logger.error(f"has_communities failed: {e}")
|
||||
return False
|
||||
|
||||
async def refresh_member_count(
|
||||
self, community_id: str, end_user_id: str
|
||||
) -> int:
|
||||
"""重新统计并更新社区成员数,返回最新数量。"""
|
||||
try:
|
||||
result = await self.connector.execute_query(
|
||||
UPDATE_COMMUNITY_MEMBER_COUNT,
|
||||
community_id=community_id,
|
||||
end_user_id=end_user_id,
|
||||
)
|
||||
return result[0]["member_count"] if result else 0
|
||||
except Exception as e:
|
||||
logger.error(f"refresh_member_count failed: {e}")
|
||||
return 0
|
||||
|
||||
async def update_community_metadata(
|
||||
self,
|
||||
community_id: str,
|
||||
end_user_id: str,
|
||||
name: str,
|
||||
summary: str,
|
||||
core_entities: List[str],
|
||||
) -> bool:
|
||||
"""更新社区的名称、摘要和核心实体列表。"""
|
||||
try:
|
||||
result = await self.connector.execute_query(
|
||||
UPDATE_COMMUNITY_METADATA,
|
||||
community_id=community_id,
|
||||
end_user_id=end_user_id,
|
||||
name=name,
|
||||
summary=summary,
|
||||
core_entities=core_entities,
|
||||
)
|
||||
return bool(result)
|
||||
except Exception as e:
|
||||
logger.error(f"update_community_metadata failed: {e}")
|
||||
return False
|
||||
@@ -1058,4 +1058,147 @@ Graph_Node_query = """
|
||||
3 AS priority
|
||||
LIMIT $limit
|
||||
|
||||
"""
|
||||
"""
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Community 节点 & BELONGS_TO_COMMUNITY 边
|
||||
# ============================================================
|
||||
|
||||
# ─── Community 聚类相关 Cypher 模板 ───────────────────────────────────────────
|
||||
|
||||
COMMUNITY_NODE_UPSERT = """
|
||||
MERGE (c:Community {community_id: $community_id})
|
||||
SET c.end_user_id = $end_user_id,
|
||||
c.member_count = $member_count,
|
||||
c.updated_at = datetime()
|
||||
RETURN c.community_id AS community_id
|
||||
"""
|
||||
|
||||
ENTITY_JOIN_COMMUNITY = """
|
||||
MATCH (e:ExtractedEntity {id: $entity_id, end_user_id: $end_user_id})
|
||||
MATCH (c:Community {community_id: $community_id, end_user_id: $end_user_id})
|
||||
MERGE (e)-[:BELONGS_TO_COMMUNITY]->(c)
|
||||
SET c.updated_at = datetime()
|
||||
RETURN e.id AS entity_id, c.community_id AS community_id
|
||||
"""
|
||||
|
||||
ENTITY_LEAVE_ALL_COMMUNITIES = """
|
||||
MATCH (e:ExtractedEntity {id: $entity_id, end_user_id: $end_user_id})
|
||||
MATCH (e)-[r:BELONGS_TO_COMMUNITY]->(:Community)
|
||||
DELETE r
|
||||
"""
|
||||
|
||||
GET_ENTITY_NEIGHBORS = """
|
||||
MATCH (e:ExtractedEntity {id: $entity_id, end_user_id: $end_user_id})
|
||||
|
||||
// 来源一:直接关系邻居(EXTRACTED_RELATIONSHIP 边)
|
||||
OPTIONAL MATCH (e)-[:EXTRACTED_RELATIONSHIP]-(nb1:ExtractedEntity {end_user_id: $end_user_id})
|
||||
|
||||
// 来源二:同 Statement 共现邻居(REFERENCES_ENTITY 边)
|
||||
OPTIONAL MATCH (s:Statement)-[:REFERENCES_ENTITY]->(e)
|
||||
OPTIONAL MATCH (s)-[:REFERENCES_ENTITY]->(nb2:ExtractedEntity {end_user_id: $end_user_id})
|
||||
WHERE nb2.id <> e.id
|
||||
|
||||
WITH collect(DISTINCT nb1) + collect(DISTINCT nb2) AS all_neighbors
|
||||
UNWIND all_neighbors AS nb
|
||||
WITH nb WHERE nb IS NOT NULL
|
||||
OPTIONAL MATCH (nb)-[:BELONGS_TO_COMMUNITY]->(c:Community)
|
||||
RETURN DISTINCT
|
||||
nb.id AS id,
|
||||
nb.name AS name,
|
||||
nb.name_embedding AS name_embedding,
|
||||
nb.activation_value AS activation_value,
|
||||
CASE WHEN c IS NOT NULL THEN c.community_id ELSE null END AS community_id
|
||||
"""
|
||||
|
||||
GET_ALL_ENTITIES_FOR_USER = """
|
||||
MATCH (e:ExtractedEntity {end_user_id: $end_user_id})
|
||||
OPTIONAL MATCH (e)-[:BELONGS_TO_COMMUNITY]->(c:Community)
|
||||
RETURN e.id AS id,
|
||||
e.name AS name,
|
||||
e.name_embedding AS name_embedding,
|
||||
e.activation_value AS activation_value,
|
||||
CASE WHEN c IS NOT NULL THEN c.community_id ELSE null END AS community_id
|
||||
"""
|
||||
|
||||
GET_COMMUNITY_MEMBERS = """
|
||||
MATCH (e:ExtractedEntity {end_user_id: $end_user_id})-[:BELONGS_TO_COMMUNITY]->(c:Community {community_id: $community_id})
|
||||
RETURN e.id AS id, e.name AS name, e.entity_type AS entity_type,
|
||||
e.importance_score AS importance_score, e.activation_value AS activation_value,
|
||||
e.name_embedding AS name_embedding
|
||||
ORDER BY coalesce(e.activation_value, 0) DESC
|
||||
"""
|
||||
|
||||
GET_ALL_COMMUNITY_MEMBERS_BATCH = """
|
||||
MATCH (e:ExtractedEntity {end_user_id: $end_user_id})-[:BELONGS_TO_COMMUNITY]->(c:Community)
|
||||
WHERE c.community_id IN $community_ids
|
||||
RETURN c.community_id AS community_id,
|
||||
e.id AS id,
|
||||
e.name_embedding AS name_embedding,
|
||||
e.activation_value AS activation_value
|
||||
"""
|
||||
|
||||
CHECK_USER_HAS_COMMUNITIES = """
|
||||
MATCH (c:Community {end_user_id: $end_user_id})
|
||||
RETURN count(c) AS community_count
|
||||
"""
|
||||
|
||||
UPDATE_COMMUNITY_MEMBER_COUNT = """
|
||||
MATCH (e:ExtractedEntity {end_user_id: $end_user_id})-[:BELONGS_TO_COMMUNITY]->(c:Community {community_id: $community_id})
|
||||
WITH c, count(e) AS cnt
|
||||
SET c.member_count = cnt
|
||||
RETURN c.community_id AS community_id, cnt AS member_count
|
||||
"""
|
||||
|
||||
UPDATE_COMMUNITY_METADATA = """
|
||||
MATCH (c:Community {community_id: $community_id, end_user_id: $end_user_id})
|
||||
SET c.name = $name,
|
||||
c.summary = $summary,
|
||||
c.core_entities = $core_entities,
|
||||
c.updated_at = datetime()
|
||||
RETURN c.community_id AS community_id
|
||||
"""
|
||||
|
||||
GET_ALL_ENTITY_NEIGHBORS_BATCH = """
|
||||
// 批量拉取某用户下所有实体的邻居(用于全量聚类预加载)
|
||||
MATCH (e:ExtractedEntity {end_user_id: $end_user_id})
|
||||
|
||||
// 来源一:直接关系邻居
|
||||
OPTIONAL MATCH (e)-[:EXTRACTED_RELATIONSHIP]-(nb1:ExtractedEntity {end_user_id: $end_user_id})
|
||||
|
||||
// 来源二:同 Statement 共现邻居
|
||||
OPTIONAL MATCH (s:Statement)-[:REFERENCES_ENTITY]->(e)
|
||||
OPTIONAL MATCH (s)-[:REFERENCES_ENTITY]->(nb2:ExtractedEntity {end_user_id: $end_user_id})
|
||||
WHERE nb2.id <> e.id
|
||||
|
||||
WITH e, collect(DISTINCT nb1) + collect(DISTINCT nb2) AS all_neighbors
|
||||
UNWIND all_neighbors AS nb
|
||||
WITH e, nb WHERE nb IS NOT NULL
|
||||
OPTIONAL MATCH (nb)-[:BELONGS_TO_COMMUNITY]->(c:Community)
|
||||
RETURN DISTINCT
|
||||
e.id AS entity_id,
|
||||
nb.id AS id,
|
||||
nb.name AS name,
|
||||
nb.name_embedding AS name_embedding,
|
||||
nb.activation_value AS activation_value,
|
||||
CASE WHEN c IS NOT NULL THEN c.community_id ELSE null END AS community_id
|
||||
"""
|
||||
|
||||
GET_COMMUNITY_GRAPH_DATA = """
|
||||
MATCH (c:Community {end_user_id: $end_user_id})
|
||||
MATCH (e:ExtractedEntity {end_user_id: $end_user_id})-[b:BELONGS_TO_COMMUNITY]->(c)
|
||||
OPTIONAL MATCH (e)-[r:EXTRACTED_RELATIONSHIP]-(e2:ExtractedEntity {end_user_id: $end_user_id})
|
||||
RETURN
|
||||
elementId(c) AS c_id,
|
||||
properties(c) AS c_props,
|
||||
elementId(e) AS e_id,
|
||||
properties(e) AS e_props,
|
||||
elementId(b) AS b_id,
|
||||
elementId(e2) AS e2_id,
|
||||
properties(e2) AS e2_props,
|
||||
elementId(r) AS r_id,
|
||||
type(r) AS r_type,
|
||||
properties(r) AS r_props,
|
||||
startNode(r) = e AS r_from_e
|
||||
"""
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
from typing import List
|
||||
import asyncio
|
||||
import os
|
||||
from typing import List, Optional
|
||||
|
||||
# 使用新的仓储层
|
||||
from app.repositories.neo4j.neo4j_connector import Neo4jConnector
|
||||
@@ -155,7 +157,9 @@ async def save_dialog_and_statements_to_neo4j(
|
||||
entity_edges: List[EntityEntityEdge],
|
||||
statement_chunk_edges: List[StatementChunkEdge],
|
||||
statement_entity_edges: List[StatementEntityEdge],
|
||||
connector: Neo4jConnector
|
||||
connector: Neo4jConnector,
|
||||
config_id: Optional[str] = None,
|
||||
llm_model_id: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Save dialogue nodes, chunk nodes, statement nodes, entities, and all relationships to Neo4j using graph models.
|
||||
|
||||
@@ -288,6 +292,10 @@ async def save_dialog_and_statements_to_neo4j(
|
||||
}
|
||||
logger.info("Transaction completed. Summary: %s", summary)
|
||||
logger.debug("Full transaction results: %r", results)
|
||||
|
||||
# 写入成功后,异步触发聚类(不阻塞写入响应)
|
||||
schedule_clustering_after_write(entity_nodes, config_id=config_id, llm_model_id=llm_model_id)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
@@ -295,3 +303,55 @@ async def save_dialog_and_statements_to_neo4j(
|
||||
print(f"Neo4j integration error: {e}")
|
||||
print("Continuing without database storage...")
|
||||
return False
|
||||
|
||||
|
||||
def schedule_clustering_after_write(
|
||||
entity_nodes: List,
|
||||
config_id: Optional[str] = None,
|
||||
llm_model_id: Optional[str] = None,
|
||||
) -> None:
|
||||
"""
|
||||
写入 Neo4j 成功后,调度后台聚类任务。
|
||||
|
||||
可通过环境变量 CLUSTERING_ENABLED=false 禁用(用于基准测试对比)。
|
||||
使用 asyncio.create_task 异步触发,不阻塞写入响应。
|
||||
"""
|
||||
if not entity_nodes:
|
||||
return
|
||||
|
||||
clustering_enabled = os.getenv("CLUSTERING_ENABLED", "true").lower() != "false"
|
||||
if not clustering_enabled:
|
||||
logger.info("[Clustering] 聚类已禁用(CLUSTERING_ENABLED=false),跳过聚类触发")
|
||||
return
|
||||
|
||||
end_user_id = entity_nodes[0].end_user_id
|
||||
new_entity_ids = [e.id for e in entity_nodes]
|
||||
logger.info(f"[Clustering] 准备触发聚类,实体数: {len(new_entity_ids)}, end_user_id: {end_user_id}")
|
||||
asyncio.create_task(_trigger_clustering(new_entity_ids, end_user_id, config_id=config_id, llm_model_id=llm_model_id))
|
||||
|
||||
|
||||
async def _trigger_clustering(
|
||||
new_entity_ids: List[str],
|
||||
end_user_id: str,
|
||||
config_id: Optional[str] = None,
|
||||
llm_model_id: Optional[str] = None,
|
||||
) -> None:
|
||||
"""
|
||||
聚类触发函数,自动判断全量初始化还是增量更新。
|
||||
"""
|
||||
connector = None
|
||||
try:
|
||||
from app.core.memory.storage_services.clustering_engine import LabelPropagationEngine
|
||||
logger.info(f"[Clustering] 开始聚类,end_user_id={end_user_id}, 实体数={len(new_entity_ids)}")
|
||||
connector = Neo4jConnector()
|
||||
engine = LabelPropagationEngine(connector, config_id=config_id, llm_model_id=llm_model_id)
|
||||
await engine.run(end_user_id=end_user_id, new_entity_ids=new_entity_ids)
|
||||
logger.info(f"[Clustering] 聚类完成,end_user_id={end_user_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"[Clustering] 聚类触发失败: {e}", exc_info=True)
|
||||
finally:
|
||||
if connector:
|
||||
try:
|
||||
await connector.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -26,5 +26,7 @@ class AgentMemory_Long_Term(ABC):
|
||||
STRATEGY_TIME = "time"
|
||||
DEFAULT_SCOPE = 6
|
||||
TIME_SCOPE=5
|
||||
|
||||
class AgentMemoryDataset(ABC):
|
||||
PRONOUN=['我','本人','在下','自己','咱','鄙人','吴','余']
|
||||
NAME='用户'
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ from app.models.tool_model import ToolConfig as ToolConfigModel
|
||||
from app.models.workflow_model import WorkflowConfig
|
||||
from app.services.workflow_service import WorkflowService
|
||||
from app.core.workflow.adapters.memory_bear.memory_bear_adapter import MemoryBearAdapter
|
||||
from app.models.memory_config_model import MemoryConfig as MemoryConfigModel
|
||||
|
||||
|
||||
class AppDslService:
|
||||
@@ -423,9 +424,19 @@ class AppDslService:
|
||||
config_id = memory.get("memory_config_id") or memory.get("memory_content")
|
||||
if not config_id:
|
||||
return memory
|
||||
from app.models.memory_config_model import MemoryConfig as MemoryConfigModel
|
||||
try:
|
||||
config_uuid = uuid.UUID(str(config_id))
|
||||
except (ValueError, AttributeError):
|
||||
exists = self.db.query(MemoryConfigModel).filter(
|
||||
MemoryConfigModel.config_id_old == int(config_id),
|
||||
MemoryConfigModel.workspace_id == workspace_id
|
||||
).first()
|
||||
if not exists:
|
||||
warnings.append(f"记忆配置 '{config_id}' 未匹配,已置空,请导入后手动配置")
|
||||
return {**memory, "memory_config_id": None, "enabled": False}
|
||||
return memory
|
||||
exists = self.db.query(MemoryConfigModel).filter(
|
||||
MemoryConfigModel.config_id == config_id,
|
||||
MemoryConfigModel.config_id == config_uuid,
|
||||
MemoryConfigModel.workspace_id == workspace_id
|
||||
).first()
|
||||
if not exists:
|
||||
|
||||
@@ -98,7 +98,7 @@ def create_long_term_memory_tool(
|
||||
**重要:如果用户的问题可以直接回答,不要调用此工具。只在确实需要历史信息时才使用。**
|
||||
|
||||
Args:
|
||||
question: 需要检索的问题(保持原问题的核心语义,使用清晰的关键词)
|
||||
question: 需要检索的问题(保持原问题的核心语义,使用清晰的关键词,第三人称描述的偏好、行为通常指用户本人,比如(我,本人,在下,自己,咱,鄙人,吴,余)通指用户)
|
||||
|
||||
Returns:
|
||||
检索到的历史记忆内容
|
||||
|
||||
@@ -535,7 +535,8 @@ def get_users_total_chunk_batch(
|
||||
|
||||
def get_rag_content(
|
||||
end_user_id: str,
|
||||
limit: int,
|
||||
page: int,
|
||||
pagesize: int,
|
||||
db: Session,
|
||||
current_user: User
|
||||
) -> dict:
|
||||
@@ -543,9 +544,9 @@ def get_rag_content(
|
||||
先在documents表中查询file_name=='end_user_id'+'.txt'的id和kb_id,
|
||||
然后调用/chunks/{kb_id}/{document_id}/chunks接口的相关代码获取所有内容,
|
||||
接着对获取的内容进行提取,只要page_content的内容,
|
||||
最后返回数据
|
||||
最后返回分页数据
|
||||
"""
|
||||
business_logger.info(f"获取RAG内容: end_user_id={end_user_id}, limit={limit}, 操作者: {current_user.username}")
|
||||
business_logger.info(f"获取RAG内容: end_user_id={end_user_id}, page={page}, pagesize={pagesize}, 操作者: {current_user.username}")
|
||||
|
||||
try:
|
||||
from app.models.document_model import Document
|
||||
@@ -562,63 +563,76 @@ def get_rag_content(
|
||||
if not documents:
|
||||
business_logger.warning(f"未找到文件: {file_name}")
|
||||
return {
|
||||
"total": 0,
|
||||
"contents": []
|
||||
"page": {
|
||||
"page": page,
|
||||
"pagesize": pagesize,
|
||||
"total": 0,
|
||||
"hasnext": False,
|
||||
},
|
||||
"items": []
|
||||
}
|
||||
|
||||
business_logger.info(f"找到 {len(documents)} 个文档记录")
|
||||
|
||||
# 3. 获取所有chunks的page_content
|
||||
all_contents = []
|
||||
total_chunks = 0
|
||||
# 3. 按全局偏移量计算当前页数据
|
||||
# 全局偏移范围:[offset_start, offset_end)
|
||||
offset_start = (page - 1) * pagesize
|
||||
offset_end = offset_start + pagesize
|
||||
|
||||
global_total = 0 # 所有文档的 chunk 总数
|
||||
page_contents = [] # 当前页的内容
|
||||
|
||||
for document in documents:
|
||||
try:
|
||||
# 获取知识库信息
|
||||
kb = knowledge_repository.get_knowledge_by_id(db, document.kb_id)
|
||||
if not kb:
|
||||
business_logger.warning(f"知识库不存在: kb_id={document.kb_id}")
|
||||
continue
|
||||
|
||||
# 初始化向量服务
|
||||
vector_service = ElasticSearchVectorFactory().init_vector(knowledge=kb)
|
||||
|
||||
# 获取该文档的所有chunks(分页获取)
|
||||
page = 1
|
||||
pagesize = 100 # 每页100条
|
||||
# 先用 pagesize=1 获取该文档的 chunk 总数
|
||||
doc_total, _ = vector_service.search_by_segment(
|
||||
document_id=str(document.id),
|
||||
query=None,
|
||||
pagesize=1,
|
||||
page=1,
|
||||
asc=True
|
||||
)
|
||||
|
||||
while True:
|
||||
total, items = vector_service.search_by_segment(
|
||||
doc_offset_start = global_total # 该文档在全局中的起始偏移
|
||||
doc_offset_end = global_total + doc_total # 该文档在全局中的结束偏移
|
||||
global_total += doc_total
|
||||
|
||||
# 当前页与该文档无交集,跳过
|
||||
if doc_offset_end <= offset_start or doc_offset_start >= offset_end:
|
||||
continue
|
||||
|
||||
# 计算需要从该文档取的局部范围
|
||||
local_start = max(offset_start - doc_offset_start, 0)
|
||||
local_end = min(offset_end - doc_offset_start, doc_total)
|
||||
need_count = local_end - local_start
|
||||
|
||||
# 换算成 ES 分页参数(ES page 从1开始)
|
||||
es_page = (local_start // pagesize) + 1
|
||||
es_offset_in_page = local_start % pagesize
|
||||
|
||||
fetched = []
|
||||
while len(fetched) < es_offset_in_page + need_count:
|
||||
_, items = vector_service.search_by_segment(
|
||||
document_id=str(document.id),
|
||||
query=None,
|
||||
pagesize=pagesize,
|
||||
page=page,
|
||||
page=es_page,
|
||||
asc=True
|
||||
)
|
||||
|
||||
if not items:
|
||||
break
|
||||
|
||||
# 提取page_content
|
||||
for item in items:
|
||||
all_contents.append(item.page_content)
|
||||
total_chunks += 1
|
||||
|
||||
# # 如果达到limit限制,直接返回
|
||||
# if limit > 0 and total_chunks >= limit:
|
||||
# business_logger.info(f"已达到limit限制: {limit}")
|
||||
# return {
|
||||
# "total": total_chunks,
|
||||
# "contents": all_contents[:limit]
|
||||
# }
|
||||
|
||||
# 检查是否还有下一页
|
||||
if page * pagesize >= total:
|
||||
break
|
||||
|
||||
page += 1
|
||||
fetched.extend(items)
|
||||
es_page += 1
|
||||
|
||||
business_logger.info(f"文档 {document.id} 获取了 {len(items)} 个chunks")
|
||||
slice_items = fetched[es_offset_in_page: es_offset_in_page + need_count]
|
||||
page_contents.extend([item.page_content for item in slice_items])
|
||||
|
||||
except Exception as e:
|
||||
business_logger.error(f"获取文档 {document.id} 的chunks失败: {str(e)}")
|
||||
@@ -626,11 +640,16 @@ def get_rag_content(
|
||||
|
||||
# 4. 返回结果
|
||||
result = {
|
||||
"total": total_chunks,
|
||||
"contents": all_contents[:limit] if limit > 0 else all_contents
|
||||
"page": {
|
||||
"page": page,
|
||||
"pagesize": pagesize,
|
||||
"total": global_total,
|
||||
"hasnext": offset_end < global_total,
|
||||
},
|
||||
"items": page_contents
|
||||
}
|
||||
|
||||
business_logger.info(f"成功获取RAG内容: total={total_chunks}, 返回={len(result['contents'])} 条")
|
||||
business_logger.info(f"成功获取RAG内容: total={global_total}, page={page}, 返回={len(page_contents)} 条")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
@@ -730,8 +749,8 @@ async def generate_rag_profile(
|
||||
if not end_user:
|
||||
raise ValueError(f"end_user {end_user_id} 不存在")
|
||||
|
||||
rag_content = get_rag_content(end_user_id, limit, db, current_user)
|
||||
chunks = rag_content.get("contents", [])
|
||||
rag_content = get_rag_content(end_user_id, page=1, pagesize=limit, db=db, current_user=current_user)
|
||||
chunks = rag_content.get("items", [])
|
||||
|
||||
if not chunks:
|
||||
business_logger.warning(f"未找到chunk内容,无法生产RAG画像: end_user_id={end_user_id}")
|
||||
|
||||
@@ -1727,6 +1727,150 @@ async def analytics_graph_data(
|
||||
|
||||
# 辅助函数
|
||||
|
||||
async def analytics_community_graph_data(
|
||||
db: Session,
|
||||
end_user_id: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
获取社区图谱数据,包含 Community 节点、ExtractedEntity 节点及其关系。
|
||||
|
||||
Returns:
|
||||
包含 nodes、edges、statistics 的字典,格式与 analytics_graph_data 一致
|
||||
"""
|
||||
try:
|
||||
user_uuid = uuid.UUID(end_user_id)
|
||||
repo = EndUserRepository(db)
|
||||
end_user = repo.get_by_id(user_uuid)
|
||||
if not end_user:
|
||||
return {
|
||||
"nodes": [], "edges": [],
|
||||
"statistics": {"total_nodes": 0, "total_edges": 0, "node_types": {}, "edge_types": {}},
|
||||
"message": "用户不存在"
|
||||
}
|
||||
|
||||
# 查询社区节点、实体节点、BELONGS_TO_COMMUNITY 边、实体间关系
|
||||
from app.repositories.neo4j.cypher_queries import GET_COMMUNITY_GRAPH_DATA
|
||||
rows = await _neo4j_connector.execute_query(GET_COMMUNITY_GRAPH_DATA, end_user_id=end_user_id)
|
||||
|
||||
nodes_map: Dict[str, dict] = {}
|
||||
edges_map: Dict[str, dict] = {}
|
||||
# 记录每个 Community 对应的实体 id 列表
|
||||
community_members: Dict[str, list] = {}
|
||||
|
||||
for row in rows:
|
||||
# Community 节点
|
||||
c_id = row["c_id"]
|
||||
if c_id and c_id not in nodes_map:
|
||||
raw = row["c_props"] or {}
|
||||
props = {k: _clean_neo4j_value(raw.get(k)) for k in (
|
||||
"community_id", "end_user_id", "member_count", "updated_at",
|
||||
"name", "summary", "core_entities",
|
||||
) if k in raw}
|
||||
nodes_map[c_id] = {
|
||||
"id": c_id,
|
||||
"label": "Community",
|
||||
"properties": props,
|
||||
}
|
||||
|
||||
# ExtractedEntity 节点 (e)
|
||||
e_id = row["e_id"]
|
||||
if e_id and e_id not in nodes_map:
|
||||
raw = row["e_props"] or {}
|
||||
props = {k: _clean_neo4j_value(raw.get(k)) for k in (
|
||||
"name", "end_user_id", "description", "created_at", "entity_type",
|
||||
) if k in raw}
|
||||
# 注入所属社区名称(c 是 e 直接归属的社区)
|
||||
c_raw = row["c_props"] or {}
|
||||
props["community_name"] = _clean_neo4j_value(c_raw.get("name")) or ""
|
||||
nodes_map[e_id] = {
|
||||
"id": e_id,
|
||||
"label": "ExtractedEntity",
|
||||
"properties": props,
|
||||
}
|
||||
|
||||
# ExtractedEntity 节点 (e2,可选)
|
||||
e2_id = row.get("e2_id")
|
||||
if e2_id and e2_id not in nodes_map:
|
||||
raw = row["e2_props"] or {}
|
||||
props = {k: _clean_neo4j_value(raw.get(k)) for k in (
|
||||
"name", "end_user_id", "description", "created_at", "entity_type",
|
||||
) if k in raw}
|
||||
# e2 的社区归属在后处理阶段通过 community_members 补充
|
||||
props["community_name"] = ""
|
||||
nodes_map[e2_id] = {
|
||||
"id": e2_id,
|
||||
"label": "ExtractedEntity",
|
||||
"properties": props,
|
||||
}
|
||||
|
||||
# BELONGS_TO_COMMUNITY 边
|
||||
b_id = row["b_id"]
|
||||
if b_id and b_id not in edges_map:
|
||||
edges_map[b_id] = {
|
||||
"id": b_id,
|
||||
"source": e_id,
|
||||
"target": c_id,
|
||||
}
|
||||
# 收集社区成员 id
|
||||
if c_id and e_id:
|
||||
community_members.setdefault(c_id, [])
|
||||
if e_id not in community_members[c_id]:
|
||||
community_members[c_id].append(e_id)
|
||||
|
||||
# EXTRACTED_RELATIONSHIP 边(可选)
|
||||
r_id = row.get("r_id")
|
||||
if r_id and r_id not in edges_map and e2_id:
|
||||
r_props = {k: _clean_neo4j_value(v) for k, v in (row["r_props"] or {}).items()}
|
||||
source = e_id if row.get("r_from_e") else e2_id
|
||||
target = e2_id if row.get("r_from_e") else e_id
|
||||
edges_map[r_id] = {
|
||||
"id": r_id,
|
||||
"source": source,
|
||||
"target": target,
|
||||
}
|
||||
|
||||
nodes = list(nodes_map.values())
|
||||
edges = list(edges_map.values())
|
||||
|
||||
# 为每个 Community 节点注入 member_entity_ids,同时补全 e2 节点的 community_name
|
||||
for c_id, member_ids in community_members.items():
|
||||
c_node = nodes_map.get(c_id)
|
||||
if c_node:
|
||||
c_node["properties"]["member_entity_ids"] = member_ids
|
||||
c_name = c_node["properties"].get("name") or ""
|
||||
# 补全属于该社区但 community_name 为空的实体(即 e2 节点)
|
||||
for eid in member_ids:
|
||||
e_node = nodes_map.get(eid)
|
||||
if e_node and e_node["label"] == "ExtractedEntity":
|
||||
if not e_node["properties"].get("community_name"):
|
||||
e_node["properties"]["community_name"] = c_name
|
||||
|
||||
node_type_counts: Dict[str, int] = {}
|
||||
for n in nodes:
|
||||
node_type_counts[n["label"]] = node_type_counts.get(n["label"], 0) + 1
|
||||
|
||||
return {
|
||||
"nodes": nodes,
|
||||
"edges": edges,
|
||||
"statistics": {
|
||||
"total_nodes": len(nodes),
|
||||
"total_edges": len(edges),
|
||||
"node_types": node_type_counts,
|
||||
}
|
||||
}
|
||||
|
||||
except ValueError:
|
||||
logger.error(f"无效的 end_user_id 格式: {end_user_id}")
|
||||
return {
|
||||
"nodes": [], "edges": [],
|
||||
"statistics": {"total_nodes": 0, "total_edges": 0, "node_types": {}, "edge_types": {}},
|
||||
"message": "无效的用户ID格式"
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"获取社区图谱数据失败: {str(e)}", exc_info=True)
|
||||
raise
|
||||
|
||||
|
||||
async def _extract_node_properties(label: str, properties: Dict[str, Any],node_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
根据节点类型提取需要的属性字段
|
||||
|
||||
137
api/app/tasks.py
@@ -1158,13 +1158,11 @@ def write_message_task(self, end_user_id: str, message: list[dict], config_id: s
|
||||
try:
|
||||
_r = get_sync_redis_client()
|
||||
if _r is not None:
|
||||
from datetime import timedelta as _td
|
||||
from datetime import timezone as _tz
|
||||
_CST = _tz(_td(hours=8))
|
||||
_now_cst = datetime.now(_CST).replace(tzinfo=None).isoformat()
|
||||
_now_utc = datetime.now(_tz.utc).isoformat()
|
||||
_r.set(
|
||||
f"write_message:last_done:{end_user_id}",
|
||||
_now_cst,
|
||||
_now_utc,
|
||||
ex=86400 * 30,
|
||||
)
|
||||
except Exception as _e:
|
||||
@@ -2662,3 +2660,134 @@ def write_perceptual_memory(
|
||||
file_url,
|
||||
file_message,
|
||||
))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 社区聚类补全任务(触发型)
|
||||
# =============================================================================
|
||||
|
||||
@celery_app.task(
|
||||
name="app.tasks.init_community_clustering_for_users",
|
||||
bind=True,
|
||||
ignore_result=False,
|
||||
max_retries=0,
|
||||
acks_late=False,
|
||||
time_limit=7200, # 2小时硬超时
|
||||
soft_time_limit=6900,
|
||||
)
|
||||
def init_community_clustering_for_users(self, end_user_ids: List[str]) -> Dict[str, Any]:
|
||||
"""触发型任务:检查指定用户列表,对有 ExtractedEntity 但无 Community 节点的用户执行全量聚类。
|
||||
|
||||
由 /dashboard/end_users 接口触发,已有社区节点的用户直接跳过。
|
||||
|
||||
Args:
|
||||
end_user_ids: 需要检查的用户 ID 列表
|
||||
|
||||
Returns:
|
||||
包含任务执行结果的字典
|
||||
"""
|
||||
start_time = time.time()
|
||||
|
||||
async def _run() -> Dict[str, Any]:
|
||||
from app.core.logging_config import get_logger
|
||||
from app.repositories.neo4j.community_repository import CommunityRepository
|
||||
from app.repositories.neo4j.neo4j_connector import Neo4jConnector
|
||||
from app.core.memory.storage_services.clustering_engine.label_propagation import LabelPropagationEngine
|
||||
|
||||
logger = get_logger(__name__)
|
||||
logger.info(f"[CommunityCluster] 开始社区聚类补全任务,候选用户数: {len(end_user_ids)}")
|
||||
|
||||
initialized = 0
|
||||
skipped = 0
|
||||
failed = 0
|
||||
|
||||
connector = Neo4jConnector()
|
||||
try:
|
||||
repo = CommunityRepository(connector)
|
||||
|
||||
# 批量预取所有用户的配置(内置兜底:用户配置不可用时自动回退到工作空间默认配置)
|
||||
user_llm_map: Dict[str, Optional[str]] = {}
|
||||
try:
|
||||
with get_db_context() as db:
|
||||
from app.services.memory_agent_service import get_end_users_connected_configs_batch
|
||||
from app.services.memory_config_service import MemoryConfigService
|
||||
batch_configs = get_end_users_connected_configs_batch(end_user_ids, db)
|
||||
for uid, cfg_info in batch_configs.items():
|
||||
config_id = cfg_info.get("memory_config_id")
|
||||
if config_id:
|
||||
try:
|
||||
cfg = MemoryConfigService(db).load_memory_config(config_id=config_id)
|
||||
user_llm_map[uid] = str(cfg.llm_model_id) if cfg.llm_model_id else None
|
||||
except Exception as e:
|
||||
logger.warning(f"[CommunityCluster] 用户 {uid} 加载 LLM 配置失败,将使用 None: {e}")
|
||||
user_llm_map[uid] = None
|
||||
else:
|
||||
user_llm_map[uid] = None
|
||||
except Exception as e:
|
||||
logger.warning(f"[CommunityCluster] 批量获取 LLM 配置失败,所有用户将使用 None: {e}")
|
||||
|
||||
for end_user_id in end_user_ids:
|
||||
try:
|
||||
# 已有社区节点则跳过
|
||||
has_communities = await repo.has_communities(end_user_id)
|
||||
if has_communities:
|
||||
skipped += 1
|
||||
logger.debug(f"[CommunityCluster] 用户 {end_user_id} 已有社区节点,跳过")
|
||||
continue
|
||||
|
||||
# 检查是否有 ExtractedEntity 节点
|
||||
entities = await repo.get_all_entities(end_user_id)
|
||||
if not entities:
|
||||
skipped += 1
|
||||
logger.debug(f"[CommunityCluster] 用户 {end_user_id} 无实体节点,跳过")
|
||||
continue
|
||||
|
||||
# 每个用户使用自己的 llm_model_id
|
||||
llm_model_id = user_llm_map.get(end_user_id)
|
||||
engine = LabelPropagationEngine(
|
||||
connector=connector,
|
||||
llm_model_id=llm_model_id,
|
||||
)
|
||||
|
||||
logger.info(f"[CommunityCluster] 用户 {end_user_id} 有 {len(entities)} 个实体,开始全量聚类,llm_model_id={llm_model_id}")
|
||||
await engine.full_clustering(end_user_id)
|
||||
initialized += 1
|
||||
logger.info(f"[CommunityCluster] 用户 {end_user_id} 聚类完成")
|
||||
|
||||
except Exception as e:
|
||||
failed += 1
|
||||
logger.error(f"[CommunityCluster] 用户 {end_user_id} 聚类失败: {e}")
|
||||
|
||||
finally:
|
||||
await connector.close()
|
||||
|
||||
logger.info(
|
||||
f"[CommunityCluster] 任务完成: 初始化={initialized}, 跳过={skipped}, 失败={failed}"
|
||||
)
|
||||
return {
|
||||
"status": "SUCCESS",
|
||||
"initialized": initialized,
|
||||
"skipped": skipped,
|
||||
"failed": failed,
|
||||
}
|
||||
|
||||
try:
|
||||
try:
|
||||
import nest_asyncio
|
||||
nest_asyncio.apply()
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
loop = set_asyncio_event_loop()
|
||||
result = loop.run_until_complete(_run())
|
||||
result["elapsed_time"] = time.time() - start_time
|
||||
result["task_id"] = self.request.id
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"status": "FAILURE",
|
||||
"error": str(e),
|
||||
"elapsed_time": time.time() - start_time,
|
||||
"task_id": self.request.id,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,38 @@
|
||||
{
|
||||
"v0.2.7": {
|
||||
"introduction": {
|
||||
"codeName": "武陵",
|
||||
"releaseDate": "2026-3-13",
|
||||
"upgradePosition": "🐻 应用可移植性、工具生态扩展与记忆智能精细化",
|
||||
"coreUpgrades": [
|
||||
"1. 应用管理与可移植性<br>* 应用导入/导出:全面支持 Agent 配置和工作流定义的导入导出,实现跨环境无缝迁移、备份和共享",
|
||||
"2. 工具生态扩展 🔌<br>* MCP 广场集成:工具管理接入 MCP 广场,提供集中式工具发现、浏览和集成枢纽",
|
||||
"3. 工作流增强 📝<br>* 备注节点:新增备注节点类型,支持工作流图中的内联文档和上下文说明,提升协作效率",
|
||||
"4. 记忆智能精细化 🧠<br>* 隐性记忆与情绪记忆生成逻辑优化:含数据存在性校验、时间轴筛选和兴趣分布缓存校验<br>* 兴趣分布生成逻辑改进:优化算法产生更准确的用户兴趣画像",
|
||||
"5. 用户体验改进 🎨<br>* 知识库分享加载状态:增加加载指示器,改善感知响应速度",
|
||||
"6. 稳健性与缺陷修复 🔧<br>* 应用调试终端用户管理:修复调试会话错误创建 end_user 记录问题<br>* 知识库数据集创建流程:解决创建数据集后无法进入下一步的缺陷<br>* RAG 空间记忆生成失败:修复记忆生成失败和存储中断的关键问题<br>* 应用字符限制强制执行:增加条件校验防止过长输入<br>* 语义剪枝情绪/兴趣保留:优化剪枝逻辑防止误删情绪和兴趣片段<br>* 语义剪枝效果优化:增强算法平衡记忆压缩与信息保留",
|
||||
"<br>",
|
||||
"v0.2.8 及更远的未来将引入多模态记忆能力,实现知识库和模型的分服务部署,为应用增加语音输入支持,并扩展应用能力至语音回复、BI 可视化、PPT 生成和直接生图。应用会话分享和联网搜索功能将得到修复和增强。记忆检索基准测试和情景记忆聚类算法将增强上下文召回和时序推理能力。通往真正智能、多模态、上下文感知应用的旅程仍在继续。",
|
||||
"记忆熊,智慧致远 🐻✨"
|
||||
]
|
||||
},
|
||||
"introduction_en": {
|
||||
"codeName": "WuLing",
|
||||
"releaseDate": "2026-3-13",
|
||||
"upgradePosition": "🐻 Application portability, tool ecosystem expansion, and memory intelligence refinement",
|
||||
"coreUpgrades": [
|
||||
"1. Application Management & Portability<br>* Application Import/Export: Full support for importing and exporting agent configurations and workflow definitions, enabling seamless cross-environment migration, backup, and sharing",
|
||||
"2. Tool Ecosystem Expansion 🔌<br>* MCP Marketplace Integration: Tool management now includes MCP Marketplace access for centralized tool discovery, browsing, and integration",
|
||||
"3. Workflow Enhancements 📝<br>* Annotation Node: Introduced annotation node type for inline documentation and contextual notes within workflow graphs, improving collaboration",
|
||||
"4. Memory Intelligence Refinement 🧠<br>* Implicit & Emotional Memory Generation Logic: Comprehensive optimization including data existence validation, timeline filtering, and interest distribution cache validation<br>* Interest Distribution Generation Logic: Refined algorithm for more accurate user interest profiles",
|
||||
"5. User Experience Improvements 🎨<br>* Knowledge Base Sharing Loading State: Added loading indicators to improve perceived responsiveness",
|
||||
"6. Robustness & Bug Fixes 🔧<br>* End User Management in App Debugging: Fixed incorrect end_user record creation during debugging sessions<br>* Knowledge Base Dataset Creation Flow: Resolved bug preventing next step after dataset creation<br>* RAG Space Memory Generation Failure: Fixed critical memory generation and storage interruption issue<br>* Application Character Limit Enforcement: Added conditional validation to prevent excessively long input<br>* Semantic Pruning Emotion/Interest Preservation: Optimized pruning logic to prevent incorrect deletion of emotional and interest fragments<br>* Semantic Pruning Effectiveness: Enhanced algorithm balance between memory compression and information retention",
|
||||
"<br>",
|
||||
"Looking forward to v0.2.8 and beyond, we will introduce multimodal memory capabilities with distributed service deployment for knowledge bases and models, enabling voice input for applications and expanding application capabilities with voice responses, BI visualizations, PPT generation, and direct image creation. Application conversation sharing and web search functionality will be restored and enhanced. Memory retrieval benchmarking and episodic memory clustering algorithms will enhance contextual recall and temporal reasoning. The journey toward truly intelligent, multimodal, context-aware applications continues.",
|
||||
"MemoryBear, Wisdom Reaching Far 🐻✨"
|
||||
]
|
||||
}
|
||||
},
|
||||
"v0.2.6": {
|
||||
"introduction": {
|
||||
"codeName": "听剑",
|
||||
|
||||
30
api/migrations/versions/ef9d172cb753_202603131800.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""202603131800
|
||||
|
||||
Revision ID: ef9d172cb753
|
||||
Revises: ea31b4e347d8
|
||||
Create Date: 2026-03-13 18:01:11.167711
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'ef9d172cb753'
|
||||
down_revision: Union[str, None] = 'ea31b4e347d8'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('app_shares', sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False, comment='是否有效,False 表示逻辑删除'))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('app_shares', 'is_active')
|
||||
# ### end Alembic commands ###
|
||||
@@ -44,6 +44,7 @@
|
||||
"i18next": "^25.6.0",
|
||||
"js-yaml": "^4.1.1",
|
||||
"lexical": "^0.39.0",
|
||||
"mammoth": "^1.12.0",
|
||||
"mermaid": "^11.12.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
@@ -59,6 +60,7 @@
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-math": "^6.0.0",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"xlsx": "^0.18.5",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 13:59:45
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-03 12:08:42
|
||||
* @Last Modified time: 2026-03-13 17:07:54
|
||||
*/
|
||||
import { request } from '@/utils/request'
|
||||
import type { ApplicationModalData } from '@/views/ApplicationManagement/types'
|
||||
import type { Config } from '@/views/ApplicationConfig/types'
|
||||
import type { Config, AppSharingForm } from '@/views/ApplicationConfig/types'
|
||||
import { handleSSE, type SSEMessage } from '@/utils/stream'
|
||||
import type { QueryParams } from '@/views/Conversation/types'
|
||||
import type { WorkflowConfig } from '@/views/Workflow/types'
|
||||
@@ -113,8 +113,8 @@ export const getShareToken = (share_token: string, user_id: string) => {
|
||||
return request.post(`/public/share/${share_token}/token`, { user_id })
|
||||
}
|
||||
// Copy application
|
||||
export const copyApplication = (app_id: string, new_name: string) => {
|
||||
return request.post(`/apps/${app_id}/copy?new_name=${new_name}`)
|
||||
export const copyApplication = (app_id: string, new_name?: string) => {
|
||||
return request.post(`/apps/${app_id}/copy`, { new_name })
|
||||
}
|
||||
// Data statistics
|
||||
export const getAppStatistics = (app_id: string, data: { start_date: number; end_date: number; }) => {
|
||||
@@ -143,4 +143,26 @@ export const appExport = (app_id: string, appName: string, data?: { release_vers
|
||||
// Import application
|
||||
export const appImport = (formData: FormData) => {
|
||||
return request.uploadFile(`/apps/import`, formData)
|
||||
}
|
||||
}
|
||||
|
||||
// Share application
|
||||
export const appSharing = (app_id: string, data: AppSharingForm) => {
|
||||
return request.post(`/apps/${app_id}/share`, data)
|
||||
}
|
||||
// Get my shared application records
|
||||
export const mySharedOutList = () => {
|
||||
return request.get(`/apps/my-shared-out`)
|
||||
}
|
||||
// Get sharing records for a specific application
|
||||
export const getAppShares = (app_id: string) => {
|
||||
return request.get(`/apps/${app_id}/shares`)
|
||||
}
|
||||
// Cancel a single share (source side operation)
|
||||
export const cancelShare = (app_id: string, target_workspace_id?: string) => {
|
||||
return request.delete(`/apps/${app_id}/share/${target_workspace_id}`)
|
||||
}
|
||||
// Cancel all shares under a workspace (source side operation)
|
||||
export const cancelSpaceShare = (target_workspace_id?: string) => {
|
||||
return request.delete(`/apps/share/${target_workspace_id}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -123,8 +123,9 @@ export const getChunkInsight = (end_user_id: string) => {
|
||||
return request.get(`/dashboard/chunk_insight`, { end_user_id })
|
||||
}
|
||||
// RAG User Memory - Storage content
|
||||
export const getRagContent = (end_user_id: string) => {
|
||||
return request.get(`/dashboard/rag_content`, { end_user_id, limit: 20 })
|
||||
export const getRagContentUrl = '/dashboard/rag_content'
|
||||
export const getRagContent = (end_user_id: string, page = 1, pagesize = 20) => {
|
||||
return request.get(getRagContentUrl, { end_user_id, page, pagesize })
|
||||
}
|
||||
// Emotion distribution analysis
|
||||
export const getWordCloud = (end_user_id: string) => {
|
||||
|
||||
@@ -6,12 +6,12 @@ export const getTools = (data: Query) => {
|
||||
return request.get('/tools', data)
|
||||
}
|
||||
// 创建MCP工具
|
||||
export const addTool = (values: MCPToolItem | CustomToolItem) => {
|
||||
return request.post('/tools', values)
|
||||
export const addTool = (values: MCPToolItem | CustomToolItem, config?: { signal?: AbortSignal }) => {
|
||||
return request.post('/tools', values, config)
|
||||
}
|
||||
// 更新工具
|
||||
export const updateTool = (tool_id: string, data: MCPToolItem | InnerToolItem | CustomToolItem) => {
|
||||
return request.put(`/tools/${tool_id}`, data)
|
||||
export const updateTool = (tool_id: string, data: MCPToolItem | InnerToolItem | CustomToolItem, config?: { signal?: AbortSignal }) => {
|
||||
return request.put(`/tools/${tool_id}`, data, config)
|
||||
}
|
||||
// 删除工具
|
||||
export const deleteTool = (tool_id: string) => {
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 14:00:26
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-03 14:00:26
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-13 15:29:03
|
||||
*/
|
||||
import { request } from '@/utils/request'
|
||||
import type { SpaceModalData } from '@/views/SpaceManagement/types'
|
||||
import type { SpaceConfigData } from '@/views/SpaceConfig/types'
|
||||
|
||||
// Workspace list
|
||||
export const getWorkspaces = () => {
|
||||
return request.get('/workspaces')
|
||||
export const getWorkspaces = (data?: { include_current?: boolean }) => {
|
||||
return request.get('/workspaces', data)
|
||||
}
|
||||
// Create workspace
|
||||
export const createWorkspace = (values: SpaceModalData) => {
|
||||
|
||||
11
web/src/assets/images/file/audio.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>音乐</title>
|
||||
<g id="空间里层页面优化" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" opacity="0.99">
|
||||
<g id="工作台-知识库-创建数据集-本地文件-2" transform="translate(-949, -851)" fill="#369F21" fill-rule="nonzero">
|
||||
<g id="音乐" transform="translate(949, 851)">
|
||||
<path d="M20,16.455738 C19.9998747,17.6766961 19.0342058,18.9480203 17.5433445,19.5353482 C15.7283829,20.2503561 13.8223534,19.6808938 13.2861085,18.2634188 C12.9392237,17.34645 13.2450501,16.2524025 14.0883859,15.3933709 C14.9317216,14.5343394 16.184444,14.0408311 17.3746646,14.0987459 C17.7820128,14.118567 18.1608598,14.201947 18.4969068,14.3398172 L18.4963476,7.04615138 C18.4963476,6.79692321 18.2897002,6.63396892 18.0924319,6.63396892 C18.0548497,6.63396892 18.0172674,6.64353993 17.9797072,6.65313341 L10.1453372,9.20299846 C9.96684901,9.25094338 9.85412426,9.41389767 9.85412426,9.59603891 L9.85412426,18.6260387 C9.84879171,19.3368367 9.52006796,20.0801486 8.91077204,20.700786 C8.06744003,21.5598158 6.81472186,22.0533241 5.62450435,21.995411 C4.43428684,21.9374978 3.48739235,21.3369617 3.14050687,20.4200187 C2.79362136,19.5030757 3.09944501,18.4090313 3.94277702,17.5500015 C4.78610903,16.6909716 6.0388272,16.1974633 7.22904471,16.2553765 C7.63671921,16.2752129 8.0158469,16.3587103 8.35209443,16.496778 L8.35111987,7.48711428 C8.35056786,6.63056391 8.90023435,5.87488803 9.70381726,5.62744028 L17.5288082,3.08714624 C18.7593794,2.70369927 19.9993516,3.6335475 19.9993516,4.93724922 Z" id="形状结合"></path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
16
web/src/assets/images/file/csv.svg
Normal file
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>编组 57</title>
|
||||
<g id="空间里层页面优化" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="工作台-知识库-创建数据集-本地文件-2" transform="translate(-443, -851)">
|
||||
<g id="编组-57" transform="translate(443, 851)">
|
||||
<path d="M6,1 L15.1715729,1 C15.7020059,1 16.2107137,1.21071368 16.5857864,1.58578644 L20.4142136,5.41421356 C20.7892863,5.78928632 21,6.29799415 21,6.82842712 L21,20 C21,21.6568542 19.6568542,23 18,23 L6,23 C4.34314575,23 3,21.6568542 3,20 L3,4 C3,2.34314575 4.34314575,1 6,1 Z" id="矩形" fill="#155EEF"></path>
|
||||
<g id="编组-11" transform="translate(4.5, 10)" fill="#FFFFFF" fill-rule="nonzero">
|
||||
<path d="M3.74998106,1.31509018 C3.74735762,1.4334406 3.69649081,1.54592661 3.60857717,1.62778835 C3.52066353,1.70965009 3.40290911,1.75417757 3.28123343,1.75156962 L2.8124858,1.75156962 C2.30551317,1.74138936 1.88592101,2.13264132 1.87499054,2.62574433 L1.87499054,4.37409375 C1.88592099,4.86719677 2.30551316,5.25844875 2.8124858,5.24826849 L3.28123343,5.24826849 C3.40290911,5.24566054 3.52066353,5.29018802 3.60857717,5.37204975 C3.69649081,5.45391149 3.74735762,5.56639751 3.74998106,5.68474793 L3.74998106,6.55892264 C3.74735763,6.67727307 3.69649082,6.78975909 3.60857718,6.87162083 C3.52066354,6.95348258 3.40290911,6.99801006 3.28123343,6.99540211 L2.8124858,6.99540211 C2.08181824,7.01169692 1.37453656,6.74441594 0.846835274,6.25258491 C0.319133983,5.76075387 0.0144251536,5.08483394 0,4.37409375 L0,2.62574433 C0.0144251609,1.91500414 0.319133993,1.23908422 0.846835283,0.747253188 C1.37453657,0.255422158 2.08181824,-0.0118588152 2.8124858,0.00443599745 L3.28123343,0.00443599745 C3.40290911,0.00182804652 3.52066353,0.0463555261 3.60857717,0.128217263 C3.69649081,0.210078999 3.74735762,0.322565017 3.74998106,0.440915438 L3.74998106,1.31509018 Z" id="路径"></path>
|
||||
<path d="M6.34371799,6.99540211 L5.6249716,6.99540211 C5.50329592,6.99801006 5.38554149,6.95348258 5.29762785,6.87162083 C5.20971421,6.78975909 5.1588474,6.67727307 5.15622397,6.55892264 L5.15622397,5.68474793 C5.15884741,5.56639751 5.20971422,5.45391149 5.29762786,5.37204975 C5.3855415,5.29018802 5.50329592,5.24566054 5.6249716,5.24826849 L6.34371799,5.24826849 C6.69246623,5.24826849 6.95371491,5.05738471 6.95371488,4.88352243 C6.94629613,4.7988134 6.90047953,4.72171762 6.82871553,4.67318553 L5.547472,3.65189656 C5.0625209,3.2801153 4.77555777,2.71562733 4.76622595,2.11509985 C4.85967193,0.868433381 5.96731214,-0.0728320292 7.24996338,0.00443599745 L7.96745977,0.00443599745 C8.08913545,0.00182804652 8.20688987,0.0463555261 8.29480351,0.128217263 C8.38271714,0.210078999 8.43358396,0.322565017 8.4362074,0.440915438 L8.4362074,1.31509018 C8.43358396,1.4334406 8.38271714,1.54592661 8.29480351,1.62778835 C8.20688987,1.70965009 8.08913545,1.75417757 7.96745977,1.75156962 L7.24996338,1.75156962 C6.90121515,1.75156962 6.63996646,1.94245339 6.63996649,2.11631568 C6.6477439,2.20058626 6.69352092,2.27717018 6.76496584,2.32543675 L8.04745938,3.35037315 C8.53241048,3.72215442 8.81937361,4.2866424 8.82870543,4.88716989 C8.73462185,6.13368729 7.62617195,7.0740823 6.34371799,6.99540211 L6.34371799,6.99540211 Z" id="路径"></path>
|
||||
<path d="M11.2499432,0.440915438 L11.2499432,1.5764915 C11.2512026,2.67777506 11.5765122,3.75569874 12.1874385,4.68291209 C12.7984289,3.75572719 13.1237441,2.67778486 13.1249337,1.5764915 L13.1249337,0.440915438 C13.1275572,0.322565017 13.178424,0.210078999 13.2663376,0.128217263 C13.3542513,0.0463555261 13.4720057,0.00182804652 13.5936814,0.00443599745 L14.5311766,0.00443599745 C14.6528523,0.00182804652 14.7706067,0.0463555261 14.8585204,0.128217263 C14.946434,0.210078999 14.9973008,0.322565017 14.9999243,0.440915438 L14.9999243,1.5764915 C15.0087494,3.4937602 14.2460649,5.33830752 12.874935,6.71576346 C12.6935617,6.89486746 12.4459015,6.99571622 12.1874385,6.99571622 C11.9289755,6.99571622 11.6813152,6.89486746 11.4999419,6.71576346 C10.128812,5.33830752 9.36612749,3.4937602 9.37495266,1.5764915 L9.37495266,0.440915438 C9.37757609,0.322565012 9.42844291,0.21007899 9.51635656,0.128217253 C9.6042702,0.0463555155 9.72202463,0.00182803917 9.84370031,0.00443599745 L10.7811956,0.00443599745 C10.9028712,0.00182804652 11.0206257,0.0463555261 11.1085393,0.128217263 C11.1964529,0.210078999 11.2473198,0.322565017 11.2499432,0.440915438 L11.2499432,0.440915438 Z" id="路径"></path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.6 KiB |
15
web/src/assets/images/file/excel.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>Excel</title>
|
||||
<g id="空间里层页面优化" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="工作台-知识库-创建数据集-本地文件-2" transform="translate(-296, -851)">
|
||||
<g id="Excel" transform="translate(296, 851)">
|
||||
<g id="编组-9" transform="translate(3, 1)">
|
||||
<path d="M3,0 L12.5983161,0 L12.5983161,0 L18,5.5 L18,19 C18,20.6568542 16.6568542,22 15,22 L3,22 C1.34314575,22 -4.4408921e-16,20.6568542 -4.4408921e-16,19 L0,3 C0,1.34314575 1.34314575,0 3,0 Z" id="矩形" fill="#369F21"></path>
|
||||
<path d="M4.47756884,8.31184687 L7.89908257,12.834 L7.89908257,12.834 L4.23221368,17.6923338 C4.16833301,17.776971 4.18515952,17.8973686 4.26979675,17.9612493 C4.30311465,17.9863962 4.34372024,18 4.38546294,18 L5.94228393,18 C6.09575976,18 6.23998192,17.9266088 6.33032081,17.8025374 L9,14.136 L9,14.136 L11.6696792,17.8025374 C11.7600181,17.9266088 11.9042402,18 12.0577161,18 L13.6127141,18 C13.7187527,18 13.8047141,17.9140387 13.8047141,17.808 C13.8047141,17.7660102 13.790949,17.7251781 13.7655273,17.6917583 L10.0703364,12.834 L10.0703364,12.834 L13.5200138,8.31246056 C13.5843332,8.22815626 13.5681322,8.10767294 13.4838279,8.04335355 C13.4503697,8.01782685 13.4094514,8.004 13.3673674,8.004 L11.8130677,8.004 C11.6595919,8.004 11.5153698,8.07739117 11.4250309,8.20146259 L9,11.532 L9,11.532 L6.57496912,8.20146259 C6.48463024,8.07739117 6.34040808,8.004 6.18693225,8.004 L4.63068156,8.004 C4.52464289,8.004 4.43868156,8.08996133 4.43868156,8.196 C4.43868156,8.2378202 4.45233583,8.27849685 4.47756884,8.31184687 Z" id="路径" fill="#FFFFFF" fill-rule="nonzero"></path>
|
||||
<path d="M12.6,0 L18,5.5 L14.328,5.5 C13.373652,5.5 12.6,4.72634805 12.6,3.772 L12.6,0 L12.6,0 Z" id="矩形" fill-opacity="0.5" fill="#FFFFFF"></path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
15
web/src/assets/images/file/html.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>Word</title>
|
||||
<g id="空间里层页面优化" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="工作台-知识库-创建数据集-本地文件-2" transform="translate(-555, -851)">
|
||||
<g id="Word" transform="translate(555, 851)">
|
||||
<g id="编组-9" transform="translate(3, 1)">
|
||||
<path d="M3,0 L12.5983161,0 L12.5983161,0 L18,5.5 L18,19 C18,20.6568542 16.6568542,22 15,22 L3,22 C1.34314575,22 -4.4408921e-16,20.6568542 -4.4408921e-16,19 L0,3 C0,1.34314575 1.34314575,0 3,0 Z" id="矩形" fill="#155EEF"></path>
|
||||
<path d="M12.6,0 L18,5.5 L14.76,5.5 C13.5670649,5.5 12.6,4.53293506 12.6,3.34 L12.6,0 L12.6,0 Z" id="矩形" fill-opacity="0.5" fill="#FFFFFF"></path>
|
||||
<path d="M6.30572751,10.1239093 L4.42963674,12 L6.30572751,13.8760908 C6.61579533,14.1971281 6.61136076,14.7074326 6.29576054,15.0230328 C5.98016031,15.338633 5.4698558,15.3430676 5.14881851,15.0329998 L2.69427324,12.5784545 C2.37486988,12.2589547 2.37486988,11.7410454 2.69427324,11.4215455 L5.14881851,8.96700027 C5.4698558,8.65693244 5.98016031,8.661367 6.29576054,8.97696722 C6.61136076,9.29256744 6.61579533,9.80287195 6.30572751,10.1239093 L6.30572751,10.1239093 Z M11.6942725,13.8760908 L13.5703633,12 L11.6942725,10.1239093 C11.3842047,9.80287195 11.3886393,9.29256744 11.7042395,8.97696722 C12.0198397,8.661367 12.5301442,8.65693244 12.8511815,8.96700027 L15.3057268,11.4215455 C15.6251302,11.7410454 15.6251302,12.2589547 15.3057268,12.5784545 L12.8511815,15.0329998 C12.5301442,15.3430676 12.0198397,15.338633 11.7042395,15.0230328 C11.3886393,14.7074326 11.3842047,14.1971281 11.6942725,13.8760908 L11.6942725,13.8760908 Z M9.47536361,8.81400027 C9.65746679,8.40542152 10.1339436,8.21882948 10.5450755,8.39509277 C10.9562074,8.57135605 11.1495693,9.04512595 10.9791817,9.45872749 L8.52463643,15.1859998 C8.34253325,15.5945785 7.86605643,15.7811706 7.45492455,15.6049073 C7.04379267,15.428644 6.85043075,14.9548741 7.02081835,14.5412726 L9.47536361,8.81400027 Z" id="形状结合" fill="#FFFFFF" fill-rule="nonzero"></path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
15
web/src/assets/images/file/image.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>编组 58</title>
|
||||
<g id="空间里层页面优化" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="工作台-知识库-创建数据集-本地文件-2" transform="translate(-823, -851)">
|
||||
<g id="编组-58" transform="translate(823, 851)">
|
||||
<g id="编组-14" transform="translate(2, 2)">
|
||||
<rect id="矩形" fill="#9C6FFF" x="0" y="0" width="20" height="20" rx="4"></rect>
|
||||
<circle id="椭圆形" fill="#FFFFFF" cx="6.7" cy="6.15" r="2.75"></circle>
|
||||
<path d="M5.0161215,17.5 L12.9367104,17.5 C18.5091262,17.5 18.6712744,12.5070581 17.2413994,11.2815463 C16.8361052,10.9341783 16.4185445,10.5833222 15.9887171,10.2289779 L15.5545124,9.87379582 C14.8635955,9.31203668 13.8276109,9.39262388 13.2405761,10.0537924 C13.2084868,10.0899341 13.1781242,10.1274478 13.1495818,10.1662183 L11.2529579,12.7408093 C10.4310905,13.8557427 8.85224477,14.1797794 7.62487827,13.4854236 L6.18377361,12.6710243 C5.61174537,12.348034 4.87992638,12.4649012 4.44849452,12.9481383 L3.22751176,14.5162069 C2.11457779,15.8777759 2.497689,17.5 5.0161215,17.5 Z" id="路径-27" fill="#FFFFFF"></path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
12
web/src/assets/images/file/json.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>JSON</title>
|
||||
<g id="空间里层页面优化" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="工作台-知识库-创建数据集-本地文件-2" transform="translate(-676, -851)" fill-rule="nonzero">
|
||||
<g id="JSON" transform="translate(676, 851)">
|
||||
<path d="M3,5.69333333 C3,4.05066667 3,3.22933333 3.30184615,2.6016 C3.56733763,2.04968249 3.990959,1.60095763 4.512,1.31973333 C5.10461538,1 5.88,1 7.43076923,1 L13.8595385,1 C14.7062308,1 15.1299231,1 15.528,1.1012 C15.8811528,1.19112104 16.2187447,1.33935687 16.5283846,1.54046667 C16.878,1.76706667 17.1770769,2.08386667 17.7759231,2.7182 L19.3779231,4.41513333 C19.9767692,5.04946667 20.2758462,5.36626667 20.4897692,5.7366 C20.6794615,6.0644 20.8193077,6.42226667 20.9044615,6.79626667 C21,7.21793333 21,7.66673333 21,8.5636 L21,18.3066667 C21,19.9493333 21,20.7706667 20.6981538,21.3984 C20.4326624,21.9503175 20.009041,22.3990424 19.488,22.6802667 C18.8953846,23 18.12,23 16.5692308,23 L7.43076923,23 C5.88,23 5.10461538,23 4.512,22.6802667 C3.990959,22.3990424 3.56733763,21.9503175 3.30184615,21.3984 C3,20.7706667 3,19.9493333 3,18.3066667 L3,5.69333333 Z" id="路径" fill="#369F21"></path>
|
||||
<path d="M8.84341209,9 C6.94273128,9 6.56627954,9.55244338 6.56627954,10.5255406 L6.56627954,11.8664226 C6.56627954,12.3951132 6.46455747,12.6065895 6,12.6065895 L6,14.0179636 C6.46455747,14.0179636 6.56627954,14.2294398 6.56627954,14.7581304 L6.56627954,16.0990124 C6.56627954,17.0667461 6.94273128,17.6268517 8.84421306,17.6268517 L8.84421306,16.4553039 C8.34120945,16.4553039 8.2659191,16.3181509 8.2659191,15.9771837 L8.2659191,14.6286395 C8.2659191,13.7750724 7.83259912,13.433339 7.14457349,13.3199387 C7.8494193,13.1743572 8.25790949,12.8655713 8.25790949,11.9951473 L8.25790949,10.6466031 C8.25790949,10.3064022 8.33239888,10.1684829 8.84421306,10.1684829 L8.84421306,9 L8.84341209,9 Z M12.0552663,11.8426698 C11.5274329,11.8426698 11.1045254,12.2487655 11.1045254,12.7437426 C11.1102202,13.2425271 11.5338357,13.6443537 12.0552663,13.6455815 C12.5735717,13.6401939 12.9923657,13.2395657 12.9979976,12.7437426 C12.9979976,12.2495318 12.5718863,11.8419036 12.0552663,11.8419036 L12.0552663,11.8426698 Z M12.0552663,14.7504682 C11.5106127,14.7504682 11.1045254,15.1389409 11.1045254,15.6438788 C11.1045254,15.9610931 11.256708,16.2285033 11.5274329,16.392474 L11.1293552,18 L12.0632759,18 L12.6327593,16.7732845 C12.8786544,16.2369317 12.9979976,15.9610931 12.9979976,15.6523072 C12.9979976,15.1389409 12.5887064,14.7504682 12.0552663,14.7504682 Z M15.1565879,9 L15.1565879,10.1684829 C15.6643973,10.1684829 15.7420905,10.3064022 15.7420905,10.6473693 L15.7420905,11.9959135 C15.7420905,12.8655713 16.1481778,13.1743572 16.8530236,13.3199387 C16.164998,13.4341052 15.7340809,13.7750724 15.7340809,14.6286395 L15.7340809,15.9771837 C15.7340809,16.3181509 15.6563877,16.4553039 15.1565879,16.4553039 L15.1565879,17.6276179 C17.0572687,17.6276179 17.4313176,17.0667461 17.4313176,16.0990124 L17.4313176,14.7581304 C17.4313176,14.2294398 17.5330396,14.0179636 18,14.0179636 L18,12.6065895 C17.5330396,12.6065895 17.4313176,12.394347 17.4313176,11.8656564 L17.4313176,10.5255406 C17.4313176,9.55244338 17.0572687,9 15.1565879,9 L15.1565879,9 Z" id="形状" fill="#FFFFFF" opacity="0.98"></path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
17
web/src/assets/images/file/md.svg
Normal file
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>PDF</title>
|
||||
<g id="空间里层页面优化" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="工作台-知识库-创建数据集-本地文件-2" transform="translate(-554, -811)">
|
||||
<g id="PDF" transform="translate(554, 811)">
|
||||
<g id="编组-9" transform="translate(3, 1)">
|
||||
<path d="M2.88,0 L12.5983161,0 L12.5983161,0 L18,5.5 L18,19.12 C18,20.7105801 16.7105801,22 15.12,22 L2.88,22 C1.28941992,22 1.33226763e-15,20.7105801 1.33226763e-15,19.12 L0,2.88 C0,1.28941992 1.28941992,-8.8817842e-16 2.88,-8.8817842e-16 Z" id="矩形" fill="#9C6FFF"></path>
|
||||
<path d="M12.6,0 L18,5.5 L14.328,5.5 C13.373652,5.5 12.6,4.72634805 12.6,3.772 L12.6,0 L12.6,0 Z" id="矩形" fill-opacity="0.5" fill="#FFFFFF"></path>
|
||||
</g>
|
||||
<text id="MD" font-family="Rubik-Medium, Rubik" font-size="10" font-weight="400" fill="#FFFFFF">
|
||||
<tspan x="4.5" y="17">MD</tspan>
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
18
web/src/assets/images/file/pdf.svg
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>PDF</title>
|
||||
<g id="空间里层页面优化" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="工作台-知识库-创建数据集-本地文件-2" transform="translate(-443, -811)">
|
||||
<g id="PDF" transform="translate(443, 811)">
|
||||
<g id="编组-9" transform="translate(3, 1)">
|
||||
<path d="M2.88,0 L12.5983161,0 L12.5983161,0 L18,5.5 L18,19.12 C18,20.7105801 16.7105801,22 15.12,22 L2.88,22 C1.28941992,22 1.33226763e-15,20.7105801 1.33226763e-15,19.12 L0,2.88 C0,1.28941992 1.28941992,-8.8817842e-16 2.88,-8.8817842e-16 Z" id="矩形" fill="#FF5D34"></path>
|
||||
<path d="M12.6,0 L18,5.5 L14.328,5.5 C13.373652,5.5 12.6,4.72634805 12.6,3.772 L12.6,0 L12.6,0 Z" id="矩形" fill-opacity="0.5" fill="#FFFFFF"></path>
|
||||
<g id="pdf" transform="translate(2.7, 5.5)" fill="#FFFFFF" fill-rule="nonzero">
|
||||
<rect id="矩形" opacity="0" x="0" y="0" width="12.5969524" height="12.8333333"></rect>
|
||||
<path d="M10.8473784,9.58129971 C9.90260697,9.50981236 8.9928216,9.152451 8.25799118,8.50919049 C6.82335331,8.83082074 5.45867526,9.29540057 4.09400952,9.86719885 C3.00926922,11.8326989 1.99451336,12.8333333 1.11972637,12.8333333 C0.944771429,12.8333333 0.734818122,12.7976022 0.594861552,12.6903838 C0.209941005,12.5117031 0,12.1186106 0,11.725493 C0,11.4038628 0.0699844356,10.5104594 3.39417747,9.0452451 C4.16399396,7.61577452 4.75884321,6.15057284 5.24873426,4.61389636 C4.82882765,3.7562115 3.91904228,1.64773675 4.5488899,0.575652663 C4.75884321,0.182535062 5.17873752,-0.0318767295 5.6336425,0.00385438127 C5.98355238,0.00385438127 6.33346226,0.182547626 6.54341556,0.468434203 C6.99830824,1.11169471 6.96330988,2.46970306 6.36846063,4.47094679 C6.92832381,5.54304344 7.66314193,6.5079342 8.53792892,7.32988795 C9.27275935,7.18693838 10.0075775,7.07971992 10.7423956,7.07971992 C12.3870114,7.11546359 12.6319384,7.90167366 12.5969524,8.36624092 C12.5969524,9.58129971 11.4422276,9.58129971 10.8473784,9.58129971 L10.8473784,9.58129971 Z M1.04974193,11.7969552 L1.15472474,11.7612241 C1.64460348,11.5825309 2.02949943,11.2251695 2.30943717,10.7605897 C1.78457235,10.975014 1.36466574,11.3323754 1.04974193,11.7969678 L1.04974193,11.7969552 Z M5.70361464,1.0759636 L5.59864414,1.0759636 C5.56365807,1.0759636 5.49367363,1.0759636 5.45867526,1.11169471 C5.31871869,1.7192241 5.4236892,2.3624846 5.66862857,2.93427032 C5.87858188,2.32674093 5.87858188,1.68348042 5.70361464,1.0759636 Z M5.94856631,6.25777873 L5.91356795,6.32926608 L5.87858188,6.29352241 C5.56365807,7.11546359 5.21373589,7.93740477 4.82882765,8.72361485 L4.89882438,8.68787117 L4.89882438,8.75935852 C5.66862857,8.47345938 6.5084295,8.22330392 7.27823369,8.04461067 L7.24324762,8.00887956 L7.34821812,8.00887956 C6.82335331,7.47282495 6.33346226,6.86530812 5.94856631,6.25777873 Z M10.7074095,8.15182913 C10.3924857,8.15182913 10.112548,8.15182913 9.79762416,8.22330392 C10.1475463,8.4019846 10.4974685,8.47344682 10.8473784,8.50919049 C11.0923055,8.54493417 11.3372571,8.50919049 11.5472104,8.43771571 C11.5472104,8.33050981 11.4072416,8.15182913 10.7074095,8.15182913 L10.7074095,8.15182913 Z" id="形状"></path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
12
web/src/assets/images/file/ppt.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>file-ppt-2-fill</title>
|
||||
<g id="空间里层页面优化" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="工作台-知识库-创建数据集-本地文件-2" transform="translate(-676, -811)" fill-rule="nonzero">
|
||||
<g id="file-ppt-2-fill" transform="translate(676, 811)">
|
||||
<path d="M16.7502375,3.34859818 L20.5500475,3.34859818 C21.0746918,3.34859818 21.5,3.77897542 21.5,4.30987266 L21.5,19.6902643 C21.5,20.2211615 21.0746918,20.6515387 20.5500475,20.6515387 L16.7502375,20.6515387 L16.7502375,3.34859818 L16.7502375,3.34859818 Z M3.31695914,3.23036142 L15.2578621,1.50487373 C15.3940533,1.4851035 15.5320247,1.52606362 15.6360746,1.61715516 C15.7401245,1.7082467 15.8000282,1.84051837 15.800285,1.97974333 L15.800285,22.0203936 C15.7999894,22.1594252 15.7402137,22.2915157 15.6363863,22.382572 C15.5325589,22.4736282 15.3948533,22.514728 15.2588121,22.4952632 L3.31600918,20.7697755 C2.84783928,20.7023027 2.5,20.2966443 2.5,19.8181138 L2.5,4.18202315 C2.5,3.70349267 2.84783928,3.29783423 3.31600918,3.23036142 L3.31695914,3.23036142 Z" id="形状结合" fill="#FF5D34"></path>
|
||||
<path d="M12.4504275,8.15497055 C12.7265699,8.15497055 12.9504275,8.37882818 12.9504275,8.65497055 L12.9504275,13.4226174 C12.9504275,13.6987598 12.7265699,13.9226174 12.4504275,13.9226174 L7.25071246,13.9226174 L7.25071246,15.5 C7.25071246,15.7761424 7.02685484,16 6.75071246,16 L5.85080744,16 C5.57466507,16 5.35080744,15.7761424 5.35080744,15.5 L5.35080744,8.65497055 C5.35080744,8.37882818 5.57466507,8.15497055 5.85080744,8.15497055 Z M11.0505225,10.0775195 L7.25071246,10.0775195 L7.25071246,12.0000685 L11.0505225,12.0000685 L11.0505225,10.0775195 Z" id="形状结合" fill="#FFFFFF"></path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
12
web/src/assets/images/file/txt.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>txt</title>
|
||||
<g id="空间里层页面优化" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="工作台-知识库-创建数据集-本地文件-2" transform="translate(-821, -811)">
|
||||
<g id="txt" transform="translate(821, 811)">
|
||||
<rect id="矩形" fill="#4DA8FF" x="2" y="2" width="20" height="20" rx="4"></rect>
|
||||
<path d="M16,8 C16.5522847,8 17,8.44771525 17,9 C17,9.55228475 16.5522847,10 16,10 L13,10 L13,17 C13,17.5522847 12.5522847,18 12,18 C11.4477153,18 11,17.5522847 11,17 L11,10 L8,10 C7.44771525,10 7,9.55228475 7,9 C7,8.44771525 7.44771525,8 8,8 L16,8 Z" id="形状结合" fill="#FFFFFF"></path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 920 B |
14
web/src/assets/images/file/video.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>编组 59</title>
|
||||
<g id="空间里层页面优化" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="工作台-知识库-创建数据集-本地文件-2" transform="translate(-949, -811)">
|
||||
<g id="编组-59" transform="translate(949, 811)">
|
||||
<g id="编组-15" transform="translate(1, 4.5)">
|
||||
<path d="M3.6,0 L12.9,0 C14.8882251,-4.4408921e-16 16.5,1.6117749 16.5,3.6 L16.5,11.4 C16.5,13.3882251 14.8882251,15 12.9,15 L3.6,15 C1.6117749,15 -4.4408921e-16,13.3882251 -4.4408921e-16,11.4 L0,3.6 C-4.4408921e-16,1.6117749 1.6117749,0 3.6,-4.4408921e-16 Z M17.879231,4.05647049 L20.3282054,2.69447403 C20.8712,2.39248768 21.5561925,2.58786354 21.8581788,3.13085821 C21.9511875,3.29809499 22,3.48629259 22,3.67765286 L22,5.5744503 L22,5.5744503 L22,11.3223471 C22,11.9436675 21.4963203,12.4473471 20.875,12.4473471 C20.6836397,12.4473471 20.4954421,12.3985347 20.3282054,12.305526 L17.879231,10.9435295 C17.5937122,10.7847383 17.4166667,10.4836906 17.4166667,10.1569864 L17.4166667,4.84301355 C17.4166667,4.51630942 17.5937122,4.21526166 17.879231,4.05647049 Z" id="形状结合" fill="#4DA8FF"></path>
|
||||
<path d="M7.56036738,10.1956474 L10.0614601,8.64798876 C10.6954768,8.25566369 10.8914066,7.42364931 10.4990815,6.78963262 C10.389064,6.61183886 10.2392539,6.46202877 10.0614601,6.35201124 L7.56036738,4.80435262 C6.92635069,4.41202755 6.09433631,4.60795731 5.70201124,5.241974 C5.56995501,5.45538338 5.5,5.7013784 5.5,5.95234138 L5.5,9.04765862 C5.5,9.79324304 6.10441559,10.3976586 6.85,10.3976586 C7.10096297,10.3976586 7.34695799,10.3277036 7.56036738,10.1956474 Z" id="路径-30" fill="#FFFFFF"></path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
15
web/src/assets/images/file/word.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>Word</title>
|
||||
<g id="空间里层页面优化" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="工作台-知识库-创建数据集-本地文件-2" transform="translate(-296, -811)">
|
||||
<g id="Word" transform="translate(296, 811)">
|
||||
<g id="编组-9" transform="translate(3, 1)">
|
||||
<path d="M3,0 L12.5983161,0 L12.5983161,0 L18,5.5 L18,19 C18,20.6568542 16.6568542,22 15,22 L3,22 C1.34314575,22 -4.4408921e-16,20.6568542 -4.4408921e-16,19 L0,3 C0,1.34314575 1.34314575,0 3,0 Z" id="矩形" fill="#155EEF"></path>
|
||||
<path d="M12.6,0 L18,5.5 L14.76,5.5 C13.5670649,5.5 12.6,4.53293506 12.6,3.34 L12.6,0 L12.6,0 Z" id="矩形" fill-opacity="0.5" fill="#FFFFFF"></path>
|
||||
<path d="M9.00012556,10.5092 L10.4465805,15.7628533 C10.4850891,15.902729 10.6152746,16 10.7640042,16 L11.6343858,16 C11.7829678,16 11.9130713,15.9029416 11.9517273,15.7632533 L13.9886889,8.40325334 C13.996199,8.37610652 14,8.34811442 14,8.32 C14,8.14328 13.8528458,8 13.6713474,8 L12.6970019,8 C12.5438715,8 12.4110293,8.10296221 12.3767026,8.24826667 L11.1226189,13.5577333 L9.75980609,8.24250667 C9.72327861,8.10001319 9.59182177,8 9.44098566,8 L8.55932024,8 C8.40846371,8 8.27697746,8.0999939 8.24044504,8.24250667 L6.88037096,13.5472267 L5.6179067,8.24770667 C5.5833622,8.10267332 5.45065244,8 5.29774428,8 L4.32873942,8 C4.29993141,8 4.27124802,8.00368186 4.24342668,8.01096 C4.06814528,8.05682667 3.96423628,8.23237334 4.01134316,8.40304 L6.04277237,15.76304 C6.08134559,15.9028129 6.21146947,16 6.36011386,16 L7.23624697,16 C7.38497656,16 7.51516205,15.902729 7.55367062,15.7628533 L9.00012556,10.5092 Z" id="路径" fill="#FFFFFF" fill-rule="nonzero"></path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -1,13 +1,23 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-06 21:11:51
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-13 17:11:14
|
||||
*/
|
||||
import { type FC, useRef, useState } from 'react'
|
||||
import RecordRTC from 'recordrtc'
|
||||
|
||||
import { fileUploadUrlWithoutApiPrefix } from '@/api/fileStorage'
|
||||
import { request } from '@/utils/request'
|
||||
|
||||
/** Props for the AudioRecorder component */
|
||||
interface AudioRecorderProps {
|
||||
/** Callback fired when recording is complete, receives uploaded file info and raw blob */
|
||||
onRecordingComplete?: (file: { file_id: string; file_key: string; url: string; type?: string; }, blob?: Blob) => void
|
||||
className?: string;
|
||||
/** Upload endpoint URL, defaults to fileUploadUrlWithoutApiPrefix */
|
||||
action?: string;
|
||||
/** Additional config passed to the upload request */
|
||||
requestConfig?: Record<string, any>;
|
||||
}
|
||||
|
||||
@@ -17,9 +27,12 @@ const AudioRecorder: FC<AudioRecorderProps> = ({
|
||||
action = fileUploadUrlWithoutApiPrefix,
|
||||
requestConfig = {}
|
||||
}) => {
|
||||
// Whether the recorder is currently capturing audio
|
||||
const [isRecording, setIsRecording] = useState(false)
|
||||
// Holds the RecordRTC instance across renders
|
||||
const recorderRef = useRef<RecordRTC | null>(null)
|
||||
|
||||
/** Request microphone access and start recording */
|
||||
const startRecording = async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
@@ -34,6 +47,7 @@ const AudioRecorder: FC<AudioRecorderProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop recording, upload the audio blob, then invoke the completion callback */
|
||||
const stopRecording = () => {
|
||||
if (recorderRef.current) {
|
||||
recorderRef.current.stopRecording(() => {
|
||||
@@ -49,6 +63,7 @@ const AudioRecorder: FC<AudioRecorderProps> = ({
|
||||
type: blob.type,
|
||||
url
|
||||
}, blob)
|
||||
// Release recorder resources after upload
|
||||
recorderRef.current?.destroy()
|
||||
recorderRef.current = null
|
||||
})
|
||||
@@ -57,12 +72,14 @@ const AudioRecorder: FC<AudioRecorderProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle between recording/idle states on click;
|
||||
// swap background image to reflect current state
|
||||
return (
|
||||
<div
|
||||
className={`rb:size-5.5 rb:cursor-pointer rb:bg-cover ${className} ${
|
||||
isRecording
|
||||
? `rb:bg-[url('@/assets/images/conversation/audio_ing.gif')]`
|
||||
: `rb:bg-[url('@/assets/images/conversation/audio.svg')] rb:hover:bg-[url('@/assets/images/conversation/audio_hover.svg')]`
|
||||
: `rb:bg-[url('@/assets/images/conversation/audio.svg')]`
|
||||
}`}
|
||||
onClick={isRecording ? stopRecording : startRecording}
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 15:01:59
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 15:46:05
|
||||
* @Last Modified time: 2026-03-12 14:59:38
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import { type FC, type ReactNode, useEffect } from 'react';
|
||||
import { type RadioGroupProps } from 'antd';
|
||||
import { type RadioGroupProps, Flex } from 'antd';
|
||||
import clsx from 'clsx'
|
||||
|
||||
// Button checkbox component props
|
||||
@@ -32,6 +32,7 @@ interface ButtonCheckboxProps extends Omit<RadioGroupProps, 'onChange'> {
|
||||
checkedIcon?: string;
|
||||
/** Button content */
|
||||
children?: ReactNode
|
||||
cicle?: boolean;
|
||||
}
|
||||
|
||||
const ButtonCheckbox: FC<ButtonCheckboxProps> = ({
|
||||
@@ -41,6 +42,7 @@ const ButtonCheckbox: FC<ButtonCheckboxProps> = ({
|
||||
icon,
|
||||
checkedIcon,
|
||||
children,
|
||||
cicle = false
|
||||
}) => {
|
||||
// Listen to value changes and trigger side effects via onValueChange callback
|
||||
useEffect(() => {
|
||||
@@ -57,21 +59,26 @@ const ButtonCheckbox: FC<ButtonCheckboxProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx("rb:flex rb:items-center rb:border rb:rounded-lg rb:px-2 rb:text-[12px] rb:h-6 rb:cursor-pointer rb:hover:bg-[#F0F3F8]", {
|
||||
<Flex
|
||||
align="center"
|
||||
justify={cicle ? 'center' : 'start'}
|
||||
gap={4}
|
||||
className={clsx("rb:flex rb:items-center rb:cursor-pointer rb:border rb:hover:bg-[#F6F6F6]", {
|
||||
'rb:size-7 rb:rounded-[14px] rb:border-[0.5px] rb:border-[#EBEBEB]': cicle,
|
||||
'rb:rounded-lg rb:px-2 rb:text-[12px] rb:h-6': !cicle,
|
||||
// Checked state: blue background and border
|
||||
"rb:bg-[rgba(21,94,239,0.06)] rb:border-[#155EEF] rb:text-[#155EEF]": checked,
|
||||
"rb:bg-[rgba(21,94,239,0.06)] rb:border-[rgba(21,94,239,0.25)] rb:hover:bg-[rgba(21,94,239,0.06)] rb:text-[#155EEF]": checked,
|
||||
// Unchecked state: gray border and dark text
|
||||
"rb:border-[#DFE4ED] rb:text-[#212332]": !checked,
|
||||
})}
|
||||
onClick={handleChange}
|
||||
>
|
||||
{/* Display unchecked icon when not checked */}
|
||||
{icon && !checked && <img src={icon} className="rb:w-4 rb:h-4 rb:mr-1" />}
|
||||
{icon && !checked && <img src={icon} className="rb:size-4" />}
|
||||
{/* Display checked icon when checked */}
|
||||
{checkedIcon && checked && <img src={checkedIcon} className="rb:w-4 rb:h-4 rb:mr-1" />}
|
||||
{children}
|
||||
</div>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2025-12-10 16:46:09
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-06 21:05:09
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-12 13:57:49
|
||||
*/
|
||||
import { type FC } from 'react'
|
||||
import ChatInput from './ChatInput'
|
||||
@@ -25,7 +25,8 @@ const Chat: FC<ChatProps> = ({
|
||||
labelFormat,
|
||||
errorDesc,
|
||||
fileList,
|
||||
fileChange
|
||||
fileChange,
|
||||
renderRuntime
|
||||
}) => {
|
||||
return (
|
||||
<div className="rb:h-full rb:relative rb:pt-2">
|
||||
@@ -37,6 +38,7 @@ const Chat: FC<ChatProps> = ({
|
||||
empty={empty}
|
||||
labelFormat={labelFormat}
|
||||
errorDesc={errorDesc}
|
||||
renderRuntime={renderRuntime}
|
||||
/>
|
||||
|
||||
{/* Chat input area */}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2025-12-10 16:45:54
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-06 21:05:09
|
||||
* @Last Modified time: 2026-03-12 13:57:51
|
||||
*/
|
||||
import { type ReactNode } from 'react'
|
||||
|
||||
@@ -53,6 +53,7 @@ export interface ChatProps {
|
||||
fileList?: any[];
|
||||
/** Attachment update */
|
||||
fileChange?: (fileList: any[]) => void;
|
||||
renderRuntime?: (item: ChatItem, index: number) => ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
import { useState, useEffect, type FC } from 'react';
|
||||
import { Spin, Alert, Button } from 'antd';
|
||||
import { ReloadOutlined } from '@ant-design/icons';
|
||||
import { Spin, Alert, Button, Table } from 'antd';
|
||||
import { ReloadOutlined, DownloadOutlined } from '@ant-design/icons';
|
||||
import RbMarkdown from '../Markdown';
|
||||
import { cookieUtils } from '@/utils/request'
|
||||
|
||||
type PreviewMode = 'office' | 'google';
|
||||
import { cookieUtils } from '@/utils/request';
|
||||
import mammoth from 'mammoth';
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
interface DocumentPreviewProps {
|
||||
fileUrl: string;
|
||||
fileName?: string;
|
||||
fileExt?: string; // 文件扩展名(优先使用)
|
||||
fileExt?: string;
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
className?: string;
|
||||
mode?: PreviewMode; // 预览模式
|
||||
showModeSwitch?: boolean; // 是否显示模式切换按钮
|
||||
}
|
||||
|
||||
const DocumentPreview: FC<DocumentPreviewProps> = ({
|
||||
@@ -24,18 +22,19 @@ const DocumentPreview: FC<DocumentPreviewProps> = ({
|
||||
width = '100%',
|
||||
height = '600px',
|
||||
className = '',
|
||||
mode = 'office',
|
||||
showModeSwitch = true,
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
const [currentMode, setCurrentMode] = useState<PreviewMode>(mode);
|
||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||
const [textContent, setTextContent] = useState<string>('');
|
||||
const [htmlContent, setHtmlContent] = useState<string>('');
|
||||
const [excelData, setExcelData] = useState<{ sheetName: string; data: any[][] }[]>([]);
|
||||
|
||||
// 支持的文件类型
|
||||
const supportedTypes = ['.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.pdf', '.txt', '.md', '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'];
|
||||
// 支持预览的文件类型
|
||||
const previewableTypes = ['.pdf', '.txt', '.md', '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.doc', '.docx', '.xls', '.xlsx'];
|
||||
// PPT 暂不支持
|
||||
const downloadOnlyTypes = ['.ppt', '.pptx'];
|
||||
|
||||
// 获取文件扩展名(优先使用 fileExt prop)
|
||||
const getFileExtension = () => {
|
||||
if (fileExt) {
|
||||
return fileExt.toLowerCase().startsWith('.') ? fileExt.toLowerCase() : `.${fileExt.toLowerCase()}`;
|
||||
@@ -45,67 +44,25 @@ const DocumentPreview: FC<DocumentPreviewProps> = ({
|
||||
return match ? `.${match[1].toLowerCase()}` : '';
|
||||
};
|
||||
|
||||
// 检查是否为文本文件
|
||||
const isTextFile = () => {
|
||||
const ext = getFileExtension();
|
||||
return ext === '.txt';
|
||||
};
|
||||
|
||||
// 检查是否为 Markdown 文件
|
||||
const isMarkdownFile = () => {
|
||||
const ext = getFileExtension();
|
||||
return ext === '.md';
|
||||
};
|
||||
|
||||
// 检查是否为图片文件
|
||||
const isTextFile = () => getFileExtension() === '.txt';
|
||||
const isMarkdownFile = () => getFileExtension() === '.md';
|
||||
const isImageFile = () => {
|
||||
const ext = getFileExtension();
|
||||
const imageExts = ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'];
|
||||
return imageExts.includes(ext);
|
||||
};
|
||||
|
||||
// 检查文件类型是否支持
|
||||
const isSupportedFile = () => {
|
||||
const ext = getFileExtension();
|
||||
return ext && supportedTypes.includes(ext);
|
||||
return imageExts.includes(getFileExtension());
|
||||
};
|
||||
const isPdfFile = () => getFileExtension() === '.pdf';
|
||||
const isWordFile = () => ['.doc', '.docx'].includes(getFileExtension());
|
||||
const isExcelFile = () => ['.xls', '.xlsx'].includes(getFileExtension());
|
||||
const isPreviewable = () => previewableTypes.includes(getFileExtension());
|
||||
const isDownloadOnly = () => downloadOnlyTypes.includes(getFileExtension());
|
||||
|
||||
// 检查是否为 PDF 文件
|
||||
const isPdfFile = () => {
|
||||
const ext = getFileExtension();
|
||||
return ext === '.pdf';
|
||||
};
|
||||
|
||||
// 构建预览 URL
|
||||
const getPreviewUrl = () => {
|
||||
// 处理文件 URL,如果是完整的 URL,转换为代理路径
|
||||
let requestUrl = fileUrl;
|
||||
|
||||
// 如果是完整的 https://devapi.mem.redbearai.com 开头的 URL,提取路径部分
|
||||
// 这样可以通过代理访问,避免 CORS 问题
|
||||
if (fileUrl.includes('devapi.mem.redbearai.com')) {
|
||||
const url = new URL(fileUrl);
|
||||
requestUrl = url.pathname; // 只取路径部分,例如 /api/files/xxx
|
||||
}
|
||||
|
||||
// 对于 PDF 文件,直接使用浏览器内置预览
|
||||
if (isPdfFile()) {
|
||||
return requestUrl;
|
||||
}
|
||||
|
||||
// 确保 fileUrl 是完整的 URL(用于第三方预览服务)
|
||||
let fullUrl = fileUrl;
|
||||
if (!fileUrl.startsWith('http')) {
|
||||
fullUrl = `${window.location.origin}${fileUrl.startsWith('/') ? '' : '/'}${fileUrl}`;
|
||||
}
|
||||
console.log('预览 URL:', fullUrl);
|
||||
// 根据模式选择预览服务
|
||||
if (currentMode === 'google') {
|
||||
return `https://docs.google.com/viewer?url=${encodeURIComponent(fullUrl)}&embedded=true`;
|
||||
}
|
||||
|
||||
// 默认使用 Microsoft Office Online Viewer
|
||||
return `https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(fullUrl)}`;
|
||||
const handleDownload = () => {
|
||||
const link = document.createElement('a');
|
||||
link.href = fileUrl;
|
||||
link.download = fileName || 'document';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
const handleLoad = () => {
|
||||
@@ -113,20 +70,24 @@ const DocumentPreview: FC<DocumentPreviewProps> = ({
|
||||
setError(false);
|
||||
};
|
||||
|
||||
const handleError = () => {
|
||||
const handleError = (msg?: string) => {
|
||||
setLoading(false);
|
||||
setError(true);
|
||||
if (msg) setErrorMessage(msg);
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
setErrorMessage('');
|
||||
|
||||
if (isTextFile() || isMarkdownFile()) {
|
||||
// 重新加载文本文件
|
||||
loadTextFile();
|
||||
} else if (isWordFile()) {
|
||||
loadWordFile();
|
||||
} else if (isExcelFile()) {
|
||||
loadExcelFile();
|
||||
} else {
|
||||
// 强制重新加载 iframe
|
||||
const iframe = document.querySelector(`iframe[title="${fileName || '文档预览'}"]`) as HTMLIFrameElement;
|
||||
if (iframe) {
|
||||
iframe.src = iframe.src;
|
||||
@@ -134,82 +95,164 @@ const DocumentPreview: FC<DocumentPreviewProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleSwitchMode = () => {
|
||||
setCurrentMode(prev => prev === 'office' ? 'google' : 'office');
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
};
|
||||
|
||||
// 加载文本文件内容
|
||||
const loadTextFile = async () => {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
setErrorMessage('');
|
||||
try {
|
||||
// 处理文件 URL,如果是完整的 URL,转换为代理路径
|
||||
let requestUrl = fileUrl;
|
||||
|
||||
// 如果是完整的 https://devapi.mem.redbearai.com 开头的 URL,提取路径部分
|
||||
if (fileUrl.includes('devapi.mem.redbearai.com')) {
|
||||
const url = new URL(fileUrl);
|
||||
requestUrl = url.pathname; // 只取路径部分,例如 /api/files/xxx
|
||||
requestUrl = url.pathname;
|
||||
}
|
||||
|
||||
const response = await fetch(requestUrl, {
|
||||
credentials: 'include', // 包含认证信息
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${cookieUtils.get('authToken') || ''}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load file');
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
// 检查响应的 Content-Type
|
||||
const contentType = response.headers.get('Content-Type') || '';
|
||||
console.log('文件 Content-Type:', contentType);
|
||||
|
||||
// 如果是图片类型,显示错误提示
|
||||
if (contentType.startsWith('image/')) {
|
||||
setError(true);
|
||||
setTextContent('');
|
||||
setLoading(false);
|
||||
console.error('文件实际是图片类型,但被标记为 txt');
|
||||
handleError('文件实际是图片类型,但被标记为文本文件');
|
||||
return;
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
|
||||
// 检查是否是二进制数据(如 PNG 文件头)
|
||||
if (text.startsWith('\x89PNG') || text.startsWith('<27>PNG')) {
|
||||
setError(true);
|
||||
setTextContent('');
|
||||
setLoading(false);
|
||||
console.error('文件内容是 PNG 图片,但扩展名是 txt');
|
||||
handleError('文件内容是图片,但扩展名是文本');
|
||||
return;
|
||||
}
|
||||
|
||||
setTextContent(text);
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
console.error('加载文本文件失败:', err);
|
||||
setError(true);
|
||||
setLoading(false);
|
||||
handleError(err.message || '加载文本文件失败');
|
||||
}
|
||||
};
|
||||
|
||||
const loadWordFile = async () => {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
setErrorMessage('');
|
||||
try {
|
||||
let requestUrl = fileUrl;
|
||||
|
||||
if (fileUrl.includes('devapi.mem.redbearai.com')) {
|
||||
const url = new URL(fileUrl);
|
||||
requestUrl = url.pathname;
|
||||
}
|
||||
|
||||
const response = await fetch(requestUrl, {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${cookieUtils.get('authToken') || ''}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const result = await mammoth.convertToHtml({ arrayBuffer });
|
||||
setHtmlContent(result.value);
|
||||
setLoading(false);
|
||||
} catch (err: any) {
|
||||
console.error('加载 Word 文件失败:', err);
|
||||
handleError(err.message || '加载 Word 文件失败,文件可能已损坏');
|
||||
}
|
||||
};
|
||||
|
||||
const loadExcelFile = async () => {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
setErrorMessage('');
|
||||
try {
|
||||
let requestUrl = fileUrl;
|
||||
|
||||
if (fileUrl.includes('devapi.mem.redbearai.com')) {
|
||||
const url = new URL(fileUrl);
|
||||
requestUrl = url.pathname;
|
||||
}
|
||||
|
||||
const response = await fetch(requestUrl, {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${cookieUtils.get('authToken') || ''}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const workbook = XLSX.read(arrayBuffer, { type: 'array' });
|
||||
|
||||
const sheets = workbook.SheetNames.map(sheetName => {
|
||||
const worksheet = workbook.Sheets[sheetName];
|
||||
const data = XLSX.utils.sheet_to_json(worksheet, { header: 1 }) as any[][];
|
||||
return { sheetName, data };
|
||||
});
|
||||
|
||||
setExcelData(sheets);
|
||||
setLoading(false);
|
||||
} catch (err: any) {
|
||||
console.error('加载 Excel 文件失败:', err);
|
||||
handleError(err.message || '加载 Excel 文件失败,文件可能已损坏');
|
||||
}
|
||||
};
|
||||
|
||||
// 当文件是 txt 或 md 时,加载文本内容
|
||||
useEffect(() => {
|
||||
if (isTextFile() || isMarkdownFile()) {
|
||||
loadTextFile();
|
||||
} else if (isWordFile()) {
|
||||
loadWordFile();
|
||||
} else if (isExcelFile()) {
|
||||
loadExcelFile();
|
||||
}
|
||||
}, [fileUrl]);
|
||||
|
||||
if (!isSupportedFile()) {
|
||||
// PPT 文件只提供下载
|
||||
if (isDownloadOnly()) {
|
||||
return (
|
||||
<div className={`rb:relative rb:flex rb:items-center rb:justify-center rb:bg-gray-50 rb:rounded rb:border rb:border-gray-200 ${className}`} style={{ width, height }}>
|
||||
<Alert
|
||||
message="PowerPoint 文档预览"
|
||||
description={
|
||||
<div className="rb:text-center">
|
||||
<p className="rb:mb-4">PPT 文件暂不支持在线预览,请下载后查看</p>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={handleDownload}
|
||||
>
|
||||
下载文件
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
type="info"
|
||||
showIcon
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isPreviewable()) {
|
||||
return (
|
||||
<Alert
|
||||
message="不支持的文件类型"
|
||||
description={`仅支持以下文件类型:${supportedTypes.join(', ')}`}
|
||||
description={`仅支持预览:${previewableTypes.join(', ')}`}
|
||||
type="warning"
|
||||
showIcon
|
||||
/>
|
||||
@@ -230,23 +273,26 @@ const DocumentPreview: FC<DocumentPreviewProps> = ({
|
||||
message="预览失败"
|
||||
description={
|
||||
<div>
|
||||
<p>无法加载文档预览,可能的原因:</p>
|
||||
<ul className="rb:list-disc rb:pl-5 rb:mt-2">
|
||||
<li>文件需要认证访问,Office 预览服务无法访问</li>
|
||||
<li>文件 URL 无法公开访问(需要配置公开访问或临时签名 URL)</li>
|
||||
<li>文件大小超过限制(Office 预览通常限制 10MB)</li>
|
||||
<li>预览服务暂时不可用</li>
|
||||
<p className="rb:mb-2">无法加载文档预览</p>
|
||||
{errorMessage && (
|
||||
<p className="rb:text-sm rb:text-red-600 rb:mb-3">
|
||||
错误详情:{errorMessage}
|
||||
</p>
|
||||
)}
|
||||
<p className="rb:text-sm rb:text-gray-600 rb:mb-3">可能的原因:</p>
|
||||
<ul className="rb:list-disc rb:pl-5 rb:text-sm rb:text-gray-600 rb:mb-3">
|
||||
<li>文件 URL 无法访问(401/403/404)</li>
|
||||
<li>认证 token 已过期</li>
|
||||
<li>文件格式损坏或不匹配</li>
|
||||
<li>网络连接问题</li>
|
||||
</ul>
|
||||
<p className="rb:mt-2 rb:text-gray-600">建议:请下载文件到本地查看</p>
|
||||
<div className="rb:mt-4 rb:flex rb:gap-2">
|
||||
<Button icon={<ReloadOutlined />} onClick={handleRetry}>
|
||||
重试
|
||||
</Button>
|
||||
{showModeSwitch && !isPdfFile() && (
|
||||
<Button onClick={handleSwitchMode}>
|
||||
切换到 {currentMode === 'office' ? 'Google' : 'Office'} 预览
|
||||
</Button>
|
||||
)}
|
||||
<Button icon={<DownloadOutlined />} onClick={handleDownload}>
|
||||
下载文件
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -256,26 +302,23 @@ const DocumentPreview: FC<DocumentPreviewProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 图片文件预览 */}
|
||||
{isImageFile() && !error && !loading && (
|
||||
<div className="rb:w-full rb:h-full rb:overflow-auto rb:bg-gray-50 rb:flex rb:items-center rb:justify-center">
|
||||
<img
|
||||
src={fileUrl}
|
||||
alt={fileName || '图片预览'}
|
||||
className="rb:max-w-full rb:max-h-full rb:object-contain"
|
||||
onError={() => setError(true)}
|
||||
onError={() => handleError('图片加载失败')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Markdown 文件预览 */}
|
||||
{isMarkdownFile() && !error && !loading && (
|
||||
<div className="rb:w-full rb:h-full rb:overflow-auto rb:bg-white rb:p-6 rb:rounded rb:border rb:border-gray-200">
|
||||
<RbMarkdown content={textContent} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 文本文件预览 */}
|
||||
{isTextFile() && !error && !loading && (
|
||||
<div className="rb:w-full rb:h-full rb:overflow-auto rb:bg-white rb:p-4 rb:rounded rb:border rb:border-gray-200">
|
||||
<pre className="rb:whitespace-pre-wrap rb:text-sm rb:text-gray-800 rb:font-mono">
|
||||
@@ -284,44 +327,52 @@ const DocumentPreview: FC<DocumentPreviewProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PDF 文件预览(使用浏览器内置预览) */}
|
||||
{isWordFile() && !error && !loading && (
|
||||
<div className="rb:w-full rb:h-full rb:overflow-auto rb:bg-white rb:p-6 rb:rounded rb:border rb:border-gray-200">
|
||||
<div
|
||||
className="rb:prose rb:max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: htmlContent }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isExcelFile() && !error && !loading && (
|
||||
<div className="rb:w-full rb:h-full rb:overflow-auto rb:bg-white rb:p-4 rb:rounded rb:border rb:border-gray-200">
|
||||
{excelData.map((sheet, index) => (
|
||||
<div key={index} className="rb:mb-6">
|
||||
<h3 className="rb:text-lg rb:font-semibold rb:mb-3">{sheet.sheetName}</h3>
|
||||
{sheet.data.length > 0 && (
|
||||
<Table
|
||||
dataSource={sheet.data.slice(1).map((row, idx) => ({ key: idx, ...row }))}
|
||||
columns={sheet.data[0]?.map((header: any, colIdx: number) => ({
|
||||
title: header || `列 ${colIdx + 1}`,
|
||||
dataIndex: colIdx,
|
||||
key: colIdx,
|
||||
width: 150,
|
||||
})) || []}
|
||||
pagination={false}
|
||||
scroll={{ x: 'max-content' }}
|
||||
size="small"
|
||||
bordered
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isPdfFile() && !error && !loading && (
|
||||
<iframe
|
||||
src={getPreviewUrl()}
|
||||
src={fileUrl}
|
||||
width="100%"
|
||||
height="100%"
|
||||
title={fileName || 'PDF 预览'}
|
||||
className="rb:border-0"
|
||||
style={{ border: 'none' }}
|
||||
onLoad={handleLoad}
|
||||
onError={handleError}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Office 文件预览 */}
|
||||
{!isTextFile() && !isMarkdownFile() && !isImageFile() && !isPdfFile() && (
|
||||
<>
|
||||
{showModeSwitch && !loading && !error && (
|
||||
<div className="rb:absolute rb:top-2 rb:right-2 rb:z-20">
|
||||
<Button size="small" onClick={handleSwitchMode}>
|
||||
切换到 {currentMode === 'office' ? 'Google' : 'Office'} 预览
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && (
|
||||
<iframe
|
||||
src={getPreviewUrl()}
|
||||
width="100%"
|
||||
height="100%"
|
||||
onLoad={handleLoad}
|
||||
onError={handleError}
|
||||
title={fileName || '文档预览'}
|
||||
className="rb:border-0"
|
||||
style={{ display: loading ? 'none' : 'block', border: 'none' }}
|
||||
sandbox="allow-scripts allow-same-origin allow-popups"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 15:18:19
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-03 15:44:42
|
||||
* @Last Modified time: 2026-03-12 18:36:19
|
||||
*/
|
||||
/**
|
||||
* PageScrollList Component
|
||||
@@ -60,8 +60,8 @@ interface PageScrollListProps<T, Q = Record<string, unknown>> {
|
||||
|
||||
/** Infinite scroll list component with pagination support */
|
||||
const PageScrollList = forwardRef(<T, Q = Record<string, unknown>>({
|
||||
renderItem,
|
||||
query,
|
||||
renderItem,
|
||||
query,
|
||||
url,
|
||||
column = 4,
|
||||
className = '',
|
||||
@@ -69,68 +69,70 @@ const PageScrollList = forwardRef(<T, Q = Record<string, unknown>>({
|
||||
}: PageScrollListProps<T, Q>, ref: React.Ref<PageScrollListRef>) => {
|
||||
/** Expose refresh method to parent component */
|
||||
useImperativeHandle(ref, () => ({
|
||||
refresh,
|
||||
refresh: () => {
|
||||
pageRef.current = 1;
|
||||
loadingRef.current = false;
|
||||
setHasMore(true);
|
||||
setData([]);
|
||||
loadMoreData(true);
|
||||
},
|
||||
}));
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [data, setData] = useState<T[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const pageRef = useRef(1);
|
||||
const loadingRef = useRef(false);
|
||||
const hasMoreRef = useRef(true);
|
||||
|
||||
/** Load more data from API with pagination */
|
||||
const loadMoreData = (flag?: boolean) => {
|
||||
if (!flag && (loading || !hasMore)) {
|
||||
return;
|
||||
}
|
||||
const loadMoreData = (reset?: boolean) => {
|
||||
if (loadingRef.current || (!reset && !hasMoreRef.current)) return;
|
||||
loadingRef.current = true;
|
||||
setLoading(true);
|
||||
const currentPage = reset ? 1 : pageRef.current;
|
||||
request.get(url, {
|
||||
page: page,
|
||||
page: currentPage,
|
||||
pagesize: PAGE_SIZE,
|
||||
...(query||{}),
|
||||
...(query || {}),
|
||||
})
|
||||
.then((res) => {
|
||||
const response = res as ApiResponse<T>;
|
||||
const results = Array.isArray(response.items) ? response.items : Array.isArray(response) ? response as T[] : [];
|
||||
// Replace data if flag is true, otherwise append
|
||||
if (flag) {
|
||||
setData(results);
|
||||
} else {
|
||||
setData(data.concat(results));
|
||||
}
|
||||
setPage(response.page.page + 1);
|
||||
pageRef.current = response.page.page + 1;
|
||||
setData(prev => reset ? results : [...prev, ...results]);
|
||||
hasMoreRef.current = response.page?.hasnext;
|
||||
setHasMore(response.page?.hasnext);
|
||||
setLoading(false);
|
||||
console.log(`${results.length} more items loaded!`);
|
||||
})
|
||||
.catch(() => {
|
||||
setLoading(false);
|
||||
hasMoreRef.current = false;
|
||||
setHasMore(false);
|
||||
console.error('Failed to load data');
|
||||
})
|
||||
.finally(() => {
|
||||
loadingRef.current = false;
|
||||
setLoading(false);
|
||||
// 内容不足以填满容器时,主动继续加载
|
||||
setTimeout(() => {
|
||||
const el = scrollRef.current;
|
||||
console.log(el, el?.scrollHeight, el?.clientHeight, hasMoreRef.current)
|
||||
if (el && hasMoreRef.current && el.scrollHeight <= el.clientHeight) {
|
||||
loadMoreData();
|
||||
}
|
||||
}, 0);
|
||||
});
|
||||
};
|
||||
|
||||
/** Reset list to initial state and reload data */
|
||||
const refresh = () => {
|
||||
setPage(1);
|
||||
/** Reset and reload when query parameters change */
|
||||
const queryKey = JSON.stringify(query);
|
||||
useEffect(() => {
|
||||
pageRef.current = 1;
|
||||
loadingRef.current = false;
|
||||
hasMoreRef.current = true;
|
||||
setHasMore(true);
|
||||
setData([]);
|
||||
}
|
||||
loadMoreData(true);
|
||||
}, [queryKey]);
|
||||
|
||||
/** Refresh when query parameters change */
|
||||
useEffect(() => {
|
||||
refresh()
|
||||
}, [query]);
|
||||
|
||||
/** Load initial data when list is reset */
|
||||
useEffect(() => {
|
||||
if (page === 1 && hasMore && data.length === 0) {
|
||||
loadMoreData(true);
|
||||
}
|
||||
}, [page, hasMore, data])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
@@ -140,7 +142,7 @@ const PageScrollList = forwardRef(<T, Q = Record<string, unknown>>({
|
||||
>
|
||||
<InfiniteScroll
|
||||
dataLength={data.length}
|
||||
next={loadMoreData}
|
||||
next={() => loadMoreData()}
|
||||
hasMore={hasMore}
|
||||
loader={loading && needLoading ? <PageLoading /> : false}
|
||||
// endMessage={<Divider plain>It is all, nothing more 🤐</Divider>}
|
||||
|
||||
@@ -458,6 +458,7 @@ export const en = {
|
||||
imageSquareRequired: 'Please upload a square image',
|
||||
nameInvalid: 'Name cannot start or end with a space',
|
||||
notAllSpaces: 'Cannot be all spaces',
|
||||
view: 'View',
|
||||
},
|
||||
model: {
|
||||
searchPlaceholder: 'search model…',
|
||||
@@ -1370,7 +1371,60 @@ export const en = {
|
||||
gotoList: 'Return to Application List',
|
||||
gotoDetail: 'View Details',
|
||||
dify: 'Dify',
|
||||
pleaseUploadFile: 'Please upload workflow file',
|
||||
pleaseUploadFile: 'Please upload file',
|
||||
setting: 'Settings',
|
||||
funConfig: 'Features',
|
||||
fileUpload: 'File Upload',
|
||||
fileUploadDesc: 'The chat input box supports file uploads. Types include images, documents, and other types',
|
||||
settings: 'File Upload Settings',
|
||||
uploadType: 'Upload Type',
|
||||
local: 'Local Upload',
|
||||
both: 'Both',
|
||||
maxSize: 'Maximum Upload',
|
||||
maxSizeDesc: 'Documents < 200.00MB, Images < 10.00MB, Audio < 50.00MB, Video < 100.00MB',
|
||||
supportedTypes: 'Supported File Types',
|
||||
document: 'Document',
|
||||
image: 'Image',
|
||||
audio: 'Audio',
|
||||
video: 'Video',
|
||||
other: 'Other File Types',
|
||||
otherFormats: 'Specify other file types',
|
||||
maxCount: 'Max Files',
|
||||
singleMaxSize: 'Max Size',
|
||||
unix: 'items',
|
||||
textTranfer: 'Text to Speech',
|
||||
textTranferDesc: 'Text can be converted to speech',
|
||||
|
||||
apps: 'My Apps',
|
||||
sharing: 'Sharing',
|
||||
sharingApp: 'Shared Apps',
|
||||
myShare: 'My Shares',
|
||||
selectTargetSpace: 'Select Target Space',
|
||||
alreadyShared: 'Already Shared',
|
||||
permissionMode: 'Permission Mode',
|
||||
readonlyMode: 'Use Shared',
|
||||
readonlyModeDesc: 'Can test and run, cannot view internals',
|
||||
editableMode: 'Copy Shared',
|
||||
editableModeDesc: 'Copy full replica, free to edit',
|
||||
confirmSharing: 'Confirm Sharing',
|
||||
selectAtLeastOneSpace: 'Please select at least one target space',
|
||||
test: 'Conversation Test',
|
||||
log: 'Logs',
|
||||
testChatEmpty: 'Send a message to test the shared app',
|
||||
allCancel: 'Cancel All',
|
||||
cancelShare: 'Cancel Sharing',
|
||||
confirmAppCancelShareDesc: 'Are you sure to cancel sharing of 【{{app}}】 app with 【{{workspace}}】 space? The other party will no longer have access after cancellation.',
|
||||
confirmWorkspaceCancelShareDesc: 'Are you sure to cancel all apps shared with 【{{workspace}}】? This action cannot be undone.',
|
||||
sourceActive: 'Active',
|
||||
sourceInactive: 'Inactive',
|
||||
readonly: 'Use Only',
|
||||
editable: 'Copyable',
|
||||
version: 'Version',
|
||||
permission: 'Permission',
|
||||
souceStatus: 'Source App Status',
|
||||
confirmCopyDesc: 'Are you sure to copy 【{{app}}】 app?',
|
||||
noShareAuth: 'No permission to share apps',
|
||||
appCount: '{{count}} apps shared to this space',
|
||||
},
|
||||
userMemory: {
|
||||
userMemory: 'User Memory',
|
||||
@@ -2017,12 +2071,14 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
|
||||
marketUrlPlaceholder: 'Market URL',
|
||||
marketCopy: 'Copy',
|
||||
marketApiKeyOptional: 'Optional',
|
||||
marketApiKeyRequired: 'API Key is required',
|
||||
marketApiKeyExtra: 'Some markets require an API Key to access the full service list',
|
||||
marketApiKeyPlaceholder: 'Enter API Key to access more services',
|
||||
marketConnectionStatus: 'Connection Status',
|
||||
marketConnected: '● Connected',
|
||||
marketDisconnected: '○ Disconnected',
|
||||
marketConnecting: 'Connecting to {{name}}...',
|
||||
marketConfigUpdated: '{{name}} configuration updated',
|
||||
serverUrlInvalid: 'Must start with http:// or https://, and cannot have leading or trailing spaces',
|
||||
requestHeaderKeyInvalid: 'Only English letters, numbers, hyphens (-), and underscores (_) are allowed, and cannot start or end with a hyphen or underscore',
|
||||
},
|
||||
|
||||
@@ -754,7 +754,60 @@ export const zh = {
|
||||
gotoList: '返回应用列表',
|
||||
gotoDetail: '查看详情',
|
||||
dify: 'Dify',
|
||||
pleaseUploadFile: '请上传工作流文件',
|
||||
pleaseUploadFile: '请上传文件',
|
||||
setting: '设置',
|
||||
funConfig: '功能',
|
||||
fileUpload: '文件上传',
|
||||
fileUploadDesc: '聊天输入框支持上传文件。类型包括图片、文档以及其它类型',
|
||||
settings: '文件上传设置',
|
||||
uploadType: '上传类型',
|
||||
local: '本地上传',
|
||||
both: '两者皆可',
|
||||
maxSize: '最大上传大小',
|
||||
maxSizeDesc: '文档 < 200.00MB,图片 < 10.00MB,音频 < 50.00MB,视频 < 100.00MB',
|
||||
supportedTypes: '支持的文件类型',
|
||||
document: '文档',
|
||||
image: '图片',
|
||||
audio: '音频',
|
||||
video: '视频',
|
||||
other: '其他文件类型',
|
||||
otherFormats: '指定其他文件类型',
|
||||
maxCount: '最大文件数',
|
||||
singleMaxSize: '单文件最大大小',
|
||||
unix: '个',
|
||||
textTranfer: '文字转语音',
|
||||
textTranferDesc: '文本可以转换成语言',
|
||||
|
||||
apps: '我的应用',
|
||||
sharing: '共享',
|
||||
sharingApp: '共享应用',
|
||||
myShare: '我的共享',
|
||||
selectTargetSpace: '选择目标空间',
|
||||
alreadyShared: '已共享',
|
||||
permissionMode: '权限模式',
|
||||
readonlyMode: '使用共享',
|
||||
readonlyModeDesc: '可测试运行,不可查看内部',
|
||||
editableMode: '复制共享',
|
||||
editableModeDesc: '复制完整副本,自由编辑',
|
||||
confirmSharing: '确认共享',
|
||||
selectAtLeastOneSpace: '请选择至少一个目标空间',
|
||||
test: '对话测试',
|
||||
log: '日志',
|
||||
testChatEmpty: '发送消息测试共享应用效果',
|
||||
allCancel: '全部取消',
|
||||
cancelShare: '取消共享',
|
||||
confirmAppCancelShareDesc: '确定取消该【{{app}}】应用对【{{workspace}}】空间的共享?取消后对方将无法访问。',
|
||||
confirmWorkspaceCancelShareDesc: '确定取消所有共享给【{{workspace}}】的应用?此操作不可恢复。',
|
||||
sourceActive: '生效中',
|
||||
sourceInactive: '已失效',
|
||||
readonly: '仅使用',
|
||||
editable: '可复制',
|
||||
version: '版本号',
|
||||
permission: '权限',
|
||||
souceStatus: '源应用状态',
|
||||
confirmCopyDesc: '确定复制【{{app}}】应用?',
|
||||
noShareAuth: '无共享应用的权限',
|
||||
appCount: '{{count}}个应用共享到此空间',
|
||||
},
|
||||
table: {
|
||||
totalRecords: '共 {{total}} 条记录'
|
||||
@@ -1038,6 +1091,7 @@ export const zh = {
|
||||
imageSquareRequired: '请上传正方形比例图片',
|
||||
nameInvalid: '不能是空格开头或结尾',
|
||||
notAllSpaces: '不能是纯空格',
|
||||
view: '查看',
|
||||
},
|
||||
model: {
|
||||
searchPlaceholder: '搜索模型…',
|
||||
@@ -2013,12 +2067,14 @@ export const zh = {
|
||||
marketUrlPlaceholder: '市场地址',
|
||||
marketCopy: '复制',
|
||||
marketApiKeyOptional: '可选',
|
||||
marketApiKeyRequired: '请输入 API Key',
|
||||
marketApiKeyExtra: '部分市场需要 API Key 才能获取完整的服务列表',
|
||||
marketApiKeyPlaceholder: '输入 API Key 以获取更多服务',
|
||||
marketConnectionStatus: '连接状态',
|
||||
marketConnected: '● 已连接',
|
||||
marketDisconnected: '○ 未连接',
|
||||
marketConnecting: '正在连接 {{name}}...',
|
||||
marketConfigUpdated: '{{name}} 配置已更新',
|
||||
serverUrlInvalid: '必须以 http:// 或 https:// 开头,且不能有前后空格',
|
||||
requestHeaderKeyInvalid: '只支持英文、数字、连字符(-)、下划线(_),不能以连字符或下划线开头结尾',
|
||||
},
|
||||
@@ -2571,7 +2627,7 @@ export const zh = {
|
||||
memoryHealthVisualization: '记忆健康可视化',
|
||||
activationValueDistribution: '激活值分布',
|
||||
forgettingTrend: '遗忘趋势(近7天)',
|
||||
|
||||
|
||||
nodes_without_activation: '观察区',
|
||||
low_activation_nodes: '遗忘区',
|
||||
health_nodes: '健康区',
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"element": "BasicLayout",
|
||||
"children": [
|
||||
{ "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" },
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:29:21
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-03 14:24:34
|
||||
* @Last Modified time: 2026-03-13 16:58:15
|
||||
*/
|
||||
import { type FC, type ReactNode, useEffect, useRef, useState, forwardRef, useImperativeHandle } from 'react';
|
||||
import clsx from 'clsx'
|
||||
@@ -23,7 +23,8 @@ import type {
|
||||
MemoryConfig,
|
||||
AiPromptModalRef,
|
||||
Source,
|
||||
ChatVariableConfigModalRef
|
||||
ChatVariableConfigModalRef,
|
||||
FunConfigForm
|
||||
} from './types'
|
||||
import type { Variable } from './components/VariableList/types'
|
||||
import type { KnowledgeConfig } from './components/Knowledge/types'
|
||||
@@ -41,6 +42,7 @@ import ToolList from './components/ToolList/ToolList'
|
||||
import SkillList from './components/Skill'
|
||||
import ChatVariableConfigModal from './components/ChatVariableConfigModal';
|
||||
import type { Skill } from '@/views/Skills/types'
|
||||
import FunConfig from './components/FunConfig'
|
||||
|
||||
/**
|
||||
* Description wrapper component
|
||||
@@ -99,7 +101,7 @@ const SwitchWrapper: FC<{ title: string, desc?: string, name: string | string[];
|
||||
* @param name - Form field name
|
||||
* @param url - API URL for options
|
||||
*/
|
||||
const SelectWrapper: FC<{ title: string, desc: string, name: string | string[], url: string }> = ({ title, desc, name, url }) => {
|
||||
const SelectWrapper: FC<{ title: string, desc: string, name: string | string[], url: string; disabled?: boolean }> = ({ title, desc, name, url, disabled }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
@@ -115,6 +117,7 @@ const SelectWrapper: FC<{ title: string, desc: string, name: string | string[],
|
||||
hasAll={false}
|
||||
valueKey='config_id'
|
||||
labelKey="config_name"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Form.Item>
|
||||
<DescWrapper desc={t(`application.${desc}`)} className="rb:mt-2" />
|
||||
@@ -352,7 +355,8 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
|
||||
}, [modelList, values?.default_model_config_id])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
handleSave
|
||||
handleSave,
|
||||
funConfig: values?.funConfig
|
||||
}))
|
||||
|
||||
const aiPromptModalRef = useRef<AiPromptModalRef>(null)
|
||||
@@ -406,7 +410,11 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
|
||||
useEffect(() => {
|
||||
setChatVariables(values?.variables || [])
|
||||
}, [values?.variables])
|
||||
console.log('values', values)
|
||||
|
||||
const handleSaveFunConfig = (value: FunConfigForm) => {
|
||||
form.setFieldValue('funConfig', value)
|
||||
}
|
||||
console.log('agent', values)
|
||||
return (
|
||||
<>
|
||||
{loading && <Spin fullscreen></Spin>}
|
||||
@@ -418,6 +426,7 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
|
||||
{defaultModel?.name ? <div className="rb:w-4 rb:h-4 rb:bg-[url('@/assets/images/application/model.svg')] rb:group-hover:bg-[url('@/assets/images/application/model_hover.svg')]"></div> : null}
|
||||
{defaultModel?.name || t('application.chooseModel')}
|
||||
</Button>
|
||||
{/* <FunConfig value={values?.funConfig as FunConfigForm} refresh={handleSaveFunConfig} /> */}
|
||||
<Button type="primary" onClick={() => handleSave()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
@@ -426,6 +435,7 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
|
||||
<Form form={form}>
|
||||
<Form.Item name="default_model_config_id" hidden noStyle></Form.Item>
|
||||
<Form.Item name="model_parameters" hidden noStyle></Form.Item>
|
||||
<Form.Item name="funConfig" hidden noStyle></Form.Item>
|
||||
<Space size={16} direction="vertical" style={{ width: '100%' }}>
|
||||
<Card title={t('application.promptConfiguration')}>
|
||||
<div className="rb:flex rb:items-center rb:justify-between rb:mb-2.75">
|
||||
@@ -464,11 +474,12 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
|
||||
<Card title={t('application.memoryConfiguration')}>
|
||||
<Space size={24} direction='vertical' style={{ width: '100%' }}>
|
||||
<SwitchWrapper title="dialogueHistoricalMemory" desc="dialogueHistoricalMemoryDesc" name={['memory', 'enabled']} />
|
||||
<SelectWrapper
|
||||
title="selectMemoryContent"
|
||||
desc="selectMemoryContentDesc"
|
||||
<SelectWrapper
|
||||
title="selectMemoryContent"
|
||||
desc="selectMemoryContentDesc"
|
||||
name={['memory', 'memory_config_id']}
|
||||
url={memoryConfigListUrl}
|
||||
disabled={!values?.memory?.enabled}
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
@@ -493,11 +504,6 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
|
||||
{t('application.debuggingAndPreview')}
|
||||
|
||||
<Space size={10}>
|
||||
{chatVariables.length > 0 &&
|
||||
<Button type="primary" ghost onClick={handleOpenVariableConfig}>
|
||||
{t('application.variableConfig')}
|
||||
</Button>
|
||||
}
|
||||
<Button type="primary" ghost onClick={handleAddModel}>
|
||||
+ {t('application.addModel')}
|
||||
</Button>
|
||||
@@ -511,6 +517,7 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
|
||||
updateChatList={setChatList}
|
||||
handleSave={handleSave}
|
||||
chatVariables={chatVariables}
|
||||
handleEditVariables={handleOpenVariableConfig}
|
||||
/>
|
||||
</RbCard>
|
||||
</Col>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:29:33
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-03 16:29:33
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-05 13:47:23
|
||||
*/
|
||||
import { useEffect, useState, useRef, forwardRef, useImperativeHandle } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -165,7 +165,8 @@ const Cluster = forwardRef<ClusterRef>((_props, ref) => {
|
||||
setSubAgents(prev => prev.filter(item => item.agent_id !== agent.agent_id))
|
||||
}
|
||||
useImperativeHandle(ref, () => ({
|
||||
handleSave
|
||||
handleSave,
|
||||
funConfig: data?.funConfig
|
||||
}))
|
||||
|
||||
const modelConfigModalRef = useRef<ModelConfigModalRef>(null)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:29:41
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-03 16:29:41
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-11 17:44:24
|
||||
*/
|
||||
import { type FC, useState, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -14,7 +14,8 @@ import RbCard from '@/components/RbCard/Card'
|
||||
import { getReleaseList, rollbackRelease, appExport } from '@/api/application'
|
||||
import ReleaseModal from './components/ReleaseModal'
|
||||
import ReleaseShareModal from './components/ReleaseShareModal'
|
||||
import type { Release, ReleaseModalRef, ReleaseShareModalRef } from './types'
|
||||
import AppSharingModal from './components/AppSharingModal'
|
||||
import type { Release, ReleaseModalRef, ReleaseShareModalRef, AppSharingModalRef } from './types'
|
||||
import type { Application } from '@/views/ApplicationManagement/types'
|
||||
import Empty from '@/components/Empty'
|
||||
import { formatDateTime } from '@/utils/format';
|
||||
@@ -39,6 +40,7 @@ const ReleasePage: FC<{data: Application; refresh: () => void}> = ({data, refres
|
||||
const { message } = App.useApp()
|
||||
const releaseModalRef = useRef<ReleaseModalRef>(null)
|
||||
const releaseShareModalRef = useRef<ReleaseShareModalRef>(null)
|
||||
const appSharingModalRef = useRef<AppSharingModalRef>(null)
|
||||
const [selectedVersion, setSelectedVersion] = useState<Release | null>(null);
|
||||
const [releaseList, setReleaseList] = useState<Release[]>([])
|
||||
|
||||
@@ -129,6 +131,7 @@ const ReleasePage: FC<{data: Application; refresh: () => void}> = ({data, refres
|
||||
{data?.type !== 'multi_agent' && <Button onClick={handleExport}>{t('common.export')}</Button>}
|
||||
{data.current_release_id !== selectedVersion.id && <Button onClick={handleRollback}>{t('application.willRollToThisVersion')}</Button>}
|
||||
<Button type="primary" ghost onClick={() => releaseShareModalRef.current?.handleOpen()}>{t('application.share')}</Button>
|
||||
<Button type="primary" ghost onClick={() => appSharingModalRef.current?.handleOpen()}>{t('application.sharing')}</Button>
|
||||
</>}
|
||||
<Button type="primary" onClick={() => releaseModalRef.current?.handleOpen()}>{t('application.release')}</Button>
|
||||
</Space>
|
||||
@@ -178,6 +181,11 @@ const ReleasePage: FC<{data: Application; refresh: () => void}> = ({data, refres
|
||||
ref={releaseShareModalRef}
|
||||
version={selectedVersion}
|
||||
/>
|
||||
<AppSharingModal
|
||||
ref={appSharingModalRef}
|
||||
appId={data.id}
|
||||
version={selectedVersion}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
642
web/src/views/ApplicationConfig/TestChat/index.tsx
Normal file
@@ -0,0 +1,642 @@
|
||||
import { type FC, useState, useRef, useEffect, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { App, Flex, Dropdown, type MenuProps, Divider, Form, Space } from 'antd'
|
||||
import { SettingOutlined } from '@ant-design/icons'
|
||||
import clsx from 'clsx'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
import ChatIcon from '@/assets/images/application/chat.png'
|
||||
|
||||
import VariableConfigModal from '@/views/Workflow/components/Chat/VariableConfigModal'
|
||||
import { draftRun } from '@/api/application';
|
||||
|
||||
import Empty from '@/components/Empty'
|
||||
import Chat from '@/components/Chat'
|
||||
import AudioRecorder from '@/components/AudioRecorder'
|
||||
import RbCard from '@/components/RbCard/Card'
|
||||
import UploadFiles from '@/views/Conversation/components/FileUpload'
|
||||
import UploadFileListModal from '@/views/Conversation/components/UploadFileListModal'
|
||||
import Runtime from '@/views/Workflow/components/Chat/Runtime';
|
||||
import { nodeLibrary } from '@/views/Workflow/constant'
|
||||
// import ButtonCheckbox from '@/components/ButtonCheckbox';
|
||||
|
||||
// import MemoryFunctionIcon from '@/assets/images/conversation/memoryFunction.svg'
|
||||
// import OnlineIcon from '@/assets/images/conversation/online.svg'
|
||||
// import OnlineCheckedIcon from '@/assets/images/conversation/onlineChecked.svg'
|
||||
// import MemoryFunctionCheckedIcon from '@/assets/images/conversation/memoryFunctionChecked.svg'
|
||||
|
||||
import type { ChatItem } from '@/components/Chat/types'
|
||||
import type { VariableConfigModalRef, WorkflowConfig } from '@/views/Workflow/types'
|
||||
import type { Variable } from '@/views/Workflow/components/Properties/VariableList/types'
|
||||
import type { TestChatProps } from './type';
|
||||
import type { UploadFileListModalRef } from '@/views/Conversation/types'
|
||||
import type { SSEMessage } from '@/utils/stream'
|
||||
|
||||
const formatParams = (message: string, conversation_id: string | null, files: any[] = [], variables: Record<string, any>) => {
|
||||
return {
|
||||
message,
|
||||
conversation_id,
|
||||
stream: true,
|
||||
files: files.map(file => {
|
||||
if (file.url) {
|
||||
return file
|
||||
} else {
|
||||
return {
|
||||
type: file.type,
|
||||
transfer_method: 'local_file',
|
||||
upload_file_id: file.response.data.file_id
|
||||
}
|
||||
}
|
||||
}),
|
||||
variables: Object.keys(variables).length > 0 ? variables : undefined
|
||||
}
|
||||
}
|
||||
|
||||
interface NodeData {
|
||||
content: string;
|
||||
conversation_id: string | null;
|
||||
cycle_id: string;
|
||||
cycle_idx: number;
|
||||
node_id: string;
|
||||
node_name?: string;
|
||||
node_type?: string;
|
||||
input?: any;
|
||||
output?: any;
|
||||
elapsed_time?: string;
|
||||
error?: any;
|
||||
state: Record<string, any>;
|
||||
status?: 'completed' | 'failed'
|
||||
}
|
||||
|
||||
interface FormData {
|
||||
files: any[];
|
||||
variables: Variable[]
|
||||
}
|
||||
const TestChat: FC<TestChatProps> = ({
|
||||
application,
|
||||
config
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { message: messageApi } = App.useApp()
|
||||
const variableConfigModalRef = useRef<VariableConfigModalRef>(null)
|
||||
const uploadFileListModalRef = useRef<UploadFileListModalRef>(null)
|
||||
|
||||
const [loading, setLoading] = useState(false) // Send button loading state
|
||||
const [chatList, setChatList] = useState<ChatItem[]>([]) // Chat message history
|
||||
const [streamLoading, setStreamLoading] = useState(false) // SSE streaming state
|
||||
const [conversationId, setConversationId] = useState<string | null>(null) // Current conversation ID
|
||||
const [message, setMessage] = useState<string | undefined>(undefined) // Current input message
|
||||
const [form] = Form.useForm<FormData>()
|
||||
const queryValues = Form.useWatch([], form)
|
||||
|
||||
useEffect(() => {
|
||||
getVariables()
|
||||
}, [application, config])
|
||||
|
||||
const getVariables = () => {
|
||||
if (!application || !config) return
|
||||
|
||||
let initVariables: Variable[] = []
|
||||
|
||||
switch (application.type) {
|
||||
case 'workflow':
|
||||
const { nodes } = config as WorkflowConfig;
|
||||
const startNodes = nodes.filter(vo => vo.type === 'start')
|
||||
if (startNodes.length) {
|
||||
const curVariables = startNodes[0].config.variables as Variable[]
|
||||
|
||||
curVariables.forEach((vo) => {
|
||||
if (typeof vo.default !== 'undefined') {
|
||||
vo.value = vo.default
|
||||
}
|
||||
const lastVo = curVariables.find(item => item.name === vo.name)
|
||||
if (lastVo?.value) {
|
||||
vo.value = lastVo.value
|
||||
}
|
||||
})
|
||||
initVariables = curVariables
|
||||
}
|
||||
break
|
||||
case 'agent':
|
||||
initVariables = config.variables as Variable[]
|
||||
break
|
||||
}
|
||||
|
||||
form.setFieldValue('variables', [...initVariables])
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the variable configuration modal
|
||||
*/
|
||||
const handleEditVariables = () => {
|
||||
variableConfigModalRef.current?.handleOpen(queryValues.variables)
|
||||
}
|
||||
/**
|
||||
* Saves updated variable values from the modal
|
||||
*/
|
||||
const handleSave = (values: Variable[]) => {
|
||||
form.setFieldValue('variables', [...values])
|
||||
}
|
||||
/**
|
||||
* Handles file upload from local device
|
||||
*/
|
||||
const fileChange = (file?: any) => {
|
||||
form.setFieldValue('files', [...(queryValues.files || []), file])
|
||||
}
|
||||
const handleRecordingComplete = async (file: any) => {
|
||||
form.setFieldValue('files', [...(queryValues.files || []), file])
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles dropdown menu actions for file upload
|
||||
*/
|
||||
const handleShowUpload: MenuProps['onClick'] = ({ key }) => {
|
||||
switch(key) {
|
||||
case 'define':
|
||||
uploadFileListModalRef.current?.handleOpen()
|
||||
break
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Adds files from remote URL modal
|
||||
*/
|
||||
const addFileList = (list?: any[]) => {
|
||||
if (!list || list.length <= 0) return
|
||||
form.setFieldValue('files', [...(queryValues.files || []), ...(list || [])])
|
||||
}
|
||||
/**
|
||||
* Updates the entire file list (used when removing files)
|
||||
*/
|
||||
const updateFileList = (list?: any[]) => {
|
||||
form.setFieldValue('files', [...list || []])
|
||||
}
|
||||
const isNeedVariableConfig = useMemo(() => {
|
||||
return queryValues?.variables.some(vo => vo.required && (vo.value === null || vo.value === undefined || vo.value === ''))
|
||||
}, [queryValues?.variables])
|
||||
|
||||
const addUserMessage = (message: string, files: any[]) => {
|
||||
const newUserMessage: ChatItem = {
|
||||
role: 'user',
|
||||
content: message,
|
||||
created_at: Date.now(),
|
||||
files
|
||||
};
|
||||
setChatList(prev => [...prev, newUserMessage])
|
||||
}
|
||||
const addAssistantMessage = () => {
|
||||
const { type } = application || {}
|
||||
setChatList(prev => [...prev, {
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
created_at: Date.now(),
|
||||
subContent: type === 'workflow' ? [] : undefined,
|
||||
}])
|
||||
}
|
||||
|
||||
const updateAssistantMessage = (content: string) => {
|
||||
setChatList(prev => {
|
||||
let newList = [...prev]
|
||||
const lastMsg = newList[newList.length - 1]
|
||||
if (lastMsg.role === 'assistant') {
|
||||
lastMsg.content += content
|
||||
}
|
||||
return newList
|
||||
})
|
||||
}
|
||||
const updateErrorAssistantMessage = (message_length: number) => {
|
||||
if (message_length > 0) return
|
||||
setChatList(prev => {
|
||||
let newList = [...prev]
|
||||
const lastMsg = newList[newList.length - 1]
|
||||
if (lastMsg.role === 'assistant') {
|
||||
lastMsg.content = null
|
||||
}
|
||||
return newList
|
||||
})
|
||||
}
|
||||
const handleSend = () => {
|
||||
if (loading || !application || !message || !message?.trim()) return
|
||||
// Validate required variables before sending
|
||||
const { variables, files } = queryValues;
|
||||
let isCanSend = true
|
||||
const params: Record<string, any> = {}
|
||||
if (variables && variables.length > 0) {
|
||||
const needRequired: string[] = []
|
||||
variables.forEach(vo => {
|
||||
params[vo.name] = vo.value
|
||||
|
||||
if (vo.required && (params[vo.name] === null || params[vo.name] === undefined || params[vo.name] === '')) {
|
||||
isCanSend = false
|
||||
needRequired.push(vo.name)
|
||||
}
|
||||
})
|
||||
|
||||
if (needRequired.length) {
|
||||
messageApi.error(`${needRequired.join(',')} ${t('workflow.variableRequired')}`)
|
||||
}
|
||||
}
|
||||
if (!isCanSend) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
addUserMessage(message, files)
|
||||
setMessage(undefined)
|
||||
form.setFieldValue('files', [])
|
||||
addAssistantMessage()
|
||||
setStreamLoading(true)
|
||||
setLoading(true)
|
||||
|
||||
draftRun(
|
||||
application.id,
|
||||
formatParams(message, conversationId, files, params),
|
||||
handleStreamMessage
|
||||
)
|
||||
.catch(() => {
|
||||
setLoading(false)
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false)
|
||||
setStreamLoading(false)
|
||||
})
|
||||
}
|
||||
const handleStreamMessage = (data: SSEMessage[]) => {
|
||||
data.map(item => {
|
||||
const { conversation_id, content, message_length } = item.data as { conversation_id: string, content: string, message_length: number };
|
||||
|
||||
switch (item.event) {
|
||||
case 'start':
|
||||
if (conversation_id && conversationId !== conversation_id) {
|
||||
setConversationId(conversation_id);
|
||||
}
|
||||
break
|
||||
case 'message':
|
||||
updateAssistantMessage(content)
|
||||
if (conversation_id && conversationId !== conversation_id) {
|
||||
setConversationId(conversation_id);
|
||||
}
|
||||
break;
|
||||
case 'end':
|
||||
updateErrorAssistantMessage(message_length)
|
||||
setStreamLoading(false)
|
||||
break;
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
const handleWorkflowSend = () => {
|
||||
if (loading || !application || !message || !message?.trim()) return
|
||||
|
||||
// Validate required variables before sending
|
||||
const { variables, files } = queryValues;
|
||||
let isCanSend = true
|
||||
const params: Record<string, any> = {}
|
||||
if (variables.length > 0) {
|
||||
const needRequired: string[] = []
|
||||
variables.forEach(vo => {
|
||||
params[vo.name] = vo.value ?? vo.defaultValue
|
||||
|
||||
if (vo.required && (params[vo.name] === null || params[vo.name] === undefined || params[vo.name] === '')) {
|
||||
isCanSend = false
|
||||
needRequired.push(vo.name)
|
||||
}
|
||||
})
|
||||
|
||||
if (needRequired.length) {
|
||||
messageApi.error(`${needRequired.join(',')} ${t('workflow.variableRequired')}`)
|
||||
}
|
||||
}
|
||||
if (!isCanSend) {
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
addUserMessage(message, files)
|
||||
addAssistantMessage()
|
||||
form.setFieldsValue({
|
||||
files: [],
|
||||
})
|
||||
|
||||
setMessage(undefined)
|
||||
setStreamLoading(true)
|
||||
draftRun(
|
||||
application.id,
|
||||
formatParams(message, conversationId, files, params),
|
||||
handleWorkflowStreamMessage
|
||||
)
|
||||
.catch((error) => {
|
||||
console.log('draftRun error', error)
|
||||
setChatList(prev => {
|
||||
const newList = [...prev]
|
||||
const lastIndex = newList.length - 1
|
||||
if (lastIndex >= 0) {
|
||||
newList[lastIndex] = {
|
||||
...newList[lastIndex],
|
||||
status: 'failed',
|
||||
content: null,
|
||||
subContent: error.error
|
||||
}
|
||||
}
|
||||
return newList
|
||||
})
|
||||
}).finally(() => {
|
||||
setLoading(false)
|
||||
setStreamLoading(false)
|
||||
})
|
||||
}
|
||||
const handleWorkflowStreamMessage = (data: SSEMessage[]) => {
|
||||
data.forEach(item => {
|
||||
const { content, conversation_id } = item.data as NodeData;
|
||||
|
||||
switch (item.event) {
|
||||
// Append streaming text chunks to assistant message
|
||||
case 'message':
|
||||
setChatList(prev => {
|
||||
const newList = [...prev]
|
||||
const lastIndex = newList.length - 1
|
||||
if (lastIndex >= 0) {
|
||||
newList[lastIndex] = {
|
||||
...newList[lastIndex],
|
||||
content: newList[lastIndex].content + content
|
||||
}
|
||||
}
|
||||
return newList
|
||||
})
|
||||
break
|
||||
// Track node execution start
|
||||
case 'node_start':
|
||||
addWorkflowNodeStartMessage(item.data as NodeData)
|
||||
break
|
||||
// Update node with execution results or errors
|
||||
case 'node_end':
|
||||
case 'node_error':
|
||||
updateWorkflowNodeEndMessage(item.data as NodeData)
|
||||
break
|
||||
// Update node with subContent
|
||||
case 'cycle_item':
|
||||
updateWorkflowCycleMessage(item.data as NodeData)
|
||||
break
|
||||
// Mark workflow as complete
|
||||
case 'workflow_end':
|
||||
updateWorkflowEndMessage(item.data as NodeData)
|
||||
setStreamLoading(false)
|
||||
setLoading(false)
|
||||
break
|
||||
}
|
||||
|
||||
if (conversation_id && conversationId !== conversation_id) {
|
||||
setConversationId(conversation_id)
|
||||
}
|
||||
})
|
||||
}
|
||||
const addWorkflowNodeStartMessage = (data: NodeData) => {
|
||||
const { node_id } = data;
|
||||
const { nodes } = config as WorkflowConfig
|
||||
|
||||
const node = nodes.find(n => n.id === node_id);
|
||||
const { name, type } = node || {}
|
||||
const icon = nodeLibrary.flatMap(g => g.nodes).find(n => n.type === type)?.icon
|
||||
setChatList(prev => {
|
||||
const newList = [...prev]
|
||||
const lastIndex = newList.length - 1
|
||||
if (lastIndex >= 0) {
|
||||
const newSubContent = newList[lastIndex].subContent || []
|
||||
const filterIndex = newSubContent.findIndex(vo => vo.id === node_id)
|
||||
if (filterIndex > -1) {
|
||||
newSubContent[filterIndex] = {
|
||||
...newSubContent[filterIndex],
|
||||
node_id: node_id,
|
||||
node_name: name,
|
||||
node_type: type,
|
||||
icon,
|
||||
content: {},
|
||||
}
|
||||
} else {
|
||||
newSubContent.push({
|
||||
id: node_id,
|
||||
node_id: node_id,
|
||||
node_name: name,
|
||||
node_type: type,
|
||||
icon,
|
||||
content: {},
|
||||
})
|
||||
}
|
||||
newList[lastIndex] = {
|
||||
...newList[lastIndex],
|
||||
subContent: newSubContent
|
||||
}
|
||||
}
|
||||
return newList
|
||||
})
|
||||
}
|
||||
const updateWorkflowNodeEndMessage = (data: NodeData) => {
|
||||
const { node_id, input, output, error, elapsed_time, status } = data;
|
||||
setChatList(prev => {
|
||||
const newList = [...prev]
|
||||
const lastIndex = newList.length - 1
|
||||
if (lastIndex >= 0) {
|
||||
const newSubContent = newList[lastIndex].subContent || []
|
||||
const filterIndex = newSubContent.findIndex(vo => vo.node_id === node_id)
|
||||
if (filterIndex > -1 && newSubContent[filterIndex].content) {
|
||||
newSubContent[filterIndex] = {
|
||||
...newSubContent[filterIndex],
|
||||
content: {
|
||||
input,
|
||||
output,
|
||||
error,
|
||||
},
|
||||
status: status || 'completed',
|
||||
elapsed_time
|
||||
}
|
||||
}
|
||||
newList[lastIndex] = {
|
||||
...newList[lastIndex],
|
||||
subContent: newSubContent
|
||||
}
|
||||
}
|
||||
return newList
|
||||
})
|
||||
}
|
||||
const updateWorkflowCycleMessage = (data: NodeData) => {
|
||||
const { node_id, cycle_id, cycle_idx, input, output, error, elapsed_time, status } = data;
|
||||
const { nodes } = config as WorkflowConfig
|
||||
|
||||
const node = nodes.find(n => n.id === node_id);
|
||||
const { name, type } = node || {}
|
||||
const icon = nodeLibrary.flatMap(g => g.nodes).find(n => n.type === type)?.icon
|
||||
setChatList(prev => {
|
||||
const newList = [...prev]
|
||||
const lastIndex = newList.length - 1
|
||||
if (lastIndex >= 0) {
|
||||
const newSubContent = newList[lastIndex].subContent || []
|
||||
const filterIndex = newSubContent.findIndex(vo => vo.id === cycle_id)
|
||||
if (filterIndex > -1) {
|
||||
const items = newSubContent[filterIndex].subContent || []
|
||||
items.push({
|
||||
cycle_id,
|
||||
cycle_idx,
|
||||
node_id,
|
||||
node_name: name,
|
||||
node_type: type,
|
||||
icon,
|
||||
content: {
|
||||
cycle_idx,
|
||||
input,
|
||||
output,
|
||||
error,
|
||||
},
|
||||
status: status || 'completed',
|
||||
elapsed_time
|
||||
})
|
||||
newSubContent[filterIndex] = {
|
||||
...newSubContent[filterIndex],
|
||||
subContent: [...items]
|
||||
}
|
||||
newList[lastIndex] = {
|
||||
...newList[lastIndex],
|
||||
subContent: newSubContent
|
||||
}
|
||||
}
|
||||
}
|
||||
return newList
|
||||
})
|
||||
}
|
||||
const updateWorkflowEndMessage = (data: NodeData) => {
|
||||
const { error, status } = data as {
|
||||
content: string;
|
||||
conversation_id: string | null;
|
||||
cycle_id: string;
|
||||
cycle_idx: number;
|
||||
node_id: string;
|
||||
node_name?: string;
|
||||
node_type?: string;
|
||||
input?: any;
|
||||
output?: any;
|
||||
elapsed_time?: string;
|
||||
error?: any;
|
||||
state: Record<string, any>;
|
||||
status?: 'completed' | 'failed'
|
||||
};
|
||||
setChatList(prev => {
|
||||
const newList = [...prev]
|
||||
const lastIndex = newList.length - 1
|
||||
if (lastIndex >= 0) {
|
||||
newList[lastIndex] = {
|
||||
...newList[lastIndex],
|
||||
status,
|
||||
error,
|
||||
content: newList[lastIndex].content === '' ? null : newList[lastIndex].content,
|
||||
}
|
||||
}
|
||||
return newList
|
||||
})
|
||||
}
|
||||
|
||||
console.log('queryValues', queryValues)
|
||||
return (
|
||||
<div className="rb:w-250 rb:p-3 rb:mx-auto">
|
||||
<RbCard
|
||||
title={t('application.test')}
|
||||
headerClassName="rb:min-h-[56px]!"
|
||||
className="rb:h-[calc(100vh-88px)]!"
|
||||
bodyClassName="rb:h-[calc(100%-56px)]! rb:overflow-y-auto rb:px-3! rb:py-0!"
|
||||
>
|
||||
<Chat
|
||||
empty={<Empty url={ChatIcon} title={t('application.testChatEmpty')} isNeedSubTitle={false} size={[240, 200]} />}
|
||||
contentClassName={clsx(`rb:mx-[16px] rb:pt-[24px]`, {
|
||||
'rb:h-[calc(100%-140px)]': !queryValues?.files?.length,
|
||||
'rb:h-[calc(100%-208px)]': !!queryValues?.files?.length,
|
||||
})}
|
||||
data={chatList}
|
||||
streamLoading={streamLoading}
|
||||
loading={loading}
|
||||
onChange={setMessage}
|
||||
onSend={application?.type === 'workflow' ? handleWorkflowSend : handleSend}
|
||||
fileList={queryValues?.files || []}
|
||||
fileChange={updateFileList}
|
||||
labelFormat={(item) => item.role === 'user' ? t('application.you') : dayjs(item.created_at).locale('en').format('MMMM D, YYYY [at] h:mm A')}
|
||||
errorDesc={t('application.ReplyException')}
|
||||
renderRuntime={application?.type === 'workflow' ? (item, index) => {
|
||||
return <Runtime item={item} index={index} />
|
||||
} : undefined}
|
||||
>
|
||||
<Form form={form}>
|
||||
<Flex justify="space-between" className="rb:flex-1">
|
||||
<Space size={8} align="center">
|
||||
<Form.Item name="files" noStyle>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'define', label: t('memoryConversation.addRemoteFile') },
|
||||
{
|
||||
key: 'upload', label: (
|
||||
<UploadFiles
|
||||
onChange={fileChange}
|
||||
/>
|
||||
)
|
||||
},
|
||||
],
|
||||
onClick: handleShowUpload
|
||||
}}
|
||||
>
|
||||
<Flex align="center" justify="center" className="rb:size-7 rb:cursor-pointer rb:rounded-[14px] rb:border rb:border-[#EBEBEB] rb:hover:bg-[#F6F6F6]">
|
||||
<div
|
||||
className="rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/conversation/link.svg')]"
|
||||
></div>
|
||||
</Flex>
|
||||
</Dropdown>
|
||||
</Form.Item>
|
||||
{/* <Form.Item name="web_search" valuePropName="checked" className="rb:mb-0!">
|
||||
<ButtonCheckbox
|
||||
icon={OnlineIcon}
|
||||
checkedIcon={OnlineCheckedIcon}
|
||||
>
|
||||
{t(`memoryConversation.web_search`)}
|
||||
</ButtonCheckbox>
|
||||
</Form.Item>
|
||||
<Tooltip title={t(`memoryConversation.memory`)}></Tooltip>
|
||||
<Form.Item name="memory" valuePropName="checked" className="rb:mb-0!">
|
||||
<ButtonCheckbox
|
||||
icon={MemoryFunctionIcon}
|
||||
checkedIcon={MemoryFunctionCheckedIcon}
|
||||
cicle={true}
|
||||
>
|
||||
</ButtonCheckbox>
|
||||
</Form.Item> */}
|
||||
<Form.Item name="variables" className="rb:mb-0!" hidden={!queryValues?.variables?.length}>
|
||||
<div
|
||||
className={clsx("rb:flex rb:items-center rb:border rb:rounded-lg rb:px-2 rb:text-[12px] rb:h-6 rb:cursor-pointer rb:hover:bg-[#F0F3F8] rb:text-[#212332]", {
|
||||
'rb:border-[#FF5D34] rb:text-[#FF5D34]': isNeedVariableConfig,
|
||||
'rb:border-[#DFE4ED]': !isNeedVariableConfig,
|
||||
})}
|
||||
onClick={handleEditVariables}
|
||||
>
|
||||
<SettingOutlined className="rb:mr-1" />
|
||||
{t(`memoryConversation.variableConfig`)}
|
||||
</div>
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Space size={8} align="center">
|
||||
<AudioRecorder
|
||||
onRecordingComplete={handleRecordingComplete}
|
||||
/>
|
||||
<Divider type="vertical" className="rb:ml-0! rb:mr-2!" />
|
||||
</Space>
|
||||
</Flex>
|
||||
</Form>
|
||||
</Chat>
|
||||
|
||||
<VariableConfigModal
|
||||
ref={variableConfigModalRef}
|
||||
refresh={handleSave}
|
||||
/>
|
||||
|
||||
<UploadFileListModal
|
||||
ref={uploadFileListModalRef}
|
||||
refresh={addFileList}
|
||||
/>
|
||||
</RbCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TestChat
|
||||
8
web/src/views/ApplicationConfig/TestChat/type.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { Application } from '@/views/ApplicationManagement/types'
|
||||
import type { Config } from '../types';
|
||||
import type { WorkflowConfig } from '@/views/Workflow/types';
|
||||
|
||||
export interface TestChatProps {
|
||||
application?: Application | null;
|
||||
config: Config | WorkflowConfig | null
|
||||
}
|
||||
183
web/src/views/ApplicationConfig/components/AppSharingModal.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-03-13 17:19:13
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-13 17:26:57
|
||||
*/
|
||||
import { forwardRef, useImperativeHandle, useState } from 'react';
|
||||
import { Checkbox, App, Form } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import RbModal from '@/components/RbModal';
|
||||
import { appSharing, getAppShares } from '@/api/application';
|
||||
import { formatDateTime } from '@/utils/format';
|
||||
import type { AppSharingModalRef, Release } from '../types';
|
||||
import type { SpaceItem } from '@/views/KnowledgeBase/types';
|
||||
import { getWorkspaces } from '@/api/workspaces';
|
||||
import RadioGroupCard from '@/components/RadioGroupCard';
|
||||
|
||||
/** Props for the AppSharingModal component */
|
||||
interface AppSharingModalProps {
|
||||
/** ID of the application being shared */
|
||||
appId: string;
|
||||
/** The release version to share */
|
||||
version: Release | null;
|
||||
}
|
||||
|
||||
const AppSharingModal = forwardRef<AppSharingModalRef, AppSharingModalProps>(({ appId, version }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const { message } = App.useApp();
|
||||
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
// All workspaces available to share with (excluding the current one)
|
||||
const [spaceList, setSpaceList] = useState<SpaceItem[]>([]);
|
||||
// IDs of workspaces that already have access to this app
|
||||
const [sharedIds, setSharedIds] = useState<string[]>([]);
|
||||
|
||||
const [form] = Form.useForm<{ target_workspace_ids: string[]; permission: 'readonly' | 'editable' }>();
|
||||
// Reactively track the currently selected workspace IDs in the form
|
||||
const selectedIds: string[] = Form.useWatch('target_workspace_ids', form) ?? [];
|
||||
|
||||
/**
|
||||
* Fetch workspaces and existing share records in parallel,
|
||||
* sort already-shared spaces to the top, then open the modal.
|
||||
* Shows a warning if the user has no shareable workspaces.
|
||||
*/
|
||||
const handleOpen = () => {
|
||||
Promise.all([getWorkspaces({ include_current: false }), getAppShares(appId)]).then(([spaces, shared]) => {
|
||||
// Normalise the shared workspace ID field across different API response shapes
|
||||
const ids = ((shared as any[]) || []).map((s: any) => s.workspace_id || s.target_workspace_id || s.id);
|
||||
// Sort: already-shared workspaces appear first
|
||||
const sorted = (spaces as SpaceItem[]).sort((a, b) =>
|
||||
ids.includes(b.id) ? 1 : ids.includes(a.id) ? -1 : 0
|
||||
);
|
||||
setSpaceList(sorted);
|
||||
setSharedIds(ids);
|
||||
|
||||
if (sorted.length > 0) {
|
||||
setVisible(true);
|
||||
} else {
|
||||
message.warning(t('application.noShareAuth'));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/** Close the modal and reset form fields */
|
||||
const handleClose = () => {
|
||||
setVisible(false);
|
||||
form.resetFields();
|
||||
};
|
||||
|
||||
// Expose open/close handlers to the parent via ref
|
||||
useImperativeHandle(ref, () => ({ handleOpen, handleClose }));
|
||||
|
||||
/**
|
||||
* Toggle a workspace in the selected list.
|
||||
* Already-shared workspaces are read-only and cannot be toggled.
|
||||
*/
|
||||
const handleToggle = (id: string, isShared: boolean) => {
|
||||
if (isShared) return;
|
||||
const prev = form.getFieldValue('target_workspace_ids') as string[] ?? [];
|
||||
form.setFieldValue(
|
||||
'target_workspace_ids',
|
||||
prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]
|
||||
);
|
||||
};
|
||||
|
||||
/** Validate the form then submit the sharing request */
|
||||
const handleConfirm = () => {
|
||||
form.validateFields().then(values => {
|
||||
setLoading(true);
|
||||
appSharing(appId, values)
|
||||
.then(() => {
|
||||
message.success(t('common.operateSuccess'));
|
||||
handleClose();
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
});
|
||||
};
|
||||
|
||||
// Normalise the version label to always start with "v"
|
||||
const versionLabel = version?.version_name
|
||||
? (version.version_name[0].toLowerCase() === 'v' ? version.version_name : `v${version.version_name}`)
|
||||
: `v${version?.version}`;
|
||||
|
||||
return (
|
||||
<RbModal
|
||||
title={t('application.sharingApp')}
|
||||
open={visible}
|
||||
onCancel={handleClose}
|
||||
okText={<>{t('application.confirmSharing')}({selectedIds.length})</>}
|
||||
onOk={handleConfirm}
|
||||
confirmLoading={loading}
|
||||
width={600}
|
||||
>
|
||||
<Form form={form} layout="vertical" initialValues={{ target_workspace_ids: [], permission: 'readonly' }}>
|
||||
{/* Version info: displays version number, release time and publisher */}
|
||||
<div className="rb:rounded-lg rb:border rb:border-[#EBEBEB] rb:bg-[#FBFDFF] rb:p-4 rb:mb-4">
|
||||
<div className="rb:text-sm rb:font-medium rb:mb-3">{t('application.VersionInformation')}</div>
|
||||
<div className="rb:grid rb:grid-cols-3 rb:gap-4 rb:text-sm">
|
||||
<div>
|
||||
<div className="rb:text-[#5B6167] rb:mb-1">{t('application.versionList').replace('列表', '号')}</div>
|
||||
<div className="rb:font-medium">{versionLabel}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="rb:text-[#5B6167] rb:mb-1">{t('application.releaseTime')}</div>
|
||||
<div className="rb:font-medium">{formatDateTime(version?.published_at || 0, 'YYYY-MM-DD HH:mm:ss')}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="rb:text-[#5B6167] rb:mb-1">{t('application.publisher')}</div>
|
||||
<div className="rb:font-medium">{version?.publisher_name}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Target space: scrollable list of workspaces with checkbox selection */}
|
||||
<Form.Item
|
||||
name="target_workspace_ids"
|
||||
label={t('application.selectTargetSpace')}
|
||||
rules={[{ required: true, message: t('common.pleaseSelect') }]}
|
||||
>
|
||||
<div className="rb:rounded-lg rb:border rb:border-[#EBEBEB] rb:divide-y rb:divide-[#EBEBEB] rb:max-h-50 rb:overflow-y-auto">
|
||||
{spaceList.map(space => {
|
||||
const isShared = sharedIds.includes(space.id);
|
||||
return (
|
||||
<div key={space.id} className="rb:flex rb:items-center rb:gap-2 rb:px-4 rb:py-3 rb:cursor-pointer" onClick={() => handleToggle(space.id, isShared)}>
|
||||
<Checkbox
|
||||
checked={isShared || selectedIds.includes(space.id)}
|
||||
disabled={isShared} // already-shared workspaces cannot be unselected
|
||||
onChange={() => handleToggle(space.id, isShared)}
|
||||
/>
|
||||
<span className="rb:flex-1 rb:text-sm">{space.name}</span>
|
||||
{/* Badge shown when the app is already shared with this workspace */}
|
||||
{isShared && (
|
||||
<span className="rb:text-xs rb:text-[#5B6167]">{t('application.alreadyShared')}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Form.Item>
|
||||
|
||||
{/* Permission mode: readonly (use only) or editable (full copy) */}
|
||||
<Form.Item
|
||||
name="permission"
|
||||
label={t('application.permissionMode')}
|
||||
rules={[{ required: true, message: t('common.pleaseSelect') }]}
|
||||
className="rb:mb-0!"
|
||||
>
|
||||
<RadioGroupCard
|
||||
options={['readonly', 'editable'].map((type) => ({
|
||||
value: type,
|
||||
label: t(`application.${type}Mode`),
|
||||
labelDesc: t(`application.${type}ModeDesc`),
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</RbModal>
|
||||
);
|
||||
});
|
||||
|
||||
export default AppSharingModal;
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:27:39
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-05 17:03:46
|
||||
* @Last Modified time: 2026-03-13 15:20:32
|
||||
*/
|
||||
/**
|
||||
* Chat debugging component for application testing
|
||||
@@ -13,7 +13,8 @@
|
||||
import { type FC, useEffect, useState, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import clsx from 'clsx'
|
||||
import { Flex, Dropdown, type MenuProps, App, Divider } from 'antd'
|
||||
import { Flex, Dropdown, type MenuProps, App, Divider } from 'antd';
|
||||
import { SettingOutlined } from '@ant-design/icons'
|
||||
|
||||
import ChatIcon from '@/assets/images/application/chat.png'
|
||||
import DebuggingEmpty from '@/assets/images/application/debuggingEmpty.png'
|
||||
@@ -45,13 +46,17 @@ interface ChatProps {
|
||||
/** Source type: multi-agent cluster or single agent */
|
||||
source?: 'multi_agent' | 'agent';
|
||||
chatVariables?: Variable[]; // Add chatVariables prop
|
||||
handleEditVariables?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat debugging component
|
||||
* Allows testing application with different model configurations side-by-side
|
||||
*/
|
||||
const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, source = 'agent', chatVariables }) => {
|
||||
const Chat: FC<ChatProps> = ({
|
||||
chatList, data, updateChatList, handleSave, source = 'agent', chatVariables,
|
||||
handleEditVariables
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { message: messageApi } = App.useApp()
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -434,6 +439,7 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
const updateFileList = (list?: any[]) => {
|
||||
setFileList([...list || []])
|
||||
}
|
||||
const isNeedVariableConfig = chatVariables?.some(vo => vo.required && (vo.value === null || vo.value === undefined || vo.value === ''))
|
||||
|
||||
return (
|
||||
<div className="rb:relative rb:h-full rb:flex rb:flex-col">
|
||||
@@ -521,6 +527,18 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
className="rb:size-6 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/link.svg')] rb:hover:bg-[url('@/assets/images/conversation/link_hover.svg')]"
|
||||
></div>
|
||||
</Dropdown>
|
||||
{chatVariables && chatVariables.length > 0 && (
|
||||
<div
|
||||
className={clsx("rb:flex rb:items-center rb:border rb:rounded-lg rb:px-2 rb:text-[12px] rb:h-6 rb:cursor-pointer rb:hover:bg-[#F0F3F8] rb:text-[#212332]", {
|
||||
'rb:border-[#FF5D34] rb:text-[#FF5D34]': isNeedVariableConfig,
|
||||
'rb:border-[#DFE4ED]': !isNeedVariableConfig,
|
||||
})}
|
||||
onClick={handleEditVariables}
|
||||
>
|
||||
<SettingOutlined className="rb:mr-1" />
|
||||
{t(`memoryConversation.variableConfig`)}
|
||||
</div>
|
||||
)}
|
||||
</Flex>
|
||||
<Flex align="center">
|
||||
<AudioRecorder onRecordingComplete={handleRecordingComplete} />
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:27:52
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-28 16:48:52
|
||||
* @Last Modified time: 2026-03-16 15:58:10
|
||||
*/
|
||||
import { type FC, useRef, useMemo } from 'react';
|
||||
import { type FC, useRef, useMemo, useCallback } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { Layout, Tabs, Dropdown, Button, Flex } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
@@ -18,9 +18,10 @@ import exportIcon from '@/assets/images/export_hover.svg'
|
||||
import deleteIcon from '@/assets/images/delete_hover.svg'
|
||||
import type { Application, ApplicationModalRef } from '@/views/ApplicationManagement/types';
|
||||
import ApplicationModal from '@/views/ApplicationManagement/components/ApplicationModal'
|
||||
import type { CopyModalRef, AgentRef, ClusterRef, WorkflowRef } from '../types'
|
||||
import type { CopyModalRef, AgentRef, ClusterRef, WorkflowRef, FunConfigForm } from '../types'
|
||||
import { deleteApplication, appExport } from '@/api/application'
|
||||
import CopyModal from './CopyModal'
|
||||
import FunConfig from './FunConfig'
|
||||
|
||||
const { Header } = Layout;
|
||||
|
||||
@@ -28,6 +29,11 @@ const { Header } = Layout;
|
||||
* Tab keys for application configuration
|
||||
*/
|
||||
const tabKeys = ['arrangement', 'api', 'release', 'statistics']
|
||||
const sharingTabKeys = [
|
||||
'test',
|
||||
// 'log',
|
||||
'api'
|
||||
]
|
||||
|
||||
/**
|
||||
* Menu icon mapping
|
||||
@@ -64,22 +70,23 @@ interface ConfigHeaderProps {
|
||||
const ConfigHeader: FC<ConfigHeaderProps> = ({
|
||||
application, activeTab, handleChangeTab, refresh,
|
||||
workflowRef,
|
||||
appRef,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
const { id, source } = useParams();
|
||||
const applicationModalRef = useRef<ApplicationModalRef>(null);
|
||||
const copyModalRef = useRef<CopyModalRef>(null);
|
||||
|
||||
/**
|
||||
* Format tab items for display
|
||||
*/
|
||||
const formatTabItems = () => {
|
||||
return tabKeys.map(key => ({
|
||||
const formatTabItems = useMemo(() => {
|
||||
return (source === 'sharing' ? sharingTabKeys : tabKeys).map(key => ({
|
||||
key,
|
||||
label: t(`application.${key}`),
|
||||
}))
|
||||
}
|
||||
}, [source, sharingTabKeys, tabKeys])
|
||||
/**
|
||||
* Handle menu item click
|
||||
*/
|
||||
@@ -90,10 +97,16 @@ const ConfigHeader: FC<ConfigHeaderProps> = ({
|
||||
applicationModalRef.current?.handleOpen(application)
|
||||
break;
|
||||
case 'copy':
|
||||
copyModalRef.current?.handleOpen()
|
||||
appRef?.current?.handleSave(false)
|
||||
.then(() => {
|
||||
copyModalRef.current?.handleOpen()
|
||||
})
|
||||
break;
|
||||
case 'export':
|
||||
appExport(application.id, application.name)
|
||||
appRef?.current?.handleSave(false)
|
||||
.then(() => {
|
||||
appExport(application.id, application.name)
|
||||
})
|
||||
break;
|
||||
case 'delete':
|
||||
handleDelete()
|
||||
@@ -160,6 +173,13 @@ const ConfigHeader: FC<ConfigHeaderProps> = ({
|
||||
return items
|
||||
}, [t, handleClick, application])
|
||||
|
||||
const funConfig = useMemo(() => {
|
||||
return (appRef?.current?.funConfig || { file_type: [] }) as FunConfigForm
|
||||
}, [appRef])
|
||||
const handleSaveFunConfig = useCallback((value: FunConfigForm) => {
|
||||
appRef?.current?.handleSaveFunConfig?.(value)
|
||||
}, [appRef])
|
||||
|
||||
console.log('formatMenuItems', formatMenuItems)
|
||||
return (
|
||||
<>
|
||||
@@ -170,7 +190,7 @@ const ConfigHeader: FC<ConfigHeaderProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="rb:max-w-[100%-80px] rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap">{application?.name}</div>
|
||||
<Dropdown
|
||||
{source !== 'sharing' && <Dropdown
|
||||
menu={{ items: formatMenuItems, onClick: handleClick }}
|
||||
trigger={['click']}
|
||||
placement="bottomRight"
|
||||
@@ -178,19 +198,20 @@ const ConfigHeader: FC<ConfigHeaderProps> = ({
|
||||
<div
|
||||
className="rb:w-5 rb:h-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/edit.svg')] rb:hover:bg-[url('@/assets/images/edit_hover.svg')]"
|
||||
></div>
|
||||
</Dropdown>
|
||||
</Dropdown>}
|
||||
</div>
|
||||
|
||||
<div className="rb:flex rb:justify-center">
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
items={formatTabItems()}
|
||||
items={formatTabItems}
|
||||
onChange={handleChangeTab}
|
||||
className={styles.tabs}
|
||||
/>
|
||||
</div>
|
||||
{application?.type === 'workflow'
|
||||
? <div className="rb:h-8 rb:flex rb:items-center rb:justify-end rb:gap-2.5">
|
||||
? <div className="rb:h-8 rb:flex rb:items-center rb:justify-end rb:gap-2.5">
|
||||
{/* <FunConfig value={funConfig} refresh={handleSaveFunConfig} /> */}
|
||||
<Button onClick={clear}>{t('workflow.clear')}</Button>
|
||||
<Button onClick={addvariable}>{t('workflow.addvariable')}</Button>
|
||||
<Button onClick={run}>{t('workflow.run')}</Button>
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-03-05
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-11 15:42:13
|
||||
*/
|
||||
import { forwardRef, useImperativeHandle, useState } from 'react';
|
||||
import { Form, Radio, InputNumber, Flex, Switch, Row, Col } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import RbModal from '@/components/RbModal';
|
||||
import type { FunConfigForm } from '../../types'
|
||||
|
||||
interface FileUploadSettingModalRef {
|
||||
handleOpen: (values?: FileUploadSettings) => void;
|
||||
handleClose: () => void;
|
||||
}
|
||||
|
||||
interface FileUploadSettings extends Omit<FunConfigForm, 'enabled'> {}
|
||||
|
||||
interface FileUploadSettingModalProps {
|
||||
onSave: (values: FileUploadSettings) => void;
|
||||
}
|
||||
|
||||
const fileTypeOptions = [
|
||||
{
|
||||
type: 'document',
|
||||
icon: <div className="rb:size-9 rb:bg-cover rb:bg-[url('@/assets/images/file/txt.svg')]"></div>,
|
||||
formats: 'TXT, MD, MDX, MARKDOWN, PDF, DOC, DOCX',
|
||||
defaultMaxCount: 1,
|
||||
defaultMaxSize: 2
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
icon: <div className="rb:size-9 rb:bg-cover rb:bg-[url('@/assets/images/file/image.svg')]"></div>,
|
||||
formats: 'JPG, JPEG, PNG, GIF, WEBP, SVG',
|
||||
defaultMaxCount: 1,
|
||||
defaultMaxSize: 2
|
||||
},
|
||||
{
|
||||
type: 'audio',
|
||||
icon: <div className="rb:size-9 rb:bg-cover rb:bg-[url('@/assets/images/file/audio.svg')]"></div>,
|
||||
formats: 'MP3, M4A, WAV, AMR, MPGA',
|
||||
defaultMaxCount: 1,
|
||||
defaultMaxSize: 2
|
||||
},
|
||||
{
|
||||
type: 'video',
|
||||
icon: <div className="rb:size-9 rb:bg-cover rb:bg-[url('@/assets/images/file/video.svg')]"></div>,
|
||||
formats: 'MP4, MOV, MPEG, WEBM',
|
||||
defaultMaxCount: 1,
|
||||
defaultMaxSize: 2
|
||||
},
|
||||
];
|
||||
|
||||
const FileUploadSettingModal = forwardRef<FileUploadSettingModalRef, FileUploadSettingModalProps>(({
|
||||
onSave,
|
||||
}, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const values = Form.useWatch([], form)
|
||||
|
||||
const handleClose = () => {
|
||||
setVisible(false);
|
||||
form.resetFields();
|
||||
};
|
||||
|
||||
const handleOpen = (values?: FileUploadSettings) => {
|
||||
setVisible(true);
|
||||
// if (values) {
|
||||
// form.setFieldsValue(values);
|
||||
// }
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const values = await form.validateFields();
|
||||
onSave(values);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
handleOpen,
|
||||
handleClose
|
||||
}));
|
||||
|
||||
|
||||
return (
|
||||
<RbModal
|
||||
title={t('application.settings')}
|
||||
open={visible}
|
||||
onCancel={handleClose}
|
||||
onOk={handleSave}
|
||||
width={600}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
uploadType: 'both',
|
||||
fileTypes: fileTypeOptions.map(opt => ({
|
||||
type: opt.type,
|
||||
enabled: false,
|
||||
maxCount: opt.defaultMaxCount,
|
||||
maxSize: opt.defaultMaxSize
|
||||
}))
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
label={t('application.uploadType')}
|
||||
name="uploadType"
|
||||
>
|
||||
<Radio.Group block buttonStyle="solid">
|
||||
<Radio.Button value="local">{t('application.local')}</Radio.Button>
|
||||
<Radio.Button value="url">URL</Radio.Button>
|
||||
<Radio.Button value="both">{t('application.both')}</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
<div className="rb:text-[12px] rb:text-[#5B6167] rb:mb-1">{t('application.maxCount')}</div>
|
||||
<Form.Item
|
||||
name="maxCount"
|
||||
label={t('application.maxCount')}
|
||||
>
|
||||
<InputNumber min={1} max={100} className="rb:w-full!" placeholder={t('common.pleaseEnter')} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={t('application.supportedTypes')}>
|
||||
<Form.List name="fileTypes">
|
||||
{(fields) => (
|
||||
<Flex vertical gap={12}>
|
||||
{fields.map((field, index) => {
|
||||
const option = fileTypeOptions[index];
|
||||
const isEnabled = values?.fileTypes?.[index]?.enabled;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={field.key}
|
||||
className={clsx("rb:border rb:border-[#DFE4ED] rb:rounded-lg rb:p-3", {
|
||||
'rb:bg-[#f5f7fc]': isEnabled
|
||||
})}
|
||||
>
|
||||
<Row gutter={12}>
|
||||
<Col flex="36px" className="rb:self-center">
|
||||
{option.icon}
|
||||
</Col>
|
||||
<Col flex="1">
|
||||
<Flex align="center" justify="space-between">
|
||||
<Flex vertical>
|
||||
<div className="rb:font-medium">{t(`application.${option.type}`)}</div>
|
||||
<div className="rb:text-[12px] rb:text-[#5B6167]">{option.formats}</div>
|
||||
</Flex>
|
||||
<Form.Item name={[field.name, 'enabled']} valuePropName="checked" noStyle>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Flex>
|
||||
</Col>
|
||||
</Row>
|
||||
{isEnabled && (
|
||||
<Flex align="center" gap={12} className="rb:mt-3! rb:pt-3! rb:border-t rb:border-[#DFE4ED]">
|
||||
<div>{t('application.singleMaxSize')}: </div>
|
||||
<Form.Item name={[field.name, 'maxSize']} noStyle>
|
||||
<InputNumber min={1} max={500} suffix="MB" className="rb:flex-1" />
|
||||
</Form.Item>
|
||||
</Flex>
|
||||
)}
|
||||
<Form.Item name={[field.name, 'type']} hidden>
|
||||
<input type="hidden" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
)}
|
||||
</Form.List>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</RbModal>
|
||||
);
|
||||
});
|
||||
|
||||
export default FileUploadSettingModal;
|
||||
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:27:56
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-13 17:20:30
|
||||
*/
|
||||
/**
|
||||
* Copy Application Modal
|
||||
* Allows users to duplicate an existing application with a new name
|
||||
*/
|
||||
|
||||
import { forwardRef, useImperativeHandle, useState, useRef } from 'react';
|
||||
import { Form, Button, Flex } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { FunConfigModalRef } from '../../types'
|
||||
import RbModal from '@/components/RbModal'
|
||||
import type { FunConfigForm } from '../../types'
|
||||
import SwitchFormItem from '@/components/FormItem/SwitchFormItem'
|
||||
import FileUploadSettingModal from './FileUploadSettingModal'
|
||||
|
||||
const FormItem = Form.Item;
|
||||
|
||||
interface FunConfigModalProps {
|
||||
refresh: (value: FunConfigForm) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal for copying applications
|
||||
*/
|
||||
const FunConfigModal = forwardRef<FunConfigModalRef, FunConfigModalProps>(({
|
||||
refresh,
|
||||
}, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [form] = Form.useForm<FunConfigForm>();
|
||||
const [loading, setLoading] = useState(false)
|
||||
const values = Form.useWatch([], form)
|
||||
const fileUploadSettingModalRef = useRef<any>(null)
|
||||
|
||||
/** Close modal and reset form */
|
||||
const handleClose = () => {
|
||||
setVisible(false);
|
||||
form.resetFields();
|
||||
setLoading(false)
|
||||
};
|
||||
|
||||
/** Open modal */
|
||||
const handleOpen = (initValue: FunConfigForm) => {
|
||||
setVisible(true);
|
||||
form.setFieldsValue(initValue)
|
||||
};
|
||||
/** Copy application with new name */
|
||||
const handleSave = () => {
|
||||
setVisible(false);
|
||||
setLoading(true)
|
||||
const values = form.getFieldsValue()
|
||||
refresh(values)
|
||||
}
|
||||
|
||||
const handleOpenSettings = () => {
|
||||
fileUploadSettingModalRef.current?.handleOpen(values)
|
||||
}
|
||||
|
||||
const handleSaveSettings = (settings: any) => {
|
||||
form.setFieldsValue(settings)
|
||||
}
|
||||
|
||||
/** Expose methods to parent component */
|
||||
useImperativeHandle(ref, () => ({
|
||||
handleOpen,
|
||||
handleClose
|
||||
}));
|
||||
return (
|
||||
<>
|
||||
<RbModal
|
||||
title={t('application.funConfig')}
|
||||
open={visible}
|
||||
onCancel={handleClose}
|
||||
okText={t('common.copy')}
|
||||
onOk={handleSave}
|
||||
confirmLoading={loading}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
>
|
||||
<Flex vertical gap={12}>
|
||||
<div className="rb:relative rb:border rb:border-[#DFE4ED] rb:p-3 rb:rounded-lg rb:bg-[#f5f7fc]">
|
||||
<SwitchFormItem
|
||||
title={t(`memoryConversation.web_search`)}
|
||||
name={['web_search', "enabled"]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rb:relative rb:border rb:border-[#DFE4ED] rb:p-3 rb:rounded-lg rb:bg-[#f5f7fc]">
|
||||
<SwitchFormItem
|
||||
title={t('application.textTranfer')}
|
||||
name={['textTranfer', "enabled"]}
|
||||
desc={t('application.textTranferDesc')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rb:relative rb:border rb:border-[#DFE4ED] rb:p-3 rb:rounded-lg rb:bg-[#f5f7fc]">
|
||||
<SwitchFormItem
|
||||
title={t('application.fileUpload')}
|
||||
name={['fileUpload', "enabled"]}
|
||||
desc={values?.fileUpload?.enabled ? undefined : t('application.fileUploadDesc')}
|
||||
/>
|
||||
{values?.fileUpload?.enabled && values?.fileTypes?.length > 0 ? <>
|
||||
<div className="rb:grid rb:grid-cols-3 rb:gap-2 rb:text-[12px] rb:text-[#5B6167]">
|
||||
<div>{t(`application.supportedTypes`)}</div>
|
||||
<div>{t('application.maxCount')}</div>
|
||||
<div>{t('application.singleMaxSize')}</div>
|
||||
</div>
|
||||
{values?.fileTypes?.filter(item => item.enabled).map(item => (
|
||||
<div key={item.type} className="rb:grid rb:grid-cols-3 rb:gap-2">
|
||||
<div>{t(`application.${item.type}`)}</div>
|
||||
<div>{item.maxCount} {t('application.unix')}</div>
|
||||
<div>{item.maxSize} MB</div>
|
||||
</div>
|
||||
))}
|
||||
<Button block onClick={handleOpenSettings}>{t('application.setting')}</Button>
|
||||
</> : null}
|
||||
<FormItem name="fileTypes" noStyle hidden></FormItem>
|
||||
<FormItem name="uploadType" noStyle hidden></FormItem>
|
||||
</div>
|
||||
</Flex>
|
||||
</Form>
|
||||
</RbModal>
|
||||
|
||||
<FileUploadSettingModal
|
||||
ref={fileUploadSettingModalRef}
|
||||
onSave={handleSaveSettings}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default FunConfigModal;
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-03-13 17:20:21
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-13 17:20:21
|
||||
*/
|
||||
import { type FC, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from 'antd';
|
||||
|
||||
import FunConfigModal from './FunConfigModal'
|
||||
import type { FunConfigModalRef, FunConfigForm } from '../../types'
|
||||
|
||||
/** Props for the FunConfig component */
|
||||
interface FunConfigProps {
|
||||
/** Current feature configuration values */
|
||||
value: FunConfigForm;
|
||||
/** Callback to propagate updated config back to the parent */
|
||||
refresh: (value: FunConfigForm) => void;
|
||||
}
|
||||
|
||||
const FunConfig: FC<FunConfigProps> = ({
|
||||
value,
|
||||
refresh
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
// Ref used to imperatively open the config modal
|
||||
const funConfigModalRef = useRef<FunConfigModalRef>(null)
|
||||
|
||||
/** Open the feature config modal pre-populated with the current values */
|
||||
const handleFunConfig = () => {
|
||||
console.log('funConfig', value)
|
||||
funConfigModalRef.current?.handleOpen(value)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Button that triggers the feature configuration modal */}
|
||||
<Button onClick={handleFunConfig}>{t('application.funConfig')}</Button>
|
||||
|
||||
{/* Modal for editing feature settings; calls refresh on save */}
|
||||
<FunConfigModal
|
||||
ref={funConfigModalRef}
|
||||
refresh={refresh}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default FunConfig
|
||||
@@ -1,22 +1,24 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:29:37
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-03 16:29:37
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-12 10:23:18
|
||||
*/
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import ConfigHeader from './components/ConfigHeader'
|
||||
import type { AgentRef, ClusterRef, WorkflowRef } from './types'
|
||||
import type { AgentRef, ClusterRef, WorkflowRef, Config } from './types'
|
||||
import type { Application } from '@/views/ApplicationManagement/types'
|
||||
import Agent from './Agent'
|
||||
import Api from './Api'
|
||||
import ReleasePage from './ReleasePage'
|
||||
import Cluster from './Cluster'
|
||||
import { getApplication } from '@/api/application'
|
||||
import { getApplication, getApplicationConfig, getMultiAgentConfig, getWorkflowConfig } from '@/api/application'
|
||||
import Workflow from '@/views/Workflow';
|
||||
import Statistics from './Statistics'
|
||||
import TestChat from './TestChat'
|
||||
import type { WorkflowConfig } from '@/views/Workflow/types';
|
||||
|
||||
/**
|
||||
* Application configuration page component
|
||||
@@ -25,7 +27,7 @@ import Statistics from './Statistics'
|
||||
*/
|
||||
const ApplicationConfig: React.FC = () => {
|
||||
// Hooks
|
||||
const { id } = useParams();
|
||||
const { id, source } = useParams();
|
||||
|
||||
// Refs for different application types
|
||||
const agentRef = useRef<AgentRef>(null)
|
||||
@@ -36,6 +38,31 @@ const ApplicationConfig: React.FC = () => {
|
||||
const [application, setApplication] = useState<Application | null>(null);
|
||||
const [activeTab, setActiveTab] = useState('arrangement');
|
||||
|
||||
useEffect(() => {
|
||||
setActiveTab(source === 'sharing' ? 'test' : 'arrangement')
|
||||
}, [source])
|
||||
|
||||
const [config, setConfig] = useState<Config | WorkflowConfig | null>(null)
|
||||
useEffect(() => {
|
||||
if (source === 'sharing' && application?.type) {
|
||||
getAppConfig()
|
||||
}
|
||||
}, [source, application?.type])
|
||||
|
||||
const getAppConfig = () => {
|
||||
if (!id || !source || !application?.type) {
|
||||
return
|
||||
}
|
||||
const request = application?.type === 'agent'
|
||||
? getApplicationConfig
|
||||
: application?.type === 'multi_agent'
|
||||
? getMultiAgentConfig
|
||||
: getWorkflowConfig
|
||||
request(id as string).then(res => {
|
||||
setConfig(res as Config | WorkflowConfig | null)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle tab change with auto-save for arrangement tab
|
||||
* @param key - New tab key
|
||||
@@ -94,6 +121,7 @@ const ApplicationConfig: React.FC = () => {
|
||||
{activeTab === 'api' && <Api application={application} />}
|
||||
{activeTab === 'release' && <ReleasePage data={application as Application} refresh={getApplicationInfo} />}
|
||||
{activeTab === 'statistics' && <Statistics application={application} />}
|
||||
{activeTab === 'test' && <TestChat application={application} config={config} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:29:49
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-28 16:40:30
|
||||
* @Last Modified time: 2026-03-13 17:01:04
|
||||
*/
|
||||
import type { KnowledgeConfig } from './components/Knowledge/types'
|
||||
import type { Variable } from './components/VariableList/types'
|
||||
@@ -77,6 +77,8 @@ export interface Config extends MultiAgentConfig {
|
||||
/** Last update timestamp */
|
||||
updated_at: number;
|
||||
skills?: SkillConfigForm | null;
|
||||
|
||||
funConfig?: FunConfigForm;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -127,6 +129,8 @@ export interface AgentRef {
|
||||
* @param flag - Whether to show success message
|
||||
*/
|
||||
handleSave: (flag?: boolean) => Promise<unknown>;
|
||||
funConfig: Config['funConfig'];
|
||||
handleSaveFunConfig?: (value: FunConfigForm) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,6 +142,8 @@ export interface ClusterRef {
|
||||
* @param flag - Whether to show success message
|
||||
*/
|
||||
handleSave: (flag?: boolean) => Promise<unknown>;
|
||||
funConfig: Config['funConfig'];
|
||||
handleSaveFunConfig?: (value: FunConfigForm) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -156,6 +162,8 @@ export interface WorkflowRef {
|
||||
/** Add variable */
|
||||
addVariable: () => void;
|
||||
config: WorkflowConfig | null;
|
||||
funConfig: WorkflowConfig['funConfig'];
|
||||
handleSaveFunConfig?: (value: FunConfigForm) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -400,4 +408,34 @@ export interface StatisticsData {
|
||||
total_api_calls: number;
|
||||
/** Total tokens used */
|
||||
total_tokens: number;
|
||||
}
|
||||
|
||||
export interface FileTypeConfig {
|
||||
type: string;
|
||||
enabled: boolean;
|
||||
maxCount: number;
|
||||
maxSize: number;
|
||||
}
|
||||
export interface FunConfigForm {
|
||||
enabled: boolean;
|
||||
fileTypes: FileTypeConfig[]
|
||||
uploadType: 'local' | 'url' | 'both';
|
||||
}
|
||||
/**
|
||||
* Function config modal ref methods
|
||||
*/
|
||||
export interface FunConfigModalRef {
|
||||
/** Open function config modal */
|
||||
handleOpen: (value: FunConfigForm) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* App sharing modal ref methods
|
||||
*/
|
||||
export interface AppSharingModalRef {
|
||||
handleOpen: () => void;
|
||||
}
|
||||
export interface AppSharingForm {
|
||||
target_workspace_ids: string[];
|
||||
permission: 'readonly' | 'editable'
|
||||
}
|
||||
158
web/src/views/ApplicationManagement/MySharing.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:34:12
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-13 17:36:16
|
||||
*/
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, App, Flex, Row, Col, Collapse } from 'antd';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import type { MySharedOutItem } from './types';
|
||||
import { mySharedOutList, cancelShare, cancelSpaceShare } from '@/api/application'
|
||||
|
||||
const MySharing: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { modal } = App.useApp();
|
||||
const [data, setData] = useState<MySharedOutItem[]>([])
|
||||
|
||||
useEffect(() => { getList() }, [])
|
||||
|
||||
const getList = () => {
|
||||
mySharedOutList().then(res => setData(res as MySharedOutItem[]))
|
||||
}
|
||||
|
||||
/** Group items by target_workspace_id */
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<string, { workspace: Pick<MySharedOutItem, 'target_workspace_id' | 'target_workspace_name' | 'target_workspace_icon'>, items: MySharedOutItem[] }>();
|
||||
data.forEach(item => {
|
||||
if (!map.has(item.target_workspace_id)) {
|
||||
map.set(item.target_workspace_id, {
|
||||
workspace: {
|
||||
target_workspace_id: item.target_workspace_id,
|
||||
target_workspace_name: item.target_workspace_name,
|
||||
target_workspace_icon: item.target_workspace_icon,
|
||||
},
|
||||
items: [],
|
||||
});
|
||||
}
|
||||
map.get(item.target_workspace_id)!.items.push(item);
|
||||
});
|
||||
return Array.from(map.values());
|
||||
}, [data]);
|
||||
|
||||
const handleAllCancel = (workspace: { target_workspace_name: string; target_workspace_id: string; }) => {
|
||||
modal.confirm({
|
||||
title: t('application.confirmWorkspaceCancelShareDesc', { workspace: workspace.target_workspace_name }),
|
||||
okText: t('common.confirm'),
|
||||
cancelText: t('common.cancel'),
|
||||
okType: 'danger',
|
||||
onOk: () => {
|
||||
cancelSpaceShare(workspace.target_workspace_id)
|
||||
.then(() => {
|
||||
getList();
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancelOne = (item: MySharedOutItem) => {
|
||||
modal.confirm({
|
||||
title: t('application.confirmAppCancelShareDesc', { app: item.source_app_name, workspace: item.target_workspace_name }),
|
||||
okText: t('common.confirm'),
|
||||
cancelText: t('common.cancel'),
|
||||
okType: 'danger',
|
||||
onOk: () => {
|
||||
cancelShare(item.source_app_id, item.target_workspace_id)
|
||||
.then(() => {
|
||||
getList();
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex vertical gap={12}>
|
||||
{grouped.map(({ workspace, items }) => (
|
||||
<Collapse
|
||||
key={workspace.target_workspace_id}
|
||||
defaultActiveKey={[workspace.target_workspace_id]}
|
||||
items={[{
|
||||
key: workspace.target_workspace_id,
|
||||
label: (
|
||||
<Flex align="center" gap={12}>
|
||||
{workspace.target_workspace_icon
|
||||
? <img src={workspace.target_workspace_icon} className="rb:w-8 rb:h-8 rb:rounded-lg rb:object-cover" />
|
||||
: <div className="rb:w-8 rb:h-8 rb:rounded-lg rb:bg-[#155eef] rb:flex rb:items-center rb:justify-center rb:text-[14px] rb:text-white">
|
||||
{workspace.target_workspace_name[0]}
|
||||
</div>
|
||||
}
|
||||
<div>
|
||||
<span className="rb:font-medium">{workspace.target_workspace_name}</span>
|
||||
<div className="rb:text-[#5B6167] rb:text-[12px]">{t('application.appCount', { count: items.length })}</div>
|
||||
</div>
|
||||
</Flex>
|
||||
),
|
||||
extra: (
|
||||
<Button
|
||||
size="small"
|
||||
onClick={e => { e.stopPropagation(); handleAllCancel(workspace); }}
|
||||
>
|
||||
{t('application.allCancel')}
|
||||
</Button>
|
||||
),
|
||||
children: (
|
||||
<Row gutter={[12, 12]}>
|
||||
{items.map(item => (
|
||||
<Col key={item.id} span={6} className="rb:bg-[#F6F6F6] rb:rounded-lg rb:py-3! rb:px-4! rb:relative">
|
||||
<div
|
||||
className="rb:absolute rb:top-3 rb:right-3 rb:cursor-pointer rb:size-4 rb:bg-cover rb:bg-[url('src/assets/images/close.svg')]"
|
||||
onClick={() => handleCancelOne(item)}
|
||||
/>
|
||||
<Flex gap={8} align="center">
|
||||
<div className="rb:size-7 rb:rounded-lg rb:bg-[#155eef] rb:flex rb:items-center rb:justify-center rb:text-[14px] rb:text-white">
|
||||
{item.source_app_name[0]}
|
||||
</div>
|
||||
<div className="rb:font-medium">{item.source_app_name}</div>
|
||||
</Flex>
|
||||
<Flex vertical gap={4} className="rb:mt-3! rb:text-[12px]!">
|
||||
<Flex gap={5} justify="space-between">
|
||||
<span className="rb:text-[#5B6167]">{t('application.type')}</span>
|
||||
<span className={clsx({
|
||||
'rb:text-[#155EEF] rb:font-medium': item.source_app_type === 'agent',
|
||||
'rb:text-[#369F21] rb:font-medium': item.source_app_type === 'multi_agent',
|
||||
})}>
|
||||
{t(`application.${item.source_app_type}`)}
|
||||
</span>
|
||||
</Flex>
|
||||
<Flex gap={5} justify="space-between">
|
||||
<span className="rb:text-[#5B6167]">{t('application.version')}</span>
|
||||
<span>{item.source_app_version}</span>
|
||||
</Flex>
|
||||
<Flex gap={5} justify="space-between">
|
||||
<span className="rb:text-[#5B6167]">{t('application.permission')}</span>
|
||||
<span className={clsx({
|
||||
'rb:text-[#369F21] rb:font-medium': item.permission === 'editable',
|
||||
'rb:text-[#5B6167] rb:font-medium': item.permission === 'readonly',
|
||||
})}>
|
||||
{t(`application.${item.permission}`)}
|
||||
</span>
|
||||
</Flex>
|
||||
<Flex gap={5} justify="space-between">
|
||||
<span className="rb:text-[#5B6167]">{t('application.souceStatus')}</span>
|
||||
<span>{item.source_app_is_active ? t('application.sourceActive') : t('application.sourceInactive')}</span>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
),
|
||||
}]}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default MySharing;
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-28 14:08:14
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-06 12:05:46
|
||||
* @Last Modified time: 2026-03-12 17:19:46
|
||||
*/
|
||||
/**
|
||||
* UploadModal Component
|
||||
@@ -63,6 +63,7 @@ const UploadModal = forwardRef<UploadModalRef, UploadModalProps>(({
|
||||
* Resets all states and form fields
|
||||
*/
|
||||
const handleClose = () => {
|
||||
refresh()
|
||||
setVisible(false);
|
||||
form.resetFields();
|
||||
setCurrent(0);
|
||||
@@ -211,7 +212,6 @@ const UploadModal = forwardRef<UploadModalRef, UploadModalProps>(({
|
||||
fileSize={100}
|
||||
maxCount={1}
|
||||
fileType={['yml']}
|
||||
draggerHeight={200}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-28 14:08:14
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-06 12:05:46
|
||||
* @Last Modified time: 2026-03-12 17:19:33
|
||||
*/
|
||||
/**
|
||||
* UploadWorkflowModal Component
|
||||
@@ -72,6 +72,7 @@ const UploadWorkflowModal = forwardRef<UploadWorkflowModalRef, UploadWorkflowMod
|
||||
setFirstFormData(null);
|
||||
setAppId(null);
|
||||
setLoading(false);
|
||||
refresh()
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:34:12
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-02 17:48:51
|
||||
* @Last Modified time: 2026-03-16 09:56:02
|
||||
*/
|
||||
/**
|
||||
* Application Management Page
|
||||
@@ -10,9 +10,9 @@
|
||||
* Supports creating, editing, and deleting applications
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import React, { useState, useRef, useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Row, Col, App, Select, Space, Dropdown } from 'antd';
|
||||
import { Button, App, Select, Space, Dropdown, type SegmentedProps, Flex, Form } from 'antd';
|
||||
import clsx from 'clsx';
|
||||
import { DeleteOutlined } from '@ant-design/icons';
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
@@ -21,12 +21,16 @@ import ApplicationModal, { types } from './components/ApplicationModal';
|
||||
import type { Application, ApplicationModalRef, Query, UploadWorkflowModalRef } from './types';
|
||||
import SearchInput from '@/components/SearchInput'
|
||||
import RbCard from '@/components/RbCard/Card'
|
||||
import { getApplicationListUrl, deleteApplication } from '@/api/application'
|
||||
import { getApplicationListUrl, deleteApplication, copyApplication } from '@/api/application'
|
||||
import PageScrollList, { type PageScrollListRef } from '@/components/PageScrollList'
|
||||
import { formatDateTime } from '@/utils/format';
|
||||
import UploadWorkflowModal from './components/UploadWorkflowModal'
|
||||
import UploadModal from './components/UploadModal'
|
||||
import PageTabs from '@/components/PageTabs'
|
||||
import MySharing from './MySharing'
|
||||
|
||||
|
||||
const tabKeys = ['apps', 'sharing', 'myShare']
|
||||
/**
|
||||
* Application management main component
|
||||
*/
|
||||
@@ -34,20 +38,19 @@ const ApplicationManagement: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { modal } = App.useApp();
|
||||
const [searchParams] = useSearchParams()
|
||||
const [query, setQuery] = useState<Query>({} as Query);
|
||||
const applicationModalRef = useRef<ApplicationModalRef>(null);
|
||||
const scrollListRef = useRef<PageScrollListRef>(null)
|
||||
const uploadWorkflowModalRef = useRef<UploadWorkflowModalRef>(null);
|
||||
const uploadModalRef = useRef<UploadWorkflowModalRef>(null);
|
||||
const [form] = Form.useForm<Query>()
|
||||
const query = Form.useWatch([], form)
|
||||
const [activeTab, setActiveTab] = useState('apps');
|
||||
|
||||
useEffect(() => {
|
||||
// Convert URLSearchParams to a plain object for easier access
|
||||
const data = Object.fromEntries(searchParams)
|
||||
const { type } = data
|
||||
setQuery(prev => ({
|
||||
...prev,
|
||||
type: type || undefined
|
||||
}))
|
||||
form.setFieldValue('type', type || undefined)
|
||||
}, [searchParams])
|
||||
|
||||
/** Refresh application list */
|
||||
@@ -61,7 +64,11 @@ const ApplicationManagement: React.FC = () => {
|
||||
}
|
||||
/** Navigate to application configuration page */
|
||||
const handleEdit = (item: Application) => {
|
||||
window.open(`/#/application/config/${item.id}`);
|
||||
let url = `/#/application/config/${item.id}`
|
||||
if (item.is_shared) {
|
||||
url += `/${activeTab}`
|
||||
}
|
||||
window.open(url);
|
||||
}
|
||||
/** Delete application with confirmation */
|
||||
const handleDelete = (item: Application) => {
|
||||
@@ -81,9 +88,6 @@ const ApplicationManagement: React.FC = () => {
|
||||
}
|
||||
})
|
||||
}
|
||||
const handleChangeType = (value?: string) => {
|
||||
setQuery(prev => ({...prev, type: value}))
|
||||
}
|
||||
|
||||
const handleImport = () => {
|
||||
uploadWorkflowModalRef.current?.handleOpen()
|
||||
@@ -97,90 +101,137 @@ const ApplicationManagement: React.FC = () => {
|
||||
uploadModalRef.current?.handleOpen()
|
||||
}
|
||||
}
|
||||
const formatTabItems = useMemo(() => {
|
||||
return tabKeys.map(value => ({
|
||||
value,
|
||||
label: t(`application.${value}`),
|
||||
}))
|
||||
}, [tabKeys, t])
|
||||
/** Handle tab change */
|
||||
const handleChangeTab = (value: SegmentedProps['value']) => {
|
||||
setActiveTab(value as string);
|
||||
form.resetFields()
|
||||
}
|
||||
const handleCopy = (item: Application) => {
|
||||
modal.confirm({
|
||||
title: t('application.confirmCopyDesc', { app: item.name }),
|
||||
okText: t('common.copy'),
|
||||
cancelText: t('common.cancel'),
|
||||
onOk: () => {
|
||||
copyApplication(item.id)
|
||||
.then(() => {
|
||||
setActiveTab('apps')
|
||||
})
|
||||
}
|
||||
});
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Row gutter={16} className="rb:mb-4">
|
||||
<Col span={4}>
|
||||
<Select
|
||||
value={query.type}
|
||||
placeholder={t('application.applicationType')}
|
||||
options={types.map((type) => ({
|
||||
value: type,
|
||||
label: t(`application.${type}`),
|
||||
}))}
|
||||
allowClear
|
||||
className="rb:w-full"
|
||||
onChange={handleChangeType}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<SearchInput
|
||||
placeholder={t('application.searchPlaceholder')}
|
||||
onSearch={(value) => setQuery({ search: value })}
|
||||
style={{width: '100%'}}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12} className="rb:text-right">
|
||||
<Space size={12}>
|
||||
<Dropdown
|
||||
menu={{ items: [
|
||||
{ key: 'thirdParty', label: t('application.importWorkflow') },
|
||||
{ key: 'import', label: t('application.import') },
|
||||
], onClick: handleClick }}
|
||||
placement="bottomRight"
|
||||
<Flex justify="space-between" className="rb:mb-3!">
|
||||
<PageTabs
|
||||
value={activeTab}
|
||||
options={formatTabItems}
|
||||
onChange={handleChangeTab}
|
||||
/>
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
initialValues={{}}
|
||||
>
|
||||
{activeTab !== 'myShare' &&
|
||||
<Space size={8}>
|
||||
<Form.Item name="type" noStyle>
|
||||
<Select
|
||||
placeholder={t('application.applicationType')}
|
||||
options={types.map((type) => ({
|
||||
value: type,
|
||||
label: t(`application.${type}`),
|
||||
}))}
|
||||
allowClear
|
||||
className="rb:w-30"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="search" noStyle>
|
||||
<SearchInput
|
||||
placeholder={t('application.searchPlaceholder')}
|
||||
className="rb:w-75!"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{activeTab === 'apps' && <>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'thirdParty', label: t('application.importWorkflow') },
|
||||
{ key: 'import', label: t('application.import') },
|
||||
], onClick: handleClick
|
||||
}}
|
||||
placement="bottomRight"
|
||||
>
|
||||
<Button>
|
||||
{t('application.import')}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
<Button type="primary" onClick={handleCreate}>
|
||||
{t('application.createApplication')}
|
||||
</Button>
|
||||
</>}
|
||||
</Space>
|
||||
}
|
||||
</Form>
|
||||
</Flex>
|
||||
|
||||
{(activeTab === 'apps' || activeTab === 'sharing') &&
|
||||
<PageScrollList<Application, Query>
|
||||
ref={scrollListRef}
|
||||
url={getApplicationListUrl}
|
||||
query={{ ...query, shared_only: activeTab === 'sharing' }}
|
||||
renderItem={(item) => (
|
||||
<RbCard
|
||||
title={item.name}
|
||||
avatar={
|
||||
<div className="rb:w-12 rb:h-12 rb:rounded-lg rb:mr-3.25 rb:bg-[#155eef] rb:flex rb:items-center rb:justify-center rb:text-[28px] rb:text-[#ffffff]">
|
||||
{item.name[0]}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button>
|
||||
{t('application.import')}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
<Button type="primary" onClick={handleCreate}>
|
||||
{t('application.createApplication')}
|
||||
</Button>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<PageScrollList<Application, Query>
|
||||
ref={scrollListRef}
|
||||
url={getApplicationListUrl}
|
||||
query={query}
|
||||
renderItem={(item) => (
|
||||
<RbCard
|
||||
title={item.name}
|
||||
avatar={
|
||||
<div className="rb:w-12 rb:h-12 rb:rounded-lg rb:mr-3.25 rb:bg-[#155eef] rb:flex rb:items-center rb:justify-center rb:text-[28px] rb:text-[#ffffff]">
|
||||
{item.name[0]}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{['type', 'source', 'created_at'].map((key, index) => (
|
||||
<div key={key} className={clsx("rb:flex rb:justify-between rb:gap-5 rb:font-regular rb:text-[14px]", {
|
||||
'rb:mt-3': index !== 0
|
||||
})}>
|
||||
<span className="rb:text-[#5B6167]">{t(`application.${key}`)}</span>
|
||||
<span className={clsx({
|
||||
'rb:text-[#155EEF] rb:font-medium': key === 'type' && item[key] === 'agent',
|
||||
'rb:text-[#369F21] rb:font-medium': key === 'type' && item[key] === 'multi_agent',
|
||||
{['type', 'source', 'created_at'].map((key, index) => (
|
||||
<div key={key} className={clsx("rb:flex rb:justify-between rb:gap-5 rb:font-regular rb:text-[14px]", {
|
||||
'rb:mt-3': index !== 0
|
||||
})}>
|
||||
{key === 'source' && item.is_shared
|
||||
? t('application.shared')
|
||||
: key === 'source' && !item.is_shared
|
||||
? t('application.configuration')
|
||||
: key === 'created_at'
|
||||
? formatDateTime(item.created_at, 'YYYY-MM-DD HH:mm:ss')
|
||||
: t(`application.${item[key as keyof Application]}`)
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<span className="rb:text-[#5B6167]">{t(`application.${key}`)}</span>
|
||||
<span className={clsx({
|
||||
'rb:text-[#155EEF] rb:font-medium': key === 'type' && item[key] === 'agent',
|
||||
'rb:text-[#369F21] rb:font-medium': key === 'type' && item[key] === 'multi_agent',
|
||||
})}>
|
||||
{key === 'source' && item.is_shared
|
||||
? item.source_workspace_name
|
||||
: key === 'source' && !item.is_shared
|
||||
? t('application.configuration')
|
||||
: key === 'created_at'
|
||||
? formatDateTime(item.created_at, 'YYYY-MM-DD HH:mm:ss')
|
||||
: t(`application.${item[key as keyof Application]}`)
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="rb:mt-5 rb:flex rb:justify-between rb:gap-2.5">
|
||||
<Button type="primary" ghost className="rb:w-[calc(100%-46px)]" onClick={() => handleEdit(item)}>{t('application.configuration')}</Button>
|
||||
<Button icon={<DeleteOutlined />} onClick={() => handleDelete(item)}></Button>
|
||||
</div>
|
||||
</RbCard>
|
||||
)}
|
||||
/>
|
||||
{item.is_shared
|
||||
? <div className="rb:mt-5 rb:flex rb:justify-between rb:gap-2.5">
|
||||
<Button type="primary" ghost block onClick={() => handleEdit(item)}>{t('common.view')}</Button>
|
||||
{item.share_permission === 'editable' && <Button type="primary" className="rb:w-[calc(100%-46px)]" onClick={() => handleCopy(item)}>{t('common.copy')}</Button>}
|
||||
</div>
|
||||
: <div className="rb:mt-5 rb:flex rb:justify-between rb:gap-2.5">
|
||||
<Button type="primary" ghost className="rb:w-[calc(100%-46px)]" onClick={() => handleEdit(item)}>{t('application.configuration')}</Button>
|
||||
<Button icon={<DeleteOutlined />} onClick={() => handleDelete(item)}></Button>
|
||||
</div>
|
||||
}
|
||||
</RbCard>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
{activeTab === 'myShare' && <MySharing />}
|
||||
|
||||
|
||||
<ApplicationModal
|
||||
ref={applicationModalRef}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:34:15
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-28 16:16:03
|
||||
* @Last Modified time: 2026-03-16 09:55:52
|
||||
*/
|
||||
/**
|
||||
* Type definitions for Application Management
|
||||
@@ -15,6 +15,7 @@ export interface Query {
|
||||
/** Search keyword */
|
||||
search: string;
|
||||
type?: string;
|
||||
shared_only?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,6 +54,11 @@ export interface Application {
|
||||
created_at: number;
|
||||
/** Last update timestamp */
|
||||
updated_at: number;
|
||||
share_permission?: string;
|
||||
source_workspace_name?: string;
|
||||
source_workspace_icon?: string;
|
||||
source_app_version?: string;
|
||||
source_app_is_active?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -241,4 +247,20 @@ export interface UploadWorkflowModalRef {
|
||||
export interface UploadModalRef {
|
||||
/** Open the upload workflow modal */
|
||||
handleOpen: () => void;
|
||||
}
|
||||
export interface MySharedOutItem {
|
||||
id: string;
|
||||
source_app_id: string;
|
||||
source_workspace_id: string;
|
||||
target_workspace_id: string;
|
||||
shared_by: string;
|
||||
permission: 'readonly' | 'editable';
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
source_app_name: string;
|
||||
source_app_type: string;
|
||||
source_app_version: string;
|
||||
source_app_is_active: boolean;
|
||||
target_workspace_name: string;
|
||||
target_workspace_icon: string;
|
||||
}
|
||||
@@ -255,6 +255,7 @@ const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () =>
|
||||
if (!source) return;
|
||||
try {
|
||||
const config: any = await getMarketConfig(sourceId);
|
||||
console.log('获取到的配置数据:', config);
|
||||
marketConfigModalRef.current?.handleOpen({
|
||||
...source,
|
||||
connected: config?.status === 1,
|
||||
@@ -306,20 +307,8 @@ const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () =>
|
||||
}));
|
||||
setConfigIdMap(prev => ({ ...prev, [sourceId]: configId }));
|
||||
|
||||
// 用 configId 获取第一页 MCP 列表
|
||||
try {
|
||||
const res: any = await getMarketMCPs({ mcp_market_config_id: configId, page: 1, pagesize: pageSize });
|
||||
if (res?.items && Array.isArray(res.items)) {
|
||||
setMcpCache(prev => ({ ...prev, [sourceId]: res.items }));
|
||||
}
|
||||
if (res?.page) {
|
||||
setMcpTotal(res.page.total || 0);
|
||||
setHasMore(!!res.page.has_next);
|
||||
setCurrentPage(1);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取 MCP 列表失败:', error);
|
||||
}
|
||||
// 使用 fetchMcpList 获取完整的 MCP 列表(包含激活状态和入库状态)
|
||||
await fetchMcpList(sourceId, 1);
|
||||
};
|
||||
|
||||
const handleRefreshAfterAdd = async () => {
|
||||
@@ -431,7 +420,7 @@ const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () =>
|
||||
dataLength={mcpList.length}
|
||||
next={loadMore}
|
||||
hasMore={hasMore}
|
||||
loader={<Skeleton active paragraph={{ rows: 2 }} className="rb:mt-4" />}
|
||||
loader={null}
|
||||
scrollableTarget="mcpScrollableDiv"
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -37,6 +37,8 @@ const MarketConfigModal = forwardRef<MarketConfigModalRef, MarketConfigModalProp
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentSource, setCurrentSource] = useState<MarketSource | null>(null);
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [initialValues, setInitialValues] = useState<{ token: string }>({ token: '' });
|
||||
const formValues = Form.useWatch([], form);
|
||||
|
||||
const handleClose = () => {
|
||||
setVisible(false);
|
||||
@@ -44,16 +46,29 @@ const MarketConfigModal = forwardRef<MarketConfigModalRef, MarketConfigModalProp
|
||||
setLoading(false);
|
||||
setCurrentSource(null);
|
||||
setShowApiKey(false);
|
||||
setInitialValues({ token: '' });
|
||||
};
|
||||
|
||||
const handleOpen = (source: MarketSource) => {
|
||||
console.log('Modal 接收到的数据:', source);
|
||||
setCurrentSource(source);
|
||||
form.setFieldsValue({
|
||||
token: source.token || '',
|
||||
});
|
||||
setInitialValues({ token: source.token || '' });
|
||||
setVisible(true);
|
||||
};
|
||||
|
||||
const handleAfterOpenChange = (open: boolean) => {
|
||||
if (open && currentSource) {
|
||||
// Modal 完全打开后再设置表单值,使用 setTimeout 确保在下一个事件循环
|
||||
setTimeout(() => {
|
||||
form.setFieldsValue({
|
||||
token: currentSource.token || '',
|
||||
});
|
||||
console.log('Modal 打开后设置表单值:', { token: currentSource.token || '' });
|
||||
console.log('当前表单所有值:', form.getFieldsValue());
|
||||
}, 100);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
form
|
||||
.validateFields()
|
||||
@@ -101,6 +116,9 @@ const MarketConfigModal = forwardRef<MarketConfigModalRef, MarketConfigModalProp
|
||||
}
|
||||
};
|
||||
|
||||
// 检查是否可以保存:token 字段必须有值
|
||||
const canSave = formValues?.token?.trim().length > 0;
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
handleOpen,
|
||||
handleClose
|
||||
@@ -113,9 +131,11 @@ const MarketConfigModal = forwardRef<MarketConfigModalRef, MarketConfigModalProp
|
||||
title={t('tool.marketConfig', { name: currentSource.name })}
|
||||
open={visible}
|
||||
onCancel={handleClose}
|
||||
afterOpenChange={handleAfterOpenChange}
|
||||
okText={t('tool.marketSaveAndConnect')}
|
||||
onOk={handleSave}
|
||||
confirmLoading={loading}
|
||||
okButtonProps={{ disabled: !canSave }}
|
||||
width={600}
|
||||
>
|
||||
<div>
|
||||
@@ -147,8 +167,10 @@ const MarketConfigModal = forwardRef<MarketConfigModalRef, MarketConfigModalProp
|
||||
</div>
|
||||
|
||||
<Form
|
||||
key={currentSource?.id || 'new'}
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={initialValues}
|
||||
>
|
||||
<FormItem label={t('tool.marketUrl')}>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
@@ -169,22 +191,28 @@ const MarketConfigModal = forwardRef<MarketConfigModalRef, MarketConfigModalProp
|
||||
name="token"
|
||||
label={
|
||||
<span>
|
||||
API Key <span className="rb:text-gray-400 rb:font-normal">({t('tool.marketApiKeyOptional')})</span>
|
||||
API Key
|
||||
</span>
|
||||
}
|
||||
rules={[
|
||||
{ required: true, message: t('tool.marketApiKeyRequired') },
|
||||
{ whitespace: true, message: t('tool.marketApiKeyRequired') }
|
||||
]}
|
||||
extra={<span style={{ display: 'inline-block', marginTop: 8 }}>{t('tool.marketApiKeyExtra')}</span>}
|
||||
>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Input
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
placeholder={t('tool.marketApiKeyPlaceholder')}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<Button
|
||||
icon={showApiKey ? <EyeInvisibleOutlined /> : <EyeOutlined />}
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
/>
|
||||
</Space.Compact>
|
||||
<Input
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
placeholder={t('tool.marketApiKeyPlaceholder')}
|
||||
autoComplete="off"
|
||||
suffix={
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={showApiKey ? <EyeInvisibleOutlined /> : <EyeOutlined />}
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<div className="rb:flex rb:items-center rb:gap-2 rb:p-3 rb:bg-gray-50 rb:rounded rb:text-sm">
|
||||
|
||||
@@ -41,6 +41,7 @@ const McpServiceModal = forwardRef<McpServiceModalRef, McpServiceModalProps>(({
|
||||
const values = Form.useWatch<MCPToolItem>([], form)
|
||||
const requestHeaderModalRef = useRef<RequestHeaderModalRef>(null)
|
||||
const [requestHeaderList, setRequestHeaderList] = useState<RequestHeader[]>([])
|
||||
const abortControllerRef = useRef<AbortController | null>(null)
|
||||
|
||||
const formatTabItems = () => {
|
||||
return tabKeys.map(key => ({
|
||||
@@ -54,6 +55,12 @@ const McpServiceModal = forwardRef<McpServiceModalRef, McpServiceModalProps>(({
|
||||
|
||||
// 封装取消方法,添加关闭弹窗逻辑
|
||||
const handleClose = () => {
|
||||
// 如果有正在进行的请求,取消它
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
|
||||
setVisible(false);
|
||||
form.resetFields();
|
||||
setLoading(false);
|
||||
@@ -103,6 +110,10 @@ const McpServiceModal = forwardRef<McpServiceModalRef, McpServiceModalProps>(({
|
||||
.validateFields()
|
||||
.then(() => {
|
||||
setLoading(true);
|
||||
|
||||
// 创建 AbortController 用于取消请求
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
// 创建新服务对象
|
||||
const { config, ...rest } = values
|
||||
|
||||
@@ -129,8 +140,13 @@ const McpServiceModal = forwardRef<McpServiceModalRef, McpServiceModalProps>(({
|
||||
(newService.config as any).mcp_service_id = (editVo as any).mcp_service_id;
|
||||
}
|
||||
|
||||
const request = editVo?.id ? updateTool(editVo.id, newService) : addTool(newService)
|
||||
const request = editVo?.id
|
||||
? updateTool(editVo.id, newService, { signal: abortControllerRef.current.signal })
|
||||
: addTool(newService, { signal: abortControllerRef.current.signal })
|
||||
request.then((res: any) => {
|
||||
// 清除 AbortController
|
||||
abortControllerRef.current = null;
|
||||
|
||||
message.success(t('common.saveSuccess'));
|
||||
setLoading(false);
|
||||
handleClose();
|
||||
@@ -141,7 +157,16 @@ const McpServiceModal = forwardRef<McpServiceModalRef, McpServiceModalProps>(({
|
||||
console.error('测试连接失败:', err);
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
.catch((error) => {
|
||||
// 清除 AbortController
|
||||
abortControllerRef.current = null;
|
||||
|
||||
// 如果是用户主动取消,不显示错误提示
|
||||
if (error.name === 'AbortError' || error.code === 'ERR_CANCELED') {
|
||||
console.log('请求已取消');
|
||||
} else {
|
||||
message.error(t('common.saveFailed'));
|
||||
}
|
||||
setLoading(false);
|
||||
})
|
||||
})
|
||||
@@ -171,7 +196,13 @@ const McpServiceModal = forwardRef<McpServiceModalRef, McpServiceModalProps>(({
|
||||
onCancel={handleClose}
|
||||
okText={t('tool.saveAndTest')}
|
||||
onOk={handleSave}
|
||||
confirmLoading={loading}
|
||||
okButtonProps={{ loading: loading }}
|
||||
footer={(_, { OkBtn, CancelBtn }) => (
|
||||
<>
|
||||
<CancelBtn />
|
||||
<OkBtn />
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 17:57:11
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-03 17:57:11
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-12 18:00:11
|
||||
*/
|
||||
/**
|
||||
* RAG User Memory Detail View
|
||||
@@ -150,9 +150,12 @@ const Rag: FC = () => {
|
||||
})
|
||||
}
|
||||
return (
|
||||
<Row gutter={[16, 16]} className="rb:pb-6">
|
||||
<Row gutter={[16, 16]} className="rb:h-full!">
|
||||
<Col span={8}>
|
||||
<RbCard>
|
||||
<RbCard
|
||||
className="rb:h-[calc(100vh-104px)]!"
|
||||
bodyClassName="rb:overflow-y-auto! rb:h-full!"
|
||||
>
|
||||
<div className="rb:flex rb:items-center">
|
||||
<div className="rb:flex-[0_0_auto] rb:w-20 rb:h-20 rb:text-center rb:font-semibold rb:text-[28px] rb:leading-20 rb:rounded-lg rb:text-[#FBFDFF] rb:bg-[#155EEF]">{name?.[0]}</div>
|
||||
<Flex>
|
||||
|
||||
@@ -1,74 +1,43 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 18:34:04
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-03 18:34:04
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-12 18:34:52
|
||||
*/
|
||||
/**
|
||||
* Conversation Memory Component
|
||||
* Displays RAG conversation memory content list
|
||||
*/
|
||||
|
||||
import { type FC, useEffect, useState } from 'react'
|
||||
import { type FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { Skeleton, List } from 'antd';
|
||||
|
||||
import RbCard from '@/components/RbCard/Card'
|
||||
import Empty from '@/components/Empty';
|
||||
import PageScrollList from '@/components/PageScrollList'
|
||||
import Markdown from '@/components/Markdown'
|
||||
import {
|
||||
getRagContent
|
||||
} from '@/api/memory'
|
||||
import { getRagContentUrl } from '@/api/memory'
|
||||
|
||||
const ConversationMemory:FC = () => {
|
||||
const ConversationMemory: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { id } = useParams()
|
||||
const [loading, setLoading] = useState<boolean>(true)
|
||||
const [list, setList] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
getList()
|
||||
}, [id])
|
||||
/** Fetch conversation memory list */
|
||||
const getList = () => {
|
||||
if (!id) return
|
||||
setLoading(true)
|
||||
getRagContent(id).then((res) => {
|
||||
setList((res as { contents?: [] }).contents || [])
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<RbCard
|
||||
<RbCard
|
||||
title={t('userMemory.conversationMemory')}
|
||||
headerClassName="rb:text-[18px]! rb:leading-[24px]"
|
||||
bodyClassName="rb:h-[100%]! rb:overflow-hidden rb:py-0!"
|
||||
bodyClassName="rb:h-[calc(100%-56px)]! rb:overflow-hidden"
|
||||
className="rb:h-[calc(100vh-104px)]!"
|
||||
>
|
||||
{loading
|
||||
? <Skeleton />
|
||||
: list.length > 0
|
||||
? <List
|
||||
dataSource={list}
|
||||
grid={{ gutter: 12, column: 1 }}
|
||||
renderItem={(item, index) => (
|
||||
<List.Item>
|
||||
<div
|
||||
key={index}
|
||||
className="rb:rounded-lg rb:border rb:border-[#DFE4ED] rb:px-4 rb:py-3 rb:bg-[#F0F3F8] rb:mt-2 rb:text-gray-800 rb:text-sm"
|
||||
>
|
||||
<Markdown content={item} />
|
||||
</div>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
: <Empty className="rb:h-full" />
|
||||
}
|
||||
<PageScrollList<string>
|
||||
url={getRagContentUrl}
|
||||
query={{ end_user_id: id }}
|
||||
column={1}
|
||||
renderItem={(item) => (
|
||||
<div className="rb:rounded-lg rb:border rb:border-[#DFE4ED] rb:px-4 rb:py-3 rb:bg-[#F0F3F8] rb:text-gray-800 rb:text-sm">
|
||||
<Markdown content={item} />
|
||||
</div>
|
||||
)}
|
||||
className="rb:h-full!"
|
||||
// className="rb:h-[calc(100%-24px)]!"
|
||||
/>
|
||||
</RbCard>
|
||||
)
|
||||
}
|
||||
export default ConversationMemory
|
||||
|
||||
export default ConversationMemory
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-24 17:57:08
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-28 16:48:09
|
||||
* @Last Modified time: 2026-03-12 13:39:24
|
||||
*/
|
||||
/*
|
||||
* Runtime Component
|
||||
@@ -225,12 +225,13 @@ const Runtime: FC<{ item: ChatItem; index: number;}> = ({
|
||||
</div>
|
||||
)
|
||||
: <>
|
||||
{item.error
|
||||
? <div className={clsx("rb:bg-[#FBFDFF] rb:rounded-md rb:py-2 rb:px-3 ", getStatus('failed'))}>
|
||||
{item.error &&
|
||||
<div className={clsx("rb:bg-[#FBFDFF] rb:rounded-md rb:py-2 rb:px-3 rb:mb-2 rb:-mt-4", getStatus('failed'))}>
|
||||
<Markdown content={item.error} />
|
||||
</div>
|
||||
: renderChild(item.subContent)
|
||||
}</>
|
||||
</div>
|
||||
}
|
||||
{renderChild(item.subContent)}
|
||||
</>
|
||||
)
|
||||
}]}
|
||||
/>
|
||||
|
||||
@@ -8,7 +8,6 @@ import RbModal from '@/components/RbModal'
|
||||
|
||||
interface VariableEditModalProps {
|
||||
refresh: (values: Variable[]) => void;
|
||||
variables: Variable[]
|
||||
}
|
||||
|
||||
const VariableConfigModal = forwardRef<VariableConfigModalRef, VariableEditModalProps>(({
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 15:39:59
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-02 17:06:41
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-16 16:11:28
|
||||
*/
|
||||
import { type FC, useEffect, useState, useMemo } from "react";
|
||||
import clsx from 'clsx'
|
||||
@@ -88,7 +88,7 @@ const Properties: FC<PropertiesProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedNode && form) {
|
||||
const { type = 'default', name = '', config } = selectedNode.getData() || {}
|
||||
const { type = 'default', name = '', config, id } = selectedNode.getData() || {}
|
||||
const initialValue: Record<string, any> = {}
|
||||
Object.keys(config || {}).forEach(key => {
|
||||
if (config && config[key] && 'defaultValue' in config[key]) {
|
||||
@@ -98,7 +98,7 @@ const Properties: FC<PropertiesProps> = ({
|
||||
|
||||
form.setFieldsValue({
|
||||
type,
|
||||
id: selectedNode.id,
|
||||
id,
|
||||
name,
|
||||
...initialValue,
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 15:17:48
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-07 15:23:39
|
||||
* @Last Modified time: 2026-03-16 16:11:01
|
||||
*/
|
||||
import { useRef, useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
@@ -593,6 +593,13 @@ export const useWorkflowGraph = ({
|
||||
if (!graphRef.current) return false;
|
||||
const selectedNodes = graphRef.current.getNodes().filter(node => node.getData()?.isSelected);
|
||||
if (selectedNodes.length) {
|
||||
selectedNodes.forEach(node => {
|
||||
const data = node.getData();
|
||||
node.setData({
|
||||
...data,
|
||||
id: `${(data.type as string).replace(/-/g, '_')}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
});
|
||||
});
|
||||
graphRef.current.copy(selectedNodes);
|
||||
}
|
||||
return false;
|
||||
|
||||
@@ -60,7 +60,8 @@ const Workflow = forwardRef<WorkflowRef>((_props, ref) => {
|
||||
handleRun,
|
||||
graphRef,
|
||||
addVariable,
|
||||
config
|
||||
config,
|
||||
funConfig: config?.funConfig
|
||||
}))
|
||||
return (
|
||||
<div className="rb:h-[calc(100vh-64px)] rb:relative">
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { Graph } from '@antv/x6';
|
||||
import type { KnowledgeConfig } from './components/Properties/Knowledge/types'
|
||||
import type { Variable } from './components/Properties/VariableList/types'
|
||||
import type { FunConfigForm } from '@/views/ApplicationConfig/types'
|
||||
export interface NodeConfig {
|
||||
type: 'input' | 'textarea' | 'select' | 'inputNumber' | 'slider' | 'customSelect' | 'define' | 'knowledge' | 'variableList' | string;
|
||||
placeholder?: string;
|
||||
@@ -89,6 +90,8 @@ export interface WorkflowConfig {
|
||||
is_active: boolean;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
|
||||
funConfig?: FunConfigForm;
|
||||
}
|
||||
|
||||
export interface ChatRef {
|
||||
|
||||