mirror of
https://github.com/langgenius/dify.git
synced 2026-03-19 22:52:01 +00:00
Compare commits
20 Commits
refactor/w
...
yanli/fix-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a17f6f62bf | ||
|
|
7b8c57d95b | ||
|
|
ceccc70d15 | ||
|
|
3193d7c9a5 | ||
|
|
cd9306d4f9 | ||
|
|
84d1b05501 | ||
|
|
f81e0c7c8d | ||
|
|
dea90b0ccd | ||
|
|
bdd4542759 | ||
|
|
5e22818296 | ||
|
|
64308c3d0d | ||
|
|
37df3899ff | ||
|
|
9100190a68 | ||
|
|
344f6be7cd | ||
|
|
f169cf8654 | ||
|
|
e76fbcb045 | ||
|
|
e6f00a2bf9 | ||
|
|
715f3affe5 | ||
|
|
4f73766a21 | ||
|
|
fe90453eed |
@@ -7,7 +7,6 @@ from sqlalchemy import select
|
||||
from werkzeug.exceptions import Unauthorized
|
||||
|
||||
import services
|
||||
from configs import dify_config
|
||||
from controllers.common.errors import (
|
||||
FilenameNotExistsError,
|
||||
FileTooLargeError,
|
||||
@@ -30,7 +29,6 @@ from libs.helper import TimestampField
|
||||
from libs.login import current_account_with_tenant, login_required
|
||||
from models.account import Tenant, TenantStatus
|
||||
from services.account_service import TenantService
|
||||
from services.billing_service import BillingService
|
||||
from services.enterprise.enterprise_service import EnterpriseService
|
||||
from services.feature_service import FeatureService
|
||||
from services.file_service import FileService
|
||||
@@ -110,27 +108,9 @@ class TenantListApi(Resource):
|
||||
current_user, current_tenant_id = current_account_with_tenant()
|
||||
tenants = TenantService.get_join_tenants(current_user)
|
||||
tenant_dicts = []
|
||||
is_enterprise_only = dify_config.ENTERPRISE_ENABLED and not dify_config.BILLING_ENABLED
|
||||
is_saas = dify_config.EDITION == "CLOUD" and dify_config.BILLING_ENABLED
|
||||
tenant_plans: dict[str, dict] = {}
|
||||
use_legacy_feature_path = not is_enterprise_only and not is_saas
|
||||
|
||||
if is_saas:
|
||||
tenant_ids = [tenant.id for tenant in tenants]
|
||||
if tenant_ids:
|
||||
try:
|
||||
tenant_plans = BillingService.get_plan_bulk(tenant_ids)
|
||||
except Exception:
|
||||
logger.exception("failed to fetch workspace plans in bulk, falling back to legacy feature path")
|
||||
use_legacy_feature_path = True
|
||||
|
||||
for tenant in tenants:
|
||||
plan = CloudPlan.SANDBOX
|
||||
if is_saas and not use_legacy_feature_path:
|
||||
plan = tenant_plans.get(tenant.id, {}).get("plan", CloudPlan.SANDBOX)
|
||||
elif not is_enterprise_only:
|
||||
features = FeatureService.get_features(tenant.id)
|
||||
plan = features.billing.subscription.plan or CloudPlan.SANDBOX
|
||||
features = FeatureService.get_features(tenant.id)
|
||||
|
||||
# Create a dictionary with tenant attributes
|
||||
tenant_dict = {
|
||||
@@ -138,7 +118,7 @@ class TenantListApi(Resource):
|
||||
"name": tenant.name,
|
||||
"status": tenant.status,
|
||||
"created_at": tenant.created_at,
|
||||
"plan": plan,
|
||||
"plan": features.billing.subscription.plan if features.billing.enabled else CloudPlan.SANDBOX,
|
||||
"current": tenant.id == current_tenant_id if current_tenant_id else False,
|
||||
}
|
||||
|
||||
|
||||
@@ -48,42 +48,41 @@ class TestToolTransformService:
|
||||
name=fake.company(),
|
||||
description=fake.text(max_nb_chars=100),
|
||||
icon='{"background": "#FF6B6B", "content": "🔧"}',
|
||||
icon_dark='{"background": "#252525", "content": "🔧"}',
|
||||
tenant_id="test_tenant_id",
|
||||
user_id="test_user_id",
|
||||
credentials_str='{"auth_type": "api_key_header", "api_key": "test_key"}',
|
||||
schema="{}",
|
||||
schema_type_str="openapi",
|
||||
tools_str="[]",
|
||||
credentials={"auth_type": "api_key_header", "api_key": "test_key"},
|
||||
provider_type="api",
|
||||
)
|
||||
elif provider_type == "builtin":
|
||||
provider = BuiltinToolProvider(
|
||||
name=fake.company(),
|
||||
description=fake.text(max_nb_chars=100),
|
||||
icon="🔧",
|
||||
icon_dark="🔧",
|
||||
tenant_id="test_tenant_id",
|
||||
user_id="test_user_id",
|
||||
provider="test_provider",
|
||||
credential_type="api_key",
|
||||
encrypted_credentials='{"api_key": "test_key"}',
|
||||
credentials={"api_key": "test_key"},
|
||||
)
|
||||
elif provider_type == "workflow":
|
||||
provider = WorkflowToolProvider(
|
||||
name=fake.company(),
|
||||
description=fake.text(max_nb_chars=100),
|
||||
icon='{"background": "#FF6B6B", "content": "🔧"}',
|
||||
icon_dark='{"background": "#252525", "content": "🔧"}',
|
||||
tenant_id="test_tenant_id",
|
||||
user_id="test_user_id",
|
||||
app_id="test_workflow_id",
|
||||
label="Test Workflow",
|
||||
version="1.0.0",
|
||||
parameter_configuration="[]",
|
||||
workflow_id="test_workflow_id",
|
||||
)
|
||||
elif provider_type == "mcp":
|
||||
provider = MCPToolProvider(
|
||||
name=fake.company(),
|
||||
icon='{"background": "#FF6B6B", "content": "🔧"}',
|
||||
description=fake.text(max_nb_chars=100),
|
||||
provider_icon='{"background": "#FF6B6B", "content": "🔧"}',
|
||||
tenant_id="test_tenant_id",
|
||||
user_id="test_user_id",
|
||||
server_url="https://mcp.example.com",
|
||||
server_url_hash="test_server_url_hash",
|
||||
server_identifier="test_server",
|
||||
tools='[{"name": "test_tool", "description": "Test tool"}]',
|
||||
authed=True,
|
||||
|
||||
@@ -36,98 +36,7 @@ def unwrap(func):
|
||||
|
||||
|
||||
class TestTenantListApi:
|
||||
def test_get_success_saas_path(self, app):
|
||||
api = TenantListApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
tenant1 = MagicMock(
|
||||
id="t1",
|
||||
name="Tenant 1",
|
||||
status="active",
|
||||
created_at=datetime.utcnow(),
|
||||
)
|
||||
tenant2 = MagicMock(
|
||||
id="t2",
|
||||
name="Tenant 2",
|
||||
status="active",
|
||||
created_at=datetime.utcnow(),
|
||||
)
|
||||
|
||||
with (
|
||||
app.test_request_context("/workspaces"),
|
||||
patch(
|
||||
"controllers.console.workspace.workspace.current_account_with_tenant", return_value=(MagicMock(), "t1")
|
||||
),
|
||||
patch(
|
||||
"controllers.console.workspace.workspace.TenantService.get_join_tenants",
|
||||
return_value=[tenant1, tenant2],
|
||||
),
|
||||
patch("controllers.console.workspace.workspace.dify_config.ENTERPRISE_ENABLED", False),
|
||||
patch("controllers.console.workspace.workspace.dify_config.BILLING_ENABLED", True),
|
||||
patch("controllers.console.workspace.workspace.dify_config.EDITION", "CLOUD"),
|
||||
patch(
|
||||
"controllers.console.workspace.workspace.BillingService.get_plan_bulk",
|
||||
return_value={
|
||||
"t1": {"plan": CloudPlan.TEAM, "expiration_date": 0},
|
||||
"t2": {"plan": CloudPlan.PROFESSIONAL, "expiration_date": 0},
|
||||
},
|
||||
) as get_plan_bulk_mock,
|
||||
patch("controllers.console.workspace.workspace.FeatureService.get_features") as get_features_mock,
|
||||
):
|
||||
result, status = method(api)
|
||||
|
||||
assert status == 200
|
||||
assert len(result["workspaces"]) == 2
|
||||
assert result["workspaces"][0]["current"] is True
|
||||
assert result["workspaces"][0]["plan"] == CloudPlan.TEAM
|
||||
assert result["workspaces"][1]["plan"] == CloudPlan.PROFESSIONAL
|
||||
get_plan_bulk_mock.assert_called_once_with(["t1", "t2"])
|
||||
get_features_mock.assert_not_called()
|
||||
|
||||
def test_get_saas_path_falls_back_to_sandbox_for_missing_tenant(self, app):
|
||||
api = TenantListApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
tenant1 = MagicMock(
|
||||
id="t1",
|
||||
name="Tenant 1",
|
||||
status="active",
|
||||
created_at=datetime.utcnow(),
|
||||
)
|
||||
tenant2 = MagicMock(
|
||||
id="t2",
|
||||
name="Tenant 2",
|
||||
status="active",
|
||||
created_at=datetime.utcnow(),
|
||||
)
|
||||
|
||||
with (
|
||||
app.test_request_context("/workspaces"),
|
||||
patch(
|
||||
"controllers.console.workspace.workspace.current_account_with_tenant", return_value=(MagicMock(), "t1")
|
||||
),
|
||||
patch(
|
||||
"controllers.console.workspace.workspace.TenantService.get_join_tenants",
|
||||
return_value=[tenant1, tenant2],
|
||||
),
|
||||
patch("controllers.console.workspace.workspace.dify_config.ENTERPRISE_ENABLED", False),
|
||||
patch("controllers.console.workspace.workspace.dify_config.BILLING_ENABLED", True),
|
||||
patch("controllers.console.workspace.workspace.dify_config.EDITION", "CLOUD"),
|
||||
patch(
|
||||
"controllers.console.workspace.workspace.BillingService.get_plan_bulk",
|
||||
return_value={"t1": {"plan": CloudPlan.TEAM, "expiration_date": 0}},
|
||||
) as get_plan_bulk_mock,
|
||||
patch("controllers.console.workspace.workspace.FeatureService.get_features") as get_features_mock,
|
||||
):
|
||||
result, status = method(api)
|
||||
|
||||
assert status == 200
|
||||
assert result["workspaces"][0]["plan"] == CloudPlan.TEAM
|
||||
assert result["workspaces"][1]["plan"] == CloudPlan.SANDBOX
|
||||
get_plan_bulk_mock.assert_called_once_with(["t1", "t2"])
|
||||
get_features_mock.assert_not_called()
|
||||
|
||||
def test_get_saas_path_falls_back_to_legacy_feature_path_on_bulk_error(self, app):
|
||||
def test_get_success(self, app):
|
||||
api = TenantListApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
@@ -145,41 +54,27 @@ class TestTenantListApi:
|
||||
)
|
||||
|
||||
features = MagicMock()
|
||||
features.billing.enabled = False
|
||||
features.billing.subscription.plan = CloudPlan.TEAM
|
||||
features.billing.enabled = True
|
||||
features.billing.subscription.plan = CloudPlan.SANDBOX
|
||||
|
||||
with (
|
||||
app.test_request_context("/workspaces"),
|
||||
patch(
|
||||
"controllers.console.workspace.workspace.current_account_with_tenant", return_value=(MagicMock(), "t2")
|
||||
"controllers.console.workspace.workspace.current_account_with_tenant", return_value=(MagicMock(), "t1")
|
||||
),
|
||||
patch(
|
||||
"controllers.console.workspace.workspace.TenantService.get_join_tenants",
|
||||
return_value=[tenant1, tenant2],
|
||||
),
|
||||
patch("controllers.console.workspace.workspace.dify_config.ENTERPRISE_ENABLED", False),
|
||||
patch("controllers.console.workspace.workspace.dify_config.BILLING_ENABLED", True),
|
||||
patch("controllers.console.workspace.workspace.dify_config.EDITION", "CLOUD"),
|
||||
patch(
|
||||
"controllers.console.workspace.workspace.BillingService.get_plan_bulk",
|
||||
side_effect=RuntimeError("billing down"),
|
||||
) as get_plan_bulk_mock,
|
||||
patch(
|
||||
"controllers.console.workspace.workspace.FeatureService.get_features",
|
||||
return_value=features,
|
||||
) as get_features_mock,
|
||||
patch("controllers.console.workspace.workspace.logger.exception") as logger_exception_mock,
|
||||
patch("controllers.console.workspace.workspace.FeatureService.get_features", return_value=features),
|
||||
):
|
||||
result, status = method(api)
|
||||
|
||||
assert status == 200
|
||||
assert result["workspaces"][0]["plan"] == CloudPlan.TEAM
|
||||
assert result["workspaces"][1]["plan"] == CloudPlan.TEAM
|
||||
get_plan_bulk_mock.assert_called_once_with(["t1", "t2"])
|
||||
assert get_features_mock.call_count == 2
|
||||
logger_exception_mock.assert_called_once()
|
||||
assert len(result["workspaces"]) == 2
|
||||
assert result["workspaces"][0]["current"] is True
|
||||
|
||||
def test_get_billing_disabled_community_path(self, app):
|
||||
def test_get_billing_disabled(self, app):
|
||||
api = TenantListApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
@@ -203,83 +98,15 @@ class TestTenantListApi:
|
||||
"controllers.console.workspace.workspace.TenantService.get_join_tenants",
|
||||
return_value=[tenant],
|
||||
),
|
||||
patch("controllers.console.workspace.workspace.dify_config.ENTERPRISE_ENABLED", False),
|
||||
patch("controllers.console.workspace.workspace.dify_config.BILLING_ENABLED", False),
|
||||
patch("controllers.console.workspace.workspace.dify_config.EDITION", "SELF_HOSTED"),
|
||||
patch(
|
||||
"controllers.console.workspace.workspace.FeatureService.get_features",
|
||||
return_value=features,
|
||||
) as get_features_mock,
|
||||
),
|
||||
):
|
||||
result, status = method(api)
|
||||
|
||||
assert status == 200
|
||||
assert result["workspaces"][0]["plan"] == CloudPlan.SANDBOX
|
||||
get_features_mock.assert_called_once_with("t1")
|
||||
|
||||
def test_get_enterprise_only_skips_feature_service(self, app):
|
||||
api = TenantListApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
tenant1 = MagicMock(
|
||||
id="t1",
|
||||
name="Tenant 1",
|
||||
status="active",
|
||||
created_at=datetime.utcnow(),
|
||||
)
|
||||
tenant2 = MagicMock(
|
||||
id="t2",
|
||||
name="Tenant 2",
|
||||
status="active",
|
||||
created_at=datetime.utcnow(),
|
||||
)
|
||||
|
||||
with (
|
||||
app.test_request_context("/workspaces"),
|
||||
patch(
|
||||
"controllers.console.workspace.workspace.current_account_with_tenant", return_value=(MagicMock(), "t2")
|
||||
),
|
||||
patch(
|
||||
"controllers.console.workspace.workspace.TenantService.get_join_tenants",
|
||||
return_value=[tenant1, tenant2],
|
||||
),
|
||||
patch("controllers.console.workspace.workspace.dify_config.ENTERPRISE_ENABLED", True),
|
||||
patch("controllers.console.workspace.workspace.dify_config.BILLING_ENABLED", False),
|
||||
patch("controllers.console.workspace.workspace.dify_config.EDITION", "SELF_HOSTED"),
|
||||
patch("controllers.console.workspace.workspace.FeatureService.get_features") as get_features_mock,
|
||||
):
|
||||
result, status = method(api)
|
||||
|
||||
assert status == 200
|
||||
assert result["workspaces"][0]["plan"] == CloudPlan.SANDBOX
|
||||
assert result["workspaces"][1]["plan"] == CloudPlan.SANDBOX
|
||||
assert result["workspaces"][0]["current"] is False
|
||||
assert result["workspaces"][1]["current"] is True
|
||||
get_features_mock.assert_not_called()
|
||||
|
||||
def test_get_enterprise_only_with_empty_tenants(self, app):
|
||||
api = TenantListApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
with (
|
||||
app.test_request_context("/workspaces"),
|
||||
patch(
|
||||
"controllers.console.workspace.workspace.current_account_with_tenant", return_value=(MagicMock(), None)
|
||||
),
|
||||
patch(
|
||||
"controllers.console.workspace.workspace.TenantService.get_join_tenants",
|
||||
return_value=[],
|
||||
),
|
||||
patch("controllers.console.workspace.workspace.dify_config.ENTERPRISE_ENABLED", True),
|
||||
patch("controllers.console.workspace.workspace.dify_config.BILLING_ENABLED", False),
|
||||
patch("controllers.console.workspace.workspace.dify_config.EDITION", "SELF_HOSTED"),
|
||||
patch("controllers.console.workspace.workspace.FeatureService.get_features") as get_features_mock,
|
||||
):
|
||||
result, status = method(api)
|
||||
|
||||
assert status == 200
|
||||
assert result["workspaces"] == []
|
||||
get_features_mock.assert_not_called()
|
||||
|
||||
|
||||
class TestWorkspaceListApi:
|
||||
|
||||
@@ -449,6 +449,66 @@ describe('useChat', () => {
|
||||
expect(lastResponse.workflowProcess?.status).toBe('failed')
|
||||
})
|
||||
|
||||
it('should keep separate iteration traces for repeated executions of the same iteration node', async () => {
|
||||
let callbacks: HookCallbacks
|
||||
|
||||
vi.mocked(ssePost).mockImplementation(async (_url, _params, options) => {
|
||||
callbacks = options as HookCallbacks
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useChat())
|
||||
|
||||
act(() => {
|
||||
result.current.handleSend('test-url', { query: 'iteration trace test' }, {})
|
||||
})
|
||||
|
||||
act(() => {
|
||||
callbacks.onWorkflowStarted({ workflow_run_id: 'wr-1', task_id: 't-1' })
|
||||
callbacks.onIterationStart({ data: { id: 'iter-run-1', node_id: 'iter-1' } })
|
||||
callbacks.onIterationStart({ data: { id: 'iter-run-2', node_id: 'iter-1' } })
|
||||
callbacks.onIterationFinish({ data: { id: 'iter-run-1', node_id: 'iter-1', status: 'succeeded' } })
|
||||
callbacks.onIterationFinish({ data: { id: 'iter-run-2', node_id: 'iter-1', status: 'succeeded' } })
|
||||
})
|
||||
|
||||
const tracing = result.current.chatList[1].workflowProcess?.tracing ?? []
|
||||
|
||||
expect(tracing).toHaveLength(2)
|
||||
expect(tracing).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ id: 'iter-run-1', status: 'succeeded' }),
|
||||
expect.objectContaining({ id: 'iter-run-2', status: 'succeeded' }),
|
||||
]))
|
||||
})
|
||||
|
||||
it('should keep separate top-level traces for repeated executions of the same node', async () => {
|
||||
let callbacks: HookCallbacks
|
||||
|
||||
vi.mocked(ssePost).mockImplementation(async (_url, _params, options) => {
|
||||
callbacks = options as HookCallbacks
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useChat())
|
||||
|
||||
act(() => {
|
||||
result.current.handleSend('test-url', { query: 'top-level trace test' }, {})
|
||||
})
|
||||
|
||||
act(() => {
|
||||
callbacks.onWorkflowStarted({ workflow_run_id: 'wr-1', task_id: 't-1' })
|
||||
callbacks.onNodeStarted({ data: { id: 'node-run-1', node_id: 'node-1', title: 'Node 1' } })
|
||||
callbacks.onNodeStarted({ data: { id: 'node-run-2', node_id: 'node-1', title: 'Node 1 retry' } })
|
||||
callbacks.onNodeFinished({ data: { id: 'node-run-1', node_id: 'node-1', status: 'succeeded' } })
|
||||
callbacks.onNodeFinished({ data: { id: 'node-run-2', node_id: 'node-1', status: 'succeeded' } })
|
||||
})
|
||||
|
||||
const tracing = result.current.chatList[1].workflowProcess?.tracing ?? []
|
||||
|
||||
expect(tracing).toHaveLength(2)
|
||||
expect(tracing).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ id: 'node-run-1', status: 'succeeded' }),
|
||||
expect.objectContaining({ id: 'node-run-2', status: 'succeeded' }),
|
||||
]))
|
||||
})
|
||||
|
||||
it('should handle early exits in tracing events during iteration or loop', async () => {
|
||||
let callbacks: HookCallbacks
|
||||
|
||||
@@ -484,7 +544,7 @@ describe('useChat', () => {
|
||||
callbacks.onNodeFinished({ data: { id: 'n-1', iteration_id: 'iter-1' } })
|
||||
})
|
||||
|
||||
const traceLen1 = result.current.chatList[result.current.chatList.length - 1].workflowProcess?.tracing?.length
|
||||
const traceLen1 = result.current.chatList.at(-1)!.workflowProcess?.tracing?.length
|
||||
expect(traceLen1).toBe(0) // None added due to iteration early hits
|
||||
})
|
||||
|
||||
@@ -568,7 +628,7 @@ describe('useChat', () => {
|
||||
|
||||
expect(result.current.chatList.some(item => item.id === 'question-m-child')).toBe(true)
|
||||
expect(result.current.chatList.some(item => item.id === 'm-child')).toBe(true)
|
||||
expect(result.current.chatList[result.current.chatList.length - 1].content).toBe('child answer')
|
||||
expect(result.current.chatList.at(-1)!.content).toBe('child answer')
|
||||
})
|
||||
|
||||
it('should strip local file urls before sending payload', () => {
|
||||
@@ -666,7 +726,7 @@ describe('useChat', () => {
|
||||
})
|
||||
|
||||
expect(onGetConversationMessages).toHaveBeenCalled()
|
||||
expect(result.current.chatList[result.current.chatList.length - 1].content).toBe('streamed content')
|
||||
expect(result.current.chatList.at(-1)!.content).toBe('streamed content')
|
||||
})
|
||||
|
||||
it('should clear suggested questions when suggestion fetch fails after completion', async () => {
|
||||
@@ -712,7 +772,7 @@ describe('useChat', () => {
|
||||
callbacks.onNodeFinished({ data: { node_id: 'n-loop', id: 'n-loop' } })
|
||||
})
|
||||
|
||||
const latestResponse = result.current.chatList[result.current.chatList.length - 1]
|
||||
const latestResponse = result.current.chatList.at(-1)!
|
||||
expect(latestResponse.workflowProcess?.tracing).toHaveLength(0)
|
||||
})
|
||||
|
||||
@@ -739,7 +799,7 @@ describe('useChat', () => {
|
||||
callbacks.onTTSChunk('m-th-bind', '')
|
||||
})
|
||||
|
||||
const latestResponse = result.current.chatList[result.current.chatList.length - 1]
|
||||
const latestResponse = result.current.chatList.at(-1)!
|
||||
expect(latestResponse.id).toBe('m-th-bind')
|
||||
expect(latestResponse.conversationId).toBe('c-th-bind')
|
||||
expect(latestResponse.workflowProcess?.status).toBe('succeeded')
|
||||
@@ -832,7 +892,7 @@ describe('useChat', () => {
|
||||
callbacks.onCompleted()
|
||||
})
|
||||
|
||||
const lastResponse = result.current.chatList[result.current.chatList.length - 1]
|
||||
const lastResponse = result.current.chatList.at(-1)!
|
||||
expect(lastResponse.agent_thoughts![0].thought).toContain('resumed')
|
||||
|
||||
expect(lastResponse.workflowProcess?.tracing?.length).toBeGreaterThan(0)
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
IOnDataMoreInfo,
|
||||
IOtherOptions,
|
||||
} from '@/service/base'
|
||||
import type { NodeTracing } from '@/types/workflow'
|
||||
import { uniqBy } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { produce, setAutoFreeze } from 'immer'
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
} from '@/app/components/base/file-uploader/utils'
|
||||
import { useToastContext } from '@/app/components/base/toast/context'
|
||||
import { NodeRunningStatus, WorkflowRunningStatus } from '@/app/components/workflow/types'
|
||||
import { upsertTopLevelTracingNodeOnStart } from '@/app/components/workflow/utils/top-level-tracing'
|
||||
import useTimestamp from '@/hooks/use-timestamp'
|
||||
import { useParams, usePathname } from '@/next/navigation'
|
||||
import {
|
||||
@@ -52,6 +54,39 @@ type SendCallback = {
|
||||
isPublicAPI?: boolean
|
||||
}
|
||||
|
||||
type ParallelTraceLike = Pick<NodeTracing, 'id' | 'node_id' | 'parallel_id' | 'execution_metadata'>
|
||||
|
||||
const findParallelTraceIndex = (
|
||||
tracing: ParallelTraceLike[],
|
||||
data: Partial<ParallelTraceLike>,
|
||||
) => {
|
||||
const incomingParallelId = data.execution_metadata?.parallel_id ?? data.parallel_id
|
||||
|
||||
if (data.id) {
|
||||
const matchedByIdIndex = tracing.findIndex((item) => {
|
||||
if (item.id !== data.id)
|
||||
return false
|
||||
|
||||
const existingParallelId = item.execution_metadata?.parallel_id ?? item.parallel_id
|
||||
if (!existingParallelId || !incomingParallelId)
|
||||
return true
|
||||
|
||||
return existingParallelId === incomingParallelId
|
||||
})
|
||||
|
||||
if (matchedByIdIndex > -1)
|
||||
return matchedByIdIndex
|
||||
}
|
||||
|
||||
return tracing.findIndex((item) => {
|
||||
if (item.node_id !== data.node_id)
|
||||
return false
|
||||
|
||||
const existingParallelId = item.execution_metadata?.parallel_id ?? item.parallel_id
|
||||
return existingParallelId === incomingParallelId
|
||||
})
|
||||
}
|
||||
|
||||
export const useChat = (
|
||||
config?: ChatConfig,
|
||||
formSettings?: {
|
||||
@@ -395,8 +430,7 @@ export const useChat = (
|
||||
if (!responseItem.workflowProcess?.tracing)
|
||||
return
|
||||
const tracing = responseItem.workflowProcess.tracing
|
||||
const iterationIndex = tracing.findIndex(item => item.node_id === iterationFinishedData.node_id
|
||||
&& (item.execution_metadata?.parallel_id === iterationFinishedData.execution_metadata?.parallel_id || item.parallel_id === iterationFinishedData.execution_metadata?.parallel_id))!
|
||||
const iterationIndex = findParallelTraceIndex(tracing, iterationFinishedData)
|
||||
if (iterationIndex > -1) {
|
||||
tracing[iterationIndex] = {
|
||||
...tracing[iterationIndex],
|
||||
@@ -408,38 +442,34 @@ export const useChat = (
|
||||
},
|
||||
onNodeStarted: ({ data: nodeStartedData }) => {
|
||||
updateChatTreeNode(messageId, (responseItem) => {
|
||||
if (params.loop_id)
|
||||
return
|
||||
|
||||
if (!responseItem.workflowProcess)
|
||||
return
|
||||
if (!responseItem.workflowProcess.tracing)
|
||||
responseItem.workflowProcess.tracing = []
|
||||
|
||||
const currentIndex = responseItem.workflowProcess.tracing.findIndex(item => item.node_id === nodeStartedData.node_id)
|
||||
// if the node is already started, update the node
|
||||
if (currentIndex > -1) {
|
||||
responseItem.workflowProcess.tracing[currentIndex] = {
|
||||
...nodeStartedData,
|
||||
status: NodeRunningStatus.Running,
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (nodeStartedData.iteration_id)
|
||||
return
|
||||
|
||||
responseItem.workflowProcess.tracing.push({
|
||||
...nodeStartedData,
|
||||
status: WorkflowRunningStatus.Running,
|
||||
})
|
||||
}
|
||||
upsertTopLevelTracingNodeOnStart(responseItem.workflowProcess.tracing, {
|
||||
...nodeStartedData,
|
||||
status: NodeRunningStatus.Running,
|
||||
})
|
||||
})
|
||||
},
|
||||
onNodeFinished: ({ data: nodeFinishedData }) => {
|
||||
updateChatTreeNode(messageId, (responseItem) => {
|
||||
if (params.loop_id)
|
||||
return
|
||||
|
||||
if (!responseItem.workflowProcess?.tracing)
|
||||
return
|
||||
|
||||
if (nodeFinishedData.iteration_id)
|
||||
return
|
||||
|
||||
if (nodeFinishedData.loop_id)
|
||||
return
|
||||
|
||||
const currentIndex = responseItem.workflowProcess.tracing.findIndex((item) => {
|
||||
if (!item.execution_metadata?.parallel_id)
|
||||
return item.id === nodeFinishedData.id
|
||||
@@ -481,8 +511,7 @@ export const useChat = (
|
||||
if (!responseItem.workflowProcess?.tracing)
|
||||
return
|
||||
const tracing = responseItem.workflowProcess.tracing
|
||||
const loopIndex = tracing.findIndex(item => item.node_id === loopFinishedData.node_id
|
||||
&& (item.execution_metadata?.parallel_id === loopFinishedData.execution_metadata?.parallel_id || item.parallel_id === loopFinishedData.execution_metadata?.parallel_id))!
|
||||
const loopIndex = findParallelTraceIndex(tracing, loopFinishedData)
|
||||
if (loopIndex > -1) {
|
||||
tracing[loopIndex] = {
|
||||
...tracing[loopIndex],
|
||||
@@ -558,7 +587,7 @@ export const useChat = (
|
||||
{},
|
||||
otherOptions,
|
||||
)
|
||||
}, [updateChatTreeNode, handleResponding, createAudioPlayerManager, config?.suggested_questions_after_answer])
|
||||
}, [updateChatTreeNode, handleResponding, createAudioPlayerManager, config?.suggested_questions_after_answer, params.loop_id])
|
||||
|
||||
const updateCurrentQAOnTree = useCallback(({
|
||||
parentId,
|
||||
@@ -948,12 +977,13 @@ export const useChat = (
|
||||
},
|
||||
onIterationFinish: ({ data: iterationFinishedData }) => {
|
||||
const tracing = responseItem.workflowProcess!.tracing!
|
||||
const iterationIndex = tracing.findIndex(item => item.node_id === iterationFinishedData.node_id
|
||||
&& (item.execution_metadata?.parallel_id === iterationFinishedData.execution_metadata?.parallel_id || item.parallel_id === iterationFinishedData.execution_metadata?.parallel_id))!
|
||||
tracing[iterationIndex] = {
|
||||
...tracing[iterationIndex],
|
||||
...iterationFinishedData,
|
||||
status: WorkflowRunningStatus.Succeeded,
|
||||
const iterationIndex = findParallelTraceIndex(tracing, iterationFinishedData)
|
||||
if (iterationIndex > -1) {
|
||||
tracing[iterationIndex] = {
|
||||
...tracing[iterationIndex],
|
||||
...iterationFinishedData,
|
||||
status: WorkflowRunningStatus.Succeeded,
|
||||
}
|
||||
}
|
||||
|
||||
updateCurrentQAOnTree({
|
||||
@@ -964,30 +994,19 @@ export const useChat = (
|
||||
})
|
||||
},
|
||||
onNodeStarted: ({ data: nodeStartedData }) => {
|
||||
// `data` is the outer send payload for this request; loop child runs should not emit top-level node traces here.
|
||||
if (data.loop_id)
|
||||
return
|
||||
|
||||
if (!responseItem.workflowProcess)
|
||||
return
|
||||
if (!responseItem.workflowProcess.tracing)
|
||||
responseItem.workflowProcess.tracing = []
|
||||
|
||||
const currentIndex = responseItem.workflowProcess.tracing.findIndex(item => item.node_id === nodeStartedData.node_id)
|
||||
if (currentIndex > -1) {
|
||||
responseItem.workflowProcess.tracing[currentIndex] = {
|
||||
...nodeStartedData,
|
||||
status: NodeRunningStatus.Running,
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (nodeStartedData.iteration_id)
|
||||
return
|
||||
|
||||
if (data.loop_id)
|
||||
return
|
||||
|
||||
responseItem.workflowProcess.tracing.push({
|
||||
...nodeStartedData,
|
||||
status: WorkflowRunningStatus.Running,
|
||||
})
|
||||
}
|
||||
upsertTopLevelTracingNodeOnStart(responseItem.workflowProcess.tracing, {
|
||||
...nodeStartedData,
|
||||
status: NodeRunningStatus.Running,
|
||||
})
|
||||
updateCurrentQAOnTree({
|
||||
placeholderQuestionId,
|
||||
questionItem,
|
||||
@@ -996,10 +1015,14 @@ export const useChat = (
|
||||
})
|
||||
},
|
||||
onNodeFinished: ({ data: nodeFinishedData }) => {
|
||||
// Use the outer request payload here as well so loop child runs skip top-level finish handling entirely.
|
||||
if (data.loop_id)
|
||||
return
|
||||
|
||||
if (nodeFinishedData.iteration_id)
|
||||
return
|
||||
|
||||
if (data.loop_id)
|
||||
if (nodeFinishedData.loop_id)
|
||||
return
|
||||
|
||||
const currentIndex = responseItem.workflowProcess!.tracing!.findIndex((item) => {
|
||||
@@ -1045,12 +1068,13 @@ export const useChat = (
|
||||
},
|
||||
onLoopFinish: ({ data: loopFinishedData }) => {
|
||||
const tracing = responseItem.workflowProcess!.tracing!
|
||||
const loopIndex = tracing.findIndex(item => item.node_id === loopFinishedData.node_id
|
||||
&& (item.execution_metadata?.parallel_id === loopFinishedData.execution_metadata?.parallel_id || item.parallel_id === loopFinishedData.execution_metadata?.parallel_id))!
|
||||
tracing[loopIndex] = {
|
||||
...tracing[loopIndex],
|
||||
...loopFinishedData,
|
||||
status: WorkflowRunningStatus.Succeeded,
|
||||
const loopIndex = findParallelTraceIndex(tracing, loopFinishedData)
|
||||
if (loopIndex > -1) {
|
||||
tracing[loopIndex] = {
|
||||
...tracing[loopIndex],
|
||||
...loopFinishedData,
|
||||
status: WorkflowRunningStatus.Succeeded,
|
||||
}
|
||||
}
|
||||
|
||||
updateCurrentQAOnTree({
|
||||
|
||||
@@ -264,7 +264,7 @@ describe('UrlInput', () => {
|
||||
|
||||
render(<UrlInput {...props} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
await userEvent.type(input, longUrl)
|
||||
fireEvent.change(input, { target: { value: longUrl } })
|
||||
|
||||
expect(input).toHaveValue(longUrl)
|
||||
})
|
||||
@@ -275,7 +275,7 @@ describe('UrlInput', () => {
|
||||
|
||||
render(<UrlInput {...props} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
await userEvent.type(input, unicodeUrl)
|
||||
fireEvent.change(input, { target: { value: unicodeUrl } })
|
||||
|
||||
expect(input).toHaveValue(unicodeUrl)
|
||||
})
|
||||
@@ -285,7 +285,7 @@ describe('UrlInput', () => {
|
||||
|
||||
render(<UrlInput {...props} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
await userEvent.type(input, 'https://rapid.com', { delay: 1 })
|
||||
fireEvent.change(input, { target: { value: 'https://rapid.com' } })
|
||||
|
||||
expect(input).toHaveValue('https://rapid.com')
|
||||
})
|
||||
@@ -297,7 +297,7 @@ describe('UrlInput', () => {
|
||||
|
||||
render(<UrlInput {...props} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
await userEvent.type(input, 'https://enter.com')
|
||||
fireEvent.change(input, { target: { value: 'https://enter.com' } })
|
||||
|
||||
// Focus button and press enter
|
||||
const button = screen.getByRole('button', { name: /run/i })
|
||||
|
||||
@@ -151,7 +151,7 @@ describe('useDatasetCardState', () => {
|
||||
expect(result.current.modalState.showRenameModal).toBe(false)
|
||||
})
|
||||
|
||||
it('should close confirm delete modal when closeConfirmDelete is called', () => {
|
||||
it('should close confirm delete modal when closeConfirmDelete is called', async () => {
|
||||
const dataset = createMockDataset()
|
||||
const { result } = renderHook(() =>
|
||||
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
|
||||
@@ -162,7 +162,7 @@ describe('useDatasetCardState', () => {
|
||||
result.current.detectIsUsedByApp()
|
||||
})
|
||||
|
||||
waitFor(() => {
|
||||
await waitFor(() => {
|
||||
expect(result.current.modalState.showConfirmDelete).toBe(true)
|
||||
})
|
||||
|
||||
|
||||
@@ -352,6 +352,10 @@ beforeEach(() => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
// Helper to find the name input in PublishAsKnowledgePipelineModal
|
||||
function getNameInput() {
|
||||
return screen.getByPlaceholderText('pipeline.common.publishAsPipeline.namePlaceholder')
|
||||
|
||||
@@ -101,6 +101,7 @@ const createHumanInput = (overrides: Partial<HumanInputFormData> = {}): HumanInp
|
||||
describe('workflow-stream-handlers helpers', () => {
|
||||
it('should update tracing, result text, and human input state', () => {
|
||||
const parallelTrace = createTrace({
|
||||
id: 'parallel-trace-1',
|
||||
node_id: 'parallel-node',
|
||||
execution_metadata: { parallel_id: 'parallel-1' },
|
||||
details: [[]],
|
||||
@@ -109,11 +110,13 @@ describe('workflow-stream-handlers helpers', () => {
|
||||
let workflowProcessData = appendParallelStart(undefined, parallelTrace)
|
||||
workflowProcessData = appendParallelNext(workflowProcessData, parallelTrace)
|
||||
workflowProcessData = finishParallelTrace(workflowProcessData, createTrace({
|
||||
id: 'parallel-trace-1',
|
||||
node_id: 'parallel-node',
|
||||
execution_metadata: { parallel_id: 'parallel-1' },
|
||||
error: 'failed',
|
||||
}))
|
||||
workflowProcessData = upsertWorkflowNode(workflowProcessData, createTrace({
|
||||
id: 'node-trace-1',
|
||||
node_id: 'node-1',
|
||||
execution_metadata: { parallel_id: 'parallel-2' },
|
||||
}))!
|
||||
@@ -160,6 +163,129 @@ describe('workflow-stream-handlers helpers', () => {
|
||||
expect(nextProcess.tracing[0]?.details).toEqual([[], []])
|
||||
})
|
||||
|
||||
it('should keep separate iteration and loop traces for repeated executions with different ids', () => {
|
||||
const process = createWorkflowProcess()
|
||||
process.tracing = [
|
||||
createTrace({
|
||||
id: 'iter-trace-1',
|
||||
node_id: 'iter-1',
|
||||
details: [[]],
|
||||
}),
|
||||
createTrace({
|
||||
id: 'iter-trace-2',
|
||||
node_id: 'iter-1',
|
||||
details: [[]],
|
||||
}),
|
||||
createTrace({
|
||||
id: 'loop-trace-1',
|
||||
node_id: 'loop-1',
|
||||
details: [[]],
|
||||
}),
|
||||
createTrace({
|
||||
id: 'loop-trace-2',
|
||||
node_id: 'loop-1',
|
||||
details: [[]],
|
||||
}),
|
||||
]
|
||||
|
||||
const iterNextProcess = appendParallelNext(process, createTrace({
|
||||
id: 'iter-trace-2',
|
||||
node_id: 'iter-1',
|
||||
}))
|
||||
const iterFinishedProcess = finishParallelTrace(iterNextProcess, createTrace({
|
||||
id: 'iter-trace-2',
|
||||
node_id: 'iter-1',
|
||||
status: NodeRunningStatus.Succeeded,
|
||||
details: undefined,
|
||||
}))
|
||||
const loopNextProcess = appendParallelNext(iterFinishedProcess, createTrace({
|
||||
id: 'loop-trace-2',
|
||||
node_id: 'loop-1',
|
||||
}))
|
||||
const loopFinishedProcess = finishParallelTrace(loopNextProcess, createTrace({
|
||||
id: 'loop-trace-2',
|
||||
node_id: 'loop-1',
|
||||
status: NodeRunningStatus.Succeeded,
|
||||
details: undefined,
|
||||
}))
|
||||
|
||||
expect(loopFinishedProcess.tracing[0]).toEqual(expect.objectContaining({
|
||||
id: 'iter-trace-1',
|
||||
details: [[]],
|
||||
status: NodeRunningStatus.Running,
|
||||
}))
|
||||
expect(loopFinishedProcess.tracing[1]).toEqual(expect.objectContaining({
|
||||
id: 'iter-trace-2',
|
||||
details: [[], []],
|
||||
status: NodeRunningStatus.Succeeded,
|
||||
}))
|
||||
expect(loopFinishedProcess.tracing[2]).toEqual(expect.objectContaining({
|
||||
id: 'loop-trace-1',
|
||||
details: [[]],
|
||||
status: NodeRunningStatus.Running,
|
||||
}))
|
||||
expect(loopFinishedProcess.tracing[3]).toEqual(expect.objectContaining({
|
||||
id: 'loop-trace-2',
|
||||
details: [[], []],
|
||||
status: NodeRunningStatus.Succeeded,
|
||||
}))
|
||||
})
|
||||
|
||||
it('should append a new top-level trace when the same node starts with a different execution id', () => {
|
||||
const process = createWorkflowProcess()
|
||||
process.tracing = [
|
||||
createTrace({
|
||||
id: 'trace-1',
|
||||
node_id: 'node-1',
|
||||
status: NodeRunningStatus.Succeeded,
|
||||
}),
|
||||
]
|
||||
|
||||
const updatedProcess = upsertWorkflowNode(process, createTrace({
|
||||
id: 'trace-2',
|
||||
node_id: 'node-1',
|
||||
}))!
|
||||
|
||||
expect(updatedProcess.tracing).toHaveLength(2)
|
||||
expect(updatedProcess.tracing[1]).toEqual(expect.objectContaining({
|
||||
id: 'trace-2',
|
||||
node_id: 'node-1',
|
||||
status: NodeRunningStatus.Running,
|
||||
}))
|
||||
})
|
||||
|
||||
it('should finish the matching top-level trace when the same node runs again with a new execution id', () => {
|
||||
const process = createWorkflowProcess()
|
||||
process.tracing = [
|
||||
createTrace({
|
||||
id: 'trace-1',
|
||||
node_id: 'node-1',
|
||||
status: NodeRunningStatus.Succeeded,
|
||||
}),
|
||||
createTrace({
|
||||
id: 'trace-2',
|
||||
node_id: 'node-1',
|
||||
status: NodeRunningStatus.Running,
|
||||
}),
|
||||
]
|
||||
|
||||
const updatedProcess = finishWorkflowNode(process, createTrace({
|
||||
id: 'trace-2',
|
||||
node_id: 'node-1',
|
||||
status: NodeRunningStatus.Succeeded,
|
||||
}))!
|
||||
|
||||
expect(updatedProcess.tracing).toHaveLength(2)
|
||||
expect(updatedProcess.tracing[0]).toEqual(expect.objectContaining({
|
||||
id: 'trace-1',
|
||||
status: NodeRunningStatus.Succeeded,
|
||||
}))
|
||||
expect(updatedProcess.tracing[1]).toEqual(expect.objectContaining({
|
||||
id: 'trace-2',
|
||||
status: NodeRunningStatus.Succeeded,
|
||||
}))
|
||||
})
|
||||
|
||||
it('should leave tracing unchanged when a parallel next event has no matching trace', () => {
|
||||
const process = createWorkflowProcess()
|
||||
process.tracing = [
|
||||
@@ -171,6 +297,7 @@ describe('workflow-stream-handlers helpers', () => {
|
||||
]
|
||||
|
||||
const nextProcess = appendParallelNext(process, createTrace({
|
||||
id: 'trace-missing',
|
||||
node_id: 'missing-node',
|
||||
execution_metadata: { parallel_id: 'parallel-2' },
|
||||
}))
|
||||
@@ -228,6 +355,7 @@ describe('workflow-stream-handlers helpers', () => {
|
||||
},
|
||||
}))
|
||||
const notFinished = finishParallelTrace(process, createTrace({
|
||||
id: 'trace-missing',
|
||||
node_id: 'missing',
|
||||
execution_metadata: {
|
||||
parallel_id: 'parallel-missing',
|
||||
@@ -243,6 +371,7 @@ describe('workflow-stream-handlers helpers', () => {
|
||||
loop_id: 'loop-1',
|
||||
}))
|
||||
const unmatchedFinish = finishWorkflowNode(process, createTrace({
|
||||
id: 'trace-missing',
|
||||
node_id: 'missing',
|
||||
execution_metadata: {
|
||||
parallel_id: 'missing',
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { HumanInputFormTimeoutData, NodeTracing, WorkflowFinishedResponse }
|
||||
import { produce } from 'immer'
|
||||
import { getFilesInLogs } from '@/app/components/base/file-uploader/utils'
|
||||
import { NodeRunningStatus, WorkflowRunningStatus } from '@/app/components/workflow/types'
|
||||
import { upsertTopLevelTracingNodeOnStart } from '@/app/components/workflow/utils/top-level-tracing'
|
||||
import { sseGet } from '@/service/base'
|
||||
|
||||
type Notify = (payload: { type: 'error' | 'warning', message: string }) => void
|
||||
@@ -49,6 +50,15 @@ const matchParallelTrace = (trace: WorkflowProcess['tracing'][number], data: Nod
|
||||
|| trace.parallel_id === data.execution_metadata?.parallel_id)
|
||||
}
|
||||
|
||||
const findParallelTraceIndex = (tracing: WorkflowProcess['tracing'], data: NodeTracing) => {
|
||||
return tracing.findIndex((trace) => {
|
||||
if (trace.id && data.id)
|
||||
return trace.id === data.id
|
||||
|
||||
return matchParallelTrace(trace, data)
|
||||
})
|
||||
}
|
||||
|
||||
const ensureParallelTraceDetails = (details?: NodeTracing['details']) => {
|
||||
return details?.length ? details : [[]]
|
||||
}
|
||||
@@ -68,7 +78,8 @@ const appendParallelStart = (current: WorkflowProcess | undefined, data: NodeTra
|
||||
const appendParallelNext = (current: WorkflowProcess | undefined, data: NodeTracing) => {
|
||||
return updateWorkflowProcess(current, (draft) => {
|
||||
draft.expand = true
|
||||
const trace = draft.tracing.find(item => matchParallelTrace(item, data))
|
||||
const traceIndex = findParallelTraceIndex(draft.tracing, data)
|
||||
const trace = draft.tracing[traceIndex]
|
||||
if (!trace)
|
||||
return
|
||||
|
||||
@@ -80,10 +91,13 @@ const appendParallelNext = (current: WorkflowProcess | undefined, data: NodeTrac
|
||||
const finishParallelTrace = (current: WorkflowProcess | undefined, data: NodeTracing) => {
|
||||
return updateWorkflowProcess(current, (draft) => {
|
||||
draft.expand = true
|
||||
const traceIndex = draft.tracing.findIndex(item => matchParallelTrace(item, data))
|
||||
const traceIndex = findParallelTraceIndex(draft.tracing, data)
|
||||
if (traceIndex > -1) {
|
||||
const currentTrace = draft.tracing[traceIndex]
|
||||
draft.tracing[traceIndex] = {
|
||||
...currentTrace,
|
||||
...data,
|
||||
details: data.details ?? currentTrace.details,
|
||||
expand: !!data.error,
|
||||
}
|
||||
}
|
||||
@@ -96,17 +110,22 @@ const upsertWorkflowNode = (current: WorkflowProcess | undefined, data: NodeTrac
|
||||
|
||||
return updateWorkflowProcess(current, (draft) => {
|
||||
draft.expand = true
|
||||
const currentIndex = draft.tracing.findIndex(item => item.node_id === data.node_id)
|
||||
const nextTrace = {
|
||||
...data,
|
||||
status: NodeRunningStatus.Running,
|
||||
expand: true,
|
||||
}
|
||||
|
||||
if (currentIndex > -1)
|
||||
draft.tracing[currentIndex] = nextTrace
|
||||
else
|
||||
draft.tracing.push(nextTrace)
|
||||
upsertTopLevelTracingNodeOnStart(draft.tracing, nextTrace)
|
||||
})
|
||||
}
|
||||
|
||||
const findWorkflowNodeTraceIndex = (tracing: WorkflowProcess['tracing'], data: NodeTracing) => {
|
||||
return tracing.findIndex((trace) => {
|
||||
if (trace.id && data.id)
|
||||
return trace.id === data.id
|
||||
|
||||
return matchParallelTrace(trace, data)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -115,7 +134,7 @@ const finishWorkflowNode = (current: WorkflowProcess | undefined, data: NodeTrac
|
||||
return current
|
||||
|
||||
return updateWorkflowProcess(current, (draft) => {
|
||||
const currentIndex = draft.tracing.findIndex(trace => matchParallelTrace(trace, data))
|
||||
const currentIndex = findWorkflowNodeTraceIndex(draft.tracing, data)
|
||||
if (currentIndex > -1) {
|
||||
draft.tracing[currentIndex] = {
|
||||
...(draft.tracing[currentIndex].extras
|
||||
|
||||
@@ -109,13 +109,13 @@ describe('useWorkflowAgentLog', () => {
|
||||
const { result, store } = renderWorkflowHook(() => useWorkflowAgentLog(), {
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData({
|
||||
tracing: [{ node_id: 'n1', execution_metadata: {} }],
|
||||
tracing: [{ id: 'trace-1', node_id: 'n1', execution_metadata: {} }],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
result.current.handleWorkflowAgentLog({
|
||||
data: { node_id: 'n1', message_id: 'm1' },
|
||||
data: { node_id: 'n1', node_execution_id: 'trace-1', message_id: 'm1' },
|
||||
} as AgentLogResponse)
|
||||
|
||||
const trace = store.getState().workflowRunningData!.tracing![0]
|
||||
@@ -128,6 +128,7 @@ describe('useWorkflowAgentLog', () => {
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData({
|
||||
tracing: [{
|
||||
id: 'trace-1',
|
||||
node_id: 'n1',
|
||||
execution_metadata: { agent_log: [{ message_id: 'm1', text: 'log1' }] },
|
||||
}],
|
||||
@@ -136,7 +137,7 @@ describe('useWorkflowAgentLog', () => {
|
||||
})
|
||||
|
||||
result.current.handleWorkflowAgentLog({
|
||||
data: { node_id: 'n1', message_id: 'm2' },
|
||||
data: { node_id: 'n1', node_execution_id: 'trace-1', message_id: 'm2' },
|
||||
} as AgentLogResponse)
|
||||
|
||||
expect(store.getState().workflowRunningData!.tracing![0].execution_metadata!.agent_log).toHaveLength(2)
|
||||
@@ -147,6 +148,7 @@ describe('useWorkflowAgentLog', () => {
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData({
|
||||
tracing: [{
|
||||
id: 'trace-1',
|
||||
node_id: 'n1',
|
||||
execution_metadata: { agent_log: [{ message_id: 'm1', text: 'old' }] },
|
||||
}],
|
||||
@@ -155,7 +157,7 @@ describe('useWorkflowAgentLog', () => {
|
||||
})
|
||||
|
||||
result.current.handleWorkflowAgentLog({
|
||||
data: { node_id: 'n1', message_id: 'm1', text: 'new' },
|
||||
data: { node_id: 'n1', node_execution_id: 'trace-1', message_id: 'm1', text: 'new' },
|
||||
} as unknown as AgentLogResponse)
|
||||
|
||||
const log = store.getState().workflowRunningData!.tracing![0].execution_metadata!.agent_log!
|
||||
@@ -167,17 +169,39 @@ describe('useWorkflowAgentLog', () => {
|
||||
const { result, store } = renderWorkflowHook(() => useWorkflowAgentLog(), {
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData({
|
||||
tracing: [{ node_id: 'n1' }],
|
||||
tracing: [{ id: 'trace-1', node_id: 'n1' }],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
result.current.handleWorkflowAgentLog({
|
||||
data: { node_id: 'n1', message_id: 'm1' },
|
||||
data: { node_id: 'n1', node_execution_id: 'trace-1', message_id: 'm1' },
|
||||
} as AgentLogResponse)
|
||||
|
||||
expect(store.getState().workflowRunningData!.tracing![0].execution_metadata!.agent_log).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should attach the log to the matching execution id when a node runs multiple times', () => {
|
||||
const { result, store } = renderWorkflowHook(() => useWorkflowAgentLog(), {
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData({
|
||||
tracing: [
|
||||
{ id: 'trace-1', node_id: 'n1', execution_metadata: {} },
|
||||
{ id: 'trace-2', node_id: 'n1', execution_metadata: {} },
|
||||
],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
result.current.handleWorkflowAgentLog({
|
||||
data: { node_id: 'n1', node_execution_id: 'trace-2', message_id: 'm2' },
|
||||
} as AgentLogResponse)
|
||||
|
||||
const tracing = store.getState().workflowRunningData!.tracing!
|
||||
expect(tracing[0].execution_metadata!.agent_log).toBeUndefined()
|
||||
expect(tracing[1].execution_metadata!.agent_log).toHaveLength(1)
|
||||
expect(tracing[1].execution_metadata!.agent_log![0].message_id).toBe('m2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useWorkflowNodeHumanInputFormFilled', () => {
|
||||
|
||||
@@ -109,7 +109,7 @@ describe('useWorkflowNodeStarted', () => {
|
||||
|
||||
act(() => {
|
||||
result.current.handleWorkflowNodeStarted(
|
||||
{ data: { node_id: 'n1' } } as NodeStartedResponse,
|
||||
{ data: { id: 'trace-n1', node_id: 'n1' } } as NodeStartedResponse,
|
||||
containerParams,
|
||||
)
|
||||
})
|
||||
@@ -138,7 +138,7 @@ describe('useWorkflowNodeStarted', () => {
|
||||
|
||||
act(() => {
|
||||
result.current.handleWorkflowNodeStarted(
|
||||
{ data: { node_id: 'n2' } } as NodeStartedResponse,
|
||||
{ data: { id: 'trace-n2', node_id: 'n2' } } as NodeStartedResponse,
|
||||
containerParams,
|
||||
)
|
||||
})
|
||||
@@ -157,8 +157,8 @@ describe('useWorkflowNodeStarted', () => {
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData({
|
||||
tracing: [
|
||||
{ node_id: 'n0', status: NodeRunningStatus.Succeeded },
|
||||
{ node_id: 'n1', status: NodeRunningStatus.Succeeded },
|
||||
{ id: 'trace-0', node_id: 'n0', status: NodeRunningStatus.Succeeded },
|
||||
{ id: 'trace-1', node_id: 'n1', status: NodeRunningStatus.Succeeded },
|
||||
],
|
||||
}),
|
||||
},
|
||||
@@ -166,7 +166,7 @@ describe('useWorkflowNodeStarted', () => {
|
||||
|
||||
act(() => {
|
||||
result.current.handleWorkflowNodeStarted(
|
||||
{ data: { node_id: 'n1' } } as NodeStartedResponse,
|
||||
{ data: { id: 'trace-1', node_id: 'n1' } } as NodeStartedResponse,
|
||||
containerParams,
|
||||
)
|
||||
})
|
||||
@@ -175,6 +175,32 @@ describe('useWorkflowNodeStarted', () => {
|
||||
expect(tracing).toHaveLength(2)
|
||||
expect(tracing[1].status).toBe(NodeRunningStatus.Running)
|
||||
})
|
||||
|
||||
it('should append a new tracing entry when the same node starts a new execution id', () => {
|
||||
const { result, store } = renderViewportHook(() => useWorkflowNodeStarted(), {
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData({
|
||||
tracing: [
|
||||
{ id: 'trace-0', node_id: 'n0', status: NodeRunningStatus.Succeeded },
|
||||
{ id: 'trace-1', node_id: 'n1', status: NodeRunningStatus.Succeeded },
|
||||
],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleWorkflowNodeStarted(
|
||||
{ data: { id: 'trace-2', node_id: 'n1' } } as NodeStartedResponse,
|
||||
containerParams,
|
||||
)
|
||||
})
|
||||
|
||||
const tracing = store.getState().workflowRunningData!.tracing!
|
||||
expect(tracing).toHaveLength(3)
|
||||
expect(tracing[2].id).toBe('trace-2')
|
||||
expect(tracing[2].node_id).toBe('n1')
|
||||
expect(tracing[2].status).toBe(NodeRunningStatus.Running)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useWorkflowNodeIterationStarted', () => {
|
||||
|
||||
@@ -14,7 +14,7 @@ export const useWorkflowAgentLog = () => {
|
||||
} = workflowStore.getState()
|
||||
|
||||
setWorkflowRunningData(produce(workflowRunningData!, (draft) => {
|
||||
const currentIndex = draft.tracing!.findIndex(item => item.node_id === data.node_id)
|
||||
const currentIndex = draft.tracing!.findIndex(item => item.id === data.node_execution_id)
|
||||
if (currentIndex > -1) {
|
||||
const current = draft.tracing![currentIndex]
|
||||
|
||||
|
||||
@@ -33,8 +33,8 @@ export const useWorkflowNodeStarted = () => {
|
||||
transform,
|
||||
} = store.getState()
|
||||
const nodes = getNodes()
|
||||
const currentIndex = workflowRunningData?.tracing?.findIndex(item => item.node_id === data.node_id)
|
||||
if (currentIndex && currentIndex > -1) {
|
||||
const currentIndex = workflowRunningData?.tracing?.findIndex(item => item.id === data.id)
|
||||
if (currentIndex !== undefined && currentIndex > -1) {
|
||||
setWorkflowRunningData(produce(workflowRunningData!, (draft) => {
|
||||
draft.tracing![currentIndex] = {
|
||||
...data,
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
import { useHooksStore } from '../../hooks-store'
|
||||
import { useWorkflowStore } from '../../store'
|
||||
import { NodeRunningStatus, WorkflowRunningStatus } from '../../types'
|
||||
import { upsertTopLevelTracingNodeOnStart } from '../../utils/top-level-tracing'
|
||||
|
||||
type GetAbortController = (abortController: AbortController) => void
|
||||
type SendCallback = {
|
||||
@@ -486,19 +487,13 @@ export const useChat = (
|
||||
}
|
||||
},
|
||||
onNodeStarted: ({ data }) => {
|
||||
const currentIndex = responseItem.workflowProcess!.tracing!.findIndex(item => item.node_id === data.node_id)
|
||||
if (currentIndex > -1) {
|
||||
responseItem.workflowProcess!.tracing![currentIndex] = {
|
||||
...data,
|
||||
status: NodeRunningStatus.Running,
|
||||
}
|
||||
}
|
||||
else {
|
||||
responseItem.workflowProcess!.tracing!.push({
|
||||
...data,
|
||||
status: NodeRunningStatus.Running,
|
||||
})
|
||||
}
|
||||
if (params.loop_id)
|
||||
return
|
||||
|
||||
upsertTopLevelTracingNodeOnStart(responseItem.workflowProcess!.tracing!, {
|
||||
...data,
|
||||
status: NodeRunningStatus.Running,
|
||||
})
|
||||
updateCurrentQAOnTree({
|
||||
placeholderQuestionId,
|
||||
questionItem,
|
||||
@@ -517,6 +512,9 @@ export const useChat = (
|
||||
})
|
||||
},
|
||||
onNodeFinished: ({ data }) => {
|
||||
if (params.loop_id)
|
||||
return
|
||||
|
||||
const currentTracingIndex = responseItem.workflowProcess!.tracing!.findIndex(item => item.id === data.id)
|
||||
if (currentTracingIndex > -1) {
|
||||
responseItem.workflowProcess!.tracing[currentTracingIndex] = {
|
||||
@@ -532,7 +530,7 @@ export const useChat = (
|
||||
}
|
||||
},
|
||||
onAgentLog: ({ data }) => {
|
||||
const currentNodeIndex = responseItem.workflowProcess!.tracing!.findIndex(item => item.node_id === data.node_id)
|
||||
const currentNodeIndex = responseItem.workflowProcess!.tracing!.findIndex(item => item.id === data.node_execution_id)
|
||||
if (currentNodeIndex > -1) {
|
||||
const current = responseItem.workflowProcess!.tracing![currentNodeIndex]
|
||||
|
||||
@@ -758,8 +756,7 @@ export const useChat = (
|
||||
if (!responseItem.workflowProcess?.tracing)
|
||||
return
|
||||
const tracing = responseItem.workflowProcess.tracing
|
||||
const iterationIndex = tracing.findIndex(item => item.node_id === iterationFinishedData.node_id
|
||||
&& (item.execution_metadata?.parallel_id === iterationFinishedData.execution_metadata?.parallel_id || item.parallel_id === iterationFinishedData.execution_metadata?.parallel_id))!
|
||||
const iterationIndex = tracing.findIndex(item => item.id === iterationFinishedData.id)!
|
||||
if (iterationIndex > -1) {
|
||||
tracing[iterationIndex] = {
|
||||
...tracing[iterationIndex],
|
||||
@@ -776,22 +773,10 @@ export const useChat = (
|
||||
if (!responseItem.workflowProcess.tracing)
|
||||
responseItem.workflowProcess.tracing = []
|
||||
|
||||
const currentIndex = responseItem.workflowProcess.tracing.findIndex(item => item.node_id === nodeStartedData.node_id)
|
||||
if (currentIndex > -1) {
|
||||
responseItem.workflowProcess.tracing[currentIndex] = {
|
||||
...nodeStartedData,
|
||||
status: NodeRunningStatus.Running,
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (nodeStartedData.iteration_id)
|
||||
return
|
||||
|
||||
responseItem.workflowProcess.tracing.push({
|
||||
...nodeStartedData,
|
||||
status: WorkflowRunningStatus.Running,
|
||||
})
|
||||
}
|
||||
upsertTopLevelTracingNodeOnStart(responseItem.workflowProcess.tracing, {
|
||||
...nodeStartedData,
|
||||
status: NodeRunningStatus.Running,
|
||||
})
|
||||
})
|
||||
},
|
||||
onNodeFinished: ({ data: nodeFinishedData }) => {
|
||||
@@ -802,6 +787,9 @@ export const useChat = (
|
||||
if (nodeFinishedData.iteration_id)
|
||||
return
|
||||
|
||||
if (nodeFinishedData.loop_id)
|
||||
return
|
||||
|
||||
const currentIndex = responseItem.workflowProcess.tracing.findIndex((item) => {
|
||||
if (!item.execution_metadata?.parallel_id)
|
||||
return item.id === nodeFinishedData.id
|
||||
@@ -829,8 +817,7 @@ export const useChat = (
|
||||
if (!responseItem.workflowProcess?.tracing)
|
||||
return
|
||||
const tracing = responseItem.workflowProcess.tracing
|
||||
const loopIndex = tracing.findIndex(item => item.node_id === loopFinishedData.node_id
|
||||
&& (item.execution_metadata?.parallel_id === loopFinishedData.execution_metadata?.parallel_id || item.parallel_id === loopFinishedData.execution_metadata?.parallel_id))!
|
||||
const loopIndex = tracing.findIndex(item => item.id === loopFinishedData.id)!
|
||||
if (loopIndex > -1) {
|
||||
tracing[loopIndex] = {
|
||||
...tracing[loopIndex],
|
||||
|
||||
133
web/app/components/workflow/utils/top-level-tracing.spec.ts
Normal file
133
web/app/components/workflow/utils/top-level-tracing.spec.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import type { NodeTracing } from '@/types/workflow'
|
||||
import { NodeRunningStatus } from '@/app/components/workflow/types'
|
||||
import { upsertTopLevelTracingNodeOnStart } from './top-level-tracing'
|
||||
|
||||
const createTrace = (overrides: Partial<NodeTracing> = {}): NodeTracing => ({
|
||||
id: 'trace-1',
|
||||
index: 0,
|
||||
predecessor_node_id: '',
|
||||
node_id: 'node-1',
|
||||
node_type: 'llm' as NodeTracing['node_type'],
|
||||
title: 'Node 1',
|
||||
inputs: {},
|
||||
inputs_truncated: false,
|
||||
process_data: {},
|
||||
process_data_truncated: false,
|
||||
outputs: {},
|
||||
outputs_truncated: false,
|
||||
status: NodeRunningStatus.Succeeded,
|
||||
elapsed_time: 0,
|
||||
metadata: {
|
||||
iterator_length: 0,
|
||||
iterator_index: 0,
|
||||
loop_length: 0,
|
||||
loop_index: 0,
|
||||
},
|
||||
created_at: 0,
|
||||
created_by: {
|
||||
id: 'user-1',
|
||||
name: 'User',
|
||||
email: 'user@example.com',
|
||||
},
|
||||
finished_at: 0,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('upsertTopLevelTracingNodeOnStart', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should append a new top-level node when no matching trace exists', () => {
|
||||
const tracing: NodeTracing[] = []
|
||||
const startedNode = createTrace({
|
||||
id: 'trace-2',
|
||||
node_id: 'node-2',
|
||||
status: NodeRunningStatus.Running,
|
||||
})
|
||||
|
||||
const updated = upsertTopLevelTracingNodeOnStart(tracing, startedNode)
|
||||
|
||||
expect(updated).toBe(true)
|
||||
expect(tracing).toEqual([startedNode])
|
||||
})
|
||||
|
||||
it('should update an existing top-level node when the execution id matches', () => {
|
||||
const tracing: NodeTracing[] = [
|
||||
createTrace({
|
||||
id: 'trace-1',
|
||||
node_id: 'node-1',
|
||||
status: NodeRunningStatus.Succeeded,
|
||||
}),
|
||||
]
|
||||
const startedNode = createTrace({
|
||||
id: 'trace-1',
|
||||
node_id: 'node-1',
|
||||
status: NodeRunningStatus.Running,
|
||||
})
|
||||
|
||||
const updated = upsertTopLevelTracingNodeOnStart(tracing, startedNode)
|
||||
|
||||
expect(updated).toBe(true)
|
||||
expect(tracing).toEqual([startedNode])
|
||||
})
|
||||
|
||||
it('should append a new top-level node when the same node starts with a new execution id', () => {
|
||||
const existingTrace = createTrace({
|
||||
id: 'trace-1',
|
||||
node_id: 'node-1',
|
||||
status: NodeRunningStatus.Succeeded,
|
||||
})
|
||||
const tracing: NodeTracing[] = [existingTrace]
|
||||
const startedNode = createTrace({
|
||||
id: 'trace-2',
|
||||
node_id: 'node-1',
|
||||
status: NodeRunningStatus.Running,
|
||||
})
|
||||
|
||||
const updated = upsertTopLevelTracingNodeOnStart(tracing, startedNode)
|
||||
|
||||
expect(updated).toBe(true)
|
||||
expect(tracing).toEqual([existingTrace, startedNode])
|
||||
})
|
||||
|
||||
it('should ignore nested iteration node starts even when the node id matches a top-level trace', () => {
|
||||
const existingTrace = createTrace({
|
||||
id: 'top-level-trace',
|
||||
node_id: 'node-1',
|
||||
status: NodeRunningStatus.Succeeded,
|
||||
})
|
||||
const tracing: NodeTracing[] = [existingTrace]
|
||||
const nestedIterationTrace = createTrace({
|
||||
id: 'iteration-trace',
|
||||
node_id: 'node-1',
|
||||
iteration_id: 'iteration-1',
|
||||
status: NodeRunningStatus.Running,
|
||||
})
|
||||
|
||||
const updated = upsertTopLevelTracingNodeOnStart(tracing, nestedIterationTrace)
|
||||
|
||||
expect(updated).toBe(false)
|
||||
expect(tracing).toEqual([existingTrace])
|
||||
})
|
||||
|
||||
it('should ignore nested loop node starts even when the node id matches a top-level trace', () => {
|
||||
const existingTrace = createTrace({
|
||||
id: 'top-level-trace',
|
||||
node_id: 'node-1',
|
||||
status: NodeRunningStatus.Succeeded,
|
||||
})
|
||||
const tracing: NodeTracing[] = [existingTrace]
|
||||
const nestedLoopTrace = createTrace({
|
||||
id: 'loop-trace',
|
||||
node_id: 'node-1',
|
||||
loop_id: 'loop-1',
|
||||
status: NodeRunningStatus.Running,
|
||||
})
|
||||
|
||||
const updated = upsertTopLevelTracingNodeOnStart(tracing, nestedLoopTrace)
|
||||
|
||||
expect(updated).toBe(false)
|
||||
expect(tracing).toEqual([existingTrace])
|
||||
})
|
||||
})
|
||||
22
web/app/components/workflow/utils/top-level-tracing.ts
Normal file
22
web/app/components/workflow/utils/top-level-tracing.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { NodeTracing } from '@/types/workflow'
|
||||
|
||||
const isNestedTracingNode = (trace: Pick<NodeTracing, 'iteration_id' | 'loop_id'>) => {
|
||||
return Boolean(trace.iteration_id || trace.loop_id)
|
||||
}
|
||||
|
||||
export const upsertTopLevelTracingNodeOnStart = (
|
||||
tracing: NodeTracing[],
|
||||
startedNode: NodeTracing,
|
||||
) => {
|
||||
if (isNestedTracingNode(startedNode))
|
||||
return false
|
||||
|
||||
const currentIndex = tracing.findIndex(item => item.id === startedNode.id)
|
||||
if (currentIndex > -1)
|
||||
// Started events are the authoritative snapshot for an execution; merging would retain stale client-side fields.
|
||||
tracing[currentIndex] = startedNode
|
||||
else
|
||||
tracing.push(startedNode)
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -35,8 +35,6 @@
|
||||
"error.emailEmpty": "عنوان البريد الإلكتروني مطلوب",
|
||||
"error.emailInValid": "يرجى إدخال عنوان بريد إلكتروني صالح",
|
||||
"error.invalidEmailOrPassword": "بريد إلكتروني أو كلمة مرور غير صالحة.",
|
||||
"error.invalidRedirectUrlOrAppCode": "رابط إعادة التوجيه أو رمز التطبيق غير صالح",
|
||||
"error.invalidSSOProtocol": "بروتوكول SSO غير صالح",
|
||||
"error.nameEmpty": "الاسم مطلوب",
|
||||
"error.passwordEmpty": "كلمة المرور مطلوبة",
|
||||
"error.passwordInvalid": "يجب أن تحتوي كلمة المرور على أحرف وأرقام، ويجب أن يكون الطول أكبر من 8",
|
||||
|
||||
@@ -35,8 +35,6 @@
|
||||
"error.emailEmpty": "E-Mail-Adresse wird benötigt",
|
||||
"error.emailInValid": "Bitte gib eine gültige E-Mail-Adresse ein",
|
||||
"error.invalidEmailOrPassword": "Ungültige E-Mail oder Passwort.",
|
||||
"error.invalidRedirectUrlOrAppCode": "Ungültige Weiterleitungs-URL oder App-Code",
|
||||
"error.invalidSSOProtocol": "Ungültiges SSO-Protokoll",
|
||||
"error.nameEmpty": "Name wird benötigt",
|
||||
"error.passwordEmpty": "Passwort wird benötigt",
|
||||
"error.passwordInvalid": "Das Passwort muss Buchstaben und Zahlen enthalten und länger als 8 Zeichen sein",
|
||||
|
||||
@@ -35,8 +35,6 @@
|
||||
"error.emailEmpty": "Se requiere una dirección de correo electrónico",
|
||||
"error.emailInValid": "Por favor, ingresa una dirección de correo electrónico válida",
|
||||
"error.invalidEmailOrPassword": "Correo electrónico o contraseña inválidos.",
|
||||
"error.invalidRedirectUrlOrAppCode": "URL de redirección o código de aplicación inválido",
|
||||
"error.invalidSSOProtocol": "Protocolo SSO inválido",
|
||||
"error.nameEmpty": "Se requiere un nombre",
|
||||
"error.passwordEmpty": "Se requiere una contraseña",
|
||||
"error.passwordInvalid": "La contraseña debe contener letras y números, y tener una longitud mayor a 8",
|
||||
|
||||
@@ -35,8 +35,6 @@
|
||||
"error.emailEmpty": "آدرس ایمیل لازم است",
|
||||
"error.emailInValid": "لطفاً یک آدرس ایمیل معتبر وارد کنید",
|
||||
"error.invalidEmailOrPassword": "ایمیل یا رمز عبور نامعتبر است.",
|
||||
"error.invalidRedirectUrlOrAppCode": "آدرس تغییر مسیر یا کد برنامه نامعتبر است",
|
||||
"error.invalidSSOProtocol": "پروتکل SSO نامعتبر است",
|
||||
"error.nameEmpty": "نام لازم است",
|
||||
"error.passwordEmpty": "رمز عبور لازم است",
|
||||
"error.passwordInvalid": "رمز عبور باید شامل حروف و اعداد باشد و طول آن بیشتر از ۸ کاراکتر باشد",
|
||||
|
||||
@@ -35,8 +35,6 @@
|
||||
"error.emailEmpty": "Une adresse e-mail est requise",
|
||||
"error.emailInValid": "Veuillez entrer une adresse email valide",
|
||||
"error.invalidEmailOrPassword": "Adresse e-mail ou mot de passe invalide.",
|
||||
"error.invalidRedirectUrlOrAppCode": "URL de redirection ou code d'application invalide",
|
||||
"error.invalidSSOProtocol": "Protocole SSO invalide",
|
||||
"error.nameEmpty": "Le nom est requis",
|
||||
"error.passwordEmpty": "Un mot de passe est requis",
|
||||
"error.passwordInvalid": "Le mot de passe doit contenir des lettres et des chiffres, et la longueur doit être supérieure à 8.",
|
||||
|
||||
@@ -35,8 +35,6 @@
|
||||
"error.emailEmpty": "ईमेल पता आवश्यक है",
|
||||
"error.emailInValid": "कृपया एक मान्य ईमेल पता दर्ज करें",
|
||||
"error.invalidEmailOrPassword": "अमान्य ईमेल या पासवर्ड।",
|
||||
"error.invalidRedirectUrlOrAppCode": "अमान्य रीडायरेक्ट URL या ऐप कोड",
|
||||
"error.invalidSSOProtocol": "अमान्य SSO प्रोटोकॉल",
|
||||
"error.nameEmpty": "नाम आवश्यक है",
|
||||
"error.passwordEmpty": "पासवर्ड आवश्यक है",
|
||||
"error.passwordInvalid": "पासवर्ड में अक्षर और अंक होने चाहिए, और लंबाई 8 से अधिक होनी चाहिए",
|
||||
|
||||
@@ -35,8 +35,6 @@
|
||||
"error.emailEmpty": "Alamat email diperlukan",
|
||||
"error.emailInValid": "Silakan masukkan alamat email yang valid",
|
||||
"error.invalidEmailOrPassword": "Email atau kata sandi tidak valid.",
|
||||
"error.invalidRedirectUrlOrAppCode": "URL pengalihan atau kode aplikasi tidak valid",
|
||||
"error.invalidSSOProtocol": "Protokol SSO tidak valid",
|
||||
"error.nameEmpty": "Nama diperlukan",
|
||||
"error.passwordEmpty": "Kata sandi diperlukan",
|
||||
"error.passwordInvalid": "Kata sandi harus berisi huruf dan angka, dan panjangnya harus lebih besar dari 8",
|
||||
|
||||
@@ -35,8 +35,6 @@
|
||||
"error.emailEmpty": "L'indirizzo email è obbligatorio",
|
||||
"error.emailInValid": "Per favore inserisci un indirizzo email valido",
|
||||
"error.invalidEmailOrPassword": "Email o password non validi.",
|
||||
"error.invalidRedirectUrlOrAppCode": "URL di reindirizzamento o codice app non valido",
|
||||
"error.invalidSSOProtocol": "Protocollo SSO non valido",
|
||||
"error.nameEmpty": "Il nome è obbligatorio",
|
||||
"error.passwordEmpty": "La password è obbligatoria",
|
||||
"error.passwordInvalid": "La password deve contenere lettere e numeri, e la lunghezza deve essere maggiore di 8",
|
||||
|
||||
@@ -35,8 +35,6 @@
|
||||
"error.emailEmpty": "メールアドレスは必須です",
|
||||
"error.emailInValid": "有効なメールアドレスを入力してください",
|
||||
"error.invalidEmailOrPassword": "無効なメールアドレスまたはパスワードです。",
|
||||
"error.invalidRedirectUrlOrAppCode": "無効なリダイレクトURLまたはアプリコード",
|
||||
"error.invalidSSOProtocol": "無効なSSOプロトコル",
|
||||
"error.nameEmpty": "名前は必須です",
|
||||
"error.passwordEmpty": "パスワードは必須です",
|
||||
"error.passwordInvalid": "パスワードは文字と数字を含み、長さは 8 以上である必要があります",
|
||||
|
||||
@@ -35,8 +35,6 @@
|
||||
"error.emailEmpty": "이메일 주소를 입력하세요.",
|
||||
"error.emailInValid": "유효한 이메일 주소를 입력하세요.",
|
||||
"error.invalidEmailOrPassword": "유효하지 않은 이메일이나 비밀번호입니다.",
|
||||
"error.invalidRedirectUrlOrAppCode": "유효하지 않은 리디렉션 URL 또는 앱 코드",
|
||||
"error.invalidSSOProtocol": "유효하지 않은 SSO 프로토콜",
|
||||
"error.nameEmpty": "사용자 이름을 입력하세요.",
|
||||
"error.passwordEmpty": "비밀번호를 입력하세요.",
|
||||
"error.passwordInvalid": "비밀번호는 문자와 숫자를 포함하고 8 자 이상이어야 합니다.",
|
||||
|
||||
@@ -35,8 +35,6 @@
|
||||
"error.emailEmpty": "Email address is required",
|
||||
"error.emailInValid": "Please enter a valid email address",
|
||||
"error.invalidEmailOrPassword": "Invalid email or password.",
|
||||
"error.invalidRedirectUrlOrAppCode": "Ongeldige doorstuur-URL of app-code",
|
||||
"error.invalidSSOProtocol": "Ongeldig SSO-protocol",
|
||||
"error.nameEmpty": "Name is required",
|
||||
"error.passwordEmpty": "Password is required",
|
||||
"error.passwordInvalid": "Password must contain letters and numbers, and the length must be greater than 8",
|
||||
|
||||
@@ -35,8 +35,6 @@
|
||||
"error.emailEmpty": "Adres e-mail jest wymagany",
|
||||
"error.emailInValid": "Proszę wpisać prawidłowy adres e-mail",
|
||||
"error.invalidEmailOrPassword": "Nieprawidłowy adres e-mail lub hasło.",
|
||||
"error.invalidRedirectUrlOrAppCode": "Nieprawidłowy adres URL przekierowania lub kod aplikacji",
|
||||
"error.invalidSSOProtocol": "Nieprawidłowy protokół SSO",
|
||||
"error.nameEmpty": "Nazwa jest wymagana",
|
||||
"error.passwordEmpty": "Hasło jest wymagane",
|
||||
"error.passwordInvalid": "Hasło musi zawierać litery i cyfry, a jego długość musi być większa niż 8",
|
||||
|
||||
@@ -35,8 +35,6 @@
|
||||
"error.emailEmpty": "O endereço de e-mail é obrigatório",
|
||||
"error.emailInValid": "Digite um endereço de e-mail válido",
|
||||
"error.invalidEmailOrPassword": "E-mail ou senha inválidos.",
|
||||
"error.invalidRedirectUrlOrAppCode": "URL de redirecionamento ou código de aplicativo inválido",
|
||||
"error.invalidSSOProtocol": "Protocolo SSO inválido",
|
||||
"error.nameEmpty": "O nome é obrigatório",
|
||||
"error.passwordEmpty": "A senha é obrigatória",
|
||||
"error.passwordInvalid": "A senha deve conter letras e números e ter um comprimento maior que 8",
|
||||
|
||||
@@ -35,8 +35,6 @@
|
||||
"error.emailEmpty": "Adresa de email este obligatorie",
|
||||
"error.emailInValid": "Te rugăm să introduci o adresă de email validă",
|
||||
"error.invalidEmailOrPassword": "Email sau parolă invalidă.",
|
||||
"error.invalidRedirectUrlOrAppCode": "URL de redirecționare sau cod de aplicație invalid",
|
||||
"error.invalidSSOProtocol": "Protocol SSO invalid",
|
||||
"error.nameEmpty": "Numele este obligatoriu",
|
||||
"error.passwordEmpty": "Parola este obligatorie",
|
||||
"error.passwordInvalid": "Parola trebuie să conțină litere și cifre, iar lungimea trebuie să fie mai mare de 8 caractere",
|
||||
|
||||
@@ -35,8 +35,6 @@
|
||||
"error.emailEmpty": "Адрес электронной почты обязателен",
|
||||
"error.emailInValid": "Пожалуйста, введите действительный адрес электронной почты",
|
||||
"error.invalidEmailOrPassword": "Неверный адрес электронной почты или пароль.",
|
||||
"error.invalidRedirectUrlOrAppCode": "Неверный URL перенаправления или код приложения",
|
||||
"error.invalidSSOProtocol": "Неверный протокол SSO",
|
||||
"error.nameEmpty": "Имя обязательно",
|
||||
"error.passwordEmpty": "Пароль обязателен",
|
||||
"error.passwordInvalid": "Пароль должен содержать буквы и цифры, а длина должна быть больше 8",
|
||||
|
||||
@@ -35,8 +35,6 @@
|
||||
"error.emailEmpty": "E-poštni naslov je obvezen",
|
||||
"error.emailInValid": "Prosimo, vnesite veljaven e-poštni naslov",
|
||||
"error.invalidEmailOrPassword": "Neveljaven e-poštni naslov ali geslo.",
|
||||
"error.invalidRedirectUrlOrAppCode": "Neveljaven URL preusmeritve ali koda aplikacije",
|
||||
"error.invalidSSOProtocol": "Neveljaven protokol SSO",
|
||||
"error.nameEmpty": "Ime je obvezno",
|
||||
"error.passwordEmpty": "Geslo je obvezno",
|
||||
"error.passwordInvalid": "Geslo mora vsebovati črke in številke, dolžina pa mora biti več kot 8 znakov",
|
||||
|
||||
@@ -35,8 +35,6 @@
|
||||
"error.emailEmpty": "ต้องระบุที่อยู่อีเมล",
|
||||
"error.emailInValid": "โปรดป้อนที่อยู่อีเมลที่ถูกต้อง",
|
||||
"error.invalidEmailOrPassword": "อีเมลหรือรหัสผ่านไม่ถูกต้อง.",
|
||||
"error.invalidRedirectUrlOrAppCode": "URL เปลี่ยนเส้นทางหรือรหัสแอปไม่ถูกต้อง",
|
||||
"error.invalidSSOProtocol": "โปรโตคอล SSO ไม่ถูกต้อง",
|
||||
"error.nameEmpty": "ต้องระบุชื่อ",
|
||||
"error.passwordEmpty": "ต้องใช้รหัสผ่าน",
|
||||
"error.passwordInvalid": "รหัสผ่านต้องมีตัวอักษรและตัวเลข และความยาวต้องมากกว่า 8",
|
||||
|
||||
@@ -35,8 +35,6 @@
|
||||
"error.emailEmpty": "E-posta adresi gereklidir",
|
||||
"error.emailInValid": "Geçerli bir e-posta adresi girin",
|
||||
"error.invalidEmailOrPassword": "Geçersiz e-posta veya şifre.",
|
||||
"error.invalidRedirectUrlOrAppCode": "Geçersiz yönlendirme URL'si veya uygulama kodu",
|
||||
"error.invalidSSOProtocol": "Geçersiz SSO protokolü",
|
||||
"error.nameEmpty": "İsim gereklidir",
|
||||
"error.passwordEmpty": "Şifre gereklidir",
|
||||
"error.passwordInvalid": "Şifre harf ve rakamlardan oluşmalı ve uzunluğu 8 karakterden fazla olmalıdır",
|
||||
|
||||
@@ -35,8 +35,6 @@
|
||||
"error.emailEmpty": "Адреса електронної пошти обов'язкова",
|
||||
"error.emailInValid": "Введіть дійсну адресу електронної пошти",
|
||||
"error.invalidEmailOrPassword": "Невірний електронний лист або пароль.",
|
||||
"error.invalidRedirectUrlOrAppCode": "Недійсний URL перенаправлення або код додатку",
|
||||
"error.invalidSSOProtocol": "Недійсний протокол SSO",
|
||||
"error.nameEmpty": "Ім'я обов'язкове",
|
||||
"error.passwordEmpty": "Пароль є обов’язковим",
|
||||
"error.passwordInvalid": "Пароль повинен містити літери та цифри, а довжина повинна бути більшою за 8",
|
||||
|
||||
@@ -35,8 +35,6 @@
|
||||
"error.emailEmpty": "Vui lòng nhập địa chỉ email",
|
||||
"error.emailInValid": "Vui lòng nhập một địa chỉ email hợp lệ",
|
||||
"error.invalidEmailOrPassword": "Email hoặc mật khẩu không hợp lệ.",
|
||||
"error.invalidRedirectUrlOrAppCode": "URL chuyển hướng hoặc mã ứng dụng không hợp lệ",
|
||||
"error.invalidSSOProtocol": "Giao thức SSO không hợp lệ",
|
||||
"error.nameEmpty": "Vui lòng nhập tên",
|
||||
"error.passwordEmpty": "Vui lòng nhập mật khẩu",
|
||||
"error.passwordInvalid": "Mật khẩu phải chứa cả chữ và số, và có độ dài ít nhất 8 ký tự",
|
||||
|
||||
@@ -35,8 +35,6 @@
|
||||
"error.emailEmpty": "邮箱不能为空",
|
||||
"error.emailInValid": "请输入有效的邮箱地址",
|
||||
"error.invalidEmailOrPassword": "邮箱或密码错误",
|
||||
"error.invalidRedirectUrlOrAppCode": "无效的重定向 URL 或应用代码",
|
||||
"error.invalidSSOProtocol": "无效的 SSO 协议",
|
||||
"error.nameEmpty": "用户名不能为空",
|
||||
"error.passwordEmpty": "密码不能为空",
|
||||
"error.passwordInvalid": "密码必须包含字母和数字,且长度不小于 8 位",
|
||||
|
||||
@@ -35,8 +35,6 @@
|
||||
"error.emailEmpty": "郵箱不能為空",
|
||||
"error.emailInValid": "請輸入有效的郵箱地址",
|
||||
"error.invalidEmailOrPassword": "無效的電子郵件或密碼。",
|
||||
"error.invalidRedirectUrlOrAppCode": "無效的重定向 URL 或應用程式代碼",
|
||||
"error.invalidSSOProtocol": "無效的 SSO 協定",
|
||||
"error.nameEmpty": "使用者名稱不能為空",
|
||||
"error.passwordEmpty": "密碼不能為空",
|
||||
"error.passwordInvalid": "密碼必須包含字母和數字,且長度不小於 8 位",
|
||||
|
||||
Reference in New Issue
Block a user