mirror of
https://github.com/langgenius/dify.git
synced 2026-07-13 00:17:56 +08:00
Compare commits
1 Commits
feat/llm-f
...
deploy/age
| Author | SHA1 | Date | |
|---|---|---|---|
| f3e3153323 |
@ -25,7 +25,6 @@ from core.plugin.impl.exc import (
|
||||
PluginPermissionDeniedError,
|
||||
PluginUniqueIdentifierError,
|
||||
)
|
||||
from core.plugin.impl.first_token_timeout import first_token_timeout_ctx
|
||||
from core.trigger.errors import (
|
||||
EventIgnoreError,
|
||||
TriggerInvokeError,
|
||||
@ -33,7 +32,6 @@ from core.trigger.errors import (
|
||||
TriggerProviderCredentialValidationError,
|
||||
)
|
||||
from graphon.model_runtime.errors.invoke import (
|
||||
FirstTokenTimeoutError,
|
||||
InvokeAuthorizationError,
|
||||
InvokeBadRequestError,
|
||||
InvokeConnectionError,
|
||||
@ -56,26 +54,6 @@ match _plugin_daemon_timeout_config:
|
||||
case _:
|
||||
plugin_daemon_request_timeout = httpx.Timeout(_plugin_daemon_timeout_config)
|
||||
|
||||
|
||||
def _read_timeout_for(first_token_timeout: float | None) -> httpx.Timeout | None:
|
||||
"""Narrow the daemon request timeout's ``read`` component to the first-token budget.
|
||||
|
||||
The daemon withholds the response headers until the model's first token and sends no
|
||||
heartbeat before it, so httpx's ``read`` timeout (which covers both the header wait and
|
||||
each body read) measures time-to-first-token. A non-positive budget disables the gate
|
||||
and keeps the default timeout. ``httpx.Timeout(base, read=x)`` is rejected when ``base``
|
||||
is a ``Timeout``, so the other components are copied explicitly.
|
||||
"""
|
||||
base = plugin_daemon_request_timeout
|
||||
if not first_token_timeout or first_token_timeout <= 0:
|
||||
return base
|
||||
if base is None:
|
||||
return httpx.Timeout(None, read=first_token_timeout)
|
||||
return httpx.Timeout(
|
||||
connect=base.connect, read=first_token_timeout, write=base.write, pool=base.pool
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PLUGIN_DAEMON_MAX_PATH_LENGTH = 4096
|
||||
@ -203,52 +181,30 @@ class BasePluginClient:
|
||||
"""
|
||||
url, headers, prepared_data, params, files = self._prepare_request(path, headers, data, params, files)
|
||||
|
||||
first_token_timeout = first_token_timeout_ctx.get()
|
||||
# Non-positive (None / 0 / negative) disables the gate; the read timeout stays default.
|
||||
first_token_gate = bool(first_token_timeout and first_token_timeout > 0)
|
||||
stream_kwargs: dict[str, Any] = {
|
||||
"method": method,
|
||||
"url": url,
|
||||
"headers": headers,
|
||||
"params": params,
|
||||
"files": files,
|
||||
# The daemon holds the response headers until the first token, so a per-request
|
||||
# read timeout equal to the first-token budget gates time-to-first-token.
|
||||
"timeout": _read_timeout_for(first_token_timeout),
|
||||
"timeout": plugin_daemon_request_timeout,
|
||||
}
|
||||
if isinstance(prepared_data, dict):
|
||||
stream_kwargs["data"] = prepared_data
|
||||
elif prepared_data is not None:
|
||||
stream_kwargs["content"] = prepared_data
|
||||
|
||||
first_token_seen = False
|
||||
|
||||
try:
|
||||
with _httpx_client.stream(**stream_kwargs) as response:
|
||||
for raw_line in response.iter_lines():
|
||||
# Blank keep-alive frames don't count as the first token, but each is still a
|
||||
# successful read that refreshes httpx's read window -- first-token gating relies
|
||||
# on the daemon sending no keep-alive before the first token.
|
||||
if not raw_line:
|
||||
continue
|
||||
line = raw_line.decode("utf-8") if isinstance(raw_line, bytes) else raw_line
|
||||
line = line.strip()
|
||||
if line.startswith("data:"):
|
||||
line = line[5:].strip()
|
||||
if not line:
|
||||
continue
|
||||
first_token_seen = True
|
||||
yield line
|
||||
except httpx.ReadTimeout as e:
|
||||
# Gate on: a read timeout before the first line means the first token was too slow.
|
||||
# After the first line (inter-token stall), or with the gate off, it's a plain
|
||||
# transport error.
|
||||
if first_token_gate and not first_token_seen:
|
||||
raise FirstTokenTimeoutError(
|
||||
f"The first token was not received within {first_token_timeout}s."
|
||||
) from e
|
||||
logger.exception("Stream request to Plugin Daemon Service failed")
|
||||
raise PluginDaemonInnerError(code=-500, message="Request to Plugin Daemon Service failed")
|
||||
if line:
|
||||
yield line
|
||||
except httpx.RequestError:
|
||||
logger.exception("Stream request to Plugin Daemon Service failed")
|
||||
raise PluginDaemonInnerError(code=-500, message="Request to Plugin Daemon Service failed")
|
||||
|
||||
@ -1,30 +0,0 @@
|
||||
"""First-token timeout plumbing for LLM streaming through the plugin daemon.
|
||||
|
||||
The per-node ``first_token_timeout`` is carried from the graphon LLM node down to the
|
||||
plugin-daemon transport through a :class:`~contextvars.ContextVar`, so the intermediate
|
||||
model-runtime layers (which don't take the value as a parameter) stay untouched.
|
||||
|
||||
Enforcement lives in the transport (``BasePluginClient._stream_request``): the value is
|
||||
applied as httpx's per-request ``read`` timeout. The daemon withholds the HTTP response
|
||||
headers until the model emits its first token and sends no heartbeat in the meantime, so
|
||||
the ``read`` timeout measures time-to-first-token directly; on expiry httpx raises
|
||||
``httpx.ReadTimeout`` and the transport surfaces it as ``FirstTokenTimeoutError``.
|
||||
|
||||
Units: this value is in **seconds** everywhere on the Python side (it feeds straight into
|
||||
``httpx.Timeout(read=...)``, which is seconds). The web UI exposes the field in milliseconds;
|
||||
the millisecond->second conversion is graphon's responsibility, done before it calls
|
||||
``DifyPreparedLLM.invoke_llm(first_token_timeout=...)``. Nothing in this repo divides by 1000,
|
||||
so a value arriving here is already seconds -- do not add a conversion, and do not treat the
|
||||
web-side millisecond bound as if it applied here.
|
||||
|
||||
Threading: the ContextVar only reaches the transport when ``_stream_request`` runs in the same
|
||||
thread/context that ``_guarded_stream`` set it in. A future model-runtime layer that prefetches
|
||||
the SSE stream on a background thread would read the default (``None``), so the gate silently
|
||||
disables -- fail-open: no false timeouts, just no enforcement.
|
||||
"""
|
||||
|
||||
from contextvars import ContextVar
|
||||
|
||||
# Seconds to wait for the first streamed token; ``None`` (or non-positive) disables the gate.
|
||||
# Set by the workflow LLM adapter around the invoke generator, read in ``_stream_request``.
|
||||
first_token_timeout_ctx: ContextVar[float | None] = ContextVar("first_token_timeout", default=None)
|
||||
@ -22,7 +22,6 @@ from core.llm_generator.output_parser.errors import OutputParserError
|
||||
from core.llm_generator.output_parser.structured_output import invoke_llm_with_structured_output
|
||||
from core.model_manager import ModelInstance
|
||||
from core.plugin.impl.exc import PluginDaemonClientSideError, PluginInvokeError
|
||||
from core.plugin.impl.first_token_timeout import first_token_timeout_ctx
|
||||
from core.plugin.impl.plugin import PluginInstaller
|
||||
from core.prompt.utils.prompt_message_util import PromptMessageUtil
|
||||
from core.repositories.human_input_repository import (
|
||||
@ -148,50 +147,6 @@ class DifyFileReferenceFactory(FileReferenceFactoryProtocol):
|
||||
)
|
||||
|
||||
|
||||
def _guarded_stream(
|
||||
seconds: float,
|
||||
inner: Generator[Any, None, None],
|
||||
) -> Generator[Any, None, None]:
|
||||
"""Iterate ``inner`` with the first-token-timeout ContextVar set.
|
||||
|
||||
Setting it inside the generator keeps it active for exactly the span of iteration,
|
||||
which is when the plugin-daemon transport (`_stream_request`) reads it to arm the
|
||||
first-token watchdog. Enforcement stays in the transport; this only carries the value.
|
||||
|
||||
This assumes the transport runs in the same thread as this iteration: a background-thread
|
||||
SSE prefetch would not see the ContextVar and the gate would fail open (no enforcement).
|
||||
"""
|
||||
token = first_token_timeout_ctx.set(seconds)
|
||||
try:
|
||||
yield from inner
|
||||
finally:
|
||||
first_token_timeout_ctx.reset(token)
|
||||
|
||||
|
||||
def _with_first_token_timeout[T](first_token_timeout: float | None, invoke: Callable[[], T]) -> T:
|
||||
"""Run ``invoke`` with the first-token-timeout ContextVar applied.
|
||||
|
||||
``first_token_timeout`` is in **seconds**; the web UI field is milliseconds and graphon
|
||||
performs the millisecond->second conversion before this layer (see
|
||||
``core.plugin.impl.first_token_timeout``). This is the single chokepoint both
|
||||
``invoke_llm`` and ``invoke_llm_with_structured_output`` funnel through.
|
||||
|
||||
A streaming result is lazy, so its generator is wrapped to keep the ContextVar set
|
||||
during iteration; a non-stream result is produced eagerly under the gate. A
|
||||
non-positive timeout (None / 0 / negative) is treated as disabled.
|
||||
"""
|
||||
if first_token_timeout is None or first_token_timeout <= 0:
|
||||
return invoke()
|
||||
token = first_token_timeout_ctx.set(first_token_timeout)
|
||||
try:
|
||||
result = invoke()
|
||||
finally:
|
||||
first_token_timeout_ctx.reset(token)
|
||||
if isinstance(result, Generator):
|
||||
return cast("T", _guarded_stream(first_token_timeout, result))
|
||||
return result
|
||||
|
||||
|
||||
class DifyPreparedLLM(LLMProtocol):
|
||||
"""Workflow-layer adapter that hides the full `ModelInstance` API from `graphon` nodes."""
|
||||
|
||||
@ -247,7 +202,6 @@ class DifyPreparedLLM(LLMProtocol):
|
||||
tools: Sequence[PromptMessageTool] | None,
|
||||
stop: Sequence[str] | None,
|
||||
stream: Literal[False],
|
||||
first_token_timeout: float | None = None,
|
||||
) -> LLMResult: ...
|
||||
|
||||
@overload
|
||||
@ -259,7 +213,6 @@ class DifyPreparedLLM(LLMProtocol):
|
||||
tools: Sequence[PromptMessageTool] | None,
|
||||
stop: Sequence[str] | None,
|
||||
stream: Literal[True],
|
||||
first_token_timeout: float | None = None,
|
||||
) -> Generator[LLMResultChunk, None, None]: ...
|
||||
|
||||
@override
|
||||
@ -271,18 +224,14 @@ class DifyPreparedLLM(LLMProtocol):
|
||||
tools: Sequence[PromptMessageTool] | None,
|
||||
stop: Sequence[str] | None,
|
||||
stream: bool,
|
||||
first_token_timeout: float | None = None,
|
||||
) -> LLMResult | Generator[LLMResultChunk, None, None]:
|
||||
return _with_first_token_timeout(
|
||||
first_token_timeout,
|
||||
lambda: self._model_instance.invoke_llm(
|
||||
prompt_messages=list(prompt_messages),
|
||||
model_parameters=dict(model_parameters),
|
||||
tools=list(tools or []),
|
||||
stop=list(stop or []),
|
||||
stream=stream,
|
||||
request_metadata=self._request_metadata,
|
||||
),
|
||||
return self._model_instance.invoke_llm(
|
||||
prompt_messages=list(prompt_messages),
|
||||
model_parameters=dict(model_parameters),
|
||||
tools=list(tools or []),
|
||||
stop=list(stop or []),
|
||||
stream=stream,
|
||||
request_metadata=self._request_metadata,
|
||||
)
|
||||
|
||||
@overload
|
||||
@ -294,7 +243,6 @@ class DifyPreparedLLM(LLMProtocol):
|
||||
model_parameters: Mapping[str, Any],
|
||||
stop: Sequence[str] | None,
|
||||
stream: Literal[False],
|
||||
first_token_timeout: float | None = None,
|
||||
) -> LLMResultWithStructuredOutput: ...
|
||||
|
||||
@overload
|
||||
@ -306,7 +254,6 @@ class DifyPreparedLLM(LLMProtocol):
|
||||
model_parameters: Mapping[str, Any],
|
||||
stop: Sequence[str] | None,
|
||||
stream: Literal[True],
|
||||
first_token_timeout: float | None = None,
|
||||
) -> Generator[LLMResultChunkWithStructuredOutput, None, None]: ...
|
||||
|
||||
@override
|
||||
@ -318,20 +265,16 @@ class DifyPreparedLLM(LLMProtocol):
|
||||
model_parameters: Mapping[str, Any],
|
||||
stop: Sequence[str] | None,
|
||||
stream: bool,
|
||||
first_token_timeout: float | None = None,
|
||||
) -> LLMResultWithStructuredOutput | Generator[LLMResultChunkWithStructuredOutput, None, None]:
|
||||
return _with_first_token_timeout(
|
||||
first_token_timeout,
|
||||
lambda: invoke_llm_with_structured_output(
|
||||
provider=self.provider,
|
||||
model_schema=self.get_model_schema(),
|
||||
model_instance=self._model_instance,
|
||||
prompt_messages=prompt_messages,
|
||||
json_schema=json_schema,
|
||||
model_parameters=model_parameters,
|
||||
stop=list(stop or []),
|
||||
stream=stream,
|
||||
),
|
||||
return invoke_llm_with_structured_output(
|
||||
provider=self.provider,
|
||||
model_schema=self.get_model_schema(),
|
||||
model_instance=self._model_instance,
|
||||
prompt_messages=prompt_messages,
|
||||
json_schema=json_schema,
|
||||
model_parameters=model_parameters,
|
||||
stop=list(stop or []),
|
||||
stream=stream,
|
||||
)
|
||||
|
||||
@override
|
||||
|
||||
@ -1,180 +0,0 @@
|
||||
import httpx
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from core.plugin.entities.plugin_daemon import PluginDaemonInnerError
|
||||
from core.plugin.impl import base as base_mod
|
||||
from core.plugin.impl.first_token_timeout import first_token_timeout_ctx
|
||||
from graphon.model_runtime.errors.invoke import FirstTokenTimeoutError
|
||||
|
||||
BasePluginClient = base_mod.BasePluginClient
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_ctx():
|
||||
"""Keep the first-token-timeout ContextVar from leaking between tests."""
|
||||
token = first_token_timeout_ctx.set(None)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
first_token_timeout_ctx.reset(token)
|
||||
|
||||
|
||||
class _PlainStream:
|
||||
"""Fully buffered fake httpx stream context."""
|
||||
|
||||
def __init__(self, lines: list[object]) -> None:
|
||||
self._lines = lines
|
||||
|
||||
def __enter__(self) -> "_PlainStream":
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc: object) -> bool:
|
||||
return False
|
||||
|
||||
def iter_lines(self):
|
||||
return iter(self._lines)
|
||||
|
||||
|
||||
class _RaiseOnEnterStream:
|
||||
"""Fake stream whose context entry raises — models a timeout while awaiting headers."""
|
||||
|
||||
def __init__(self, exc: BaseException) -> None:
|
||||
self._exc = exc
|
||||
|
||||
def __enter__(self) -> "_RaiseOnEnterStream":
|
||||
raise self._exc
|
||||
|
||||
def __exit__(self, *exc: object) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
class _LinesThenRaiseStream:
|
||||
"""Yields some lines, then raises — models a stall after the first token(s)."""
|
||||
|
||||
def __init__(self, lines: list[object], exc: BaseException) -> None:
|
||||
self._lines = lines
|
||||
self._exc = exc
|
||||
|
||||
def __enter__(self) -> "_LinesThenRaiseStream":
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc: object) -> bool:
|
||||
return False
|
||||
|
||||
def iter_lines(self):
|
||||
yield from self._lines
|
||||
raise self._exc
|
||||
|
||||
|
||||
# --- _read_timeout_for ------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_read_timeout_for_narrows_read_only() -> None:
|
||||
base = base_mod.plugin_daemon_request_timeout
|
||||
timeout = base_mod._read_timeout_for(5.0)
|
||||
|
||||
assert timeout is not base
|
||||
assert timeout.read == 5.0
|
||||
# The other components are preserved from the default timeout.
|
||||
assert timeout.connect == base.connect
|
||||
assert timeout.write == base.write
|
||||
assert timeout.pool == base.pool
|
||||
|
||||
|
||||
@pytest.mark.parametrize("disabled", [None, 0.0, -1.0])
|
||||
def test_read_timeout_for_disabled_returns_base_unchanged(disabled: float | None) -> None:
|
||||
assert base_mod._read_timeout_for(disabled) is base_mod.plugin_daemon_request_timeout
|
||||
|
||||
|
||||
def test_read_timeout_for_with_no_base_timeout(mocker: MockerFixture) -> None:
|
||||
mocker.patch("core.plugin.impl.base.plugin_daemon_request_timeout", None)
|
||||
|
||||
timeout = base_mod._read_timeout_for(5.0)
|
||||
|
||||
assert timeout.read == 5.0
|
||||
assert timeout.connect is None
|
||||
assert timeout.write is None
|
||||
assert timeout.pool is None
|
||||
|
||||
|
||||
# --- _stream_request timeout wiring ------------------------------------------------------
|
||||
|
||||
|
||||
def test_stream_request_narrows_read_when_gate_enabled(mocker: MockerFixture) -> None:
|
||||
client = BasePluginClient()
|
||||
stream = mocker.patch("httpx.Client.stream", return_value=_PlainStream([b"data: hi"]))
|
||||
first_token_timeout_ctx.set(1.5)
|
||||
|
||||
result = list(client._stream_request("POST", "plugin/tenant/stream", data={"k": "v"}))
|
||||
|
||||
assert result == ["hi"]
|
||||
assert stream.call_args.kwargs["timeout"].read == 1.5
|
||||
|
||||
|
||||
@pytest.mark.parametrize("disabled", [None, 0.0])
|
||||
def test_stream_request_keeps_default_timeout_when_gate_disabled(
|
||||
mocker: MockerFixture, disabled: float | None
|
||||
) -> None:
|
||||
client = BasePluginClient()
|
||||
stream = mocker.patch("httpx.Client.stream", return_value=_PlainStream([b"data: hi"]))
|
||||
first_token_timeout_ctx.set(disabled)
|
||||
|
||||
list(client._stream_request("POST", "plugin/tenant/stream", data={"k": "v"}))
|
||||
|
||||
assert stream.call_args.kwargs["timeout"] is base_mod.plugin_daemon_request_timeout
|
||||
|
||||
|
||||
def test_stream_request_forwards_all_lines(mocker: MockerFixture) -> None:
|
||||
client = BasePluginClient()
|
||||
mocker.patch("httpx.Client.stream", return_value=_PlainStream([b"", b"data: hello", "world"]))
|
||||
first_token_timeout_ctx.set(1.0)
|
||||
|
||||
result = list(client._stream_request("POST", "plugin/tenant/stream", data={"k": "v"}))
|
||||
|
||||
assert result == ["hello", "world"]
|
||||
|
||||
|
||||
# --- _stream_request timeout semantics ---------------------------------------------------
|
||||
|
||||
|
||||
def test_read_timeout_before_first_line_raises_first_token_timeout(mocker: MockerFixture) -> None:
|
||||
client = BasePluginClient()
|
||||
mocker.patch("httpx.Client.stream", return_value=_RaiseOnEnterStream(httpx.ReadTimeout("headers")))
|
||||
first_token_timeout_ctx.set(0.5)
|
||||
|
||||
with pytest.raises(FirstTokenTimeoutError):
|
||||
list(client._stream_request("POST", "plugin/tenant/stream", data={"k": "v"}))
|
||||
|
||||
|
||||
def test_read_timeout_with_gate_disabled_is_transport_error(mocker: MockerFixture) -> None:
|
||||
client = BasePluginClient()
|
||||
mocker.patch("httpx.Client.stream", return_value=_RaiseOnEnterStream(httpx.ReadTimeout("headers")))
|
||||
# ctx is None (autouse fixture) -> gate off -> a read timeout is just a transport error.
|
||||
|
||||
with pytest.raises(PluginDaemonInnerError):
|
||||
list(client._stream_request("POST", "plugin/tenant/stream", data={"k": "v"}))
|
||||
|
||||
|
||||
def test_read_timeout_after_first_line_is_transport_error(mocker: MockerFixture) -> None:
|
||||
client = BasePluginClient()
|
||||
mocker.patch(
|
||||
"httpx.Client.stream",
|
||||
return_value=_LinesThenRaiseStream([b"data: hello"], httpx.ReadTimeout("inter-token")),
|
||||
)
|
||||
first_token_timeout_ctx.set(0.5)
|
||||
|
||||
# First token already seen -> a later read timeout is an inter-token stall, not a
|
||||
# first-token timeout.
|
||||
with pytest.raises(PluginDaemonInnerError):
|
||||
list(client._stream_request("POST", "plugin/tenant/stream", data={"k": "v"}))
|
||||
|
||||
|
||||
def test_non_timeout_request_error_is_transport_error(mocker: MockerFixture) -> None:
|
||||
client = BasePluginClient()
|
||||
mocker.patch("httpx.Client.stream", return_value=_RaiseOnEnterStream(httpx.ConnectError("boom")))
|
||||
first_token_timeout_ctx.set(0.5)
|
||||
|
||||
# Only a ReadTimeout maps to FirstTokenTimeoutError; other transport errors do not.
|
||||
with pytest.raises(PluginDaemonInnerError):
|
||||
list(client._stream_request("POST", "plugin/tenant/stream", data={"k": "v"}))
|
||||
@ -8,7 +8,6 @@ from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunC
|
||||
from core.app.file_access import FileAccessScope, bind_file_access_scope, grant_retriever_segment_access
|
||||
from core.llm_generator.output_parser.errors import OutputParserError
|
||||
from core.plugin.impl.exc import PluginLLMPollingUnsupportedError
|
||||
from core.plugin.impl.first_token_timeout import first_token_timeout_ctx
|
||||
from core.plugin.impl.model import PluginModelClient
|
||||
from core.plugin.impl.model_runtime import PluginModelRuntime
|
||||
from core.plugin.plugin_service import PluginService
|
||||
@ -202,164 +201,6 @@ def test_dify_prepared_llm_wraps_model_instance_calls() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_dify_prepared_llm_sets_first_token_timeout_ctx_during_streaming() -> None:
|
||||
observed: list[float | None] = []
|
||||
|
||||
def _fake_invoke(**_kwargs: object):
|
||||
def _gen():
|
||||
observed.append(first_token_timeout_ctx.get())
|
||||
yield "chunk-1"
|
||||
observed.append(first_token_timeout_ctx.get())
|
||||
yield "chunk-2"
|
||||
|
||||
return _gen()
|
||||
|
||||
model_instance = _ModelInstanceStub(model_schema=_build_model_schema())
|
||||
model_instance.invoke_llm = _fake_invoke # type: ignore[assignment]
|
||||
prepared = DifyPreparedLLM(model_instance)
|
||||
|
||||
generator = prepared.invoke_llm(
|
||||
prompt_messages=[],
|
||||
model_parameters={},
|
||||
tools=None,
|
||||
stop=None,
|
||||
stream=True,
|
||||
first_token_timeout=1.5,
|
||||
)
|
||||
# The ContextVar is set inside the generator (during iteration), not on this frame.
|
||||
assert first_token_timeout_ctx.get() is None
|
||||
|
||||
result = list(generator)
|
||||
|
||||
assert result == ["chunk-1", "chunk-2"]
|
||||
assert observed == [1.5, 1.5]
|
||||
# Reset once the generator is exhausted.
|
||||
assert first_token_timeout_ctx.get() is None
|
||||
|
||||
|
||||
def test_dify_prepared_llm_leaves_ctx_unset_when_no_first_token_timeout() -> None:
|
||||
observed: list[float | None] = []
|
||||
|
||||
def _fake_invoke(**_kwargs: object):
|
||||
def _gen():
|
||||
observed.append(first_token_timeout_ctx.get())
|
||||
yield "chunk"
|
||||
|
||||
return _gen()
|
||||
|
||||
model_instance = _ModelInstanceStub(model_schema=_build_model_schema())
|
||||
model_instance.invoke_llm = _fake_invoke # type: ignore[assignment]
|
||||
prepared = DifyPreparedLLM(model_instance)
|
||||
|
||||
result = list(
|
||||
prepared.invoke_llm(
|
||||
prompt_messages=[],
|
||||
model_parameters={},
|
||||
tools=None,
|
||||
stop=None,
|
||||
stream=True,
|
||||
)
|
||||
)
|
||||
|
||||
assert result == ["chunk"]
|
||||
assert observed == [None]
|
||||
|
||||
|
||||
def test_dify_prepared_llm_sets_first_token_timeout_ctx_for_non_stream_eager_path() -> None:
|
||||
observed: list[float | None] = []
|
||||
|
||||
def _fake_invoke(**_kwargs: object):
|
||||
# A non-stream call resolves eagerly; the ContextVar must be set during this call.
|
||||
observed.append(first_token_timeout_ctx.get())
|
||||
return sentinel.result
|
||||
|
||||
model_instance = _ModelInstanceStub(model_schema=_build_model_schema())
|
||||
model_instance.invoke_llm = _fake_invoke # type: ignore[assignment]
|
||||
prepared = DifyPreparedLLM(model_instance)
|
||||
|
||||
result = prepared.invoke_llm(
|
||||
prompt_messages=[],
|
||||
model_parameters={},
|
||||
tools=None,
|
||||
stop=None,
|
||||
stream=False,
|
||||
first_token_timeout=1.5,
|
||||
)
|
||||
|
||||
# The non-generator result is returned unchanged, and the gate is reset once it returns.
|
||||
assert result is sentinel.result
|
||||
assert observed == [1.5]
|
||||
assert first_token_timeout_ctx.get() is None
|
||||
|
||||
|
||||
def test_dify_prepared_llm_propagates_first_token_timeout_ctx_through_structured_output(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
observed: list[float | None] = []
|
||||
|
||||
def _fake_structured(**_kwargs: object):
|
||||
def _gen():
|
||||
observed.append(first_token_timeout_ctx.get())
|
||||
yield "chunk-1"
|
||||
observed.append(first_token_timeout_ctx.get())
|
||||
yield "chunk-2"
|
||||
|
||||
return _gen()
|
||||
|
||||
monkeypatch.setattr(node_runtime, "invoke_llm_with_structured_output", _fake_structured)
|
||||
model_instance = _ModelInstanceStub(model_schema=_build_model_schema())
|
||||
prepared = DifyPreparedLLM(model_instance)
|
||||
|
||||
generator = prepared.invoke_llm_with_structured_output(
|
||||
prompt_messages=[],
|
||||
json_schema={"type": "object"},
|
||||
model_parameters={},
|
||||
stop=None,
|
||||
stream=True,
|
||||
first_token_timeout=1.5,
|
||||
)
|
||||
# Lazy: the ContextVar is set during iteration, not on this frame.
|
||||
assert first_token_timeout_ctx.get() is None
|
||||
|
||||
result = list(generator)
|
||||
|
||||
assert result == ["chunk-1", "chunk-2"]
|
||||
assert observed == [1.5, 1.5]
|
||||
assert first_token_timeout_ctx.get() is None
|
||||
|
||||
|
||||
def test_dify_prepared_llm_resets_first_token_timeout_ctx_when_stream_raises() -> None:
|
||||
observed: list[float | None] = []
|
||||
|
||||
def _fake_invoke(**_kwargs: object):
|
||||
def _gen():
|
||||
observed.append(first_token_timeout_ctx.get())
|
||||
yield "chunk-1"
|
||||
raise RuntimeError("boom")
|
||||
|
||||
return _gen()
|
||||
|
||||
model_instance = _ModelInstanceStub(model_schema=_build_model_schema())
|
||||
model_instance.invoke_llm = _fake_invoke # type: ignore[assignment]
|
||||
prepared = DifyPreparedLLM(model_instance)
|
||||
|
||||
generator = prepared.invoke_llm(
|
||||
prompt_messages=[],
|
||||
model_parameters={},
|
||||
tools=None,
|
||||
stop=None,
|
||||
stream=True,
|
||||
first_token_timeout=1.5,
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="boom"):
|
||||
list(generator)
|
||||
|
||||
# The try/finally in _guarded_stream resets the ContextVar even when iteration errors out.
|
||||
assert observed == [1.5]
|
||||
assert first_token_timeout_ctx.get() is None
|
||||
|
||||
|
||||
def test_dify_prepared_llm_requires_model_schema() -> None:
|
||||
model_instance = _ModelInstanceStub(model_schema=None)
|
||||
model_instance.credentials = {}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -149,8 +149,7 @@ export function WebAppSSONotEnabledTip() {
|
||||
return (
|
||||
<Infotip
|
||||
aria-label={tip}
|
||||
className="text-text-warning-secondary hover:text-text-warning-secondary"
|
||||
iconSize="large"
|
||||
iconClassName="h-4 w-4 shrink-0 text-text-warning-secondary hover:text-text-warning-secondary"
|
||||
>
|
||||
{tip}
|
||||
</Infotip>
|
||||
|
||||
@ -247,6 +247,7 @@ const ConfigContent: FC<Props> = ({
|
||||
<Infotip
|
||||
aria-label={option.tips}
|
||||
className="ml-0.5 size-3.5"
|
||||
iconClassName="h-3.5 w-3.5"
|
||||
popupClassName="w-[200px]"
|
||||
>
|
||||
{option.tips}
|
||||
|
||||
@ -756,17 +756,16 @@ describe('ChatInputArea', () => {
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
describe('Feature Bar', () => {
|
||||
it('should render footer notice with an accessible infotip', async () => {
|
||||
it('should render footer notice with tooltip when provided', async () => {
|
||||
const user = userEvent.setup({ delay: null })
|
||||
const footerNotice = 'Agent runs in a Linux sandbox.'
|
||||
const footerNoticeTooltip = 'For Dify Community Edition, each of your agents runs in a Linux 7.0.0-10060-aws sandbox environment within your docker. Your edits to the environment via Build Chats are persistent.'
|
||||
const accessibleName = `common.operation.learnMore: ${footerNotice}`
|
||||
render(<ChatInputArea visionConfig={mockVisionConfig} footerNotice={footerNotice} footerNoticeTooltip={footerNoticeTooltip} />)
|
||||
|
||||
expect(screen.getByText(footerNotice)).toBeInTheDocument()
|
||||
expect(screen.queryByText(footerNoticeTooltip)).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: accessibleName }))
|
||||
await user.hover(screen.getByRole('button', { name: footerNoticeTooltip }))
|
||||
|
||||
expect(await screen.findByText(footerNoticeTooltip)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@ -4,6 +4,7 @@ import type { EnableType, OnSend } from '../../types'
|
||||
import type { InputForm } from '../type'
|
||||
import type { FileUpload } from '@/app/components/base/features/types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { decode } from 'html-entities'
|
||||
@ -15,7 +16,6 @@ import FeatureBar from '@/app/components/base/features/new-feature-panel/feature
|
||||
import { FileListInChatInput } from '@/app/components/base/file-uploader'
|
||||
import { useFile } from '@/app/components/base/file-uploader/hooks'
|
||||
import { FileContextProvider, useFileStore } from '@/app/components/base/file-uploader/store'
|
||||
import { Infotip } from '@/app/components/base/infotip'
|
||||
import VoiceInput from '@/app/components/base/voice-input'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { useCheckInputsForms } from '../check-input-forms-hooks'
|
||||
@ -170,10 +170,6 @@ const ChatInputArea = ({ readonly, botName, customPlaceholder, showFeatureBar, s
|
||||
const operation = (<Operation ref={holdSpaceRef} readonly={readonly} fileConfig={visionConfig} speechToTextConfig={speechToTextConfig} onShowVoiceInput={handleShowVoiceInput} onSend={handleSend} sendButtonLabel={sendButtonLabel} sendButtonLoading={sendButtonLoading} disabled={!canSend} theme={theme} />)
|
||||
const shouldShowFooterNotice = footerNotice !== undefined && footerNotice !== null
|
||||
const shouldShowFooterNoticeTooltip = footerNoticeTooltip !== undefined && footerNoticeTooltip !== null
|
||||
const footerNoticeText = typeof footerNotice === 'string' ? footerNotice.trim() : ''
|
||||
const footerNoticeAriaLabel = footerNoticeText
|
||||
? `${t('operation.learnMore', { ns: 'common' })}: ${footerNoticeText}`
|
||||
: t('operation.learnMore', { ns: 'common' })
|
||||
return (
|
||||
<>
|
||||
<div className={cn('pointer-events-auto relative z-10 overflow-hidden rounded-xl border border-components-chat-input-border bg-components-panel-bg-blur pb-[9px] shadow-md', isDragActive && 'border border-dashed border-components-option-card-option-selected-border', disabled && 'pointer-events-none border-components-panel-border opacity-50 shadow-none')}>
|
||||
@ -216,16 +212,31 @@ const ChatInputArea = ({ readonly, botName, customPlaceholder, showFeatureBar, s
|
||||
{shouldShowFooterNotice && (
|
||||
<div className="m-1 mt-0 -translate-y-2 rounded-b-[10px] border-r border-b border-l border-components-panel-border-subtle bg-util-colors-indigo-indigo-50 px-2.5 py-2 pt-4">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="min-w-0 flex-1 body-xs-medium text-text-accent">{footerNotice}</div>
|
||||
<span aria-hidden className="i-ri-information-line size-3.5 shrink-0 text-text-accent" />
|
||||
<div className="body-xs-medium text-text-accent">{footerNotice}</div>
|
||||
{shouldShowFooterNoticeTooltip && (
|
||||
<Infotip
|
||||
aria-label={footerNoticeAriaLabel}
|
||||
className="ml-auto size-5 rounded-md text-text-accent hover:bg-state-base-hover hover:text-text-accent"
|
||||
iconVariant="information"
|
||||
popupClassName="max-w-80 border-0 text-start wrap-break-word"
|
||||
>
|
||||
{footerNoticeTooltip}
|
||||
</Infotip>
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
openOnHover
|
||||
delay={300}
|
||||
closeDelay={200}
|
||||
render={(
|
||||
<button
|
||||
type="button"
|
||||
className="ml-auto flex size-5 items-center justify-center rounded-md system-xs-medium text-text-accent hover:bg-state-base-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-components-button-primary-bg"
|
||||
aria-label={typeof footerNoticeTooltip === 'string' ? footerNoticeTooltip : undefined}
|
||||
>
|
||||
<span aria-hidden className="i-ri-question-line size-3.5 shrink-0" />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="top"
|
||||
popupClassName="max-w-80 rounded-md border-0 px-3 py-2 text-start system-xs-regular wrap-break-word text-text-tertiary"
|
||||
>
|
||||
{footerNoticeTooltip}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -373,7 +373,7 @@ describe('OpeningSettingModal', () => {
|
||||
expect(screen.queryByText(/openingStatement\.openingQuestionDescription/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show the opening questions description in an infotip', async () => {
|
||||
it('should show the opening questions description in a tooltip', async () => {
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={defaultData}
|
||||
@ -382,9 +382,11 @@ describe('OpeningSettingModal', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
await userEvent.hover(screen.getByRole('button', { name: /openingStatement\.openingQuestionDescription/ }))
|
||||
act(() => {
|
||||
fireEvent.mouseEnter(screen.getByRole('button', { name: /openingStatement\.openingQuestionDescription/ }))
|
||||
})
|
||||
|
||||
expect(await screen.findByText(/openingStatement\.openingQuestionDescription/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/openingStatement\.openingQuestionDescription/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onAutoAddPromptVariable when confirm add is clicked', async () => {
|
||||
|
||||
@ -121,6 +121,7 @@ const OpeningSettingModal = ({
|
||||
aria-label={t('openingStatement.openingQuestionDescription', { ns: 'appDebug' })}
|
||||
className="size-3.5"
|
||||
popupClassName="max-w-[220px] system-sm-regular text-text-secondary"
|
||||
delay={0}
|
||||
>
|
||||
{t('openingStatement.openingQuestionDescription', { ns: 'appDebug' })}
|
||||
</Infotip>
|
||||
|
||||
@ -1,53 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Infotip } from '../index'
|
||||
|
||||
function ClickBoundary({ children, onClick }: { children: ReactNode, onClick: () => void }) {
|
||||
return (
|
||||
<button type="button" aria-label="Parent action" onClick={onClick}>
|
||||
{createPortal(children, document.body)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
describe('Infotip', () => {
|
||||
it('should open with keyboard activation', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Infotip aria-label="Rate limits">Rate limit details</Infotip>)
|
||||
|
||||
screen.getByRole('button', { name: 'Rate limits' }).focus()
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
expect(await screen.findByRole('dialog')).toHaveTextContent('Rate limit details')
|
||||
})
|
||||
|
||||
it('should close the dialog with Escape', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Infotip aria-label="Rate limits">Rate limit details</Infotip>)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Rate limits' }))
|
||||
expect(await screen.findByRole('dialog')).toBeInTheDocument()
|
||||
|
||||
await user.keyboard('{Escape}')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not propagate trigger clicks', async () => {
|
||||
const user = userEvent.setup()
|
||||
const parentClick = vi.fn()
|
||||
render(
|
||||
<ClickBoundary onClick={parentClick}>
|
||||
<Infotip aria-label="Rate limits">Rate limit details</Infotip>
|
||||
</ClickBoundary>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Rate limits' }))
|
||||
|
||||
expect(parentClick).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@ -1,25 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import type { Placement } from '@langgenius/dify-ui/popover'
|
||||
import type { MouseEvent, ReactNode } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
|
||||
const iconClassNames = {
|
||||
question: 'i-ri-question-line',
|
||||
information: 'i-ri-information-line',
|
||||
} as const
|
||||
|
||||
const iconSizeClassNames = {
|
||||
small: 'size-3',
|
||||
medium: 'size-3.5',
|
||||
large: 'size-4',
|
||||
} as const
|
||||
|
||||
type InfotipIconVariant = keyof typeof iconClassNames
|
||||
type InfotipIconSize = keyof typeof iconSizeClassNames
|
||||
|
||||
/**
|
||||
* Infotip — an info glyph that reveals a long-form explanation on hover / keyboard activation / tap.
|
||||
* Infotip — a `?` icon that reveals a long-form explanation on hover / focus / tap.
|
||||
*
|
||||
* Implements the pattern Base UI calls an "infotip":
|
||||
* https://base-ui.com/react/components/tooltip#infotips
|
||||
@ -45,25 +32,34 @@ type InfotipIconSize = keyof typeof iconSizeClassNames
|
||||
type InfotipProps = {
|
||||
/** Popup content. Rich nodes are allowed. */
|
||||
'children': ReactNode
|
||||
/** Accessible name for the icon-only trigger. */
|
||||
/** Accessible name for the trigger. Required; should match the popup text. */
|
||||
'aria-label': string
|
||||
/** Extra classes on the trigger for contextual layout and color. */
|
||||
/** Placement of the popup relative to the trigger. Defaults to `top`. */
|
||||
'placement'?: Placement
|
||||
/** Distance between the trigger and popup. Defaults to the popover primitive spacing. */
|
||||
'sideOffset'?: number
|
||||
/** Extra classes on the outer trigger wrapper (layout / margin). */
|
||||
'className'?: string
|
||||
/** Icon glyph. Defaults to `question`. */
|
||||
'iconVariant'?: InfotipIconVariant
|
||||
/** Icon size. Defaults to `medium` (14px). */
|
||||
'iconSize'?: InfotipIconSize
|
||||
/** Extra classes on the `?` icon itself (size / color overrides). */
|
||||
'iconClassName'?: string
|
||||
/** Extra classes on the popup body (width / padding / whitespace overrides). */
|
||||
'popupClassName'?: string
|
||||
/** Hover open delay in ms. Defaults to 300 to match the app-wide Tooltip delay. */
|
||||
'delay'?: number
|
||||
/** Hover close delay in ms. Defaults to 200 to match the app-wide Tooltip delay. */
|
||||
'closeDelay'?: number
|
||||
}
|
||||
|
||||
export function Infotip({
|
||||
children,
|
||||
'aria-label': ariaLabel,
|
||||
placement = 'top',
|
||||
sideOffset,
|
||||
className,
|
||||
iconVariant = 'question',
|
||||
iconSize = 'medium',
|
||||
iconClassName,
|
||||
popupClassName,
|
||||
delay = 300,
|
||||
closeDelay = 200,
|
||||
}: InfotipProps) {
|
||||
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation()
|
||||
@ -73,19 +69,20 @@ export function Infotip({
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
openOnHover
|
||||
delay={300}
|
||||
closeDelay={200}
|
||||
delay={delay}
|
||||
closeDelay={closeDelay}
|
||||
aria-label={ariaLabel}
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
'inline-flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-sm border-0 bg-transparent p-0 text-text-quaternary outline-hidden hover:text-text-tertiary focus-visible:ring-2 focus-visible:ring-state-accent-solid',
|
||||
'inline-flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-sm border-0 bg-transparent p-0 outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span aria-hidden className={cn(iconClassNames[iconVariant], iconSizeClassNames[iconSize])} />
|
||||
<span aria-hidden className={cn('i-ri-question-line size-3.5 text-text-quaternary hover:text-text-tertiary', iconClassName)} />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
placement="top"
|
||||
placement={placement}
|
||||
sideOffset={sideOffset}
|
||||
popupClassName={cn('max-w-[300px] rounded-md px-3 py-2 system-xs-regular text-text-tertiary', popupClassName)}
|
||||
>
|
||||
{children}
|
||||
|
||||
@ -534,26 +534,6 @@ describe('MarkdownForm', () => {
|
||||
|
||||
// Unicode letters should be valid form field names.
|
||||
describe('Unicode name support', () => {
|
||||
it('should include fields whose names contain supported full-width and half-width punctuation', async () => {
|
||||
const user = userEvent.setup()
|
||||
const node = createRootNode(
|
||||
[
|
||||
createElementNode('input', { type: 'hidden', name: '字段()!*&-', value: 'full-width' }),
|
||||
createElementNode('input', { type: 'hidden', name: 'field()!*&-', value: 'half-width' }),
|
||||
createElementNode('button', {}, [createTextNode('Submit')]),
|
||||
],
|
||||
{ dataFormat: 'json' },
|
||||
)
|
||||
|
||||
render(<MarkdownForm node={node} />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Submit' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnSend).toHaveBeenCalledWith('{"字段()!*&-":"full-width","field()!*&-":"half-width"}')
|
||||
})
|
||||
})
|
||||
|
||||
it('should include Unicode-named fields from all supported controls in JSON output', async () => {
|
||||
const user = userEvent.setup()
|
||||
const node = createRootNode(
|
||||
|
||||
@ -43,7 +43,7 @@ const SUPPORTED_TYPES_SET = new Set<string>(Object.values(SUPPORTED_TYPES))
|
||||
|
||||
const SAFE_NAME_RE = (() => {
|
||||
try {
|
||||
return new RegExp('^\\p{L}[\\p{L}\\p{M}\\p{N}_()!*&()!*&--]*$', 'u')
|
||||
return new RegExp('^\\p{L}[\\p{L}\\p{M}\\p{N}_-]*$', 'u')
|
||||
}
|
||||
catch {
|
||||
// Fallback for browsers without Unicode property escape support.
|
||||
|
||||
@ -196,6 +196,7 @@ export const GeneralChunkingOptions: FC<GeneralChunkingOptionsProps> = ({
|
||||
<Infotip
|
||||
aria-label={t('stepTwo.QATip', { ns: 'datasetCreation' })}
|
||||
className="size-3.5"
|
||||
iconClassName="h-full w-full"
|
||||
>
|
||||
{t('stepTwo.QATip', { ns: 'datasetCreation' })}
|
||||
</Infotip>
|
||||
|
||||
@ -142,7 +142,8 @@ const EditMetadataBatchModal: FC<Props> = ({ datasetId, documentNum, list, onSav
|
||||
</label>
|
||||
<Infotip
|
||||
aria-label={t(`${i18nPrefix}.applyToAllSelectDocumentTip`, { ns: 'dataset' })}
|
||||
className="p-px text-text-tertiary"
|
||||
className="p-px"
|
||||
iconClassName="size-3.5 text-text-tertiary"
|
||||
popupClassName="max-w-[240px]"
|
||||
>
|
||||
{t(`${i18nPrefix}.applyToAllSelectDocumentTip`, { ns: 'dataset' })}
|
||||
|
||||
@ -156,7 +156,7 @@ const ModelLoadBalancingConfigs = ({
|
||||
<Infotip
|
||||
aria-label={t('modelProvider.loadBalancingInfo', { ns: 'common' })}
|
||||
className="size-3"
|
||||
iconSize="small"
|
||||
iconClassName="h-full w-full"
|
||||
popupClassName="max-w-[300px]"
|
||||
>
|
||||
{t('modelProvider.loadBalancingInfo', { ns: 'common' })}
|
||||
|
||||
@ -128,6 +128,7 @@ export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BU
|
||||
<Infotip
|
||||
aria-label={t('subscription.addType.options.manual.tip', { ns: 'pluginTrigger' })}
|
||||
className="size-3.5"
|
||||
iconClassName="h-full w-full"
|
||||
>
|
||||
{t('subscription.addType.options.manual.tip', { ns: 'pluginTrigger' })}
|
||||
</Infotip>
|
||||
|
||||
@ -34,6 +34,7 @@ export const SubscriptionListView: React.FC<SubscriptionListViewProps> = ({
|
||||
<Infotip
|
||||
aria-label={t('subscription.list.tip', { ns: 'pluginTrigger' })}
|
||||
className="size-3.5"
|
||||
iconClassName="h-full w-full"
|
||||
>
|
||||
{t('subscription.list.tip', { ns: 'pluginTrigger' })}
|
||||
</Infotip>
|
||||
|
||||
@ -37,6 +37,7 @@ export const SubscriptionSelectorView: React.FC<SubscriptionSelectorProps> = ({
|
||||
<Infotip
|
||||
aria-label={t('subscription.list.tip', { ns: 'pluginTrigger' })}
|
||||
className="size-3.5"
|
||||
iconClassName="h-full w-full"
|
||||
>
|
||||
{t('subscription.list.tip', { ns: 'pluginTrigger' })}
|
||||
</Infotip>
|
||||
|
||||
@ -75,6 +75,7 @@ const InfoTooltip = ({ children }: { children: string }) => {
|
||||
<Infotip
|
||||
aria-label={children}
|
||||
className="ml-1 size-3.5"
|
||||
iconClassName="h-3.5 w-3.5"
|
||||
popupClassName="w-[180px]"
|
||||
>
|
||||
{children}
|
||||
|
||||
@ -303,6 +303,7 @@ export function SearchMethodOption({
|
||||
<Infotip
|
||||
aria-label={rerankModelTip}
|
||||
className="ml-0.5 size-3.5 shrink-0"
|
||||
iconClassName="h-3.5 w-3.5"
|
||||
>
|
||||
{rerankModelTip}
|
||||
</Infotip>
|
||||
|
||||
@ -72,6 +72,7 @@ export function TopKAndScoreThreshold({
|
||||
<Infotip
|
||||
aria-label={topKTip}
|
||||
className="ml-0.5 size-3.5"
|
||||
iconClassName="h-3.5 w-3.5"
|
||||
>
|
||||
{topKTip}
|
||||
</Infotip>
|
||||
@ -114,6 +115,7 @@ export function TopKAndScoreThreshold({
|
||||
<Infotip
|
||||
aria-label={scoreThresholdTip}
|
||||
className="ml-0.5 size-3.5"
|
||||
iconClassName="h-3.5 w-3.5"
|
||||
>
|
||||
{scoreThresholdTip}
|
||||
</Infotip>
|
||||
|
||||
@ -2,7 +2,6 @@ import type { LLMNodeType } from '../types'
|
||||
import type { ModelProvider } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import type { PanelProps } from '@/types/workflow'
|
||||
import { screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
|
||||
import {
|
||||
ConfigurationMethodEnum,
|
||||
@ -117,7 +116,6 @@ const buildUseConfigResult = (overrides?: Partial<MockUseConfigReturn>) => ({
|
||||
handleModelChanged: vi.fn(),
|
||||
hasSetBlockStatus: false,
|
||||
handleCompletionParamsChange: vi.fn(),
|
||||
handleFirstTokenTimeoutChange: vi.fn(),
|
||||
handleContextVarChange: vi.fn(),
|
||||
filterInputVar: vi.fn(),
|
||||
filterVar: vi.fn(),
|
||||
@ -200,48 +198,4 @@ describe('LLM Panel', () => {
|
||||
expect(modelField?.querySelector('.bg-text-warning-secondary')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('First token timeout', () => {
|
||||
const timeoutLabel = 'workflow.nodes.llm.firstTokenTimeout'
|
||||
|
||||
const renderWithTimeout = (first_token_timeout?: number) => {
|
||||
const inputs = { ...baseNodeData, invocation: { first_token_timeout } }
|
||||
mockUseConfig.mockReturnValue(buildUseConfigResult({ inputs }))
|
||||
renderPanel(inputs)
|
||||
return screen.getByLabelText(timeoutLabel)
|
||||
}
|
||||
|
||||
it('renders an empty field (not a literal "0") when the timeout is off', () => {
|
||||
// Regression: a literal "0" could not be cleared without first typing another digit.
|
||||
const input = renderWithTimeout(0)
|
||||
expect(input).toHaveValue(null)
|
||||
expect((input as HTMLInputElement).value).toBe('')
|
||||
})
|
||||
|
||||
it('renders the configured timeout value', () => {
|
||||
expect(renderWithTimeout(1500)).toHaveValue(1500)
|
||||
})
|
||||
|
||||
it('reports 0 (off) when the field is cleared', async () => {
|
||||
const handleFirstTokenTimeoutChange = vi.fn()
|
||||
const inputs = { ...baseNodeData, invocation: { first_token_timeout: 1500 } }
|
||||
mockUseConfig.mockReturnValue(buildUseConfigResult({ inputs, handleFirstTokenTimeoutChange }))
|
||||
renderPanel(inputs)
|
||||
|
||||
await userEvent.clear(screen.getByLabelText(timeoutLabel))
|
||||
|
||||
expect(handleFirstTokenTimeoutChange).toHaveBeenCalledWith(0)
|
||||
})
|
||||
|
||||
it('reports the parsed number when a value is typed', async () => {
|
||||
const handleFirstTokenTimeoutChange = vi.fn()
|
||||
const inputs = { ...baseNodeData, invocation: { first_token_timeout: 0 } }
|
||||
mockUseConfig.mockReturnValue(buildUseConfigResult({ inputs, handleFirstTokenTimeoutChange }))
|
||||
renderPanel(inputs)
|
||||
|
||||
await userEvent.type(screen.getByLabelText(timeoutLabel), '5')
|
||||
|
||||
expect(handleFirstTokenTimeoutChange).toHaveBeenLastCalledWith(5)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -267,42 +267,6 @@ describe('llm/use-config', () => {
|
||||
expect(appendDefaultPromptConfig).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('writes first_token_timeout into invocation and clamps it to [0, 30000]', () => {
|
||||
const { result } = renderHook(() => useConfig('llm-node', inputRef.current))
|
||||
|
||||
const cases: [number, number][] = [
|
||||
[1500, 1500], // in range
|
||||
[99999, 30000], // above max
|
||||
[-5, 0], // below min
|
||||
[Number.NaN, 0], // non-numeric input (e.g. cleared field)
|
||||
]
|
||||
for (const [input, expected] of cases) {
|
||||
setInputs.mockClear()
|
||||
act(() => {
|
||||
result.current.handleFirstTokenTimeoutChange(input)
|
||||
})
|
||||
expect(setInputs).toHaveBeenCalledWith(expect.objectContaining({
|
||||
invocation: expect.objectContaining({ first_token_timeout: expected }),
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves other invocation fields when updating first_token_timeout', () => {
|
||||
inputRef.current = {
|
||||
...inputRef.current,
|
||||
invocation: { first_token_timeout: 5, keepMe: 'x' },
|
||||
} as unknown as LLMNodeType
|
||||
const { result } = renderHook(() => useConfig('llm-node', inputRef.current))
|
||||
|
||||
setInputs.mockClear()
|
||||
act(() => {
|
||||
result.current.handleFirstTokenTimeoutChange(2000)
|
||||
})
|
||||
expect(setInputs).toHaveBeenCalledWith(expect.objectContaining({
|
||||
invocation: { first_token_timeout: 2000, keepMe: 'x' },
|
||||
}))
|
||||
})
|
||||
|
||||
it('hydrates the model from the current provider, appends mode-specific defaults, and triggers the vision follow-up effect', async () => {
|
||||
inputRef.current = createPayload({
|
||||
model: {
|
||||
|
||||
@ -83,6 +83,7 @@ const PromptEditor: FC<PromptEditorProps> = ({
|
||||
<Infotip
|
||||
aria-label={t('nodes.llm.jsonSchema.promptTooltip', { ns: 'workflow' })}
|
||||
className="size-3.5"
|
||||
iconClassName="h-full w-full"
|
||||
>
|
||||
{t('nodes.llm.jsonSchema.promptTooltip', { ns: 'workflow' })}
|
||||
</Infotip>
|
||||
|
||||
@ -55,9 +55,6 @@ const nodeDefault: NodeDefault<LLMNodeType> = {
|
||||
vision: {
|
||||
enabled: false,
|
||||
},
|
||||
invocation: {
|
||||
first_token_timeout: 0,
|
||||
},
|
||||
},
|
||||
defaultRunInputData: {
|
||||
'#context#': [RETRIEVAL_OUTPUT_STRUCT],
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import type { FC } from 'react'
|
||||
import type { LLMNodeType } from './types'
|
||||
import type { NodePanelProps } from '@/app/components/workflow/types'
|
||||
import { Input } from '@langgenius/dify-ui/input'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import * as React from 'react'
|
||||
import { useCallback } from 'react'
|
||||
@ -42,7 +41,6 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({
|
||||
handleModelChanged,
|
||||
hasSetBlockStatus,
|
||||
handleCompletionParamsChange,
|
||||
handleFirstTokenTimeoutChange,
|
||||
handleContextVarChange,
|
||||
filterInputVar,
|
||||
filterVar,
|
||||
@ -239,29 +237,6 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({
|
||||
onChange={handleReasoningFormatChange}
|
||||
readonly={readOnly}
|
||||
/>
|
||||
|
||||
{/* First token timeout */}
|
||||
<Field
|
||||
title={t(`${i18nPrefix}.firstTokenTimeout`, { ns: 'workflow' })}
|
||||
tooltip={t(`${i18nPrefix}.firstTokenTimeoutTooltip`, { ns: 'workflow' })!}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
className="grow"
|
||||
value={inputs.invocation?.first_token_timeout || ''}
|
||||
onValueChange={value => handleFirstTokenTimeoutChange(value === '' ? 0 : Number.parseInt(value, 10))}
|
||||
min={0}
|
||||
max={30000}
|
||||
placeholder="0"
|
||||
disabled={readOnly}
|
||||
aria-label={t(`${i18nPrefix}.firstTokenTimeout`, { ns: 'workflow' })}
|
||||
/>
|
||||
<span className="shrink-0 system-xs-regular text-text-tertiary">
|
||||
{t('nodes.common.retry.ms', { ns: 'workflow' })}
|
||||
</span>
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
<PanelOutputSection
|
||||
readOnly={readOnly}
|
||||
|
||||
@ -18,10 +18,6 @@ export type LLMNodeType = CommonNodeType & {
|
||||
structured_output_enabled?: boolean
|
||||
structured_output?: StructuredOutput
|
||||
reasoning_format?: 'tagged' | 'separated'
|
||||
invocation?: {
|
||||
// First-token timeout in milliseconds; 0 disables it. Enforced in the backend transport.
|
||||
first_token_timeout?: number
|
||||
}
|
||||
}
|
||||
|
||||
export enum Type {
|
||||
|
||||
@ -99,16 +99,6 @@ const useConfig = (id: string, payload: LLMNodeType) => {
|
||||
setInputs(newInputs)
|
||||
}, [setInputs])
|
||||
|
||||
const handleFirstTokenTimeoutChange = useCallback((value: number) => {
|
||||
const clamped = Number.isNaN(value) ? 0 : Math.min(Math.max(value, 0), 30000)
|
||||
const newInputs = produce(inputRef.current, (draft) => {
|
||||
if (!draft.invocation)
|
||||
draft.invocation = {}
|
||||
draft.invocation.first_token_timeout = clamped
|
||||
})
|
||||
setInputs(newInputs)
|
||||
}, [inputRef, setInputs])
|
||||
|
||||
// change to vision model to set vision enabled, else disabled
|
||||
useEffect(() => {
|
||||
if (!modelChanged)
|
||||
@ -151,7 +141,6 @@ const useConfig = (id: string, payload: LLMNodeType) => {
|
||||
isVisionModel,
|
||||
handleModelChanged,
|
||||
handleCompletionParamsChange,
|
||||
handleFirstTokenTimeoutChange,
|
||||
isShowVars: promptConfig.isShowVars,
|
||||
handleVarListChange: promptConfig.handleVarListChange,
|
||||
handleVarNameChange: promptConfig.handleVarNameChange,
|
||||
|
||||
@ -134,6 +134,7 @@ const Panel: FC<NodePanelProps<ParameterExtractorNodeType>> = ({
|
||||
<Infotip
|
||||
aria-label={t(`${i18nPrefix}.instructionTip`, { ns: 'workflow' })}
|
||||
className="ml-0.5 size-3.5"
|
||||
iconClassName="h-3.5 w-3.5"
|
||||
popupClassName="w-[120px]"
|
||||
>
|
||||
{t(`${i18nPrefix}.instructionTip`, { ns: 'workflow' })}
|
||||
|
||||
@ -51,6 +51,7 @@ const AdvancedSetting: FC<Props> = ({
|
||||
<Infotip
|
||||
aria-label={t(`${i18nPrefix}.instructionTip`, { ns: 'workflow' })}
|
||||
className="ml-0.5 size-3.5"
|
||||
iconClassName="h-3.5 w-3.5"
|
||||
popupClassName="w-[120px]"
|
||||
>
|
||||
{t(`${i18nPrefix}.instructionTip`, { ns: 'workflow' })}
|
||||
|
||||
@ -60,7 +60,7 @@ const MonthlyDaysSelector = ({ selectedDays, onChange }: MonthlyDaysSelectorProp
|
||||
<Infotip
|
||||
aria-label={t('nodes.triggerSchedule.lastDayTooltip', { ns: 'workflow' })}
|
||||
className="mr-1 size-3"
|
||||
iconSize="small"
|
||||
iconClassName="h-3 w-3"
|
||||
>
|
||||
{t('nodes.triggerSchedule.lastDayTooltip', { ns: 'workflow' })}
|
||||
</Infotip>
|
||||
|
||||
@ -132,6 +132,7 @@ const VariableModal = ({
|
||||
<Infotip
|
||||
aria-label={t('env.modal.secretTip', { ns: 'workflow' })}
|
||||
className="ml-0.5 size-3.5"
|
||||
iconClassName="h-3.5 w-3.5"
|
||||
popupClassName="w-[240px]"
|
||||
>
|
||||
{t('env.modal.secretTip', { ns: 'workflow' })}
|
||||
|
||||
@ -321,8 +321,8 @@ function AgentBuildNoteInfotip() {
|
||||
return (
|
||||
<Infotip
|
||||
aria-label={t('agentDetail.configure.files.buildNote.tooltip')}
|
||||
className="size-5 text-text-quaternary hover:text-text-quaternary"
|
||||
iconSize="large"
|
||||
className="size-5"
|
||||
iconClassName="size-4 text-text-quaternary hover:text-text-quaternary"
|
||||
popupClassName="w-[230px] rounded-xl bg-components-tooltip-bg px-4 py-3.5 text-text-secondary shadow-lg backdrop-blur-[5px]"
|
||||
>
|
||||
<p className="body-xs-regular text-text-secondary">
|
||||
|
||||
@ -843,8 +843,6 @@
|
||||
"nodes.llm.context": "السياق",
|
||||
"nodes.llm.contextTooltip": "يمكنك استيراد المعرفة كسياق",
|
||||
"nodes.llm.files": "الملفات",
|
||||
"nodes.llm.firstTokenTimeout": "مهلة الرمز الأول",
|
||||
"nodes.llm.firstTokenTimeoutTooltip": "الحد الأقصى لوقت انتظار الرمز الأول بالميلي ثانية. 0 يعطّله؛ عند انتهاء المهلة يفشل العنصر ويتبع استراتيجية معالجة الأخطاء الخاصة به. كما يحدّ أيضًا من أقصى فاصل زمني بين الرموز المتدفقة.",
|
||||
"nodes.llm.jsonSchema.addChildField": "إضافة حقل فرعي",
|
||||
"nodes.llm.jsonSchema.addField": "إضافة حقل",
|
||||
"nodes.llm.jsonSchema.apply": "تطبيق",
|
||||
|
||||
@ -541,7 +541,7 @@
|
||||
"nodes.common.pluginNotInstalled": "Integration ist nicht installiert",
|
||||
"nodes.common.pluginsNotInstalled": "{{count}} Integrationen nicht installiert",
|
||||
"nodes.common.retry.maxRetries": "Max. Wiederholungen",
|
||||
"nodes.common.retry.ms": "ms",
|
||||
"nodes.common.retry.ms": "Frau",
|
||||
"nodes.common.retry.retries": "{{num}} Wiederholungen",
|
||||
"nodes.common.retry.retry": "Wiederholen",
|
||||
"nodes.common.retry.retryFailed": "Wiederholung fehlgeschlagen",
|
||||
@ -843,8 +843,6 @@
|
||||
"nodes.llm.context": "Kontext",
|
||||
"nodes.llm.contextTooltip": "Sie können Wissen als Kontext importieren",
|
||||
"nodes.llm.files": "Dateien",
|
||||
"nodes.llm.firstTokenTimeout": "Timeout für erstes Token",
|
||||
"nodes.llm.firstTokenTimeoutTooltip": "Maximale Wartezeit auf das erste Token in Millisekunden. 0 deaktiviert es; bei Zeitüberschreitung schlägt der Knoten fehl und folgt seiner Fehlerbehandlungsstrategie. Sie begrenzt außerdem den maximalen Abstand zwischen gestreamten Token.",
|
||||
"nodes.llm.jsonSchema.addChildField": "Kindfeld hinzufügen",
|
||||
"nodes.llm.jsonSchema.addField": "Feld hinzufügen",
|
||||
"nodes.llm.jsonSchema.apply": "Bewerben",
|
||||
|
||||
@ -843,8 +843,6 @@
|
||||
"nodes.llm.context": "context",
|
||||
"nodes.llm.contextTooltip": "You can import Knowledge as context",
|
||||
"nodes.llm.files": "Files",
|
||||
"nodes.llm.firstTokenTimeout": "first token timeout",
|
||||
"nodes.llm.firstTokenTimeoutTooltip": "Max time to wait for the first token, in milliseconds. 0 disables it; on timeout the node fails and follows its error-handling strategy. It also limits the maximum interval between streamed tokens.",
|
||||
"nodes.llm.jsonSchema.addChildField": "Add Child Field",
|
||||
"nodes.llm.jsonSchema.addField": "Add Field",
|
||||
"nodes.llm.jsonSchema.apply": "Apply",
|
||||
|
||||
@ -541,7 +541,7 @@
|
||||
"nodes.common.pluginNotInstalled": "El complemento no está instalado",
|
||||
"nodes.common.pluginsNotInstalled": "{{count}} integraciones no instalados",
|
||||
"nodes.common.retry.maxRetries": "Número máximo de reintentos",
|
||||
"nodes.common.retry.ms": "ms",
|
||||
"nodes.common.retry.ms": "Sra.",
|
||||
"nodes.common.retry.retries": "{{num}} Reintentos",
|
||||
"nodes.common.retry.retry": "Reintentar",
|
||||
"nodes.common.retry.retryFailed": "Error en el reintento",
|
||||
@ -843,8 +843,6 @@
|
||||
"nodes.llm.context": "contexto",
|
||||
"nodes.llm.contextTooltip": "Puedes importar el conocimiento como contexto",
|
||||
"nodes.llm.files": "Archivos",
|
||||
"nodes.llm.firstTokenTimeout": "tiempo de espera del primer token",
|
||||
"nodes.llm.firstTokenTimeoutTooltip": "Tiempo máximo de espera del primer token, en milisegundos. 0 lo desactiva; al agotarse, el nodo falla y sigue su estrategia de manejo de errores. También limita el intervalo máximo entre tokens transmitidos.",
|
||||
"nodes.llm.jsonSchema.addChildField": "Agregar campo de niño",
|
||||
"nodes.llm.jsonSchema.addField": "Agregar campo",
|
||||
"nodes.llm.jsonSchema.apply": "Aplicar",
|
||||
|
||||
@ -843,8 +843,6 @@
|
||||
"nodes.llm.context": "زمینه (Context)",
|
||||
"nodes.llm.contextTooltip": "میتوانید دانش را به عنوان زمینه وارد کنید",
|
||||
"nodes.llm.files": "فایلها",
|
||||
"nodes.llm.firstTokenTimeout": "مهلت اولین توکن",
|
||||
"nodes.llm.firstTokenTimeoutTooltip": "حداکثر زمان انتظار برای اولین توکن (میلیثانیه). 0 آن را غیرفعال میکند؛ در صورت اتمام مهلت، گره ناموفق میشود و از استراتژی مدیریت خطای خود پیروی میکند. همچنین حداکثر فاصله بین توکنهای جریانی را محدود میکند.",
|
||||
"nodes.llm.jsonSchema.addChildField": "افزودن فیلد فرزند",
|
||||
"nodes.llm.jsonSchema.addField": "افزودن فیلد",
|
||||
"nodes.llm.jsonSchema.apply": "اعمال",
|
||||
|
||||
@ -843,8 +843,6 @@
|
||||
"nodes.llm.context": "contexte",
|
||||
"nodes.llm.contextTooltip": "Vous pouvez importer des connaissances en tant que contexte",
|
||||
"nodes.llm.files": "Fichiers",
|
||||
"nodes.llm.firstTokenTimeout": "délai du premier token",
|
||||
"nodes.llm.firstTokenTimeoutTooltip": "Temps d'attente maximal du premier token, en millisecondes. 0 le désactive ; en cas de délai dépassé, le nœud échoue et suit sa stratégie de gestion des erreurs. Il limite également l'intervalle maximal entre les tokens diffusés.",
|
||||
"nodes.llm.jsonSchema.addChildField": "Ajouter un champ enfant",
|
||||
"nodes.llm.jsonSchema.addField": "Ajouter un champ",
|
||||
"nodes.llm.jsonSchema.apply": "Appliquer",
|
||||
|
||||
@ -541,7 +541,7 @@
|
||||
"nodes.common.pluginNotInstalled": "एकीकरण इंस्टॉल नहीं है",
|
||||
"nodes.common.pluginsNotInstalled": "{{count}} एकीकरण इंस्टॉल नहीं हैं",
|
||||
"nodes.common.retry.maxRetries": "अधिकतम पुनः प्रयास करता है",
|
||||
"nodes.common.retry.ms": "ms",
|
||||
"nodes.common.retry.ms": "सुश्री",
|
||||
"nodes.common.retry.retries": "{{num}} पुनर्प्रयास",
|
||||
"nodes.common.retry.retry": "पुनर्प्रयास",
|
||||
"nodes.common.retry.retryFailed": "पुनः प्रयास विफल रहा",
|
||||
@ -843,8 +843,6 @@
|
||||
"nodes.llm.context": "संदर्भ",
|
||||
"nodes.llm.contextTooltip": "संदर्भ के रूप में ज्ञान आयात कर सकते हैं",
|
||||
"nodes.llm.files": "फाइलें",
|
||||
"nodes.llm.firstTokenTimeout": "पहला टोकन टाइमआउट",
|
||||
"nodes.llm.firstTokenTimeoutTooltip": "पहले टोकन की प्रतीक्षा का अधिकतम समय (मिलीसेकंड)। 0 इसे अक्षम करता है; टाइमआउट पर नोड विफल होता है और अपनी त्रुटि-प्रबंधन रणनीति का पालन करता है। यह स्ट्रीम किए गए टोकन के बीच अधिकतम अंतराल को भी सीमित करता है।",
|
||||
"nodes.llm.jsonSchema.addChildField": "बच्चे का क्षेत्र जोड़ें",
|
||||
"nodes.llm.jsonSchema.addField": "क्षेत्र जोड़ें",
|
||||
"nodes.llm.jsonSchema.apply": "लागू करें",
|
||||
|
||||
@ -541,7 +541,7 @@
|
||||
"nodes.common.pluginNotInstalled": "Integrasi tidak terpasang",
|
||||
"nodes.common.pluginsNotInstalled": "{{count}} integrasi tidak terpasang",
|
||||
"nodes.common.retry.maxRetries": "percobaan ulang maks",
|
||||
"nodes.common.retry.ms": "ms",
|
||||
"nodes.common.retry.ms": "Ms",
|
||||
"nodes.common.retry.retries": "{{num}} Percobaan Ulang",
|
||||
"nodes.common.retry.retry": "Pengulangan",
|
||||
"nodes.common.retry.retryFailed": "Coba lagi gagal",
|
||||
@ -843,8 +843,6 @@
|
||||
"nodes.llm.context": "konteks",
|
||||
"nodes.llm.contextTooltip": "Anda dapat mengimpor Pengetahuan sebagai konteks",
|
||||
"nodes.llm.files": "File",
|
||||
"nodes.llm.firstTokenTimeout": "batas waktu token pertama",
|
||||
"nodes.llm.firstTokenTimeoutTooltip": "Waktu tunggu maksimum untuk token pertama, dalam milidetik. 0 menonaktifkannya; saat waktu habis, node gagal dan mengikuti strategi penanganan kesalahannya. Ini juga membatasi interval maksimum antar token yang di-streaming.",
|
||||
"nodes.llm.jsonSchema.addChildField": "Tambahkan Bidang Anak",
|
||||
"nodes.llm.jsonSchema.addField": "Tambahkan Bidang",
|
||||
"nodes.llm.jsonSchema.apply": "Berlaku",
|
||||
|
||||
@ -843,8 +843,6 @@
|
||||
"nodes.llm.context": "contesto",
|
||||
"nodes.llm.contextTooltip": "Puoi importare Conoscenza come contesto",
|
||||
"nodes.llm.files": "File",
|
||||
"nodes.llm.firstTokenTimeout": "timeout primo token",
|
||||
"nodes.llm.firstTokenTimeoutTooltip": "Tempo massimo di attesa del primo token, in millisecondi. 0 lo disabilita; allo scadere, il nodo fallisce e segue la sua strategia di gestione degli errori. Limita inoltre l'intervallo massimo tra i token trasmessi in streaming.",
|
||||
"nodes.llm.jsonSchema.addChildField": "Aggiungi campo bambino",
|
||||
"nodes.llm.jsonSchema.addField": "Aggiungi campo",
|
||||
"nodes.llm.jsonSchema.apply": "Applica",
|
||||
|
||||
@ -843,8 +843,6 @@
|
||||
"nodes.llm.context": "コンテキスト",
|
||||
"nodes.llm.contextTooltip": "ナレッジベースをコンテキストとして利用",
|
||||
"nodes.llm.files": "ファイル",
|
||||
"nodes.llm.firstTokenTimeout": "最初のトークンのタイムアウト",
|
||||
"nodes.llm.firstTokenTimeoutTooltip": "最初のトークンを待つ最大時間(ミリ秒)。0 で無効。タイムアウト時はノードが失敗し、エラー処理戦略に従います。ストリーミング中のトークン間の最大間隔も制限します。",
|
||||
"nodes.llm.jsonSchema.addChildField": "サブフィールドを追加",
|
||||
"nodes.llm.jsonSchema.addField": "フィールドを追加",
|
||||
"nodes.llm.jsonSchema.apply": "適用",
|
||||
|
||||
@ -843,8 +843,6 @@
|
||||
"nodes.llm.context": "컨텍스트",
|
||||
"nodes.llm.contextTooltip": "컨텍스트로 지식을 가져올 수 있습니다",
|
||||
"nodes.llm.files": "파일",
|
||||
"nodes.llm.firstTokenTimeout": "첫 토큰 타임아웃",
|
||||
"nodes.llm.firstTokenTimeoutTooltip": "첫 토큰을 기다리는 최대 시간(밀리초). 0이면 비활성화되며, 시간 초과 시 노드가 실패하고 오류 처리 전략을 따릅니다. 스트리밍되는 토큰 간의 최대 간격도 제한합니다.",
|
||||
"nodes.llm.jsonSchema.addChildField": "자녀 필드 추가",
|
||||
"nodes.llm.jsonSchema.addField": "필드 추가",
|
||||
"nodes.llm.jsonSchema.apply": "적용",
|
||||
|
||||
@ -843,8 +843,6 @@
|
||||
"nodes.llm.context": "context",
|
||||
"nodes.llm.contextTooltip": "You can import Knowledge as context",
|
||||
"nodes.llm.files": "Files",
|
||||
"nodes.llm.firstTokenTimeout": "time-out voor eerste token",
|
||||
"nodes.llm.firstTokenTimeoutTooltip": "Maximale wachttijd op het eerste token, in milliseconden. 0 schakelt het uit; bij time-out mislukt de node en volgt zijn foutafhandelingsstrategie. Het beperkt ook het maximale interval tussen gestreamde tokens.",
|
||||
"nodes.llm.jsonSchema.addChildField": "Add Child Field",
|
||||
"nodes.llm.jsonSchema.addField": "Add Field",
|
||||
"nodes.llm.jsonSchema.apply": "Apply",
|
||||
|
||||
@ -541,7 +541,7 @@
|
||||
"nodes.common.pluginNotInstalled": "Integracja nie jest zainstalowana",
|
||||
"nodes.common.pluginsNotInstalled": "{{count}} wtyczek niezainstalowanych",
|
||||
"nodes.common.retry.maxRetries": "Maksymalna liczba ponownych prób",
|
||||
"nodes.common.retry.ms": "ms",
|
||||
"nodes.common.retry.ms": "Ms",
|
||||
"nodes.common.retry.retries": "{{num}} Ponownych prób",
|
||||
"nodes.common.retry.retry": "Ponów próbę",
|
||||
"nodes.common.retry.retryFailed": "Ponawianie próby nie powiodło się",
|
||||
@ -843,8 +843,6 @@
|
||||
"nodes.llm.context": "kontekst",
|
||||
"nodes.llm.contextTooltip": "Możesz zaimportować wiedzę jako kontekst",
|
||||
"nodes.llm.files": "Pliki",
|
||||
"nodes.llm.firstTokenTimeout": "limit czasu pierwszego tokena",
|
||||
"nodes.llm.firstTokenTimeoutTooltip": "Maksymalny czas oczekiwania na pierwszy token w milisekundach. 0 wyłącza; po przekroczeniu limitu węzeł kończy się błędem i stosuje swoją strategię obsługi błędów. Ogranicza również maksymalny odstęp między strumieniowanymi tokenami.",
|
||||
"nodes.llm.jsonSchema.addChildField": "Dodaj pole dziecka",
|
||||
"nodes.llm.jsonSchema.addField": "Dodaj pole",
|
||||
"nodes.llm.jsonSchema.apply": "Zastosować",
|
||||
|
||||
@ -843,8 +843,6 @@
|
||||
"nodes.llm.context": "contexto",
|
||||
"nodes.llm.contextTooltip": "Você pode importar Conhecimento como contexto",
|
||||
"nodes.llm.files": "Arquivos",
|
||||
"nodes.llm.firstTokenTimeout": "tempo limite do primeiro token",
|
||||
"nodes.llm.firstTokenTimeoutTooltip": "Tempo máximo de espera pelo primeiro token, em milissegundos. 0 desativa; ao expirar, o nó falha e segue sua estratégia de tratamento de erros. Também limita o intervalo máximo entre tokens transmitidos.",
|
||||
"nodes.llm.jsonSchema.addChildField": "Adicionar Campo de Criança",
|
||||
"nodes.llm.jsonSchema.addField": "Adicionar Campo",
|
||||
"nodes.llm.jsonSchema.apply": "Aplicar",
|
||||
|
||||
@ -541,7 +541,7 @@
|
||||
"nodes.common.pluginNotInstalled": "Integrareul nu este instalat",
|
||||
"nodes.common.pluginsNotInstalled": "{{count}} integrări neinstalate",
|
||||
"nodes.common.retry.maxRetries": "numărul maxim de încercări",
|
||||
"nodes.common.retry.ms": "ms",
|
||||
"nodes.common.retry.ms": "Ms",
|
||||
"nodes.common.retry.retries": "{{num}} Încercări",
|
||||
"nodes.common.retry.retry": "Reîncercare",
|
||||
"nodes.common.retry.retryFailed": "Reîncercarea a eșuat",
|
||||
@ -843,8 +843,6 @@
|
||||
"nodes.llm.context": "context",
|
||||
"nodes.llm.contextTooltip": "Puteți importa cunoștințe ca și context",
|
||||
"nodes.llm.files": "Fișiere",
|
||||
"nodes.llm.firstTokenTimeout": "timeout pentru primul token",
|
||||
"nodes.llm.firstTokenTimeoutTooltip": "Timpul maxim de așteptare pentru primul token, în milisecunde. 0 îl dezactivează; la expirare, nodul eșuează și urmează strategia sa de gestionare a erorilor. De asemenea, limitează intervalul maxim dintre tokenurile transmise în flux.",
|
||||
"nodes.llm.jsonSchema.addChildField": "Adăugați câmpul copil",
|
||||
"nodes.llm.jsonSchema.addField": "Adaugă câmp",
|
||||
"nodes.llm.jsonSchema.apply": "Aplică",
|
||||
|
||||
@ -843,8 +843,6 @@
|
||||
"nodes.llm.context": "контекст",
|
||||
"nodes.llm.contextTooltip": "Вы можете импортировать знания как контекст",
|
||||
"nodes.llm.files": "Файлы",
|
||||
"nodes.llm.firstTokenTimeout": "тайм-аут первого токена",
|
||||
"nodes.llm.firstTokenTimeoutTooltip": "Максимальное время ожидания первого токена в миллисекундах. 0 отключает; при таймауте узел завершается с ошибкой и следует своей стратегии обработки ошибок. Также ограничивает максимальный интервал между передаваемыми токенами.",
|
||||
"nodes.llm.jsonSchema.addChildField": "Добавить поле ребенка",
|
||||
"nodes.llm.jsonSchema.addField": "Добавить поле",
|
||||
"nodes.llm.jsonSchema.apply": "Подать заявку",
|
||||
|
||||
@ -843,8 +843,6 @@
|
||||
"nodes.llm.context": "kontekst",
|
||||
"nodes.llm.contextTooltip": "Lahko uvažate znanje kot kontekst",
|
||||
"nodes.llm.files": "Datoteke",
|
||||
"nodes.llm.firstTokenTimeout": "časovna omejitev prvega žetona",
|
||||
"nodes.llm.firstTokenTimeoutTooltip": "Najdaljši čas čakanja na prvi žeton v milisekundah. 0 ga onemogoči; ob poteku vozlišče spodleti in sledi svoji strategiji obravnave napak. Prav tako omeji največji razmik med pretočnimi žetoni.",
|
||||
"nodes.llm.jsonSchema.addChildField": "Dodaj polje za otroka",
|
||||
"nodes.llm.jsonSchema.addField": "Dodaj polje",
|
||||
"nodes.llm.jsonSchema.apply": "Uporabi",
|
||||
|
||||
@ -541,7 +541,7 @@
|
||||
"nodes.common.pluginNotInstalled": "การผสานรวมไม่ได้ติดตั้ง",
|
||||
"nodes.common.pluginsNotInstalled": "{{count}} การผสานรวมยังไม่ได้ติดตั้ง",
|
||||
"nodes.common.retry.maxRetries": "การลองซ้ําสูงสุด",
|
||||
"nodes.common.retry.ms": "ms",
|
||||
"nodes.common.retry.ms": "นางสาว",
|
||||
"nodes.common.retry.retries": "{{num}} ลอง",
|
||||
"nodes.common.retry.retry": "ลอง",
|
||||
"nodes.common.retry.retryFailed": "ลองใหม่ล้มเหลว",
|
||||
@ -843,8 +843,6 @@
|
||||
"nodes.llm.context": "บริบท",
|
||||
"nodes.llm.contextTooltip": "คุณสามารถนําเข้าความรู้เป็นบริบทได้",
|
||||
"nodes.llm.files": "แฟ้ม",
|
||||
"nodes.llm.firstTokenTimeout": "หมดเวลารอโทเค็นแรก",
|
||||
"nodes.llm.firstTokenTimeoutTooltip": "เวลารอสูงสุดสำหรับโทเค็นแรก (มิลลิวินาที) 0 คือปิดใช้งาน เมื่อหมดเวลา โหนดจะล้มเหลวและทำตามกลยุทธ์การจัดการข้อผิดพลาด และยังจำกัดช่วงเวลาสูงสุดระหว่างโทเค็นที่สตรีมด้วย",
|
||||
"nodes.llm.jsonSchema.addChildField": "เพิ่มฟิลด์เด็ก",
|
||||
"nodes.llm.jsonSchema.addField": "เพิ่มฟิลด์",
|
||||
"nodes.llm.jsonSchema.apply": "สมัคร",
|
||||
|
||||
@ -541,7 +541,7 @@
|
||||
"nodes.common.pluginNotInstalled": "Entegrasyon yüklü değil",
|
||||
"nodes.common.pluginsNotInstalled": "{{count}} entegrasyon yüklenmedi",
|
||||
"nodes.common.retry.maxRetries": "En fazla yeniden deneme",
|
||||
"nodes.common.retry.ms": "ms",
|
||||
"nodes.common.retry.ms": "Ms",
|
||||
"nodes.common.retry.retries": "{{num}} Yeniden deneme",
|
||||
"nodes.common.retry.retry": "Yeniden deneme",
|
||||
"nodes.common.retry.retryFailed": "Yeniden deneme başarısız oldu",
|
||||
@ -843,8 +843,6 @@
|
||||
"nodes.llm.context": "bağlam",
|
||||
"nodes.llm.contextTooltip": "Bağlam olarak Bilgi ekleyebilirsiniz",
|
||||
"nodes.llm.files": "Dosyalar",
|
||||
"nodes.llm.firstTokenTimeout": "ilk token zaman aşımı",
|
||||
"nodes.llm.firstTokenTimeoutTooltip": "İlk token için maksimum bekleme süresi (milisaniye). 0 devre dışı bırakır; zaman aşımında düğüm başarısız olur ve hata işleme stratejisini uygular. Ayrıca akış halindeki token'lar arasındaki maksimum aralığı da sınırlar.",
|
||||
"nodes.llm.jsonSchema.addChildField": "Çocuk Alanı Ekle",
|
||||
"nodes.llm.jsonSchema.addField": "Alan Ekle",
|
||||
"nodes.llm.jsonSchema.apply": "Uygula",
|
||||
|
||||
@ -541,7 +541,7 @@
|
||||
"nodes.common.pluginNotInstalled": "Інтеграція не встановлений",
|
||||
"nodes.common.pluginsNotInstalled": "{{count}} інтеграцій не встановлено",
|
||||
"nodes.common.retry.maxRetries": "Максимальна кількість повторних спроб",
|
||||
"nodes.common.retry.ms": "мс",
|
||||
"nodes.common.retry.ms": "МС",
|
||||
"nodes.common.retry.retries": "{{num}} Спроб",
|
||||
"nodes.common.retry.retry": "Повторити",
|
||||
"nodes.common.retry.retryFailed": "Повторна спроба не вдалася",
|
||||
@ -843,8 +843,6 @@
|
||||
"nodes.llm.context": "контекст",
|
||||
"nodes.llm.contextTooltip": "Ви можете імпортувати знання як контекст",
|
||||
"nodes.llm.files": "Файли",
|
||||
"nodes.llm.firstTokenTimeout": "тайм-аут першого токена",
|
||||
"nodes.llm.firstTokenTimeoutTooltip": "Максимальний час очікування першого токена в мілісекундах. 0 вимикає; після таймауту вузол завершується помилкою та застосовує свою стратегію обробки помилок. Також обмежує максимальний інтервал між потоковими токенами.",
|
||||
"nodes.llm.jsonSchema.addChildField": "Додати поле дитини",
|
||||
"nodes.llm.jsonSchema.addField": "Додати поле",
|
||||
"nodes.llm.jsonSchema.apply": "Застосувати",
|
||||
|
||||
@ -541,7 +541,7 @@
|
||||
"nodes.common.pluginNotInstalled": "Tích hợp chưa được cài đặt",
|
||||
"nodes.common.pluginsNotInstalled": "{{count}} tích hợp chưa được cài đặt",
|
||||
"nodes.common.retry.maxRetries": "Số lần thử lại tối đa",
|
||||
"nodes.common.retry.ms": "ms",
|
||||
"nodes.common.retry.ms": "Ms",
|
||||
"nodes.common.retry.retries": "{{num}} Thử lại",
|
||||
"nodes.common.retry.retry": "Thử lại",
|
||||
"nodes.common.retry.retryFailed": "Thử lại không thành công",
|
||||
@ -843,8 +843,6 @@
|
||||
"nodes.llm.context": "ngữ cảnh",
|
||||
"nodes.llm.contextTooltip": "Bạn có thể nhập Kiến thức làm ngữ cảnh",
|
||||
"nodes.llm.files": "Tệp",
|
||||
"nodes.llm.firstTokenTimeout": "thời gian chờ token đầu tiên",
|
||||
"nodes.llm.firstTokenTimeoutTooltip": "Thời gian chờ tối đa cho token đầu tiên, tính bằng mili giây. 0 để tắt; khi hết thời gian, node thất bại và tuân theo chiến lược xử lý lỗi của nó. Nó cũng giới hạn khoảng thời gian tối đa giữa các token được truyền phát.",
|
||||
"nodes.llm.jsonSchema.addChildField": "Thêm trường trẻ em",
|
||||
"nodes.llm.jsonSchema.addField": "Thêm trường",
|
||||
"nodes.llm.jsonSchema.apply": "Áp dụng",
|
||||
|
||||
@ -843,8 +843,6 @@
|
||||
"nodes.llm.context": "上下文",
|
||||
"nodes.llm.contextTooltip": "您可以导入知识库作为上下文",
|
||||
"nodes.llm.files": "文件",
|
||||
"nodes.llm.firstTokenTimeout": "首个 token 超时",
|
||||
"nodes.llm.firstTokenTimeoutTooltip": "等待首个 token 的最长时间(毫秒)。0 表示关闭;超时后节点失败并按其错误处理策略处理。同时也会限制流式输出 token 之间的最大间隔。",
|
||||
"nodes.llm.jsonSchema.addChildField": "添加子字段",
|
||||
"nodes.llm.jsonSchema.addField": "添加字段",
|
||||
"nodes.llm.jsonSchema.apply": "应用",
|
||||
|
||||
@ -843,8 +843,6 @@
|
||||
"nodes.llm.context": "上下文",
|
||||
"nodes.llm.contextTooltip": "您可以導入知識庫作為上下文",
|
||||
"nodes.llm.files": "文件",
|
||||
"nodes.llm.firstTokenTimeout": "首個 token 逾時",
|
||||
"nodes.llm.firstTokenTimeoutTooltip": "等待首個 token 的最長時間(毫秒)。0 表示關閉;逾時後節點失敗並依其錯誤處理策略處理。同時也會限制串流輸出 token 之間的最大間隔。",
|
||||
"nodes.llm.jsonSchema.addChildField": "新增子欄位",
|
||||
"nodes.llm.jsonSchema.addField": "新增字段",
|
||||
"nodes.llm.jsonSchema.apply": "申請",
|
||||
|
||||
Reference in New Issue
Block a user