Compare commits

..

1 Commits

Author SHA1 Message Date
f3e3153323 fix: final answer would clear after sse if agent thought has answer 2026-07-10 14:23:42 +08:00
60 changed files with 900 additions and 1428 deletions

View File

@ -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")

View File

@ -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)

View File

@ -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

View File

@ -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"}))

View File

@ -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

View File

@ -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>

View File

@ -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}

View File

@ -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()
})

View File

@ -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>

View File

@ -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 () => {

View File

@ -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>

View File

@ -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()
})
})

View File

@ -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}

View File

@ -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(

View File

@ -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.

View File

@ -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>

View File

@ -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' })}

View File

@ -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' })}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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}

View File

@ -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>

View File

@ -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>

View File

@ -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)
})
})
})

View File

@ -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: {

View File

@ -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>

View File

@ -55,9 +55,6 @@ const nodeDefault: NodeDefault<LLMNodeType> = {
vision: {
enabled: false,
},
invocation: {
first_token_timeout: 0,
},
},
defaultRunInputData: {
'#context#': [RETRIEVAL_OUTPUT_STRUCT],

View File

@ -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}

View File

@ -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 {

View File

@ -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,

View File

@ -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' })}

View File

@ -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' })}

View File

@ -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>

View File

@ -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' })}

View File

@ -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">

View File

@ -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": "تطبيق",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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": "اعمال",

View File

@ -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",

View File

@ -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": "लागू करें",

View File

@ -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",

View File

@ -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",

View File

@ -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": "適用",

View File

@ -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": "적용",

View File

@ -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",

View File

@ -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ć",

View File

@ -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",

View File

@ -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ă",

View File

@ -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": "Подать заявку",

View File

@ -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",

View File

@ -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": "สมัคร",

View File

@ -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",

View File

@ -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": "Застосувати",

View File

@ -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",

View File

@ -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": "应用",

View File

@ -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": "申請",