mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-09-08 10:14:35 +08:00
feat(go-agent): Ported retrieval node, added Keenable web search tool (#16396)
Ported retrieval node, added Keenable web search tool - [x] New Feature (non-breaking change which adds functionality)
This commit is contained in:
+30
-6
@@ -415,6 +415,7 @@ class Canvas(Graph):
|
|||||||
if not self.globals["sys.conversation_turns"] :
|
if not self.globals["sys.conversation_turns"] :
|
||||||
self.globals["sys.conversation_turns"] = 0
|
self.globals["sys.conversation_turns"] = 0
|
||||||
self.globals["sys.conversation_turns"] += 1
|
self.globals["sys.conversation_turns"] += 1
|
||||||
|
is_resume = bool(self.path) and self.path[0].lower().find("userfillup") >= 0
|
||||||
|
|
||||||
def decorate(event, dt):
|
def decorate(event, dt):
|
||||||
nonlocal created_at
|
nonlocal created_at
|
||||||
@@ -427,16 +428,16 @@ class Canvas(Graph):
|
|||||||
"data": dt
|
"data": dt
|
||||||
}
|
}
|
||||||
|
|
||||||
if not self.path or self.path[-1].lower().find("userfillup") < 0:
|
if not is_resume:
|
||||||
self.path.append("begin")
|
self.path.append("begin")
|
||||||
self.retrieval.append({"chunks": [], "doc_aggs": []})
|
self.retrieval.append({"chunks": [], "doc_aggs": []})
|
||||||
|
|
||||||
if self.is_canceled():
|
if self.is_canceled():
|
||||||
msg = f"Task {self.task_id} has been canceled before starting."
|
msg = f"Task {self.task_id} has been canceled before starting."
|
||||||
logging.info(msg)
|
logging.info(msg)
|
||||||
raise TaskCanceledException(msg)
|
raise TaskCanceledException(msg)
|
||||||
|
|
||||||
yield decorate("workflow_started", {"inputs": kwargs.get("inputs")})
|
if not is_resume:
|
||||||
|
yield decorate("workflow_started", {"inputs": kwargs.get("inputs")})
|
||||||
self.retrieval.append({"chunks": {}, "doc_aggs": {}})
|
self.retrieval.append({"chunks": {}, "doc_aggs": {}})
|
||||||
|
|
||||||
async def _run_batch(f, t):
|
async def _run_batch(f, t):
|
||||||
@@ -501,7 +502,7 @@ class Canvas(Graph):
|
|||||||
})
|
})
|
||||||
|
|
||||||
self.error = ""
|
self.error = ""
|
||||||
idx = len(self.path) - 1
|
idx = 0 if is_resume else len(self.path) - 1
|
||||||
partials = []
|
partials = []
|
||||||
tts_mdl = None
|
tts_mdl = None
|
||||||
while idx < len(self.path):
|
while idx < len(self.path):
|
||||||
@@ -647,9 +648,14 @@ class Canvas(Graph):
|
|||||||
o = self.get_component_obj(c)
|
o = self.get_component_obj(c)
|
||||||
if o.component_name.lower() == "userfillup":
|
if o.component_name.lower() == "userfillup":
|
||||||
o.invoke()
|
o.invoke()
|
||||||
another_inputs.update(o.get_input_elements())
|
another_inputs.update({
|
||||||
|
k: v for k, v in o.get_input_elements().items()
|
||||||
|
if not self._is_input_field_satisfied(v)
|
||||||
|
})
|
||||||
if o.get_param("enable_tips"):
|
if o.get_param("enable_tips"):
|
||||||
tips = o.output("tips")
|
tips = o.output("tips")
|
||||||
|
if not another_inputs:
|
||||||
|
continue
|
||||||
self.path = path
|
self.path = path
|
||||||
yield decorate("user_inputs", {"inputs": another_inputs, "tips": tips})
|
yield decorate("user_inputs", {"inputs": another_inputs, "tips": tips})
|
||||||
return
|
return
|
||||||
@@ -734,7 +740,25 @@ class Canvas(Graph):
|
|||||||
|
|
||||||
def add_user_input(self, question):
|
def add_user_input(self, question):
|
||||||
self.history.append(("user", question))
|
self.history.append(("user", question))
|
||||||
self.globals["sys.history"].append(f"{self.history[-1][0]}: {self.history[-1][1]}")
|
rendered = json.dumps(question, ensure_ascii=False) if isinstance(question, dict) else question
|
||||||
|
self.globals["sys.history"].append(f"{self.history[-1][0]}: {rendered}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_input_field_satisfied(field: Any) -> bool:
|
||||||
|
if not isinstance(field, dict):
|
||||||
|
return field is not None
|
||||||
|
|
||||||
|
value = field.get("value")
|
||||||
|
field_type = str(field.get("type", "")).lower()
|
||||||
|
if field_type.find("file") >= 0:
|
||||||
|
if field.get("optional") and value is None:
|
||||||
|
return True
|
||||||
|
return value not in (None, [], "")
|
||||||
|
|
||||||
|
if value is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
def get_prologue(self):
|
def get_prologue(self):
|
||||||
return self.components["begin"]["obj"]._param.prologue
|
return self.components["begin"]["obj"]._param.prologue
|
||||||
|
|||||||
@@ -14,7 +14,6 @@
|
|||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
#
|
#
|
||||||
from agent.component.fillup import UserFillUpParam, UserFillUp
|
from agent.component.fillup import UserFillUpParam, UserFillUp
|
||||||
from api.db.services.file_service import FileService
|
|
||||||
|
|
||||||
|
|
||||||
class BeginParam(UserFillUpParam):
|
class BeginParam(UserFillUpParam):
|
||||||
@@ -42,20 +41,11 @@ class Begin(UserFillUp):
|
|||||||
return
|
return
|
||||||
|
|
||||||
layout_recognize = self._param.layout_recognize or None
|
layout_recognize = self._param.layout_recognize or None
|
||||||
for k, v in kwargs.get("inputs", {}).items():
|
merged_inputs = self._merge_runtime_inputs(kwargs.get("inputs", {}))
|
||||||
|
for k, v in merged_inputs.items():
|
||||||
if self.check_if_canceled("Begin processing"):
|
if self.check_if_canceled("Begin processing"):
|
||||||
return
|
return
|
||||||
|
v = self._resolve_input_value(v, layout_recognize)
|
||||||
if isinstance(v, dict) and v.get("type", "").lower().find("file") >= 0:
|
|
||||||
if v.get("optional") and v.get("value", None) is None:
|
|
||||||
v = None
|
|
||||||
else:
|
|
||||||
file_value = v["value"]
|
|
||||||
# Support both single file (backward compatibility) and multiple files
|
|
||||||
files = file_value if isinstance(file_value, list) else [file_value]
|
|
||||||
v = FileService.get_files(files, layout_recognize=layout_recognize)
|
|
||||||
else:
|
|
||||||
v = v.get("value")
|
|
||||||
self.set_output(k, v)
|
self.set_output(k, v)
|
||||||
self.set_input_value(k, v)
|
self.set_input_value(k, v)
|
||||||
|
|
||||||
|
|||||||
+54
-12
@@ -21,6 +21,9 @@ from agent.component.base import ComponentParamBase, ComponentBase
|
|||||||
from api.db.services.file_service import FileService
|
from api.db.services.file_service import FileService
|
||||||
|
|
||||||
|
|
||||||
|
_INITIAL_USER_INPUT_CONSUMED_KEY = "sys.__initial_user_input_consumed__"
|
||||||
|
|
||||||
|
|
||||||
class UserFillUpParam(ComponentParamBase):
|
class UserFillUpParam(ComponentParamBase):
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
@@ -36,6 +39,52 @@ class UserFillUpParam(ComponentParamBase):
|
|||||||
class UserFillUp(ComponentBase):
|
class UserFillUp(ComponentBase):
|
||||||
component_name = "UserFillUp"
|
component_name = "UserFillUp"
|
||||||
|
|
||||||
|
def _merge_runtime_inputs(self, runtime_inputs):
|
||||||
|
if runtime_inputs:
|
||||||
|
return runtime_inputs
|
||||||
|
|
||||||
|
fields = self.get_input_elements()
|
||||||
|
if not fields:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
if self._canvas.globals.get(_INITIAL_USER_INPUT_CONSUMED_KEY):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
query = self._canvas.globals.get("sys.query")
|
||||||
|
if query is None or query == "":
|
||||||
|
return {}
|
||||||
|
|
||||||
|
if isinstance(query, dict):
|
||||||
|
matched = {
|
||||||
|
key: value if isinstance(value, dict) else {"value": value}
|
||||||
|
for key, value in query.items()
|
||||||
|
if key in fields
|
||||||
|
}
|
||||||
|
if matched:
|
||||||
|
self._canvas.globals[_INITIAL_USER_INPUT_CONSUMED_KEY] = True
|
||||||
|
return matched
|
||||||
|
|
||||||
|
if len(fields) == 1:
|
||||||
|
field_name = next(iter(fields))
|
||||||
|
self._canvas.globals[_INITIAL_USER_INPUT_CONSUMED_KEY] = True
|
||||||
|
return {field_name: {"value": query}}
|
||||||
|
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def _resolve_input_value(self, value, layout_recognize):
|
||||||
|
if isinstance(value, dict) and value.get("type", "").lower().find("file") >= 0:
|
||||||
|
if value.get("optional") and value.get("value", None) is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
file_value = value["value"]
|
||||||
|
files = file_value if isinstance(file_value, list) else [file_value]
|
||||||
|
return FileService.get_files(files, layout_recognize=layout_recognize)
|
||||||
|
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return value.get("value")
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
def _invoke(self, **kwargs):
|
def _invoke(self, **kwargs):
|
||||||
if self.check_if_canceled("UserFillUp processing"):
|
if self.check_if_canceled("UserFillUp processing"):
|
||||||
return
|
return
|
||||||
@@ -63,20 +112,13 @@ class UserFillUp(ComponentBase):
|
|||||||
|
|
||||||
self.set_output("tips", content)
|
self.set_output("tips", content)
|
||||||
layout_recognize = self._param.layout_recognize or None
|
layout_recognize = self._param.layout_recognize or None
|
||||||
for k, v in kwargs.get("inputs", {}).items():
|
merged_inputs = self._merge_runtime_inputs(kwargs.get("inputs", {}))
|
||||||
|
for k, v in merged_inputs.items():
|
||||||
if self.check_if_canceled("UserFillUp processing"):
|
if self.check_if_canceled("UserFillUp processing"):
|
||||||
return
|
return
|
||||||
if isinstance(v, dict) and v.get("type", "").lower().find("file") >= 0:
|
resolved = self._resolve_input_value(v, layout_recognize)
|
||||||
if v.get("optional") and v.get("value", None) is None:
|
self.set_output(k, resolved)
|
||||||
v = None
|
self.set_input_value(k, resolved)
|
||||||
else:
|
|
||||||
file_value = v["value"]
|
|
||||||
# Support both single file (backward compatibility) and multiple files
|
|
||||||
files = file_value if isinstance(file_value, list) else [file_value]
|
|
||||||
v = FileService.get_files(files, layout_recognize=layout_recognize)
|
|
||||||
else:
|
|
||||||
v = v.get("value")
|
|
||||||
self.set_output(k, v)
|
|
||||||
|
|
||||||
def thoughts(self) -> str:
|
def thoughts(self) -> str:
|
||||||
return "Waiting for your input..."
|
return "Waiting for your input..."
|
||||||
|
|||||||
@@ -38,9 +38,17 @@ class ListOperationsParam(ComponentParamBase):
|
|||||||
"type": "?"
|
"type": "?"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_operation_name(operation):
|
||||||
|
op = "" if operation is None else str(operation).strip()
|
||||||
|
if op.lower() == "topn":
|
||||||
|
return "head"
|
||||||
|
return op or "nth"
|
||||||
|
|
||||||
def check(self):
|
def check(self):
|
||||||
self.check_empty(self.query, "query")
|
self.check_empty(self.query, "query")
|
||||||
|
self.operations = self._normalize_operation_name(self.operations)
|
||||||
self.check_valid_value(
|
self.check_valid_value(
|
||||||
self.operations,
|
self.operations,
|
||||||
"Support operations",
|
"Support operations",
|
||||||
|
|||||||
@@ -226,7 +226,7 @@ class AliyunCodeInterpreterProvider(SandboxProvider):
|
|||||||
# Connect to existing sandbox instance
|
# Connect to existing sandbox instance
|
||||||
sandbox = Sandbox.connect(sandbox_id=instance_id, config=self._config)
|
sandbox = Sandbox.connect(sandbox_id=instance_id, config=self._config)
|
||||||
|
|
||||||
# agentrun-sdk 0.0.26 only exposes CodeLanguage.PYTHON; keep JS as string fallback.
|
# CodeLanguage enum only exposes PYTHON across agentrun-sdk 0.0.26+; keep JS as string fallback.
|
||||||
code_language = CodeLanguage.PYTHON if normalized_lang == "python" else "javascript"
|
code_language = CodeLanguage.PYTHON if normalized_lang == "python" else "javascript"
|
||||||
|
|
||||||
# Wrap code to call main() function
|
# Wrap code to call main() function
|
||||||
@@ -355,7 +355,7 @@ class AliyunCodeInterpreterProvider(SandboxProvider):
|
|||||||
# Try to list templates to verify connection
|
# Try to list templates to verify connection
|
||||||
from agentrun.sandbox import Template
|
from agentrun.sandbox import Template
|
||||||
|
|
||||||
templates = Template.list(config=self._config)
|
templates = Template.list_templates(config=self._config)
|
||||||
return templates is not None
|
return templates is not None
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -45,11 +45,11 @@ class TestAliyunCodeInterpreterProvider:
|
|||||||
assert provider.timeout == 30
|
assert provider.timeout == 30
|
||||||
assert not provider._initialized
|
assert not provider._initialized
|
||||||
|
|
||||||
@patch("agent.sandbox.providers.aliyun_codeinterpreter.Template")
|
@patch("agentrun.sandbox.Template")
|
||||||
def test_initialize_success(self, mock_template):
|
def test_initialize_success(self, mock_template):
|
||||||
"""Test successful initialization."""
|
"""Test successful initialization."""
|
||||||
# Mock health check response
|
# Mock health check response
|
||||||
mock_template.list.return_value = []
|
mock_template.list_templates.return_value = []
|
||||||
|
|
||||||
provider = AliyunCodeInterpreterProvider()
|
provider = AliyunCodeInterpreterProvider()
|
||||||
result = provider.initialize(
|
result = provider.initialize(
|
||||||
@@ -89,10 +89,10 @@ class TestAliyunCodeInterpreterProvider:
|
|||||||
result = provider2.initialize({"access_key_id": "LTAI5tXXXXXXXXXX", "access_key_secret": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"})
|
result = provider2.initialize({"access_key_id": "LTAI5tXXXXXXXXXX", "access_key_secret": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"})
|
||||||
assert result is False
|
assert result is False
|
||||||
|
|
||||||
@patch("agent.sandbox.providers.aliyun_codeinterpreter.Template")
|
@patch("agentrun.sandbox.Template")
|
||||||
def test_initialize_default_config(self, mock_template):
|
def test_initialize_default_config(self, mock_template):
|
||||||
"""Test initialization with default config."""
|
"""Test initialization with default config."""
|
||||||
mock_template.list.return_value = []
|
mock_template.list_templates.return_value = []
|
||||||
|
|
||||||
provider = AliyunCodeInterpreterProvider()
|
provider = AliyunCodeInterpreterProvider()
|
||||||
result = provider.initialize({"access_key_id": "LTAI5tXXXXXXXXXX", "access_key_secret": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", "account_id": "1234567890123456"})
|
result = provider.initialize({"access_key_id": "LTAI5tXXXXXXXXXX", "access_key_secret": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", "account_id": "1234567890123456"})
|
||||||
|
|||||||
@@ -574,7 +574,7 @@ async def move_files(uid: str, src_file_ids: list, dest_file_id: str = None, new
|
|||||||
)
|
)
|
||||||
except Exception as storage_err:
|
except Exception as storage_err:
|
||||||
raise RuntimeError(f"Move file failed at storage layer: {str(storage_err)}")
|
raise RuntimeError(f"Move file failed at storage layer: {str(storage_err)}")
|
||||||
if not moved:
|
if moved is False:
|
||||||
raise RuntimeError("Move file failed at storage layer")
|
raise RuntimeError("Move file failed at storage layer")
|
||||||
updates["parent_id"] = dest_folder_entry.id
|
updates["parent_id"] = dest_folder_entry.id
|
||||||
updates["location"] = new_location
|
updates["location"] = new_location
|
||||||
|
|||||||
@@ -178,6 +178,32 @@ def split_model_name(model_name: str):
|
|||||||
return pure_model_name, instance_name, provider_name
|
return pure_model_name, instance_name, provider_name
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_instance_for_model(provider_obj, instance_name: str, model_name: str):
|
||||||
|
instance_obj = TenantModelInstanceService.get_by_provider_id_and_instance_name(provider_obj.id, instance_name)
|
||||||
|
if instance_obj:
|
||||||
|
return instance_obj
|
||||||
|
if instance_name != "default":
|
||||||
|
raise LookupError(f"Instance {instance_name} not found for model {model_name}.")
|
||||||
|
|
||||||
|
active_instances = [
|
||||||
|
inst for inst in TenantModelInstanceService.get_all_by_provider_id(provider_obj.id)
|
||||||
|
if inst.status == ActiveStatusEnum.ACTIVE.value
|
||||||
|
]
|
||||||
|
if len(active_instances) == 1:
|
||||||
|
logger.warning(
|
||||||
|
"Model instance fallback applied for legacy default instance name",
|
||||||
|
extra={
|
||||||
|
"provider_name": provider_obj.provider_name,
|
||||||
|
"requested_instance_name": instance_name,
|
||||||
|
"resolved_instance_name": active_instances[0].instance_name,
|
||||||
|
"model_name": model_name,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return active_instances[0]
|
||||||
|
|
||||||
|
raise LookupError(f"Instance {instance_name} not found for model {model_name}.")
|
||||||
|
|
||||||
|
|
||||||
def get_model_config_from_provider_instance(tenant_id, model_type: str|enum.Enum, model_name: str):
|
def get_model_config_from_provider_instance(tenant_id, model_type: str|enum.Enum, model_name: str):
|
||||||
pure_model_name, instance_name, provider_name = split_model_name(model_name)
|
pure_model_name, instance_name, provider_name = split_model_name(model_name)
|
||||||
model_type_val = model_type if isinstance(model_type, str) else model_type.value
|
model_type_val = model_type if isinstance(model_type, str) else model_type.value
|
||||||
@@ -203,9 +229,7 @@ def get_model_config_from_provider_instance(tenant_id, model_type: str|enum.Enum
|
|||||||
provider_obj = TenantModelProviderService.get_by_tenant_id_and_provider_name(tenant_id, provider_name)
|
provider_obj = TenantModelProviderService.get_by_tenant_id_and_provider_name(tenant_id, provider_name)
|
||||||
if not provider_obj:
|
if not provider_obj:
|
||||||
raise LookupError(f"Provider {provider_name} not found for model {model_name}.")
|
raise LookupError(f"Provider {provider_name} not found for model {model_name}.")
|
||||||
instance_obj = TenantModelInstanceService.get_by_provider_id_and_instance_name(provider_obj.id, instance_name)
|
instance_obj = _resolve_instance_for_model(provider_obj, instance_name, model_name)
|
||||||
if not instance_obj:
|
|
||||||
raise LookupError(f"Instance {instance_name} not found for model {model_name}.")
|
|
||||||
model_obj = TenantModelService.get_by_provider_id_and_instance_id_and_model_type_and_model_name(provider_obj.id, instance_obj.id, model_type_val, pure_model_name)
|
model_obj = TenantModelService.get_by_provider_id_and_instance_id_and_model_type_and_model_name(provider_obj.id, instance_obj.id, model_type_val, pure_model_name)
|
||||||
|
|
||||||
api_key, is_tool, api_key_payload = _decode_api_key_config(instance_obj.api_key)
|
api_key, is_tool, api_key_payload = _decode_api_key_config(instance_obj.api_key)
|
||||||
@@ -242,7 +266,7 @@ def get_model_config_from_provider_instance(tenant_id, model_type: str|enum.Enum
|
|||||||
raise LookupError(f"Model provider config not found: {provider_name}")
|
raise LookupError(f"Model provider config not found: {provider_name}")
|
||||||
llm_list = [llm for llm in fac_list[0]["llm"] if llm["llm_name"] == pure_model_name]
|
llm_list = [llm for llm in fac_list[0]["llm"] if llm["llm_name"] == pure_model_name]
|
||||||
if not llm_list:
|
if not llm_list:
|
||||||
raise LookupError(f"Model config not found: {model_name}")
|
raise LookupError(f"Instance {instance_name} not found for model {model_name}.")
|
||||||
llm_info = llm_list[0]
|
llm_info = llm_list[0]
|
||||||
if model_type_val not in _factory_model_types(llm_info):
|
if model_type_val not in _factory_model_types(llm_info):
|
||||||
raise LookupError(f"Model {model_name} is not a {model_type_val} model.")
|
raise LookupError(f"Model {model_name} is not a {model_type_val} model.")
|
||||||
@@ -268,9 +292,7 @@ def get_api_key(tenant_id: str, model_name: str):
|
|||||||
provider_obj = TenantModelProviderService.get_by_tenant_id_and_provider_name(tenant_id, provider_name)
|
provider_obj = TenantModelProviderService.get_by_tenant_id_and_provider_name(tenant_id, provider_name)
|
||||||
if not provider_obj:
|
if not provider_obj:
|
||||||
raise LookupError(f"Provider {provider_name} not found.")
|
raise LookupError(f"Provider {provider_name} not found.")
|
||||||
instance_obj = TenantModelInstanceService.get_by_provider_id_and_instance_name(provider_obj.id, instance_name)
|
instance_obj = _resolve_instance_for_model(provider_obj, instance_name, model_name)
|
||||||
if not instance_obj:
|
|
||||||
raise LookupError(f"Instance {instance_name} not found.")
|
|
||||||
return instance_obj.api_key
|
return instance_obj.api_key
|
||||||
|
|
||||||
|
|
||||||
@@ -279,9 +301,7 @@ def get_model_type_by_name(tenant_id: str, model_name: str):
|
|||||||
provider_obj = TenantModelProviderService.get_by_tenant_id_and_provider_name(tenant_id, provider_name)
|
provider_obj = TenantModelProviderService.get_by_tenant_id_and_provider_name(tenant_id, provider_name)
|
||||||
if not provider_obj:
|
if not provider_obj:
|
||||||
raise LookupError(f"Provider {provider_name} not found for model {model_name}.")
|
raise LookupError(f"Provider {provider_name} not found for model {model_name}.")
|
||||||
instance_obj = TenantModelInstanceService.get_by_provider_id_and_instance_name(provider_obj.id, instance_name)
|
instance_obj = _resolve_instance_for_model(provider_obj, instance_name, model_name)
|
||||||
if not instance_obj:
|
|
||||||
raise LookupError(f"Instance {instance_name} not found for model {model_name}.")
|
|
||||||
model_objs = TenantModelService.get_by_provider_id_and_instance_id_and_model_name(provider_obj.id, instance_obj.id, pure_model_name)
|
model_objs = TenantModelService.get_by_provider_id_and_instance_id_and_model_name(provider_obj.id, instance_obj.id, pure_model_name)
|
||||||
types_in_json = []
|
types_in_json = []
|
||||||
if not model_objs:
|
if not model_objs:
|
||||||
|
|||||||
@@ -279,16 +279,20 @@ def normalize_str(v: Any) -> Any:
|
|||||||
|
|
||||||
def validate_uuid1_hex(v: Any) -> str:
|
def validate_uuid1_hex(v: Any) -> str:
|
||||||
"""
|
"""
|
||||||
Validates and converts input to a UUID version 1 hexadecimal string.
|
Validates and converts input to a UUID hexadecimal string.
|
||||||
|
|
||||||
This function performs strict validation and normalization:
|
The function name is retained for backward compatibility; only UUID
|
||||||
|
*format* is enforced (any version is accepted), because some IDs in the
|
||||||
|
system originate from external imports and use non-v1 UUIDs.
|
||||||
|
|
||||||
|
This function performs validation and normalization:
|
||||||
1. Accepts either UUID objects or UUID-formatted strings
|
1. Accepts either UUID objects or UUID-formatted strings
|
||||||
2. Verifies the UUID is version 1 (time-based)
|
2. Returns the 32-character hexadecimal representation
|
||||||
3. Returns the 32-character hexadecimal representation
|
3. Rejects anything that is not a valid UUID
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
v (Any): Input value to validate. Can be:
|
v (Any): Input value to validate. Can be:
|
||||||
- UUID object (must be version 1)
|
- UUID object (any version)
|
||||||
- String in UUID format (e.g. "550e8400-e29b-41d4-a716-446655440000")
|
- String in UUID format (e.g. "550e8400-e29b-41d4-a716-446655440000")
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -296,9 +300,8 @@ def validate_uuid1_hex(v: Any) -> str:
|
|||||||
Example: "550e8400e29b41d4a716446655440000"
|
Example: "550e8400e29b41d4a716446655440000"
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
PydanticCustomError: With code "invalid_UUID1_format" when:
|
PydanticCustomError: With code "invalid_uuid_format" when:
|
||||||
- Input is not a UUID object or valid UUID string
|
- Input is not a UUID object or valid UUID string
|
||||||
- UUID version is not 1
|
|
||||||
- String doesn't match UUID format
|
- String doesn't match UUID format
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
@@ -311,20 +314,22 @@ def validate_uuid1_hex(v: Any) -> str:
|
|||||||
Invalid cases:
|
Invalid cases:
|
||||||
>>> validate_uuid1_hex("not-a-uuid") # raises PydanticCustomError
|
>>> validate_uuid1_hex("not-a-uuid") # raises PydanticCustomError
|
||||||
>>> validate_uuid1_hex(12345) # raises PydanticCustomError
|
>>> validate_uuid1_hex(12345) # raises PydanticCustomError
|
||||||
>>> validate_uuid1_hex(UUID(int=0)) # v4, raises PydanticCustomError
|
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- Uses Python's built-in UUID parser for format validation
|
- Uses Python's built-in UUID parser for format validation
|
||||||
- Version check prevents accidental use of other UUID versions
|
- UUID version is no longer enforced (v1, v4, v7, etc. all accepted)
|
||||||
- Hyphens in input strings are automatically removed in output
|
- Hyphens in input strings are automatically removed in output
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
uuid_obj = UUID(v) if isinstance(v, str) else v
|
if isinstance(v, UUID):
|
||||||
if uuid_obj.version != 1:
|
uuid_obj = v
|
||||||
raise PydanticCustomError("invalid_UUID1_format", "Must be a UUID1 format")
|
elif isinstance(v, str):
|
||||||
|
uuid_obj = UUID(v)
|
||||||
|
else:
|
||||||
|
raise TypeError
|
||||||
return uuid_obj.hex
|
return uuid_obj.hex
|
||||||
except (AttributeError, ValueError, TypeError):
|
except (AttributeError, ValueError, TypeError):
|
||||||
raise PydanticCustomError("invalid_UUID1_format", "Invalid UUID1 format")
|
raise PydanticCustomError("invalid_uuid_format", "Invalid UUID format")
|
||||||
|
|
||||||
|
|
||||||
class Base(BaseModel):
|
class Base(BaseModel):
|
||||||
@@ -801,7 +806,7 @@ class DeleteReq(Base):
|
|||||||
|
|
||||||
This post-processing validator performs:
|
This post-processing validator performs:
|
||||||
1. None input handling (pass-through)
|
1. None input handling (pass-through)
|
||||||
2. UUID version 1 validation for each list item
|
2. UUID format validation for each list item (any version accepted)
|
||||||
3. Duplicate value detection
|
3. Duplicate value detection
|
||||||
4. Returns normalized UUID hex strings or None
|
4. Returns normalized UUID hex strings or None
|
||||||
|
|
||||||
@@ -814,18 +819,18 @@ class DeleteReq(Base):
|
|||||||
- None if input was None
|
- None if input was None
|
||||||
- List of normalized UUID hex strings otherwise:
|
- List of normalized UUID hex strings otherwise:
|
||||||
* 32-character lowercase
|
* 32-character lowercase
|
||||||
* Valid UUID version 1
|
* Valid UUID format (any version)
|
||||||
* Unique within list
|
* Unique within list
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
PydanticCustomError: With structured error details when:
|
PydanticCustomError: With structured error details when:
|
||||||
- "invalid_UUID1_format": Any string fails UUIDv1 validation
|
- "invalid_uuid_format": Any string fails UUID format validation
|
||||||
- "duplicate_uuids": If duplicate IDs are detected
|
- "duplicate_uuids": If duplicate IDs are detected
|
||||||
|
|
||||||
Validation Rules:
|
Validation Rules:
|
||||||
- None input returns None
|
- None input returns None
|
||||||
- Empty list returns empty list
|
- Empty list returns empty list
|
||||||
- All non-None items must be valid UUIDv1
|
- All non-None items must be valid UUIDs (any version)
|
||||||
- No duplicates permitted
|
- No duplicates permitted
|
||||||
- Original order preserved
|
- Original order preserved
|
||||||
|
|
||||||
@@ -840,12 +845,12 @@ class DeleteReq(Base):
|
|||||||
|
|
||||||
Invalid cases:
|
Invalid cases:
|
||||||
>>> validate_ids(["invalid"])
|
>>> validate_ids(["invalid"])
|
||||||
# raises PydanticCustomError(invalid_UUID1_format)
|
# raises PydanticCustomError(invalid_uuid_format)
|
||||||
>>> validate_ids(["550e...", "550e..."])
|
>>> validate_ids(["550e...", "550e..."])
|
||||||
# raises PydanticCustomError(duplicate_uuids)
|
# raises PydanticCustomError(duplicate_uuids)
|
||||||
|
|
||||||
Security Notes:
|
Security Notes:
|
||||||
- Validates UUID version to prevent version spoofing
|
- Validates UUID format (any version)
|
||||||
- Duplicate check prevents data injection
|
- Duplicate check prevents data injection
|
||||||
- None handling maintains pipeline integrity
|
- None handling maintains pipeline integrity
|
||||||
"""
|
"""
|
||||||
|
|||||||
+8
-5
@@ -43,6 +43,7 @@ import (
|
|||||||
"ragflow/internal/agent/canvas"
|
"ragflow/internal/agent/canvas"
|
||||||
_ "ragflow/internal/agent/component" // blank import: registers every Component factory (Begin / Agent / LLM / Message / Retrieval / ...) into the shared runtime at package init
|
_ "ragflow/internal/agent/component" // blank import: registers every Component factory (Begin / Agent / LLM / Message / Retrieval / ...) into the shared runtime at package init
|
||||||
"ragflow/internal/agent/runtime"
|
"ragflow/internal/agent/runtime"
|
||||||
|
agenttool "ragflow/internal/agent/tool"
|
||||||
"ragflow/internal/dao"
|
"ragflow/internal/dao"
|
||||||
"ragflow/internal/engine"
|
"ragflow/internal/engine"
|
||||||
"ragflow/internal/handler"
|
"ragflow/internal/handler"
|
||||||
@@ -90,9 +91,8 @@ func main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize logger with default level
|
// Temporarily default to debug while investigating the Go chat/SSE path.
|
||||||
// logger.Init("info"); // set debug log level
|
if err := common.Init("debug", common.FileOutput{Path: "server_main.log"}); err != nil {
|
||||||
if err := common.Init("info", common.FileOutput{Path: "server_main.log"}); err != nil {
|
|
||||||
panic(fmt.Sprintf("Failed to initialize logger: %v", err))
|
panic(fmt.Sprintf("Failed to initialize logger: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,7 +115,7 @@ func main() {
|
|||||||
// Reinitialize logger with configured level if different
|
// Reinitialize logger with configured level if different
|
||||||
level := config.Log.Level
|
level := config.Log.Level
|
||||||
if level == "" {
|
if level == "" {
|
||||||
level = "info"
|
level = "debug"
|
||||||
}
|
}
|
||||||
|
|
||||||
if debugFlag {
|
if debugFlag {
|
||||||
@@ -236,6 +236,9 @@ func startServer(config *server.Config) {
|
|||||||
|
|
||||||
// Initialize doc engine for skill search
|
// Initialize doc engine for skill search
|
||||||
docEngine := engine.Get()
|
docEngine := engine.Get()
|
||||||
|
documentDAO := dao.NewDocumentDAO()
|
||||||
|
agenttool.SetRetrievalService(agenttool.NewNLPRetrievalAdapterFromDeps(docEngine, documentDAO))
|
||||||
|
common.Info("agent: retrieval service adapter installed")
|
||||||
|
|
||||||
// Initialize handler layer
|
// Initialize handler layer
|
||||||
authHandler := handler.NewAuthHandler()
|
authHandler := handler.NewAuthHandler()
|
||||||
@@ -296,7 +299,7 @@ func startServer(config *server.Config) {
|
|||||||
fileCommitHandler := handler.NewFileCommitHandler(service.NewFileCommitService())
|
fileCommitHandler := handler.NewFileCommitHandler(service.NewFileCommitService())
|
||||||
|
|
||||||
// Dify retrieval handler
|
// Dify retrieval handler
|
||||||
docDAO := dao.NewDocumentDAO()
|
docDAO := documentDAO
|
||||||
retrievalService := nlp.NewRetrievalService(docEngine, docDAO)
|
retrievalService := nlp.NewRetrievalService(docEngine, docDAO)
|
||||||
difyRetrievalHandler := handler.NewDifyRetrievalHandler(
|
difyRetrievalHandler := handler.NewDifyRetrievalHandler(
|
||||||
knowledgebaseService,
|
knowledgebaseService,
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ go 1.26.4
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/DATA-DOG/go-sqlmock v1.5.2
|
github.com/DATA-DOG/go-sqlmock v1.5.2
|
||||||
github.com/alibabacloud-go/agentrun-20250910 v1.1.0
|
github.com/alibabacloud-go/agentrun-20250910/v5 v5.8.4
|
||||||
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.12
|
github.com/alibabacloud-go/darabonba-openapi/v2 v2.2.1
|
||||||
github.com/alicebob/miniredis/v2 v2.38.0
|
github.com/alicebob/miniredis/v2 v2.38.0
|
||||||
github.com/aws/aws-sdk-go-v2 v1.41.3
|
github.com/aws/aws-sdk-go-v2 v1.41.3
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6
|
||||||
@@ -74,8 +74,8 @@ require (
|
|||||||
connectrpc.com/connect v1.19.2 // indirect
|
connectrpc.com/connect v1.19.2 // indirect
|
||||||
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 // indirect
|
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 // indirect
|
||||||
github.com/alibabacloud-go/debug v1.0.1 // indirect
|
github.com/alibabacloud-go/debug v1.0.1 // indirect
|
||||||
github.com/alibabacloud-go/tea v1.3.12 // indirect
|
github.com/alibabacloud-go/tea v1.5.0 // indirect
|
||||||
github.com/alibabacloud-go/tea-utils/v2 v2.0.7 // indirect
|
github.com/alibabacloud-go/tea-utils/v2 v2.0.9 // indirect
|
||||||
github.com/aliyun/credentials-go v1.4.5 // indirect
|
github.com/aliyun/credentials-go v1.4.5 // indirect
|
||||||
github.com/apache/thrift v0.23.0 // indirect
|
github.com/apache/thrift v0.23.0 // indirect
|
||||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
|
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7Oputl
|
|||||||
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
||||||
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
|
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
|
||||||
github.com/airbrake/gobrake v3.6.1+incompatible/go.mod h1:wM4gu3Cn0W0K7GUuVWnlXZU11AGBXMILnrdOU8Kn00o=
|
github.com/airbrake/gobrake v3.6.1+incompatible/go.mod h1:wM4gu3Cn0W0K7GUuVWnlXZU11AGBXMILnrdOU8Kn00o=
|
||||||
github.com/alibabacloud-go/agentrun-20250910 v1.1.0 h1:Vvhs0/Fd8Urn7gpfZmbWahA+c9GPsSnjRMcKNPWiF3k=
|
github.com/alibabacloud-go/agentrun-20250910/v5 v5.8.4 h1:hiAsm9pz6aICOPLI1FC54vga10xwd/XxfNj06ow5jVM=
|
||||||
github.com/alibabacloud-go/agentrun-20250910 v1.1.0/go.mod h1:j4kaTDVOaXT/I7alT6886+H310G3uypMyRQbdj3bU8o=
|
github.com/alibabacloud-go/agentrun-20250910/v5 v5.8.4/go.mod h1:CYOwrIjr05fOHvdhid7MWtP/IT5ILLcE0eqiolzQT/Q=
|
||||||
github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6 h1:eIf+iGJxdU4U9ypaUfbtOWCsZSbTb8AUHvyPrxu6mAA=
|
github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6 h1:eIf+iGJxdU4U9ypaUfbtOWCsZSbTb8AUHvyPrxu6mAA=
|
||||||
github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6/go.mod h1:4EUIoxs/do24zMOGGqYVWgw0s9NtiylnJglOeEB5UJo=
|
github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6/go.mod h1:4EUIoxs/do24zMOGGqYVWgw0s9NtiylnJglOeEB5UJo=
|
||||||
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.4/go.mod h1:sCavSAvdzOjul4cEqeVtvlSaSScfNsTQ+46HwlTL1hc=
|
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.4/go.mod h1:sCavSAvdzOjul4cEqeVtvlSaSScfNsTQ+46HwlTL1hc=
|
||||||
@@ -28,8 +28,8 @@ github.com/alibabacloud-go/darabonba-encode-util v0.0.2 h1:1uJGrbsGEVqWcWxrS9MyC
|
|||||||
github.com/alibabacloud-go/darabonba-encode-util v0.0.2/go.mod h1:JiW9higWHYXm7F4PKuMgEUETNZasrDM6vqVr/Can7H8=
|
github.com/alibabacloud-go/darabonba-encode-util v0.0.2/go.mod h1:JiW9higWHYXm7F4PKuMgEUETNZasrDM6vqVr/Can7H8=
|
||||||
github.com/alibabacloud-go/darabonba-map v0.0.2 h1:qvPnGB4+dJbJIxOOfawxzF3hzMnIpjmafa0qOTp6udc=
|
github.com/alibabacloud-go/darabonba-map v0.0.2 h1:qvPnGB4+dJbJIxOOfawxzF3hzMnIpjmafa0qOTp6udc=
|
||||||
github.com/alibabacloud-go/darabonba-map v0.0.2/go.mod h1:28AJaX8FOE/ym8OUFWga+MtEzBunJwQGceGQlvaPGPc=
|
github.com/alibabacloud-go/darabonba-map v0.0.2/go.mod h1:28AJaX8FOE/ym8OUFWga+MtEzBunJwQGceGQlvaPGPc=
|
||||||
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.12 h1:e2yCrhtWd6Qcsy4he2OL+jIAU+93Lx9OcLlPRoFLT1w=
|
github.com/alibabacloud-go/darabonba-openapi/v2 v2.2.1 h1:R8b55YFS4K9x5P5IdgA+QWinVfVmulzqaJG/tr6HhxM=
|
||||||
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.12/go.mod h1:f2wDpbM7hK9SvLIH09zSKVU1TsyemUNOqErMscMMl7c=
|
github.com/alibabacloud-go/darabonba-openapi/v2 v2.2.1/go.mod h1:OCFim1kMbp2m+V8WS5IBnnVrk6nXaJiDwZpg3uqw8Po=
|
||||||
github.com/alibabacloud-go/darabonba-signature-util v0.0.7 h1:UzCnKvsjPFzApvODDNEYqBHMFt1w98wC7FOo0InLyxg=
|
github.com/alibabacloud-go/darabonba-signature-util v0.0.7 h1:UzCnKvsjPFzApvODDNEYqBHMFt1w98wC7FOo0InLyxg=
|
||||||
github.com/alibabacloud-go/darabonba-signature-util v0.0.7/go.mod h1:oUzCYV2fcCH797xKdL6BDH8ADIHlzrtKVjeRtunBNTQ=
|
github.com/alibabacloud-go/darabonba-signature-util v0.0.7/go.mod h1:oUzCYV2fcCH797xKdL6BDH8ADIHlzrtKVjeRtunBNTQ=
|
||||||
github.com/alibabacloud-go/darabonba-string v1.0.2 h1:E714wms5ibdzCqGeYJ9JCFywE5nDyvIXIIQbZVFkkqo=
|
github.com/alibabacloud-go/darabonba-string v1.0.2 h1:E714wms5ibdzCqGeYJ9JCFywE5nDyvIXIIQbZVFkkqo=
|
||||||
@@ -49,13 +49,13 @@ github.com/alibabacloud-go/tea v1.1.11/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/Ke
|
|||||||
github.com/alibabacloud-go/tea v1.1.17/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A=
|
github.com/alibabacloud-go/tea v1.1.17/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A=
|
||||||
github.com/alibabacloud-go/tea v1.1.20/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A=
|
github.com/alibabacloud-go/tea v1.1.20/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A=
|
||||||
github.com/alibabacloud-go/tea v1.2.2/go.mod h1:CF3vOzEMAG+bR4WOql8gc2G9H3EkH3ZLAQdpmpXMgwk=
|
github.com/alibabacloud-go/tea v1.2.2/go.mod h1:CF3vOzEMAG+bR4WOql8gc2G9H3EkH3ZLAQdpmpXMgwk=
|
||||||
github.com/alibabacloud-go/tea v1.3.12 h1:ir2Io80UlBy1JHf7t+uCTxmaGQtiEta1WpV29NGJTkE=
|
github.com/alibabacloud-go/tea v1.5.0 h1:8pUo8WzMChtJT+jpLIcN1vzMi6SW3rihzzBoihPGUvs=
|
||||||
github.com/alibabacloud-go/tea v1.3.12/go.mod h1:A560v/JTQ1n5zklt2BEpurJzZTI8TUT+Psg2drWlxRg=
|
github.com/alibabacloud-go/tea v1.5.0/go.mod h1:hgSs82CkOiehSQMoiFN79dL6zsGX7pVGvnn9SIEs8/0=
|
||||||
github.com/alibabacloud-go/tea-utils v1.3.1 h1:iWQeRzRheqCMuiF3+XkfybB3kTgUXkXX+JMrqfLeB2I=
|
github.com/alibabacloud-go/tea-utils v1.3.1 h1:iWQeRzRheqCMuiF3+XkfybB3kTgUXkXX+JMrqfLeB2I=
|
||||||
github.com/alibabacloud-go/tea-utils v1.3.1/go.mod h1:EI/o33aBfj3hETm4RLiAxF/ThQdSngxrpF8rKUDJjPE=
|
github.com/alibabacloud-go/tea-utils v1.3.1/go.mod h1:EI/o33aBfj3hETm4RLiAxF/ThQdSngxrpF8rKUDJjPE=
|
||||||
github.com/alibabacloud-go/tea-utils/v2 v2.0.5/go.mod h1:dL6vbUT35E4F4bFTHL845eUloqaerYBYPsdWR2/jhe4=
|
github.com/alibabacloud-go/tea-utils/v2 v2.0.5/go.mod h1:dL6vbUT35E4F4bFTHL845eUloqaerYBYPsdWR2/jhe4=
|
||||||
github.com/alibabacloud-go/tea-utils/v2 v2.0.7 h1:WDx5qW3Xa5ZgJ1c8NfqJkF6w+AU5wB8835UdhPr6Ax0=
|
github.com/alibabacloud-go/tea-utils/v2 v2.0.9 h1:y6pUIlhjxbZl9ObDAcmA1H3c21eaAxADHTDQmBnAIgA=
|
||||||
github.com/alibabacloud-go/tea-utils/v2 v2.0.7/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I=
|
github.com/alibabacloud-go/tea-utils/v2 v2.0.9/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I=
|
||||||
github.com/alicebob/miniredis/v2 v2.38.0 h1:nZAzCR+Lj+Vxk4ZXzm2NuKq2O33RXj1XxJ2e2uP9jiw=
|
github.com/alicebob/miniredis/v2 v2.38.0 h1:nZAzCR+Lj+Vxk4ZXzm2NuKq2O33RXj1XxJ2e2uP9jiw=
|
||||||
github.com/alicebob/miniredis/v2 v2.38.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
github.com/alicebob/miniredis/v2 v2.38.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
||||||
github.com/aliyun/credentials-go v1.1.2/go.mod h1:ozcZaMR5kLM7pwtCMEpVmQ242suV6qTJya2bDq4X1Tw=
|
github.com/aliyun/credentials-go v1.1.2/go.mod h1:ozcZaMR5kLM7pwtCMEpVmQ242suV6qTJya2bDq4X1Tw=
|
||||||
@@ -485,6 +485,8 @@ github.com/yuin/goldmark v1.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
|
|||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
||||||
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
||||||
|
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||||
|
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||||
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
||||||
|
|||||||
@@ -189,6 +189,10 @@ func initialUserFillUpData(ctx context.Context, inputSpec map[string]any) (any,
|
|||||||
if err != nil || raw == nil {
|
if err != nil || raw == nil {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
if values, ok := raw.(map[string]any); ok {
|
||||||
|
state.Sys["__initial_user_input_consumed__"] = true
|
||||||
|
return values, true
|
||||||
|
}
|
||||||
text, ok := raw.(string)
|
text, ok := raw.(string)
|
||||||
if !ok || text == "" {
|
if !ok || text == "" {
|
||||||
return nil, false
|
return nil, false
|
||||||
|
|||||||
@@ -352,6 +352,29 @@ func TestInitialUserFillUpData_UsesSysQueryWhenSchemaPresent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestInitialUserFillUpData_UsesStructuredSysQueryWhenSchemaPresent(t *testing.T) {
|
||||||
|
state := NewCanvasState("run-1", "task-1")
|
||||||
|
state.Sys["query"] = map[string]any{"kb": "da1", "query": "合同"}
|
||||||
|
ctx := WithState(context.Background(), state)
|
||||||
|
|
||||||
|
got, ok := initialUserFillUpData(ctx, map[string]any{
|
||||||
|
"inputs": map[string]any{
|
||||||
|
"kb": map[string]any{"type": "line"},
|
||||||
|
"query": map[string]any{"type": "line"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected initialUserFillUpData to consume structured sys.query")
|
||||||
|
}
|
||||||
|
values, ok := got.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("got type %T, want map[string]any", got)
|
||||||
|
}
|
||||||
|
if values["kb"] != "da1" || values["query"] != "合同" {
|
||||||
|
t.Fatalf("got %#v, want kb=da1 query=合同", values)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestInitialUserFillUpData_SkipsWhenNoSchema(t *testing.T) {
|
func TestInitialUserFillUpData_SkipsWhenNoSchema(t *testing.T) {
|
||||||
state := NewCanvasState("run-1", "task-1")
|
state := NewCanvasState("run-1", "task-1")
|
||||||
state.Sys["query"] = "loop"
|
state.Sys["query"] = "loop"
|
||||||
|
|||||||
@@ -221,7 +221,8 @@ func (r *Runner) getInterruptID(canvasID, sessionID string) string {
|
|||||||
func (r *Runner) Run(
|
func (r *Runner) Run(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
run RunFunc,
|
run RunFunc,
|
||||||
canvasID, sessionID, userInput string,
|
canvasID, sessionID string,
|
||||||
|
userInput any,
|
||||||
root map[string]any,
|
root map[string]any,
|
||||||
) <-chan RunEvent {
|
) <-chan RunEvent {
|
||||||
out := make(chan RunEvent, 8)
|
out := make(chan RunEvent, 8)
|
||||||
@@ -295,7 +296,7 @@ func (r *Runner) Run(
|
|||||||
// invoking the workflow. The sentinel keys are deleted from
|
// invoking the workflow. The sentinel keys are deleted from
|
||||||
// root inside the RunFunc — see service/agent.go's
|
// root inside the RunFunc — see service/agent.go's
|
||||||
// buildRunFunc.
|
// buildRunFunc.
|
||||||
if userInput != "" {
|
if userInput != nil {
|
||||||
if id := r.getInterruptID(canvasID, sessionID); id != "" {
|
if id := r.getInterruptID(canvasID, sessionID); id != "" {
|
||||||
root["__resume_interrupt_id__"] = id
|
root["__resume_interrupt_id__"] = id
|
||||||
root["__resume_data__"] = userInput
|
root["__resume_data__"] = userInput
|
||||||
|
|||||||
@@ -34,7 +34,13 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"ragflow/internal/agent/runtime"
|
||||||
agenttool "ragflow/internal/agent/tool"
|
agenttool "ragflow/internal/agent/tool"
|
||||||
|
"ragflow/internal/dao"
|
||||||
|
"ragflow/internal/entity"
|
||||||
)
|
)
|
||||||
|
|
||||||
type codeExecSandboxRecorder struct {
|
type codeExecSandboxRecorder struct {
|
||||||
@@ -190,6 +196,203 @@ func TestRetrieval_KbIDsTranslatedToDatasetIDs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRetrieval_LegacyQueryStringNormalized(t *testing.T) {
|
||||||
|
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{TranslateError: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to open sqlite: %v", err)
|
||||||
|
}
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to unwrap sql db: %v", err)
|
||||||
|
}
|
||||||
|
sqlDB.SetMaxOpenConns(1)
|
||||||
|
if err := db.AutoMigrate(&entity.Knowledgebase{}); err != nil {
|
||||||
|
t.Fatalf("failed to migrate knowledgebase: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&entity.UserTenant{}); err != nil {
|
||||||
|
t.Fatalf("failed to migrate user_tenant: %v", err)
|
||||||
|
}
|
||||||
|
origDB := dao.DB
|
||||||
|
dao.DB = db
|
||||||
|
t.Cleanup(func() { dao.DB = origDB })
|
||||||
|
activeStatus := "1"
|
||||||
|
if err := db.Create(&entity.UserTenant{
|
||||||
|
ID: "ut-1",
|
||||||
|
UserID: "user-1",
|
||||||
|
TenantID: "tenant-1",
|
||||||
|
Role: "owner",
|
||||||
|
InvitedBy: "user-1",
|
||||||
|
Status: &activeStatus,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("failed to seed user_tenant: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Create(&entity.Knowledgebase{
|
||||||
|
ID: "kb-da1",
|
||||||
|
Name: "da1",
|
||||||
|
TenantID: "tenant-1",
|
||||||
|
EmbdID: "BAAI/bge-m3@yy2@SILICONFLOW",
|
||||||
|
Permission: "me",
|
||||||
|
CreatedBy: "user-1",
|
||||||
|
Status: func() *string { s := string(entity.StatusValid); return &s }(),
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("failed to seed kb: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c, err := newRetrievalComponent(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newRetrievalComponent: %v", err)
|
||||||
|
}
|
||||||
|
rc := c.(*retrievalComponent)
|
||||||
|
merged := rc.applyDefaults(map[string]any{
|
||||||
|
"query": "UserFillUp: da1\nInput diamond necklace\n",
|
||||||
|
})
|
||||||
|
state := runtime.NewCanvasState("run-1", "task-1")
|
||||||
|
state.Sys["user_id"] = "user-1"
|
||||||
|
normalizeLegacyRetrievalInputs(runtime.WithState(context.Background(), state), merged)
|
||||||
|
|
||||||
|
if got, _ := merged["query"].(string); got != "diamond necklace" {
|
||||||
|
t.Fatalf("query = %q, want diamond necklace", got)
|
||||||
|
}
|
||||||
|
ds, ok := merged["dataset_ids"].([]string)
|
||||||
|
if !ok || len(ds) != 1 || ds[0] != "kb-da1" {
|
||||||
|
t.Fatalf("dataset_ids = %#v, want []string{\"kb-da1\"}", merged["dataset_ids"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRetrieval_StructuredUserFillInputNormalized(t *testing.T) {
|
||||||
|
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{TranslateError: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to open sqlite: %v", err)
|
||||||
|
}
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to unwrap sql db: %v", err)
|
||||||
|
}
|
||||||
|
sqlDB.SetMaxOpenConns(1)
|
||||||
|
if err := db.AutoMigrate(&entity.Knowledgebase{}, &entity.UserTenant{}); err != nil {
|
||||||
|
t.Fatalf("failed to migrate tables: %v", err)
|
||||||
|
}
|
||||||
|
origDB := dao.DB
|
||||||
|
dao.DB = db
|
||||||
|
t.Cleanup(func() { dao.DB = origDB })
|
||||||
|
|
||||||
|
activeStatus := "1"
|
||||||
|
if err := db.Create(&entity.UserTenant{
|
||||||
|
ID: "ut-1",
|
||||||
|
UserID: "user-1",
|
||||||
|
TenantID: "tenant-1",
|
||||||
|
Role: "owner",
|
||||||
|
InvitedBy: "user-1",
|
||||||
|
Status: &activeStatus,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("failed to seed user_tenant: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&entity.Knowledgebase{
|
||||||
|
ID: "kb-da1",
|
||||||
|
Name: "da1",
|
||||||
|
TenantID: "tenant-1",
|
||||||
|
EmbdID: "BAAI/bge-m3@yy2@SILICONFLOW",
|
||||||
|
Permission: "me",
|
||||||
|
CreatedBy: "user-1",
|
||||||
|
Status: func() *string { s := string(entity.StatusValid); return &s }(),
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("failed to seed kb: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c, err := newRetrievalComponent(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newRetrievalComponent: %v", err)
|
||||||
|
}
|
||||||
|
rc := c.(*retrievalComponent)
|
||||||
|
merged := rc.applyDefaults(map[string]any{
|
||||||
|
"state": map[string]any{
|
||||||
|
"UserFillUp:KBInput": map[string]any{
|
||||||
|
"kb": "da1",
|
||||||
|
"query": "合同",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
state := runtime.NewCanvasState("run-1", "task-1")
|
||||||
|
state.Sys["user_id"] = "user-1"
|
||||||
|
normalizeLegacyRetrievalInputs(runtime.WithState(context.Background(), state), merged)
|
||||||
|
|
||||||
|
if got, _ := merged["query"].(string); got != "合同" {
|
||||||
|
t.Fatalf("query = %q, want 合同", got)
|
||||||
|
}
|
||||||
|
ds, ok := merged["dataset_ids"].([]string)
|
||||||
|
if !ok || len(ds) != 1 || ds[0] != "kb-da1" {
|
||||||
|
t.Fatalf("dataset_ids = %#v, want []string{\"kb-da1\"}", merged["dataset_ids"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRetrieval_ResolveDatasetIDByTenantName(t *testing.T) {
|
||||||
|
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{TranslateError: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to open sqlite: %v", err)
|
||||||
|
}
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to unwrap sql db: %v", err)
|
||||||
|
}
|
||||||
|
sqlDB.SetMaxOpenConns(1)
|
||||||
|
if err := db.AutoMigrate(&entity.Knowledgebase{}); err != nil {
|
||||||
|
t.Fatalf("failed to migrate knowledgebase: %v", err)
|
||||||
|
}
|
||||||
|
origDB := dao.DB
|
||||||
|
dao.DB = db
|
||||||
|
t.Cleanup(func() { dao.DB = origDB })
|
||||||
|
|
||||||
|
if err := db.Create(&entity.Knowledgebase{
|
||||||
|
ID: "kb-da1",
|
||||||
|
Name: "da1",
|
||||||
|
TenantID: "tenant-1",
|
||||||
|
EmbdID: "BAAI/bge-m3@yy2@SILICONFLOW",
|
||||||
|
Permission: "me",
|
||||||
|
CreatedBy: "user-1",
|
||||||
|
Status: func() *string { s := string(entity.StatusValid); return &s }(),
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("failed to seed kb: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
state := runtime.NewCanvasState("run-1", "task-1")
|
||||||
|
state.Sys["tenant_id"] = "tenant-1"
|
||||||
|
ctx := runtime.WithState(context.Background(), state)
|
||||||
|
|
||||||
|
if got := resolveRetrievalDatasetID(ctx, "da1"); got != "kb-da1" {
|
||||||
|
t.Fatalf("resolveRetrievalDatasetID = %q, want kb-da1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRetrieval_StructuredInputPreservesQueryWhenDatasetIDsAlreadyPresent(t *testing.T) {
|
||||||
|
c, err := newRetrievalComponent(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newRetrievalComponent: %v", err)
|
||||||
|
}
|
||||||
|
rc := c.(*retrievalComponent)
|
||||||
|
merged := rc.applyDefaults(map[string]any{
|
||||||
|
"dataset_ids": []string{"kb-fixed"},
|
||||||
|
"state": map[string]any{
|
||||||
|
"UserFillUp:KBInput": map[string]any{
|
||||||
|
"kb": "da1",
|
||||||
|
"query": "合同",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
consumed := normalizeStructuredRetrievalInputs(context.Background(), merged)
|
||||||
|
if !consumed {
|
||||||
|
t.Fatal("normalizeStructuredRetrievalInputs should consume structured query")
|
||||||
|
}
|
||||||
|
if got, _ := merged["query"].(string); got != "合同" {
|
||||||
|
t.Fatalf("query = %q, want 合同", got)
|
||||||
|
}
|
||||||
|
ds, ok := merged["dataset_ids"].([]string)
|
||||||
|
if !ok || len(ds) != 1 || ds[0] != "kb-fixed" {
|
||||||
|
t.Fatalf("dataset_ids = %#v, want []string{\"kb-fixed\"}", merged["dataset_ids"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestRetrieval_KbIDsEndToEndThroughTool is the wire-level
|
// TestRetrieval_KbIDsEndToEndThroughTool is the wire-level
|
||||||
// companion to TestRetrieval_KbIDsTranslatedToDatasetIDs: it
|
// companion to TestRetrieval_KbIDsTranslatedToDatasetIDs: it
|
||||||
// installs the simple retrieval service, builds a wrapper with
|
// installs the simple retrieval service, builds a wrapper with
|
||||||
|
|||||||
@@ -353,6 +353,22 @@ func evaluateClause(clause map[string]any, state *runtime.CanvasState) (bool, er
|
|||||||
right := clause["right"]
|
right := clause["right"]
|
||||||
lv := leftValue(left, state)
|
lv := leftValue(left, state)
|
||||||
|
|
||||||
|
// Port of python PR #16320: for the four string operators,
|
||||||
|
// coerce nil on either side to "". In Python this avoids
|
||||||
|
// AttributeError on `.lower()`; in Go there's no crash (fmt
|
||||||
|
// renders nil as "<nil>"), but the Python post-fix semantic —
|
||||||
|
// where "foo" contains None is True — diverges from Go's
|
||||||
|
// "<nil>" rendering. Coercing to "" aligns the Go port with
|
||||||
|
// the Python workflow.
|
||||||
|
if op == "contains" || op == "not contains" || op == "start with" || op == "end with" {
|
||||||
|
if lv == nil {
|
||||||
|
lv = ""
|
||||||
|
}
|
||||||
|
if right == nil {
|
||||||
|
right = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
switch op {
|
switch op {
|
||||||
case "==":
|
case "==":
|
||||||
return equalFoldValues(lv, right), nil
|
return equalFoldValues(lv, right), nil
|
||||||
|
|||||||
@@ -241,6 +241,149 @@ func TestSwitch_LegacyConditionsAndArrayTo(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSwitch_NilUpstreamContainsEmptyNeedleMatches ports the
|
||||||
|
// regression covered by python PR #16320: when an upstream
|
||||||
|
// component yields nil and the configured value is the empty
|
||||||
|
// string, the "contains" operator must match (Python semantics
|
||||||
|
// after the fix: "" in "anything"). Pre-fix Python crashed with
|
||||||
|
// AttributeError; pre-port Go returned false because fmt rendered
|
||||||
|
// nil as "<nil>" instead of "". The fix coerces nil → "" before
|
||||||
|
// formatting, restoring parity with the Python workflow.
|
||||||
|
func TestSwitch_NilUpstreamContainsEmptyNeedleMatches(t *testing.T) {
|
||||||
|
s, _ := NewSwitchComponent(nil)
|
||||||
|
state := canvas.NewCanvasState("run-nil-contains", "task-nil-contains")
|
||||||
|
state.Sys["answer"] = nil
|
||||||
|
ctx := withStateForTest(context.Background(), state)
|
||||||
|
|
||||||
|
inputs := map[string]any{
|
||||||
|
"conditions": []any{
|
||||||
|
map[string]any{
|
||||||
|
"op": "and",
|
||||||
|
"to": []any{"case_target"},
|
||||||
|
"clauses": []any{
|
||||||
|
map[string]any{"left": "{{sys.answer}}", "op": "contains", "right": ""},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"default": "else_target",
|
||||||
|
}
|
||||||
|
out, err := s.Invoke(ctx, inputs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Invoke: %v", err)
|
||||||
|
}
|
||||||
|
targets := nextTargets(out)
|
||||||
|
if len(targets) != 1 || targets[0] != "case_target" {
|
||||||
|
t.Errorf("_next: got %v, want [\"case_target\"] (nil coerced to \"\" should match empty needle)", targets)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSwitch_NilUpstreamContainsNonEmptyDoesNotMatch verifies
|
||||||
|
// the inverse: nil coerced to "" still must NOT match a
|
||||||
|
// non-empty needle (we only coerce, we don't synthesize a match).
|
||||||
|
func TestSwitch_NilUpstreamContainsNonEmptyDoesNotMatch(t *testing.T) {
|
||||||
|
s, _ := NewSwitchComponent(nil)
|
||||||
|
state := canvas.NewCanvasState("run-nil-needle", "task-nil-needle")
|
||||||
|
state.Sys["answer"] = nil
|
||||||
|
ctx := withStateForTest(context.Background(), state)
|
||||||
|
|
||||||
|
inputs := map[string]any{
|
||||||
|
"conditions": []any{
|
||||||
|
map[string]any{
|
||||||
|
"op": "and",
|
||||||
|
"to": []any{"case_target"},
|
||||||
|
"clauses": []any{
|
||||||
|
map[string]any{"left": "{{sys.answer}}", "op": "contains", "right": "foo"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"default": "else_target",
|
||||||
|
}
|
||||||
|
out, err := s.Invoke(ctx, inputs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Invoke: %v", err)
|
||||||
|
}
|
||||||
|
targets := nextTargets(out)
|
||||||
|
if len(targets) != 1 || targets[0] != "else_target" {
|
||||||
|
t.Errorf("_next: got %v, want [\"else_target\"] (\"\" does not contain \"foo\")", targets)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSwitch_NilValueContainsDoesNotRaise mirrors python test
|
||||||
|
// "test_switch_none_value_contains_does_not_raise": the configured
|
||||||
|
// value can also be nil and the operator must not crash. With
|
||||||
|
// nil coerced to "" on both sides, "foobar" contains "" matches.
|
||||||
|
func TestSwitch_NilValueContainsDoesNotRaise(t *testing.T) {
|
||||||
|
s, _ := NewSwitchComponent(nil)
|
||||||
|
state := canvas.NewCanvasState("run-nil-value", "task-nil-value")
|
||||||
|
state.Sys["answer"] = "foobar"
|
||||||
|
ctx := withStateForTest(context.Background(), state)
|
||||||
|
|
||||||
|
inputs := map[string]any{
|
||||||
|
"conditions": []any{
|
||||||
|
map[string]any{
|
||||||
|
"op": "and",
|
||||||
|
"to": []any{"case_target"},
|
||||||
|
"clauses": []any{
|
||||||
|
map[string]any{"left": "{{sys.answer}}", "op": "contains", "right": nil},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"default": "else_target",
|
||||||
|
}
|
||||||
|
out, err := s.Invoke(ctx, inputs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Invoke: %v", err)
|
||||||
|
}
|
||||||
|
targets := nextTargets(out)
|
||||||
|
if len(targets) != 1 || targets[0] != "case_target" {
|
||||||
|
t.Errorf("_next: got %v, want [\"case_target\"] (nil value coerced to \"\" matches any string)", targets)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSwitch_NilUpstreamStartWithEndWithDoNotCrash guards the
|
||||||
|
// remaining two string operators covered by PR #16320. They were
|
||||||
|
// crash-prone in Python for the same reason; in Go they don't
|
||||||
|
// crash but the nil → "" coercion still applies, so a nil
|
||||||
|
// upstream with an empty prefix/suffix must match (rather than
|
||||||
|
// being rendered as "<nil>" and silently missing).
|
||||||
|
func TestSwitch_NilUpstreamStartWithEndWithDoNotCrash(t *testing.T) {
|
||||||
|
s, _ := NewSwitchComponent(nil)
|
||||||
|
state := canvas.NewCanvasState("run-nil-start-end", "task-nil-start-end")
|
||||||
|
state.Sys["answer"] = nil
|
||||||
|
ctx := withStateForTest(context.Background(), state)
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
op string
|
||||||
|
}{
|
||||||
|
{name: "start with", op: "start with"},
|
||||||
|
{name: "end with", op: "end with"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
inputs := map[string]any{
|
||||||
|
"conditions": []any{
|
||||||
|
map[string]any{
|
||||||
|
"op": "and",
|
||||||
|
"to": []any{"case_target"},
|
||||||
|
"clauses": []any{
|
||||||
|
map[string]any{"left": "{{sys.answer}}", "op": tc.op, "right": ""},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"default": "else_target",
|
||||||
|
}
|
||||||
|
out, err := s.Invoke(ctx, inputs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Invoke: %v", err)
|
||||||
|
}
|
||||||
|
targets := nextTargets(out)
|
||||||
|
if len(targets) != 1 || targets[0] != "case_target" {
|
||||||
|
t.Errorf("_next: got %v, want [\"case_target\"] (nil coerced to \"\" %s \"\")", targets, tc.op)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestSwitch_MultiTargetTo verifies that Switch returns all cpn_ids
|
// TestSwitch_MultiTargetTo verifies that Switch returns all cpn_ids
|
||||||
// from a multi-element "to" field. This mirrors Python's behavior
|
// from a multi-element "to" field. This mirrors Python's behavior
|
||||||
// where a condition can route to multiple downstream nodes
|
// where a condition can route to multiple downstream nodes
|
||||||
|
|||||||
@@ -31,16 +31,22 @@ package component
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
einotool "github.com/cloudwego/eino/components/tool"
|
einotool "github.com/cloudwego/eino/components/tool"
|
||||||
|
|
||||||
|
"ragflow/internal/agent/runtime"
|
||||||
agenttool "ragflow/internal/agent/tool"
|
agenttool "ragflow/internal/agent/tool"
|
||||||
"ragflow/internal/common"
|
"ragflow/internal/common"
|
||||||
|
"ragflow/internal/dao"
|
||||||
|
"ragflow/internal/entity"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// tavilySearchComponent delegates to internal/agent/tool/TavilyTool.
|
// tavilySearchComponent delegates to internal/agent/tool/TavilyTool.
|
||||||
@@ -153,6 +159,8 @@ type retrievalComponent struct {
|
|||||||
params retrievalParams
|
params retrievalParams
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var legacyRetrievalQueryPattern = regexp.MustCompile(`(?s)^\s*UserFillUp:\s*(.*?)\s+Input\s+(.*?)\s*$`)
|
||||||
|
|
||||||
func newRetrievalComponent(params map[string]any) (Component, error) {
|
func newRetrievalComponent(params map[string]any) (Component, error) {
|
||||||
return &retrievalComponent{
|
return &retrievalComponent{
|
||||||
inner: agenttool.NewRetrievalTool(),
|
inner: agenttool.NewRetrievalTool(),
|
||||||
@@ -180,11 +188,19 @@ func (c *retrievalComponent) Outputs() map[string]string {
|
|||||||
|
|
||||||
func (c *retrievalComponent) Invoke(ctx context.Context, inputs map[string]any) (map[string]any, error) {
|
func (c *retrievalComponent) Invoke(ctx context.Context, inputs map[string]any) (map[string]any, error) {
|
||||||
merged := c.applyDefaults(inputs)
|
merged := c.applyDefaults(inputs)
|
||||||
|
normalizeLegacyRetrievalInputs(ctx, merged)
|
||||||
|
common.Debug("agent retrieval component: invoke",
|
||||||
|
zap.Any("inputs", inputs),
|
||||||
|
zap.Any("merged", merged),
|
||||||
|
)
|
||||||
argsJSON, _ := json.Marshal(merged)
|
argsJSON, _ := json.Marshal(merged)
|
||||||
out, err := c.inner.InvokableRun(ctx, string(argsJSON))
|
out, err := c.inner.InvokableRun(ctx, string(argsJSON))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("canvas: Retrieval: %w", err)
|
return nil, fmt.Errorf("canvas: Retrieval: %w", err)
|
||||||
}
|
}
|
||||||
|
common.Debug("agent retrieval component: output",
|
||||||
|
zap.String("tool_output", out),
|
||||||
|
)
|
||||||
return parseToolEnvelope(out), nil
|
return parseToolEnvelope(out), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,6 +277,148 @@ func (c *retrievalComponent) applyDefaults(inputs map[string]any) map[string]any
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeLegacyRetrievalInputs(ctx context.Context, out map[string]any) {
|
||||||
|
if normalizeStructuredRetrievalInputs(ctx, out) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rawQuery, _ := out["query"].(string)
|
||||||
|
rawQuery = strings.TrimSpace(rawQuery)
|
||||||
|
if rawQuery == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
matches := legacyRetrievalQueryPattern.FindStringSubmatch(rawQuery)
|
||||||
|
if len(matches) != 3 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
kbName := strings.TrimSpace(matches[1])
|
||||||
|
queryText := strings.TrimSpace(matches[2])
|
||||||
|
if queryText != "" {
|
||||||
|
out["query"] = queryText
|
||||||
|
}
|
||||||
|
if _, hasDatasetIDs := out["dataset_ids"]; hasDatasetIDs {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if kbName == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if datasetID := resolveRetrievalDatasetID(ctx, kbName); datasetID != "" {
|
||||||
|
out["dataset_ids"] = []string{datasetID}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeStructuredRetrievalInputs(ctx context.Context, out map[string]any) bool {
|
||||||
|
_, hasDatasetIDs := out["dataset_ids"]
|
||||||
|
candidateMaps := []map[string]any{}
|
||||||
|
if stateMap, ok := out["state"].(map[string]any); ok {
|
||||||
|
if raw, ok := stateMap["UserFillUp:KBInput"].(map[string]any); ok {
|
||||||
|
candidateMaps = append(candidateMaps, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
candidateMaps = append(candidateMaps, out)
|
||||||
|
|
||||||
|
consumed := false
|
||||||
|
for _, candidate := range candidateMaps {
|
||||||
|
kbName, _ := candidate["kb"].(string)
|
||||||
|
queryText, _ := candidate["query"].(string)
|
||||||
|
if kbName == "" && legacyRetrievalQueryPattern.MatchString(strings.TrimSpace(queryText)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if kbName == "" && queryText == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
consumed = true
|
||||||
|
if queryText != "" {
|
||||||
|
out["query"] = queryText
|
||||||
|
}
|
||||||
|
if kbName != "" && !hasDatasetIDs {
|
||||||
|
if datasetID := resolveRetrievalDatasetID(ctx, strings.TrimSpace(kbName)); datasetID != "" {
|
||||||
|
out["dataset_ids"] = []string{datasetID}
|
||||||
|
common.Debug("agent retrieval component: resolved dataset id",
|
||||||
|
zap.String("kb", strings.TrimSpace(kbName)),
|
||||||
|
zap.String("dataset_id", datasetID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if queryText != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if kbName != "" && out["dataset_ids"] != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return consumed
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveRetrievalDatasetID(ctx context.Context, kbName string) string {
|
||||||
|
if kbName == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if kb, err := dao.NewKnowledgebaseDAO().GetByID(kbName); err == nil && kb != nil {
|
||||||
|
common.Debug("agent retrieval component: resolved dataset id by direct id",
|
||||||
|
zap.String("kb", kbName),
|
||||||
|
zap.String("dataset_id", kb.ID))
|
||||||
|
return kb.ID
|
||||||
|
} else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
common.Warn("agent retrieval component: resolve dataset id by id failed",
|
||||||
|
zap.String("kb", kbName),
|
||||||
|
zap.Error(err))
|
||||||
|
}
|
||||||
|
if state, _, err := runtime.GetStateFromContext[*runtime.CanvasState](ctx); err == nil && state != nil {
|
||||||
|
common.Debug("agent retrieval component: resolve dataset id context",
|
||||||
|
zap.String("kb", kbName),
|
||||||
|
zap.Any("sys_query", state.Sys["query"]),
|
||||||
|
zap.Any("tenant_id", state.Sys["tenant_id"]),
|
||||||
|
zap.Any("user_id", state.Sys["user_id"]))
|
||||||
|
if tenantID, _ := state.Sys["tenant_id"].(string); tenantID != "" {
|
||||||
|
if kb, lookupErr := dao.NewKnowledgebaseDAO().GetByName(kbName, tenantID); lookupErr == nil && kb != nil {
|
||||||
|
common.Debug("agent retrieval component: resolved dataset id by tenant",
|
||||||
|
zap.String("kb", kbName),
|
||||||
|
zap.String("tenant_id", tenantID),
|
||||||
|
zap.String("dataset_id", kb.ID))
|
||||||
|
return kb.ID
|
||||||
|
} else if lookupErr != nil && !errors.Is(lookupErr, gorm.ErrRecordNotFound) {
|
||||||
|
common.Warn("agent retrieval component: resolve dataset id by tenant failed",
|
||||||
|
zap.String("kb", kbName),
|
||||||
|
zap.String("tenant_id", tenantID),
|
||||||
|
zap.Error(lookupErr))
|
||||||
|
} else {
|
||||||
|
common.Debug("agent retrieval component: tenant lookup missed",
|
||||||
|
zap.String("kb", kbName),
|
||||||
|
zap.String("tenant_id", tenantID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if userID, _ := state.Sys["user_id"].(string); userID != "" {
|
||||||
|
if kbs, lookupErr := dao.NewKnowledgebaseDAO().GetKBByNameAndUserID(kbName, userID); lookupErr == nil && len(kbs) > 0 {
|
||||||
|
for _, kb := range kbs {
|
||||||
|
if kb == nil || kb.Status == nil || *kb.Status != string(entity.StatusValid) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
common.Debug("agent retrieval component: resolved dataset id by user visibility",
|
||||||
|
zap.String("kb", kbName),
|
||||||
|
zap.String("user_id", userID),
|
||||||
|
zap.String("dataset_id", kb.ID))
|
||||||
|
return kb.ID
|
||||||
|
}
|
||||||
|
} else if lookupErr != nil {
|
||||||
|
common.Warn("agent retrieval component: resolve dataset id by name failed",
|
||||||
|
zap.String("kb", kbName),
|
||||||
|
zap.String("user_id", userID),
|
||||||
|
zap.Error(lookupErr))
|
||||||
|
} else {
|
||||||
|
common.Debug("agent retrieval component: user visibility lookup missed",
|
||||||
|
zap.String("kb", kbName),
|
||||||
|
zap.String("user_id", userID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
common.Debug("agent retrieval component: resolve dataset id missing canvas state",
|
||||||
|
zap.String("kb", kbName),
|
||||||
|
zap.Error(err))
|
||||||
|
}
|
||||||
|
common.Debug("agent retrieval component: dataset id unresolved",
|
||||||
|
zap.String("kb", kbName))
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// exesqlComponent delegates to internal/agent/tool/ExeSQLTool. The
|
// exesqlComponent delegates to internal/agent/tool/ExeSQLTool. The
|
||||||
// connection params (db_type, host, port, database, username,
|
// connection params (db_type, host, port, database, username,
|
||||||
// password) are passed via the canvas node's params map at build
|
// password) are passed via the canvas node's params map at build
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
// The Python provider uses the high-level `agentrun-sdk` Python
|
// The Python provider uses the high-level `agentrun-sdk` Python
|
||||||
// package, which exposes a `Sandbox` class with `create()` /
|
// package, which exposes a `Sandbox` class with `create()` /
|
||||||
// `connect()` / `context.execute()` / `delete_by_id()` methods.
|
// `connect()` / `context.execute()` / `delete_by_id()` methods.
|
||||||
// The Go SDK at v1.1.0 is the OpenAPI stub — it has lifecycle
|
// The Go SDK at v5.8.4 is the OpenAPI stub — it has lifecycle
|
||||||
// operations (CreateCodeInterpreter / DeleteCodeInterpreter /
|
// operations (CreateCodeInterpreter / DeleteCodeInterpreter /
|
||||||
// ListCodeInterpreters / GetCodeInterpreter) but does NOT expose
|
// ListCodeInterpreters / GetCodeInterpreter) but does NOT expose
|
||||||
// the execute endpoint.
|
// the execute endpoint.
|
||||||
@@ -48,8 +48,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/alibabacloud-go/agentrun-20250910/client"
|
"github.com/alibabacloud-go/agentrun-20250910/v5/client"
|
||||||
agentrun "github.com/alibabacloud-go/agentrun-20250910/client"
|
agentrun "github.com/alibabacloud-go/agentrun-20250910/v5/client"
|
||||||
openapiutil "github.com/alibabacloud-go/darabonba-openapi/v2/utils"
|
openapiutil "github.com/alibabacloud-go/darabonba-openapi/v2/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -213,7 +213,7 @@ func (p *AliyunCodeInterpreterProvider) CreateInstance(ctx context.Context, temp
|
|||||||
templateName = fmt.Sprintf("ragflow-%s-default", lang)
|
templateName = fmt.Sprintf("ragflow-%s-default", lang)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTE: Go SDK v1.1.0's CreateCodeInterpreterInput does not
|
// NOTE: Go SDK v5.8.4's CreateCodeInterpreterInput does not
|
||||||
// expose a TemplateName field. The Python SDK creates the
|
// expose a TemplateName field. The Python SDK creates the
|
||||||
// template via the high-level `Template.create()` API and
|
// template via the high-level `Template.create()` API and
|
||||||
// then references it from `Sandbox.create(template_name=...)`.
|
// then references it from `Sandbox.create(template_name=...)`.
|
||||||
|
|||||||
@@ -0,0 +1,312 @@
|
|||||||
|
//
|
||||||
|
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
//
|
||||||
|
|
||||||
|
package tool
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
neturl "net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/cloudwego/eino/components/tool"
|
||||||
|
"github.com/cloudwego/eino/schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
const keenableToolName = "keenable"
|
||||||
|
|
||||||
|
// keenableToolDescription follows the upstream Python tool's description,
|
||||||
|
// trimmed for the chat model. The "no API key required" line is the
|
||||||
|
// differentiator from Tavily/DuckDuckGo/SearXNG.
|
||||||
|
const keenableToolDescription = `Keenable is a web search API built for AI agents. It returns fresh, relevant web results for a query and works without an API key by default (keyless free tier). When searching:
|
||||||
|
- Use a focused query of the most important terms (and synonyms).
|
||||||
|
- Optionally restrict to a single site/domain.`
|
||||||
|
|
||||||
|
// keenableParams is the JSON shape the model sends into InvokableRun.
|
||||||
|
// site is an optional single-domain filter. mode is "pro" (default,
|
||||||
|
// deeper) or "realtime" (requires a server-configured key). top_n caps
|
||||||
|
// how many results we keep from the upstream `results` array.
|
||||||
|
type keenableParams struct {
|
||||||
|
Query string `json:"query"`
|
||||||
|
Site string `json:"site"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
TopN int `json:"top_n"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// keenableRequestBody is the JSON body POSTed to the Keenable search
|
||||||
|
// endpoint. Mirrors the upstream Python tool — query, mode, and an
|
||||||
|
// optional site filter.
|
||||||
|
type keenableRequestBody struct {
|
||||||
|
Query string `json:"query"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
Site string `json:"site,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// keenableResult mirrors one element of the upstream `results` array.
|
||||||
|
// The Python tool's _retrieve_chunks reads `title`, `url`, `description`,
|
||||||
|
// so we model those fields and pass everything else through verbatim
|
||||||
|
// when serializing to the model.
|
||||||
|
type keenableResult struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// keenableResponse is the envelope returned by Keenable. We only model
|
||||||
|
// the fields we care about; the upstream API has more, but they are
|
||||||
|
// ignored.
|
||||||
|
type keenableResponse struct {
|
||||||
|
Results []keenableResult `json:"results"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// keenableEnvelope is the shape the model actually sees, identical to
|
||||||
|
// the Python tool's output convention.
|
||||||
|
type keenableEnvelope struct {
|
||||||
|
Results []keenableResult `json:"results"`
|
||||||
|
Error string `json:"_ERROR,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeenableTool is the Keenable web search tool. It POSTs a search
|
||||||
|
// request to the public keyless endpoint by default and to the keyed
|
||||||
|
// endpoint (with X-API-Key) when an API key is provided. The upstream
|
||||||
|
// `results` array is returned as JSON.
|
||||||
|
type KeenableTool struct {
|
||||||
|
helper *HTTPHelper
|
||||||
|
apiKey string
|
||||||
|
|
||||||
|
// envBaseURL resolves the Keenable API base URL from the
|
||||||
|
// KEENABLE_API_URL env var (HTTPS enforced). Exposed as a
|
||||||
|
// function so tests can inject a fake without mutating process
|
||||||
|
// state — matches the envKey pattern used by TavilyTool.
|
||||||
|
envBaseURL func() string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewKeenableTool returns a KeenableTool using the default HTTPHelper
|
||||||
|
// and the KEENABLE_API_URL env var for base-URL resolution.
|
||||||
|
func NewKeenableTool() *KeenableTool {
|
||||||
|
return NewKeenableToolWith(NewHTTPHelper())
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewKeenableToolWithAPIKey returns a KeenableTool that uses a
|
||||||
|
// server-provided API key instead of model-visible runtime args.
|
||||||
|
func NewKeenableToolWithAPIKey(h *HTTPHelper, apiKey string) *KeenableTool {
|
||||||
|
t := NewKeenableToolWith(h)
|
||||||
|
t.apiKey = strings.TrimSpace(apiKey)
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewKeenableToolWith returns a KeenableTool that uses the provided
|
||||||
|
// HTTPHelper. Useful for tests that want to inject a custom transport.
|
||||||
|
func NewKeenableToolWith(h *HTTPHelper) *KeenableTool {
|
||||||
|
if h == nil {
|
||||||
|
h = NewHTTPHelper()
|
||||||
|
}
|
||||||
|
return &KeenableTool{helper: h, envBaseURL: defaultKeenableEnvBaseURL}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewKeenableToolWithEnvBaseURL returns a KeenableTool with a custom
|
||||||
|
// base-URL resolver. Useful for tests that want to inject a fake env
|
||||||
|
// without mutating process state.
|
||||||
|
func NewKeenableToolWithEnvBaseURL(h *HTTPHelper, envBaseURL func() string) *KeenableTool {
|
||||||
|
if h == nil {
|
||||||
|
h = NewHTTPHelper()
|
||||||
|
}
|
||||||
|
if envBaseURL == nil {
|
||||||
|
envBaseURL = defaultKeenableEnvBaseURL
|
||||||
|
}
|
||||||
|
return &KeenableTool{helper: h, envBaseURL: envBaseURL}
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultKeenableEnvBaseURL is the production base-URL resolver.
|
||||||
|
// Pulled out as a named function (not a var) so tests cannot
|
||||||
|
// accidentally mutate it via package-var assignment.
|
||||||
|
func defaultKeenableEnvBaseURL() string {
|
||||||
|
if v := strings.TrimSpace(os.Getenv("KEENABLE_API_URL")); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return "https://api.keenable.ai"
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveKeenableBaseURL returns the validated Keenable base URL.
|
||||||
|
// HTTPS is required for any non-loopback host; loopback hosts may
|
||||||
|
// use plain http for local development. Mirrors the Python tool's
|
||||||
|
// _base_url() guard — a misconfigured URL fails fast at request time
|
||||||
|
// rather than silently making a request to the wrong host.
|
||||||
|
func resolveKeenableBaseURL(raw string) (string, error) {
|
||||||
|
raw = strings.TrimRight(strings.TrimSpace(raw), "/")
|
||||||
|
if raw == "" {
|
||||||
|
return "", fmt.Errorf("keenable: empty base URL")
|
||||||
|
}
|
||||||
|
u, err := neturl.Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("keenable: parse KEENABLE_API_URL %q: %w", raw, err)
|
||||||
|
}
|
||||||
|
host := u.Hostname()
|
||||||
|
if host == "" {
|
||||||
|
return "", fmt.Errorf("keenable: KEENABLE_API_URL must have a host, got %q", raw)
|
||||||
|
}
|
||||||
|
if u.RawQuery != "" || u.Fragment != "" {
|
||||||
|
return "", fmt.Errorf("keenable: KEENABLE_API_URL must not include query or fragment, got %q", raw)
|
||||||
|
}
|
||||||
|
switch u.Scheme {
|
||||||
|
case "https":
|
||||||
|
return raw, nil
|
||||||
|
case "http":
|
||||||
|
if host == "localhost" || host == "127.0.0.1" || host == "::1" {
|
||||||
|
return raw, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("keenable: KEENABLE_API_URL must be https://, got %q", raw)
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("keenable: KEENABLE_API_URL scheme %q not allowed (https required)", u.Scheme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info returns the tool's metadata for the chat model. The description
|
||||||
|
// is the short prose above; the parameter schema lists the model-emitted
|
||||||
|
// fields with sane defaults documented inline.
|
||||||
|
func (k *KeenableTool) Info(_ context.Context) (*schema.ToolInfo, error) {
|
||||||
|
return &schema.ToolInfo{
|
||||||
|
Name: keenableToolName,
|
||||||
|
Desc: keenableToolDescription,
|
||||||
|
ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
|
||||||
|
"query": {
|
||||||
|
Type: schema.String,
|
||||||
|
Desc: "Search keywords to execute with Keenable. The most important words/terms (and synonyms) from the original request.",
|
||||||
|
Required: true,
|
||||||
|
},
|
||||||
|
"site": {
|
||||||
|
Type: schema.String,
|
||||||
|
Desc: "Optional. Restrict results to a single domain, e.g. 'techcrunch.com'. Defaults to '' (no filter).",
|
||||||
|
Required: false,
|
||||||
|
},
|
||||||
|
"mode": {
|
||||||
|
Type: schema.String,
|
||||||
|
Desc: `Search mode: "pro" (default, deeper) or "realtime" (low latency; requires a server-configured API key).`,
|
||||||
|
Required: false,
|
||||||
|
},
|
||||||
|
"top_n": {
|
||||||
|
Type: schema.Integer,
|
||||||
|
Desc: "Maximum number of results to return. Defaults to 10.",
|
||||||
|
Required: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvokableRun performs the Keenable search.
|
||||||
|
func (k *KeenableTool) InvokableRun(ctx context.Context, argsJSON string, _ ...tool.Option) (string, error) {
|
||||||
|
var p keenableParams
|
||||||
|
if err := json.Unmarshal([]byte(argsJSON), &p); err != nil {
|
||||||
|
return keenableErrJSON(fmt.Errorf("keenable: parse arguments: %w", err)),
|
||||||
|
fmt.Errorf("keenable: parse arguments: %w", err)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(p.Query) == "" {
|
||||||
|
return keenableErrJSON(fmt.Errorf("query is required")),
|
||||||
|
fmt.Errorf("keenable: query is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
mode := strings.TrimSpace(p.Mode)
|
||||||
|
if mode == "" {
|
||||||
|
mode = "pro"
|
||||||
|
}
|
||||||
|
if mode != "pro" && mode != "realtime" {
|
||||||
|
return keenableErrJSON(fmt.Errorf("keenable: mode %q must be one of [pro realtime]", p.Mode)),
|
||||||
|
fmt.Errorf("keenable: mode %q must be one of [pro realtime]", p.Mode)
|
||||||
|
}
|
||||||
|
// 'realtime' is only available on the keyed endpoint. Reject the
|
||||||
|
// invalid combination up front instead of letting the upstream
|
||||||
|
// return a confusing error — matches the Python tool's check().
|
||||||
|
if mode == "realtime" && strings.TrimSpace(k.apiKey) == "" {
|
||||||
|
return keenableErrJSON(fmt.Errorf("keenable: 'realtime' mode requires a configured api_key")),
|
||||||
|
fmt.Errorf("keenable: 'realtime' mode requires a configured api_key")
|
||||||
|
}
|
||||||
|
|
||||||
|
topN := p.TopN
|
||||||
|
if topN <= 0 {
|
||||||
|
topN = 10
|
||||||
|
}
|
||||||
|
|
||||||
|
baseURL, err := resolveKeenableBaseURL(k.envBaseURL())
|
||||||
|
if err != nil {
|
||||||
|
// Config/local error — won't be fixed by retrying, so fail fast
|
||||||
|
// (matches the Python tool's behavior for ValueError).
|
||||||
|
return keenableErrJSON(err), err
|
||||||
|
}
|
||||||
|
|
||||||
|
apiKey := strings.TrimSpace(k.apiKey)
|
||||||
|
path := "/v1/search/public"
|
||||||
|
headers := map[string]string{
|
||||||
|
"User-Agent": "keenable-ragflow",
|
||||||
|
"X-Keenable-Title": "RAGFlow",
|
||||||
|
}
|
||||||
|
if apiKey != "" {
|
||||||
|
path = "/v1/search"
|
||||||
|
headers["X-API-Key"] = apiKey
|
||||||
|
}
|
||||||
|
|
||||||
|
body := keenableRequestBody{
|
||||||
|
Query: p.Query,
|
||||||
|
Mode: mode,
|
||||||
|
}
|
||||||
|
if site := strings.TrimSpace(p.Site); site != "" {
|
||||||
|
body.Site = site
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyJSON, _ := json.Marshal(body)
|
||||||
|
|
||||||
|
resp, err := k.helper.Do(ctx,
|
||||||
|
http.MethodPost, baseURL+path, string(bodyJSON), "application/json", headers,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return keenableErrJSON(err), err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return keenableErrJSON(fmt.Errorf("keenable: upstream returned %d", resp.StatusCode)),
|
||||||
|
fmt.Errorf("keenable: upstream returned %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var raw keenableResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
|
||||||
|
return keenableErrJSON(fmt.Errorf("keenable: decode response: %w", err)),
|
||||||
|
fmt.Errorf("keenable: decode response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
results := raw.Results
|
||||||
|
if len(results) > topN {
|
||||||
|
results = results[:topN]
|
||||||
|
}
|
||||||
|
|
||||||
|
return keenableJSON(keenableEnvelope{Results: results}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// keenableJSON marshals the envelope to a JSON string for the model.
|
||||||
|
func keenableJSON(env keenableEnvelope) string {
|
||||||
|
b, err := json.Marshal(env)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf(`{"_ERROR":"keenable: marshal result: %s"}`, err)
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// keenableErrJSON wraps an error in the standard envelope.
|
||||||
|
func keenableErrJSON(err error) string {
|
||||||
|
return keenableJSON(keenableEnvelope{Error: err.Error()})
|
||||||
|
}
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
//
|
||||||
|
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
//
|
||||||
|
|
||||||
|
package tool
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestKeenable_KeylessPath verifies that when no api_key is supplied the
|
||||||
|
// tool POSTs to /v1/search/public with the attribution headers but
|
||||||
|
// without an X-API-Key header.
|
||||||
|
func TestKeenable_KeylessPath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var gotMethod, gotPath, gotUA, gotTitle, gotAPIKey, gotCT string
|
||||||
|
var gotBody map[string]any
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotMethod = r.Method
|
||||||
|
gotPath = r.URL.Path
|
||||||
|
gotUA = r.Header.Get("User-Agent")
|
||||||
|
gotTitle = r.Header.Get("X-Keenable-Title")
|
||||||
|
gotAPIKey = r.Header.Get("X-API-Key")
|
||||||
|
gotCT = r.Header.Get("Content-Type")
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"results":[]}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
helper := NewHTTPHelper().WithClient(&http.Client{
|
||||||
|
Transport: rewriteHostTransport(srv.URL),
|
||||||
|
})
|
||||||
|
tool := NewKeenableToolWithEnvBaseURL(helper, func() string { return "https://" + srv.URL[len("http://"):] })
|
||||||
|
|
||||||
|
if _, err := tool.InvokableRun(context.Background(), `{"query":"ragflow"}`); err != nil {
|
||||||
|
t.Fatalf("InvokableRun: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if gotMethod != http.MethodPost {
|
||||||
|
t.Errorf("method = %q, want POST", gotMethod)
|
||||||
|
}
|
||||||
|
if gotPath != "/v1/search/public" {
|
||||||
|
t.Errorf("path = %q, want /v1/search/public (keyless endpoint)", gotPath)
|
||||||
|
}
|
||||||
|
if gotUA != "keenable-ragflow" {
|
||||||
|
t.Errorf("User-Agent = %q, want keenable-ragflow", gotUA)
|
||||||
|
}
|
||||||
|
if gotTitle != "RAGFlow" {
|
||||||
|
t.Errorf("X-Keenable-Title = %q, want RAGFlow", gotTitle)
|
||||||
|
}
|
||||||
|
if gotAPIKey != "" {
|
||||||
|
t.Errorf("X-API-Key = %q, want empty on keyless path", gotAPIKey)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(gotCT, "application/json") {
|
||||||
|
t.Errorf("Content-Type = %q, want application/json", gotCT)
|
||||||
|
}
|
||||||
|
if gotBody["query"] != "ragflow" {
|
||||||
|
t.Errorf("body.query = %v, want ragflow", gotBody["query"])
|
||||||
|
}
|
||||||
|
if gotBody["mode"] != "pro" {
|
||||||
|
t.Errorf("body.mode = %v, want pro (default)", gotBody["mode"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestKeenable_KeyedPath verifies that a server-configured api_key
|
||||||
|
// switches the tool to the /v1/search endpoint and sets X-API-Key on
|
||||||
|
// the request.
|
||||||
|
func TestKeenable_KeyedPath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var gotPath, gotAPIKey string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotPath = r.URL.Path
|
||||||
|
gotAPIKey = r.Header.Get("X-API-Key")
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"results":[]}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
helper := NewHTTPHelper().WithClient(&http.Client{
|
||||||
|
Transport: rewriteHostTransport(srv.URL),
|
||||||
|
})
|
||||||
|
tool := NewKeenableToolWithAPIKey(helper, "key-xyz")
|
||||||
|
tool.envBaseURL = func() string { return "https://" + srv.URL[len("http://"):] }
|
||||||
|
|
||||||
|
if _, err := tool.InvokableRun(context.Background(),
|
||||||
|
`{"query":"ragflow","mode":"realtime"}`); err != nil {
|
||||||
|
t.Fatalf("InvokableRun: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if gotPath != "/v1/search" {
|
||||||
|
t.Errorf("path = %q, want /v1/search (keyed endpoint)", gotPath)
|
||||||
|
}
|
||||||
|
if gotAPIKey != "key-xyz" {
|
||||||
|
t.Errorf("X-API-Key = %q, want key-xyz", gotAPIKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestKeenable_SiteAndTopN verifies the site filter is forwarded and
|
||||||
|
// that the result list is truncated to top_n.
|
||||||
|
func TestKeenable_SiteAndTopN(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var gotBody map[string]any
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"results":[
|
||||||
|
{"title":"A","url":"https://a","description":"alpha"},
|
||||||
|
{"title":"B","url":"https://b","description":"beta"},
|
||||||
|
{"title":"C","url":"https://c","description":"gamma"},
|
||||||
|
{"title":"D","url":"https://d","description":"delta"}
|
||||||
|
]}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
helper := NewHTTPHelper().WithClient(&http.Client{
|
||||||
|
Transport: rewriteHostTransport(srv.URL),
|
||||||
|
})
|
||||||
|
tool := NewKeenableToolWithEnvBaseURL(helper, func() string { return "https://" + srv.URL[len("http://"):] })
|
||||||
|
|
||||||
|
out, err := tool.InvokableRun(context.Background(),
|
||||||
|
`{"query":"x","site":"example.com","top_n":2}`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("InvokableRun: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if gotBody["site"] != "example.com" {
|
||||||
|
t.Errorf("body.site = %v, want example.com", gotBody["site"])
|
||||||
|
}
|
||||||
|
|
||||||
|
var env keenableEnvelope
|
||||||
|
if jerr := json.Unmarshal([]byte(out), &env); jerr != nil {
|
||||||
|
t.Fatalf("output not valid JSON: %v (raw=%s)", jerr, out)
|
||||||
|
}
|
||||||
|
if env.Error != "" {
|
||||||
|
t.Errorf("Error = %q, want empty", env.Error)
|
||||||
|
}
|
||||||
|
if len(env.Results) != 2 {
|
||||||
|
t.Fatalf("Results len = %d, want 2 (capped by top_n)", len(env.Results))
|
||||||
|
}
|
||||||
|
if env.Results[0].Title != "A" || env.Results[1].Title != "B" {
|
||||||
|
t.Errorf("Results = %+v, want first 2 upstream items", env.Results)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestKeenable_DefaultTopN verifies that omitting top_n keeps up to 10
|
||||||
|
// results from the upstream response (the default in the Python tool).
|
||||||
|
func TestKeenable_DefaultTopN(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// 12 results; default top_n is 10, so we expect 10 in the envelope.
|
||||||
|
var results []map[string]string
|
||||||
|
for range 12 {
|
||||||
|
results = append(results, map[string]string{
|
||||||
|
"title": "T",
|
||||||
|
"url": "https://u",
|
||||||
|
"description": "d",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(map[string]any{"results": results})
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write(b)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
helper := NewHTTPHelper().WithClient(&http.Client{
|
||||||
|
Transport: rewriteHostTransport(srv.URL),
|
||||||
|
})
|
||||||
|
tool := NewKeenableToolWithEnvBaseURL(helper, func() string { return "https://" + srv.URL[len("http://"):] })
|
||||||
|
|
||||||
|
out, err := tool.InvokableRun(context.Background(), `{"query":"x"}`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("InvokableRun: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var env keenableEnvelope
|
||||||
|
if jerr := json.Unmarshal([]byte(out), &env); jerr != nil {
|
||||||
|
t.Fatalf("output not valid JSON: %v", jerr)
|
||||||
|
}
|
||||||
|
if len(env.Results) != 10 {
|
||||||
|
t.Errorf("Results len = %d, want 10 (default top_n)", len(env.Results))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestKeenable_MissingQuery verifies that an empty query is rejected
|
||||||
|
// before any HTTP request is made.
|
||||||
|
func TestKeenable_MissingQuery(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tool := NewKeenableTool()
|
||||||
|
_, err := tool.InvokableRun(context.Background(), `{}`)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing query")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "query") {
|
||||||
|
t.Errorf("err = %v, want to mention query", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestKeenable_RealtimeRequiresAPIKey verifies the config-time rejection
|
||||||
|
// of realtime mode without a configured api_key.
|
||||||
|
func TestKeenable_RealtimeRequiresAPIKey(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tool := NewKeenableTool()
|
||||||
|
_, err := tool.InvokableRun(context.Background(), `{"query":"x","mode":"realtime"}`)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for realtime mode without api_key")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "configured api_key") {
|
||||||
|
t.Errorf("err = %v, want to mention configured api_key", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestKeenable_InvalidMode verifies that an unknown mode is rejected
|
||||||
|
// up front instead of being forwarded to the upstream.
|
||||||
|
func TestKeenable_InvalidMode(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tool := NewKeenableTool()
|
||||||
|
_, err := tool.InvokableRun(context.Background(), `{"query":"x","mode":"bogus"}`)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid mode")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "mode") {
|
||||||
|
t.Errorf("err = %v, want to mention mode", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestKeenable_ResolveBaseURL exercises the HTTPS-only / loopback-http
|
||||||
|
// guard around KEENABLE_API_URL.
|
||||||
|
func TestKeenable_ResolveBaseURL(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
raw string
|
||||||
|
wantOK bool
|
||||||
|
wantValue string
|
||||||
|
}{
|
||||||
|
{"https default", "https://api.keenable.ai", true, "https://api.keenable.ai"},
|
||||||
|
{"https trailing slash", "https://api.keenable.ai/", true, "https://api.keenable.ai"},
|
||||||
|
{"http loopback ok", "http://localhost:8080", true, "http://localhost:8080"},
|
||||||
|
{"http 127 ok", "http://127.0.0.1:8080", true, "http://127.0.0.1:8080"},
|
||||||
|
{"http ::1 ok", "http://[::1]:8080", true, "http://[::1]:8080"},
|
||||||
|
{"http non-loopback rejected", "http://example.com", false, ""},
|
||||||
|
{"ftp rejected", "ftp://api.keenable.ai", false, ""},
|
||||||
|
{"query rejected", "https://api.keenable.ai?x=1", false, ""},
|
||||||
|
{"fragment rejected", "https://api.keenable.ai#frag", false, ""},
|
||||||
|
{"no host rejected", "https:///path", false, ""},
|
||||||
|
{"empty rejected", "", false, ""},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
got, err := resolveKeenableBaseURL(tc.raw)
|
||||||
|
if tc.wantOK {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("err = %v, want nil", err)
|
||||||
|
}
|
||||||
|
if got != tc.wantValue {
|
||||||
|
t.Errorf("got = %q, want %q", got, tc.wantValue)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("got = %q, want error", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestKeenable_BaseURLFromEnv verifies that the KEENABLE_API_URL env var
|
||||||
|
// is honored. We use a fake resolver that does NOT touch os.Getenv so
|
||||||
|
// the test does not depend on the host environment.
|
||||||
|
func TestKeenable_BaseURLFromEnv(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var gotPath string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotPath = r.URL.Path
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"results":[]}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
helper := NewHTTPHelper().WithClient(&http.Client{
|
||||||
|
Transport: rewriteHostTransport(srv.URL),
|
||||||
|
})
|
||||||
|
tool := NewKeenableToolWithEnvBaseURL(helper, func() string {
|
||||||
|
return "https://" + srv.URL[len("http://"):]
|
||||||
|
})
|
||||||
|
|
||||||
|
if _, err := tool.InvokableRun(context.Background(), `{"query":"x"}`); err != nil {
|
||||||
|
t.Fatalf("InvokableRun: %v", err)
|
||||||
|
}
|
||||||
|
if gotPath != "/v1/search/public" {
|
||||||
|
t.Errorf("path = %q, want /v1/search/public", gotPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestKeenable_BadBaseURL verifies that an invalid KEENABLE_API_URL is
|
||||||
|
// reported back to the caller instead of being silently sent.
|
||||||
|
func TestKeenable_BadBaseURL(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tool := NewKeenableToolWithEnvBaseURL(NewHTTPHelper(), func() string { return "http://example.com" })
|
||||||
|
_, err := tool.InvokableRun(context.Background(), `{"query":"x"}`)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for non-https non-loopback base URL")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "https") {
|
||||||
|
t.Errorf("err = %v, want to mention https requirement", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestKeenable_UpstreamError verifies that a non-2xx upstream response
|
||||||
|
// is surfaced as an error and an _ERROR envelope.
|
||||||
|
func TestKeenable_UpstreamError(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, "boom", http.StatusInternalServerError)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
helper := NewHTTPHelper().WithClient(&http.Client{
|
||||||
|
Transport: rewriteHostTransport(srv.URL),
|
||||||
|
})
|
||||||
|
tool := NewKeenableToolWithEnvBaseURL(helper, func() string { return "https://" + srv.URL[len("http://"):] })
|
||||||
|
|
||||||
|
out, err := tool.InvokableRun(context.Background(), `{"query":"x"}`)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for 5xx response")
|
||||||
|
}
|
||||||
|
var env keenableEnvelope
|
||||||
|
if jerr := json.Unmarshal([]byte(out), &env); jerr != nil {
|
||||||
|
t.Fatalf("output not valid JSON: %v (raw=%s)", jerr, out)
|
||||||
|
}
|
||||||
|
if env.Error == "" {
|
||||||
|
t.Errorf("envelope Error = %q, want non-empty", env.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestKeenable_Info verifies the model-facing metadata.
|
||||||
|
func TestKeenable_Info(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tool := NewKeenableTool()
|
||||||
|
info, err := tool.Info(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Info: %v", err)
|
||||||
|
}
|
||||||
|
if info.Name != "keenable" {
|
||||||
|
t.Errorf("Name = %q, want keenable", info.Name)
|
||||||
|
}
|
||||||
|
if !strings.Contains(info.Desc, "Keenable") {
|
||||||
|
t.Errorf("Desc = %q, want to mention Keenable", info.Desc)
|
||||||
|
}
|
||||||
|
if info.ParamsOneOf == nil {
|
||||||
|
t.Fatal("ParamsOneOf = nil, want schema definition")
|
||||||
|
}
|
||||||
|
paramsJSON, err := json.Marshal(info.ParamsOneOf)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal ParamsOneOf: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(paramsJSON), "api_key") {
|
||||||
|
t.Fatalf("Info ParamsOneOf unexpectedly exposes api_key: %s", string(paramsJSON))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,7 +31,6 @@ func TestQWeather_BuildURL(t *testing.T) {
|
|||||||
// `qweatherEndpoint` var, which other tests (running in parallel)
|
// `qweatherEndpoint` var, which other tests (running in parallel)
|
||||||
// temporarily replace with a httptest.Server URL.
|
// temporarily replace with a httptest.Server URL.
|
||||||
|
|
||||||
|
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
params qweatherParams
|
params qweatherParams
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ var registry = map[string]Factory{
|
|||||||
"google": noConfig("google", func() einotool.BaseTool { return NewGoogleTool() }),
|
"google": noConfig("google", func() einotool.BaseTool { return NewGoogleTool() }),
|
||||||
"google_scholar": noConfig("google_scholar", func() einotool.BaseTool { return NewGoogleScholarTool() }),
|
"google_scholar": noConfig("google_scholar", func() einotool.BaseTool { return NewGoogleScholarTool() }),
|
||||||
"jin10": noConfig("jin10", func() einotool.BaseTool { return NewJin10Tool() }),
|
"jin10": noConfig("jin10", func() einotool.BaseTool { return NewJin10Tool() }),
|
||||||
|
"keenable": buildKeenableTool,
|
||||||
"pubmed": noConfig("pubmed", func() einotool.BaseTool { return NewPubMedTool() }),
|
"pubmed": noConfig("pubmed", func() einotool.BaseTool { return NewPubMedTool() }),
|
||||||
"qweather": noConfig("qweather", func() einotool.BaseTool { return NewQWeatherTool() }),
|
"qweather": noConfig("qweather", func() einotool.BaseTool { return NewQWeatherTool() }),
|
||||||
"retrieval": noConfig("retrieval", func() einotool.BaseTool { return NewRetrievalTool() }),
|
"retrieval": noConfig("retrieval", func() einotool.BaseTool { return NewRetrievalTool() }),
|
||||||
@@ -112,6 +113,22 @@ func buildExeSQLTool(params map[string]any) (einotool.BaseTool, error) {
|
|||||||
return NewExeSQLTool(conn), nil
|
return NewExeSQLTool(conn), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildKeenableTool(params map[string]any) (einotool.BaseTool, error) {
|
||||||
|
if len(params) == 0 {
|
||||||
|
return NewKeenableTool(), nil
|
||||||
|
}
|
||||||
|
for key := range params {
|
||||||
|
if key != "api_key" {
|
||||||
|
return nil, fmt.Errorf("agent tool: tool %q only accepts node-level param api_key", "keenable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
apiKey, ok := params["api_key"].(string)
|
||||||
|
if !ok || strings.TrimSpace(apiKey) == "" {
|
||||||
|
return nil, fmt.Errorf("agent tool: tool %q requires non-empty string node-level param api_key", "keenable")
|
||||||
|
}
|
||||||
|
return NewKeenableToolWithAPIKey(nil, apiKey), nil
|
||||||
|
}
|
||||||
|
|
||||||
func decodeExeSQLConnParams(params map[string]any) (exesqlConnParams, error) {
|
func decodeExeSQLConnParams(params map[string]any) (exesqlConnParams, error) {
|
||||||
if len(params) == 0 {
|
if len(params) == 0 {
|
||||||
return exesqlConnParams{}, fmt.Errorf(
|
return exesqlConnParams{}, fmt.Errorf(
|
||||||
|
|||||||
@@ -49,12 +49,12 @@ func TestBuildAll_AllRegisteredTools(t *testing.T) {
|
|||||||
}
|
}
|
||||||
params := map[string]map[string]any{
|
params := map[string]map[string]any{
|
||||||
"execute_sql": {
|
"execute_sql": {
|
||||||
"db_type": "mysql",
|
"db_type": "mysql",
|
||||||
"host": "127.0.0.1",
|
"host": "127.0.0.1",
|
||||||
"port": 3306,
|
"port": 3306,
|
||||||
"database": "demo",
|
"database": "demo",
|
||||||
"username": "u",
|
"username": "u",
|
||||||
"password": "p",
|
"password": "p",
|
||||||
"max_records": 10,
|
"max_records": 10,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -77,6 +77,18 @@ func TestBuildAll_ExeSQLRequiresNodeParams(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildAll_KeenableRejectsEmptyNodeAPIKey(t *testing.T) {
|
||||||
|
_, err := BuildAll([]string{"keenable"}, map[string]map[string]any{
|
||||||
|
"keenable": {"api_key": ""},
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected keenable config error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "requires non-empty string node-level param api_key") {
|
||||||
|
t.Fatalf("err = %q, want keenable api_key validation error", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestToolRegistry_SchemasAreComplete sweeps every name the public
|
// TestToolRegistry_SchemasAreComplete sweeps every name the public
|
||||||
// registry advertises (including the execute_sql/exesql and
|
// registry advertises (including the execute_sql/exesql and
|
||||||
// retrieval/search_my_dateset alias pairs), builds the tool, and
|
// retrieval/search_my_dateset alias pairs), builds the tool, and
|
||||||
@@ -95,7 +107,7 @@ func TestToolRegistry_SchemasAreComplete(t *testing.T) {
|
|||||||
names := []string{
|
names := []string{
|
||||||
"akshare", "arxiv", "code_exec", "crawler", "deepl", "duckduckgo",
|
"akshare", "arxiv", "code_exec", "crawler", "deepl", "duckduckgo",
|
||||||
"email", "execute_sql", "exesql", "github", "google",
|
"email", "execute_sql", "exesql", "github", "google",
|
||||||
"google_scholar", "jin10", "pubmed", "qweather", "retrieval",
|
"google_scholar", "jin10", "keenable", "pubmed", "qweather", "retrieval",
|
||||||
"search_my_dateset", "searxng", "tavily", "tushare", "wencai",
|
"search_my_dateset", "searxng", "tavily", "tushare", "wencai",
|
||||||
"wikipedia", "yahoo_finance",
|
"wikipedia", "yahoo_finance",
|
||||||
}
|
}
|
||||||
@@ -118,6 +130,9 @@ func TestToolRegistry_SchemasAreComplete(t *testing.T) {
|
|||||||
"password": "p",
|
"password": "p",
|
||||||
"max_records": 10,
|
"max_records": 10,
|
||||||
},
|
},
|
||||||
|
"keenable": {
|
||||||
|
"api_key": "key-xyz",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
tools, err := BuildAll(names, params)
|
tools, err := BuildAll(names, params)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -150,9 +165,9 @@ func TestToolRegistry_SchemasAreComplete(t *testing.T) {
|
|||||||
// search_my_dateset. A bug here would mean an alias was
|
// search_my_dateset. A bug here would mean an alias was
|
||||||
// accidentally pointed at a different tool.
|
// accidentally pointed at a different tool.
|
||||||
canonicalByAlias := map[string]string{
|
canonicalByAlias := map[string]string{
|
||||||
"execute_sql": "execute_sql",
|
"execute_sql": "execute_sql",
|
||||||
"exesql": "execute_sql",
|
"exesql": "execute_sql",
|
||||||
"retrieval": "search_my_dateset",
|
"retrieval": "search_my_dateset",
|
||||||
"search_my_dateset": "search_my_dateset",
|
"search_my_dateset": "search_my_dateset",
|
||||||
}
|
}
|
||||||
for _, name := range names {
|
for _, name := range names {
|
||||||
|
|||||||
@@ -25,8 +25,10 @@ import (
|
|||||||
|
|
||||||
"github.com/cloudwego/eino/components/tool"
|
"github.com/cloudwego/eino/components/tool"
|
||||||
"github.com/cloudwego/eino/schema"
|
"github.com/cloudwego/eino/schema"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"ragflow/internal/agent/runtime"
|
"ragflow/internal/agent/runtime"
|
||||||
|
"ragflow/internal/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrGraphRAGNotSupported is returned by the Retrieval tool when
|
// ErrGraphRAGNotSupported is returned by the Retrieval tool when
|
||||||
@@ -134,6 +136,12 @@ func (r *RetrievalTool) InvokableRun(ctx context.Context, argumentsInJSON string
|
|||||||
return "", fmt.Errorf("retrieval: parse arguments: %w", err)
|
return "", fmt.Errorf("retrieval: parse arguments: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
common.Debug("agent retrieval tool: parsed arguments",
|
||||||
|
zap.String("query", args.Query),
|
||||||
|
zap.Strings("dataset_ids", args.DatasetIDs),
|
||||||
|
zap.Int("top_n", args.TopN),
|
||||||
|
zap.Bool("use_kg", args.UseKG),
|
||||||
|
)
|
||||||
|
|
||||||
if args.UseKG {
|
if args.UseKG {
|
||||||
// Plan + §9 Q3: GraphRAG is out of scope for the Go
|
// Plan + §9 Q3: GraphRAG is out of scope for the Go
|
||||||
@@ -166,6 +174,9 @@ func (r *RetrievalTool) InvokableRun(ctx context.Context, argumentsInJSON string
|
|||||||
Error: err.Error(),
|
Error: err.Error(),
|
||||||
}), err
|
}), err
|
||||||
}
|
}
|
||||||
|
common.Debug("agent retrieval tool: search result",
|
||||||
|
zap.Int("chunks_count", len(chunks)),
|
||||||
|
)
|
||||||
// Map the chunks into the result envelope. The retrievalResult
|
// Map the chunks into the result envelope. The retrievalResult
|
||||||
// type carries the eino-tool envelope shape (chunkPayload, not
|
// type carries the eino-tool envelope shape (chunkPayload, not
|
||||||
// RetrievalChunk), so we translate.
|
// RetrievalChunk), so we translate.
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ import (
|
|||||||
|
|
||||||
"ragflow/internal/dao"
|
"ragflow/internal/dao"
|
||||||
"ragflow/internal/engine"
|
"ragflow/internal/engine"
|
||||||
|
"ragflow/internal/entity"
|
||||||
"ragflow/internal/service/nlp"
|
"ragflow/internal/service/nlp"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -71,13 +72,21 @@ import (
|
|||||||
// beyond its docEngine + documentDAO handles, both of which the
|
// beyond its docEngine + documentDAO handles, both of which the
|
||||||
// nlp package treats as concurrent-safe.
|
// nlp package treats as concurrent-safe.
|
||||||
type NLPRetrievalAdapter struct {
|
type NLPRetrievalAdapter struct {
|
||||||
svc *nlp.RetrievalService
|
svc *nlp.RetrievalService
|
||||||
|
kbDAO knowledgebaseLookup
|
||||||
|
}
|
||||||
|
|
||||||
|
type knowledgebaseLookup interface {
|
||||||
|
GetByIDs(ids []string) ([]*entity.Knowledgebase, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewNLPRetrievalAdapter wraps an already-constructed
|
// NewNLPRetrievalAdapter wraps an already-constructed
|
||||||
// *nlp.RetrievalService.
|
// *nlp.RetrievalService.
|
||||||
func NewNLPRetrievalAdapter(svc *nlp.RetrievalService) *NLPRetrievalAdapter {
|
func NewNLPRetrievalAdapter(svc *nlp.RetrievalService) *NLPRetrievalAdapter {
|
||||||
return &NLPRetrievalAdapter{svc: svc}
|
return &NLPRetrievalAdapter{
|
||||||
|
svc: svc,
|
||||||
|
kbDAO: dao.NewKnowledgebaseDAO(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewNLPRetrievalAdapterFromDeps is the convenience constructor
|
// NewNLPRetrievalAdapterFromDeps is the convenience constructor
|
||||||
@@ -88,7 +97,10 @@ func NewNLPRetrievalAdapter(svc *nlp.RetrievalService) *NLPRetrievalAdapter {
|
|||||||
// matches chat_session.go's newChatSessionServiceWithRetrieval
|
// matches chat_session.go's newChatSessionServiceWithRetrieval
|
||||||
// call site.
|
// call site.
|
||||||
func NewNLPRetrievalAdapterFromDeps(docEngine engine.DocEngine, documentDAO *dao.DocumentDAO) *NLPRetrievalAdapter {
|
func NewNLPRetrievalAdapterFromDeps(docEngine engine.DocEngine, documentDAO *dao.DocumentDAO) *NLPRetrievalAdapter {
|
||||||
return &NLPRetrievalAdapter{svc: nlp.NewRetrievalService(docEngine, documentDAO)}
|
return &NLPRetrievalAdapter{
|
||||||
|
svc: nlp.NewRetrievalService(docEngine, documentDAO),
|
||||||
|
kbDAO: dao.NewKnowledgebaseDAO(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search implements RetrievalService. The translation rules live
|
// Search implements RetrievalService. The translation rules live
|
||||||
@@ -120,6 +132,7 @@ func (a *NLPRetrievalAdapter) Search(ctx context.Context, req RetrievalRequest)
|
|||||||
// headroom — matches the chat_session.go call pattern).
|
// headroom — matches the chat_session.go call pattern).
|
||||||
nlpReq := &nlp.RetrievalRequest{
|
nlpReq := &nlp.RetrievalRequest{
|
||||||
Question: req.Query,
|
Question: req.Query,
|
||||||
|
TenantIDs: a.resolveTenantIDs(req),
|
||||||
KbIDs: append([]string(nil), req.DatasetIDs...),
|
KbIDs: append([]string(nil), req.DatasetIDs...),
|
||||||
Page: 1,
|
Page: 1,
|
||||||
PageSize: topN,
|
PageSize: topN,
|
||||||
@@ -148,6 +161,24 @@ func (a *NLPRetrievalAdapter) Search(ctx context.Context, req RetrievalRequest)
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *NLPRetrievalAdapter) resolveTenantIDs(req RetrievalRequest) []string {
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
tenantIDs := make([]string, 0, 1)
|
||||||
|
appendTenantID := func(tenantID string) {
|
||||||
|
if tenantID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := seen[tenantID]; ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[tenantID] = struct{}{}
|
||||||
|
tenantIDs = append(tenantIDs, tenantID)
|
||||||
|
}
|
||||||
|
|
||||||
|
appendTenantID(req.TenantID)
|
||||||
|
return tenantIDs
|
||||||
|
}
|
||||||
|
|
||||||
// translateChunk converts one nlp chunk map into a RetrievalChunk.
|
// translateChunk converts one nlp chunk map into a RetrievalChunk.
|
||||||
// Tolerates missing fields (returns zero values) and wrong types
|
// Tolerates missing fields (returns zero values) and wrong types
|
||||||
// (returns zero values) so a single bad chunk from the doc engine
|
// (returns zero values) so a single bad chunk from the doc engine
|
||||||
|
|||||||
@@ -207,3 +207,18 @@ func TestNewNLPRetrievalAdapter_NilService(t *testing.T) {
|
|||||||
t.Errorf("err = %v, want ErrRetrievalServiceMissing", err)
|
t.Errorf("err = %v, want ErrRetrievalServiceMissing", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNLPRetrievalAdapter_ResolveTenantIDsStaysWithinRequestTenant(t *testing.T) {
|
||||||
|
a := &NLPRetrievalAdapter{}
|
||||||
|
got := a.resolveTenantIDs(RetrievalRequest{
|
||||||
|
TenantID: "tenant-a",
|
||||||
|
DatasetIDs: []string{"kb-1", "kb-2", "kb-missing"},
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("tenantIDs len=%d want 1, got=%v", len(got), got)
|
||||||
|
}
|
||||||
|
if got[0] != "tenant-a" {
|
||||||
|
t.Fatalf("tenantIDs=%v want [tenant-a]", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
// a normal workflow node. See the .claude/plans/eino-workflow-loop.md
|
// a normal workflow node. See the .claude/plans/eino-workflow-loop.md
|
||||||
// plan for the design rationale.
|
// plan for the design rationale.
|
||||||
//
|
//
|
||||||
// Foundation for the canvas Loop component
|
// # Foundation for the canvas Loop component
|
||||||
//
|
//
|
||||||
// AddLoopNode is also the runtime driver for the RAGFlow agent canvas's
|
// AddLoopNode is also the runtime driver for the RAGFlow agent canvas's
|
||||||
// "Loop" component (internal/agent/component/loop.go). The canvas engine
|
// "Loop" component (internal/agent/component/loop.go). The canvas engine
|
||||||
@@ -141,11 +141,11 @@ var (
|
|||||||
type LoopOption func(*loopOptions)
|
type LoopOption func(*loopOptions)
|
||||||
|
|
||||||
type loopOptions struct {
|
type loopOptions struct {
|
||||||
maxIterations int
|
maxIterations int
|
||||||
compileOpts []compose.GraphCompileOption
|
compileOpts []compose.GraphCompileOption
|
||||||
runOpts []compose.Option
|
runOpts []compose.Option
|
||||||
streamMode LoopStreamMode
|
streamMode LoopStreamMode
|
||||||
checkpointBuilder func(nodeKey string, iteration int) string
|
checkpointBuilder func(nodeKey string, iteration int) string
|
||||||
enableSubCheckpoint bool
|
enableSubCheckpoint bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,8 +237,8 @@ func defaultCheckpointBuilder(nodeKey string, iteration int) string {
|
|||||||
|
|
||||||
func getLoopOptions(opts []LoopOption) *loopOptions {
|
func getLoopOptions(opts []LoopOption) *loopOptions {
|
||||||
o := &loopOptions{
|
o := &loopOptions{
|
||||||
streamMode: LoopStreamFinalOnly,
|
streamMode: LoopStreamFinalOnly,
|
||||||
checkpointBuilder: defaultCheckpointBuilder,
|
checkpointBuilder: defaultCheckpointBuilder,
|
||||||
enableSubCheckpoint: true,
|
enableSubCheckpoint: true,
|
||||||
}
|
}
|
||||||
for _, opt := range opts {
|
for _, opt := range opts {
|
||||||
@@ -260,12 +260,12 @@ func getLoopOptions(opts []LoopOption) *loopOptions {
|
|||||||
// by the loop itself) sidesteps the need for callers to register
|
// by the loop itself) sidesteps the need for callers to register
|
||||||
// generic types with the schema package — see plan §"Type shape".
|
// generic types with the schema package — see plan §"Type shape".
|
||||||
type loopInterruptState struct {
|
type loopInterruptState struct {
|
||||||
Iteration int `json:"iteration"`
|
Iteration int `json:"iteration"`
|
||||||
CurrentInput []byte `json:"current_input"`
|
CurrentInput []byte `json:"current_input"`
|
||||||
StreamMode LoopStreamMode `json:"stream_mode"`
|
StreamMode LoopStreamMode `json:"stream_mode"`
|
||||||
SubCheckpointID string `json:"sub_checkpoint_id"`
|
SubCheckpointID string `json:"sub_checkpoint_id"`
|
||||||
SubCheckpoints map[string][]byte `json:"sub_checkpoints,omitempty"`
|
SubCheckpoints map[string][]byte `json:"sub_checkpoints,omitempty"`
|
||||||
ReplayChunks [][]byte `json:"replay_chunks,omitempty"`
|
ReplayChunks [][]byte `json:"replay_chunks,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddLoopNode appends a loop node to the outer workflow `wf`. The
|
// AddLoopNode appends a loop node to the outer workflow `wf`. The
|
||||||
@@ -375,15 +375,15 @@ func loadLoopSnapshot[T any](ctx context.Context, defaultMode LoopStreamMode) (l
|
|||||||
if streamMode == "" {
|
if streamMode == "" {
|
||||||
streamMode = defaultMode
|
streamMode = defaultMode
|
||||||
}
|
}
|
||||||
return loopSnapshot{
|
return loopSnapshot{
|
||||||
startIteration: st.Iteration,
|
startIteration: st.Iteration,
|
||||||
current: st.CurrentInput,
|
current: st.CurrentInput,
|
||||||
streamMode: streamMode,
|
streamMode: streamMode,
|
||||||
subCheckID: st.SubCheckpointID,
|
subCheckID: st.SubCheckpointID,
|
||||||
subCheckpoints: cloneCheckpointMap(st.SubCheckpoints),
|
subCheckpoints: cloneCheckpointMap(st.SubCheckpoints),
|
||||||
replayChunks: cloneByteSlices(st.ReplayChunks),
|
replayChunks: cloneByteSlices(st.ReplayChunks),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// encodeState marshals a loop snapshot to the persisted form.
|
// encodeState marshals a loop snapshot to the persisted form.
|
||||||
func encodeState(s loopSnapshot) ([]byte, error) {
|
func encodeState(s loopSnapshot) ([]byte, error) {
|
||||||
|
|||||||
@@ -313,9 +313,9 @@ func TestOptions_CompileFailureIsolated(t *testing.T) {
|
|||||||
func TestOptions_SentinelErrorsExist(t *testing.T) {
|
func TestOptions_SentinelErrorsExist(t *testing.T) {
|
||||||
sentinels := map[string]error{
|
sentinels := map[string]error{
|
||||||
"ErrLoopMaxIterationsExceeded": ErrLoopMaxIterationsExceeded,
|
"ErrLoopMaxIterationsExceeded": ErrLoopMaxIterationsExceeded,
|
||||||
"ErrLoopSubGraphInterrupted": ErrLoopSubGraphInterrupted,
|
"ErrLoopSubGraphInterrupted": ErrLoopSubGraphInterrupted,
|
||||||
"ErrLoopResumeStateInvalid": ErrLoopResumeStateInvalid,
|
"ErrLoopResumeStateInvalid": ErrLoopResumeStateInvalid,
|
||||||
"ErrLoopQuitConditionFailed": ErrLoopQuitConditionFailed,
|
"ErrLoopQuitConditionFailed": ErrLoopQuitConditionFailed,
|
||||||
}
|
}
|
||||||
for name, e := range sentinels {
|
for name, e := range sentinels {
|
||||||
if e == nil {
|
if e == nil {
|
||||||
|
|||||||
@@ -104,8 +104,8 @@ func (e *Engine) resolveProvider(path string) (Provider, string, error) {
|
|||||||
|
|
||||||
// List lists nodes at the given path
|
// List lists nodes at the given path
|
||||||
// If path is empty, returns:
|
// If path is empty, returns:
|
||||||
// 1. Built-in providers (e.g., datasets)
|
// 1. Built-in providers (e.g., datasets)
|
||||||
// 2. Top-level directories from files provider (if any)
|
// 2. Top-level directories from files provider (if any)
|
||||||
func (e *Engine) List(ctx stdctx.Context, path string, opts *ListOptions) (*Result, error) {
|
func (e *Engine) List(ctx stdctx.Context, path string, opts *ListOptions) (*Result, error) {
|
||||||
// Normalize path
|
// Normalize path
|
||||||
path = normalizePath(path)
|
path = normalizePath(path)
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ package security
|
|||||||
|
|
||||||
// ThreatPattern represents a security threat detection pattern
|
// ThreatPattern represents a security threat detection pattern
|
||||||
// Inspired by hermes-agent's skills_guard.py
|
// Inspired by hermes-agent's skills_guard.py
|
||||||
type ThreatPattern struct {
|
type ThreatPattern struct {
|
||||||
Pattern string // Regular expression pattern
|
Pattern string // Regular expression pattern
|
||||||
PatternID string // Unique identifier for this pattern
|
PatternID string // Unique identifier for this pattern
|
||||||
Severity string // critical | high | medium | low
|
Severity string // critical | high | medium | low
|
||||||
@@ -271,9 +271,9 @@ var TrustedRepos = map[string]bool{
|
|||||||
// Format: [safe, caution, dangerous] -> action
|
// Format: [safe, caution, dangerous] -> action
|
||||||
// Actions: allow, block, ask
|
// Actions: allow, block, ask
|
||||||
var InstallPolicy = map[string][3]string{
|
var InstallPolicy = map[string][3]string{
|
||||||
"builtin": {"allow", "allow", "allow"}, // Official skills: always allow
|
"builtin": {"allow", "allow", "allow"}, // Official skills: always allow
|
||||||
"trusted": {"allow", "allow", "block"}, // Trusted repos: caution allowed, dangerous blocked
|
"trusted": {"allow", "allow", "block"}, // Trusted repos: caution allowed, dangerous blocked
|
||||||
"community": {"allow", "block", "block"}, // Community: only safe allowed
|
"community": {"allow", "block", "block"}, // Community: only safe allowed
|
||||||
}
|
}
|
||||||
|
|
||||||
// VerdictIndex maps verdict to array index
|
// VerdictIndex maps verdict to array index
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ type Finding struct {
|
|||||||
type ScanResult struct {
|
type ScanResult struct {
|
||||||
SkillName string
|
SkillName string
|
||||||
Source string
|
Source string
|
||||||
TrustLevel string // builtin | trusted | community
|
TrustLevel string // builtin | trusted | community
|
||||||
Verdict string // safe | caution | dangerous
|
Verdict string // safe | caution | dangerous
|
||||||
Findings []Finding
|
Findings []Finding
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -897,12 +897,12 @@ func extractQueryTerms(query string) []string {
|
|||||||
func isSafePath(path string) bool {
|
func isSafePath(path string) bool {
|
||||||
// Clean the path
|
// Clean the path
|
||||||
clean := filepath.Clean(path)
|
clean := filepath.Clean(path)
|
||||||
|
|
||||||
// Check for absolute paths
|
// Check for absolute paths
|
||||||
if filepath.IsAbs(clean) {
|
if filepath.IsAbs(clean) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for parent directory references
|
// Check for parent directory references
|
||||||
parts := strings.Split(clean, string(filepath.Separator))
|
parts := strings.Split(clean, string(filepath.Separator))
|
||||||
for _, part := range parts {
|
for _, part := range parts {
|
||||||
@@ -910,7 +910,7 @@ func isSafePath(path string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ func (s *GitHubSource) fetchFileContent(owner, repo, filePath string) (string, e
|
|||||||
}
|
}
|
||||||
|
|
||||||
var result struct {
|
var result struct {
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Encoding string `json:"encoding"`
|
Encoding string `json:"encoding"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
@@ -194,9 +194,9 @@ func (s *GitHubSource) fetchDirectoryContents(owner, repo, dirPath string) (map[
|
|||||||
}
|
}
|
||||||
|
|
||||||
var items []struct {
|
var items []struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
DownloadURL string `json:"download_url"`
|
DownloadURL string `json:"download_url"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&items); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&items); err != nil {
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ type SkillInstallCommand struct {
|
|||||||
// sourceHTTPClientAdapter adapts filesystem.HTTPClientInterface to source.HTTPClientInterface
|
// sourceHTTPClientAdapter adapts filesystem.HTTPClientInterface to source.HTTPClientInterface
|
||||||
// This allows us to use the existing HTTP client infrastructure with the source package
|
// This allows us to use the existing HTTP client infrastructure with the source package
|
||||||
type sourceHTTPClientAdapter struct {
|
type sourceHTTPClientAdapter struct {
|
||||||
client HTTPClientInterface
|
client HTTPClientInterface
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,9 +121,9 @@ func NewInstallSkillCommand(client HTTPClientInterface, fileProvider *FileProvid
|
|||||||
adaptedClient := &sourceHTTPClientAdapter{
|
adaptedClient := &sourceHTTPClientAdapter{
|
||||||
client: client,
|
client: client,
|
||||||
httpClient: &http.Client{
|
httpClient: &http.Client{
|
||||||
Timeout: 60 * time.Second,
|
Timeout: 60 * time.Second,
|
||||||
Transport: transport,
|
Transport: transport,
|
||||||
Jar: jar,
|
Jar: jar,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,13 +34,13 @@ const (
|
|||||||
// Node represents a node in the context filesystem
|
// Node represents a node in the context filesystem
|
||||||
// This is the unified output format for all providers
|
// This is the unified output format for all providers
|
||||||
type Node struct {
|
type Node struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
Type NodeType `json:"type"`
|
Type NodeType `json:"type"`
|
||||||
Size int64 `json:"size,omitempty"`
|
Size int64 `json:"size,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at,omitempty"`
|
CreatedAt time.Time `json:"created_at,omitempty"`
|
||||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CommandType represents the type of command
|
// CommandType represents the type of command
|
||||||
@@ -70,12 +70,12 @@ type ListOptions struct {
|
|||||||
|
|
||||||
// SearchOptions represents options for search operations
|
// SearchOptions represents options for search operations
|
||||||
type SearchOptions struct {
|
type SearchOptions struct {
|
||||||
Query string `json:"query"`
|
Query string `json:"query"`
|
||||||
Limit int `json:"limit,omitempty"`
|
Limit int `json:"limit,omitempty"`
|
||||||
Offset int `json:"offset,omitempty"`
|
Offset int `json:"offset,omitempty"`
|
||||||
Recursive bool `json:"recursive,omitempty"`
|
Recursive bool `json:"recursive,omitempty"`
|
||||||
TopK int `json:"top_k,omitempty"` // Number of top results to return (default: 10)
|
TopK int `json:"top_k,omitempty"` // Number of top results to return (default: 10)
|
||||||
Threshold float64 `json:"threshold,omitempty"` // Similarity threshold (default: 0.2)
|
Threshold float64 `json:"threshold,omitempty"` // Similarity threshold (default: 0.2)
|
||||||
Dirs []string `json:"dirs,omitempty"` // List of directories to search in
|
Dirs []string `json:"dirs,omitempty"` // List of directories to search in
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,12 +90,12 @@ type Result struct {
|
|||||||
|
|
||||||
// PathInfo represents parsed path information
|
// PathInfo represents parsed path information
|
||||||
type PathInfo struct {
|
type PathInfo struct {
|
||||||
Provider string // The provider name (e.g., "datasets", "chats")
|
Provider string // The provider name (e.g., "datasets", "chats")
|
||||||
Path string // The full path
|
Path string // The full path
|
||||||
Components []string // Path components
|
Components []string // Path components
|
||||||
IsRoot bool // Whether this is the root path for the provider
|
IsRoot bool // Whether this is the root path for the provider
|
||||||
ResourceID string // Resource ID if applicable
|
ResourceID string // Resource ID if applicable
|
||||||
ResourceName string // Resource name if applicable
|
ResourceName string // Resource name if applicable
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProviderInfo holds metadata about a provider
|
// ProviderInfo holds metadata about a provider
|
||||||
@@ -107,10 +107,10 @@ type ProviderInfo struct {
|
|||||||
|
|
||||||
// Common error messages
|
// Common error messages
|
||||||
const (
|
const (
|
||||||
ErrInvalidPath = "invalid path"
|
ErrInvalidPath = "invalid path"
|
||||||
ErrProviderNotFound = "provider not found for path"
|
ErrProviderNotFound = "provider not found for path"
|
||||||
ErrNotSupported = "operation not supported"
|
ErrNotSupported = "operation not supported"
|
||||||
ErrNotFound = "resource not found"
|
ErrNotFound = "resource not found"
|
||||||
ErrUnauthorized = "unauthorized"
|
ErrUnauthorized = "unauthorized"
|
||||||
ErrInternal = "internal error"
|
ErrInternal = "internal error"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -36,10 +36,10 @@ func FormatNode(node *Node, format string) map[string]interface{} {
|
|||||||
}
|
}
|
||||||
case "table":
|
case "table":
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"name": node.Name,
|
"name": node.Name,
|
||||||
"path": node.Path,
|
"path": node.Path,
|
||||||
"type": string(node.Type),
|
"type": string(node.Type),
|
||||||
"size": formatSize(node.Size),
|
"size": formatSize(node.Size),
|
||||||
"created_at": formatTime(node.CreatedAt),
|
"created_at": formatTime(node.CreatedAt),
|
||||||
"updated_at": formatTime(node.UpdatedAt),
|
"updated_at": formatTime(node.UpdatedAt),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,10 +97,10 @@ func ConvertFloatsToPyFormat(v interface{}) interface{} {
|
|||||||
// the O(n * eps) of a naive left-to-right loop.
|
// the O(n * eps) of a naive left-to-right loop.
|
||||||
//
|
//
|
||||||
// This implementation matches numpy's exact pairwise summation algorithm:
|
// This implementation matches numpy's exact pairwise summation algorithm:
|
||||||
// - For n < 16: uses naive left-to-right sum (matching numpy's small-array optimization)
|
// - For n < 16: uses naive left-to-right sum (matching numpy's small-array optimization)
|
||||||
// - For n >= 16: processes pairs left-to-right, carrying any odd element to the end
|
// - For n >= 16: processes pairs left-to-right, carrying any odd element to the end
|
||||||
// of the next level. This matches numpy's pairwise reduction in
|
// of the next level. This matches numpy's pairwise reduction in
|
||||||
// numpy/core/src/umath/reduction.c.
|
// numpy/core/src/umath/reduction.c.
|
||||||
//
|
//
|
||||||
// xs is modified in place. Pass a copy if the caller still needs the input.
|
// xs is modified in place. Pass a copy if the caller still needs the input.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -27,14 +27,17 @@ import (
|
|||||||
// - ISO 8601 / RFC3339 (e.g., "2026-04-09T18:55:46+08:00")
|
// - ISO 8601 / RFC3339 (e.g., "2026-04-09T18:55:46+08:00")
|
||||||
//
|
//
|
||||||
// Args:
|
// Args:
|
||||||
// dateString: Date string in supported format
|
//
|
||||||
|
// dateString: Date string in supported format
|
||||||
//
|
//
|
||||||
// Returns:
|
// Returns:
|
||||||
// float64: Number of seconds between the given date and current time
|
//
|
||||||
|
// float64: Number of seconds between the given date and current time
|
||||||
//
|
//
|
||||||
// Example:
|
// Example:
|
||||||
// DeltaSeconds("2024-01-01 12:00:00")
|
//
|
||||||
// DeltaSeconds("2026-04-09T18:55:46+08:00")
|
// DeltaSeconds("2024-01-01 12:00:00")
|
||||||
|
// DeltaSeconds("2026-04-09T18:55:46+08:00")
|
||||||
func DeltaSeconds(dateString string) (float64, error) {
|
func DeltaSeconds(dateString string) (float64, error) {
|
||||||
// Try RFC3339 format first (ISO 8601 with timezone, e.g., "2026-04-09T18:55:46+08:00")
|
// Try RFC3339 format first (ISO 8601 with timezone, e.g., "2026-04-09T18:55:46+08:00")
|
||||||
dt, err := time.Parse(time.RFC3339, dateString)
|
dt, err := time.Parse(time.RFC3339, dateString)
|
||||||
|
|||||||
@@ -210,8 +210,8 @@ func (c *Client) doPost(ctx context.Context, url string, buildBody bodyBuilder,
|
|||||||
// httpError carries the HTTP status + body so callers can inspect.
|
// httpError carries the HTTP status + body so callers can inspect.
|
||||||
// retryable=true means the doPost loop already exhausted retries.
|
// retryable=true means the doPost loop already exhausted retries.
|
||||||
type httpError struct {
|
type httpError struct {
|
||||||
Status string
|
Status string
|
||||||
Body string
|
Body string
|
||||||
retryable bool
|
retryable bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-10
@@ -50,16 +50,16 @@ type DLAResult struct {
|
|||||||
// at indices 4/6/7/9 are kept verbatim for backward compatibility
|
// at indices 4/6/7/9 are kept verbatim for backward compatibility
|
||||||
// with existing inference servers.
|
// with existing inference servers.
|
||||||
var DLAClasses = []string{
|
var DLAClasses = []string{
|
||||||
"title", // 0
|
"title", // 0
|
||||||
"text", // 1
|
"text", // 1
|
||||||
"reference", // 2
|
"reference", // 2
|
||||||
"figure", // 3
|
"figure", // 3
|
||||||
"figure caption", // 4
|
"figure caption", // 4
|
||||||
"table", // 5
|
"table", // 5
|
||||||
"table caption", // 6
|
"table caption", // 6
|
||||||
"table caption", // 7 duplicate
|
"table caption", // 7 duplicate
|
||||||
"equation", // 8
|
"equation", // 8
|
||||||
"figure caption", // 9 duplicate
|
"figure caption", // 9 duplicate
|
||||||
}
|
}
|
||||||
|
|
||||||
// rawDLA is the wire format the DLA server returns
|
// rawDLA is the wire format the DLA server returns
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ func TestDLA_SuccessfulResponse(t *testing.T) {
|
|||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
"bboxes": [][]float64{
|
"bboxes": [][]float64{
|
||||||
{10, 20, 100, 200, 0.95, 0}, // title
|
{10, 20, 100, 200, 0.95, 0}, // title
|
||||||
{10, 220, 500, 400, 0.88, 1}, // text
|
{10, 220, 500, 400, 0.88, 1}, // text
|
||||||
{50, 420, 600, 700, 0.77, 3}, // figure
|
{50, 420, 600, 700, 0.77, 3}, // figure
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -95,4 +95,4 @@ func (e *elasticsearchEngine) indexExists(ctx context.Context, indexName string)
|
|||||||
// buildMetadataIndexName returns the metadata index name for a tenant
|
// buildMetadataIndexName returns the metadata index name for a tenant
|
||||||
func buildMetadataIndexName(tenantID string) string {
|
func buildMetadataIndexName(tenantID string) string {
|
||||||
return fmt.Sprintf("ragflow_doc_meta_%s", tenantID)
|
return fmt.Sprintf("ragflow_doc_meta_%s", tenantID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,33 +35,33 @@ var dateRegex = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
|
|||||||
|
|
||||||
// Supported operators
|
// Supported operators
|
||||||
var supportedOperators = map[string]bool{
|
var supportedOperators = map[string]bool{
|
||||||
"=": true,
|
"=": true,
|
||||||
"≠": true,
|
"≠": true,
|
||||||
">": true,
|
">": true,
|
||||||
"<": true,
|
"<": true,
|
||||||
"≥": true,
|
"≥": true,
|
||||||
"≤": true,
|
"≤": true,
|
||||||
"in": true,
|
"in": true,
|
||||||
"not in": true,
|
"not in": true,
|
||||||
"contains": true,
|
"contains": true,
|
||||||
"not contains": true,
|
"not contains": true,
|
||||||
"start with": true,
|
"start with": true,
|
||||||
"end with": true,
|
"end with": true,
|
||||||
"empty": true,
|
"empty": true,
|
||||||
"not empty": true,
|
"not empty": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Range operators mapping
|
// Range operators mapping
|
||||||
var rangeOps = map[string]string{
|
var rangeOps = map[string]string{
|
||||||
">": "gt",
|
">": "gt",
|
||||||
"<": "lt",
|
"<": "lt",
|
||||||
"≥": "gte",
|
"≥": "gte",
|
||||||
"≤": "lte",
|
"≤": "lte",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Negative operators unsafe for multi-valued fields
|
// Negative operators unsafe for multi-valued fields
|
||||||
var multivalueUnsafeNegativeOps = map[string]bool{
|
var multivalueUnsafeNegativeOps = map[string]bool{
|
||||||
"≠": true,
|
"≠": true,
|
||||||
"not in": true,
|
"not in": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,8 +77,8 @@ func (e *UnsupportedMetaFilterError) Error() string {
|
|||||||
|
|
||||||
// TranslatedFilter represents a single filter rendered as ES bool clauses
|
// TranslatedFilter represents a single filter rendered as ES bool clauses
|
||||||
type TranslatedFilter struct {
|
type TranslatedFilter struct {
|
||||||
Must []map[string]interface{}
|
Must []map[string]interface{}
|
||||||
MustNot []map[string]interface{}
|
MustNot []map[string]interface{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToClauses converts to ES clauses
|
// ToClauses converts to ES clauses
|
||||||
@@ -97,7 +97,7 @@ func (f *TranslatedFilter) ToClauses() []map[string]interface{} {
|
|||||||
|
|
||||||
// MetaFilterPushdownPlan represents composed ES bool query body
|
// MetaFilterPushdownPlan represents composed ES bool query body
|
||||||
type MetaFilterPushdownPlan struct {
|
type MetaFilterPushdownPlan struct {
|
||||||
Logic string
|
Logic string
|
||||||
translated []*TranslatedFilter
|
translated []*TranslatedFilter
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -522,7 +522,7 @@ func termOrMatch(fieldPath string, value interface{}) map[string]interface{} {
|
|||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"term": map[string]interface{}{
|
"term": map[string]interface{}{
|
||||||
keywordPath(fieldPath): map[string]interface{}{
|
keywordPath(fieldPath): map[string]interface{}{
|
||||||
"value": s,
|
"value": s,
|
||||||
"case_insensitive": true,
|
"case_insensitive": true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -556,7 +556,7 @@ func termsStringOrNumeric(fieldPath string, members []interface{}) map[string]in
|
|||||||
|
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"bool": map[string]interface{}{
|
"bool": map[string]interface{}{
|
||||||
"should": shouldClauses,
|
"should": shouldClauses,
|
||||||
"minimum_should_match": 1,
|
"minimum_should_match": 1,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -567,7 +567,7 @@ func wildcard(fieldPath string, pattern string) map[string]interface{} {
|
|||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"wildcard": map[string]interface{}{
|
"wildcard": map[string]interface{}{
|
||||||
keywordPath(fieldPath): map[string]interface{}{
|
keywordPath(fieldPath): map[string]interface{}{
|
||||||
"value": pattern,
|
"value": pattern,
|
||||||
"case_insensitive": true,
|
"case_insensitive": true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -679,4 +679,4 @@ func splitJSONParts(s string) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return parts
|
return parts
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,28 +30,28 @@ var keyPattern = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
|
|||||||
|
|
||||||
// Supported operators
|
// Supported operators
|
||||||
var supportedOperators = map[string]bool{
|
var supportedOperators = map[string]bool{
|
||||||
"=": true,
|
"=": true,
|
||||||
"≠": true,
|
"≠": true,
|
||||||
">": true,
|
">": true,
|
||||||
"<": true,
|
"<": true,
|
||||||
"≥": true,
|
"≥": true,
|
||||||
"≤": true,
|
"≤": true,
|
||||||
"in": true,
|
"in": true,
|
||||||
"not in": true,
|
"not in": true,
|
||||||
"contains": true,
|
"contains": true,
|
||||||
"not contains": true,
|
"not contains": true,
|
||||||
"start with": true,
|
"start with": true,
|
||||||
"end with": true,
|
"end with": true,
|
||||||
"empty": true,
|
"empty": true,
|
||||||
"not empty": true,
|
"not empty": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Range operators mapping
|
// Range operators mapping
|
||||||
var rangeOps = map[string]string{
|
var rangeOps = map[string]string{
|
||||||
">": ">",
|
">": ">",
|
||||||
"<": "<",
|
"<": "<",
|
||||||
"≥": ">=",
|
"≥": ">=",
|
||||||
"≤": "<=",
|
"≤": "<=",
|
||||||
}
|
}
|
||||||
|
|
||||||
// MetaFilterTranslator translates filter clauses to Infinity SQL
|
// MetaFilterTranslator translates filter clauses to Infinity SQL
|
||||||
@@ -150,7 +150,7 @@ func (t *MetaFilterTranslator) translateIn(key string, value interface{}, flt ma
|
|||||||
coerced := coerceRangeValue(m, flt)
|
coerced := coerceRangeValue(m, flt)
|
||||||
if num, ok := coerceToFloat(coerced); ok {
|
if num, ok := coerceToFloat(coerced); ok {
|
||||||
numParts = append(numParts, fmt.Sprintf("JSON_CONTAINS(meta_fields, '$.%s', %v)", key, num))
|
numParts = append(numParts, fmt.Sprintf("JSON_CONTAINS(meta_fields, '$.%s', %v)", key, num))
|
||||||
} else if s, ok := coerced.(string); ok {
|
} else if s, ok := coerced.(string); ok {
|
||||||
escaped := escapeSQLString(s)
|
escaped := escapeSQLString(s)
|
||||||
stringParts = append(stringParts, fmt.Sprintf("JSON_CONTAINS(meta_fields, '$.%s', '\"%s\"')", key, escaped))
|
stringParts = append(stringParts, fmt.Sprintf("JSON_CONTAINS(meta_fields, '$.%s', '\"%s\"')", key, escaped))
|
||||||
}
|
}
|
||||||
@@ -579,4 +579,4 @@ func splitJSONParts(s string) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return parts
|
return parts
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,36 +55,36 @@ func (FileCommitItem) TableName() string {
|
|||||||
|
|
||||||
// TreeNode represents a file node in the commit tree state snapshot.
|
// TreeNode represents a file node in the commit tree state snapshot.
|
||||||
type TreeNode struct {
|
type TreeNode struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Type string `json:"type"` // "file" or "folder"
|
Type string `json:"type"` // "file" or "folder"
|
||||||
Hash string `json:"hash,omitempty"`
|
Hash string `json:"hash,omitempty"`
|
||||||
Location string `json:"location,omitempty"`
|
Location string `json:"location,omitempty"`
|
||||||
Size int64 `json:"size,omitempty"`
|
Size int64 `json:"size,omitempty"`
|
||||||
Status string `json:"status"` // "1" = active, "0" = deleted
|
Status string `json:"status"` // "1" = active, "0" = deleted
|
||||||
Children []*TreeNode `json:"children,omitempty"`
|
Children []*TreeNode `json:"children,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// FileChange represents a file change in a commit request.
|
// FileChange represents a file change in a commit request.
|
||||||
type FileChange struct {
|
type FileChange struct {
|
||||||
FileID string `json:"file_id"`
|
FileID string `json:"file_id"`
|
||||||
FileName string `json:"file_name"`
|
FileName string `json:"file_name"`
|
||||||
Operation string `json:"operation"` // "add", "modify", "delete", "rename"
|
Operation string `json:"operation"` // "add", "modify", "delete", "rename"
|
||||||
Content string `json:"content,omitempty"`
|
Content string `json:"content,omitempty"`
|
||||||
OldName string `json:"old_name,omitempty"`
|
OldName string `json:"old_name,omitempty"`
|
||||||
NewName string `json:"new_name,omitempty"`
|
NewName string `json:"new_name,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CommitResponse is the API response for a commit.
|
// CommitResponse is the API response for a commit.
|
||||||
type CommitResponse struct {
|
type CommitResponse struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
FolderID string `json:"folder_id"`
|
FolderID string `json:"folder_id"`
|
||||||
ParentID *string `json:"parent_id,omitempty"`
|
ParentID *string `json:"parent_id,omitempty"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
AuthorID string `json:"author_id"`
|
AuthorID string `json:"author_id"`
|
||||||
FileCount int `json:"file_count"`
|
FileCount int `json:"file_count"`
|
||||||
TreeState *string `json:"tree_state,omitempty"`
|
TreeState *string `json:"tree_state,omitempty"`
|
||||||
CreateTime *int64 `json:"create_time,omitempty"`
|
CreateTime *int64 `json:"create_time,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DiffEntry represents a single diff entry between two commits.
|
// DiffEntry represents a single diff entry between two commits.
|
||||||
@@ -100,9 +100,9 @@ type DiffEntry struct {
|
|||||||
|
|
||||||
// VersionEntry represents a single version in a file's version history.
|
// VersionEntry represents a single version in a file's version history.
|
||||||
type VersionEntry struct {
|
type VersionEntry struct {
|
||||||
CommitID string `json:"commit_id"`
|
CommitID string `json:"commit_id"`
|
||||||
Operation string `json:"operation"`
|
Operation string `json:"operation"`
|
||||||
Hash string `json:"hash"`
|
Hash string `json:"hash"`
|
||||||
CreateTime *int64 `json:"create_time,omitempty"`
|
CreateTime *int64 `json:"create_time,omitempty"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -364,4 +364,3 @@ func (a *AvianModel) ListTasks(apiConfig *APIConfig) ([]ListTaskStatus, error) {
|
|||||||
func (a *AvianModel) ShowTask(taskID string, apiConfig *APIConfig) (*TaskResponse, error) {
|
func (a *AvianModel) ShowTask(taskID string, apiConfig *APIConfig) (*TaskResponse, error) {
|
||||||
return nil, fmt.Errorf("%s, no such method", a.Name())
|
return nil, fmt.Errorf("%s, no such method", a.Name())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,18 +44,18 @@ func DefaultFieldConfig() FieldConfig {
|
|||||||
|
|
||||||
// SkillSearchConfig represents the search configuration for skills
|
// SkillSearchConfig represents the search configuration for skills
|
||||||
type SkillSearchConfig struct {
|
type SkillSearchConfig struct {
|
||||||
ID string `gorm:"column:id;primaryKey;size:32" json:"id"`
|
ID string `gorm:"column:id;primaryKey;size:32" json:"id"`
|
||||||
TenantID string `gorm:"column:tenant_id;size:32;not null;index" json:"tenant_id"`
|
TenantID string `gorm:"column:tenant_id;size:32;not null;index" json:"tenant_id"`
|
||||||
SpaceID string `gorm:"column:space_id;size:128;not null;default:'default';index" json:"space_id"`
|
SpaceID string `gorm:"column:space_id;size:128;not null;default:'default';index" json:"space_id"`
|
||||||
EmbdID string `gorm:"column:embd_id;size:128;not null" json:"embd_id"`
|
EmbdID string `gorm:"column:embd_id;size:128;not null" json:"embd_id"`
|
||||||
Status string `gorm:"column:status;size:1;default:1" json:"status"`
|
Status string `gorm:"column:status;size:1;default:1" json:"status"`
|
||||||
VectorSimilarityWeight float64 `gorm:"column:vector_similarity_weight;default:0.3" json:"vector_similarity_weight"`
|
VectorSimilarityWeight float64 `gorm:"column:vector_similarity_weight;default:0.3" json:"vector_similarity_weight"`
|
||||||
SimilarityThreshold float64 `gorm:"column:similarity_threshold;default:0.2" json:"similarity_threshold"`
|
SimilarityThreshold float64 `gorm:"column:similarity_threshold;default:0.2" json:"similarity_threshold"`
|
||||||
FieldConfig JSONMap `gorm:"column:field_config;type:json" json:"field_config"`
|
FieldConfig JSONMap `gorm:"column:field_config;type:json" json:"field_config"`
|
||||||
RerankID *string `gorm:"column:rerank_id;size:128" json:"rerank_id,omitempty"`
|
RerankID *string `gorm:"column:rerank_id;size:128" json:"rerank_id,omitempty"`
|
||||||
TenantRerankID *int64 `gorm:"column:tenant_rerank_id" json:"tenant_rerank_id,omitempty"`
|
TenantRerankID *int64 `gorm:"column:tenant_rerank_id" json:"tenant_rerank_id,omitempty"`
|
||||||
TopK int64 `gorm:"column:top_k;default:10" json:"top_k"`
|
TopK int64 `gorm:"column:top_k;default:10" json:"top_k"`
|
||||||
IndexVersion string `gorm:"column:index_version;size:32;default:'1.0.0'" json:"index_version"`
|
IndexVersion string `gorm:"column:index_version;size:32;default:'1.0.0'" json:"index_version"`
|
||||||
BaseModel
|
BaseModel
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ func (s *SkillSearchConfig) ToMap() map[string]interface{} {
|
|||||||
result := map[string]interface{}{
|
result := map[string]interface{}{
|
||||||
"id": s.ID,
|
"id": s.ID,
|
||||||
"tenant_id": s.TenantID,
|
"tenant_id": s.TenantID,
|
||||||
"space_id": s.SpaceID,
|
"space_id": s.SpaceID,
|
||||||
"embd_id": s.EmbdID,
|
"embd_id": s.EmbdID,
|
||||||
"vector_similarity_weight": s.VectorSimilarityWeight,
|
"vector_similarity_weight": s.VectorSimilarityWeight,
|
||||||
"similarity_threshold": s.SimilarityThreshold,
|
"similarity_threshold": s.SimilarityThreshold,
|
||||||
|
|||||||
@@ -27,15 +27,15 @@ const (
|
|||||||
|
|
||||||
// SkillSpace represents a skills space (library) that contains skills
|
// SkillSpace represents a skills space (library) that contains skills
|
||||||
type SkillSpace struct {
|
type SkillSpace struct {
|
||||||
ID string `gorm:"column:id;primaryKey;size:32" json:"id"`
|
ID string `gorm:"column:id;primaryKey;size:32" json:"id"`
|
||||||
TenantID string `gorm:"column:tenant_id;size:32;not null;index" json:"tenant_id"`
|
TenantID string `gorm:"column:tenant_id;size:32;not null;index" json:"tenant_id"`
|
||||||
Name string `gorm:"column:name;size:128;not null" json:"name"`
|
Name string `gorm:"column:name;size:128;not null" json:"name"`
|
||||||
FolderID string `gorm:"column:folder_id;size:32;not null" json:"folder_id"`
|
FolderID string `gorm:"column:folder_id;size:32;not null" json:"folder_id"`
|
||||||
Description string `gorm:"column:description;type:text" json:"description"`
|
Description string `gorm:"column:description;type:text" json:"description"`
|
||||||
EmbdID string `gorm:"column:embd_id;size:128" json:"embd_id"`
|
EmbdID string `gorm:"column:embd_id;size:128" json:"embd_id"`
|
||||||
RerankID string `gorm:"column:rerank_id;size:128" json:"rerank_id"`
|
RerankID string `gorm:"column:rerank_id;size:128" json:"rerank_id"`
|
||||||
TopK int `gorm:"column:top_k;default:10" json:"top_k"`
|
TopK int `gorm:"column:top_k;default:10" json:"top_k"`
|
||||||
Status string `gorm:"column:status;size:1;default:1" json:"status"`
|
Status string `gorm:"column:status;size:1;default:1" json:"status"`
|
||||||
BaseModel
|
BaseModel
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,12 +61,12 @@ func (s *SkillSpace) StatusDescription() string {
|
|||||||
// ToMap converts SkillSpace to a map for JSON response
|
// ToMap converts SkillSpace to a map for JSON response
|
||||||
func (s *SkillSpace) ToMap() map[string]interface{} {
|
func (s *SkillSpace) ToMap() map[string]interface{} {
|
||||||
result := map[string]interface{}{
|
result := map[string]interface{}{
|
||||||
"id": s.ID,
|
"id": s.ID,
|
||||||
"tenant_id": s.TenantID,
|
"tenant_id": s.TenantID,
|
||||||
"name": s.Name,
|
"name": s.Name,
|
||||||
"folder_id": s.FolderID,
|
"folder_id": s.FolderID,
|
||||||
"top_k": s.TopK,
|
"top_k": s.TopK,
|
||||||
"status": s.StatusDescription(),
|
"status": s.StatusDescription(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.Description != "" {
|
if s.Description != "" {
|
||||||
|
|||||||
+131
-6
@@ -29,6 +29,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"ragflow/internal/agent/canvas"
|
"ragflow/internal/agent/canvas"
|
||||||
"ragflow/internal/common"
|
"ragflow/internal/common"
|
||||||
@@ -53,7 +54,7 @@ type agentFileService interface {
|
|||||||
// NewAgentHandler assigns the concrete *service.AgentService — which
|
// NewAgentHandler assigns the concrete *service.AgentService — which
|
||||||
// satisfies this interface because its RunAgent signature matches.
|
// satisfies this interface because its RunAgent signature matches.
|
||||||
type chatAgentService interface {
|
type chatAgentService interface {
|
||||||
RunAgent(ctx context.Context, userID, canvasID, sessionID, version, userInput string) (<-chan canvas.RunEvent, error)
|
RunAgent(ctx context.Context, userID, canvasID, sessionID, version string, userInput any) (<-chan canvas.RunEvent, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AgentHandler agent handler
|
// AgentHandler agent handler
|
||||||
@@ -869,6 +870,7 @@ func (h *AgentHandler) DeleteAgentSession(c *gin.Context) {
|
|||||||
type agentChatCompletionsRequest struct {
|
type agentChatCompletionsRequest struct {
|
||||||
AgentID string `json:"agent_id"`
|
AgentID string `json:"agent_id"`
|
||||||
Query string `json:"query"`
|
Query string `json:"query"`
|
||||||
|
Inputs map[string]interface{} `json:"inputs"`
|
||||||
SessionID string `json:"session_id"`
|
SessionID string `json:"session_id"`
|
||||||
Stream bool `json:"stream"`
|
Stream bool `json:"stream"`
|
||||||
OpenAICompat bool `json:"openai-compatible"`
|
OpenAICompat bool `json:"openai-compatible"`
|
||||||
@@ -895,6 +897,76 @@ func extractLastUserContent(messages []map[string]interface{}) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// extractUserInputFromFormInputs mirrors the front-end's wait-for-user submit
|
||||||
|
// shape: `inputs` is an object keyed by form field name, and each entry carries
|
||||||
|
// a nested `value`. The current chat-completion resume path consumes a single
|
||||||
|
// string payload, so we lift the first field's value and stringify it.
|
||||||
|
func extractUserInputFromFormInputs(inputs map[string]interface{}) interface{} {
|
||||||
|
if len(inputs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(inputs) == 1 {
|
||||||
|
for _, raw := range inputs {
|
||||||
|
if field, ok := raw.(map[string]interface{}); ok {
|
||||||
|
if v, ok := field["value"]; ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make(map[string]any, len(inputs))
|
||||||
|
for name, raw := range inputs {
|
||||||
|
if field, ok := raw.(map[string]interface{}); ok {
|
||||||
|
if v, ok := field["value"]; ok {
|
||||||
|
out[name] = v
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out[name] = raw
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func countInputValues(inputs map[string]interface{}) int {
|
||||||
|
count := 0
|
||||||
|
for _, raw := range inputs {
|
||||||
|
if field, ok := raw.(map[string]interface{}); ok {
|
||||||
|
if _, exists := field["value"]; exists {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if raw != nil {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
func userInputMeta(userInput any) []zap.Field {
|
||||||
|
fields := []zap.Field{zap.String("user_input_type", fmt.Sprintf("%T", userInput))}
|
||||||
|
switch v := userInput.(type) {
|
||||||
|
case nil:
|
||||||
|
fields = append(fields, zap.Bool("user_input_present", false))
|
||||||
|
case string:
|
||||||
|
fields = append(fields,
|
||||||
|
zap.Bool("user_input_present", true),
|
||||||
|
zap.Int("user_input_length", len(v)),
|
||||||
|
zap.Bool("user_input_blank", v == ""),
|
||||||
|
)
|
||||||
|
case map[string]interface{}:
|
||||||
|
fields = append(fields,
|
||||||
|
zap.Bool("user_input_present", true),
|
||||||
|
zap.Int("user_input_keys", len(v)),
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
fields = append(fields, zap.Bool("user_input_present", true))
|
||||||
|
}
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
|
||||||
func (h *AgentHandler) AgentChatCompletions(c *gin.Context) {
|
func (h *AgentHandler) AgentChatCompletions(c *gin.Context) {
|
||||||
user, code, msg := GetUser(c)
|
user, code, msg := GetUser(c)
|
||||||
if code != common.CodeSuccess {
|
if code != common.CodeSuccess {
|
||||||
@@ -914,6 +986,18 @@ func (h *AgentHandler) AgentChatCompletions(c *gin.Context) {
|
|||||||
jsonError(c, common.CodeDataError, "at least one message is required in openai-compatible mode.")
|
jsonError(c, common.CodeDataError, "at least one message is required in openai-compatible mode.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
common.Debug("agent chat completions: request received",
|
||||||
|
zap.String("user_id", user.ID),
|
||||||
|
zap.String("agent_id", req.AgentID),
|
||||||
|
zap.String("session_id", req.SessionID),
|
||||||
|
zap.Bool("stream", req.Stream),
|
||||||
|
zap.Bool("openai_compatible", req.OpenAICompat),
|
||||||
|
zap.Bool("query_present", req.Query != ""),
|
||||||
|
zap.Int("query_length", len(req.Query)),
|
||||||
|
zap.Int("inputs_count", len(req.Inputs)),
|
||||||
|
zap.Int("inputs_with_values_count", countInputValues(req.Inputs)),
|
||||||
|
zap.Int("messages_count", len(req.Messages)),
|
||||||
|
)
|
||||||
|
|
||||||
// TODO(phase5-openai-framing): the openai-compat branches below are
|
// TODO(phase5-openai-framing): the openai-compat branches below are
|
||||||
// stubs. They keep the existing "choices"-shape contract for the
|
// stubs. They keep the existing "choices"-shape contract for the
|
||||||
@@ -936,13 +1020,31 @@ func (h *AgentHandler) AgentChatCompletions(c *gin.Context) {
|
|||||||
// Real canvas run — derive userInput from `query` first, then fall
|
// Real canvas run — derive userInput from `query` first, then fall
|
||||||
// back to the last user message (covers the front-end that posts
|
// back to the last user message (covers the front-end that posts
|
||||||
// running_hint_text without a top-level `query`).
|
// running_hint_text without a top-level `query`).
|
||||||
userInput := req.Query
|
var userInput any = req.Query
|
||||||
if userInput == "" {
|
if req.Query == "" {
|
||||||
userInput = extractLastUserContent(req.Messages)
|
if extracted := extractUserInputFromFormInputs(req.Inputs); extracted != nil {
|
||||||
|
userInput = extracted
|
||||||
|
} else if extracted := extractLastUserContent(req.Messages); extracted != "" {
|
||||||
|
userInput = extracted
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
common.Debug("agent chat completions: derived user input",
|
||||||
|
append([]zap.Field{
|
||||||
|
zap.String("agent_id", req.AgentID),
|
||||||
|
zap.String("session_id", req.SessionID),
|
||||||
|
}, userInputMeta(userInput)...)...,
|
||||||
|
)
|
||||||
|
|
||||||
events, err := h.chatRunner.RunAgent(c.Request.Context(), user.ID, req.AgentID, req.SessionID, "", userInput)
|
events, err := h.chatRunner.RunAgent(c.Request.Context(), user.ID, req.AgentID, req.SessionID, "", userInput)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
common.Warn("agent chat completions: RunAgent failed",
|
||||||
|
append([]zap.Field{
|
||||||
|
zap.String("user_id", user.ID),
|
||||||
|
zap.String("agent_id", req.AgentID),
|
||||||
|
zap.String("session_id", req.SessionID),
|
||||||
|
zap.Error(err),
|
||||||
|
}, userInputMeta(userInput)...)...,
|
||||||
|
)
|
||||||
ec, em := mapAgentError(err)
|
ec, em := mapAgentError(err)
|
||||||
jsonError(c, ec, em)
|
jsonError(c, ec, em)
|
||||||
return
|
return
|
||||||
@@ -963,8 +1065,19 @@ func (h *AgentHandler) AgentChatCompletions(c *gin.Context) {
|
|||||||
// /api/v1/agents/{id}/run endpoint's wire format — see
|
// /api/v1/agents/{id}/run endpoint's wire format — see
|
||||||
// writeRunEventSSE at agent.go for that path.
|
// writeRunEventSSE at agent.go for that path.
|
||||||
for ev := range events {
|
for ev := range events {
|
||||||
writeChatCompletionSSE(c.Writer, flusher, ev)
|
common.Debug("agent chat completions: streaming event",
|
||||||
|
zap.String("agent_id", req.AgentID),
|
||||||
|
zap.String("session_id", req.SessionID),
|
||||||
|
zap.String("event_type", ev.Type),
|
||||||
|
zap.String("message_id", ev.MessageID),
|
||||||
|
zap.String("task_id", ev.TaskID),
|
||||||
|
)
|
||||||
|
writeChatCompletionSSE(c.Writer, flusher, req.AgentID, ev)
|
||||||
}
|
}
|
||||||
|
common.Debug("agent chat completions: stream closed",
|
||||||
|
zap.String("agent_id", req.AgentID),
|
||||||
|
zap.String("session_id", req.SessionID),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeChatCompletionSSE emits one canvas.RunEvent in the
|
// writeChatCompletionSSE emits one canvas.RunEvent in the
|
||||||
@@ -973,8 +1086,13 @@ func (h *AgentHandler) AgentChatCompletions(c *gin.Context) {
|
|||||||
// data:{"event":"<ev.Type>","message_id":"<ev.MessageID>","created_at":<ev.CreatedAt>,"task_id":"<ev.TaskID>","session_id":"<ev.SessionID>","data":<ev.Data>}
|
// data:{"event":"<ev.Type>","message_id":"<ev.MessageID>","created_at":<ev.CreatedAt>,"task_id":"<ev.TaskID>","session_id":"<ev.SessionID>","data":<ev.Data>}
|
||||||
//
|
//
|
||||||
// The special "done" type sends `data: [DONE]\n\n` (no JSON envelope).
|
// The special "done" type sends `data: [DONE]\n\n` (no JSON envelope).
|
||||||
func writeChatCompletionSSE(w io.Writer, flusher http.Flusher, ev canvas.RunEvent) {
|
func writeChatCompletionSSE(w io.Writer, flusher http.Flusher, agentID string, ev canvas.RunEvent) {
|
||||||
if ev.Type == "done" {
|
if ev.Type == "done" {
|
||||||
|
common.Debug("agent chat completions: writing done sentinel",
|
||||||
|
zap.String("agent_id", agentID),
|
||||||
|
zap.String("session_id", ev.SessionID),
|
||||||
|
zap.String("task_id", ev.TaskID),
|
||||||
|
)
|
||||||
fmt.Fprint(w, "data: [DONE]\n\n")
|
fmt.Fprint(w, "data: [DONE]\n\n")
|
||||||
if flusher != nil {
|
if flusher != nil {
|
||||||
flusher.Flush()
|
flusher.Flush()
|
||||||
@@ -985,6 +1103,13 @@ func writeChatCompletionSSE(w io.Writer, flusher http.Flusher, ev canvas.RunEven
|
|||||||
if data == "" {
|
if data == "" {
|
||||||
data = "{}"
|
data = "{}"
|
||||||
}
|
}
|
||||||
|
common.Debug("agent chat completions: writing sse frame",
|
||||||
|
zap.String("agent_id", agentID),
|
||||||
|
zap.String("event_type", ev.Type),
|
||||||
|
zap.String("message_id", ev.MessageID),
|
||||||
|
zap.String("session_id", ev.SessionID),
|
||||||
|
zap.String("task_id", ev.TaskID),
|
||||||
|
)
|
||||||
envelope := sseEnvelope(ev.Type, ev.MessageID, ev.CreatedAt, ev.TaskID, ev.SessionID, data)
|
envelope := sseEnvelope(ev.Type, ev.MessageID, ev.CreatedAt, ev.TaskID, ev.SessionID, data)
|
||||||
fmt.Fprintf(w, "data: %s\n\n", envelope)
|
fmt.Fprintf(w, "data: %s\n\n", envelope)
|
||||||
if flusher != nil {
|
if flusher != nil {
|
||||||
|
|||||||
@@ -447,7 +447,7 @@ func (f *fullFakeAgentService) UpdateAgent(context.Context, string, string, enti
|
|||||||
func (f *fullFakeAgentService) DeleteAgent(context.Context, string, string) error {
|
func (f *fullFakeAgentService) DeleteAgent(context.Context, string, string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
func (f *fullFakeAgentService) RunAgent(context.Context, string, string, string, string, string) (<-chan canvas.RunEvent, error) {
|
func (f *fullFakeAgentService) RunAgent(context.Context, string, string, string, string, any) (<-chan canvas.RunEvent, error) {
|
||||||
ch := make(chan canvas.RunEvent)
|
ch := make(chan canvas.RunEvent)
|
||||||
close(ch)
|
close(ch)
|
||||||
return ch, nil
|
return ch, nil
|
||||||
@@ -715,7 +715,7 @@ type stubChatRunner struct {
|
|||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *stubChatRunner) RunAgent(_ context.Context, _, _, _, _, _ string) (<-chan canvas.RunEvent, error) {
|
func (s *stubChatRunner) RunAgent(_ context.Context, _, _, _, _ string, _ any) (<-chan canvas.RunEvent, error) {
|
||||||
if s.err != nil {
|
if s.err != nil {
|
||||||
return nil, s.err
|
return nil, s.err
|
||||||
}
|
}
|
||||||
@@ -815,13 +815,61 @@ func TestAgentChatCompletions_DerivesUserInputFromMessages(t *testing.T) {
|
|||||||
c.Set("user", &entity.User{ID: "u1"})
|
c.Set("user", &entity.User{ID: "u1"})
|
||||||
c.Set("user_id", "u1")
|
c.Set("user_id", "u1")
|
||||||
|
|
||||||
var captured string
|
var captured any
|
||||||
runner := &captureChatRunner{captured: &captured}
|
runner := &captureChatRunner{captured: &captured}
|
||||||
h := &AgentHandler{chatRunner: runner}
|
h := &AgentHandler{chatRunner: runner}
|
||||||
h.AgentChatCompletions(c)
|
h.AgentChatCompletions(c)
|
||||||
|
|
||||||
if captured != "from-messages" {
|
if captured != "from-messages" {
|
||||||
t.Errorf("userInput = %q, want %q (last user message content)", captured, "from-messages")
|
t.Errorf("userInput = %#v, want %q (last user message content)", captured, "from-messages")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAgentChatCompletions_DerivesUserInputFromInputs covers the wait-for-user
|
||||||
|
// resume path used by the front-end: the follow-up submit posts `inputs`
|
||||||
|
// instead of a top-level `query`. The handler must lift the nested field value
|
||||||
|
// and pass it through as the resumed user input.
|
||||||
|
func TestAgentChatCompletions_DerivesUserInputFromInputs(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
c.Request = httptest.NewRequest("POST", "/api/v1/agents/chat/completions",
|
||||||
|
strings.NewReader(`{"agent_id":"a1","session_id":"s1","inputs":{"text":{"name":"text","value":"a b c d e","type":"line"}}}`))
|
||||||
|
c.Request.Header.Set("Content-Type", "application/json")
|
||||||
|
c.Set("user", &entity.User{ID: "u1"})
|
||||||
|
c.Set("user_id", "u1")
|
||||||
|
|
||||||
|
var captured any
|
||||||
|
runner := &captureChatRunner{captured: &captured}
|
||||||
|
h := &AgentHandler{chatRunner: runner}
|
||||||
|
h.AgentChatCompletions(c)
|
||||||
|
|
||||||
|
if captured != "a b c d e" {
|
||||||
|
t.Errorf("userInput = %#v, want %q (nested inputs.value)", captured, "a b c d e")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentChatCompletions_DerivesStructuredUserInputFromInputs(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
c.Request = httptest.NewRequest("POST", "/api/v1/agents/chat/completions",
|
||||||
|
strings.NewReader(`{"agent_id":"a1","session_id":"s1","inputs":{"kb":{"name":"KB","value":"da1","type":"line"},"query":{"name":"Query","value":"合同","type":"line"}}}`))
|
||||||
|
c.Request.Header.Set("Content-Type", "application/json")
|
||||||
|
c.Set("user", &entity.User{ID: "u1"})
|
||||||
|
c.Set("user_id", "u1")
|
||||||
|
|
||||||
|
var captured any
|
||||||
|
runner := &captureChatRunner{captured: &captured}
|
||||||
|
h := &AgentHandler{chatRunner: runner}
|
||||||
|
h.AgentChatCompletions(c)
|
||||||
|
|
||||||
|
got, ok := captured.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("userInput type = %T, want map[string]any", captured)
|
||||||
|
}
|
||||||
|
if got["kb"] != "da1" || got["query"] != "合同" {
|
||||||
|
t.Fatalf("userInput = %#v, want kb=da1 query=合同", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -829,10 +877,10 @@ func TestAgentChatCompletions_DerivesUserInputFromMessages(t *testing.T) {
|
|||||||
// returns an empty (closed) channel. Used to assert on argument
|
// returns an empty (closed) channel. Used to assert on argument
|
||||||
// derivation without exercising the runner.
|
// derivation without exercising the runner.
|
||||||
type captureChatRunner struct {
|
type captureChatRunner struct {
|
||||||
captured *string
|
captured *any
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *captureChatRunner) RunAgent(_ context.Context, _, _, _, _, userInput string) (<-chan canvas.RunEvent, error) {
|
func (c *captureChatRunner) RunAgent(_ context.Context, _, _, _, _ string, userInput any) (<-chan canvas.RunEvent, error) {
|
||||||
*c.captured = userInput
|
*c.captured = userInput
|
||||||
ch := make(chan canvas.RunEvent)
|
ch := make(chan canvas.RunEvent)
|
||||||
close(ch)
|
close(ch)
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/glebarez/sqlite"
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"ragflow/internal/common"
|
"ragflow/internal/common"
|
||||||
@@ -58,10 +58,10 @@ func setupUploadTestDB(t *testing.T) *gorm.DB {
|
|||||||
|
|
||||||
// fakeUploadFileService implements fileUploader for tests.
|
// fakeUploadFileService implements fileUploader for tests.
|
||||||
type fakeUploadFileService struct {
|
type fakeUploadFileService struct {
|
||||||
uploaded []map[string]interface{}
|
uploaded []map[string]interface{}
|
||||||
err error
|
err error
|
||||||
lastTenantID string
|
lastTenantID string
|
||||||
lastParentID string
|
lastParentID string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeUploadFileService) UploadFile(tenantID, parentID string, files []*multipart.FileHeader) ([]map[string]interface{}, error) {
|
func (f *fakeUploadFileService) UploadFile(tenantID, parentID string, files []*multipart.FileHeader) ([]map[string]interface{}, error) {
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ func (f *waitFakeAgentService) DeleteAgent(context.Context, string, string) erro
|
|||||||
// RunAgent mimics service.AgentService.RunAgent for the test
|
// RunAgent mimics service.AgentService.RunAgent for the test
|
||||||
// driver. It loads the canvas (a no-op in tests), builds a RunFunc
|
// driver. It loads the canvas (a no-op in tests), builds a RunFunc
|
||||||
// from the supplied stub, and hands off to the orchestrator.
|
// from the supplied stub, and hands off to the orchestrator.
|
||||||
func (f *waitFakeAgentService) RunAgent(ctx context.Context, userID, canvasID, sessionID, version, userInput string) (<-chan canvas.RunEvent, error) {
|
func (f *waitFakeAgentService) RunAgent(ctx context.Context, userID, canvasID, sessionID, version string, userInput any) (<-chan canvas.RunEvent, error) {
|
||||||
_ = ctx
|
_ = ctx
|
||||||
_ = userID
|
_ = userID
|
||||||
_ = version
|
_ = version
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -28,10 +28,10 @@ import (
|
|||||||
|
|
||||||
"ragflow/internal/common"
|
"ragflow/internal/common"
|
||||||
"ragflow/internal/engine"
|
"ragflow/internal/engine"
|
||||||
|
"ragflow/internal/engine/types"
|
||||||
"ragflow/internal/entity"
|
"ragflow/internal/entity"
|
||||||
modelModule "ragflow/internal/entity/models"
|
modelModule "ragflow/internal/entity/models"
|
||||||
"ragflow/internal/service/nlp"
|
"ragflow/internal/service/nlp"
|
||||||
"ragflow/internal/engine/types"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -135,12 +135,12 @@ type mockDocEngine struct {
|
|||||||
engine.DocEngine
|
engine.DocEngine
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockDocEngine) Close() error { return nil }
|
func (m *mockDocEngine) Close() error { return nil }
|
||||||
func (m *mockDocEngine) Ping(ctx context.Context) error { return nil }
|
func (m *mockDocEngine) Ping(ctx context.Context) error { return nil }
|
||||||
func (m *mockDocEngine) GetType() string { return "mock" }
|
func (m *mockDocEngine) GetType() string { return "mock" }
|
||||||
func (m *mockDocEngine) Search(ctx context.Context, req *types.SearchRequest) (*types.SearchResult, error) {
|
func (m *mockDocEngine) Search(ctx context.Context, req *types.SearchRequest) (*types.SearchResult, error) {
|
||||||
return &types.SearchResult{}, nil
|
return &types.SearchResult{}, nil
|
||||||
}
|
}
|
||||||
func (m *mockDocEngine) GetChunk(ctx context.Context, _, _ string, _ []string) (interface{}, error) {
|
func (m *mockDocEngine) GetChunk(ctx context.Context, _, _ string, _ []string) (interface{}, error) {
|
||||||
return map[string]interface{}{}, nil
|
return map[string]interface{}{}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -300,14 +300,14 @@ func (h *FileCommitHandler) GetCommit(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"code": common.CodeSuccess,
|
"code": common.CodeSuccess,
|
||||||
"data": gin.H{
|
"data": gin.H{
|
||||||
"id": commit.ID,
|
"id": commit.ID,
|
||||||
"folder_id": commit.FolderID,
|
"folder_id": commit.FolderID,
|
||||||
"parent_id": commit.ParentID,
|
"parent_id": commit.ParentID,
|
||||||
"message": commit.Message,
|
"message": commit.Message,
|
||||||
"author_id": commit.AuthorID,
|
"author_id": commit.AuthorID,
|
||||||
"file_count": commit.FileCount,
|
"file_count": commit.FileCount,
|
||||||
"create_time": ct,
|
"create_time": ct,
|
||||||
"files": items,
|
"files": items,
|
||||||
},
|
},
|
||||||
"message": common.CodeSuccess.Message(),
|
"message": common.CodeSuccess.Message(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -31,15 +31,15 @@ import (
|
|||||||
|
|
||||||
// mockFileCommitSvc implements FileCommitServiceInterface for testing
|
// mockFileCommitSvc implements FileCommitServiceInterface for testing
|
||||||
type mockFileCommitSvc struct {
|
type mockFileCommitSvc struct {
|
||||||
createCommitFn func(folderID, authorID, message string, changes []entity.FileChange) (*entity.FileCommit, error)
|
createCommitFn func(folderID, authorID, message string, changes []entity.FileChange) (*entity.FileCommit, error)
|
||||||
listCommitsFn func(folderID string, page, pageSize int, orderBy string, desc bool) ([]*entity.FileCommit, int64, error)
|
listCommitsFn func(folderID string, page, pageSize int, orderBy string, desc bool) ([]*entity.FileCommit, int64, error)
|
||||||
getCommitFn func(commitID string) (*entity.FileCommit, error)
|
getCommitFn func(commitID string) (*entity.FileCommit, error)
|
||||||
listCommitFilesFn func(commitID string) ([]*entity.FileCommitItem, error)
|
listCommitFilesFn func(commitID string) ([]*entity.FileCommitItem, error)
|
||||||
diffCommitsFn func(fromID, toID string) ([]entity.DiffEntry, error)
|
diffCommitsFn func(fromID, toID string) ([]entity.DiffEntry, error)
|
||||||
getUncommittedChangesFn func(folderID string) ([]entity.DiffEntry, error)
|
getUncommittedChangesFn func(folderID string) ([]entity.DiffEntry, error)
|
||||||
getCommitTreeFn func(commitID string) (map[string]interface{}, error)
|
getCommitTreeFn func(commitID string) (map[string]interface{}, error)
|
||||||
getCommitFileContentFn func(folderID, commitID, fileID string) ([]byte, error)
|
getCommitFileContentFn func(folderID, commitID, fileID string) ([]byte, error)
|
||||||
getFileVersionHistoryFn func(fileID string) ([]entity.VersionEntry, error)
|
getFileVersionHistoryFn func(fileID string) ([]entity.VersionEntry, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockFileCommitSvc) CreateCommit(folderID, authorID, message string, changes []entity.FileChange) (*entity.FileCommit, error) {
|
func (m *mockFileCommitSvc) CreateCommit(folderID, authorID, message string, changes []entity.FileChange) (*entity.FileCommit, error) {
|
||||||
|
|||||||
@@ -188,7 +188,6 @@ func mcpDetailError(c *gin.Context, code common.ErrorCode, err error) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// UpdateMCPServer updates an MCP server for the current user.
|
// UpdateMCPServer updates an MCP server for the current user.
|
||||||
func (h *MCPHandler) UpdateMCPServer(c *gin.Context) {
|
func (h *MCPHandler) UpdateMCPServer(c *gin.Context) {
|
||||||
user, errorCode, errorMessage := GetUser(c)
|
user, errorCode, errorMessage := GetUser(c)
|
||||||
|
|||||||
@@ -377,15 +377,21 @@ func jsonDecodeMessage(t *testing.T, body []byte) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func nullableInt(p *int) string {
|
func nullableInt(p *int) string {
|
||||||
if p == nil { return "nil" }
|
if p == nil {
|
||||||
|
return "nil"
|
||||||
|
}
|
||||||
return fmt.Sprintf("%d", *p)
|
return fmt.Sprintf("%d", *p)
|
||||||
}
|
}
|
||||||
func nullableBool(p *bool) string {
|
func nullableBool(p *bool) string {
|
||||||
if p == nil { return "nil" }
|
if p == nil {
|
||||||
|
return "nil"
|
||||||
|
}
|
||||||
return fmt.Sprintf("%v", *p)
|
return fmt.Sprintf("%v", *p)
|
||||||
}
|
}
|
||||||
func nullableFloat(p *float64) string {
|
func nullableFloat(p *float64) string {
|
||||||
if p == nil { return "nil" }
|
if p == nil {
|
||||||
|
return "nil"
|
||||||
|
}
|
||||||
return fmt.Sprintf("%v", *p)
|
return fmt.Sprintf("%v", *p)
|
||||||
}
|
}
|
||||||
func TestSearchBotsRetrieval_EmptyQuestion(t *testing.T) {
|
func TestSearchBotsRetrieval_EmptyQuestion(t *testing.T) {
|
||||||
@@ -404,6 +410,7 @@ func TestSearchBotsRetrieval_EmptyQuestion(t *testing.T) {
|
|||||||
t.Errorf("expected validation error mentioning Question and required, got %q", msg)
|
t.Errorf("expected validation error mentioning Question and required, got %q", msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// fakeSearchbotLLM implements searchbotLLM for testing.
|
// fakeSearchbotLLM implements searchbotLLM for testing.
|
||||||
type fakeSearchbotLLM struct {
|
type fakeSearchbotLLM struct {
|
||||||
response string
|
response string
|
||||||
@@ -899,8 +906,6 @@ func TestAskHandler_WhitespaceKbIDFiltered(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ---- SSE helper direct tests ----
|
// ---- SSE helper direct tests ----
|
||||||
|
|
||||||
func TestSseAnswer_Final(t *testing.T) {
|
func TestSseAnswer_Final(t *testing.T) {
|
||||||
|
|||||||
@@ -560,7 +560,7 @@ func (s *AgentService) DeleteVersion(ctx context.Context, userID, canvasID, vers
|
|||||||
// The per-run RunFunc is built by buildRunFunc — see its doc comment
|
// The per-run RunFunc is built by buildRunFunc — see its doc comment
|
||||||
// for the full production chain (real Compile/Invoke, resume path,
|
// for the full production chain (real Compile/Invoke, resume path,
|
||||||
// error-layering contract).
|
// error-layering contract).
|
||||||
func (s *AgentService) RunAgent(ctx context.Context, userID, canvasID, sessionID, version, userInput string) (<-chan canvas.RunEvent, error) {
|
func (s *AgentService) RunAgent(ctx context.Context, userID, canvasID, sessionID, version string, userInput any) (<-chan canvas.RunEvent, error) {
|
||||||
canvasRow, err := s.loadCanvasForUser(ctx, userID, canvasID)
|
canvasRow, err := s.loadCanvasForUser(ctx, userID, canvasID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -664,7 +664,7 @@ func (s *AgentService) RunAgent(ctx context.Context, userID, canvasID, sessionID
|
|||||||
"session_id": sessionID,
|
"session_id": sessionID,
|
||||||
"user_id": userID,
|
"user_id": userID,
|
||||||
}
|
}
|
||||||
if userInput != "" {
|
if userInput != nil {
|
||||||
root["user_input"] = userInput
|
root["user_input"] = userInput
|
||||||
}
|
}
|
||||||
if dsl != nil {
|
if dsl != nil {
|
||||||
@@ -693,7 +693,7 @@ func (s *AgentService) RunAgent(ctx context.Context, userID, canvasID, sessionID
|
|||||||
zap.String("userID", userID),
|
zap.String("userID", userID),
|
||||||
zap.String("sessionID", sessionID),
|
zap.String("sessionID", sessionID),
|
||||||
zap.Any("tenantID", root["tenant_id"]),
|
zap.Any("tenantID", root["tenant_id"]),
|
||||||
zap.Int("userInput_len", func() int { s, _ := root["user_input"].(string); return len(s) }()))
|
zap.Any("userInput", root["user_input"]))
|
||||||
|
|
||||||
return s.runner.Run(ctx, run, canvasID, sessionID, userInput, root), nil
|
return s.runner.Run(ctx, run, canvasID, sessionID, userInput, root), nil
|
||||||
}
|
}
|
||||||
@@ -755,9 +755,10 @@ func (s *AgentService) buildRunFunc(canvasID string, versionRow *entity.UserCanv
|
|||||||
|
|
||||||
startedAt := float64(time.Now().UnixNano()) / 1e9
|
startedAt := float64(time.Now().UnixNano()) / 1e9
|
||||||
|
|
||||||
userInput := ""
|
userInput := root["user_input"]
|
||||||
if v, ok := root["user_input"].(string); ok {
|
userInputText := ""
|
||||||
userInput = v
|
if v, ok := userInput.(string); ok {
|
||||||
|
userInputText = v
|
||||||
}
|
}
|
||||||
|
|
||||||
resumeID, isResume := root["__resume_interrupt_id__"].(string)
|
resumeID, isResume := root["__resume_interrupt_id__"].(string)
|
||||||
@@ -824,6 +825,9 @@ func (s *AgentService) buildRunFunc(canvasID string, versionRow *entity.UserCanv
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
state.Sys["query"] = userInput
|
state.Sys["query"] = userInput
|
||||||
|
if uid, ok := root["user_id"].(string); ok && uid != "" {
|
||||||
|
state.Sys["user_id"] = uid
|
||||||
|
}
|
||||||
if tid, ok := root["tenant_id"].(string); ok && tid != "" {
|
if tid, ok := root["tenant_id"].(string); ok && tid != "" {
|
||||||
state.Sys["tenant_id"] = tid
|
state.Sys["tenant_id"] = tid
|
||||||
}
|
}
|
||||||
@@ -853,7 +857,7 @@ func (s *AgentService) buildRunFunc(canvasID string, versionRow *entity.UserCanv
|
|||||||
|
|
||||||
if s.runTracker != nil {
|
if s.runTracker != nil {
|
||||||
_ = s.runTracker.Start(ctx2, runID, canvasID,
|
_ = s.runTracker.Start(ctx2, runID, canvasID,
|
||||||
tenantIDFromRoot(root), userInput)
|
tenantIDFromRoot(root), userInputText)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compile.
|
// Compile.
|
||||||
@@ -975,7 +979,7 @@ func (s *AgentService) buildRunFunc(canvasID string, versionRow *entity.UserCanv
|
|||||||
emit("message_end", string(meData))
|
emit("message_end", string(meData))
|
||||||
|
|
||||||
wfData, _ := json.Marshal(map[string]interface{}{
|
wfData, _ := json.Marshal(map[string]interface{}{
|
||||||
"inputs": map[string]string{"query": userInput},
|
"inputs": map[string]any{"query": userInput},
|
||||||
"outputs": answer,
|
"outputs": answer,
|
||||||
"elapsed_time": now - startedAt,
|
"elapsed_time": now - startedAt,
|
||||||
"created_at": now,
|
"created_at": now,
|
||||||
@@ -1003,7 +1007,7 @@ func (s *AgentService) buildRunFunc(canvasID string, versionRow *entity.UserCanv
|
|||||||
|
|
||||||
// Emit workflow_finished with the final outputs.
|
// Emit workflow_finished with the final outputs.
|
||||||
wfData, _ := json.Marshal(map[string]interface{}{
|
wfData, _ := json.Marshal(map[string]interface{}{
|
||||||
"inputs": map[string]string{"query": userInput},
|
"inputs": map[string]any{"query": userInput},
|
||||||
"outputs": answer,
|
"outputs": answer,
|
||||||
"elapsed_time": now - startedAt,
|
"elapsed_time": now - startedAt,
|
||||||
"created_at": now,
|
"created_at": now,
|
||||||
|
|||||||
@@ -644,9 +644,9 @@ type CreateAgentSessionRequest struct {
|
|||||||
// - user_id : caller's id
|
// - user_id : caller's id
|
||||||
// - message : JSON array (default []); GET path normalises it
|
// - message : JSON array (default []); GET path normalises it
|
||||||
// - reference : JSON object (default {}) so GET-side parsing
|
// - reference : JSON object (default {}) so GET-side parsing
|
||||||
// does not crash on .chunks
|
// does not crash on .chunks
|
||||||
// - dsl : JSON map; copied from user_canvas.dsl if the
|
// - dsl : JSON map; copied from user_canvas.dsl if the
|
||||||
// caller did not pass one
|
// caller did not pass one
|
||||||
// - create_time : unix-millis
|
// - create_time : unix-millis
|
||||||
// - update_time : unix-millis
|
// - update_time : unix-millis
|
||||||
// - create_date : local-time.Truncate(time.Second)
|
// - create_date : local-time.Truncate(time.Second)
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"ragflow/internal/engine/types"
|
|
||||||
"ragflow/internal/common"
|
"ragflow/internal/common"
|
||||||
|
"ragflow/internal/engine/types"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,11 +17,11 @@
|
|||||||
package chunk
|
package chunk
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"reflect"
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
"context"
|
|
||||||
|
|
||||||
"ragflow/internal/engine/types"
|
"ragflow/internal/engine/types"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -23,11 +23,11 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
"ragflow/internal/common"
|
"ragflow/internal/common"
|
||||||
"ragflow/internal/engine"
|
"ragflow/internal/engine"
|
||||||
"ragflow/internal/engine/types"
|
"ragflow/internal/engine/types"
|
||||||
modelModule "ragflow/internal/entity/models"
|
modelModule "ragflow/internal/entity/models"
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Pipeline encapsulates the knowledge graph retrieval pipeline.
|
// Pipeline encapsulates the knowledge graph retrieval pipeline.
|
||||||
@@ -157,7 +157,7 @@ func (p *Pipeline) Retrieval(ctx context.Context) (map[string]interface{}, error
|
|||||||
wg.Wait()
|
wg.Wait()
|
||||||
if entsErr != nil {
|
if entsErr != nil {
|
||||||
return nil, entsErr
|
return nil, entsErr
|
||||||
}// 5. N-hop analysis + score fusion
|
} // 5. N-hop analysis + score fusion
|
||||||
nhopPathes := AnalyzeNHopPaths(entsFromQuery)
|
nhopPathes := AnalyzeNHopPaths(entsFromQuery)
|
||||||
DoubleHitBoost(entsFromQuery, entsFromTypes)
|
DoubleHitBoost(entsFromQuery, entsFromTypes)
|
||||||
FuseRelationScores(relsFromText, entsFromTypes, nhopPathes)
|
FuseRelationScores(relsFromText, entsFromTypes, nhopPathes)
|
||||||
@@ -175,19 +175,19 @@ func (p *Pipeline) Retrieval(ctx context.Context) (map[string]interface{}, error
|
|||||||
|
|
||||||
// 9. Build synthetic chunk
|
// 9. Build synthetic chunk
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"chunk_id": "",
|
"chunk_id": "",
|
||||||
"content_ltks": "",
|
"content_ltks": "",
|
||||||
"content_with_weight": entsRelsContent + communityContent,
|
"content_with_weight": entsRelsContent + communityContent,
|
||||||
"doc_id": "",
|
"doc_id": "",
|
||||||
"docnm_kwd": "Related content in Knowledge Graph",
|
"docnm_kwd": "Related content in Knowledge Graph",
|
||||||
"kb_id": p.kbIDs,
|
"kb_id": p.kbIDs,
|
||||||
"important_kwd": []string{},
|
"important_kwd": []string{},
|
||||||
"image_id": "",
|
"image_id": "",
|
||||||
"similarity": 1.0,
|
"similarity": 1.0,
|
||||||
"vector_similarity": 1.0,
|
"vector_similarity": 1.0,
|
||||||
"term_similarity": 0,
|
"term_similarity": 0,
|
||||||
"vector": []float64{},
|
"vector": []float64{},
|
||||||
"positions": []interface{}{},
|
"positions": []interface{}{},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -100,9 +100,9 @@ func TestEntityFromChunk_MissingFields(t *testing.T) {
|
|||||||
|
|
||||||
func TestRelationFromChunk_Basic(t *testing.T) {
|
func TestRelationFromChunk_Basic(t *testing.T) {
|
||||||
chunk := map[string]interface{}{
|
chunk := map[string]interface{}{
|
||||||
"from_entity_kwd": "Elon Musk",
|
"from_entity_kwd": "Elon Musk",
|
||||||
"to_entity_kwd": "SpaceX",
|
"to_entity_kwd": "SpaceX",
|
||||||
"weight_int": float64(5),
|
"weight_int": float64(5),
|
||||||
"content_with_weight": "Founder",
|
"content_with_weight": "Founder",
|
||||||
}
|
}
|
||||||
edge, rel := relationFromChunk(chunk)
|
edge, rel := relationFromChunk(chunk)
|
||||||
@@ -489,7 +489,6 @@ type assertError string
|
|||||||
|
|
||||||
func (e assertError) Error() string { return string(e) }
|
func (e assertError) Error() string { return string(e) }
|
||||||
|
|
||||||
|
|
||||||
// --- indexName ---
|
// --- indexName ---
|
||||||
|
|
||||||
func TestIndexName_Normal(t *testing.T) {
|
func TestIndexName_Normal(t *testing.T) {
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"ragflow/internal/engine"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"ragflow/internal/engine"
|
||||||
"ragflow/internal/engine/types"
|
"ragflow/internal/engine/types"
|
||||||
modelModule "ragflow/internal/entity/models"
|
modelModule "ragflow/internal/entity/models"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -441,4 +441,3 @@ func TestSearchTypeSamples_WithMock(t *testing.T) {
|
|||||||
t.Errorf("expected empty, got %d", len(samples))
|
t.Errorf("expected empty, got %d", len(samples))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,39 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var providerCatalogNameByCanonical = map[string]string{
|
||||||
|
"SILICONFLOW": "SiliconFlow",
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalProviderName(name string) (string, error) {
|
||||||
|
trimmed := strings.TrimSpace(name)
|
||||||
|
if trimmed == "" {
|
||||||
|
return "", fmt.Errorf("provider name is required")
|
||||||
|
}
|
||||||
|
factoryDAO := dao.NewLLMFactoryDAO()
|
||||||
|
if _, err := factoryDAO.GetByName(trimmed); err == nil {
|
||||||
|
return trimmed, nil
|
||||||
|
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
for canonical, catalog := range providerCatalogNameByCanonical {
|
||||||
|
if trimmed == catalog {
|
||||||
|
if _, err := factoryDAO.GetByName(canonical); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return canonical, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("provider '%s' not found", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func catalogProviderName(name string) string {
|
||||||
|
if mapped, ok := providerCatalogNameByCanonical[name]; ok {
|
||||||
|
return mapped
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
// parseModelName parses a composite model name in format "model@instance@provider" or "model@provider"
|
// parseModelName parses a composite model name in format "model@instance@provider" or "model@provider"
|
||||||
// Returns modelName, instanceName, providerName separately
|
// Returns modelName, instanceName, providerName separately
|
||||||
func parseModelName(compositeName string) (modelName, instanceName, providerName string, err error) {
|
func parseModelName(compositeName string) (modelName, instanceName, providerName string, err error) {
|
||||||
@@ -102,8 +135,7 @@ type CheckConnectionRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *ModelProviderService) AddModelProvider(providerName, userID string) (common.ErrorCode, error) {
|
func (m *ModelProviderService) AddModelProvider(providerName, userID string) (common.ErrorCode, error) {
|
||||||
|
canonicalName, err := canonicalProviderName(providerName)
|
||||||
_, err := dao.GetModelProviderManager().GetProviderByName(providerName)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return common.CodeNotFound, err
|
return common.CodeNotFound, err
|
||||||
}
|
}
|
||||||
@@ -123,7 +155,7 @@ func (m *ModelProviderService) AddModelProvider(providerName, userID string) (co
|
|||||||
|
|
||||||
tenantModelProvider := &entity.TenantModelProvider{
|
tenantModelProvider := &entity.TenantModelProvider{
|
||||||
ID: providerID,
|
ID: providerID,
|
||||||
ProviderName: providerName,
|
ProviderName: canonicalName,
|
||||||
TenantID: tenantID,
|
TenantID: tenantID,
|
||||||
}
|
}
|
||||||
err = m.modelProviderDAO.Create(tenantModelProvider)
|
err = m.modelProviderDAO.Create(tenantModelProvider)
|
||||||
@@ -170,6 +202,7 @@ func (m *ModelProviderService) ListProvidersOfTenant(userID string) ([]map[strin
|
|||||||
}
|
}
|
||||||
return nil, common.CodeServerError, err
|
return nil, common.CodeServerError, err
|
||||||
}
|
}
|
||||||
|
provider["name"] = providerName
|
||||||
result = append(result, provider)
|
result = append(result, provider)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,6 +239,10 @@ func (m *ModelProviderService) DeleteModelProvider(providerName, userID string)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *ModelProviderService) ListSupportedModels(providerName, instanceName, userID string) ([]map[string]interface{}, error) {
|
func (m *ModelProviderService) ListSupportedModels(providerName, instanceName, userID string) ([]map[string]interface{}, error) {
|
||||||
|
providerName, err := canonicalProviderName(providerName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
// Get tenant ID from user
|
// Get tenant ID from user
|
||||||
tenants, err := m.userTenantDAO.GetByUserIDAndRole(userID, "owner")
|
tenants, err := m.userTenantDAO.GetByUserIDAndRole(userID, "owner")
|
||||||
@@ -230,7 +267,7 @@ func (m *ModelProviderService) ListSupportedModels(providerName, instanceName, u
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
providerInfo := dao.GetModelProviderManager().FindProvider(providerName)
|
providerInfo := dao.GetModelProviderManager().FindProvider(catalogProviderName(providerName))
|
||||||
if providerInfo == nil {
|
if providerInfo == nil {
|
||||||
return nil, fmt.Errorf("provider %s not found", providerName)
|
return nil, fmt.Errorf("provider %s not found", providerName)
|
||||||
}
|
}
|
||||||
@@ -280,6 +317,10 @@ func (m *ModelProviderService) ListSupportedModels(providerName, instanceName, u
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *ModelProviderService) CreateProviderInstance(providerName, instanceName, apiKey, baseURL, region, userID string) (common.ErrorCode, error) {
|
func (m *ModelProviderService) CreateProviderInstance(providerName, instanceName, apiKey, baseURL, region, userID string) (common.ErrorCode, error) {
|
||||||
|
providerName, err := canonicalProviderName(providerName)
|
||||||
|
if err != nil {
|
||||||
|
return common.CodeNotFound, err
|
||||||
|
}
|
||||||
// Get tenant ID from user
|
// Get tenant ID from user
|
||||||
tenants, err := m.userTenantDAO.GetByUserIDAndRole(userID, "owner")
|
tenants, err := m.userTenantDAO.GetByUserIDAndRole(userID, "owner")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -327,6 +368,10 @@ func (m *ModelProviderService) CreateProviderInstance(providerName, instanceName
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *ModelProviderService) ListProviderInstances(providerName, userID string) ([]map[string]interface{}, common.ErrorCode, error) {
|
func (m *ModelProviderService) ListProviderInstances(providerName, userID string) ([]map[string]interface{}, common.ErrorCode, error) {
|
||||||
|
providerName, err := canonicalProviderName(providerName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, common.CodeNotFound, err
|
||||||
|
}
|
||||||
|
|
||||||
// Get tenant ID from user
|
// Get tenant ID from user
|
||||||
tenants, err := m.userTenantDAO.GetByUserIDAndRole(userID, "owner")
|
tenants, err := m.userTenantDAO.GetByUserIDAndRole(userID, "owner")
|
||||||
|
|||||||
@@ -52,19 +52,19 @@ func TestQueryBuilder_IsChinese(t *testing.T) {
|
|||||||
{"Single Chinese char", "中", true},
|
{"Single Chinese char", "中", true},
|
||||||
{"Two Chinese chars", "中文", true},
|
{"Two Chinese chars", "中文", true},
|
||||||
{"Three Chinese chars", "中文字", true},
|
{"Three Chinese chars", "中文字", true},
|
||||||
{"Four Chinese chars", "中文字符", true}, // ratio >=0.7
|
{"Four Chinese chars", "中文字符", true}, // ratio >=0.7
|
||||||
{"Mixed with English", "hello world", true}, // fields=2 <=3
|
{"Mixed with English", "hello world", true}, // fields=2 <=3
|
||||||
{"Mostly Chinese", "hello 世界 测试", true}, // fields=3 <=3
|
{"Mostly Chinese", "hello 世界 测试", true}, // fields=3 <=3
|
||||||
{"Mostly English", "hello world test", true}, // fields=3 <=3
|
{"Mostly English", "hello world test", true}, // fields=3 <=3
|
||||||
{"English with punctuation", "Hello, world!", true}, // fields=2 <=3 (after split)
|
{"English with punctuation", "Hello, world!", true}, // fields=2 <=3 (after split)
|
||||||
{"Chinese with spaces", "这 是 一个 测试", true}, // fields=4, non-alpha=4, ratio=1 >=0.7
|
{"Chinese with spaces", "这 是 一个 测试", true}, // fields=4, non-alpha=4, ratio=1 >=0.7
|
||||||
{"Mixed with numbers", "123 abc", true}, // fields=2 <=3
|
{"Mixed with numbers", "123 abc", true}, // fields=2 <=3
|
||||||
// Additional cases where fields >3 and ratio determines result
|
// Additional cases where fields >3 and ratio determines result
|
||||||
{"Many English words", "this is a long english sentence", false}, // fields=6, non-alpha=0, ratio=0 <0.7
|
{"Many English words", "this is a long english sentence", false}, // fields=6, non-alpha=0, ratio=0 <0.7
|
||||||
{"Mixed with mostly Chinese", "hello world 中文 测试 多个", false}, // fields=5, non-alpha=3, ratio=0.6 <0.7 => false
|
{"Mixed with mostly Chinese", "hello world 中文 测试 多个", false}, // fields=5, non-alpha=3, ratio=0.6 <0.7 => false
|
||||||
{"Mostly Chinese with many words", "这 是 一个 中文 测试 多个 汉字", true}, // fields=7, non-alpha=7, ratio=1 >=0.7
|
{"Mostly Chinese with many words", "这 是 一个 中文 测试 多个 汉字", true}, // fields=7, non-alpha=7, ratio=1 >=0.7
|
||||||
{"English with Chinese suffix", "hello world 中文", true}, // fields=3 <=3
|
{"English with Chinese suffix", "hello world 中文", true}, // fields=3 <=3
|
||||||
{"Chinese with English suffix", "中文 test", true}, // fields=2 <=3
|
{"Chinese with English suffix", "中文 test", true}, // fields=2 <=3
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
@@ -119,12 +119,12 @@ func TestQueryBuilder_RmWWW(t *testing.T) {
|
|||||||
{"No stop words", "普通文本", "普通文本"},
|
{"No stop words", "普通文本", "普通文本"},
|
||||||
{"Chinese question word", "请问如何操作", "操作"}, // "请问" and "如何" both matched
|
{"Chinese question word", "请问如何操作", "操作"}, // "请问" and "如何" both matched
|
||||||
{"Chinese stop word 怎么办", "怎么办安装", "安装"},
|
{"Chinese stop word 怎么办", "怎么办安装", "安装"},
|
||||||
{"English what", "what is this", " this"}, // removes "what " and "is "
|
{"English what", "what is this", " this"}, // removes "what " and "is "
|
||||||
{"English who", "who are you", " you"}, // removes "who " and "are "
|
{"English who", "who are you", " you"}, // removes "who " and "are "
|
||||||
{"Mixed stop words", "请问what is the problem", " the problem"}, // Chinese removed, "what ", "is " removed
|
{"Mixed stop words", "请问what is the problem", " the problem"}, // Chinese removed, "what ", "is " removed
|
||||||
{"All removed becomes empty", "请问", "请问"}, // should revert to original
|
{"All removed becomes empty", "请问", "请问"}, // should revert to original
|
||||||
{"English articles", "the cat is on a mat", " cat on mat"}, // removes "the ", "is ", "a "
|
{"English articles", "the cat is on a mat", " cat on mat"}, // removes "the ", "is ", "a "
|
||||||
{"Case insensitive", "WHAT IS THIS", " THIS"}, // removes "WHAT " and "IS "
|
{"Case insensitive", "WHAT IS THIS", " THIS"}, // removes "WHAT " and "IS "
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
@@ -181,7 +181,7 @@ func TestQueryBuilder_StrFullWidth2HalfWidth(t *testing.T) {
|
|||||||
{"Chinese characters unchanged", "你好世界", "你好世界"},
|
{"Chinese characters unchanged", "你好世界", "你好世界"},
|
||||||
{"Japanese characters unchanged", "こんにちは", "こんにちは"},
|
{"Japanese characters unchanged", "こんにちは", "こんにちは"},
|
||||||
{"Korean characters unchanged", "안녕하세요", "안녕하세요"},
|
{"Korean characters unchanged", "안녕하세요", "안녕하세요"},
|
||||||
{"Full-width symbols outside range", "@@@", "@@@"}, // Actually full-width '@' is U+FF20 which maps to U+0040
|
{"Full-width symbols outside range", "@@@", "@@@"}, // Actually full-width '@' is U+FF20 which maps to U+0040
|
||||||
{"Edge case: character just below range", "\u001F", "\u001F"}, // U+001F is < 0x0020, should remain
|
{"Edge case: character just below range", "\u001F", "\u001F"}, // U+001F is < 0x0020, should remain
|
||||||
{"Edge case: character just above range", "\u007F", "\u007F"}, // U+007F is > 0x7E, should remain
|
{"Edge case: character just above range", "\u007F", "\u007F"}, // U+007F is > 0x7E, should remain
|
||||||
}
|
}
|
||||||
@@ -224,12 +224,12 @@ func TestQueryBuilder_Traditional2Simplified(t *testing.T) {
|
|||||||
func TestQueryBuilder_Question(t *testing.T) {
|
func TestQueryBuilder_Question(t *testing.T) {
|
||||||
qb := NewQueryBuilder()
|
qb := NewQueryBuilder()
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
txt string
|
txt string
|
||||||
tbl string
|
tbl string
|
||||||
minMatch float64
|
minMatch float64
|
||||||
expectNil bool
|
expectNil bool
|
||||||
checkExpr func(*types.MatchTextExpr) bool
|
checkExpr func(*types.MatchTextExpr) bool
|
||||||
checkKeywords func([]string) bool
|
checkKeywords func([]string) bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
@@ -275,10 +275,10 @@ func TestQueryBuilder_Question(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Empty text",
|
name: "Empty text",
|
||||||
txt: "",
|
txt: "",
|
||||||
tbl: "test",
|
tbl: "test",
|
||||||
minMatch: 0.5,
|
minMatch: 0.5,
|
||||||
expectNil: true,
|
expectNil: true,
|
||||||
checkExpr: func(expr *types.MatchTextExpr) bool {
|
checkExpr: func(expr *types.MatchTextExpr) bool {
|
||||||
return expr == nil
|
return expr == nil
|
||||||
@@ -310,59 +310,59 @@ func TestQueryBuilder_Question(t *testing.T) {
|
|||||||
func TestQueryBuilder_Paragraph(t *testing.T) {
|
func TestQueryBuilder_Paragraph(t *testing.T) {
|
||||||
qb := NewQueryBuilder()
|
qb := NewQueryBuilder()
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
contentTks string
|
contentTks string
|
||||||
keywords []string
|
keywords []string
|
||||||
keywordsTopN int
|
keywordsTopN int
|
||||||
expectedQuery string
|
expectedQuery string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "No keywords",
|
name: "No keywords",
|
||||||
contentTks: "some content terms",
|
contentTks: "some content terms",
|
||||||
keywords: []string{},
|
keywords: []string{},
|
||||||
keywordsTopN: 0,
|
keywordsTopN: 0,
|
||||||
expectedQuery: "",
|
expectedQuery: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Single keyword",
|
name: "Single keyword",
|
||||||
contentTks: "content",
|
contentTks: "content",
|
||||||
keywords: []string{"hello"},
|
keywords: []string{"hello"},
|
||||||
keywordsTopN: 0,
|
keywordsTopN: 0,
|
||||||
expectedQuery: `"hello"`,
|
expectedQuery: `"hello"`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Multiple keywords",
|
name: "Multiple keywords",
|
||||||
contentTks: "content",
|
contentTks: "content",
|
||||||
keywords: []string{"hello", "world", "test"},
|
keywords: []string{"hello", "world", "test"},
|
||||||
keywordsTopN: 0,
|
keywordsTopN: 0,
|
||||||
expectedQuery: `"hello" "world" "test"`,
|
expectedQuery: `"hello" "world" "test"`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Trim spaces",
|
name: "Trim spaces",
|
||||||
contentTks: "",
|
contentTks: "",
|
||||||
keywords: []string{" hello ", " world "},
|
keywords: []string{" hello ", " world "},
|
||||||
keywordsTopN: 0,
|
keywordsTopN: 0,
|
||||||
expectedQuery: `"hello" "world"`,
|
expectedQuery: `"hello" "world"`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "TopN limit",
|
name: "TopN limit",
|
||||||
contentTks: "",
|
contentTks: "",
|
||||||
keywords: []string{"a", "b", "c", "d", "e"},
|
keywords: []string{"a", "b", "c", "d", "e"},
|
||||||
keywordsTopN: 3,
|
keywordsTopN: 3,
|
||||||
expectedQuery: `"a" "b" "c"`,
|
expectedQuery: `"a" "b" "c"`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "TopN larger than slice",
|
name: "TopN larger than slice",
|
||||||
contentTks: "",
|
contentTks: "",
|
||||||
keywords: []string{"a", "b"},
|
keywords: []string{"a", "b"},
|
||||||
keywordsTopN: 10,
|
keywordsTopN: 10,
|
||||||
expectedQuery: `"a" "b"`,
|
expectedQuery: `"a" "b"`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Empty keyword filtered",
|
name: "Empty keyword filtered",
|
||||||
contentTks: "",
|
contentTks: "",
|
||||||
keywords: []string{"a", "", "b"},
|
keywords: []string{"a", "", "b"},
|
||||||
keywordsTopN: 0,
|
keywordsTopN: 0,
|
||||||
expectedQuery: `"a" "b"`,
|
expectedQuery: `"a" "b"`,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -442,4 +442,4 @@ func TestQueryBuilder_SetQueryFields(t *testing.T) {
|
|||||||
if !reflect.DeepEqual(expr.Fields, newFields) {
|
if !reflect.DeepEqual(expr.Fields, newFields) {
|
||||||
t.Errorf("Paragraph fields not updated after SetQueryFields, got %v, want %v", expr.Fields, newFields)
|
t.Errorf("Paragraph fields not updated after SetQueryFields, got %v, want %v", expr.Fields, newFields)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,12 +17,12 @@ package nlp
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
"sync/atomic"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"ragflow/internal/common"
|
"ragflow/internal/common"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|||||||
@@ -102,10 +102,10 @@ func TestNewSynonymWithMockFile(t *testing.T) {
|
|||||||
|
|
||||||
// Create mock synonym.json
|
// Create mock synonym.json
|
||||||
synonymData := map[string]interface{}{
|
synonymData := map[string]interface{}{
|
||||||
"happy": []string{"joyful", "cheerful", "glad"},
|
"happy": []string{"joyful", "cheerful", "glad"},
|
||||||
"sad": []string{"unhappy", "sorrowful"},
|
"sad": []string{"unhappy", "sorrowful"},
|
||||||
"test": "single", // Test string value
|
"test": "single", // Test string value
|
||||||
"UPPER": []string{"lower"}, // Test case conversion
|
"UPPER": []string{"lower"}, // Test case conversion
|
||||||
}
|
}
|
||||||
data, _ := json.Marshal(synonymData)
|
data, _ := json.Marshal(synonymData)
|
||||||
if err := os.WriteFile(filepath.Join(tmpDir, "synonym.json"), data, 0644); err != nil {
|
if err := os.WriteFile(filepath.Join(tmpDir, "synonym.json"), data, 0644); err != nil {
|
||||||
@@ -238,7 +238,7 @@ func TestSynonymLoad(t *testing.T) {
|
|||||||
s := NewSynonym(redis, tmpDir, testSynonymWordNetDir)
|
s := NewSynonym(redis, tmpDir, testSynonymWordNetDir)
|
||||||
|
|
||||||
// Simulate multiple lookups to trigger load
|
// Simulate multiple lookups to trigger load
|
||||||
s.lookupNum.Store(200) // Set above threshold
|
s.lookupNum.Store(200) // Set above threshold
|
||||||
s.loadTm = time.Now().Add(-4000 * time.Second) // Set load time > 1 hour ago
|
s.loadTm = time.Now().Add(-4000 * time.Second) // Set load time > 1 hour ago
|
||||||
|
|
||||||
// Call load directly
|
// Call load directly
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ func TestTokenMerge(t *testing.T) {
|
|||||||
result := d.TokenMerge(tt.tks)
|
result := d.TokenMerge(tt.tks)
|
||||||
if !reflect.DeepEqual(result, tt.expected) {
|
if !reflect.DeepEqual(result, tt.expected) {
|
||||||
// Debug: print detailed comparison
|
// Debug: print detailed comparison
|
||||||
t.Errorf("TokenMerge(%v) = %v (len=%d), expected %v (len=%d)",
|
t.Errorf("TokenMerge(%v) = %v (len=%d), expected %v (len=%d)",
|
||||||
tt.tks, result, len(result), tt.expected, len(tt.expected))
|
tt.tks, result, len(result), tt.expected, len(tt.expected))
|
||||||
for i, r := range result {
|
for i, r := range result {
|
||||||
t.Errorf(" result[%d] = %q (len=%d)", i, r, len(r))
|
t.Errorf(" result[%d] = %q (len=%d)", i, r, len(r))
|
||||||
@@ -250,8 +250,8 @@ func TestSplit(t *testing.T) {
|
|||||||
expected []string
|
expected []string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "simple split",
|
name: "simple split",
|
||||||
txt: "hello world test",
|
txt: "hello world test",
|
||||||
// Consecutive English words ending with letters are merged
|
// Consecutive English words ending with letters are merged
|
||||||
expected: []string{"hello world test"},
|
expected: []string{"hello world test"},
|
||||||
},
|
},
|
||||||
@@ -261,8 +261,8 @@ func TestSplit(t *testing.T) {
|
|||||||
expected: []string{"machine learning algorithm"}, // Should merge
|
expected: []string{"machine learning algorithm"}, // Should merge
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "mixed Chinese and English",
|
name: "mixed Chinese and English",
|
||||||
txt: "hello 世界 world",
|
txt: "hello 世界 world",
|
||||||
// "hello" ends with letter, "世界" doesn't start with letter but doesn't end with letter either
|
// "hello" ends with letter, "世界" doesn't start with letter but doesn't end with letter either
|
||||||
expected: []string{"hello", "世界", "world"},
|
expected: []string{"hello", "世界", "world"},
|
||||||
},
|
},
|
||||||
@@ -272,8 +272,8 @@ func TestSplit(t *testing.T) {
|
|||||||
expected: []string{""},
|
expected: []string{""},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "multiple spaces",
|
name: "multiple spaces",
|
||||||
txt: "hello world",
|
txt: "hello world",
|
||||||
// Multiple spaces are normalized, then merged if both end with letters
|
// Multiple spaces are normalized, then merged if both end with letters
|
||||||
expected: []string{"hello world"},
|
expected: []string{"hello world"},
|
||||||
},
|
},
|
||||||
@@ -283,7 +283,7 @@ func TestSplit(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
result := d.Split(tt.txt)
|
result := d.Split(tt.txt)
|
||||||
if !reflect.DeepEqual(result, tt.expected) {
|
if !reflect.DeepEqual(result, tt.expected) {
|
||||||
t.Errorf("Split('%s') = %v (len=%d), expected %v (len=%d)",
|
t.Errorf("Split('%s') = %v (len=%d), expected %v (len=%d)",
|
||||||
tt.txt, result, len(result), tt.expected, len(tt.expected))
|
tt.txt, result, len(result), tt.expected, len(tt.expected))
|
||||||
for i, r := range result {
|
for i, r := range result {
|
||||||
t.Errorf(" result[%d] = %q", i, r)
|
t.Errorf(" result[%d] = %q", i, r)
|
||||||
|
|||||||
@@ -17,8 +17,8 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
_ "unsafe"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
_ "unsafe"
|
||||||
|
|
||||||
"github.com/glebarez/sqlite"
|
"github.com/glebarez/sqlite"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|||||||
@@ -246,4 +246,4 @@ func ExtractVisibleAnswer(raw string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -253,4 +253,4 @@ func TestStreamThinkTagDelta_NoThinkTags(t *testing.T) {
|
|||||||
if joined != "just plain text" {
|
if joined != "just plain text" {
|
||||||
t.Errorf("got %q, want 'just plain text'", joined)
|
t.Errorf("got %q, want 'just plain text'", joined)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,14 +51,14 @@ import (
|
|||||||
// so a scale of 4 produces 20x28 glyphs, which are ~16x16 px after
|
// so a scale of 4 produces 20x28 glyphs, which are ~16x16 px after
|
||||||
// padding — comfortably readable for humans at typical browser zoom.
|
// padding — comfortably readable for humans at typical browser zoom.
|
||||||
const (
|
const (
|
||||||
captchaPNGScale = 4
|
captchaPNGScale = 4
|
||||||
captchaGlyphW = 5
|
captchaGlyphW = 5
|
||||||
captchaGlyphH = 7
|
captchaGlyphH = 7
|
||||||
captchaCharSpacing = 4 // px between glyphs (after scaling)
|
captchaCharSpacing = 4 // px between glyphs (after scaling)
|
||||||
captchaSidePadding = 8
|
captchaSidePadding = 8
|
||||||
captchaTopPadding = 6
|
captchaTopPadding = 6
|
||||||
captchaNoiseDots = 60
|
captchaNoiseDots = 60
|
||||||
captchaNoiseLines = 4
|
captchaNoiseLines = 4
|
||||||
)
|
)
|
||||||
|
|
||||||
// font5x7 maps a single character to its 7-row bitmap. Each row is a
|
// font5x7 maps a single character to its 7-row bitmap. Each row is a
|
||||||
|
|||||||
@@ -350,4 +350,4 @@ func FloatToString(f float64) string {
|
|||||||
s = s + ".0"
|
s = s + ".0"
|
||||||
}
|
}
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-21
@@ -32,23 +32,23 @@ import (
|
|||||||
|
|
||||||
// Forgot-password constants — match api/utils/web_utils.py.
|
// Forgot-password constants — match api/utils/web_utils.py.
|
||||||
const (
|
const (
|
||||||
OTPLength = 4
|
OTPLength = 4
|
||||||
OTPTTL = 5 * time.Minute
|
OTPTTL = 5 * time.Minute
|
||||||
OTPAttemptLimit = 5
|
OTPAttemptLimit = 5
|
||||||
OTPAttemptLockDuration = 30 * time.Minute
|
OTPAttemptLockDuration = 30 * time.Minute
|
||||||
OTPResendCooldown = 60 * time.Second
|
OTPResendCooldown = 60 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
// otpUpperAlphabet is the OTP alphabet (uppercase letters, same as
|
// otpUpperAlphabet is the OTP alphabet (uppercase letters, same as
|
||||||
// Python ``string.ascii_uppercase``).
|
// Python “string.ascii_uppercase“).
|
||||||
const otpUpperAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
const otpUpperAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||||
|
|
||||||
// captchaAlphabet is the captcha alphabet (uppercase letters + digits,
|
// captchaAlphabet is the captcha alphabet (uppercase letters + digits,
|
||||||
// same as Python ``string.ascii_uppercase + string.digits``).
|
// same as Python “string.ascii_uppercase + string.digits“).
|
||||||
const captchaAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
const captchaAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||||
|
|
||||||
// normalizeEmail lowercases and trims an email address for keying. Mirrors
|
// normalizeEmail lowercases and trims an email address for keying. Mirrors
|
||||||
// the leading ``email = (email or "").strip().lower()`` in Python's
|
// the leading “email = (email or "").strip().lower()“ in Python's
|
||||||
// otp_keys helper.
|
// otp_keys helper.
|
||||||
func normalizeEmail(email string) string {
|
func normalizeEmail(email string) string {
|
||||||
return strings.ToLower(strings.TrimSpace(email))
|
return strings.ToLower(strings.TrimSpace(email))
|
||||||
@@ -58,7 +58,7 @@ func normalizeEmail(email string) string {
|
|||||||
// for a server-issued captcha_id. The handler returns the id to the
|
// for a server-issued captcha_id. The handler returns the id to the
|
||||||
// client and never the code itself, so an attacker cannot read the
|
// client and never the code itself, so an attacker cannot read the
|
||||||
// expected answer from the response. Diverges from Python's
|
// expected answer from the response. Diverges from Python's
|
||||||
// email-keyed ``captcha_key`` on purpose — captchas are 60s-lived
|
// email-keyed “captcha_key“ on purpose — captchas are 60s-lived
|
||||||
// and never cross between Go and Python in practice, so there is no
|
// and never cross between Go and Python in practice, so there is no
|
||||||
// shared-state requirement.
|
// shared-state requirement.
|
||||||
func CaptchaIDRedisKey(captchaID string) string {
|
func CaptchaIDRedisKey(captchaID string) string {
|
||||||
@@ -66,7 +66,7 @@ func CaptchaIDRedisKey(captchaID string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// OTPRedisKeys returns the four Redis keys used by the forgot-password
|
// OTPRedisKeys returns the four Redis keys used by the forgot-password
|
||||||
// flow, in the same order as Python's ``otp_keys`` helper:
|
// flow, in the same order as Python's “otp_keys“ helper:
|
||||||
//
|
//
|
||||||
// code, attempts, last_sent, lock
|
// code, attempts, last_sent, lock
|
||||||
func OTPRedisKeys(email string) (codeKey, attemptsKey, lastSentKey, lockKey string) {
|
func OTPRedisKeys(email string) (codeKey, attemptsKey, lastSentKey, lockKey string) {
|
||||||
@@ -79,13 +79,13 @@ func OTPRedisKeys(email string) (codeKey, attemptsKey, lastSentKey, lockKey stri
|
|||||||
|
|
||||||
// OTPVerifiedRedisKey returns the Redis key that records a successful OTP
|
// OTPVerifiedRedisKey returns the Redis key that records a successful OTP
|
||||||
// verification, used as the gate for the password-reset step (matches
|
// verification, used as the gate for the password-reset step (matches
|
||||||
// Python ``_verified_key``).
|
// Python “_verified_key“).
|
||||||
func OTPVerifiedRedisKey(email string) string {
|
func OTPVerifiedRedisKey(email string) string {
|
||||||
return "otp:verified:" + normalizeEmail(email)
|
return "otp:verified:" + normalizeEmail(email)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HashOTPCode computes the HMAC-SHA256 of an OTP using the given salt and
|
// HashOTPCode computes the HMAC-SHA256 of an OTP using the given salt and
|
||||||
// returns its hex digest, matching Python's ``hash_code`` helper.
|
// returns its hex digest, matching Python's “hash_code“ helper.
|
||||||
func HashOTPCode(code string, salt []byte) string {
|
func HashOTPCode(code string, salt []byte) string {
|
||||||
mac := hmac.New(sha256.New, salt)
|
mac := hmac.New(sha256.New, salt)
|
||||||
mac.Write([]byte(code))
|
mac.Write([]byte(code))
|
||||||
@@ -93,7 +93,7 @@ func HashOTPCode(code string, salt []byte) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GenerateOTPSalt returns a cryptographically random 16-byte salt for
|
// GenerateOTPSalt returns a cryptographically random 16-byte salt for
|
||||||
// hashing an OTP — same width as Python ``os.urandom(16)``.
|
// hashing an OTP — same width as Python “os.urandom(16)“.
|
||||||
func GenerateOTPSalt() ([]byte, error) {
|
func GenerateOTPSalt() ([]byte, error) {
|
||||||
salt := make([]byte, 16)
|
salt := make([]byte, 16)
|
||||||
if _, err := rand.Read(salt); err != nil {
|
if _, err := rand.Read(salt); err != nil {
|
||||||
@@ -102,22 +102,22 @@ func GenerateOTPSalt() ([]byte, error) {
|
|||||||
return salt, nil
|
return salt, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateOTPCode generates an OTP of length ``OTPLength`` drawn uniformly
|
// GenerateOTPCode generates an OTP of length “OTPLength“ drawn uniformly
|
||||||
// from ``otpUpperAlphabet`` using crypto/rand (matches Python
|
// from “otpUpperAlphabet“ using crypto/rand (matches Python
|
||||||
// ``secrets.choice``).
|
// “secrets.choice“).
|
||||||
func GenerateOTPCode() (string, error) {
|
func GenerateOTPCode() (string, error) {
|
||||||
return randomStringFromAlphabet(otpUpperAlphabet, OTPLength)
|
return randomStringFromAlphabet(otpUpperAlphabet, OTPLength)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateCaptchaCode generates a captcha of length ``OTPLength`` drawn
|
// GenerateCaptchaCode generates a captcha of length “OTPLength“ drawn
|
||||||
// uniformly from ``captchaAlphabet`` using crypto/rand. The shared length
|
// uniformly from “captchaAlphabet“ using crypto/rand. The shared length
|
||||||
// is intentional — Python uses ``OTP_LENGTH`` for both.
|
// is intentional — Python uses “OTP_LENGTH“ for both.
|
||||||
func GenerateCaptchaCode() (string, error) {
|
func GenerateCaptchaCode() (string, error) {
|
||||||
return randomStringFromAlphabet(captchaAlphabet, OTPLength)
|
return randomStringFromAlphabet(captchaAlphabet, OTPLength)
|
||||||
}
|
}
|
||||||
|
|
||||||
// EncodeOTPStorageValue serializes the (hash, salt) pair the way Python
|
// EncodeOTPStorageValue serializes the (hash, salt) pair the way Python
|
||||||
// stores it in Redis: ``"<hex_hash>:<hex_salt>"``. Returning the salt's
|
// stores it in Redis: “"<hex_hash>:<hex_salt>"“. Returning the salt's
|
||||||
// hex form (not raw bytes) keeps the value safe to store as a Redis
|
// hex form (not raw bytes) keeps the value safe to store as a Redis
|
||||||
// string and matches the Python encoding so either backend can verify a
|
// string and matches the Python encoding so either backend can verify a
|
||||||
// code minted by the other.
|
// code minted by the other.
|
||||||
@@ -125,7 +125,7 @@ func EncodeOTPStorageValue(codeHash string, salt []byte) string {
|
|||||||
return codeHash + ":" + hex.EncodeToString(salt)
|
return codeHash + ":" + hex.EncodeToString(salt)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DecodeOTPStorageValue reverses ``EncodeOTPStorageValue``. Returns the
|
// DecodeOTPStorageValue reverses “EncodeOTPStorageValue“. Returns the
|
||||||
// stored hash, decoded salt bytes, and a non-nil error if the value is
|
// stored hash, decoded salt bytes, and a non-nil error if the value is
|
||||||
// malformed.
|
// malformed.
|
||||||
func DecodeOTPStorageValue(stored string) (codeHash string, salt []byte, err error) {
|
func DecodeOTPStorageValue(stored string) (codeHash string, salt []byte, err error) {
|
||||||
|
|||||||
+9
-3
@@ -112,7 +112,7 @@ dependencies = [
|
|||||||
"en-core-web-sm @ https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl",
|
"en-core-web-sm @ https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl",
|
||||||
"slack-sdk==3.37.0",
|
"slack-sdk==3.37.0",
|
||||||
"socksio==1.0.0",
|
"socksio==1.0.0",
|
||||||
"agentrun-sdk>=0.0.16,<1.0.0",
|
"agentrun-sdk>=0.0.51,<1.0.0",
|
||||||
"nest-asyncio>=1.6.0,<2.0.0", # Needed for agent/component/message.py
|
"nest-asyncio>=1.6.0,<2.0.0", # Needed for agent/component/message.py
|
||||||
"sqlglotrs==0.9.0",
|
"sqlglotrs==0.9.0",
|
||||||
"tavily-python==0.5.1",
|
"tavily-python==0.5.1",
|
||||||
@@ -238,13 +238,19 @@ packages = [
|
|||||||
'agent',
|
'agent',
|
||||||
'api',
|
'api',
|
||||||
'deepdoc',
|
'deepdoc',
|
||||||
'graphrag',
|
'rag.graphrag',
|
||||||
'intergrations.chatgpt-on-wechat.plugins',
|
'tools.chatgpt_on_wechat.plugins',
|
||||||
'mcp.server',
|
'mcp.server',
|
||||||
'rag',
|
'rag',
|
||||||
'sdk.python.ragflow_sdk',
|
'sdk.python.ragflow_sdk',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# tools/ holds standalone Python projects whose names contain hyphens
|
||||||
|
# (chatgpt-on-wechat). Python package names cannot contain hyphens, so
|
||||||
|
# we map the underscored Python name to the hyphenated filesystem path.
|
||||||
|
[tool.setuptools.package-dir]
|
||||||
|
"tools.chatgpt_on_wechat.plugins" = "tools/chatgpt-on-wechat/plugins"
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
line-length = 200
|
line-length = 200
|
||||||
exclude = [".venv", "rag/svr/discord_svr.py"]
|
exclude = [".venv", "rag/svr/discord_svr.py"]
|
||||||
|
|||||||
@@ -673,13 +673,13 @@ def test_dataset_update_identifier_validation_contract(rest_client):
|
|||||||
assert not_uuid_res.status_code == 200
|
assert not_uuid_res.status_code == 200
|
||||||
not_uuid_payload = not_uuid_res.json()
|
not_uuid_payload = not_uuid_res.json()
|
||||||
assert not_uuid_payload["code"] == 101, not_uuid_payload
|
assert not_uuid_payload["code"] == 101, not_uuid_payload
|
||||||
assert "Invalid UUID1 format" in not_uuid_payload["message"], not_uuid_payload
|
assert "Invalid UUID format" in not_uuid_payload["message"], not_uuid_payload
|
||||||
|
|
||||||
not_uuid1_res = rest_client.put(f"/datasets/{uuid.uuid4().hex}", json=payload)
|
not_uuid1_res = rest_client.put(f"/datasets/{uuid.uuid4().hex}", json=payload)
|
||||||
assert not_uuid1_res.status_code == 200
|
assert not_uuid1_res.status_code == 200
|
||||||
not_uuid1_payload = not_uuid1_res.json()
|
not_uuid1_payload = not_uuid1_res.json()
|
||||||
assert not_uuid1_payload["code"] == 101, not_uuid1_payload
|
assert not_uuid1_payload["code"] == 102, not_uuid1_payload
|
||||||
assert "Invalid UUID1 format" in not_uuid1_payload["message"], not_uuid1_payload
|
assert "lacks permission for dataset" in not_uuid1_payload["message"], not_uuid1_payload
|
||||||
|
|
||||||
wrong_uuid_res = rest_client.put("/datasets/d94a8dc02c9711f0930f7fbc369eab6d", json=payload)
|
wrong_uuid_res = rest_client.put("/datasets/d94a8dc02c9711f0930f7fbc369eab6d", json=payload)
|
||||||
assert wrong_uuid_res.status_code == 200
|
assert wrong_uuid_res.status_code == 200
|
||||||
@@ -1835,13 +1835,13 @@ def test_dataset_delete_contract_matrix(rest_client, clear_datasets):
|
|||||||
assert id_not_uuid_res.status_code == 200
|
assert id_not_uuid_res.status_code == 200
|
||||||
id_not_uuid_payload = id_not_uuid_res.json()
|
id_not_uuid_payload = id_not_uuid_res.json()
|
||||||
assert id_not_uuid_payload["code"] == 101, id_not_uuid_payload
|
assert id_not_uuid_payload["code"] == 101, id_not_uuid_payload
|
||||||
assert "Invalid UUID1 format" in id_not_uuid_payload["message"], id_not_uuid_payload
|
assert "Invalid UUID format" in id_not_uuid_payload["message"], id_not_uuid_payload
|
||||||
|
|
||||||
id_not_uuid1_res = rest_client.delete("/datasets", json={"ids": [uuid.uuid4().hex]})
|
id_not_uuid1_res = rest_client.delete("/datasets", json={"ids": [uuid.uuid4().hex]})
|
||||||
assert id_not_uuid1_res.status_code == 200
|
assert id_not_uuid1_res.status_code == 200
|
||||||
id_not_uuid1_payload = id_not_uuid1_res.json()
|
id_not_uuid1_payload = id_not_uuid1_res.json()
|
||||||
assert id_not_uuid1_payload["code"] == 101, id_not_uuid1_payload
|
assert id_not_uuid1_payload["code"] == 102, id_not_uuid1_payload
|
||||||
assert "Invalid UUID1 format" in id_not_uuid1_payload["message"], id_not_uuid1_payload
|
assert "lacks permission for dataset" in id_not_uuid1_payload["message"], id_not_uuid1_payload
|
||||||
|
|
||||||
id_wrong_uuid_res = rest_client.delete("/datasets", json={"ids": ["d94a8dc02c9711f0930f7fbc369eab6d"]})
|
id_wrong_uuid_res = rest_client.delete("/datasets", json={"ids": ["d94a8dc02c9711f0930f7fbc369eab6d"]})
|
||||||
assert id_wrong_uuid_res.status_code == 200
|
assert id_wrong_uuid_res.status_code == 200
|
||||||
@@ -2113,13 +2113,13 @@ def test_dataset_list_query_contract_matrix(rest_client, clear_datasets):
|
|||||||
assert id_not_uuid_res.status_code == 200
|
assert id_not_uuid_res.status_code == 200
|
||||||
id_not_uuid_payload = id_not_uuid_res.json()
|
id_not_uuid_payload = id_not_uuid_res.json()
|
||||||
assert id_not_uuid_payload["code"] == 101, id_not_uuid_payload
|
assert id_not_uuid_payload["code"] == 101, id_not_uuid_payload
|
||||||
assert "Invalid UUID1 format" in id_not_uuid_payload["message"], id_not_uuid_payload
|
assert "Invalid UUID format" in id_not_uuid_payload["message"], id_not_uuid_payload
|
||||||
|
|
||||||
id_not_uuid1_res = rest_client.get("/datasets", params={"id": uuid.uuid4().hex})
|
id_not_uuid1_res = rest_client.get("/datasets", params={"id": uuid.uuid4().hex})
|
||||||
assert id_not_uuid1_res.status_code == 200
|
assert id_not_uuid1_res.status_code == 200
|
||||||
id_not_uuid1_payload = id_not_uuid1_res.json()
|
id_not_uuid1_payload = id_not_uuid1_res.json()
|
||||||
assert id_not_uuid1_payload["code"] == 101, id_not_uuid1_payload
|
assert id_not_uuid1_payload["code"] == 102, id_not_uuid1_payload
|
||||||
assert "Invalid UUID1 format" in id_not_uuid1_payload["message"], id_not_uuid1_payload
|
assert "lacks permission for dataset" in id_not_uuid1_payload["message"], id_not_uuid1_payload
|
||||||
|
|
||||||
id_wrong_uuid_res = rest_client.get("/datasets", params={"id": "d94a8dc02c9711f0930f7fbc369eab6d"})
|
id_wrong_uuid_res = rest_client.get("/datasets", params={"id": "d94a8dc02c9711f0930f7fbc369eab6d"})
|
||||||
assert id_wrong_uuid_res.status_code == 200
|
assert id_wrong_uuid_res.status_code == 200
|
||||||
@@ -2131,7 +2131,7 @@ def test_dataset_list_query_contract_matrix(rest_client, clear_datasets):
|
|||||||
assert id_empty_res.status_code == 200
|
assert id_empty_res.status_code == 200
|
||||||
id_empty_payload = id_empty_res.json()
|
id_empty_payload = id_empty_res.json()
|
||||||
assert id_empty_payload["code"] == 101, id_empty_payload
|
assert id_empty_payload["code"] == 101, id_empty_payload
|
||||||
assert "Invalid UUID1 format" in id_empty_payload["message"], id_empty_payload
|
assert "Invalid UUID format" in id_empty_payload["message"], id_empty_payload
|
||||||
|
|
||||||
id_none_res = rest_client.get("/datasets", params={"id": None})
|
id_none_res = rest_client.get("/datasets", params={"id": None})
|
||||||
assert id_none_res.status_code == 200
|
assert id_none_res.status_code == 200
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ class TestDatasetsDelete:
|
|||||||
payload = {"ids": ["not_uuid"]}
|
payload = {"ids": ["not_uuid"]}
|
||||||
res = delete_datasets(HttpApiAuth, payload)
|
res = delete_datasets(HttpApiAuth, payload)
|
||||||
assert res["code"] == 101, res
|
assert res["code"] == 101, res
|
||||||
assert "Invalid UUID1 format" in res["message"], res
|
assert "Invalid UUID format" in res["message"], res
|
||||||
|
|
||||||
res = list_datasets(HttpApiAuth)
|
res = list_datasets(HttpApiAuth)
|
||||||
assert len(res["data"]) == 1, res
|
assert len(res["data"]) == 1, res
|
||||||
@@ -152,8 +152,8 @@ class TestDatasetsDelete:
|
|||||||
def test_id_not_uuid1(self, HttpApiAuth):
|
def test_id_not_uuid1(self, HttpApiAuth):
|
||||||
payload = {"ids": [uuid.uuid4().hex]}
|
payload = {"ids": [uuid.uuid4().hex]}
|
||||||
res = delete_datasets(HttpApiAuth, payload)
|
res = delete_datasets(HttpApiAuth, payload)
|
||||||
assert res["code"] == 101, res
|
assert res["code"] == 102, res
|
||||||
assert "Invalid UUID1 format" in res["message"], res
|
assert "lacks permission for dataset" in res["message"], res
|
||||||
|
|
||||||
@pytest.mark.p2
|
@pytest.mark.p2
|
||||||
@pytest.mark.usefixtures("add_dataset_func")
|
@pytest.mark.usefixtures("add_dataset_func")
|
||||||
|
|||||||
@@ -268,14 +268,14 @@ class TestDatasetsList:
|
|||||||
params = {"id": "not_uuid"}
|
params = {"id": "not_uuid"}
|
||||||
res = list_datasets(HttpApiAuth, params)
|
res = list_datasets(HttpApiAuth, params)
|
||||||
assert res["code"] == 101, res
|
assert res["code"] == 101, res
|
||||||
assert "Invalid UUID1 format" in res["message"], res
|
assert "Invalid UUID format" in res["message"], res
|
||||||
|
|
||||||
@pytest.mark.p2
|
@pytest.mark.p2
|
||||||
def test_id_not_uuid1(self, HttpApiAuth):
|
def test_id_not_uuid1(self, HttpApiAuth):
|
||||||
params = {"id": uuid.uuid4().hex}
|
params = {"id": uuid.uuid4().hex}
|
||||||
res = list_datasets(HttpApiAuth, params)
|
res = list_datasets(HttpApiAuth, params)
|
||||||
assert res["code"] == 101, res
|
assert res["code"] == 102, res
|
||||||
assert "Invalid UUID1 format" in res["message"], res
|
assert "lacks permission for dataset" in res["message"], res
|
||||||
|
|
||||||
@pytest.mark.p2
|
@pytest.mark.p2
|
||||||
def test_id_wrong_uuid(self, HttpApiAuth):
|
def test_id_wrong_uuid(self, HttpApiAuth):
|
||||||
@@ -289,7 +289,7 @@ class TestDatasetsList:
|
|||||||
params = {"id": ""}
|
params = {"id": ""}
|
||||||
res = list_datasets(HttpApiAuth, params)
|
res = list_datasets(HttpApiAuth, params)
|
||||||
assert res["code"] == 101, res
|
assert res["code"] == 101, res
|
||||||
assert "Invalid UUID1 format" in res["message"], res
|
assert "Invalid UUID format" in res["message"], res
|
||||||
|
|
||||||
@pytest.mark.p2
|
@pytest.mark.p2
|
||||||
def test_id_none(self, HttpApiAuth):
|
def test_id_none(self, HttpApiAuth):
|
||||||
|
|||||||
@@ -105,14 +105,14 @@ class TestDatasetUpdate:
|
|||||||
payload = {"name": "not uuid"}
|
payload = {"name": "not uuid"}
|
||||||
res = update_dataset(HttpApiAuth, "not_uuid", payload)
|
res = update_dataset(HttpApiAuth, "not_uuid", payload)
|
||||||
assert res["code"] == 101, res
|
assert res["code"] == 101, res
|
||||||
assert "Invalid UUID1 format" in res["message"], res
|
assert "Invalid UUID format" in res["message"], res
|
||||||
|
|
||||||
@pytest.mark.p3
|
@pytest.mark.p3
|
||||||
def test_dataset_id_not_uuid1(self, HttpApiAuth):
|
def test_dataset_id_not_uuid1(self, HttpApiAuth):
|
||||||
payload = {"name": "not uuid1"}
|
payload = {"name": "not uuid1"}
|
||||||
res = update_dataset(HttpApiAuth, uuid.uuid4().hex, payload)
|
res = update_dataset(HttpApiAuth, uuid.uuid4().hex, payload)
|
||||||
assert res["code"] == 101, res
|
assert res["code"] == 102, res
|
||||||
assert "Invalid UUID1 format" in res["message"], res
|
assert "lacks permission for dataset" in res["message"], res
|
||||||
|
|
||||||
@pytest.mark.p3
|
@pytest.mark.p3
|
||||||
def test_dataset_id_wrong_uuid(self, HttpApiAuth):
|
def test_dataset_id_wrong_uuid(self, HttpApiAuth):
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ class TestDatasetsDelete:
|
|||||||
payload = {"ids": ["not_uuid"]}
|
payload = {"ids": ["not_uuid"]}
|
||||||
with pytest.raises(Exception) as exception_info:
|
with pytest.raises(Exception) as exception_info:
|
||||||
client.delete_datasets(**payload)
|
client.delete_datasets(**payload)
|
||||||
assert "Invalid UUID1 format" in str(exception_info.value), str(exception_info.value)
|
assert "Invalid UUID format" in str(exception_info.value), str(exception_info.value)
|
||||||
|
|
||||||
datasets = client.list_datasets()
|
datasets = client.list_datasets()
|
||||||
assert len(datasets) == 1, str(datasets)
|
assert len(datasets) == 1, str(datasets)
|
||||||
@@ -114,7 +114,7 @@ class TestDatasetsDelete:
|
|||||||
payload = {"ids": [uuid.uuid4().hex]}
|
payload = {"ids": [uuid.uuid4().hex]}
|
||||||
with pytest.raises(Exception) as exception_info:
|
with pytest.raises(Exception) as exception_info:
|
||||||
client.delete_datasets(**payload)
|
client.delete_datasets(**payload)
|
||||||
assert "Invalid UUID1 format" in str(exception_info.value), str(exception_info.value)
|
assert "lacks permission for dataset" in str(exception_info.value), str(exception_info.value)
|
||||||
|
|
||||||
@pytest.mark.p2
|
@pytest.mark.p2
|
||||||
@pytest.mark.usefixtures("add_dataset_func")
|
@pytest.mark.usefixtures("add_dataset_func")
|
||||||
|
|||||||
@@ -250,14 +250,14 @@ class TestDatasetsList:
|
|||||||
params = {"id": "not_uuid"}
|
params = {"id": "not_uuid"}
|
||||||
with pytest.raises(Exception) as exception_info:
|
with pytest.raises(Exception) as exception_info:
|
||||||
client.list_datasets(**params)
|
client.list_datasets(**params)
|
||||||
assert "Invalid UUID1 format" in str(exception_info.value), str(exception_info.value)
|
assert "Invalid UUID format" in str(exception_info.value), str(exception_info.value)
|
||||||
|
|
||||||
@pytest.mark.p2
|
@pytest.mark.p2
|
||||||
def test_id_not_uuid1(self, client):
|
def test_id_not_uuid1(self, client):
|
||||||
params = {"id": uuid.uuid4().hex}
|
params = {"id": uuid.uuid4().hex}
|
||||||
with pytest.raises(Exception) as exception_info:
|
with pytest.raises(Exception) as exception_info:
|
||||||
client.list_datasets(**params)
|
client.list_datasets(**params)
|
||||||
assert "Invalid UUID1 format" in str(exception_info.value), str(exception_info.value)
|
assert "lacks permission for dataset" in str(exception_info.value), str(exception_info.value)
|
||||||
|
|
||||||
@pytest.mark.p2
|
@pytest.mark.p2
|
||||||
def test_id_wrong_uuid(self, client):
|
def test_id_wrong_uuid(self, client):
|
||||||
@@ -271,7 +271,7 @@ class TestDatasetsList:
|
|||||||
params = {"id": ""}
|
params = {"id": ""}
|
||||||
with pytest.raises(Exception) as exception_info:
|
with pytest.raises(Exception) as exception_info:
|
||||||
client.list_datasets(**params)
|
client.list_datasets(**params)
|
||||||
assert "Invalid UUID1 format" in str(exception_info.value), str(exception_info.value)
|
assert "Invalid UUID format" in str(exception_info.value), str(exception_info.value)
|
||||||
|
|
||||||
@pytest.mark.p2
|
@pytest.mark.p2
|
||||||
def test_id_none(self, client):
|
def test_id_none(self, client):
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
#
|
||||||
|
# Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from types import ModuleType, SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
def _load_fillup_module(monkeypatch):
|
||||||
|
repo_root = Path(__file__).resolve().parents[4]
|
||||||
|
|
||||||
|
agent_pkg = ModuleType("agent")
|
||||||
|
agent_pkg.__path__ = [str(repo_root / "agent")]
|
||||||
|
monkeypatch.setitem(sys.modules, "agent", agent_pkg)
|
||||||
|
|
||||||
|
component_pkg = ModuleType("agent.component")
|
||||||
|
component_pkg.__path__ = [str(repo_root / "agent" / "component")]
|
||||||
|
monkeypatch.setitem(sys.modules, "agent.component", component_pkg)
|
||||||
|
|
||||||
|
base_mod = ModuleType("agent.component.base")
|
||||||
|
|
||||||
|
class _ComponentParamBase:
|
||||||
|
def __init__(self):
|
||||||
|
self.inputs = {}
|
||||||
|
self.outputs = {}
|
||||||
|
|
||||||
|
class _ComponentBase:
|
||||||
|
def get_input_elements(self):
|
||||||
|
return self._param.inputs
|
||||||
|
|
||||||
|
def check_if_canceled(self, *_args, **_kwargs):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_input_elements_from_text(self, *_args, **_kwargs):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def set_output(self, key, value):
|
||||||
|
if key not in self._param.outputs:
|
||||||
|
self._param.outputs[key] = {"value": None}
|
||||||
|
self._param.outputs[key]["value"] = value
|
||||||
|
|
||||||
|
def set_input_value(self, key, value):
|
||||||
|
if key not in self._param.inputs:
|
||||||
|
self._param.inputs[key] = {"value": None}
|
||||||
|
self._param.inputs[key]["value"] = value
|
||||||
|
|
||||||
|
base_mod.ComponentBase = _ComponentBase
|
||||||
|
base_mod.ComponentParamBase = _ComponentParamBase
|
||||||
|
monkeypatch.setitem(sys.modules, "agent.component.base", base_mod)
|
||||||
|
|
||||||
|
api_pkg = ModuleType("api")
|
||||||
|
api_pkg.__path__ = [str(repo_root / "api")]
|
||||||
|
monkeypatch.setitem(sys.modules, "api", api_pkg)
|
||||||
|
|
||||||
|
services_pkg = ModuleType("api.db.services")
|
||||||
|
services_pkg.__path__ = [str(repo_root / "api" / "db" / "services")]
|
||||||
|
monkeypatch.setitem(sys.modules, "api.db.services", services_pkg)
|
||||||
|
|
||||||
|
file_service_mod = ModuleType("api.db.services.file_service")
|
||||||
|
|
||||||
|
class _FileService:
|
||||||
|
@staticmethod
|
||||||
|
def get_files(files, layout_recognize=None):
|
||||||
|
return {"files": files, "layout_recognize": layout_recognize}
|
||||||
|
|
||||||
|
file_service_mod.FileService = _FileService
|
||||||
|
monkeypatch.setitem(sys.modules, "api.db.services.file_service", file_service_mod)
|
||||||
|
|
||||||
|
module_path = repo_root / "agent" / "component" / "fillup.py"
|
||||||
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
"test_fillup_unit_module", module_path
|
||||||
|
)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
monkeypatch.setitem(sys.modules, "test_fillup_unit_module", module)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
def _make_fillup(module, *, query, inputs):
|
||||||
|
component = module.UserFillUp.__new__(module.UserFillUp)
|
||||||
|
component._canvas = SimpleNamespace(
|
||||||
|
globals={
|
||||||
|
"sys.query": query,
|
||||||
|
"sys.__initial_user_input_consumed__": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
component._param = SimpleNamespace(
|
||||||
|
enable_tips=False,
|
||||||
|
tips="",
|
||||||
|
layout_recognize="",
|
||||||
|
inputs=inputs,
|
||||||
|
outputs={},
|
||||||
|
)
|
||||||
|
return component
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.p2
|
||||||
|
def test_user_fillup_auto_consumes_initial_query_for_single_field(monkeypatch):
|
||||||
|
module = _load_fillup_module(monkeypatch)
|
||||||
|
component = _make_fillup(
|
||||||
|
module,
|
||||||
|
query="code",
|
||||||
|
inputs={"demo": {"type": "options", "name": "Demo"}},
|
||||||
|
)
|
||||||
|
|
||||||
|
component._invoke(inputs={})
|
||||||
|
|
||||||
|
assert component._param.inputs["demo"]["value"] == "code"
|
||||||
|
assert component._param.outputs["demo"]["value"] == "code"
|
||||||
|
assert component._canvas.globals["sys.__initial_user_input_consumed__"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.p2
|
||||||
|
def test_user_fillup_only_auto_consumes_initial_query_once(monkeypatch):
|
||||||
|
module = _load_fillup_module(monkeypatch)
|
||||||
|
component = _make_fillup(
|
||||||
|
module,
|
||||||
|
query="code",
|
||||||
|
inputs={"demo": {"type": "options", "name": "Demo"}},
|
||||||
|
)
|
||||||
|
|
||||||
|
component._invoke(inputs={})
|
||||||
|
component._param.outputs = {}
|
||||||
|
component._invoke(inputs={})
|
||||||
|
|
||||||
|
assert component._param.outputs == {}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.p2
|
||||||
|
def test_user_fillup_does_not_consume_unmatched_structured_query(monkeypatch):
|
||||||
|
module = _load_fillup_module(monkeypatch)
|
||||||
|
component = _make_fillup(
|
||||||
|
module,
|
||||||
|
query={"x": 8},
|
||||||
|
inputs={"demo": {"type": "options", "name": "Demo"}},
|
||||||
|
)
|
||||||
|
|
||||||
|
component._invoke(inputs={})
|
||||||
|
|
||||||
|
assert component._param.outputs == {}
|
||||||
|
assert component._canvas.globals["sys.__initial_user_input_consumed__"] is False
|
||||||
@@ -212,6 +212,54 @@ def _load_canvas_runtime(monkeypatch):
|
|||||||
def thoughts(self):
|
def thoughts(self):
|
||||||
return "sink"
|
return "sink"
|
||||||
|
|
||||||
|
class UserFillUpParam(base_mod.ComponentParamBase):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.enable_tips = True
|
||||||
|
self.tips = "Please fill"
|
||||||
|
self.inputs = {"value": {"type": "line", "name": "Value"}}
|
||||||
|
|
||||||
|
def get_input_form(self):
|
||||||
|
return self.inputs
|
||||||
|
|
||||||
|
def check(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
class UserFillUp(base_mod.ComponentBase):
|
||||||
|
component_name = "UserFillUp"
|
||||||
|
|
||||||
|
def _invoke(self, **kwargs):
|
||||||
|
incoming = kwargs.get("inputs", {})
|
||||||
|
if "value" in incoming:
|
||||||
|
raw = incoming["value"]
|
||||||
|
value = raw.get("value") if isinstance(raw, dict) else raw
|
||||||
|
self.set_output("value", value)
|
||||||
|
if self._param.enable_tips:
|
||||||
|
self.set_output("tips", self._param.tips)
|
||||||
|
|
||||||
|
def get_input_elements(self):
|
||||||
|
return self._param.inputs
|
||||||
|
|
||||||
|
def thoughts(self):
|
||||||
|
return "fill"
|
||||||
|
|
||||||
|
class MessageParam(base_mod.ComponentParamBase):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.content = "{UserFillUp:1@value}"
|
||||||
|
|
||||||
|
def check(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
class Message(base_mod.ComponentBase):
|
||||||
|
component_name = "Message"
|
||||||
|
|
||||||
|
def _invoke(self, **kwargs):
|
||||||
|
self.set_output("content", self.string_format(self._param.content, {}))
|
||||||
|
|
||||||
|
def thoughts(self):
|
||||||
|
return "message"
|
||||||
|
|
||||||
class_map = {
|
class_map = {
|
||||||
"Begin": Begin,
|
"Begin": Begin,
|
||||||
"BeginParam": BeginParam,
|
"BeginParam": BeginParam,
|
||||||
@@ -223,6 +271,10 @@ def _load_canvas_runtime(monkeypatch):
|
|||||||
"ProbeParam": ProbeParam,
|
"ProbeParam": ProbeParam,
|
||||||
"Sink": Sink,
|
"Sink": Sink,
|
||||||
"SinkParam": SinkParam,
|
"SinkParam": SinkParam,
|
||||||
|
"UserFillUp": UserFillUp,
|
||||||
|
"UserFillUpParam": UserFillUpParam,
|
||||||
|
"Message": Message,
|
||||||
|
"MessageParam": MessageParam,
|
||||||
}
|
}
|
||||||
|
|
||||||
component_pkg.component_class = lambda name: class_map[name]
|
component_pkg.component_class = lambda name: class_map[name]
|
||||||
@@ -237,9 +289,9 @@ def _load_canvas_runtime(monkeypatch):
|
|||||||
return canvas_mod
|
return canvas_mod
|
||||||
|
|
||||||
|
|
||||||
async def _collect_events(canvas):
|
async def _collect_events(stream):
|
||||||
events = []
|
events = []
|
||||||
async for event in canvas.run():
|
async for event in stream:
|
||||||
events.append(event)
|
events.append(event)
|
||||||
return events
|
return events
|
||||||
|
|
||||||
@@ -308,7 +360,7 @@ def test_iteration_runtime_processes_all_array_items(monkeypatch):
|
|||||||
}
|
}
|
||||||
|
|
||||||
canvas = canvas_mod.Canvas(json.dumps(dsl))
|
canvas = canvas_mod.Canvas(json.dumps(dsl))
|
||||||
events = asyncio.run(_collect_events(canvas))
|
events = asyncio.run(_collect_events(canvas.run()))
|
||||||
|
|
||||||
assert canvas.globals["probe.calls"] == ["a", "b", "c"]
|
assert canvas.globals["probe.calls"] == ["a", "b", "c"]
|
||||||
assert any(event["event"] == "workflow_finished" for event in events)
|
assert any(event["event"] == "workflow_finished" for event in events)
|
||||||
@@ -386,6 +438,78 @@ def test_iteration_runtime_supports_bare_iteration_aliases(monkeypatch, query, e
|
|||||||
}
|
}
|
||||||
|
|
||||||
canvas = canvas_mod.Canvas(json.dumps(dsl))
|
canvas = canvas_mod.Canvas(json.dumps(dsl))
|
||||||
asyncio.run(_collect_events(canvas))
|
asyncio.run(_collect_events(canvas.run()))
|
||||||
|
|
||||||
assert canvas.globals["probe.calls"] == expected_calls
|
assert canvas.globals["probe.calls"] == expected_calls
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.p2
|
||||||
|
def test_canvas_resume_does_not_emit_duplicate_workflow_started(monkeypatch):
|
||||||
|
canvas_mod = _load_canvas_runtime(monkeypatch)
|
||||||
|
|
||||||
|
dsl = {
|
||||||
|
"components": {
|
||||||
|
"begin": {
|
||||||
|
"obj": {"component_name": "Begin", "params": {}},
|
||||||
|
"downstream": ["UserFillUp:1"],
|
||||||
|
"upstream": [],
|
||||||
|
},
|
||||||
|
"UserFillUp:1": {
|
||||||
|
"obj": {
|
||||||
|
"component_name": "UserFillUp",
|
||||||
|
"params": {
|
||||||
|
"enable_tips": True,
|
||||||
|
"tips": "Enter value",
|
||||||
|
"inputs": {"value": {"type": "line", "name": "Value"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"downstream": ["Message:1"],
|
||||||
|
"upstream": ["begin"],
|
||||||
|
},
|
||||||
|
"Message:1": {
|
||||||
|
"obj": {
|
||||||
|
"component_name": "Message",
|
||||||
|
"params": {"content": "{UserFillUp:1@value}"},
|
||||||
|
},
|
||||||
|
"downstream": [],
|
||||||
|
"upstream": ["UserFillUp:1"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"graph": {
|
||||||
|
"nodes": [
|
||||||
|
{"id": "begin", "data": {"name": "Begin"}},
|
||||||
|
{"id": "UserFillUp:1", "data": {"name": "UserFillUp"}},
|
||||||
|
{"id": "Message:1", "data": {"name": "Message"}},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"history": [],
|
||||||
|
"path": [],
|
||||||
|
"retrieval": [],
|
||||||
|
"globals": {
|
||||||
|
"sys.query": "",
|
||||||
|
"sys.user_id": "",
|
||||||
|
"sys.conversation_turns": 0,
|
||||||
|
"sys.files": [],
|
||||||
|
"sys.history": [],
|
||||||
|
"sys.date": "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas = canvas_mod.Canvas(json.dumps(dsl))
|
||||||
|
first_events = asyncio.run(_collect_events(canvas.run()))
|
||||||
|
first_kinds = [event["event"] for event in first_events]
|
||||||
|
assert first_kinds == [
|
||||||
|
"workflow_started",
|
||||||
|
"node_started",
|
||||||
|
"node_finished",
|
||||||
|
"user_inputs",
|
||||||
|
]
|
||||||
|
|
||||||
|
resumed_events = asyncio.run(
|
||||||
|
_collect_events(canvas.run(query="hello", inputs={"value": {"value": "hello"}}))
|
||||||
|
)
|
||||||
|
resumed_kinds = [event["event"] for event in resumed_events]
|
||||||
|
assert resumed_kinds[0] == "node_started"
|
||||||
|
assert "workflow_started" not in resumed_kinds
|
||||||
|
assert "message" in resumed_kinds
|
||||||
|
assert resumed_kinds[-1] == "workflow_finished"
|
||||||
|
|||||||
@@ -189,3 +189,13 @@ def test_set_outputs_tracks_first_and_last(monkeypatch):
|
|||||||
assert component._param.outputs["result"]["value"] == ["c", "d", "e"]
|
assert component._param.outputs["result"]["value"] == ["c", "d", "e"]
|
||||||
assert component._param.outputs["first"]["value"] == "c"
|
assert component._param.outputs["first"]["value"] == "c"
|
||||||
assert component._param.outputs["last"]["value"] == "e"
|
assert component._param.outputs["last"]["value"] == "e"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.p2
|
||||||
|
def test_topn_operation_alias_normalizes_to_head(monkeypatch):
|
||||||
|
module = _load_list_operations_module(monkeypatch)
|
||||||
|
param = module.ListOperationsParam()
|
||||||
|
param.query = "items"
|
||||||
|
param.operations = "topN"
|
||||||
|
param.check()
|
||||||
|
assert param.operations == "head"
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
#
|
||||||
|
# Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from common.constants import ActiveStatusEnum
|
||||||
|
from api.db.joint_services import tenant_model_service as module
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_instance_for_model_falls_back_from_default_to_single_active_instance(monkeypatch):
|
||||||
|
provider = SimpleNamespace(id="provider-1", provider_name="SILICONFLOW")
|
||||||
|
resolved = SimpleNamespace(
|
||||||
|
id="instance-1",
|
||||||
|
instance_name="yy2",
|
||||||
|
status=ActiveStatusEnum.ACTIVE.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
module.TenantModelInstanceService,
|
||||||
|
"get_by_provider_id_and_instance_name",
|
||||||
|
lambda provider_id, instance_name: None,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
module.TenantModelInstanceService,
|
||||||
|
"get_all_by_provider_id",
|
||||||
|
lambda provider_id: [resolved],
|
||||||
|
)
|
||||||
|
|
||||||
|
got = module._resolve_instance_for_model(
|
||||||
|
provider,
|
||||||
|
"default",
|
||||||
|
"Qwen/Qwen3-8B@default@SILICONFLOW",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert got is resolved
|
||||||
@@ -15,6 +15,7 @@ export enum MessageEventType {
|
|||||||
MessageEnd = 'message_end',
|
MessageEnd = 'message_end',
|
||||||
WorkflowFinished = 'workflow_finished',
|
WorkflowFinished = 'workflow_finished',
|
||||||
UserInputs = 'user_inputs',
|
UserInputs = 'user_inputs',
|
||||||
|
WaitingForUser = 'waiting_for_user',
|
||||||
NodeLogs = 'node_logs',
|
NodeLogs = 'node_logs',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,6 +87,28 @@ export type IChatEvent = INodeEvent | IMessageEvent | IMessageEndEvent;
|
|||||||
|
|
||||||
export type IEventList = Array<IChatEvent>;
|
export type IEventList = Array<IChatEvent>;
|
||||||
|
|
||||||
|
const parseAgentEventData = (data: any) => {
|
||||||
|
if (typeof data !== 'string') return data;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(data);
|
||||||
|
} catch {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeAgentEvent = (value: any) => {
|
||||||
|
if (value?.event === MessageEventType.WaitingForUser) {
|
||||||
|
return {
|
||||||
|
...value,
|
||||||
|
event: MessageEventType.UserInputs,
|
||||||
|
data: parseAgentEventData(value.data),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
export const useSendMessageBySSE = (url: string) => {
|
export const useSendMessageBySSE = (url: string) => {
|
||||||
const [answerList, setAnswerList] = useState<IEventList>([]);
|
const [answerList, setAnswerList] = useState<IEventList>([]);
|
||||||
const [done, setDone] = useState(true);
|
const [done, setDone] = useState(true);
|
||||||
@@ -123,7 +146,14 @@ export const useSendMessageBySSE = (url: string) => {
|
|||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
signal: controller?.signal || sseRef.current?.signal,
|
signal: controller?.signal || sseRef.current?.signal,
|
||||||
});
|
});
|
||||||
const responseDataPromise = response
|
// SSE streams (text/event-stream) emit `data: {...}\n\n` frames, not
|
||||||
|
// a single JSON document. The .clone().json() call below is kept
|
||||||
|
// for non-streaming callers (lastEventData will be set from the
|
||||||
|
// per-frame parser below when the response IS SSE); for SSE
|
||||||
|
// bodies the JSON parse rejects — swallow it silently instead
|
||||||
|
// of console.warn'ing `SyntaxError: Unexpected token 'd', "data:
|
||||||
|
// {"ev"... is not valid JSON` on every chat completion.
|
||||||
|
const responseDataPromise: Promise<ResponseType | undefined> = response
|
||||||
.clone()
|
.clone()
|
||||||
.json()
|
.json()
|
||||||
.then((data: ResponseType) => data)
|
.then((data: ResponseType) => data)
|
||||||
@@ -166,6 +196,7 @@ export const useSendMessageBySSE = (url: string) => {
|
|||||||
}
|
}
|
||||||
const { done, value } = x;
|
const { done, value } = x;
|
||||||
if (done) {
|
if (done) {
|
||||||
|
console.log('agent chat sse reader done');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,9 +216,11 @@ export const useSendMessageBySSE = (url: string) => {
|
|||||||
// `data: [DONE]` payload is caught and the stream
|
// `data: [DONE]` payload is caught and the stream
|
||||||
// loop is terminated.
|
// loop is terminated.
|
||||||
if (payload === '[DONE]') {
|
if (payload === '[DONE]') {
|
||||||
|
console.log('agent chat sse done sentinel');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
const val = JSON.parse(payload);
|
const val = normalizeAgentEvent(JSON.parse(payload));
|
||||||
|
console.log('agent chat sse event', val);
|
||||||
|
|
||||||
if (typeof val?.code === 'number' && val.code !== 0) {
|
if (typeof val?.code === 'number' && val.code !== 0) {
|
||||||
message.error(val.message);
|
message.error(val.message);
|
||||||
|
|||||||
Reference in New Issue
Block a user