Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 32 additions & 3 deletions backend/apps/aidp_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@

from consts.error_code import ErrorCode
from consts.exceptions import AppException
from services.aidp_service import fetch_aidp_knowledge_bases_impl
from services.aidp_service import (
fetch_aidp_knowledge_bases_impl,
fetch_all_aidp_knowledge_bases_impl,
)

router = APIRouter(prefix="/aidp")
logger = logging.getLogger("aidp_app")
Expand All @@ -22,9 +25,9 @@ async def fetch_aidp_knowledge_bases_api(
server_url: Annotated[str, Query(description="AIDP API server URL")],
api_key: Annotated[str, Query(description="AIDP API key")],
page: Annotated[int, Query(ge=1, description="Page number starting from 1")] = 1,
page_size: Annotated[int, Query(ge=1, le=100, description="Page size from 1 to 100")] = 20,
page_size: Annotated[int, Query(ge=1, le=100, description="Page size from 1 to 100")] = 10,
) -> JSONResponse:
"""Fetch paginated knowledge bases from the external AIDP API."""
"""Fetch a single page of knowledge bases from the external AIDP API."""
try:
result = fetch_aidp_knowledge_bases_impl(
server_url=server_url,
Expand All @@ -41,3 +44,29 @@ async def fetch_aidp_knowledge_bases_api(
ErrorCode.AIDP_SERVICE_ERROR,
f"Failed to fetch AIDP knowledge bases: {str(e)}",
)


@router.get("/knowledge-bases-all")
async def fetch_all_aidp_knowledge_bases_api(
server_url: Annotated[str, Query(description="AIDP API server URL")],
api_key: Annotated[str, Query(description="AIDP API key")],
) -> JSONResponse:
"""Fetch ALL knowledge bases from AIDP (accumulates every page internally).

Use this when you need the total count and want to handle pagination
entirely on the client side.
"""
try:
result = fetch_all_aidp_knowledge_bases_impl(
server_url=server_url,
api_key=api_key,
)
return JSONResponse(status_code=HTTPStatus.OK, content=result)
except AppException:
raise
except Exception as e:
logger.exception("Failed to fetch all AIDP knowledge bases: %s", e)
raise AppException(
ErrorCode.AIDP_SERVICE_ERROR,
f"Failed to fetch all AIDP knowledge bases: {str(e)}",
)
15 changes: 13 additions & 2 deletions backend/database/tool_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@ def create_or_update_tool_by_tool_info(tool_info, tenant_id: str, user_id: str,
tool_info_dict = tool_info.__dict__ | {
"tenant_id": tenant_id, "user_id": user_id, "version_no": version_no}

# Filter out null values from params to avoid saving nulls to database

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

过滤 null 值的逻辑会丢失用户有意设置为 null 的默认参数。例如某个参数的 default 本身就是 None(表示"未配置"),这里会被过滤掉,导致前端无法区分"参数不存在"和"参数值为 null"。建议仅过滤显式传入的 null,而非所有 null 值。

if 'params' in tool_info_dict and tool_info_dict['params'] is not None:
tool_info_dict['params'] = {
k: v for k, v in tool_info_dict['params'].items()
if v is not None
}

with get_db_session() as session:
# Query if there is an existing ToolInstance
# Note: Do not filter by user_id to avoid creating duplicate instances
Expand All @@ -71,7 +78,7 @@ def create_or_update_tool_by_tool_info(tool_info, tenant_id: str, user_id: str,
session.add(new_tool_instance)
session.flush() # Flush to get the ID
tool_instance = new_tool_instance
return tool_instance
return as_dict(tool_instance)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

返回类型从 ORM 对象改为 dict(as_dict(tool_instance)),这可能破坏依赖 ORM 属性的调用方。建议检查所有调用 create_or_update_tool_by_tool_info 的地方,确认它们都能正确处理 dict 返回值。



def query_all_tools(tenant_id: str):
Expand Down Expand Up @@ -258,7 +265,11 @@ def add_tool_field(tool_info):
tool_params = tool.params
for ele in tool_params:
param_name = ele["name"]
ele["default"] = tool_info["params"].get(param_name)
instance_value = tool_info["params"].get(param_name)
# Only set default if instance value is not None
# This prevents null values from being saved to database and returned as defaults
if instance_value is not None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

instance_value is not None 的检查只过滤了 None 值,但不过滤空字符串、空列表等 falsy 值。如果用户有意将参数设置为空字符串(表示"清空"),这个逻辑是正确的。但如果参数值为 0False(合法的 falsy 值),它们也会被保留。建议确认这种行为的意图,并在注释中说明。

ele["default"] = instance_value
tool_dict = as_dict(tool)
tool_dict["params"] = tool_params

Expand Down
4 changes: 3 additions & 1 deletion backend/services/agent_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -1199,7 +1199,9 @@ async def update_agent_info_impl(request: AgentInfoRequest, authorization: str =
if inst.get("tool_id") == tool_id),
None
)
params = (existing_instance or {}).get("params", {})
# Safely get params, default to empty dict if None or not present
raw_params = (existing_instance or {}).get("params")
params = raw_params if raw_params is not None else {}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

防御性 None 检查是好的,但当 raw_params 为 None 时静默回退到空 dict 可能掩盖数据问题。建议在此处添加 logger.warning 日志,记录 tool instance 的 params 字段为 None 的异常情况,便于排查数据一致性问题。

create_or_update_tool_by_tool_info(
tool_info=ToolInstanceInfoRequest(
tool_id=tool_id,
Expand Down
162 changes: 156 additions & 6 deletions backend/services/aidp_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
Handles API calls to AIDP for paginated knowledge base listing.
"""
import logging
from typing import Any, Dict
from typing import Any, Dict, List
from urllib.parse import urljoin

import httpx
Expand Down Expand Up @@ -41,9 +41,9 @@
server_url: str,
api_key: str,
page: int = 1,
page_size: int = 20,
page_size: int = 10,
) -> Dict[str, Any]:
"""Fetch paginated knowledge bases from AIDP API."""
"""Fetch a single page from AIDP API (simple passthrough)."""
normalized_url = _validate_params(server_url, api_key)

headers = {
Expand All @@ -58,8 +58,8 @@
try:
client = http_client_manager.get_sync_client(
base_url=normalized_url,
timeout=20.0,
verify_ssl=True,
timeout=60.0,
verify_ssl=False,
)
response = client.get(list_url, headers=headers)
response.raise_for_status()
Expand All @@ -69,7 +69,157 @@
ErrorCode.AIDP_SERVICE_ERROR,
"Unexpected AIDP knowledge base response format",
)
return result
return _normalize_response(result)
except httpx.RequestError as e:
logger.exception("AIDP request failed: %s", e)
raise AppException(
ErrorCode.AIDP_CONNECTION_ERROR,
f"AIDP API request failed: {str(e)}",
)
except httpx.HTTPStatusError as e:
logger.exception(
"AIDP API HTTP error: %s, status_code: %s",
e,
e.response.status_code,
)
if e.response.status_code in (401, 403):
raise AppException(
ErrorCode.AIDP_AUTH_ERROR,
f"AIDP authentication failed: {str(e)}",
)
raise AppException(
ErrorCode.AIDP_SERVICE_ERROR,
f"AIDP API HTTP error {e.response.status_code}: {str(e)}",
)
except ValueError as e:
logger.exception("Failed to parse AIDP API response: %s", e)
raise AppException(
ErrorCode.AIDP_SERVICE_ERROR,
f"Failed to parse AIDP API response: {str(e)}",
)


def _normalize_response(raw: Dict[str, Any]) -> Dict[str, Any]:
"""Map AIDP API response fields to the canonical {value, total_count, next_link} shape."""
items = (
raw.get("value")
if raw.get("value") is not None
else raw.get("data")
if raw.get("data") is not None
else raw.get("items")
if raw.get("items") is not None
else raw.get("knowledge_bases")
if raw.get("knowledge_bases") is not None
else []

Check warning on line 113 in backend/services/aidp_service.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested conditional expression into an independent statement.

See more on https://sonarcloud.io/project/issues?id=ModelEngine-Group_nexent&issues=AZ78dzKKWYpOdl3gAM03&open=AZ78dzKKWYpOdl3gAM03&pullRequest=3297

Check warning on line 113 in backend/services/aidp_service.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested conditional expression into an independent statement.

See more on https://sonarcloud.io/project/issues?id=ModelEngine-Group_nexent&issues=AZ78dzKKWYpOdl3gAM02&open=AZ78dzKKWYpOdl3gAM02&pullRequest=3297

Check warning on line 113 in backend/services/aidp_service.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested conditional expression into an independent statement.

See more on https://sonarcloud.io/project/issues?id=ModelEngine-Group_nexent&issues=AZ78dzKKWYpOdl3gAM01&open=AZ78dzKKWYpOdl3gAM01&pullRequest=3297
)
total_keys = ("total_count", "total", "totalRecords", "count")
total = next((raw.get(k) for k in total_keys if raw.get(k) is not None), None)
next_link = raw.get("next_link") or raw.get("next") or None
return {
"value": items,
"total_count": total,
"next_link": next_link,
}


def _extract_tenant_from_url(url: str) -> str | None:
"""Extract tenant ID from a URL like /KnowledgeBase/Tenants/{tenant}/KnowledgeBases."""
import re
match = re.search(r"/Tenants/([^/]+)/", url)
return match.group(1) if match else None


def fetch_all_aidp_knowledge_bases_impl(

Check failure on line 132 in backend/services/aidp_service.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 36 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=ModelEngine-Group_nexent&issues=AZ78dzKKWYpOdl3gAM04&open=AZ78dzKKWYpOdl3gAM04&pullRequest=3297
server_url: str,
api_key: str,
) -> Dict[str, Any]:
"""Fetch all knowledge bases from AIDP by following next_link until exhausted.

AIDP does not return a true total count, so we follow next_link pages
until there is no next_link left. We also detect the real tenant ID
from the first response's next_link (AIDP embeds it there) and use it
for any manual page construction needed.
"""
normalized_url = _validate_params(server_url, api_key)

headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}

try:
client = http_client_manager.get_sync_client(
base_url=normalized_url,
timeout=120.0,
verify_ssl=False,
)

all_items: List[Any] = []
current_page = 1
max_pages = 1000
page_size = 100
detected_tenant: str | None = None

# Build the first request URL using the known path pattern
first_path = f"{_LIST_PATH}?page=1&page_size={page_size}"
current_url: str | None = urljoin(f"{normalized_url}/", first_path)

while current_page <= max_pages and current_url:
logger.info(
"Fetching AIDP KBs — page %d from %s",
current_page,
current_url,
)

response = client.get(current_url, headers=headers)
response.raise_for_status()
result = response.json()
if not isinstance(result, dict):
raise AppException(
ErrorCode.AIDP_SERVICE_ERROR,
"Unexpected AIDP knowledge base response format",
)

page_items = (
result.get("value")
if result.get("value") is not None
else result.get("data")
if result.get("data") is not None
else result.get("items")
if result.get("items") is not None
else result.get("knowledge_bases")
if result.get("knowledge_bases") is not None
else []

Check warning on line 192 in backend/services/aidp_service.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested conditional expression into an independent statement.

See more on https://sonarcloud.io/project/issues?id=ModelEngine-Group_nexent&issues=AZ78dzKKWYpOdl3gAM07&open=AZ78dzKKWYpOdl3gAM07&pullRequest=3297

Check warning on line 192 in backend/services/aidp_service.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested conditional expression into an independent statement.

See more on https://sonarcloud.io/project/issues?id=ModelEngine-Group_nexent&issues=AZ78dzKKWYpOdl3gAM05&open=AZ78dzKKWYpOdl3gAM05&pullRequest=3297

Check warning on line 192 in backend/services/aidp_service.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested conditional expression into an independent statement.

See more on https://sonarcloud.io/project/issues?id=ModelEngine-Group_nexent&issues=AZ78dzKKWYpOdl3gAM06&open=AZ78dzKKWYpOdl3gAM06&pullRequest=3297
)
if not isinstance(page_items, list):
page_items = []

all_items.extend(page_items)

# Detect real tenant from next_link on the first page
if current_page == 1 and detected_tenant is None:
raw_next = result.get("next_link") or result.get("next") or ""
detected_tenant = _extract_tenant_from_url(str(raw_next))
if detected_tenant:
logger.info("Detected AIDP tenant: %s", detected_tenant)

# Follow next_link if present, otherwise construct next page manually
raw_next = result.get("next_link") or result.get("next") or ""
next_url_str = str(raw_next).strip()
if next_url_str:
current_url = urljoin(normalized_url + "/", next_url_str)
current_page += 1
else:
current_url = None

total_count = len(all_items)
logger.info("AIDP KBs: accumulated %d total items (tenant=%s)", total_count, detected_tenant)

return {
"value": all_items,
"total_count": total_count,
"next_link": None,
}
except httpx.RequestError as e:
logger.exception("AIDP request failed: %s", e)
raise AppException(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,13 +143,15 @@ export default function ToolManagement({

if (tooInstance.success && tooInstance.data) {
// Merge instance params with default params
// Only use instance value if it exists and is not null/undefined
const mergedParams =
defaultTool.initParams?.map((param: ToolParam) => {
const instanceValue = tooInstance.data?.params?.[param.name];
// Use instance value only if it's not null or undefined
const hasValidInstanceValue = instanceValue !== null && instanceValue !== undefined;
return {
...param,
value:
instanceValue !== undefined ? instanceValue : param.value,
value: hasValidInstanceValue ? instanceValue : param.value,
};
}) ||
defaultTool.initParams ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1313,8 +1313,18 @@
return;
}

// Convert params to backend format (use the synced params)
const paramsObj = currentParams.reduce(
// Convert params to backend format - use latestFormValues directly to avoid async state issues
// This ensures we capture the most recent form values without relying on async setState
const syncedParams = [...currentParams];
if (latestFormValues) {
Object.entries(latestFormValues).forEach(([fieldName, value]) => {
const index = parseInt(fieldName.replace("param_", ""));

Check warning on line 1321 in frontend/app/[locale]/agents/components/agentConfig/tool/ToolConfigModal.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `Number.parseInt` over `parseInt`.

See more on https://sonarcloud.io/project/issues?id=ModelEngine-Group_nexent&issues=AZ78dzELWYpOdl3gAM0w&open=AZ78dzELWYpOdl3gAM0w&pullRequest=3297
if (!isNaN(index) && syncedParams[index]) {

Check warning on line 1322 in frontend/app/[locale]/agents/components/agentConfig/tool/ToolConfigModal.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `Number.isNaN` over `isNaN`.

See more on https://sonarcloud.io/project/issues?id=ModelEngine-Group_nexent&issues=AZ78dzELWYpOdl3gAM0x&open=AZ78dzELWYpOdl3gAM0x&pullRequest=3297
syncedParams[index] = { ...syncedParams[index], value };
}
});
}
const paramsObj = syncedParams.reduce(

Check warning on line 1327 in frontend/app/[locale]/agents/components/agentConfig/tool/ToolConfigModal.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this useless assignment to variable "paramsObj".

See more on https://sonarcloud.io/project/issues?id=ModelEngine-Group_nexent&issues=AZ78dzELWYpOdl3gAM0y&open=AZ78dzELWYpOdl3gAM0y&pullRequest=3297
(acc, param) => {
acc[param.name] = param.value;
return acc;
Expand All @@ -1326,7 +1336,7 @@
// Include display_names for knowledge base tools to pass to prompt generation
const updatedTool: typeof toolToSave = {
...toolToSave,
initParams: currentParams,
initParams: syncedParams,
// Store knowledge base display names for prompt generation
...(toolRequiresKbSelection && selectedKbDisplayNames.length > 0
? { display_names: selectedKbDisplayNames }
Expand Down
Loading
Loading