mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-09 13:58:36 +03:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15d7e7c822 |
@@ -268,7 +268,6 @@ Tracing covers the providers that go through nanobot's OpenAI-compatible client
|
|||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| `custom` | Any OpenAI-compatible endpoint | — |
|
| `custom` | Any OpenAI-compatible endpoint | — |
|
||||||
| `openrouter` | LLM gateway for hosted model families + Voice transcription (STT models) | [openrouter.ai](https://openrouter.ai) |
|
| `openrouter` | LLM gateway for hosted model families + Voice transcription (STT models) | [openrouter.ai](https://openrouter.ai) |
|
||||||
| `edenai` | LLM gateway for Eden AI's OpenAI-compatible model catalog | [app.edenai.run](https://app.edenai.run/) |
|
|
||||||
| `opencode` | LLM gateway (OpenCode Zen coding-agent models) | [opencode.ai/docs/zen](https://opencode.ai/docs/zen/) |
|
| `opencode` | LLM gateway (OpenCode Zen coding-agent models) | [opencode.ai/docs/zen](https://opencode.ai/docs/zen/) |
|
||||||
| `opencode_zen` | LLM gateway (legacy alias for OpenCode Zen) | [opencode.ai/docs/zen](https://opencode.ai/docs/zen/) |
|
| `opencode_zen` | LLM gateway (legacy alias for OpenCode Zen) | [opencode.ai/docs/zen](https://opencode.ai/docs/zen/) |
|
||||||
| `opencode_go` | LLM gateway (OpenCode Go low-cost coding models) | [opencode.ai/docs/go](https://opencode.ai/docs/go/) |
|
| `opencode_go` | LLM gateway (OpenCode Go low-cost coding models) | [opencode.ai/docs/go](https://opencode.ai/docs/go/) |
|
||||||
|
|||||||
@@ -100,39 +100,6 @@ Gateway-style setup for model IDs served through OpenRouter.
|
|||||||
|
|
||||||
Use the model ID exactly as OpenRouter lists it.
|
Use the model ID exactly as OpenRouter lists it.
|
||||||
|
|
||||||
### Eden AI Gateway
|
|
||||||
|
|
||||||
Eden AI exposes an OpenAI-compatible chat-completions endpoint at
|
|
||||||
`https://api.edenai.run/v3`. Configure the built-in `edenai` provider and use
|
|
||||||
the full `provider/model` identifier listed by Eden AI:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"providers": {
|
|
||||||
"edenai": {
|
|
||||||
"apiKey": "${EDENAI_API_KEY}"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"modelPresets": {
|
|
||||||
"primary": {
|
|
||||||
"provider": "edenai",
|
|
||||||
"model": "anthropic/claude-sonnet-4-5",
|
|
||||||
"maxTokens": 8192
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"agents": {
|
|
||||||
"defaults": {
|
|
||||||
"modelPreset": "primary"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Nanobot sends the model ID unchanged, including its provider prefix. Use
|
|
||||||
Eden AI's [model listing](https://www.edenai.co/docs/v3/llms/listing-models)
|
|
||||||
to choose a currently available model. The WebUI can also load that catalog
|
|
||||||
after the Eden AI API key is saved under **Settings → Models**.
|
|
||||||
|
|
||||||
### OpenCode Zen and Go
|
### OpenCode Zen and Go
|
||||||
|
|
||||||
OpenCode Zen and OpenCode Go are OpenCode-managed gateways for coding-agent models.
|
OpenCode Zen and OpenCode Go are OpenCode-managed gateways for coding-agent models.
|
||||||
@@ -337,53 +304,6 @@ If your custom endpoint documents a nonstandard thinking toggle, set `providers.
|
|||||||
|
|
||||||
This named custom provider path is not for Anthropic-compatible endpoints. For Anthropic-compatible proxies, use `providers.anthropic.apiBase` and set the preset provider to `anthropic`.
|
This named custom provider path is not for Anthropic-compatible endpoints. For Anthropic-compatible proxies, use `providers.anthropic.apiBase` and set the preset provider to `anthropic`.
|
||||||
|
|
||||||
### ModelScope
|
|
||||||
|
|
||||||
ModelScope (魔搭社区) exposes an OpenAI-compatible LLM endpoint plus a separate async image generation API. Both are covered by the built-in `modelscope` provider.
|
|
||||||
|
|
||||||
Create a ModelScope [access token](https://modelscope.cn/my/myaccesstoken), then choose a model whose page exposes API-Inference. The example below uses [`Qwen/Qwen3-32B`](https://modelscope.cn/models/Qwen/Qwen3-32B); hosted availability and quotas are controlled by ModelScope. See the official [API-Inference guide](https://modelscope.cn/docs/model-service/API-Inference/intro) for current service details.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"providers": {
|
|
||||||
"modelscope": {
|
|
||||||
"apiKey": "${MODELSCOPE_API_KEY}"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"modelPresets": {
|
|
||||||
"primary": {
|
|
||||||
"provider": "modelscope",
|
|
||||||
"model": "Qwen/Qwen3-32B",
|
|
||||||
"maxTokens": 8192,
|
|
||||||
"contextWindowTokens": 65536
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"agents": {
|
|
||||||
"defaults": {
|
|
||||||
"modelPreset": "primary"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Use an inference-enabled model ID exactly as ModelScope publishes it (usually `Namespace/model-name`). The default base URL is `https://api-inference.modelscope.cn/v1`; override `providers.modelscope.apiBase` only if your account routes through a different host. Chat model IDs may optionally be prefixed with `modelscope/`; nanobot strips that routing prefix before sending the request.
|
|
||||||
|
|
||||||
ModelScope image generation reuses the same provider key but is configured under `tools.imageGeneration`, not in a model preset:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"tools": {
|
|
||||||
"imageGeneration": {
|
|
||||||
"enabled": true,
|
|
||||||
"provider": "modelscope",
|
|
||||||
"model": "Qwen/Qwen-Image-2512"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Use the image model's exact ModelScope ID without a leading `modelscope/`; the image client sends this value unchanged and handles ModelScope's async submit/poll flow. The example uses [`Qwen/Qwen-Image-2512`](https://modelscope.cn/models/Qwen/Qwen-Image-2512). See [Image Generation](./image-generation.md#modelscope) for supported sizes, aspect ratios, and the complete provider configuration.
|
|
||||||
|
|
||||||
### Ollama
|
### Ollama
|
||||||
|
|
||||||
Start Ollama separately, then point nanobot at the OpenAI-compatible endpoint.
|
Start Ollama separately, then point nanobot at the OpenAI-compatible endpoint.
|
||||||
|
|||||||
+12
-12
@@ -217,16 +217,18 @@ class ContextBuilder:
|
|||||||
include_memory_recent_history: bool = True,
|
include_memory_recent_history: bool = True,
|
||||||
session_key: str | None = None,
|
session_key: str | None = None,
|
||||||
unified_session: bool = False,
|
unified_session: bool = False,
|
||||||
|
conversation_only: bool = False,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Build the complete message list for an LLM call."""
|
"""Build the complete message list for an LLM call."""
|
||||||
root = workspace or self.workspace
|
messages = list(history)
|
||||||
active_skill_names = (
|
if not conversation_only:
|
||||||
self.skills.get_explicitly_invoked_skills(current_message)
|
root = workspace or self.workspace
|
||||||
if current_role == "user"
|
active_skill_names = (
|
||||||
else []
|
self.skills.get_explicitly_invoked_skills(current_message)
|
||||||
)
|
if current_role == "user"
|
||||||
messages: list[dict[str, Any]] = [
|
else []
|
||||||
{
|
)
|
||||||
|
messages.insert(0, {
|
||||||
"role": "system",
|
"role": "system",
|
||||||
"content": self.build_system_prompt(
|
"content": self.build_system_prompt(
|
||||||
active_skill_names=active_skill_names,
|
active_skill_names=active_skill_names,
|
||||||
@@ -237,16 +239,14 @@ class ContextBuilder:
|
|||||||
session_key=session_key,
|
session_key=session_key,
|
||||||
unified_session=unified_session,
|
unified_session=unified_session,
|
||||||
),
|
),
|
||||||
},
|
})
|
||||||
*history,
|
|
||||||
]
|
|
||||||
current = self.build_current_message(
|
current = self.build_current_message(
|
||||||
current_message,
|
current_message,
|
||||||
media=media,
|
media=media,
|
||||||
current_role=current_role,
|
current_role=current_role,
|
||||||
runtime_context_blocks=runtime_context_blocks,
|
runtime_context_blocks=runtime_context_blocks,
|
||||||
)
|
)
|
||||||
if messages[-1].get("role") == current_role:
|
if messages and messages[-1].get("role") == current_role:
|
||||||
last = dict(messages[-1])
|
last = dict(messages[-1])
|
||||||
last["content"] = self._merge_message_content(
|
last["content"] = self._merge_message_content(
|
||||||
last.get("content"),
|
last.get("content"),
|
||||||
|
|||||||
+35
-43
@@ -399,7 +399,6 @@ class AgentLoop:
|
|||||||
self._runtime_context_providers: list[RuntimeContextProvider] = []
|
self._runtime_context_providers: list[RuntimeContextProvider] = []
|
||||||
self._active_tasks: dict[str, set[asyncio.Task[Any]]] = {}
|
self._active_tasks: dict[str, set[asyncio.Task[Any]]] = {}
|
||||||
self._background_tasks: set[asyncio.Task[Any]] = set()
|
self._background_tasks: set[asyncio.Task[Any]] = set()
|
||||||
self._close_mcp_lock = asyncio.Lock()
|
|
||||||
self._session_locks: weakref.WeakValueDictionary[str, asyncio.Lock] = (
|
self._session_locks: weakref.WeakValueDictionary[str, asyncio.Lock] = (
|
||||||
weakref.WeakValueDictionary()
|
weakref.WeakValueDictionary()
|
||||||
)
|
)
|
||||||
@@ -724,6 +723,7 @@ class AgentLoop:
|
|||||||
include_memory_recent_history=not ctx.ephemeral,
|
include_memory_recent_history=not ctx.ephemeral,
|
||||||
session_key=ctx.session.key,
|
session_key=ctx.session.key,
|
||||||
unified_session=self._unified_session,
|
unified_session=self._unified_session,
|
||||||
|
conversation_only=ctx.session.transient is True,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _request_context_for_turn(self, ctx: TurnContext) -> RequestContext:
|
def _request_context_for_turn(self, ctx: TurnContext) -> RequestContext:
|
||||||
@@ -751,10 +751,12 @@ class AgentLoop:
|
|||||||
self,
|
self,
|
||||||
ctx: TurnContext,
|
ctx: TurnContext,
|
||||||
) -> list[RuntimeContextBlock]:
|
) -> list[RuntimeContextBlock]:
|
||||||
|
if ctx.require_session().transient is True:
|
||||||
|
return []
|
||||||
assert ctx.request_context is not None
|
assert ctx.request_context is not None
|
||||||
return await self._resolve_runtime_context_for_request(
|
return await self._resolve_runtime_context_for_request(
|
||||||
ctx.request_context,
|
ctx.request_context,
|
||||||
ctx.tools or self.tools,
|
ctx.tools if ctx.tools is not None else self.tools,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _resolve_runtime_context_for_request(
|
async def _resolve_runtime_context_for_request(
|
||||||
@@ -785,18 +787,24 @@ class AgentLoop:
|
|||||||
else:
|
else:
|
||||||
logger.warning("Command '{}' matched but dispatch returned None", raw)
|
logger.warning("Command '{}' matched but dispatch returned None", raw)
|
||||||
|
|
||||||
async def _cancel_active_tasks(self, key: str) -> int:
|
async def cancel_active_turn(self, key: str) -> int:
|
||||||
"""Cancel and await all active tasks and subagents for *key*.
|
"""Cancel active work and discard queued follow-ups for *key*.
|
||||||
|
|
||||||
Returns the total number of cancelled tasks + subagents.
|
Returns the total number of cancelled tasks + subagents.
|
||||||
"""
|
"""
|
||||||
|
pending = self._pending_queues.pop(key, None)
|
||||||
|
queued = 0
|
||||||
|
if pending is not None:
|
||||||
|
while not pending.empty():
|
||||||
|
pending.get_nowait()
|
||||||
|
queued += 1
|
||||||
tasks = tuple(self._active_tasks.pop(key, set()))
|
tasks = tuple(self._active_tasks.pop(key, set()))
|
||||||
cancelled = sum(1 for t in tasks if not t.done() and t.cancel())
|
cancelled = sum(1 for t in tasks if not t.done() and t.cancel())
|
||||||
for t in tasks:
|
for t in tasks:
|
||||||
with suppress(asyncio.CancelledError, Exception):
|
with suppress(asyncio.CancelledError, Exception):
|
||||||
await t
|
await t
|
||||||
sub_cancelled = await self.subagents.cancel_by_session(key)
|
sub_cancelled = await self.subagents.cancel_by_session(key)
|
||||||
return cancelled + sub_cancelled
|
return queued + cancelled + sub_cancelled
|
||||||
|
|
||||||
def _effective_session_key(self, msg: InboundMessage) -> str:
|
def _effective_session_key(self, msg: InboundMessage) -> str:
|
||||||
"""Return the session key used for task routing and mid-turn injections."""
|
"""Return the session key used for task routing and mid-turn injections."""
|
||||||
@@ -923,7 +931,10 @@ class AgentLoop:
|
|||||||
if isinstance(metadata_value, dict)
|
if isinstance(metadata_value, dict)
|
||||||
else {}
|
else {}
|
||||||
)
|
)
|
||||||
if pending_msg.channel != "system":
|
if (
|
||||||
|
pending_msg.channel != "system"
|
||||||
|
and not (session is not None and session.transient is True)
|
||||||
|
):
|
||||||
scope = self.workspace_scopes.for_turn(
|
scope = self.workspace_scopes.for_turn(
|
||||||
channel=pending_msg.channel,
|
channel=pending_msg.channel,
|
||||||
message_metadata=metadata,
|
message_metadata=metadata,
|
||||||
@@ -1003,7 +1014,7 @@ class AgentLoop:
|
|||||||
message_metadata=metadata,
|
message_metadata=metadata,
|
||||||
session_metadata=session.metadata if session is not None else None,
|
session_metadata=session.metadata if session is not None else None,
|
||||||
)
|
)
|
||||||
effective_tools = tools or self.tools
|
effective_tools = tools if tools is not None else self.tools
|
||||||
request_ctx = request_context or RequestContext(
|
request_ctx = request_context or RequestContext(
|
||||||
channel=channel,
|
channel=channel,
|
||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
@@ -1161,6 +1172,11 @@ class AgentLoop:
|
|||||||
effective_key = self._effective_session_key(msg)
|
effective_key = self._effective_session_key(msg)
|
||||||
if await agent_context.handle_runtime_control(self, msg, self.tools):
|
if await agent_context.handle_runtime_control(self, msg, self.tools):
|
||||||
continue
|
continue
|
||||||
|
if (
|
||||||
|
msg.transient_session
|
||||||
|
and not self.sessions.is_transient_active(effective_key)
|
||||||
|
):
|
||||||
|
continue
|
||||||
if self.commands.is_priority(raw):
|
if self.commands.is_priority(raw):
|
||||||
await self._dispatch_command_inline(
|
await self._dispatch_command_inline(
|
||||||
msg, effective_key, raw,
|
msg, effective_key, raw,
|
||||||
@@ -1272,6 +1288,8 @@ class AgentLoop:
|
|||||||
session_key,
|
session_key,
|
||||||
exc_info=True,
|
exc_info=True,
|
||||||
)
|
)
|
||||||
|
if msg.transient_session:
|
||||||
|
raise
|
||||||
# Preserve partial context from the interrupted turn so
|
# Preserve partial context from the interrupted turn so
|
||||||
# the user does not lose tool results and assistant
|
# the user does not lose tool results and assistant
|
||||||
# messages accumulated before /stop. The checkpoint was
|
# messages accumulated before /stop. The checkpoint was
|
||||||
@@ -1339,42 +1357,11 @@ class AgentLoop:
|
|||||||
await self._publish_next_deferred_automation_turn(session_key)
|
await self._publish_next_deferred_automation_turn(session_key)
|
||||||
|
|
||||||
async def close_mcp(self) -> None:
|
async def close_mcp(self) -> None:
|
||||||
"""Stop active work, then close exec, subagent, and MCP resources.
|
"""Drain background work, stop exec sessions, then close MCP connections."""
|
||||||
|
if self._background_tasks:
|
||||||
Resource teardown must still run if cancellation interrupts task draining.
|
await asyncio.gather(*self._background_tasks, return_exceptions=True)
|
||||||
Gateway shutdown deliberately bounds this coroutine, so keeping the cleanup
|
|
||||||
phase in ``finally`` prevents a timed-out background task from leaving
|
|
||||||
subprocess transports alive after the event loop closes.
|
|
||||||
"""
|
|
||||||
# The agent loop closes itself from ``run()`` while gateway shutdown also
|
|
||||||
# performs a guaranteed final close. Serialize those owners so they cannot
|
|
||||||
# tear down the same subprocess transports concurrently.
|
|
||||||
close_lock = getattr(self, "_close_mcp_lock", None)
|
|
||||||
if close_lock is None:
|
|
||||||
close_lock = self._close_mcp_lock = asyncio.Lock()
|
|
||||||
async with close_lock:
|
|
||||||
await self._close_mcp_unlocked()
|
|
||||||
|
|
||||||
async def _close_mcp_unlocked(self) -> None:
|
|
||||||
errors: list[BaseException] = []
|
|
||||||
active_task_groups = getattr(self, "_active_tasks", {})
|
|
||||||
active_tasks = tuple({task for tasks in active_task_groups.values() for task in tasks})
|
|
||||||
active_task_groups.clear()
|
|
||||||
current_task = asyncio.current_task()
|
|
||||||
active_tasks = tuple(task for task in active_tasks if task is not current_task)
|
|
||||||
for task in active_tasks:
|
|
||||||
if not task.done():
|
|
||||||
task.cancel()
|
|
||||||
try:
|
|
||||||
if active_tasks:
|
|
||||||
await asyncio.gather(*active_tasks, return_exceptions=True)
|
|
||||||
if self._background_tasks:
|
|
||||||
await asyncio.gather(*self._background_tasks, return_exceptions=True)
|
|
||||||
except BaseException as exc:
|
|
||||||
errors.append(exc)
|
|
||||||
finally:
|
|
||||||
self._background_tasks.clear()
|
self._background_tasks.clear()
|
||||||
|
errors: list[BaseException] = []
|
||||||
cleanup_steps = (
|
cleanup_steps = (
|
||||||
self.subagents.close,
|
self.subagents.close,
|
||||||
self._exec_session_manager.close_all,
|
self._exec_session_manager.close_all,
|
||||||
@@ -1605,13 +1592,16 @@ class AgentLoop:
|
|||||||
if ctx.session is None:
|
if ctx.session is None:
|
||||||
ctx.session = self.sessions.get_or_create(ctx.session_key)
|
ctx.session = self.sessions.get_or_create(ctx.session_key)
|
||||||
session = ctx.session
|
session = ctx.session
|
||||||
|
if session.transient is True:
|
||||||
|
ctx.ephemeral = True
|
||||||
|
ctx.tools = ToolRegistry()
|
||||||
self._remember_unified_session_route(
|
self._remember_unified_session_route(
|
||||||
session,
|
session,
|
||||||
msg,
|
msg,
|
||||||
is_user_turn=ctx.original_user_text is not None,
|
is_user_turn=ctx.original_user_text is not None,
|
||||||
)
|
)
|
||||||
await ctx.delivery.started()
|
await ctx.delivery.started()
|
||||||
if ctx.kind is TurnKind.USER:
|
if ctx.kind is TurnKind.USER and not session.transient:
|
||||||
self.workspace_scopes.persist_message_scope(session, msg)
|
self.workspace_scopes.persist_message_scope(session, msg)
|
||||||
|
|
||||||
if self._restore_runtime_checkpoint(session):
|
if self._restore_runtime_checkpoint(session):
|
||||||
@@ -1621,6 +1611,8 @@ class AgentLoop:
|
|||||||
|
|
||||||
async def _compact_session(self, ctx: TurnContext) -> None:
|
async def _compact_session(self, ctx: TurnContext) -> None:
|
||||||
session = ctx.require_session()
|
session = ctx.require_session()
|
||||||
|
if session.transient is True:
|
||||||
|
return
|
||||||
ctx.session, pending = self.auto_compact.prepare_session(
|
ctx.session, pending = self.auto_compact.prepare_session(
|
||||||
session,
|
session,
|
||||||
ctx.session_key,
|
ctx.session_key,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ INBOUND_META_RUNTIME_CONTROL = "_runtime_control"
|
|||||||
RUNTIME_CONTROL_ACK = "_ack"
|
RUNTIME_CONTROL_ACK = "_ack"
|
||||||
RUNTIME_CONTROL_MCP_RELOAD = "mcp_reload"
|
RUNTIME_CONTROL_MCP_RELOAD = "mcp_reload"
|
||||||
RUNTIME_CONTROL_IMAGE_GENERATION_RELOAD = "image_generation_reload"
|
RUNTIME_CONTROL_IMAGE_GENERATION_RELOAD = "image_generation_reload"
|
||||||
|
INBOUND_META_TRANSIENT_SESSION = "_transient_session"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -32,6 +33,7 @@ class InboundMessage:
|
|||||||
media: list[str] = field(default_factory=list) # Media URLs
|
media: list[str] = field(default_factory=list) # Media URLs
|
||||||
metadata: dict[str, Any] = field(default_factory=dict) # Channel-specific data
|
metadata: dict[str, Any] = field(default_factory=dict) # Channel-specific data
|
||||||
session_key_override: str | None = None # Optional override for thread-scoped sessions
|
session_key_override: str | None = None # Optional override for thread-scoped sessions
|
||||||
|
transient_session: bool = False # In-memory session whose lifetime is owned by the channel
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def session_key(self) -> str:
|
def session_key(self) -> str:
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ from typing import Any, cast
|
|||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.bus.events import InboundMessage, OutboundMessage
|
from nanobot.bus.events import (
|
||||||
|
INBOUND_META_TRANSIENT_SESSION,
|
||||||
|
InboundMessage,
|
||||||
|
OutboundMessage,
|
||||||
|
)
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.pairing import (
|
from nanobot.pairing import (
|
||||||
PAIRING_CODE_META_KEY,
|
PAIRING_CODE_META_KEY,
|
||||||
@@ -277,7 +281,8 @@ class BaseChannel(ABC):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
meta = metadata or {}
|
meta = dict(metadata or {})
|
||||||
|
transient_session = meta.pop(INBOUND_META_TRANSIENT_SESSION, False) is True
|
||||||
if self.supports_streaming:
|
if self.supports_streaming:
|
||||||
meta = {**meta, "_wants_stream": True}
|
meta = {**meta, "_wants_stream": True}
|
||||||
|
|
||||||
@@ -289,6 +294,7 @@ class BaseChannel(ABC):
|
|||||||
media=media or [],
|
media=media or [],
|
||||||
metadata=meta,
|
metadata=meta,
|
||||||
session_key_override=session_key,
|
session_key_override=session_key,
|
||||||
|
transient_session=transient_session,
|
||||||
)
|
)
|
||||||
|
|
||||||
await self.bus.publish_inbound(msg)
|
await self.bus.publish_inbound(msg)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
import inspect
|
import inspect
|
||||||
from collections.abc import Callable, Iterable
|
from collections.abc import Awaitable, Callable, Iterable
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, cast
|
from typing import TYPE_CHECKING, Any, cast
|
||||||
@@ -97,6 +97,7 @@ class ChannelManager:
|
|||||||
webui_runtime_model_name: Callable[[], str | None] | None = None,
|
webui_runtime_model_name: Callable[[], str | None] | None = None,
|
||||||
webui_cron_pending_job_ids: Callable[[str], set[str]] | None = None,
|
webui_cron_pending_job_ids: Callable[[str], set[str]] | None = None,
|
||||||
webui_local_trigger_pending_ids: Callable[[str], set[str]] | None = None,
|
webui_local_trigger_pending_ids: Callable[[str], set[str]] | None = None,
|
||||||
|
webui_cancel_active_turn: Callable[[str], Awaitable[int]] | None = None,
|
||||||
webui_static_dist: bool = True,
|
webui_static_dist: bool = True,
|
||||||
webui_runtime_surface: str = "browser",
|
webui_runtime_surface: str = "browser",
|
||||||
webui_runtime_capabilities: dict[str, Any] | None = None,
|
webui_runtime_capabilities: dict[str, Any] | None = None,
|
||||||
@@ -110,6 +111,7 @@ class ChannelManager:
|
|||||||
self._webui_runtime_model_name = webui_runtime_model_name
|
self._webui_runtime_model_name = webui_runtime_model_name
|
||||||
self._webui_cron_pending_job_ids = webui_cron_pending_job_ids
|
self._webui_cron_pending_job_ids = webui_cron_pending_job_ids
|
||||||
self._webui_local_trigger_pending_ids = webui_local_trigger_pending_ids
|
self._webui_local_trigger_pending_ids = webui_local_trigger_pending_ids
|
||||||
|
self._webui_cancel_active_turn = webui_cancel_active_turn
|
||||||
self._webui_static_dist = webui_static_dist
|
self._webui_static_dist = webui_static_dist
|
||||||
self._webui_runtime_surface = webui_runtime_surface
|
self._webui_runtime_surface = webui_runtime_surface
|
||||||
self._webui_runtime_capabilities = dict(webui_runtime_capabilities or {})
|
self._webui_runtime_capabilities = dict(webui_runtime_capabilities or {})
|
||||||
@@ -178,6 +180,7 @@ class ChannelManager:
|
|||||||
local_trigger_store=self._local_trigger_store,
|
local_trigger_store=self._local_trigger_store,
|
||||||
cron_pending_job_ids=self._webui_cron_pending_job_ids,
|
cron_pending_job_ids=self._webui_cron_pending_job_ids,
|
||||||
local_trigger_pending_ids=self._webui_local_trigger_pending_ids,
|
local_trigger_pending_ids=self._webui_local_trigger_pending_ids,
|
||||||
|
cancel_active_turn=self._webui_cancel_active_turn,
|
||||||
channel_feature_action=self.apply_channel_feature_action,
|
channel_feature_action=self.apply_channel_feature_action,
|
||||||
channel_runtime_status=self.get_status,
|
channel_runtime_status=self.get_status,
|
||||||
skill_state_action=self._webui_skill_state_action,
|
skill_state_action=self._webui_skill_state_action,
|
||||||
|
|||||||
@@ -18,7 +18,11 @@ from websockets.asyncio.server import ServerConnection, serve, unix_serve
|
|||||||
from websockets.exceptions import ConnectionClosed
|
from websockets.exceptions import ConnectionClosed
|
||||||
from websockets.http11 import Request as WsRequest
|
from websockets.http11 import Request as WsRequest
|
||||||
|
|
||||||
from nanobot.bus.events import OUTBOUND_META_AGENT_UI, OutboundMessage
|
from nanobot.bus.events import (
|
||||||
|
INBOUND_META_TRANSIENT_SESSION,
|
||||||
|
OUTBOUND_META_AGENT_UI,
|
||||||
|
OutboundMessage,
|
||||||
|
)
|
||||||
from nanobot.bus.outbound_events import (
|
from nanobot.bus.outbound_events import (
|
||||||
GoalStateSyncEvent,
|
GoalStateSyncEvent,
|
||||||
GoalStatusEvent,
|
GoalStatusEvent,
|
||||||
@@ -32,6 +36,10 @@ from nanobot.bus.outbound_events import (
|
|||||||
)
|
)
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels.base import BaseChannel
|
from nanobot.channels.base import BaseChannel
|
||||||
|
from nanobot.channels.websocket.temporary_chat import (
|
||||||
|
TemporaryChatLifecycle,
|
||||||
|
TemporaryChatLifecycleError,
|
||||||
|
)
|
||||||
from nanobot.command.builtin import builtin_command_starts_agent_turn
|
from nanobot.command.builtin import builtin_command_starts_agent_turn
|
||||||
from nanobot.config.schema import Base
|
from nanobot.config.schema import Base
|
||||||
from nanobot.runtime_context import (
|
from nanobot.runtime_context import (
|
||||||
@@ -76,6 +84,8 @@ from nanobot.webui.websocket_logging import websockets_server_logger
|
|||||||
|
|
||||||
# Plain HTTP WebUI routes also run through websockets.process_request.
|
# Plain HTTP WebUI routes also run through websockets.process_request.
|
||||||
_WEBUI_HTTP_OPEN_TIMEOUT_S = 360.0
|
_WEBUI_HTTP_OPEN_TIMEOUT_S = 360.0
|
||||||
|
_TEMPORARY_CHAT_ID_PREFIX = "temporary-"
|
||||||
|
_TEMPORARY_COMMANDS = frozenset({"/model", "/stop"})
|
||||||
|
|
||||||
|
|
||||||
class WebSocketConfig(Base):
|
class WebSocketConfig(Base):
|
||||||
@@ -215,6 +225,10 @@ def _is_valid_chat_id(value: Any) -> TypeGuard[str]:
|
|||||||
return isinstance(value, str) and _CHAT_ID_RE.match(value) is not None
|
return isinstance(value, str) and _CHAT_ID_RE.match(value) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_temporary_chat_id(value: Any) -> TypeGuard[str]:
|
||||||
|
return _is_valid_chat_id(value) and value.startswith(_TEMPORARY_CHAT_ID_PREFIX)
|
||||||
|
|
||||||
|
|
||||||
def _parse_envelope(raw: str) -> dict[str, Any] | None:
|
def _parse_envelope(raw: str) -> dict[str, Any] | None:
|
||||||
"""Return a typed envelope dict if the frame is a new-style JSON envelope, else None.
|
"""Return a typed envelope dict if the frame is a new-style JSON envelope, else None.
|
||||||
|
|
||||||
@@ -286,6 +300,13 @@ class WebSocketChannel(BaseChannel):
|
|||||||
self._workspaces = gateway.workspaces
|
self._workspaces = gateway.workspaces
|
||||||
|
|
||||||
self._stream_text_buffers: dict[tuple[str, str], list[str]] = {}
|
self._stream_text_buffers: dict[tuple[str, str], list[str]] = {}
|
||||||
|
self._temporary_chats = TemporaryChatLifecycle(
|
||||||
|
sessions=gateway.session_manager,
|
||||||
|
cancel_active_turn=gateway.cancel_active_turn,
|
||||||
|
attach=self._attach,
|
||||||
|
detach=self._detach,
|
||||||
|
clear_stream_buffers=self._clear_stream_buffers,
|
||||||
|
)
|
||||||
|
|
||||||
# -- Subscription bookkeeping -------------------------------------------
|
# -- Subscription bookkeeping -------------------------------------------
|
||||||
|
|
||||||
@@ -297,6 +318,23 @@ class WebSocketChannel(BaseChannel):
|
|||||||
self._subs.setdefault(chat_id, set()).add(connection)
|
self._subs.setdefault(chat_id, set()).add(connection)
|
||||||
self._conn_chats.setdefault(connection, set()).add(chat_id)
|
self._conn_chats.setdefault(connection, set()).add(chat_id)
|
||||||
|
|
||||||
|
def _detach(self, connection: ServerConnection, chat_id: str) -> None:
|
||||||
|
chats = self._conn_chats.get(connection)
|
||||||
|
if chats is not None:
|
||||||
|
chats.discard(chat_id)
|
||||||
|
if not chats:
|
||||||
|
self._conn_chats.pop(connection, None)
|
||||||
|
subscribers = self._subs.get(chat_id)
|
||||||
|
if subscribers is not None:
|
||||||
|
subscribers.discard(connection)
|
||||||
|
if not subscribers:
|
||||||
|
self._subs.pop(chat_id, None)
|
||||||
|
|
||||||
|
def _clear_stream_buffers(self, chat_id: str) -> None:
|
||||||
|
for key in tuple(self._stream_text_buffers):
|
||||||
|
if key[0] == chat_id:
|
||||||
|
self._stream_text_buffers.pop(key, None)
|
||||||
|
|
||||||
async def send_webui_protocol_error(
|
async def send_webui_protocol_error(
|
||||||
self,
|
self,
|
||||||
connection: ServerConnection,
|
connection: ServerConnection,
|
||||||
@@ -325,18 +363,15 @@ class WebSocketChannel(BaseChannel):
|
|||||||
)
|
)
|
||||||
await self._hydrate_after_subscribe(fork_id)
|
await self._hydrate_after_subscribe(fork_id)
|
||||||
|
|
||||||
def _cleanup_connection(self, connection: ServerConnection) -> None:
|
async def _cleanup_connection(self, connection: ServerConnection) -> None:
|
||||||
"""Remove *connection* from every subscription set; safe to call multiple times."""
|
"""Remove *connection* from every subscription set; safe to call multiple times."""
|
||||||
chat_ids = self._conn_chats.pop(connection, set())
|
try:
|
||||||
for cid in chat_ids:
|
await self._temporary_chats.discard_owner(connection)
|
||||||
subs = self._subs.get(cid)
|
finally:
|
||||||
if subs is None:
|
for chat_id in tuple(self._conn_chats.get(connection, ())):
|
||||||
continue
|
self._detach(connection, chat_id)
|
||||||
subs.discard(connection)
|
self._conn_default.pop(connection, None)
|
||||||
if not subs:
|
self._webui_connections.discard(connection)
|
||||||
self._subs.pop(cid, None)
|
|
||||||
self._conn_default.pop(connection, None)
|
|
||||||
self._webui_connections.discard(connection)
|
|
||||||
|
|
||||||
async def _maybe_push_active_goal_state(self, chat_id: str) -> None:
|
async def _maybe_push_active_goal_state(self, chat_id: str) -> None:
|
||||||
"""Replay an active sustained goal from session metadata after *chat_id* is subscribed.
|
"""Replay an active sustained goal from session metadata after *chat_id* is subscribed.
|
||||||
@@ -387,7 +422,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
try:
|
try:
|
||||||
await connection.send(raw)
|
await connection.send(raw)
|
||||||
except ConnectionClosed:
|
except ConnectionClosed:
|
||||||
self._cleanup_connection(connection)
|
await self._cleanup_connection(connection)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.warning("failed to send {} event: {}", event, e)
|
self.logger.warning("failed to send {} event: {}", event, e)
|
||||||
|
|
||||||
@@ -609,7 +644,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.debug("connection ended: {}", e)
|
self.logger.debug("connection ended: {}", e)
|
||||||
finally:
|
finally:
|
||||||
self._cleanup_connection(connection)
|
await self._cleanup_connection(connection)
|
||||||
|
|
||||||
# -- Inbound WebSocket envelopes ---------------------------------------
|
# -- Inbound WebSocket envelopes ---------------------------------------
|
||||||
|
|
||||||
@@ -647,11 +682,36 @@ class WebSocketChannel(BaseChannel):
|
|||||||
if t == "fork_chat":
|
if t == "fork_chat":
|
||||||
await handle_webui_fork_chat(self, connection, envelope)
|
await handle_webui_fork_chat(self, connection, envelope)
|
||||||
return
|
return
|
||||||
|
if t == "discard_temporary_chat":
|
||||||
|
cid = envelope.get("chat_id")
|
||||||
|
if not _is_temporary_chat_id(cid):
|
||||||
|
await self._send_event(connection, "error", detail="invalid temporary chat_id")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await self._temporary_chats.discard(connection, cid)
|
||||||
|
except TemporaryChatLifecycleError as exc:
|
||||||
|
await self._send_event(
|
||||||
|
connection,
|
||||||
|
"error",
|
||||||
|
detail=exc.detail,
|
||||||
|
chat_id=cid,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
await self._send_event(connection, "temporary_chat_discarded", chat_id=cid)
|
||||||
|
return
|
||||||
if t == "attach":
|
if t == "attach":
|
||||||
cid = envelope.get("chat_id")
|
cid = envelope.get("chat_id")
|
||||||
if not _is_valid_chat_id(cid):
|
if not _is_valid_chat_id(cid):
|
||||||
await self._send_event(connection, "error", detail="invalid chat_id")
|
await self._send_event(connection, "error", detail="invalid chat_id")
|
||||||
return
|
return
|
||||||
|
if _is_temporary_chat_id(cid):
|
||||||
|
await self._send_event(
|
||||||
|
connection,
|
||||||
|
"error",
|
||||||
|
detail="temporary_chat_cannot_attach",
|
||||||
|
chat_id=cid,
|
||||||
|
)
|
||||||
|
return
|
||||||
self._attach(connection, cid)
|
self._attach(connection, cid)
|
||||||
await self._send_event(connection, "attached", chat_id=cid)
|
await self._send_event(connection, "attached", chat_id=cid)
|
||||||
await self._hydrate_after_subscribe(cid)
|
await self._hydrate_after_subscribe(cid)
|
||||||
@@ -661,6 +721,14 @@ class WebSocketChannel(BaseChannel):
|
|||||||
if not _is_valid_chat_id(cid):
|
if not _is_valid_chat_id(cid):
|
||||||
await self._send_event(connection, "error", detail="invalid chat_id")
|
await self._send_event(connection, "error", detail="invalid chat_id")
|
||||||
return
|
return
|
||||||
|
if _is_temporary_chat_id(cid):
|
||||||
|
await self._send_event(
|
||||||
|
connection,
|
||||||
|
"error",
|
||||||
|
detail="temporary_chat_has_no_workspace",
|
||||||
|
chat_id=cid,
|
||||||
|
)
|
||||||
|
return
|
||||||
scope = await self._workspace_scope_or_error(
|
scope = await self._workspace_scope_or_error(
|
||||||
connection,
|
connection,
|
||||||
lambda: self._workspaces.scope_for_set_request(
|
lambda: self._workspaces.scope_for_set_request(
|
||||||
@@ -692,6 +760,15 @@ class WebSocketChannel(BaseChannel):
|
|||||||
if not _is_valid_chat_id(cid):
|
if not _is_valid_chat_id(cid):
|
||||||
await self._send_event(connection, "error", detail="invalid chat_id")
|
await self._send_event(connection, "error", detail="invalid chat_id")
|
||||||
return
|
return
|
||||||
|
temporary = envelope.get("temporary") is True
|
||||||
|
if _is_temporary_chat_id(cid) != temporary:
|
||||||
|
await self._send_event(
|
||||||
|
connection,
|
||||||
|
"error",
|
||||||
|
detail="temporary_chat_mismatch",
|
||||||
|
chat_id=cid,
|
||||||
|
)
|
||||||
|
return
|
||||||
raw_turn_id = envelope.get("turn_id")
|
raw_turn_id = envelope.get("turn_id")
|
||||||
turn_id = raw_turn_id if isinstance(raw_turn_id, str) and raw_turn_id else None
|
turn_id = raw_turn_id if isinstance(raw_turn_id, str) and raw_turn_id else None
|
||||||
rejection_fields = {
|
rejection_fields = {
|
||||||
@@ -728,6 +805,17 @@ class WebSocketChannel(BaseChannel):
|
|||||||
**rejection_fields,
|
**rejection_fields,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
if temporary:
|
||||||
|
await self._dispatch_temporary_message(
|
||||||
|
connection,
|
||||||
|
client_id=client_id,
|
||||||
|
chat_id=cid,
|
||||||
|
content=content,
|
||||||
|
turn_id=turn_id,
|
||||||
|
envelope=envelope,
|
||||||
|
rejection_fields=rejection_fields,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
raw_media = envelope.get("media")
|
raw_media = envelope.get("media")
|
||||||
media_paths: list[str] = []
|
media_paths: list[str] = []
|
||||||
@@ -849,6 +937,103 @@ class WebSocketChannel(BaseChannel):
|
|||||||
return
|
return
|
||||||
await self._send_event(connection, "error", detail=f"unknown type: {t!r}")
|
await self._send_event(connection, "error", detail=f"unknown type: {t!r}")
|
||||||
|
|
||||||
|
async def _dispatch_temporary_message(
|
||||||
|
self,
|
||||||
|
connection: ServerConnection,
|
||||||
|
*,
|
||||||
|
client_id: str,
|
||||||
|
chat_id: str,
|
||||||
|
content: str,
|
||||||
|
turn_id: str | None,
|
||||||
|
envelope: dict[str, Any],
|
||||||
|
rejection_fields: dict[str, str],
|
||||||
|
) -> None:
|
||||||
|
"""Admit a WebUI-only message without durable or local-agent capabilities."""
|
||||||
|
if connection not in self._webui_connections:
|
||||||
|
await self._send_event(
|
||||||
|
connection,
|
||||||
|
"error",
|
||||||
|
detail="temporary_chat_unavailable",
|
||||||
|
**rejection_fields,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
forbidden = (
|
||||||
|
"media",
|
||||||
|
"cli_apps",
|
||||||
|
"mcp_presets",
|
||||||
|
"quoted_context",
|
||||||
|
"workspace_scope",
|
||||||
|
)
|
||||||
|
if any(field in envelope for field in forbidden):
|
||||||
|
await self._send_event(
|
||||||
|
connection,
|
||||||
|
"error",
|
||||||
|
detail="temporary_chat_capability_rejected",
|
||||||
|
**rejection_fields,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if not content.strip():
|
||||||
|
await self._send_event(
|
||||||
|
connection,
|
||||||
|
"error",
|
||||||
|
detail="missing content",
|
||||||
|
**rejection_fields,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
command = content.strip().partition(" ")[0].lower()
|
||||||
|
if command.startswith("/") and command not in _TEMPORARY_COMMANDS:
|
||||||
|
await self._send_event(
|
||||||
|
connection,
|
||||||
|
"error",
|
||||||
|
detail="temporary_chat_command_rejected",
|
||||||
|
**rejection_fields,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
session_key = self._temporary_chats.claim(connection, chat_id)
|
||||||
|
except TemporaryChatLifecycleError as exc:
|
||||||
|
await self._send_event(
|
||||||
|
connection,
|
||||||
|
"error",
|
||||||
|
detail=exc.detail,
|
||||||
|
**rejection_fields,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
metadata: dict[str, Any] = {
|
||||||
|
"remote": getattr(connection, "remote_address", None),
|
||||||
|
"webui": True,
|
||||||
|
INBOUND_META_TRANSIENT_SESSION: True,
|
||||||
|
**self._transcripts.client_turn_metadata(turn_id),
|
||||||
|
}
|
||||||
|
queued_owner = None
|
||||||
|
if builtin_command_starts_agent_turn(content):
|
||||||
|
queued_owner = register_queued_websocket_turn_if_idle(chat_id, turn_id)
|
||||||
|
if queued_owner is not None:
|
||||||
|
metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY] = queued_owner
|
||||||
|
accepted = False
|
||||||
|
try:
|
||||||
|
await self._handle_message(
|
||||||
|
sender_id=client_id,
|
||||||
|
chat_id=chat_id,
|
||||||
|
content=content,
|
||||||
|
metadata=metadata,
|
||||||
|
session_key=session_key,
|
||||||
|
is_dm=False,
|
||||||
|
)
|
||||||
|
accepted = True
|
||||||
|
finally:
|
||||||
|
if not accepted and queued_owner is not None:
|
||||||
|
clear_websocket_turn_if_current(chat_id, queued_owner)
|
||||||
|
if turn_id:
|
||||||
|
await self._send_event(
|
||||||
|
connection,
|
||||||
|
"message_accepted",
|
||||||
|
chat_id=chat_id,
|
||||||
|
turn_id=turn_id,
|
||||||
|
)
|
||||||
|
|
||||||
async def _workspace_scope_or_error(
|
async def _workspace_scope_or_error(
|
||||||
self,
|
self,
|
||||||
connection: ServerConnection,
|
connection: ServerConnection,
|
||||||
@@ -889,6 +1074,8 @@ class WebSocketChannel(BaseChannel):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.warning("server task error during shutdown: {}", e)
|
self.logger.warning("server task error during shutdown: {}", e)
|
||||||
self._server_task = None
|
self._server_task = None
|
||||||
|
for connection in tuple(self._conn_chats):
|
||||||
|
await self._temporary_chats.discard_owner(connection)
|
||||||
self._subs.clear()
|
self._subs.clear()
|
||||||
self._conn_chats.clear()
|
self._conn_chats.clear()
|
||||||
self._conn_default.clear()
|
self._conn_default.clear()
|
||||||
@@ -906,7 +1093,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
try:
|
try:
|
||||||
await connection.send(raw)
|
await connection.send(raw)
|
||||||
except ConnectionClosed:
|
except ConnectionClosed:
|
||||||
self._cleanup_connection(connection)
|
await self._cleanup_connection(connection)
|
||||||
self.logger.warning("connection gone{}", label)
|
self.logger.warning("connection gone{}", label)
|
||||||
except Exception:
|
except Exception:
|
||||||
self.logger.exception("send failed{}", label)
|
self.logger.exception("send failed{}", label)
|
||||||
@@ -923,6 +1110,8 @@ class WebSocketChannel(BaseChannel):
|
|||||||
transcript_overrides: dict[str, Any] | None = None,
|
transcript_overrides: dict[str, Any] | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Persist one canonical turn event and retain unsafe owners on failure."""
|
"""Persist one canonical turn event and retain unsafe owners on failure."""
|
||||||
|
if _is_temporary_chat_id(chat_id):
|
||||||
|
return True
|
||||||
persisted = self._transcripts.prepare_and_append(
|
persisted = self._transcripts.prepare_and_append(
|
||||||
chat_id,
|
chat_id,
|
||||||
event,
|
event,
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""Connection-owned lifecycle for WebUI Temporary Chat sessions."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
|
||||||
|
from websockets.asyncio.server import ServerConnection
|
||||||
|
|
||||||
|
from nanobot.session.manager import SessionManager
|
||||||
|
from nanobot.session.webui_turns import clear_websocket_turns
|
||||||
|
|
||||||
|
|
||||||
|
class TemporaryChatLifecycleError(RuntimeError):
|
||||||
|
"""A stable WebSocket protocol error raised by the temporary-chat lifecycle."""
|
||||||
|
|
||||||
|
def __init__(self, detail: str) -> None:
|
||||||
|
self.detail = detail
|
||||||
|
super().__init__(detail)
|
||||||
|
|
||||||
|
|
||||||
|
class TemporaryChatLifecycle:
|
||||||
|
"""Own temporary session identity, cancellation, and cleanup ordering."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
sessions: SessionManager | None,
|
||||||
|
cancel_active_turn: Callable[[str], Awaitable[int]] | None,
|
||||||
|
attach: Callable[[ServerConnection, str], None],
|
||||||
|
detach: Callable[[ServerConnection, str], None],
|
||||||
|
clear_stream_buffers: Callable[[str], None],
|
||||||
|
) -> None:
|
||||||
|
self._sessions = sessions
|
||||||
|
self._cancel_active_turn = cancel_active_turn
|
||||||
|
self._attach = attach
|
||||||
|
self._detach = detach
|
||||||
|
self._clear_stream_buffers = clear_stream_buffers
|
||||||
|
self._owners: dict[str, ServerConnection] = {}
|
||||||
|
|
||||||
|
def claim(self, owner: ServerConnection, chat_id: str) -> str:
|
||||||
|
"""Claim *chat_id* for *owner* and return its in-memory session key."""
|
||||||
|
if self._sessions is None or self._cancel_active_turn is None:
|
||||||
|
raise TemporaryChatLifecycleError("temporary_chat_unavailable")
|
||||||
|
current = self._owners.get(chat_id)
|
||||||
|
if current is not None and current is not owner:
|
||||||
|
raise TemporaryChatLifecycleError("temporary_chat_not_owned")
|
||||||
|
|
||||||
|
session_key = f"websocket:{chat_id}"
|
||||||
|
self._sessions.get_or_create_transient(session_key)
|
||||||
|
self._owners[chat_id] = owner
|
||||||
|
self._attach(owner, chat_id)
|
||||||
|
return session_key
|
||||||
|
|
||||||
|
async def discard(self, owner: ServerConnection, chat_id: str) -> None:
|
||||||
|
"""Discard an owned chat; an unused chat is already discarded."""
|
||||||
|
current = self._owners.get(chat_id)
|
||||||
|
if current is None:
|
||||||
|
return
|
||||||
|
if current is not owner:
|
||||||
|
raise TemporaryChatLifecycleError("temporary_chat_not_owned")
|
||||||
|
await self._discard_owned(owner, chat_id)
|
||||||
|
|
||||||
|
async def discard_owner(self, owner: ServerConnection) -> None:
|
||||||
|
"""Discard every temporary chat held by a disconnected owner."""
|
||||||
|
chat_ids = (
|
||||||
|
chat_id
|
||||||
|
for chat_id, current in self._owners.items()
|
||||||
|
if current is owner
|
||||||
|
)
|
||||||
|
for chat_id in tuple(chat_ids):
|
||||||
|
await self._discard_owned(owner, chat_id)
|
||||||
|
|
||||||
|
async def _discard_owned(self, owner: ServerConnection, chat_id: str) -> None:
|
||||||
|
self._owners.pop(chat_id, None)
|
||||||
|
self._detach(owner, chat_id)
|
||||||
|
|
||||||
|
session_key = f"websocket:{chat_id}"
|
||||||
|
assert self._sessions is not None
|
||||||
|
assert self._cancel_active_turn is not None
|
||||||
|
self._sessions.discard_transient(session_key)
|
||||||
|
try:
|
||||||
|
await self._cancel_active_turn(session_key)
|
||||||
|
finally:
|
||||||
|
clear_websocket_turns(chat_id)
|
||||||
|
self._clear_stream_buffers(chat_id)
|
||||||
@@ -111,6 +111,7 @@ def _basic_handler(bus: Any, **kw: Any) -> GatewayServices:
|
|||||||
runtime_model_name=None,
|
runtime_model_name=None,
|
||||||
runtime_surface=kw.get("runtime_surface", "browser"),
|
runtime_surface=kw.get("runtime_surface", "browser"),
|
||||||
runtime_capabilities_overrides=kw.get("runtime_capabilities_overrides"),
|
runtime_capabilities_overrides=kw.get("runtime_capabilities_overrides"),
|
||||||
|
cancel_active_turn=kw.get("cancel_active_turn"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -190,6 +191,182 @@ def isolate_webui_workspace_state(tmp_path, monkeypatch) -> None:
|
|||||||
wth._WEBSOCKET_TURN_OWNERS.clear()
|
wth._WEBSOCKET_TURN_OWNERS.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_temporary_message_registers_in_memory_session(bus, tmp_path) -> None:
|
||||||
|
sessions = SessionManager(tmp_path)
|
||||||
|
cancel = AsyncMock(return_value=0)
|
||||||
|
channel = WebSocketChannel(
|
||||||
|
{"enabled": True, "allowFrom": ["*"]},
|
||||||
|
bus,
|
||||||
|
gateway=_basic_handler(
|
||||||
|
bus,
|
||||||
|
session_manager=sessions,
|
||||||
|
cancel_active_turn=cancel,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connection = AsyncMock()
|
||||||
|
connection.remote_address = None
|
||||||
|
channel._webui_connections.add(connection)
|
||||||
|
chat_id = "temporary-test"
|
||||||
|
|
||||||
|
await channel._dispatch_envelope(
|
||||||
|
connection,
|
||||||
|
"client",
|
||||||
|
{
|
||||||
|
"type": "message",
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"content": "hello",
|
||||||
|
"turn_id": "turn-1",
|
||||||
|
"temporary": True,
|
||||||
|
"webui": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
inbound = bus.publish_inbound.await_args.args[0]
|
||||||
|
assert inbound.session_key == f"websocket:{chat_id}"
|
||||||
|
assert inbound.transient_session is True
|
||||||
|
assert sessions.is_transient_active(inbound.session_key) is True
|
||||||
|
assert sessions.get_cached(inbound.session_key).transient is True
|
||||||
|
assert read_transcript_lines(inbound.session_key) == []
|
||||||
|
assert json.loads(connection.send.await_args.args[0])["event"] == "message_accepted"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"envelope",
|
||||||
|
[
|
||||||
|
{"type": "attach", "chat_id": "temporary-test"},
|
||||||
|
{
|
||||||
|
"type": "set_workspace_scope",
|
||||||
|
"chat_id": "temporary-test",
|
||||||
|
"workspace_scope": {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "message",
|
||||||
|
"chat_id": "temporary-test",
|
||||||
|
"content": "hello",
|
||||||
|
"temporary": True,
|
||||||
|
"media": [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "message",
|
||||||
|
"chat_id": "temporary-test",
|
||||||
|
"content": "/history",
|
||||||
|
"temporary": True,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_temporary_chat_rejects_persistent_capabilities(
|
||||||
|
bus,
|
||||||
|
tmp_path,
|
||||||
|
envelope,
|
||||||
|
) -> None:
|
||||||
|
channel = WebSocketChannel(
|
||||||
|
{"enabled": True, "allowFrom": ["*"]},
|
||||||
|
bus,
|
||||||
|
gateway=_basic_handler(
|
||||||
|
bus,
|
||||||
|
session_manager=SessionManager(tmp_path),
|
||||||
|
cancel_active_turn=AsyncMock(return_value=0),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connection = AsyncMock()
|
||||||
|
connection.remote_address = None
|
||||||
|
channel._webui_connections.add(connection)
|
||||||
|
|
||||||
|
await channel._dispatch_envelope(connection, "client", envelope)
|
||||||
|
|
||||||
|
payload = json.loads(connection.send.await_args.args[0])
|
||||||
|
assert payload["event"] == "error"
|
||||||
|
bus.publish_inbound.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_discard_temporary_chat_cancels_then_forgets_session(bus, tmp_path) -> None:
|
||||||
|
sessions = SessionManager(tmp_path)
|
||||||
|
cancel = AsyncMock(return_value=1)
|
||||||
|
channel = WebSocketChannel(
|
||||||
|
{"enabled": True, "allowFrom": ["*"]},
|
||||||
|
bus,
|
||||||
|
gateway=_basic_handler(
|
||||||
|
bus,
|
||||||
|
session_manager=sessions,
|
||||||
|
cancel_active_turn=cancel,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connection = AsyncMock()
|
||||||
|
connection.remote_address = None
|
||||||
|
channel._webui_connections.add(connection)
|
||||||
|
chat_id = "temporary-test"
|
||||||
|
session_key = channel._temporary_chats.claim(connection, chat_id)
|
||||||
|
sessions.get_cached(session_key).add_message("user", "private")
|
||||||
|
|
||||||
|
await channel._dispatch_envelope(
|
||||||
|
connection,
|
||||||
|
"client",
|
||||||
|
{"type": "discard_temporary_chat", "chat_id": chat_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
cancel.assert_awaited_once_with(session_key)
|
||||||
|
assert sessions.get_cached(session_key) is None
|
||||||
|
assert chat_id not in channel._subs
|
||||||
|
assert json.loads(connection.send.await_args.args[0]) == {
|
||||||
|
"event": "temporary_chat_discarded",
|
||||||
|
"chat_id": chat_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_discard_unused_temporary_chat_is_idempotent(bus, tmp_path) -> None:
|
||||||
|
cancel = AsyncMock(return_value=0)
|
||||||
|
channel = WebSocketChannel(
|
||||||
|
{"enabled": True, "allowFrom": ["*"]},
|
||||||
|
bus,
|
||||||
|
gateway=_basic_handler(
|
||||||
|
bus,
|
||||||
|
session_manager=SessionManager(tmp_path),
|
||||||
|
cancel_active_turn=cancel,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connection = AsyncMock()
|
||||||
|
|
||||||
|
await channel._dispatch_envelope(
|
||||||
|
connection,
|
||||||
|
"client",
|
||||||
|
{"type": "discard_temporary_chat", "chat_id": "temporary-unused"},
|
||||||
|
)
|
||||||
|
|
||||||
|
cancel.assert_not_awaited()
|
||||||
|
assert json.loads(connection.send.await_args.args[0]) == {
|
||||||
|
"event": "temporary_chat_discarded",
|
||||||
|
"chat_id": "temporary-unused",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_disconnect_discards_owned_temporary_chat(bus, tmp_path) -> None:
|
||||||
|
sessions = SessionManager(tmp_path)
|
||||||
|
cancel = AsyncMock(return_value=1)
|
||||||
|
channel = WebSocketChannel(
|
||||||
|
{"enabled": True, "allowFrom": ["*"]},
|
||||||
|
bus,
|
||||||
|
gateway=_basic_handler(
|
||||||
|
bus,
|
||||||
|
session_manager=sessions,
|
||||||
|
cancel_active_turn=cancel,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connection = AsyncMock()
|
||||||
|
chat_id = "temporary-disconnect"
|
||||||
|
session_key = channel._temporary_chats.claim(connection, chat_id)
|
||||||
|
|
||||||
|
await channel._cleanup_connection(connection)
|
||||||
|
|
||||||
|
cancel.assert_awaited_once_with(session_key)
|
||||||
|
assert sessions.get_cached(session_key) is None
|
||||||
|
assert chat_id not in channel._subs
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_send_session_updated_broadcasts_to_other_webui_connections(bus) -> None:
|
async def test_send_session_updated_broadcasts_to_other_webui_connections(bus) -> None:
|
||||||
class Conn:
|
class Conn:
|
||||||
@@ -2583,8 +2760,6 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
assert body["agent"]["model_preset"] == "default"
|
assert body["agent"]["model_preset"] == "default"
|
||||||
assert body["agent"]["max_tokens"] == 8192
|
assert body["agent"]["max_tokens"] == 8192
|
||||||
assert body["agent"]["timezone"] == "UTC"
|
assert body["agent"]["timezone"] == "UTC"
|
||||||
assert "bot_name" not in body["agent"]
|
|
||||||
assert "bot_icon" not in body["agent"]
|
|
||||||
assert body["agent"]["tool_hint_max_length"] == 40
|
assert body["agent"]["tool_hint_max_length"] == 40
|
||||||
presets = {preset["name"]: preset for preset in body["model_presets"]}
|
presets = {preset["name"]: preset for preset in body["model_presets"]}
|
||||||
assert presets["default"]["active"] is True
|
assert presets["default"]["active"] is True
|
||||||
@@ -2876,8 +3051,8 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
assert saved.model_presets["fast-writing"].model == "openai/gpt-5.5"
|
assert saved.model_presets["fast-writing"].model == "openai/gpt-5.5"
|
||||||
assert saved.model_presets["fast-writing"].provider == "openai"
|
assert saved.model_presets["fast-writing"].provider == "openai"
|
||||||
assert saved.agents.defaults.timezone == "Asia/Shanghai"
|
assert saved.agents.defaults.timezone == "Asia/Shanghai"
|
||||||
assert saved.agents.defaults.bot_name == "nanobot"
|
assert saved.agents.defaults.bot_name == "Nano"
|
||||||
assert saved.agents.defaults.bot_icon == "🐈"
|
assert saved.agents.defaults.bot_icon == "N"
|
||||||
assert saved.agents.defaults.tool_hint_max_length == 120
|
assert saved.agents.defaults.tool_hint_max_length == 120
|
||||||
assert saved.providers.openrouter.api_key == "sk-or-next"
|
assert saved.providers.openrouter.api_key == "sk-or-next"
|
||||||
assert saved.providers.openrouter.api_base == "https://openrouter.ai/api/v1"
|
assert saved.providers.openrouter.api_base == "https://openrouter.ai/api/v1"
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ from nanobot.runtime_context import (
|
|||||||
RuntimeContextBlock,
|
RuntimeContextBlock,
|
||||||
append_runtime_context,
|
append_runtime_context,
|
||||||
)
|
)
|
||||||
from nanobot.security.workspace_access import WORKSPACE_SCOPE_METADATA_KEY
|
|
||||||
from nanobot.session.keys import UNIFIED_SESSION_KEY
|
from nanobot.session.keys import UNIFIED_SESSION_KEY
|
||||||
from nanobot.session.manager import Session, SessionManager
|
from nanobot.session.manager import Session, SessionManager
|
||||||
from nanobot.triggers.local_store import LocalTriggerStore
|
from nanobot.triggers.local_store import LocalTriggerStore
|
||||||
@@ -428,7 +427,6 @@ async def test_session_automations_route_lists_local_triggers(
|
|||||||
chat_id="abc",
|
chat_id="abc",
|
||||||
session_key="websocket:abc",
|
session_key="websocket:abc",
|
||||||
)
|
)
|
||||||
trigger_store.enqueue(trigger.id, "Review PR #4591")
|
|
||||||
channel = _ch(
|
channel = _ch(
|
||||||
bus,
|
bus,
|
||||||
session_manager=_seed_session(tmp_path, key="websocket:abc"),
|
session_manager=_seed_session(tmp_path, key="websocket:abc"),
|
||||||
@@ -455,7 +453,6 @@ async def test_session_automations_route_lists_local_triggers(
|
|||||||
assert job["kind"] == "local_trigger"
|
assert job["kind"] == "local_trigger"
|
||||||
assert job["schedule"]["kind"] == "local"
|
assert job["schedule"]["kind"] == "local"
|
||||||
assert job["payload"]["kind"] == "local_trigger"
|
assert job["payload"]["kind"] == "local_trigger"
|
||||||
assert job["payload"]["message"] == "Review PR #4591"
|
|
||||||
assert job["payload"]["command"] == f'nanobot trigger {trigger.id} "message"'
|
assert job["payload"]["command"] == f'nanobot trigger {trigger.id} "message"'
|
||||||
assert job["state"]["pending"] is True
|
assert job["state"]["pending"] is True
|
||||||
finally:
|
finally:
|
||||||
@@ -2204,7 +2201,7 @@ async def test_mcp_presets_routes_require_token_and_return_payload(
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_sessions_list_only_returns_websocket_sessions_by_default(
|
async def test_sessions_list_only_returns_websocket_sessions_by_default(
|
||||||
bus: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
bus: MagicMock, tmp_path: Path
|
||||||
) -> None:
|
) -> None:
|
||||||
# Seed a realistic multi-channel disk state: CLI, Slack, Lark and
|
# Seed a realistic multi-channel disk state: CLI, Slack, Lark and
|
||||||
# websocket sessions all live in the same ``sessions/`` directory.
|
# websocket sessions all live in the same ``sessions/`` directory.
|
||||||
@@ -2218,20 +2215,7 @@ async def test_sessions_list_only_returns_websocket_sessions_by_default(
|
|||||||
"websocket:beta",
|
"websocket:beta",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
project = tmp_path / "project"
|
channel = _ch(bus, session_manager=sm, port=29906)
|
||||||
project.mkdir()
|
|
||||||
scoped = sm.get_or_create("websocket:beta")
|
|
||||||
scoped.metadata[WORKSPACE_SCOPE_METADATA_KEY] = {
|
|
||||||
"project_path": str(project),
|
|
||||||
"access_mode": "restricted",
|
|
||||||
}
|
|
||||||
sm.save(scoped)
|
|
||||||
|
|
||||||
def fail_metadata_read(_key: str) -> None:
|
|
||||||
raise AssertionError("the session list must use its own index metadata")
|
|
||||||
|
|
||||||
monkeypatch.setattr(sm, "read_session_metadata", fail_metadata_read)
|
|
||||||
channel = _ch(bus, session_manager=sm, workspace_path=tmp_path, port=29906)
|
|
||||||
server_task = asyncio.create_task(channel.start())
|
server_task = asyncio.create_task(channel.start())
|
||||||
try:
|
try:
|
||||||
token = channel.gateway.tokens.issue_api_token(300)
|
token = channel.gateway.tokens.issue_api_token(300)
|
||||||
@@ -2241,17 +2225,10 @@ async def test_sessions_list_only_returns_websocket_sessions_by_default(
|
|||||||
"http://127.0.0.1:29906/api/sessions", headers=auth
|
"http://127.0.0.1:29906/api/sessions", headers=auth
|
||||||
)
|
)
|
||||||
assert listing.status_code == 200
|
assert listing.status_code == 200
|
||||||
sessions = listing.json()["sessions"]
|
keys = {s["key"] for s in listing.json()["sessions"]}
|
||||||
keys = {s["key"] for s in sessions}
|
|
||||||
# Only websocket-channel sessions are part of the webui surface; CLI /
|
# Only websocket-channel sessions are part of the webui surface; CLI /
|
||||||
# Slack / Lark rows would be non-resumable from the browser.
|
# Slack / Lark rows would be non-resumable from the browser.
|
||||||
assert keys == {"websocket:alpha", "websocket:beta"}
|
assert keys == {"websocket:alpha", "websocket:beta"}
|
||||||
rows = {row["key"]: row for row in sessions}
|
|
||||||
assert rows["websocket:beta"]["workspace_scope"]["project_path"] == str(
|
|
||||||
project.resolve()
|
|
||||||
)
|
|
||||||
assert rows["websocket:beta"]["workspace_scope"]["access_mode"] == "restricted"
|
|
||||||
assert all(not any(key.startswith("_") for key in row) for row in sessions)
|
|
||||||
finally:
|
finally:
|
||||||
await channel.stop()
|
await channel.stop()
|
||||||
await server_task
|
await server_task
|
||||||
@@ -2617,7 +2594,6 @@ async def test_webui_automations_route_manages_local_triggers(
|
|||||||
by_id = {job["id"]: job for job in listed.json()["jobs"]}
|
by_id = {job["id"]: job for job in listed.json()["jobs"]}
|
||||||
assert by_id[trigger.id]["kind"] == "local_trigger"
|
assert by_id[trigger.id]["kind"] == "local_trigger"
|
||||||
assert by_id[trigger.id]["state"]["pending"] is True
|
assert by_id[trigger.id]["state"]["pending"] is True
|
||||||
assert by_id[trigger.id]["payload"]["message"] == "Review queued PR"
|
|
||||||
assert by_id[trigger.id]["trigger"]["command"] == f'nanobot trigger {trigger.id} "message"'
|
assert by_id[trigger.id]["trigger"]["command"] == f'nanobot trigger {trigger.id} "message"'
|
||||||
|
|
||||||
disabled = await _http_get(
|
disabled = await _http_get(
|
||||||
@@ -2980,139 +2956,6 @@ async def test_webui_thread_resigns_assistant_media_urls(
|
|||||||
await server_task
|
await server_task
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_sessions_list_negotiates_gzip_across_repeated_headers(
|
|
||||||
bus: MagicMock, tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
sm = _seed_many(tmp_path, [f"websocket:gzip-{index:03d}" for index in range(80)])
|
|
||||||
port = _free_port()
|
|
||||||
channel = _ch(bus, session_manager=sm, workspace_path=tmp_path, port=port)
|
|
||||||
server_task = asyncio.create_task(channel.start())
|
|
||||||
try:
|
|
||||||
token = channel.gateway.tokens.issue_api_token(300)
|
|
||||||
response = await _http_get(
|
|
||||||
f"http://127.0.0.1:{port}/api/sessions",
|
|
||||||
headers=[
|
|
||||||
("Authorization", f"Bearer {token}"),
|
|
||||||
("Accept-Encoding", "identity;q=0"),
|
|
||||||
("Accept-Encoding", "gzip"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.headers["Content-Encoding"] == "gzip"
|
|
||||||
assert response.headers["Vary"] == "Accept-Encoding"
|
|
||||||
assert len(response.json()["sessions"]) == 80
|
|
||||||
finally:
|
|
||||||
await channel.stop()
|
|
||||||
await server_task
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_webui_thread_complete_transcript_skips_session_history_read(
|
|
||||||
bus: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
||||||
) -> None:
|
|
||||||
from nanobot.webui.transcript import append_transcript_object
|
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
|
||||||
key = "websocket:fast-thread"
|
|
||||||
sm = _seed_session(tmp_path, key=key)
|
|
||||||
for event in (
|
|
||||||
{"event": "user", "chat_id": "fast-thread", "text": "hi"},
|
|
||||||
{"event": "message", "chat_id": "fast-thread", "text": "hello back"},
|
|
||||||
{"event": "turn_end", "chat_id": "fast-thread"},
|
|
||||||
):
|
|
||||||
append_transcript_object(key, event)
|
|
||||||
|
|
||||||
read_session_file = MagicMock(
|
|
||||||
side_effect=AssertionError("complete transcripts must not read canonical history")
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(sm, "read_session_file", read_session_file)
|
|
||||||
port = _free_port()
|
|
||||||
channel = _ch(
|
|
||||||
bus,
|
|
||||||
session_manager=sm,
|
|
||||||
workspace_path=tmp_path,
|
|
||||||
port=port,
|
|
||||||
)
|
|
||||||
server_task = asyncio.create_task(channel.start())
|
|
||||||
try:
|
|
||||||
token = channel.gateway.tokens.issue_api_token(300)
|
|
||||||
response = await _http_get(
|
|
||||||
f"http://127.0.0.1:{port}/api/sessions/"
|
|
||||||
"websocket%3Afast-thread/webui-thread?limit=160&direction=latest",
|
|
||||||
headers={"Authorization": f"Bearer {token}"},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert [message["content"] for message in response.json()["messages"]] == [
|
|
||||||
"hi",
|
|
||||||
"hello back",
|
|
||||||
]
|
|
||||||
read_session_file.assert_not_called()
|
|
||||||
finally:
|
|
||||||
await channel.stop()
|
|
||||||
await server_task
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_webui_thread_negotiates_gzip_for_large_payloads(
|
|
||||||
bus: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
||||||
) -> None:
|
|
||||||
from nanobot.webui.transcript import append_transcript_object
|
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
|
||||||
sm = SessionManager(tmp_path)
|
|
||||||
append_transcript_object(
|
|
||||||
"websocket:gzip-thread",
|
|
||||||
{
|
|
||||||
"event": "user",
|
|
||||||
"chat_id": "gzip-thread",
|
|
||||||
"text": "compress me " * 1_000,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
port = _free_port()
|
|
||||||
channel = _ch(bus, session_manager=sm, workspace_path=tmp_path, port=port)
|
|
||||||
server_task = asyncio.create_task(channel.start())
|
|
||||||
try:
|
|
||||||
token = channel.gateway.tokens.issue_api_token(300)
|
|
||||||
url = (
|
|
||||||
f"http://127.0.0.1:{port}/api/sessions/"
|
|
||||||
"websocket%3Agzip-thread/webui-thread?limit=80&direction=latest"
|
|
||||||
)
|
|
||||||
compressed = await _http_get(
|
|
||||||
url,
|
|
||||||
headers={
|
|
||||||
"Authorization": f"Bearer {token}",
|
|
||||||
"Accept-Encoding": "br, gzip",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert compressed.status_code == 200
|
|
||||||
assert compressed.headers["Content-Encoding"] == "gzip"
|
|
||||||
assert compressed.headers["Vary"] == "Accept-Encoding"
|
|
||||||
assert int(compressed.headers["Content-Length"]) < len(compressed.content)
|
|
||||||
assert compressed.json()["messages"][0]["content"].startswith("compress me")
|
|
||||||
|
|
||||||
identity = await _http_get(
|
|
||||||
url,
|
|
||||||
headers={
|
|
||||||
"Authorization": f"Bearer {token}",
|
|
||||||
"Accept-Encoding": "gzip;q=0, br",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert identity.status_code == 200
|
|
||||||
assert "Content-Encoding" not in identity.headers
|
|
||||||
assert identity.json() == compressed.json()
|
|
||||||
|
|
||||||
unauthorized = await _http_get(url, headers={"Accept-Encoding": "gzip"})
|
|
||||||
assert unauthorized.status_code == 401
|
|
||||||
assert "Content-Encoding" not in unauthorized.headers
|
|
||||||
finally:
|
|
||||||
await channel.stop()
|
|
||||||
await server_task
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_session_routes_reject_non_websocket_keys(
|
async def test_session_routes_reject_non_websocket_keys(
|
||||||
bus: MagicMock, tmp_path: Path
|
bus: MagicMock, tmp_path: Path
|
||||||
|
|||||||
@@ -248,7 +248,7 @@ class WsTestClient:
|
|||||||
|
|
||||||
async def http_get(
|
async def http_get(
|
||||||
url: str,
|
url: str,
|
||||||
headers: dict[str, str] | list[tuple[str, str]] | None = None,
|
headers: dict[str, str] | None = None,
|
||||||
) -> httpx.Response:
|
) -> httpx.Response:
|
||||||
"""GET a local test server without loading an unused TLS trust store."""
|
"""GET a local test server without loading an unused TLS trust store."""
|
||||||
request = httpx.Request("GET", url, headers=headers or {})
|
request = httpx.Request("GET", url, headers=headers or {})
|
||||||
|
|||||||
@@ -201,58 +201,6 @@ def _print_gateway_health_endpoint(host: str, port: int) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _close_gateway_runtime(
|
|
||||||
agent: AgentLoop,
|
|
||||||
channels: Any,
|
|
||||||
tasks: list[asyncio.Task[Any]],
|
|
||||||
runtime_tasks: asyncio.Future[list[Any]] | None,
|
|
||||||
*,
|
|
||||||
task_wait_timeout: float = 15.0,
|
|
||||||
close_timeout: float = 15.0,
|
|
||||||
) -> None:
|
|
||||||
"""Cancel runtime tasks, then deterministically close agent resources.
|
|
||||||
|
|
||||||
Order matters: runtime tasks (including the agent loop and any in-flight
|
|
||||||
turn) are cancelled and awaited -- bounded -- before exec sessions,
|
|
||||||
subagents, and MCP servers are torn down, so no active turn is using a
|
|
||||||
shared resource when it closes. The final close is bounded and idempotent:
|
|
||||||
the agent loop's own finally also calls ``close_mcp()``, so this runs again
|
|
||||||
as a no-op when that path already completed, and as the guaranteed final
|
|
||||||
close when it was skipped or cut short (which previously left asyncio
|
|
||||||
subprocess transports alive past ``loop.close()``, producing
|
|
||||||
"RuntimeError: Event loop is closed" noise and potentially orphaned
|
|
||||||
processes at interpreter exit).
|
|
||||||
"""
|
|
||||||
# Some SDKs swallow task cancellation while attempting to reconnect.
|
|
||||||
# Close channel transports before waiting for their runners to exit.
|
|
||||||
await channels.stop_all()
|
|
||||||
for task in tasks:
|
|
||||||
if not task.done():
|
|
||||||
task.cancel()
|
|
||||||
pending: set[asyncio.Task[Any]] = set()
|
|
||||||
if tasks:
|
|
||||||
# Bounded: a coroutine that swallows cancellation (e.g. an SDK reconnect
|
|
||||||
# loop) must not hold the stop open until systemd's timeout kills the
|
|
||||||
# cgroup. Anything still pending is abandoned and closed underneath.
|
|
||||||
_done, pending = await asyncio.wait(tasks, timeout=task_wait_timeout)
|
|
||||||
# A task can swallow the first cancellation while unwinding. Re-cancel
|
|
||||||
# timed-out tasks so an agent loop stuck draining background work reaches
|
|
||||||
# its resource-cleanup phase before the explicit final close below.
|
|
||||||
for task in pending:
|
|
||||||
task.cancel()
|
|
||||||
if runtime_tasks is not None and not runtime_tasks.done():
|
|
||||||
runtime_tasks.cancel()
|
|
||||||
try:
|
|
||||||
await asyncio.wait_for(agent.close_mcp(), timeout=close_timeout)
|
|
||||||
except BaseException as exc: # noqa: BLE001 - shutdown must proceed
|
|
||||||
logger.warning("Gateway shutdown: agent resource cleanup incomplete: {}", exc)
|
|
||||||
# Retrieving an already-finished gather prevents noisy unhandled exceptions,
|
|
||||||
# but never wait for it here: its children were bounded individually above.
|
|
||||||
if runtime_tasks is not None and runtime_tasks.done():
|
|
||||||
with suppress(asyncio.CancelledError, Exception):
|
|
||||||
await runtime_tasks
|
|
||||||
|
|
||||||
|
|
||||||
def _run_gateway(
|
def _run_gateway(
|
||||||
config: Config,
|
config: Config,
|
||||||
*,
|
*,
|
||||||
@@ -633,6 +581,7 @@ def _run_gateway(
|
|||||||
webui_runtime_model_name=_webui_runtime_model_name,
|
webui_runtime_model_name=_webui_runtime_model_name,
|
||||||
webui_cron_pending_job_ids=agent.pending_cron_job_ids_for_session,
|
webui_cron_pending_job_ids=agent.pending_cron_job_ids_for_session,
|
||||||
webui_local_trigger_pending_ids=agent.pending_local_trigger_ids_for_session,
|
webui_local_trigger_pending_ids=agent.pending_local_trigger_ids_for_session,
|
||||||
|
webui_cancel_active_turn=getattr(agent, "cancel_active_turn", None),
|
||||||
webui_static_dist=webui_static_dist,
|
webui_static_dist=webui_static_dist,
|
||||||
webui_runtime_surface=webui_runtime_surface,
|
webui_runtime_surface=webui_runtime_surface,
|
||||||
webui_runtime_capabilities=webui_runtime_capabilities,
|
webui_runtime_capabilities=webui_runtime_capabilities,
|
||||||
@@ -786,6 +735,7 @@ def _run_gateway(
|
|||||||
tasks: list[asyncio.Task[Any]] = []
|
tasks: list[asyncio.Task[Any]] = []
|
||||||
shutdown_task: asyncio.Task[Any] | None = None
|
shutdown_task: asyncio.Task[Any] | None = None
|
||||||
runtime_tasks: asyncio.Future[list[Any]] | None = None
|
runtime_tasks: asyncio.Future[list[Any]] | None = None
|
||||||
|
runtime_tasks_drained = False
|
||||||
shutdown_event = asyncio.Event()
|
shutdown_event = asyncio.Event()
|
||||||
cli_terminal._ensure_interactive_tty_mode()
|
cli_terminal._ensure_interactive_tty_mode()
|
||||||
restore_shutdown_handlers = _install_gateway_shutdown_handlers(
|
restore_shutdown_handlers = _install_gateway_shutdown_handlers(
|
||||||
@@ -837,6 +787,7 @@ def _run_gateway(
|
|||||||
return_when=asyncio.FIRST_COMPLETED,
|
return_when=asyncio.FIRST_COMPLETED,
|
||||||
)
|
)
|
||||||
if runtime_tasks in done:
|
if runtime_tasks in done:
|
||||||
|
runtime_tasks_drained = True
|
||||||
await runtime_tasks
|
await runtime_tasks
|
||||||
else:
|
else:
|
||||||
runtime_tasks.cancel()
|
runtime_tasks.cancel()
|
||||||
@@ -855,9 +806,17 @@ def _run_gateway(
|
|||||||
await shutdown_task
|
await shutdown_task
|
||||||
cron.stop()
|
cron.stop()
|
||||||
agent.stop()
|
agent.stop()
|
||||||
# Cancel runtime tasks first, then deterministically close
|
# Some SDKs swallow task cancellation while attempting to reconnect.
|
||||||
# exec/MCP resources while the event loop is still alive.
|
# Close channel transports before waiting for their runners to exit.
|
||||||
await _close_gateway_runtime(agent, channels, tasks, runtime_tasks)
|
await channels.stop_all()
|
||||||
|
for task in tasks:
|
||||||
|
if not task.done():
|
||||||
|
task.cancel()
|
||||||
|
if tasks:
|
||||||
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
if runtime_tasks is not None and not runtime_tasks_drained:
|
||||||
|
with suppress(asyncio.CancelledError, Exception):
|
||||||
|
await runtime_tasks
|
||||||
# Flush all cached sessions to durable storage before exit.
|
# Flush all cached sessions to durable storage before exit.
|
||||||
# This prevents data loss on filesystems with write-back
|
# This prevents data loss on filesystems with write-back
|
||||||
# caching (rclone VFS, NFS, FUSE mounts, etc.).
|
# caching (rclone VFS, NFS, FUSE mounts, etc.).
|
||||||
|
|||||||
@@ -203,16 +203,7 @@ async def cmd_stop(ctx: CommandContext) -> OutboundMessage:
|
|||||||
"""Cancel all active tasks and subagents for the session."""
|
"""Cancel all active tasks and subagents for the session."""
|
||||||
loop = ctx.loop
|
loop = ctx.loop
|
||||||
msg = ctx.msg
|
msg = ctx.msg
|
||||||
total = await loop._cancel_active_tasks(ctx.key) # pyright: ignore[reportPrivateUsage]
|
total = await loop.cancel_active_turn(ctx.key)
|
||||||
# Also drain pending queue to prevent mid-turn injection deadlock
|
|
||||||
pending = loop._pending_queues.pop(ctx.key, None) # pyright: ignore[reportPrivateUsage]
|
|
||||||
if pending is not None:
|
|
||||||
while not pending.empty():
|
|
||||||
try:
|
|
||||||
pending.get_nowait()
|
|
||||||
total += 1
|
|
||||||
except Exception:
|
|
||||||
break
|
|
||||||
content = f"Stopped {total} task(s)." if total else "No active task to stop."
|
content = f"Stopped {total} task(s)." if total else "No active task to stop."
|
||||||
return OutboundMessage(
|
return OutboundMessage(
|
||||||
channel=msg.channel, chat_id=msg.chat_id, content=content,
|
channel=msg.channel, chat_id=msg.chat_id, content=content,
|
||||||
@@ -301,7 +292,7 @@ async def cmd_status(ctx: CommandContext) -> OutboundMessage:
|
|||||||
async def cmd_new(ctx: CommandContext) -> OutboundMessage:
|
async def cmd_new(ctx: CommandContext) -> OutboundMessage:
|
||||||
"""Stop active task and start a fresh session."""
|
"""Stop active task and start a fresh session."""
|
||||||
loop = ctx.loop
|
loop = ctx.loop
|
||||||
await loop._cancel_active_tasks(ctx.key) # pyright: ignore[reportPrivateUsage]
|
await loop.cancel_active_turn(ctx.key)
|
||||||
session = ctx.session or loop.sessions.get_or_create(ctx.key)
|
session = ctx.session or loop.sessions.get_or_create(ctx.key)
|
||||||
snapshot = session.messages[session.last_consolidated:]
|
snapshot = session.messages[session.last_consolidated:]
|
||||||
runtime = None
|
runtime = None
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ class AgentDefaults(Base):
|
|||||||
validation_alias=AliasChoices("toolHintMaxLength"),
|
validation_alias=AliasChoices("toolHintMaxLength"),
|
||||||
serialization_alias="toolHintMaxLength",
|
serialization_alias="toolHintMaxLength",
|
||||||
) # Max characters for tool hint display (e.g. "$ cd …/project && npm test")
|
) # Max characters for tool hint display (e.g. "$ cd …/project && npm test")
|
||||||
reasoning_effort: str | None = None # low / medium / high / xhigh / max / adaptive / none — LLM thinking effort; None preserves the provider default
|
reasoning_effort: str | None = None # low / medium / high / adaptive / none — LLM thinking effort; None preserves the provider default
|
||||||
timezone: str = "UTC" # IANA timezone, e.g. "Asia/Shanghai", "America/New_York"
|
timezone: str = "UTC" # IANA timezone, e.g. "Asia/Shanghai", "America/New_York"
|
||||||
bot_name: str = "nanobot" # Display name shown in CLI prompts (e.g. "{name} is thinking...")
|
bot_name: str = "nanobot" # Display name shown in CLI prompts (e.g. "{name} is thinking...")
|
||||||
bot_icon: str = "🐈" # Short icon (emoji or text) shown next to the bot name in CLI; "" to omit
|
bot_icon: str = "🐈" # Short icon (emoji or text) shown next to the bot name in CLI; "" to omit
|
||||||
@@ -269,7 +269,6 @@ class ProvidersConfig(Base):
|
|||||||
ant_ling: ProviderConfig = Field(default_factory=ProviderConfig) # Ant Ling
|
ant_ling: ProviderConfig = Field(default_factory=ProviderConfig) # Ant Ling
|
||||||
aihubmix: ProviderConfig = Field(default_factory=ProviderConfig) # AiHubMix API gateway
|
aihubmix: ProviderConfig = Field(default_factory=ProviderConfig) # AiHubMix API gateway
|
||||||
siliconflow: ProviderConfig = Field(default_factory=ProviderConfig) # SiliconFlow (硅基流动)
|
siliconflow: ProviderConfig = Field(default_factory=ProviderConfig) # SiliconFlow (硅基流动)
|
||||||
edenai: ProviderConfig = Field(default_factory=ProviderConfig) # Eden AI API gateway
|
|
||||||
novita: ProviderConfig = Field(default_factory=ProviderConfig) # Novita AI
|
novita: ProviderConfig = Field(default_factory=ProviderConfig) # Novita AI
|
||||||
volcengine: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine (火山引擎)
|
volcengine: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine (火山引擎)
|
||||||
volcengine_coding_plan: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine Coding Plan
|
volcengine_coding_plan: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine Coding Plan
|
||||||
|
|||||||
+5
-14
@@ -75,22 +75,13 @@ def _validate_schedule_for_add(schedule: CronSchedule) -> None:
|
|||||||
if schedule.tz and schedule.kind != "cron":
|
if schedule.tz and schedule.kind != "cron":
|
||||||
raise ValueError("tz can only be used with cron schedules")
|
raise ValueError("tz can only be used with cron schedules")
|
||||||
|
|
||||||
if schedule.kind == "cron":
|
if schedule.kind == "cron" and schedule.tz:
|
||||||
if not schedule.expr or not schedule.expr.strip():
|
|
||||||
raise ValueError("cron schedule requires a non-empty 'expr'")
|
|
||||||
try:
|
try:
|
||||||
from croniter import croniter
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
croniter(schedule.expr)
|
ZoneInfo(schedule.tz)
|
||||||
except Exception as exc:
|
except Exception:
|
||||||
raise ValueError(f"invalid cron expression '{schedule.expr}': {exc}") from None
|
raise ValueError(f"unknown timezone '{schedule.tz}'") from None
|
||||||
if schedule.tz:
|
|
||||||
try:
|
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
ZoneInfo(schedule.tz)
|
|
||||||
except Exception:
|
|
||||||
raise ValueError(f"unknown timezone '{schedule.tz}'") from None
|
|
||||||
|
|
||||||
|
|
||||||
def _has_legacy_delivery_context(payload: CronPayload) -> bool:
|
def _has_legacy_delivery_context(payload: CronPayload) -> bool:
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -181,18 +179,13 @@ def extra_installed(extra: str, deps: list[str] | None) -> bool:
|
|||||||
return all(requirement_installed(dep, extra) for dep in deps)
|
return all(requirement_installed(dep, extra) for dep in deps)
|
||||||
|
|
||||||
|
|
||||||
def run_install_command(
|
def run_install_command(argv: list[str]) -> subprocess.CompletedProcess[str]:
|
||||||
argv: list[str],
|
|
||||||
*,
|
|
||||||
env: dict[str, str] | None = None,
|
|
||||||
) -> subprocess.CompletedProcess[str]:
|
|
||||||
try:
|
try:
|
||||||
return subprocess.run(
|
return subprocess.run(
|
||||||
argv,
|
argv,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=_INSTALL_TIMEOUT_SECONDS,
|
timeout=_INSTALL_TIMEOUT_SECONDS,
|
||||||
env=env,
|
|
||||||
)
|
)
|
||||||
except subprocess.TimeoutExpired as exc:
|
except subprocess.TimeoutExpired as exc:
|
||||||
stdout = exc.stdout.decode(errors="replace") if isinstance(exc.stdout, bytes) else exc.stdout
|
stdout = exc.stdout.decode(errors="replace") if isinstance(exc.stdout, bytes) else exc.stdout
|
||||||
@@ -241,20 +234,6 @@ def install_extra(
|
|||||||
failed_cmd = pip_cmd
|
failed_cmd = pip_cmd
|
||||||
failed_proc = proc
|
failed_proc = proc
|
||||||
if missing_pip(proc):
|
if missing_pip(proc):
|
||||||
if shutil.which("uv"):
|
|
||||||
uv_cmd = ["uv", "pip", "install", "--python", sys.executable, *install_args]
|
|
||||||
uv_env = os.environ.copy()
|
|
||||||
if index_url := os.environ.get("PIP_INDEX_URL", "").strip():
|
|
||||||
uv_env["UV_INDEX_URL"] = index_url
|
|
||||||
logger.info("pip missing while installing '{}'; running {}", extra, command_text(uv_cmd))
|
|
||||||
uv_proc = runner(uv_cmd, env=uv_env)
|
|
||||||
_log_completed_command(f"Optional feature '{extra}' uv install", uv_proc)
|
|
||||||
if uv_proc.returncode == 0:
|
|
||||||
importlib.invalidate_caches()
|
|
||||||
return InstallResult(True, label, pip_cmd)
|
|
||||||
output = (uv_proc.stderr or uv_proc.stdout or "").strip()
|
|
||||||
return InstallResult(False, label, pip_cmd, failed_cmd=uv_cmd, output=output)
|
|
||||||
|
|
||||||
ensure_cmd = [sys.executable, "-m", "ensurepip", "--upgrade"]
|
ensure_cmd = [sys.executable, "-m", "ensurepip", "--upgrade"]
|
||||||
logger.info("pip missing while installing '{}'; running {}", extra, command_text(ensure_cmd))
|
logger.info("pip missing while installing '{}'; running {}", extra, command_text(ensure_cmd))
|
||||||
ensure_proc = runner(ensure_cmd)
|
ensure_proc = runner(ensure_cmd)
|
||||||
|
|||||||
@@ -31,36 +31,6 @@ def _gen_tool_id() -> str:
|
|||||||
|
|
||||||
_VALID_TOOL_ID = re.compile(r"^[a-zA-Z0-9_-]+$")
|
_VALID_TOOL_ID = re.compile(r"^[a-zA-Z0-9_-]+$")
|
||||||
|
|
||||||
_CLAUDE_MODEL_VERSION = re.compile(
|
|
||||||
r"claude-(?P<family>[a-z]+)-(?P<major>\d+)"
|
|
||||||
r"(?:-(?P<minor>\d{1,2})(?=-|$))?"
|
|
||||||
)
|
|
||||||
_ADAPTIVE_ONLY_MIN_VERSIONS = {
|
|
||||||
"opus": (4, 7),
|
|
||||||
"sonnet": (5, 0),
|
|
||||||
"fable": (5, 0),
|
|
||||||
"mythos": (5, 0),
|
|
||||||
}
|
|
||||||
_THINKING_DISABLE_MIN_VERSIONS = {
|
|
||||||
"opus": (5, 0),
|
|
||||||
"sonnet": (5, 0),
|
|
||||||
}
|
|
||||||
_SAMPLING_DEPRECATED_MODELS = {"claude-mythos-preview"}
|
|
||||||
|
|
||||||
|
|
||||||
def _model_version_at_least(
|
|
||||||
model_name: str,
|
|
||||||
minimum_versions: dict[str, tuple[int, int]],
|
|
||||||
) -> bool:
|
|
||||||
match = _CLAUDE_MODEL_VERSION.search(model_name.lower())
|
|
||||||
if match is None:
|
|
||||||
return False
|
|
||||||
minimum = minimum_versions.get(match.group("family"))
|
|
||||||
if minimum is None:
|
|
||||||
return False
|
|
||||||
version = (int(match.group("major")), int(match.group("minor") or 0))
|
|
||||||
return version >= minimum
|
|
||||||
|
|
||||||
|
|
||||||
def _sanitize_tool_id(tid: str) -> str:
|
def _sanitize_tool_id(tid: str) -> str:
|
||||||
"""Ensure tool_use/tool_result IDs match Anthropic's required pattern.
|
"""Ensure tool_use/tool_result IDs match Anthropic's required pattern.
|
||||||
@@ -592,13 +562,13 @@ class AnthropicProvider(LLMProvider):
|
|||||||
)
|
)
|
||||||
|
|
||||||
max_tokens = max(1, max_tokens)
|
max_tokens = max(1, max_tokens)
|
||||||
reasoning_effort_lower = reasoning_effort.lower() if reasoning_effort else None
|
thinking_enabled = bool(reasoning_effort) and reasoning_effort.lower() != "none"
|
||||||
thinking_enabled = reasoning_effort_lower not in (None, "", "none")
|
|
||||||
adaptive_only = _model_version_at_least(model_name, _ADAPTIVE_ONLY_MIN_VERSIONS)
|
# Several Anthropic models (opus-4-7, opus-4-8, sonnet-5, fable) deprecated the
|
||||||
# Mythos Preview rejects sampling parameters but still accepts manual
|
# `temperature` parameter — the API returns 400 if it is present.
|
||||||
# thinking budgets, so it is not part of the adaptive-only capability.
|
_model_lower = model_name.lower()
|
||||||
omit_temperature = (
|
omit_temperature = any(
|
||||||
adaptive_only or model_name.lower() in _SAMPLING_DEPRECATED_MODELS
|
m in _model_lower for m in ("opus-4-7", "opus-4-8", "sonnet-5", "fable")
|
||||||
)
|
)
|
||||||
|
|
||||||
kwargs: dict[str, Any] = {
|
kwargs: dict[str, Any] = {
|
||||||
@@ -610,26 +580,16 @@ class AnthropicProvider(LLMProvider):
|
|||||||
if system:
|
if system:
|
||||||
kwargs["system"] = system
|
kwargs["system"] = system
|
||||||
|
|
||||||
if reasoning_effort_lower == "none" and _model_version_at_least(
|
if reasoning_effort == "adaptive":
|
||||||
model_name, _THINKING_DISABLE_MIN_VERSIONS
|
|
||||||
):
|
|
||||||
# These models think by default, so omission would not honor an
|
|
||||||
# explicit request to disable thinking.
|
|
||||||
kwargs["thinking"] = {"type": "disabled"}
|
|
||||||
elif reasoning_effort_lower == "adaptive":
|
|
||||||
# Adaptive thinking: model decides when and how much to think
|
# Adaptive thinking: model decides when and how much to think
|
||||||
|
# Supported on claude-sonnet-4-6 and claude-opus-4-6.
|
||||||
# Also auto-enables interleaved thinking between tool calls.
|
# Also auto-enables interleaved thinking between tool calls.
|
||||||
kwargs["thinking"] = {"type": "adaptive"}
|
kwargs["thinking"] = {"type": "adaptive"}
|
||||||
if not omit_temperature:
|
if not omit_temperature:
|
||||||
kwargs["temperature"] = 1.0
|
kwargs["temperature"] = 1.0
|
||||||
elif thinking_enabled and adaptive_only:
|
|
||||||
# Newer Claude models removed manual token budgets. Their effort
|
|
||||||
# control is independent from the adaptive thinking mode.
|
|
||||||
kwargs["thinking"] = {"type": "adaptive"}
|
|
||||||
kwargs["output_config"] = {"effort": reasoning_effort_lower}
|
|
||||||
elif thinking_enabled:
|
elif thinking_enabled:
|
||||||
budget_map = {"low": 1024, "medium": 4096, "high": max(8192, max_tokens)}
|
budget_map = {"low": 1024, "medium": 4096, "high": max(8192, max_tokens)}
|
||||||
budget = budget_map.get(reasoning_effort_lower, 4096)
|
budget = budget_map.get(cast(str, reasoning_effort).lower(), 4096)
|
||||||
kwargs["thinking"] = {"type": "enabled", "budget_tokens": budget}
|
kwargs["thinking"] = {"type": "enabled", "budget_tokens": budget}
|
||||||
kwargs["max_tokens"] = max(max_tokens, budget + 4096)
|
kwargs["max_tokens"] = max(max_tokens, budget + 4096)
|
||||||
if not omit_temperature:
|
if not omit_temperature:
|
||||||
|
|||||||
@@ -808,12 +808,7 @@ class GeminiImageGenerationClient(ImageGenerationProvider):
|
|||||||
generation_config: dict[str, Any] = {"responseModalities": ["TEXT", "IMAGE"]}
|
generation_config: dict[str, Any] = {"responseModalities": ["TEXT", "IMAGE"]}
|
||||||
image_config = _gemini_flash_image_config(model, aspect_ratio, image_size)
|
image_config = _gemini_flash_image_config(model, aspect_ratio, image_size)
|
||||||
if image_config:
|
if image_config:
|
||||||
# Gemini Flash image models accept plain-string values under
|
generation_config["responseFormat"] = {"image": image_config}
|
||||||
# ``generationConfig.imageConfig``. The legacy
|
|
||||||
# ``responseFormat.image`` block is rejected with INVALID_ARGUMENT
|
|
||||||
# by gemini-3.1-flash-lite-image (enum-based fields), so it is not
|
|
||||||
# used here.
|
|
||||||
generation_config["imageConfig"] = image_config
|
|
||||||
|
|
||||||
body: dict[str, Any] = {
|
body: dict[str, Any] = {
|
||||||
"contents": [{"role": "user", "parts": parts}],
|
"contents": [{"role": "user", "parts": parts}],
|
||||||
@@ -869,13 +864,11 @@ def _gemini_flash_image_config(
|
|||||||
aspect_ratio: str | None,
|
aspect_ratio: str | None,
|
||||||
image_size: str | None,
|
image_size: str | None,
|
||||||
) -> dict[str, str]:
|
) -> dict[str, str]:
|
||||||
"""Build the ``generationConfig.imageConfig`` config for Gemini Flash image models.
|
"""Build the ``responseFormat.image`` config for Gemini Flash image models.
|
||||||
|
|
||||||
Values are the documented plain strings (e.g. ``16:9``, ``1K``) that the
|
Capabilities are model-specific: Gemini 3.1 Flash variants support four
|
||||||
live v1beta API accepts under ``imageConfig``. Capabilities are
|
additional extreme ratios, while configurable image sizes are limited to
|
||||||
model-specific: Gemini 3.1 Flash variants support four additional extreme
|
the documented Gemini 3 image model families.
|
||||||
ratios, while configurable image sizes are limited to the documented
|
|
||||||
Gemini 3 image model families.
|
|
||||||
"""
|
"""
|
||||||
config: dict[str, str] = {}
|
config: dict[str, str] = {}
|
||||||
if aspect_ratio and aspect_ratio in _gemini_flash_supported_aspect_ratios(model):
|
if aspect_ratio and aspect_ratio in _gemini_flash_supported_aspect_ratios(model):
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ def convert_messages(
|
|||||||
if isinstance(reasoning, str) and reasoning:
|
if isinstance(reasoning, str) and reasoning:
|
||||||
input_items.append({
|
input_items.append({
|
||||||
"type": "reasoning",
|
"type": "reasoning",
|
||||||
"content": [{"type": "output_text", "text": reasoning}],
|
"content": reasoning,
|
||||||
})
|
})
|
||||||
if isinstance(content, str) and content:
|
if isinstance(content, str) and content:
|
||||||
message_id = _unique_item_id(f"msg_{idx}", used_item_ids)
|
message_id = _unique_item_id(f"msg_{idx}", used_item_ids)
|
||||||
|
|||||||
@@ -196,18 +196,6 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
|||||||
supports_prompt_caching=True,
|
supports_prompt_caching=True,
|
||||||
gateway_reasoning_style="reasoning_effort",
|
gateway_reasoning_style="reasoning_effort",
|
||||||
),
|
),
|
||||||
# Eden AI: OpenAI-compatible gateway. Models use the "provider/model"
|
|
||||||
# naming scheme (e.g. "anthropic/claude-sonnet-4-5"); the full id is sent upstream.
|
|
||||||
ProviderSpec(
|
|
||||||
name="edenai",
|
|
||||||
keywords=("edenai",),
|
|
||||||
env_key="EDENAI_API_KEY",
|
|
||||||
display_name="Eden AI",
|
|
||||||
backend="openai_compat",
|
|
||||||
is_gateway=True,
|
|
||||||
detect_by_base_keyword="edenai",
|
|
||||||
default_api_base="https://api.edenai.run/v3",
|
|
||||||
),
|
|
||||||
# OpenCode Zen: OpenAI-compatible chat-completions gateway for coding models.
|
# OpenCode Zen: OpenAI-compatible chat-completions gateway for coding models.
|
||||||
# models.dev/OpenCode use provider id "opencode" and model ids like
|
# models.dev/OpenCode use provider id "opencode" and model ids like
|
||||||
# "opencode/<model>"; send the bare model upstream.
|
# "opencode/<model>"; send the bare model upstream.
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ class Session:
|
|||||||
metadata: dict[str, Any] = field(default_factory=dict)
|
metadata: dict[str, Any] = field(default_factory=dict)
|
||||||
last_consolidated: int = 0 # Number of messages already consolidated to files
|
last_consolidated: int = 0 # Number of messages already consolidated to files
|
||||||
provider_state: ProviderConversationState | None = field(default=None, repr=False)
|
provider_state: ProviderConversationState | None = field(default=None, repr=False)
|
||||||
|
transient: bool = field(default=False, repr=False, compare=False)
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
if not isinstance(cast(object, self.metadata), dict):
|
if not isinstance(cast(object, self.metadata), dict):
|
||||||
@@ -964,6 +965,7 @@ class SessionManager:
|
|||||||
self._cache: OrderedDict[str, Session] = OrderedDict()
|
self._cache: OrderedDict[str, Session] = OrderedDict()
|
||||||
# Preserve identity for sessions held by active callers without retaining idle ones.
|
# Preserve identity for sessions held by active callers without retaining idle ones.
|
||||||
self._overflow_cache: WeakValueDictionary[str, Session] = WeakValueDictionary()
|
self._overflow_cache: WeakValueDictionary[str, Session] = WeakValueDictionary()
|
||||||
|
self._transient_sessions: dict[str, Session] = {}
|
||||||
self._max_cached_sessions = SESSION_CACHE_MAX_SIZE
|
self._max_cached_sessions = SESSION_CACHE_MAX_SIZE
|
||||||
self._file_cap_archiver: Callable[..., None] | None = None
|
self._file_cap_archiver: Callable[..., None] | None = None
|
||||||
|
|
||||||
@@ -977,6 +979,10 @@ class SessionManager:
|
|||||||
self._overflow_cache[key] = evicted
|
self._overflow_cache[key] = evicted
|
||||||
|
|
||||||
def _cached(self, key: str) -> Session | None:
|
def _cached(self, key: str) -> Session | None:
|
||||||
|
transient = self._transient_sessions.get(key)
|
||||||
|
if transient is not None:
|
||||||
|
return transient
|
||||||
|
|
||||||
session = self._cache.get(key)
|
session = self._cache.get(key)
|
||||||
if session is not None:
|
if session is not None:
|
||||||
self._cache.move_to_end(key)
|
self._cache.move_to_end(key)
|
||||||
@@ -1053,6 +1059,24 @@ class SessionManager:
|
|||||||
self._remember(session)
|
self._remember(session)
|
||||||
return session
|
return session
|
||||||
|
|
||||||
|
def get_or_create_transient(self, key: str) -> Session:
|
||||||
|
"""Return an active in-memory session that can never reach the store."""
|
||||||
|
session = self._transient_sessions.get(key)
|
||||||
|
if session is None:
|
||||||
|
self._cache.pop(key, None)
|
||||||
|
self._overflow_cache.pop(key, None)
|
||||||
|
session = Session(key=key, transient=True)
|
||||||
|
self._transient_sessions[key] = session
|
||||||
|
return session
|
||||||
|
|
||||||
|
def is_transient_active(self, key: str) -> bool:
|
||||||
|
"""Return whether *key* still accepts transient turns."""
|
||||||
|
return key in self._transient_sessions
|
||||||
|
|
||||||
|
def discard_transient(self, key: str) -> bool:
|
||||||
|
"""Forget all transient contents without retaining a discarded-key tombstone."""
|
||||||
|
return self._transient_sessions.pop(key, None) is not None
|
||||||
|
|
||||||
def _load(self, key: str) -> Session | None:
|
def _load(self, key: str) -> Session | None:
|
||||||
return self._store.load(key)
|
return self._store.load(key)
|
||||||
|
|
||||||
@@ -1066,6 +1090,9 @@ class SessionManager:
|
|||||||
|
|
||||||
def save(self, session: Session, *, fsync: bool = False) -> None:
|
def save(self, session: Session, *, fsync: bool = False) -> None:
|
||||||
"""Persist a session and retain it in the cache."""
|
"""Persist a session and retain it in the cache."""
|
||||||
|
if session.transient is True:
|
||||||
|
return
|
||||||
|
|
||||||
archiver = self._file_cap_archiver
|
archiver = self._file_cap_archiver
|
||||||
if archiver is not None:
|
if archiver is not None:
|
||||||
session.enforce_file_cap(
|
session.enforce_file_cap(
|
||||||
@@ -1098,6 +1125,7 @@ class SessionManager:
|
|||||||
|
|
||||||
def invalidate(self, key: str) -> None:
|
def invalidate(self, key: str) -> None:
|
||||||
"""Remove a session from the in-memory cache."""
|
"""Remove a session from the in-memory cache."""
|
||||||
|
self._transient_sessions.pop(key, None)
|
||||||
self._cache.pop(key, None)
|
self._cache.pop(key, None)
|
||||||
self._overflow_cache.pop(key, None)
|
self._overflow_cache.pop(key, None)
|
||||||
|
|
||||||
|
|||||||
@@ -334,6 +334,16 @@ def clear_websocket_turn_if_current(
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def clear_websocket_turns(chat_id: str) -> int:
|
||||||
|
"""Clear every in-memory lifecycle owner for a discarded chat."""
|
||||||
|
turns = _WEBSOCKET_ACTIVE_TURNS.pop(chat_id, None)
|
||||||
|
count = len(turns) if turns is not None else 0
|
||||||
|
_WEBSOCKET_TURN_WALL_STARTED_AT.pop(chat_id, None)
|
||||||
|
_WEBSOCKET_TURN_IDS.pop(chat_id, None)
|
||||||
|
_WEBSOCKET_TURN_OWNERS.pop(chat_id, None)
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
def build_bus_progress_callback(
|
def build_bus_progress_callback(
|
||||||
bus: MessageBus,
|
bus: MessageBus,
|
||||||
msg: InboundMessage,
|
msg: InboundMessage,
|
||||||
|
|||||||
@@ -166,8 +166,7 @@ class LocalTriggerStore:
|
|||||||
raise ValueError("trigger message is required")
|
raise ValueError("trigger message is required")
|
||||||
self._ensure_dirs()
|
self._ensure_dirs()
|
||||||
with self._lock:
|
with self._lock:
|
||||||
triggers = self._load_triggers_unlocked()
|
trigger = self._find_unlocked(self._load_triggers_unlocked(), trigger_id)
|
||||||
trigger = self._find_unlocked(triggers, trigger_id)
|
|
||||||
if trigger is None:
|
if trigger is None:
|
||||||
raise TriggerNotFoundError(f"trigger not found: {trigger_id}")
|
raise TriggerNotFoundError(f"trigger not found: {trigger_id}")
|
||||||
if not trigger.enabled:
|
if not trigger.enabled:
|
||||||
@@ -181,20 +180,10 @@ class LocalTriggerStore:
|
|||||||
path = self.inbox_dir / f"{delivery.created_at_ms}-{delivery.id}.json"
|
path = self.inbox_dir / f"{delivery.created_at_ms}-{delivery.id}.json"
|
||||||
self._atomic_write(path, json.dumps(_delivery_payload(delivery), ensure_ascii=False))
|
self._atomic_write(path, json.dumps(_delivery_payload(delivery), ensure_ascii=False))
|
||||||
delivery.path = path
|
delivery.path = path
|
||||||
run_record_path: Path | None = None
|
|
||||||
try:
|
try:
|
||||||
run_record_path = self.write_delivery_run_record(
|
self.write_delivery_run_record(delivery, trigger=trigger, status="queued")
|
||||||
delivery,
|
|
||||||
trigger=trigger,
|
|
||||||
status="queued",
|
|
||||||
)
|
|
||||||
trigger.last_message = _run_record_text(content)
|
|
||||||
trigger.updated_at_ms = delivery.created_at_ms
|
|
||||||
self._save_triggers_unlocked(triggers)
|
|
||||||
except BaseException:
|
except BaseException:
|
||||||
path.unlink(missing_ok=True)
|
path.unlink(missing_ok=True)
|
||||||
if run_record_path is not None:
|
|
||||||
run_record_path.unlink(missing_ok=True)
|
|
||||||
delivery.path = None
|
delivery.path = None
|
||||||
raise
|
raise
|
||||||
return delivery
|
return delivery
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ class LocalTrigger:
|
|||||||
origin_metadata: dict[str, Any] = field(default_factory=dict)
|
origin_metadata: dict[str, Any] = field(default_factory=dict)
|
||||||
created_at_ms: int = 0
|
created_at_ms: int = 0
|
||||||
updated_at_ms: int = 0
|
updated_at_ms: int = 0
|
||||||
last_message: str = ""
|
|
||||||
last_run_at_ms: int | None = None
|
last_run_at_ms: int | None = None
|
||||||
last_status: TriggerStatus | None = None
|
last_status: TriggerStatus | None = None
|
||||||
last_error: str | None = None
|
last_error: str | None = None
|
||||||
@@ -91,7 +90,6 @@ class LocalTrigger:
|
|||||||
origin_metadata=dict(_get(data, "originMetadata", "origin_metadata", {}) or {}),
|
origin_metadata=dict(_get(data, "originMetadata", "origin_metadata", {}) or {}),
|
||||||
created_at_ms=_int_or_zero(_get(data, "createdAtMs", "created_at_ms", 0)),
|
created_at_ms=_int_or_zero(_get(data, "createdAtMs", "created_at_ms", 0)),
|
||||||
updated_at_ms=_int_or_zero(_get(data, "updatedAtMs", "updated_at_ms", 0)),
|
updated_at_ms=_int_or_zero(_get(data, "updatedAtMs", "updated_at_ms", 0)),
|
||||||
last_message=str(_get(data, "lastMessage", "last_message", "") or ""),
|
|
||||||
last_run_at_ms=_optional_int(_get(data, "lastRunAtMs", "last_run_at_ms")),
|
last_run_at_ms=_optional_int(_get(data, "lastRunAtMs", "last_run_at_ms")),
|
||||||
last_status=_get(data, "lastStatus", "last_status"), # type: ignore[arg-type]
|
last_status=_get(data, "lastStatus", "last_status"), # type: ignore[arg-type]
|
||||||
last_error=_get(data, "lastError", "last_error"),
|
last_error=_get(data, "lastError", "last_error"),
|
||||||
@@ -110,7 +108,6 @@ class LocalTrigger:
|
|||||||
"originMetadata": self.origin_metadata,
|
"originMetadata": self.origin_metadata,
|
||||||
"createdAtMs": self.created_at_ms,
|
"createdAtMs": self.created_at_ms,
|
||||||
"updatedAtMs": self.updated_at_ms,
|
"updatedAtMs": self.updated_at_ms,
|
||||||
"lastMessage": self.last_message,
|
|
||||||
"lastRunAtMs": self.last_run_at_ms,
|
"lastRunAtMs": self.last_run_at_ms,
|
||||||
"lastStatus": self.last_status,
|
"lastStatus": self.last_status,
|
||||||
"lastError": self.last_error,
|
"lastError": self.last_error,
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, Callable
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from loguru import logger as default_logger
|
from loguru import logger as default_logger
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@ class GatewayServices:
|
|||||||
local_trigger_store: LocalTriggerStore | None
|
local_trigger_store: LocalTriggerStore | None
|
||||||
cron_pending_job_ids: Callable[[str], set[str]] | None
|
cron_pending_job_ids: Callable[[str], set[str]] | None
|
||||||
local_trigger_pending_ids: Callable[[str], set[str]] | None
|
local_trigger_pending_ids: Callable[[str], set[str]] | None
|
||||||
|
cancel_active_turn: Callable[[str], Awaitable[int]] | None
|
||||||
|
|
||||||
|
|
||||||
def build_gateway_services(
|
def build_gateway_services(
|
||||||
@@ -56,6 +58,7 @@ def build_gateway_services(
|
|||||||
local_trigger_store: LocalTriggerStore | None = None,
|
local_trigger_store: LocalTriggerStore | None = None,
|
||||||
cron_pending_job_ids: Callable[[str], set[str]] | None = None,
|
cron_pending_job_ids: Callable[[str], set[str]] | None = None,
|
||||||
local_trigger_pending_ids: Callable[[str], set[str]] | None = None,
|
local_trigger_pending_ids: Callable[[str], set[str]] | None = None,
|
||||||
|
cancel_active_turn: Callable[[str], Awaitable[int]] | None = None,
|
||||||
channel_feature_action: Callable[..., Any] | None = None,
|
channel_feature_action: Callable[..., Any] | None = None,
|
||||||
channel_runtime_status: Callable[[], dict[str, Any]] | None = None,
|
channel_runtime_status: Callable[[], dict[str, Any]] | None = None,
|
||||||
skill_state_action: Callable[[set[str]], None] | None = None,
|
skill_state_action: Callable[[set[str]], None] | None = None,
|
||||||
@@ -117,4 +120,5 @@ def build_gateway_services(
|
|||||||
local_trigger_store=local_trigger_store,
|
local_trigger_store=local_trigger_store,
|
||||||
cron_pending_job_ids=cron_pending_job_ids,
|
cron_pending_job_ids=cron_pending_job_ids,
|
||||||
local_trigger_pending_ids=local_trigger_pending_ids,
|
local_trigger_pending_ids=local_trigger_pending_ids,
|
||||||
|
cancel_active_turn=cancel_active_turn,
|
||||||
)
|
)
|
||||||
|
|||||||
+10
-51
@@ -3,7 +3,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import email.utils
|
import email.utils
|
||||||
import gzip
|
|
||||||
import hmac
|
import hmac
|
||||||
import http
|
import http
|
||||||
import ipaddress
|
import ipaddress
|
||||||
@@ -17,9 +16,6 @@ from websockets.http11 import Response
|
|||||||
|
|
||||||
QueryParams = dict[str, list[str]]
|
QueryParams = dict[str, list[str]]
|
||||||
|
|
||||||
_JSON_GZIP_MIN_BYTES = 4 * 1024
|
|
||||||
_JSON_GZIP_LEVEL = 5
|
|
||||||
|
|
||||||
|
|
||||||
def strip_trailing_slash(path: str) -> str:
|
def strip_trailing_slash(path: str) -> str:
|
||||||
if len(path) > 1 and path.endswith("/"):
|
if len(path) > 1 and path.endswith("/"):
|
||||||
@@ -45,15 +41,6 @@ def case_insensitive_header(headers: Any, key: str) -> str:
|
|||||||
return str(value or "").strip()
|
return str(value or "").strip()
|
||||||
|
|
||||||
|
|
||||||
def combined_list_header(headers: Any, key: str) -> str:
|
|
||||||
"""Combine repeated values for a comma-separated HTTP list header."""
|
|
||||||
try:
|
|
||||||
values = headers.get_all(key)
|
|
||||||
except (AttributeError, KeyError):
|
|
||||||
return case_insensitive_header(headers, key)
|
|
||||||
return ", ".join(str(value).strip() for value in values if str(value).strip())
|
|
||||||
|
|
||||||
|
|
||||||
def safe_host_header(value: str) -> str:
|
def safe_host_header(value: str) -> str:
|
||||||
"""Return a safe Host header value, or empty when it should not be echoed."""
|
"""Return a safe Host header value, or empty when it should not be echoed."""
|
||||||
value = value.strip()
|
value = value.strip()
|
||||||
@@ -75,46 +62,18 @@ def host_for_url(host: str, port: int) -> str:
|
|||||||
return f"{host}:{port}"
|
return f"{host}:{port}"
|
||||||
|
|
||||||
|
|
||||||
def _accepts_gzip(value: str) -> bool:
|
def http_json_response(data: dict[str, Any], *, status: int = 200) -> Response:
|
||||||
wildcard_quality: float | None = None
|
|
||||||
for item in value.split(","):
|
|
||||||
name, *params = (part.strip() for part in item.split(";"))
|
|
||||||
quality = 1.0
|
|
||||||
for param in params:
|
|
||||||
key, separator, raw_value = param.partition("=")
|
|
||||||
if separator and key.strip().lower() == "q":
|
|
||||||
try:
|
|
||||||
quality = float(raw_value.strip())
|
|
||||||
except ValueError:
|
|
||||||
quality = 0.0
|
|
||||||
break
|
|
||||||
if name.lower() == "gzip":
|
|
||||||
return quality > 0
|
|
||||||
if name == "*":
|
|
||||||
wildcard_quality = quality
|
|
||||||
return wildcard_quality is not None and wildcard_quality > 0
|
|
||||||
|
|
||||||
|
|
||||||
def http_json_response(
|
|
||||||
data: dict[str, Any],
|
|
||||||
*,
|
|
||||||
status: int = 200,
|
|
||||||
accept_encoding: str | None = None,
|
|
||||||
) -> Response:
|
|
||||||
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
||||||
headers = [
|
headers = Headers(
|
||||||
("Date", email.utils.formatdate(usegmt=True)),
|
[
|
||||||
("Connection", "close"),
|
("Date", email.utils.formatdate(usegmt=True)),
|
||||||
("Content-Type", "application/json; charset=utf-8"),
|
("Connection", "close"),
|
||||||
]
|
("Content-Length", str(len(body))),
|
||||||
if accept_encoding is not None:
|
("Content-Type", "application/json; charset=utf-8"),
|
||||||
headers.append(("Vary", "Accept-Encoding"))
|
]
|
||||||
if len(body) >= _JSON_GZIP_MIN_BYTES and _accepts_gzip(accept_encoding):
|
)
|
||||||
body = gzip.compress(body, compresslevel=_JSON_GZIP_LEVEL, mtime=0)
|
|
||||||
headers.append(("Content-Encoding", "gzip"))
|
|
||||||
headers.append(("Content-Length", str(len(body))))
|
|
||||||
reason = http.HTTPStatus(status).phrase
|
reason = http.HTTPStatus(status).phrase
|
||||||
return Response(status, reason, Headers(headers), body)
|
return Response(status, reason, headers, body)
|
||||||
|
|
||||||
|
|
||||||
def http_response(
|
def http_response(
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ def _serialize_trigger(
|
|||||||
},
|
},
|
||||||
"payload": {
|
"payload": {
|
||||||
"kind": "local_trigger",
|
"kind": "local_trigger",
|
||||||
"message": trigger.last_message or command,
|
"message": command,
|
||||||
"command": command,
|
"command": command,
|
||||||
},
|
},
|
||||||
"state": {
|
"state": {
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ from typing import Any, cast
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.config.paths import get_webui_dir
|
from nanobot.config.paths import get_webui_dir
|
||||||
from nanobot.security.workspace_access import WORKSPACE_SCOPE_METADATA_KEY
|
|
||||||
from nanobot.session.history_visibility import is_hidden_history_message
|
from nanobot.session.history_visibility import is_hidden_history_message
|
||||||
from nanobot.session.manager import (
|
from nanobot.session.manager import (
|
||||||
_PROVIDER_STATE_RECORD_TYPE, # pyright: ignore[reportPrivateUsage]
|
_PROVIDER_STATE_RECORD_TYPE, # pyright: ignore[reportPrivateUsage]
|
||||||
@@ -30,16 +29,9 @@ from nanobot.session.manager import (
|
|||||||
)
|
)
|
||||||
from nanobot.session.model_selection import model_preset_from_metadata
|
from nanobot.session.model_selection import model_preset_from_metadata
|
||||||
|
|
||||||
_INDEX_VERSION = 6
|
_INDEX_VERSION = 4
|
||||||
_INDEX_FILENAME = ".webui_session_index.json"
|
_INDEX_FILENAME = ".webui_session_index.json"
|
||||||
_MODEL_PRESET_FIELD = "model_preset"
|
_MODEL_PRESET_FIELD = "model_preset"
|
||||||
_WORKSPACE_SCOPE_PRESENT_FIELD = "_workspace_scope_present"
|
|
||||||
_WORKSPACE_SCOPE_VALUE_FIELD = "_workspace_scope_value"
|
|
||||||
WEBUI_SESSION_INDEX_INTERNAL_FIELDS = frozenset(
|
|
||||||
{_WORKSPACE_SCOPE_PRESENT_FIELD, _WORKSPACE_SCOPE_VALUE_FIELD}
|
|
||||||
)
|
|
||||||
_INDEXED_WORKSPACE_SCOPE_KEYS = ("project_path", "path", "access_mode")
|
|
||||||
_MAX_INDEXED_WORKSPACE_SCOPE_BYTES = 4096
|
|
||||||
_WEBUI_ACTIVITY_MTIME_NS = "webui_activity_mtime_ns"
|
_WEBUI_ACTIVITY_MTIME_NS = "webui_activity_mtime_ns"
|
||||||
_WEBUI_ACTIVITY_SIZE = "webui_activity_size"
|
_WEBUI_ACTIVITY_SIZE = "webui_activity_size"
|
||||||
_VISIBLE_TRANSCRIPT_ROLES = {"user", "assistant"}
|
_VISIBLE_TRANSCRIPT_ROLES = {"user", "assistant"}
|
||||||
@@ -69,21 +61,17 @@ def _reconcile_index(session_manager: SessionManager) -> tuple[list[dict[str, An
|
|||||||
for path in session_manager.sessions_dir.glob("*.jsonl")
|
for path in session_manager.sessions_dir.glob("*.jsonl")
|
||||||
if SessionManager._session_key_from_path(path) is not None # pyright: ignore[reportPrivateUsage]
|
if SessionManager._session_key_from_path(path) is not None # pyright: ignore[reportPrivateUsage]
|
||||||
)
|
)
|
||||||
if not paths:
|
|
||||||
return [], existing_rows != []
|
|
||||||
|
|
||||||
webui_dir = get_webui_dir()
|
|
||||||
rows: list[dict[str, Any]] = []
|
rows: list[dict[str, Any]] = []
|
||||||
changed = existing_rows is None
|
changed = existing_rows is None
|
||||||
|
|
||||||
for path in paths:
|
for path in paths:
|
||||||
row = existing_by_file.get(path.name)
|
row = existing_by_file.get(path.name)
|
||||||
if row is not None and _indexed_row_matches_file(row, path, webui_dir):
|
if row is not None and _indexed_row_matches_file(row, path):
|
||||||
rows.append(row)
|
rows.append(row)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
changed = True
|
changed = True
|
||||||
scanned = _scan_session_row(session_manager, path, webui_dir)
|
scanned = _scan_session_row(session_manager, path)
|
||||||
if scanned is not None:
|
if scanned is not None:
|
||||||
rows.append(scanned)
|
rows.append(scanned)
|
||||||
|
|
||||||
@@ -137,20 +125,18 @@ def _file_signature(path: Path) -> dict[str, int]:
|
|||||||
return {"mtime_ns": stat.st_mtime_ns, "size": stat.st_size}
|
return {"mtime_ns": stat.st_mtime_ns, "size": stat.st_size}
|
||||||
|
|
||||||
|
|
||||||
def _indexed_row_matches_file(row: dict[str, Any], path: Path, webui_dir: Path) -> bool:
|
def _indexed_row_matches_file(row: dict[str, Any], path: Path) -> bool:
|
||||||
if not all(isinstance(row.get(key), str) for key in ("key", "created_at", "updated_at")):
|
if not all(isinstance(row.get(key), str) for key in ("key", "created_at", "updated_at")):
|
||||||
return False
|
return False
|
||||||
if not isinstance(row.get("title", ""), str) or not isinstance(row.get("preview", ""), str):
|
if not isinstance(row.get("title", ""), str) or not isinstance(row.get("preview", ""), str):
|
||||||
return False
|
return False
|
||||||
if not isinstance(row.get(_WORKSPACE_SCOPE_PRESENT_FIELD), bool):
|
|
||||||
return False
|
|
||||||
if row.get("file") != path.name:
|
if row.get("file") != path.name:
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
signature = _file_signature(path)
|
signature = _file_signature(path)
|
||||||
except OSError:
|
except OSError:
|
||||||
return False
|
return False
|
||||||
activity_signature = _webui_activity_signature(str(row.get("key")), webui_dir)
|
activity_signature = _webui_activity_signature(str(row.get("key")))
|
||||||
return (
|
return (
|
||||||
row.get("mtime_ns") == signature["mtime_ns"]
|
row.get("mtime_ns") == signature["mtime_ns"]
|
||||||
and row.get("size") == signature["size"]
|
and row.get("size") == signature["size"]
|
||||||
@@ -167,57 +153,10 @@ def _public_row(sessions_dir: Path, row: dict[str, Any]) -> dict[str, Any]:
|
|||||||
"title": row.get("title", ""),
|
"title": row.get("title", ""),
|
||||||
"preview": row.get("preview", ""),
|
"preview": row.get("preview", ""),
|
||||||
_MODEL_PRESET_FIELD: row.get(_MODEL_PRESET_FIELD),
|
_MODEL_PRESET_FIELD: row.get(_MODEL_PRESET_FIELD),
|
||||||
_WORKSPACE_SCOPE_PRESENT_FIELD: row.get(_WORKSPACE_SCOPE_PRESENT_FIELD, False),
|
|
||||||
_WORKSPACE_SCOPE_VALUE_FIELD: row.get(_WORKSPACE_SCOPE_VALUE_FIELD),
|
|
||||||
"path": str(sessions_dir / str(row.get("file", ""))),
|
"path": str(sessions_dir / str(row.get("file", ""))),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def indexed_workspace_scope(row: dict[str, Any]) -> tuple[bool, object]:
|
|
||||||
"""Return the cached sidebar scope value while preserving missing vs null."""
|
|
||||||
return (
|
|
||||||
row.get(_WORKSPACE_SCOPE_PRESENT_FIELD) is True,
|
|
||||||
cast(object, row.get(_WORKSPACE_SCOPE_VALUE_FIELD)),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _indexed_workspace_scope_fields(metadata: object) -> dict[str, object]:
|
|
||||||
if not isinstance(metadata, dict):
|
|
||||||
return {
|
|
||||||
_WORKSPACE_SCOPE_PRESENT_FIELD: False,
|
|
||||||
_WORKSPACE_SCOPE_VALUE_FIELD: None,
|
|
||||||
}
|
|
||||||
metadata_data = cast(dict[str, Any], metadata)
|
|
||||||
if WORKSPACE_SCOPE_METADATA_KEY not in metadata_data:
|
|
||||||
return {
|
|
||||||
_WORKSPACE_SCOPE_PRESENT_FIELD: False,
|
|
||||||
_WORKSPACE_SCOPE_VALUE_FIELD: None,
|
|
||||||
}
|
|
||||||
|
|
||||||
raw_scope = metadata_data.get(WORKSPACE_SCOPE_METADATA_KEY)
|
|
||||||
indexed_scope: object = False
|
|
||||||
if raw_scope is None:
|
|
||||||
indexed_scope = None
|
|
||||||
elif isinstance(raw_scope, dict):
|
|
||||||
scope_data = cast(dict[object, object], raw_scope)
|
|
||||||
recognized = {
|
|
||||||
key: scope_data[key]
|
|
||||||
for key in _INDEXED_WORKSPACE_SCOPE_KEYS
|
|
||||||
if key in scope_data
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
encoded = json.dumps(recognized, ensure_ascii=False)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
if len(encoded.encode("utf-8")) <= _MAX_INDEXED_WORKSPACE_SCOPE_BYTES:
|
|
||||||
indexed_scope = cast(object, json.loads(encoded))
|
|
||||||
return {
|
|
||||||
_WORKSPACE_SCOPE_PRESENT_FIELD: True,
|
|
||||||
_WORKSPACE_SCOPE_VALUE_FIELD: indexed_scope,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _preview_from_messages(messages: list[dict[str, Any]]) -> str:
|
def _preview_from_messages(messages: list[dict[str, Any]]) -> str:
|
||||||
fallback_preview = ""
|
fallback_preview = ""
|
||||||
scanned_records = 0
|
scanned_records = 0
|
||||||
@@ -242,18 +181,19 @@ def _preview_from_messages(messages: list[dict[str, Any]]) -> str:
|
|||||||
return fallback_preview
|
return fallback_preview
|
||||||
|
|
||||||
|
|
||||||
def _webui_activity_paths(session_key: str, webui_dir: Path) -> list[Path]:
|
def _webui_activity_paths(session_key: str) -> list[Path]:
|
||||||
stem = SessionManager.safe_key(session_key)
|
stem = SessionManager.safe_key(session_key)
|
||||||
|
webui_dir = get_webui_dir()
|
||||||
return [
|
return [
|
||||||
webui_dir / f"{stem}.jsonl",
|
webui_dir / f"{stem}.jsonl",
|
||||||
webui_dir / f"{stem}.json",
|
webui_dir / f"{stem}.json",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def _webui_activity_signature(session_key: str, webui_dir: Path) -> dict[str, int]:
|
def _webui_activity_signature(session_key: str) -> dict[str, int]:
|
||||||
latest_mtime_ns = 0
|
latest_mtime_ns = 0
|
||||||
total_size = 0
|
total_size = 0
|
||||||
for path in _webui_activity_paths(session_key, webui_dir):
|
for path in _webui_activity_paths(session_key):
|
||||||
try:
|
try:
|
||||||
stat = path.stat()
|
stat = path.stat()
|
||||||
except OSError:
|
except OSError:
|
||||||
@@ -291,10 +231,10 @@ def _latest_updated_at(stored: str | None, activity: str | None) -> str | None:
|
|||||||
|
|
||||||
|
|
||||||
def _visible_message_timestamp(item: dict[str, Any]) -> str | None:
|
def _visible_message_timestamp(item: dict[str, Any]) -> str | None:
|
||||||
if item.get("role") not in _VISIBLE_TRANSCRIPT_ROLES:
|
|
||||||
return None
|
|
||||||
if is_hidden_history_message(item):
|
if is_hidden_history_message(item):
|
||||||
return None
|
return None
|
||||||
|
if item.get("role") not in _VISIBLE_TRANSCRIPT_ROLES:
|
||||||
|
return None
|
||||||
timestamp = item.get("timestamp")
|
timestamp = item.get("timestamp")
|
||||||
return timestamp if isinstance(timestamp, str) else None
|
return timestamp if isinstance(timestamp, str) else None
|
||||||
|
|
||||||
@@ -316,9 +256,9 @@ def _visible_activity_updated_at(
|
|||||||
return _latest_updated_at(visible_message_at, webui_activity) or stored
|
return _latest_updated_at(visible_message_at, webui_activity) or stored
|
||||||
|
|
||||||
|
|
||||||
def _indexed_row_for_session(session: Session, path: Path, webui_dir: Path) -> dict[str, Any]:
|
def _indexed_row_for_session(session: Session, path: Path) -> dict[str, Any]:
|
||||||
signature = _file_signature(path)
|
signature = _file_signature(path)
|
||||||
activity_signature = _webui_activity_signature(session.key, webui_dir)
|
activity_signature = _webui_activity_signature(session.key)
|
||||||
activity_updated_at = _webui_activity_updated_at(activity_signature)
|
activity_updated_at = _webui_activity_updated_at(activity_signature)
|
||||||
visible_message_at = _last_visible_message_at(session.messages)
|
visible_message_at = _last_visible_message_at(session.messages)
|
||||||
return {
|
return {
|
||||||
@@ -332,7 +272,6 @@ def _indexed_row_for_session(session: Session, path: Path, webui_dir: Path) -> d
|
|||||||
"title": _metadata_title(session.metadata),
|
"title": _metadata_title(session.metadata),
|
||||||
"preview": _preview_from_messages(session.messages),
|
"preview": _preview_from_messages(session.messages),
|
||||||
_MODEL_PRESET_FIELD: model_preset_from_metadata(session.metadata),
|
_MODEL_PRESET_FIELD: model_preset_from_metadata(session.metadata),
|
||||||
**_indexed_workspace_scope_fields(session.metadata),
|
|
||||||
"file": path.name,
|
"file": path.name,
|
||||||
"mtime_ns": signature["mtime_ns"],
|
"mtime_ns": signature["mtime_ns"],
|
||||||
"size": signature["size"],
|
"size": signature["size"],
|
||||||
@@ -340,16 +279,11 @@ def _indexed_row_for_session(session: Session, path: Path, webui_dir: Path) -> d
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _scan_session_row(
|
def _scan_session_row(session_manager: SessionManager, path: Path) -> dict[str, Any] | None:
|
||||||
session_manager: SessionManager,
|
|
||||||
path: Path,
|
|
||||||
webui_dir: Path,
|
|
||||||
) -> dict[str, Any] | None:
|
|
||||||
storage_key = SessionManager._session_key_from_path(path) # pyright: ignore[reportPrivateUsage]
|
storage_key = SessionManager._session_key_from_path(path) # pyright: ignore[reportPrivateUsage]
|
||||||
if storage_key is None:
|
if storage_key is None:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
signature = _file_signature(path)
|
|
||||||
with open(path, encoding="utf-8") as f:
|
with open(path, encoding="utf-8") as f:
|
||||||
first_line = f.readline().strip()
|
first_line = f.readline().strip()
|
||||||
if not first_line:
|
if not first_line:
|
||||||
@@ -396,6 +330,7 @@ def _scan_session_row(
|
|||||||
continue
|
continue
|
||||||
if not fallback_preview and item.get("role") == "assistant":
|
if not fallback_preview and item.get("role") == "assistant":
|
||||||
fallback_preview = text
|
fallback_preview = text
|
||||||
|
signature = _file_signature(path)
|
||||||
created_at_s = data.get("created_at")
|
created_at_s = data.get("created_at")
|
||||||
updated_at_s = data.get("updated_at")
|
updated_at_s = data.get("updated_at")
|
||||||
if not created_at_s or not updated_at_s:
|
if not created_at_s or not updated_at_s:
|
||||||
@@ -403,8 +338,7 @@ def _scan_session_row(
|
|||||||
created_at_s = created_at_s or fallback_time
|
created_at_s = created_at_s or fallback_time
|
||||||
updated_at_s = updated_at_s or fallback_time
|
updated_at_s = updated_at_s or fallback_time
|
||||||
key = data.get("key") or storage_key
|
key = data.get("key") or storage_key
|
||||||
metadata = data.get("metadata", {})
|
activity_signature = _webui_activity_signature(key)
|
||||||
activity_signature = _webui_activity_signature(key, webui_dir)
|
|
||||||
activity_updated_at = _webui_activity_updated_at(activity_signature)
|
activity_updated_at = _webui_activity_updated_at(activity_signature)
|
||||||
return {
|
return {
|
||||||
"key": key,
|
"key": key,
|
||||||
@@ -414,10 +348,9 @@ def _scan_session_row(
|
|||||||
visible_message_at,
|
visible_message_at,
|
||||||
activity_updated_at,
|
activity_updated_at,
|
||||||
),
|
),
|
||||||
"title": _metadata_title(metadata),
|
"title": _metadata_title(data.get("metadata", {})),
|
||||||
"preview": preview or fallback_preview,
|
"preview": preview or fallback_preview,
|
||||||
_MODEL_PRESET_FIELD: model_preset_from_metadata(metadata),
|
_MODEL_PRESET_FIELD: model_preset_from_metadata(data.get("metadata", {})),
|
||||||
**_indexed_workspace_scope_fields(metadata),
|
|
||||||
"file": path.name,
|
"file": path.name,
|
||||||
"mtime_ns": signature["mtime_ns"],
|
"mtime_ns": signature["mtime_ns"],
|
||||||
"size": signature["size"],
|
"size": signature["size"],
|
||||||
@@ -427,4 +360,4 @@ def _scan_session_row(
|
|||||||
repaired = session_manager._repair(storage_key) # pyright: ignore[reportPrivateUsage]
|
repaired = session_manager._repair(storage_key) # pyright: ignore[reportPrivateUsage]
|
||||||
if repaired is None:
|
if repaired is None:
|
||||||
return None
|
return None
|
||||||
return _indexed_row_for_session(repaired, path, webui_dir)
|
return _indexed_row_for_session(repaired, path)
|
||||||
|
|||||||
@@ -1234,6 +1234,8 @@ def settings_payload(
|
|||||||
"temperature": effective_preset.temperature,
|
"temperature": effective_preset.temperature,
|
||||||
"reasoning_effort": effective_preset.reasoning_effort,
|
"reasoning_effort": effective_preset.reasoning_effort,
|
||||||
"timezone": defaults.timezone,
|
"timezone": defaults.timezone,
|
||||||
|
"bot_name": defaults.bot_name,
|
||||||
|
"bot_icon": defaults.bot_icon,
|
||||||
"tool_hint_max_length": defaults.tool_hint_max_length,
|
"tool_hint_max_length": defaults.tool_hint_max_length,
|
||||||
},
|
},
|
||||||
"model_presets": model_presets,
|
"model_presets": model_presets,
|
||||||
@@ -1404,6 +1406,24 @@ def update_agent_settings(query: QueryParams) -> dict[str, Any]:
|
|||||||
changed = True
|
changed = True
|
||||||
restart_required = True
|
restart_required = True
|
||||||
|
|
||||||
|
bot_name = _query_first_alias(query, "bot_name", "botName")
|
||||||
|
if bot_name is not None:
|
||||||
|
bot_name = bot_name.strip()
|
||||||
|
if not bot_name:
|
||||||
|
raise WebUISettingsError("bot_name is required")
|
||||||
|
if defaults.bot_name != bot_name:
|
||||||
|
defaults.bot_name = bot_name
|
||||||
|
changed = True
|
||||||
|
restart_required = True
|
||||||
|
|
||||||
|
bot_icon = _query_first_alias(query, "bot_icon", "botIcon")
|
||||||
|
if bot_icon is not None:
|
||||||
|
bot_icon = bot_icon.strip()
|
||||||
|
if defaults.bot_icon != bot_icon:
|
||||||
|
defaults.bot_icon = bot_icon
|
||||||
|
changed = True
|
||||||
|
restart_required = True
|
||||||
|
|
||||||
tool_hint_max_length = _query_first_alias(
|
tool_hint_max_length = _query_first_alias(
|
||||||
query,
|
query,
|
||||||
"tool_hint_max_length",
|
"tool_hint_max_length",
|
||||||
|
|||||||
+59
-129
@@ -28,8 +28,7 @@ WEBUI_TRANSCRIPT_SCHEMA_VERSION = 3
|
|||||||
WEBUI_FORK_MARKER_EVENT = "fork_marker"
|
WEBUI_FORK_MARKER_EVENT = "fork_marker"
|
||||||
WEBUI_TRANSCRIPT_INCOMPLETE_KEY = "transcript_incomplete"
|
WEBUI_TRANSCRIPT_INCOMPLETE_KEY = "transcript_incomplete"
|
||||||
_MAX_TRANSCRIPT_FILE_BYTES = 8 * 1024 * 1024
|
_MAX_TRANSCRIPT_FILE_BYTES = 8 * 1024 * 1024
|
||||||
_ACTIVE_TRANSCRIPT_ROTATE_BYTES = 2 * 1024 * 1024
|
_TARGET_ACTIVE_TRANSCRIPT_BYTES = _MAX_TRANSCRIPT_FILE_BYTES // 2
|
||||||
_TARGET_ACTIVE_TRANSCRIPT_BYTES = _ACTIVE_TRANSCRIPT_ROTATE_BYTES // 2
|
|
||||||
_TRANSCRIPT_SEGMENT_MANIFEST_VERSION = 2
|
_TRANSCRIPT_SEGMENT_MANIFEST_VERSION = 2
|
||||||
_TRANSCRIPT_ACTIVE_CHUNK_ID = "active"
|
_TRANSCRIPT_ACTIVE_CHUNK_ID = "active"
|
||||||
_TRANSCRIPT_SEGMENT_RE = re.compile(r"^\d{6}\.jsonl$")
|
_TRANSCRIPT_SEGMENT_RE = re.compile(r"^\d{6}\.jsonl$")
|
||||||
@@ -285,12 +284,12 @@ def _normalize_manifest_entry(session_key: str, entry: Any) -> dict[str, Any] |
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _write_segment_manifest(session_key: str, entries: list[dict[str, Any]]) -> None:
|
def _write_segment_manifest(session_key: str, segment_ids: list[str]) -> None:
|
||||||
directory = webui_transcript_segments_dir(session_key)
|
directory = webui_transcript_segments_dir(session_key)
|
||||||
directory.mkdir(parents=True, exist_ok=True)
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
data = {
|
data = {
|
||||||
"version": _TRANSCRIPT_SEGMENT_MANIFEST_VERSION,
|
"version": _TRANSCRIPT_SEGMENT_MANIFEST_VERSION,
|
||||||
"segments": entries,
|
"segments": [_segment_manifest_entry(session_key, segment_id) for segment_id in segment_ids],
|
||||||
}
|
}
|
||||||
path = _webui_transcript_manifest_path(session_key)
|
path = _webui_transcript_manifest_path(session_key)
|
||||||
tmp_path = path.with_suffix(".json.tmp")
|
tmp_path = path.with_suffix(".json.tmp")
|
||||||
@@ -302,14 +301,17 @@ def _write_segment_manifest(session_key: str, entries: list[dict[str, Any]]) ->
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
def _rebuild_segment_manifest(session_key: str) -> list[dict[str, Any]]:
|
def _rebuild_segment_manifest(session_key: str) -> list[str]:
|
||||||
segment_ids = _segment_ids_on_disk(session_key)
|
segment_ids = _segment_ids_on_disk(session_key)
|
||||||
entries = [_segment_manifest_entry(session_key, segment_id) for segment_id in segment_ids]
|
if segment_ids:
|
||||||
if entries:
|
_write_segment_manifest(session_key, segment_ids)
|
||||||
_write_segment_manifest(session_key, entries)
|
|
||||||
else:
|
else:
|
||||||
_webui_transcript_manifest_path(session_key).unlink(missing_ok=True)
|
_webui_transcript_manifest_path(session_key).unlink(missing_ok=True)
|
||||||
return entries
|
return segment_ids
|
||||||
|
|
||||||
|
|
||||||
|
def _rebuilt_segment_manifest_entries(session_key: str) -> list[dict[str, Any]]:
|
||||||
|
return [_segment_manifest_entry(session_key, segment_id) for segment_id in _rebuild_segment_manifest(session_key)]
|
||||||
|
|
||||||
|
|
||||||
def _read_segment_manifest_entries(session_key: str) -> list[dict[str, Any]]:
|
def _read_segment_manifest_entries(session_key: str) -> list[dict[str, Any]]:
|
||||||
@@ -318,7 +320,7 @@ def _read_segment_manifest_entries(session_key: str) -> list[dict[str, Any]]:
|
|||||||
return []
|
return []
|
||||||
path = _webui_transcript_manifest_path(session_key)
|
path = _webui_transcript_manifest_path(session_key)
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
return _rebuild_segment_manifest(session_key)
|
return _rebuilt_segment_manifest_entries(session_key)
|
||||||
try:
|
try:
|
||||||
data = json.loads(path.read_text(encoding="utf-8"))
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
manifest = cast(dict[str, Any], data) if isinstance(data, dict) else None
|
manifest = cast(dict[str, Any], data) if isinstance(data, dict) else None
|
||||||
@@ -328,18 +330,18 @@ def _read_segment_manifest_entries(session_key: str) -> list[dict[str, Any]]:
|
|||||||
or manifest.get("version") != _TRANSCRIPT_SEGMENT_MANIFEST_VERSION
|
or manifest.get("version") != _TRANSCRIPT_SEGMENT_MANIFEST_VERSION
|
||||||
or not isinstance(raw_segments, list)
|
or not isinstance(raw_segments, list)
|
||||||
):
|
):
|
||||||
return _rebuild_segment_manifest(session_key)
|
return _rebuilt_segment_manifest_entries(session_key)
|
||||||
entries: list[dict[str, Any]] = []
|
entries: list[dict[str, Any]] = []
|
||||||
for entry in cast(list[Any], raw_segments):
|
for entry in cast(list[Any], raw_segments):
|
||||||
normalized = _normalize_manifest_entry(session_key, entry)
|
normalized = _normalize_manifest_entry(session_key, entry)
|
||||||
if normalized is None:
|
if normalized is None:
|
||||||
return _rebuild_segment_manifest(session_key)
|
return _rebuilt_segment_manifest_entries(session_key)
|
||||||
entries.append(normalized)
|
entries.append(normalized)
|
||||||
if [entry["id"] for entry in entries] != _segment_ids_on_disk(session_key):
|
if [entry["id"] for entry in entries] != _segment_ids_on_disk(session_key):
|
||||||
return _rebuild_segment_manifest(session_key)
|
return _rebuilt_segment_manifest_entries(session_key)
|
||||||
return entries
|
return entries
|
||||||
except (OSError, json.JSONDecodeError, TypeError, AttributeError):
|
except (OSError, json.JSONDecodeError, TypeError, AttributeError):
|
||||||
return _rebuild_segment_manifest(session_key)
|
return _rebuilt_segment_manifest_entries(session_key)
|
||||||
|
|
||||||
|
|
||||||
def _read_segment_ids(session_key: str) -> list[str]:
|
def _read_segment_ids(session_key: str) -> list[str]:
|
||||||
@@ -349,40 +351,26 @@ def _read_segment_ids(session_key: str) -> list[str]:
|
|||||||
def _append_segment_turns(session_key: str, turns: list[list[dict[str, Any]]]) -> None:
|
def _append_segment_turns(session_key: str, turns: list[list[dict[str, Any]]]) -> None:
|
||||||
if not turns:
|
if not turns:
|
||||||
return
|
return
|
||||||
entries = _read_segment_manifest_entries(session_key)
|
segment_ids = _read_segment_ids(session_key)
|
||||||
next_id = int(entries[-1]["id"]) + 1 if entries else 1
|
next_id = int(segment_ids[-1]) + 1 if segment_ids else 1
|
||||||
batch: list[list[dict[str, Any]]] = []
|
batch: list[list[dict[str, Any]]] = []
|
||||||
batch_bytes = 0
|
batch_bytes = 0
|
||||||
|
|
||||||
def write_batch() -> None:
|
|
||||||
nonlocal next_id
|
|
||||||
segment_id = f"{next_id:06d}"
|
|
||||||
path = _segment_file_path(session_key, segment_id)
|
|
||||||
_write_records_to_path(path, _flatten_turns(batch))
|
|
||||||
entries.append({
|
|
||||||
"id": segment_id,
|
|
||||||
"bytes": path.stat().st_size,
|
|
||||||
"turn_count": len(batch),
|
|
||||||
"user_count": sum(
|
|
||||||
1
|
|
||||||
for turn in batch
|
|
||||||
for row in turn
|
|
||||||
if _is_user_transcript_row(row)
|
|
||||||
),
|
|
||||||
})
|
|
||||||
next_id += 1
|
|
||||||
|
|
||||||
for turn in turns:
|
for turn in turns:
|
||||||
turn_bytes = _records_bytes(turn)
|
turn_bytes = _records_bytes(turn)
|
||||||
if batch and batch_bytes + turn_bytes > _MAX_TRANSCRIPT_FILE_BYTES:
|
if batch and batch_bytes + turn_bytes > _MAX_TRANSCRIPT_FILE_BYTES:
|
||||||
write_batch()
|
segment_id = f"{next_id:06d}"
|
||||||
|
_write_records_to_path(_segment_file_path(session_key, segment_id), _flatten_turns(batch))
|
||||||
|
segment_ids.append(segment_id)
|
||||||
|
next_id += 1
|
||||||
batch = []
|
batch = []
|
||||||
batch_bytes = 0
|
batch_bytes = 0
|
||||||
batch.append(turn)
|
batch.append(turn)
|
||||||
batch_bytes += turn_bytes
|
batch_bytes += turn_bytes
|
||||||
if batch:
|
if batch:
|
||||||
write_batch()
|
segment_id = f"{next_id:06d}"
|
||||||
_write_segment_manifest(session_key, entries)
|
_write_records_to_path(_segment_file_path(session_key, segment_id), _flatten_turns(batch))
|
||||||
|
segment_ids.append(segment_id)
|
||||||
|
_write_segment_manifest(session_key, segment_ids)
|
||||||
|
|
||||||
|
|
||||||
def _rotate_active_transcript_if_needed(session_key: str) -> None:
|
def _rotate_active_transcript_if_needed(session_key: str) -> None:
|
||||||
@@ -390,7 +378,7 @@ def _rotate_active_transcript_if_needed(session_key: str) -> None:
|
|||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
if path.stat().st_size <= _ACTIVE_TRANSCRIPT_ROTATE_BYTES:
|
if path.stat().st_size <= _MAX_TRANSCRIPT_FILE_BYTES:
|
||||||
return
|
return
|
||||||
except OSError:
|
except OSError:
|
||||||
return
|
return
|
||||||
@@ -438,16 +426,6 @@ def _read_chunk_turns(session_key: str, chunk_id: str) -> list[list[dict[str, An
|
|||||||
return _split_transcript_turns(_read_transcript_file(path))
|
return _split_transcript_turns(_read_transcript_file(path))
|
||||||
|
|
||||||
|
|
||||||
def _cached_chunk_turns(
|
|
||||||
session_key: str,
|
|
||||||
chunk_id: str,
|
|
||||||
turn_cache: dict[str, list[list[dict[str, Any]]]],
|
|
||||||
) -> list[list[dict[str, Any]]]:
|
|
||||||
if chunk_id not in turn_cache:
|
|
||||||
turn_cache[chunk_id] = _read_chunk_turns(session_key, chunk_id)
|
|
||||||
return turn_cache[chunk_id]
|
|
||||||
|
|
||||||
|
|
||||||
def _encode_page_cursor(before_turn_ordinal: int) -> str:
|
def _encode_page_cursor(before_turn_ordinal: int) -> str:
|
||||||
raw = json.dumps(
|
raw = json.dumps(
|
||||||
{"before_turn": before_turn_ordinal},
|
{"before_turn": before_turn_ordinal},
|
||||||
@@ -484,10 +462,7 @@ def _coerce_page_limit(limit: int | None) -> int:
|
|||||||
return max(1, min(_MAX_TRANSCRIPT_PAGE_LIMIT, int(limit)))
|
return max(1, min(_MAX_TRANSCRIPT_PAGE_LIMIT, int(limit)))
|
||||||
|
|
||||||
|
|
||||||
def _chunk_turn_refs(
|
def _chunk_turn_refs(session_key: str) -> list[_TranscriptChunkRef]:
|
||||||
session_key: str,
|
|
||||||
turn_cache: dict[str, list[list[dict[str, Any]]]],
|
|
||||||
) -> list[_TranscriptChunkRef]:
|
|
||||||
_rotate_active_transcript_if_needed(session_key)
|
_rotate_active_transcript_if_needed(session_key)
|
||||||
refs: list[_TranscriptChunkRef] = []
|
refs: list[_TranscriptChunkRef] = []
|
||||||
ordinal = 0
|
ordinal = 0
|
||||||
@@ -499,11 +474,7 @@ def _chunk_turn_refs(
|
|||||||
refs.append(_TranscriptChunkRef(chunk_id, ordinal, turn_count, int(entry["user_count"])))
|
refs.append(_TranscriptChunkRef(chunk_id, ordinal, turn_count, int(entry["user_count"])))
|
||||||
ordinal += turn_count
|
ordinal += turn_count
|
||||||
if webui_transcript_path(session_key).is_file():
|
if webui_transcript_path(session_key).is_file():
|
||||||
active_turns = _cached_chunk_turns(
|
active_turns = _read_chunk_turns(session_key, _TRANSCRIPT_ACTIVE_CHUNK_ID)
|
||||||
session_key,
|
|
||||||
_TRANSCRIPT_ACTIVE_CHUNK_ID,
|
|
||||||
turn_cache,
|
|
||||||
)
|
|
||||||
active_turn_count = len(active_turns)
|
active_turn_count = len(active_turns)
|
||||||
if active_turn_count > 0:
|
if active_turn_count > 0:
|
||||||
refs.append(
|
refs.append(
|
||||||
@@ -521,7 +492,6 @@ def _count_user_messages_before_ordinal(
|
|||||||
session_key: str,
|
session_key: str,
|
||||||
chunks: list[_TranscriptChunkRef],
|
chunks: list[_TranscriptChunkRef],
|
||||||
before_ordinal: int,
|
before_ordinal: int,
|
||||||
turn_cache: dict[str, list[list[dict[str, Any]]]],
|
|
||||||
) -> int:
|
) -> int:
|
||||||
total = 0
|
total = 0
|
||||||
for chunk in chunks:
|
for chunk in chunks:
|
||||||
@@ -533,7 +503,7 @@ def _count_user_messages_before_ordinal(
|
|||||||
if local_end >= chunk.turn_count:
|
if local_end >= chunk.turn_count:
|
||||||
total += chunk.user_count
|
total += chunk.user_count
|
||||||
continue
|
continue
|
||||||
turns = _cached_chunk_turns(session_key, chunk.chunk_id, turn_cache)
|
turns = _read_chunk_turns(session_key, chunk.chunk_id)
|
||||||
total += sum(
|
total += sum(
|
||||||
1
|
1
|
||||||
for turn in turns[:local_end]
|
for turn in turns[:local_end]
|
||||||
@@ -551,8 +521,7 @@ def _select_transcript_page(
|
|||||||
_manifest_rebuilt: bool = False,
|
_manifest_rebuilt: bool = False,
|
||||||
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
||||||
page_limit = _coerce_page_limit(limit)
|
page_limit = _coerce_page_limit(limit)
|
||||||
turn_cache: dict[str, list[list[dict[str, Any]]]] = {}
|
chunks = _chunk_turn_refs(session_key)
|
||||||
chunks = _chunk_turn_refs(session_key, turn_cache)
|
|
||||||
total_turns = sum(chunk.turn_count for chunk in chunks)
|
total_turns = sum(chunk.turn_count for chunk in chunks)
|
||||||
before_ordinal = _decode_page_cursor(before)
|
before_ordinal = _decode_page_cursor(before)
|
||||||
upper_ordinal = total_turns if before_ordinal is None else min(before_ordinal, total_turns)
|
upper_ordinal = total_turns if before_ordinal is None else min(before_ordinal, total_turns)
|
||||||
@@ -565,7 +534,7 @@ def _select_transcript_page(
|
|||||||
local_upper = min(chunk.turn_count, upper_ordinal - chunk.start_ordinal)
|
local_upper = min(chunk.turn_count, upper_ordinal - chunk.start_ordinal)
|
||||||
if local_upper <= 0:
|
if local_upper <= 0:
|
||||||
continue
|
continue
|
||||||
turns = _cached_chunk_turns(session_key, chunk.chunk_id, turn_cache)
|
turns = _read_chunk_turns(session_key, chunk.chunk_id)
|
||||||
if (
|
if (
|
||||||
chunk.chunk_id != _TRANSCRIPT_ACTIVE_CHUNK_ID
|
chunk.chunk_id != _TRANSCRIPT_ACTIVE_CHUNK_ID
|
||||||
and len(turns) != chunk.turn_count
|
and len(turns) != chunk.turn_count
|
||||||
@@ -616,7 +585,6 @@ def _select_transcript_page(
|
|||||||
session_key,
|
session_key,
|
||||||
chunks,
|
chunks,
|
||||||
first_ref.ordinal,
|
first_ref.ordinal,
|
||||||
turn_cache,
|
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
return lines, page
|
return lines, page
|
||||||
@@ -1214,18 +1182,19 @@ def _is_recoverable_answer_record(record: dict[str, Any]) -> bool:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _needs_incomplete_turn_recovery(lines: list[dict[str, Any]]) -> bool:
|
def recover_incomplete_turns_from_session(
|
||||||
return any(
|
|
||||||
record.get("event") == "turn_end"
|
|
||||||
and record.get(WEBUI_TRANSCRIPT_INCOMPLETE_KEY) is True
|
|
||||||
for record in lines
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _recover_incomplete_turns(
|
|
||||||
lines: list[dict[str, Any]],
|
lines: list[dict[str, Any]],
|
||||||
session_turns: list[_SessionBackfillTurn],
|
session_messages: list[dict[str, Any]] | None,
|
||||||
|
*,
|
||||||
|
session_key: str,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Recover marked transcript answers only when one durable session turn matches."""
|
||||||
|
if not lines or not session_messages:
|
||||||
|
return lines
|
||||||
|
session_turns = _session_backfill_turns(session_key, session_messages)
|
||||||
|
if not session_turns:
|
||||||
|
return lines
|
||||||
|
|
||||||
recovered: list[dict[str, Any]] = []
|
recovered: list[dict[str, Any]] = []
|
||||||
for turn in _split_transcript_turns(lines):
|
for turn in _split_transcript_turns(lines):
|
||||||
turn_end = turn[-1] if turn else None
|
turn_end = turn[-1] if turn else None
|
||||||
@@ -1275,21 +1244,6 @@ def _recover_incomplete_turns(
|
|||||||
return recovered
|
return recovered
|
||||||
|
|
||||||
|
|
||||||
def recover_incomplete_turns_from_session(
|
|
||||||
lines: list[dict[str, Any]],
|
|
||||||
session_messages: list[dict[str, Any]] | None,
|
|
||||||
*,
|
|
||||||
session_key: str,
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
"""Recover marked transcript answers only when one durable session turn matches."""
|
|
||||||
if not lines or not session_messages or not _needs_incomplete_turn_recovery(lines):
|
|
||||||
return lines
|
|
||||||
session_turns = _session_backfill_turns(session_key, session_messages)
|
|
||||||
if not session_turns:
|
|
||||||
return lines
|
|
||||||
return _recover_incomplete_turns(lines, session_turns)
|
|
||||||
|
|
||||||
|
|
||||||
def _with_backfilled_user(
|
def _with_backfilled_user(
|
||||||
records: list[dict[str, Any]],
|
records: list[dict[str, Any]],
|
||||||
user_event: dict[str, Any],
|
user_event: dict[str, Any],
|
||||||
@@ -1300,19 +1254,18 @@ def _with_backfilled_user(
|
|||||||
return records
|
return records
|
||||||
|
|
||||||
|
|
||||||
def _needs_user_event_backfill(lines: list[dict[str, Any]]) -> bool:
|
def inject_missing_user_events_from_session(
|
||||||
for turn in _split_transcript_turns(lines):
|
session_key: str,
|
||||||
if any(record.get("event") == "user" for record in turn):
|
|
||||||
continue
|
|
||||||
if _transcript_turn_signature(turn):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _inject_missing_user_events(
|
|
||||||
lines: list[dict[str, Any]],
|
lines: list[dict[str, Any]],
|
||||||
session_turns: list[_SessionBackfillTurn],
|
session_messages: list[dict[str, Any]] | None,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Backfill user rows for legacy WebUI transcripts that only stored assistant streams."""
|
||||||
|
if not lines or not session_messages:
|
||||||
|
return lines
|
||||||
|
session_turns = _session_backfill_turns(session_key, session_messages)
|
||||||
|
if not session_turns:
|
||||||
|
return lines
|
||||||
|
|
||||||
out: list[dict[str, Any]] = []
|
out: list[dict[str, Any]] = []
|
||||||
session_cursor = 0
|
session_cursor = 0
|
||||||
for turn in _split_transcript_turns(lines):
|
for turn in _split_transcript_turns(lines):
|
||||||
@@ -1327,20 +1280,6 @@ def _inject_missing_user_events(
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def inject_missing_user_events_from_session(
|
|
||||||
session_key: str,
|
|
||||||
lines: list[dict[str, Any]],
|
|
||||||
session_messages: list[dict[str, Any]] | None,
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
"""Backfill user rows for legacy WebUI transcripts that only stored assistant streams."""
|
|
||||||
if not lines or not session_messages or not _needs_user_event_backfill(lines):
|
|
||||||
return lines
|
|
||||||
session_turns = _session_backfill_turns(session_key, session_messages)
|
|
||||||
if not session_turns:
|
|
||||||
return lines
|
|
||||||
return _inject_missing_user_events(lines, session_turns)
|
|
||||||
|
|
||||||
|
|
||||||
def _format_tool_call_trace(call: Any) -> str | None:
|
def _format_tool_call_trace(call: Any) -> str | None:
|
||||||
if not call or not isinstance(call, dict):
|
if not call or not isinstance(call, dict):
|
||||||
return None
|
return None
|
||||||
@@ -2419,7 +2358,6 @@ def build_webui_thread_response(
|
|||||||
augment_assistant_media: Callable[[list[str]], list[dict[str, Any]]] | None = None,
|
augment_assistant_media: Callable[[list[str]], list[dict[str, Any]]] | None = None,
|
||||||
augment_assistant_text: Callable[[str], str] | None = None,
|
augment_assistant_text: Callable[[str], str] | None = None,
|
||||||
session_messages: list[dict[str, Any]] | None = None,
|
session_messages: list[dict[str, Any]] | None = None,
|
||||||
session_messages_loader: Callable[[], list[dict[str, Any]] | None] | None = None,
|
|
||||||
active_turn_started_at: float | None = None,
|
active_turn_started_at: float | None = None,
|
||||||
active_turn_id: str | None = None,
|
active_turn_id: str | None = None,
|
||||||
active_turn_transcript_persistence_failed: bool = False,
|
active_turn_transcript_persistence_failed: bool = False,
|
||||||
@@ -2436,20 +2374,12 @@ def build_webui_thread_response(
|
|||||||
lines = _annotate_replay_identities(read_transcript_lines(session_key))
|
lines = _annotate_replay_identities(read_transcript_lines(session_key))
|
||||||
if not lines and active_turn_started_at is None:
|
if not lines and active_turn_started_at is None:
|
||||||
return None
|
return None
|
||||||
needs_user_backfill = _needs_user_event_backfill(lines)
|
lines = inject_missing_user_events_from_session(session_key, lines, session_messages)
|
||||||
needs_incomplete_recovery = _needs_incomplete_turn_recovery(lines)
|
lines = recover_incomplete_turns_from_session(
|
||||||
if (
|
lines,
|
||||||
session_messages is None
|
session_messages,
|
||||||
and session_messages_loader is not None
|
session_key=session_key,
|
||||||
and (needs_user_backfill or needs_incomplete_recovery)
|
)
|
||||||
):
|
|
||||||
session_messages = session_messages_loader()
|
|
||||||
if session_messages and (needs_user_backfill or needs_incomplete_recovery):
|
|
||||||
session_turns = _session_backfill_turns(session_key, session_messages)
|
|
||||||
if needs_user_backfill:
|
|
||||||
lines = _inject_missing_user_events(lines, session_turns)
|
|
||||||
if needs_incomplete_recovery:
|
|
||||||
lines = _recover_incomplete_turns(lines, session_turns)
|
|
||||||
lines = _ensure_replay_identities(lines)
|
lines = _ensure_replay_identities(lines)
|
||||||
fork_boundary = fork_boundary_message_count(lines)
|
fork_boundary = fork_boundary_message_count(lines)
|
||||||
msgs = replay_transcript_to_ui_messages(
|
msgs = replay_transcript_to_ui_messages(
|
||||||
|
|||||||
+10
-33
@@ -191,47 +191,24 @@ class WebUIWorkspaceController:
|
|||||||
self._default_restrict_to_workspace,
|
self._default_restrict_to_workspace,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _scope_from_metadata_value(
|
def scope_for_session_key(self, session_key: str) -> WorkspaceScope:
|
||||||
self,
|
if self._sessions is None:
|
||||||
raw_scope: object,
|
return self.default_scope()
|
||||||
*,
|
data = self._sessions.read_session_metadata(session_key)
|
||||||
default_scope: WorkspaceScope | None = None,
|
session_data = data if data is not None else {}
|
||||||
) -> WorkspaceScope:
|
metadata = session_data.get("metadata", {})
|
||||||
|
if not isinstance(metadata, dict) or WORKSPACE_SCOPE_METADATA_KEY not in metadata:
|
||||||
|
return self.default_scope()
|
||||||
|
metadata = cast(dict[str, Any], metadata)
|
||||||
try:
|
try:
|
||||||
return validate_workspace_scope_payload(
|
return validate_workspace_scope_payload(
|
||||||
raw_scope,
|
metadata.get(WORKSPACE_SCOPE_METADATA_KEY),
|
||||||
default_workspace=self._default_workspace,
|
default_workspace=self._default_workspace,
|
||||||
default_restrict_to_workspace=self._default_restrict_to_workspace,
|
default_restrict_to_workspace=self._default_restrict_to_workspace,
|
||||||
source_channel=_WEBUI_SCOPE_CHANNEL,
|
source_channel=_WEBUI_SCOPE_CHANNEL,
|
||||||
)
|
)
|
||||||
except WorkspaceScopeError:
|
except WorkspaceScopeError:
|
||||||
return default_scope if default_scope is not None else self.default_scope()
|
|
||||||
|
|
||||||
def scope_for_indexed_metadata(
|
|
||||||
self,
|
|
||||||
raw_scope: object,
|
|
||||||
*,
|
|
||||||
scope_present: bool,
|
|
||||||
default_scope: WorkspaceScope,
|
|
||||||
) -> WorkspaceScope:
|
|
||||||
"""Resolve a sidebar-only metadata snapshot without an authority-store read."""
|
|
||||||
if not scope_present:
|
|
||||||
return default_scope
|
|
||||||
return self._scope_from_metadata_value(raw_scope, default_scope=default_scope)
|
|
||||||
|
|
||||||
def scope_for_session_key(self, session_key: str) -> WorkspaceScope:
|
|
||||||
if self._sessions is None:
|
|
||||||
return self.default_scope()
|
return self.default_scope()
|
||||||
data = self._sessions.read_session_metadata(session_key)
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
return self.default_scope()
|
|
||||||
metadata = data.get("metadata", {})
|
|
||||||
if not isinstance(metadata, dict) or WORKSPACE_SCOPE_METADATA_KEY not in metadata:
|
|
||||||
return self.default_scope()
|
|
||||||
metadata_data = cast(dict[str, Any], metadata)
|
|
||||||
return self._scope_from_metadata_value(
|
|
||||||
cast(object, metadata_data.get(WORKSPACE_SCOPE_METADATA_KEY))
|
|
||||||
)
|
|
||||||
|
|
||||||
def payload(self, *, controls_available: bool) -> dict[str, Any]:
|
def payload(self, *, controls_available: bool) -> dict[str, Any]:
|
||||||
return workspaces_payload(
|
return workspaces_payload(
|
||||||
|
|||||||
+16
-69
@@ -27,7 +27,6 @@ from nanobot.command.builtin import builtin_command_palette
|
|||||||
from nanobot.cron.session_turns import is_bound_cron_job
|
from nanobot.cron.session_turns import is_bound_cron_job
|
||||||
from nanobot.cron.types import CronJob, CronSchedule
|
from nanobot.cron.types import CronJob, CronSchedule
|
||||||
from nanobot.runtime_context import public_history_messages
|
from nanobot.runtime_context import public_history_messages
|
||||||
from nanobot.security.workspace_access import WorkspaceScope
|
|
||||||
from nanobot.triggers.local_types import LocalTrigger
|
from nanobot.triggers.local_types import LocalTrigger
|
||||||
from nanobot.utils.subagent_channel_display import scrub_subagent_messages_for_channel
|
from nanobot.utils.subagent_channel_display import scrub_subagent_messages_for_channel
|
||||||
from nanobot.webui.file_preview import (
|
from nanobot.webui.file_preview import (
|
||||||
@@ -39,9 +38,6 @@ from nanobot.webui.gateway_tokens import GatewayTokenStore, token_response_paylo
|
|||||||
from nanobot.webui.http_utils import (
|
from nanobot.webui.http_utils import (
|
||||||
case_insensitive_header as _case_insensitive_header,
|
case_insensitive_header as _case_insensitive_header,
|
||||||
)
|
)
|
||||||
from nanobot.webui.http_utils import (
|
|
||||||
combined_list_header as _combined_list_header,
|
|
||||||
)
|
|
||||||
from nanobot.webui.http_utils import (
|
from nanobot.webui.http_utils import (
|
||||||
host_for_url as _host_for_url,
|
host_for_url as _host_for_url,
|
||||||
)
|
)
|
||||||
@@ -86,11 +82,7 @@ from nanobot.webui.session_automations import (
|
|||||||
session_automation_jobs,
|
session_automation_jobs,
|
||||||
session_automations_payload,
|
session_automations_payload,
|
||||||
)
|
)
|
||||||
from nanobot.webui.session_list_index import (
|
from nanobot.webui.session_list_index import list_webui_sessions
|
||||||
WEBUI_SESSION_INDEX_INTERNAL_FIELDS,
|
|
||||||
indexed_workspace_scope,
|
|
||||||
list_webui_sessions,
|
|
||||||
)
|
|
||||||
from nanobot.webui.sidebar_state import (
|
from nanobot.webui.sidebar_state import (
|
||||||
read_webui_sidebar_state,
|
read_webui_sidebar_state,
|
||||||
write_webui_sidebar_state,
|
write_webui_sidebar_state,
|
||||||
@@ -116,30 +108,6 @@ from nanobot.webui.workspaces import WebUIWorkspaceController
|
|||||||
_SLOW_WEBUI_HTTP_LOG_MS = 1_000
|
_SLOW_WEBUI_HTTP_LOG_MS = 1_000
|
||||||
_AUTOMATION_VALUES_HEADER = "X-Nanobot-Automation-Values"
|
_AUTOMATION_VALUES_HEADER = "X-Nanobot-Automation-Values"
|
||||||
|
|
||||||
# Fix for #5190: On Windows, mimetypes.guess_type() reads the registry key
|
|
||||||
# HKEY_CLASSES_ROOT\.js\Content Type, which is commonly set to 'text/plain'
|
|
||||||
# because .js is associated with Windows Script Host rather than web JavaScript.
|
|
||||||
# That registry value overrides Python's built-in mapping and causes browsers to
|
|
||||||
# reject ES module scripts with:
|
|
||||||
# Failed to load module script: Expected a JavaScript-or-Wasm module script
|
|
||||||
# but the server responded with a MIME type of "text/plain".
|
|
||||||
# We explicitly register correct MIME types for common web static assets here
|
|
||||||
# (module-import time) so all callers of mimetypes.guess_type() in this process
|
|
||||||
# benefit, regardless of host registry configuration.
|
|
||||||
_MIME_FIXES: dict[str, str] = {
|
|
||||||
".js": "application/javascript",
|
|
||||||
".mjs": "application/javascript",
|
|
||||||
".css": "text/css",
|
|
||||||
".html": "text/html",
|
|
||||||
".json": "application/json",
|
|
||||||
".svg": "image/svg+xml",
|
|
||||||
".wasm": "application/wasm",
|
|
||||||
}
|
|
||||||
|
|
||||||
for _ext, _ctype in _MIME_FIXES.items():
|
|
||||||
mimetypes.add_type(_ctype, _ext, strict=True)
|
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels.websocket.runtime import WebSocketConfig
|
from nanobot.channels.websocket.runtime import WebSocketConfig
|
||||||
@@ -147,6 +115,7 @@ if TYPE_CHECKING:
|
|||||||
from nanobot.session.manager import SessionManager
|
from nanobot.session.manager import SessionManager
|
||||||
from nanobot.triggers.local_store import LocalTriggerStore
|
from nanobot.triggers.local_store import LocalTriggerStore
|
||||||
|
|
||||||
|
|
||||||
def _decode_api_key(raw_key: str) -> str | None:
|
def _decode_api_key(raw_key: str) -> str | None:
|
||||||
key = unquote(raw_key)
|
key = unquote(raw_key)
|
||||||
_api_key_re = re.compile(r"^[A-Za-z0-9_:.-]{1,128}$")
|
_api_key_re = re.compile(r"^[A-Za-z0-9_:.-]{1,128}$")
|
||||||
@@ -453,10 +422,7 @@ class GatewayHTTPHandler:
|
|||||||
if self.session_manager is None:
|
if self.session_manager is None:
|
||||||
return _http_error(503, "session manager unavailable")
|
return _http_error(503, "session manager unavailable")
|
||||||
payload = await asyncio.to_thread(self._sessions_list_payload)
|
payload = await asyncio.to_thread(self._sessions_list_payload)
|
||||||
return _http_json_response(
|
return _http_json_response(payload)
|
||||||
payload,
|
|
||||||
accept_encoding=_combined_list_header(request.headers, "Accept-Encoding"),
|
|
||||||
)
|
|
||||||
|
|
||||||
def _sessions_list_payload(self) -> dict[str, Any]:
|
def _sessions_list_payload(self) -> dict[str, Any]:
|
||||||
assert self.session_manager is not None
|
assert self.session_manager is not None
|
||||||
@@ -464,28 +430,16 @@ class GatewayHTTPHandler:
|
|||||||
from nanobot.session.webui_turns import websocket_turn_wall_started_at
|
from nanobot.session.webui_turns import websocket_turn_wall_started_at
|
||||||
|
|
||||||
cleaned: list[dict[str, Any]] = []
|
cleaned: list[dict[str, Any]] = []
|
||||||
default_scope: WorkspaceScope | None = None
|
|
||||||
for s in sessions:
|
for s in sessions:
|
||||||
key = s.get("key")
|
key = s.get("key")
|
||||||
if not (isinstance(key, str) and key.startswith("websocket:")):
|
if not (isinstance(key, str) and key.startswith("websocket:")):
|
||||||
continue
|
continue
|
||||||
row = {
|
row = {k: v for k, v in s.items() if k != "path"}
|
||||||
k: v
|
|
||||||
for k, v in s.items()
|
|
||||||
if k != "path" and k not in WEBUI_SESSION_INDEX_INTERNAL_FIELDS
|
|
||||||
}
|
|
||||||
chat_id = key.split(":", 1)[1]
|
chat_id = key.split(":", 1)[1]
|
||||||
started_at = websocket_turn_wall_started_at(chat_id)
|
started_at = websocket_turn_wall_started_at(chat_id)
|
||||||
if started_at is not None:
|
if started_at is not None:
|
||||||
row["run_started_at"] = started_at
|
row["run_started_at"] = started_at
|
||||||
if default_scope is None:
|
scope = self.workspaces.scope_for_session_key(key)
|
||||||
default_scope = self.workspaces.default_scope()
|
|
||||||
scope_present, raw_scope = indexed_workspace_scope(s)
|
|
||||||
scope = self.workspaces.scope_for_indexed_metadata(
|
|
||||||
raw_scope,
|
|
||||||
scope_present=scope_present,
|
|
||||||
default_scope=default_scope,
|
|
||||||
)
|
|
||||||
row["workspace_scope"] = scope.payload()
|
row["workspace_scope"] = scope.payload()
|
||||||
cleaned.append(row)
|
cleaned.append(row)
|
||||||
return {"sessions": cleaned}
|
return {"sessions": cleaned}
|
||||||
@@ -527,21 +481,17 @@ class GatewayHTTPHandler:
|
|||||||
if not _is_websocket_channel_session_key(decoded_key):
|
if not _is_websocket_channel_session_key(decoded_key):
|
||||||
return _http_error(404, "session not found")
|
return _http_error(404, "session not found")
|
||||||
scope = self.workspaces.scope_for_session_key(decoded_key)
|
scope = self.workspaces.scope_for_session_key(decoded_key)
|
||||||
|
session_messages: list[dict[str, Any]] | None = None
|
||||||
def load_session_messages() -> list[dict[str, Any]] | None:
|
if self.session_manager is not None:
|
||||||
if self.session_manager is None:
|
|
||||||
return None
|
|
||||||
session_data = self.session_manager.read_session_file(decoded_key)
|
session_data = self.session_manager.read_session_file(decoded_key)
|
||||||
raw_messages = session_data.get("messages") if isinstance(session_data, dict) else None
|
raw_messages = session_data.get("messages") if isinstance(session_data, dict) else None
|
||||||
if not isinstance(raw_messages, list):
|
if isinstance(raw_messages, list):
|
||||||
return None
|
raw_session_messages = cast(list[Any], raw_messages)
|
||||||
raw_session_messages = cast(list[Any], raw_messages)
|
session_messages = [
|
||||||
return [
|
cast(dict[str, Any], raw_message)
|
||||||
cast(dict[str, Any], raw_message)
|
for raw_message in raw_session_messages
|
||||||
for raw_message in raw_session_messages
|
if isinstance(raw_message, dict)
|
||||||
if isinstance(raw_message, dict)
|
]
|
||||||
]
|
|
||||||
|
|
||||||
query = _parse_query(request.path)
|
query = _parse_query(request.path)
|
||||||
raw_limit = _query_first(query, "limit")
|
raw_limit = _query_first(query, "limit")
|
||||||
limit: int | None = None
|
limit: int | None = None
|
||||||
@@ -574,7 +524,7 @@ class GatewayHTTPHandler:
|
|||||||
text,
|
text,
|
||||||
workspace_path=scope.project_path,
|
workspace_path=scope.project_path,
|
||||||
),
|
),
|
||||||
session_messages_loader=load_session_messages,
|
session_messages=session_messages,
|
||||||
active_turn_started_at=active_turn_started_at,
|
active_turn_started_at=active_turn_started_at,
|
||||||
active_turn_id=active_turn_id,
|
active_turn_id=active_turn_id,
|
||||||
active_turn_transcript_persistence_failed=(
|
active_turn_transcript_persistence_failed=(
|
||||||
@@ -587,10 +537,7 @@ class GatewayHTTPHandler:
|
|||||||
if data is None:
|
if data is None:
|
||||||
return _http_error(404, "webui thread not found")
|
return _http_error(404, "webui thread not found")
|
||||||
data["workspace_scope"] = scope.payload()
|
data["workspace_scope"] = scope.payload()
|
||||||
return _http_json_response(
|
return _http_json_response(data)
|
||||||
data,
|
|
||||||
accept_encoding=_combined_list_header(request.headers, "Accept-Encoding"),
|
|
||||||
)
|
|
||||||
|
|
||||||
def _handle_file_preview(self, request: WsRequest, key: str) -> Response:
|
def _handle_file_preview(self, request: WsRequest, key: str) -> Response:
|
||||||
if not self.check_api_token(request):
|
if not self.check_api_token(request):
|
||||||
|
|||||||
+1
-1
@@ -24,7 +24,7 @@ license-files = [
|
|||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"typer>=0.20.0,<1.0.0",
|
"typer>=0.20.0,<1.0.0",
|
||||||
"anthropic>=0.100.0,<1.0.0",
|
"anthropic>=0.45.0,<1.0.0",
|
||||||
"pydantic>=2.12.0,<3.0.0",
|
"pydantic>=2.12.0,<3.0.0",
|
||||||
"pydantic-settings>=2.12.0,<3.0.0",
|
"pydantic-settings>=2.12.0,<3.0.0",
|
||||||
# Feishu's lark-oapi currently requires websockets<16; core supports 15 and 16.
|
# Feishu's lark-oapi currently requires websockets<16; core supports 15 and 16.
|
||||||
|
|||||||
@@ -15,6 +15,20 @@ def _builder(tmp_path: Path, **kw) -> ContextBuilder:
|
|||||||
return ContextBuilder(workspace=tmp_path, **kw)
|
return ContextBuilder(workspace=tmp_path, **kw)
|
||||||
|
|
||||||
|
|
||||||
|
def test_conversation_only_messages_omit_the_system_prompt(tmp_path) -> None:
|
||||||
|
(tmp_path / "AGENTS.md").write_text("SECRET PROJECT INSTRUCTIONS", encoding="utf-8")
|
||||||
|
builder = _builder(tmp_path)
|
||||||
|
|
||||||
|
messages = builder.build_messages(
|
||||||
|
[],
|
||||||
|
"hello",
|
||||||
|
conversation_only=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert messages == [{"role": "user", "content": "hello"}]
|
||||||
|
assert "SECRET PROJECT INSTRUCTIONS" not in str(messages)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# _merge_message_content (static)
|
# _merge_message_content (static)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -55,62 +55,6 @@ class TestHandleStop:
|
|||||||
out = await cmd_stop(ctx)
|
out = await cmd_stop(ctx)
|
||||||
assert "No active task" in out.content
|
assert "No active task" in out.content
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_close_mcp_cancels_active_turn_before_resources(self):
|
|
||||||
loop, _bus = _make_loop()
|
|
||||||
events: list[str] = []
|
|
||||||
|
|
||||||
async def active_turn():
|
|
||||||
try:
|
|
||||||
await asyncio.sleep(60)
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
events.append("turn_cancelled")
|
|
||||||
raise
|
|
||||||
|
|
||||||
task = asyncio.create_task(active_turn())
|
|
||||||
await asyncio.sleep(0)
|
|
||||||
loop._active_tasks["test:c1"] = {task}
|
|
||||||
|
|
||||||
async def close_subagents():
|
|
||||||
events.append("resources_closed")
|
|
||||||
|
|
||||||
loop.subagents.close = close_subagents
|
|
||||||
loop._exec_session_manager.close_all = AsyncMock()
|
|
||||||
with patch("nanobot.agent.loop.agent_context.close_mcp", AsyncMock()):
|
|
||||||
await loop.close_mcp()
|
|
||||||
|
|
||||||
assert events == ["turn_cancelled", "resources_closed"]
|
|
||||||
assert task.cancelled()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_close_mcp_serializes_duplicate_cleanup(self):
|
|
||||||
loop, _bus = _make_loop()
|
|
||||||
entered = asyncio.Event()
|
|
||||||
release = asyncio.Event()
|
|
||||||
concurrent = 0
|
|
||||||
max_concurrent = 0
|
|
||||||
|
|
||||||
async def close_subagents():
|
|
||||||
nonlocal concurrent, max_concurrent
|
|
||||||
concurrent += 1
|
|
||||||
max_concurrent = max(max_concurrent, concurrent)
|
|
||||||
entered.set()
|
|
||||||
await release.wait()
|
|
||||||
concurrent -= 1
|
|
||||||
|
|
||||||
loop.subagents.close = close_subagents
|
|
||||||
loop._exec_session_manager.close_all = AsyncMock()
|
|
||||||
with patch("nanobot.agent.loop.agent_context.close_mcp", AsyncMock()):
|
|
||||||
first = asyncio.create_task(loop.close_mcp())
|
|
||||||
await entered.wait()
|
|
||||||
second = asyncio.create_task(loop.close_mcp())
|
|
||||||
await asyncio.sleep(0)
|
|
||||||
assert not second.done()
|
|
||||||
release.set()
|
|
||||||
await asyncio.gather(first, second)
|
|
||||||
|
|
||||||
assert max_concurrent == 1
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_stop_cancels_active_task(self):
|
async def test_stop_cancels_active_task(self):
|
||||||
from nanobot.bus.events import InboundMessage
|
from nanobot.bus.events import InboundMessage
|
||||||
@@ -167,8 +111,50 @@ class TestHandleStop:
|
|||||||
assert all(e.is_set() for e in events)
|
assert all(e.is_set() for e in events)
|
||||||
assert "2 task" in out.content
|
assert "2 task" in out.content
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cancel_active_turn_discards_pending_followups(self):
|
||||||
|
from nanobot.bus.events import InboundMessage
|
||||||
|
|
||||||
|
loop, _ = _make_loop()
|
||||||
|
pending = asyncio.Queue()
|
||||||
|
pending.put_nowait(
|
||||||
|
InboundMessage(channel="test", sender_id="u1", chat_id="c1", content="next")
|
||||||
|
)
|
||||||
|
loop._pending_queues["test:c1"] = pending
|
||||||
|
|
||||||
|
assert await loop.cancel_active_turn("test:c1") == 1
|
||||||
|
assert "test:c1" not in loop._pending_queues
|
||||||
|
|
||||||
|
|
||||||
class TestDispatch:
|
class TestDispatch:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_drops_deactivated_transient_message(self):
|
||||||
|
from nanobot.bus.events import InboundMessage
|
||||||
|
|
||||||
|
loop, bus = _make_loop()
|
||||||
|
msg = InboundMessage(
|
||||||
|
channel="websocket",
|
||||||
|
sender_id="u1",
|
||||||
|
chat_id="temporary-test",
|
||||||
|
content="private",
|
||||||
|
session_key_override="websocket:temporary-test",
|
||||||
|
transient_session=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def consume_once():
|
||||||
|
loop.stop()
|
||||||
|
return msg
|
||||||
|
|
||||||
|
bus.consume_inbound = AsyncMock(side_effect=consume_once)
|
||||||
|
loop.sessions.is_transient_active.return_value = False
|
||||||
|
loop._dispatch = AsyncMock()
|
||||||
|
loop.close_mcp = AsyncMock()
|
||||||
|
loop._running = True
|
||||||
|
|
||||||
|
await loop.run()
|
||||||
|
|
||||||
|
loop._dispatch.assert_not_awaited()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_run_logs_and_continues_after_leaked_cancelled_error(self, monkeypatch):
|
async def test_run_logs_and_continues_after_leaked_cancelled_error(self, monkeypatch):
|
||||||
loop, bus = _make_loop()
|
loop, bus = _make_loop()
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import asyncio
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nanobot.agent.loop import AgentLoop
|
||||||
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
|
from nanobot.bus.events import InboundMessage
|
||||||
|
from nanobot.bus.queue import MessageBus
|
||||||
|
from nanobot.providers.base import GenerationSettings, LLMResponse
|
||||||
|
from nanobot.runtime_context import RuntimeContextBlock
|
||||||
|
from nanobot.session.manager import SessionManager
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_temporary_chat_reuses_memory_only_history_without_tools(tmp_path) -> None:
|
||||||
|
(tmp_path / "AGENTS.md").write_text("private project instruction", encoding="utf-8")
|
||||||
|
provider = MagicMock()
|
||||||
|
provider.get_default_model.return_value = "test-model"
|
||||||
|
provider.generation = GenerationSettings()
|
||||||
|
provider.chat_with_retry = AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
LLMResponse(content="first answer", usage={}),
|
||||||
|
LLMResponse(content="second answer", usage={}),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
loop = AgentLoop(
|
||||||
|
bus=MessageBus(),
|
||||||
|
provider=provider,
|
||||||
|
workspace=tmp_path,
|
||||||
|
model="test-model",
|
||||||
|
unified_session=True,
|
||||||
|
)
|
||||||
|
key = "websocket:temporary-test"
|
||||||
|
loop.sessions.get_or_create_transient(key)
|
||||||
|
|
||||||
|
for content in ("first question", "second question"):
|
||||||
|
response = await loop._process_message(
|
||||||
|
InboundMessage(
|
||||||
|
channel="websocket",
|
||||||
|
sender_id="user",
|
||||||
|
chat_id="temporary-test",
|
||||||
|
content=content,
|
||||||
|
session_key_override=key,
|
||||||
|
transient_session=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert response is not None
|
||||||
|
|
||||||
|
first_call, second_call = provider.chat_with_retry.await_args_list
|
||||||
|
assert first_call.kwargs["tools"] == []
|
||||||
|
assert second_call.kwargs["tools"] == []
|
||||||
|
assert all(
|
||||||
|
message["role"] != "system"
|
||||||
|
for call in (first_call, second_call)
|
||||||
|
for message in call.kwargs["messages"]
|
||||||
|
)
|
||||||
|
assert "private project instruction" not in str(first_call.kwargs["messages"])
|
||||||
|
assert str(tmp_path) not in str(first_call.kwargs["messages"])
|
||||||
|
assert "first answer" in str(second_call.kwargs["messages"])
|
||||||
|
|
||||||
|
transient = loop.sessions.get_cached(key)
|
||||||
|
assert transient is not None
|
||||||
|
assert [message["role"] for message in transient.messages] == [
|
||||||
|
"user",
|
||||||
|
"assistant",
|
||||||
|
"user",
|
||||||
|
"assistant",
|
||||||
|
]
|
||||||
|
assert loop.sessions.read_session_file(key) is None
|
||||||
|
assert SessionManager(tmp_path).read_session_file(key) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_temporary_follow_up_does_not_resolve_runtime_context(tmp_path) -> None:
|
||||||
|
provider = MagicMock()
|
||||||
|
provider.get_default_model.return_value = "test-model"
|
||||||
|
provider.generation = GenerationSettings()
|
||||||
|
provider.chat_with_retry = AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
LLMResponse(content="first answer", usage={}),
|
||||||
|
LLMResponse(content="second answer", usage={}),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
loop = AgentLoop(
|
||||||
|
bus=MessageBus(),
|
||||||
|
provider=provider,
|
||||||
|
workspace=tmp_path,
|
||||||
|
model="test-model",
|
||||||
|
)
|
||||||
|
runtime_context_provider = AsyncMock(
|
||||||
|
return_value=RuntimeContextBlock(
|
||||||
|
source="project",
|
||||||
|
content="SECRET LOCAL PROJECT CONTEXT",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
loop.register_runtime_context_provider(runtime_context_provider)
|
||||||
|
|
||||||
|
key = "websocket:temporary-follow-up"
|
||||||
|
session = loop.sessions.get_or_create_transient(key)
|
||||||
|
pending_queue: asyncio.Queue[InboundMessage] = asyncio.Queue()
|
||||||
|
await pending_queue.put(
|
||||||
|
InboundMessage(
|
||||||
|
channel="websocket",
|
||||||
|
sender_id="user",
|
||||||
|
chat_id="temporary-follow-up",
|
||||||
|
content="follow up",
|
||||||
|
session_key_override=key,
|
||||||
|
transient_session=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
_, _, messages, _, _ = await loop._run_agent_loop(
|
||||||
|
[{"role": "user", "content": "first question"}],
|
||||||
|
runtime=loop.llm_runtime(),
|
||||||
|
session=session,
|
||||||
|
channel="websocket",
|
||||||
|
chat_id="temporary-follow-up",
|
||||||
|
session_key=key,
|
||||||
|
pending_queue=pending_queue,
|
||||||
|
tools=ToolRegistry(),
|
||||||
|
)
|
||||||
|
|
||||||
|
runtime_context_provider.assert_not_awaited()
|
||||||
|
assert "SECRET LOCAL PROJECT CONTEXT" not in str(messages)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_discarding_active_temporary_chat_does_not_create_durable_session(
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
|
provider_started = asyncio.Event()
|
||||||
|
provider = MagicMock()
|
||||||
|
provider.get_default_model.return_value = "test-model"
|
||||||
|
provider.generation = GenerationSettings()
|
||||||
|
|
||||||
|
async def block_provider(**_kwargs):
|
||||||
|
provider_started.set()
|
||||||
|
await asyncio.Event().wait()
|
||||||
|
|
||||||
|
provider.chat_with_retry = AsyncMock(side_effect=block_provider)
|
||||||
|
loop = AgentLoop(
|
||||||
|
bus=MessageBus(),
|
||||||
|
provider=provider,
|
||||||
|
workspace=tmp_path,
|
||||||
|
model="test-model",
|
||||||
|
)
|
||||||
|
key = "websocket:temporary-cancelled"
|
||||||
|
loop.sessions.get_or_create_transient(key)
|
||||||
|
message = InboundMessage(
|
||||||
|
channel="websocket",
|
||||||
|
sender_id="user",
|
||||||
|
chat_id="temporary-cancelled",
|
||||||
|
content="private",
|
||||||
|
session_key_override=key,
|
||||||
|
transient_session=True,
|
||||||
|
)
|
||||||
|
task = asyncio.create_task(loop._dispatch(message))
|
||||||
|
active_tasks = loop._active_tasks.setdefault(key, set())
|
||||||
|
active_tasks.add(task)
|
||||||
|
task.add_done_callback(active_tasks.discard)
|
||||||
|
|
||||||
|
await provider_started.wait()
|
||||||
|
assert loop.sessions.discard_transient(key)
|
||||||
|
assert await loop.cancel_active_turn(key) == 1
|
||||||
|
|
||||||
|
assert loop.sessions.get_cached(key) is None
|
||||||
|
assert loop.sessions.flush_all() == 0
|
||||||
|
assert loop.sessions.read_session_file(key) is None
|
||||||
@@ -253,7 +253,7 @@ class TestCmdNewUnifiedSession:
|
|||||||
loop = SimpleNamespace(
|
loop = SimpleNamespace(
|
||||||
sessions=sessions,
|
sessions=sessions,
|
||||||
consolidator=SimpleNamespace(archive=AsyncMock(return_value=True)),
|
consolidator=SimpleNamespace(archive=AsyncMock(return_value=True)),
|
||||||
_cancel_active_tasks=AsyncMock(return_value=0),
|
cancel_active_turn=AsyncMock(return_value=0),
|
||||||
llm_runtime=MagicMock(return_value=MagicMock()),
|
llm_runtime=MagicMock(return_value=MagicMock()),
|
||||||
schedule_background=lambda coro: asyncio.ensure_future(coro),
|
schedule_background=lambda coro: asyncio.ensure_future(coro),
|
||||||
)
|
)
|
||||||
@@ -301,7 +301,7 @@ class TestCmdNewUnifiedSession:
|
|||||||
loop = SimpleNamespace(
|
loop = SimpleNamespace(
|
||||||
sessions=sessions,
|
sessions=sessions,
|
||||||
consolidator=SimpleNamespace(archive=AsyncMock(return_value=True)),
|
consolidator=SimpleNamespace(archive=AsyncMock(return_value=True)),
|
||||||
_cancel_active_tasks=AsyncMock(return_value=0),
|
cancel_active_turn=AsyncMock(return_value=0),
|
||||||
runtime_for_session=MagicMock(return_value=MagicMock()),
|
runtime_for_session=MagicMock(return_value=MagicMock()),
|
||||||
schedule_background=lambda coro: asyncio.ensure_future(coro),
|
schedule_background=lambda coro: asyncio.ensure_future(coro),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2479,69 +2479,6 @@ def test_optional_features_payload_preserves_legacy_flat_feishu_config(monkeypat
|
|||||||
assert "instances" not in saved
|
assert "instances" not in saved
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"index_url",
|
|
||||||
[
|
|
||||||
"",
|
|
||||||
"https://mirror.example/simple",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_enable_uses_uv_when_tool_environment_has_no_pip(
|
|
||||||
monkeypatch,
|
|
||||||
index_url,
|
|
||||||
):
|
|
||||||
from nanobot import optional_features
|
|
||||||
|
|
||||||
calls: list[list[str]] = []
|
|
||||||
call_envs: list[dict[str, str] | None] = []
|
|
||||||
|
|
||||||
def _run(
|
|
||||||
argv: list[str],
|
|
||||||
*,
|
|
||||||
env: dict[str, str] | None = None,
|
|
||||||
) -> subprocess.CompletedProcess[str]:
|
|
||||||
calls.append(argv)
|
|
||||||
call_envs.append(env)
|
|
||||||
if len(calls) == 1:
|
|
||||||
return subprocess.CompletedProcess(argv, 1, stdout="", stderr="No module named pip")
|
|
||||||
if argv[0] == "uv":
|
|
||||||
return subprocess.CompletedProcess(argv, 0, stdout="", stderr="")
|
|
||||||
return subprocess.CompletedProcess(
|
|
||||||
argv,
|
|
||||||
1,
|
|
||||||
stdout="",
|
|
||||||
stderr="No module named ensurepip",
|
|
||||||
)
|
|
||||||
|
|
||||||
monkeypatch.setattr("shutil.which", lambda name: "uv" if name == "uv" else None)
|
|
||||||
monkeypatch.setenv("HTTPS_PROXY", "http://proxy.example:8080")
|
|
||||||
monkeypatch.delenv("UV_INDEX_URL", raising=False)
|
|
||||||
if index_url:
|
|
||||||
monkeypatch.setenv("PIP_INDEX_URL", index_url)
|
|
||||||
else:
|
|
||||||
monkeypatch.delenv("PIP_INDEX_URL", raising=False)
|
|
||||||
|
|
||||||
assert optional_features.install_extra("feishu", ["lark-oapi>=1.5.0"], runner=_run).ok is True
|
|
||||||
assert calls == [
|
|
||||||
[sys.executable, "-m", "pip", "install", "lark-oapi>=1.5.0"],
|
|
||||||
[
|
|
||||||
"uv",
|
|
||||||
"pip",
|
|
||||||
"install",
|
|
||||||
"--python",
|
|
||||||
sys.executable,
|
|
||||||
"lark-oapi>=1.5.0",
|
|
||||||
],
|
|
||||||
]
|
|
||||||
assert call_envs[0] is None
|
|
||||||
assert call_envs[1] is not None
|
|
||||||
assert call_envs[1]["HTTPS_PROXY"] == "http://proxy.example:8080"
|
|
||||||
if index_url:
|
|
||||||
assert call_envs[1]["UV_INDEX_URL"] == index_url
|
|
||||||
else:
|
|
||||||
assert "UV_INDEX_URL" not in call_envs[1]
|
|
||||||
|
|
||||||
|
|
||||||
def test_enable_bootstraps_pip_with_ensurepip(monkeypatch):
|
def test_enable_bootstraps_pip_with_ensurepip(monkeypatch):
|
||||||
from nanobot import optional_features
|
from nanobot import optional_features
|
||||||
|
|
||||||
@@ -2553,8 +2490,6 @@ def test_enable_bootstraps_pip_with_ensurepip(monkeypatch):
|
|||||||
return subprocess.CompletedProcess(argv, 1, stdout="", stderr="No module named pip")
|
return subprocess.CompletedProcess(argv, 1, stdout="", stderr="No module named pip")
|
||||||
return subprocess.CompletedProcess(argv, 0, stdout="", stderr="")
|
return subprocess.CompletedProcess(argv, 0, stdout="", stderr="")
|
||||||
|
|
||||||
monkeypatch.setattr("shutil.which", lambda _name: None)
|
|
||||||
|
|
||||||
assert optional_features.install_extra("bedrock", None, runner=_run).ok is True
|
assert optional_features.install_extra("bedrock", None, runner=_run).ok is True
|
||||||
assert calls == [
|
assert calls == [
|
||||||
[sys.executable, "-m", "pip", "install", "nanobot-ai[bedrock]"],
|
[sys.executable, "-m", "pip", "install", "nanobot-ai[bedrock]"],
|
||||||
|
|||||||
@@ -1,186 +0,0 @@
|
|||||||
"""Regression tests for gateway runtime resource teardown on stop.
|
|
||||||
|
|
||||||
Covers the lifecycle contract of ``_close_gateway_runtime``: runtime tasks
|
|
||||||
(including the agent loop and in-flight turns) are cancelled and awaited --
|
|
||||||
bounded -- before exec sessions, subagents, and MCP servers are closed, the
|
|
||||||
close is deterministic and idempotent, and a stuck or failing cleanup cannot
|
|
||||||
block the stop.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import time
|
|
||||||
from contextlib import suppress
|
|
||||||
|
|
||||||
from nanobot.cli.gateway_runtime import _close_gateway_runtime
|
|
||||||
|
|
||||||
|
|
||||||
class _FakeAgent:
|
|
||||||
def __init__(self, events: list[str] | None = None) -> None:
|
|
||||||
self.close_calls = 0
|
|
||||||
self.events = events if events is not None else []
|
|
||||||
self.hang_on_close = False
|
|
||||||
self.raise_on_close = False
|
|
||||||
self.background: asyncio.Task[None] | None = None
|
|
||||||
|
|
||||||
async def close_mcp(self) -> None:
|
|
||||||
self.close_calls += 1
|
|
||||||
if self.hang_on_close:
|
|
||||||
await asyncio.sleep(3600)
|
|
||||||
if self.raise_on_close:
|
|
||||||
raise RuntimeError("cleanup exploded")
|
|
||||||
if self.background is not None:
|
|
||||||
await self.background
|
|
||||||
self.events.append("close_mcp")
|
|
||||||
|
|
||||||
|
|
||||||
class _FakeChannels:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.stopped = 0
|
|
||||||
self.events: list[str] = []
|
|
||||||
|
|
||||||
async def stop_all(self) -> None:
|
|
||||||
self.stopped += 1
|
|
||||||
self.events.append("channels_stopped")
|
|
||||||
|
|
||||||
|
|
||||||
async def _cancellable_task(events: list[str]) -> None:
|
|
||||||
try:
|
|
||||||
await asyncio.sleep(3600)
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
events.append("cancelled")
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
async def _stubborn_task(events: list[str]) -> None:
|
|
||||||
"""Task that swallows cancellation and keeps running."""
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
await asyncio.sleep(3600)
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
events.append("swallowed")
|
|
||||||
await asyncio.sleep(3600)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_runtime_tasks_cancelled_before_resources_closed() -> None:
|
|
||||||
events: list[str] = []
|
|
||||||
agent = _FakeAgent(events)
|
|
||||||
channels = _FakeChannels()
|
|
||||||
task = asyncio.create_task(_cancellable_task(events))
|
|
||||||
await asyncio.sleep(0) # let the task start (cancellation pre-start skips its body)
|
|
||||||
|
|
||||||
await _close_gateway_runtime(agent, channels, [task], None)
|
|
||||||
|
|
||||||
assert events == ["cancelled", "close_mcp"] # cancel happens before close
|
|
||||||
assert channels.stopped == 1
|
|
||||||
assert agent.close_calls == 1
|
|
||||||
assert task.cancelled()
|
|
||||||
|
|
||||||
|
|
||||||
async def test_pending_background_work_is_drained_before_close_returns() -> None:
|
|
||||||
agent = _FakeAgent()
|
|
||||||
channels = _FakeChannels()
|
|
||||||
done: dict[str, bool] = {"done": False}
|
|
||||||
|
|
||||||
async def background_work() -> None:
|
|
||||||
await asyncio.sleep(0.01)
|
|
||||||
done["done"] = True
|
|
||||||
|
|
||||||
agent.background = asyncio.create_task(background_work())
|
|
||||||
|
|
||||||
await _close_gateway_runtime(agent, channels, [], None)
|
|
||||||
|
|
||||||
assert done["done"] is True
|
|
||||||
assert agent.close_calls == 1
|
|
||||||
|
|
||||||
|
|
||||||
async def test_stubborn_task_does_not_block_past_wait_timeout() -> None:
|
|
||||||
agent = _FakeAgent()
|
|
||||||
channels = _FakeChannels()
|
|
||||||
events: list[str] = []
|
|
||||||
task = asyncio.create_task(_stubborn_task(events))
|
|
||||||
await asyncio.sleep(0) # let the task start (cancellation pre-start skips its body)
|
|
||||||
runtime_tasks = asyncio.gather(task)
|
|
||||||
|
|
||||||
start = time.monotonic()
|
|
||||||
await _close_gateway_runtime(
|
|
||||||
agent,
|
|
||||||
channels,
|
|
||||||
[task],
|
|
||||||
runtime_tasks,
|
|
||||||
task_wait_timeout=0.05,
|
|
||||||
)
|
|
||||||
elapsed = time.monotonic() - start
|
|
||||||
for _ in range(10):
|
|
||||||
await asyncio.sleep(0) # let the swallowed cancellation handler run
|
|
||||||
|
|
||||||
assert "swallowed" in events # task was cancelled, then refused to die
|
|
||||||
assert task.done() # the timed-out task received a second cancellation
|
|
||||||
assert runtime_tasks.done()
|
|
||||||
assert agent.close_calls == 1 # resources still closed underneath it
|
|
||||||
assert elapsed < 1.0 # bounded, not held open by the stubborn task
|
|
||||||
|
|
||||||
|
|
||||||
async def test_hanging_close_is_bounded_and_does_not_raise() -> None:
|
|
||||||
agent = _FakeAgent()
|
|
||||||
agent.hang_on_close = True
|
|
||||||
channels = _FakeChannels()
|
|
||||||
|
|
||||||
start = time.monotonic()
|
|
||||||
await _close_gateway_runtime(agent, channels, [], None, close_timeout=0.05)
|
|
||||||
elapsed = time.monotonic() - start
|
|
||||||
|
|
||||||
assert agent.close_calls == 1
|
|
||||||
assert channels.stopped == 1
|
|
||||||
assert elapsed < 1.0
|
|
||||||
|
|
||||||
|
|
||||||
async def test_failing_close_is_logged_but_shutdown_proceeds() -> None:
|
|
||||||
agent = _FakeAgent()
|
|
||||||
agent.raise_on_close = True
|
|
||||||
channels = _FakeChannels()
|
|
||||||
|
|
||||||
await _close_gateway_runtime(agent, channels, [], None)
|
|
||||||
|
|
||||||
assert agent.close_calls == 1
|
|
||||||
assert channels.stopped == 1 # teardown continued past the failure
|
|
||||||
|
|
||||||
|
|
||||||
async def test_duplicate_cleanup_is_idempotent() -> None:
|
|
||||||
agent = _FakeAgent()
|
|
||||||
channels = _FakeChannels()
|
|
||||||
task = asyncio.create_task(_cancellable_task([]))
|
|
||||||
|
|
||||||
await _close_gateway_runtime(agent, channels, [task], None)
|
|
||||||
await _close_gateway_runtime(agent, channels, [task], None)
|
|
||||||
|
|
||||||
assert agent.close_calls == 2 # second pass is a clean no-op
|
|
||||||
assert channels.stopped == 2
|
|
||||||
assert task.cancelled()
|
|
||||||
|
|
||||||
|
|
||||||
async def test_finished_runtime_tasks_gather_is_retrieved() -> None:
|
|
||||||
agent = _FakeAgent()
|
|
||||||
channels = _FakeChannels()
|
|
||||||
finished = asyncio.get_running_loop().create_future()
|
|
||||||
finished.set_result(None)
|
|
||||||
runtime_tasks = asyncio.gather(finished)
|
|
||||||
await asyncio.sleep(0) # let the gather observe the finished child
|
|
||||||
|
|
||||||
await _close_gateway_runtime(agent, channels, [], runtime_tasks)
|
|
||||||
|
|
||||||
assert runtime_tasks.done()
|
|
||||||
assert agent.close_calls == 1
|
|
||||||
|
|
||||||
|
|
||||||
async def test_cancelled_runtime_tasks_gather_does_not_raise() -> None:
|
|
||||||
agent = _FakeAgent()
|
|
||||||
channels = _FakeChannels()
|
|
||||||
runtime_tasks = asyncio.gather(asyncio.sleep(3600))
|
|
||||||
runtime_tasks.cancel()
|
|
||||||
|
|
||||||
await _close_gateway_runtime(agent, channels, [], runtime_tasks)
|
|
||||||
with suppress(asyncio.CancelledError):
|
|
||||||
await runtime_tasks # settle the cancelled gather without raising
|
|
||||||
|
|
||||||
assert runtime_tasks.done() # the cancelled gather was awaited without raising
|
|
||||||
assert agent.close_calls == 1
|
|
||||||
@@ -109,7 +109,7 @@ class TestMidTurnCommandDispatchedDirectly:
|
|||||||
loop.sessions.save = MagicMock()
|
loop.sessions.save = MagicMock()
|
||||||
loop.sessions.invalidate = MagicMock()
|
loop.sessions.invalidate = MagicMock()
|
||||||
loop.schedule_background = MagicMock()
|
loop.schedule_background = MagicMock()
|
||||||
loop._cancel_active_tasks = AsyncMock(return_value=0)
|
loop.cancel_active_turn = AsyncMock(return_value=0)
|
||||||
return loop
|
return loop
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""Test cmd_stop drains pending queue to prevent mid-turn injection deadlock."""
|
"""Test cmd_stop drains pending queue to prevent mid-turn injection deadlock."""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -14,13 +13,7 @@ from nanobot.command.router import CommandContext
|
|||||||
async def test_cmd_stop_drains_pending_queue():
|
async def test_cmd_stop_drains_pending_queue():
|
||||||
"""cmd_stop should drain pending queue in addition to cancelling active tasks."""
|
"""cmd_stop should drain pending queue in addition to cancelling active tasks."""
|
||||||
mock_loop = MagicMock()
|
mock_loop = MagicMock()
|
||||||
mock_loop._cancel_active_tasks = AsyncMock(return_value=1)
|
mock_loop.cancel_active_turn = AsyncMock(return_value=3)
|
||||||
mock_loop._pending_queues = {}
|
|
||||||
|
|
||||||
pending = asyncio.Queue()
|
|
||||||
await pending.put("msg1")
|
|
||||||
await pending.put("msg2")
|
|
||||||
mock_loop._pending_queues["test-session"] = pending
|
|
||||||
|
|
||||||
ctx = CommandContext(
|
ctx = CommandContext(
|
||||||
msg=MagicMock(channel="websocket", chat_id="test-chat", metadata={}),
|
msg=MagicMock(channel="websocket", chat_id="test-chat", metadata={}),
|
||||||
@@ -34,18 +27,14 @@ async def test_cmd_stop_drains_pending_queue():
|
|||||||
|
|
||||||
assert isinstance(result, OutboundMessage)
|
assert isinstance(result, OutboundMessage)
|
||||||
assert "Stopped 3 task(s)" in result.content # 1 cancelled + 2 drained
|
assert "Stopped 3 task(s)" in result.content # 1 cancelled + 2 drained
|
||||||
assert "test-session" not in mock_loop._pending_queues
|
mock_loop.cancel_active_turn.assert_awaited_once_with("test-session")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_cmd_stop_with_empty_pending_queue():
|
async def test_cmd_stop_with_empty_pending_queue():
|
||||||
"""cmd_stop should work correctly when pending queue is empty."""
|
"""cmd_stop should work correctly when pending queue is empty."""
|
||||||
mock_loop = MagicMock()
|
mock_loop = MagicMock()
|
||||||
mock_loop._cancel_active_tasks = AsyncMock(return_value=2)
|
mock_loop.cancel_active_turn = AsyncMock(return_value=2)
|
||||||
mock_loop._pending_queues = {}
|
|
||||||
|
|
||||||
pending = asyncio.Queue()
|
|
||||||
mock_loop._pending_queues["test-session"] = pending
|
|
||||||
|
|
||||||
ctx = CommandContext(
|
ctx = CommandContext(
|
||||||
msg=MagicMock(channel="websocket", chat_id="test-chat", metadata={}),
|
msg=MagicMock(channel="websocket", chat_id="test-chat", metadata={}),
|
||||||
@@ -58,15 +47,14 @@ async def test_cmd_stop_with_empty_pending_queue():
|
|||||||
result = await cmd_stop(ctx)
|
result = await cmd_stop(ctx)
|
||||||
|
|
||||||
assert "Stopped 2 task(s)" in result.content
|
assert "Stopped 2 task(s)" in result.content
|
||||||
assert "test-session" not in mock_loop._pending_queues
|
mock_loop.cancel_active_turn.assert_awaited_once_with("test-session")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_cmd_stop_no_pending_queue():
|
async def test_cmd_stop_no_pending_queue():
|
||||||
"""cmd_stop should work when no pending queue exists."""
|
"""cmd_stop should work when no pending queue exists."""
|
||||||
mock_loop = MagicMock()
|
mock_loop = MagicMock()
|
||||||
mock_loop._cancel_active_tasks = AsyncMock(return_value=0)
|
mock_loop.cancel_active_turn = AsyncMock(return_value=0)
|
||||||
mock_loop._pending_queues = {}
|
|
||||||
|
|
||||||
ctx = CommandContext(
|
ctx = CommandContext(
|
||||||
msg=MagicMock(channel="websocket", chat_id="test-chat", metadata={}),
|
msg=MagicMock(channel="websocket", chat_id="test-chat", metadata={}),
|
||||||
|
|||||||
@@ -141,33 +141,6 @@ def test_add_job_accepts_valid_timezone(tmp_path) -> None:
|
|||||||
assert job.state.next_run_at_ms is not None
|
assert job.state.next_run_at_ms is not None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("expr", [None, "", " "])
|
|
||||||
def test_add_job_rejects_missing_cron_expression(tmp_path, expr: str | None) -> None:
|
|
||||||
service = CronService(tmp_path / "cron" / "jobs.json")
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="requires a non-empty 'expr'"):
|
|
||||||
service.add_job(
|
|
||||||
name="missing expression",
|
|
||||||
schedule=CronSchedule(kind="cron", expr=expr),
|
|
||||||
message="hello",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert service.list_jobs(include_disabled=True) == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_add_job_rejects_invalid_cron_expression_before_persisting(tmp_path) -> None:
|
|
||||||
service = CronService(tmp_path / "cron" / "jobs.json")
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="invalid cron expression"):
|
|
||||||
service.add_job(
|
|
||||||
name="bad expression",
|
|
||||||
schedule=CronSchedule(kind="cron", expr="not a cron expression"),
|
|
||||||
message="hello",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert service.list_jobs(include_disabled=True) == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_write_run_record_uses_cron_runs_dir(tmp_path) -> None:
|
def test_write_run_record_uses_cron_runs_dir(tmp_path) -> None:
|
||||||
service = CronService(tmp_path / "cron" / "jobs.json")
|
service = CronService(tmp_path / "cron" / "jobs.json")
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from nanobot.providers.anthropic_provider import AnthropicProvider
|
from nanobot.providers.anthropic_provider import AnthropicProvider
|
||||||
|
|
||||||
|
|
||||||
@@ -67,24 +65,17 @@ def test_none_does_not_enable_thinking() -> None:
|
|||||||
assert kw["temperature"] == 0.7
|
assert kw["temperature"] == 0.7
|
||||||
|
|
||||||
|
|
||||||
def test_empty_effort_does_not_enable_thinking() -> None:
|
|
||||||
kw = _build(_make_provider(), "")
|
|
||||||
assert "thinking" not in kw
|
|
||||||
assert kw["temperature"] == 0.7
|
|
||||||
|
|
||||||
|
|
||||||
def test_opus_4_7_omits_temperature_adaptive() -> None:
|
def test_opus_4_7_omits_temperature_adaptive() -> None:
|
||||||
kw = _build(_make_provider("claude-opus-4-7"), "adaptive")
|
kw = _build(_make_provider("claude-opus-4-7"), "adaptive")
|
||||||
assert "temperature" not in kw
|
assert "temperature" not in kw
|
||||||
assert kw["thinking"] == {"type": "adaptive"}
|
assert kw["thinking"] == {"type": "adaptive"}
|
||||||
|
|
||||||
|
|
||||||
def test_opus_4_7_high_uses_adaptive_effort() -> None:
|
def test_opus_4_7_omits_temperature_enabled() -> None:
|
||||||
|
"""Enabled thinking (high) must also omit temperature for opus-4-7."""
|
||||||
kw = _build(_make_provider("claude-opus-4-7"), "high", max_tokens=4096)
|
kw = _build(_make_provider("claude-opus-4-7"), "high", max_tokens=4096)
|
||||||
assert "temperature" not in kw
|
assert "temperature" not in kw
|
||||||
assert kw["thinking"] == {"type": "adaptive"}
|
assert kw["thinking"]["type"] == "enabled"
|
||||||
assert kw["output_config"] == {"effort": "high"}
|
|
||||||
assert kw["max_tokens"] == 4096
|
|
||||||
|
|
||||||
|
|
||||||
def test_opus_4_7_omits_temperature_none() -> None:
|
def test_opus_4_7_omits_temperature_none() -> None:
|
||||||
@@ -99,11 +90,9 @@ def test_opus_4_8_omits_temperature_adaptive() -> None:
|
|||||||
assert "temperature" not in kw
|
assert "temperature" not in kw
|
||||||
|
|
||||||
|
|
||||||
def test_opus_4_8_high_uses_adaptive_effort() -> None:
|
def test_opus_4_8_omits_temperature_enabled() -> None:
|
||||||
kw = _build(_make_provider("claude-opus-4-8"), "high", max_tokens=4096)
|
kw = _build(_make_provider("claude-opus-4-8"), "high", max_tokens=4096)
|
||||||
assert "temperature" not in kw
|
assert "temperature" not in kw
|
||||||
assert kw["thinking"] == {"type": "adaptive"}
|
|
||||||
assert kw["output_config"] == {"effort": "high"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_opus_4_8_omits_temperature_none() -> None:
|
def test_opus_4_8_omits_temperature_none() -> None:
|
||||||
@@ -116,11 +105,9 @@ def test_fable_omits_temperature_adaptive() -> None:
|
|||||||
assert "temperature" not in kw
|
assert "temperature" not in kw
|
||||||
|
|
||||||
|
|
||||||
def test_fable_high_uses_adaptive_effort() -> None:
|
def test_fable_omits_temperature_enabled() -> None:
|
||||||
kw = _build(_make_provider("claude-fable-5"), "high", max_tokens=4096)
|
kw = _build(_make_provider("claude-fable-5"), "high", max_tokens=4096)
|
||||||
assert "temperature" not in kw
|
assert "temperature" not in kw
|
||||||
assert kw["thinking"] == {"type": "adaptive"}
|
|
||||||
assert kw["output_config"] == {"effort": "high"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_fable_omits_temperature_none() -> None:
|
def test_fable_omits_temperature_none() -> None:
|
||||||
@@ -134,67 +121,16 @@ def test_sonnet_5_omits_temperature_adaptive() -> None:
|
|||||||
assert kw["thinking"] == {"type": "adaptive"}
|
assert kw["thinking"] == {"type": "adaptive"}
|
||||||
|
|
||||||
|
|
||||||
def test_sonnet_5_high_uses_adaptive_effort() -> None:
|
def test_sonnet_5_omits_temperature_enabled() -> None:
|
||||||
kw = _build(_make_provider("claude-sonnet-5"), "high", max_tokens=4096)
|
kw = _build(_make_provider("claude-sonnet-5"), "high", max_tokens=4096)
|
||||||
assert "temperature" not in kw
|
assert "temperature" not in kw
|
||||||
assert kw["thinking"] == {"type": "adaptive"}
|
assert kw["thinking"]["type"] == "enabled"
|
||||||
assert kw["output_config"] == {"effort": "high"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_sonnet_5_omits_temperature_none() -> None:
|
def test_sonnet_5_omits_temperature_none() -> None:
|
||||||
kw = _build(_make_provider("anthropic/claude-sonnet-5"), "none")
|
kw = _build(_make_provider("anthropic/claude-sonnet-5"), None)
|
||||||
assert "temperature" not in kw
|
assert "temperature" not in kw
|
||||||
assert kw["thinking"] == {"type": "disabled"}
|
|
||||||
assert "output_config" not in kw
|
|
||||||
|
|
||||||
|
|
||||||
def test_mythos_preview_omits_temperature_but_keeps_manual_budget() -> None:
|
|
||||||
kw = _build(_make_provider("claude-mythos-preview"), "high", max_tokens=4096)
|
|
||||||
assert "temperature" not in kw
|
|
||||||
assert kw["thinking"] == {"type": "enabled", "budget_tokens": 8192}
|
|
||||||
assert "output_config" not in kw
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"reasoning_effort", [None, "none", "adaptive", "low", "medium", "high", "xhigh", "max"]
|
|
||||||
)
|
|
||||||
def test_opus_5_omits_temperature(reasoning_effort: str | None) -> None:
|
|
||||||
kw = _build(_make_provider("claude-opus-5"), reasoning_effort)
|
|
||||||
assert "temperature" not in kw
|
|
||||||
|
|
||||||
|
|
||||||
def test_opus_5_none_disables_default_thinking() -> None:
|
|
||||||
kw = _build(_make_provider("claude-opus-5"), "none")
|
|
||||||
assert kw["thinking"] == {"type": "disabled"}
|
|
||||||
assert "output_config" not in kw
|
|
||||||
|
|
||||||
|
|
||||||
def test_opus_5_unset_preserves_provider_default() -> None:
|
|
||||||
kw = _build(_make_provider("claude-opus-5"), None)
|
|
||||||
assert "thinking" not in kw
|
assert "thinking" not in kw
|
||||||
assert "output_config" not in kw
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("reasoning_effort", ["low", "medium", "high", "xhigh", "max"])
|
|
||||||
def test_opus_5_uses_adaptive_thinking_with_effort(reasoning_effort: str) -> None:
|
|
||||||
kw = _build(_make_provider("claude-opus-5"), reasoning_effort, max_tokens=4096)
|
|
||||||
assert kw["thinking"] == {"type": "adaptive"}
|
|
||||||
assert kw["output_config"] == {"effort": reasoning_effort}
|
|
||||||
assert kw["max_tokens"] == 4096
|
|
||||||
|
|
||||||
|
|
||||||
def test_dated_opus_5_model_uses_family_capabilities() -> None:
|
|
||||||
kw = _build(_make_provider("claude-opus-5-20260724"), "medium")
|
|
||||||
assert "temperature" not in kw
|
|
||||||
assert kw["thinking"] == {"type": "adaptive"}
|
|
||||||
assert kw["output_config"] == {"effort": "medium"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_dated_opus_4_model_does_not_treat_date_as_minor_version() -> None:
|
|
||||||
kw = _build(_make_provider("claude-opus-4-20250514"), "high")
|
|
||||||
assert kw["temperature"] == 1.0
|
|
||||||
assert kw["thinking"] == {"type": "enabled", "budget_tokens": 8192}
|
|
||||||
assert "output_config" not in kw
|
|
||||||
|
|
||||||
|
|
||||||
def test_ordinary_model_sends_temperature() -> None:
|
def test_ordinary_model_sends_temperature() -> None:
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
"""Tests for the Eden AI provider registration."""
|
|
||||||
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from nanobot.config.schema import Config, ProvidersConfig
|
|
||||||
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
|
||||||
from nanobot.providers.registry import PROVIDERS, find_by_name
|
|
||||||
|
|
||||||
|
|
||||||
def test_edenai_config_field_exists() -> None:
|
|
||||||
assert hasattr(ProvidersConfig(), "edenai")
|
|
||||||
|
|
||||||
|
|
||||||
def test_edenai_registry_contract() -> None:
|
|
||||||
specs = {spec.name: spec for spec in PROVIDERS}
|
|
||||||
|
|
||||||
assert "edenai" in specs
|
|
||||||
edenai = specs["edenai"]
|
|
||||||
assert edenai.backend == "openai_compat"
|
|
||||||
assert edenai.env_key == "EDENAI_API_KEY"
|
|
||||||
assert edenai.display_name == "Eden AI"
|
|
||||||
assert edenai.is_gateway is True
|
|
||||||
assert edenai.detect_by_base_keyword == "edenai"
|
|
||||||
assert edenai.default_api_base == "https://api.edenai.run/v3"
|
|
||||||
assert edenai.strip_model_prefix is False
|
|
||||||
# Eden accepts OpenAI's top-level reasoning_effort parameter. Do not add
|
|
||||||
# OpenRouter's separate {"reasoning": {"effort": ...}} request shape.
|
|
||||||
assert edenai.gateway_reasoning_style == ""
|
|
||||||
|
|
||||||
|
|
||||||
def test_edenai_forced_provider_uses_default_api_base() -> None:
|
|
||||||
config = Config.model_validate(
|
|
||||||
{
|
|
||||||
"providers": {"edenai": {"apiKey": "eden-key"}},
|
|
||||||
"agents": {
|
|
||||||
"defaults": {
|
|
||||||
"provider": "edenai",
|
|
||||||
"model": "anthropic/claude-sonnet-4-5",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
model = "anthropic/claude-sonnet-4-5"
|
|
||||||
assert config.get_provider_name(model) == "edenai"
|
|
||||||
assert config.get_api_key(model) == "eden-key"
|
|
||||||
assert config.get_api_base(model) == "https://api.edenai.run/v3"
|
|
||||||
|
|
||||||
|
|
||||||
def test_edenai_preserves_model_id_and_reasoning_effort() -> None:
|
|
||||||
spec = find_by_name("edenai")
|
|
||||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
|
||||||
provider = OpenAICompatProvider(
|
|
||||||
api_key="eden-key",
|
|
||||||
default_model="anthropic/claude-sonnet-4-5",
|
|
||||||
spec=spec,
|
|
||||||
)
|
|
||||||
|
|
||||||
kwargs = provider._build_kwargs(
|
|
||||||
messages=[{"role": "user", "content": "hi"}],
|
|
||||||
tools=None,
|
|
||||||
model="anthropic/claude-sonnet-4-5",
|
|
||||||
max_tokens=1024,
|
|
||||||
temperature=0.7,
|
|
||||||
reasoning_effort="medium",
|
|
||||||
tool_choice=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert kwargs["model"] == "anthropic/claude-sonnet-4-5"
|
|
||||||
assert kwargs["reasoning_effort"] == "medium"
|
|
||||||
assert "reasoning" not in kwargs.get("extra_body", {})
|
|
||||||
@@ -464,7 +464,7 @@ async def test_gemini_flash_forwards_aspect_ratio_and_image_size() -> None:
|
|||||||
image_size="2K",
|
image_size="2K",
|
||||||
)
|
)
|
||||||
|
|
||||||
image_config = fake.calls[0]["json"]["generationConfig"]["imageConfig"]
|
image_config = fake.calls[0]["json"]["generationConfig"]["responseFormat"]["image"]
|
||||||
assert image_config == {"aspectRatio": "16:9", "imageSize": "2K"}
|
assert image_config == {"aspectRatio": "16:9", "imageSize": "2K"}
|
||||||
|
|
||||||
|
|
||||||
@@ -480,7 +480,7 @@ async def test_gemini_flash_2_5_drops_image_size() -> None:
|
|||||||
image_size="1K",
|
image_size="1K",
|
||||||
)
|
)
|
||||||
|
|
||||||
image_config = fake.calls[0]["json"]["generationConfig"]["imageConfig"]
|
image_config = fake.calls[0]["json"]["generationConfig"]["responseFormat"]["image"]
|
||||||
assert image_config == {"aspectRatio": "4:3"}
|
assert image_config == {"aspectRatio": "4:3"}
|
||||||
|
|
||||||
|
|
||||||
@@ -496,7 +496,7 @@ async def test_gemini_flash_2_0_drops_image_size() -> None:
|
|||||||
image_size="1K",
|
image_size="1K",
|
||||||
)
|
)
|
||||||
|
|
||||||
image_config = fake.calls[0]["json"]["generationConfig"]["imageConfig"]
|
image_config = fake.calls[0]["json"]["generationConfig"]["responseFormat"]["image"]
|
||||||
assert image_config == {"aspectRatio": "16:9"}
|
assert image_config == {"aspectRatio": "16:9"}
|
||||||
|
|
||||||
|
|
||||||
@@ -524,8 +524,8 @@ async def test_gemini_flash_scopes_extreme_aspect_ratios_by_model(
|
|||||||
aspect_ratio=aspect_ratio,
|
aspect_ratio=aspect_ratio,
|
||||||
)
|
)
|
||||||
|
|
||||||
image_config = fake.calls[0]["json"]["generationConfig"].get("imageConfig")
|
response_format = fake.calls[0]["json"]["generationConfig"].get("responseFormat")
|
||||||
assert image_config == expected
|
assert response_format == ({"image": expected} if expected else None)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
@@ -553,8 +553,8 @@ async def test_gemini_flash_scopes_image_size_by_model(
|
|||||||
image_size=image_size,
|
image_size=image_size,
|
||||||
)
|
)
|
||||||
|
|
||||||
image_config = fake.calls[0]["json"]["generationConfig"].get("imageConfig")
|
response_format = fake.calls[0]["json"]["generationConfig"].get("responseFormat")
|
||||||
assert image_config == expected
|
assert response_format == ({"image": expected} if expected else None)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -571,7 +571,7 @@ async def test_gemini_flash_ignores_unsupported_hints() -> None:
|
|||||||
image_size="1024x1024",
|
image_size="1024x1024",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert "imageConfig" not in fake.calls[0]["json"]["generationConfig"]
|
assert "responseFormat" not in fake.calls[0]["json"]["generationConfig"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -156,10 +156,7 @@ class TestConvertMessages:
|
|||||||
], preserve_reasoning=True)
|
], preserve_reasoning=True)
|
||||||
|
|
||||||
assert items == [
|
assert items == [
|
||||||
{
|
{"type": "reasoning", "content": "think first"},
|
||||||
"type": "reasoning",
|
|
||||||
"content": [{"type": "output_text", "text": "think first"}],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "message",
|
"type": "message",
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
@@ -169,32 +166,6 @@ class TestConvertMessages:
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
def test_reasoning_content_serialized_as_array_for_deepseek(self):
|
|
||||||
# Regression for PR #5214: DeepSeek's Responses gateway rejects
|
|
||||||
# reasoning items whose ``content`` is a plain string with
|
|
||||||
# "input: invalid type: string ..., expected a sequence" (observed
|
|
||||||
# after context consolidation cleared provider state and forced
|
|
||||||
# full-history conversion). ``content`` must be a list of parts,
|
|
||||||
# matching both the OpenAI Responses schema and DeepSeek's accepted
|
|
||||||
# wire shape.
|
|
||||||
_, items = convert_messages([
|
|
||||||
{
|
|
||||||
"role": "assistant",
|
|
||||||
"reasoning_content": "Michael topped up DeepSeek with $10.",
|
|
||||||
"content": "",
|
|
||||||
"tool_calls": [{
|
|
||||||
"id": "call_1|fc_1",
|
|
||||||
"function": {"name": "list_dir", "arguments": "{}"},
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
], preserve_reasoning=True)
|
|
||||||
|
|
||||||
assert items[0]["type"] == "reasoning"
|
|
||||||
assert items[0]["content"] == [
|
|
||||||
{"type": "output_text", "text": "Michael topped up DeepSeek with $10."},
|
|
||||||
]
|
|
||||||
assert items[1]["type"] == "function_call"
|
|
||||||
|
|
||||||
def test_assistant_empty_content_skipped(self):
|
def test_assistant_empty_content_skipped(self):
|
||||||
_, items = convert_messages([{"role": "assistant", "content": ""}])
|
_, items = convert_messages([{"role": "assistant", "content": ""}])
|
||||||
assert len(items) == 0
|
assert len(items) == 0
|
||||||
@@ -853,59 +824,6 @@ class TestResponsesConversationState:
|
|||||||
}
|
}
|
||||||
assert "lossy public transcript" not in str(items)
|
assert "lossy public transcript" not in str(items)
|
||||||
|
|
||||||
def test_replayed_and_delta_reasoning_items_keep_array_content(self):
|
|
||||||
# Regression for PR #5214: token consolidation clears
|
|
||||||
# ``provider_state``, so the next turn converts the full history
|
|
||||||
# (including assistant reasoning) instead of replaying server items.
|
|
||||||
# Both paths must keep reasoning ``content`` as a list - DeepSeek's
|
|
||||||
# Responses gateway rejects the string form with a serde error.
|
|
||||||
prior_items = [
|
|
||||||
{
|
|
||||||
"type": "reasoning",
|
|
||||||
"id": "rs_1",
|
|
||||||
"content": [{"type": "output_text", "text": "prior reasoning"}],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "message",
|
|
||||||
"role": "assistant",
|
|
||||||
"content": [{"type": "output_text", "text": "prior answer"}],
|
|
||||||
"status": "completed",
|
|
||||||
"id": "msg_0",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
state = build_responses_state(
|
|
||||||
provider="openai:test",
|
|
||||||
model="deepseek-v4-flash",
|
|
||||||
input_items=prior_items,
|
|
||||||
output_items=[],
|
|
||||||
).with_pending_messages([
|
|
||||||
{
|
|
||||||
"role": "assistant",
|
|
||||||
"reasoning_content": "think before acting",
|
|
||||||
"content": "answer",
|
|
||||||
},
|
|
||||||
{"role": "user", "content": "audit the tools"},
|
|
||||||
])
|
|
||||||
|
|
||||||
instructions, items, replayed = prepare_responses_input(
|
|
||||||
[
|
|
||||||
{"role": "system", "content": "You are KITT."},
|
|
||||||
{"role": "user", "content": "audit the tools"},
|
|
||||||
],
|
|
||||||
state=state,
|
|
||||||
provider="openai:test",
|
|
||||||
model="deepseek-v4-flash",
|
|
||||||
preserve_reasoning=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert instructions == "You are KITT."
|
|
||||||
assert replayed is True
|
|
||||||
reasoning_items = [item for item in items if item.get("type") == "reasoning"]
|
|
||||||
assert len(reasoning_items) == 2 # one replayed, one converted delta
|
|
||||||
for item in reasoning_items:
|
|
||||||
assert isinstance(item["content"], list)
|
|
||||||
assert item["content"][0]["type"] == "output_text"
|
|
||||||
|
|
||||||
|
|
||||||
# ======================================================================
|
# ======================================================================
|
||||||
# parsing - consume_sse
|
# parsing - consume_sse
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ from nanobot.providers.openai_compat_provider import (
|
|||||||
_RESPONSES_PROBE_INTERVAL_S,
|
_RESPONSES_PROBE_INTERVAL_S,
|
||||||
OpenAICompatProvider,
|
OpenAICompatProvider,
|
||||||
)
|
)
|
||||||
from nanobot.providers.openai_responses.state import build_responses_state
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
@@ -151,140 +150,3 @@ def test_reasoning_effort_key_is_case_insensitive(provider):
|
|||||||
for _ in range(_RESPONSES_FAILURE_THRESHOLD):
|
for _ in range(_RESPONSES_FAILURE_THRESHOLD):
|
||||||
provider._record_responses_failure("o3", "High")
|
provider._record_responses_failure("o3", "High")
|
||||||
assert provider._should_use_responses_api("o3", "high") is False
|
assert provider._should_use_responses_api("o3", "high") is False
|
||||||
|
|
||||||
|
|
||||||
# ======================================================================
|
|
||||||
# _should_fallback_from_responses_error
|
|
||||||
# ======================================================================
|
|
||||||
|
|
||||||
|
|
||||||
class _FakeAPIError(Exception):
|
|
||||||
def __init__(self, status_code, body):
|
|
||||||
super().__init__(str(body))
|
|
||||||
self.status_code = status_code
|
|
||||||
self.body = body
|
|
||||||
self.response = None
|
|
||||||
|
|
||||||
|
|
||||||
def test_serde_deserialize_error_does_not_trigger_fallback():
|
|
||||||
# Serde errors can also identify malformed user-provided request fields.
|
|
||||||
# The known DeepSeek wire-shape bug is fixed at serialization time instead.
|
|
||||||
err = _FakeAPIError(400, {
|
|
||||||
"message": (
|
|
||||||
"Failed to deserialize the JSON body into the target type: "
|
|
||||||
"input: invalid type: string \"Michael topped up DeepSeek ...\", "
|
|
||||||
"expected a sequence at line 1 column 268612"
|
|
||||||
),
|
|
||||||
"type": "invalid_request_error",
|
|
||||||
"param": None,
|
|
||||||
})
|
|
||||||
assert OpenAICompatProvider._should_fallback_from_responses_error(err) is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_legacy_compatibility_markers_still_trigger_fallback():
|
|
||||||
err = _FakeAPIError(400, "parameter `instructions` is unsupported")
|
|
||||||
assert OpenAICompatProvider._should_fallback_from_responses_error(err) is True
|
|
||||||
|
|
||||||
|
|
||||||
# ======================================================================
|
|
||||||
# DeepSeek Responses wire shape (PR #5214 root cause)
|
|
||||||
# ======================================================================
|
|
||||||
|
|
||||||
|
|
||||||
def _deepseek_provider(provider):
|
|
||||||
provider._spec = type("Spec", (), {
|
|
||||||
"name": "deepseek",
|
|
||||||
"responses_models": ("deepseek-v4-flash",),
|
|
||||||
"strip_model_prefix": False,
|
|
||||||
"strip_model_prefixes": (),
|
|
||||||
})()
|
|
||||||
provider._effective_base = "https://api.deepseek.com"
|
|
||||||
provider.default_model = "deepseek-v4-flash"
|
|
||||||
provider._extra_body = {}
|
|
||||||
return provider
|
|
||||||
|
|
||||||
|
|
||||||
def test_deepseek_full_history_body_keeps_reasoning_content_as_array(provider):
|
|
||||||
# Full-history fixture: DeepSeek's Responses gateway rejects reasoning
|
|
||||||
# items whose ``content`` is a plain string ("input: invalid type: string
|
|
||||||
# ..., expected a sequence"); the wire body must keep it as a part list.
|
|
||||||
_deepseek_provider(provider)
|
|
||||||
|
|
||||||
body = provider._build_responses_body(
|
|
||||||
messages=[
|
|
||||||
{
|
|
||||||
"role": "assistant",
|
|
||||||
"reasoning_content": "Michael topped up DeepSeek with $10.",
|
|
||||||
"content": "All systems aligned now.",
|
|
||||||
},
|
|
||||||
{"role": "user", "content": "audit the custom tools"},
|
|
||||||
],
|
|
||||||
tools=None,
|
|
||||||
model="deepseek-v4-flash",
|
|
||||||
max_tokens=1000,
|
|
||||||
temperature=0.1,
|
|
||||||
reasoning_effort=None,
|
|
||||||
tool_choice=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
reasoning_items = [item for item in body["input"] if item.get("type") == "reasoning"]
|
|
||||||
assert len(reasoning_items) == 1
|
|
||||||
assert reasoning_items[0]["content"] == [
|
|
||||||
{"type": "output_text", "text": "Michael topped up DeepSeek with $10."},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_deepseek_replay_body_keeps_reasoning_content_as_array(provider):
|
|
||||||
# Replay/consolidation fixture: after token consolidation clears
|
|
||||||
# provider_state the next turn converts full history on top of the
|
|
||||||
# replayed prior items. Both replayed and converted reasoning items must
|
|
||||||
# keep list content on the wire.
|
|
||||||
_deepseek_provider(provider)
|
|
||||||
|
|
||||||
prior_items = [
|
|
||||||
{
|
|
||||||
"type": "reasoning",
|
|
||||||
"id": "rs_1",
|
|
||||||
"content": [{"type": "output_text", "text": "prior reasoning"}],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "message",
|
|
||||||
"role": "assistant",
|
|
||||||
"content": [{"type": "output_text", "text": "prior answer"}],
|
|
||||||
"status": "completed",
|
|
||||||
"id": "msg_0",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
state = build_responses_state(
|
|
||||||
provider=provider._responses_state_provider(),
|
|
||||||
model="deepseek-v4-flash",
|
|
||||||
input_items=prior_items,
|
|
||||||
output_items=[],
|
|
||||||
).with_pending_messages([
|
|
||||||
{
|
|
||||||
"role": "assistant",
|
|
||||||
"reasoning_content": "think first",
|
|
||||||
"content": "answer",
|
|
||||||
},
|
|
||||||
{"role": "user", "content": "audit the custom tools"},
|
|
||||||
])
|
|
||||||
|
|
||||||
body = provider._build_responses_body(
|
|
||||||
messages=[
|
|
||||||
{"role": "system", "content": "You are KITT."},
|
|
||||||
{"role": "user", "content": "audit the custom tools"},
|
|
||||||
],
|
|
||||||
tools=None,
|
|
||||||
model="deepseek-v4-flash",
|
|
||||||
max_tokens=1000,
|
|
||||||
temperature=0.1,
|
|
||||||
reasoning_effort=None,
|
|
||||||
tool_choice=None,
|
|
||||||
provider_context=ProviderCallContext(conversation_state=state),
|
|
||||||
)
|
|
||||||
|
|
||||||
reasoning_items = [item for item in body["input"] if item.get("type") == "reasoning"]
|
|
||||||
assert len(reasoning_items) == 2 # one replayed from state, one converted
|
|
||||||
for item in reasoning_items:
|
|
||||||
assert isinstance(item["content"], list)
|
|
||||||
assert item["content"][0]["type"] == "output_text"
|
|
||||||
|
|||||||
@@ -73,3 +73,23 @@ def test_flush_all_includes_live_sessions_outside_strong_cache(tmp_path, monkeyp
|
|||||||
|
|
||||||
assert manager.flush_all() == 2
|
assert manager.flush_all() == 2
|
||||||
assert set(saved) == {("test:active", True), ("test:other", True)}
|
assert set(saved) == {("test:active", True), ("test:other", True)}
|
||||||
|
|
||||||
|
|
||||||
|
def test_transient_session_never_reaches_store(tmp_path) -> None:
|
||||||
|
manager = SessionManager(tmp_path)
|
||||||
|
session = manager.get_or_create_transient("websocket:temporary-test")
|
||||||
|
session.add_message("user", "private")
|
||||||
|
|
||||||
|
manager.save(session, fsync=True)
|
||||||
|
|
||||||
|
assert manager.get_cached(session.key) is session
|
||||||
|
assert manager.read_session_file(session.key) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_transient_session_becomes_inactive_when_discarded(tmp_path) -> None:
|
||||||
|
manager = SessionManager(tmp_path)
|
||||||
|
session = manager.get_or_create_transient("websocket:temporary-test")
|
||||||
|
|
||||||
|
assert manager.discard_transient(session.key) is True
|
||||||
|
assert manager.is_transient_active(session.key) is False
|
||||||
|
assert manager.get_cached(session.key) is None
|
||||||
|
|||||||
@@ -150,36 +150,6 @@ def test_enqueue_writes_trigger_run_record(tmp_path: Path) -> None:
|
|||||||
assert record["content"] == "Review PR #4591"
|
assert record["content"] == "Review PR #4591"
|
||||||
assert record["origin_metadata"] == {"webui": True}
|
assert record["origin_metadata"] == {"webui": True}
|
||||||
assert record["updated_at_ms"] > 0
|
assert record["updated_at_ms"] > 0
|
||||||
stored = store.get(trigger.id)
|
|
||||||
assert stored is not None
|
|
||||||
assert stored.last_message == "Review PR #4591"
|
|
||||||
|
|
||||||
|
|
||||||
def test_enqueue_rolls_back_delivery_and_audit_when_trigger_save_fails(
|
|
||||||
tmp_path: Path,
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
store = LocalTriggerStore(tmp_path)
|
|
||||||
trigger = store.create(
|
|
||||||
name="PR review",
|
|
||||||
channel="websocket",
|
|
||||||
chat_id="chat-1",
|
|
||||||
session_key="websocket:chat-1",
|
|
||||||
)
|
|
||||||
|
|
||||||
def fail_save(_triggers: list[LocalTrigger]) -> None:
|
|
||||||
raise OSError("store write failed")
|
|
||||||
|
|
||||||
monkeypatch.setattr(store, "_save_triggers_unlocked", fail_save)
|
|
||||||
|
|
||||||
with pytest.raises(OSError, match="store write failed"):
|
|
||||||
store.enqueue(trigger.id, "Review PR #4591")
|
|
||||||
|
|
||||||
assert list(store.inbox_dir.glob("*.json")) == []
|
|
||||||
assert list(store.runs_dir.glob("*.json")) == []
|
|
||||||
stored = LocalTriggerStore(tmp_path).get(trigger.id)
|
|
||||||
assert stored is not None
|
|
||||||
assert stored.last_message == ""
|
|
||||||
|
|
||||||
|
|
||||||
def test_delivery_run_record_truncates_large_content_and_response(tmp_path: Path) -> None:
|
def test_delivery_run_record_truncates_large_content_and_response(tmp_path: Path) -> None:
|
||||||
@@ -198,9 +168,6 @@ def test_delivery_run_record_truncates_large_content_and_response(tmp_path: Path
|
|||||||
assert queued_record["content"].startswith("content-")
|
assert queued_record["content"].startswith("content-")
|
||||||
assert queued_record["content"].endswith("\n... (truncated)")
|
assert queued_record["content"].endswith("\n... (truncated)")
|
||||||
assert len(queued_record["content"]) < len(large_content)
|
assert len(queued_record["content"]) < len(large_content)
|
||||||
stored = store.get(trigger.id)
|
|
||||||
assert stored is not None
|
|
||||||
assert stored.last_message == queued_record["content"]
|
|
||||||
|
|
||||||
store.write_delivery_run_record(
|
store.write_delivery_run_record(
|
||||||
delivery,
|
delivery,
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ from nanobot.webui.transcript import (
|
|||||||
def test_delete_webui_thread_removes_legacy_json_and_transcript(tmp_path, monkeypatch) -> None:
|
def test_delete_webui_thread_removes_legacy_json_and_transcript(tmp_path, monkeypatch) -> None:
|
||||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||||
monkeypatch.setattr("nanobot.webui.transcript._MAX_TRANSCRIPT_FILE_BYTES", 520)
|
monkeypatch.setattr("nanobot.webui.transcript._MAX_TRANSCRIPT_FILE_BYTES", 520)
|
||||||
monkeypatch.setattr("nanobot.webui.transcript._ACTIVE_TRANSCRIPT_ROTATE_BYTES", 520)
|
|
||||||
monkeypatch.setattr("nanobot.webui.transcript._TARGET_ACTIVE_TRANSCRIPT_BYTES", 260)
|
monkeypatch.setattr("nanobot.webui.transcript._TARGET_ACTIVE_TRANSCRIPT_BYTES", 260)
|
||||||
key = "websocket:k1"
|
key = "websocket:k1"
|
||||||
json_path = webui_thread_file_path(key)
|
json_path = webui_thread_file_path(key)
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import nanobot.webui.transcript as transcript_module
|
|
||||||
from nanobot.session.history_visibility import HIDDEN_HISTORY_META
|
from nanobot.session.history_visibility import HIDDEN_HISTORY_META
|
||||||
from nanobot.webui.transcript import (
|
from nanobot.webui.transcript import (
|
||||||
WEBUI_TRANSCRIPT_SCHEMA_VERSION,
|
WEBUI_TRANSCRIPT_SCHEMA_VERSION,
|
||||||
@@ -39,7 +38,6 @@ def test_append_stamps_created_at_ms(tmp_path, monkeypatch) -> None:
|
|||||||
|
|
||||||
def _force_small_transcript_budget(monkeypatch, *, limit: int = 520, target: int = 260) -> None:
|
def _force_small_transcript_budget(monkeypatch, *, limit: int = 520, target: int = 260) -> None:
|
||||||
monkeypatch.setattr("nanobot.webui.transcript._MAX_TRANSCRIPT_FILE_BYTES", limit)
|
monkeypatch.setattr("nanobot.webui.transcript._MAX_TRANSCRIPT_FILE_BYTES", limit)
|
||||||
monkeypatch.setattr("nanobot.webui.transcript._ACTIVE_TRANSCRIPT_ROTATE_BYTES", limit)
|
|
||||||
monkeypatch.setattr("nanobot.webui.transcript._TARGET_ACTIVE_TRANSCRIPT_BYTES", target)
|
monkeypatch.setattr("nanobot.webui.transcript._TARGET_ACTIVE_TRANSCRIPT_BYTES", target)
|
||||||
|
|
||||||
|
|
||||||
@@ -124,28 +122,6 @@ def test_segmented_transcript_paginates_latest_and_older_without_overlap(
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_latest_page_reads_active_chunk_once(tmp_path, monkeypatch) -> None:
|
|
||||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
|
||||||
key = "websocket:single-active-read"
|
|
||||||
for idx in range(1, 7):
|
|
||||||
_append_numbered_turn(key, "single-active-read", idx)
|
|
||||||
|
|
||||||
original = transcript_module._read_chunk_turns
|
|
||||||
read_chunk_ids: list[str] = []
|
|
||||||
|
|
||||||
def track_read(session_key: str, chunk_id: str) -> list[list[dict]]:
|
|
||||||
read_chunk_ids.append(chunk_id)
|
|
||||||
return original(session_key, chunk_id)
|
|
||||||
|
|
||||||
monkeypatch.setattr(transcript_module, "_read_chunk_turns", track_read)
|
|
||||||
|
|
||||||
latest = build_webui_thread_response(key, limit=4, direction="latest")
|
|
||||||
|
|
||||||
assert latest is not None
|
|
||||||
assert _message_contents(latest) == _numbered_turn_texts(5, 6)
|
|
||||||
assert read_chunk_ids == ["active"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_page_cursor_survives_active_rotation_after_latest_page(
|
def test_page_cursor_survives_active_rotation_after_latest_page(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
@@ -172,53 +148,15 @@ def test_segment_manifest_can_be_rebuilt_when_missing_or_corrupt(tmp_path, monke
|
|||||||
key = "websocket:manifest"
|
key = "websocket:manifest"
|
||||||
_write_segmented_turns(tmp_path, monkeypatch, key, "manifest", 4)
|
_write_segmented_turns(tmp_path, monkeypatch, key, "manifest", 4)
|
||||||
|
|
||||||
segment_dir = webui_transcript_segments_dir(key)
|
manifest = webui_transcript_segments_dir(key) / "manifest.json"
|
||||||
segment_names = sorted(path.name for path in segment_dir.glob("*.jsonl"))
|
|
||||||
assert segment_names
|
|
||||||
original = transcript_module._read_transcript_file
|
|
||||||
segment_reads: list[str] = []
|
|
||||||
|
|
||||||
def track_read(path):
|
|
||||||
if path.parent == segment_dir and path.suffix == ".jsonl":
|
|
||||||
segment_reads.append(path.name)
|
|
||||||
return original(path)
|
|
||||||
|
|
||||||
monkeypatch.setattr(transcript_module, "_read_transcript_file", track_read)
|
|
||||||
manifest = segment_dir / "manifest.json"
|
|
||||||
manifest.write_text("{not json", encoding="utf-8")
|
manifest.write_text("{not json", encoding="utf-8")
|
||||||
|
|
||||||
entries = transcript_module._read_segment_manifest_entries(key)
|
|
||||||
|
|
||||||
assert [entry["id"] for entry in entries] == [path.removesuffix(".jsonl") for path in segment_names]
|
|
||||||
assert segment_reads == segment_names
|
|
||||||
|
|
||||||
lines = read_transcript_lines(key)
|
lines = read_transcript_lines(key)
|
||||||
|
|
||||||
assert len([line for line in lines if line.get("event") == "user"]) == 4
|
assert len([line for line in lines if line.get("event") == "user"]) == 4
|
||||||
assert manifest.read_text(encoding="utf-8").lstrip().startswith("{")
|
assert manifest.read_text(encoding="utf-8").lstrip().startswith("{")
|
||||||
|
|
||||||
|
|
||||||
def test_rotation_does_not_reread_existing_segments(tmp_path, monkeypatch) -> None:
|
|
||||||
key = "websocket:manifest-append"
|
|
||||||
_write_segmented_turns(tmp_path, monkeypatch, key, "manifest-append", 4)
|
|
||||||
segment_dir = webui_transcript_segments_dir(key)
|
|
||||||
assert list(segment_dir.glob("*.jsonl"))
|
|
||||||
|
|
||||||
original = transcript_module._read_transcript_file
|
|
||||||
segment_reads: list[str] = []
|
|
||||||
|
|
||||||
def track_read(path):
|
|
||||||
if path.parent == segment_dir and path.suffix == ".jsonl":
|
|
||||||
segment_reads.append(path.name)
|
|
||||||
return original(path)
|
|
||||||
|
|
||||||
monkeypatch.setattr(transcript_module, "_read_transcript_file", track_read)
|
|
||||||
for idx in range(5, 9):
|
|
||||||
_append_numbered_turn(key, "manifest-append", idx)
|
|
||||||
|
|
||||||
assert segment_reads == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_delete_webui_transcript_removes_segments(tmp_path, monkeypatch) -> None:
|
def test_delete_webui_transcript_removes_segments(tmp_path, monkeypatch) -> None:
|
||||||
from nanobot.webui.thread_disk import webui_thread_file_path
|
from nanobot.webui.thread_disk import webui_thread_file_path
|
||||||
from nanobot.webui.transcript import delete_webui_transcript, webui_transcript_path
|
from nanobot.webui.transcript import delete_webui_transcript, webui_transcript_path
|
||||||
@@ -848,83 +786,6 @@ def test_build_response_restores_session_users_for_legacy_transcript(
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_complete_transcript_does_not_load_session_messages(tmp_path, monkeypatch) -> None:
|
|
||||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
|
||||||
key = "websocket:complete-fast-path"
|
|
||||||
for event in (
|
|
||||||
{"event": "user", "chat_id": "complete-fast-path", "text": "question"},
|
|
||||||
{"event": "message", "chat_id": "complete-fast-path", "text": "answer"},
|
|
||||||
{"event": "turn_end", "chat_id": "complete-fast-path"},
|
|
||||||
):
|
|
||||||
append_transcript_object(key, event)
|
|
||||||
|
|
||||||
def fail_if_loaded() -> list[dict]:
|
|
||||||
raise AssertionError("complete transcripts must not read canonical session history")
|
|
||||||
|
|
||||||
out = build_webui_thread_response(
|
|
||||||
key,
|
|
||||||
limit=4,
|
|
||||||
direction="latest",
|
|
||||||
session_messages_loader=fail_if_loaded,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert out is not None
|
|
||||||
assert [(message["role"], message["content"]) for message in out["messages"]] == [
|
|
||||||
("user", "question"),
|
|
||||||
("assistant", "answer"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_legacy_recovery_loads_session_and_builds_backfill_turns_once(
|
|
||||||
tmp_path,
|
|
||||||
monkeypatch,
|
|
||||||
) -> None:
|
|
||||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
|
||||||
key = "websocket:lazy-legacy-recovery"
|
|
||||||
append_transcript_object(
|
|
||||||
key,
|
|
||||||
{"event": "message", "chat_id": "lazy-legacy-recovery", "text": "answer"},
|
|
||||||
)
|
|
||||||
append_transcript_object(
|
|
||||||
key,
|
|
||||||
{
|
|
||||||
"event": "turn_end",
|
|
||||||
"chat_id": "lazy-legacy-recovery",
|
|
||||||
"transcript_incomplete": True,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
loader_calls = 0
|
|
||||||
backfill_calls = 0
|
|
||||||
original = transcript_module._session_backfill_turns
|
|
||||||
|
|
||||||
def load_session_messages() -> list[dict]:
|
|
||||||
nonlocal loader_calls
|
|
||||||
loader_calls += 1
|
|
||||||
return [
|
|
||||||
{"role": "user", "content": "question"},
|
|
||||||
{"role": "assistant", "content": "answer"},
|
|
||||||
]
|
|
||||||
|
|
||||||
def track_backfill(session_key: str, session_messages: list[dict]):
|
|
||||||
nonlocal backfill_calls
|
|
||||||
backfill_calls += 1
|
|
||||||
return original(session_key, session_messages)
|
|
||||||
|
|
||||||
monkeypatch.setattr(transcript_module, "_session_backfill_turns", track_backfill)
|
|
||||||
|
|
||||||
out = build_webui_thread_response(key, session_messages_loader=load_session_messages)
|
|
||||||
|
|
||||||
assert out is not None
|
|
||||||
assert loader_calls == 1
|
|
||||||
assert backfill_calls == 1
|
|
||||||
assert [(message["role"], message["content"]) for message in out["messages"]] == [
|
|
||||||
("user", "question"),
|
|
||||||
("assistant", "answer"),
|
|
||||||
]
|
|
||||||
assert out["has_pending_tool_calls"] is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_response_restores_session_users_without_duplicating_new_transcript_users(
|
def test_build_response_restores_session_users_without_duplicating_new_transcript_users(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
|
|||||||
@@ -1,14 +1,9 @@
|
|||||||
import json
|
import json
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.security.workspace_access import (
|
from nanobot.security.workspace_access import WorkspaceScopeError, default_workspace_scope
|
||||||
WORKSPACE_SCOPE_METADATA_KEY,
|
from nanobot.session.manager import SessionManager
|
||||||
WorkspaceScopeError,
|
|
||||||
default_workspace_scope,
|
|
||||||
)
|
|
||||||
from nanobot.session.manager import SessionManager, SessionStore
|
|
||||||
from nanobot.webui.workspaces import (
|
from nanobot.webui.workspaces import (
|
||||||
WebUIWorkspaceController,
|
WebUIWorkspaceController,
|
||||||
read_webui_default_access_mode,
|
read_webui_default_access_mode,
|
||||||
@@ -140,33 +135,6 @@ def test_webui_default_access_applies_to_unscoped_old_sessions(tmp_path, monkeyp
|
|||||||
assert new_scope.access_mode == "full"
|
assert new_scope.access_mode == "full"
|
||||||
|
|
||||||
|
|
||||||
def test_indexed_scope_preserves_missing_and_explicit_null_semantics(tmp_path, monkeypatch) -> None:
|
|
||||||
monkeypatch.setattr("nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui")
|
|
||||||
default = tmp_path / "default"
|
|
||||||
default.mkdir()
|
|
||||||
write_webui_default_access_mode("full")
|
|
||||||
controller = WebUIWorkspaceController(
|
|
||||||
session_manager=None,
|
|
||||||
default_workspace=default,
|
|
||||||
default_restrict_to_workspace=True,
|
|
||||||
)
|
|
||||||
webui_default = controller.default_scope()
|
|
||||||
|
|
||||||
missing = controller.scope_for_indexed_metadata(
|
|
||||||
None,
|
|
||||||
scope_present=False,
|
|
||||||
default_scope=webui_default,
|
|
||||||
)
|
|
||||||
explicit_null = controller.scope_for_indexed_metadata(
|
|
||||||
None,
|
|
||||||
scope_present=True,
|
|
||||||
default_scope=webui_default,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert missing.access_mode == "full"
|
|
||||||
assert explicit_null.access_mode == "restricted"
|
|
||||||
|
|
||||||
|
|
||||||
def test_webui_default_access_does_not_override_explicit_session_scope(tmp_path, monkeypatch) -> None:
|
def test_webui_default_access_does_not_override_explicit_session_scope(tmp_path, monkeypatch) -> None:
|
||||||
monkeypatch.setattr("nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui")
|
monkeypatch.setattr("nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui")
|
||||||
default = tmp_path / "default"
|
default = tmp_path / "default"
|
||||||
@@ -217,53 +185,6 @@ def test_scope_for_session_key_reads_metadata_without_full_history(
|
|||||||
assert scope.access_mode == "full"
|
assert scope.access_mode == "full"
|
||||||
|
|
||||||
|
|
||||||
def test_scope_for_session_key_always_reads_the_active_store(tmp_path, monkeypatch) -> None:
|
|
||||||
monkeypatch.setattr("nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui")
|
|
||||||
default = tmp_path / "default"
|
|
||||||
project = tmp_path / "project"
|
|
||||||
default.mkdir()
|
|
||||||
project.mkdir()
|
|
||||||
workspace = tmp_path / "session-data"
|
|
||||||
full_scope = default_workspace_scope(project, restrict_to_workspace=False)
|
|
||||||
restricted_scope = default_workspace_scope(project, restrict_to_workspace=True)
|
|
||||||
|
|
||||||
residual_sessions = SessionManager(workspace)
|
|
||||||
residual = residual_sessions.get_or_create("websocket:cached")
|
|
||||||
residual.metadata[WORKSPACE_SCOPE_METADATA_KEY] = full_scope.metadata()
|
|
||||||
residual_sessions.save(residual)
|
|
||||||
|
|
||||||
store = MagicMock(spec=SessionStore)
|
|
||||||
store.read_metadata.side_effect = [
|
|
||||||
{
|
|
||||||
"key": "websocket:cached",
|
|
||||||
"created_at": None,
|
|
||||||
"updated_at": None,
|
|
||||||
"metadata": {WORKSPACE_SCOPE_METADATA_KEY: full_scope.metadata()},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "websocket:cached",
|
|
||||||
"created_at": None,
|
|
||||||
"updated_at": None,
|
|
||||||
"metadata": {WORKSPACE_SCOPE_METADATA_KEY: restricted_scope.metadata()},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
sessions = SessionManager(workspace, store=store)
|
|
||||||
controller = WebUIWorkspaceController(
|
|
||||||
session_manager=sessions,
|
|
||||||
default_workspace=default,
|
|
||||||
default_restrict_to_workspace=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
first = controller.scope_for_session_key("websocket:cached")
|
|
||||||
second = controller.scope_for_session_key("websocket:cached")
|
|
||||||
|
|
||||||
assert first.project_path == project.resolve()
|
|
||||||
assert first.access_mode == "full"
|
|
||||||
assert second.project_path == project.resolve()
|
|
||||||
assert second.access_mode == "restricted"
|
|
||||||
assert store.read_metadata.call_count == 2
|
|
||||||
|
|
||||||
|
|
||||||
def test_remote_existing_chat_can_reduce_its_workspace_access(tmp_path, monkeypatch) -> None:
|
def test_remote_existing_chat_can_reduce_its_workspace_access(tmp_path, monkeypatch) -> None:
|
||||||
monkeypatch.setattr("nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui")
|
monkeypatch.setattr("nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui")
|
||||||
default = tmp_path / "default"
|
default = tmp_path / "default"
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
"""Tests for shared embedded WebUI HTTP helpers."""
|
|
||||||
|
|
||||||
import gzip
|
|
||||||
import json
|
|
||||||
|
|
||||||
from nanobot.webui.http_utils import http_json_response
|
|
||||||
|
|
||||||
|
|
||||||
def test_http_json_response_compresses_large_payload_when_gzip_is_accepted() -> None:
|
|
||||||
payload = {"message": "响应内容" * 2_000}
|
|
||||||
|
|
||||||
response = http_json_response(payload, accept_encoding="br, gzip; q=0.5")
|
|
||||||
|
|
||||||
assert response.headers["Content-Encoding"] == "gzip"
|
|
||||||
assert response.headers["Vary"] == "Accept-Encoding"
|
|
||||||
assert int(response.headers["Content-Length"]) == len(response.body)
|
|
||||||
assert json.loads(gzip.decompress(response.body)) == payload
|
|
||||||
|
|
||||||
|
|
||||||
def test_http_json_response_preserves_identity_when_gzip_is_rejected() -> None:
|
|
||||||
payload = {"message": "x" * 8_000}
|
|
||||||
|
|
||||||
response = http_json_response(payload, accept_encoding="gzip;q=0, br")
|
|
||||||
|
|
||||||
assert "Content-Encoding" not in response.headers
|
|
||||||
assert response.headers["Vary"] == "Accept-Encoding"
|
|
||||||
assert int(response.headers["Content-Length"]) == len(response.body)
|
|
||||||
assert json.loads(response.body) == payload
|
|
||||||
|
|
||||||
|
|
||||||
def test_http_json_response_does_not_compress_small_payload() -> None:
|
|
||||||
payload = {"ok": True}
|
|
||||||
|
|
||||||
response = http_json_response(payload, accept_encoding="gzip")
|
|
||||||
|
|
||||||
assert "Content-Encoding" not in response.headers
|
|
||||||
assert response.headers["Vary"] == "Accept-Encoding"
|
|
||||||
assert json.loads(response.body) == payload
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import io
|
|
||||||
import os
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -10,7 +9,6 @@ import pytest
|
|||||||
import nanobot.webui.session_list_index as session_list_index
|
import nanobot.webui.session_list_index as session_list_index
|
||||||
from nanobot.cron.session_turns import CRON_HISTORY_META
|
from nanobot.cron.session_turns import CRON_HISTORY_META
|
||||||
from nanobot.providers.base import ProviderConversationState
|
from nanobot.providers.base import ProviderConversationState
|
||||||
from nanobot.security.workspace_access import WORKSPACE_SCOPE_METADATA_KEY
|
|
||||||
from nanobot.session.automation_turns import AUTOMATION_HISTORY_META
|
from nanobot.session.automation_turns import AUTOMATION_HISTORY_META
|
||||||
from nanobot.session.history_visibility import HIDDEN_HISTORY_META
|
from nanobot.session.history_visibility import HIDDEN_HISTORY_META
|
||||||
from nanobot.session.manager import SessionManager
|
from nanobot.session.manager import SessionManager
|
||||||
@@ -30,7 +28,7 @@ def test_webui_session_list_reuses_valid_index_without_scanning_files(
|
|||||||
assert list_webui_sessions(manager)[0]["preview"] == "indexed preview"
|
assert list_webui_sessions(manager)[0]["preview"] == "indexed preview"
|
||||||
assert list_webui_sessions(manager)[0]["model_preset"] == "fast"
|
assert list_webui_sessions(manager)[0]["model_preset"] == "fast"
|
||||||
|
|
||||||
def fail_scan(session_manager: SessionManager, path: Path, webui_dir: Path) -> None:
|
def fail_scan(session_manager: SessionManager, path: Path) -> None:
|
||||||
raise AssertionError(f"unexpected session file scan: {path}")
|
raise AssertionError(f"unexpected session file scan: {path}")
|
||||||
|
|
||||||
monkeypatch.setattr(session_list_index, "_scan_session_row", fail_scan)
|
monkeypatch.setattr(session_list_index, "_scan_session_row", fail_scan)
|
||||||
@@ -42,89 +40,6 @@ def test_webui_session_list_reuses_valid_index_without_scanning_files(
|
|||||||
assert rows[0]["model_preset"] == "fast"
|
assert rows[0]["model_preset"] == "fast"
|
||||||
|
|
||||||
|
|
||||||
def test_webui_session_list_indexes_workspace_scope_and_preserves_null(
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
manager = SessionManager(tmp_path)
|
|
||||||
project = tmp_path / "project"
|
|
||||||
project.mkdir()
|
|
||||||
|
|
||||||
scoped = manager.get_or_create("websocket:scoped")
|
|
||||||
scoped.metadata[WORKSPACE_SCOPE_METADATA_KEY] = {
|
|
||||||
"project_path": str(project),
|
|
||||||
"access_mode": "full",
|
|
||||||
"future_extension": "x" * 5000,
|
|
||||||
}
|
|
||||||
manager.save(scoped)
|
|
||||||
explicit_null = manager.get_or_create("websocket:null")
|
|
||||||
explicit_null.metadata[WORKSPACE_SCOPE_METADATA_KEY] = None
|
|
||||||
manager.save(explicit_null)
|
|
||||||
manager.save(manager.get_or_create("websocket:missing"))
|
|
||||||
|
|
||||||
rows = {row["key"]: row for row in list_webui_sessions(manager)}
|
|
||||||
|
|
||||||
assert session_list_index.indexed_workspace_scope(rows["websocket:scoped"]) == (
|
|
||||||
True,
|
|
||||||
{"project_path": str(project), "access_mode": "full"},
|
|
||||||
)
|
|
||||||
assert session_list_index.indexed_workspace_scope(rows["websocket:null"]) == (True, None)
|
|
||||||
assert session_list_index.indexed_workspace_scope(rows["websocket:missing"]) == (False, None)
|
|
||||||
|
|
||||||
scoped.metadata[WORKSPACE_SCOPE_METADATA_KEY]["access_mode"] = "restricted"
|
|
||||||
manager.save(scoped)
|
|
||||||
|
|
||||||
refreshed = {row["key"]: row for row in list_webui_sessions(manager)}
|
|
||||||
assert session_list_index.indexed_workspace_scope(refreshed["websocket:scoped"])[1] == {
|
|
||||||
"project_path": str(project),
|
|
||||||
"access_mode": "restricted",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_webui_session_list_does_not_cache_old_snapshot_with_new_signature(
|
|
||||||
tmp_path: Path,
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
manager = SessionManager(tmp_path)
|
|
||||||
session_key = "websocket:scope-race"
|
|
||||||
session = manager.get_or_create(session_key)
|
|
||||||
session.metadata[WORKSPACE_SCOPE_METADATA_KEY] = {
|
|
||||||
"project_path": str(tmp_path),
|
|
||||||
"access_mode": "full",
|
|
||||||
}
|
|
||||||
session.add_message("user", "hello")
|
|
||||||
manager.save(session)
|
|
||||||
session_path = manager._get_session_path(session_key)
|
|
||||||
original_open = open
|
|
||||||
scope_changed = False
|
|
||||||
|
|
||||||
class RacingReader(io.StringIO):
|
|
||||||
def __next__(self) -> str:
|
|
||||||
nonlocal scope_changed
|
|
||||||
if not scope_changed:
|
|
||||||
scope_changed = True
|
|
||||||
current = manager.get_or_create(session_key)
|
|
||||||
current.metadata[WORKSPACE_SCOPE_METADATA_KEY] = {
|
|
||||||
"project_path": str(tmp_path),
|
|
||||||
"access_mode": "restricted",
|
|
||||||
}
|
|
||||||
manager.save(current)
|
|
||||||
return super().__next__()
|
|
||||||
|
|
||||||
def racing_open(path, *args, **kwargs):
|
|
||||||
if Path(path) == session_path:
|
|
||||||
with original_open(path, *args, **kwargs) as source:
|
|
||||||
return RacingReader(source.read())
|
|
||||||
return original_open(path, *args, **kwargs)
|
|
||||||
|
|
||||||
monkeypatch.setattr(session_list_index, "open", racing_open, raising=False)
|
|
||||||
|
|
||||||
first = list_webui_sessions(manager)[0]
|
|
||||||
second = list_webui_sessions(manager)[0]
|
|
||||||
|
|
||||||
assert session_list_index.indexed_workspace_scope(first)[1]["access_mode"] == "full"
|
|
||||||
assert session_list_index.indexed_workspace_scope(second)[1]["access_mode"] == "restricted"
|
|
||||||
|
|
||||||
|
|
||||||
def test_webui_session_list_rejects_invalid_internal_model_preset_metadata(
|
def test_webui_session_list_rejects_invalid_internal_model_preset_metadata(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -159,13 +74,9 @@ def test_webui_session_list_rescans_only_changed_file(tmp_path: Path, monkeypatc
|
|||||||
original_scan = session_list_index._scan_session_row
|
original_scan = session_list_index._scan_session_row
|
||||||
scanned: list[str] = []
|
scanned: list[str] = []
|
||||||
|
|
||||||
def record_scan(
|
def record_scan(session_manager: SessionManager, path: Path) -> dict | None:
|
||||||
session_manager: SessionManager,
|
|
||||||
path: Path,
|
|
||||||
webui_dir: Path,
|
|
||||||
) -> dict | None:
|
|
||||||
scanned.append(path.name)
|
scanned.append(path.name)
|
||||||
return original_scan(session_manager, path, webui_dir)
|
return original_scan(session_manager, path)
|
||||||
|
|
||||||
monkeypatch.setattr(session_list_index, "_scan_session_row", record_scan)
|
monkeypatch.setattr(session_list_index, "_scan_session_row", record_scan)
|
||||||
|
|
||||||
@@ -336,13 +247,9 @@ def test_webui_session_list_rescans_when_transcript_changes(
|
|||||||
original_scan = session_list_index._scan_session_row
|
original_scan = session_list_index._scan_session_row
|
||||||
scanned: list[str] = []
|
scanned: list[str] = []
|
||||||
|
|
||||||
def record_scan(
|
def record_scan(session_manager: SessionManager, path: Path) -> dict | None:
|
||||||
session_manager: SessionManager,
|
|
||||||
path: Path,
|
|
||||||
webui_dir: Path,
|
|
||||||
) -> dict | None:
|
|
||||||
scanned.append(path.name)
|
scanned.append(path.name)
|
||||||
return original_scan(session_manager, path, webui_dir)
|
return original_scan(session_manager, path)
|
||||||
|
|
||||||
monkeypatch.setattr(session_list_index, "_scan_session_row", record_scan)
|
monkeypatch.setattr(session_list_index, "_scan_session_row", record_scan)
|
||||||
|
|
||||||
|
|||||||
@@ -86,26 +86,6 @@ def test_settings_payload_includes_versioned_docs(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_settings_payload_exposes_edenai_provider(
|
|
||||||
tmp_path,
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
config_path = tmp_path / "config.json"
|
|
||||||
config = Config()
|
|
||||||
config.providers.edenai.api_key = "eden-test-key"
|
|
||||||
save_config(config, config_path)
|
|
||||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
|
||||||
|
|
||||||
payload = settings_payload()
|
|
||||||
edenai = next(row for row in payload["providers"] if row["name"] == "edenai")
|
|
||||||
|
|
||||||
assert edenai["label"] == "Eden AI"
|
|
||||||
assert edenai["configured"] is True
|
|
||||||
assert edenai["default_api_base"] == "https://api.edenai.run/v3"
|
|
||||||
assert edenai["model_catalog"] == "catalog"
|
|
||||||
assert edenai["model_selectable"] is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_settings_payload_includes_relocated_capabilities(
|
def test_settings_payload_includes_relocated_capabilities(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
|||||||
+3
-3
@@ -135,15 +135,15 @@
|
|||||||
},
|
},
|
||||||
"pt-BR": {
|
"pt-BR": {
|
||||||
boot: "Carregando nanobot…",
|
boot: "Carregando nanobot…",
|
||||||
description: "Interface web do nanobot — converse com o seu espaço de trabalho do nanobot."
|
description: "Interface web do nanobot — converse com o seu workspace do nanobot."
|
||||||
},
|
},
|
||||||
vi: {
|
vi: {
|
||||||
boot: "Đang tải nanobot…",
|
boot: "Đang tải nanobot…",
|
||||||
description: "Giao diện web nanobot — trò chuyện với không gian làm việc nanobot của bạn."
|
description: "Giao diện web nanobot — trò chuyện với workspace nanobot của bạn."
|
||||||
},
|
},
|
||||||
id: {
|
id: {
|
||||||
boot: "Memuat nanobot…",
|
boot: "Memuat nanobot…",
|
||||||
description: "UI web nanobot — ngobrol dengan ruang kerja nanobot Anda."
|
description: "UI web nanobot — ngobrol dengan workspace nanobot Anda."
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+149
-16
@@ -8,7 +8,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Moon, PanelLeft, ShieldCheck, Sun, X } from "lucide-react";
|
import { Ghost, Moon, PanelLeft, ShieldCheck, Sun, X } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { channelUiPresentation } from "@/channel-plugins/registry";
|
import { channelUiPresentation } from "@/channel-plugins/registry";
|
||||||
import { Sidebar } from "@/components/Sidebar";
|
import { Sidebar } from "@/components/Sidebar";
|
||||||
@@ -37,6 +37,13 @@ import {
|
|||||||
import { displayTitle } from "@/lib/chat-groups";
|
import { displayTitle } from "@/lib/chat-groups";
|
||||||
import { deriveTitle } from "@/lib/format";
|
import { deriveTitle } from "@/lib/format";
|
||||||
import { NanobotClient } from "@/lib/nanobot-client";
|
import { NanobotClient } from "@/lib/nanobot-client";
|
||||||
|
import {
|
||||||
|
createTemporaryChatSession,
|
||||||
|
isQuickChatKey,
|
||||||
|
QUICK_CHAT_ID,
|
||||||
|
QUICK_CHAT_KEY,
|
||||||
|
quickChatSession,
|
||||||
|
} from "@/lib/quick-chat";
|
||||||
import { ClientProvider, useClient } from "@/providers/ClientProvider";
|
import { ClientProvider, useClient } from "@/providers/ClientProvider";
|
||||||
import type {
|
import type {
|
||||||
BootstrapResponse,
|
BootstrapResponse,
|
||||||
@@ -116,13 +123,12 @@ const RenameChatDialog = lazy(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function SurfaceLoadingFallback() {
|
function SurfaceLoadingFallback() {
|
||||||
const { t } = useTranslation();
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
aria-busy="true"
|
aria-busy="true"
|
||||||
className="flex h-full w-full flex-col gap-5 px-5 py-8 sm:px-8 lg:px-12"
|
className="flex h-full w-full flex-col gap-5 px-5 py-8 sm:px-8 lg:px-12"
|
||||||
>
|
>
|
||||||
<span className="sr-only">{t("settings.status.loading")}</span>
|
<span className="sr-only">Loading</span>
|
||||||
<div className="h-4 w-20 animate-pulse rounded bg-muted/70 motion-reduce:animate-none" />
|
<div className="h-4 w-20 animate-pulse rounded bg-muted/70 motion-reduce:animate-none" />
|
||||||
<div className="h-9 w-48 animate-pulse rounded bg-muted/70 motion-reduce:animate-none" />
|
<div className="h-9 w-48 animate-pulse rounded bg-muted/70 motion-reduce:animate-none" />
|
||||||
<div className="mt-4 h-12 w-full max-w-3xl animate-pulse rounded-md bg-muted/55 motion-reduce:animate-none" />
|
<div className="mt-4 h-12 w-full max-w-3xl animate-pulse rounded-md bg-muted/55 motion-reduce:animate-none" />
|
||||||
@@ -226,6 +232,9 @@ function readShellRoute(): ShellRoute {
|
|||||||
if (path === "/skills") {
|
if (path === "/skills") {
|
||||||
return { view: "skills", activeKey, settingsSection: "skills" };
|
return { view: "skills", activeKey, settingsSection: "skills" };
|
||||||
}
|
}
|
||||||
|
if (path === "/quick-chat") {
|
||||||
|
return { view: "chat", activeKey: QUICK_CHAT_KEY, settingsSection: "overview" };
|
||||||
|
}
|
||||||
if (path.startsWith("/chat/")) {
|
if (path.startsWith("/chat/")) {
|
||||||
const encoded = path.slice("/chat/".length);
|
const encoded = path.slice("/chat/".length);
|
||||||
try {
|
try {
|
||||||
@@ -242,6 +251,7 @@ function readShellRoute(): ShellRoute {
|
|||||||
|
|
||||||
function shellRouteHash(route: ShellRoute): string {
|
function shellRouteHash(route: ShellRoute): string {
|
||||||
if (route.view === "chat") {
|
if (route.view === "chat") {
|
||||||
|
if (isQuickChatKey(route.activeKey)) return "#/quick-chat";
|
||||||
return route.activeKey
|
return route.activeKey
|
||||||
? `#/chat/${encodeURIComponent(route.activeKey)}`
|
? `#/chat/${encodeURIComponent(route.activeKey)}`
|
||||||
: "#/new";
|
: "#/new";
|
||||||
@@ -948,14 +958,24 @@ function Shell({
|
|||||||
deleteChat,
|
deleteChat,
|
||||||
getSessionAutomations,
|
getSessionAutomations,
|
||||||
} = useSessions();
|
} = useSessions();
|
||||||
|
const regularSessions = useMemo(
|
||||||
|
() => sessions.filter((session) => !isQuickChatKey(session.key)),
|
||||||
|
[sessions],
|
||||||
|
);
|
||||||
|
const quickSession = useMemo(
|
||||||
|
() => quickChatSession(sessions.find((session) => isQuickChatKey(session.key))),
|
||||||
|
[sessions],
|
||||||
|
);
|
||||||
const { state: sidebarState, update: updateSidebarState } =
|
const { state: sidebarState, update: updateSidebarState } =
|
||||||
useSidebarState(sessions, !loading);
|
useSidebarState(regularSessions, !loading);
|
||||||
const initialRouteRef = useRef<ShellRoute | null>(null);
|
const initialRouteRef = useRef<ShellRoute | null>(null);
|
||||||
if (!initialRouteRef.current) initialRouteRef.current = readShellRoute();
|
if (!initialRouteRef.current) initialRouteRef.current = readShellRoute();
|
||||||
const [activeKey, setActiveKey] = useState<string | null>(
|
const [activeKey, setActiveKey] = useState<string | null>(
|
||||||
initialRouteRef.current.activeKey,
|
initialRouteRef.current.activeKey,
|
||||||
);
|
);
|
||||||
const [view, setView] = useState<ShellView>(initialRouteRef.current.view);
|
const [view, setView] = useState<ShellView>(initialRouteRef.current.view);
|
||||||
|
const [temporarySession, setTemporarySession] = useState<ChatSummary | null>(null);
|
||||||
|
const temporarySessionRef = useRef<ChatSummary | null>(null);
|
||||||
const [settingsInitialSection, setSettingsInitialSection] =
|
const [settingsInitialSection, setSettingsInitialSection] =
|
||||||
useState<SettingsSectionKey>(initialRouteRef.current.settingsSection);
|
useState<SettingsSectionKey>(initialRouteRef.current.settingsSection);
|
||||||
const [hostSidebarOpen, setHostSidebarOpen] =
|
const [hostSidebarOpen, setHostSidebarOpen] =
|
||||||
@@ -1005,19 +1025,33 @@ function Shell({
|
|||||||
const showHostChrome = effectiveRuntimeSurface === "native";
|
const showHostChrome = effectiveRuntimeSurface === "native";
|
||||||
const showMainSidebar = view !== "settings";
|
const showMainSidebar = view !== "settings";
|
||||||
|
|
||||||
|
const discardTemporaryChat = useCallback(() => {
|
||||||
|
const current = temporarySessionRef.current;
|
||||||
|
if (!current) return;
|
||||||
|
temporarySessionRef.current = null;
|
||||||
|
client.discardTemporaryChat(current.chatId);
|
||||||
|
setTemporarySession(null);
|
||||||
|
}, [client]);
|
||||||
|
|
||||||
const navigate = useCallback(
|
const navigate = useCallback(
|
||||||
(route: ShellRoute, options?: { replace?: boolean }) => {
|
(route: ShellRoute, options?: { replace?: boolean }) => {
|
||||||
|
if (route.view !== "chat" || route.activeKey !== QUICK_CHAT_KEY) {
|
||||||
|
discardTemporaryChat();
|
||||||
|
}
|
||||||
setActiveKey(route.activeKey);
|
setActiveKey(route.activeKey);
|
||||||
setView(route.view);
|
setView(route.view);
|
||||||
setSettingsInitialSection(route.settingsSection);
|
setSettingsInitialSection(route.settingsSection);
|
||||||
writeShellRoute(route, options?.replace);
|
writeShellRoute(route, options?.replace);
|
||||||
},
|
},
|
||||||
[],
|
[discardTemporaryChat],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const applyRoute = () => {
|
const applyRoute = () => {
|
||||||
const route = readShellRoute();
|
const route = readShellRoute();
|
||||||
|
if (route.view !== "chat" || route.activeKey !== QUICK_CHAT_KEY) {
|
||||||
|
discardTemporaryChat();
|
||||||
|
}
|
||||||
setActiveKey(route.activeKey);
|
setActiveKey(route.activeKey);
|
||||||
setView(route.view);
|
setView(route.view);
|
||||||
setSettingsInitialSection(route.settingsSection);
|
setSettingsInitialSection(route.settingsSection);
|
||||||
@@ -1028,7 +1062,15 @@ function Shell({
|
|||||||
};
|
};
|
||||||
window.addEventListener("hashchange", applyRoute);
|
window.addEventListener("hashchange", applyRoute);
|
||||||
return () => window.removeEventListener("hashchange", applyRoute);
|
return () => window.removeEventListener("hashchange", applyRoute);
|
||||||
}, []);
|
}, [discardTemporaryChat]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return client.onStatus((status) => {
|
||||||
|
if (status !== "open") discardTemporaryChat();
|
||||||
|
});
|
||||||
|
}, [client, discardTemporaryChat]);
|
||||||
|
|
||||||
|
useEffect(() => () => discardTemporaryChat(), [discardTemporaryChat]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -1115,8 +1157,11 @@ function Shell({
|
|||||||
|
|
||||||
const activeSession = useMemo<ChatSummary | null>(() => {
|
const activeSession = useMemo<ChatSummary | null>(() => {
|
||||||
if (!activeKey) return null;
|
if (!activeKey) return null;
|
||||||
|
if (isQuickChatKey(activeKey)) return temporarySession ?? quickSession;
|
||||||
return sessions.find((s) => s.key === activeKey) ?? null;
|
return sessions.find((s) => s.key === activeKey) ?? null;
|
||||||
}, [sessions, activeKey]);
|
}, [sessions, activeKey, quickSession, temporarySession]);
|
||||||
|
const quickChatActive = isQuickChatKey(activeKey);
|
||||||
|
const temporaryChatActive = quickChatActive && temporarySession !== null;
|
||||||
const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
|
const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
|
||||||
const updatedChatIdList = useMemo(() => Array.from(updatedChatIds), [updatedChatIds]);
|
const updatedChatIdList = useMemo(() => Array.from(updatedChatIds), [updatedChatIds]);
|
||||||
const activeChatId = activeSession?.chatId ?? null;
|
const activeChatId = activeSession?.chatId ?? null;
|
||||||
@@ -1131,6 +1176,12 @@ function Shell({
|
|||||||
});
|
});
|
||||||
}, [activeChatId]);
|
}, [activeChatId]);
|
||||||
const activeWorkspaceScope = useMemo<WorkspaceScopePayload | null>(() => {
|
const activeWorkspaceScope = useMemo<WorkspaceScopePayload | null>(() => {
|
||||||
|
if (temporaryChatActive) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (quickChatActive) {
|
||||||
|
return workspaces?.default_scope ?? null;
|
||||||
|
}
|
||||||
if (activeChatId && workspaceOverrides[activeChatId]) {
|
if (activeChatId && workspaceOverrides[activeChatId]) {
|
||||||
return workspaceOverrides[activeChatId];
|
return workspaceOverrides[activeChatId];
|
||||||
}
|
}
|
||||||
@@ -1142,6 +1193,8 @@ function Shell({
|
|||||||
activeChatId,
|
activeChatId,
|
||||||
activeSession?.workspaceScope,
|
activeSession?.workspaceScope,
|
||||||
draftWorkspaceScope,
|
draftWorkspaceScope,
|
||||||
|
quickChatActive,
|
||||||
|
temporaryChatActive,
|
||||||
workspaceOverrides,
|
workspaceOverrides,
|
||||||
workspaces?.default_scope,
|
workspaces?.default_scope,
|
||||||
]);
|
]);
|
||||||
@@ -1162,7 +1215,10 @@ function Shell({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loading) return;
|
if (loading) return;
|
||||||
const knownChatIds = new Set(sessions.map((session) => session.chatId));
|
const knownChatIds = new Set([
|
||||||
|
QUICK_CHAT_ID,
|
||||||
|
...sessions.map((session) => session.chatId),
|
||||||
|
]);
|
||||||
setUpdatedChatIds((current) => {
|
setUpdatedChatIds((current) => {
|
||||||
const next = new Set(
|
const next = new Set(
|
||||||
Array.from(current).filter((chatId) => knownChatIds.has(chatId)),
|
Array.from(current).filter((chatId) => knownChatIds.has(chatId)),
|
||||||
@@ -1177,6 +1233,7 @@ function Shell({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loading || !activeKey) return;
|
if (loading || !activeKey) return;
|
||||||
|
if (isQuickChatKey(activeKey)) return;
|
||||||
if (sessions.some((session) => session.key === activeKey)) return;
|
if (sessions.some((session) => session.key === activeKey)) return;
|
||||||
const currentRoute = readShellRoute();
|
const currentRoute = readShellRoute();
|
||||||
navigate(
|
navigate(
|
||||||
@@ -1418,6 +1475,28 @@ function Shell({
|
|||||||
setMobileSidebarOpen(false);
|
setMobileSidebarOpen(false);
|
||||||
}, [navigate]);
|
}, [navigate]);
|
||||||
|
|
||||||
|
const onOpenQuickChat = useCallback(() => {
|
||||||
|
setDraftWorkspaceScope(null);
|
||||||
|
setWorkspaceError(null);
|
||||||
|
setSessionSearchOpen(false);
|
||||||
|
navigate({
|
||||||
|
view: "chat",
|
||||||
|
activeKey: QUICK_CHAT_KEY,
|
||||||
|
settingsSection: "overview",
|
||||||
|
});
|
||||||
|
setMobileSidebarOpen(false);
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
const onToggleTemporaryChat = useCallback(() => {
|
||||||
|
if (temporarySessionRef.current) {
|
||||||
|
discardTemporaryChat();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const session = createTemporaryChatSession();
|
||||||
|
temporarySessionRef.current = session;
|
||||||
|
setTemporarySession(session);
|
||||||
|
}, [discardTemporaryChat]);
|
||||||
|
|
||||||
const onNewChatInProject = useCallback(
|
const onNewChatInProject = useCallback(
|
||||||
(projectPath: string, projectName: string) => {
|
(projectPath: string, projectName: string) => {
|
||||||
const base = workspaces?.default_scope ?? activeWorkspaceScope;
|
const base = workspaces?.default_scope ?? activeWorkspaceScope;
|
||||||
@@ -1683,6 +1762,7 @@ function Shell({
|
|||||||
setMobileSidebarOpen(false);
|
setMobileSidebarOpen(false);
|
||||||
const nextKey = (() => {
|
const nextKey = (() => {
|
||||||
if (!activeKey) return null;
|
if (!activeKey) return null;
|
||||||
|
if (isQuickChatKey(activeKey)) return activeKey;
|
||||||
if (sessions.some((session) => session.key === activeKey)) return activeKey;
|
if (sessions.some((session) => session.key === activeKey)) return activeKey;
|
||||||
return sessions[0]?.key ?? null;
|
return sessions[0]?.key ?? null;
|
||||||
})();
|
})();
|
||||||
@@ -1774,7 +1854,10 @@ function Shell({
|
|||||||
});
|
});
|
||||||
}, [client, t]);
|
}, [client, t]);
|
||||||
|
|
||||||
const onTurnEnd = useDeferredTitleRefresh(activeSession, refresh);
|
const onTurnEnd = useDeferredTitleRefresh(
|
||||||
|
quickChatActive ? null : activeSession,
|
||||||
|
refresh,
|
||||||
|
);
|
||||||
|
|
||||||
const onConfirmDelete = useCallback(async () => {
|
const onConfirmDelete = useCallback(async () => {
|
||||||
if (!pendingDelete) return;
|
if (!pendingDelete) return;
|
||||||
@@ -1864,11 +1947,39 @@ function Shell({
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const headerTitle = activeSession
|
const headerTitle = temporaryChatActive
|
||||||
|
? t("quickChat.temporary.title")
|
||||||
|
: quickChatActive
|
||||||
|
? t("sidebar.quickChat")
|
||||||
|
: activeSession
|
||||||
? sidebarState.title_overrides[activeSession.key] ||
|
? sidebarState.title_overrides[activeSession.key] ||
|
||||||
activeSession.title ||
|
activeSession.title ||
|
||||||
deriveTitle(activeSession.preview, t("chat.newChat"))
|
deriveTitle(activeSession.preview, t("chat.newChat"))
|
||||||
: t("app.brand");
|
: t("app.brand");
|
||||||
|
|
||||||
|
const temporaryChatAction = quickChatActive ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
aria-pressed={temporaryChatActive}
|
||||||
|
aria-label={
|
||||||
|
temporaryChatActive
|
||||||
|
? t("quickChat.temporary.exit")
|
||||||
|
: t("quickChat.temporary.enter")
|
||||||
|
}
|
||||||
|
onClick={onToggleTemporaryChat}
|
||||||
|
className={cn(
|
||||||
|
"host-no-drag h-8 rounded-full px-2.5 text-xs text-muted-foreground",
|
||||||
|
temporaryChatActive && "bg-foreground text-background hover:bg-foreground/90 hover:text-background",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Ghost className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
{temporaryChatActive
|
||||||
|
? t("quickChat.temporary.active")
|
||||||
|
: t("quickChat.temporary.enter")}
|
||||||
|
</Button>
|
||||||
|
) : undefined;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (view === "settings") {
|
if (view === "settings") {
|
||||||
@@ -1901,10 +2012,12 @@ function Shell({
|
|||||||
}, [activeSession, headerTitle, i18n.resolvedLanguage, t, view]);
|
}, [activeSession, headerTitle, i18n.resolvedLanguage, t, view]);
|
||||||
|
|
||||||
const sidebarProps = {
|
const sidebarProps = {
|
||||||
sessions,
|
sessions: regularSessions,
|
||||||
activeKey: view === "chat" ? activeKey : null,
|
activeKey: view === "chat" ? activeKey : null,
|
||||||
loading,
|
loading,
|
||||||
|
quickChatActive: view === "chat" && quickChatActive,
|
||||||
newChatActive: view === "chat" && activeKey === null,
|
newChatActive: view === "chat" && activeKey === null,
|
||||||
|
onOpenQuickChat,
|
||||||
onNewChat,
|
onNewChat,
|
||||||
onSelect: onSelectChat,
|
onSelect: onSelectChat,
|
||||||
onRequestDelete,
|
onRequestDelete,
|
||||||
@@ -2067,7 +2180,7 @@ function Shell({
|
|||||||
<SessionSearchDialog
|
<SessionSearchDialog
|
||||||
open
|
open
|
||||||
onOpenChange={setSessionSearchOpen}
|
onOpenChange={setSessionSearchOpen}
|
||||||
sessions={sessions}
|
sessions={regularSessions}
|
||||||
activeKey={activeKey}
|
activeKey={activeKey}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
titleOverrides={sidebarState.title_overrides}
|
titleOverrides={sidebarState.title_overrides}
|
||||||
@@ -2092,7 +2205,7 @@ function Shell({
|
|||||||
onToggleSidebar={toggleSidebar}
|
onToggleSidebar={toggleSidebar}
|
||||||
onNewChat={onNewChat}
|
onNewChat={onNewChat}
|
||||||
onCreateChat={onCreateChat}
|
onCreateChat={onCreateChat}
|
||||||
onForkChat={onForkChat}
|
onForkChat={quickChatActive ? undefined : onForkChat}
|
||||||
onTurnEnd={onTurnEnd}
|
onTurnEnd={onTurnEnd}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
onToggleTheme={toggle}
|
onToggleTheme={toggle}
|
||||||
@@ -2100,14 +2213,34 @@ function Shell({
|
|||||||
hostChromeTitleInset={hostSidebarCollapsed}
|
hostChromeTitleInset={hostSidebarCollapsed}
|
||||||
hideHeader={false}
|
hideHeader={false}
|
||||||
workspaceScope={activeWorkspaceScope}
|
workspaceScope={activeWorkspaceScope}
|
||||||
workspaceDefaultScope={workspaces?.default_scope ?? null}
|
workspaceDefaultScope={
|
||||||
workspaceControls={workspaces?.controls ?? null}
|
temporaryChatActive ? null : workspaces?.default_scope ?? null
|
||||||
|
}
|
||||||
|
workspaceControls={
|
||||||
|
quickChatActive ? null : (workspaces?.controls ?? null)
|
||||||
|
}
|
||||||
workspaceScopeDisabled={activeChatRunning}
|
workspaceScopeDisabled={activeChatRunning}
|
||||||
workspaceError={workspaceError}
|
workspaceError={workspaceError}
|
||||||
onWorkspaceScopeChange={applyWorkspaceScope}
|
onWorkspaceScopeChange={applyWorkspaceScope}
|
||||||
settingsSnapshot={settingsSnapshot}
|
settingsSnapshot={settingsSnapshot}
|
||||||
onOpenModelSettings={onOpenModelSettings}
|
onOpenModelSettings={onOpenModelSettings}
|
||||||
skills={skills}
|
skills={skills}
|
||||||
|
allowConversationReset={!quickChatActive}
|
||||||
|
showSessionInfo={!quickChatActive}
|
||||||
|
emptyStateGreeting={
|
||||||
|
temporaryChatActive
|
||||||
|
? t("quickChat.temporary.greeting")
|
||||||
|
: quickChatActive
|
||||||
|
? t("quickChat.greeting")
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
emptyStateDescription={
|
||||||
|
temporaryChatActive
|
||||||
|
? t("quickChat.temporary.description")
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
temporary={temporaryChatActive}
|
||||||
|
headerAction={temporaryChatAction}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{view !== "chat" && (
|
{view !== "chat" && (
|
||||||
|
|||||||
@@ -31,9 +31,7 @@ export function AttachmentTile({ attachment, className, inline = false, variant
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer noopener"
|
rel="noreferrer noopener"
|
||||||
className="block bg-muted/20"
|
className="block bg-muted/20"
|
||||||
aria-label={attachment.name
|
aria-label={attachment.name ? `Open ${attachment.name}` : t("lightbox.open", { defaultValue: "Open image" })}
|
||||||
? t("message.openAttachment", { name: attachment.name })
|
|
||||||
: t("lightbox.open", { defaultValue: "Open image" })}
|
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={attachment.url}
|
src={attachment.url}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
INLINE_TOKEN_HIGHLIGHT_COLOR,
|
INLINE_TOKEN_HIGHLIGHT_COLOR,
|
||||||
@@ -141,7 +140,6 @@ export function CliAppMentionToken({
|
|||||||
variant: "composer" | "message";
|
variant: "composer" | "message";
|
||||||
isHero?: boolean;
|
isHero?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const color = app.brand_color || INLINE_TOKEN_HIGHLIGHT_COLOR;
|
const color = app.brand_color || INLINE_TOKEN_HIGHLIGHT_COLOR;
|
||||||
const mentionName = label.startsWith("@") ? label.slice(1) : label;
|
const mentionName = label.startsWith("@") ? label.slice(1) : label;
|
||||||
const logoUrls = useMemo(() => logoFallbackUrls(app.logo_url), [app.logo_url]);
|
const logoUrls = useMemo(() => logoFallbackUrls(app.logo_url), [app.logo_url]);
|
||||||
@@ -152,7 +150,7 @@ export function CliAppMentionToken({
|
|||||||
return (
|
return (
|
||||||
<InlineTokenHighlight
|
<InlineTokenHighlight
|
||||||
testId={`${testIdPrefix}-cli-mention-${app.name}`}
|
testId={`${testIdPrefix}-cli-mention-${app.name}`}
|
||||||
title={t("thread.composer.mentions.cliTitle", { name: app.display_name || app.name })}
|
title={`CLI app: ${app.display_name || app.name}`}
|
||||||
color={color}
|
color={color}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
@@ -197,7 +195,6 @@ export function McpPresetMentionToken({
|
|||||||
variant: "composer" | "message";
|
variant: "composer" | "message";
|
||||||
isHero?: boolean;
|
isHero?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const color = preset.brand_color || INLINE_TOKEN_HIGHLIGHT_COLOR;
|
const color = preset.brand_color || INLINE_TOKEN_HIGHLIGHT_COLOR;
|
||||||
const mentionName = label.startsWith("@") ? label.slice(1) : label;
|
const mentionName = label.startsWith("@") ? label.slice(1) : label;
|
||||||
const logoUrls = useMemo(() => logoFallbackUrls(preset.logo_url), [preset.logo_url]);
|
const logoUrls = useMemo(() => logoFallbackUrls(preset.logo_url), [preset.logo_url]);
|
||||||
@@ -208,7 +205,7 @@ export function McpPresetMentionToken({
|
|||||||
return (
|
return (
|
||||||
<InlineTokenHighlight
|
<InlineTokenHighlight
|
||||||
testId={`${testIdPrefix}-mcp-mention-${preset.name}`}
|
testId={`${testIdPrefix}-mcp-mention-${preset.name}`}
|
||||||
title={t("thread.composer.mentions.mcpTitle", { name: preset.display_name || preset.name })}
|
title={`MCP server: ${preset.display_name || preset.name}`}
|
||||||
color={color}
|
color={color}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -411,7 +411,6 @@ function inlineLinkPreviewFromChildren(children: ReactNode): InlineLinkPreview |
|
|||||||
}
|
}
|
||||||
|
|
||||||
function InlineLinkPreviewRow({ link }: { link: InlineLinkPreview }) {
|
function InlineLinkPreviewRow({ link }: { link: InlineLinkPreview }) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const { favicon, onFaviconError, onFaviconLoad } = useFaviconFallback(link.host);
|
const { favicon, onFaviconError, onFaviconLoad } = useFaviconFallback(link.host);
|
||||||
const label = link.prefix
|
const label = link.prefix
|
||||||
? `${link.prefix} — ${link.title}`
|
? `${link.prefix} — ${link.title}`
|
||||||
@@ -422,7 +421,7 @@ function InlineLinkPreviewRow({ link }: { link: InlineLinkPreview }) {
|
|||||||
href={link.href}
|
href={link.href}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer noopener"
|
rel="noreferrer noopener"
|
||||||
aria-label={t("message.openLink", { label })}
|
aria-label={`Open link: ${label}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"not-prose inline-flex max-w-full items-center gap-2 align-baseline",
|
"not-prose inline-flex max-w-full items-center gap-2 align-baseline",
|
||||||
"text-blue-500 no-underline underline-offset-2 hover:underline dark:text-blue-300",
|
"text-blue-500 no-underline underline-offset-2 hover:underline dark:text-blue-300",
|
||||||
|
|||||||
@@ -253,9 +253,6 @@ export function MessageBubble({
|
|||||||
const hasText = userContent.trim().length > 0;
|
const hasText = userContent.trim().length > 0;
|
||||||
const showDeliveryStatus =
|
const showDeliveryStatus =
|
||||||
message.deliveryStatus === "sending" || message.deliveryStatus === "failed";
|
message.deliveryStatus === "sending" || message.deliveryStatus === "failed";
|
||||||
const createdAtLabel = formatMessageEndTime(message.createdAt);
|
|
||||||
const showCreatedAt = createdAtLabel.length > 0;
|
|
||||||
const createdAtTitle = showCreatedAt ? fmtDateTime(message.createdAt) : "";
|
|
||||||
const quotedContext = parsedMessage.quotedContext;
|
const quotedContext = parsedMessage.quotedContext;
|
||||||
const slashCommand = matchingSlashCommand(userContent, slashCommands);
|
const slashCommand = matchingSlashCommand(userContent, slashCommands);
|
||||||
const messageText = slashCommand ? (
|
const messageText = slashCommand ? (
|
||||||
@@ -301,19 +298,9 @@ export function MessageBubble({
|
|||||||
{messageText}
|
{messageText}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{showDeliveryStatus || showCreatedAt || (hasText && showCopyAction) ? (
|
{showDeliveryStatus || (hasText && showCopyAction) ? (
|
||||||
<TooltipProvider delayDuration={220} skipDelayDuration={80}>
|
<TooltipProvider delayDuration={220} skipDelayDuration={80}>
|
||||||
<div className="flex min-h-8 items-center justify-end gap-1.5 text-muted-foreground">
|
<div className="flex min-h-8 items-center justify-end gap-1.5 text-muted-foreground">
|
||||||
{showCreatedAt ? (
|
|
||||||
<time
|
|
||||||
data-message-created-at
|
|
||||||
dateTime={new Date(message.createdAt).toISOString()}
|
|
||||||
className="text-[11px] leading-none text-muted-foreground/70 tabular-nums"
|
|
||||||
title={createdAtTitle}
|
|
||||||
>
|
|
||||||
{createdAtLabel}
|
|
||||||
</time>
|
|
||||||
) : null}
|
|
||||||
<UserDeliveryStatus
|
<UserDeliveryStatus
|
||||||
status={message.deliveryStatus}
|
status={message.deliveryStatus}
|
||||||
errorKind={message.deliveryErrorKind}
|
errorKind={message.deliveryErrorKind}
|
||||||
@@ -351,22 +338,11 @@ export function MessageBubble({
|
|||||||
message.role === "assistant" && !message.isStreaming
|
message.role === "assistant" && !message.isStreaming
|
||||||
? formatMessageEndTime(completedAt)
|
? formatMessageEndTime(completedAt)
|
||||||
: "";
|
: "";
|
||||||
const assistantTimestamp =
|
|
||||||
typeof completedAt === "number" && Number.isFinite(completedAt)
|
|
||||||
? completedAt
|
|
||||||
: message.createdAt;
|
|
||||||
const assistantTimestampLabel =
|
|
||||||
message.role === "assistant" && !message.isStreaming
|
|
||||||
? formatMessageEndTime(assistantTimestamp)
|
|
||||||
: "";
|
|
||||||
const showCompletedAt =
|
const showCompletedAt =
|
||||||
completedAtLabel.length > 0
|
completedAtLabel.length > 0
|
||||||
&& (!empty || hasReasoning || media.length > 0);
|
&& (!empty || hasReasoning || media.length > 0);
|
||||||
const showAssistantTimestamp =
|
const completedAtTitle = showCompletedAt ? fmtDateTime(completedAt) : "";
|
||||||
assistantTimestampLabel.length > 0
|
const showAssistantFooterRow = showCopyButton || showForkButton || showCompletedAt;
|
||||||
&& (!empty || hasReasoning || media.length > 0);
|
|
||||||
const assistantTimestampTitle = showAssistantTimestamp ? fmtDateTime(assistantTimestamp) : "";
|
|
||||||
const showAssistantFooterRow = showCopyButton || showForkButton || showAssistantTimestamp;
|
|
||||||
const showAssistantFooterSlot =
|
const showAssistantFooterSlot =
|
||||||
message.role === "assistant"
|
message.role === "assistant"
|
||||||
&& (!empty || hasReasoning || media.length > 0);
|
&& (!empty || hasReasoning || media.length > 0);
|
||||||
@@ -438,15 +414,14 @@ export function MessageBubble({
|
|||||||
<TooltipContent side="top" align="center">{forkLabel}</TooltipContent>
|
<TooltipContent side="top" align="center">{forkLabel}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null}
|
) : null}
|
||||||
{showAssistantTimestamp ? (
|
{showCompletedAt ? (
|
||||||
<time
|
<time
|
||||||
{...(showCompletedAt ? { "data-assistant-completed-at": true } : {})}
|
data-assistant-completed-at
|
||||||
data-message-timestamp
|
dateTime={new Date(completedAt!).toISOString()}
|
||||||
dateTime={new Date(assistantTimestamp).toISOString()}
|
|
||||||
className="text-[11px] leading-none text-muted-foreground/70 tabular-nums"
|
className="text-[11px] leading-none text-muted-foreground/70 tabular-nums"
|
||||||
title={assistantTimestampTitle}
|
title={completedAtTitle}
|
||||||
>
|
>
|
||||||
{assistantTimestampLabel}
|
{completedAtLabel}
|
||||||
</time>
|
</time>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Archive,
|
Archive,
|
||||||
Brain,
|
Brain,
|
||||||
CalendarClock,
|
CalendarClock,
|
||||||
|
MessageCircle,
|
||||||
Menu,
|
Menu,
|
||||||
Search,
|
Search,
|
||||||
Settings,
|
Settings,
|
||||||
@@ -33,7 +34,9 @@ interface SidebarProps {
|
|||||||
sessions: ChatSummary[];
|
sessions: ChatSummary[];
|
||||||
activeKey: string | null;
|
activeKey: string | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
quickChatActive: boolean;
|
||||||
newChatActive: boolean;
|
newChatActive: boolean;
|
||||||
|
onOpenQuickChat: () => void;
|
||||||
onNewChat: () => void;
|
onNewChat: () => void;
|
||||||
onSelect: (key: string) => void;
|
onSelect: (key: string) => void;
|
||||||
onRequestDelete: (key: string, label: string) => void;
|
onRequestDelete: (key: string, label: string) => void;
|
||||||
@@ -93,11 +96,13 @@ export function Sidebar(props: SidebarProps) {
|
|||||||
const toggleLabel = t("thread.header.toggleSidebar");
|
const toggleLabel = t("thread.header.toggleSidebar");
|
||||||
const newChatShortcut = newChatShortcutLabel();
|
const newChatShortcut = newChatShortcutLabel();
|
||||||
const activeActionRef = useRef<HTMLButtonElement>(null);
|
const activeActionRef = useRef<HTMLButtonElement>(null);
|
||||||
const activeActionId = props.newChatActive
|
const activeActionId = props.quickChatActive
|
||||||
? "new-chat"
|
? "quick-chat"
|
||||||
: props.activeUtility
|
: props.newChatActive
|
||||||
? `utility:${props.activeUtility}`
|
? "new-chat"
|
||||||
: null;
|
: props.activeUtility
|
||||||
|
? `utility:${props.activeUtility}`
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
@@ -158,6 +163,14 @@ export function Sidebar(props: SidebarProps) {
|
|||||||
collapsed && "flex w-14 flex-col items-center px-0",
|
collapsed && "flex w-14 flex-col items-center px-0",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
<SidebarActionButton
|
||||||
|
collapsed={collapsed}
|
||||||
|
label={t("sidebar.quickChat")}
|
||||||
|
onClick={props.onOpenQuickChat}
|
||||||
|
active={props.quickChatActive}
|
||||||
|
selectionRef={activeActionRef}
|
||||||
|
icon={<MessageCircle className="h-4 w-4" />}
|
||||||
|
/>
|
||||||
<SidebarActionButton
|
<SidebarActionButton
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
label={t("sidebar.newChat")}
|
label={t("sidebar.newChat")}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Fragment } from "react";
|
import { Fragment } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CliAppMentionToken,
|
CliAppMentionToken,
|
||||||
@@ -70,7 +69,6 @@ export function UserMessageText({
|
|||||||
cliApps: CliAppInfo[];
|
cliApps: CliAppInfo[];
|
||||||
mcpPresets: McpPresetInfo[];
|
mcpPresets: McpPresetInfo[];
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const segments = splitUserMessageSegments(text, cliApps, mcpPresets);
|
const segments = splitUserMessageSegments(text, cliApps, mcpPresets);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -82,7 +80,7 @@ export function UserMessageText({
|
|||||||
<InlineTokenHighlight
|
<InlineTokenHighlight
|
||||||
key={`skill-${segment.name}-${index}`}
|
key={`skill-${segment.name}-${index}`}
|
||||||
testId={`message-skill-reference-${segment.name.toLowerCase()}`}
|
testId={`message-skill-reference-${segment.name.toLowerCase()}`}
|
||||||
title={t("message.skill", { name: segment.name })}
|
title={`Skill: ${segment.name}`}
|
||||||
color={INLINE_TOKEN_HIGHLIGHT_COLOR}
|
color={INLINE_TOKEN_HIGHLIGHT_COLOR}
|
||||||
className="font-medium"
|
className="font-medium"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -234,6 +234,8 @@ interface AgentSettingsDraft {
|
|||||||
temperature: number;
|
temperature: number;
|
||||||
reasoningEffort: string;
|
reasoningEffort: string;
|
||||||
timezone: string;
|
timezone: string;
|
||||||
|
botName: string;
|
||||||
|
botIcon: string;
|
||||||
toolHintMaxLength: number;
|
toolHintMaxLength: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -473,6 +475,8 @@ const DEFAULT_AGENT_SETTINGS_DRAFT: AgentSettingsDraft = {
|
|||||||
temperature: 0.1,
|
temperature: 0.1,
|
||||||
reasoningEffort: "",
|
reasoningEffort: "",
|
||||||
timezone: "UTC",
|
timezone: "UTC",
|
||||||
|
botName: "nanobot",
|
||||||
|
botIcon: "",
|
||||||
toolHintMaxLength: 40,
|
toolHintMaxLength: 40,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -540,6 +544,8 @@ function agentDraftFromPayload(
|
|||||||
temperature: activePreset?.temperature ?? payload.agent.temperature,
|
temperature: activePreset?.temperature ?? payload.agent.temperature,
|
||||||
reasoningEffort: activePreset?.reasoning_effort ?? "",
|
reasoningEffort: activePreset?.reasoning_effort ?? "",
|
||||||
timezone: payload.agent.timezone,
|
timezone: payload.agent.timezone,
|
||||||
|
botName: payload.agent.bot_name,
|
||||||
|
botIcon: payload.agent.bot_icon,
|
||||||
toolHintMaxLength: payload.agent.tool_hint_max_length,
|
toolHintMaxLength: payload.agent.tool_hint_max_length,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1075,7 +1081,11 @@ export function SettingsView({
|
|||||||
|
|
||||||
const runtimeDirty = useMemo(() => {
|
const runtimeDirty = useMemo(() => {
|
||||||
if (!settings) return false;
|
if (!settings) return false;
|
||||||
return form.timezone !== settings.agent.timezone;
|
return (
|
||||||
|
form.timezone !== settings.agent.timezone ||
|
||||||
|
form.botName !== settings.agent.bot_name ||
|
||||||
|
form.botIcon !== settings.agent.bot_icon
|
||||||
|
);
|
||||||
}, [form, settings]);
|
}, [form, settings]);
|
||||||
|
|
||||||
const imageGenerationDirty = useMemo(() => {
|
const imageGenerationDirty = useMemo(() => {
|
||||||
@@ -1396,6 +1406,8 @@ export function SettingsView({
|
|||||||
try {
|
try {
|
||||||
const payload = await updateSettings(token, {
|
const payload = await updateSettings(token, {
|
||||||
timezone: form.timezone,
|
timezone: form.timezone,
|
||||||
|
botName: form.botName,
|
||||||
|
botIcon: form.botIcon,
|
||||||
});
|
});
|
||||||
applyPayload(payload);
|
applyPayload(payload);
|
||||||
if (payload.requires_restart) {
|
if (payload.requires_restart) {
|
||||||
@@ -7678,7 +7690,7 @@ function McpAppsCatalogRow({
|
|||||||
onClick={() => setSetupOpen(false)}
|
onClick={() => setSetupOpen(false)}
|
||||||
className="h-7 rounded-full px-2.5 text-[11.5px] font-semibold text-muted-foreground"
|
className="h-7 rounded-full px-2.5 text-[11.5px] font-semibold text-muted-foreground"
|
||||||
>
|
>
|
||||||
{tx("settings.actions.cancel", "Cancel")}
|
{tx("actions.cancel", "Cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 grid gap-2">
|
<div className="mt-3 grid gap-2">
|
||||||
@@ -8403,15 +8415,23 @@ function RuntimeSettings({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-7">
|
<div className="space-y-7">
|
||||||
<section>
|
<section>
|
||||||
<SettingsSectionTitle>{tx("settings.sections.regional", "Regional")}</SettingsSectionTitle>
|
<SettingsSectionTitle>{tx("settings.sections.identity", "Identity")}</SettingsSectionTitle>
|
||||||
<SettingsGroup>
|
<SettingsGroup>
|
||||||
<SettingsRow
|
<SettingsRow title={tx("settings.rows.botName", "Bot name")} description={tx("settings.help.botName", "Shown wherever nanobot uses a display name.")}>
|
||||||
title={tx("settings.rows.timezone", "Timezone")}
|
<Input
|
||||||
description={tx(
|
value={form.botName}
|
||||||
"settings.help.timezone",
|
onChange={(event) => setForm((prev) => ({ ...prev, botName: event.target.value }))}
|
||||||
"Used for schedules and time-aware replies.",
|
className="h-8 w-[220px] rounded-full text-[13px]"
|
||||||
)}
|
/>
|
||||||
>
|
</SettingsRow>
|
||||||
|
<SettingsRow title={tx("settings.rows.botIcon", "Bot icon")} description={tx("settings.help.botIcon", "Short emoji or text shown with the bot name.")}>
|
||||||
|
<Input
|
||||||
|
value={form.botIcon}
|
||||||
|
onChange={(event) => setForm((prev) => ({ ...prev, botIcon: event.target.value }))}
|
||||||
|
className="h-8 w-[120px] rounded-full text-center text-[13px]"
|
||||||
|
/>
|
||||||
|
</SettingsRow>
|
||||||
|
<SettingsRow title={tx("settings.rows.timezone", "Timezone")} description={tx("settings.help.timezone", "Used for schedules and time-aware replies.")}>
|
||||||
<TimezonePicker
|
<TimezonePicker
|
||||||
value={form.timezone}
|
value={form.timezone}
|
||||||
onChange={(timezone) => setForm((prev) => ({ ...prev, timezone }))}
|
onChange={(timezone) => setForm((prev) => ({ ...prev, timezone }))}
|
||||||
|
|||||||
@@ -304,13 +304,10 @@ export function ChannelValidationDetails({ validation }: { validation: ChannelVa
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ChannelValidationChecks({ validation }: { validation: ChannelValidationPayload }) {
|
export function ChannelValidationChecks({ validation }: { validation: ChannelValidationPayload }) {
|
||||||
const { t } = useTranslation();
|
|
||||||
if (!validation.checks.length) return null;
|
if (!validation.checks.length) return null;
|
||||||
return (
|
return (
|
||||||
<div className="border-t border-border/60 px-4 py-4">
|
<div className="border-t border-border/60 px-4 py-4">
|
||||||
<div className="mb-2 text-[12px] font-semibold text-foreground">
|
<div className="mb-2 text-[12px] font-semibold text-foreground">Connection checks</div>
|
||||||
{t("settings.channels.connectionChecks")}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{validation.checks.slice(0, 6).map((check) => (
|
{validation.checks.slice(0, 6).map((check) => (
|
||||||
<div key={check.id} className="flex gap-2 text-[12px] leading-5">
|
<div key={check.id} className="flex gap-2 text-[12px] leading-5">
|
||||||
@@ -329,7 +326,7 @@ export function ChannelValidationChecks({ validation }: { validation: ChannelVal
|
|||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="inline-flex items-center gap-1 text-foreground underline decoration-border underline-offset-4"
|
className="inline-flex items-center gap-1 text-foreground underline decoration-border underline-offset-4"
|
||||||
>
|
>
|
||||||
{t("settings.channels.open")}
|
Open
|
||||||
<ExternalLink className="h-3 w-3" aria-hidden />
|
<ExternalLink className="h-3 w-3" aria-hidden />
|
||||||
</a>
|
</a>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { type RefObject, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { type RefObject, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { UIMessage } from "@/lib/types";
|
import type { UIMessage } from "@/lib/types";
|
||||||
@@ -50,7 +49,6 @@ export function PromptRail({
|
|||||||
onJumpToPrompt,
|
onJumpToPrompt,
|
||||||
scrollRef,
|
scrollRef,
|
||||||
}: PromptRailProps) {
|
}: PromptRailProps) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const railRef = useRef<HTMLDivElement>(null);
|
const railRef = useRef<HTMLDivElement>(null);
|
||||||
const measuredPromptsRef = useRef<MeasuredPrompt[]>([]);
|
const measuredPromptsRef = useRef<MeasuredPrompt[]>([]);
|
||||||
const promptAnchors = useMemo(() => userPromptAnchors(messages), [messages]);
|
const promptAnchors = useMemo(() => userPromptAnchors(messages), [messages]);
|
||||||
@@ -144,7 +142,7 @@ export function PromptRail({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={railRef}
|
ref={railRef}
|
||||||
aria-label={t("thread.promptNavigator.railAria")}
|
aria-label="User prompt navigation"
|
||||||
className={cn(
|
className={cn(
|
||||||
"thread-prompt-rail group pointer-events-auto absolute top-3 z-20 w-9 opacity-100",
|
"thread-prompt-rail group pointer-events-auto absolute top-3 z-20 w-9 opacity-100",
|
||||||
"transition-opacity duration-200",
|
"transition-opacity duration-200",
|
||||||
@@ -161,7 +159,7 @@ export function PromptRail({
|
|||||||
<button
|
<button
|
||||||
key={marker.ids.join("|")}
|
key={marker.ids.join("|")}
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={t("thread.promptNavigator.jumpTo", { label: marker.label })}
|
aria-label={`Jump to prompt: ${marker.label}`}
|
||||||
onClick={() => onJumpToPrompt(marker.ids[marker.ids.length - 1])}
|
onClick={() => onJumpToPrompt(marker.ids[marker.ids.length - 1])}
|
||||||
onBlur={() => setFocusedMarkerIndex(null)}
|
onBlur={() => setFocusedMarkerIndex(null)}
|
||||||
onFocus={() => setFocusedMarkerIndex(index)}
|
onFocus={() => setFocusedMarkerIndex(index)}
|
||||||
|
|||||||
@@ -75,7 +75,6 @@ import {
|
|||||||
} from "@/hooks/useAttachedImages";
|
} from "@/hooks/useAttachedImages";
|
||||||
import { useClipboardAndDrop } from "@/hooks/useClipboardAndDrop";
|
import { useClipboardAndDrop } from "@/hooks/useClipboardAndDrop";
|
||||||
import { useLogoFallback } from "@/hooks/useLogoFallback";
|
import { useLogoFallback } from "@/hooks/useLogoFallback";
|
||||||
import { useMediaQuery } from "@/hooks/useMediaQuery";
|
|
||||||
import type { SendAttachment, SendOptions } from "@/hooks/useNanobotStream";
|
import type { SendAttachment, SendOptions } from "@/hooks/useNanobotStream";
|
||||||
import { usePageVisibility } from "@/hooks/usePageVisibility";
|
import { usePageVisibility } from "@/hooks/usePageVisibility";
|
||||||
import { useVoiceRecorder, type VoiceRecorderErrorKey } from "@/hooks/useVoiceRecorder";
|
import { useVoiceRecorder, type VoiceRecorderErrorKey } from "@/hooks/useVoiceRecorder";
|
||||||
@@ -203,6 +202,7 @@ interface ThreadComposerProps {
|
|||||||
quotedContext?: string | null;
|
quotedContext?: string | null;
|
||||||
focusRequest?: number;
|
focusRequest?: number;
|
||||||
onQuotedContextChange?: (text: string | null) => void;
|
onQuotedContextChange?: (text: string | null) => void;
|
||||||
|
allowAttachments?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const COMMAND_ICONS: Record<string, LucideIcon> = {
|
const COMMAND_ICONS: Record<string, LucideIcon> = {
|
||||||
@@ -851,6 +851,7 @@ export function ThreadComposer({
|
|||||||
quotedContext = null,
|
quotedContext = null,
|
||||||
focusRequest = 0,
|
focusRequest = 0,
|
||||||
onQuotedContextChange,
|
onQuotedContextChange,
|
||||||
|
allowAttachments = true,
|
||||||
}: ThreadComposerProps) {
|
}: ThreadComposerProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [value, setValue] = useState("");
|
const [value, setValue] = useState("");
|
||||||
@@ -863,7 +864,6 @@ export function ThreadComposer({
|
|||||||
const [cursorPosition, setCursorPosition] = useState(0);
|
const [cursorPosition, setCursorPosition] = useState(0);
|
||||||
const [recentSlashCommands, setRecentSlashCommands] = useState<string[]>(() => readSlashRecents());
|
const [recentSlashCommands, setRecentSlashCommands] = useState<string[]>(() => readSlashRecents());
|
||||||
const [queuedPrompts, setQueuedPrompts] = useState<QueuedPrompt[]>([]);
|
const [queuedPrompts, setQueuedPrompts] = useState<QueuedPrompt[]>([]);
|
||||||
const hasTouchPrimaryPointer = useMediaQuery("(hover: none) and (pointer: coarse)");
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const formRef = useRef<HTMLFormElement>(null);
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -915,6 +915,10 @@ export function ThreadComposer({
|
|||||||
const { images, enqueue, remove, clear, restoreReadyImages, encoding, full } =
|
const { images, enqueue, remove, clear, restoreReadyImages, encoding, full } =
|
||||||
useAttachedImages({ ingressLimits });
|
useAttachedImages({ ingressLimits });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!allowAttachments) clear();
|
||||||
|
}, [allowAttachments, clear]);
|
||||||
|
|
||||||
const formatRejection = useCallback(
|
const formatRejection = useCallback(
|
||||||
(reason: AttachmentError): string => {
|
(reason: AttachmentError): string => {
|
||||||
const key = `thread.composer.imageRejected.${reason}`;
|
const key = `thread.composer.imageRejected.${reason}`;
|
||||||
@@ -944,6 +948,7 @@ export function ThreadComposer({
|
|||||||
|
|
||||||
const addFiles = useCallback(
|
const addFiles = useCallback(
|
||||||
(files: File[]) => {
|
(files: File[]) => {
|
||||||
|
if (!allowAttachments) return;
|
||||||
if (files.length === 0) return;
|
if (files.length === 0) return;
|
||||||
secondEnterPromptIdRef.current = null;
|
secondEnterPromptIdRef.current = null;
|
||||||
const { rejected } = enqueue(files);
|
const { rejected } = enqueue(files);
|
||||||
@@ -953,7 +958,7 @@ export function ThreadComposer({
|
|||||||
setInlineError(null);
|
setInlineError(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[enqueue, formatRejection],
|
[allowAttachments, enqueue, formatRejection],
|
||||||
);
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -966,12 +971,12 @@ export function ThreadComposer({
|
|||||||
} = useClipboardAndDrop(addFiles);
|
} = useClipboardAndDrop(addFiles);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (disabled || hasTouchPrimaryPointer) return;
|
if (disabled) return;
|
||||||
const el = textareaRef.current;
|
const el = textareaRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const id = requestAnimationFrame(() => el.focus());
|
const id = requestAnimationFrame(() => el.focus());
|
||||||
return () => cancelAnimationFrame(id);
|
return () => cancelAnimationFrame(id);
|
||||||
}, [disabled, hasTouchPrimaryPointer]);
|
}, [disabled]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!focusRequest || disabled) return;
|
if (!focusRequest || disabled) return;
|
||||||
@@ -1302,13 +1307,13 @@ export function ThreadComposer({
|
|||||||
};
|
};
|
||||||
}, [filteredMentionCandidates.length, filteredSlashCommands.length, showAnyPalette]);
|
}, [filteredMentionCandidates.length, filteredSlashCommands.length, showAnyPalette]);
|
||||||
|
|
||||||
const resizeTextarea = useCallback((restoreFocus = true) => {
|
const resizeTextarea = useCallback(() => {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const el = textareaRef.current;
|
const el = textareaRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
el.style.height = "auto";
|
el.style.height = "auto";
|
||||||
el.style.height = `${Math.min(el.scrollHeight, 260)}px`;
|
el.style.height = `${Math.min(el.scrollHeight, 260)}px`;
|
||||||
if (restoreFocus) el.focus();
|
el.focus();
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -1479,13 +1484,13 @@ export function ThreadComposer({
|
|||||||
[cliAppMention, resizeTextarea, value],
|
[cliAppMention, resizeTextarea, value],
|
||||||
);
|
);
|
||||||
|
|
||||||
const clearComposerText = useCallback((restoreFocus = true) => {
|
const clearComposerText = useCallback(() => {
|
||||||
setValue("");
|
setValue("");
|
||||||
setInlineError(null);
|
setInlineError(null);
|
||||||
setSlashMenuDismissed(false);
|
setSlashMenuDismissed(false);
|
||||||
setCliAppMenuDismissed(false);
|
setCliAppMenuDismissed(false);
|
||||||
setCursorPosition(0);
|
setCursorPosition(0);
|
||||||
resizeTextarea(restoreFocus);
|
resizeTextarea();
|
||||||
}, [resizeTextarea]);
|
}, [resizeTextarea]);
|
||||||
|
|
||||||
const queueGuidancePrompt = useCallback(() => {
|
const queueGuidancePrompt = useCallback(() => {
|
||||||
@@ -1694,12 +1699,11 @@ export function ThreadComposer({
|
|||||||
}
|
}
|
||||||
: options,
|
: options,
|
||||||
);
|
);
|
||||||
if (hasTouchPrimaryPointer) textareaRef.current?.blur();
|
|
||||||
setQueuedPrompts([]);
|
setQueuedPrompts([]);
|
||||||
// Bubble owns the data URL copy; safe to revoke every staged blob
|
// Bubble owns the data URL copy; safe to revoke every staged blob
|
||||||
// preview here without affecting the rendered message.
|
// preview here without affecting the rendered message.
|
||||||
clear();
|
clear();
|
||||||
clearComposerText(!hasTouchPrimaryPointer);
|
clearComposerText();
|
||||||
onQuotedContextChange?.(null);
|
onQuotedContextChange?.(null);
|
||||||
}, [
|
}, [
|
||||||
activeCliMentionApps,
|
activeCliMentionApps,
|
||||||
@@ -1707,7 +1711,6 @@ export function ThreadComposer({
|
|||||||
canSend,
|
canSend,
|
||||||
clear,
|
clear,
|
||||||
clearComposerText,
|
clearComposerText,
|
||||||
hasTouchPrimaryPointer,
|
|
||||||
handleStop,
|
handleStop,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
maxTextBytes,
|
maxTextBytes,
|
||||||
@@ -1799,7 +1802,6 @@ export function ThreadComposer({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onInput: React.FormEventHandler<HTMLTextAreaElement> = (e) => {
|
const onInput: React.FormEventHandler<HTMLTextAreaElement> = (e) => {
|
||||||
if ((e.nativeEvent as InputEvent).isComposing) return;
|
|
||||||
const el = e.currentTarget;
|
const el = e.currentTarget;
|
||||||
el.style.height = "auto";
|
el.style.height = "auto";
|
||||||
el.style.height = `${Math.min(el.scrollHeight, 260)}px`;
|
el.style.height = `${Math.min(el.scrollHeight, 260)}px`;
|
||||||
@@ -1879,10 +1881,10 @@ export function ThreadComposer({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
submit();
|
submit();
|
||||||
}}
|
}}
|
||||||
onDragEnter={onDragEnter}
|
onDragEnter={allowAttachments ? onDragEnter : undefined}
|
||||||
onDragOver={onDragOver}
|
onDragOver={allowAttachments ? onDragOver : undefined}
|
||||||
onDragLeave={onDragLeave}
|
onDragLeave={allowAttachments ? onDragLeave : undefined}
|
||||||
onDrop={onDrop}
|
onDrop={allowAttachments ? onDrop : undefined}
|
||||||
className={cn("relative w-full", isHero ? "px-0" : "px-1 pb-1.5 pt-1 sm:px-0")}
|
className={cn("relative w-full", isHero ? "px-0" : "px-1 pb-1.5 pt-1 sm:px-0")}
|
||||||
>
|
>
|
||||||
{showSlashMenu ? (
|
{showSlashMenu ? (
|
||||||
@@ -1912,7 +1914,9 @@ export function ThreadComposer({
|
|||||||
? "max-w-[58rem] rounded-[28px] bg-muted/30 focus-within:bg-muted/50 dark:bg-card dark:focus-within:bg-white/[0.06]"
|
? "max-w-[58rem] rounded-[28px] bg-muted/30 focus-within:bg-muted/50 dark:bg-card dark:focus-within:bg-white/[0.06]"
|
||||||
: "max-w-[49.5rem] rounded-[22px] bg-muted/30 focus-within:bg-muted/50 dark:bg-card dark:focus-within:bg-white/[0.06]",
|
: "max-w-[49.5rem] rounded-[22px] bg-muted/30 focus-within:bg-muted/50 dark:bg-card dark:focus-within:bg-white/[0.06]",
|
||||||
disabled && "opacity-60",
|
disabled && "opacity-60",
|
||||||
isDragging && "ring-2 ring-primary/40 motion-reduce:ring-0 motion-reduce:border-primary",
|
allowAttachments
|
||||||
|
&& isDragging
|
||||||
|
&& "ring-2 ring-primary/40 motion-reduce:ring-0 motion-reduce:border-primary",
|
||||||
goalState?.active &&
|
goalState?.active &&
|
||||||
"goal-shell-glow ring-1 ring-sky-400/35 motion-reduce:ring-sky-400/25 dark:ring-sky-400/45",
|
"goal-shell-glow ring-1 ring-sky-400/35 motion-reduce:ring-sky-400/25 dark:ring-sky-400/45",
|
||||||
)}
|
)}
|
||||||
@@ -2019,7 +2023,7 @@ export function ThreadComposer({
|
|||||||
onKeyUp={(e) => setCursorPosition(e.currentTarget.selectionStart ?? e.currentTarget.value.length)}
|
onKeyUp={(e) => setCursorPosition(e.currentTarget.selectionStart ?? e.currentTarget.value.length)}
|
||||||
onSelect={(e) => setCursorPosition(e.currentTarget.selectionStart ?? e.currentTarget.value.length)}
|
onSelect={(e) => setCursorPosition(e.currentTarget.selectionStart ?? e.currentTarget.value.length)}
|
||||||
onClick={(e) => setCursorPosition(e.currentTarget.selectionStart ?? e.currentTarget.value.length)}
|
onClick={(e) => setCursorPosition(e.currentTarget.selectionStart ?? e.currentTarget.value.length)}
|
||||||
onPaste={onPaste}
|
onPaste={allowAttachments ? onPaste : undefined}
|
||||||
rows={1}
|
rows={1}
|
||||||
placeholder={resolvedPlaceholder}
|
placeholder={resolvedPlaceholder}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
@@ -2062,30 +2066,34 @@ export function ThreadComposer({
|
|||||||
isHero ? "gap-1.5" : "gap-2",
|
isHero ? "gap-1.5" : "gap-2",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<input
|
{allowAttachments ? (
|
||||||
ref={fileInputRef}
|
<>
|
||||||
type="file"
|
<input
|
||||||
accept={ACCEPT_ATTR}
|
ref={fileInputRef}
|
||||||
multiple
|
type="file"
|
||||||
hidden
|
accept={ACCEPT_ATTR}
|
||||||
onChange={onFilePick}
|
multiple
|
||||||
/>
|
hidden
|
||||||
<Button
|
onChange={onFilePick}
|
||||||
type="button"
|
/>
|
||||||
size="icon"
|
<Button
|
||||||
variant="ghost"
|
type="button"
|
||||||
disabled={attachButtonDisabled}
|
size="icon"
|
||||||
aria-label={t("thread.composer.attachImage")}
|
variant="ghost"
|
||||||
onClick={() => fileInputRef.current?.click()}
|
disabled={attachButtonDisabled}
|
||||||
className={cn(
|
aria-label={t("thread.composer.attachImage")}
|
||||||
"thread-composer-action touch-target rounded-full text-muted-foreground hover:text-foreground",
|
onClick={() => fileInputRef.current?.click()}
|
||||||
isHero
|
className={cn(
|
||||||
? "h-8 w-8 border border-border/55 bg-card shadow-[0_2px_8px_rgba(15,23,42,0.05)] hover:bg-card"
|
"thread-composer-action touch-target rounded-full text-muted-foreground hover:text-foreground",
|
||||||
: "h-9 w-9 border border-border/55 bg-card shadow-[0_2px_8px_rgba(15,23,42,0.05)] hover:bg-card",
|
isHero
|
||||||
)}
|
? "h-8 w-8 border border-border/55 bg-card shadow-[0_2px_8px_rgba(15,23,42,0.05)] hover:bg-card"
|
||||||
>
|
: "h-9 w-9 border border-border/55 bg-card shadow-[0_2px_8px_rgba(15,23,42,0.05)] hover:bg-card",
|
||||||
<Plus className={cn(isHero ? "h-[18px] w-[18px]" : "h-4 w-4")} />
|
)}
|
||||||
</Button>
|
>
|
||||||
|
<Plus className={cn(isHero ? "h-[18px] w-[18px]" : "h-4 w-4")} />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
{voiceRecorder.isRecording ? (
|
{voiceRecorder.isRecording ? (
|
||||||
<VoiceRecordingMeter
|
<VoiceRecordingMeter
|
||||||
ariaLabel={voiceRecordingStatusLabel}
|
ariaLabel={voiceRecordingStatusLabel}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ interface ThreadHeaderProps {
|
|||||||
minimal?: boolean;
|
minimal?: boolean;
|
||||||
promptNavigatorAction?: ReactNode;
|
promptNavigatorAction?: ReactNode;
|
||||||
sessionInfoAction?: ReactNode;
|
sessionInfoAction?: ReactNode;
|
||||||
|
headerAction?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ThreadHeader({
|
export function ThreadHeader({
|
||||||
@@ -29,6 +30,7 @@ export function ThreadHeader({
|
|||||||
minimal = false,
|
minimal = false,
|
||||||
promptNavigatorAction,
|
promptNavigatorAction,
|
||||||
sessionInfoAction,
|
sessionInfoAction,
|
||||||
|
headerAction,
|
||||||
}: ThreadHeaderProps) {
|
}: ThreadHeaderProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -61,6 +63,7 @@ export function ThreadHeader({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||||
|
{headerAction}
|
||||||
{sessionInfoAction}
|
{sessionInfoAction}
|
||||||
{promptNavigatorAction}
|
{promptNavigatorAction}
|
||||||
{!hideThemeButton ? (
|
{!hideThemeButton ? (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { PointerEvent as ReactPointerEvent } from "react";
|
import type { PointerEvent as ReactPointerEvent, ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { FilePreviewAvailabilityProvider } from "@/components/FilePreviewAvailabilityContext";
|
import { FilePreviewAvailabilityProvider } from "@/components/FilePreviewAvailabilityContext";
|
||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
} from "@/lib/mcp-preset-events";
|
} from "@/lib/mcp-preset-events";
|
||||||
import type { CanonicalRunSnapshot, StreamError } from "@/lib/nanobot-client";
|
import type { CanonicalRunSnapshot, StreamError } from "@/lib/nanobot-client";
|
||||||
import { inferProviderFromModelName, providerDisplayLabel } from "@/lib/provider-brand";
|
import { inferProviderFromModelName, providerDisplayLabel } from "@/lib/provider-brand";
|
||||||
|
import { TEMPORARY_CHAT_ID_PREFIX } from "@/lib/quick-chat";
|
||||||
import type {
|
import type {
|
||||||
ChatSummary,
|
ChatSummary,
|
||||||
SettingsPayload,
|
SettingsPayload,
|
||||||
@@ -315,6 +316,12 @@ interface ThreadShellProps {
|
|||||||
settingsSnapshot?: SettingsPayload | null;
|
settingsSnapshot?: SettingsPayload | null;
|
||||||
onOpenModelSettings?: () => void;
|
onOpenModelSettings?: () => void;
|
||||||
skills?: SkillSummary[];
|
skills?: SkillSummary[];
|
||||||
|
allowConversationReset?: boolean;
|
||||||
|
showSessionInfo?: boolean;
|
||||||
|
emptyStateGreeting?: string;
|
||||||
|
emptyStateDescription?: string;
|
||||||
|
temporary?: boolean;
|
||||||
|
headerAction?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toModelBadgeLabel(modelName: string | null): string | null {
|
function toModelBadgeLabel(modelName: string | null): string | null {
|
||||||
@@ -597,10 +604,16 @@ export function ThreadShell({
|
|||||||
settingsSnapshot = null,
|
settingsSnapshot = null,
|
||||||
onOpenModelSettings,
|
onOpenModelSettings,
|
||||||
skills = [],
|
skills = [],
|
||||||
|
allowConversationReset = true,
|
||||||
|
showSessionInfo = true,
|
||||||
|
emptyStateGreeting,
|
||||||
|
emptyStateDescription,
|
||||||
|
temporary = false,
|
||||||
|
headerAction,
|
||||||
}: ThreadShellProps) {
|
}: ThreadShellProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const chatId = session?.chatId ?? null;
|
const chatId = session?.chatId ?? null;
|
||||||
const historyKey = session?.key ?? null;
|
const historyKey = temporary ? null : session?.key ?? null;
|
||||||
const {
|
const {
|
||||||
messages: historical,
|
messages: historical,
|
||||||
loading,
|
loading,
|
||||||
@@ -622,6 +635,16 @@ export function ThreadShell({
|
|||||||
const [fallbackModelName, setFallbackModelName] = useState<string | null>(null);
|
const [fallbackModelName, setFallbackModelName] = useState<string | null>(null);
|
||||||
const [booting, setBooting] = useState(false);
|
const [booting, setBooting] = useState(false);
|
||||||
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
|
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
|
||||||
|
const availableSlashCommands = useMemo(
|
||||||
|
() => temporary
|
||||||
|
? slashCommands.filter((command) =>
|
||||||
|
command.command === "/model" || command.command === "/stop",
|
||||||
|
)
|
||||||
|
: allowConversationReset
|
||||||
|
? slashCommands
|
||||||
|
: slashCommands.filter((command) => command.command !== "/new"),
|
||||||
|
[allowConversationReset, slashCommands, temporary],
|
||||||
|
);
|
||||||
const cliApps = useInstalledSettingItems({
|
const cliApps = useInstalledSettingItems({
|
||||||
getToken,
|
getToken,
|
||||||
eventName: CLI_APPS_CHANGED_EVENT,
|
eventName: CLI_APPS_CHANGED_EVENT,
|
||||||
@@ -669,8 +692,9 @@ export function ThreadShell({
|
|||||||
|
|
||||||
const initial = useMemo(() => {
|
const initial = useMemo(() => {
|
||||||
if (!chatId) return historical;
|
if (!chatId) return historical;
|
||||||
|
if (temporary) return historical;
|
||||||
return messageCacheRef.current.get(chatId) ?? historical;
|
return messageCacheRef.current.get(chatId) ?? historical;
|
||||||
}, [chatId, historical]);
|
}, [chatId, historical, temporary]);
|
||||||
const handleTurnEnd = useCallback(() => {
|
const handleTurnEnd = useCallback(() => {
|
||||||
if (chatId) activeViewportTurnByChatIdRef.current.delete(chatId);
|
if (chatId) activeViewportTurnByChatIdRef.current.delete(chatId);
|
||||||
setSubmittedViewportTurnId(null);
|
setSubmittedViewportTurnId(null);
|
||||||
@@ -690,7 +714,13 @@ export function ThreadShell({
|
|||||||
setMessages,
|
setMessages,
|
||||||
streamError,
|
streamError,
|
||||||
dismissStreamError,
|
dismissStreamError,
|
||||||
} = useNanobotStream(chatId, initial, hasPendingToolCalls, handleTurnEnd);
|
} = useNanobotStream(
|
||||||
|
chatId,
|
||||||
|
initial,
|
||||||
|
hasPendingToolCalls,
|
||||||
|
handleTurnEnd,
|
||||||
|
{ temporary },
|
||||||
|
);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (currentUiMessagesRef.current === messages) return;
|
if (currentUiMessagesRef.current === messages) return;
|
||||||
@@ -819,9 +849,12 @@ export function ThreadShell({
|
|||||||
const handleModelPresetChange = useCallback((name: string) => {
|
const handleModelPresetChange = useCallback((name: string) => {
|
||||||
setLocalModelPreset(name);
|
setLocalModelPreset(name);
|
||||||
if (chatId) {
|
if (chatId) {
|
||||||
void client.sendSystemCommand(chatId, `/model ${name}`).catch(() => {});
|
const request = temporary
|
||||||
|
? client.sendSystemCommand(chatId, `/model ${name}`, 5_000, { temporary: true })
|
||||||
|
: client.sendSystemCommand(chatId, `/model ${name}`);
|
||||||
|
void request.catch(() => {});
|
||||||
}
|
}
|
||||||
}, [chatId, client]);
|
}, [chatId, client, temporary]);
|
||||||
const modelPresetOptions = useMemo(
|
const modelPresetOptions = useMemo(
|
||||||
() => modelPresetOptionsFromSettings(settings),
|
() => modelPresetOptionsFromSettings(settings),
|
||||||
[settings],
|
[settings],
|
||||||
@@ -842,13 +875,16 @@ export function ThreadShell({
|
|||||||
|
|
||||||
const withWorkspaceScope = useCallback(
|
const withWorkspaceScope = useCallback(
|
||||||
(options?: SendOptions): SendOptions | undefined => {
|
(options?: SendOptions): SendOptions | undefined => {
|
||||||
|
if (temporary) {
|
||||||
|
return { ...(options ?? {}), temporary: true };
|
||||||
|
}
|
||||||
if (!workspaceScope) return options;
|
if (!workspaceScope) return options;
|
||||||
return {
|
return {
|
||||||
...(options ?? {}),
|
...(options ?? {}),
|
||||||
workspaceScope,
|
workspaceScope,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
[workspaceScope],
|
[temporary, workspaceScope],
|
||||||
);
|
);
|
||||||
|
|
||||||
const refreshModelSettings = useCallback(async () => {
|
const refreshModelSettings = useCallback(async () => {
|
||||||
@@ -882,11 +918,11 @@ export function ThreadShell({
|
|||||||
return client.onChat(chatId, (event) => {
|
return client.onChat(chatId, (event) => {
|
||||||
if (event.event !== "turn_model_updated") return;
|
if (event.event !== "turn_model_updated") return;
|
||||||
setFallbackModelName(event.model_name);
|
setFallbackModelName(event.model_name);
|
||||||
});
|
}, { temporary });
|
||||||
}, [chatId, client]);
|
}, [chatId, client, temporary]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!chatId || loading) return;
|
if (!chatId || loading || temporary) return;
|
||||||
const cached = messageCacheRef.current.get(chatId);
|
const cached = messageCacheRef.current.get(chatId);
|
||||||
const pendingCanonicalHydrate = pendingCanonicalHydrateRef.current.get(chatId);
|
const pendingCanonicalHydrate = pendingCanonicalHydrateRef.current.get(chatId);
|
||||||
const hasNewCanonicalHistory = (
|
const hasNewCanonicalHistory = (
|
||||||
@@ -1016,6 +1052,7 @@ export function ThreadShell({
|
|||||||
historyLineage,
|
historyLineage,
|
||||||
historyActiveTurnId,
|
historyActiveTurnId,
|
||||||
hasPendingToolCalls,
|
hasPendingToolCalls,
|
||||||
|
temporary,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
@@ -1067,7 +1104,7 @@ export function ThreadShell({
|
|||||||
}, [chatId, hasPendingToolCalls, historyVersion, messages, reconcileTurnComplete]);
|
}, [chatId, hasPendingToolCalls, historyVersion, messages, reconcileTurnComplete]);
|
||||||
|
|
||||||
const refreshCanonicalHistory = useCallback(() => {
|
const refreshCanonicalHistory = useCallback(() => {
|
||||||
if (!chatId) return;
|
if (!chatId || temporary) return;
|
||||||
pendingCanonicalHydrateRef.current.set(chatId, {
|
pendingCanonicalHydrateRef.current.set(chatId, {
|
||||||
historyLineage,
|
historyLineage,
|
||||||
historyVersion,
|
historyVersion,
|
||||||
@@ -1077,7 +1114,7 @@ export function ThreadShell({
|
|||||||
uiRevision: uiRevisionRef.current,
|
uiRevision: uiRevisionRef.current,
|
||||||
});
|
});
|
||||||
refreshHistory();
|
refreshHistory();
|
||||||
}, [chatId, client, historyLineage, historyVersion, refreshHistory]);
|
}, [chatId, client, historyLineage, historyVersion, refreshHistory, temporary]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!chatId) return;
|
if (!chatId) return;
|
||||||
@@ -1144,16 +1181,22 @@ export function ThreadShell({
|
|||||||
if (chatId) {
|
if (chatId) {
|
||||||
const prev = prevChatIdForCacheRef.current;
|
const prev = prevChatIdForCacheRef.current;
|
||||||
if (prev && prev !== chatId) {
|
if (prev && prev !== chatId) {
|
||||||
messageCacheRef.current.set(prev, displayMessages);
|
if (prev.startsWith(TEMPORARY_CHAT_ID_PREFIX)) {
|
||||||
|
messageCacheRef.current.delete(prev);
|
||||||
|
} else {
|
||||||
|
messageCacheRef.current.set(prev, displayMessages);
|
||||||
|
}
|
||||||
skipLayoutCacheRef.current = true;
|
skipLayoutCacheRef.current = true;
|
||||||
}
|
}
|
||||||
prevChatIdForCacheRef.current = chatId;
|
prevChatIdForCacheRef.current = chatId;
|
||||||
} else {
|
} else {
|
||||||
if (prevChatIdForCacheRef.current) {
|
if (prevChatIdForCacheRef.current) {
|
||||||
messageCacheRef.current.set(
|
const prev = prevChatIdForCacheRef.current;
|
||||||
prevChatIdForCacheRef.current,
|
if (prev.startsWith(TEMPORARY_CHAT_ID_PREFIX)) {
|
||||||
displayMessages,
|
messageCacheRef.current.delete(prev);
|
||||||
);
|
} else {
|
||||||
|
messageCacheRef.current.set(prev, displayMessages);
|
||||||
|
}
|
||||||
skipLayoutCacheRef.current = true;
|
skipLayoutCacheRef.current = true;
|
||||||
}
|
}
|
||||||
prevChatIdForCacheRef.current = null;
|
prevChatIdForCacheRef.current = null;
|
||||||
@@ -1164,7 +1207,7 @@ export function ThreadShell({
|
|||||||
// ``useEffect`` reset has flushed; ``skipLayoutCacheRef`` drops the first run that still
|
// ``useEffect`` reset has flushed; ``skipLayoutCacheRef`` drops the first run that still
|
||||||
// sees the *previous* chat's ``messages`` (avoids stale rows leaking across sessions).
|
// sees the *previous* chat's ``messages`` (avoids stale rows leaking across sessions).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!chatId) {
|
if (!chatId || temporary) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (skipLayoutCacheRef.current) {
|
if (skipLayoutCacheRef.current) {
|
||||||
@@ -1175,7 +1218,7 @@ export function ThreadShell({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
messageCacheRef.current.set(chatId, displayMessages);
|
messageCacheRef.current.set(chatId, displayMessages);
|
||||||
}, [chatId, displayMessages, loading]);
|
}, [chatId, displayMessages, loading, temporary]);
|
||||||
|
|
||||||
// The landing composer queues the first message while `new_chat` is in flight.
|
// The landing composer queues the first message while `new_chat` is in flight.
|
||||||
// Only the chat created for that send may consume it; selecting another chat
|
// Only the chat created for that send may consume it; selecting another chat
|
||||||
@@ -1374,12 +1417,12 @@ export function ThreadShell({
|
|||||||
fallbackModelName={fallbackModelName}
|
fallbackModelName={fallbackModelName}
|
||||||
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
|
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
|
||||||
variant={showHeroComposer ? "hero" : "thread"}
|
variant={showHeroComposer ? "hero" : "thread"}
|
||||||
slashCommands={slashCommands}
|
slashCommands={availableSlashCommands}
|
||||||
cliApps={cliApps}
|
cliApps={temporary ? [] : cliApps}
|
||||||
mcpPresets={mcpPresets}
|
mcpPresets={temporary ? [] : mcpPresets}
|
||||||
skills={skills}
|
skills={temporary ? [] : skills}
|
||||||
onStop={stop}
|
onStop={stop}
|
||||||
onTranscribeAudio={transcribeAudio}
|
onTranscribeAudio={temporary ? undefined : transcribeAudio}
|
||||||
runStartedAt={currentRunStartedAt}
|
runStartedAt={currentRunStartedAt}
|
||||||
goalState={currentGoalState}
|
goalState={currentGoalState}
|
||||||
workspaceScope={workspaceScope}
|
workspaceScope={workspaceScope}
|
||||||
@@ -1394,6 +1437,7 @@ export function ThreadShell({
|
|||||||
quotedContext={quotedContext}
|
quotedContext={quotedContext}
|
||||||
focusRequest={composerFocusSignal}
|
focusRequest={composerFocusSignal}
|
||||||
onQuotedContextChange={setQuotedContext}
|
onQuotedContextChange={setQuotedContext}
|
||||||
|
allowAttachments={!temporary}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ThreadComposer
|
<ThreadComposer
|
||||||
@@ -1416,7 +1460,7 @@ export function ThreadShell({
|
|||||||
fallbackModelName={fallbackModelName}
|
fallbackModelName={fallbackModelName}
|
||||||
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
|
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
|
||||||
variant="hero"
|
variant="hero"
|
||||||
slashCommands={slashCommands}
|
slashCommands={availableSlashCommands}
|
||||||
cliApps={cliApps}
|
cliApps={cliApps}
|
||||||
mcpPresets={mcpPresets}
|
mcpPresets={mcpPresets}
|
||||||
skills={skills}
|
skills={skills}
|
||||||
@@ -1442,10 +1486,15 @@ export function ThreadShell({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex w-full flex-col items-center text-center animate-in fade-in-0 slide-in-from-bottom-2 duration-500">
|
<div className="flex w-full flex-col items-center text-center animate-in fade-in-0 slide-in-from-bottom-2 duration-500">
|
||||||
<HeroGreeting text={t(heroGreetingKey)} />
|
<HeroGreeting text={emptyStateGreeting ?? t(heroGreetingKey)} />
|
||||||
|
{emptyStateDescription ? (
|
||||||
|
<p className="mt-3 max-w-xl text-sm text-muted-foreground">
|
||||||
|
{emptyStateDescription}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
const sessionInfoAction = historyKey ? (
|
const sessionInfoAction = historyKey && showSessionInfo ? (
|
||||||
<SessionInfoPopover sessionKey={historyKey} token={token} title={title} />
|
<SessionInfoPopover sessionKey={historyKey} token={token} title={title} />
|
||||||
) : undefined;
|
) : undefined;
|
||||||
const promptNavigatorAction = historyKey ? (
|
const promptNavigatorAction = historyKey ? (
|
||||||
@@ -1470,6 +1519,7 @@ export function ThreadShell({
|
|||||||
minimal={!session && !loading}
|
minimal={!session && !loading}
|
||||||
promptNavigatorAction={promptNavigatorAction}
|
promptNavigatorAction={promptNavigatorAction}
|
||||||
sessionInfoAction={sessionInfoAction}
|
sessionInfoAction={sessionInfoAction}
|
||||||
|
headerAction={headerAction}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<FilePreviewAvailabilityProvider
|
<FilePreviewAvailabilityProvider
|
||||||
@@ -1486,17 +1536,17 @@ export function ThreadShell({
|
|||||||
conversationKey={historyKey}
|
conversationKey={historyKey}
|
||||||
conversationReady={messagesReady}
|
conversationReady={messagesReady}
|
||||||
showScrollToBottomButton={!!session}
|
showScrollToBottomButton={!!session}
|
||||||
cliApps={cliApps}
|
cliApps={temporary ? [] : cliApps}
|
||||||
mcpPresets={mcpPresets}
|
mcpPresets={temporary ? [] : mcpPresets}
|
||||||
slashCommands={slashCommands}
|
slashCommands={availableSlashCommands}
|
||||||
forkBoundaryMessageCount={forkBoundaryMessageCount}
|
forkBoundaryMessageCount={forkBoundaryMessageCount}
|
||||||
hasMoreBefore={hasMoreBefore}
|
hasMoreBefore={hasMoreBefore}
|
||||||
loadingOlder={loadingOlder}
|
loadingOlder={loadingOlder}
|
||||||
userMessageOffset={userMessageOffset}
|
userMessageOffset={userMessageOffset}
|
||||||
onLoadOlder={loadOlder}
|
onLoadOlder={loadOlder}
|
||||||
onOpenFilePreview={historyKey ? handleOpenFilePreview : undefined}
|
onOpenFilePreview={historyKey ? handleOpenFilePreview : undefined}
|
||||||
onForkFromMessage={onForkChat ? handleForkFromMessage : undefined}
|
onForkFromMessage={!temporary && onForkChat ? handleForkFromMessage : undefined}
|
||||||
onQuoteSelection={session ? handleQuoteSelection : undefined}
|
onQuoteSelection={session && !temporary ? handleQuoteSelection : undefined}
|
||||||
/>
|
/>
|
||||||
</FilePreviewAvailabilityProvider>
|
</FilePreviewAvailabilityProvider>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -186,7 +186,6 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
const pendingPromptJumpRef = useRef<string | null>(null);
|
const pendingPromptJumpRef = useRef<string | null>(null);
|
||||||
const restoreScrollAfterPrependRef =
|
const restoreScrollAfterPrependRef =
|
||||||
useRef<{ height: number; top: number } | null>(null);
|
useRef<{ height: number; top: number } | null>(null);
|
||||||
const composerInputScrollTopRef = useRef<number | null>(null);
|
|
||||||
const composerDockHeightRef = useRef(0);
|
const composerDockHeightRef = useRef(0);
|
||||||
const [atBottom, setAtBottom] = useState(true);
|
const [atBottom, setAtBottom] = useState(true);
|
||||||
const [composerDockHeight, setComposerDockHeight] = useState(0);
|
const [composerDockHeight, setComposerDockHeight] = useState(0);
|
||||||
@@ -689,23 +688,9 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
data-testid="thread-composer-dock"
|
data-testid="thread-composer-dock"
|
||||||
onInputCapture={(event) => {
|
onInputCapture={(event) => {
|
||||||
if (event.target instanceof HTMLTextAreaElement) {
|
if (event.target instanceof HTMLTextAreaElement) {
|
||||||
composerInputScrollTopRef.current = scrollRef.current?.scrollTop ?? null;
|
|
||||||
threadMotionRef.current?.handleComposerInput();
|
threadMotionRef.current?.handleComposerInput();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onInput={(event) => {
|
|
||||||
if (!(event.target instanceof HTMLTextAreaElement)) return;
|
|
||||||
const previousScrollTop = composerInputScrollTopRef.current;
|
|
||||||
composerInputScrollTopRef.current = null;
|
|
||||||
const scrollEl = scrollRef.current;
|
|
||||||
if (scrollEl && previousScrollTop !== null) {
|
|
||||||
// Textarea autosizing briefly collapses to `height: auto` while
|
|
||||||
// measuring. Chrome can clamp the sibling thread scrollport in
|
|
||||||
// that intermediate layout; restore it before paint, then let
|
|
||||||
// ResizeObserver handle any real final composer height change.
|
|
||||||
scrollEl.scrollTop = previousScrollTop;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"row-start-2 z-10 w-full",
|
"row-start-2 z-10 w-full",
|
||||||
hasMessages ? "relative bg-background" : "relative self-center",
|
hasMessages ? "relative bg-background" : "relative self-center",
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -31,32 +30,29 @@ interface DialogContentProps
|
|||||||
const DialogContent = React.forwardRef<
|
const DialogContent = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
DialogContentProps
|
DialogContentProps
|
||||||
>(({ className, children, showCloseButton = true, ...props }, ref) => {
|
>(({ className, children, showCloseButton = true, ...props }, ref) => (
|
||||||
const { t } = useTranslation();
|
<DialogPortal>
|
||||||
return (
|
<DialogOverlay />
|
||||||
<DialogPortal>
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
<DialogOverlay />
|
<DialogPrimitive.Content
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
ref={ref}
|
||||||
<DialogPrimitive.Content
|
className={cn(
|
||||||
ref={ref}
|
"grid w-full max-w-lg origin-center gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg",
|
||||||
className={cn(
|
className,
|
||||||
"grid w-full max-w-lg origin-center gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg",
|
)}
|
||||||
className,
|
{...props}
|
||||||
)}
|
>
|
||||||
{...props}
|
{children}
|
||||||
>
|
{showCloseButton ? (
|
||||||
{children}
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
|
||||||
{showCloseButton ? (
|
<X className="h-4 w-4" />
|
||||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
|
<span className="sr-only">Close</span>
|
||||||
<X className="h-4 w-4" />
|
</DialogPrimitive.Close>
|
||||||
<span className="sr-only">{t("common.close")}</span>
|
) : null}
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Content>
|
||||||
) : null}
|
</div>
|
||||||
</DialogPrimitive.Content>
|
</DialogPortal>
|
||||||
</div>
|
));
|
||||||
</DialogPortal>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
const DialogHeader = ({
|
const DialogHeader = ({
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import * as React from "react";
|
|||||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { cva, type VariantProps } from "class-variance-authority";
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -75,39 +74,36 @@ const SheetContent = React.forwardRef<
|
|||||||
...props
|
...props
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
) => {
|
) => (
|
||||||
const { t } = useTranslation();
|
<SheetPortal>
|
||||||
return (
|
<SheetOverlay />
|
||||||
<SheetPortal>
|
<DialogPrimitive.Content
|
||||||
<SheetOverlay />
|
ref={ref}
|
||||||
<DialogPrimitive.Content
|
className={cn(
|
||||||
ref={ref}
|
sheetVariants({ side }),
|
||||||
className={cn(
|
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||||
sheetVariants({ side }),
|
"duration-300",
|
||||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
className,
|
||||||
"duration-300",
|
)}
|
||||||
className,
|
{...props}
|
||||||
)}
|
>
|
||||||
{...props}
|
{children}
|
||||||
>
|
{showCloseButton ? (
|
||||||
{children}
|
<DialogPrimitive.Close
|
||||||
{showCloseButton ? (
|
className={cn(
|
||||||
<DialogPrimitive.Close
|
"absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity",
|
||||||
className={cn(
|
"hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||||
"absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity",
|
"disabled:pointer-events-none",
|
||||||
"hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
closeButtonClassName,
|
||||||
"disabled:pointer-events-none",
|
)}
|
||||||
closeButtonClassName,
|
>
|
||||||
)}
|
<X className="h-4 w-4" />
|
||||||
>
|
<span className="sr-only">Close</span>
|
||||||
<X className="h-4 w-4" />
|
</DialogPrimitive.Close>
|
||||||
<span className="sr-only">{t("common.close")}</span>
|
) : null}
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Content>
|
||||||
) : null}
|
</SheetPortal>
|
||||||
</DialogPrimitive.Content>
|
));
|
||||||
</SheetPortal>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
SheetContent.displayName = DialogPrimitive.Content.displayName;
|
SheetContent.displayName = DialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
const SheetTitle = React.forwardRef<
|
const SheetTitle = React.forwardRef<
|
||||||
|
|||||||
@@ -487,6 +487,7 @@ export interface SendOptions {
|
|||||||
finalizeActiveTurn?: boolean;
|
finalizeActiveTurn?: boolean;
|
||||||
/** Append guidance to the running turn without detaching its active answer segment. */
|
/** Append guidance to the running turn without detaching its active answer segment. */
|
||||||
continueActiveTurn?: boolean;
|
continueActiveTurn?: boolean;
|
||||||
|
temporary?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubmittedTurn {
|
export interface SubmittedTurn {
|
||||||
@@ -546,6 +547,7 @@ export function useNanobotStream(
|
|||||||
initialMessages: UIMessage[] = [],
|
initialMessages: UIMessage[] = [],
|
||||||
hasPendingToolCalls = false,
|
hasPendingToolCalls = false,
|
||||||
onTurnEnd?: () => void,
|
onTurnEnd?: () => void,
|
||||||
|
options?: { temporary?: boolean },
|
||||||
): {
|
): {
|
||||||
messages: UIMessage[];
|
messages: UIMessage[];
|
||||||
/** Whether ``messages`` belongs to the current ``chatId`` after a session switch. */
|
/** Whether ``messages`` belongs to the current ``chatId`` after a session switch. */
|
||||||
@@ -1341,7 +1343,9 @@ export function useNanobotStream(
|
|||||||
// ``attached`` frames aren't actionable here.
|
// ``attached`` frames aren't actionable here.
|
||||||
};
|
};
|
||||||
|
|
||||||
const unsub = client.onChat(chatId, handle);
|
const unsub = options?.temporary
|
||||||
|
? client.onChat(chatId, handle, { temporary: true })
|
||||||
|
: client.onChat(chatId, handle);
|
||||||
return () => {
|
return () => {
|
||||||
unsub();
|
unsub();
|
||||||
buffer.current = null;
|
buffer.current = null;
|
||||||
@@ -1363,6 +1367,7 @@ export function useNanobotStream(
|
|||||||
flushPendingStreamEvents,
|
flushPendingStreamEvents,
|
||||||
isSideChannelEvent,
|
isSideChannelEvent,
|
||||||
onTurnEnd,
|
onTurnEnd,
|
||||||
|
options?.temporary,
|
||||||
schedulePendingStreamFlush,
|
schedulePendingStreamFlush,
|
||||||
scheduleStreamEndTimer,
|
scheduleStreamEndTimer,
|
||||||
]);
|
]);
|
||||||
@@ -1450,8 +1455,18 @@ export function useNanobotStream(
|
|||||||
return prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m));
|
return prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m));
|
||||||
});
|
});
|
||||||
suppressStreamUntilTurnEndRef.current = false;
|
suppressStreamUntilTurnEndRef.current = false;
|
||||||
client.sendMessage(chatId, "/stop");
|
if (options?.temporary) {
|
||||||
}, [chatId, clearActivitySegment, client, flushPendingStreamEvents]);
|
client.sendMessage(chatId, "/stop", undefined, { temporary: true });
|
||||||
|
} else {
|
||||||
|
client.sendMessage(chatId, "/stop");
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
chatId,
|
||||||
|
clearActivitySegment,
|
||||||
|
client,
|
||||||
|
flushPendingStreamEvents,
|
||||||
|
options?.temporary,
|
||||||
|
]);
|
||||||
|
|
||||||
const reconcileTurnComplete = useCallback(() => {
|
const reconcileTurnComplete = useCallback(() => {
|
||||||
cancelStreamEndTimer();
|
cancelStreamEndTimer();
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import type {
|
|||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
|
|
||||||
const EMPTY_MESSAGES: UIMessage[] = [];
|
const EMPTY_MESSAGES: UIMessage[] = [];
|
||||||
const INITIAL_HISTORY_PAGE_LIMIT = 80;
|
const INITIAL_HISTORY_PAGE_LIMIT = 160;
|
||||||
const OLDER_HISTORY_PAGE_LIMIT = 120;
|
const OLDER_HISTORY_PAGE_LIMIT = 120;
|
||||||
const CHAT_CREATE_TIMEOUT_MS = 60_000;
|
const CHAT_CREATE_TIMEOUT_MS = 60_000;
|
||||||
|
|
||||||
|
|||||||
@@ -38,20 +38,12 @@
|
|||||||
},
|
},
|
||||||
"meta": {
|
"meta": {
|
||||||
"description": "nanobot web UI — chat with your nanobot workspace."
|
"description": "nanobot web UI — chat with your nanobot workspace."
|
||||||
},
|
|
||||||
"pairing": {
|
|
||||||
"title": "Pair a chat user",
|
|
||||||
"description": "Enter the pairing code shown in the chat.",
|
|
||||||
"code": "Pairing code",
|
|
||||||
"matched": "Matched {{channel}}. Connecting...",
|
|
||||||
"expiresInline": "Code expires {{expires}}.",
|
|
||||||
"queueCount": "{{count}} pending",
|
|
||||||
"noMatch": "No pending request matches this code."
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"navigation": "Sidebar navigation",
|
"navigation": "Sidebar navigation",
|
||||||
"collapse": "Collapse sidebar",
|
"collapse": "Collapse sidebar",
|
||||||
|
"quickChat": "Quick Chat",
|
||||||
"newChat": "New topic",
|
"newChat": "New topic",
|
||||||
"searchAria": "Search",
|
"searchAria": "Search",
|
||||||
"searchPlaceholder": "Search",
|
"searchPlaceholder": "Search",
|
||||||
@@ -69,6 +61,17 @@
|
|||||||
"title": "Skills"
|
"title": "Skills"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"quickChat": {
|
||||||
|
"greeting": "What's on your mind?",
|
||||||
|
"temporary": {
|
||||||
|
"title": "Temporary Chat",
|
||||||
|
"enter": "Temporary",
|
||||||
|
"active": "Temporary",
|
||||||
|
"exit": "Exit temporary chat",
|
||||||
|
"greeting": "Start a temporary chat",
|
||||||
|
"description": "No history, memory, tools, or project access. Content is still sent to your selected model provider."
|
||||||
|
}
|
||||||
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"backToChat": "Back to chat",
|
"backToChat": "Back to chat",
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
@@ -108,7 +111,7 @@
|
|||||||
"webBehavior": "Behavior",
|
"webBehavior": "Behavior",
|
||||||
"cliApps": "CLI apps",
|
"cliApps": "CLI apps",
|
||||||
"mcp": "MCP services",
|
"mcp": "MCP services",
|
||||||
"regional": "Regional",
|
"identity": "Identity",
|
||||||
"webuiSafety": "Web safety",
|
"webuiSafety": "Web safety",
|
||||||
"capabilities": "Capabilities",
|
"capabilities": "Capabilities",
|
||||||
"apps": "Apps",
|
"apps": "Apps",
|
||||||
@@ -199,6 +202,8 @@
|
|||||||
"defaultImageSize": "Default size",
|
"defaultImageSize": "Default size",
|
||||||
"maxImagesPerTurn": "Max images per turn",
|
"maxImagesPerTurn": "Max images per turn",
|
||||||
"imageSaveDir": "Save directory",
|
"imageSaveDir": "Save directory",
|
||||||
|
"botName": "Bot name",
|
||||||
|
"botIcon": "Bot icon",
|
||||||
"timezone": "Timezone",
|
"timezone": "Timezone",
|
||||||
"workspacePath": "Default workspace",
|
"workspacePath": "Default workspace",
|
||||||
"localServiceAccess": "Local services",
|
"localServiceAccess": "Local services",
|
||||||
@@ -242,6 +247,8 @@
|
|||||||
"defaultAspectRatio": "Used when the prompt does not choose an aspect ratio.",
|
"defaultAspectRatio": "Used when the prompt does not choose an aspect ratio.",
|
||||||
"defaultImageSize": "Size hint sent to providers that support it.",
|
"defaultImageSize": "Size hint sent to providers that support it.",
|
||||||
"maxImagesPerTurn": "Upper bound for one generate_image request.",
|
"maxImagesPerTurn": "Upper bound for one generate_image request.",
|
||||||
|
"botName": "Shown wherever nanobot uses a display name.",
|
||||||
|
"botIcon": "Short emoji or text shown with the bot name.",
|
||||||
"timezone": "Used for schedules and time-aware replies.",
|
"timezone": "Used for schedules and time-aware replies.",
|
||||||
"cliAppsCatalog": "Install only the app-specific CLI adapters nanobot can run locally; native apps stay untouched.",
|
"cliAppsCatalog": "Install only the app-specific CLI adapters nanobot can run locally; native apps stay untouched.",
|
||||||
"cliAppsFilter": "Search by app, category, or capability.",
|
"cliAppsFilter": "Search by app, category, or capability.",
|
||||||
@@ -349,7 +356,6 @@
|
|||||||
"statusMissingCredentials": "Needs key",
|
"statusMissingCredentials": "Needs key",
|
||||||
"statusMissingDependency": "Needs dependency",
|
"statusMissingDependency": "Needs dependency",
|
||||||
"statusComingSoon": "Coming soon",
|
"statusComingSoon": "Coming soon",
|
||||||
"comingSoon": "Coming soon",
|
|
||||||
"statusNotInstalled": "Not enabled",
|
"statusNotInstalled": "Not enabled",
|
||||||
"toolScope": "Tools",
|
"toolScope": "Tools",
|
||||||
"allTools": "All",
|
"allTools": "All",
|
||||||
@@ -382,10 +388,7 @@
|
|||||||
"configured": "Configured",
|
"configured": "Configured",
|
||||||
"notConfigured": "Not configured",
|
"notConfigured": "Not configured",
|
||||||
"pending": "Pending",
|
"pending": "Pending",
|
||||||
"restartingEngine": "Restarting",
|
"restartingEngine": "Restarting"
|
||||||
"checking": "Checking",
|
|
||||||
"running": "Running",
|
|
||||||
"needsSetup": "Needs setup"
|
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "Loading settings...",
|
"loading": "Loading settings...",
|
||||||
@@ -413,7 +416,6 @@
|
|||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"deleting": "Deleting...",
|
"deleting": "Deleting...",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"dismiss": "Dismiss",
|
|
||||||
"open": "Open",
|
"open": "Open",
|
||||||
"export": "Export",
|
"export": "Export",
|
||||||
"opening": "Opening...",
|
"opening": "Opening...",
|
||||||
@@ -523,19 +525,9 @@
|
|||||||
"selectProvider": "Select provider",
|
"selectProvider": "Select provider",
|
||||||
"selectAspect": "Select aspect",
|
"selectAspect": "Select aspect",
|
||||||
"selectSize": "Select size",
|
"selectSize": "Select size",
|
||||||
"selectModel": "Select image model",
|
|
||||||
"searchOrTypeModel": "Search or type model ID",
|
|
||||||
"typeModelId": "Type the model ID supported by this provider.",
|
|
||||||
"configureProvider": "Configure provider",
|
"configureProvider": "Configure provider",
|
||||||
"missingCredential": "Configure this provider before enabling image generation."
|
"missingCredential": "Configure this provider before enabling image generation."
|
||||||
},
|
},
|
||||||
"capabilities": {
|
|
||||||
"providerSupport": "Provider support",
|
|
||||||
"providerInstallOnSave": "Required support will be installed automatically when you save this provider.",
|
|
||||||
"searchSupport": "Search provider support",
|
|
||||||
"searchInstallOnSave": "Olostep support will be installed automatically when you save.",
|
|
||||||
"installing": "Installing support..."
|
|
||||||
},
|
|
||||||
"api": {
|
"api": {
|
||||||
"title": "API server",
|
"title": "API server",
|
||||||
"openaiCompatible": "OpenAI-compatible API",
|
"openaiCompatible": "OpenAI-compatible API",
|
||||||
@@ -603,8 +595,6 @@
|
|||||||
"advanced": "Advanced",
|
"advanced": "Advanced",
|
||||||
"checkAndEnable": "Check and enable",
|
"checkAndEnable": "Check and enable",
|
||||||
"checkConnection": "Check connection",
|
"checkConnection": "Check connection",
|
||||||
"connectionChecks": "Connection checks",
|
|
||||||
"open": "Open",
|
|
||||||
"checkedAndEnabled": "Checked and enabled.",
|
"checkedAndEnabled": "Checked and enabled.",
|
||||||
"checking": "Checking...",
|
"checking": "Checking...",
|
||||||
"checkOnly": "Check only",
|
"checkOnly": "Check only",
|
||||||
@@ -700,8 +690,6 @@
|
|||||||
"protected": "Protected",
|
"protected": "Protected",
|
||||||
"editTitle": "Edit automation",
|
"editTitle": "Edit automation",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"commandCopied": "Copied",
|
|
||||||
"copyCommand": "Copy",
|
|
||||||
"deleteTitle": "Delete automation",
|
"deleteTitle": "Delete automation",
|
||||||
"deleteDescription": "This removes {{name}} from the cron store. Past chat messages stay in the session.",
|
"deleteDescription": "This removes {{name}} from the cron store. Past chat messages stay in the session.",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
@@ -761,7 +749,6 @@
|
|||||||
"fields": {
|
"fields": {
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
"message": "Message",
|
"message": "Message",
|
||||||
"command": "Command",
|
|
||||||
"scheduleType": "Schedule type",
|
"scheduleType": "Schedule type",
|
||||||
"every": "Every",
|
"every": "Every",
|
||||||
"unit": "Unit",
|
"unit": "Unit",
|
||||||
@@ -1222,9 +1209,7 @@
|
|||||||
"cliBadge": "CLI",
|
"cliBadge": "CLI",
|
||||||
"mcpBadge": "MCP",
|
"mcpBadge": "MCP",
|
||||||
"cliDescription": "Use @{{name}} as a local CLI app",
|
"cliDescription": "Use @{{name}} as a local CLI app",
|
||||||
"mcpDescription": "Use @{{name}} as an MCP server",
|
"mcpDescription": "Use @{{name}} as an MCP server"
|
||||||
"cliTitle": "CLI app: {{name}}",
|
|
||||||
"mcpTitle": "MCP server: {{name}}"
|
|
||||||
},
|
},
|
||||||
"encoding": "Encoding…",
|
"encoding": "Encoding…",
|
||||||
"remove": "Remove attachment",
|
"remove": "Remove attachment",
|
||||||
@@ -1260,8 +1245,7 @@
|
|||||||
"title": "Prompts",
|
"title": "Prompts",
|
||||||
"search": "Search prompts",
|
"search": "Search prompts",
|
||||||
"noResults": "No matching prompts.",
|
"noResults": "No matching prompts.",
|
||||||
"jumpTo": "Jump to prompt: {{label}}",
|
"jumpTo": "Jump to prompt: {{label}}"
|
||||||
"railAria": "User prompt navigation"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
@@ -1300,14 +1284,6 @@
|
|||||||
"cliRunRan": "Used",
|
"cliRunRan": "Used",
|
||||||
"cliRunFailed": "Failed",
|
"cliRunFailed": "Failed",
|
||||||
"imageAttachment": "Image attachment",
|
"imageAttachment": "Image attachment",
|
||||||
"videoAttachment": "Video attachment",
|
|
||||||
"fileAttachment": "File attachment",
|
|
||||||
"attachmentUnavailable": "Attachment unavailable",
|
|
||||||
"dataTable": "Data table",
|
|
||||||
"fileEditPreparing": "Preparing file edit…",
|
|
||||||
"openLink": "Open link: {{label}}",
|
|
||||||
"openAttachment": "Open {{name}}",
|
|
||||||
"skill": "Skill: {{name}}",
|
|
||||||
"automationSourceFallback": "Automation",
|
"automationSourceFallback": "Automation",
|
||||||
"automationTriggered": "Triggered automatically",
|
"automationTriggered": "Triggered automatically",
|
||||||
"askAboutSelection": "Ask about this",
|
"askAboutSelection": "Ask about this",
|
||||||
@@ -1333,7 +1309,6 @@
|
|||||||
},
|
},
|
||||||
"filePreview": {
|
"filePreview": {
|
||||||
"aria": "File preview",
|
"aria": "File preview",
|
||||||
"breadcrumb": "File path",
|
|
||||||
"close": "Close file preview",
|
"close": "Close file preview",
|
||||||
"loading": "Loading preview...",
|
"loading": "Loading preview...",
|
||||||
"failed": "Could not preview this file.",
|
"failed": "Could not preview this file.",
|
||||||
@@ -1348,10 +1323,7 @@
|
|||||||
"copied": "Copied"
|
"copied": "Copied"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"dismiss": "Dismiss",
|
"dismiss": "Dismiss"
|
||||||
"close": "Close",
|
|
||||||
"current": "Current",
|
|
||||||
"cancel": "Cancel"
|
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"messageTooBig": {
|
"messageTooBig": {
|
||||||
|
|||||||
@@ -38,20 +38,12 @@
|
|||||||
},
|
},
|
||||||
"meta": {
|
"meta": {
|
||||||
"description": "Interfaz web de nanobot: conversa con tu espacio de trabajo de nanobot."
|
"description": "Interfaz web de nanobot: conversa con tu espacio de trabajo de nanobot."
|
||||||
},
|
|
||||||
"pairing": {
|
|
||||||
"title": "Vincular a un usuario del chat",
|
|
||||||
"description": "Introduce el código de vinculación que aparece en el chat.",
|
|
||||||
"code": "Código de vinculación",
|
|
||||||
"matched": "Coincidencia: {{channel}}. Conectando...",
|
|
||||||
"expiresInline": "El código caduca {{expires}}.",
|
|
||||||
"queueCount": "{{count}} pendientes",
|
|
||||||
"noMatch": "No hay ninguna solicitud pendiente que coincida con este código."
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"navigation": "Navegación de la barra lateral",
|
"navigation": "Navegación de la barra lateral",
|
||||||
"collapse": "Contraer barra lateral",
|
"collapse": "Contraer barra lateral",
|
||||||
|
"quickChat": "Chat rápido",
|
||||||
"newChat": "Nuevo tema",
|
"newChat": "Nuevo tema",
|
||||||
"searchAria": "Buscar",
|
"searchAria": "Buscar",
|
||||||
"searchPlaceholder": "Buscar",
|
"searchPlaceholder": "Buscar",
|
||||||
@@ -63,12 +55,23 @@
|
|||||||
"label": "Idioma",
|
"label": "Idioma",
|
||||||
"ariaLabel": "Cambiar idioma"
|
"ariaLabel": "Cambiar idioma"
|
||||||
},
|
},
|
||||||
"apps": "Aplicaciones",
|
"apps": "Apps",
|
||||||
"automations": "Automatizaciones",
|
"automations": "Automatizaciones",
|
||||||
"skills": {
|
"skills": {
|
||||||
"title": "Habilidades"
|
"title": "Habilidades"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"quickChat": {
|
||||||
|
"greeting": "¿Qué tienes en mente?",
|
||||||
|
"temporary": {
|
||||||
|
"title": "Chat temporal",
|
||||||
|
"enter": "Temporal",
|
||||||
|
"active": "Temporal",
|
||||||
|
"exit": "Salir del chat temporal",
|
||||||
|
"greeting": "Inicia un chat temporal",
|
||||||
|
"description": "Sin historial, memoria, herramientas ni acceso al proyecto. El contenido se envía al proveedor del modelo elegido."
|
||||||
|
}
|
||||||
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"backToChat": "Volver al chat",
|
"backToChat": "Volver al chat",
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
@@ -88,7 +91,7 @@
|
|||||||
"channels": "Canales",
|
"channels": "Canales",
|
||||||
"runtime": "Sistema",
|
"runtime": "Sistema",
|
||||||
"advanced": "Seguridad",
|
"advanced": "Seguridad",
|
||||||
"cliApps": "Aplicaciones CLI",
|
"cliApps": "Apps CLI",
|
||||||
"mcp": "MCP",
|
"mcp": "MCP",
|
||||||
"apps": "Aplicaciones",
|
"apps": "Aplicaciones",
|
||||||
"automations": "Automatizaciones",
|
"automations": "Automatizaciones",
|
||||||
@@ -106,7 +109,7 @@
|
|||||||
"imageDefaults": "Valores predeterminados",
|
"imageDefaults": "Valores predeterminados",
|
||||||
"webSearch": "Búsqueda web",
|
"webSearch": "Búsqueda web",
|
||||||
"webBehavior": "Comportamiento",
|
"webBehavior": "Comportamiento",
|
||||||
"regional": "Configuración regional",
|
"identity": "Identidad",
|
||||||
"webuiSafety": "Seguridad de WebUI",
|
"webuiSafety": "Seguridad de WebUI",
|
||||||
"capabilities": "Capacidades",
|
"capabilities": "Capacidades",
|
||||||
"cliApps": "Aplicaciones CLI",
|
"cliApps": "Aplicaciones CLI",
|
||||||
@@ -145,8 +148,10 @@
|
|||||||
"defaultImageSize": "Tamaño predeterminado",
|
"defaultImageSize": "Tamaño predeterminado",
|
||||||
"maxImagesPerTurn": "Máx. imágenes por turno",
|
"maxImagesPerTurn": "Máx. imágenes por turno",
|
||||||
"imageSaveDir": "Directorio de guardado",
|
"imageSaveDir": "Directorio de guardado",
|
||||||
|
"botName": "Nombre del bot",
|
||||||
|
"botIcon": "Icono del bot",
|
||||||
"timezone": "Zona horaria",
|
"timezone": "Zona horaria",
|
||||||
"workspacePath": "Espacio de trabajo predeterminado",
|
"workspacePath": "Workspace predeterminado",
|
||||||
"localServiceAccess": "Servicios locales",
|
"localServiceAccess": "Servicios locales",
|
||||||
"webuiDefaultAccess": "Acceso predeterminado",
|
"webuiDefaultAccess": "Acceso predeterminado",
|
||||||
"currentModel": "Configuración actual",
|
"currentModel": "Configuración actual",
|
||||||
@@ -157,12 +162,12 @@
|
|||||||
"logs": "Registros",
|
"logs": "Registros",
|
||||||
"diagnostics": "Diagnóstico",
|
"diagnostics": "Diagnóstico",
|
||||||
"contextWindow": "Ventana de contexto",
|
"contextWindow": "Ventana de contexto",
|
||||||
"transcription": "Transcripción",
|
"transcription": "Transcripcion",
|
||||||
"transcriptionProvider": "Proveedor",
|
"transcriptionProvider": "Proveedor",
|
||||||
"transcriptionProviderStatus": "Estado del proveedor de transcripción",
|
"transcriptionProviderStatus": "Estado del proveedor",
|
||||||
"transcriptionModel": "Modelo",
|
"transcriptionModel": "Modelo",
|
||||||
"transcriptionLanguage": "Idioma",
|
"transcriptionLanguage": "Idioma",
|
||||||
"voiceLimits": "Límites"
|
"voiceLimits": "Limites"
|
||||||
},
|
},
|
||||||
"help": {
|
"help": {
|
||||||
"theme": "Cambia entre apariencia clara y oscura.",
|
"theme": "Cambia entre apariencia clara y oscura.",
|
||||||
@@ -171,40 +176,42 @@
|
|||||||
"model": "Elige el modelo que usa este preajuste.",
|
"model": "Elige el modelo que usa este preajuste.",
|
||||||
"configPath": "Archivo de configuración que usa actualmente el gateway.",
|
"configPath": "Archivo de configuración que usa actualmente el gateway.",
|
||||||
"selectedPreset": "Los preajustes con nombre son de solo lectura aquí; edítalos en config.json.",
|
"selectedPreset": "Los preajustes con nombre son de solo lectura aquí; edítalos en config.json.",
|
||||||
"presetModel": "Cambia a Predeterminado para editar el modelo y el proveedor desde WebUI.",
|
"presetModel": "Cambia a Default para editar modelo y proveedor desde WebUI.",
|
||||||
"density": "Solo se guarda en este navegador.",
|
"density": "Solo se guarda en este navegador.",
|
||||||
"activityMode": "Elige cuánto detalle de actividad del agente se muestra por defecto.",
|
"activityMode": "Elige cuánto detalle de actividad del agente se muestra por defecto.",
|
||||||
"fileEditDisplay": "Elige si la actividad de edición muestra recuentos de líneas o diferencias.",
|
"fileEditDisplay": "Elige si la actividad de edición muestra recuentos de líneas o el diff.",
|
||||||
"codeWrap": "Mantiene legibles las líneas largas de código en pantallas pequeñas.",
|
"codeWrap": "Mantiene legibles las líneas largas de código en pantallas pequeñas.",
|
||||||
"maxResults": "Resultados devueltos por cada llamada web_search.",
|
"maxResults": "Resultados devueltos por cada llamada web_search.",
|
||||||
"timeout": "Segundos antes de que una solicitud de búsqueda expire.",
|
"timeout": "Segundos antes de que una solicitud de búsqueda expire.",
|
||||||
"jinaReader": "Usa Jina Reader para web_fetch cuando esté disponible.",
|
"jinaReader": "Usa Jina Reader para web_fetch cuando esté disponible.",
|
||||||
"imageGeneration": "Expone generate_image en chats cuando hay un proveedor de imagen configurado.",
|
"imageGeneration": "Expone generate_image en chats cuando hay un proveedor de imagen configurado.",
|
||||||
"imageProvider": "Elige el proveedor registrado usado por generate_image.",
|
"imageProvider": "Elige el proveedor registrado usado por generate_image.",
|
||||||
"imageProviderStatus": "La generación de imágenes reutiliza las credenciales de los proveedores.",
|
"imageProviderStatus": "La generación de imágenes reutiliza credenciales de Proveedores.",
|
||||||
"imageModel": "Nombre del modelo enviado al proveedor de imágenes seleccionado.",
|
"imageModel": "Nombre del modelo enviado al proveedor de imágenes seleccionado.",
|
||||||
"defaultAspectRatio": "Se usa cuando la instrucción no elige una proporción.",
|
"defaultAspectRatio": "Se usa cuando el prompt no elige una proporción.",
|
||||||
"defaultImageSize": "Pista de tamaño enviada a proveedores compatibles.",
|
"defaultImageSize": "Pista de tamaño enviada a proveedores compatibles.",
|
||||||
"maxImagesPerTurn": "Límite superior para una solicitud generate_image.",
|
"maxImagesPerTurn": "Límite superior para una solicitud generate_image.",
|
||||||
|
"botName": "Se muestra donde nanobot usa un nombre visible.",
|
||||||
|
"botIcon": "Emoji o texto corto junto al nombre del bot.",
|
||||||
"timezone": "Se usa para horarios y respuestas con conciencia temporal.",
|
"timezone": "Se usa para horarios y respuestas con conciencia temporal.",
|
||||||
"localServiceAccess": "Permite que los comandos shell con acceso completo alcancen servicios locales.",
|
"localServiceAccess": "Permite que comandos shell con Full Access alcancen servicios localhost.",
|
||||||
"webuiDefaultAccess": "Usado por chats web sin permiso específico de proyecto.",
|
"webuiDefaultAccess": "Usado por chats web sin permiso específico de proyecto.",
|
||||||
"securityManagedControls": "Las capturas web siempre protegen servicios locales, privados y metadata. La seguridad de canales core se gestiona en config.json.",
|
"securityManagedControls": "Las capturas web siempre protegen servicios locales, privados y metadata. La seguridad de canales core se gestiona en config.json.",
|
||||||
"currentModel": "Se usa para nuevas respuestas.",
|
"currentModel": "Se usa para nuevas respuestas.",
|
||||||
"selectedModelProvider": "Definido por el modelo seleccionado.",
|
"selectedModelProvider": "Definido por el modelo seleccionado.",
|
||||||
"selectedModelValue": "Definido por el modelo seleccionado.",
|
"selectedModelValue": "Definido por el modelo seleccionado.",
|
||||||
"brandLogos": "Muestra logos de proveedores de terceros y CLI en Ajustes.",
|
"brandLogos": "Muestra logos de proveedores de terceros y CLI en Ajustes.",
|
||||||
"cliAppsCatalog": "Instala solo adaptadores CLI de aplicaciones que nanobot puede ejecutar localmente; las aplicaciones nativas no se modifican.",
|
"cliAppsCatalog": "Instala solo adaptadores CLI de apps que nanobot puede ejecutar localmente; las apps nativas no se modifican.",
|
||||||
"cliAppsFilter": "Busca por aplicación, categoría o capacidad.",
|
"cliAppsFilter": "Busca por app, categoría o capacidad.",
|
||||||
"logs": "Abre la carpeta de registros del motor nativo.",
|
"logs": "Abre la carpeta de registros del motor nativo.",
|
||||||
"diagnostics": "Exporta un pequeño informe del tiempo de ejecución para soporte.",
|
"diagnostics": "Exporta un pequeño informe de runtime para soporte.",
|
||||||
"localServiceAccessNative": "Permite que los comandos shell con acceso completo alcancen servicios en este Mac.",
|
"localServiceAccessNative": "Permite que comandos shell con Full Access alcancen servicios en este Mac.",
|
||||||
"webuiDefaultAccessNative": "Usado por chats nativos sin permiso específico de proyecto.",
|
"webuiDefaultAccessNative": "Usado por chats nativos sin permiso específico de proyecto.",
|
||||||
"contextWindow": "Elige el presupuesto de contexto predeterminado para esta configuración de modelo.",
|
"contextWindow": "Elige el presupuesto de contexto predeterminado para esta configuración de modelo.",
|
||||||
"transcription": "Transcribe la entrada del micrófono antes de enviarla. Los mensajes de voz de los canales usan la misma configuración.",
|
"transcription": "Transcribe la entrada del microfono antes de enviarla. Los mensajes de voz de los canales usan la misma configuracion.",
|
||||||
"transcriptionProvider": "Usa las credenciales del proveedor correspondiente en la sección Proveedores.",
|
"transcriptionProvider": "Usa las credenciales del proveedor correspondiente en Proveedores.",
|
||||||
"transcriptionProviderStatus": "Las claves API permanecen en los proveedores, no en la configuración de transcripción.",
|
"transcriptionProviderStatus": "Las claves API permanecen en proveedores, no en la configuracion de transcripcion.",
|
||||||
"transcriptionModel": "Déjalo como el valor predeterminado resuelto, salvo que el proveedor necesite un identificador de modelo personalizado.",
|
"transcriptionModel": "Dejalo como el valor predeterminado resuelto salvo que el proveedor necesite un id de modelo personalizado.",
|
||||||
"transcriptionLanguage": "Pista ISO-639 opcional, como en, zh, ja o ko."
|
"transcriptionLanguage": "Pista ISO-639 opcional, como en, zh, ja o ko."
|
||||||
},
|
},
|
||||||
"values": {
|
"values": {
|
||||||
@@ -224,7 +231,7 @@
|
|||||||
"expanded": "Expandido",
|
"expanded": "Expandido",
|
||||||
"default": "Predeterminado",
|
"default": "Predeterminado",
|
||||||
"summary": "Resumen",
|
"summary": "Resumen",
|
||||||
"diff": "Diferencias",
|
"diff": "Diff",
|
||||||
"collapsedDiff": "Diff contraído",
|
"collapsedDiff": "Diff contraído",
|
||||||
"on": "Activado",
|
"on": "Activado",
|
||||||
"off": "Desactivado",
|
"off": "Desactivado",
|
||||||
@@ -233,10 +240,7 @@
|
|||||||
"configured": "Configurado",
|
"configured": "Configurado",
|
||||||
"notConfigured": "Sin configurar",
|
"notConfigured": "Sin configurar",
|
||||||
"pending": "Pendiente",
|
"pending": "Pendiente",
|
||||||
"restartingEngine": "Reiniciando",
|
"restartingEngine": "Reiniciando"
|
||||||
"checking": "Comprobando",
|
|
||||||
"running": "En ejecución",
|
|
||||||
"needsSetup": "Requiere configuración"
|
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "Cargando ajustes...",
|
"loading": "Cargando ajustes...",
|
||||||
@@ -264,7 +268,6 @@
|
|||||||
"deleting": "Eliminando...",
|
"deleting": "Eliminando...",
|
||||||
"edit": "Editar",
|
"edit": "Editar",
|
||||||
"cancel": "Cancelar",
|
"cancel": "Cancelar",
|
||||||
"dismiss": "Descartar",
|
|
||||||
"open": "Abrir",
|
"open": "Abrir",
|
||||||
"export": "Exportar",
|
"export": "Exportar",
|
||||||
"opening": "Abriendo...",
|
"opening": "Abriendo...",
|
||||||
@@ -278,15 +281,15 @@
|
|||||||
"notConfiguredSection": "Sin configurar",
|
"notConfiguredSection": "Sin configurar",
|
||||||
"showMore": "Mostrar {{count}} más",
|
"showMore": "Mostrar {{count}} más",
|
||||||
"showLess": "Mostrar menos",
|
"showLess": "Mostrar menos",
|
||||||
"apiKey": "Clave API",
|
"apiKey": "API key",
|
||||||
"apiBase": "Base de la API",
|
"apiBase": "API base",
|
||||||
"apiKeyPlaceholder": "Introduce la clave API",
|
"apiKeyPlaceholder": "Introduce la API key",
|
||||||
"apiKeyConfiguredPlaceholder": "Déjalo vacío para conservar la clave actual",
|
"apiKeyConfiguredPlaceholder": "Deja vacío para conservar la key actual",
|
||||||
"configuredKeyHint": "Key configurada",
|
"configuredKeyHint": "Key configurada",
|
||||||
"apiBasePlaceholder": "Usar el valor predeterminado del proveedor",
|
"apiBasePlaceholder": "Usar el valor predeterminado del proveedor",
|
||||||
"apiKeyRequired": "Se requiere una clave API para configurar este proveedor.",
|
"apiKeyRequired": "Se requiere una API key para configurar este proveedor.",
|
||||||
"showApiKey": "Mostrar clave API",
|
"showApiKey": "Mostrar API key",
|
||||||
"hideApiKey": "Ocultar clave API",
|
"hideApiKey": "Ocultar API key",
|
||||||
"noConfiguredProviders": "No hay proveedores configurados",
|
"noConfiguredProviders": "No hay proveedores configurados",
|
||||||
"configureFirst": "Configura primero un proveedor en BYOK.",
|
"configureFirst": "Configura primero un proveedor en BYOK.",
|
||||||
"openByok": "Abrir BYOK",
|
"openByok": "Abrir BYOK",
|
||||||
@@ -297,19 +300,19 @@
|
|||||||
},
|
},
|
||||||
"webSearch": {
|
"webSearch": {
|
||||||
"provider": "Proveedor de búsqueda",
|
"provider": "Proveedor de búsqueda",
|
||||||
"providerHelp": "Elige el backend que usará la herramienta de búsqueda web.",
|
"providerHelp": "Elige el backend que usará la herramienta web search.",
|
||||||
"selectProvider": "Seleccionar proveedor",
|
"selectProvider": "Seleccionar proveedor",
|
||||||
"credentials": "Credenciales",
|
"credentials": "Credenciales",
|
||||||
"noCredentialRequired": "No requiere clave",
|
"noCredentialRequired": "No requiere key",
|
||||||
"noCredentialHelp": "DuckDuckGo funciona sin guardar una API key.",
|
"noCredentialHelp": "DuckDuckGo funciona sin guardar una API key.",
|
||||||
"apiKeyHelp": "Se guarda en config y se muestra enmascarada después de guardar.",
|
"apiKeyHelp": "Se guarda en config y se muestra enmascarada después de guardar.",
|
||||||
"baseUrl": "URL base",
|
"baseUrl": "Base URL",
|
||||||
"baseUrlHelp": "SearXNG necesita la URL de tu propia instancia.",
|
"baseUrlHelp": "SearXNG necesita la URL de tu propia instancia.",
|
||||||
"baseUrlPlaceholder": "https://search.example.com",
|
"baseUrlPlaceholder": "https://search.example.com",
|
||||||
"apiKeyRequired": "Este proveedor de búsqueda requiere una clave API.",
|
"apiKeyRequired": "Este proveedor de búsqueda requiere una API key.",
|
||||||
"baseUrlRequired": "SearXNG requiere una URL base.",
|
"baseUrlRequired": "SearXNG requiere una Base URL.",
|
||||||
"missingCredential": "Añade la credencial requerida antes de guardar.",
|
"missingCredential": "Añade la credencial requerida antes de guardar.",
|
||||||
"saveHint": "Los cambios se aplican a nuevas solicitudes de búsqueda web."
|
"saveHint": "Los cambios se aplican a nuevas solicitudes de web search."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"overview": {
|
"overview": {
|
||||||
@@ -324,7 +327,7 @@
|
|||||||
},
|
},
|
||||||
"usage": {
|
"usage": {
|
||||||
"title": "Actividad de tokens",
|
"title": "Actividad de tokens",
|
||||||
"shortTitle": "Uso de tokens",
|
"shortTitle": "Token Usage",
|
||||||
"subtitle": "Uso reportado por el proveedor durante los últimos 12 meses.",
|
"subtitle": "Uso reportado por el proveedor durante los últimos 12 meses.",
|
||||||
"empty": "La actividad de tokens aparecerá después de nuevas respuestas del modelo.",
|
"empty": "La actividad de tokens aparecerá después de nuevas respuestas del modelo.",
|
||||||
"totalTokens": "Tokens totales",
|
"totalTokens": "Tokens totales",
|
||||||
@@ -371,19 +374,9 @@
|
|||||||
"selectProvider": "Seleccionar proveedor",
|
"selectProvider": "Seleccionar proveedor",
|
||||||
"selectAspect": "Seleccionar proporción",
|
"selectAspect": "Seleccionar proporción",
|
||||||
"selectSize": "Seleccionar tamaño",
|
"selectSize": "Seleccionar tamaño",
|
||||||
"selectModel": "Seleccionar modelo de imagen",
|
|
||||||
"searchOrTypeModel": "Buscar o escribir ID del modelo",
|
|
||||||
"typeModelId": "Escribe el ID de modelo compatible con este proveedor.",
|
|
||||||
"configureProvider": "Configurar proveedor",
|
"configureProvider": "Configurar proveedor",
|
||||||
"missingCredential": "Configura este proveedor antes de activar la generación de imágenes."
|
"missingCredential": "Configura este proveedor antes de activar la generación de imágenes."
|
||||||
},
|
},
|
||||||
"capabilities": {
|
|
||||||
"providerSupport": "Compatibilidad del proveedor",
|
|
||||||
"providerInstallOnSave": "La compatibilidad necesaria se instalará automáticamente al guardar este proveedor.",
|
|
||||||
"searchSupport": "Compatibilidad del proveedor de búsqueda",
|
|
||||||
"searchInstallOnSave": "La compatibilidad con Olostep se instalará automáticamente al guardar.",
|
|
||||||
"installing": "Instalando compatibilidad..."
|
|
||||||
},
|
|
||||||
"models": {
|
"models": {
|
||||||
"selectModel": "Seleccionar modelo",
|
"selectModel": "Seleccionar modelo",
|
||||||
"addConfiguration": "Agregar configuración",
|
"addConfiguration": "Agregar configuración",
|
||||||
@@ -456,8 +449,8 @@
|
|||||||
"statusUnsupported": "No compatible",
|
"statusUnsupported": "No compatible",
|
||||||
"statusNotInstalled": "No instalada",
|
"statusNotInstalled": "No instalada",
|
||||||
"unsupported": "No compatible",
|
"unsupported": "No compatible",
|
||||||
"loading": "Cargando aplicaciones CLI...",
|
"loading": "Cargando apps CLI...",
|
||||||
"empty": "Ninguna aplicación CLI coincide con este filtro.",
|
"empty": "Ninguna app CLI coincide con este filtro.",
|
||||||
"readyTitle": "@{{name}} está listo",
|
"readyTitle": "@{{name}} está listo",
|
||||||
"readyStatus": "Listo",
|
"readyStatus": "Listo",
|
||||||
"readyPrompt": "Usa @{{name}} para ver qué puede hacer este CLI.",
|
"readyPrompt": "Usa @{{name}} para ver qué puede hacer este CLI.",
|
||||||
@@ -481,11 +474,11 @@
|
|||||||
},
|
},
|
||||||
"mcp": {
|
"mcp": {
|
||||||
"allCategories": "Todas las categorías",
|
"allCategories": "Todas las categorías",
|
||||||
"summary": "{{installed}} de {{total}} preajustes habilitados",
|
"summary": "{{installed}} de {{total}} presets habilitados",
|
||||||
"filterAll": "Todos",
|
"filterAll": "Todos",
|
||||||
"filterInstalled": "Habilitados",
|
"filterInstalled": "Habilitados",
|
||||||
"filterNotInstalled": "No habilitados",
|
"filterNotInstalled": "No habilitados",
|
||||||
"searchPlaceholder": "Buscar preajustes MCP",
|
"searchPlaceholder": "Buscar presets MCP",
|
||||||
"moreOptions": "Más opciones de MCP",
|
"moreOptions": "Más opciones de MCP",
|
||||||
"moreOptionsSubtitle": "Añade un servidor personalizado o importa mcp.json.",
|
"moreOptionsSubtitle": "Añade un servidor personalizado o importa mcp.json.",
|
||||||
"customTitle": "MCP personalizado",
|
"customTitle": "MCP personalizado",
|
||||||
@@ -496,9 +489,9 @@
|
|||||||
"serverUrl": "URL",
|
"serverUrl": "URL",
|
||||||
"transport": "Transporte",
|
"transport": "Transporte",
|
||||||
"command": "Comando",
|
"command": "Comando",
|
||||||
"args": "Argumentos JSON",
|
"args": "Args JSON",
|
||||||
"headers": "Encabezados JSON",
|
"headers": "Headers JSON",
|
||||||
"env": "Entorno JSON",
|
"env": "Env JSON",
|
||||||
"timeout": "Tiempo límite de herramienta",
|
"timeout": "Tiempo límite de herramienta",
|
||||||
"advancedOptions": "Opciones avanzadas",
|
"advancedOptions": "Opciones avanzadas",
|
||||||
"hideAdvanced": "Ocultar avanzado",
|
"hideAdvanced": "Ocultar avanzado",
|
||||||
@@ -507,8 +500,8 @@
|
|||||||
"importConfig": "Importar",
|
"importConfig": "Importar",
|
||||||
"restartRequired": "Reinicia nanobot para conectar las herramientas MCP actualizadas.",
|
"restartRequired": "Reinicia nanobot para conectar las herramientas MCP actualizadas.",
|
||||||
"toolsFound": "{{count}} herramientas",
|
"toolsFound": "{{count}} herramientas",
|
||||||
"loading": "Cargando preajustes MCP...",
|
"loading": "Cargando presets MCP...",
|
||||||
"empty": "Ningún preajuste MCP coincide con este filtro.",
|
"empty": "Ningún preset MCP coincide con este filtro.",
|
||||||
"openDocs": "Abrir docs",
|
"openDocs": "Abrir docs",
|
||||||
"test": "Probar",
|
"test": "Probar",
|
||||||
"remove": "Eliminar",
|
"remove": "Eliminar",
|
||||||
@@ -526,7 +519,6 @@
|
|||||||
"statusMissingCredentials": "Necesita clave",
|
"statusMissingCredentials": "Necesita clave",
|
||||||
"statusMissingDependency": "Necesita dependencia",
|
"statusMissingDependency": "Necesita dependencia",
|
||||||
"statusComingSoon": "Próximamente",
|
"statusComingSoon": "Próximamente",
|
||||||
"comingSoon": "Próximamente",
|
|
||||||
"statusNotInstalled": "No habilitado",
|
"statusNotInstalled": "No habilitado",
|
||||||
"toolScope": "Herramientas",
|
"toolScope": "Herramientas",
|
||||||
"allTools": "Todas",
|
"allTools": "Todas",
|
||||||
@@ -552,24 +544,24 @@
|
|||||||
},
|
},
|
||||||
"apps": {
|
"apps": {
|
||||||
"description": "Añade herramientas a nanobot y luego úsalas con @ en el chat.",
|
"description": "Añade herramientas a nanobot y luego úsalas con @ en el chat.",
|
||||||
"cliLabel": "Aplicación",
|
"cliLabel": "App",
|
||||||
"mcpLabel": "Integración",
|
"mcpLabel": "Integración",
|
||||||
"channelLabel": "Canal",
|
"channelLabel": "Canal",
|
||||||
"featureLabel": "Función",
|
"featureLabel": "Función",
|
||||||
"filterAll": "Listo",
|
"filterAll": "Listo",
|
||||||
"filterPlugins": "Complementos",
|
"filterPlugins": "Complementos",
|
||||||
"filterCli": "Aplicaciones",
|
"filterCli": "Apps",
|
||||||
"filterMcp": "Integraciones",
|
"filterMcp": "Integraciones",
|
||||||
"enabledSummary": "{{count}} listos",
|
"enabledSummary": "{{count}} listos",
|
||||||
"caption": "{{cli}} aplicaciones · {{mcp}} integraciones",
|
"caption": "{{cli}} apps · {{mcp}} integraciones",
|
||||||
"searchPlaceholder": "Buscar aplicaciones",
|
"searchPlaceholder": "Buscar apps",
|
||||||
"featured": "Herramientas",
|
"featured": "Herramientas",
|
||||||
"loading": "Cargando aplicaciones...",
|
"loading": "Cargando apps...",
|
||||||
"empty": "Ninguna herramienta coincide con esta vista.",
|
"empty": "Ninguna herramienta coincide con esta vista.",
|
||||||
"restartRequired": "Reinicia nanobot para aplicar las aplicaciones y funciones actualizadas."
|
"restartRequired": "Reinicia nanobot para aplicar apps y funciones actualizadas."
|
||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
"description": "Conecta nanobot con aplicaciones de chat. Instalar el soporte solo añade el paquete de integración; la mayoría de canales aún necesitan tokens o configuración del espacio de trabajo.",
|
"description": "Conecta nanobot con apps de chat. Instalar soporte solo añade el paquete de integración; la mayoría de canales aún necesitan tokens o configuración del espacio de trabajo.",
|
||||||
"caption": "{{enabled}} activados · {{total}} canales",
|
"caption": "{{enabled}} activados · {{total}} canales",
|
||||||
"searchPlaceholder": "Buscar canales",
|
"searchPlaceholder": "Buscar canales",
|
||||||
"backToChannels": "Todos los canales",
|
"backToChannels": "Todos los canales",
|
||||||
@@ -590,8 +582,6 @@
|
|||||||
"advanced": "Avanzado",
|
"advanced": "Avanzado",
|
||||||
"checkAndEnable": "Comprobar y activar",
|
"checkAndEnable": "Comprobar y activar",
|
||||||
"checkConnection": "Comprobar conexión",
|
"checkConnection": "Comprobar conexión",
|
||||||
"connectionChecks": "Comprobaciones de conexión",
|
|
||||||
"open": "Abrir",
|
|
||||||
"checkedAndEnabled": "Comprobado y activado.",
|
"checkedAndEnabled": "Comprobado y activado.",
|
||||||
"checking": "Comprobando...",
|
"checking": "Comprobando...",
|
||||||
"checkOnly": "Solo comprobar",
|
"checkOnly": "Solo comprobar",
|
||||||
@@ -687,8 +677,6 @@
|
|||||||
"protected": "Protegida",
|
"protected": "Protegida",
|
||||||
"editTitle": "Editar automatización",
|
"editTitle": "Editar automatización",
|
||||||
"save": "Guardar",
|
"save": "Guardar",
|
||||||
"commandCopied": "Copiado",
|
|
||||||
"copyCommand": "Copiar",
|
|
||||||
"deleteTitle": "Eliminar automatización",
|
"deleteTitle": "Eliminar automatización",
|
||||||
"deleteDescription": "Esto elimina {{name}} del almacén cron. Los mensajes de chat anteriores permanecen en la sesión.",
|
"deleteDescription": "Esto elimina {{name}} del almacén cron. Los mensajes de chat anteriores permanecen en la sesión.",
|
||||||
"cancel": "Cancelar",
|
"cancel": "Cancelar",
|
||||||
@@ -748,7 +736,6 @@
|
|||||||
"fields": {
|
"fields": {
|
||||||
"name": "Nombre",
|
"name": "Nombre",
|
||||||
"message": "Mensaje",
|
"message": "Mensaje",
|
||||||
"command": "Comando",
|
|
||||||
"scheduleType": "Tipo de programación",
|
"scheduleType": "Tipo de programación",
|
||||||
"every": "Cada",
|
"every": "Cada",
|
||||||
"unit": "Unidad",
|
"unit": "Unidad",
|
||||||
@@ -814,48 +801,48 @@
|
|||||||
"customGroup": "Personalizadas",
|
"customGroup": "Personalizadas",
|
||||||
"builtinGroup": "Integradas",
|
"builtinGroup": "Integradas",
|
||||||
"otherGroup": "Otras",
|
"otherGroup": "Otras",
|
||||||
"searchInstalled": "Buscar habilidades instaladas",
|
"searchInstalled": "Buscar skills instaladas",
|
||||||
"filterAll": "Todas",
|
"filterAll": "Todas",
|
||||||
"filterEnabled": "Activadas",
|
"filterEnabled": "Activadas",
|
||||||
"filterDisabled": "Desactivadas",
|
"filterDisabled": "Desactivadas",
|
||||||
"noMatching": "No hay habilidades coincidentes.",
|
"noMatching": "No hay skills coincidentes.",
|
||||||
"statusDisabled": "Desactivada",
|
"statusDisabled": "Desactivada",
|
||||||
"statusEnabled": "Activada",
|
"statusEnabled": "Activada",
|
||||||
"statusNeedsSetup": "Requiere configuración",
|
"statusNeedsSetup": "Requiere configuración",
|
||||||
"showLess": "Mostrar menos",
|
"showLess": "Mostrar menos",
|
||||||
"showMore": "Mostrar más",
|
"showMore": "Mostrar más",
|
||||||
"enabledControl": "Usar esta habilidad",
|
"enabledControl": "Usar esta skill",
|
||||||
"enabledDescription": "Permite que el agente cargue esta habilidad cuando sus requisitos estén listos.",
|
"enabledDescription": "Permite que el agente cargue esta skill cuando sus requisitos estén listos.",
|
||||||
"enableSkill": "Activar {{name}}",
|
"enableSkill": "Activar {{name}}",
|
||||||
"disableSkill": "Desactivar {{name}}",
|
"disableSkill": "Desactivar {{name}}",
|
||||||
"updateFailed": "No se pudo actualizar esta habilidad.",
|
"updateFailed": "No se pudo actualizar esta skill.",
|
||||||
"deleteTitle": "Eliminar habilidad",
|
"deleteTitle": "Eliminar skill",
|
||||||
"deleteDescription": "Elimina esta habilidad del espacio de trabajo actual.",
|
"deleteDescription": "Elimina esta skill del espacio de trabajo actual.",
|
||||||
"deleteAction": "Eliminar",
|
"deleteAction": "Eliminar",
|
||||||
"deleteFailed": "No se pudo eliminar esta habilidad.",
|
"deleteFailed": "No se pudo eliminar esta skill.",
|
||||||
"deleteConfirmTitle": "¿Eliminar {{name}}?",
|
"deleteConfirmTitle": "¿Eliminar {{name}}?",
|
||||||
"deleteConfirmDescription": "Esto elimina los archivos de la habilidad del espacio de trabajo actual. Esta acción no se puede deshacer.",
|
"deleteConfirmDescription": "Esto elimina los archivos de la skill del espacio de trabajo actual. Esta acción no se puede deshacer.",
|
||||||
"deleteConfirmAction": "Eliminar habilidad",
|
"deleteConfirmAction": "Eliminar skill",
|
||||||
"instructionsTitle": "Instrucciones de la habilidad",
|
"instructionsTitle": "Instrucciones de la skill",
|
||||||
"setupRequired": "Requiere configuración",
|
"setupRequired": "Requiere configuración",
|
||||||
"setupDescription": "Instala la dependencia que falta en el equipo donde se ejecuta nanobot y vuelve a comprobarlo.",
|
"setupDescription": "Instala la dependencia que falta en el equipo donde se ejecuta nanobot y vuelve a comprobarlo.",
|
||||||
"copySetupCommand": "Copiar comando de configuración",
|
"copySetupCommand": "Copiar comando de configuración",
|
||||||
"checkAgain": "Comprobar de nuevo",
|
"checkAgain": "Comprobar de nuevo",
|
||||||
"marketplaceSearchFailed": "No se pudieron buscar los mercados de habilidades.",
|
"marketplaceSearchFailed": "No se pudieron buscar los mercados de skills.",
|
||||||
"marketplaceInstallFailed": "No se pudo instalar esta habilidad.",
|
"marketplaceInstallFailed": "No se pudo instalar este skill.",
|
||||||
"marketplaceSearchPlaceholder": "Buscar habilidades",
|
"marketplaceSearchPlaceholder": "Buscar skills",
|
||||||
"marketplaceSearchLabel": "Buscar habilidades",
|
"marketplaceSearchLabel": "Buscar skills",
|
||||||
"marketplaceSearching": "Buscando",
|
"marketplaceSearching": "Buscando",
|
||||||
"marketplaceProviderFilter": "Origen de la habilidad",
|
"marketplaceProviderFilter": "Origen del skill",
|
||||||
"marketplaceProviderAll": "Todos",
|
"marketplaceProviderAll": "Todos",
|
||||||
"marketplaceTrendingTitle": "Tendencias por mercado",
|
"marketplaceTrendingTitle": "Tendencias por mercado",
|
||||||
"marketplaceTrendingDescription": "Cada mercado conserva su propio ranking y métricas de instalación.",
|
"marketplaceTrendingDescription": "Cada mercado conserva su propio ranking y métricas de instalación.",
|
||||||
"marketplaceViewAll": "Ver todos",
|
"marketplaceViewAll": "Ver todos",
|
||||||
"marketplaceTrendingUnavailable": "Las habilidades populares no están disponibles temporalmente.",
|
"marketplaceTrendingUnavailable": "Los skills populares no están disponibles temporalmente.",
|
||||||
"marketplaceEmpty": "No se encontraron habilidades para “{{query}}”.",
|
"marketplaceEmpty": "No se encontraron skills para “{{query}}”.",
|
||||||
"marketplaceConfirmTitle": "¿Instalar {{name}}?",
|
"marketplaceConfirmTitle": "¿Instalar {{name}}?",
|
||||||
"marketplaceConfirmDescription": "Esta habilidad de terceros procede de {{provider}} ({{source}}) y puede incluir instrucciones o scripts ejecutables.",
|
"marketplaceConfirmDescription": "Este skill de terceros procede de {{provider}} ({{source}}) y puede incluir instrucciones o scripts ejecutables.",
|
||||||
"marketplaceConfirmInstall": "Instalar habilidad",
|
"marketplaceConfirmInstall": "Instalar skill",
|
||||||
"marketplaceOpen": "Abrir {{name}} en {{provider}}",
|
"marketplaceOpen": "Abrir {{name}} en {{provider}}",
|
||||||
"marketplaceOpenProvider": "Abrir {{provider}}",
|
"marketplaceOpenProvider": "Abrir {{provider}}",
|
||||||
"marketplaceInstalls24h": "{{formattedCount}} instalaciones / 24 h",
|
"marketplaceInstalls24h": "{{formattedCount}} instalaciones / 24 h",
|
||||||
@@ -893,7 +880,7 @@
|
|||||||
"voice": {
|
"voice": {
|
||||||
"selectProvider": "Seleccionar proveedor",
|
"selectProvider": "Seleccionar proveedor",
|
||||||
"configureProvider": "Configurar proveedor",
|
"configureProvider": "Configurar proveedor",
|
||||||
"languageAuto": "Automático"
|
"languageAuto": "Auto"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
@@ -907,34 +894,34 @@
|
|||||||
"actions": "Acciones del tema {{title}}",
|
"actions": "Acciones del tema {{title}}",
|
||||||
"newInProject": "Iniciar un tema nuevo en {{project}}",
|
"newInProject": "Iniciar un tema nuevo en {{project}}",
|
||||||
"activity": {
|
"activity": {
|
||||||
"running": "Agente en ejecución",
|
"running": "Agent running",
|
||||||
"complete": "Agente terminado",
|
"complete": "Agent finished",
|
||||||
"updated": "Nueva actividad"
|
"updated": "New activity"
|
||||||
},
|
},
|
||||||
"pin": "Fijar",
|
"pin": "Pin",
|
||||||
"unpin": "Desfijar",
|
"unpin": "Unpin",
|
||||||
"rename": "Renombrar",
|
"rename": "Rename",
|
||||||
"renameTitle": "Renombrar tema",
|
"renameTitle": "Renombrar tema",
|
||||||
"renameDescription": "Elige un nombre local de la barra lateral para este tema.",
|
"renameDescription": "Elige un nombre local de la barra lateral para este tema.",
|
||||||
"renamePlaceholder": "Nombre del tema",
|
"renamePlaceholder": "Nombre del tema",
|
||||||
"renameProjectTitle": "Renombrar proyecto",
|
"renameProjectTitle": "Rename project",
|
||||||
"renameProjectDescription": "Elige un nombre local para este proyecto en la barra lateral.",
|
"renameProjectDescription": "Choose a local sidebar name for this project.",
|
||||||
"renameProjectPlaceholder": "Nombre del proyecto",
|
"renameProjectPlaceholder": "Project name",
|
||||||
"renameSave": "Guardar",
|
"renameSave": "Save",
|
||||||
"archive": "Archivar",
|
"archive": "Archive",
|
||||||
"unarchive": "Desarchivar",
|
"unarchive": "Unarchive",
|
||||||
"showArchived": "Mostrar archivados",
|
"showArchived": "Show archived",
|
||||||
"hideArchived": "Ocultar archivados",
|
"hideArchived": "Hide archived",
|
||||||
"delete": "Eliminar",
|
"delete": "Eliminar",
|
||||||
"newChat": "Nuevo tema",
|
"newChat": "Nuevo tema",
|
||||||
"groups": {
|
"groups": {
|
||||||
"pinned": "Fijados",
|
"pinned": "Pinned",
|
||||||
"all": "Temas",
|
"all": "Temas",
|
||||||
"projects": "Proyectos",
|
"projects": "Projects",
|
||||||
"today": "Hoy",
|
"today": "Today",
|
||||||
"yesterday": "Ayer",
|
"yesterday": "Yesterday",
|
||||||
"earlier": "Anteriores",
|
"earlier": "Earlier",
|
||||||
"archived": "Archivados"
|
"archived": "Archived"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"deleteConfirm": {
|
"deleteConfirm": {
|
||||||
@@ -1000,25 +987,25 @@
|
|||||||
},
|
},
|
||||||
"more": {
|
"more": {
|
||||||
"title": "Más",
|
"title": "Más",
|
||||||
"prompt": "Muéstrame algunas formas útiles en las que puedes ayudar en este espacio de trabajo."
|
"prompt": "Muéstrame algunas formas útiles en las que puedes ayudar en este workspace."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"imageQuickActions": {
|
"imageQuickActions": {
|
||||||
"icon": {
|
"icon": {
|
||||||
"title": "Diseñar un icono de aplicación",
|
"title": "Diseñar un icono de app",
|
||||||
"prompt": "Genera un icono de aplicación 1:1 limpio para nanobot: robot amigable, estilo vectorial simple, paleta suave azul y blanca, sin texto."
|
"prompt": "Genera un icono de app 1:1 limpio para nanobot: robot amigable, estilo vectorial simple, paleta suave azul y blanca, sin texto."
|
||||||
},
|
},
|
||||||
"sticker": {
|
"sticker": {
|
||||||
"title": "Crear una pegatina",
|
"title": "Crear un sticker",
|
||||||
"prompt": "Genera una imagen estilo pegatina de un pequeño asistente robot, con fondo de apariencia transparente, expresivo y divertido."
|
"prompt": "Genera una imagen estilo sticker de un pequeño asistente robot, con fondo de apariencia transparente, expresivo y divertido."
|
||||||
},
|
},
|
||||||
"poster": {
|
"poster": {
|
||||||
"title": "Crear un póster",
|
"title": "Crear un póster",
|
||||||
"prompt": "Genera un concepto de póster pulido para un asistente personal de IA, composición moderna, jerarquía visual fuerte, apto para una página de destino."
|
"prompt": "Genera un concepto de póster pulido para un asistente personal de IA, composición moderna, jerarquía visual fuerte, apto para una landing page."
|
||||||
},
|
},
|
||||||
"product": {
|
"product": {
|
||||||
"title": "Maqueta de producto",
|
"title": "Mockup de producto",
|
||||||
"prompt": "Genera una imagen limpia de maqueta de producto para una aplicación web de IA conversacional, interfaz mínima, iluminación premium, marco de dispositivo realista."
|
"prompt": "Genera una imagen limpia de mockup de producto para una app web de IA conversacional, interfaz mínima, iluminación premium, marco de dispositivo realista."
|
||||||
},
|
},
|
||||||
"portrait": {
|
"portrait": {
|
||||||
"title": "Retrato estilizado",
|
"title": "Retrato estilizado",
|
||||||
@@ -1096,7 +1083,7 @@
|
|||||||
"aspectAria": "Relación de aspecto de imagen",
|
"aspectAria": "Relación de aspecto de imagen",
|
||||||
"aspectLabel": "Formato de imagen",
|
"aspectLabel": "Formato de imagen",
|
||||||
"aspect": {
|
"aspect": {
|
||||||
"auto": "Automático",
|
"auto": "Auto",
|
||||||
"1_1": "Cuadrado 1:1",
|
"1_1": "Cuadrado 1:1",
|
||||||
"3_4": "Vertical 3:4",
|
"3_4": "Vertical 3:4",
|
||||||
"9_16": "Historia 9:16",
|
"9_16": "Historia 9:16",
|
||||||
@@ -1139,7 +1126,7 @@
|
|||||||
},
|
},
|
||||||
"stop": {
|
"stop": {
|
||||||
"title": "Detener tarea actual",
|
"title": "Detener tarea actual",
|
||||||
"description": "Cancela el turno activo del agente en este chat."
|
"description": "Cancela el turno activo del agent en este chat."
|
||||||
},
|
},
|
||||||
"restart": {
|
"restart": {
|
||||||
"title": "Reiniciar nanobot",
|
"title": "Reiniciar nanobot",
|
||||||
@@ -1147,11 +1134,11 @@
|
|||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"title": "Mostrar estado",
|
"title": "Mostrar estado",
|
||||||
"description": "Muestra el estado del tiempo de ejecución, proveedor y canales."
|
"description": "Muestra el estado del runtime, provider y channels."
|
||||||
},
|
},
|
||||||
"model": {
|
"model": {
|
||||||
"title": "Modelo",
|
"title": "Modelo",
|
||||||
"description": "Muestra o cambia el preajuste de modelo activo."
|
"description": "Muestra o cambia el preset de modelo activo."
|
||||||
},
|
},
|
||||||
"history": {
|
"history": {
|
||||||
"title": "Mostrar historial",
|
"title": "Mostrar historial",
|
||||||
@@ -1178,8 +1165,8 @@
|
|||||||
"description": "Indica al agente que trate esto como un objetivo sostenido en varios pasos."
|
"description": "Indica al agente que trate esto como un objetivo sostenido en varios pasos."
|
||||||
},
|
},
|
||||||
"trigger": {
|
"trigger": {
|
||||||
"title": "Crear un activador local",
|
"title": "Crear trigger local",
|
||||||
"description": "Crea un activador de CLI vinculado a esta sesión de chat."
|
"description": "Crea un trigger de CLI vinculado a esta sesion de chat."
|
||||||
},
|
},
|
||||||
"help": {
|
"help": {
|
||||||
"title": "Mostrar ayuda",
|
"title": "Mostrar ayuda",
|
||||||
@@ -1203,7 +1190,7 @@
|
|||||||
},
|
},
|
||||||
"encoding": "Procesando…",
|
"encoding": "Procesando…",
|
||||||
"remove": "Quitar adjunto",
|
"remove": "Quitar adjunto",
|
||||||
"normalizedSizeHint": "{{orig}} → {{current}} (automático)",
|
"normalizedSizeHint": "{{orig}} → {{current}} (auto)",
|
||||||
"textTooLarge": "El texto del mensaje es demasiado grande (máximo {{max}})",
|
"textTooLarge": "El texto del mensaje es demasiado grande (máximo {{max}})",
|
||||||
"imageRejected": {
|
"imageRejected": {
|
||||||
"unsupported_type": "Tipo de archivo no compatible",
|
"unsupported_type": "Tipo de archivo no compatible",
|
||||||
@@ -1218,16 +1205,14 @@
|
|||||||
"io": "No se pudo leer este archivo"
|
"io": "No se pudo leer este archivo"
|
||||||
},
|
},
|
||||||
"mentions": {
|
"mentions": {
|
||||||
"ariaLabel": "Aplicaciones",
|
"ariaLabel": "Apps",
|
||||||
"label": "Aplicaciones",
|
"label": "Apps",
|
||||||
"cliGroup": "Aplicaciones CLI",
|
"cliGroup": "Apps CLI",
|
||||||
"mcpGroup": "Servicios MCP",
|
"mcpGroup": "Servicios MCP",
|
||||||
"cliBadge": "CLI",
|
"cliBadge": "CLI",
|
||||||
"mcpBadge": "MCP",
|
"mcpBadge": "MCP",
|
||||||
"cliDescription": "Usar @{{name}} como aplicación CLI local",
|
"cliDescription": "Usar @{{name}} como app CLI local",
|
||||||
"mcpDescription": "Usar @{{name}} como servidor MCP",
|
"mcpDescription": "Usar @{{name}} como servidor MCP"
|
||||||
"cliTitle": "Aplicación CLI: {{name}}",
|
|
||||||
"mcpTitle": "Servidor MCP: {{name}}"
|
|
||||||
},
|
},
|
||||||
"workspace": {
|
"workspace": {
|
||||||
"accessAria": "Modo de acceso al espacio de trabajo",
|
"accessAria": "Modo de acceso al espacio de trabajo",
|
||||||
@@ -1243,12 +1228,11 @@
|
|||||||
"loadEarlier": "Cargar mensajes anteriores",
|
"loadEarlier": "Cargar mensajes anteriores",
|
||||||
"forkedFromHistory": "Bifurcado desde el historial",
|
"forkedFromHistory": "Bifurcado desde el historial",
|
||||||
"promptNavigator": {
|
"promptNavigator": {
|
||||||
"open": "Abrir el navegador de instrucciones",
|
"open": "Abrir navegador de prompts",
|
||||||
"title": "Instrucciones",
|
"title": "Prompts",
|
||||||
"search": "Buscar instrucciones",
|
"search": "Buscar prompts",
|
||||||
"noResults": "No hay instrucciones coincidentes.",
|
"noResults": "No hay prompts coincidentes.",
|
||||||
"jumpTo": "Ir a la instrucción: {{label}}",
|
"jumpTo": "Ir al prompt: {{label}}"
|
||||||
"railAria": "Navegación por instrucciones del usuario"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
@@ -1272,27 +1256,19 @@
|
|||||||
"agentActivityLiveSummary": "En curso… · {{reasoning}} pasos · {{tools}} llamadas a herramientas",
|
"agentActivityLiveSummary": "En curso… · {{reasoning}} pasos · {{tools}} llamadas a herramientas",
|
||||||
"agentActivityLiveToolsOnly": "En curso… · {{tools}} llamadas a herramientas",
|
"agentActivityLiveToolsOnly": "En curso… · {{tools}} llamadas a herramientas",
|
||||||
"imageAttachment": "Imagen adjunta",
|
"imageAttachment": "Imagen adjunta",
|
||||||
"videoAttachment": "Archivo de vídeo",
|
|
||||||
"fileAttachment": "Archivo adjunto",
|
|
||||||
"attachmentUnavailable": "Adjunto no disponible",
|
|
||||||
"dataTable": "Tabla de datos",
|
|
||||||
"fileEditPreparing": "Preparando la edición del archivo…",
|
|
||||||
"openLink": "Abrir enlace: {{label}}",
|
|
||||||
"openAttachment": "Abrir {{name}}",
|
|
||||||
"skill": "Habilidad: {{name}}",
|
|
||||||
"askAboutSelection": "Preguntar sobre esto",
|
"askAboutSelection": "Preguntar sobre esto",
|
||||||
"forkFromHere": "Bifurcar",
|
"forkFromHere": "Bifurcar",
|
||||||
"copyReply": "Copiar",
|
"copyReply": "Copiar",
|
||||||
"copiedReply": "Copiado",
|
"copiedReply": "Copiado",
|
||||||
"turnLatencyTitle": "Tiempo de respuesta (extremo a extremo)",
|
"turnLatencyTitle": "Tiempo de respuesta (extremo a extremo)",
|
||||||
"fileEditViewDiff": "Ver diferencias",
|
"fileEditViewDiff": "Ver diff",
|
||||||
"fileEditViewLargeDiff": "Ver diferencias grandes",
|
"fileEditViewLargeDiff": "Ver diff grande",
|
||||||
"fileEditDiffLineCount": "{{count}} líneas",
|
"fileEditDiffLineCount": "{{count}} líneas",
|
||||||
"fileEditUnchangedLinesHidden": "{{count}} líneas sin cambios ocultas",
|
"fileEditUnchangedLinesHidden": "{{count}} líneas sin cambios ocultas",
|
||||||
"fileEditShowMoreLines": "Mostrar {{count}} líneas más",
|
"fileEditShowMoreLines": "Mostrar {{count}} líneas más",
|
||||||
"fileEditShowFewerLines": "Mostrar menos líneas",
|
"fileEditShowFewerLines": "Mostrar menos líneas",
|
||||||
"fileEditOpenFile": "Abrir archivo",
|
"fileEditOpenFile": "Abrir archivo",
|
||||||
"fileEditDiffTruncated": "Diferencias truncadas. Abre el archivo para ver el cambio completo.",
|
"fileEditDiffTruncated": "Diff truncado. Abre el archivo para ver el cambio completo.",
|
||||||
"activityThinkingFor": "Pensando durante {{duration}}",
|
"activityThinkingFor": "Pensando durante {{duration}}",
|
||||||
"activityThought": "Pensamiento completado",
|
"activityThought": "Pensamiento completado",
|
||||||
"activityThoughtFor": "Pensó durante {{duration}}",
|
"activityThoughtFor": "Pensó durante {{duration}}",
|
||||||
@@ -1302,9 +1278,9 @@
|
|||||||
"cliActivityRunningOne": "Usando {{name}}",
|
"cliActivityRunningOne": "Usando {{name}}",
|
||||||
"cliActivityRanOne": "Usó {{name}}",
|
"cliActivityRanOne": "Usó {{name}}",
|
||||||
"cliActivityFailedOne": "Falló {{name}}",
|
"cliActivityFailedOne": "Falló {{name}}",
|
||||||
"cliActivityRunningMany": "Usando {{count}} aplicaciones CLI",
|
"cliActivityRunningMany": "Usando {{count}} apps CLI",
|
||||||
"cliActivityRanMany": "Usó {{count}} aplicaciones CLI",
|
"cliActivityRanMany": "Usó {{count}} apps CLI",
|
||||||
"cliActivityFailedMany": "Fallaron {{count}} aplicaciones CLI",
|
"cliActivityFailedMany": "Fallaron {{count}} apps CLI",
|
||||||
"cliRunRunning": "Usando",
|
"cliRunRunning": "Usando",
|
||||||
"cliRunRan": "Usado",
|
"cliRunRan": "Usado",
|
||||||
"cliRunFailed": "Falló",
|
"cliRunFailed": "Falló",
|
||||||
@@ -1320,7 +1296,6 @@
|
|||||||
},
|
},
|
||||||
"filePreview": {
|
"filePreview": {
|
||||||
"aria": "Vista previa de archivo",
|
"aria": "Vista previa de archivo",
|
||||||
"breadcrumb": "Ruta del archivo",
|
|
||||||
"close": "Cerrar vista previa de archivo",
|
"close": "Cerrar vista previa de archivo",
|
||||||
"loading": "Cargando vista previa...",
|
"loading": "Cargando vista previa...",
|
||||||
"failed": "No se pudo previsualizar este archivo.",
|
"failed": "No se pudo previsualizar este archivo.",
|
||||||
@@ -1335,10 +1310,7 @@
|
|||||||
"copied": "Copiado"
|
"copied": "Copiado"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"dismiss": "Cerrar",
|
"dismiss": "Cerrar"
|
||||||
"close": "Cerrar",
|
|
||||||
"current": "Actual",
|
|
||||||
"cancel": "Cancelar"
|
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"messageTooBig": {
|
"messageTooBig": {
|
||||||
|
|||||||
@@ -38,20 +38,12 @@
|
|||||||
},
|
},
|
||||||
"meta": {
|
"meta": {
|
||||||
"description": "Interface web nanobot — discutez avec votre espace de travail nanobot."
|
"description": "Interface web nanobot — discutez avec votre espace de travail nanobot."
|
||||||
},
|
|
||||||
"pairing": {
|
|
||||||
"title": "Associer un utilisateur du chat",
|
|
||||||
"description": "Saisissez le code d’association affiché dans le chat.",
|
|
||||||
"code": "Code d’association",
|
|
||||||
"matched": "Correspondance {{channel}}. Connexion...",
|
|
||||||
"expiresInline": "Le code expire {{expires}}.",
|
|
||||||
"queueCount": "{{count}} en attente",
|
|
||||||
"noMatch": "Aucune demande en attente ne correspond à ce code."
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"navigation": "Navigation de la barre latérale",
|
"navigation": "Navigation de la barre latérale",
|
||||||
"collapse": "Réduire la barre latérale",
|
"collapse": "Réduire la barre latérale",
|
||||||
|
"quickChat": "Discussion rapide",
|
||||||
"newChat": "Nouveau sujet",
|
"newChat": "Nouveau sujet",
|
||||||
"searchAria": "Rechercher",
|
"searchAria": "Rechercher",
|
||||||
"searchPlaceholder": "Rechercher",
|
"searchPlaceholder": "Rechercher",
|
||||||
@@ -63,12 +55,23 @@
|
|||||||
"label": "Langue",
|
"label": "Langue",
|
||||||
"ariaLabel": "Changer de langue"
|
"ariaLabel": "Changer de langue"
|
||||||
},
|
},
|
||||||
"apps": "Applications",
|
"apps": "Apps",
|
||||||
"automations": "Automatisations",
|
"automations": "Automatisations",
|
||||||
"skills": {
|
"skills": {
|
||||||
"title": "Compétences"
|
"title": "Compétences"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"quickChat": {
|
||||||
|
"greeting": "De quoi avez-vous envie de parler ?",
|
||||||
|
"temporary": {
|
||||||
|
"title": "Discussion temporaire",
|
||||||
|
"enter": "Temporaire",
|
||||||
|
"active": "Temporaire",
|
||||||
|
"exit": "Quitter la discussion temporaire",
|
||||||
|
"greeting": "Démarrer une discussion temporaire",
|
||||||
|
"description": "Aucun historique, mémoire, outil ou accès au projet. Le contenu est transmis au fournisseur du modèle choisi."
|
||||||
|
}
|
||||||
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"backToChat": "Retour au chat",
|
"backToChat": "Retour au chat",
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
@@ -88,7 +91,7 @@
|
|||||||
"channels": "Canaux",
|
"channels": "Canaux",
|
||||||
"runtime": "Système",
|
"runtime": "Système",
|
||||||
"advanced": "Sécurité",
|
"advanced": "Sécurité",
|
||||||
"cliApps": "Applications CLI",
|
"cliApps": "Apps CLI",
|
||||||
"mcp": "MCP",
|
"mcp": "MCP",
|
||||||
"apps": "Applications",
|
"apps": "Applications",
|
||||||
"automations": "Automatisations",
|
"automations": "Automatisations",
|
||||||
@@ -106,7 +109,7 @@
|
|||||||
"imageDefaults": "Valeurs par défaut",
|
"imageDefaults": "Valeurs par défaut",
|
||||||
"webSearch": "Recherche web",
|
"webSearch": "Recherche web",
|
||||||
"webBehavior": "Comportement",
|
"webBehavior": "Comportement",
|
||||||
"regional": "Paramètres régionaux",
|
"identity": "Identité",
|
||||||
"webuiSafety": "Sécurité WebUI",
|
"webuiSafety": "Sécurité WebUI",
|
||||||
"capabilities": "Capacités",
|
"capabilities": "Capacités",
|
||||||
"cliApps": "Applications CLI",
|
"cliApps": "Applications CLI",
|
||||||
@@ -145,6 +148,8 @@
|
|||||||
"defaultImageSize": "Taille par défaut",
|
"defaultImageSize": "Taille par défaut",
|
||||||
"maxImagesPerTurn": "Images max. par tour",
|
"maxImagesPerTurn": "Images max. par tour",
|
||||||
"imageSaveDir": "Dossier d’enregistrement",
|
"imageSaveDir": "Dossier d’enregistrement",
|
||||||
|
"botName": "Nom du bot",
|
||||||
|
"botIcon": "Icône du bot",
|
||||||
"timezone": "Fuseau horaire",
|
"timezone": "Fuseau horaire",
|
||||||
"workspacePath": "Espace de travail par défaut",
|
"workspacePath": "Espace de travail par défaut",
|
||||||
"localServiceAccess": "Services locaux",
|
"localServiceAccess": "Services locaux",
|
||||||
@@ -159,8 +164,8 @@
|
|||||||
"contextWindow": "Fenêtre de contexte",
|
"contextWindow": "Fenêtre de contexte",
|
||||||
"transcription": "Transcription",
|
"transcription": "Transcription",
|
||||||
"transcriptionProvider": "Fournisseur",
|
"transcriptionProvider": "Fournisseur",
|
||||||
"transcriptionProviderStatus": "État du fournisseur",
|
"transcriptionProviderStatus": "Etat du fournisseur",
|
||||||
"transcriptionModel": "Modèle",
|
"transcriptionModel": "Modele",
|
||||||
"transcriptionLanguage": "Langue",
|
"transcriptionLanguage": "Langue",
|
||||||
"voiceLimits": "Limites"
|
"voiceLimits": "Limites"
|
||||||
},
|
},
|
||||||
@@ -171,10 +176,10 @@
|
|||||||
"model": "Choisissez le modèle utilisé par ce préréglage.",
|
"model": "Choisissez le modèle utilisé par ce préréglage.",
|
||||||
"configPath": "Le fichier de configuration actuellement utilisé par la passerelle.",
|
"configPath": "Le fichier de configuration actuellement utilisé par la passerelle.",
|
||||||
"selectedPreset": "Les préréglages nommés sont en lecture seule ici ; modifiez-les dans config.json.",
|
"selectedPreset": "Les préréglages nommés sont en lecture seule ici ; modifiez-les dans config.json.",
|
||||||
"presetModel": "Passez à la valeur par défaut pour modifier le modèle et le fournisseur depuis la WebUI.",
|
"presetModel": "Passez à Default pour modifier le modèle et le fournisseur depuis WebUI.",
|
||||||
"density": "Enregistré seulement dans ce navigateur.",
|
"density": "Enregistré seulement dans ce navigateur.",
|
||||||
"activityMode": "Choisissez le niveau de détail de l’activité de l’agent affiché par défaut.",
|
"activityMode": "Choisissez le niveau de détail d’activité agent affiché par défaut.",
|
||||||
"fileEditDisplay": "Choisissez si l’activité de modification affiche le nombre de lignes ou les différences.",
|
"fileEditDisplay": "Choisissez si l’activité de modification affiche le nombre de lignes ou le diff.",
|
||||||
"codeWrap": "Garde les longues lignes de code lisibles sur les petits écrans.",
|
"codeWrap": "Garde les longues lignes de code lisibles sur les petits écrans.",
|
||||||
"maxResults": "Résultats renvoyés par chaque appel web_search.",
|
"maxResults": "Résultats renvoyés par chaque appel web_search.",
|
||||||
"timeout": "Nombre de secondes avant l’expiration d’une requête de recherche.",
|
"timeout": "Nombre de secondes avant l’expiration d’une requête de recherche.",
|
||||||
@@ -183,28 +188,30 @@
|
|||||||
"imageProvider": "Choisissez le fournisseur inscrit utilisé par generate_image.",
|
"imageProvider": "Choisissez le fournisseur inscrit utilisé par generate_image.",
|
||||||
"imageProviderStatus": "La génération d’images réutilise les identifiants des fournisseurs.",
|
"imageProviderStatus": "La génération d’images réutilise les identifiants des fournisseurs.",
|
||||||
"imageModel": "Nom du modèle envoyé au fournisseur d’images sélectionné.",
|
"imageModel": "Nom du modèle envoyé au fournisseur d’images sélectionné.",
|
||||||
"defaultAspectRatio": "Utilisé lorsque l’instruction ne choisit pas de ratio.",
|
"defaultAspectRatio": "Utilisé lorsque le prompt ne choisit pas de ratio.",
|
||||||
"defaultImageSize": "Indication de taille envoyée aux fournisseurs compatibles.",
|
"defaultImageSize": "Indication de taille envoyée aux fournisseurs compatibles.",
|
||||||
"maxImagesPerTurn": "Limite supérieure pour une requête generate_image.",
|
"maxImagesPerTurn": "Limite supérieure pour une requête generate_image.",
|
||||||
|
"botName": "Affiché là où nanobot utilise un nom visible.",
|
||||||
|
"botIcon": "Emoji ou texte court affiché avec le nom du bot.",
|
||||||
"timezone": "Utilisé pour les horaires et les réponses tenant compte du temps.",
|
"timezone": "Utilisé pour les horaires et les réponses tenant compte du temps.",
|
||||||
"localServiceAccess": "Autorise les commandes shell avec accès complet à atteindre les services localhost.",
|
"localServiceAccess": "Autorise les commandes shell Full Access à atteindre les services localhost.",
|
||||||
"webuiDefaultAccess": "Utilisé par les chats web sans permission propre au projet.",
|
"webuiDefaultAccess": "Utilisé par les chats web sans permission propre au projet.",
|
||||||
"securityManagedControls": "Les récupérations web protègent toujours les services locaux, privés et de métadonnées. La sécurité des canaux principaux reste gérée dans config.json.",
|
"securityManagedControls": "Les récupérations web protègent toujours les services locaux, privés et de métadonnées. La sécurité des canaux principaux reste gérée dans config.json.",
|
||||||
"currentModel": "Utilisée pour les nouvelles réponses.",
|
"currentModel": "Utilisée pour les nouvelles réponses.",
|
||||||
"selectedModelProvider": "Défini par le modèle sélectionné.",
|
"selectedModelProvider": "Défini par le modèle sélectionné.",
|
||||||
"selectedModelValue": "Défini par le modèle sélectionné.",
|
"selectedModelValue": "Défini par le modèle sélectionné.",
|
||||||
"brandLogos": "Affiche les logos de fournisseurs tiers et CLI dans les Réglages.",
|
"brandLogos": "Affiche les logos de fournisseurs tiers et CLI dans les Réglages.",
|
||||||
"cliAppsCatalog": "Installe uniquement les adaptateurs CLI d’applications que nanobot peut exécuter localement ; les applications natives restent inchangées.",
|
"cliAppsCatalog": "Installe uniquement les adaptateurs CLI d’apps que nanobot peut exécuter localement ; les apps natives restent inchangées.",
|
||||||
"cliAppsFilter": "Recherchez par application, catégorie ou capacité.",
|
"cliAppsFilter": "Recherchez par app, catégorie ou capacité.",
|
||||||
"logs": "Ouvre le dossier des journaux du moteur natif.",
|
"logs": "Ouvre le dossier des journaux du moteur natif.",
|
||||||
"diagnostics": "Exporte un petit rapport d’exécution pour le support.",
|
"diagnostics": "Exporte un petit rapport d’exécution pour le support.",
|
||||||
"localServiceAccessNative": "Autorise les commandes shell avec accès complet à atteindre les services sur ce Mac.",
|
"localServiceAccessNative": "Autorise les commandes shell Full Access à atteindre les services sur ce Mac.",
|
||||||
"webuiDefaultAccessNative": "Utilisé par les chats natifs sans permission propre au projet.",
|
"webuiDefaultAccessNative": "Utilisé par les chats natifs sans permission propre au projet.",
|
||||||
"contextWindow": "Choisissez le budget de contexte par défaut pour cette configuration de modèle.",
|
"contextWindow": "Choisissez le budget de contexte par défaut pour cette configuration de modèle.",
|
||||||
"transcription": "Transcrit l’entrée du micro avant l’envoi. Les messages vocaux des canaux utilisent les mêmes réglages.",
|
"transcription": "Transcrit l'entree micro avant l'envoi. Les messages vocaux des canaux utilisent les memes reglages.",
|
||||||
"transcriptionProvider": "Utilise les identifiants du fournisseur correspondant dans la section Fournisseurs.",
|
"transcriptionProvider": "Utilise les identifiants du fournisseur correspondant dans Fournisseurs.",
|
||||||
"transcriptionProviderStatus": "Les clés API restent dans les fournisseurs, pas dans les réglages de transcription.",
|
"transcriptionProviderStatus": "Les cles API restent dans les fournisseurs, pas dans les reglages de transcription.",
|
||||||
"transcriptionModel": "Laissez le modèle résolu par défaut, sauf si votre fournisseur exige un identifiant personnalisé.",
|
"transcriptionModel": "Laissez le modele resolu par defaut sauf si votre fournisseur exige un id personnalise.",
|
||||||
"transcriptionLanguage": "Indice ISO-639 facultatif, comme en, zh, ja ou ko."
|
"transcriptionLanguage": "Indice ISO-639 facultatif, comme en, zh, ja ou ko."
|
||||||
},
|
},
|
||||||
"values": {
|
"values": {
|
||||||
@@ -224,7 +231,7 @@
|
|||||||
"expanded": "Développé",
|
"expanded": "Développé",
|
||||||
"default": "Par défaut",
|
"default": "Par défaut",
|
||||||
"summary": "Résumé",
|
"summary": "Résumé",
|
||||||
"diff": "Différences",
|
"diff": "Diff",
|
||||||
"collapsedDiff": "Diff replié",
|
"collapsedDiff": "Diff replié",
|
||||||
"on": "Activé",
|
"on": "Activé",
|
||||||
"off": "Désactivé",
|
"off": "Désactivé",
|
||||||
@@ -233,10 +240,7 @@
|
|||||||
"configured": "Configuré",
|
"configured": "Configuré",
|
||||||
"notConfigured": "Non configuré",
|
"notConfigured": "Non configuré",
|
||||||
"pending": "En attente",
|
"pending": "En attente",
|
||||||
"restartingEngine": "Redémarrage",
|
"restartingEngine": "Redémarrage"
|
||||||
"checking": "Vérification",
|
|
||||||
"running": "En cours",
|
|
||||||
"needsSetup": "Configuration requise"
|
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "Chargement des réglages...",
|
"loading": "Chargement des réglages...",
|
||||||
@@ -264,7 +268,6 @@
|
|||||||
"deleting": "Suppression...",
|
"deleting": "Suppression...",
|
||||||
"edit": "Modifier",
|
"edit": "Modifier",
|
||||||
"cancel": "Annuler",
|
"cancel": "Annuler",
|
||||||
"dismiss": "Ignorer",
|
|
||||||
"open": "Ouvrir",
|
"open": "Ouvrir",
|
||||||
"export": "Exporter",
|
"export": "Exporter",
|
||||||
"opening": "Ouverture...",
|
"opening": "Ouverture...",
|
||||||
@@ -278,15 +281,15 @@
|
|||||||
"notConfiguredSection": "Non configurés",
|
"notConfiguredSection": "Non configurés",
|
||||||
"showMore": "Afficher {{count}} de plus",
|
"showMore": "Afficher {{count}} de plus",
|
||||||
"showLess": "Afficher moins",
|
"showLess": "Afficher moins",
|
||||||
"apiKey": "Clé API",
|
"apiKey": "API key",
|
||||||
"apiBase": "URL de base de l’API",
|
"apiBase": "API base",
|
||||||
"apiKeyPlaceholder": "Saisir la clé API",
|
"apiKeyPlaceholder": "Saisir l'API key",
|
||||||
"apiKeyConfiguredPlaceholder": "Laisser vide pour conserver la clé actuelle",
|
"apiKeyConfiguredPlaceholder": "Laisser vide pour conserver la key actuelle",
|
||||||
"configuredKeyHint": "Key configurée",
|
"configuredKeyHint": "Key configurée",
|
||||||
"apiBasePlaceholder": "Utiliser la valeur par défaut du fournisseur",
|
"apiBasePlaceholder": "Utiliser la valeur par défaut du fournisseur",
|
||||||
"apiKeyRequired": "Une clé API est requise pour configurer ce fournisseur.",
|
"apiKeyRequired": "Une API key est requise pour configurer ce fournisseur.",
|
||||||
"showApiKey": "Afficher la clé API",
|
"showApiKey": "Afficher l'API key",
|
||||||
"hideApiKey": "Masquer la clé API",
|
"hideApiKey": "Masquer l'API key",
|
||||||
"noConfiguredProviders": "Aucun fournisseur configuré",
|
"noConfiguredProviders": "Aucun fournisseur configuré",
|
||||||
"configureFirst": "Configurez d'abord un fournisseur dans BYOK.",
|
"configureFirst": "Configurez d'abord un fournisseur dans BYOK.",
|
||||||
"openByok": "Ouvrir BYOK",
|
"openByok": "Ouvrir BYOK",
|
||||||
@@ -297,19 +300,19 @@
|
|||||||
},
|
},
|
||||||
"webSearch": {
|
"webSearch": {
|
||||||
"provider": "Fournisseur de recherche",
|
"provider": "Fournisseur de recherche",
|
||||||
"providerHelp": "Choisissez le service utilisé par l’outil de recherche web.",
|
"providerHelp": "Choisissez le backend utilisé par l'outil web search.",
|
||||||
"selectProvider": "Choisir un fournisseur",
|
"selectProvider": "Choisir un fournisseur",
|
||||||
"credentials": "Identifiants",
|
"credentials": "Identifiants",
|
||||||
"noCredentialRequired": "Aucune clé requise",
|
"noCredentialRequired": "Aucune key requise",
|
||||||
"noCredentialHelp": "DuckDuckGo fonctionne sans clé API enregistrée.",
|
"noCredentialHelp": "DuckDuckGo fonctionne sans API key enregistrée.",
|
||||||
"apiKeyHelp": "Enregistrée dans la config et masquée après l'enregistrement.",
|
"apiKeyHelp": "Enregistrée dans la config et masquée après l'enregistrement.",
|
||||||
"baseUrl": "URL de base",
|
"baseUrl": "Base URL",
|
||||||
"baseUrlHelp": "SearXNG nécessite l'URL de votre propre instance.",
|
"baseUrlHelp": "SearXNG nécessite l'URL de votre propre instance.",
|
||||||
"baseUrlPlaceholder": "https://search.example.com",
|
"baseUrlPlaceholder": "https://search.example.com",
|
||||||
"apiKeyRequired": "Ce fournisseur de recherche nécessite une clé API.",
|
"apiKeyRequired": "Ce fournisseur de recherche nécessite une API key.",
|
||||||
"baseUrlRequired": "SearXNG nécessite une URL de base.",
|
"baseUrlRequired": "SearXNG nécessite une Base URL.",
|
||||||
"missingCredential": "Ajoutez l'identifiant requis avant d'enregistrer.",
|
"missingCredential": "Ajoutez l'identifiant requis avant d'enregistrer.",
|
||||||
"saveHint": "Les changements s’appliquent aux nouvelles requêtes de recherche web."
|
"saveHint": "Les changements s'appliquent aux nouvelles requêtes web search."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"overview": {
|
"overview": {
|
||||||
@@ -324,7 +327,7 @@
|
|||||||
},
|
},
|
||||||
"usage": {
|
"usage": {
|
||||||
"title": "Activité des tokens",
|
"title": "Activité des tokens",
|
||||||
"shortTitle": "Utilisation des tokens",
|
"shortTitle": "Token Usage",
|
||||||
"subtitle": "Usage signalé par le fournisseur sur les 12 derniers mois.",
|
"subtitle": "Usage signalé par le fournisseur sur les 12 derniers mois.",
|
||||||
"empty": "L’activité des tokens apparaîtra après les nouvelles réponses du modèle.",
|
"empty": "L’activité des tokens apparaîtra après les nouvelles réponses du modèle.",
|
||||||
"totalTokens": "Tokens cumulés",
|
"totalTokens": "Tokens cumulés",
|
||||||
@@ -371,18 +374,8 @@
|
|||||||
"selectProvider": "Choisir un fournisseur",
|
"selectProvider": "Choisir un fournisseur",
|
||||||
"selectAspect": "Choisir un ratio",
|
"selectAspect": "Choisir un ratio",
|
||||||
"selectSize": "Choisir une taille",
|
"selectSize": "Choisir une taille",
|
||||||
"selectModel": "Choisir un modèle d’image",
|
|
||||||
"searchOrTypeModel": "Rechercher ou saisir l’ID du modèle",
|
|
||||||
"typeModelId": "Saisissez l’ID de modèle pris en charge par ce fournisseur.",
|
|
||||||
"configureProvider": "Configurer le fournisseur",
|
"configureProvider": "Configurer le fournisseur",
|
||||||
"missingCredential": "Configurez ce fournisseur avant d’activer la génération d’images."
|
"missingCredential": "Configura este proveedor antes de activar la generación de imágenes."
|
||||||
},
|
|
||||||
"capabilities": {
|
|
||||||
"providerSupport": "Prise en charge du fournisseur",
|
|
||||||
"providerInstallOnSave": "La prise en charge requise sera installée automatiquement lors de l’enregistrement de ce fournisseur.",
|
|
||||||
"searchSupport": "Prise en charge du fournisseur de recherche",
|
|
||||||
"searchInstallOnSave": "La prise en charge d’Olostep sera installée automatiquement lors de l’enregistrement.",
|
|
||||||
"installing": "Installation de la prise en charge..."
|
|
||||||
},
|
},
|
||||||
"models": {
|
"models": {
|
||||||
"selectModel": "Choisir un modèle",
|
"selectModel": "Choisir un modèle",
|
||||||
@@ -456,8 +449,8 @@
|
|||||||
"statusUnsupported": "Non compatible",
|
"statusUnsupported": "Non compatible",
|
||||||
"statusNotInstalled": "Non installée",
|
"statusNotInstalled": "Non installée",
|
||||||
"unsupported": "Non compatible",
|
"unsupported": "Non compatible",
|
||||||
"loading": "Chargement des applications CLI...",
|
"loading": "Chargement des apps CLI...",
|
||||||
"empty": "Aucune application CLI ne correspond à ce filtre.",
|
"empty": "Aucune app CLI ne correspond à ce filtre.",
|
||||||
"readyTitle": "@{{name}} est prêt",
|
"readyTitle": "@{{name}} est prêt",
|
||||||
"readyStatus": "Prêt",
|
"readyStatus": "Prêt",
|
||||||
"readyPrompt": "Utilisez @{{name}} pour voir ce que ce CLI peut faire.",
|
"readyPrompt": "Utilisez @{{name}} pour voir ce que ce CLI peut faire.",
|
||||||
@@ -481,11 +474,11 @@
|
|||||||
},
|
},
|
||||||
"mcp": {
|
"mcp": {
|
||||||
"allCategories": "Toutes les catégories",
|
"allCategories": "Toutes les catégories",
|
||||||
"summary": "{{installed}} préréglages activés sur {{total}}",
|
"summary": "{{installed}} presets activés sur {{total}}",
|
||||||
"filterAll": "Tout",
|
"filterAll": "Tout",
|
||||||
"filterInstalled": "Activés",
|
"filterInstalled": "Activés",
|
||||||
"filterNotInstalled": "Non activés",
|
"filterNotInstalled": "Non activés",
|
||||||
"searchPlaceholder": "Rechercher des préréglages MCP",
|
"searchPlaceholder": "Rechercher des presets MCP",
|
||||||
"moreOptions": "Plus d'options MCP",
|
"moreOptions": "Plus d'options MCP",
|
||||||
"moreOptionsSubtitle": "Ajoutez un serveur personnalisé ou importez mcp.json.",
|
"moreOptionsSubtitle": "Ajoutez un serveur personnalisé ou importez mcp.json.",
|
||||||
"customTitle": "MCP personnalisé",
|
"customTitle": "MCP personnalisé",
|
||||||
@@ -496,9 +489,9 @@
|
|||||||
"serverUrl": "URL",
|
"serverUrl": "URL",
|
||||||
"transport": "Transport",
|
"transport": "Transport",
|
||||||
"command": "Commande",
|
"command": "Commande",
|
||||||
"args": "Arguments JSON",
|
"args": "Args JSON",
|
||||||
"headers": "En-têtes JSON",
|
"headers": "Headers JSON",
|
||||||
"env": "Environnement JSON",
|
"env": "Env JSON",
|
||||||
"timeout": "Délai d'outil",
|
"timeout": "Délai d'outil",
|
||||||
"advancedOptions": "Options avancées",
|
"advancedOptions": "Options avancées",
|
||||||
"hideAdvanced": "Masquer les options avancées",
|
"hideAdvanced": "Masquer les options avancées",
|
||||||
@@ -507,8 +500,8 @@
|
|||||||
"importConfig": "Importer",
|
"importConfig": "Importer",
|
||||||
"restartRequired": "Redémarrez nanobot pour connecter les outils MCP mis à jour.",
|
"restartRequired": "Redémarrez nanobot pour connecter les outils MCP mis à jour.",
|
||||||
"toolsFound": "{{count}} outils",
|
"toolsFound": "{{count}} outils",
|
||||||
"loading": "Chargement des préréglages MCP...",
|
"loading": "Chargement des presets MCP...",
|
||||||
"empty": "Aucun préréglage MCP ne correspond à ce filtre.",
|
"empty": "Aucun preset MCP ne correspond à ce filtre.",
|
||||||
"openDocs": "Ouvrir la doc",
|
"openDocs": "Ouvrir la doc",
|
||||||
"test": "Tester",
|
"test": "Tester",
|
||||||
"remove": "Supprimer",
|
"remove": "Supprimer",
|
||||||
@@ -526,7 +519,6 @@
|
|||||||
"statusMissingCredentials": "Clé requise",
|
"statusMissingCredentials": "Clé requise",
|
||||||
"statusMissingDependency": "Dépendance requise",
|
"statusMissingDependency": "Dépendance requise",
|
||||||
"statusComingSoon": "Bientôt disponible",
|
"statusComingSoon": "Bientôt disponible",
|
||||||
"comingSoon": "Bientôt disponible",
|
|
||||||
"statusNotInstalled": "Non activé",
|
"statusNotInstalled": "Non activé",
|
||||||
"toolScope": "Outils",
|
"toolScope": "Outils",
|
||||||
"allTools": "Tous",
|
"allTools": "Tous",
|
||||||
@@ -551,24 +543,24 @@
|
|||||||
},
|
},
|
||||||
"apps": {
|
"apps": {
|
||||||
"description": "Ajoutez des outils à nanobot, puis utilisez-les avec @ dans le chat.",
|
"description": "Ajoutez des outils à nanobot, puis utilisez-les avec @ dans le chat.",
|
||||||
"cliLabel": "Application",
|
"cliLabel": "App",
|
||||||
"mcpLabel": "Intégration",
|
"mcpLabel": "Intégration",
|
||||||
"channelLabel": "Canal",
|
"channelLabel": "Canal",
|
||||||
"featureLabel": "Fonction",
|
"featureLabel": "Fonction",
|
||||||
"filterAll": "Prêts",
|
"filterAll": "Prêts",
|
||||||
"filterPlugins": "Extensions",
|
"filterPlugins": "Extensions",
|
||||||
"filterCli": "Applications",
|
"filterCli": "Apps",
|
||||||
"filterMcp": "Intégrations",
|
"filterMcp": "Intégrations",
|
||||||
"enabledSummary": "{{count}} prêts",
|
"enabledSummary": "{{count}} prêts",
|
||||||
"caption": "{{cli}} applications · {{mcp}} intégrations",
|
"caption": "{{cli}} apps · {{mcp}} intégrations",
|
||||||
"searchPlaceholder": "Rechercher des applications",
|
"searchPlaceholder": "Rechercher des apps",
|
||||||
"featured": "Outils",
|
"featured": "Outils",
|
||||||
"loading": "Chargement des applications...",
|
"loading": "Chargement des apps...",
|
||||||
"empty": "Aucun outil ne correspond à cette vue.",
|
"empty": "Aucun outil ne correspond à cette vue.",
|
||||||
"restartRequired": "Redémarrez nanobot pour appliquer les applications et fonctions mises à jour."
|
"restartRequired": "Redémarrez nanobot pour appliquer les apps et fonctions mises à jour."
|
||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
"description": "Connectez nanobot aux applications de discussion. L'installation du support ajoute seulement le paquet d'intégration ; la plupart des canaux nécessitent encore des jetons ou des réglages d'espace de travail.",
|
"description": "Connectez nanobot aux apps de discussion. L'installation du support ajoute seulement le paquet d'intégration ; la plupart des canaux nécessitent encore des tokens ou des réglages d'espace de travail.",
|
||||||
"caption": "{{enabled}} activés · {{total}} canaux",
|
"caption": "{{enabled}} activés · {{total}} canaux",
|
||||||
"searchPlaceholder": "Rechercher des canaux",
|
"searchPlaceholder": "Rechercher des canaux",
|
||||||
"backToChannels": "Tous les canaux",
|
"backToChannels": "Tous les canaux",
|
||||||
@@ -589,8 +581,6 @@
|
|||||||
"advanced": "Avancé",
|
"advanced": "Avancé",
|
||||||
"checkAndEnable": "Vérifier et activer",
|
"checkAndEnable": "Vérifier et activer",
|
||||||
"checkConnection": "Vérifier la connexion",
|
"checkConnection": "Vérifier la connexion",
|
||||||
"connectionChecks": "Vérifications de connexion",
|
|
||||||
"open": "Ouvrir",
|
|
||||||
"checkedAndEnabled": "Vérifié et activé.",
|
"checkedAndEnabled": "Vérifié et activé.",
|
||||||
"checking": "Vérification...",
|
"checking": "Vérification...",
|
||||||
"checkOnly": "Vérifier uniquement",
|
"checkOnly": "Vérifier uniquement",
|
||||||
@@ -686,8 +676,6 @@
|
|||||||
"protected": "Protégée",
|
"protected": "Protégée",
|
||||||
"editTitle": "Modifier l’automatisation",
|
"editTitle": "Modifier l’automatisation",
|
||||||
"save": "Enregistrer",
|
"save": "Enregistrer",
|
||||||
"commandCopied": "Copié",
|
|
||||||
"copyCommand": "Copier",
|
|
||||||
"deleteTitle": "Supprimer l’automatisation",
|
"deleteTitle": "Supprimer l’automatisation",
|
||||||
"deleteDescription": "Cela supprime {{name}} du stockage cron. Les anciens messages de chat restent dans la session.",
|
"deleteDescription": "Cela supprime {{name}} du stockage cron. Les anciens messages de chat restent dans la session.",
|
||||||
"cancel": "Annuler",
|
"cancel": "Annuler",
|
||||||
@@ -747,7 +735,6 @@
|
|||||||
"fields": {
|
"fields": {
|
||||||
"name": "Nom",
|
"name": "Nom",
|
||||||
"message": "Message",
|
"message": "Message",
|
||||||
"command": "Commande",
|
|
||||||
"scheduleType": "Type de planning",
|
"scheduleType": "Type de planning",
|
||||||
"every": "Toutes les",
|
"every": "Toutes les",
|
||||||
"unit": "Unité",
|
"unit": "Unité",
|
||||||
@@ -782,7 +769,7 @@
|
|||||||
"signInAgain": "Se reconnecter",
|
"signInAgain": "Se reconnecter",
|
||||||
"signOut": "Se déconnecter",
|
"signOut": "Se déconnecter",
|
||||||
"signedInAs": "Connecté en tant que {{account}}",
|
"signedInAs": "Connecté en tant que {{account}}",
|
||||||
"signInHelp": "Connectez-vous depuis cet appareil ; aucune clé API n’est enregistrée dans la configuration.",
|
"signInHelp": "Inicia sesión desde este dispositivo; no se guarda API key en config.",
|
||||||
"remoteSignInHelp": "Sélectionnez Se connecter pour ouvrir xAI sur votre ordinateur, puis collez le code d’autorisation affiché après la connexion.",
|
"remoteSignInHelp": "Sélectionnez Se connecter pour ouvrir xAI sur votre ordinateur, puis collez le code d’autorisation affiché après la connexion.",
|
||||||
"codexRemoteSignInHelp": "Connectez-vous dans ce navigateur, puis recollez dans nanobot l’URL complète de rappel localhost.",
|
"codexRemoteSignInHelp": "Connectez-vous dans ce navigateur, puis recollez dans nanobot l’URL complète de rappel localhost.",
|
||||||
"signInRequired": "Connexion requise",
|
"signInRequired": "Connexion requise",
|
||||||
@@ -865,7 +852,7 @@
|
|||||||
"marketplaceInstall": "Installer",
|
"marketplaceInstall": "Installer",
|
||||||
"marketplaceNoTrend": "Pas encore de tendance",
|
"marketplaceNoTrend": "Pas encore de tendance",
|
||||||
"marketplaceTrendLabel": "Tendance des installations sur 8 semaines",
|
"marketplaceTrendLabel": "Tendance des installations sur 8 semaines",
|
||||||
"featured": "Compétences de l’agent",
|
"featured": "Compétences agent",
|
||||||
"empty": "Aucune compétence disponible.",
|
"empty": "Aucune compétence disponible.",
|
||||||
"sourceWorkspace": "Personnalisée",
|
"sourceWorkspace": "Personnalisée",
|
||||||
"sourceBuiltin": "Intégrée",
|
"sourceBuiltin": "Intégrée",
|
||||||
@@ -892,7 +879,7 @@
|
|||||||
"voice": {
|
"voice": {
|
||||||
"selectProvider": "Choisir un fournisseur",
|
"selectProvider": "Choisir un fournisseur",
|
||||||
"configureProvider": "Configurer le fournisseur",
|
"configureProvider": "Configurer le fournisseur",
|
||||||
"languageAuto": "Automatique"
|
"languageAuto": "Auto"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
@@ -906,34 +893,34 @@
|
|||||||
"actions": "Actions du sujet {{title}}",
|
"actions": "Actions du sujet {{title}}",
|
||||||
"newInProject": "Démarrer un nouveau sujet dans {{project}}",
|
"newInProject": "Démarrer un nouveau sujet dans {{project}}",
|
||||||
"activity": {
|
"activity": {
|
||||||
"running": "Agent en cours",
|
"running": "Agent running",
|
||||||
"complete": "Agent terminé",
|
"complete": "Agent finished",
|
||||||
"updated": "Nouvelle activité"
|
"updated": "New activity"
|
||||||
},
|
},
|
||||||
"pin": "Épingler",
|
"pin": "Pin",
|
||||||
"unpin": "Désépingler",
|
"unpin": "Unpin",
|
||||||
"rename": "Renommer",
|
"rename": "Rename",
|
||||||
"renameTitle": "Renommer le sujet",
|
"renameTitle": "Renommer le sujet",
|
||||||
"renameDescription": "Choisissez un nom local dans la barre latérale pour ce sujet.",
|
"renameDescription": "Choisissez un nom local dans la barre latérale pour ce sujet.",
|
||||||
"renamePlaceholder": "Nom du sujet",
|
"renamePlaceholder": "Nom du sujet",
|
||||||
"renameProjectTitle": "Renommer le projet",
|
"renameProjectTitle": "Rename project",
|
||||||
"renameProjectDescription": "Choisissez un nom local dans la barre latérale pour ce projet.",
|
"renameProjectDescription": "Choose a local sidebar name for this project.",
|
||||||
"renameProjectPlaceholder": "Nom du projet",
|
"renameProjectPlaceholder": "Project name",
|
||||||
"renameSave": "Enregistrer",
|
"renameSave": "Save",
|
||||||
"archive": "Archiver",
|
"archive": "Archive",
|
||||||
"unarchive": "Désarchiver",
|
"unarchive": "Unarchive",
|
||||||
"showArchived": "Afficher les archives",
|
"showArchived": "Show archived",
|
||||||
"hideArchived": "Masquer les archives",
|
"hideArchived": "Hide archived",
|
||||||
"delete": "Supprimer",
|
"delete": "Supprimer",
|
||||||
"newChat": "Nouveau sujet",
|
"newChat": "Nouveau sujet",
|
||||||
"groups": {
|
"groups": {
|
||||||
"pinned": "Épinglés",
|
"pinned": "Pinned",
|
||||||
"all": "Sujets",
|
"all": "Sujets",
|
||||||
"projects": "Projets",
|
"projects": "Projects",
|
||||||
"today": "Aujourd’hui",
|
"today": "Today",
|
||||||
"yesterday": "Hier",
|
"yesterday": "Yesterday",
|
||||||
"earlier": "Plus anciens",
|
"earlier": "Earlier",
|
||||||
"archived": "Archivés"
|
"archived": "Archived"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"deleteConfirm": {
|
"deleteConfirm": {
|
||||||
@@ -1009,11 +996,11 @@
|
|||||||
},
|
},
|
||||||
"sticker": {
|
"sticker": {
|
||||||
"title": "Créer un sticker",
|
"title": "Créer un sticker",
|
||||||
"prompt": "Générez une image façon autocollant d’un petit assistant robot, avec un fond d’apparence transparente, expressive et ludique."
|
"prompt": "Générez une image façon sticker d’un petit assistant robot, avec un fond d’apparence transparente, expressive et ludique."
|
||||||
},
|
},
|
||||||
"poster": {
|
"poster": {
|
||||||
"title": "Créer une affiche",
|
"title": "Créer une affiche",
|
||||||
"prompt": "Générez un concept d’affiche soigné pour un assistant IA personnel, composition moderne, hiérarchie visuelle forte, adapté à une page de destination."
|
"prompt": "Générez un concept d’affiche soigné pour un assistant IA personnel, composition moderne, hiérarchie visuelle forte, adapté à une landing page."
|
||||||
},
|
},
|
||||||
"product": {
|
"product": {
|
||||||
"title": "Maquette produit",
|
"title": "Maquette produit",
|
||||||
@@ -1095,7 +1082,7 @@
|
|||||||
"aspectAria": "Format de l’image",
|
"aspectAria": "Format de l’image",
|
||||||
"aspectLabel": "Format de l’image",
|
"aspectLabel": "Format de l’image",
|
||||||
"aspect": {
|
"aspect": {
|
||||||
"auto": "Automatique",
|
"auto": "Auto",
|
||||||
"1_1": "Carré 1:1",
|
"1_1": "Carré 1:1",
|
||||||
"3_4": "Portrait 3:4",
|
"3_4": "Portrait 3:4",
|
||||||
"9_16": "Story 9:16",
|
"9_16": "Story 9:16",
|
||||||
@@ -1138,7 +1125,7 @@
|
|||||||
},
|
},
|
||||||
"stop": {
|
"stop": {
|
||||||
"title": "Arrêter la tâche en cours",
|
"title": "Arrêter la tâche en cours",
|
||||||
"description": "Annuler le tour actif de l’agent pour cette discussion."
|
"description": "Annuler le tour agent actif pour cette discussion."
|
||||||
},
|
},
|
||||||
"restart": {
|
"restart": {
|
||||||
"title": "Redémarrer nanobot",
|
"title": "Redémarrer nanobot",
|
||||||
@@ -1146,7 +1133,7 @@
|
|||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"title": "Afficher l’état",
|
"title": "Afficher l’état",
|
||||||
"description": "Afficher l’état du temps d’exécution, du fournisseur et des canaux."
|
"description": "Afficher l’état du runtime, du provider et des channels."
|
||||||
},
|
},
|
||||||
"model": {
|
"model": {
|
||||||
"title": "Modèle",
|
"title": "Modèle",
|
||||||
@@ -1177,8 +1164,8 @@
|
|||||||
"description": "Demandez à l’agent de traiter ceci comme un objectif multi‑étapes durable."
|
"description": "Demandez à l’agent de traiter ceci comme un objectif multi‑étapes durable."
|
||||||
},
|
},
|
||||||
"trigger": {
|
"trigger": {
|
||||||
"title": "Créer un déclencheur local",
|
"title": "Créer un trigger local",
|
||||||
"description": "Crée un déclencheur CLI lié à cette session de chat."
|
"description": "Crée un trigger CLI lié à cette session de chat."
|
||||||
},
|
},
|
||||||
"help": {
|
"help": {
|
||||||
"title": "Afficher l’aide",
|
"title": "Afficher l’aide",
|
||||||
@@ -1202,7 +1189,7 @@
|
|||||||
},
|
},
|
||||||
"encoding": "Traitement…",
|
"encoding": "Traitement…",
|
||||||
"remove": "Retirer la pièce jointe",
|
"remove": "Retirer la pièce jointe",
|
||||||
"normalizedSizeHint": "{{orig}} → {{current}} (automatique)",
|
"normalizedSizeHint": "{{orig}} → {{current}} (auto)",
|
||||||
"textTooLarge": "Le texte du message est trop volumineux (maximum {{max}})",
|
"textTooLarge": "Le texte du message est trop volumineux (maximum {{max}})",
|
||||||
"imageRejected": {
|
"imageRejected": {
|
||||||
"unsupported_type": "Type de fichier non pris en charge",
|
"unsupported_type": "Type de fichier non pris en charge",
|
||||||
@@ -1217,16 +1204,14 @@
|
|||||||
"io": "Impossible de lire ce fichier"
|
"io": "Impossible de lire ce fichier"
|
||||||
},
|
},
|
||||||
"mentions": {
|
"mentions": {
|
||||||
"ariaLabel": "Applications",
|
"ariaLabel": "Apps",
|
||||||
"label": "Applications",
|
"label": "Apps",
|
||||||
"cliGroup": "Applications CLI",
|
"cliGroup": "Apps CLI",
|
||||||
"mcpGroup": "Services MCP",
|
"mcpGroup": "Services MCP",
|
||||||
"cliBadge": "CLI",
|
"cliBadge": "CLI",
|
||||||
"mcpBadge": "MCP",
|
"mcpBadge": "MCP",
|
||||||
"cliDescription": "Utiliser @{{name}} comme application CLI locale",
|
"cliDescription": "Utiliser @{{name}} comme app CLI locale",
|
||||||
"mcpDescription": "Utiliser @{{name}} comme serveur MCP",
|
"mcpDescription": "Utiliser @{{name}} comme serveur MCP"
|
||||||
"cliTitle": "Application CLI : {{name}}",
|
|
||||||
"mcpTitle": "Serveur MCP : {{name}}"
|
|
||||||
},
|
},
|
||||||
"workspace": {
|
"workspace": {
|
||||||
"accessAria": "Mode d’accès à l’espace de travail",
|
"accessAria": "Mode d’accès à l’espace de travail",
|
||||||
@@ -1242,12 +1227,11 @@
|
|||||||
"loadEarlier": "Charger les messages précédents",
|
"loadEarlier": "Charger les messages précédents",
|
||||||
"forkedFromHistory": "Bifurqué depuis l'historique",
|
"forkedFromHistory": "Bifurqué depuis l'historique",
|
||||||
"promptNavigator": {
|
"promptNavigator": {
|
||||||
"open": "Ouvrir le navigateur d’instructions",
|
"open": "Ouvrir le navigateur de prompts",
|
||||||
"title": "Instructions",
|
"title": "Prompts",
|
||||||
"search": "Rechercher des instructions",
|
"search": "Rechercher des prompts",
|
||||||
"noResults": "Aucune instruction correspondante.",
|
"noResults": "Aucun prompt correspondant.",
|
||||||
"jumpTo": "Aller à l’instruction : {{label}}",
|
"jumpTo": "Aller au prompt : {{label}}"
|
||||||
"railAria": "Navigation dans les instructions utilisateur"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
@@ -1271,27 +1255,19 @@
|
|||||||
"agentActivityLiveSummary": "En cours… · {{reasoning}} étapes · {{tools}} appels d’outils",
|
"agentActivityLiveSummary": "En cours… · {{reasoning}} étapes · {{tools}} appels d’outils",
|
||||||
"agentActivityLiveToolsOnly": "En cours… · {{tools}} appels d’outils",
|
"agentActivityLiveToolsOnly": "En cours… · {{tools}} appels d’outils",
|
||||||
"imageAttachment": "Pièce jointe image",
|
"imageAttachment": "Pièce jointe image",
|
||||||
"videoAttachment": "Pièce jointe vidéo",
|
|
||||||
"fileAttachment": "Pièce jointe",
|
|
||||||
"attachmentUnavailable": "Pièce jointe indisponible",
|
|
||||||
"dataTable": "Tableau de données",
|
|
||||||
"fileEditPreparing": "Préparation de la modification du fichier…",
|
|
||||||
"openLink": "Ouvrir le lien : {{label}}",
|
|
||||||
"openAttachment": "Ouvrir {{name}}",
|
|
||||||
"skill": "Compétence : {{name}}",
|
|
||||||
"askAboutSelection": "Poser une question à ce sujet",
|
"askAboutSelection": "Poser une question à ce sujet",
|
||||||
"forkFromHere": "Bifurquer",
|
"forkFromHere": "Bifurquer",
|
||||||
"copyReply": "Copier",
|
"copyReply": "Copier",
|
||||||
"copiedReply": "Copié",
|
"copiedReply": "Copié",
|
||||||
"turnLatencyTitle": "Temps de réponse (de bout en bout)",
|
"turnLatencyTitle": "Temps de réponse (de bout en bout)",
|
||||||
"fileEditViewDiff": "Voir les différences",
|
"fileEditViewDiff": "Voir le diff",
|
||||||
"fileEditViewLargeDiff": "Voir les grandes différences",
|
"fileEditViewLargeDiff": "Voir le grand diff",
|
||||||
"fileEditDiffLineCount": "{{count}} lignes",
|
"fileEditDiffLineCount": "{{count}} lignes",
|
||||||
"fileEditUnchangedLinesHidden": "{{count}} lignes inchangées masquées",
|
"fileEditUnchangedLinesHidden": "{{count}} lignes inchangées masquées",
|
||||||
"fileEditShowMoreLines": "Afficher {{count}} lignes de plus",
|
"fileEditShowMoreLines": "Afficher {{count}} lignes de plus",
|
||||||
"fileEditShowFewerLines": "Afficher moins de lignes",
|
"fileEditShowFewerLines": "Afficher moins de lignes",
|
||||||
"fileEditOpenFile": "Ouvrir le fichier",
|
"fileEditOpenFile": "Ouvrir le fichier",
|
||||||
"fileEditDiffTruncated": "Différences tronquées. Ouvrez le fichier pour voir la modification complète.",
|
"fileEditDiffTruncated": "Diff tronqué. Ouvrez le fichier pour voir la modification complète.",
|
||||||
"activityThinkingFor": "Réflexion pendant {{duration}}",
|
"activityThinkingFor": "Réflexion pendant {{duration}}",
|
||||||
"activityThought": "Réflexion terminée",
|
"activityThought": "Réflexion terminée",
|
||||||
"activityThoughtFor": "Réflexion terminée en {{duration}}",
|
"activityThoughtFor": "Réflexion terminée en {{duration}}",
|
||||||
@@ -1301,9 +1277,9 @@
|
|||||||
"cliActivityRunningOne": "Utilisation de {{name}}",
|
"cliActivityRunningOne": "Utilisation de {{name}}",
|
||||||
"cliActivityRanOne": "{{name}} utilisé",
|
"cliActivityRanOne": "{{name}} utilisé",
|
||||||
"cliActivityFailedOne": "Échec de {{name}}",
|
"cliActivityFailedOne": "Échec de {{name}}",
|
||||||
"cliActivityRunningMany": "Utilisation de {{count}} applications CLI",
|
"cliActivityRunningMany": "Utilisation de {{count}} apps CLI",
|
||||||
"cliActivityRanMany": "{{count}} applications CLI utilisées",
|
"cliActivityRanMany": "{{count}} apps CLI utilisées",
|
||||||
"cliActivityFailedMany": "Échec de {{count}} applications CLI",
|
"cliActivityFailedMany": "Échec de {{count}} apps CLI",
|
||||||
"cliRunRunning": "Utilisation",
|
"cliRunRunning": "Utilisation",
|
||||||
"cliRunRan": "Utilisé",
|
"cliRunRan": "Utilisé",
|
||||||
"cliRunFailed": "Échec",
|
"cliRunFailed": "Échec",
|
||||||
@@ -1319,7 +1295,6 @@
|
|||||||
},
|
},
|
||||||
"filePreview": {
|
"filePreview": {
|
||||||
"aria": "Aperçu du fichier",
|
"aria": "Aperçu du fichier",
|
||||||
"breadcrumb": "Chemin du fichier",
|
|
||||||
"close": "Fermer l’aperçu du fichier",
|
"close": "Fermer l’aperçu du fichier",
|
||||||
"loading": "Chargement de l’aperçu...",
|
"loading": "Chargement de l’aperçu...",
|
||||||
"failed": "Impossible de prévisualiser ce fichier.",
|
"failed": "Impossible de prévisualiser ce fichier.",
|
||||||
@@ -1334,10 +1309,7 @@
|
|||||||
"copied": "Copié"
|
"copied": "Copié"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"dismiss": "Fermer",
|
"dismiss": "Fermer"
|
||||||
"close": "Fermer",
|
|
||||||
"current": "Actuel",
|
|
||||||
"cancel": "Annuler"
|
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"messageTooBig": {
|
"messageTooBig": {
|
||||||
|
|||||||
@@ -26,8 +26,8 @@
|
|||||||
"restartHint": "Mulai ulang nanobot untuk menerapkan perubahan runtime.",
|
"restartHint": "Mulai ulang nanobot untuk menerapkan perubahan runtime.",
|
||||||
"restart": "Mulai ulang nanobot",
|
"restart": "Mulai ulang nanobot",
|
||||||
"restarting": "Memulai ulang...",
|
"restarting": "Memulai ulang...",
|
||||||
"restartEngine": "Mulai ulang mesin",
|
"restartEngine": "Mulai ulang engine",
|
||||||
"restartingEngine": "Memulai ulang mesin..."
|
"restartingEngine": "Memulai ulang engine..."
|
||||||
},
|
},
|
||||||
"restart": {
|
"restart": {
|
||||||
"completed": "Mulai ulang selesai dalam {{seconds}} dtk."
|
"completed": "Mulai ulang selesai dalam {{seconds}} dtk."
|
||||||
@@ -37,21 +37,13 @@
|
|||||||
"chat": "{{title}} · nanobot"
|
"chat": "{{title}} · nanobot"
|
||||||
},
|
},
|
||||||
"meta": {
|
"meta": {
|
||||||
"description": "UI web nanobot — ngobrol dengan ruang kerja nanobot Anda."
|
"description": "UI web nanobot — ngobrol dengan workspace nanobot Anda."
|
||||||
},
|
|
||||||
"pairing": {
|
|
||||||
"title": "Hubungkan pengguna chat",
|
|
||||||
"description": "Masukkan kode pairing yang ditampilkan di chat.",
|
|
||||||
"code": "Kode pairing",
|
|
||||||
"matched": "Cocok dengan {{channel}}. Menghubungkan...",
|
|
||||||
"expiresInline": "Kode kedaluwarsa {{expires}}.",
|
|
||||||
"queueCount": "{{count}} menunggu",
|
|
||||||
"noMatch": "Tidak ada permintaan tertunda yang cocok dengan kode ini."
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"navigation": "Navigasi bilah samping",
|
"navigation": "Navigasi bilah samping",
|
||||||
"collapse": "Ciutkan sidebar",
|
"collapse": "Ciutkan sidebar",
|
||||||
|
"quickChat": "Obrolan cepat",
|
||||||
"newChat": "Topik baru",
|
"newChat": "Topik baru",
|
||||||
"searchAria": "Cari",
|
"searchAria": "Cari",
|
||||||
"searchPlaceholder": "Cari",
|
"searchPlaceholder": "Cari",
|
||||||
@@ -66,7 +58,18 @@
|
|||||||
"apps": "Aplikasi",
|
"apps": "Aplikasi",
|
||||||
"automations": "Otomasi",
|
"automations": "Otomasi",
|
||||||
"skills": {
|
"skills": {
|
||||||
"title": "Keterampilan"
|
"title": "Skill"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"quickChat": {
|
||||||
|
"greeting": "Apa yang sedang kamu pikirkan?",
|
||||||
|
"temporary": {
|
||||||
|
"title": "Obrolan sementara",
|
||||||
|
"enter": "Sementara",
|
||||||
|
"active": "Sementara",
|
||||||
|
"exit": "Keluar dari obrolan sementara",
|
||||||
|
"greeting": "Mulai obrolan sementara",
|
||||||
|
"description": "Tanpa riwayat, memori, alat, atau akses proyek. Konten tetap dikirim ke penyedia model pilihan Anda."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
@@ -92,7 +95,7 @@
|
|||||||
"mcp": "MCP",
|
"mcp": "MCP",
|
||||||
"apps": "Aplikasi",
|
"apps": "Aplikasi",
|
||||||
"automations": "Otomasi",
|
"automations": "Otomasi",
|
||||||
"skills": "Keterampilan"
|
"skills": "Skill"
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"interface": "Antarmuka",
|
"interface": "Antarmuka",
|
||||||
@@ -101,18 +104,18 @@
|
|||||||
"about": "Tentang",
|
"about": "Tentang",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
"localPreferences": "Preferensi lokal",
|
"localPreferences": "Preferensi lokal",
|
||||||
"presets": "Prasetel",
|
"presets": "Preset",
|
||||||
"imageGeneration": "Pembuatan gambar",
|
"imageGeneration": "Pembuatan gambar",
|
||||||
"imageDefaults": "Bawaan",
|
"imageDefaults": "Default",
|
||||||
"webSearch": "Pencarian web",
|
"webSearch": "Pencarian web",
|
||||||
"webBehavior": "Perilaku",
|
"webBehavior": "Perilaku",
|
||||||
"regional": "Regional",
|
"identity": "Identitas",
|
||||||
"webuiSafety": "Keamanan WebUI",
|
"webuiSafety": "Keamanan WebUI",
|
||||||
"capabilities": "Kemampuan",
|
"capabilities": "Kemampuan",
|
||||||
"cliApps": "Aplikasi CLI",
|
"cliApps": "Aplikasi CLI",
|
||||||
"mcp": "Layanan MCP",
|
"mcp": "Layanan MCP",
|
||||||
"apps": "Aplikasi",
|
"apps": "Aplikasi",
|
||||||
"nativeHost": "Host asli",
|
"nativeHost": "Host native",
|
||||||
"hostSafety": "Keamanan aplikasi",
|
"hostSafety": "Keamanan aplikasi",
|
||||||
"voiceInput": "Input suara"
|
"voiceInput": "Input suara"
|
||||||
},
|
},
|
||||||
@@ -123,15 +126,15 @@
|
|||||||
"model": "Model",
|
"model": "Model",
|
||||||
"restart": "Mulai ulang nanobot",
|
"restart": "Mulai ulang nanobot",
|
||||||
"configPath": "Path konfigurasi",
|
"configPath": "Path konfigurasi",
|
||||||
"activePreset": "Prasetel aktif",
|
"activePreset": "Preset aktif",
|
||||||
"gateway": "Gerbang",
|
"gateway": "Gerbang",
|
||||||
"restartState": "Status mulai ulang",
|
"restartState": "Status mulai ulang",
|
||||||
"pendingChanges": "Perubahan tertunda",
|
"pendingChanges": "Perubahan tertunda",
|
||||||
"selectedPreset": "Prasetel terpilih",
|
"selectedPreset": "Preset terpilih",
|
||||||
"presetModel": "Model prasetel",
|
"presetModel": "Model preset",
|
||||||
"density": "Kerapatan",
|
"density": "Kerapatan",
|
||||||
"activityMode": "Detail aktivitas",
|
"activityMode": "Detail aktivitas",
|
||||||
"fileEditDisplay": "Tampilan perubahan file",
|
"fileEditDisplay": "Tampilan edit file",
|
||||||
"codeWrap": "Bungkus kode",
|
"codeWrap": "Bungkus kode",
|
||||||
"maxResults": "Hasil maksimum",
|
"maxResults": "Hasil maksimum",
|
||||||
"timeout": "Batas waktu",
|
"timeout": "Batas waktu",
|
||||||
@@ -141,14 +144,16 @@
|
|||||||
"imageProviderStatus": "Status penyedia",
|
"imageProviderStatus": "Status penyedia",
|
||||||
"imageProviderBase": "Basis penyedia",
|
"imageProviderBase": "Basis penyedia",
|
||||||
"imageModel": "Model gambar",
|
"imageModel": "Model gambar",
|
||||||
"defaultAspectRatio": "Rasio bawaan",
|
"defaultAspectRatio": "Rasio default",
|
||||||
"defaultImageSize": "Ukuran bawaan",
|
"defaultImageSize": "Ukuran default",
|
||||||
"maxImagesPerTurn": "Maks. gambar per giliran",
|
"maxImagesPerTurn": "Maks. gambar per giliran",
|
||||||
"imageSaveDir": "Direktori simpan",
|
"imageSaveDir": "Direktori simpan",
|
||||||
|
"botName": "Nama bot",
|
||||||
|
"botIcon": "Ikon bot",
|
||||||
"timezone": "Zona waktu",
|
"timezone": "Zona waktu",
|
||||||
"workspacePath": "Ruang kerja bawaan",
|
"workspacePath": "Workspace default",
|
||||||
"localServiceAccess": "Layanan lokal",
|
"localServiceAccess": "Layanan lokal",
|
||||||
"webuiDefaultAccess": "Akses bawaan",
|
"webuiDefaultAccess": "Akses default",
|
||||||
"currentModel": "Konfigurasi saat ini",
|
"currentModel": "Konfigurasi saat ini",
|
||||||
"brandLogos": "Logo merek",
|
"brandLogos": "Logo merek",
|
||||||
"cliAppsCatalog": "Katalog",
|
"cliAppsCatalog": "Katalog",
|
||||||
@@ -167,44 +172,46 @@
|
|||||||
"help": {
|
"help": {
|
||||||
"theme": "Beralih antara tampilan terang dan gelap.",
|
"theme": "Beralih antara tampilan terang dan gelap.",
|
||||||
"language": "Pilih bahasa yang digunakan WebUI.",
|
"language": "Pilih bahasa yang digunakan WebUI.",
|
||||||
"provider": "Pilih penyedia untuk permintaan model baru.",
|
"provider": "Selecciona el proveedor para nuevas solicitudes de modelo.",
|
||||||
"model": "Pilih model yang digunakan oleh prasetel ini.",
|
"model": "Pilih model yang digunakan oleh preset ini.",
|
||||||
"configPath": "File konfigurasi gateway yang sedang digunakan.",
|
"configPath": "Archivo de configuración que usa actualmente el gateway.",
|
||||||
"selectedPreset": "Prasetel bernama hanya-baca di sini; ubah di config.json.",
|
"selectedPreset": "Los preajustes con nombre son de solo lectura aquí; edítalos en config.json.",
|
||||||
"presetModel": "Beralih ke Bawaan untuk mengubah model dan penyedia dari WebUI.",
|
"presetModel": "Beralih ke Default untuk mengedit model dan penyedia dari WebUI.",
|
||||||
"density": "Hanya disimpan di browser ini.",
|
"density": "Hanya disimpan di browser ini.",
|
||||||
"activityMode": "Pilih seberapa banyak detail aktivitas agen yang ditampilkan secara default.",
|
"activityMode": "Pilih seberapa banyak detail aktivitas agen yang ditampilkan secara default.",
|
||||||
"fileEditDisplay": "Pilih apakah aktivitas perubahan file ditampilkan sebagai jumlah baris atau perbedaan.",
|
"fileEditDisplay": "Pilih aktivitas edit file dibuka sebagai jumlah baris atau diff.",
|
||||||
"codeWrap": "Menjaga baris kode panjang tetap terbaca di layar kecil.",
|
"codeWrap": "Menjaga baris kode panjang tetap terbaca di layar kecil.",
|
||||||
"maxResults": "Hasil yang dikembalikan oleh setiap panggilan web_search.",
|
"maxResults": "Resultados devueltos por cada llamada web_search.",
|
||||||
"timeout": "Detik sebelum permintaan penyedia pencarian mencapai batas waktu.",
|
"timeout": "Segundos antes de que una solicitud de búsqueda expire.",
|
||||||
"jinaReader": "Gunakan Jina Reader untuk web_fetch jika tersedia.",
|
"jinaReader": "Usa Jina Reader para web_fetch cuando esté disponible.",
|
||||||
"imageGeneration": "Tampilkan generate_image di chat saat penyedia gambar yang dikonfigurasi tersedia.",
|
"imageGeneration": "Expone generate_image en chats cuando hay un proveedor de imagen configurado.",
|
||||||
"imageProvider": "Pilih penyedia registry yang digunakan oleh generate_image.",
|
"imageProvider": "Elige el proveedor registrado usado por generate_image.",
|
||||||
"imageProviderStatus": "Pembuatan gambar menggunakan kembali kredensial penyedia dari bagian Penyedia.",
|
"imageProviderStatus": "La generación de imágenes reutiliza credenciales de Proveedores.",
|
||||||
"imageModel": "Nama model yang dikirim ke penyedia gambar yang dipilih.",
|
"imageModel": "Nombre del modelo enviado al proveedor de imágenes seleccionado.",
|
||||||
"defaultAspectRatio": "Digunakan saat instruksi tidak memilih rasio aspek.",
|
"defaultAspectRatio": "Se usa cuando el prompt no elige una proporción.",
|
||||||
"defaultImageSize": "Petunjuk ukuran yang dikirim ke penyedia yang mendukungnya.",
|
"defaultImageSize": "Petunjuk ukuran yang dikirim ke penyedia yang mendukungnya.",
|
||||||
"maxImagesPerTurn": "Batas atas untuk satu permintaan generate_image.",
|
"maxImagesPerTurn": "Batas atas untuk satu permintaan generate_image.",
|
||||||
"timezone": "Dipakai untuk jadwal dan balasan yang peka waktu.",
|
"botName": "Se muestra donde nanobot usa un nombre visible.",
|
||||||
"localServiceAccess": "Izinkan perintah shell dengan akses penuh menjangkau layanan lokal.",
|
"botIcon": "Emoji o texto corto junto al nombre del bot.",
|
||||||
|
"timezone": "Se usa para horarios y respuestas con conciencia temporal.",
|
||||||
|
"localServiceAccess": "Izinkan perintah shell Full Access menjangkau layanan localhost.",
|
||||||
"webuiDefaultAccess": "Digunakan oleh chat web tanpa izin khusus proyek.",
|
"webuiDefaultAccess": "Digunakan oleh chat web tanpa izin khusus proyek.",
|
||||||
"securityManagedControls": "Pengambilan web selalu melindungi layanan lokal, privat, dan metadata. Keamanan kanal inti tetap dikelola di config.json.",
|
"securityManagedControls": "Las capturas web siempre protegen servicios locales, privados y metadata. La seguridad de canales core se gestiona en config.json.",
|
||||||
"currentModel": "Digunakan untuk balasan baru.",
|
"currentModel": "Digunakan untuk balasan baru.",
|
||||||
"selectedModelProvider": "Ditentukan oleh model yang dipilih.",
|
"selectedModelProvider": "Definido por el modelo seleccionado.",
|
||||||
"selectedModelValue": "Ditentukan oleh model yang dipilih.",
|
"selectedModelValue": "Definido por el modelo seleccionado.",
|
||||||
"brandLogos": "Tampilkan logo penyedia pihak ketiga dan CLI di Pengaturan.",
|
"brandLogos": "Tampilkan logo penyedia pihak ketiga dan CLI di Pengaturan.",
|
||||||
"cliAppsCatalog": "Instal hanya adaptor CLI aplikasi yang dapat dijalankan nanobot secara lokal; aplikasi asli tidak diubah.",
|
"cliAppsCatalog": "Instala solo adaptadores CLI de apps que nanobot puede ejecutar localmente; las apps nativas no se modifican.",
|
||||||
"cliAppsFilter": "Cari berdasarkan aplikasi, kategori, atau kemampuan.",
|
"cliAppsFilter": "Busca por app, categoría o capacidad.",
|
||||||
"logs": "Buka folder log mesin asli.",
|
"logs": "Abre la carpeta de registros del motor nativo.",
|
||||||
"diagnostics": "Ekspor laporan waktu proses singkat untuk dukungan.",
|
"diagnostics": "Exporta un pequeño informe de runtime para soporte.",
|
||||||
"localServiceAccessNative": "Izinkan perintah shell dengan akses penuh mengakses layanan di Mac ini.",
|
"localServiceAccessNative": "Permite que comandos shell con Full Access alcancen servicios en este Mac.",
|
||||||
"webuiDefaultAccessNative": "Digunakan oleh chat bawaan tanpa izin khusus proyek.",
|
"webuiDefaultAccessNative": "Usado por chats nativos sin permiso específico de proyecto.",
|
||||||
"contextWindow": "Pilih anggaran konteks bawaan untuk konfigurasi model ini.",
|
"contextWindow": "Pilih anggaran konteks default untuk konfigurasi model ini.",
|
||||||
"transcription": "Transkripsikan input mikrofon sebelum dikirim. Pesan suara kanal memakai pengaturan yang sama.",
|
"transcription": "Transkripsikan input mikrofon sebelum dikirim. Pesan suara channel memakai pengaturan yang sama.",
|
||||||
"transcriptionProvider": "Menggunakan kredensial penyedia yang sesuai dari Penyedia.",
|
"transcriptionProvider": "Menggunakan kredensial penyedia yang sesuai dari Providers.",
|
||||||
"transcriptionProviderStatus": "Kunci API tetap berada di bagian penyedia, bukan di pengaturan transkripsi.",
|
"transcriptionProviderStatus": "API key tetap berada di providers, bukan di pengaturan transkripsi.",
|
||||||
"transcriptionModel": "Biarkan memakai bawaan yang ter-resolve kecuali penyedia membutuhkan ID model khusus.",
|
"transcriptionModel": "Biarkan memakai default yang teresolusi kecuali penyedia membutuhkan id model khusus.",
|
||||||
"transcriptionLanguage": "Petunjuk ISO-639 opsional, seperti en, zh, ja, atau ko."
|
"transcriptionLanguage": "Petunjuk ISO-639 opsional, seperti en, zh, ja, atau ko."
|
||||||
},
|
},
|
||||||
"values": {
|
"values": {
|
||||||
@@ -217,78 +224,74 @@
|
|||||||
"ready": "Siap",
|
"ready": "Siap",
|
||||||
"privateEngine": "Mesin privat",
|
"privateEngine": "Mesin privat",
|
||||||
"unixSocket": "Soket Unix",
|
"unixSocket": "Soket Unix",
|
||||||
"defaultWorkspace": "Ruang kerja bawaan",
|
"defaultWorkspace": "Workspace default",
|
||||||
"comfortable": "Nyaman",
|
"comfortable": "Nyaman",
|
||||||
"compact": "Ringkas",
|
"compact": "Ringkas",
|
||||||
"auto": "Otomatis",
|
"auto": "Otomatis",
|
||||||
"expanded": "Diperluas",
|
"expanded": "Diperluas",
|
||||||
"default": "Bawaan",
|
"default": "Default",
|
||||||
"summary": "Ringkasan",
|
"summary": "Ringkasan",
|
||||||
"diff": "Perbedaan",
|
"diff": "Diff",
|
||||||
"collapsedDiff": "Perbedaan diciutkan",
|
"collapsedDiff": "Diff diciutkan",
|
||||||
"on": "Aktif",
|
"on": "Aktif",
|
||||||
"off": "Nonaktif",
|
"off": "Nonaktif",
|
||||||
"defaultPermission": "Izin bawaan",
|
"defaultPermission": "Izin default",
|
||||||
"fullAccess": "Akses penuh",
|
"fullAccess": "Akses penuh",
|
||||||
"configured": "Terkonfigurasi",
|
"configured": "Terkonfigurasi",
|
||||||
"notConfigured": "Belum dikonfigurasi",
|
"notConfigured": "Belum dikonfigurasi",
|
||||||
"pending": "Tertunda",
|
"pending": "Tertunda",
|
||||||
"restartingEngine": "Memulai ulang",
|
"restartingEngine": "Memulai ulang"
|
||||||
"checking": "Memeriksa",
|
|
||||||
"running": "Berjalan",
|
|
||||||
"needsSetup": "Perlu penyiapan"
|
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "Memuat pengaturan...",
|
"loading": "Memuat pengaturan...",
|
||||||
"loadError": "Tidak dapat memuat pengaturan",
|
"loadError": "Tidak dapat memuat pengaturan",
|
||||||
"unsaved": "Perubahan belum disimpan.",
|
"unsaved": "Perubahan belum disimpan.",
|
||||||
"upToDate": "Sudah terbaru.",
|
"upToDate": "Sudah terbaru.",
|
||||||
"savedRestart": "Tersimpan. Mulai ulang nanobot untuk menerapkan.",
|
"savedRestart": "Guardado. Reinicia nanobot para aplicar.",
|
||||||
"restartAfterSaving": "Simpan perubahan, lalu mulai ulang saat siap.",
|
"restartAfterSaving": "Guarda los cambios y reinicia cuando puedas.",
|
||||||
"savedRestartApply": "Tersimpan. Mulai ulang saat siap.",
|
"savedRestartApply": "Guardado. Reinicia cuando puedas.",
|
||||||
"imageProviderRestart": "Perubahan penyedia gambar tersimpan. Mulai ulang saat siap.",
|
"imageProviderRestart": "Cambios del proveedor de imagen guardados. Reinicia cuando puedas.",
|
||||||
"hostRestartAfterSaving": "Saat disimpan, nanobot akan memulai ulang mesinnya.",
|
"hostRestartAfterSaving": "Al guardar, nanobot reiniciará su motor.",
|
||||||
"hostRestartPending": "Tersimpan. Mesin akan dimulai ulang saat siap.",
|
"hostRestartPending": "Guardado. El motor se reiniciará cuando esté listo.",
|
||||||
"hostApiUnavailable": "Tindakan host hanya tersedia di aplikasi asli.",
|
"hostApiUnavailable": "Las acciones del host solo están disponibles en la app nativa.",
|
||||||
"logsOpened": "Folder log dibuka.",
|
"logsOpened": "Carpeta de registros abierta.",
|
||||||
"logsOpenFailed": "Tidak dapat membuka folder log.",
|
"logsOpenFailed": "No se pudo abrir la carpeta de registros.",
|
||||||
"diagnosticsExported": "Diagnostik diekspor ke {{path}}.",
|
"diagnosticsExported": "Diagnóstico exportado a {{path}}.",
|
||||||
"diagnosticsExportFailed": "Tidak dapat mengekspor diagnostik."
|
"diagnosticsExportFailed": "No se pudo exportar el diagnóstico."
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"save": "Simpan",
|
"save": "Simpan",
|
||||||
"saving": "Menyimpan",
|
"saving": "Menyimpan",
|
||||||
"saveOrder": "Simpan urutan",
|
"saveOrder": "Simpan urutan",
|
||||||
"savePreset": "Simpan prasetel",
|
"savePreset": "Simpan preset",
|
||||||
"delete": "Hapus",
|
"delete": "Hapus",
|
||||||
"deleting": "Menghapus...",
|
"deleting": "Menghapus...",
|
||||||
"edit": "Ubah",
|
"edit": "Edit",
|
||||||
"cancel": "Batal",
|
"cancel": "Batal",
|
||||||
"dismiss": "Abaikan",
|
|
||||||
"open": "Buka",
|
"open": "Buka",
|
||||||
"export": "Ekspor",
|
"export": "Ekspor",
|
||||||
"opening": "Membuka...",
|
"opening": "Membuka...",
|
||||||
"exporting": "Mengekspor..."
|
"exporting": "Mengekspor..."
|
||||||
},
|
},
|
||||||
"byok": {
|
"byok": {
|
||||||
"description": "Gunakan kunci penyedia Anda sendiri. Nanobot membaca nilai ini dari konfigurasi saat ini dan hanya penyedia yang sudah dikonfigurasi yang dapat digunakan dalam prasetel model.",
|
"description": "Gunakan kunci provider Anda sendiri. Nanobot membaca nilai ini dari config saat ini dan hanya provider yang sudah dikonfigurasi yang dapat digunakan dalam preset model.",
|
||||||
"configured": "Terkonfigurasi",
|
"configured": "Terkonfigurasi",
|
||||||
"notConfigured": "Belum dikonfigurasi",
|
"notConfigured": "Belum dikonfigurasi",
|
||||||
"configuredSection": "Terkonfigurasi",
|
"configuredSection": "Terkonfigurasi",
|
||||||
"notConfiguredSection": "Belum dikonfigurasi",
|
"notConfiguredSection": "Belum dikonfigurasi",
|
||||||
"showMore": "Tampilkan {{count}} lagi",
|
"showMore": "Tampilkan {{count}} lagi",
|
||||||
"showLess": "Tampilkan lebih sedikit",
|
"showLess": "Tampilkan lebih sedikit",
|
||||||
"apiKey": "Kunci API",
|
"apiKey": "API key",
|
||||||
"apiBase": "Basis API",
|
"apiBase": "API base",
|
||||||
"apiKeyPlaceholder": "Masukkan kunci API",
|
"apiKeyPlaceholder": "Masukkan API key",
|
||||||
"apiKeyConfiguredPlaceholder": "Kosongkan untuk mempertahankan kunci saat ini",
|
"apiKeyConfiguredPlaceholder": "Kosongkan untuk mempertahankan key saat ini",
|
||||||
"configuredKeyHint": "Kunci yang dikonfigurasi",
|
"configuredKeyHint": "Key terkonfigurasi",
|
||||||
"apiBasePlaceholder": "Gunakan nilai bawaan penyedia",
|
"apiBasePlaceholder": "Gunakan default provider",
|
||||||
"apiKeyRequired": "Kunci API diperlukan untuk mengonfigurasi penyedia ini.",
|
"apiKeyRequired": "API key diperlukan untuk mengonfigurasi provider ini.",
|
||||||
"showApiKey": "Tampilkan kunci API",
|
"showApiKey": "Tampilkan API key",
|
||||||
"hideApiKey": "Sembunyikan kunci API",
|
"hideApiKey": "Sembunyikan API key",
|
||||||
"noConfiguredProviders": "Belum ada penyedia yang dikonfigurasi",
|
"noConfiguredProviders": "Belum ada provider terkonfigurasi",
|
||||||
"configureFirst": "Konfigurasikan penyedia di BYOK terlebih dahulu.",
|
"configureFirst": "Konfigurasikan provider di BYOK terlebih dahulu.",
|
||||||
"openByok": "Buka BYOK",
|
"openByok": "Buka BYOK",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"ariaLabel": "Jenis kredensial BYOK",
|
"ariaLabel": "Jenis kredensial BYOK",
|
||||||
@@ -297,19 +300,19 @@
|
|||||||
},
|
},
|
||||||
"webSearch": {
|
"webSearch": {
|
||||||
"provider": "Penyedia pencarian",
|
"provider": "Penyedia pencarian",
|
||||||
"providerHelp": "Pilih backend yang digunakan alat pencarian web.",
|
"providerHelp": "Pilih backend yang digunakan alat web search.",
|
||||||
"selectProvider": "Pilih penyedia",
|
"selectProvider": "Pilih provider",
|
||||||
"credentials": "Kredensial",
|
"credentials": "Kredensial",
|
||||||
"noCredentialRequired": "Tidak perlu kunci",
|
"noCredentialRequired": "Tidak perlu key",
|
||||||
"noCredentialHelp": "DuckDuckGo berfungsi tanpa menyimpan kunci API.",
|
"noCredentialHelp": "DuckDuckGo berfungsi tanpa menyimpan API key.",
|
||||||
"apiKeyHelp": "Disimpan di config dan ditampilkan tersamarkan setelah disimpan.",
|
"apiKeyHelp": "Disimpan di config dan ditampilkan tersamarkan setelah disimpan.",
|
||||||
"baseUrl": "URL dasar",
|
"baseUrl": "Base URL",
|
||||||
"baseUrlHelp": "SearXNG memerlukan URL instance Anda sendiri.",
|
"baseUrlHelp": "SearXNG memerlukan URL instance Anda sendiri.",
|
||||||
"baseUrlPlaceholder": "https://search.example.com",
|
"baseUrlPlaceholder": "https://search.example.com",
|
||||||
"apiKeyRequired": "Penyedia pencarian ini memerlukan kunci API.",
|
"apiKeyRequired": "Provider pencarian ini memerlukan API key.",
|
||||||
"baseUrlRequired": "SearXNG memerlukan URL dasar.",
|
"baseUrlRequired": "SearXNG memerlukan Base URL.",
|
||||||
"missingCredential": "Tambahkan kredensial yang diperlukan sebelum menyimpan.",
|
"missingCredential": "Tambahkan kredensial yang diperlukan sebelum menyimpan.",
|
||||||
"saveHint": "Perubahan berlaku untuk permintaan pencarian web baru."
|
"saveHint": "Perubahan berlaku untuk permintaan web search baru."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"overview": {
|
"overview": {
|
||||||
@@ -324,7 +327,7 @@
|
|||||||
},
|
},
|
||||||
"usage": {
|
"usage": {
|
||||||
"title": "Aktivitas token",
|
"title": "Aktivitas token",
|
||||||
"shortTitle": "Penggunaan token",
|
"shortTitle": "Token Usage",
|
||||||
"subtitle": "Penggunaan yang dilaporkan penyedia selama 12 bulan terakhir.",
|
"subtitle": "Penggunaan yang dilaporkan penyedia selama 12 bulan terakhir.",
|
||||||
"empty": "Aktivitas token akan muncul setelah balasan model baru.",
|
"empty": "Aktivitas token akan muncul setelah balasan model baru.",
|
||||||
"totalTokens": "Total token",
|
"totalTokens": "Total token",
|
||||||
@@ -371,18 +374,8 @@
|
|||||||
"selectProvider": "Pilih penyedia",
|
"selectProvider": "Pilih penyedia",
|
||||||
"selectAspect": "Pilih rasio",
|
"selectAspect": "Pilih rasio",
|
||||||
"selectSize": "Pilih ukuran",
|
"selectSize": "Pilih ukuran",
|
||||||
"selectModel": "Pilih model gambar",
|
|
||||||
"searchOrTypeModel": "Cari atau ketik ID model",
|
|
||||||
"typeModelId": "Ketik ID model yang didukung penyedia ini.",
|
|
||||||
"configureProvider": "Konfigurasi penyedia",
|
"configureProvider": "Konfigurasi penyedia",
|
||||||
"missingCredential": "Konfigurasikan penyedia ini sebelum mengaktifkan pembuatan gambar."
|
"missingCredential": "Configura este proveedor antes de activar la generación de imágenes."
|
||||||
},
|
|
||||||
"capabilities": {
|
|
||||||
"providerSupport": "Dukungan penyedia",
|
|
||||||
"providerInstallOnSave": "Dukungan yang diperlukan akan dipasang otomatis saat Anda menyimpan penyedia ini.",
|
|
||||||
"searchSupport": "Dukungan penyedia pencarian",
|
|
||||||
"searchInstallOnSave": "Dukungan Olostep akan dipasang otomatis saat Anda menyimpan.",
|
|
||||||
"installing": "Memasang dukungan..."
|
|
||||||
},
|
},
|
||||||
"models": {
|
"models": {
|
||||||
"selectModel": "Pilih model",
|
"selectModel": "Pilih model",
|
||||||
@@ -395,12 +388,12 @@
|
|||||||
"callOrder": "Urutan pemanggilan model",
|
"callOrder": "Urutan pemanggilan model",
|
||||||
"primary": "Utama",
|
"primary": "Utama",
|
||||||
"fallbackNumber": "Cadangan {{number}}",
|
"fallbackNumber": "Cadangan {{number}}",
|
||||||
"addToOrder": "Aktifkan prasetel",
|
"addToOrder": "Aktifkan preset",
|
||||||
"newPreset": "Prasetel model baru",
|
"newPreset": "Preset model baru",
|
||||||
"newPresetHelp": "Simpan model yang dapat digunakan kembali beserta pengaturan generasinya.",
|
"newPresetHelp": "Simpan model yang dapat digunakan kembali beserta pengaturan generasinya.",
|
||||||
"presets": "Prasetel model",
|
"presets": "Preset model",
|
||||||
"editPreset": "Ubah prasetel",
|
"editPreset": "Edit preset",
|
||||||
"presetName": "Nama prasetel",
|
"presetName": "Nama preset",
|
||||||
"presetNameHelp": "Nama singkat yang digunakan di pengaturan model.",
|
"presetNameHelp": "Nama singkat yang digunakan di pengaturan model.",
|
||||||
"presetNamePlaceholder": "Menulis cepat",
|
"presetNamePlaceholder": "Menulis cepat",
|
||||||
"advancedOptions": "Opsi lanjutan",
|
"advancedOptions": "Opsi lanjutan",
|
||||||
@@ -409,22 +402,22 @@
|
|||||||
"temperature": "Temperatur",
|
"temperature": "Temperatur",
|
||||||
"reasoningEffort": "Upaya penalaran",
|
"reasoningEffort": "Upaya penalaran",
|
||||||
"convertTitle": "Konversi pengaturan model saat ini",
|
"convertTitle": "Konversi pengaturan model saat ini",
|
||||||
"convertHelp": "Ubah model utama dan cadangan yang ada menjadi prasetel agar urutannya dapat dikelola di sini.",
|
"convertHelp": "Ubah model utama dan cadangan yang ada menjadi preset agar urutannya dapat dikelola di sini.",
|
||||||
"converting": "Mengonversi...",
|
"converting": "Mengonversi...",
|
||||||
"convertAction": "Konversi ke prasetel",
|
"convertAction": "Konversi ke preset",
|
||||||
"dragToReorder": "Seret untuk mengurutkan ulang",
|
"dragToReorder": "Seret untuk mengurutkan ulang",
|
||||||
"moveUp": "Naikkan",
|
"moveUp": "Naikkan",
|
||||||
"moveDown": "Turunkan",
|
"moveDown": "Turunkan",
|
||||||
"removeFromOrder": "Nonaktifkan prasetel",
|
"removeFromOrder": "Nonaktifkan preset",
|
||||||
"inCallOrder": "Dalam urutan pemanggilan",
|
"inCallOrder": "Dalam urutan pemanggilan",
|
||||||
"disabled": "Nonaktif",
|
"disabled": "Nonaktif",
|
||||||
"noPresets": "Belum ada prasetel model",
|
"noPresets": "Belum ada preset model",
|
||||||
"noPresetsHelp": "Buat prasetel, lalu tambahkan ke urutan pemanggilan.",
|
"noPresetsHelp": "Buat preset, lalu tambahkan ke urutan pemanggilan.",
|
||||||
"removeBeforeDelete": "Hapus prasetel ini dari urutan pemanggilan sebelum menghapusnya.",
|
"removeBeforeDelete": "Hapus preset ini dari urutan pemanggilan sebelum menghapusnya.",
|
||||||
"providerSetupRequired": "Penyedia perlu dikonfigurasi",
|
"providerSetupRequired": "Penyedia perlu dikonfigurasi",
|
||||||
"configureProviderBeforeSaving": "Konfigurasikan penyedia ini sebelum menyimpan prasetel.",
|
"configureProviderBeforeSaving": "Konfigurasikan penyedia ini sebelum menyimpan preset.",
|
||||||
"deletePresetTitle": "Hapus prasetel model?",
|
"deletePresetTitle": "Hapus preset model?",
|
||||||
"deletePresetHelp": "Prasetel “{{name}}” akan dihapus. Kredensial penyedia tidak terpengaruh.",
|
"deletePresetHelp": "Preset “{{name}}” akan dihapus. Kredensial penyedia tidak terpengaruh.",
|
||||||
"searchModels": "Cari atau ketik ID model",
|
"searchModels": "Cari atau ketik ID model",
|
||||||
"useCustomModel": "Gunakan",
|
"useCustomModel": "Gunakan",
|
||||||
"loadingModels": "Memuat model...",
|
"loadingModels": "Memuat model...",
|
||||||
@@ -481,11 +474,11 @@
|
|||||||
},
|
},
|
||||||
"mcp": {
|
"mcp": {
|
||||||
"allCategories": "Semua kategori",
|
"allCategories": "Semua kategori",
|
||||||
"summary": "{{installed}} dari {{total}} prasetel diaktifkan",
|
"summary": "{{installed}} dari {{total}} preset diaktifkan",
|
||||||
"filterAll": "Semua",
|
"filterAll": "Semua",
|
||||||
"filterInstalled": "Aktif",
|
"filterInstalled": "Aktif",
|
||||||
"filterNotInstalled": "Tidak aktif",
|
"filterNotInstalled": "Tidak aktif",
|
||||||
"searchPlaceholder": "Cari prasetel MCP",
|
"searchPlaceholder": "Cari preset MCP",
|
||||||
"moreOptions": "Opsi MCP lainnya",
|
"moreOptions": "Opsi MCP lainnya",
|
||||||
"moreOptionsSubtitle": "Tambahkan server khusus atau impor mcp.json.",
|
"moreOptionsSubtitle": "Tambahkan server khusus atau impor mcp.json.",
|
||||||
"customTitle": "MCP khusus",
|
"customTitle": "MCP khusus",
|
||||||
@@ -496,9 +489,9 @@
|
|||||||
"serverUrl": "URL",
|
"serverUrl": "URL",
|
||||||
"transport": "Transport",
|
"transport": "Transport",
|
||||||
"command": "Perintah",
|
"command": "Perintah",
|
||||||
"args": "Argumen JSON",
|
"args": "Args JSON",
|
||||||
"headers": "Header JSON",
|
"headers": "Headers JSON",
|
||||||
"env": "Lingkungan JSON",
|
"env": "Env JSON",
|
||||||
"timeout": "Batas waktu alat",
|
"timeout": "Batas waktu alat",
|
||||||
"advancedOptions": "Opsi lanjutan",
|
"advancedOptions": "Opsi lanjutan",
|
||||||
"hideAdvanced": "Sembunyikan lanjutan",
|
"hideAdvanced": "Sembunyikan lanjutan",
|
||||||
@@ -507,8 +500,8 @@
|
|||||||
"importConfig": "Impor",
|
"importConfig": "Impor",
|
||||||
"restartRequired": "Mulai ulang nanobot untuk menyambungkan alat MCP yang diperbarui.",
|
"restartRequired": "Mulai ulang nanobot untuk menyambungkan alat MCP yang diperbarui.",
|
||||||
"toolsFound": "{{count}} alat",
|
"toolsFound": "{{count}} alat",
|
||||||
"loading": "Memuat prasetel MCP...",
|
"loading": "Memuat preset MCP...",
|
||||||
"empty": "Tidak ada prasetel MCP yang cocok dengan filter ini.",
|
"empty": "Tidak ada preset MCP yang cocok dengan filter ini.",
|
||||||
"openDocs": "Buka dokumentasi",
|
"openDocs": "Buka dokumentasi",
|
||||||
"test": "Uji",
|
"test": "Uji",
|
||||||
"remove": "Hapus",
|
"remove": "Hapus",
|
||||||
@@ -526,7 +519,6 @@
|
|||||||
"statusMissingCredentials": "Butuh kunci",
|
"statusMissingCredentials": "Butuh kunci",
|
||||||
"statusMissingDependency": "Butuh dependensi",
|
"statusMissingDependency": "Butuh dependensi",
|
||||||
"statusComingSoon": "Segera hadir",
|
"statusComingSoon": "Segera hadir",
|
||||||
"comingSoon": "Segera hadir",
|
|
||||||
"statusNotInstalled": "Tidak aktif",
|
"statusNotInstalled": "Tidak aktif",
|
||||||
"toolScope": "Alat",
|
"toolScope": "Alat",
|
||||||
"allTools": "Semua",
|
"allTools": "Semua",
|
||||||
@@ -568,7 +560,7 @@
|
|||||||
"restartRequired": "Mulai ulang nanobot untuk menerapkan aplikasi dan fitur yang diperbarui."
|
"restartRequired": "Mulai ulang nanobot untuk menerapkan aplikasi dan fitur yang diperbarui."
|
||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
"description": "Hubungkan nanobot ke aplikasi chat. Memasang dukungan hanya menambahkan paket integrasi; sebagian besar kanal tetap memerlukan token atau pengaturan ruang kerja.",
|
"description": "Hubungkan nanobot ke aplikasi chat. Memasang dukungan hanya menambahkan paket integrasi; sebagian besar kanal tetap memerlukan token atau pengaturan workspace.",
|
||||||
"caption": "{{enabled}} aktif · {{total}} kanal",
|
"caption": "{{enabled}} aktif · {{total}} kanal",
|
||||||
"searchPlaceholder": "Cari kanal",
|
"searchPlaceholder": "Cari kanal",
|
||||||
"backToChannels": "Semua kanal",
|
"backToChannels": "Semua kanal",
|
||||||
@@ -578,7 +570,7 @@
|
|||||||
"restartRequired": "Mulai ulang nanobot untuk menerapkan dukungan kanal yang diperbarui.",
|
"restartRequired": "Mulai ulang nanobot untuk menerapkan dukungan kanal yang diperbarui.",
|
||||||
"requires": "Memerlukan: {{requirements}}",
|
"requires": "Memerlukan: {{requirements}}",
|
||||||
"setUp": "Siapkan",
|
"setUp": "Siapkan",
|
||||||
"setupGuide": "Panduan penyiapan",
|
"setupGuide": "Panduan setup",
|
||||||
"setupSummary": "Mengaktifkan hanya menyalakan dukungan nanobot. Tambahkan kredensial platform, lalu mulai ulang nanobot.",
|
"setupSummary": "Mengaktifkan hanya menyalakan dukungan nanobot. Tambahkan kredensial platform, lalu mulai ulang nanobot.",
|
||||||
"configKeys": "Kunci konfigurasi",
|
"configKeys": "Kunci konfigurasi",
|
||||||
"enable": "Aktifkan kanal",
|
"enable": "Aktifkan kanal",
|
||||||
@@ -589,8 +581,6 @@
|
|||||||
"advanced": "Lanjutan",
|
"advanced": "Lanjutan",
|
||||||
"checkAndEnable": "Periksa dan aktifkan",
|
"checkAndEnable": "Periksa dan aktifkan",
|
||||||
"checkConnection": "Periksa koneksi",
|
"checkConnection": "Periksa koneksi",
|
||||||
"connectionChecks": "Pemeriksaan koneksi",
|
|
||||||
"open": "Buka",
|
|
||||||
"checkedAndEnabled": "Sudah diperiksa dan diaktifkan.",
|
"checkedAndEnabled": "Sudah diperiksa dan diaktifkan.",
|
||||||
"checking": "Memeriksa...",
|
"checking": "Memeriksa...",
|
||||||
"checkOnly": "Periksa saja",
|
"checkOnly": "Periksa saja",
|
||||||
@@ -681,13 +671,11 @@
|
|||||||
"runNow": "Jalankan sekarang",
|
"runNow": "Jalankan sekarang",
|
||||||
"pause": "Jeda",
|
"pause": "Jeda",
|
||||||
"resume": "Lanjutkan",
|
"resume": "Lanjutkan",
|
||||||
"edit": "Ubah",
|
"edit": "Edit",
|
||||||
"delete": "Hapus",
|
"delete": "Hapus",
|
||||||
"protected": "Terlindungi",
|
"protected": "Terlindungi",
|
||||||
"editTitle": "Ubah otomasi",
|
"editTitle": "Edit otomasi",
|
||||||
"save": "Simpan",
|
"save": "Simpan",
|
||||||
"commandCopied": "Disalin",
|
|
||||||
"copyCommand": "Salin",
|
|
||||||
"deleteTitle": "Hapus otomasi",
|
"deleteTitle": "Hapus otomasi",
|
||||||
"deleteDescription": "Ini menghapus {{name}} dari penyimpanan cron. Pesan chat sebelumnya tetap ada di sesi.",
|
"deleteDescription": "Ini menghapus {{name}} dari penyimpanan cron. Pesan chat sebelumnya tetap ada di sesi.",
|
||||||
"cancel": "Batal",
|
"cancel": "Batal",
|
||||||
@@ -747,7 +735,6 @@
|
|||||||
"fields": {
|
"fields": {
|
||||||
"name": "Nama",
|
"name": "Nama",
|
||||||
"message": "Pesan",
|
"message": "Pesan",
|
||||||
"command": "Perintah",
|
|
||||||
"scheduleType": "Jenis jadwal",
|
"scheduleType": "Jenis jadwal",
|
||||||
"every": "Setiap",
|
"every": "Setiap",
|
||||||
"unit": "Unit",
|
"unit": "Unit",
|
||||||
@@ -782,11 +769,11 @@
|
|||||||
"signInAgain": "Masuk lagi",
|
"signInAgain": "Masuk lagi",
|
||||||
"signOut": "Keluar",
|
"signOut": "Keluar",
|
||||||
"signedInAs": "Masuk sebagai {{account}}",
|
"signedInAs": "Masuk sebagai {{account}}",
|
||||||
"signInHelp": "Masuk dari perangkat ini; kunci API tidak disimpan di config.",
|
"signInHelp": "Inicia sesión desde este dispositivo; no se guarda API key en config.",
|
||||||
"remoteSignInHelp": "Pilih Masuk untuk membuka xAI di komputer Anda, lalu tempel kode otorisasi yang ditampilkan setelah masuk.",
|
"remoteSignInHelp": "Pilih Masuk untuk membuka xAI di komputer Anda, lalu tempel kode otorisasi yang ditampilkan setelah masuk.",
|
||||||
"codexRemoteSignInHelp": "Masuk melalui browser ini, lalu tempel URL callback localhost lengkap kembali ke nanobot.",
|
"codexRemoteSignInHelp": "Masuk melalui browser ini, lalu tempel URL callback localhost lengkap kembali ke nanobot.",
|
||||||
"signInRequired": "Perlu masuk",
|
"signInRequired": "Perlu masuk",
|
||||||
"signInBeforeSaving": "Masuk ke penyedia ini sebelum menyimpan prasetel.",
|
"signInBeforeSaving": "Masuk ke penyedia ini sebelum menyimpan preset.",
|
||||||
"signedIn": "Sudah masuk",
|
"signedIn": "Sudah masuk",
|
||||||
"notSignedIn": "Belum masuk",
|
"notSignedIn": "Belum masuk",
|
||||||
"proxyLabel": "Proksi jaringan",
|
"proxyLabel": "Proksi jaringan",
|
||||||
@@ -805,56 +792,56 @@
|
|||||||
"finishSignIn": "Selesaikan masuk"
|
"finishSignIn": "Selesaikan masuk"
|
||||||
},
|
},
|
||||||
"skills": {
|
"skills": {
|
||||||
"description": "Tinjau keterampilan instruksi yang dapat dimuat agen ini selama percakapan.",
|
"description": "Tinjau skill instruksi yang dapat dimuat agent ini selama percakapan.",
|
||||||
"caption": "{{available}} tersedia · {{total}} total",
|
"caption": "{{available}} tersedia · {{total}} total",
|
||||||
"views": "Tampilan keterampilan",
|
"views": "Tampilan skill",
|
||||||
"installedTab": "Terpasang",
|
"installedTab": "Terpasang",
|
||||||
"discoverTab": "Temukan",
|
"discoverTab": "Temukan",
|
||||||
"customGroup": "Kustom",
|
"customGroup": "Kustom",
|
||||||
"builtinGroup": "Bawaan",
|
"builtinGroup": "Bawaan",
|
||||||
"otherGroup": "Lainnya",
|
"otherGroup": "Lainnya",
|
||||||
"searchInstalled": "Cari keterampilan terpasang",
|
"searchInstalled": "Cari skill terpasang",
|
||||||
"filterAll": "Semua",
|
"filterAll": "Semua",
|
||||||
"filterEnabled": "Aktif",
|
"filterEnabled": "Aktif",
|
||||||
"filterDisabled": "Nonaktif",
|
"filterDisabled": "Nonaktif",
|
||||||
"noMatching": "Tidak ada keterampilan yang cocok.",
|
"noMatching": "Tidak ada skill yang cocok.",
|
||||||
"statusDisabled": "Nonaktif",
|
"statusDisabled": "Nonaktif",
|
||||||
"statusEnabled": "Aktif",
|
"statusEnabled": "Aktif",
|
||||||
"statusNeedsSetup": "Perlu penyiapan",
|
"statusNeedsSetup": "Perlu penyiapan",
|
||||||
"showLess": "Tampilkan lebih sedikit",
|
"showLess": "Tampilkan lebih sedikit",
|
||||||
"showMore": "Tampilkan lebih banyak",
|
"showMore": "Tampilkan lebih banyak",
|
||||||
"enabledControl": "Gunakan keterampilan ini",
|
"enabledControl": "Gunakan skill ini",
|
||||||
"enabledDescription": "Izinkan agen memuat keterampilan ini saat persyaratannya terpenuhi.",
|
"enabledDescription": "Izinkan agen memuat skill ini saat persyaratannya terpenuhi.",
|
||||||
"enableSkill": "Aktifkan {{name}}",
|
"enableSkill": "Aktifkan {{name}}",
|
||||||
"disableSkill": "Nonaktifkan {{name}}",
|
"disableSkill": "Nonaktifkan {{name}}",
|
||||||
"updateFailed": "Keterampilan ini tidak dapat diperbarui.",
|
"updateFailed": "Skill ini tidak dapat diperbarui.",
|
||||||
"deleteTitle": "Hapus keterampilan",
|
"deleteTitle": "Hapus skill",
|
||||||
"deleteDescription": "Hapus keterampilan ini dari ruang kerja saat ini.",
|
"deleteDescription": "Hapus skill ini dari workspace saat ini.",
|
||||||
"deleteAction": "Hapus",
|
"deleteAction": "Hapus",
|
||||||
"deleteFailed": "Keterampilan ini tidak dapat dihapus.",
|
"deleteFailed": "Skill ini tidak dapat dihapus.",
|
||||||
"deleteConfirmTitle": "Hapus {{name}}?",
|
"deleteConfirmTitle": "Hapus {{name}}?",
|
||||||
"deleteConfirmDescription": "Tindakan ini menghapus file keterampilan dari ruang kerja saat ini dan tidak dapat dibatalkan.",
|
"deleteConfirmDescription": "Tindakan ini menghapus file skill dari workspace saat ini dan tidak dapat dibatalkan.",
|
||||||
"deleteConfirmAction": "Hapus keterampilan",
|
"deleteConfirmAction": "Hapus skill",
|
||||||
"instructionsTitle": "Petunjuk keterampilan",
|
"instructionsTitle": "Petunjuk skill",
|
||||||
"setupRequired": "Perlu penyiapan",
|
"setupRequired": "Perlu penyiapan",
|
||||||
"setupDescription": "Instal dependensi yang belum tersedia di mesin yang menjalankan nanobot, lalu periksa lagi.",
|
"setupDescription": "Instal dependensi yang belum tersedia di mesin yang menjalankan nanobot, lalu periksa lagi.",
|
||||||
"copySetupCommand": "Salin perintah penyiapan",
|
"copySetupCommand": "Salin perintah penyiapan",
|
||||||
"checkAgain": "Periksa lagi",
|
"checkAgain": "Periksa lagi",
|
||||||
"marketplaceSearchFailed": "Tidak dapat mencari marketplace keterampilan.",
|
"marketplaceSearchFailed": "Tidak dapat mencari marketplace skill.",
|
||||||
"marketplaceInstallFailed": "Tidak dapat memasang keterampilan ini.",
|
"marketplaceInstallFailed": "Tidak dapat memasang skill ini.",
|
||||||
"marketplaceSearchPlaceholder": "Cari keterampilan",
|
"marketplaceSearchPlaceholder": "Cari skill",
|
||||||
"marketplaceSearchLabel": "Cari keterampilan",
|
"marketplaceSearchLabel": "Cari skill",
|
||||||
"marketplaceSearching": "Mencari",
|
"marketplaceSearching": "Mencari",
|
||||||
"marketplaceProviderFilter": "Sumber keterampilan",
|
"marketplaceProviderFilter": "Sumber skill",
|
||||||
"marketplaceProviderAll": "Semua",
|
"marketplaceProviderAll": "Semua",
|
||||||
"marketplaceTrendingTitle": "Tren per marketplace",
|
"marketplaceTrendingTitle": "Tren per marketplace",
|
||||||
"marketplaceTrendingDescription": "Setiap marketplace mempertahankan peringkat dan metrik pemasangannya sendiri.",
|
"marketplaceTrendingDescription": "Setiap marketplace mempertahankan peringkat dan metrik pemasangannya sendiri.",
|
||||||
"marketplaceViewAll": "Lihat semua",
|
"marketplaceViewAll": "Lihat semua",
|
||||||
"marketplaceTrendingUnavailable": "Keterampilan populer sementara tidak tersedia.",
|
"marketplaceTrendingUnavailable": "Skill populer sementara tidak tersedia.",
|
||||||
"marketplaceEmpty": "Tidak ada keterampilan yang ditemukan untuk “{{query}}”.",
|
"marketplaceEmpty": "Tidak ada skill yang ditemukan untuk “{{query}}”.",
|
||||||
"marketplaceConfirmTitle": "Pasang {{name}}?",
|
"marketplaceConfirmTitle": "Pasang {{name}}?",
|
||||||
"marketplaceConfirmDescription": "Keterampilan pihak ketiga ini berasal dari {{provider}} ({{source}}) dan mungkin berisi instruksi atau skrip yang dapat dijalankan.",
|
"marketplaceConfirmDescription": "Skill pihak ketiga ini berasal dari {{provider}} ({{source}}) dan mungkin berisi instruksi atau skrip yang dapat dijalankan.",
|
||||||
"marketplaceConfirmInstall": "Pasang keterampilan",
|
"marketplaceConfirmInstall": "Pasang skill",
|
||||||
"marketplaceOpen": "Buka {{name}} di {{provider}}",
|
"marketplaceOpen": "Buka {{name}} di {{provider}}",
|
||||||
"marketplaceOpenProvider": "Buka {{provider}}",
|
"marketplaceOpenProvider": "Buka {{provider}}",
|
||||||
"marketplaceInstalls24h": "{{formattedCount}} pemasangan / 24 jam",
|
"marketplaceInstalls24h": "{{formattedCount}} pemasangan / 24 jam",
|
||||||
@@ -865,16 +852,16 @@
|
|||||||
"marketplaceInstall": "Pasang",
|
"marketplaceInstall": "Pasang",
|
||||||
"marketplaceNoTrend": "Belum ada tren",
|
"marketplaceNoTrend": "Belum ada tren",
|
||||||
"marketplaceTrendLabel": "Tren pemasangan 8 minggu",
|
"marketplaceTrendLabel": "Tren pemasangan 8 minggu",
|
||||||
"featured": "Keterampilan agen",
|
"featured": "Skill agent",
|
||||||
"empty": "Tidak ada keterampilan yang tersedia.",
|
"empty": "Tidak ada skill yang tersedia.",
|
||||||
"sourceWorkspace": "Kustom",
|
"sourceWorkspace": "Kustom",
|
||||||
"sourceBuiltin": "Bawaan",
|
"sourceBuiltin": "Bawaan",
|
||||||
"statusAvailable": "Tersedia",
|
"statusAvailable": "Tersedia",
|
||||||
"statusUnavailable": "Tidak tersedia",
|
"statusUnavailable": "Tidak tersedia",
|
||||||
"unavailableReason": "Kurang: {{reason}}",
|
"unavailableReason": "Kurang: {{reason}}",
|
||||||
"openDetails": "Buka detail {{name}}",
|
"openDetails": "Buka detail {{name}}",
|
||||||
"loadingDetail": "Memuat detail keterampilan...",
|
"loadingDetail": "Memuat detail skill...",
|
||||||
"loadFailed": "Tidak dapat memuat detail keterampilan.",
|
"loadFailed": "Tidak dapat memuat detail skill.",
|
||||||
"descriptionTitle": "Deskripsi",
|
"descriptionTitle": "Deskripsi",
|
||||||
"source": "Sumber",
|
"source": "Sumber",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
@@ -892,7 +879,7 @@
|
|||||||
"voice": {
|
"voice": {
|
||||||
"selectProvider": "Pilih penyedia",
|
"selectProvider": "Pilih penyedia",
|
||||||
"configureProvider": "Konfigurasi penyedia",
|
"configureProvider": "Konfigurasi penyedia",
|
||||||
"languageAuto": "Otomatis"
|
"languageAuto": "Auto"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
@@ -906,34 +893,34 @@
|
|||||||
"actions": "Aksi topik untuk {{title}}",
|
"actions": "Aksi topik untuk {{title}}",
|
||||||
"newInProject": "Mulai topik baru di {{project}}",
|
"newInProject": "Mulai topik baru di {{project}}",
|
||||||
"activity": {
|
"activity": {
|
||||||
"running": "Agen sedang berjalan",
|
"running": "Agent running",
|
||||||
"complete": "Agen selesai",
|
"complete": "Agent finished",
|
||||||
"updated": "Aktivitas baru"
|
"updated": "New activity"
|
||||||
},
|
},
|
||||||
"pin": "Sematkan",
|
"pin": "Pin",
|
||||||
"unpin": "Lepas sematan",
|
"unpin": "Unpin",
|
||||||
"rename": "Ganti nama",
|
"rename": "Rename",
|
||||||
"renameTitle": "Ganti nama topik",
|
"renameTitle": "Ganti nama topik",
|
||||||
"renameDescription": "Pilih nama lokal di bilah sisi untuk topik ini.",
|
"renameDescription": "Pilih nama lokal di bilah sisi untuk topik ini.",
|
||||||
"renamePlaceholder": "Nama topik",
|
"renamePlaceholder": "Nama topik",
|
||||||
"renameProjectTitle": "Ganti nama proyek",
|
"renameProjectTitle": "Rename project",
|
||||||
"renameProjectDescription": "Pilih nama lokal untuk proyek ini di bilah sisi.",
|
"renameProjectDescription": "Choose a local sidebar name for this project.",
|
||||||
"renameProjectPlaceholder": "Nama proyek",
|
"renameProjectPlaceholder": "Project name",
|
||||||
"renameSave": "Simpan",
|
"renameSave": "Save",
|
||||||
"archive": "Arsipkan",
|
"archive": "Archive",
|
||||||
"unarchive": "Batalkan arsip",
|
"unarchive": "Unarchive",
|
||||||
"showArchived": "Tampilkan yang diarsipkan",
|
"showArchived": "Show archived",
|
||||||
"hideArchived": "Sembunyikan yang diarsipkan",
|
"hideArchived": "Hide archived",
|
||||||
"delete": "Hapus",
|
"delete": "Hapus",
|
||||||
"newChat": "Topik baru",
|
"newChat": "Topik baru",
|
||||||
"groups": {
|
"groups": {
|
||||||
"pinned": "Disematkan",
|
"pinned": "Pinned",
|
||||||
"all": "Topik",
|
"all": "Topik",
|
||||||
"projects": "Proyek",
|
"projects": "Projects",
|
||||||
"today": "Hari ini",
|
"today": "Today",
|
||||||
"yesterday": "Kemarin",
|
"yesterday": "Yesterday",
|
||||||
"earlier": "Sebelumnya",
|
"earlier": "Earlier",
|
||||||
"archived": "Diarsipkan"
|
"archived": "Archived"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"deleteConfirm": {
|
"deleteConfirm": {
|
||||||
@@ -960,7 +947,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"connection": {
|
"connection": {
|
||||||
"idle": "Tidak aktif",
|
"idle": "Idle",
|
||||||
"connecting": "Menghubungkan…",
|
"connecting": "Menghubungkan…",
|
||||||
"open": "Terhubung",
|
"open": "Terhubung",
|
||||||
"reconnecting": "Menyambung ulang…",
|
"reconnecting": "Menyambung ulang…",
|
||||||
@@ -986,8 +973,8 @@
|
|||||||
"prompt": "Bantu saya menganalisis data ini dan soroti pola yang paling penting."
|
"prompt": "Bantu saya menganalisis data ini dan soroti pola yang paling penting."
|
||||||
},
|
},
|
||||||
"brainstorm": {
|
"brainstorm": {
|
||||||
"title": "Curah gagasan",
|
"title": "Brainstorm ide",
|
||||||
"prompt": "Curahkan beberapa ide praktis dan pertimbangannya untuk masalah ini."
|
"prompt": "Brainstorm beberapa ide praktis dan tradeoff untuk masalah ini."
|
||||||
},
|
},
|
||||||
"code": {
|
"code": {
|
||||||
"title": "Tulis kode",
|
"title": "Tulis kode",
|
||||||
@@ -999,7 +986,7 @@
|
|||||||
},
|
},
|
||||||
"more": {
|
"more": {
|
||||||
"title": "Lainnya",
|
"title": "Lainnya",
|
||||||
"prompt": "Tunjukkan beberapa cara berguna Anda dapat membantu di ruang kerja ini."
|
"prompt": "Tunjukkan beberapa cara berguna Anda dapat membantu di workspace ini."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"imageQuickActions": {
|
"imageQuickActions": {
|
||||||
@@ -1013,19 +1000,19 @@
|
|||||||
},
|
},
|
||||||
"poster": {
|
"poster": {
|
||||||
"title": "Buat poster",
|
"title": "Buat poster",
|
||||||
"prompt": "Buat konsep poster yang rapi untuk asisten AI pribadi, komposisi modern, hierarki visual kuat, cocok untuk halaman arahan."
|
"prompt": "Buat konsep poster yang rapi untuk asisten AI pribadi, komposisi modern, hierarki visual kuat, cocok untuk landing page."
|
||||||
},
|
},
|
||||||
"product": {
|
"product": {
|
||||||
"title": "Maket produk",
|
"title": "Mockup produk",
|
||||||
"prompt": "Buat gambar maket produk yang bersih untuk aplikasi web AI percakapan, antarmuka minimal, pencahayaan premium, bingkai perangkat realistis."
|
"prompt": "Buat gambar mockup produk yang bersih untuk aplikasi web AI percakapan, antarmuka minimal, pencahayaan premium, bingkai perangkat realistis."
|
||||||
},
|
},
|
||||||
"portrait": {
|
"portrait": {
|
||||||
"title": "Potret bergaya",
|
"title": "Potret bergaya",
|
||||||
"prompt": "Buat potret bergaya dari pendamping AI yang ramah, pencahayaan lembut, detail tetapi tetap mudah didekati, gaya ilustrasi modern."
|
"prompt": "Buat potret bergaya dari pendamping AI yang ramah, pencahayaan lembut, detail tetapi tetap mudah didekati, gaya ilustrasi modern."
|
||||||
},
|
},
|
||||||
"edit": {
|
"edit": {
|
||||||
"title": "Ubah gambar",
|
"title": "Edit gambar",
|
||||||
"prompt": "Bantu saya mengubah gambar. Minta saya mengunggah atau menyebutkan gambar terlebih dahulu, lalu buat hasil ubahannya."
|
"prompt": "Bantu saya mengedit gambar. Minta saya mengunggah atau menyebutkan gambar terlebih dahulu, lalu buat hasil editnya."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1084,21 +1071,21 @@
|
|||||||
"label": "Panduan antrean",
|
"label": "Panduan antrean",
|
||||||
"guide": "Pandu",
|
"guide": "Pandu",
|
||||||
"delete": "Hapus panduan",
|
"delete": "Hapus panduan",
|
||||||
"edit": "Ubah panduan",
|
"edit": "Edit panduan",
|
||||||
"drag": "Seret untuk mengurutkan"
|
"drag": "Seret untuk mengurutkan"
|
||||||
},
|
},
|
||||||
"attachImage": "Lampirkan file",
|
"attachImage": "Lampirkan file",
|
||||||
"imageMode": {
|
"imageMode": {
|
||||||
"label": "Buat gambar",
|
"label": "Buat gambar",
|
||||||
"toggle": "Alihkan mode pembuatan gambar",
|
"toggle": "Alihkan mode pembuatan gambar",
|
||||||
"placeholder": "Deskripsikan atau ubah gambar…",
|
"placeholder": "Deskripsikan atau edit gambar…",
|
||||||
"aspectAria": "Rasio aspek gambar",
|
"aspectAria": "Rasio aspek gambar",
|
||||||
"aspectLabel": "Rasio gambar",
|
"aspectLabel": "Rasio gambar",
|
||||||
"aspect": {
|
"aspect": {
|
||||||
"auto": "Otomatis",
|
"auto": "Otomatis",
|
||||||
"1_1": "Persegi 1:1",
|
"1_1": "Persegi 1:1",
|
||||||
"3_4": "Potret 3:4",
|
"3_4": "Potret 3:4",
|
||||||
"9_16": "Cerita 9:16",
|
"9_16": "Story 9:16",
|
||||||
"4_3": "Lanskap 4:3",
|
"4_3": "Lanskap 4:3",
|
||||||
"16_9": "Lebar 16:9"
|
"16_9": "Lebar 16:9"
|
||||||
}
|
}
|
||||||
@@ -1138,7 +1125,7 @@
|
|||||||
},
|
},
|
||||||
"stop": {
|
"stop": {
|
||||||
"title": "Hentikan tugas saat ini",
|
"title": "Hentikan tugas saat ini",
|
||||||
"description": "Batalkan giliran agen yang sedang aktif di chat ini."
|
"description": "Batalkan giliran agent yang sedang aktif di chat ini."
|
||||||
},
|
},
|
||||||
"restart": {
|
"restart": {
|
||||||
"title": "Mulai ulang nanobot",
|
"title": "Mulai ulang nanobot",
|
||||||
@@ -1146,11 +1133,11 @@
|
|||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"title": "Tampilkan status",
|
"title": "Tampilkan status",
|
||||||
"description": "Tampilkan status waktu proses, penyedia, dan kanal."
|
"description": "Tampilkan status runtime, provider, dan channel."
|
||||||
},
|
},
|
||||||
"model": {
|
"model": {
|
||||||
"title": "Model",
|
"title": "Model",
|
||||||
"description": "Tampilkan atau ganti prasetel model aktif."
|
"description": "Tampilkan atau ganti preset model aktif."
|
||||||
},
|
},
|
||||||
"history": {
|
"history": {
|
||||||
"title": "Tampilkan riwayat",
|
"title": "Tampilkan riwayat",
|
||||||
@@ -1170,15 +1157,15 @@
|
|||||||
},
|
},
|
||||||
"dream_prompt": {
|
"dream_prompt": {
|
||||||
"title": "Memori Dream",
|
"title": "Memori Dream",
|
||||||
"description": "Atur cara Dream menyusun memori ruang kerja ini."
|
"description": "Atur cara Dream menyusun memori workspace ini."
|
||||||
},
|
},
|
||||||
"goal": {
|
"goal": {
|
||||||
"title": "Tujuan jangka panjang",
|
"title": "Tujuan jangka panjang",
|
||||||
"description": "Instruksikan agen memperlakukan ini sebagai tujuan multi-langkah yang berkelanjutan."
|
"description": "Instruksikan agen memperlakukan ini sebagai tujuan multi-langkah yang berkelanjutan."
|
||||||
},
|
},
|
||||||
"trigger": {
|
"trigger": {
|
||||||
"title": "Buat pemicu lokal",
|
"title": "Buat trigger lokal",
|
||||||
"description": "Buat pemicu CLI yang terikat ke sesi chat ini."
|
"description": "Buat trigger CLI yang terikat ke sesi chat ini."
|
||||||
},
|
},
|
||||||
"help": {
|
"help": {
|
||||||
"title": "Tampilkan bantuan",
|
"title": "Tampilkan bantuan",
|
||||||
@@ -1202,7 +1189,7 @@
|
|||||||
},
|
},
|
||||||
"encoding": "Memproses…",
|
"encoding": "Memproses…",
|
||||||
"remove": "Hapus lampiran",
|
"remove": "Hapus lampiran",
|
||||||
"normalizedSizeHint": "{{orig}} → {{current}} (otomatis)",
|
"normalizedSizeHint": "{{orig}} → {{current}} (auto)",
|
||||||
"textTooLarge": "Teks pesan terlalu besar (maksimum {{max}})",
|
"textTooLarge": "Teks pesan terlalu besar (maksimum {{max}})",
|
||||||
"imageRejected": {
|
"imageRejected": {
|
||||||
"unsupported_type": "Tipe file tidak didukung",
|
"unsupported_type": "Tipe file tidak didukung",
|
||||||
@@ -1219,35 +1206,32 @@
|
|||||||
"mentions": {
|
"mentions": {
|
||||||
"ariaLabel": "Aplikasi",
|
"ariaLabel": "Aplikasi",
|
||||||
"label": "Aplikasi",
|
"label": "Aplikasi",
|
||||||
"cliGroup": "Aplikasi CLI",
|
"cliGroup": "App CLI",
|
||||||
"mcpGroup": "Layanan MCP",
|
"mcpGroup": "Layanan MCP",
|
||||||
"cliBadge": "CLI",
|
"cliBadge": "CLI",
|
||||||
"mcpBadge": "MCP",
|
"mcpBadge": "MCP",
|
||||||
"cliDescription": "Gunakan @{{name}} sebagai aplikasi CLI lokal",
|
"cliDescription": "Gunakan @{{name}} sebagai aplikasi CLI lokal",
|
||||||
"mcpDescription": "Gunakan @{{name}} sebagai server MCP",
|
"mcpDescription": "Gunakan @{{name}} sebagai server MCP"
|
||||||
"cliTitle": "Aplikasi CLI: {{name}}",
|
|
||||||
"mcpTitle": "Server MCP: {{name}}"
|
|
||||||
},
|
},
|
||||||
"workspace": {
|
"workspace": {
|
||||||
"accessAria": "Mode akses ruang kerja",
|
"accessAria": "Mode akses workspace",
|
||||||
"projectAria": "Pilih proyek",
|
"projectAria": "Pilih proyek",
|
||||||
"projectPlaceholder": "Pilih proyek",
|
"projectPlaceholder": "Pilih proyek",
|
||||||
"default": "Izin bawaan",
|
"default": "Izin default",
|
||||||
"defaultShort": "Bawaan",
|
"defaultShort": "Default",
|
||||||
"full": "Akses penuh",
|
"full": "Akses penuh",
|
||||||
"fullShort": "Penuh"
|
"fullShort": "Penuh"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scrollToBottom": "Gulir ke bawah",
|
"scrollToBottom": "Gulir ke bawah",
|
||||||
"loadEarlier": "Muat pesan sebelumnya",
|
"loadEarlier": "Muat pesan sebelumnya",
|
||||||
"forkedFromHistory": "Cabang dari riwayat",
|
"forkedFromHistory": "Fork dari riwayat",
|
||||||
"promptNavigator": {
|
"promptNavigator": {
|
||||||
"open": "Buka navigasi instruksi",
|
"open": "Buka navigator prompt",
|
||||||
"title": "Instruksi",
|
"title": "Prompt",
|
||||||
"search": "Cari instruksi",
|
"search": "Cari prompt",
|
||||||
"noResults": "Tidak ada instruksi yang cocok.",
|
"noResults": "Tidak ada prompt yang cocok.",
|
||||||
"jumpTo": "Lompat ke instruksi: {{label}}",
|
"jumpTo": "Lompat ke prompt: {{label}}"
|
||||||
"railAria": "Navigasi instruksi pengguna"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
@@ -1271,27 +1255,19 @@
|
|||||||
"agentActivityLiveSummary": "Berjalan… · {{reasoning}} langkah · {{tools}} panggilan alat",
|
"agentActivityLiveSummary": "Berjalan… · {{reasoning}} langkah · {{tools}} panggilan alat",
|
||||||
"agentActivityLiveToolsOnly": "Berjalan… · {{tools}} panggilan alat",
|
"agentActivityLiveToolsOnly": "Berjalan… · {{tools}} panggilan alat",
|
||||||
"imageAttachment": "Lampiran gambar",
|
"imageAttachment": "Lampiran gambar",
|
||||||
"videoAttachment": "Lampiran video",
|
|
||||||
"fileAttachment": "Lampiran file",
|
|
||||||
"attachmentUnavailable": "Lampiran tidak tersedia",
|
|
||||||
"dataTable": "Tabel data",
|
|
||||||
"fileEditPreparing": "Menyiapkan perubahan file…",
|
|
||||||
"openLink": "Buka tautan: {{label}}",
|
|
||||||
"openAttachment": "Buka {{name}}",
|
|
||||||
"skill": "Keterampilan: {{name}}",
|
|
||||||
"askAboutSelection": "Tanyakan tentang ini",
|
"askAboutSelection": "Tanyakan tentang ini",
|
||||||
"forkFromHere": "Buat cabang",
|
"forkFromHere": "Fork",
|
||||||
"copyReply": "Salin",
|
"copyReply": "Salin",
|
||||||
"copiedReply": "Disalin",
|
"copiedReply": "Disalin",
|
||||||
"turnLatencyTitle": "Waktu respons (ujung ke ujung)",
|
"turnLatencyTitle": "Waktu respons (ujung ke ujung)",
|
||||||
"fileEditViewDiff": "Lihat perbedaan",
|
"fileEditViewDiff": "Lihat diff",
|
||||||
"fileEditViewLargeDiff": "Lihat perbedaan besar",
|
"fileEditViewLargeDiff": "Lihat diff besar",
|
||||||
"fileEditDiffLineCount": "{{count}} baris",
|
"fileEditDiffLineCount": "{{count}} baris",
|
||||||
"fileEditUnchangedLinesHidden": "{{count}} baris tidak berubah disembunyikan",
|
"fileEditUnchangedLinesHidden": "{{count}} baris tidak berubah disembunyikan",
|
||||||
"fileEditShowMoreLines": "Tampilkan {{count}} baris lagi",
|
"fileEditShowMoreLines": "Tampilkan {{count}} baris lagi",
|
||||||
"fileEditShowFewerLines": "Tampilkan lebih sedikit baris",
|
"fileEditShowFewerLines": "Tampilkan lebih sedikit baris",
|
||||||
"fileEditOpenFile": "Buka file",
|
"fileEditOpenFile": "Buka file",
|
||||||
"fileEditDiffTruncated": "Perbedaan dipotong. Buka file untuk melihat perubahan lengkap.",
|
"fileEditDiffTruncated": "Diff dipotong. Buka file untuk melihat perubahan lengkap.",
|
||||||
"activityThinkingFor": "Berpikir selama {{duration}}",
|
"activityThinkingFor": "Berpikir selama {{duration}}",
|
||||||
"activityThought": "Selesai berpikir",
|
"activityThought": "Selesai berpikir",
|
||||||
"activityThoughtFor": "Selesai berpikir dalam {{duration}}",
|
"activityThoughtFor": "Selesai berpikir dalam {{duration}}",
|
||||||
@@ -1319,7 +1295,6 @@
|
|||||||
},
|
},
|
||||||
"filePreview": {
|
"filePreview": {
|
||||||
"aria": "Pratinjau file",
|
"aria": "Pratinjau file",
|
||||||
"breadcrumb": "Jalur file",
|
|
||||||
"close": "Tutup pratinjau file",
|
"close": "Tutup pratinjau file",
|
||||||
"loading": "Memuat pratinjau...",
|
"loading": "Memuat pratinjau...",
|
||||||
"failed": "Tidak dapat mempratinjau file ini.",
|
"failed": "Tidak dapat mempratinjau file ini.",
|
||||||
@@ -1334,10 +1309,7 @@
|
|||||||
"copied": "Tersalin"
|
"copied": "Tersalin"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"dismiss": "Tutup",
|
"dismiss": "Tutup"
|
||||||
"close": "Tutup",
|
|
||||||
"current": "Saat ini",
|
|
||||||
"cancel": "Batal"
|
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"messageTooBig": {
|
"messageTooBig": {
|
||||||
@@ -1345,8 +1317,8 @@
|
|||||||
"body": "Server menolak pesan terakhir karena melebihi batas ukuran. Hapus beberapa gambar atau gunakan berkas yang lebih kecil, lalu coba lagi."
|
"body": "Server menolak pesan terakhir karena melebihi batas ukuran. Hapus beberapa gambar atau gunakan berkas yang lebih kecil, lalu coba lagi."
|
||||||
},
|
},
|
||||||
"workspaceScopeRejected": {
|
"workspaceScopeRejected": {
|
||||||
"title": "Ruang kerja tidak berubah",
|
"title": "Workspace tidak berubah",
|
||||||
"body": "Gateway menolak proyek atau mode akses yang diminta, jadi Nanobot tetap memakai ruang kerja sebelumnya."
|
"body": "Gateway menolak proyek atau mode akses yang diminta, jadi Nanobot tetap memakai workspace sebelumnya."
|
||||||
},
|
},
|
||||||
"turnRejected": {
|
"turnRejected": {
|
||||||
"title": "Pesan tidak terkirim",
|
"title": "Pesan tidak terkirim",
|
||||||
@@ -1355,7 +1327,7 @@
|
|||||||
},
|
},
|
||||||
"workspace": {
|
"workspace": {
|
||||||
"dialog": {
|
"dialog": {
|
||||||
"defaultProject": "Ruang kerja bawaan",
|
"defaultProject": "Workspace default",
|
||||||
"manual": "Tempel path",
|
"manual": "Tempel path",
|
||||||
"manualPlaceholder": "/Users/name/project",
|
"manualPlaceholder": "/Users/name/project",
|
||||||
"usePath": "Gunakan path",
|
"usePath": "Gunakan path",
|
||||||
|
|||||||
@@ -38,20 +38,12 @@
|
|||||||
},
|
},
|
||||||
"meta": {
|
"meta": {
|
||||||
"description": "nanobot Web UI — nanobot ワークスペースと会話します。"
|
"description": "nanobot Web UI — nanobot ワークスペースと会話します。"
|
||||||
},
|
|
||||||
"pairing": {
|
|
||||||
"title": "チャットユーザーをペアリング",
|
|
||||||
"description": "チャットに表示されたペアリングコードを入力してください。",
|
|
||||||
"code": "ペアリングコード",
|
|
||||||
"matched": "{{channel}} と一致しました。接続中…",
|
|
||||||
"expiresInline": "コードの有効期限: {{expires}}。",
|
|
||||||
"queueCount": "{{count}} 件待機中",
|
|
||||||
"noMatch": "このコードに一致する保留中のリクエストはありません。"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"navigation": "サイドバーのナビゲーション",
|
"navigation": "サイドバーのナビゲーション",
|
||||||
"collapse": "サイドバーを閉じる",
|
"collapse": "サイドバーを閉じる",
|
||||||
|
"quickChat": "クイックチャット",
|
||||||
"newChat": "新しいトピック",
|
"newChat": "新しいトピック",
|
||||||
"searchAria": "検索",
|
"searchAria": "検索",
|
||||||
"searchPlaceholder": "検索",
|
"searchPlaceholder": "検索",
|
||||||
@@ -69,6 +61,17 @@
|
|||||||
"title": "スキル"
|
"title": "スキル"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"quickChat": {
|
||||||
|
"greeting": "何について話しますか?",
|
||||||
|
"temporary": {
|
||||||
|
"title": "一時チャット",
|
||||||
|
"enter": "一時チャット",
|
||||||
|
"active": "一時チャット中",
|
||||||
|
"exit": "一時チャットを終了",
|
||||||
|
"greeting": "一時チャットを始める",
|
||||||
|
"description": "履歴、メモリ、ツール、プロジェクトにはアクセスしません。内容は選択したモデル提供元に送信されます。"
|
||||||
|
}
|
||||||
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"backToChat": "チャットに戻る",
|
"backToChat": "チャットに戻る",
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
@@ -106,7 +109,7 @@
|
|||||||
"imageDefaults": "既定値",
|
"imageDefaults": "既定値",
|
||||||
"webSearch": "ウェブ検索",
|
"webSearch": "ウェブ検索",
|
||||||
"webBehavior": "動作",
|
"webBehavior": "動作",
|
||||||
"regional": "地域",
|
"identity": "ID",
|
||||||
"webuiSafety": "WebUI の安全性",
|
"webuiSafety": "WebUI の安全性",
|
||||||
"capabilities": "機能",
|
"capabilities": "機能",
|
||||||
"cliApps": "CLI アプリ",
|
"cliApps": "CLI アプリ",
|
||||||
@@ -145,6 +148,8 @@
|
|||||||
"defaultImageSize": "既定のサイズ",
|
"defaultImageSize": "既定のサイズ",
|
||||||
"maxImagesPerTurn": "1 ターンの最大画像数",
|
"maxImagesPerTurn": "1 ターンの最大画像数",
|
||||||
"imageSaveDir": "保存先ディレクトリ",
|
"imageSaveDir": "保存先ディレクトリ",
|
||||||
|
"botName": "Bot 名",
|
||||||
|
"botIcon": "Bot アイコン",
|
||||||
"timezone": "タイムゾーン",
|
"timezone": "タイムゾーン",
|
||||||
"workspacePath": "既定のワークスペース",
|
"workspacePath": "既定のワークスペース",
|
||||||
"localServiceAccess": "ローカルサービス",
|
"localServiceAccess": "ローカルサービス",
|
||||||
@@ -171,9 +176,9 @@
|
|||||||
"model": "このプリセットで使用するモデルを選択します。",
|
"model": "このプリセットで使用するモデルを選択します。",
|
||||||
"configPath": "現在ゲートウェイが使用している設定ファイルです。",
|
"configPath": "現在ゲートウェイが使用している設定ファイルです。",
|
||||||
"selectedPreset": "名前付きプリセットはここでは読み取り専用です。編集するには config.json を変更してください。",
|
"selectedPreset": "名前付きプリセットはここでは読み取り専用です。編集するには config.json を変更してください。",
|
||||||
"presetModel": "既定に切り替えると、WebUI からモデルとプロバイダーを編集できます。",
|
"presetModel": "Default に切り替えると、WebUI からモデルとプロバイダーを編集できます。",
|
||||||
"density": "このブラウザーにのみ保存されます。",
|
"density": "このブラウザーにのみ保存されます。",
|
||||||
"activityMode": "既定で表示するエージェントアクティビティの詳細量を選択します。",
|
"activityMode": "既定で表示する agent アクティビティの詳細量を選択します。",
|
||||||
"fileEditDisplay": "ファイル編集アクティビティを行数または差分で表示します。",
|
"fileEditDisplay": "ファイル編集アクティビティを行数または差分で表示します。",
|
||||||
"codeWrap": "小さな画面でも長いコード行を読みやすくします。",
|
"codeWrap": "小さな画面でも長いコード行を読みやすくします。",
|
||||||
"maxResults": "各 web_search 呼び出しで返す結果数です。",
|
"maxResults": "各 web_search 呼び出しで返す結果数です。",
|
||||||
@@ -181,13 +186,15 @@
|
|||||||
"jinaReader": "利用可能な場合、web_fetch に Jina Reader を使います。",
|
"jinaReader": "利用可能な場合、web_fetch に Jina Reader を使います。",
|
||||||
"imageGeneration": "画像プロバイダーが設定済みのとき、チャットで generate_image を有効にします。",
|
"imageGeneration": "画像プロバイダーが設定済みのとき、チャットで generate_image を有効にします。",
|
||||||
"imageProvider": "generate_image で使用する登録済みプロバイダーを選択します。",
|
"imageProvider": "generate_image で使用する登録済みプロバイダーを選択します。",
|
||||||
"imageProviderStatus": "画像生成はプロバイダー設定の認証情報を再利用します。",
|
"imageProviderStatus": "画像生成は「プロバイダー」の認証情報を再利用します。",
|
||||||
"imageModel": "選択した画像プロバイダーへ送信するモデル名です。",
|
"imageModel": "選択した画像プロバイダーへ送信するモデル名です。",
|
||||||
"defaultAspectRatio": "プロンプトで比率が指定されていない場合に使用します。",
|
"defaultAspectRatio": "プロンプトで比率が指定されていない場合に使用します。",
|
||||||
"defaultImageSize": "対応しているプロバイダーへ送信するサイズ指定です。",
|
"defaultImageSize": "対応しているプロバイダーへ送信するサイズ指定です。",
|
||||||
"maxImagesPerTurn": "1 回の generate_image リクエストで生成できる画像数の上限です。",
|
"maxImagesPerTurn": "1 回の generate_image リクエストで生成できる画像数の上限です。",
|
||||||
|
"botName": "nanobot が表示名を使う場所に表示されます。",
|
||||||
|
"botIcon": "Bot 名の横に表示する短い emoji またはテキストです。",
|
||||||
"timezone": "スケジュールと時刻を考慮する返信に使用します。",
|
"timezone": "スケジュールと時刻を考慮する返信に使用します。",
|
||||||
"localServiceAccess": "フルアクセスの shell コマンドが localhost サービスにアクセスできるようにします。",
|
"localServiceAccess": "Full Access の shell コマンドが localhost サービスにアクセスできるようにします。",
|
||||||
"webuiDefaultAccess": "プロジェクト固有の権限がない Web チャットで使用します。",
|
"webuiDefaultAccess": "プロジェクト固有の権限がない Web チャットで使用します。",
|
||||||
"securityManagedControls": "Web 取得は常にローカル、プライベート、メタデータサービスを保護します。コアチャネルの安全性は config.json で管理されます。",
|
"securityManagedControls": "Web 取得は常にローカル、プライベート、メタデータサービスを保護します。コアチャネルの安全性は config.json で管理されます。",
|
||||||
"currentModel": "新しい返信に使用します。",
|
"currentModel": "新しい返信に使用します。",
|
||||||
@@ -198,7 +205,7 @@
|
|||||||
"cliAppsFilter": "アプリ、カテゴリ、機能で検索します。",
|
"cliAppsFilter": "アプリ、カテゴリ、機能で検索します。",
|
||||||
"logs": "ネイティブエンジンのログフォルダーを開きます。",
|
"logs": "ネイティブエンジンのログフォルダーを開きます。",
|
||||||
"diagnostics": "サポート用の小さなランタイムレポートを書き出します。",
|
"diagnostics": "サポート用の小さなランタイムレポートを書き出します。",
|
||||||
"localServiceAccessNative": "フルアクセスの shell コマンドがこの Mac 上のサービスにアクセスできるようにします。",
|
"localServiceAccessNative": "Full Access の shell コマンドがこの Mac 上のサービスにアクセスできるようにします。",
|
||||||
"webuiDefaultAccessNative": "プロジェクト固有の権限がないネイティブチャットで使用します。",
|
"webuiDefaultAccessNative": "プロジェクト固有の権限がないネイティブチャットで使用します。",
|
||||||
"contextWindow": "このモデル設定で使う既定のコンテキスト予算を選択します。",
|
"contextWindow": "このモデル設定で使う既定のコンテキスト予算を選択します。",
|
||||||
"transcription": "マイク入力を送信前に文字起こしします。チャネルの音声メッセージも同じ設定を使います。",
|
"transcription": "マイク入力を送信前に文字起こしします。チャネルの音声メッセージも同じ設定を使います。",
|
||||||
@@ -217,12 +224,12 @@
|
|||||||
"ready": "準備完了",
|
"ready": "準備完了",
|
||||||
"privateEngine": "プライベートエンジン",
|
"privateEngine": "プライベートエンジン",
|
||||||
"unixSocket": "Unix ソケット",
|
"unixSocket": "Unix ソケット",
|
||||||
"defaultWorkspace": "既定のワークスペース",
|
"defaultWorkspace": "デフォルトワークスペース",
|
||||||
"comfortable": "標準",
|
"comfortable": "標準",
|
||||||
"compact": "コンパクト",
|
"compact": "コンパクト",
|
||||||
"auto": "自動",
|
"auto": "自動",
|
||||||
"expanded": "展開",
|
"expanded": "展開",
|
||||||
"default": "既定",
|
"default": "デフォルト",
|
||||||
"summary": "概要",
|
"summary": "概要",
|
||||||
"diff": "差分",
|
"diff": "差分",
|
||||||
"collapsedDiff": "折りたたみ差分",
|
"collapsedDiff": "折りたたみ差分",
|
||||||
@@ -233,10 +240,7 @@
|
|||||||
"configured": "設定済み",
|
"configured": "設定済み",
|
||||||
"notConfigured": "未設定",
|
"notConfigured": "未設定",
|
||||||
"pending": "保留中",
|
"pending": "保留中",
|
||||||
"restartingEngine": "再起動中",
|
"restartingEngine": "再起動中"
|
||||||
"checking": "確認中",
|
|
||||||
"running": "実行中",
|
|
||||||
"needsSetup": "設定が必要"
|
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "設定を読み込んでいます...",
|
"loading": "設定を読み込んでいます...",
|
||||||
@@ -264,31 +268,30 @@
|
|||||||
"deleting": "削除中...",
|
"deleting": "削除中...",
|
||||||
"edit": "編集",
|
"edit": "編集",
|
||||||
"cancel": "キャンセル",
|
"cancel": "キャンセル",
|
||||||
"dismiss": "閉じる",
|
|
||||||
"open": "開く",
|
"open": "開く",
|
||||||
"export": "書き出す",
|
"export": "書き出す",
|
||||||
"opening": "開いています...",
|
"opening": "開いています...",
|
||||||
"exporting": "書き出しています..."
|
"exporting": "書き出しています..."
|
||||||
},
|
},
|
||||||
"byok": {
|
"byok": {
|
||||||
"description": "自分のプロバイダーキーを使います。Nanobot は現在の設定から値を読み込み、設定済みのプロバイダーだけをモデルプリセットで使用できます。",
|
"description": "自分の provider キーを使います。Nanobot は現在の config から値を読み込み、設定済みの provider だけをモデルプリセットで使用できます。",
|
||||||
"configured": "設定済み",
|
"configured": "設定済み",
|
||||||
"notConfigured": "未設定",
|
"notConfigured": "未設定",
|
||||||
"configuredSection": "設定済み",
|
"configuredSection": "設定済み",
|
||||||
"notConfiguredSection": "未設定",
|
"notConfiguredSection": "未設定",
|
||||||
"showMore": "さらに {{count}} 件表示",
|
"showMore": "さらに {{count}} 件表示",
|
||||||
"showLess": "折りたたむ",
|
"showLess": "折りたたむ",
|
||||||
"apiKey": "API キー",
|
"apiKey": "API key",
|
||||||
"apiBase": "API ベース",
|
"apiBase": "API base",
|
||||||
"apiKeyPlaceholder": "API キーを入力",
|
"apiKeyPlaceholder": "API key を入力",
|
||||||
"apiKeyConfiguredPlaceholder": "空欄のままなら現在の key を保持",
|
"apiKeyConfiguredPlaceholder": "空欄のままなら現在の key を保持",
|
||||||
"configuredKeyHint": "設定済み key",
|
"configuredKeyHint": "設定済み key",
|
||||||
"apiBasePlaceholder": "プロバイダーの既定値を使用",
|
"apiBasePlaceholder": "provider の既定値を使用",
|
||||||
"apiKeyRequired": "このプロバイダーを設定するには API キーが必要です。",
|
"apiKeyRequired": "この provider を設定するには API key が必要です。",
|
||||||
"showApiKey": "API キーを表示",
|
"showApiKey": "API key を表示",
|
||||||
"hideApiKey": "API キーを隠す",
|
"hideApiKey": "API key を隠す",
|
||||||
"noConfiguredProviders": "設定済みプロバイダーがありません",
|
"noConfiguredProviders": "設定済み provider がありません",
|
||||||
"configureFirst": "先に BYOK でプロバイダーを設定してください。",
|
"configureFirst": "先に BYOK で provider を設定してください。",
|
||||||
"openByok": "BYOK を開く",
|
"openByok": "BYOK を開く",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"ariaLabel": "BYOK 認証情報タイプ",
|
"ariaLabel": "BYOK 認証情報タイプ",
|
||||||
@@ -296,20 +299,20 @@
|
|||||||
"webSearch": "ウェブ検索"
|
"webSearch": "ウェブ検索"
|
||||||
},
|
},
|
||||||
"webSearch": {
|
"webSearch": {
|
||||||
"provider": "検索プロバイダー",
|
"provider": "検索 provider",
|
||||||
"providerHelp": "Web 検索ツールで使うバックエンドを選択します。",
|
"providerHelp": "web search ツールで使うバックエンドを選択します。",
|
||||||
"selectProvider": "プロバイダーを選択",
|
"selectProvider": "provider を選択",
|
||||||
"credentials": "認証情報",
|
"credentials": "認証情報",
|
||||||
"noCredentialRequired": "key は不要",
|
"noCredentialRequired": "key は不要",
|
||||||
"noCredentialHelp": "DuckDuckGo は API キーを保存せずに使えます。",
|
"noCredentialHelp": "DuckDuckGo は API key を保存せずに使えます。",
|
||||||
"apiKeyHelp": "config に保存され、保存後はマスク表示されます。",
|
"apiKeyHelp": "config に保存され、保存後はマスク表示されます。",
|
||||||
"baseUrl": "ベース URL",
|
"baseUrl": "Base URL",
|
||||||
"baseUrlHelp": "SearXNG には自分のインスタンス URL が必要です。",
|
"baseUrlHelp": "SearXNG には自分のインスタンス URL が必要です。",
|
||||||
"baseUrlPlaceholder": "https://search.example.com",
|
"baseUrlPlaceholder": "https://search.example.com",
|
||||||
"apiKeyRequired": "この検索プロバイダーには API キーが必要です。",
|
"apiKeyRequired": "この検索 provider には API key が必要です。",
|
||||||
"baseUrlRequired": "SearXNG にはベース URL が必要です。",
|
"baseUrlRequired": "SearXNG には Base URL が必要です。",
|
||||||
"missingCredential": "保存する前に必要な認証情報を入力してください。",
|
"missingCredential": "保存する前に必要な認証情報を入力してください。",
|
||||||
"saveHint": "変更は新しい Web 検索リクエストに適用されます。"
|
"saveHint": "変更は新しい web search リクエストに適用されます。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"overview": {
|
"overview": {
|
||||||
@@ -323,13 +326,13 @@
|
|||||||
"workspace": "ワークスペース"
|
"workspace": "ワークスペース"
|
||||||
},
|
},
|
||||||
"usage": {
|
"usage": {
|
||||||
"title": "トークンアクティビティ",
|
"title": "Token アクティビティ",
|
||||||
"shortTitle": "トークン使用量",
|
"shortTitle": "Token Usage",
|
||||||
"subtitle": "直近 12 か月にプロバイダーが報告したトークン使用量。",
|
"subtitle": "直近 12 か月にプロバイダーが報告した使用量。",
|
||||||
"empty": "新しいモデル返信の後にトークンアクティビティが表示されます。",
|
"empty": "新しいモデル返信の後に token アクティビティが表示されます。",
|
||||||
"totalTokens": "累計トークン数",
|
"totalTokens": "累計 Token 数",
|
||||||
"peakTokens": "ピークトークン数",
|
"peakTokens": "ピーク Token 数",
|
||||||
"thirtyDayTokens": "30 日間のトークン数",
|
"thirtyDayTokens": "30 日 Token 数",
|
||||||
"currentStreak": "現在の連続日数",
|
"currentStreak": "現在の連続日数",
|
||||||
"longestStreak": "最長連続日数",
|
"longestStreak": "最長連続日数",
|
||||||
"daysValue": "{{count}} 日",
|
"daysValue": "{{count}} 日",
|
||||||
@@ -338,7 +341,7 @@
|
|||||||
"requests": "リクエスト",
|
"requests": "リクエスト",
|
||||||
"estimated": "推定",
|
"estimated": "推定",
|
||||||
"includesEstimates": "推定を含む",
|
"includesEstimates": "推定を含む",
|
||||||
"cellTitle": "{{date}}: {{tokens}} トークン、{{requests}} 件のリクエスト",
|
"cellTitle": "{{date}}: {{tokens}} tokens, {{requests}} 件のリクエスト",
|
||||||
"sources": {
|
"sources": {
|
||||||
"user": "チャット",
|
"user": "チャット",
|
||||||
"api": "API",
|
"api": "API",
|
||||||
@@ -371,19 +374,9 @@
|
|||||||
"selectProvider": "プロバイダーを選択",
|
"selectProvider": "プロバイダーを選択",
|
||||||
"selectAspect": "比率を選択",
|
"selectAspect": "比率を選択",
|
||||||
"selectSize": "サイズを選択",
|
"selectSize": "サイズを選択",
|
||||||
"selectModel": "画像モデルを選択",
|
|
||||||
"searchOrTypeModel": "モデル ID を検索または入力",
|
|
||||||
"typeModelId": "このプロバイダーが対応するモデル ID を入力してください。",
|
|
||||||
"configureProvider": "プロバイダーを設定",
|
"configureProvider": "プロバイダーを設定",
|
||||||
"missingCredential": "画像生成を有効にする前に、このプロバイダーを設定してください。"
|
"missingCredential": "画像生成を有効にする前に、このプロバイダーを設定してください。"
|
||||||
},
|
},
|
||||||
"capabilities": {
|
|
||||||
"providerSupport": "プロバイダーサポート",
|
|
||||||
"providerInstallOnSave": "このプロバイダーを保存すると、必要なサポートが自動的にインストールされます。",
|
|
||||||
"searchSupport": "検索プロバイダーサポート",
|
|
||||||
"searchInstallOnSave": "保存時に Olostep のサポートが自動的にインストールされます。",
|
|
||||||
"installing": "サポートをインストール中..."
|
|
||||||
},
|
|
||||||
"models": {
|
"models": {
|
||||||
"selectModel": "モデルを選択",
|
"selectModel": "モデルを選択",
|
||||||
"addConfiguration": "設定を追加",
|
"addConfiguration": "設定を追加",
|
||||||
@@ -406,7 +399,7 @@
|
|||||||
"advancedOptions": "詳細オプション",
|
"advancedOptions": "詳細オプション",
|
||||||
"advancedSummary": "コンテキスト {{context}} · 最大 {{max}} トークン",
|
"advancedSummary": "コンテキスト {{context}} · 最大 {{max}} トークン",
|
||||||
"maxTokens": "最大出力トークン",
|
"maxTokens": "最大出力トークン",
|
||||||
"temperature": "温度",
|
"temperature": "Temperature",
|
||||||
"reasoningEffort": "推論の強度",
|
"reasoningEffort": "推論の強度",
|
||||||
"convertTitle": "現在のモデル設定を変換",
|
"convertTitle": "現在のモデル設定を変換",
|
||||||
"convertHelp": "既存のプライマリモデルとフォールバックモデルをプリセットに変換し、ここで順序を管理できるようにします。",
|
"convertHelp": "既存のプライマリモデルとフォールバックモデルをプリセットに変換し、ここで順序を管理できるようにします。",
|
||||||
@@ -496,9 +489,9 @@
|
|||||||
"serverUrl": "URL",
|
"serverUrl": "URL",
|
||||||
"transport": "トランスポート",
|
"transport": "トランスポート",
|
||||||
"command": "コマンド",
|
"command": "コマンド",
|
||||||
"args": "引数 JSON",
|
"args": "Args JSON",
|
||||||
"headers": "ヘッダー JSON",
|
"headers": "Headers JSON",
|
||||||
"env": "環境変数 JSON",
|
"env": "Env JSON",
|
||||||
"timeout": "ツールのタイムアウト",
|
"timeout": "ツールのタイムアウト",
|
||||||
"advancedOptions": "詳細オプション",
|
"advancedOptions": "詳細オプション",
|
||||||
"hideAdvanced": "詳細を隠す",
|
"hideAdvanced": "詳細を隠す",
|
||||||
@@ -526,7 +519,6 @@
|
|||||||
"statusMissingCredentials": "キーが必要",
|
"statusMissingCredentials": "キーが必要",
|
||||||
"statusMissingDependency": "依存関係が必要",
|
"statusMissingDependency": "依存関係が必要",
|
||||||
"statusComingSoon": "近日公開",
|
"statusComingSoon": "近日公開",
|
||||||
"comingSoon": "近日公開",
|
|
||||||
"statusNotInstalled": "未有効",
|
"statusNotInstalled": "未有効",
|
||||||
"toolScope": "ツール",
|
"toolScope": "ツール",
|
||||||
"allTools": "すべて",
|
"allTools": "すべて",
|
||||||
@@ -589,8 +581,6 @@
|
|||||||
"advanced": "詳細設定",
|
"advanced": "詳細設定",
|
||||||
"checkAndEnable": "確認して有効化",
|
"checkAndEnable": "確認して有効化",
|
||||||
"checkConnection": "接続を確認",
|
"checkConnection": "接続を確認",
|
||||||
"connectionChecks": "接続チェック",
|
|
||||||
"open": "開く",
|
|
||||||
"checkedAndEnabled": "確認して有効化しました。",
|
"checkedAndEnabled": "確認して有効化しました。",
|
||||||
"checking": "確認中...",
|
"checking": "確認中...",
|
||||||
"checkOnly": "確認のみ",
|
"checkOnly": "確認のみ",
|
||||||
@@ -686,8 +676,6 @@
|
|||||||
"protected": "保護済み",
|
"protected": "保護済み",
|
||||||
"editTitle": "自動タスクを編集",
|
"editTitle": "自動タスクを編集",
|
||||||
"save": "保存",
|
"save": "保存",
|
||||||
"commandCopied": "コピーしました",
|
|
||||||
"copyCommand": "コピー",
|
|
||||||
"deleteTitle": "自動タスクを削除",
|
"deleteTitle": "自動タスクを削除",
|
||||||
"deleteDescription": "{{name}} を cron ストアから削除します。過去のチャットメッセージはセッションに残ります。",
|
"deleteDescription": "{{name}} を cron ストアから削除します。過去のチャットメッセージはセッションに残ります。",
|
||||||
"cancel": "キャンセル",
|
"cancel": "キャンセル",
|
||||||
@@ -747,7 +735,6 @@
|
|||||||
"fields": {
|
"fields": {
|
||||||
"name": "名前",
|
"name": "名前",
|
||||||
"message": "メッセージ",
|
"message": "メッセージ",
|
||||||
"command": "コマンド",
|
|
||||||
"scheduleType": "スケジュール種別",
|
"scheduleType": "スケジュール種別",
|
||||||
"every": "間隔",
|
"every": "間隔",
|
||||||
"unit": "単位",
|
"unit": "単位",
|
||||||
@@ -782,7 +769,7 @@
|
|||||||
"signInAgain": "再度サインイン",
|
"signInAgain": "再度サインイン",
|
||||||
"signOut": "サインアウト",
|
"signOut": "サインアウト",
|
||||||
"signedInAs": "{{account}} としてサインイン済み",
|
"signedInAs": "{{account}} としてサインイン済み",
|
||||||
"signInHelp": "このデバイスからサインインします。API キーは設定に保存されません。",
|
"signInHelp": "このデバイスからサインインします。API key は config に保存されません。",
|
||||||
"remoteSignInHelp": "「サインイン」を選択して自分のコンピューターで xAI を開き、サインイン後に表示される認証コードを貼り付けてください。",
|
"remoteSignInHelp": "「サインイン」を選択して自分のコンピューターで xAI を開き、サインイン後に表示される認証コードを貼り付けてください。",
|
||||||
"codexRemoteSignInHelp": "このブラウザーでサインインし、localhost の完全なコールバック URL を nanobot に貼り付けてください。",
|
"codexRemoteSignInHelp": "このブラウザーでサインインし、localhost の完全なコールバック URL を nanobot に貼り付けてください。",
|
||||||
"signInRequired": "サインインが必要です",
|
"signInRequired": "サインインが必要です",
|
||||||
@@ -906,34 +893,34 @@
|
|||||||
"actions": "「{{title}}」のトピック操作",
|
"actions": "「{{title}}」のトピック操作",
|
||||||
"newInProject": "「{{project}}」で新しいトピックを開始",
|
"newInProject": "「{{project}}」で新しいトピックを開始",
|
||||||
"activity": {
|
"activity": {
|
||||||
"running": "エージェント実行中",
|
"running": "Agent running",
|
||||||
"complete": "エージェント完了",
|
"complete": "Agent finished",
|
||||||
"updated": "新しいアクティビティ"
|
"updated": "New activity"
|
||||||
},
|
},
|
||||||
"pin": "ピン留め",
|
"pin": "Pin",
|
||||||
"unpin": "ピン留めを解除",
|
"unpin": "Unpin",
|
||||||
"rename": "名前を変更",
|
"rename": "Rename",
|
||||||
"renameTitle": "トピック名を変更",
|
"renameTitle": "トピック名を変更",
|
||||||
"renameDescription": "このトピックのサイドバー表示名を選択します。",
|
"renameDescription": "このトピックのサイドバー表示名を選択します。",
|
||||||
"renamePlaceholder": "トピック名",
|
"renamePlaceholder": "トピック名",
|
||||||
"renameProjectTitle": "プロジェクト名を変更",
|
"renameProjectTitle": "Rename project",
|
||||||
"renameProjectDescription": "このプロジェクトのサイドバー表示名を選択します。",
|
"renameProjectDescription": "Choose a local sidebar name for this project.",
|
||||||
"renameProjectPlaceholder": "プロジェクト名",
|
"renameProjectPlaceholder": "Project name",
|
||||||
"renameSave": "保存",
|
"renameSave": "Save",
|
||||||
"archive": "アーカイブ",
|
"archive": "Archive",
|
||||||
"unarchive": "アーカイブを解除",
|
"unarchive": "Unarchive",
|
||||||
"showArchived": "アーカイブ済みを表示",
|
"showArchived": "Show archived",
|
||||||
"hideArchived": "アーカイブ済みを隠す",
|
"hideArchived": "Hide archived",
|
||||||
"delete": "削除",
|
"delete": "削除",
|
||||||
"newChat": "新しいトピック",
|
"newChat": "新しいトピック",
|
||||||
"groups": {
|
"groups": {
|
||||||
"pinned": "ピン留め",
|
"pinned": "Pinned",
|
||||||
"all": "トピック",
|
"all": "トピック",
|
||||||
"projects": "プロジェクト",
|
"projects": "Projects",
|
||||||
"today": "今日",
|
"today": "Today",
|
||||||
"yesterday": "昨日",
|
"yesterday": "Yesterday",
|
||||||
"earlier": "以前",
|
"earlier": "Earlier",
|
||||||
"archived": "アーカイブ済み"
|
"archived": "Archived"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"deleteConfirm": {
|
"deleteConfirm": {
|
||||||
@@ -1138,7 +1125,7 @@
|
|||||||
},
|
},
|
||||||
"stop": {
|
"stop": {
|
||||||
"title": "現在のタスクを停止",
|
"title": "現在のタスクを停止",
|
||||||
"description": "このチャットで実行中のエージェントのターンをキャンセルします。"
|
"description": "このチャットで実行中の agent ターンをキャンセルします。"
|
||||||
},
|
},
|
||||||
"restart": {
|
"restart": {
|
||||||
"title": "nanobot を再起動",
|
"title": "nanobot を再起動",
|
||||||
@@ -1146,7 +1133,7 @@
|
|||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"title": "ステータスを表示",
|
"title": "ステータスを表示",
|
||||||
"description": "ランタイム、プロバイダー、チャンネルの状態を表示します。"
|
"description": "ランタイム、provider、channel の状態を表示します。"
|
||||||
},
|
},
|
||||||
"model": {
|
"model": {
|
||||||
"title": "モデル",
|
"title": "モデル",
|
||||||
@@ -1224,9 +1211,7 @@
|
|||||||
"cliBadge": "CLI",
|
"cliBadge": "CLI",
|
||||||
"mcpBadge": "MCP",
|
"mcpBadge": "MCP",
|
||||||
"cliDescription": "@{{name}} をローカル CLI アプリとして使用",
|
"cliDescription": "@{{name}} をローカル CLI アプリとして使用",
|
||||||
"mcpDescription": "@{{name}} を MCP サーバーとして使用",
|
"mcpDescription": "@{{name}} を MCP サーバーとして使用"
|
||||||
"cliTitle": "CLI アプリ: {{name}}",
|
|
||||||
"mcpTitle": "MCP サーバー: {{name}}"
|
|
||||||
},
|
},
|
||||||
"workspace": {
|
"workspace": {
|
||||||
"accessAria": "ワークスペースのアクセスモード",
|
"accessAria": "ワークスペースのアクセスモード",
|
||||||
@@ -1246,8 +1231,7 @@
|
|||||||
"title": "プロンプト",
|
"title": "プロンプト",
|
||||||
"search": "プロンプトを検索",
|
"search": "プロンプトを検索",
|
||||||
"noResults": "一致するプロンプトがありません。",
|
"noResults": "一致するプロンプトがありません。",
|
||||||
"jumpTo": "プロンプトへ移動: {{label}}",
|
"jumpTo": "プロンプトへ移動: {{label}}"
|
||||||
"railAria": "ユーザープロンプトのナビゲーション"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
@@ -1271,14 +1255,6 @@
|
|||||||
"agentActivityLiveSummary": "実行中… · {{reasoning}} ステップ · ツール呼び出し {{tools}} 回",
|
"agentActivityLiveSummary": "実行中… · {{reasoning}} ステップ · ツール呼び出し {{tools}} 回",
|
||||||
"agentActivityLiveToolsOnly": "実行中… · ツール呼び出し {{tools}} 回",
|
"agentActivityLiveToolsOnly": "実行中… · ツール呼び出し {{tools}} 回",
|
||||||
"imageAttachment": "画像の添付",
|
"imageAttachment": "画像の添付",
|
||||||
"videoAttachment": "動画の添付",
|
|
||||||
"fileAttachment": "ファイルの添付",
|
|
||||||
"attachmentUnavailable": "添付ファイルを利用できません",
|
|
||||||
"dataTable": "データテーブル",
|
|
||||||
"fileEditPreparing": "ファイル編集を準備中…",
|
|
||||||
"openLink": "リンクを開く: {{label}}",
|
|
||||||
"openAttachment": "開く: {{name}}",
|
|
||||||
"skill": "スキル: {{name}}",
|
|
||||||
"askAboutSelection": "この内容について質問",
|
"askAboutSelection": "この内容について質問",
|
||||||
"forkFromHere": "分岐",
|
"forkFromHere": "分岐",
|
||||||
"copyReply": "コピー",
|
"copyReply": "コピー",
|
||||||
@@ -1319,7 +1295,6 @@
|
|||||||
},
|
},
|
||||||
"filePreview": {
|
"filePreview": {
|
||||||
"aria": "ファイルプレビュー",
|
"aria": "ファイルプレビュー",
|
||||||
"breadcrumb": "ファイルパス",
|
|
||||||
"close": "ファイルプレビューを閉じる",
|
"close": "ファイルプレビューを閉じる",
|
||||||
"loading": "プレビューを読み込み中...",
|
"loading": "プレビューを読み込み中...",
|
||||||
"failed": "このファイルをプレビューできませんでした。",
|
"failed": "このファイルをプレビューできませんでした。",
|
||||||
@@ -1334,10 +1309,7 @@
|
|||||||
"copied": "コピーしました"
|
"copied": "コピーしました"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"dismiss": "閉じる",
|
"dismiss": "閉じる"
|
||||||
"close": "閉じる",
|
|
||||||
"current": "現在",
|
|
||||||
"cancel": "キャンセル"
|
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"messageTooBig": {
|
"messageTooBig": {
|
||||||
|
|||||||
@@ -38,20 +38,12 @@
|
|||||||
},
|
},
|
||||||
"meta": {
|
"meta": {
|
||||||
"description": "nanobot 웹 UI — nanobot 작업공간과 대화하세요."
|
"description": "nanobot 웹 UI — nanobot 작업공간과 대화하세요."
|
||||||
},
|
|
||||||
"pairing": {
|
|
||||||
"title": "채팅 사용자 연결",
|
|
||||||
"description": "채팅에 표시된 연결 코드를 입력하세요.",
|
|
||||||
"code": "연결 코드",
|
|
||||||
"matched": "{{channel}} 일치. 연결 중...",
|
|
||||||
"expiresInline": "코드 만료: {{expires}}.",
|
|
||||||
"queueCount": "{{count}}개 대기 중",
|
|
||||||
"noMatch": "이 코드와 일치하는 대기 중인 요청이 없습니다."
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"navigation": "사이드바 탐색",
|
"navigation": "사이드바 탐색",
|
||||||
"collapse": "사이드바 접기",
|
"collapse": "사이드바 접기",
|
||||||
|
"quickChat": "빠른 채팅",
|
||||||
"newChat": "새 주제",
|
"newChat": "새 주제",
|
||||||
"searchAria": "검색",
|
"searchAria": "검색",
|
||||||
"searchPlaceholder": "검색",
|
"searchPlaceholder": "검색",
|
||||||
@@ -69,6 +61,17 @@
|
|||||||
"title": "스킬"
|
"title": "스킬"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"quickChat": {
|
||||||
|
"greeting": "무슨 이야기를 나눠볼까요?",
|
||||||
|
"temporary": {
|
||||||
|
"title": "임시 채팅",
|
||||||
|
"enter": "임시 채팅",
|
||||||
|
"active": "임시 채팅 중",
|
||||||
|
"exit": "임시 채팅 종료",
|
||||||
|
"greeting": "임시 채팅 시작하기",
|
||||||
|
"description": "기록, 메모리, 도구, 프로젝트에 접근하지 않습니다. 내용은 선택한 모델 제공업체로 전송됩니다."
|
||||||
|
}
|
||||||
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"backToChat": "채팅으로 돌아가기",
|
"backToChat": "채팅으로 돌아가기",
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
@@ -106,7 +109,7 @@
|
|||||||
"imageDefaults": "기본값",
|
"imageDefaults": "기본값",
|
||||||
"webSearch": "웹 검색",
|
"webSearch": "웹 검색",
|
||||||
"webBehavior": "동작",
|
"webBehavior": "동작",
|
||||||
"regional": "지역",
|
"identity": "ID",
|
||||||
"webuiSafety": "WebUI 보안",
|
"webuiSafety": "WebUI 보안",
|
||||||
"capabilities": "기능",
|
"capabilities": "기능",
|
||||||
"cliApps": "CLI 앱",
|
"cliApps": "CLI 앱",
|
||||||
@@ -145,6 +148,8 @@
|
|||||||
"defaultImageSize": "기본 크기",
|
"defaultImageSize": "기본 크기",
|
||||||
"maxImagesPerTurn": "턴당 최대 이미지 수",
|
"maxImagesPerTurn": "턴당 최대 이미지 수",
|
||||||
"imageSaveDir": "저장 디렉터리",
|
"imageSaveDir": "저장 디렉터리",
|
||||||
|
"botName": "Bot 이름",
|
||||||
|
"botIcon": "Bot 아이콘",
|
||||||
"timezone": "시간대",
|
"timezone": "시간대",
|
||||||
"workspacePath": "기본 작업공간",
|
"workspacePath": "기본 작업공간",
|
||||||
"localServiceAccess": "로컬 서비스",
|
"localServiceAccess": "로컬 서비스",
|
||||||
@@ -171,10 +176,10 @@
|
|||||||
"model": "이 프리셋에서 사용할 모델을 선택하세요.",
|
"model": "이 프리셋에서 사용할 모델을 선택하세요.",
|
||||||
"configPath": "현재 게이트웨이가 사용하는 설정 파일입니다.",
|
"configPath": "현재 게이트웨이가 사용하는 설정 파일입니다.",
|
||||||
"selectedPreset": "이름 있는 프리셋은 여기서 읽기 전용입니다. config.json에서 편집하세요.",
|
"selectedPreset": "이름 있는 프리셋은 여기서 읽기 전용입니다. config.json에서 편집하세요.",
|
||||||
"presetModel": "기본값으로 전환하면 WebUI에서 모델과 제공자를 편집할 수 있습니다.",
|
"presetModel": "Default로 전환하면 WebUI에서 모델과 제공자를 편집할 수 있습니다.",
|
||||||
"density": "이 브라우저에만 저장됩니다.",
|
"density": "이 브라우저에만 저장됩니다.",
|
||||||
"activityMode": "기본으로 표시할 에이전트 활동 세부 수준을 선택합니다.",
|
"activityMode": "기본으로 표시할 agent 활동 세부 수준을 선택합니다.",
|
||||||
"fileEditDisplay": "파일 편집 활동을 줄 수 또는 변경 사항으로 표시할지 선택합니다.",
|
"fileEditDisplay": "파일 편집 활동을 줄 수 또는 diff로 표시할지 선택합니다.",
|
||||||
"codeWrap": "작은 화면에서도 긴 코드 줄을 읽기 쉽게 유지합니다.",
|
"codeWrap": "작은 화면에서도 긴 코드 줄을 읽기 쉽게 유지합니다.",
|
||||||
"maxResults": "각 web_search 호출에서 반환되는 결과 수입니다.",
|
"maxResults": "각 web_search 호출에서 반환되는 결과 수입니다.",
|
||||||
"timeout": "검색 제공자 요청이 타임아웃되기 전의 초입니다.",
|
"timeout": "검색 제공자 요청이 타임아웃되기 전의 초입니다.",
|
||||||
@@ -186,9 +191,11 @@
|
|||||||
"defaultAspectRatio": "프롬프트가 비율을 선택하지 않을 때 사용됩니다.",
|
"defaultAspectRatio": "프롬프트가 비율을 선택하지 않을 때 사용됩니다.",
|
||||||
"defaultImageSize": "지원하는 제공자에 보낼 크기 힌트입니다.",
|
"defaultImageSize": "지원하는 제공자에 보낼 크기 힌트입니다.",
|
||||||
"maxImagesPerTurn": "한 번의 generate_image 요청에서 생성할 수 있는 이미지 상한입니다.",
|
"maxImagesPerTurn": "한 번의 generate_image 요청에서 생성할 수 있는 이미지 상한입니다.",
|
||||||
|
"botName": "nanobot이 표시 이름을 사용하는 곳에 표시됩니다.",
|
||||||
|
"botIcon": "Bot 이름 옆에 표시할 짧은 emoji 또는 텍스트입니다.",
|
||||||
"timezone": "일정과 시간 인식 답변에 사용됩니다.",
|
"timezone": "일정과 시간 인식 답변에 사용됩니다.",
|
||||||
"localServiceAccess": "전체 접근 권한 shell 명령이 localhost 서비스에 접근할 수 있게 합니다.",
|
"localServiceAccess": "Full Access shell 명령이 localhost 서비스에 접근할 수 있게 합니다.",
|
||||||
"webuiDefaultAccess": "프로젝트별 권한이 없는 웹 채팅에 사용됩니다.",
|
"webuiDefaultAccess": "프로젝트별 권한이 없는 Web 채팅에 사용됩니다.",
|
||||||
"securityManagedControls": "웹 가져오기는 항상 로컬, 사설, 메타데이터 서비스를 보호합니다. 핵심 채널 보안은 config.json에서 관리됩니다.",
|
"securityManagedControls": "웹 가져오기는 항상 로컬, 사설, 메타데이터 서비스를 보호합니다. 핵심 채널 보안은 config.json에서 관리됩니다.",
|
||||||
"currentModel": "새 응답에 사용됩니다.",
|
"currentModel": "새 응답에 사용됩니다.",
|
||||||
"selectedModelProvider": "선택한 모델에 의해 설정됩니다.",
|
"selectedModelProvider": "선택한 모델에 의해 설정됩니다.",
|
||||||
@@ -198,12 +205,12 @@
|
|||||||
"cliAppsFilter": "앱, 카테고리 또는 기능으로 검색합니다.",
|
"cliAppsFilter": "앱, 카테고리 또는 기능으로 검색합니다.",
|
||||||
"logs": "네이티브 엔진 로그 폴더를 엽니다.",
|
"logs": "네이티브 엔진 로그 폴더를 엽니다.",
|
||||||
"diagnostics": "지원용 작은 런타임 보고서를 내보냅니다.",
|
"diagnostics": "지원용 작은 런타임 보고서를 내보냅니다.",
|
||||||
"localServiceAccessNative": "전체 접근 권한 shell 명령이 이 Mac의 서비스에 접근할 수 있게 합니다.",
|
"localServiceAccessNative": "Full Access shell 명령이 이 Mac의 서비스에 접근할 수 있게 합니다.",
|
||||||
"webuiDefaultAccessNative": "프로젝트별 권한이 없는 네이티브 채팅에 사용됩니다.",
|
"webuiDefaultAccessNative": "프로젝트별 권한이 없는 네이티브 채팅에 사용됩니다.",
|
||||||
"contextWindow": "이 모델 구성의 기본 컨텍스트 예산을 선택합니다.",
|
"contextWindow": "이 모델 구성의 기본 컨텍스트 예산을 선택합니다.",
|
||||||
"transcription": "마이크 입력을 보내기 전에 텍스트로 변환합니다. 채널 음성 메시지도 같은 설정을 사용합니다.",
|
"transcription": "마이크 입력을 보내기 전에 텍스트로 변환합니다. 채널 음성 메시지도 같은 설정을 사용합니다.",
|
||||||
"transcriptionProvider": "제공자 설정에 저장된 해당 제공자의 인증 정보를 사용합니다.",
|
"transcriptionProvider": "Providers에 저장된 해당 제공자의 인증 정보를 사용합니다.",
|
||||||
"transcriptionProviderStatus": "API 키는 음성 변환 설정이 아니라 제공자 설정에 유지됩니다.",
|
"transcriptionProviderStatus": "API 키는 transcription 설정이 아니라 providers 아래에 유지됩니다.",
|
||||||
"transcriptionModel": "제공자가 사용자 지정 모델 ID를 요구하지 않으면 해석된 기본값을 사용하세요.",
|
"transcriptionModel": "제공자가 사용자 지정 모델 ID를 요구하지 않으면 해석된 기본값을 사용하세요.",
|
||||||
"transcriptionLanguage": "en, zh, ja, ko 같은 선택적 ISO-639 힌트입니다."
|
"transcriptionLanguage": "en, zh, ja, ko 같은 선택적 ISO-639 힌트입니다."
|
||||||
},
|
},
|
||||||
@@ -224,8 +231,8 @@
|
|||||||
"expanded": "펼침",
|
"expanded": "펼침",
|
||||||
"default": "기본값",
|
"default": "기본값",
|
||||||
"summary": "요약",
|
"summary": "요약",
|
||||||
"diff": "변경 사항",
|
"diff": "Diff",
|
||||||
"collapsedDiff": "접힌 변경 사항",
|
"collapsedDiff": "접힌 diff",
|
||||||
"on": "켜짐",
|
"on": "켜짐",
|
||||||
"off": "꺼짐",
|
"off": "꺼짐",
|
||||||
"defaultPermission": "기본 권한",
|
"defaultPermission": "기본 권한",
|
||||||
@@ -233,10 +240,7 @@
|
|||||||
"configured": "구성됨",
|
"configured": "구성됨",
|
||||||
"notConfigured": "미구성",
|
"notConfigured": "미구성",
|
||||||
"pending": "대기 중",
|
"pending": "대기 중",
|
||||||
"restartingEngine": "재시작 중",
|
"restartingEngine": "재시작 중"
|
||||||
"checking": "확인 중",
|
|
||||||
"running": "실행 중",
|
|
||||||
"needsSetup": "설정 필요"
|
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "설정을 불러오는 중...",
|
"loading": "설정을 불러오는 중...",
|
||||||
@@ -264,31 +268,30 @@
|
|||||||
"deleting": "삭제 중...",
|
"deleting": "삭제 중...",
|
||||||
"edit": "편집",
|
"edit": "편집",
|
||||||
"cancel": "취소",
|
"cancel": "취소",
|
||||||
"dismiss": "닫기",
|
|
||||||
"open": "열기",
|
"open": "열기",
|
||||||
"export": "내보내기",
|
"export": "내보내기",
|
||||||
"opening": "여는 중...",
|
"opening": "여는 중...",
|
||||||
"exporting": "내보내는 중..."
|
"exporting": "내보내는 중..."
|
||||||
},
|
},
|
||||||
"byok": {
|
"byok": {
|
||||||
"description": "직접 제공자 키를 사용합니다. Nanobot은 현재 구성에서 값을 읽고, 설정된 제공자만 모델 프리셋에서 사용할 수 있습니다.",
|
"description": "직접 provider 키를 가져옵니다. Nanobot은 현재 config에서 값을 읽고, 설정된 provider만 모델 프리셋에서 사용할 수 있습니다.",
|
||||||
"configured": "설정됨",
|
"configured": "설정됨",
|
||||||
"notConfigured": "설정 안 됨",
|
"notConfigured": "설정 안 됨",
|
||||||
"configuredSection": "설정됨",
|
"configuredSection": "설정됨",
|
||||||
"notConfiguredSection": "설정 안 됨",
|
"notConfiguredSection": "설정 안 됨",
|
||||||
"showMore": "{{count}}개 더 보기",
|
"showMore": "{{count}}개 더 보기",
|
||||||
"showLess": "접기",
|
"showLess": "접기",
|
||||||
"apiKey": "API 키",
|
"apiKey": "API key",
|
||||||
"apiBase": "API 기본 주소",
|
"apiBase": "API base",
|
||||||
"apiKeyPlaceholder": "API 키 입력",
|
"apiKeyPlaceholder": "API key 입력",
|
||||||
"apiKeyConfiguredPlaceholder": "비워 두면 현재 key 유지",
|
"apiKeyConfiguredPlaceholder": "비워 두면 현재 key 유지",
|
||||||
"configuredKeyHint": "설정된 key",
|
"configuredKeyHint": "설정된 key",
|
||||||
"apiBasePlaceholder": "제공자 기본값 사용",
|
"apiBasePlaceholder": "provider 기본값 사용",
|
||||||
"apiKeyRequired": "이 제공자를 설정하려면 API 키가 필요합니다.",
|
"apiKeyRequired": "이 provider를 설정하려면 API key가 필요합니다.",
|
||||||
"showApiKey": "API 키 표시",
|
"showApiKey": "API key 표시",
|
||||||
"hideApiKey": "API 키 숨기기",
|
"hideApiKey": "API key 숨기기",
|
||||||
"noConfiguredProviders": "설정된 제공자가 없습니다",
|
"noConfiguredProviders": "설정된 provider가 없습니다",
|
||||||
"configureFirst": "먼저 BYOK에서 제공자를 설정하세요.",
|
"configureFirst": "먼저 BYOK에서 provider를 설정하세요.",
|
||||||
"openByok": "BYOK 열기",
|
"openByok": "BYOK 열기",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"ariaLabel": "BYOK 자격 증명 유형",
|
"ariaLabel": "BYOK 자격 증명 유형",
|
||||||
@@ -296,20 +299,20 @@
|
|||||||
"webSearch": "웹 검색"
|
"webSearch": "웹 검색"
|
||||||
},
|
},
|
||||||
"webSearch": {
|
"webSearch": {
|
||||||
"provider": "검색 제공자",
|
"provider": "검색 provider",
|
||||||
"providerHelp": "웹 검색 도구가 사용할 백엔드를 선택합니다.",
|
"providerHelp": "web search 도구가 사용할 백엔드를 선택합니다.",
|
||||||
"selectProvider": "제공자 선택",
|
"selectProvider": "provider 선택",
|
||||||
"credentials": "자격 증명",
|
"credentials": "자격 증명",
|
||||||
"noCredentialRequired": "key 필요 없음",
|
"noCredentialRequired": "key 필요 없음",
|
||||||
"noCredentialHelp": "DuckDuckGo는 API 키를 저장하지 않고 사용할 수 있습니다.",
|
"noCredentialHelp": "DuckDuckGo는 API key를 저장하지 않고 사용할 수 있습니다.",
|
||||||
"apiKeyHelp": "config에 저장되며 저장 후에는 마스킹되어 표시됩니다.",
|
"apiKeyHelp": "config에 저장되며 저장 후에는 마스킹되어 표시됩니다.",
|
||||||
"baseUrl": "기본 URL",
|
"baseUrl": "Base URL",
|
||||||
"baseUrlHelp": "SearXNG에는 자체 인스턴스 URL이 필요합니다.",
|
"baseUrlHelp": "SearXNG에는 자체 인스턴스 URL이 필요합니다.",
|
||||||
"baseUrlPlaceholder": "https://search.example.com",
|
"baseUrlPlaceholder": "https://search.example.com",
|
||||||
"apiKeyRequired": "이 검색 제공자에는 API 키가 필요합니다.",
|
"apiKeyRequired": "이 검색 provider에는 API key가 필요합니다.",
|
||||||
"baseUrlRequired": "SearXNG에는 기본 URL이 필요합니다.",
|
"baseUrlRequired": "SearXNG에는 Base URL이 필요합니다.",
|
||||||
"missingCredential": "저장하기 전에 필요한 자격 증명을 입력하세요.",
|
"missingCredential": "저장하기 전에 필요한 자격 증명을 입력하세요.",
|
||||||
"saveHint": "변경 사항은 새 웹 검색 요청에 적용됩니다."
|
"saveHint": "변경 사항은 새 web search 요청에 적용됩니다."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"overview": {
|
"overview": {
|
||||||
@@ -323,13 +326,13 @@
|
|||||||
"workspace": "작업공간"
|
"workspace": "작업공간"
|
||||||
},
|
},
|
||||||
"usage": {
|
"usage": {
|
||||||
"title": "토큰 활동",
|
"title": "Token 활동",
|
||||||
"shortTitle": "토큰 사용량",
|
"shortTitle": "Token Usage",
|
||||||
"subtitle": "최근 12개월 동안 제공자가 보고한 사용량입니다.",
|
"subtitle": "최근 12개월 동안 제공자가 보고한 사용량입니다.",
|
||||||
"empty": "새 모델 응답 이후 토큰 활동이 표시됩니다.",
|
"empty": "새 모델 응답 이후 token 활동이 표시됩니다.",
|
||||||
"totalTokens": "누적 토큰 수",
|
"totalTokens": "누적 Token 수",
|
||||||
"peakTokens": "최고 토큰 수",
|
"peakTokens": "최고 Token 수",
|
||||||
"thirtyDayTokens": "30일 토큰 수",
|
"thirtyDayTokens": "30일 Token 수",
|
||||||
"currentStreak": "현재 연속 일수",
|
"currentStreak": "현재 연속 일수",
|
||||||
"longestStreak": "최장 연속 일수",
|
"longestStreak": "최장 연속 일수",
|
||||||
"daysValue": "{{count}}일",
|
"daysValue": "{{count}}일",
|
||||||
@@ -338,7 +341,7 @@
|
|||||||
"requests": "요청",
|
"requests": "요청",
|
||||||
"estimated": "추정",
|
"estimated": "추정",
|
||||||
"includesEstimates": "추정 포함",
|
"includesEstimates": "추정 포함",
|
||||||
"cellTitle": "{{date}}: {{tokens}} 토큰, 요청 {{requests}}회",
|
"cellTitle": "{{date}}: {{tokens}} tokens, 요청 {{requests}}회",
|
||||||
"sources": {
|
"sources": {
|
||||||
"user": "채팅",
|
"user": "채팅",
|
||||||
"api": "API",
|
"api": "API",
|
||||||
@@ -371,19 +374,9 @@
|
|||||||
"selectProvider": "제공자 선택",
|
"selectProvider": "제공자 선택",
|
||||||
"selectAspect": "비율 선택",
|
"selectAspect": "비율 선택",
|
||||||
"selectSize": "크기 선택",
|
"selectSize": "크기 선택",
|
||||||
"selectModel": "이미지 모델 선택",
|
|
||||||
"searchOrTypeModel": "모델 ID 검색 또는 입력",
|
|
||||||
"typeModelId": "이 제공자가 지원하는 모델 ID를 입력하세요.",
|
|
||||||
"configureProvider": "제공자 구성",
|
"configureProvider": "제공자 구성",
|
||||||
"missingCredential": "이미지 생성을 활성화하기 전에 이 제공자를 구성하세요."
|
"missingCredential": "이미지 생성을 활성화하기 전에 이 제공자를 구성하세요."
|
||||||
},
|
},
|
||||||
"capabilities": {
|
|
||||||
"providerSupport": "제공자 지원",
|
|
||||||
"providerInstallOnSave": "이 제공자를 저장하면 필요한 지원이 자동으로 설치됩니다.",
|
|
||||||
"searchSupport": "검색 제공자 지원",
|
|
||||||
"searchInstallOnSave": "저장하면 Olostep 지원이 자동으로 설치됩니다.",
|
|
||||||
"installing": "지원 설치 중..."
|
|
||||||
},
|
|
||||||
"models": {
|
"models": {
|
||||||
"selectModel": "모델 선택",
|
"selectModel": "모델 선택",
|
||||||
"addConfiguration": "구성 추가",
|
"addConfiguration": "구성 추가",
|
||||||
@@ -406,7 +399,7 @@
|
|||||||
"advancedOptions": "고급 옵션",
|
"advancedOptions": "고급 옵션",
|
||||||
"advancedSummary": "컨텍스트 {{context}} · 최대 {{max}} 토큰",
|
"advancedSummary": "컨텍스트 {{context}} · 최대 {{max}} 토큰",
|
||||||
"maxTokens": "최대 출력 토큰",
|
"maxTokens": "최대 출력 토큰",
|
||||||
"temperature": "온도",
|
"temperature": "Temperature",
|
||||||
"reasoningEffort": "추론 강도",
|
"reasoningEffort": "추론 강도",
|
||||||
"convertTitle": "현재 모델 설정 변환",
|
"convertTitle": "현재 모델 설정 변환",
|
||||||
"convertHelp": "기존 기본 및 대체 모델을 프리셋으로 변환하여 여기서 순서를 관리합니다.",
|
"convertHelp": "기존 기본 및 대체 모델을 프리셋으로 변환하여 여기서 순서를 관리합니다.",
|
||||||
@@ -496,9 +489,9 @@
|
|||||||
"serverUrl": "URL",
|
"serverUrl": "URL",
|
||||||
"transport": "전송 방식",
|
"transport": "전송 방식",
|
||||||
"command": "명령",
|
"command": "명령",
|
||||||
"args": "인자 JSON",
|
"args": "Args JSON",
|
||||||
"headers": "헤더 JSON",
|
"headers": "Headers JSON",
|
||||||
"env": "환경 변수 JSON",
|
"env": "Env JSON",
|
||||||
"timeout": "도구 제한 시간",
|
"timeout": "도구 제한 시간",
|
||||||
"advancedOptions": "고급 옵션",
|
"advancedOptions": "고급 옵션",
|
||||||
"hideAdvanced": "고급 숨기기",
|
"hideAdvanced": "고급 숨기기",
|
||||||
@@ -526,7 +519,6 @@
|
|||||||
"statusMissingCredentials": "키 필요",
|
"statusMissingCredentials": "키 필요",
|
||||||
"statusMissingDependency": "의존성 필요",
|
"statusMissingDependency": "의존성 필요",
|
||||||
"statusComingSoon": "곧 제공",
|
"statusComingSoon": "곧 제공",
|
||||||
"comingSoon": "곧 제공",
|
|
||||||
"statusNotInstalled": "비활성",
|
"statusNotInstalled": "비활성",
|
||||||
"toolScope": "도구",
|
"toolScope": "도구",
|
||||||
"allTools": "전체",
|
"allTools": "전체",
|
||||||
@@ -589,8 +581,6 @@
|
|||||||
"advanced": "고급",
|
"advanced": "고급",
|
||||||
"checkAndEnable": "확인 후 활성화",
|
"checkAndEnable": "확인 후 활성화",
|
||||||
"checkConnection": "연결 확인",
|
"checkConnection": "연결 확인",
|
||||||
"connectionChecks": "연결 확인",
|
|
||||||
"open": "열기",
|
|
||||||
"checkedAndEnabled": "확인 후 활성화했습니다.",
|
"checkedAndEnabled": "확인 후 활성화했습니다.",
|
||||||
"checking": "확인 중...",
|
"checking": "확인 중...",
|
||||||
"checkOnly": "확인만",
|
"checkOnly": "확인만",
|
||||||
@@ -686,8 +676,6 @@
|
|||||||
"protected": "보호됨",
|
"protected": "보호됨",
|
||||||
"editTitle": "자동화 편집",
|
"editTitle": "자동화 편집",
|
||||||
"save": "저장",
|
"save": "저장",
|
||||||
"commandCopied": "복사됨",
|
|
||||||
"copyCommand": "복사",
|
|
||||||
"deleteTitle": "자동화 삭제",
|
"deleteTitle": "자동화 삭제",
|
||||||
"deleteDescription": "{{name}}을 cron 저장소에서 삭제합니다. 이전 채팅 메시지는 세션에 남습니다.",
|
"deleteDescription": "{{name}}을 cron 저장소에서 삭제합니다. 이전 채팅 메시지는 세션에 남습니다.",
|
||||||
"cancel": "취소",
|
"cancel": "취소",
|
||||||
@@ -747,7 +735,6 @@
|
|||||||
"fields": {
|
"fields": {
|
||||||
"name": "이름",
|
"name": "이름",
|
||||||
"message": "메시지",
|
"message": "메시지",
|
||||||
"command": "명령",
|
|
||||||
"scheduleType": "일정 유형",
|
"scheduleType": "일정 유형",
|
||||||
"every": "간격",
|
"every": "간격",
|
||||||
"unit": "단위",
|
"unit": "단위",
|
||||||
@@ -782,7 +769,7 @@
|
|||||||
"signInAgain": "다시 로그인",
|
"signInAgain": "다시 로그인",
|
||||||
"signOut": "로그아웃",
|
"signOut": "로그아웃",
|
||||||
"signedInAs": "{{account}}로 로그인됨",
|
"signedInAs": "{{account}}로 로그인됨",
|
||||||
"signInHelp": "이 기기에서 로그인합니다. API 키는 구성에 저장되지 않습니다.",
|
"signInHelp": "이 기기에서 로그인합니다. API key는 config에 저장되지 않습니다.",
|
||||||
"remoteSignInHelp": "로그인을 선택하여 사용자 컴퓨터에서 xAI를 연 다음, 로그인 후 표시되는 인증 코드를 붙여 넣으세요.",
|
"remoteSignInHelp": "로그인을 선택하여 사용자 컴퓨터에서 xAI를 연 다음, 로그인 후 표시되는 인증 코드를 붙여 넣으세요.",
|
||||||
"codexRemoteSignInHelp": "이 브라우저에서 로그인한 다음 전체 localhost 콜백 URL을 nanobot에 붙여 넣으세요.",
|
"codexRemoteSignInHelp": "이 브라우저에서 로그인한 다음 전체 localhost 콜백 URL을 nanobot에 붙여 넣으세요.",
|
||||||
"signInRequired": "로그인이 필요합니다",
|
"signInRequired": "로그인이 필요합니다",
|
||||||
@@ -906,34 +893,34 @@
|
|||||||
"actions": "{{title}} 주제 작업",
|
"actions": "{{title}} 주제 작업",
|
||||||
"newInProject": "{{project}}에서 새 주제 시작",
|
"newInProject": "{{project}}에서 새 주제 시작",
|
||||||
"activity": {
|
"activity": {
|
||||||
"running": "에이전트 실행 중",
|
"running": "Agent running",
|
||||||
"complete": "에이전트 완료",
|
"complete": "Agent finished",
|
||||||
"updated": "새 활동"
|
"updated": "New activity"
|
||||||
},
|
},
|
||||||
"pin": "고정",
|
"pin": "Pin",
|
||||||
"unpin": "고정 해제",
|
"unpin": "Unpin",
|
||||||
"rename": "이름 변경",
|
"rename": "Rename",
|
||||||
"renameTitle": "주제 이름 변경",
|
"renameTitle": "주제 이름 변경",
|
||||||
"renameDescription": "이 주제에 사용할 사이드바 이름을 선택하세요.",
|
"renameDescription": "이 주제에 사용할 사이드바 이름을 선택하세요.",
|
||||||
"renamePlaceholder": "주제 이름",
|
"renamePlaceholder": "주제 이름",
|
||||||
"renameProjectTitle": "프로젝트 이름 변경",
|
"renameProjectTitle": "Rename project",
|
||||||
"renameProjectDescription": "이 프로젝트에 사용할 사이드바 이름을 선택하세요.",
|
"renameProjectDescription": "Choose a local sidebar name for this project.",
|
||||||
"renameProjectPlaceholder": "프로젝트 이름",
|
"renameProjectPlaceholder": "Project name",
|
||||||
"renameSave": "저장",
|
"renameSave": "Save",
|
||||||
"archive": "보관",
|
"archive": "Archive",
|
||||||
"unarchive": "보관 해제",
|
"unarchive": "Unarchive",
|
||||||
"showArchived": "보관된 항목 표시",
|
"showArchived": "Show archived",
|
||||||
"hideArchived": "보관된 항목 숨기기",
|
"hideArchived": "Hide archived",
|
||||||
"delete": "삭제",
|
"delete": "삭제",
|
||||||
"newChat": "새 주제",
|
"newChat": "새 주제",
|
||||||
"groups": {
|
"groups": {
|
||||||
"pinned": "고정됨",
|
"pinned": "Pinned",
|
||||||
"all": "주제",
|
"all": "주제",
|
||||||
"projects": "프로젝트",
|
"projects": "Projects",
|
||||||
"today": "오늘",
|
"today": "Today",
|
||||||
"yesterday": "어제",
|
"yesterday": "Yesterday",
|
||||||
"earlier": "이전",
|
"earlier": "Earlier",
|
||||||
"archived": "보관됨"
|
"archived": "Archived"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"deleteConfirm": {
|
"deleteConfirm": {
|
||||||
@@ -1138,7 +1125,7 @@
|
|||||||
},
|
},
|
||||||
"stop": {
|
"stop": {
|
||||||
"title": "현재 작업 중지",
|
"title": "현재 작업 중지",
|
||||||
"description": "이 채팅에서 실행 중인 에이전트 턴을 취소합니다."
|
"description": "이 채팅에서 실행 중인 agent 턴을 취소합니다."
|
||||||
},
|
},
|
||||||
"restart": {
|
"restart": {
|
||||||
"title": "nanobot 재시작",
|
"title": "nanobot 재시작",
|
||||||
@@ -1146,7 +1133,7 @@
|
|||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"title": "상태 보기",
|
"title": "상태 보기",
|
||||||
"description": "런타임, 제공자, 채널 상태를 표시합니다."
|
"description": "런타임, provider, channel 상태를 표시합니다."
|
||||||
},
|
},
|
||||||
"model": {
|
"model": {
|
||||||
"title": "모델",
|
"title": "모델",
|
||||||
@@ -1224,9 +1211,7 @@
|
|||||||
"cliBadge": "CLI",
|
"cliBadge": "CLI",
|
||||||
"mcpBadge": "MCP",
|
"mcpBadge": "MCP",
|
||||||
"cliDescription": "@{{name}}을 로컬 CLI 앱으로 사용",
|
"cliDescription": "@{{name}}을 로컬 CLI 앱으로 사용",
|
||||||
"mcpDescription": "@{{name}}을 MCP 서버로 사용",
|
"mcpDescription": "@{{name}}을 MCP 서버로 사용"
|
||||||
"cliTitle": "CLI 앱: {{name}}",
|
|
||||||
"mcpTitle": "MCP 서버: {{name}}"
|
|
||||||
},
|
},
|
||||||
"workspace": {
|
"workspace": {
|
||||||
"accessAria": "작업공간 접근 모드",
|
"accessAria": "작업공간 접근 모드",
|
||||||
@@ -1246,8 +1231,7 @@
|
|||||||
"title": "프롬프트",
|
"title": "프롬프트",
|
||||||
"search": "프롬프트 검색",
|
"search": "프롬프트 검색",
|
||||||
"noResults": "일치하는 프롬프트가 없습니다.",
|
"noResults": "일치하는 프롬프트가 없습니다.",
|
||||||
"jumpTo": "프롬프트로 이동: {{label}}",
|
"jumpTo": "프롬프트로 이동: {{label}}"
|
||||||
"railAria": "사용자 프롬프트 탐색"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
@@ -1271,27 +1255,19 @@
|
|||||||
"agentActivityLiveSummary": "진행 중… · {{reasoning}}단계 · 도구 호출 {{tools}}회",
|
"agentActivityLiveSummary": "진행 중… · {{reasoning}}단계 · 도구 호출 {{tools}}회",
|
||||||
"agentActivityLiveToolsOnly": "진행 중… · 도구 호출 {{tools}}회",
|
"agentActivityLiveToolsOnly": "진행 중… · 도구 호출 {{tools}}회",
|
||||||
"imageAttachment": "이미지 첨부",
|
"imageAttachment": "이미지 첨부",
|
||||||
"videoAttachment": "동영상 첨부",
|
|
||||||
"fileAttachment": "파일 첨부",
|
|
||||||
"attachmentUnavailable": "첨부 파일을 사용할 수 없음",
|
|
||||||
"dataTable": "데이터 표",
|
|
||||||
"fileEditPreparing": "파일 편집 준비 중…",
|
|
||||||
"openLink": "링크 열기: {{label}}",
|
|
||||||
"openAttachment": "{{name}} 열기",
|
|
||||||
"skill": "스킬: {{name}}",
|
|
||||||
"askAboutSelection": "이 내용에 대해 질문하기",
|
"askAboutSelection": "이 내용에 대해 질문하기",
|
||||||
"forkFromHere": "분기",
|
"forkFromHere": "분기",
|
||||||
"copyReply": "복사",
|
"copyReply": "복사",
|
||||||
"copiedReply": "복사됨",
|
"copiedReply": "복사됨",
|
||||||
"turnLatencyTitle": "응답 시간(엔드투엔드)",
|
"turnLatencyTitle": "응답 시간(엔드투엔드)",
|
||||||
"fileEditViewDiff": "변경 사항 보기",
|
"fileEditViewDiff": "Diff 보기",
|
||||||
"fileEditViewLargeDiff": "큰 변경 사항 보기",
|
"fileEditViewLargeDiff": "큰 diff 보기",
|
||||||
"fileEditDiffLineCount": "{{count}}줄",
|
"fileEditDiffLineCount": "{{count}}줄",
|
||||||
"fileEditUnchangedLinesHidden": "변경되지 않은 {{count}}줄 숨김",
|
"fileEditUnchangedLinesHidden": "변경되지 않은 {{count}}줄 숨김",
|
||||||
"fileEditShowMoreLines": "{{count}}줄 더 보기",
|
"fileEditShowMoreLines": "{{count}}줄 더 보기",
|
||||||
"fileEditShowFewerLines": "줄 줄이기",
|
"fileEditShowFewerLines": "줄 줄이기",
|
||||||
"fileEditOpenFile": "파일 열기",
|
"fileEditOpenFile": "파일 열기",
|
||||||
"fileEditDiffTruncated": "변경 사항이 잘렸습니다. 전체 변경은 파일을 열어 확인하세요.",
|
"fileEditDiffTruncated": "Diff가 잘렸습니다. 전체 변경은 파일을 열어 확인하세요.",
|
||||||
"activityThinkingFor": "{{duration}} 동안 생각 중",
|
"activityThinkingFor": "{{duration}} 동안 생각 중",
|
||||||
"activityThought": "생각함",
|
"activityThought": "생각함",
|
||||||
"activityThoughtFor": "{{duration}} 동안 생각함",
|
"activityThoughtFor": "{{duration}} 동안 생각함",
|
||||||
@@ -1319,7 +1295,6 @@
|
|||||||
},
|
},
|
||||||
"filePreview": {
|
"filePreview": {
|
||||||
"aria": "파일 미리보기",
|
"aria": "파일 미리보기",
|
||||||
"breadcrumb": "파일 경로",
|
|
||||||
"close": "파일 미리보기 닫기",
|
"close": "파일 미리보기 닫기",
|
||||||
"loading": "미리보기 로딩 중...",
|
"loading": "미리보기 로딩 중...",
|
||||||
"failed": "이 파일을 미리 볼 수 없습니다.",
|
"failed": "이 파일을 미리 볼 수 없습니다.",
|
||||||
@@ -1334,10 +1309,7 @@
|
|||||||
"copied": "복사됨"
|
"copied": "복사됨"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"dismiss": "닫기",
|
"dismiss": "닫기"
|
||||||
"close": "닫기",
|
|
||||||
"current": "현재",
|
|
||||||
"cancel": "취소"
|
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"messageTooBig": {
|
"messageTooBig": {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
},
|
},
|
||||||
"system": {
|
"system": {
|
||||||
"section": "Sistema",
|
"section": "Sistema",
|
||||||
"restartHint": "Reinicie o nanobot para aplicar as alterações de tempo de execução.",
|
"restartHint": "Reinicie o nanobot para aplicar as alterações de runtime.",
|
||||||
"restart": "Reiniciar nanobot",
|
"restart": "Reiniciar nanobot",
|
||||||
"restarting": "Reiniciando nanobot...",
|
"restarting": "Reiniciando nanobot...",
|
||||||
"restartEngine": "Reiniciar motor",
|
"restartEngine": "Reiniciar motor",
|
||||||
@@ -37,21 +37,13 @@
|
|||||||
"chat": "{{title}} · nanobot"
|
"chat": "{{title}} · nanobot"
|
||||||
},
|
},
|
||||||
"meta": {
|
"meta": {
|
||||||
"description": "Interface web do nanobot — converse com o seu espaço de trabalho do nanobot."
|
"description": "Interface web do nanobot — converse com o seu workspace do nanobot."
|
||||||
},
|
|
||||||
"pairing": {
|
|
||||||
"title": "Vincular usuário do chat",
|
|
||||||
"description": "Digite o código de vinculação exibido no chat.",
|
|
||||||
"code": "Código de vinculação",
|
|
||||||
"matched": "Correspondência com {{channel}}. Conectando...",
|
|
||||||
"expiresInline": "O código expira {{expires}}.",
|
|
||||||
"queueCount": "{{count}} pendentes",
|
|
||||||
"noMatch": "Nenhuma solicitação pendente corresponde a este código."
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"navigation": "Navegação da barra lateral",
|
"navigation": "Navegação da barra lateral",
|
||||||
"collapse": "Recolher barra lateral",
|
"collapse": "Recolher barra lateral",
|
||||||
|
"quickChat": "Chat rápido",
|
||||||
"newChat": "Novo tópico",
|
"newChat": "Novo tópico",
|
||||||
"searchAria": "Buscar",
|
"searchAria": "Buscar",
|
||||||
"searchPlaceholder": "Buscar",
|
"searchPlaceholder": "Buscar",
|
||||||
@@ -63,10 +55,21 @@
|
|||||||
"label": "Idioma",
|
"label": "Idioma",
|
||||||
"ariaLabel": "Trocar idioma"
|
"ariaLabel": "Trocar idioma"
|
||||||
},
|
},
|
||||||
"apps": "Aplicativos",
|
"apps": "Apps",
|
||||||
"automations": "Automações",
|
"automations": "Automações",
|
||||||
"skills": {
|
"skills": {
|
||||||
"title": "Habilidades"
|
"title": "Skills"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"quickChat": {
|
||||||
|
"greeting": "O que você está pensando?",
|
||||||
|
"temporary": {
|
||||||
|
"title": "Chat temporário",
|
||||||
|
"enter": "Temporário",
|
||||||
|
"active": "Temporário",
|
||||||
|
"exit": "Sair do chat temporário",
|
||||||
|
"greeting": "Inicie um chat temporário",
|
||||||
|
"description": "Sem histórico, memória, ferramentas ou acesso ao projeto. O conteúdo ainda é enviado ao provedor do modelo escolhido."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
@@ -86,13 +89,13 @@
|
|||||||
"voice": "Voz",
|
"voice": "Voz",
|
||||||
"browser": "Web",
|
"browser": "Web",
|
||||||
"channels": "Canais",
|
"channels": "Canais",
|
||||||
"cliApps": "Aplicativos CLI",
|
"cliApps": "Apps CLI",
|
||||||
"mcp": "MCP",
|
"mcp": "MCP",
|
||||||
"runtime": "Sistema",
|
"runtime": "Sistema",
|
||||||
"advanced": "Segurança",
|
"advanced": "Segurança",
|
||||||
"apps": "Aplicativos",
|
"apps": "Aplicativos",
|
||||||
"automations": "Automações",
|
"automations": "Automações",
|
||||||
"skills": "Habilidades"
|
"skills": "Skills"
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"interface": "Interface do usuário",
|
"interface": "Interface do usuário",
|
||||||
@@ -106,9 +109,9 @@
|
|||||||
"imageDefaults": "Padrões",
|
"imageDefaults": "Padrões",
|
||||||
"webSearch": "Busca na web",
|
"webSearch": "Busca na web",
|
||||||
"webBehavior": "Comportamento",
|
"webBehavior": "Comportamento",
|
||||||
"cliApps": "Aplicativos CLI",
|
"cliApps": "Apps CLI",
|
||||||
"mcp": "Servidores MCP",
|
"mcp": "Servidores MCP",
|
||||||
"regional": "Regional",
|
"identity": "Identidade",
|
||||||
"webuiSafety": "Segurança da WebUI",
|
"webuiSafety": "Segurança da WebUI",
|
||||||
"capabilities": "Capacidades",
|
"capabilities": "Capacidades",
|
||||||
"apps": "Aplicativos",
|
"apps": "Aplicativos",
|
||||||
@@ -199,8 +202,10 @@
|
|||||||
"defaultImageSize": "Tamanho padrão",
|
"defaultImageSize": "Tamanho padrão",
|
||||||
"maxImagesPerTurn": "Máx. de imagens por turno",
|
"maxImagesPerTurn": "Máx. de imagens por turno",
|
||||||
"imageSaveDir": "Diretório de salvamento",
|
"imageSaveDir": "Diretório de salvamento",
|
||||||
|
"botName": "Nome do bot",
|
||||||
|
"botIcon": "Ícone do bot",
|
||||||
"timezone": "Fuso horário",
|
"timezone": "Fuso horário",
|
||||||
"workspacePath": "Espaço de trabalho padrão",
|
"workspacePath": "Workspace padrão",
|
||||||
"localServiceAccess": "Serviços locais",
|
"localServiceAccess": "Serviços locais",
|
||||||
"webuiDefaultAccess": "Acesso padrão",
|
"webuiDefaultAccess": "Acesso padrão",
|
||||||
"cliAppsCatalog": "Catálogo",
|
"cliAppsCatalog": "Catálogo",
|
||||||
@@ -226,10 +231,10 @@
|
|||||||
"selectedModelProvider": "Definido pelo modelo selecionado.",
|
"selectedModelProvider": "Definido pelo modelo selecionado.",
|
||||||
"selectedModelValue": "Definido pelo modelo selecionado.",
|
"selectedModelValue": "Definido pelo modelo selecionado.",
|
||||||
"selectedPreset": "As predefinições nomeadas são somente leitura aqui; edite-as em config.json.",
|
"selectedPreset": "As predefinições nomeadas são somente leitura aqui; edite-as em config.json.",
|
||||||
"presetModel": "Mude para Padrão para editar o modelo e o provedor pela WebUI.",
|
"presetModel": "Mude para Default para editar modelo e provedor pela WebUI.",
|
||||||
"density": "Armazenado apenas neste navegador.",
|
"density": "Armazenado apenas neste navegador.",
|
||||||
"activityMode": "Escolha quanto detalhe de atividade do agente é exibido por padrão.",
|
"activityMode": "Escolha quanto detalhe de atividade do agente é exibido por padrão.",
|
||||||
"fileEditDisplay": "Escolha se a atividade de edição de arquivo é exibida como contagem de linhas ou como diferenças.",
|
"fileEditDisplay": "Escolha se a atividade de edição de arquivo é exibida como contagem de linhas ou como diff.",
|
||||||
"codeWrap": "Mantém linhas longas de código legíveis em telas menores.",
|
"codeWrap": "Mantém linhas longas de código legíveis em telas menores.",
|
||||||
"brandLogos": "Mostra logotipos de provedores terceiros e de CLIs em Configurações.",
|
"brandLogos": "Mostra logotipos de provedores terceiros e de CLIs em Configurações.",
|
||||||
"maxResults": "Resultados retornados por cada chamada de web_search.",
|
"maxResults": "Resultados retornados por cada chamada de web_search.",
|
||||||
@@ -237,20 +242,22 @@
|
|||||||
"jinaReader": "Usa o Jina Reader para web_fetch quando disponível.",
|
"jinaReader": "Usa o Jina Reader para web_fetch quando disponível.",
|
||||||
"imageGeneration": "Expõe generate_image nas conversas quando há um provedor de imagem configurado.",
|
"imageGeneration": "Expõe generate_image nas conversas quando há um provedor de imagem configurado.",
|
||||||
"imageProvider": "Escolha o provedor do registro usado por generate_image.",
|
"imageProvider": "Escolha o provedor do registro usado por generate_image.",
|
||||||
"imageProviderStatus": "A geração de imagens reaproveita as credenciais dos provedores.",
|
"imageProviderStatus": "A geração de imagens reaproveita as credenciais de Provedores.",
|
||||||
"imageModel": "Nome do modelo enviado ao provedor de imagem selecionado.",
|
"imageModel": "Nome do modelo enviado ao provedor de imagem selecionado.",
|
||||||
"defaultAspectRatio": "Usado quando a instrução não escolhe uma proporção.",
|
"defaultAspectRatio": "Usado quando o prompt não escolhe uma proporção.",
|
||||||
"defaultImageSize": "Dica de tamanho enviada a provedores compatíveis.",
|
"defaultImageSize": "Dica de tamanho enviada a provedores compatíveis.",
|
||||||
"maxImagesPerTurn": "Limite superior para uma requisição de generate_image.",
|
"maxImagesPerTurn": "Limite superior para uma requisição de generate_image.",
|
||||||
|
"botName": "Exibido sempre que o nanobot usa um nome visível.",
|
||||||
|
"botIcon": "Emoji ou texto curto exibido junto ao nome do bot.",
|
||||||
"timezone": "Usado para agendamentos e respostas sensíveis ao horário.",
|
"timezone": "Usado para agendamentos e respostas sensíveis ao horário.",
|
||||||
"cliAppsCatalog": "Instale apenas os adaptadores CLI de aplicativos que o nanobot pode executar localmente; aplicativos nativos permanecem intactos.",
|
"cliAppsCatalog": "Instale apenas os adaptadores CLI de apps que o nanobot pode executar localmente; apps nativos permanecem intactos.",
|
||||||
"cliAppsFilter": "Busque por aplicativo, categoria ou capacidade.",
|
"cliAppsFilter": "Busque por app, categoria ou capacidade.",
|
||||||
"localServiceAccess": "Permite que comandos shell com acesso completo alcancem serviços locais.",
|
"localServiceAccess": "Permite que comandos shell com Acesso Total alcancem serviços localhost.",
|
||||||
"webuiDefaultAccess": "Usado por chats web sem permissão específica de projeto.",
|
"webuiDefaultAccess": "Usado por chats web sem permissão específica de projeto.",
|
||||||
"securityManagedControls": "As buscas na web sempre protegem serviços locais, privados e de metadados. A segurança essencial dos canais fica em config.json.",
|
"securityManagedControls": "As buscas na web sempre protegem serviços locais, privados e de metadados. A segurança essencial dos canais fica em config.json.",
|
||||||
"logs": "Abre a pasta de logs do motor nativo.",
|
"logs": "Abre a pasta de logs do motor nativo.",
|
||||||
"diagnostics": "Exporta um pequeno relatório de tempo de execução para o suporte.",
|
"diagnostics": "Exporta um pequeno relatório de runtime para o suporte.",
|
||||||
"localServiceAccessNative": "Permite que comandos shell com acesso completo alcancem serviços neste Mac.",
|
"localServiceAccessNative": "Permite que comandos shell com Acesso Total alcancem serviços neste Mac.",
|
||||||
"webuiDefaultAccessNative": "Usado por chats nativos sem permissão específica de projeto.",
|
"webuiDefaultAccessNative": "Usado por chats nativos sem permissão específica de projeto.",
|
||||||
"contextWindow": "Escolha o orçamento de contexto padrão para esta configuração de modelo.",
|
"contextWindow": "Escolha o orçamento de contexto padrão para esta configuração de modelo.",
|
||||||
"transcription": "Transcreve a entrada do microfone antes de enviá-la. Mensagens de voz dos canais de chat usam as mesmas configurações.",
|
"transcription": "Transcreve a entrada do microfone antes de enviá-la. Mensagens de voz dos canais de chat usam as mesmas configurações.",
|
||||||
@@ -266,25 +273,25 @@
|
|||||||
},
|
},
|
||||||
"cliApps": {
|
"cliApps": {
|
||||||
"allCategories": "Todas as categorias",
|
"allCategories": "Todas as categorias",
|
||||||
"availableCount": "{{count}} aplicativos",
|
"availableCount": "{{count}} apps",
|
||||||
"installedCount": "{{count}} CLIs instaladas",
|
"installedCount": "{{count}} CLIs instaladas",
|
||||||
"summary": "{{installed}} de {{total}} CLIs instaladas",
|
"summary": "{{installed}} de {{total}} CLIs instaladas",
|
||||||
"filterAll": "Todos",
|
"filterAll": "Todos",
|
||||||
"filterInstalled": "CLIs instaladas",
|
"filterInstalled": "CLIs instaladas",
|
||||||
"filterNotInstalled": "Não instaladas",
|
"filterNotInstalled": "Não instaladas",
|
||||||
"searchPlaceholder": "Buscar CLIs",
|
"searchPlaceholder": "Buscar CLIs",
|
||||||
"loading": "Carregando aplicativos CLI...",
|
"loading": "Carregando Apps CLI...",
|
||||||
"empty": "Nenhum aplicativo CLI corresponde a este filtro.",
|
"empty": "Nenhum App CLI corresponde a este filtro.",
|
||||||
"statusInstalled": "Aplicativo pronto",
|
"statusInstalled": "App pronto",
|
||||||
"statusMissing": "Faltando",
|
"statusMissing": "Faltando",
|
||||||
"statusAvailable": "Disponível",
|
"statusAvailable": "Disponível",
|
||||||
"statusUnsupported": "Não compatível",
|
"statusUnsupported": "Não compatível",
|
||||||
"statusNotInstalled": "Aplicativo não instalado",
|
"statusNotInstalled": "App não instalado",
|
||||||
"requires": "Requer",
|
"requires": "Requer",
|
||||||
"test": "Testar aplicativo",
|
"test": "Testar app",
|
||||||
"update": "Atualizar aplicativo",
|
"update": "Atualizar app",
|
||||||
"uninstall": "Desinstalar aplicativo",
|
"uninstall": "Desinstalar app",
|
||||||
"install": "Instalar aplicativo",
|
"install": "Instalar app",
|
||||||
"readyTitle": "@{{name}} está pronto",
|
"readyTitle": "@{{name}} está pronto",
|
||||||
"readyStatus": "Pronto",
|
"readyStatus": "Pronto",
|
||||||
"readyTry": "Experimentar @{{name}}",
|
"readyTry": "Experimentar @{{name}}",
|
||||||
@@ -319,9 +326,9 @@
|
|||||||
"serverUrl": "URL",
|
"serverUrl": "URL",
|
||||||
"transport": "Transporte",
|
"transport": "Transporte",
|
||||||
"command": "Comando",
|
"command": "Comando",
|
||||||
"args": "Argumentos JSON",
|
"args": "Args JSON",
|
||||||
"headers": "Cabeçalhos JSON",
|
"headers": "Headers JSON",
|
||||||
"env": "Ambiente JSON",
|
"env": "Env JSON",
|
||||||
"timeout": "Tempo limite da ferramenta",
|
"timeout": "Tempo limite da ferramenta",
|
||||||
"advancedOptions": "Opções avançadas",
|
"advancedOptions": "Opções avançadas",
|
||||||
"hideAdvanced": "Ocultar avançado",
|
"hideAdvanced": "Ocultar avançado",
|
||||||
@@ -349,7 +356,6 @@
|
|||||||
"statusMissingCredentials": "Precisa de chave",
|
"statusMissingCredentials": "Precisa de chave",
|
||||||
"statusMissingDependency": "Precisa de dependência",
|
"statusMissingDependency": "Precisa de dependência",
|
||||||
"statusComingSoon": "Em breve",
|
"statusComingSoon": "Em breve",
|
||||||
"comingSoon": "Em breve",
|
|
||||||
"statusNotInstalled": "Não habilitado",
|
"statusNotInstalled": "Não habilitado",
|
||||||
"toolScope": "Ferramentas",
|
"toolScope": "Ferramentas",
|
||||||
"allTools": "Todas",
|
"allTools": "Todas",
|
||||||
@@ -366,15 +372,15 @@
|
|||||||
"ready": "Pronto",
|
"ready": "Pronto",
|
||||||
"privateEngine": "Motor privado",
|
"privateEngine": "Motor privado",
|
||||||
"unixSocket": "Socket Unix",
|
"unixSocket": "Socket Unix",
|
||||||
"defaultWorkspace": "Espaço de trabalho padrão",
|
"defaultWorkspace": "Workspace padrão",
|
||||||
"comfortable": "Confortável",
|
"comfortable": "Confortável",
|
||||||
"compact": "Compacto",
|
"compact": "Compacto",
|
||||||
"auto": "Automático",
|
"auto": "Automático",
|
||||||
"expanded": "Expandido",
|
"expanded": "Expandido",
|
||||||
"default": "Padrão",
|
"default": "Padrão",
|
||||||
"summary": "Resumo",
|
"summary": "Resumo",
|
||||||
"diff": "Diferenças",
|
"diff": "Diff",
|
||||||
"collapsedDiff": "Diferenças recolhidas",
|
"collapsedDiff": "Diff recolhido",
|
||||||
"on": "Ligado",
|
"on": "Ligado",
|
||||||
"off": "Desligado",
|
"off": "Desligado",
|
||||||
"defaultPermission": "Permissão padrão",
|
"defaultPermission": "Permissão padrão",
|
||||||
@@ -382,10 +388,7 @@
|
|||||||
"configured": "Configurado",
|
"configured": "Configurado",
|
||||||
"notConfigured": "Não configurado",
|
"notConfigured": "Não configurado",
|
||||||
"pending": "Pendente",
|
"pending": "Pendente",
|
||||||
"restartingEngine": "Reiniciando",
|
"restartingEngine": "Reiniciando"
|
||||||
"checking": "Verificando",
|
|
||||||
"running": "Em execução",
|
|
||||||
"needsSetup": "Requer configuração"
|
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "Carregando configurações...",
|
"loading": "Carregando configurações...",
|
||||||
@@ -413,7 +416,6 @@
|
|||||||
"deleting": "Excluindo...",
|
"deleting": "Excluindo...",
|
||||||
"edit": "Editar",
|
"edit": "Editar",
|
||||||
"cancel": "Cancelar",
|
"cancel": "Cancelar",
|
||||||
"dismiss": "Dispensar",
|
|
||||||
"open": "Abrir",
|
"open": "Abrir",
|
||||||
"export": "Exportar",
|
"export": "Exportar",
|
||||||
"opening": "Abrindo...",
|
"opening": "Abrindo...",
|
||||||
@@ -469,7 +471,7 @@
|
|||||||
"webSearch": "Busca na web",
|
"webSearch": "Busca na web",
|
||||||
"imageGeneration": "Geração de imagens",
|
"imageGeneration": "Geração de imagens",
|
||||||
"voiceInput": "Entrada de voz",
|
"voiceInput": "Entrada de voz",
|
||||||
"workspace": "Espaço de trabalho"
|
"workspace": "Workspace"
|
||||||
},
|
},
|
||||||
"usage": {
|
"usage": {
|
||||||
"title": "Atividade de tokens",
|
"title": "Atividade de tokens",
|
||||||
@@ -523,19 +525,9 @@
|
|||||||
"selectProvider": "Selecionar provedor",
|
"selectProvider": "Selecionar provedor",
|
||||||
"selectAspect": "Selecionar proporção",
|
"selectAspect": "Selecionar proporção",
|
||||||
"selectSize": "Selecionar tamanho",
|
"selectSize": "Selecionar tamanho",
|
||||||
"selectModel": "Selecionar modelo de imagem",
|
|
||||||
"searchOrTypeModel": "Pesquisar ou digitar ID do modelo",
|
|
||||||
"typeModelId": "Digite o ID de modelo compatível com este provedor.",
|
|
||||||
"configureProvider": "Configurar provedor",
|
"configureProvider": "Configurar provedor",
|
||||||
"missingCredential": "Configure o provedor antes de habilitar a geração de imagens."
|
"missingCredential": "Configure o provedor antes de habilitar a geração de imagens."
|
||||||
},
|
},
|
||||||
"capabilities": {
|
|
||||||
"providerSupport": "Suporte do provedor",
|
|
||||||
"providerInstallOnSave": "O suporte necessário será instalado automaticamente ao salvar este provedor.",
|
|
||||||
"searchSupport": "Suporte do provedor de pesquisa",
|
|
||||||
"searchInstallOnSave": "O suporte ao Olostep será instalado automaticamente ao salvar.",
|
|
||||||
"installing": "Instalando suporte..."
|
|
||||||
},
|
|
||||||
"api": {
|
"api": {
|
||||||
"title": "Servidor de API",
|
"title": "Servidor de API",
|
||||||
"openaiCompatible": "API compatível com OpenAI",
|
"openaiCompatible": "API compatível com OpenAI",
|
||||||
@@ -565,29 +557,29 @@
|
|||||||
},
|
},
|
||||||
"apps": {
|
"apps": {
|
||||||
"description": "Adicione ferramentas ao nanobot e mencione-as com @ na conversa.",
|
"description": "Adicione ferramentas ao nanobot e mencione-as com @ na conversa.",
|
||||||
"cliLabel": "Aplicativo",
|
"cliLabel": "App",
|
||||||
"mcpLabel": "Integração",
|
"mcpLabel": "Integração",
|
||||||
"channelLabel": "Canal",
|
"channelLabel": "Canal",
|
||||||
"featureLabel": "Recurso",
|
"featureLabel": "Recurso",
|
||||||
"filterAll": "Prontos",
|
"filterAll": "Prontos",
|
||||||
"filterPlugins": "Complementos",
|
"filterPlugins": "Complementos",
|
||||||
"filterCli": "Aplicativos",
|
"filterCli": "Apps",
|
||||||
"filterMcp": "Integrações",
|
"filterMcp": "Integrações",
|
||||||
"enabledSummary": "{{count}} prontos",
|
"enabledSummary": "{{count}} prontos",
|
||||||
"caption": "{{cli}} aplicativos · {{mcp}} integrações",
|
"caption": "{{cli}} apps · {{mcp}} integrações",
|
||||||
"searchPlaceholder": "Buscar ferramentas",
|
"searchPlaceholder": "Buscar ferramentas",
|
||||||
"featured": "Ferramentas",
|
"featured": "Ferramentas",
|
||||||
"loading": "Carregando aplicativos...",
|
"loading": "Carregando Apps...",
|
||||||
"empty": "Nenhuma ferramenta corresponde a esta visualização.",
|
"empty": "Nenhuma ferramenta corresponde a esta visualização.",
|
||||||
"restartRequired": "Reinicie o nanobot para aplicar os aplicativos e integrações atualizados."
|
"restartRequired": "Reinicie o nanobot para aplicar os apps e integrações atualizados."
|
||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
"description": "Conecte aplicativos de chat, e-mail e WebUI ao nanobot.",
|
"description": "Conecte apps de chat, e-mail e WebUI ao nanobot.",
|
||||||
"caption": "{{enabled}} habilitados · {{total}} canais",
|
"caption": "{{enabled}} habilitados · {{total}} canais",
|
||||||
"searchPlaceholder": "Buscar canais",
|
"searchPlaceholder": "Buscar canais",
|
||||||
"backToChannels": "Todos os canais",
|
"backToChannels": "Todos os canais",
|
||||||
"catalog": "Canais",
|
"catalog": "Canais",
|
||||||
"loading": "Carregando canais...",
|
"loading": "Carregando Canais...",
|
||||||
"empty": "Nenhum canal corresponde a este filtro.",
|
"empty": "Nenhum canal corresponde a este filtro.",
|
||||||
"restartRequired": "Reinicie o nanobot para aplicar o suporte de canais atualizado.",
|
"restartRequired": "Reinicie o nanobot para aplicar o suporte de canais atualizado.",
|
||||||
"requires": "Requer: {{requirements}}",
|
"requires": "Requer: {{requirements}}",
|
||||||
@@ -603,8 +595,6 @@
|
|||||||
"advanced": "Avançado",
|
"advanced": "Avançado",
|
||||||
"checkAndEnable": "Verificar e ativar",
|
"checkAndEnable": "Verificar e ativar",
|
||||||
"checkConnection": "Verificar conexão",
|
"checkConnection": "Verificar conexão",
|
||||||
"connectionChecks": "Verificações de conexão",
|
|
||||||
"open": "Abrir",
|
|
||||||
"checkedAndEnabled": "Verificado e ativado.",
|
"checkedAndEnabled": "Verificado e ativado.",
|
||||||
"checking": "Verificando...",
|
"checking": "Verificando...",
|
||||||
"checkOnly": "Apenas verificar",
|
"checkOnly": "Apenas verificar",
|
||||||
@@ -700,8 +690,6 @@
|
|||||||
"protected": "Protegida",
|
"protected": "Protegida",
|
||||||
"editTitle": "Editar automação",
|
"editTitle": "Editar automação",
|
||||||
"save": "Salvar",
|
"save": "Salvar",
|
||||||
"commandCopied": "Copiado",
|
|
||||||
"copyCommand": "Copiar",
|
|
||||||
"deleteTitle": "Excluir automação",
|
"deleteTitle": "Excluir automação",
|
||||||
"deleteDescription": "Isso remove {{name}} do armazenamento do cron. As mensagens anteriores da conversa permanecem na sessão.",
|
"deleteDescription": "Isso remove {{name}} do armazenamento do cron. As mensagens anteriores da conversa permanecem na sessão.",
|
||||||
"cancel": "Cancelar",
|
"cancel": "Cancelar",
|
||||||
@@ -761,7 +749,6 @@
|
|||||||
"fields": {
|
"fields": {
|
||||||
"name": "Nome",
|
"name": "Nome",
|
||||||
"message": "Mensagem",
|
"message": "Mensagem",
|
||||||
"command": "Comando",
|
|
||||||
"scheduleType": "Tipo de agendamento",
|
"scheduleType": "Tipo de agendamento",
|
||||||
"every": "A cada",
|
"every": "A cada",
|
||||||
"unit": "Unidade",
|
"unit": "Unidade",
|
||||||
@@ -819,56 +806,56 @@
|
|||||||
"finishSignIn": "Concluir login"
|
"finishSignIn": "Concluir login"
|
||||||
},
|
},
|
||||||
"skills": {
|
"skills": {
|
||||||
"description": "Revise as habilidades de instrução que este agente pode carregar durante uma conversa.",
|
"description": "Revise as skills de instrução que este agente pode carregar durante uma conversa.",
|
||||||
"caption": "{{available}} disponíveis · {{total}} no total",
|
"caption": "{{available}} disponíveis · {{total}} no total",
|
||||||
"views": "Visualizações de habilidades",
|
"views": "Visualizações de skills",
|
||||||
"installedTab": "Instaladas",
|
"installedTab": "Instaladas",
|
||||||
"discoverTab": "Descobrir",
|
"discoverTab": "Descobrir",
|
||||||
"customGroup": "Personalizadas",
|
"customGroup": "Personalizadas",
|
||||||
"builtinGroup": "Integradas",
|
"builtinGroup": "Integradas",
|
||||||
"otherGroup": "Outras",
|
"otherGroup": "Outras",
|
||||||
"searchInstalled": "Buscar habilidades instaladas",
|
"searchInstalled": "Buscar skills instaladas",
|
||||||
"filterAll": "Todas",
|
"filterAll": "Todas",
|
||||||
"filterEnabled": "Ativadas",
|
"filterEnabled": "Ativadas",
|
||||||
"filterDisabled": "Desativadas",
|
"filterDisabled": "Desativadas",
|
||||||
"noMatching": "Nenhuma habilidade correspondente.",
|
"noMatching": "Nenhuma skill correspondente.",
|
||||||
"statusDisabled": "Desativada",
|
"statusDisabled": "Desativada",
|
||||||
"statusEnabled": "Ativada",
|
"statusEnabled": "Ativada",
|
||||||
"statusNeedsSetup": "Requer configuração",
|
"statusNeedsSetup": "Requer configuração",
|
||||||
"showLess": "Mostrar menos",
|
"showLess": "Mostrar menos",
|
||||||
"showMore": "Mostrar mais",
|
"showMore": "Mostrar mais",
|
||||||
"enabledControl": "Usar esta habilidade",
|
"enabledControl": "Usar esta skill",
|
||||||
"enabledDescription": "Permite que o agente carregue esta habilidade quando os requisitos estiverem prontos.",
|
"enabledDescription": "Permite que o agente carregue esta skill quando os requisitos estiverem prontos.",
|
||||||
"enableSkill": "Ativar {{name}}",
|
"enableSkill": "Ativar {{name}}",
|
||||||
"disableSkill": "Desativar {{name}}",
|
"disableSkill": "Desativar {{name}}",
|
||||||
"updateFailed": "Não foi possível atualizar esta habilidade.",
|
"updateFailed": "Não foi possível atualizar esta skill.",
|
||||||
"deleteTitle": "Excluir habilidade",
|
"deleteTitle": "Excluir skill",
|
||||||
"deleteDescription": "Remove esta habilidade do espaço de trabalho atual.",
|
"deleteDescription": "Remove esta skill do workspace atual.",
|
||||||
"deleteAction": "Excluir",
|
"deleteAction": "Excluir",
|
||||||
"deleteFailed": "Não foi possível excluir esta habilidade.",
|
"deleteFailed": "Não foi possível excluir esta skill.",
|
||||||
"deleteConfirmTitle": "Excluir {{name}}?",
|
"deleteConfirmTitle": "Excluir {{name}}?",
|
||||||
"deleteConfirmDescription": "Isso remove os arquivos da habilidade do espaço de trabalho atual. Esta ação não pode ser desfeita.",
|
"deleteConfirmDescription": "Isso remove os arquivos da skill do workspace atual. Esta ação não pode ser desfeita.",
|
||||||
"deleteConfirmAction": "Excluir habilidade",
|
"deleteConfirmAction": "Excluir skill",
|
||||||
"instructionsTitle": "Instruções da habilidade",
|
"instructionsTitle": "Instruções da skill",
|
||||||
"setupRequired": "Requer configuração",
|
"setupRequired": "Requer configuração",
|
||||||
"setupDescription": "Instale a dependência ausente na máquina que executa o nanobot e verifique novamente.",
|
"setupDescription": "Instale a dependência ausente na máquina que executa o nanobot e verifique novamente.",
|
||||||
"copySetupCommand": "Copiar comando de configuração",
|
"copySetupCommand": "Copiar comando de configuração",
|
||||||
"checkAgain": "Verificar novamente",
|
"checkAgain": "Verificar novamente",
|
||||||
"marketplaceSearchFailed": "Não foi possível pesquisar nos mercados de habilidades.",
|
"marketplaceSearchFailed": "Não foi possível pesquisar nos mercados de skills.",
|
||||||
"marketplaceInstallFailed": "Não foi possível instalar esta habilidade.",
|
"marketplaceInstallFailed": "Não foi possível instalar esta skill.",
|
||||||
"marketplaceSearchPlaceholder": "Pesquisar habilidades",
|
"marketplaceSearchPlaceholder": "Pesquisar skills",
|
||||||
"marketplaceSearchLabel": "Pesquisar habilidades",
|
"marketplaceSearchLabel": "Pesquisar skills",
|
||||||
"marketplaceSearching": "Pesquisando",
|
"marketplaceSearching": "Pesquisando",
|
||||||
"marketplaceProviderFilter": "Origem da habilidade",
|
"marketplaceProviderFilter": "Origem da skill",
|
||||||
"marketplaceProviderAll": "Todas",
|
"marketplaceProviderAll": "Todas",
|
||||||
"marketplaceTrendingTitle": "Tendências por mercado",
|
"marketplaceTrendingTitle": "Tendências por mercado",
|
||||||
"marketplaceTrendingDescription": "Cada mercado mantém seu próprio ranking e métricas de instalação.",
|
"marketplaceTrendingDescription": "Cada mercado mantém seu próprio ranking e métricas de instalação.",
|
||||||
"marketplaceViewAll": "Ver todas",
|
"marketplaceViewAll": "Ver todas",
|
||||||
"marketplaceTrendingUnavailable": "As habilidades em alta estão temporariamente indisponíveis.",
|
"marketplaceTrendingUnavailable": "As skills em alta estão temporariamente indisponíveis.",
|
||||||
"marketplaceEmpty": "Nenhuma habilidade encontrada para “{{query}}”.",
|
"marketplaceEmpty": "Nenhuma skill encontrada para “{{query}}”.",
|
||||||
"marketplaceConfirmTitle": "Instalar {{name}}?",
|
"marketplaceConfirmTitle": "Instalar {{name}}?",
|
||||||
"marketplaceConfirmDescription": "Esta habilidade de terceiros vem de {{provider}} ({{source}}) e pode incluir instruções ou scripts executáveis.",
|
"marketplaceConfirmDescription": "Esta skill de terceiros vem de {{provider}} ({{source}}) e pode incluir instruções ou scripts executáveis.",
|
||||||
"marketplaceConfirmInstall": "Instalar habilidade",
|
"marketplaceConfirmInstall": "Instalar skill",
|
||||||
"marketplaceOpen": "Abrir {{name}} no {{provider}}",
|
"marketplaceOpen": "Abrir {{name}} no {{provider}}",
|
||||||
"marketplaceOpenProvider": "Abrir {{provider}}",
|
"marketplaceOpenProvider": "Abrir {{provider}}",
|
||||||
"marketplaceInstalls24h": "{{formattedCount}} instalações / 24 h",
|
"marketplaceInstalls24h": "{{formattedCount}} instalações / 24 h",
|
||||||
@@ -879,16 +866,16 @@
|
|||||||
"marketplaceInstall": "Instalar",
|
"marketplaceInstall": "Instalar",
|
||||||
"marketplaceNoTrend": "Ainda sem tendência",
|
"marketplaceNoTrend": "Ainda sem tendência",
|
||||||
"marketplaceTrendLabel": "Tendência de instalações em 8 semanas",
|
"marketplaceTrendLabel": "Tendência de instalações em 8 semanas",
|
||||||
"featured": "Habilidades do agente",
|
"featured": "Skills do agente",
|
||||||
"empty": "Nenhuma habilidade disponível.",
|
"empty": "Nenhuma skill disponível.",
|
||||||
"sourceWorkspace": "Personalizada",
|
"sourceWorkspace": "Personalizada",
|
||||||
"sourceBuiltin": "Embutida",
|
"sourceBuiltin": "Embutida",
|
||||||
"statusAvailable": "Disponível",
|
"statusAvailable": "Disponível",
|
||||||
"statusUnavailable": "Indisponível",
|
"statusUnavailable": "Indisponível",
|
||||||
"unavailableReason": "Faltando: {{reason}}",
|
"unavailableReason": "Faltando: {{reason}}",
|
||||||
"openDetails": "Abrir detalhes de {{name}}",
|
"openDetails": "Abrir detalhes de {{name}}",
|
||||||
"loadingDetail": "Carregando detalhes da habilidade...",
|
"loadingDetail": "Carregando detalhes da skill...",
|
||||||
"loadFailed": "Não foi possível carregar os detalhes da habilidade.",
|
"loadFailed": "Não foi possível carregar os detalhes da skill.",
|
||||||
"descriptionTitle": "Descrição",
|
"descriptionTitle": "Descrição",
|
||||||
"source": "Origem",
|
"source": "Origem",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
@@ -906,12 +893,12 @@
|
|||||||
"voice": {
|
"voice": {
|
||||||
"selectProvider": "Selecionar provedor",
|
"selectProvider": "Selecionar provedor",
|
||||||
"configureProvider": "Configurar provedor",
|
"configureProvider": "Configurar provedor",
|
||||||
"languageAuto": "Automático"
|
"languageAuto": "Auto"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"fallbackTitle": "Tópico {{id}}",
|
"fallbackTitle": "Tópico {{id}}",
|
||||||
"forkTitle": "Bifurcação: {{title}}",
|
"forkTitle": "Fork: {{title}}",
|
||||||
"loading": "Carregando…",
|
"loading": "Carregando…",
|
||||||
"noSessions": "Nenhuma sessão ainda.",
|
"noSessions": "Nenhuma sessão ainda.",
|
||||||
"showMore": "Mostrar mais {{count}}",
|
"showMore": "Mostrar mais {{count}}",
|
||||||
@@ -1001,7 +988,7 @@
|
|||||||
},
|
},
|
||||||
"brainstorm": {
|
"brainstorm": {
|
||||||
"title": "Fazer um brainstorming",
|
"title": "Fazer um brainstorming",
|
||||||
"prompt": "Sugira algumas ideias práticas e seus compromissos para este problema."
|
"prompt": "Sugira algumas ideias práticas e tradeoffs para este problema."
|
||||||
},
|
},
|
||||||
"code": {
|
"code": {
|
||||||
"title": "Escrever código",
|
"title": "Escrever código",
|
||||||
@@ -1013,25 +1000,25 @@
|
|||||||
},
|
},
|
||||||
"more": {
|
"more": {
|
||||||
"title": "Mais",
|
"title": "Mais",
|
||||||
"prompt": "Mostre-me algumas formas úteis com as quais você pode ajudar neste espaço de trabalho."
|
"prompt": "Mostre-me algumas formas úteis com as quais você pode ajudar neste workspace."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"imageQuickActions": {
|
"imageQuickActions": {
|
||||||
"icon": {
|
"icon": {
|
||||||
"title": "Desenhar um ícone de aplicativo",
|
"title": "Desenhar um ícone de app",
|
||||||
"prompt": "Gere um ícone de aplicativo 1:1 limpo para o nanobot: robô amigável, estilo vetorial simples, paleta suave em azul e branco, sem texto."
|
"prompt": "Gere um ícone de app 1:1 limpo para o nanobot: robô amigável, estilo vetorial simples, paleta suave em azul e branco, sem texto."
|
||||||
},
|
},
|
||||||
"sticker": {
|
"sticker": {
|
||||||
"title": "Criar um sticker",
|
"title": "Criar um sticker",
|
||||||
"prompt": "Gere uma imagem estilo adesivo de um pequeno assistente robô, com fundo de aparência transparente, expressivo e divertido."
|
"prompt": "Gere uma imagem estilo sticker de um pequeno assistente robô, com fundo de aparência transparente, expressivo e divertido."
|
||||||
},
|
},
|
||||||
"poster": {
|
"poster": {
|
||||||
"title": "Criar um pôster",
|
"title": "Criar um pôster",
|
||||||
"prompt": "Gere um conceito de pôster polido para um assistente pessoal de IA, composição moderna, hierarquia visual forte, adequado para uma página de destino."
|
"prompt": "Gere um conceito de pôster polido para um assistente pessoal de IA, composição moderna, hierarquia visual forte, adequado para uma landing page."
|
||||||
},
|
},
|
||||||
"product": {
|
"product": {
|
||||||
"title": "Maquete de produto",
|
"title": "Mockup de produto",
|
||||||
"prompt": "Gere uma imagem limpa de maquete de produto para um aplicativo web de IA conversacional, interface mínima, iluminação premium, moldura de dispositivo realista."
|
"prompt": "Gere uma imagem limpa de mockup de produto para um app web de IA conversacional, interface mínima, iluminação premium, moldura de dispositivo realista."
|
||||||
},
|
},
|
||||||
"portrait": {
|
"portrait": {
|
||||||
"title": "Retrato estilizado",
|
"title": "Retrato estilizado",
|
||||||
@@ -1170,7 +1157,7 @@
|
|||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"title": "Mostrar status",
|
"title": "Mostrar status",
|
||||||
"description": "Exibe o status de tempo de execução, provedor e canais."
|
"description": "Exibe o status de runtime, provedor e canais."
|
||||||
},
|
},
|
||||||
"model": {
|
"model": {
|
||||||
"title": "Modelo",
|
"title": "Modelo",
|
||||||
@@ -1194,7 +1181,7 @@
|
|||||||
},
|
},
|
||||||
"dream_prompt": {
|
"dream_prompt": {
|
||||||
"title": "Memória do Dream",
|
"title": "Memória do Dream",
|
||||||
"description": "Diz ao Dream como organizar a memória deste espaço de trabalho."
|
"description": "Diz ao Dream como organizar a memória deste workspace."
|
||||||
},
|
},
|
||||||
"goal": {
|
"goal": {
|
||||||
"title": "Objetivo de longa duração",
|
"title": "Objetivo de longa duração",
|
||||||
@@ -1215,20 +1202,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"mentions": {
|
"mentions": {
|
||||||
"ariaLabel": "Aplicativos",
|
"ariaLabel": "Apps",
|
||||||
"label": "Aplicativos",
|
"label": "Apps",
|
||||||
"cliGroup": "Aplicativos CLI",
|
"cliGroup": "Apps CLI",
|
||||||
"mcpGroup": "Serviços MCP",
|
"mcpGroup": "Serviços MCP",
|
||||||
"cliBadge": "CLI",
|
"cliBadge": "CLI",
|
||||||
"mcpBadge": "MCP",
|
"mcpBadge": "MCP",
|
||||||
"cliDescription": "Usar @{{name}} como aplicativo CLI local",
|
"cliDescription": "Usar @{{name}} como app CLI local",
|
||||||
"mcpDescription": "Usar @{{name}} como servidor MCP",
|
"mcpDescription": "Usar @{{name}} como servidor MCP"
|
||||||
"cliTitle": "Aplicativo CLI: {{name}}",
|
|
||||||
"mcpTitle": "Servidor MCP: {{name}}"
|
|
||||||
},
|
},
|
||||||
"encoding": "Codificando…",
|
"encoding": "Codificando…",
|
||||||
"remove": "Remover anexo",
|
"remove": "Remover anexo",
|
||||||
"normalizedSizeHint": "{{orig}} → {{current}} (automático)",
|
"normalizedSizeHint": "{{orig}} → {{current}} (auto)",
|
||||||
"textTooLarge": "O texto da mensagem é grande demais (máximo de {{max}})",
|
"textTooLarge": "O texto da mensagem é grande demais (máximo de {{max}})",
|
||||||
"imageRejected": {
|
"imageRejected": {
|
||||||
"unsupported_type": "Tipo de arquivo não compatível",
|
"unsupported_type": "Tipo de arquivo não compatível",
|
||||||
@@ -1243,7 +1228,7 @@
|
|||||||
"io": "Não foi possível ler este arquivo"
|
"io": "Não foi possível ler este arquivo"
|
||||||
},
|
},
|
||||||
"workspace": {
|
"workspace": {
|
||||||
"accessAria": "Modo de acesso ao espaço de trabalho",
|
"accessAria": "Modo de acesso ao workspace",
|
||||||
"projectAria": "Escolher projeto",
|
"projectAria": "Escolher projeto",
|
||||||
"projectPlaceholder": "Selecionar projeto",
|
"projectPlaceholder": "Selecionar projeto",
|
||||||
"default": "Permissão padrão",
|
"default": "Permissão padrão",
|
||||||
@@ -1254,14 +1239,13 @@
|
|||||||
},
|
},
|
||||||
"scrollToBottom": "Rolar para o final",
|
"scrollToBottom": "Rolar para o final",
|
||||||
"loadEarlier": "Carregar mensagens anteriores",
|
"loadEarlier": "Carregar mensagens anteriores",
|
||||||
"forkedFromHistory": "Bifurcado a partir do histórico",
|
"forkedFromHistory": "Fork a partir do histórico",
|
||||||
"promptNavigator": {
|
"promptNavigator": {
|
||||||
"open": "Abrir navegador de instruções",
|
"open": "Abrir navegador de prompts",
|
||||||
"title": "Instruções",
|
"title": "Prompts",
|
||||||
"search": "Buscar instruções",
|
"search": "Buscar prompts",
|
||||||
"noResults": "Nenhuma instrução correspondente.",
|
"noResults": "Nenhum prompt correspondente.",
|
||||||
"jumpTo": "Ir para a instrução: {{label}}",
|
"jumpTo": "Ir para o prompt: {{label}}"
|
||||||
"railAria": "Navegação pelas instruções do usuário"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
@@ -1293,21 +1277,13 @@
|
|||||||
"cliActivityRunningOne": "Usando {{name}}",
|
"cliActivityRunningOne": "Usando {{name}}",
|
||||||
"cliActivityRanOne": "Usou {{name}}",
|
"cliActivityRanOne": "Usou {{name}}",
|
||||||
"cliActivityFailedOne": "Falhou em {{name}}",
|
"cliActivityFailedOne": "Falhou em {{name}}",
|
||||||
"cliActivityRunningMany": "Usando {{count}} aplicativos CLI",
|
"cliActivityRunningMany": "Usando {{count}} apps CLI",
|
||||||
"cliActivityRanMany": "Usou {{count}} aplicativos CLI",
|
"cliActivityRanMany": "Usou {{count}} apps CLI",
|
||||||
"cliActivityFailedMany": "{{count}} aplicativos CLI falharam",
|
"cliActivityFailedMany": "{{count}} apps CLI falharam",
|
||||||
"cliRunRunning": "Usando",
|
"cliRunRunning": "Usando",
|
||||||
"cliRunRan": "Usou",
|
"cliRunRan": "Usou",
|
||||||
"cliRunFailed": "Falhou",
|
"cliRunFailed": "Falhou",
|
||||||
"imageAttachment": "Anexo de imagem",
|
"imageAttachment": "Anexo de imagem",
|
||||||
"videoAttachment": "Anexo de vídeo",
|
|
||||||
"fileAttachment": "Anexo de arquivo",
|
|
||||||
"attachmentUnavailable": "Anexo indisponível",
|
|
||||||
"dataTable": "Tabela de dados",
|
|
||||||
"fileEditPreparing": "Preparando a edição do arquivo…",
|
|
||||||
"openLink": "Abrir link: {{label}}",
|
|
||||||
"openAttachment": "Abrir {{name}}",
|
|
||||||
"skill": "Habilidade: {{name}}",
|
|
||||||
"automationSourceFallback": "Automação",
|
"automationSourceFallback": "Automação",
|
||||||
"automationTriggered": "Acionada automaticamente",
|
"automationTriggered": "Acionada automaticamente",
|
||||||
"askAboutSelection": "Perguntar sobre isto",
|
"askAboutSelection": "Perguntar sobre isto",
|
||||||
@@ -1315,14 +1291,14 @@
|
|||||||
"copyReply": "Copiar",
|
"copyReply": "Copiar",
|
||||||
"copiedReply": "Copiado",
|
"copiedReply": "Copiado",
|
||||||
"turnLatencyTitle": "Tempo de resposta (ponta a ponta)",
|
"turnLatencyTitle": "Tempo de resposta (ponta a ponta)",
|
||||||
"fileEditViewDiff": "Ver diferenças",
|
"fileEditViewDiff": "Ver diff",
|
||||||
"fileEditViewLargeDiff": "Ver diferenças grandes",
|
"fileEditViewLargeDiff": "Ver diff grande",
|
||||||
"fileEditDiffLineCount": "{{count}} linhas",
|
"fileEditDiffLineCount": "{{count}} linhas",
|
||||||
"fileEditUnchangedLinesHidden": "{{count}} linhas inalteradas ocultas",
|
"fileEditUnchangedLinesHidden": "{{count}} linhas inalteradas ocultas",
|
||||||
"fileEditShowMoreLines": "Mostrar mais {{count}} linhas",
|
"fileEditShowMoreLines": "Mostrar mais {{count}} linhas",
|
||||||
"fileEditShowFewerLines": "Mostrar menos linhas",
|
"fileEditShowFewerLines": "Mostrar menos linhas",
|
||||||
"fileEditOpenFile": "Abrir arquivo",
|
"fileEditOpenFile": "Abrir arquivo",
|
||||||
"fileEditDiffTruncated": "Diferenças truncadas. Abra o arquivo para ver a alteração completa."
|
"fileEditDiffTruncated": "Diff truncado. Abra o arquivo para ver a alteração completa."
|
||||||
},
|
},
|
||||||
"lightbox": {
|
"lightbox": {
|
||||||
"title": "Pré-visualização de imagem",
|
"title": "Pré-visualização de imagem",
|
||||||
@@ -1333,7 +1309,6 @@
|
|||||||
},
|
},
|
||||||
"filePreview": {
|
"filePreview": {
|
||||||
"aria": "Pré-visualização de arquivo",
|
"aria": "Pré-visualização de arquivo",
|
||||||
"breadcrumb": "Caminho do arquivo",
|
|
||||||
"close": "Fechar pré-visualização de arquivo",
|
"close": "Fechar pré-visualização de arquivo",
|
||||||
"loading": "Carregando pré-visualização...",
|
"loading": "Carregando pré-visualização...",
|
||||||
"failed": "Não foi possível pré-visualizar este arquivo.",
|
"failed": "Não foi possível pré-visualizar este arquivo.",
|
||||||
@@ -1348,10 +1323,7 @@
|
|||||||
"copied": "Copiado"
|
"copied": "Copiado"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"dismiss": "Descartar",
|
"dismiss": "Descartar"
|
||||||
"close": "Fechar",
|
|
||||||
"current": "Atual",
|
|
||||||
"cancel": "Cancelar"
|
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"messageTooBig": {
|
"messageTooBig": {
|
||||||
@@ -1359,8 +1331,8 @@
|
|||||||
"body": "O servidor rejeitou sua última mensagem porque ela excedeu o limite de tamanho. Remova algumas imagens ou tente arquivos menores e envie novamente."
|
"body": "O servidor rejeitou sua última mensagem porque ela excedeu o limite de tamanho. Remova algumas imagens ou tente arquivos menores e envie novamente."
|
||||||
},
|
},
|
||||||
"workspaceScopeRejected": {
|
"workspaceScopeRejected": {
|
||||||
"title": "O espaço de trabalho não foi alterado",
|
"title": "O workspace não foi alterado",
|
||||||
"body": "O nanobot manteve o espaço de trabalho anterior porque o projeto ou modo de acesso solicitado foi rejeitado pelo gateway."
|
"body": "O nanobot manteve o workspace anterior porque o projeto ou modo de acesso solicitado foi rejeitado pelo gateway."
|
||||||
},
|
},
|
||||||
"turnRejected": {
|
"turnRejected": {
|
||||||
"title": "A mensagem não foi enviada",
|
"title": "A mensagem não foi enviada",
|
||||||
@@ -1369,7 +1341,7 @@
|
|||||||
},
|
},
|
||||||
"workspace": {
|
"workspace": {
|
||||||
"dialog": {
|
"dialog": {
|
||||||
"defaultProject": "Espaço de trabalho padrão",
|
"defaultProject": "Workspace padrão",
|
||||||
"manual": "Colar caminho",
|
"manual": "Colar caminho",
|
||||||
"manualPlaceholder": "/Users/nome/projeto",
|
"manualPlaceholder": "/Users/nome/projeto",
|
||||||
"usePath": "Usar caminho",
|
"usePath": "Usar caminho",
|
||||||
|
|||||||
@@ -23,11 +23,11 @@
|
|||||||
},
|
},
|
||||||
"system": {
|
"system": {
|
||||||
"section": "Hệ thống",
|
"section": "Hệ thống",
|
||||||
"restartHint": "Khởi động lại nanobot để áp dụng thay đổi thời gian chạy.",
|
"restartHint": "Khởi động lại nanobot để áp dụng thay đổi runtime.",
|
||||||
"restart": "Khởi động lại nanobot",
|
"restart": "Khởi động lại nanobot",
|
||||||
"restarting": "Đang khởi động lại...",
|
"restarting": "Đang khởi động lại...",
|
||||||
"restartEngine": "Khởi động lại bộ máy",
|
"restartEngine": "Khởi động lại engine",
|
||||||
"restartingEngine": "Đang khởi động lại bộ máy..."
|
"restartingEngine": "Đang khởi động lại engine..."
|
||||||
},
|
},
|
||||||
"restart": {
|
"restart": {
|
||||||
"completed": "Khởi động lại hoàn tất sau {{seconds}} giây."
|
"completed": "Khởi động lại hoàn tất sau {{seconds}} giây."
|
||||||
@@ -37,21 +37,13 @@
|
|||||||
"chat": "{{title}} · nanobot"
|
"chat": "{{title}} · nanobot"
|
||||||
},
|
},
|
||||||
"meta": {
|
"meta": {
|
||||||
"description": "Giao diện web nanobot — trò chuyện với không gian làm việc nanobot của bạn."
|
"description": "Giao diện web nanobot — trò chuyện với workspace nanobot của bạn."
|
||||||
},
|
|
||||||
"pairing": {
|
|
||||||
"title": "Liên kết người dùng chat",
|
|
||||||
"description": "Nhập mã liên kết hiển thị trong cuộc trò chuyện.",
|
|
||||||
"code": "Mã liên kết",
|
|
||||||
"matched": "Đã khớp với {{channel}}. Đang kết nối...",
|
|
||||||
"expiresInline": "Mã hết hạn {{expires}}.",
|
|
||||||
"queueCount": "{{count}} đang chờ",
|
|
||||||
"noMatch": "Không có yêu cầu đang chờ nào khớp với mã này."
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"navigation": "Điều hướng thanh bên",
|
"navigation": "Điều hướng thanh bên",
|
||||||
"collapse": "Thu gọn thanh bên",
|
"collapse": "Thu gọn thanh bên",
|
||||||
|
"quickChat": "Trò chuyện nhanh",
|
||||||
"newChat": "Chủ đề mới",
|
"newChat": "Chủ đề mới",
|
||||||
"searchAria": "Tìm kiếm",
|
"searchAria": "Tìm kiếm",
|
||||||
"searchPlaceholder": "Tìm kiếm",
|
"searchPlaceholder": "Tìm kiếm",
|
||||||
@@ -69,6 +61,17 @@
|
|||||||
"title": "Kỹ năng"
|
"title": "Kỹ năng"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"quickChat": {
|
||||||
|
"greeting": "Bạn đang nghĩ gì?",
|
||||||
|
"temporary": {
|
||||||
|
"title": "Trò chuyện tạm thời",
|
||||||
|
"enter": "Tạm thời",
|
||||||
|
"active": "Tạm thời",
|
||||||
|
"exit": "Thoát trò chuyện tạm thời",
|
||||||
|
"greeting": "Bắt đầu trò chuyện tạm thời",
|
||||||
|
"description": "Không lịch sử, bộ nhớ, công cụ hay quyền truy cập dự án. Nội dung vẫn được gửi đến nhà cung cấp mô hình bạn chọn."
|
||||||
|
}
|
||||||
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"backToChat": "Quay lại chat",
|
"backToChat": "Quay lại chat",
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
@@ -101,20 +104,20 @@
|
|||||||
"about": "Giới thiệu",
|
"about": "Giới thiệu",
|
||||||
"status": "Trạng thái",
|
"status": "Trạng thái",
|
||||||
"localPreferences": "Tùy chọn cục bộ",
|
"localPreferences": "Tùy chọn cục bộ",
|
||||||
"presets": "Cấu hình đặt trước",
|
"presets": "Preset",
|
||||||
"imageGeneration": "Tạo hình ảnh",
|
"imageGeneration": "Tạo hình ảnh",
|
||||||
"imageDefaults": "Mặc định",
|
"imageDefaults": "Mặc định",
|
||||||
"webSearch": "Tìm kiếm web",
|
"webSearch": "Tìm kiếm web",
|
||||||
"webBehavior": "Hành vi",
|
"webBehavior": "Hành vi",
|
||||||
"regional": "Khu vực",
|
"identity": "Danh tính",
|
||||||
"webuiSafety": "An toàn WebUI",
|
"webuiSafety": "An toàn WebUI",
|
||||||
"capabilities": "Khả năng",
|
"capabilities": "Khả năng",
|
||||||
"cliApps": "Ứng dụng CLI",
|
"cliApps": "Ứng dụng CLI",
|
||||||
"mcp": "Dịch vụ MCP",
|
"mcp": "Dịch vụ MCP",
|
||||||
"apps": "Ứng dụng",
|
"apps": "Ứng dụng",
|
||||||
"nativeHost": "Máy chủ gốc",
|
"nativeHost": "Host gốc",
|
||||||
"hostSafety": "An toàn ứng dụng",
|
"hostSafety": "An toàn ứng dụng",
|
||||||
"voiceInput": "Nhập bằng giọng nói"
|
"voiceInput": "Nhap giong noi"
|
||||||
},
|
},
|
||||||
"rows": {
|
"rows": {
|
||||||
"theme": "Chủ đề",
|
"theme": "Chủ đề",
|
||||||
@@ -123,12 +126,12 @@
|
|||||||
"model": "Mô hình",
|
"model": "Mô hình",
|
||||||
"restart": "Khởi động lại nanobot",
|
"restart": "Khởi động lại nanobot",
|
||||||
"configPath": "Đường dẫn cấu hình",
|
"configPath": "Đường dẫn cấu hình",
|
||||||
"activePreset": "Cấu hình đặt trước đang dùng",
|
"activePreset": "Preset đang dùng",
|
||||||
"gateway": "Cổng",
|
"gateway": "Cổng",
|
||||||
"restartState": "Trạng thái khởi động lại",
|
"restartState": "Trạng thái khởi động lại",
|
||||||
"pendingChanges": "Thay đổi chờ áp dụng",
|
"pendingChanges": "Thay đổi chờ áp dụng",
|
||||||
"selectedPreset": "Cấu hình đặt trước đã chọn",
|
"selectedPreset": "Preset đã chọn",
|
||||||
"presetModel": "Mô hình cấu hình đặt trước",
|
"presetModel": "Mô hình preset",
|
||||||
"density": "Mật độ",
|
"density": "Mật độ",
|
||||||
"activityMode": "Chi tiết hoạt động",
|
"activityMode": "Chi tiết hoạt động",
|
||||||
"fileEditDisplay": "Hiển thị sửa tệp",
|
"fileEditDisplay": "Hiển thị sửa tệp",
|
||||||
@@ -145,8 +148,10 @@
|
|||||||
"defaultImageSize": "Kích thước mặc định",
|
"defaultImageSize": "Kích thước mặc định",
|
||||||
"maxImagesPerTurn": "Ảnh tối đa mỗi lượt",
|
"maxImagesPerTurn": "Ảnh tối đa mỗi lượt",
|
||||||
"imageSaveDir": "Thư mục lưu",
|
"imageSaveDir": "Thư mục lưu",
|
||||||
|
"botName": "Tên bot",
|
||||||
|
"botIcon": "Biểu tượng bot",
|
||||||
"timezone": "Múi giờ",
|
"timezone": "Múi giờ",
|
||||||
"workspacePath": "Không gian làm việc mặc định",
|
"workspacePath": "Workspace mặc định",
|
||||||
"localServiceAccess": "Dịch vụ cục bộ",
|
"localServiceAccess": "Dịch vụ cục bộ",
|
||||||
"webuiDefaultAccess": "Quyền mặc định",
|
"webuiDefaultAccess": "Quyền mặc định",
|
||||||
"currentModel": "Cấu hình hiện tại",
|
"currentModel": "Cấu hình hiện tại",
|
||||||
@@ -157,55 +162,57 @@
|
|||||||
"logs": "Nhật ký",
|
"logs": "Nhật ký",
|
||||||
"diagnostics": "Chẩn đoán",
|
"diagnostics": "Chẩn đoán",
|
||||||
"contextWindow": "Cửa sổ ngữ cảnh",
|
"contextWindow": "Cửa sổ ngữ cảnh",
|
||||||
"transcription": "Chuyển giọng nói thành văn bản",
|
"transcription": "Phien am",
|
||||||
"transcriptionProvider": "Nhà cung cấp chuyển giọng nói",
|
"transcriptionProvider": "Nha cung cap",
|
||||||
"transcriptionProviderStatus": "Trạng thái nhà cung cấp chuyển giọng nói",
|
"transcriptionProviderStatus": "Trang thai nha cung cap",
|
||||||
"transcriptionModel": "Mô hình chuyển giọng nói",
|
"transcriptionModel": "Mo hinh",
|
||||||
"transcriptionLanguage": "Ngôn ngữ",
|
"transcriptionLanguage": "Ngon ngu",
|
||||||
"voiceLimits": "Giới hạn"
|
"voiceLimits": "Gioi han"
|
||||||
},
|
},
|
||||||
"help": {
|
"help": {
|
||||||
"theme": "Chuyển giữa giao diện sáng và tối.",
|
"theme": "Chuyển giữa giao diện sáng và tối.",
|
||||||
"language": "Chọn ngôn ngữ dùng trong WebUI.",
|
"language": "Chọn ngôn ngữ dùng trong WebUI.",
|
||||||
"provider": "Chọn nhà cung cấp cho các yêu cầu mô hình mới.",
|
"provider": "Selecciona el proveedor para nuevas solicitudes de modelo.",
|
||||||
"model": "Chọn mô hình mà cấu hình sẵn này sử dụng.",
|
"model": "Chọn mô hình mà cấu hình sẵn này sử dụng.",
|
||||||
"configPath": "Tệp cấu hình gateway hiện đang dùng.",
|
"configPath": "Archivo de configuración que usa actualmente el gateway.",
|
||||||
"selectedPreset": "Cấu hình đặt trước có tên chỉ đọc tại đây; hãy chỉnh sửa trong config.json.",
|
"selectedPreset": "Los preajustes con nombre son de solo lectura aquí; edítalos en config.json.",
|
||||||
"presetModel": "Chuyển sang Mặc định để chỉnh sửa mô hình và nhà cung cấp trong WebUI.",
|
"presetModel": "Chuyển sang Default để chỉnh sửa mô hình và nhà cung cấp từ WebUI.",
|
||||||
"density": "Chỉ lưu trong trình duyệt này.",
|
"density": "Chỉ lưu trong trình duyệt này.",
|
||||||
"activityMode": "Chọn mức chi tiết hoạt động của tác nhân hiển thị mặc định.",
|
"activityMode": "Chọn mức chi tiết hoạt động agent hiển thị mặc định.",
|
||||||
"fileEditDisplay": "Chọn hoạt động sửa tệp hiển thị số dòng hay khác biệt.",
|
"fileEditDisplay": "Chọn hoạt động sửa tệp hiển thị số dòng hay diff.",
|
||||||
"codeWrap": "Giữ các dòng mã dài dễ đọc trên màn hình nhỏ.",
|
"codeWrap": "Giữ các dòng mã dài dễ đọc trên màn hình nhỏ.",
|
||||||
"maxResults": "Số kết quả được trả về sau mỗi lần gọi web_search.",
|
"maxResults": "Resultados devueltos por cada llamada web_search.",
|
||||||
"timeout": "Số giây trước khi yêu cầu của nhà cung cấp tìm kiếm hết thời gian.",
|
"timeout": "Segundos antes de que una solicitud de búsqueda expire.",
|
||||||
"jinaReader": "Dùng Jina Reader cho web_fetch khi có thể.",
|
"jinaReader": "Usa Jina Reader para web_fetch cuando esté disponible.",
|
||||||
"imageGeneration": "Hiển thị generate_image trong chat khi đã cấu hình nhà cung cấp hình ảnh.",
|
"imageGeneration": "Expone generate_image en chats cuando hay un proveedor de imagen configurado.",
|
||||||
"imageProvider": "Chọn nhà cung cấp registry được generate_image sử dụng.",
|
"imageProvider": "Elige el proveedor registrado usado por generate_image.",
|
||||||
"imageProviderStatus": "Tạo ảnh dùng lại thông tin xác thực từ mục Nhà cung cấp.",
|
"imageProviderStatus": "La generación de imágenes reutiliza credenciales de Proveedores.",
|
||||||
"imageModel": "Tên mô hình gửi tới nhà cung cấp ảnh đã chọn.",
|
"imageModel": "Nombre del modelo enviado al proveedor de imágenes seleccionado.",
|
||||||
"defaultAspectRatio": "Được dùng khi lời nhắc không chọn tỷ lệ khung hình.",
|
"defaultAspectRatio": "Se usa cuando el prompt no elige una proporción.",
|
||||||
"defaultImageSize": "Gợi ý kích thước gửi tới nhà cung cấp hỗ trợ.",
|
"defaultImageSize": "Gợi ý kích thước gửi tới nhà cung cấp hỗ trợ.",
|
||||||
"maxImagesPerTurn": "Giới hạn trên cho một yêu cầu generate_image.",
|
"maxImagesPerTurn": "Giới hạn trên cho một yêu cầu generate_image.",
|
||||||
"timezone": "Dùng cho lịch và các câu trả lời có yếu tố thời gian.",
|
"botName": "Se muestra donde nanobot usa un nombre visible.",
|
||||||
"localServiceAccess": "Cho phép lệnh shell có quyền truy cập đầy đủ truy cập các dịch vụ cục bộ.",
|
"botIcon": "Emoji o texto corto junto al nombre del bot.",
|
||||||
|
"timezone": "Se usa para horarios y respuestas con conciencia temporal.",
|
||||||
|
"localServiceAccess": "Cho phép lệnh shell Full Access truy cập dịch vụ localhost.",
|
||||||
"webuiDefaultAccess": "Dùng cho chat web không có quyền riêng theo dự án.",
|
"webuiDefaultAccess": "Dùng cho chat web không có quyền riêng theo dự án.",
|
||||||
"securityManagedControls": "Việc tải nội dung web luôn bảo vệ các dịch vụ cục bộ, riêng tư và siêu dữ liệu. Tính an toàn của các kênh cốt lõi vẫn do config.json quản lý.",
|
"securityManagedControls": "Las capturas web siempre protegen servicios locales, privados y metadata. La seguridad de canales core se gestiona en config.json.",
|
||||||
"currentModel": "Dùng cho các phản hồi mới.",
|
"currentModel": "Dùng cho các phản hồi mới.",
|
||||||
"selectedModelProvider": "Được đặt bởi mô hình đã chọn.",
|
"selectedModelProvider": "Definido por el modelo seleccionado.",
|
||||||
"selectedModelValue": "Được đặt bởi mô hình đã chọn.",
|
"selectedModelValue": "Definido por el modelo seleccionado.",
|
||||||
"brandLogos": "Hiển thị logo nhà cung cấp bên thứ ba và CLI trong Cài đặt.",
|
"brandLogos": "Hiển thị logo nhà cung cấp bên thứ ba và CLI trong Cài đặt.",
|
||||||
"cliAppsCatalog": "Chỉ cài đặt các bộ chuyển đổi CLI ứng dụng mà nanobot có thể chạy cục bộ; ứng dụng gốc không bị thay đổi.",
|
"cliAppsCatalog": "Instala solo adaptadores CLI de apps que nanobot puede ejecutar localmente; las apps nativas no se modifican.",
|
||||||
"cliAppsFilter": "Tìm theo ứng dụng, danh mục hoặc khả năng.",
|
"cliAppsFilter": "Busca por app, categoría o capacidad.",
|
||||||
"logs": "Mở thư mục nhật ký của bộ máy gốc.",
|
"logs": "Abre la carpeta de registros del motor nativo.",
|
||||||
"diagnostics": "Xuất báo cáo thời gian chạy ngắn để hỗ trợ.",
|
"diagnostics": "Exporta un pequeño informe de runtime para soporte.",
|
||||||
"localServiceAccessNative": "Cho phép lệnh shell có quyền truy cập đầy đủ truy cập các dịch vụ trên máy Mac này.",
|
"localServiceAccessNative": "Permite que comandos shell con Full Access alcancen servicios en este Mac.",
|
||||||
"webuiDefaultAccessNative": "Dùng cho chat gốc không có quyền riêng theo dự án.",
|
"webuiDefaultAccessNative": "Usado por chats nativos sin permiso específico de proyecto.",
|
||||||
"contextWindow": "Chọn ngân sách ngữ cảnh mặc định cho cấu hình mô hình này.",
|
"contextWindow": "Chọn ngân sách ngữ cảnh mặc định cho cấu hình mô hình này.",
|
||||||
"transcription": "Chuyển giọng nói từ micrô thành văn bản trước khi gửi. Tin nhắn thoại từ các kênh chat cũng dùng cùng cài đặt.",
|
"transcription": "Phien am dau vao micro truoc khi gui. Tin nhan giong noi tu kenh chat dung cung cai dat.",
|
||||||
"transcriptionProvider": "Dùng thông tin xác thực của nhà cung cấp tương ứng trong mục Nhà cung cấp.",
|
"transcriptionProvider": "Dung thong tin xac thuc cua nha cung cap tu Providers.",
|
||||||
"transcriptionProviderStatus": "Khóa API nằm trong mục nhà cung cấp, không nằm trong cài đặt chuyển giọng nói.",
|
"transcriptionProviderStatus": "API key nam trong providers, khong nam trong cai dat transcription.",
|
||||||
"transcriptionModel": "Giữ mô hình mặc định đã phân giải, trừ khi nhà cung cấp yêu cầu ID mô hình tùy chỉnh.",
|
"transcriptionModel": "Giu mac dinh da resolve tru khi nha cung cap can id model tuy chinh.",
|
||||||
"transcriptionLanguage": "Gợi ý ISO-639 tùy chọn, chẳng hạn en, zh, ja hoặc ko."
|
"transcriptionLanguage": "Goi y ISO-639 tuy chon, nhu en, zh, ja hoac ko."
|
||||||
},
|
},
|
||||||
"values": {
|
"values": {
|
||||||
"light": "Sáng",
|
"light": "Sáng",
|
||||||
@@ -217,15 +224,15 @@
|
|||||||
"ready": "Sẵn sàng",
|
"ready": "Sẵn sàng",
|
||||||
"privateEngine": "Bộ máy riêng",
|
"privateEngine": "Bộ máy riêng",
|
||||||
"unixSocket": "Socket Unix",
|
"unixSocket": "Socket Unix",
|
||||||
"defaultWorkspace": "Không gian làm việc mặc định",
|
"defaultWorkspace": "Workspace mặc định",
|
||||||
"comfortable": "Thoải mái",
|
"comfortable": "Thoải mái",
|
||||||
"compact": "Gọn",
|
"compact": "Gọn",
|
||||||
"auto": "Tự động",
|
"auto": "Tự động",
|
||||||
"expanded": "Mở rộng",
|
"expanded": "Mở rộng",
|
||||||
"default": "Mặc định",
|
"default": "Mặc định",
|
||||||
"summary": "Tóm tắt",
|
"summary": "Tóm tắt",
|
||||||
"diff": "Khác biệt",
|
"diff": "Diff",
|
||||||
"collapsedDiff": "Khác biệt đã thu gọn",
|
"collapsedDiff": "Diff thu gọn",
|
||||||
"on": "Bật",
|
"on": "Bật",
|
||||||
"off": "Tắt",
|
"off": "Tắt",
|
||||||
"defaultPermission": "Quyền mặc định",
|
"defaultPermission": "Quyền mặc định",
|
||||||
@@ -233,27 +240,24 @@
|
|||||||
"configured": "Đã cấu hình",
|
"configured": "Đã cấu hình",
|
||||||
"notConfigured": "Chưa cấu hình",
|
"notConfigured": "Chưa cấu hình",
|
||||||
"pending": "Đang chờ",
|
"pending": "Đang chờ",
|
||||||
"restartingEngine": "Đang khởi động lại",
|
"restartingEngine": "Đang khởi động lại"
|
||||||
"checking": "Đang kiểm tra",
|
|
||||||
"running": "Đang chạy",
|
|
||||||
"needsSetup": "Cần thiết lập"
|
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "Đang tải cài đặt...",
|
"loading": "Đang tải cài đặt...",
|
||||||
"loadError": "Không thể tải cài đặt",
|
"loadError": "Không thể tải cài đặt",
|
||||||
"unsaved": "Có thay đổi chưa lưu.",
|
"unsaved": "Có thay đổi chưa lưu.",
|
||||||
"upToDate": "Đã cập nhật.",
|
"upToDate": "Đã cập nhật.",
|
||||||
"savedRestart": "Đã lưu. Khởi động lại nanobot để áp dụng.",
|
"savedRestart": "Guardado. Reinicia nanobot para aplicar.",
|
||||||
"restartAfterSaving": "Lưu thay đổi, rồi khởi động lại khi sẵn sàng.",
|
"restartAfterSaving": "Guarda los cambios y reinicia cuando puedas.",
|
||||||
"savedRestartApply": "Đã lưu. Khởi động lại khi sẵn sàng.",
|
"savedRestartApply": "Guardado. Reinicia cuando puedas.",
|
||||||
"imageProviderRestart": "Đã lưu thay đổi nhà cung cấp ảnh. Khởi động lại khi sẵn sàng.",
|
"imageProviderRestart": "Cambios del proveedor de imagen guardados. Reinicia cuando puedas.",
|
||||||
"hostRestartAfterSaving": "Sau khi lưu, nanobot sẽ khởi động lại bộ máy.",
|
"hostRestartAfterSaving": "Al guardar, nanobot reiniciará su motor.",
|
||||||
"hostRestartPending": "Đã lưu. Bộ máy sẽ khởi động lại khi sẵn sàng.",
|
"hostRestartPending": "Guardado. El motor se reiniciará cuando esté listo.",
|
||||||
"hostApiUnavailable": "Các thao tác máy chủ chỉ khả dụng trong ứng dụng gốc.",
|
"hostApiUnavailable": "Las acciones del host solo están disponibles en la app nativa.",
|
||||||
"logsOpened": "Đã mở thư mục nhật ký.",
|
"logsOpened": "Carpeta de registros abierta.",
|
||||||
"logsOpenFailed": "Không thể mở thư mục nhật ký.",
|
"logsOpenFailed": "No se pudo abrir la carpeta de registros.",
|
||||||
"diagnosticsExported": "Đã xuất chẩn đoán tới {{path}}.",
|
"diagnosticsExported": "Diagnóstico exportado a {{path}}.",
|
||||||
"diagnosticsExportFailed": "Không thể xuất chẩn đoán."
|
"diagnosticsExportFailed": "No se pudo exportar el diagnóstico."
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"save": "Lưu",
|
"save": "Lưu",
|
||||||
@@ -264,31 +268,30 @@
|
|||||||
"deleting": "Đang xóa...",
|
"deleting": "Đang xóa...",
|
||||||
"edit": "Sửa",
|
"edit": "Sửa",
|
||||||
"cancel": "Hủy",
|
"cancel": "Hủy",
|
||||||
"dismiss": "Bỏ qua",
|
|
||||||
"open": "Mở",
|
"open": "Mở",
|
||||||
"export": "Xuất",
|
"export": "Xuất",
|
||||||
"opening": "Đang mở...",
|
"opening": "Đang mở...",
|
||||||
"exporting": "Đang xuất..."
|
"exporting": "Đang xuất..."
|
||||||
},
|
},
|
||||||
"byok": {
|
"byok": {
|
||||||
"description": "Dùng khóa nhà cung cấp của riêng bạn. Nanobot đọc các giá trị này từ cấu hình hiện tại và chỉ nhà cung cấp đã cấu hình mới có thể dùng trong cấu hình mô hình đặt trước.",
|
"description": "Dùng key provider của riêng bạn. Nanobot đọc các giá trị này từ config hiện tại và chỉ provider đã cấu hình mới có thể dùng trong cấu hình mô hình đặt trước.",
|
||||||
"configured": "Đã cấu hình",
|
"configured": "Đã cấu hình",
|
||||||
"notConfigured": "Chưa cấu hình",
|
"notConfigured": "Chưa cấu hình",
|
||||||
"configuredSection": "Đã cấu hình",
|
"configuredSection": "Đã cấu hình",
|
||||||
"notConfiguredSection": "Chưa cấu hình",
|
"notConfiguredSection": "Chưa cấu hình",
|
||||||
"showMore": "Hiển thị thêm {{count}}",
|
"showMore": "Hiển thị thêm {{count}}",
|
||||||
"showLess": "Thu gọn",
|
"showLess": "Thu gọn",
|
||||||
"apiKey": "Khóa API",
|
"apiKey": "API key",
|
||||||
"apiBase": "Cơ sở API",
|
"apiBase": "API base",
|
||||||
"apiKeyPlaceholder": "Nhập khóa API",
|
"apiKeyPlaceholder": "Nhập API key",
|
||||||
"apiKeyConfiguredPlaceholder": "Để trống để giữ khóa hiện tại",
|
"apiKeyConfiguredPlaceholder": "Để trống để giữ key hiện tại",
|
||||||
"configuredKeyHint": "Khóa đã cấu hình",
|
"configuredKeyHint": "Key đã cấu hình",
|
||||||
"apiBasePlaceholder": "Dùng giá trị mặc định của nhà cung cấp",
|
"apiBasePlaceholder": "Dùng mặc định của provider",
|
||||||
"apiKeyRequired": "Cần khóa API để cấu hình nhà cung cấp này.",
|
"apiKeyRequired": "Cần API key để cấu hình provider này.",
|
||||||
"showApiKey": "Hiển thị khóa API",
|
"showApiKey": "Hiển thị API key",
|
||||||
"hideApiKey": "Ẩn khóa API",
|
"hideApiKey": "Ẩn API key",
|
||||||
"noConfiguredProviders": "Chưa có nhà cung cấp nào được cấu hình",
|
"noConfiguredProviders": "Chưa có provider đã cấu hình",
|
||||||
"configureFirst": "Hãy cấu hình nhà cung cấp trong BYOK trước.",
|
"configureFirst": "Hãy cấu hình provider trong BYOK trước.",
|
||||||
"openByok": "Mở BYOK",
|
"openByok": "Mở BYOK",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"ariaLabel": "Loại thông tin xác thực BYOK",
|
"ariaLabel": "Loại thông tin xác thực BYOK",
|
||||||
@@ -297,19 +300,19 @@
|
|||||||
},
|
},
|
||||||
"webSearch": {
|
"webSearch": {
|
||||||
"provider": "Nhà cung cấp tìm kiếm",
|
"provider": "Nhà cung cấp tìm kiếm",
|
||||||
"providerHelp": "Chọn hệ thống phụ trợ mà công cụ tìm kiếm web sẽ dùng.",
|
"providerHelp": "Chọn backend mà công cụ web search sẽ dùng.",
|
||||||
"selectProvider": "Chọn nhà cung cấp",
|
"selectProvider": "Chọn provider",
|
||||||
"credentials": "Thông tin xác thực",
|
"credentials": "Thông tin xác thực",
|
||||||
"noCredentialRequired": "Không cần khóa",
|
"noCredentialRequired": "Không cần key",
|
||||||
"noCredentialHelp": "DuckDuckGo hoạt động mà không cần lưu khóa API.",
|
"noCredentialHelp": "DuckDuckGo hoạt động mà không cần lưu API key.",
|
||||||
"apiKeyHelp": "Được lưu trong config và chỉ hiện dạng che sau khi lưu.",
|
"apiKeyHelp": "Được lưu trong config và chỉ hiện dạng che sau khi lưu.",
|
||||||
"baseUrl": "URL cơ sở",
|
"baseUrl": "Base URL",
|
||||||
"baseUrlHelp": "SearXNG cần URL instance của bạn.",
|
"baseUrlHelp": "SearXNG cần URL instance của bạn.",
|
||||||
"baseUrlPlaceholder": "https://search.example.com",
|
"baseUrlPlaceholder": "https://search.example.com",
|
||||||
"apiKeyRequired": "Nhà cung cấp tìm kiếm này cần khóa API.",
|
"apiKeyRequired": "Provider tìm kiếm này cần API key.",
|
||||||
"baseUrlRequired": "SearXNG cần URL cơ sở.",
|
"baseUrlRequired": "SearXNG cần Base URL.",
|
||||||
"missingCredential": "Thêm thông tin bắt buộc trước khi lưu.",
|
"missingCredential": "Thêm thông tin bắt buộc trước khi lưu.",
|
||||||
"saveHint": "Thay đổi áp dụng cho các yêu cầu tìm kiếm trên web mới."
|
"saveHint": "Thay đổi áp dụng cho các yêu cầu web search mới."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"overview": {
|
"overview": {
|
||||||
@@ -324,7 +327,7 @@
|
|||||||
},
|
},
|
||||||
"usage": {
|
"usage": {
|
||||||
"title": "Hoạt động token",
|
"title": "Hoạt động token",
|
||||||
"shortTitle": "Mức dùng token",
|
"shortTitle": "Token Usage",
|
||||||
"subtitle": "Mức dùng do nhà cung cấp báo cáo trong 12 tháng gần nhất.",
|
"subtitle": "Mức dùng do nhà cung cấp báo cáo trong 12 tháng gần nhất.",
|
||||||
"empty": "Hoạt động token sẽ xuất hiện sau các phản hồi mô hình mới.",
|
"empty": "Hoạt động token sẽ xuất hiện sau các phản hồi mô hình mới.",
|
||||||
"totalTokens": "Tổng token",
|
"totalTokens": "Tổng token",
|
||||||
@@ -371,18 +374,8 @@
|
|||||||
"selectProvider": "Chọn nhà cung cấp",
|
"selectProvider": "Chọn nhà cung cấp",
|
||||||
"selectAspect": "Chọn tỷ lệ",
|
"selectAspect": "Chọn tỷ lệ",
|
||||||
"selectSize": "Chọn kích thước",
|
"selectSize": "Chọn kích thước",
|
||||||
"selectModel": "Chọn mô hình ảnh",
|
|
||||||
"searchOrTypeModel": "Tìm kiếm hoặc nhập ID mô hình",
|
|
||||||
"typeModelId": "Nhập ID mô hình được nhà cung cấp này hỗ trợ.",
|
|
||||||
"configureProvider": "Cấu hình nhà cung cấp",
|
"configureProvider": "Cấu hình nhà cung cấp",
|
||||||
"missingCredential": "Cấu hình nhà cung cấp này trước khi bật tạo ảnh."
|
"missingCredential": "Configura este proveedor antes de activar la generación de imágenes."
|
||||||
},
|
|
||||||
"capabilities": {
|
|
||||||
"providerSupport": "Hỗ trợ nhà cung cấp",
|
|
||||||
"providerInstallOnSave": "Hỗ trợ cần thiết sẽ được cài đặt tự động khi bạn lưu nhà cung cấp này.",
|
|
||||||
"searchSupport": "Hỗ trợ nhà cung cấp tìm kiếm",
|
|
||||||
"searchInstallOnSave": "Hỗ trợ Olostep sẽ được cài đặt tự động khi bạn lưu.",
|
|
||||||
"installing": "Đang cài đặt hỗ trợ..."
|
|
||||||
},
|
},
|
||||||
"models": {
|
"models": {
|
||||||
"selectModel": "Chọn mô hình",
|
"selectModel": "Chọn mô hình",
|
||||||
@@ -406,7 +399,7 @@
|
|||||||
"advancedOptions": "Tùy chọn nâng cao",
|
"advancedOptions": "Tùy chọn nâng cao",
|
||||||
"advancedSummary": "Ngữ cảnh {{context}} · Tối đa {{max}} token",
|
"advancedSummary": "Ngữ cảnh {{context}} · Tối đa {{max}} token",
|
||||||
"maxTokens": "Token đầu ra tối đa",
|
"maxTokens": "Token đầu ra tối đa",
|
||||||
"temperature": "Nhiệt độ",
|
"temperature": "Temperature",
|
||||||
"reasoningEffort": "Mức suy luận",
|
"reasoningEffort": "Mức suy luận",
|
||||||
"convertTitle": "Chuyển đổi thiết lập mô hình hiện tại",
|
"convertTitle": "Chuyển đổi thiết lập mô hình hiện tại",
|
||||||
"convertHelp": "Chuyển mô hình chính và các mô hình dự phòng hiện có thành cấu hình đặt trước để quản lý thứ tự tại đây.",
|
"convertHelp": "Chuyển mô hình chính và các mô hình dự phòng hiện có thành cấu hình đặt trước để quản lý thứ tự tại đây.",
|
||||||
@@ -481,11 +474,11 @@
|
|||||||
},
|
},
|
||||||
"mcp": {
|
"mcp": {
|
||||||
"allCategories": "Tất cả danh mục",
|
"allCategories": "Tất cả danh mục",
|
||||||
"summary": "Đã bật {{installed}} / {{total}} cấu hình đặt trước",
|
"summary": "Đã bật {{installed}} / {{total}} preset",
|
||||||
"filterAll": "Tất cả",
|
"filterAll": "Tất cả",
|
||||||
"filterInstalled": "Đã bật",
|
"filterInstalled": "Đã bật",
|
||||||
"filterNotInstalled": "Chưa bật",
|
"filterNotInstalled": "Chưa bật",
|
||||||
"searchPlaceholder": "Tìm cấu hình đặt trước MCP",
|
"searchPlaceholder": "Tìm preset MCP",
|
||||||
"moreOptions": "Tùy chọn MCP khác",
|
"moreOptions": "Tùy chọn MCP khác",
|
||||||
"moreOptionsSubtitle": "Thêm máy chủ tùy chỉnh hoặc nhập mcp.json.",
|
"moreOptionsSubtitle": "Thêm máy chủ tùy chỉnh hoặc nhập mcp.json.",
|
||||||
"customTitle": "MCP tùy chỉnh",
|
"customTitle": "MCP tùy chỉnh",
|
||||||
@@ -496,9 +489,9 @@
|
|||||||
"serverUrl": "URL",
|
"serverUrl": "URL",
|
||||||
"transport": "Giao thức truyền",
|
"transport": "Giao thức truyền",
|
||||||
"command": "Lệnh",
|
"command": "Lệnh",
|
||||||
"args": "Đối số JSON",
|
"args": "Args JSON",
|
||||||
"headers": "Header JSON",
|
"headers": "Headers JSON",
|
||||||
"env": "Môi trường JSON",
|
"env": "Env JSON",
|
||||||
"timeout": "Thời gian chờ công cụ",
|
"timeout": "Thời gian chờ công cụ",
|
||||||
"advancedOptions": "Tùy chọn nâng cao",
|
"advancedOptions": "Tùy chọn nâng cao",
|
||||||
"hideAdvanced": "Ẩn nâng cao",
|
"hideAdvanced": "Ẩn nâng cao",
|
||||||
@@ -507,8 +500,8 @@
|
|||||||
"importConfig": "Nhập",
|
"importConfig": "Nhập",
|
||||||
"restartRequired": "Khởi động lại nanobot để kết nối các công cụ MCP đã cập nhật.",
|
"restartRequired": "Khởi động lại nanobot để kết nối các công cụ MCP đã cập nhật.",
|
||||||
"toolsFound": "{{count}} công cụ",
|
"toolsFound": "{{count}} công cụ",
|
||||||
"loading": "Đang tải cấu hình đặt trước MCP...",
|
"loading": "Đang tải preset MCP...",
|
||||||
"empty": "Không có cấu hình đặt trước MCP nào khớp bộ lọc này.",
|
"empty": "Không có preset MCP nào khớp bộ lọc này.",
|
||||||
"openDocs": "Mở tài liệu",
|
"openDocs": "Mở tài liệu",
|
||||||
"test": "Kiểm tra",
|
"test": "Kiểm tra",
|
||||||
"remove": "Xóa",
|
"remove": "Xóa",
|
||||||
@@ -526,7 +519,6 @@
|
|||||||
"statusMissingCredentials": "Cần khóa",
|
"statusMissingCredentials": "Cần khóa",
|
||||||
"statusMissingDependency": "Cần phụ thuộc",
|
"statusMissingDependency": "Cần phụ thuộc",
|
||||||
"statusComingSoon": "Sắp ra mắt",
|
"statusComingSoon": "Sắp ra mắt",
|
||||||
"comingSoon": "Sắp ra mắt",
|
|
||||||
"statusNotInstalled": "Chưa bật",
|
"statusNotInstalled": "Chưa bật",
|
||||||
"toolScope": "Công cụ",
|
"toolScope": "Công cụ",
|
||||||
"allTools": "Tất cả",
|
"allTools": "Tất cả",
|
||||||
@@ -534,7 +526,7 @@
|
|||||||
"testForTools": "Chạy Kiểm tra để xem và chọn từng công cụ."
|
"testForTools": "Chạy Kiểm tra để xem và chọn từng công cụ."
|
||||||
},
|
},
|
||||||
"api": {
|
"api": {
|
||||||
"title": "Máy chủ API", "openaiCompatible": "API tương thích OpenAI", "description": "Kết nối SDK và tác nhân qua điểm cuối /v1 cục bộ.",
|
"title": "Máy chủ API", "openaiCompatible": "API tương thích OpenAI", "description": "Kết nối SDK và agent qua endpoint /v1 cục bộ.",
|
||||||
"start": "Khởi động API", "starting": "Đang khởi động...", "stop": "Dừng", "stopping": "Đang dừng...",
|
"start": "Khởi động API", "starting": "Đang khởi động...", "stop": "Dừng", "stopping": "Đang dừng...",
|
||||||
"access": "Truy cập", "thisDevice": "Thiết bị này", "localNetwork": "Mạng nội bộ",
|
"access": "Truy cập", "thisDevice": "Thiết bị này", "localNetwork": "Mạng nội bộ",
|
||||||
"localHelp": "Chỉ thiết bị này có thể kết nối.", "networkHelp": "Thiết bị khác có thể kết nối; cần khóa API.",
|
"localHelp": "Chỉ thiết bị này có thể kết nối.", "networkHelp": "Thiết bị khác có thể kết nối; cần khóa API.",
|
||||||
@@ -568,7 +560,7 @@
|
|||||||
"restartRequired": "Khởi động lại nanobot để áp dụng ứng dụng và tính năng đã cập nhật."
|
"restartRequired": "Khởi động lại nanobot để áp dụng ứng dụng và tính năng đã cập nhật."
|
||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
"description": "Kết nối nanobot với các ứng dụng chat. Cài đặt hỗ trợ chỉ thêm gói tích hợp; hầu hết kênh vẫn cần token hoặc cấu hình không gian làm việc.",
|
"description": "Kết nối nanobot với các ứng dụng chat. Cài đặt hỗ trợ chỉ thêm gói tích hợp; hầu hết kênh vẫn cần token hoặc cấu hình workspace.",
|
||||||
"caption": "{{enabled}} đã bật · {{total}} kênh",
|
"caption": "{{enabled}} đã bật · {{total}} kênh",
|
||||||
"searchPlaceholder": "Tìm kênh",
|
"searchPlaceholder": "Tìm kênh",
|
||||||
"backToChannels": "Tất cả kênh",
|
"backToChannels": "Tất cả kênh",
|
||||||
@@ -589,8 +581,6 @@
|
|||||||
"advanced": "Nâng cao",
|
"advanced": "Nâng cao",
|
||||||
"checkAndEnable": "Kiểm tra và bật",
|
"checkAndEnable": "Kiểm tra và bật",
|
||||||
"checkConnection": "Kiểm tra kết nối",
|
"checkConnection": "Kiểm tra kết nối",
|
||||||
"connectionChecks": "Kiểm tra kết nối",
|
|
||||||
"open": "Mở",
|
|
||||||
"checkedAndEnabled": "Đã kiểm tra và bật.",
|
"checkedAndEnabled": "Đã kiểm tra và bật.",
|
||||||
"checking": "Đang kiểm tra...",
|
"checking": "Đang kiểm tra...",
|
||||||
"checkOnly": "Chỉ kiểm tra",
|
"checkOnly": "Chỉ kiểm tra",
|
||||||
@@ -686,8 +676,6 @@
|
|||||||
"protected": "Được bảo vệ",
|
"protected": "Được bảo vệ",
|
||||||
"editTitle": "Sửa tự động hóa",
|
"editTitle": "Sửa tự động hóa",
|
||||||
"save": "Lưu",
|
"save": "Lưu",
|
||||||
"commandCopied": "Đã sao chép",
|
|
||||||
"copyCommand": "Sao chép",
|
|
||||||
"deleteTitle": "Xóa tự động hóa",
|
"deleteTitle": "Xóa tự động hóa",
|
||||||
"deleteDescription": "Thao tác này xóa {{name}} khỏi kho cron. Tin nhắn chat trước đó vẫn ở trong phiên.",
|
"deleteDescription": "Thao tác này xóa {{name}} khỏi kho cron. Tin nhắn chat trước đó vẫn ở trong phiên.",
|
||||||
"cancel": "Hủy",
|
"cancel": "Hủy",
|
||||||
@@ -747,7 +735,6 @@
|
|||||||
"fields": {
|
"fields": {
|
||||||
"name": "Tên",
|
"name": "Tên",
|
||||||
"message": "Tin nhắn",
|
"message": "Tin nhắn",
|
||||||
"command": "Lệnh",
|
|
||||||
"scheduleType": "Loại lịch",
|
"scheduleType": "Loại lịch",
|
||||||
"every": "Mỗi",
|
"every": "Mỗi",
|
||||||
"unit": "Đơn vị",
|
"unit": "Đơn vị",
|
||||||
@@ -782,7 +769,7 @@
|
|||||||
"signInAgain": "Đăng nhập lại",
|
"signInAgain": "Đăng nhập lại",
|
||||||
"signOut": "Đăng xuất",
|
"signOut": "Đăng xuất",
|
||||||
"signedInAs": "Đã đăng nhập bằng {{account}}",
|
"signedInAs": "Đã đăng nhập bằng {{account}}",
|
||||||
"signInHelp": "Đăng nhập từ thiết bị này; khóa API không được lưu trong config.",
|
"signInHelp": "Inicia sesión desde este dispositivo; no se guarda API key en config.",
|
||||||
"remoteSignInHelp": "Chọn Đăng nhập để mở xAI trên máy tính của bạn, sau đó dán mã ủy quyền được hiển thị sau khi đăng nhập.",
|
"remoteSignInHelp": "Chọn Đăng nhập để mở xAI trên máy tính của bạn, sau đó dán mã ủy quyền được hiển thị sau khi đăng nhập.",
|
||||||
"codexRemoteSignInHelp": "Đăng nhập trong trình duyệt này, sau đó dán lại URL callback localhost đầy đủ vào nanobot.",
|
"codexRemoteSignInHelp": "Đăng nhập trong trình duyệt này, sau đó dán lại URL callback localhost đầy đủ vào nanobot.",
|
||||||
"signInRequired": "Cần đăng nhập",
|
"signInRequired": "Cần đăng nhập",
|
||||||
@@ -805,7 +792,7 @@
|
|||||||
"finishSignIn": "Hoàn tất đăng nhập"
|
"finishSignIn": "Hoàn tất đăng nhập"
|
||||||
},
|
},
|
||||||
"skills": {
|
"skills": {
|
||||||
"description": "Xem các kỹ năng chỉ dẫn mà tác nhân này có thể tải trong cuộc trò chuyện.",
|
"description": "Xem các kỹ năng chỉ dẫn mà agent này có thể tải trong cuộc trò chuyện.",
|
||||||
"caption": "{{available}} khả dụng · tổng {{total}}",
|
"caption": "{{available}} khả dụng · tổng {{total}}",
|
||||||
"views": "Chế độ xem kỹ năng",
|
"views": "Chế độ xem kỹ năng",
|
||||||
"installedTab": "Đã cài đặt",
|
"installedTab": "Đã cài đặt",
|
||||||
@@ -813,34 +800,34 @@
|
|||||||
"customGroup": "Tùy chỉnh",
|
"customGroup": "Tùy chỉnh",
|
||||||
"builtinGroup": "Tích hợp sẵn",
|
"builtinGroup": "Tích hợp sẵn",
|
||||||
"otherGroup": "Khác",
|
"otherGroup": "Khác",
|
||||||
"searchInstalled": "Tìm kỹ năng đã cài đặt",
|
"searchInstalled": "Tìm skill đã cài đặt",
|
||||||
"filterAll": "Tất cả",
|
"filterAll": "Tất cả",
|
||||||
"filterEnabled": "Đã bật",
|
"filterEnabled": "Đã bật",
|
||||||
"filterDisabled": "Đã tắt",
|
"filterDisabled": "Đã tắt",
|
||||||
"noMatching": "Không có kỹ năng phù hợp.",
|
"noMatching": "Không có skill phù hợp.",
|
||||||
"statusDisabled": "Đã tắt",
|
"statusDisabled": "Đã tắt",
|
||||||
"statusEnabled": "Đã bật",
|
"statusEnabled": "Đã bật",
|
||||||
"statusNeedsSetup": "Cần thiết lập",
|
"statusNeedsSetup": "Cần thiết lập",
|
||||||
"showLess": "Thu gọn",
|
"showLess": "Thu gọn",
|
||||||
"showMore": "Hiển thị thêm",
|
"showMore": "Hiển thị thêm",
|
||||||
"enabledControl": "Sử dụng kỹ năng này",
|
"enabledControl": "Sử dụng skill này",
|
||||||
"enabledDescription": "Cho phép tác nhân tải kỹ năng này khi các yêu cầu đã sẵn sàng.",
|
"enabledDescription": "Cho phép agent tải skill này khi các yêu cầu đã sẵn sàng.",
|
||||||
"enableSkill": "Bật {{name}}",
|
"enableSkill": "Bật {{name}}",
|
||||||
"disableSkill": "Tắt {{name}}",
|
"disableSkill": "Tắt {{name}}",
|
||||||
"updateFailed": "Không thể cập nhật kỹ năng này.",
|
"updateFailed": "Không thể cập nhật skill này.",
|
||||||
"deleteTitle": "Xóa kỹ năng",
|
"deleteTitle": "Xóa skill",
|
||||||
"deleteDescription": "Xóa kỹ năng này khỏi không gian làm việc hiện tại.",
|
"deleteDescription": "Xóa skill này khỏi workspace hiện tại.",
|
||||||
"deleteAction": "Xóa",
|
"deleteAction": "Xóa",
|
||||||
"deleteFailed": "Không thể xóa kỹ năng này.",
|
"deleteFailed": "Không thể xóa skill này.",
|
||||||
"deleteConfirmTitle": "Xóa {{name}}?",
|
"deleteConfirmTitle": "Xóa {{name}}?",
|
||||||
"deleteConfirmDescription": "Thao tác này xóa các tệp kỹ năng khỏi không gian làm việc hiện tại và không thể hoàn tác.",
|
"deleteConfirmDescription": "Thao tác này xóa các tệp skill khỏi workspace hiện tại và không thể hoàn tác.",
|
||||||
"deleteConfirmAction": "Xóa kỹ năng",
|
"deleteConfirmAction": "Xóa skill",
|
||||||
"instructionsTitle": "Hướng dẫn kỹ năng",
|
"instructionsTitle": "Hướng dẫn skill",
|
||||||
"setupRequired": "Cần thiết lập",
|
"setupRequired": "Cần thiết lập",
|
||||||
"setupDescription": "Cài đặt phần phụ thuộc còn thiếu trên máy chạy nanobot rồi kiểm tra lại.",
|
"setupDescription": "Cài đặt phần phụ thuộc còn thiếu trên máy chạy nanobot rồi kiểm tra lại.",
|
||||||
"copySetupCommand": "Sao chép lệnh thiết lập",
|
"copySetupCommand": "Sao chép lệnh thiết lập",
|
||||||
"checkAgain": "Kiểm tra lại",
|
"checkAgain": "Kiểm tra lại",
|
||||||
"marketplaceSearchFailed": "Không thể tìm kiếm các chợ kỹ năng.",
|
"marketplaceSearchFailed": "Không thể tìm kiếm các kho kỹ năng.",
|
||||||
"marketplaceInstallFailed": "Không thể cài đặt kỹ năng này.",
|
"marketplaceInstallFailed": "Không thể cài đặt kỹ năng này.",
|
||||||
"marketplaceSearchPlaceholder": "Tìm kiếm kỹ năng",
|
"marketplaceSearchPlaceholder": "Tìm kiếm kỹ năng",
|
||||||
"marketplaceSearchLabel": "Tìm kiếm kỹ năng",
|
"marketplaceSearchLabel": "Tìm kiếm kỹ năng",
|
||||||
@@ -853,7 +840,7 @@
|
|||||||
"marketplaceTrendingUnavailable": "Các kỹ năng thịnh hành tạm thời không khả dụng.",
|
"marketplaceTrendingUnavailable": "Các kỹ năng thịnh hành tạm thời không khả dụng.",
|
||||||
"marketplaceEmpty": "Không tìm thấy kỹ năng cho “{{query}}”.",
|
"marketplaceEmpty": "Không tìm thấy kỹ năng cho “{{query}}”.",
|
||||||
"marketplaceConfirmTitle": "Cài đặt {{name}}?",
|
"marketplaceConfirmTitle": "Cài đặt {{name}}?",
|
||||||
"marketplaceConfirmDescription": "Kỹ năng của bên thứ ba này đến từ {{provider}} ({{source}}) và có thể chứa hướng dẫn hoặc tập lệnh thực thi.",
|
"marketplaceConfirmDescription": "Kỹ năng bên thứ ba này đến từ {{provider}} ({{source}}) và có thể chứa hướng dẫn hoặc tập lệnh thực thi.",
|
||||||
"marketplaceConfirmInstall": "Cài đặt kỹ năng",
|
"marketplaceConfirmInstall": "Cài đặt kỹ năng",
|
||||||
"marketplaceOpen": "Mở {{name}} trên {{provider}}",
|
"marketplaceOpen": "Mở {{name}} trên {{provider}}",
|
||||||
"marketplaceOpenProvider": "Mở {{provider}}",
|
"marketplaceOpenProvider": "Mở {{provider}}",
|
||||||
@@ -865,7 +852,7 @@
|
|||||||
"marketplaceInstall": "Cài đặt",
|
"marketplaceInstall": "Cài đặt",
|
||||||
"marketplaceNoTrend": "Chưa có xu hướng",
|
"marketplaceNoTrend": "Chưa có xu hướng",
|
||||||
"marketplaceTrendLabel": "Xu hướng lượt cài đặt trong 8 tuần",
|
"marketplaceTrendLabel": "Xu hướng lượt cài đặt trong 8 tuần",
|
||||||
"featured": "Kỹ năng của tác nhân",
|
"featured": "Kỹ năng agent",
|
||||||
"empty": "Không có kỹ năng nào khả dụng.",
|
"empty": "Không có kỹ năng nào khả dụng.",
|
||||||
"sourceWorkspace": "Tùy chỉnh",
|
"sourceWorkspace": "Tùy chỉnh",
|
||||||
"sourceBuiltin": "Tích hợp",
|
"sourceBuiltin": "Tích hợp",
|
||||||
@@ -890,9 +877,9 @@
|
|||||||
"detailDescription": "Chi tiết cho {{name}}."
|
"detailDescription": "Chi tiết cho {{name}}."
|
||||||
},
|
},
|
||||||
"voice": {
|
"voice": {
|
||||||
"selectProvider": "Chọn nhà cung cấp",
|
"selectProvider": "Chon nha cung cap",
|
||||||
"configureProvider": "Cấu hình nhà cung cấp",
|
"configureProvider": "Cau hinh nha cung cap",
|
||||||
"languageAuto": "Tự động"
|
"languageAuto": "Tu dong"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
@@ -906,34 +893,34 @@
|
|||||||
"actions": "Tác vụ cho chủ đề {{title}}",
|
"actions": "Tác vụ cho chủ đề {{title}}",
|
||||||
"newInProject": "Bắt đầu chủ đề mới trong {{project}}",
|
"newInProject": "Bắt đầu chủ đề mới trong {{project}}",
|
||||||
"activity": {
|
"activity": {
|
||||||
"running": "Tác nhân đang chạy",
|
"running": "Agent running",
|
||||||
"complete": "Tác nhân đã hoàn tất",
|
"complete": "Agent finished",
|
||||||
"updated": "Hoạt động mới"
|
"updated": "New activity"
|
||||||
},
|
},
|
||||||
"pin": "Ghim",
|
"pin": "Pin",
|
||||||
"unpin": "Bỏ ghim",
|
"unpin": "Unpin",
|
||||||
"rename": "Đổi tên",
|
"rename": "Rename",
|
||||||
"renameTitle": "Đổi tên chủ đề",
|
"renameTitle": "Đổi tên chủ đề",
|
||||||
"renameDescription": "Chọn tên hiển thị trong thanh bên cho chủ đề này.",
|
"renameDescription": "Chọn tên hiển thị trong thanh bên cho chủ đề này.",
|
||||||
"renamePlaceholder": "Tên chủ đề",
|
"renamePlaceholder": "Tên chủ đề",
|
||||||
"renameProjectTitle": "Đổi tên dự án",
|
"renameProjectTitle": "Rename project",
|
||||||
"renameProjectDescription": "Chọn tên hiển thị cục bộ cho dự án này trên thanh bên.",
|
"renameProjectDescription": "Choose a local sidebar name for this project.",
|
||||||
"renameProjectPlaceholder": "Tên dự án",
|
"renameProjectPlaceholder": "Project name",
|
||||||
"renameSave": "Lưu",
|
"renameSave": "Save",
|
||||||
"archive": "Lưu trữ",
|
"archive": "Archive",
|
||||||
"unarchive": "Bỏ lưu trữ",
|
"unarchive": "Unarchive",
|
||||||
"showArchived": "Hiện mục đã lưu trữ",
|
"showArchived": "Show archived",
|
||||||
"hideArchived": "Ẩn mục đã lưu trữ",
|
"hideArchived": "Hide archived",
|
||||||
"delete": "Xóa",
|
"delete": "Xóa",
|
||||||
"newChat": "Chủ đề mới",
|
"newChat": "Chủ đề mới",
|
||||||
"groups": {
|
"groups": {
|
||||||
"pinned": "Đã ghim",
|
"pinned": "Pinned",
|
||||||
"all": "Chủ đề",
|
"all": "Chủ đề",
|
||||||
"projects": "Dự án",
|
"projects": "Projects",
|
||||||
"today": "Hôm nay",
|
"today": "Today",
|
||||||
"yesterday": "Hôm qua",
|
"yesterday": "Yesterday",
|
||||||
"earlier": "Trước đó",
|
"earlier": "Earlier",
|
||||||
"archived": "Đã lưu trữ"
|
"archived": "Archived"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"deleteConfirm": {
|
"deleteConfirm": {
|
||||||
@@ -999,25 +986,25 @@
|
|||||||
},
|
},
|
||||||
"more": {
|
"more": {
|
||||||
"title": "Thêm",
|
"title": "Thêm",
|
||||||
"prompt": "Cho tôi xem vài cách hữu ích mà bạn có thể giúp trong không gian làm việc này."
|
"prompt": "Cho tôi xem vài cách hữu ích mà bạn có thể giúp trong workspace này."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"imageQuickActions": {
|
"imageQuickActions": {
|
||||||
"icon": {
|
"icon": {
|
||||||
"title": "Thiết kế biểu tượng ứng dụng",
|
"title": "Thiết kế biểu tượng app",
|
||||||
"prompt": "Tạo một biểu tượng ứng dụng 1:1 gọn gàng cho nanobot: robot thân thiện, phong cách vector đơn giản, bảng màu xanh trắng dịu, không có chữ."
|
"prompt": "Tạo một biểu tượng ứng dụng 1:1 gọn gàng cho nanobot: robot thân thiện, phong cách vector đơn giản, bảng màu xanh trắng dịu, không có chữ."
|
||||||
},
|
},
|
||||||
"sticker": {
|
"sticker": {
|
||||||
"title": "Tạo nhãn dán",
|
"title": "Tạo sticker",
|
||||||
"prompt": "Tạo một hình kiểu nhãn dán dễ thương của trợ lý robot nhỏ, nền trông như trong suốt, biểu cảm và vui nhộn."
|
"prompt": "Tạo một hình kiểu sticker dễ thương của trợ lý robot nhỏ, nền trông như trong suốt, biểu cảm và vui nhộn."
|
||||||
},
|
},
|
||||||
"poster": {
|
"poster": {
|
||||||
"title": "Tạo poster",
|
"title": "Tạo poster",
|
||||||
"prompt": "Tạo một ý tưởng poster chỉn chu cho trợ lý AI cá nhân, bố cục hiện đại, phân cấp thị giác rõ, phù hợp cho trang đích."
|
"prompt": "Tạo một ý tưởng poster chỉn chu cho trợ lý AI cá nhân, bố cục hiện đại, phân cấp thị giác rõ, phù hợp cho landing page."
|
||||||
},
|
},
|
||||||
"product": {
|
"product": {
|
||||||
"title": "Mô hình mẫu sản phẩm",
|
"title": "Mockup sản phẩm",
|
||||||
"prompt": "Tạo một hình mô hình mẫu sản phẩm gọn gàng cho ứng dụng web AI hội thoại, giao diện tối giản, ánh sáng cao cấp, khung thiết bị chân thực."
|
"prompt": "Tạo một hình mockup sản phẩm gọn gàng cho ứng dụng web AI hội thoại, giao diện tối giản, ánh sáng cao cấp, khung thiết bị chân thực."
|
||||||
},
|
},
|
||||||
"portrait": {
|
"portrait": {
|
||||||
"title": "Chân dung cách điệu",
|
"title": "Chân dung cách điệu",
|
||||||
@@ -1098,7 +1085,7 @@
|
|||||||
"auto": "Tự động",
|
"auto": "Tự động",
|
||||||
"1_1": "Vuông 1:1",
|
"1_1": "Vuông 1:1",
|
||||||
"3_4": "Dọc 3:4",
|
"3_4": "Dọc 3:4",
|
||||||
"9_16": "Tin 9:16",
|
"9_16": "Story 9:16",
|
||||||
"4_3": "Ngang 4:3",
|
"4_3": "Ngang 4:3",
|
||||||
"16_9": "Rộng 16:9"
|
"16_9": "Rộng 16:9"
|
||||||
}
|
}
|
||||||
@@ -1138,7 +1125,7 @@
|
|||||||
},
|
},
|
||||||
"stop": {
|
"stop": {
|
||||||
"title": "Dừng tác vụ hiện tại",
|
"title": "Dừng tác vụ hiện tại",
|
||||||
"description": "Hủy lượt của tác nhân đang chạy trong cuộc trò chuyện này."
|
"description": "Hủy lượt agent đang chạy trong cuộc trò chuyện này."
|
||||||
},
|
},
|
||||||
"restart": {
|
"restart": {
|
||||||
"title": "Khởi động lại nanobot",
|
"title": "Khởi động lại nanobot",
|
||||||
@@ -1146,11 +1133,11 @@
|
|||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"title": "Hiển thị trạng thái",
|
"title": "Hiển thị trạng thái",
|
||||||
"description": "Hiển thị trạng thái thời gian chạy, nhà cung cấp và kênh."
|
"description": "Hiển thị trạng thái runtime, provider và channel."
|
||||||
},
|
},
|
||||||
"model": {
|
"model": {
|
||||||
"title": "Mô hình",
|
"title": "Mô hình",
|
||||||
"description": "Hiển thị hoặc chuyển cấu hình đặt trước của mô hình đang hoạt động."
|
"description": "Hiển thị hoặc chuyển preset mô hình đang hoạt động."
|
||||||
},
|
},
|
||||||
"history": {
|
"history": {
|
||||||
"title": "Hiển thị lịch sử",
|
"title": "Hiển thị lịch sử",
|
||||||
@@ -1166,19 +1153,19 @@
|
|||||||
},
|
},
|
||||||
"dream_restore": {
|
"dream_restore": {
|
||||||
"title": "Khôi phục bộ nhớ",
|
"title": "Khôi phục bộ nhớ",
|
||||||
"description": "Đưa bộ nhớ về một ảnh chụp Dream trước đó."
|
"description": "Đưa bộ nhớ về một snapshot Dream trước đó."
|
||||||
},
|
},
|
||||||
"dream_prompt": {
|
"dream_prompt": {
|
||||||
"title": "Bộ nhớ Dream",
|
"title": "Bộ nhớ Dream",
|
||||||
"description": "Cho Dream biết cách sắp xếp bộ nhớ của không gian làm việc này."
|
"description": "Cho Dream biết cách sắp xếp bộ nhớ của workspace này."
|
||||||
},
|
},
|
||||||
"goal": {
|
"goal": {
|
||||||
"title": "Mục tiêu dài hạn",
|
"title": "Mục tiêu dài hạn",
|
||||||
"description": "Yêu cầu tác nhân xử lý đây là mục tiêu nhiều bước kéo dài."
|
"description": "Yêu cầu agent xử lý đây là mục tiêu nhiều bước kéo dài."
|
||||||
},
|
},
|
||||||
"trigger": {
|
"trigger": {
|
||||||
"title": "Tạo trình kích hoạt cục bộ",
|
"title": "Tạo trigger cục bộ",
|
||||||
"description": "Tạo trình kích hoạt CLI gắn với phiên chat này."
|
"description": "Tạo trigger CLI gắn với phiên chat này."
|
||||||
},
|
},
|
||||||
"help": {
|
"help": {
|
||||||
"title": "Hiển thị trợ giúp",
|
"title": "Hiển thị trợ giúp",
|
||||||
@@ -1224,12 +1211,10 @@
|
|||||||
"cliBadge": "CLI",
|
"cliBadge": "CLI",
|
||||||
"mcpBadge": "MCP",
|
"mcpBadge": "MCP",
|
||||||
"cliDescription": "Dùng @{{name}} như ứng dụng CLI cục bộ",
|
"cliDescription": "Dùng @{{name}} như ứng dụng CLI cục bộ",
|
||||||
"mcpDescription": "Dùng @{{name}} như máy chủ MCP",
|
"mcpDescription": "Dùng @{{name}} như máy chủ MCP"
|
||||||
"cliTitle": "Ứng dụng CLI: {{name}}",
|
|
||||||
"mcpTitle": "Máy chủ MCP: {{name}}"
|
|
||||||
},
|
},
|
||||||
"workspace": {
|
"workspace": {
|
||||||
"accessAria": "Chế độ truy cập không gian làm việc",
|
"accessAria": "Chế độ truy cập workspace",
|
||||||
"projectAria": "Chọn dự án",
|
"projectAria": "Chọn dự án",
|
||||||
"projectPlaceholder": "Chọn dự án",
|
"projectPlaceholder": "Chọn dự án",
|
||||||
"default": "Quyền mặc định",
|
"default": "Quyền mặc định",
|
||||||
@@ -1242,12 +1227,11 @@
|
|||||||
"loadEarlier": "Tải tin nhắn trước đó",
|
"loadEarlier": "Tải tin nhắn trước đó",
|
||||||
"forkedFromHistory": "Tách nhánh từ lịch sử",
|
"forkedFromHistory": "Tách nhánh từ lịch sử",
|
||||||
"promptNavigator": {
|
"promptNavigator": {
|
||||||
"open": "Mở trình điều hướng lời nhắc",
|
"open": "Mở trình điều hướng prompt",
|
||||||
"title": "Lời nhắc",
|
"title": "Prompt",
|
||||||
"search": "Tìm lời nhắc",
|
"search": "Tìm prompt",
|
||||||
"noResults": "Không có lời nhắc phù hợp.",
|
"noResults": "Không có prompt phù hợp.",
|
||||||
"jumpTo": "Nhảy tới lời nhắc: {{label}}",
|
"jumpTo": "Nhảy tới prompt: {{label}}"
|
||||||
"railAria": "Điều hướng lời nhắc của người dùng"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
@@ -1271,27 +1255,19 @@
|
|||||||
"agentActivityLiveSummary": "Đang chạy… · {{reasoning}} bước · {{tools}} lần gọi công cụ",
|
"agentActivityLiveSummary": "Đang chạy… · {{reasoning}} bước · {{tools}} lần gọi công cụ",
|
||||||
"agentActivityLiveToolsOnly": "Đang chạy… · {{tools}} lần gọi công cụ",
|
"agentActivityLiveToolsOnly": "Đang chạy… · {{tools}} lần gọi công cụ",
|
||||||
"imageAttachment": "Tệp hình ảnh đính kèm",
|
"imageAttachment": "Tệp hình ảnh đính kèm",
|
||||||
"videoAttachment": "Tệp video đính kèm",
|
|
||||||
"fileAttachment": "Tệp đính kèm",
|
|
||||||
"attachmentUnavailable": "Tệp đính kèm không khả dụng",
|
|
||||||
"dataTable": "Bảng dữ liệu",
|
|
||||||
"fileEditPreparing": "Đang chuẩn bị sửa tệp…",
|
|
||||||
"openLink": "Mở liên kết: {{label}}",
|
|
||||||
"openAttachment": "Mở {{name}}",
|
|
||||||
"skill": "Kỹ năng: {{name}}",
|
|
||||||
"askAboutSelection": "Hỏi về nội dung này",
|
"askAboutSelection": "Hỏi về nội dung này",
|
||||||
"forkFromHere": "Tách nhánh",
|
"forkFromHere": "Tách nhánh",
|
||||||
"copyReply": "Sao chép",
|
"copyReply": "Sao chép",
|
||||||
"copiedReply": "Đã sao chép",
|
"copiedReply": "Đã sao chép",
|
||||||
"turnLatencyTitle": "Thời gian phản hồi (end-to-end)",
|
"turnLatencyTitle": "Thời gian phản hồi (end-to-end)",
|
||||||
"fileEditViewDiff": "Xem khác biệt",
|
"fileEditViewDiff": "Xem diff",
|
||||||
"fileEditViewLargeDiff": "Xem khác biệt lớn",
|
"fileEditViewLargeDiff": "Xem diff lớn",
|
||||||
"fileEditDiffLineCount": "{{count}} dòng",
|
"fileEditDiffLineCount": "{{count}} dòng",
|
||||||
"fileEditUnchangedLinesHidden": "Đã ẩn {{count}} dòng không đổi",
|
"fileEditUnchangedLinesHidden": "Đã ẩn {{count}} dòng không đổi",
|
||||||
"fileEditShowMoreLines": "Hiển thị thêm {{count}} dòng",
|
"fileEditShowMoreLines": "Hiển thị thêm {{count}} dòng",
|
||||||
"fileEditShowFewerLines": "Hiển thị ít dòng hơn",
|
"fileEditShowFewerLines": "Hiển thị ít dòng hơn",
|
||||||
"fileEditOpenFile": "Mở tệp",
|
"fileEditOpenFile": "Mở tệp",
|
||||||
"fileEditDiffTruncated": "Khác biệt đã bị cắt bớt. Mở tệp để xem toàn bộ thay đổi.",
|
"fileEditDiffTruncated": "Diff đã bị cắt bớt. Mở tệp để xem toàn bộ thay đổi.",
|
||||||
"activityThinkingFor": "Đang suy nghĩ trong {{duration}}",
|
"activityThinkingFor": "Đang suy nghĩ trong {{duration}}",
|
||||||
"activityThought": "Đã suy nghĩ",
|
"activityThought": "Đã suy nghĩ",
|
||||||
"activityThoughtFor": "Đã suy nghĩ trong {{duration}}",
|
"activityThoughtFor": "Đã suy nghĩ trong {{duration}}",
|
||||||
@@ -1319,7 +1295,6 @@
|
|||||||
},
|
},
|
||||||
"filePreview": {
|
"filePreview": {
|
||||||
"aria": "Xem trước tệp",
|
"aria": "Xem trước tệp",
|
||||||
"breadcrumb": "Đường dẫn tệp",
|
|
||||||
"close": "Đóng xem trước tệp",
|
"close": "Đóng xem trước tệp",
|
||||||
"loading": "Đang tải bản xem trước...",
|
"loading": "Đang tải bản xem trước...",
|
||||||
"failed": "Không thể xem trước tệp này.",
|
"failed": "Không thể xem trước tệp này.",
|
||||||
@@ -1334,10 +1309,7 @@
|
|||||||
"copied": "Đã sao chép"
|
"copied": "Đã sao chép"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"dismiss": "Đóng",
|
"dismiss": "Đóng"
|
||||||
"close": "Đóng",
|
|
||||||
"current": "Hiện tại",
|
|
||||||
"cancel": "Hủy"
|
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"messageTooBig": {
|
"messageTooBig": {
|
||||||
@@ -1345,8 +1317,8 @@
|
|||||||
"body": "Máy chủ đã từ chối tin nhắn trước vì vượt quá giới hạn kích thước. Hãy bớt ảnh hoặc chọn tệp nhỏ hơn rồi thử lại."
|
"body": "Máy chủ đã từ chối tin nhắn trước vì vượt quá giới hạn kích thước. Hãy bớt ảnh hoặc chọn tệp nhỏ hơn rồi thử lại."
|
||||||
},
|
},
|
||||||
"workspaceScopeRejected": {
|
"workspaceScopeRejected": {
|
||||||
"title": "Không gian làm việc không thay đổi",
|
"title": "Workspace không thay đổi",
|
||||||
"body": "Gateway đã từ chối dự án hoặc chế độ truy cập được yêu cầu, nên Nanobot giữ không gian làm việc trước đó."
|
"body": "Gateway đã từ chối dự án hoặc chế độ truy cập được yêu cầu, nên Nanobot giữ workspace trước đó."
|
||||||
},
|
},
|
||||||
"turnRejected": {
|
"turnRejected": {
|
||||||
"title": "Tin nhắn chưa được gửi",
|
"title": "Tin nhắn chưa được gửi",
|
||||||
@@ -1355,7 +1327,7 @@
|
|||||||
},
|
},
|
||||||
"workspace": {
|
"workspace": {
|
||||||
"dialog": {
|
"dialog": {
|
||||||
"defaultProject": "Không gian làm việc mặc định",
|
"defaultProject": "Workspace mặc định",
|
||||||
"manual": "Dán đường dẫn",
|
"manual": "Dán đường dẫn",
|
||||||
"manualPlaceholder": "/Users/name/project",
|
"manualPlaceholder": "/Users/name/project",
|
||||||
"usePath": "Dùng đường dẫn",
|
"usePath": "Dùng đường dẫn",
|
||||||
|
|||||||
@@ -7,18 +7,18 @@
|
|||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"title": "无法连接到 nanobot",
|
"title": "无法连接到 nanobot",
|
||||||
"gatewayHint": "请确认网关已启动(`nanobot gateway`),并且当前页面与网关运行在同一台机器上。"
|
"gatewayHint": "请确认 gateway 已启动(`nanobot gateway`),并且当前页面与 gateway 运行在同一台机器上。"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"title": "需要验证",
|
"title": "需要验证",
|
||||||
"hint": "请输入网关配置中的 tokenIssueSecret。",
|
"hint": "请输入 gateway 配置中的 tokenIssueSecret。",
|
||||||
"placeholder": "密码",
|
"placeholder": "密码",
|
||||||
"submit": "连接",
|
"submit": "连接",
|
||||||
"invalid": "密码无效,请重试。"
|
"invalid": "密码无效,请重试。"
|
||||||
},
|
},
|
||||||
"account": {
|
"account": {
|
||||||
"section": "账户",
|
"section": "账户",
|
||||||
"logoutHint": "断开此浏览器与网关的连接。",
|
"logoutHint": "断开此浏览器与 gateway 的连接。",
|
||||||
"logout": "退出登录"
|
"logout": "退出登录"
|
||||||
},
|
},
|
||||||
"system": {
|
"system": {
|
||||||
@@ -38,20 +38,12 @@
|
|||||||
},
|
},
|
||||||
"meta": {
|
"meta": {
|
||||||
"description": "nanobot Web UI —— 与你的 nanobot 工作区对话。"
|
"description": "nanobot Web UI —— 与你的 nanobot 工作区对话。"
|
||||||
},
|
|
||||||
"pairing": {
|
|
||||||
"title": "配对聊天用户",
|
|
||||||
"description": "输入聊天中显示的配对码。",
|
|
||||||
"code": "配对码",
|
|
||||||
"matched": "已匹配 {{channel}},正在连接…",
|
|
||||||
"expiresInline": "配对码将于 {{expires}} 过期。",
|
|
||||||
"queueCount": "{{count}} 个待处理",
|
|
||||||
"noMatch": "没有待处理请求与此配对码匹配。"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"navigation": "侧边栏导航",
|
"navigation": "侧边栏导航",
|
||||||
"collapse": "收起侧边栏",
|
"collapse": "收起侧边栏",
|
||||||
|
"quickChat": "随便聊聊",
|
||||||
"newChat": "新建话题",
|
"newChat": "新建话题",
|
||||||
"searchAria": "搜索",
|
"searchAria": "搜索",
|
||||||
"searchPlaceholder": "搜索",
|
"searchPlaceholder": "搜索",
|
||||||
@@ -69,6 +61,17 @@
|
|||||||
"title": "技能"
|
"title": "技能"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"quickChat": {
|
||||||
|
"greeting": "想聊点什么?",
|
||||||
|
"temporary": {
|
||||||
|
"title": "临时聊天",
|
||||||
|
"enter": "临时聊天",
|
||||||
|
"active": "临时聊天中",
|
||||||
|
"exit": "退出临时聊天",
|
||||||
|
"greeting": "开启一次临时聊天",
|
||||||
|
"description": "不保存记录,不读取记忆或项目,也不使用工具;内容仍会发送给你选择的模型服务商。"
|
||||||
|
}
|
||||||
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"backToChat": "返回聊天",
|
"backToChat": "返回聊天",
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
@@ -84,7 +87,7 @@
|
|||||||
"providers": "提供商",
|
"providers": "提供商",
|
||||||
"image": "图片",
|
"image": "图片",
|
||||||
"voice": "语音",
|
"voice": "语音",
|
||||||
"browser": "网络",
|
"browser": "网页",
|
||||||
"channels": "渠道",
|
"channels": "渠道",
|
||||||
"cliApps": "CLI 应用",
|
"cliApps": "CLI 应用",
|
||||||
"mcp": "MCP",
|
"mcp": "MCP",
|
||||||
@@ -104,11 +107,11 @@
|
|||||||
"presets": "预设",
|
"presets": "预设",
|
||||||
"imageGeneration": "图片生成",
|
"imageGeneration": "图片生成",
|
||||||
"imageDefaults": "默认值",
|
"imageDefaults": "默认值",
|
||||||
"webSearch": "网络搜索",
|
"webSearch": "网页搜索",
|
||||||
"webBehavior": "网络行为",
|
"webBehavior": "行为",
|
||||||
"cliApps": "CLI 应用",
|
"cliApps": "CLI 应用",
|
||||||
"mcp": "MCP 服务",
|
"mcp": "MCP 服务",
|
||||||
"regional": "区域",
|
"identity": "身份",
|
||||||
"webuiSafety": "WebUI 安全",
|
"webuiSafety": "WebUI 安全",
|
||||||
"capabilities": "能力",
|
"capabilities": "能力",
|
||||||
"apps": "应用",
|
"apps": "应用",
|
||||||
@@ -138,7 +141,7 @@
|
|||||||
"advancedOptions": "高级选项",
|
"advancedOptions": "高级选项",
|
||||||
"advancedSummary": "上下文 {{context}} · 最大输出 {{max}} tokens",
|
"advancedSummary": "上下文 {{context}} · 最大输出 {{max}} tokens",
|
||||||
"maxTokens": "最大输出 tokens",
|
"maxTokens": "最大输出 tokens",
|
||||||
"temperature": "温度",
|
"temperature": "Temperature",
|
||||||
"reasoningEffort": "推理强度",
|
"reasoningEffort": "推理强度",
|
||||||
"convertTitle": "转换现有模型设置",
|
"convertTitle": "转换现有模型设置",
|
||||||
"convertHelp": "把现有主模型和备用模型转换成预设,之后即可在这里管理调用顺序。",
|
"convertHelp": "把现有主模型和备用模型转换成预设,之后即可在这里管理调用顺序。",
|
||||||
@@ -184,7 +187,7 @@
|
|||||||
"presetModel": "预设模型",
|
"presetModel": "预设模型",
|
||||||
"density": "密度",
|
"density": "密度",
|
||||||
"activityMode": "活动详情",
|
"activityMode": "活动详情",
|
||||||
"fileEditDisplay": "文件编辑显示",
|
"fileEditDisplay": "文件编辑展示",
|
||||||
"codeWrap": "代码换行",
|
"codeWrap": "代码换行",
|
||||||
"brandLogos": "品牌 Logo",
|
"brandLogos": "品牌 Logo",
|
||||||
"maxResults": "最大结果数",
|
"maxResults": "最大结果数",
|
||||||
@@ -199,6 +202,8 @@
|
|||||||
"defaultImageSize": "默认尺寸",
|
"defaultImageSize": "默认尺寸",
|
||||||
"maxImagesPerTurn": "每轮最大图片数",
|
"maxImagesPerTurn": "每轮最大图片数",
|
||||||
"imageSaveDir": "保存目录",
|
"imageSaveDir": "保存目录",
|
||||||
|
"botName": "Bot 名称",
|
||||||
|
"botIcon": "Bot 图标",
|
||||||
"timezone": "时区",
|
"timezone": "时区",
|
||||||
"workspacePath": "默认工作区",
|
"workspacePath": "默认工作区",
|
||||||
"localServiceAccess": "本机服务",
|
"localServiceAccess": "本机服务",
|
||||||
@@ -226,22 +231,24 @@
|
|||||||
"selectedModelProvider": "由选中的模型决定。",
|
"selectedModelProvider": "由选中的模型决定。",
|
||||||
"selectedModelValue": "由选中的模型决定。",
|
"selectedModelValue": "由选中的模型决定。",
|
||||||
"selectedPreset": "命名预设在这里只读;请在 config.json 中编辑。",
|
"selectedPreset": "命名预设在这里只读;请在 config.json 中编辑。",
|
||||||
"presetModel": "切回默认预设后可在 WebUI 中编辑模型和提供商。",
|
"presetModel": "切回 Default 后可在 WebUI 中编辑模型和提供商。",
|
||||||
"density": "只保存在此浏览器中。",
|
"density": "只保存在此浏览器中。",
|
||||||
"activityMode": "选择默认显示多少智能体活动详情。",
|
"activityMode": "选择默认显示多少 agent 活动细节。",
|
||||||
"fileEditDisplay": "选择文件编辑活动默认显示行数还是差异。",
|
"fileEditDisplay": "选择文件编辑活动默认显示行数还是差异。",
|
||||||
"codeWrap": "让长代码行在小屏幕上也易读。",
|
"codeWrap": "让长代码行在小屏幕上也易读。",
|
||||||
"brandLogos": "在设置中显示第三方提供商和 CLI 图标。",
|
"brandLogos": "在设置中显示第三方提供商和 CLI 图标。",
|
||||||
"maxResults": "每次 web_search 调用返回的结果数。",
|
"maxResults": "每次 web_search 调用返回的结果数。",
|
||||||
"timeout": "搜索提供商请求超时前等待的秒数。",
|
"timeout": "搜索提供商请求超时前的秒数。",
|
||||||
"jinaReader": "可用时为 web_fetch 使用 Jina Reader。",
|
"jinaReader": "可用时为 web_fetch 使用 Jina Reader。",
|
||||||
"imageGeneration": "配置图片提供商后,即可在聊天中使用 generate_image。",
|
"imageGeneration": "配置图片提供商后,在聊天中开放 generate_image。",
|
||||||
"imageProvider": "选择 generate_image 使用的注册提供商。",
|
"imageProvider": "选择 generate_image 使用的注册提供商。",
|
||||||
"imageProviderStatus": "图片生成会复用「提供商」里的凭据。",
|
"imageProviderStatus": "图片生成会复用「提供商」里的凭据。",
|
||||||
"imageModel": "选择当前图片提供商支持的模型。",
|
"imageModel": "选择当前图片提供商支持的模型。",
|
||||||
"defaultAspectRatio": "当提示词没有指定比例时使用。",
|
"defaultAspectRatio": "当提示词没有指定比例时使用。",
|
||||||
"defaultImageSize": "发送给支持此选项的提供商的尺寸提示。",
|
"defaultImageSize": "发送给支持此选项的提供商的尺寸提示。",
|
||||||
"maxImagesPerTurn": "单次 generate_image 请求可生成的图片上限。",
|
"maxImagesPerTurn": "单次 generate_image 请求可生成的图片上限。",
|
||||||
|
"botName": "显示在 nanobot 使用展示名称的地方。",
|
||||||
|
"botIcon": "显示在 Bot 名称旁的短 emoji 或文字。",
|
||||||
"timezone": "用于日程和需要时间感知的回复。",
|
"timezone": "用于日程和需要时间感知的回复。",
|
||||||
"cliAppsCatalog": "只安装 nanobot 可本地运行的应用 CLI 适配器;不会改动原生应用。",
|
"cliAppsCatalog": "只安装 nanobot 可本地运行的应用 CLI 适配器;不会改动原生应用。",
|
||||||
"cliAppsFilter": "按应用、类别或能力搜索。",
|
"cliAppsFilter": "按应用、类别或能力搜索。",
|
||||||
@@ -255,7 +262,7 @@
|
|||||||
"contextWindow": "选择此模型配置的默认上下文预算。",
|
"contextWindow": "选择此模型配置的默认上下文预算。",
|
||||||
"transcription": "发送前先把麦克风输入转写到输入框。聊天渠道里的语音消息也使用同一套设置。",
|
"transcription": "发送前先把麦克风输入转写到输入框。聊天渠道里的语音消息也使用同一套设置。",
|
||||||
"transcriptionProvider": "使用「提供商」中对应提供商的凭据。",
|
"transcriptionProvider": "使用「提供商」中对应提供商的凭据。",
|
||||||
"transcriptionProviderStatus": "API 密钥仍保存在“提供商”配置中,不写入“语音转写”设置。",
|
"transcriptionProviderStatus": "API Key 仍保存在 providers 里,不写进 transcription 设置。",
|
||||||
"transcriptionModel": "除非提供商需要自定义模型 ID,否则保持解析后的默认值即可。",
|
"transcriptionModel": "除非提供商需要自定义模型 ID,否则保持解析后的默认值即可。",
|
||||||
"transcriptionLanguage": "可选 ISO-639 语言提示,例如 en、zh、ja 或 ko。"
|
"transcriptionLanguage": "可选 ISO-639 语言提示,例如 en、zh、ja 或 ko。"
|
||||||
},
|
},
|
||||||
@@ -340,16 +347,15 @@
|
|||||||
"setup": "连接",
|
"setup": "连接",
|
||||||
"configure": "连接",
|
"configure": "连接",
|
||||||
"connectTitle": "连接 {{name}}",
|
"connectTitle": "连接 {{name}}",
|
||||||
"connectHint": "填入账户中的密钥。",
|
"connectHint": "填入你账户里的 key。",
|
||||||
"saveAndEnable": "保存并启用",
|
"saveAndEnable": "保存并启用",
|
||||||
"updateSetup": "更新配置",
|
"updateSetup": "更新配置",
|
||||||
"configured": "已配置",
|
"configured": "已配置",
|
||||||
"keepExisting": "留空则保留当前值",
|
"keepExisting": "留空则保留当前值",
|
||||||
"statusConfigured": "已配置",
|
"statusConfigured": "已配置",
|
||||||
"statusMissingCredentials": "需要密钥",
|
"statusMissingCredentials": "需要 key",
|
||||||
"statusMissingDependency": "缺少依赖",
|
"statusMissingDependency": "缺少依赖",
|
||||||
"statusComingSoon": "暂不支持",
|
"statusComingSoon": "暂不支持",
|
||||||
"comingSoon": "即将推出",
|
|
||||||
"statusNotInstalled": "未启用",
|
"statusNotInstalled": "未启用",
|
||||||
"toolScope": "工具",
|
"toolScope": "工具",
|
||||||
"allTools": "全部",
|
"allTools": "全部",
|
||||||
@@ -365,7 +371,7 @@
|
|||||||
"restartPending": "等待重启",
|
"restartPending": "等待重启",
|
||||||
"ready": "就绪",
|
"ready": "就绪",
|
||||||
"privateEngine": "私有引擎",
|
"privateEngine": "私有引擎",
|
||||||
"unixSocket": "Unix 套接字",
|
"unixSocket": "Unix socket",
|
||||||
"defaultWorkspace": "默认工作区",
|
"defaultWorkspace": "默认工作区",
|
||||||
"comfortable": "舒适",
|
"comfortable": "舒适",
|
||||||
"compact": "紧凑",
|
"compact": "紧凑",
|
||||||
@@ -382,10 +388,7 @@
|
|||||||
"configured": "已配置",
|
"configured": "已配置",
|
||||||
"notConfigured": "未配置",
|
"notConfigured": "未配置",
|
||||||
"pending": "等待中",
|
"pending": "等待中",
|
||||||
"restartingEngine": "正在重启",
|
"restartingEngine": "正在重启"
|
||||||
"checking": "检查中",
|
|
||||||
"running": "运行中",
|
|
||||||
"needsSetup": "需要设置"
|
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "正在加载设置...",
|
"loading": "正在加载设置...",
|
||||||
@@ -413,52 +416,51 @@
|
|||||||
"delete": "删除",
|
"delete": "删除",
|
||||||
"deleting": "正在删除...",
|
"deleting": "正在删除...",
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"dismiss": "关闭",
|
|
||||||
"open": "打开",
|
"open": "打开",
|
||||||
"export": "导出",
|
"export": "导出",
|
||||||
"opening": "正在打开...",
|
"opening": "正在打开...",
|
||||||
"exporting": "正在导出..."
|
"exporting": "正在导出..."
|
||||||
},
|
},
|
||||||
"byok": {
|
"byok": {
|
||||||
"description": "使用自己的提供商密钥。nanobot 会从当前配置读取这些值,只有已配置的提供商才能用于模型预设。",
|
"description": "自带服务商密钥。Nanobot 会从当前 config 读取这些值,只有已配置的服务商才能用于模型预设。",
|
||||||
"configured": "已配置",
|
"configured": "已配置",
|
||||||
"notConfigured": "未配置",
|
"notConfigured": "未配置",
|
||||||
"configuredSection": "已配置",
|
"configuredSection": "已配置",
|
||||||
"notConfiguredSection": "未配置",
|
"notConfiguredSection": "未配置",
|
||||||
"showMore": "再显示 {{count}} 个",
|
"showMore": "再显示 {{count}} 个",
|
||||||
"showLess": "收起",
|
"showLess": "收起",
|
||||||
"apiKey": "API 密钥",
|
"apiKey": "API key",
|
||||||
"apiBase": "API 基础地址",
|
"apiBase": "API base",
|
||||||
"apiKeyPlaceholder": "输入 API 密钥",
|
"apiKeyPlaceholder": "输入 API key",
|
||||||
"apiKeyConfiguredPlaceholder": "留空则保留当前密钥",
|
"apiKeyConfiguredPlaceholder": "留空则保留当前 key",
|
||||||
"configuredKeyHint": "已配置的密钥",
|
"configuredKeyHint": "已配置的 key",
|
||||||
"apiBasePlaceholder": "使用提供商默认地址",
|
"apiBasePlaceholder": "使用服务商默认地址",
|
||||||
"apiKeyRequired": "需要 API 密钥才能配置此提供商。",
|
"apiKeyRequired": "需要 API key 才能配置此服务商。",
|
||||||
"showApiKey": "显示 API 密钥",
|
"showApiKey": "显示 API key",
|
||||||
"hideApiKey": "隐藏 API 密钥",
|
"hideApiKey": "隐藏 API key",
|
||||||
"noConfiguredProviders": "没有已配置的提供商",
|
"noConfiguredProviders": "没有已配置的服务商",
|
||||||
"configureFirst": "请先在 BYOK 中配置提供商。",
|
"configureFirst": "请先在 BYOK 里配置服务商。",
|
||||||
"openByok": "打开 BYOK",
|
"openByok": "打开 BYOK",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"ariaLabel": "BYOK 凭证类型",
|
"ariaLabel": "BYOK 凭证类型",
|
||||||
"llm": "LLM",
|
"llm": "LLM",
|
||||||
"webSearch": "网络搜索"
|
"webSearch": "网页搜索"
|
||||||
},
|
},
|
||||||
"webSearch": {
|
"webSearch": {
|
||||||
"provider": "搜索提供商",
|
"provider": "搜索服务商",
|
||||||
"providerHelp": "选择网络搜索工具使用的后端。",
|
"providerHelp": "选择网页搜索工具使用的后端。",
|
||||||
"selectProvider": "选择提供商",
|
"selectProvider": "选择服务商",
|
||||||
"credentials": "凭证",
|
"credentials": "凭证",
|
||||||
"noCredentialRequired": "无需密钥",
|
"noCredentialRequired": "无需 key",
|
||||||
"noCredentialHelp": "DuckDuckGo 无需保存 API 密钥。",
|
"noCredentialHelp": "DuckDuckGo 不需要保存 API key。",
|
||||||
"apiKeyHelp": "保存到 config 后仅显示掩码。",
|
"apiKeyHelp": "保存到 config 后只显示掩码提示。",
|
||||||
"baseUrl": "基础 URL",
|
"baseUrl": "Base URL",
|
||||||
"baseUrlHelp": "SearXNG 需要你自己的实例地址。",
|
"baseUrlHelp": "SearXNG 需要你自己的实例地址。",
|
||||||
"baseUrlPlaceholder": "https://search.example.com",
|
"baseUrlPlaceholder": "https://search.example.com",
|
||||||
"apiKeyRequired": "此搜索提供商需要 API 密钥。",
|
"apiKeyRequired": "这个搜索服务商需要 API key。",
|
||||||
"baseUrlRequired": "SearXNG 需要基础 URL。",
|
"baseUrlRequired": "SearXNG 需要 Base URL。",
|
||||||
"missingCredential": "填写所需凭证后才能保存。",
|
"missingCredential": "填写所需凭证后才能保存。",
|
||||||
"saveHint": "改动会应用到新的网络搜索请求。"
|
"saveHint": "改动会应用到新的网页搜索请求。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"overview": {
|
"overview": {
|
||||||
@@ -466,19 +468,19 @@
|
|||||||
"providers": "提供商",
|
"providers": "提供商",
|
||||||
"configuredCount": "已配置 {{count}} 个",
|
"configuredCount": "已配置 {{count}} 个",
|
||||||
"totalProviders": "共 {{count}} 个可用",
|
"totalProviders": "共 {{count}} 个可用",
|
||||||
"webSearch": "网络搜索",
|
"webSearch": "网页搜索",
|
||||||
"imageGeneration": "图片生成",
|
"imageGeneration": "图片生成",
|
||||||
"voiceInput": "语音识别",
|
"voiceInput": "语音识别",
|
||||||
"workspace": "工作区"
|
"workspace": "工作区"
|
||||||
},
|
},
|
||||||
"usage": {
|
"usage": {
|
||||||
"title": "Token 用量",
|
"title": "Token 活动",
|
||||||
"shortTitle": "Token 用量",
|
"shortTitle": "Token Usage",
|
||||||
"subtitle": "最近 12 个月由提供商上报的 Token 用量。",
|
"subtitle": "最近 12 个月由提供商上报的 token 用量。",
|
||||||
"empty": "模型产生新的回复后,这里会显示 Token 用量。",
|
"empty": "新的模型回复产生后,这里会显示 token 活动。",
|
||||||
"totalTokens": "Token 总数",
|
"totalTokens": "累计 Token 数",
|
||||||
"peakTokens": "Token 峰值",
|
"peakTokens": "峰值 Token 数",
|
||||||
"thirtyDayTokens": "30 天 Token 用量",
|
"thirtyDayTokens": "30 天 Token 数",
|
||||||
"currentStreak": "当前连续天数",
|
"currentStreak": "当前连续天数",
|
||||||
"longestStreak": "最长连续天数",
|
"longestStreak": "最长连续天数",
|
||||||
"daysValue": "{{count}} 天",
|
"daysValue": "{{count}} 天",
|
||||||
@@ -523,23 +525,13 @@
|
|||||||
"selectProvider": "选择提供商",
|
"selectProvider": "选择提供商",
|
||||||
"selectAspect": "选择比例",
|
"selectAspect": "选择比例",
|
||||||
"selectSize": "选择尺寸",
|
"selectSize": "选择尺寸",
|
||||||
"selectModel": "选择图片模型",
|
|
||||||
"searchOrTypeModel": "搜索或输入模型 ID",
|
|
||||||
"typeModelId": "输入此提供商支持的模型 ID。",
|
|
||||||
"configureProvider": "配置提供商",
|
"configureProvider": "配置提供商",
|
||||||
"missingCredential": "启用图片生成前请先配置此提供商。"
|
"missingCredential": "启用图片生成前请先配置此提供商。"
|
||||||
},
|
},
|
||||||
"capabilities": {
|
|
||||||
"providerSupport": "提供商支持",
|
|
||||||
"providerInstallOnSave": "保存此提供商时会自动安装所需支持。",
|
|
||||||
"searchSupport": "搜索提供商支持",
|
|
||||||
"searchInstallOnSave": "保存时会自动安装 Olostep 支持。",
|
|
||||||
"installing": "正在安装支持…"
|
|
||||||
},
|
|
||||||
"api": {
|
"api": {
|
||||||
"title": "API 服务",
|
"title": "API 服务",
|
||||||
"openaiCompatible": "OpenAI 兼容 API",
|
"openaiCompatible": "OpenAI 兼容 API",
|
||||||
"description": "让 SDK 和其他智能体通过本地 /v1 接口连接 nanobot。",
|
"description": "让 SDK 和其他 Agent 通过本地 /v1 接口连接 nanobot。",
|
||||||
"start": "启动 API 服务",
|
"start": "启动 API 服务",
|
||||||
"starting": "正在启动...",
|
"starting": "正在启动...",
|
||||||
"stop": "停止",
|
"stop": "停止",
|
||||||
@@ -548,13 +540,13 @@
|
|||||||
"thisDevice": "仅此设备",
|
"thisDevice": "仅此设备",
|
||||||
"localNetwork": "局域网",
|
"localNetwork": "局域网",
|
||||||
"localHelp": "只有当前设备可以连接。",
|
"localHelp": "只有当前设备可以连接。",
|
||||||
"networkHelp": "局域网内其他设备可以连接,因此必须设置 API 密钥。",
|
"networkHelp": "局域网内其他设备可以连接,因此必须设置 API Key。",
|
||||||
"port": "端口",
|
"port": "端口",
|
||||||
"portHelp": "API 服务使用的本地端口。",
|
"portHelp": "API 服务使用的本地端口。",
|
||||||
"apiKey": "API 密钥",
|
"apiKey": "API Key",
|
||||||
"apiKeyHelp": "客户端使用 Bearer Token 发送此密钥。",
|
"apiKeyHelp": "客户端使用 Bearer Token 发送此密钥。",
|
||||||
"apiKeyRequired": "向局域网开放 API 前必须设置密钥。",
|
"apiKeyRequired": "向局域网开放 API 前必须设置密钥。",
|
||||||
"apiKeyPlaceholder": "输入 API 密钥",
|
"apiKeyPlaceholder": "输入 API Key",
|
||||||
"autoInstall": "启动时会自动安装 API 支持。"
|
"autoInstall": "启动时会自动安装 API 支持。"
|
||||||
},
|
},
|
||||||
"observability": {
|
"observability": {
|
||||||
@@ -564,7 +556,7 @@
|
|||||||
"enable": "启用追踪支持"
|
"enable": "启用追踪支持"
|
||||||
},
|
},
|
||||||
"apps": {
|
"apps": {
|
||||||
"description": "将工具接入 nanobot,然后在对话中通过 @ 调用。",
|
"description": "把工具连接到 nanobot,然后在对话中 @ 使用。",
|
||||||
"cliLabel": "应用",
|
"cliLabel": "应用",
|
||||||
"mcpLabel": "集成",
|
"mcpLabel": "集成",
|
||||||
"channelLabel": "渠道",
|
"channelLabel": "渠道",
|
||||||
@@ -593,7 +585,7 @@
|
|||||||
"requires": "需要:{{requirements}}",
|
"requires": "需要:{{requirements}}",
|
||||||
"setUp": "设置",
|
"setUp": "设置",
|
||||||
"setupGuide": "配置指南",
|
"setupGuide": "配置指南",
|
||||||
"setupSummary": "启用只会开启 nanobot 对该渠道的支持。请补充平台凭据,然后重启 nanobot。",
|
"setupSummary": "启用只会打开 nanobot 的渠道支持。请补充平台凭据,然后重启 nanobot。",
|
||||||
"configKeys": "配置字段",
|
"configKeys": "配置字段",
|
||||||
"enable": "启用渠道",
|
"enable": "启用渠道",
|
||||||
"disable": "禁用渠道",
|
"disable": "禁用渠道",
|
||||||
@@ -603,8 +595,6 @@
|
|||||||
"advanced": "高级",
|
"advanced": "高级",
|
||||||
"checkAndEnable": "检查并启用",
|
"checkAndEnable": "检查并启用",
|
||||||
"checkConnection": "检查连接",
|
"checkConnection": "检查连接",
|
||||||
"connectionChecks": "连接检查",
|
|
||||||
"open": "打开",
|
|
||||||
"checkedAndEnabled": "已检查并启用。",
|
"checkedAndEnabled": "已检查并启用。",
|
||||||
"checking": "正在检查...",
|
"checking": "正在检查...",
|
||||||
"checkOnly": "仅检查",
|
"checkOnly": "仅检查",
|
||||||
@@ -627,7 +617,7 @@
|
|||||||
"managedByWebui": "由 WebUI 管理",
|
"managedByWebui": "由 WebUI 管理",
|
||||||
"officialGuide": "官方指南",
|
"officialGuide": "官方指南",
|
||||||
"optional": "可选",
|
"optional": "可选",
|
||||||
"providerPreset": "提供商",
|
"providerPreset": "服务商",
|
||||||
"requiredSetup": "必需配置",
|
"requiredSetup": "必需配置",
|
||||||
"savedSecret": "已保存",
|
"savedSecret": "已保存",
|
||||||
"savedSecretPlaceholder": "已保存的密钥",
|
"savedSecretPlaceholder": "已保存的密钥",
|
||||||
@@ -700,8 +690,6 @@
|
|||||||
"protected": "受保护",
|
"protected": "受保护",
|
||||||
"editTitle": "编辑自动任务",
|
"editTitle": "编辑自动任务",
|
||||||
"save": "保存",
|
"save": "保存",
|
||||||
"commandCopied": "已复制",
|
|
||||||
"copyCommand": "复制",
|
|
||||||
"deleteTitle": "删除自动任务",
|
"deleteTitle": "删除自动任务",
|
||||||
"deleteDescription": "这会从 cron 存储中删除 {{name}},历史聊天消息会保留在会话中。",
|
"deleteDescription": "这会从 cron 存储中删除 {{name}},历史聊天消息会保留在会话中。",
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
@@ -761,7 +749,6 @@
|
|||||||
"fields": {
|
"fields": {
|
||||||
"name": "名称",
|
"name": "名称",
|
||||||
"message": "消息",
|
"message": "消息",
|
||||||
"command": "命令",
|
|
||||||
"scheduleType": "计划类型",
|
"scheduleType": "计划类型",
|
||||||
"every": "每隔",
|
"every": "每隔",
|
||||||
"unit": "单位",
|
"unit": "单位",
|
||||||
@@ -796,7 +783,7 @@
|
|||||||
"signInAgain": "重新登录",
|
"signInAgain": "重新登录",
|
||||||
"signOut": "退出登录",
|
"signOut": "退出登录",
|
||||||
"signedInAs": "已登录为 {{account}}",
|
"signedInAs": "已登录为 {{account}}",
|
||||||
"signInHelp": "从这台设备登录;不会在配置中保存 API 密钥。",
|
"signInHelp": "从这台设备登录;不会在配置中保存 API key。",
|
||||||
"remoteSignInHelp": "点击“登录”在你的电脑上打开 xAI,完成登录后粘贴页面显示的授权码。",
|
"remoteSignInHelp": "点击“登录”在你的电脑上打开 xAI,完成登录后粘贴页面显示的授权码。",
|
||||||
"codexRemoteSignInHelp": "在此浏览器中登录,然后将完整的 localhost 回调 URL 粘贴回 nanobot。",
|
"codexRemoteSignInHelp": "在此浏览器中登录,然后将完整的 localhost 回调 URL 粘贴回 nanobot。",
|
||||||
"signInRequired": "需要登录",
|
"signInRequired": "需要登录",
|
||||||
@@ -819,7 +806,7 @@
|
|||||||
"finishSignIn": "完成登录"
|
"finishSignIn": "完成登录"
|
||||||
},
|
},
|
||||||
"skills": {
|
"skills": {
|
||||||
"description": "查看此智能体在对话中可以加载的指令技能。",
|
"description": "查看此 agent 在对话中可以加载的指令技能。",
|
||||||
"caption": "{{available}} 个可用 · 共 {{total}} 个",
|
"caption": "{{available}} 个可用 · 共 {{total}} 个",
|
||||||
"views": "技能视图",
|
"views": "技能视图",
|
||||||
"installedTab": "已安装",
|
"installedTab": "已安装",
|
||||||
@@ -838,7 +825,7 @@
|
|||||||
"showLess": "收起",
|
"showLess": "收起",
|
||||||
"showMore": "展开",
|
"showMore": "展开",
|
||||||
"enabledControl": "使用此技能",
|
"enabledControl": "使用此技能",
|
||||||
"enabledDescription": "当技能需求满足时,允许智能体加载并使用它。",
|
"enabledDescription": "当技能需求满足时,允许 agent 加载并使用它。",
|
||||||
"enableSkill": "启用 {{name}}",
|
"enableSkill": "启用 {{name}}",
|
||||||
"disableSkill": "停用 {{name}}",
|
"disableSkill": "停用 {{name}}",
|
||||||
"updateFailed": "无法更新此技能。",
|
"updateFailed": "无法更新此技能。",
|
||||||
@@ -879,7 +866,7 @@
|
|||||||
"marketplaceInstall": "安装",
|
"marketplaceInstall": "安装",
|
||||||
"marketplaceNoTrend": "暂无趋势",
|
"marketplaceNoTrend": "暂无趋势",
|
||||||
"marketplaceTrendLabel": "近 8 周安装趋势",
|
"marketplaceTrendLabel": "近 8 周安装趋势",
|
||||||
"featured": "智能体技能",
|
"featured": "Agent 技能",
|
||||||
"empty": "暂无可用技能。",
|
"empty": "暂无可用技能。",
|
||||||
"sourceWorkspace": "自定义",
|
"sourceWorkspace": "自定义",
|
||||||
"sourceBuiltin": "内置",
|
"sourceBuiltin": "内置",
|
||||||
@@ -920,8 +907,8 @@
|
|||||||
"actions": "“{{title}}” 的话题操作",
|
"actions": "“{{title}}” 的话题操作",
|
||||||
"newInProject": "在 {{project}} 中开始新话题",
|
"newInProject": "在 {{project}} 中开始新话题",
|
||||||
"activity": {
|
"activity": {
|
||||||
"running": "智能体正在运行",
|
"running": "Agent 正在运行",
|
||||||
"complete": "智能体已完成",
|
"complete": "Agent 已完成",
|
||||||
"updated": "有新内容"
|
"updated": "有新内容"
|
||||||
},
|
},
|
||||||
"pin": "置顶",
|
"pin": "置顶",
|
||||||
@@ -1094,7 +1081,7 @@
|
|||||||
"modelNotConfigured": "模型未配置",
|
"modelNotConfigured": "模型未配置",
|
||||||
"configureModel": "配置模型",
|
"configureModel": "配置模型",
|
||||||
"queued": {
|
"queued": {
|
||||||
"label": "排队中的引导消息",
|
"label": "待引导提示",
|
||||||
"guide": "引导",
|
"guide": "引导",
|
||||||
"delete": "删除引导",
|
"delete": "删除引导",
|
||||||
"edit": "编辑引导",
|
"edit": "编辑引导",
|
||||||
@@ -1161,7 +1148,7 @@
|
|||||||
},
|
},
|
||||||
"stop": {
|
"stop": {
|
||||||
"title": "停止当前任务",
|
"title": "停止当前任务",
|
||||||
"description": "取消这个对话中正在运行的智能体回合。"
|
"description": "取消这个对话中正在运行的 agent 回合。"
|
||||||
},
|
},
|
||||||
"restart": {
|
"restart": {
|
||||||
"title": "重启 nanobot",
|
"title": "重启 nanobot",
|
||||||
@@ -1169,7 +1156,7 @@
|
|||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"title": "查看状态",
|
"title": "查看状态",
|
||||||
"description": "显示运行时、提供商和渠道状态。"
|
"description": "显示运行时、服务商和通道状态。"
|
||||||
},
|
},
|
||||||
"model": {
|
"model": {
|
||||||
"title": "模型",
|
"title": "模型",
|
||||||
@@ -1221,9 +1208,7 @@
|
|||||||
"cliBadge": "CLI",
|
"cliBadge": "CLI",
|
||||||
"mcpBadge": "MCP",
|
"mcpBadge": "MCP",
|
||||||
"cliDescription": "使用 @{{name}} 调用本地 CLI",
|
"cliDescription": "使用 @{{name}} 调用本地 CLI",
|
||||||
"mcpDescription": "使用 @{{name}} 调用 MCP 服务",
|
"mcpDescription": "使用 @{{name}} 调用 MCP 服务"
|
||||||
"cliTitle": "CLI 应用:{{name}}",
|
|
||||||
"mcpTitle": "MCP 服务:{{name}}"
|
|
||||||
},
|
},
|
||||||
"encoding": "处理中…",
|
"encoding": "处理中…",
|
||||||
"remove": "移除附件",
|
"remove": "移除附件",
|
||||||
@@ -1256,12 +1241,11 @@
|
|||||||
"loadEarlier": "加载更早消息",
|
"loadEarlier": "加载更早消息",
|
||||||
"forkedFromHistory": "从历史消息分叉",
|
"forkedFromHistory": "从历史消息分叉",
|
||||||
"promptNavigator": {
|
"promptNavigator": {
|
||||||
"open": "打开提示词导航",
|
"open": "打开输入导航",
|
||||||
"title": "提示词列表",
|
"title": "输入列表",
|
||||||
"search": "搜索提示词",
|
"search": "搜索输入",
|
||||||
"noResults": "没有匹配的提示词。",
|
"noResults": "没有匹配的输入。",
|
||||||
"jumpTo": "跳转到提示词:{{label}}",
|
"jumpTo": "跳转到输入:{{label}}"
|
||||||
"railAria": "用户提示词导航"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
@@ -1300,18 +1284,10 @@
|
|||||||
"cliRunRan": "已使用",
|
"cliRunRan": "已使用",
|
||||||
"cliRunFailed": "失败",
|
"cliRunFailed": "失败",
|
||||||
"imageAttachment": "图片附件",
|
"imageAttachment": "图片附件",
|
||||||
"videoAttachment": "视频附件",
|
|
||||||
"fileAttachment": "文件附件",
|
|
||||||
"attachmentUnavailable": "附件不可用",
|
|
||||||
"dataTable": "数据表",
|
|
||||||
"fileEditPreparing": "正在准备文件编辑…",
|
|
||||||
"openLink": "打开链接:{{label}}",
|
|
||||||
"openAttachment": "打开 {{name}}",
|
|
||||||
"skill": "技能:{{name}}",
|
|
||||||
"automationSourceFallback": "自动化",
|
"automationSourceFallback": "自动化",
|
||||||
"automationTriggered": "自动触发",
|
"automationTriggered": "自动触发",
|
||||||
"askAboutSelection": "询问此内容",
|
"askAboutSelection": "继续提问",
|
||||||
"forkFromHere": "从此处分叉",
|
"forkFromHere": "分叉",
|
||||||
"copyReply": "复制",
|
"copyReply": "复制",
|
||||||
"copiedReply": "已复制",
|
"copiedReply": "已复制",
|
||||||
"turnLatencyTitle": "本轮耗时(端到端)",
|
"turnLatencyTitle": "本轮耗时(端到端)",
|
||||||
@@ -1333,11 +1309,10 @@
|
|||||||
},
|
},
|
||||||
"filePreview": {
|
"filePreview": {
|
||||||
"aria": "文件预览",
|
"aria": "文件预览",
|
||||||
"breadcrumb": "文件路径",
|
|
||||||
"close": "关闭文件预览",
|
"close": "关闭文件预览",
|
||||||
"loading": "正在加载预览...",
|
"loading": "正在加载预览...",
|
||||||
"failed": "无法预览这个文件。",
|
"failed": "无法预览这个文件。",
|
||||||
"routeMissing": "文件预览需要最新的网关。请重启 nanobot gateway 后再试。",
|
"routeMissing": "文件预览需要最新的 gateway。请重启 nanobot gateway 后再试。",
|
||||||
"resize": "调整文件预览宽度",
|
"resize": "调整文件预览宽度",
|
||||||
"truncated": "文件较大,当前只显示前半部分预览。"
|
"truncated": "文件较大,当前只显示前半部分预览。"
|
||||||
},
|
},
|
||||||
@@ -1348,10 +1323,7 @@
|
|||||||
"copied": "已复制"
|
"copied": "已复制"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"dismiss": "关闭",
|
"dismiss": "关闭"
|
||||||
"close": "关闭",
|
|
||||||
"current": "当前",
|
|
||||||
"cancel": "取消"
|
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"messageTooBig": {
|
"messageTooBig": {
|
||||||
|
|||||||
@@ -38,20 +38,12 @@
|
|||||||
},
|
},
|
||||||
"meta": {
|
"meta": {
|
||||||
"description": "nanobot Web UI —— 與你的 nanobot 工作區對話。"
|
"description": "nanobot Web UI —— 與你的 nanobot 工作區對話。"
|
||||||
},
|
|
||||||
"pairing": {
|
|
||||||
"title": "配對聊天使用者",
|
|
||||||
"description": "輸入聊天中顯示的配對碼。",
|
|
||||||
"code": "配對碼",
|
|
||||||
"matched": "已符合 {{channel}},正在連線…",
|
|
||||||
"expiresInline": "配對碼將於 {{expires}} 到期。",
|
|
||||||
"queueCount": "{{count}} 個待處理",
|
|
||||||
"noMatch": "沒有待處理請求符合此配對碼。"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"navigation": "側邊欄導覽",
|
"navigation": "側邊欄導覽",
|
||||||
"collapse": "收合側邊欄",
|
"collapse": "收合側邊欄",
|
||||||
|
"quickChat": "輕鬆聊聊",
|
||||||
"newChat": "新增話題",
|
"newChat": "新增話題",
|
||||||
"searchAria": "搜尋",
|
"searchAria": "搜尋",
|
||||||
"searchPlaceholder": "搜尋",
|
"searchPlaceholder": "搜尋",
|
||||||
@@ -69,6 +61,17 @@
|
|||||||
"title": "技能"
|
"title": "技能"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"quickChat": {
|
||||||
|
"greeting": "想聊點什麼?",
|
||||||
|
"temporary": {
|
||||||
|
"title": "臨時聊天",
|
||||||
|
"enter": "臨時聊天",
|
||||||
|
"active": "臨時聊天中",
|
||||||
|
"exit": "退出臨時聊天",
|
||||||
|
"greeting": "開啟一次臨時聊天",
|
||||||
|
"description": "不儲存記錄,不讀取記憶或專案,也不使用工具;內容仍會傳送給你選擇的模型服務商。"
|
||||||
|
}
|
||||||
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"backToChat": "返回聊天",
|
"backToChat": "返回聊天",
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
@@ -84,7 +87,7 @@
|
|||||||
"providers": "供應商",
|
"providers": "供應商",
|
||||||
"image": "圖片",
|
"image": "圖片",
|
||||||
"voice": "語音",
|
"voice": "語音",
|
||||||
"browser": "網路",
|
"browser": "網頁",
|
||||||
"channels": "通訊管道",
|
"channels": "通訊管道",
|
||||||
"runtime": "系統",
|
"runtime": "系統",
|
||||||
"advanced": "安全",
|
"advanced": "安全",
|
||||||
@@ -104,9 +107,9 @@
|
|||||||
"presets": "預設",
|
"presets": "預設",
|
||||||
"imageGeneration": "圖片生成",
|
"imageGeneration": "圖片生成",
|
||||||
"imageDefaults": "預設值",
|
"imageDefaults": "預設值",
|
||||||
"webSearch": "網路搜尋",
|
"webSearch": "網頁搜尋",
|
||||||
"webBehavior": "網路行為",
|
"webBehavior": "行為",
|
||||||
"regional": "區域",
|
"identity": "身分",
|
||||||
"webuiSafety": "WebUI 安全",
|
"webuiSafety": "WebUI 安全",
|
||||||
"capabilities": "能力",
|
"capabilities": "能力",
|
||||||
"cliApps": "CLI 應用程式",
|
"cliApps": "CLI 應用程式",
|
||||||
@@ -145,6 +148,8 @@
|
|||||||
"defaultImageSize": "預設尺寸",
|
"defaultImageSize": "預設尺寸",
|
||||||
"maxImagesPerTurn": "每輪最大圖片數",
|
"maxImagesPerTurn": "每輪最大圖片數",
|
||||||
"imageSaveDir": "儲存目錄",
|
"imageSaveDir": "儲存目錄",
|
||||||
|
"botName": "Bot 名稱",
|
||||||
|
"botIcon": "Bot 圖示",
|
||||||
"timezone": "時區",
|
"timezone": "時區",
|
||||||
"workspacePath": "預設工作區",
|
"workspacePath": "預設工作區",
|
||||||
"localServiceAccess": "本機服務",
|
"localServiceAccess": "本機服務",
|
||||||
@@ -171,9 +176,9 @@
|
|||||||
"model": "選擇此預設使用的模型。",
|
"model": "選擇此預設使用的模型。",
|
||||||
"configPath": "目前閘道使用中的設定檔。",
|
"configPath": "目前閘道使用中的設定檔。",
|
||||||
"selectedPreset": "命名預設在此為唯讀;請在 config.json 中編輯。",
|
"selectedPreset": "命名預設在此為唯讀;請在 config.json 中編輯。",
|
||||||
"presetModel": "切回預設後可在 WebUI 中編輯模型與供應商。",
|
"presetModel": "切回 Default 後可在 WebUI 中編輯模型與供應商。",
|
||||||
"density": "只儲存在此瀏覽器中。",
|
"density": "只儲存在此瀏覽器中。",
|
||||||
"activityMode": "選擇預設顯示多少智能體活動細節。",
|
"activityMode": "選擇預設顯示多少 Agent 活動細節。",
|
||||||
"fileEditDisplay": "選擇檔案編輯活動預設顯示行數或差異。",
|
"fileEditDisplay": "選擇檔案編輯活動預設顯示行數或差異。",
|
||||||
"codeWrap": "讓長程式碼行在小螢幕上也易讀。",
|
"codeWrap": "讓長程式碼行在小螢幕上也易讀。",
|
||||||
"maxResults": "每次呼叫 web_search 所回傳的結果數。",
|
"maxResults": "每次呼叫 web_search 所回傳的結果數。",
|
||||||
@@ -186,6 +191,8 @@
|
|||||||
"defaultAspectRatio": "提示詞未指定比例時使用。",
|
"defaultAspectRatio": "提示詞未指定比例時使用。",
|
||||||
"defaultImageSize": "傳送給支援此選項的供應商的尺寸提示。",
|
"defaultImageSize": "傳送給支援此選項的供應商的尺寸提示。",
|
||||||
"maxImagesPerTurn": "單次 generate_image 請求可產生的圖片數上限。",
|
"maxImagesPerTurn": "單次 generate_image 請求可產生的圖片數上限。",
|
||||||
|
"botName": "顯示於 nanobot 所有使用顯示名稱的位置。",
|
||||||
|
"botIcon": "顯示在 Bot 名稱旁的短 emoji 或文字。",
|
||||||
"timezone": "用於排程,以及需要時間資訊的回覆。",
|
"timezone": "用於排程,以及需要時間資訊的回覆。",
|
||||||
"localServiceAccess": "允許具有完整存取權的 shell 命令存取 localhost 服務。",
|
"localServiceAccess": "允許具有完整存取權的 shell 命令存取 localhost 服務。",
|
||||||
"webuiDefaultAccess": "用於未指定個別專案權限的 Web 聊天。",
|
"webuiDefaultAccess": "用於未指定個別專案權限的 Web 聊天。",
|
||||||
@@ -203,7 +210,7 @@
|
|||||||
"contextWindow": "選擇此模型設定的預設上下文預算。",
|
"contextWindow": "選擇此模型設定的預設上下文預算。",
|
||||||
"transcription": "送出前先將麥克風輸入轉寫至輸入框。聊天通訊管道的語音訊息也會使用同一組設定。",
|
"transcription": "送出前先將麥克風輸入轉寫至輸入框。聊天通訊管道的語音訊息也會使用同一組設定。",
|
||||||
"transcriptionProvider": "使用 [供應商] 中對應供應商的憑證。",
|
"transcriptionProvider": "使用 [供應商] 中對應供應商的憑證。",
|
||||||
"transcriptionProviderStatus": "API 金鑰仍儲存在「供應商」中,不會寫入「語音轉寫」設定。",
|
"transcriptionProviderStatus": "API 金鑰仍儲存在 providers 中,不會寫入 transcription 設定。",
|
||||||
"transcriptionModel": "除非供應商需要自訂模型 ID,否則保留解析後的預設值即可。",
|
"transcriptionModel": "除非供應商需要自訂模型 ID,否則保留解析後的預設值即可。",
|
||||||
"transcriptionLanguage": "選填的 ISO-639 語言提示,例如 en、zh、ja 或 ko。"
|
"transcriptionLanguage": "選填的 ISO-639 語言提示,例如 en、zh、ja 或 ko。"
|
||||||
},
|
},
|
||||||
@@ -216,7 +223,7 @@
|
|||||||
"restartPending": "等待重新啟動",
|
"restartPending": "等待重新啟動",
|
||||||
"ready": "就緒",
|
"ready": "就緒",
|
||||||
"privateEngine": "私有引擎",
|
"privateEngine": "私有引擎",
|
||||||
"unixSocket": "Unix 套接字",
|
"unixSocket": "Unix socket",
|
||||||
"defaultWorkspace": "預設工作區",
|
"defaultWorkspace": "預設工作區",
|
||||||
"comfortable": "舒適",
|
"comfortable": "舒適",
|
||||||
"compact": "緊湊",
|
"compact": "緊湊",
|
||||||
@@ -233,10 +240,7 @@
|
|||||||
"configured": "已設定",
|
"configured": "已設定",
|
||||||
"notConfigured": "未設定",
|
"notConfigured": "未設定",
|
||||||
"pending": "等待中",
|
"pending": "等待中",
|
||||||
"restartingEngine": "正在重新啟動",
|
"restartingEngine": "正在重新啟動"
|
||||||
"checking": "檢查中",
|
|
||||||
"running": "執行中",
|
|
||||||
"needsSetup": "需要設定"
|
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "正在載入設定…",
|
"loading": "正在載入設定…",
|
||||||
@@ -264,7 +268,6 @@
|
|||||||
"deleting": "正在刪除…",
|
"deleting": "正在刪除…",
|
||||||
"edit": "編輯",
|
"edit": "編輯",
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"dismiss": "關閉",
|
|
||||||
"open": "開啟",
|
"open": "開啟",
|
||||||
"export": "匯出",
|
"export": "匯出",
|
||||||
"opening": "正在開啟…",
|
"opening": "正在開啟…",
|
||||||
@@ -293,23 +296,23 @@
|
|||||||
"tabs": {
|
"tabs": {
|
||||||
"ariaLabel": "BYOK 憑證類型",
|
"ariaLabel": "BYOK 憑證類型",
|
||||||
"llm": "LLM",
|
"llm": "LLM",
|
||||||
"webSearch": "網路搜尋"
|
"webSearch": "網頁搜尋"
|
||||||
},
|
},
|
||||||
"webSearch": {
|
"webSearch": {
|
||||||
"provider": "搜尋供應商",
|
"provider": "搜尋供應商",
|
||||||
"providerHelp": "選擇網路搜尋工具使用的後端。",
|
"providerHelp": "選擇網頁搜尋工具使用的後端。",
|
||||||
"selectProvider": "選擇供應商",
|
"selectProvider": "選擇供應商",
|
||||||
"credentials": "憑證",
|
"credentials": "憑證",
|
||||||
"noCredentialRequired": "不需要金鑰",
|
"noCredentialRequired": "不需要金鑰",
|
||||||
"noCredentialHelp": "使用 DuckDuckGo 不需要儲存 API 金鑰。",
|
"noCredentialHelp": "使用 DuckDuckGo 不需要儲存 API 金鑰。",
|
||||||
"apiKeyHelp": "金鑰會儲存在設定檔中,儲存後以遮罩顯示。",
|
"apiKeyHelp": "金鑰會儲存在設定檔中,儲存後以遮罩顯示。",
|
||||||
"baseUrl": "基礎 URL",
|
"baseUrl": "Base URL",
|
||||||
"baseUrlHelp": "SearXNG 需要自行架設的執行個體網址。",
|
"baseUrlHelp": "SearXNG 需要自行架設的執行個體網址。",
|
||||||
"baseUrlPlaceholder": "https://search.example.com",
|
"baseUrlPlaceholder": "https://search.example.com",
|
||||||
"apiKeyRequired": "此搜尋供應商需要 API 金鑰。",
|
"apiKeyRequired": "此搜尋供應商需要 API 金鑰。",
|
||||||
"baseUrlRequired": "SearXNG 需要基礎 URL。",
|
"baseUrlRequired": "SearXNG 需要 Base URL。",
|
||||||
"missingCredential": "填寫必要憑證後才能儲存。",
|
"missingCredential": "填寫必要憑證後才能儲存。",
|
||||||
"saveHint": "變更會套用至新的網路搜尋請求。"
|
"saveHint": "變更會套用至新的網頁搜尋請求。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"overview": {
|
"overview": {
|
||||||
@@ -317,7 +320,7 @@
|
|||||||
"providers": "供應商",
|
"providers": "供應商",
|
||||||
"configuredCount": "已設定 {{count}} 個",
|
"configuredCount": "已設定 {{count}} 個",
|
||||||
"totalProviders": "共 {{count}} 個可用",
|
"totalProviders": "共 {{count}} 個可用",
|
||||||
"webSearch": "網路搜尋",
|
"webSearch": "網頁搜尋",
|
||||||
"imageGeneration": "圖片生成",
|
"imageGeneration": "圖片生成",
|
||||||
"voiceInput": "語音輸入",
|
"voiceInput": "語音輸入",
|
||||||
"workspace": "工作區"
|
"workspace": "工作區"
|
||||||
@@ -371,19 +374,9 @@
|
|||||||
"selectProvider": "選擇供應商",
|
"selectProvider": "選擇供應商",
|
||||||
"selectAspect": "選擇比例",
|
"selectAspect": "選擇比例",
|
||||||
"selectSize": "選擇尺寸",
|
"selectSize": "選擇尺寸",
|
||||||
"selectModel": "選擇圖片模型",
|
|
||||||
"searchOrTypeModel": "搜尋或輸入模型 ID",
|
|
||||||
"typeModelId": "輸入此供應商支援的模型 ID。",
|
|
||||||
"configureProvider": "設定供應商",
|
"configureProvider": "設定供應商",
|
||||||
"missingCredential": "啟用圖片生成功能前,請先設定此供應商。"
|
"missingCredential": "啟用圖片生成功能前,請先設定此供應商。"
|
||||||
},
|
},
|
||||||
"capabilities": {
|
|
||||||
"providerSupport": "供應商支援",
|
|
||||||
"providerInstallOnSave": "儲存此供應商時會自動安裝所需支援。",
|
|
||||||
"searchSupport": "搜尋供應商支援",
|
|
||||||
"searchInstallOnSave": "儲存時會自動安裝 Olostep 支援。",
|
|
||||||
"installing": "正在安裝支援…"
|
|
||||||
},
|
|
||||||
"models": {
|
"models": {
|
||||||
"selectModel": "選擇模型",
|
"selectModel": "選擇模型",
|
||||||
"addConfiguration": "新增設定",
|
"addConfiguration": "新增設定",
|
||||||
@@ -406,7 +399,7 @@
|
|||||||
"advancedOptions": "進階選項",
|
"advancedOptions": "進階選項",
|
||||||
"advancedSummary": "上下文 {{context}} · 最大輸出 {{max}} tokens",
|
"advancedSummary": "上下文 {{context}} · 最大輸出 {{max}} tokens",
|
||||||
"maxTokens": "最大輸出 tokens",
|
"maxTokens": "最大輸出 tokens",
|
||||||
"temperature": "溫度",
|
"temperature": "Temperature",
|
||||||
"reasoningEffort": "推理強度",
|
"reasoningEffort": "推理強度",
|
||||||
"convertTitle": "轉換現有模型設定",
|
"convertTitle": "轉換現有模型設定",
|
||||||
"convertHelp": "將現有主要模型和備用模型轉換為預設,之後即可在這裡管理呼叫順序。",
|
"convertHelp": "將現有主要模型和備用模型轉換為預設,之後即可在這裡管理呼叫順序。",
|
||||||
@@ -526,7 +519,6 @@
|
|||||||
"statusMissingCredentials": "需要金鑰",
|
"statusMissingCredentials": "需要金鑰",
|
||||||
"statusMissingDependency": "需要相依項",
|
"statusMissingDependency": "需要相依項",
|
||||||
"statusComingSoon": "即將推出",
|
"statusComingSoon": "即將推出",
|
||||||
"comingSoon": "即將推出",
|
|
||||||
"statusNotInstalled": "未啟用",
|
"statusNotInstalled": "未啟用",
|
||||||
"toolScope": "工具",
|
"toolScope": "工具",
|
||||||
"allTools": "全部",
|
"allTools": "全部",
|
||||||
@@ -534,7 +526,7 @@
|
|||||||
"testForTools": "執行 [測試] 以檢視並選擇個別工具。"
|
"testForTools": "執行 [測試] 以檢視並選擇個別工具。"
|
||||||
},
|
},
|
||||||
"api": {
|
"api": {
|
||||||
"title": "API 伺服器", "openaiCompatible": "OpenAI 相容 API", "description": "讓 SDK 與其他智能體透過本機 /v1 端點連線 nanobot。",
|
"title": "API 伺服器", "openaiCompatible": "OpenAI 相容 API", "description": "讓 SDK 與其他 Agent 透過本機 /v1 端點連線 nanobot。",
|
||||||
"start": "啟動 API 伺服器", "starting": "正在啟動…", "stop": "停止", "stopping": "正在停止…",
|
"start": "啟動 API 伺服器", "starting": "正在啟動…", "stop": "停止", "stopping": "正在停止…",
|
||||||
"access": "存取範圍", "thisDevice": "僅此裝置", "localNetwork": "區域網路",
|
"access": "存取範圍", "thisDevice": "僅此裝置", "localNetwork": "區域網路",
|
||||||
"localHelp": "只有目前裝置可以連線。", "networkHelp": "區域網路內其他裝置可以連線,因此必須設定 API 金鑰。",
|
"localHelp": "只有目前裝置可以連線。", "networkHelp": "區域網路內其他裝置可以連線,因此必須設定 API 金鑰。",
|
||||||
@@ -589,8 +581,6 @@
|
|||||||
"advanced": "進階",
|
"advanced": "進階",
|
||||||
"checkAndEnable": "檢查並啟用",
|
"checkAndEnable": "檢查並啟用",
|
||||||
"checkConnection": "檢查連線",
|
"checkConnection": "檢查連線",
|
||||||
"connectionChecks": "連線檢查",
|
|
||||||
"open": "開啟",
|
|
||||||
"checkedAndEnabled": "已檢查並啟用。",
|
"checkedAndEnabled": "已檢查並啟用。",
|
||||||
"checking": "正在檢查...",
|
"checking": "正在檢查...",
|
||||||
"checkOnly": "僅檢查",
|
"checkOnly": "僅檢查",
|
||||||
@@ -686,8 +676,6 @@
|
|||||||
"protected": "受保護",
|
"protected": "受保護",
|
||||||
"editTitle": "編輯自動任務",
|
"editTitle": "編輯自動任務",
|
||||||
"save": "儲存",
|
"save": "儲存",
|
||||||
"commandCopied": "已複製",
|
|
||||||
"copyCommand": "複製",
|
|
||||||
"deleteTitle": "刪除自動任務",
|
"deleteTitle": "刪除自動任務",
|
||||||
"deleteDescription": "這會從 cron 儲存區移除 {{name}},過往的聊天訊息仍會保留在該對話中。",
|
"deleteDescription": "這會從 cron 儲存區移除 {{name}},過往的聊天訊息仍會保留在該對話中。",
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
@@ -747,7 +735,6 @@
|
|||||||
"fields": {
|
"fields": {
|
||||||
"name": "名稱",
|
"name": "名稱",
|
||||||
"message": "訊息",
|
"message": "訊息",
|
||||||
"command": "指令",
|
|
||||||
"scheduleType": "排程類型",
|
"scheduleType": "排程類型",
|
||||||
"every": "每隔",
|
"every": "每隔",
|
||||||
"unit": "單位",
|
"unit": "單位",
|
||||||
@@ -805,7 +792,7 @@
|
|||||||
"finishSignIn": "完成登入"
|
"finishSignIn": "完成登入"
|
||||||
},
|
},
|
||||||
"skills": {
|
"skills": {
|
||||||
"description": "檢閱此智能體可在對話期間載入的指令技能。",
|
"description": "檢閱此 Agent 可在對話期間載入的指令技能。",
|
||||||
"caption": "{{available}} 個可用 · 共 {{total}} 個",
|
"caption": "{{available}} 個可用 · 共 {{total}} 個",
|
||||||
"views": "技能檢視",
|
"views": "技能檢視",
|
||||||
"installedTab": "已安裝",
|
"installedTab": "已安裝",
|
||||||
@@ -824,7 +811,7 @@
|
|||||||
"showLess": "收合",
|
"showLess": "收合",
|
||||||
"showMore": "展開",
|
"showMore": "展開",
|
||||||
"enabledControl": "使用此技能",
|
"enabledControl": "使用此技能",
|
||||||
"enabledDescription": "當技能需求已滿足時,允許智能體載入並使用它。",
|
"enabledDescription": "當技能需求已滿足時,允許 agent 載入並使用它。",
|
||||||
"enableSkill": "啟用 {{name}}",
|
"enableSkill": "啟用 {{name}}",
|
||||||
"disableSkill": "停用 {{name}}",
|
"disableSkill": "停用 {{name}}",
|
||||||
"updateFailed": "無法更新此技能。",
|
"updateFailed": "無法更新此技能。",
|
||||||
@@ -865,7 +852,7 @@
|
|||||||
"marketplaceInstall": "安裝",
|
"marketplaceInstall": "安裝",
|
||||||
"marketplaceNoTrend": "暫無趨勢",
|
"marketplaceNoTrend": "暫無趨勢",
|
||||||
"marketplaceTrendLabel": "近 8 週安裝趨勢",
|
"marketplaceTrendLabel": "近 8 週安裝趨勢",
|
||||||
"featured": "智能體技能",
|
"featured": "Agent 技能",
|
||||||
"empty": "目前沒有可用的技能。",
|
"empty": "目前沒有可用的技能。",
|
||||||
"sourceWorkspace": "自訂",
|
"sourceWorkspace": "自訂",
|
||||||
"sourceBuiltin": "內建",
|
"sourceBuiltin": "內建",
|
||||||
@@ -906,8 +893,8 @@
|
|||||||
"actions": "「{{title}}」的話題操作",
|
"actions": "「{{title}}」的話題操作",
|
||||||
"newInProject": "在 {{project}} 中開始新話題",
|
"newInProject": "在 {{project}} 中開始新話題",
|
||||||
"activity": {
|
"activity": {
|
||||||
"running": "智能體正在執行",
|
"running": "Agent 正在執行",
|
||||||
"complete": "智能體已完成",
|
"complete": "Agent 已完成",
|
||||||
"updated": "有新內容"
|
"updated": "有新內容"
|
||||||
},
|
},
|
||||||
"pin": "置頂",
|
"pin": "置頂",
|
||||||
@@ -1138,7 +1125,7 @@
|
|||||||
},
|
},
|
||||||
"stop": {
|
"stop": {
|
||||||
"title": "停止目前任務",
|
"title": "停止目前任務",
|
||||||
"description": "取消這個對話中正在執行的智能體回合。"
|
"description": "取消這個對話中正在執行的 Agent 回合。"
|
||||||
},
|
},
|
||||||
"restart": {
|
"restart": {
|
||||||
"title": "重新啟動 nanobot",
|
"title": "重新啟動 nanobot",
|
||||||
@@ -1224,9 +1211,7 @@
|
|||||||
"cliBadge": "CLI",
|
"cliBadge": "CLI",
|
||||||
"mcpBadge": "MCP",
|
"mcpBadge": "MCP",
|
||||||
"cliDescription": "將 @{{name}} 作為本機 CLI 應用程式使用",
|
"cliDescription": "將 @{{name}} 作為本機 CLI 應用程式使用",
|
||||||
"mcpDescription": "將 @{{name}} 作為 MCP 伺服器使用",
|
"mcpDescription": "將 @{{name}} 作為 MCP 伺服器使用"
|
||||||
"cliTitle": "CLI 應用程式:{{name}}",
|
|
||||||
"mcpTitle": "MCP 伺服器:{{name}}"
|
|
||||||
},
|
},
|
||||||
"workspace": {
|
"workspace": {
|
||||||
"accessAria": "工作區存取模式",
|
"accessAria": "工作區存取模式",
|
||||||
@@ -1246,8 +1231,7 @@
|
|||||||
"title": "提示詞",
|
"title": "提示詞",
|
||||||
"search": "搜尋提示詞",
|
"search": "搜尋提示詞",
|
||||||
"noResults": "找不到符合的提示詞。",
|
"noResults": "找不到符合的提示詞。",
|
||||||
"jumpTo": "跳到提示詞:{{label}}",
|
"jumpTo": "跳到提示詞:{{label}}"
|
||||||
"railAria": "使用者提示詞導覽"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
@@ -1271,14 +1255,6 @@
|
|||||||
"agentActivityLiveSummary": "進行中… · {{reasoning}} 步 · {{tools}} 次工具呼叫",
|
"agentActivityLiveSummary": "進行中… · {{reasoning}} 步 · {{tools}} 次工具呼叫",
|
||||||
"agentActivityLiveToolsOnly": "進行中… · {{tools}} 次工具呼叫",
|
"agentActivityLiveToolsOnly": "進行中… · {{tools}} 次工具呼叫",
|
||||||
"imageAttachment": "圖片附件",
|
"imageAttachment": "圖片附件",
|
||||||
"videoAttachment": "影片附件",
|
|
||||||
"fileAttachment": "檔案附件",
|
|
||||||
"attachmentUnavailable": "附件無法使用",
|
|
||||||
"dataTable": "資料表",
|
|
||||||
"fileEditPreparing": "正在準備檔案編輯…",
|
|
||||||
"openLink": "開啟連結:{{label}}",
|
|
||||||
"openAttachment": "開啟 {{name}}",
|
|
||||||
"skill": "技能:{{name}}",
|
|
||||||
"forkFromHere": "建立分支",
|
"forkFromHere": "建立分支",
|
||||||
"copyReply": "複製",
|
"copyReply": "複製",
|
||||||
"copiedReply": "已複製",
|
"copiedReply": "已複製",
|
||||||
@@ -1319,7 +1295,6 @@
|
|||||||
},
|
},
|
||||||
"filePreview": {
|
"filePreview": {
|
||||||
"aria": "檔案預覽",
|
"aria": "檔案預覽",
|
||||||
"breadcrumb": "檔案路徑",
|
|
||||||
"close": "關閉檔案預覽",
|
"close": "關閉檔案預覽",
|
||||||
"loading": "正在載入預覽…",
|
"loading": "正在載入預覽…",
|
||||||
"failed": "無法預覽這個檔案。",
|
"failed": "無法預覽這個檔案。",
|
||||||
@@ -1334,10 +1309,7 @@
|
|||||||
"copied": "已複製"
|
"copied": "已複製"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"dismiss": "關閉",
|
"dismiss": "關閉"
|
||||||
"close": "關閉",
|
|
||||||
"current": "目前",
|
|
||||||
"cancel": "取消"
|
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"messageTooBig": {
|
"messageTooBig": {
|
||||||
|
|||||||
@@ -858,6 +858,8 @@ export async function updateSettings(
|
|||||||
query.set("context_window_tokens", String(update.contextWindowTokens));
|
query.set("context_window_tokens", String(update.contextWindowTokens));
|
||||||
}
|
}
|
||||||
if (update.timezone !== undefined) query.set("timezone", update.timezone);
|
if (update.timezone !== undefined) query.set("timezone", update.timezone);
|
||||||
|
if (update.botName !== undefined) query.set("bot_name", update.botName);
|
||||||
|
if (update.botIcon !== undefined) query.set("bot_icon", update.botIcon);
|
||||||
if (update.toolHintMaxLength !== undefined) {
|
if (update.toolHintMaxLength !== undefined) {
|
||||||
query.set("tool_hint_max_length", String(update.toolHintMaxLength));
|
query.set("tool_hint_max_length", String(update.toolHintMaxLength));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -673,8 +673,12 @@ export class NanobotClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Subscribe to events for a given chat_id. Auto-attaches on the next open. */
|
/** Subscribe to events for a given chat_id. Auto-attaches unless it is temporary. */
|
||||||
onChat(chatId: string, handler: EventHandler): Unsubscribe {
|
onChat(
|
||||||
|
chatId: string,
|
||||||
|
handler: EventHandler,
|
||||||
|
options?: { temporary?: boolean },
|
||||||
|
): Unsubscribe {
|
||||||
let handlers = this.chatHandlers.get(chatId);
|
let handlers = this.chatHandlers.get(chatId);
|
||||||
if (!handlers) {
|
if (!handlers) {
|
||||||
handlers = new Set();
|
handlers = new Set();
|
||||||
@@ -689,7 +693,7 @@ export class NanobotClient {
|
|||||||
handler(ev);
|
handler(ev);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.attach(chatId);
|
if (!options?.temporary) this.attach(chatId);
|
||||||
return () => {
|
return () => {
|
||||||
const current = this.chatHandlers.get(chatId);
|
const current = this.chatHandlers.get(chatId);
|
||||||
if (!current) return;
|
if (!current) return;
|
||||||
@@ -809,9 +813,10 @@ export class NanobotClient {
|
|||||||
turnId?: string;
|
turnId?: string;
|
||||||
/** False for side-channel or injected messages that do not own a lifecycle. */
|
/** False for side-channel or injected messages that do not own a lifecycle. */
|
||||||
startsNewRun?: boolean;
|
startsNewRun?: boolean;
|
||||||
|
temporary?: boolean;
|
||||||
},
|
},
|
||||||
): void {
|
): void {
|
||||||
this.knownChats.add(chatId);
|
if (!options?.temporary) this.knownChats.add(chatId);
|
||||||
const frame: Outbound = {
|
const frame: Outbound = {
|
||||||
type: "message",
|
type: "message",
|
||||||
chat_id: chatId,
|
chat_id: chatId,
|
||||||
@@ -822,6 +827,7 @@ export class NanobotClient {
|
|||||||
...(options?.quotedContext?.trim() ? { quoted_context: options.quotedContext.trim() } : {}),
|
...(options?.quotedContext?.trim() ? { quoted_context: options.quotedContext.trim() } : {}),
|
||||||
...(options?.workspaceScope ? { workspace_scope: options.workspaceScope } : {}),
|
...(options?.workspaceScope ? { workspace_scope: options.workspaceScope } : {}),
|
||||||
...(options?.turnId ? { turn_id: options.turnId } : {}),
|
...(options?.turnId ? { turn_id: options.turnId } : {}),
|
||||||
|
...(options?.temporary ? { temporary: true } : {}),
|
||||||
webui: true,
|
webui: true,
|
||||||
};
|
};
|
||||||
if (!this.frameFitsTransport(frame)) {
|
if (!this.frameFitsTransport(frame)) {
|
||||||
@@ -843,7 +849,12 @@ export class NanobotClient {
|
|||||||
this.queueSend(frame);
|
this.queueSend(frame);
|
||||||
}
|
}
|
||||||
|
|
||||||
sendSystemCommand(chatId: string, command: string, timeoutMs = 5_000): Promise<void> {
|
sendSystemCommand(
|
||||||
|
chatId: string,
|
||||||
|
command: string,
|
||||||
|
timeoutMs = 5_000,
|
||||||
|
options?: { temporary?: boolean },
|
||||||
|
): Promise<void> {
|
||||||
const normalized = command.trim();
|
const normalized = command.trim();
|
||||||
const turnId = `${SYSTEM_COMMAND_TURN_PREFIX}${crypto.randomUUID()}`;
|
const turnId = `${SYSTEM_COMMAND_TURN_PREFIX}${crypto.randomUUID()}`;
|
||||||
return new Promise<void>((resolve, reject) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
@@ -852,10 +863,46 @@ export class NanobotClient {
|
|||||||
reject(new Error("system command timed out"));
|
reject(new Error("system command timed out"));
|
||||||
}, timeoutMs);
|
}, timeoutMs);
|
||||||
this.pendingSystemCommands.set(turnId, { resolve, reject, timer });
|
this.pendingSystemCommands.set(turnId, { resolve, reject, timer });
|
||||||
this.sendMessage(chatId, normalized, undefined, { turnId });
|
this.sendMessage(chatId, normalized, undefined, {
|
||||||
|
turnId,
|
||||||
|
temporary: options?.temporary,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
discardTemporaryChat(chatId: string): void {
|
||||||
|
this.knownChats.delete(chatId);
|
||||||
|
this.chatHandlers.delete(chatId);
|
||||||
|
this.pendingInboundByChat.delete(chatId);
|
||||||
|
this.runStartedAtByChatId.delete(chatId);
|
||||||
|
this.goalStateByChatId.delete(chatId);
|
||||||
|
this.runGenerationByChatId.delete(chatId);
|
||||||
|
this.latestRunTurnIdByChatId.delete(chatId);
|
||||||
|
this.unsettledRunTurnIdsByChatId.delete(chatId);
|
||||||
|
this.canonicalCompletedTurnIdsByChatId.delete(chatId);
|
||||||
|
const turnKeyPrefix = `${chatId}\u0000`;
|
||||||
|
for (const key of this.runStartedAtByTurnKey.keys()) {
|
||||||
|
if (key.startsWith(turnKeyPrefix)) this.runStartedAtByTurnKey.delete(key);
|
||||||
|
}
|
||||||
|
for (const [key, pending] of this.pendingMessageSends) {
|
||||||
|
if (pending.chatId !== chatId) continue;
|
||||||
|
if (isSystemCommandTurnId(pending.turnId)) {
|
||||||
|
this.rejectSystemCommand(pending.turnId, "temporary chat discarded");
|
||||||
|
}
|
||||||
|
this.pendingMessageSends.delete(key);
|
||||||
|
this.socketPendingMessageSendKeys.delete(key);
|
||||||
|
}
|
||||||
|
if (this.lastSocketMessageSendKey?.startsWith(turnKeyPrefix)) {
|
||||||
|
this.lastSocketMessageSendKey = null;
|
||||||
|
}
|
||||||
|
this.sendQueue = this.sendQueue.filter(
|
||||||
|
(frame) => !("chat_id" in frame) || frame.chat_id !== chatId,
|
||||||
|
);
|
||||||
|
if (this.socket?.readyState === WS_OPEN) {
|
||||||
|
this.rawSend({ type: "discard_temporary_chat", chat_id: chatId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setWorkspaceScope(chatId: string, workspaceScope: WorkspaceScopePayload): void {
|
setWorkspaceScope(chatId: string, workspaceScope: WorkspaceScopePayload): void {
|
||||||
this.knownChats.add(chatId);
|
this.knownChats.add(chatId);
|
||||||
this.queueSend({
|
this.queueSend({
|
||||||
@@ -1007,6 +1054,11 @@ export class NanobotClient {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (parsed.event === "temporary_chat_discarded") {
|
||||||
|
this.pendingInboundByChat.delete(parsed.chat_id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (parsed.event === "error" && parsed.detail === "workspace_scope_rejected") {
|
if (parsed.event === "error" && parsed.detail === "workspace_scope_rejected") {
|
||||||
this.emitError({
|
this.emitError({
|
||||||
kind: "workspace_scope_rejected",
|
kind: "workspace_scope_rejected",
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { ChatSummary } from "@/lib/types";
|
||||||
|
|
||||||
|
export const QUICK_CHAT_ID = "quick-chat";
|
||||||
|
export const QUICK_CHAT_KEY = `websocket:${QUICK_CHAT_ID}`;
|
||||||
|
export const TEMPORARY_CHAT_ID_PREFIX = "temporary-";
|
||||||
|
|
||||||
|
export function isQuickChatKey(key: string | null): boolean {
|
||||||
|
return key === QUICK_CHAT_KEY;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function quickChatSession(persisted?: ChatSummary): ChatSummary {
|
||||||
|
return {
|
||||||
|
key: QUICK_CHAT_KEY,
|
||||||
|
channel: "websocket",
|
||||||
|
chatId: QUICK_CHAT_ID,
|
||||||
|
createdAt: persisted?.createdAt ?? null,
|
||||||
|
updatedAt: persisted?.updatedAt ?? null,
|
||||||
|
preview: persisted?.preview ?? "",
|
||||||
|
modelPreset: persisted?.modelPreset ?? null,
|
||||||
|
runStartedAt: persisted?.runStartedAt ?? null,
|
||||||
|
workspaceScope: persisted?.workspaceScope ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTemporaryChatSession(): ChatSummary {
|
||||||
|
const chatId = `${TEMPORARY_CHAT_ID_PREFIX}${crypto.randomUUID()}`;
|
||||||
|
return {
|
||||||
|
key: `websocket:${chatId}`,
|
||||||
|
channel: "websocket",
|
||||||
|
chatId,
|
||||||
|
createdAt: null,
|
||||||
|
updatedAt: null,
|
||||||
|
preview: "",
|
||||||
|
modelPreset: null,
|
||||||
|
runStartedAt: null,
|
||||||
|
workspaceScope: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -490,6 +490,8 @@ export interface SettingsPayload {
|
|||||||
temperature: number;
|
temperature: number;
|
||||||
reasoning_effort: string | null;
|
reasoning_effort: string | null;
|
||||||
timezone: string;
|
timezone: string;
|
||||||
|
bot_name: string;
|
||||||
|
bot_icon: string;
|
||||||
tool_hint_max_length: number;
|
tool_hint_max_length: number;
|
||||||
};
|
};
|
||||||
model_presets: Array<{
|
model_presets: Array<{
|
||||||
@@ -1026,6 +1028,8 @@ export interface SettingsUpdate {
|
|||||||
modelPreset?: string | null;
|
modelPreset?: string | null;
|
||||||
contextWindowTokens?: number;
|
contextWindowTokens?: number;
|
||||||
timezone?: string;
|
timezone?: string;
|
||||||
|
botName?: string;
|
||||||
|
botIcon?: string;
|
||||||
toolHintMaxLength?: number;
|
toolHintMaxLength?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1243,6 +1247,7 @@ export type InboundEvent =
|
|||||||
scope?: "metadata" | "thread" | string;
|
scope?: "metadata" | "thread" | string;
|
||||||
workspace_scope?: WorkspaceScopePayload;
|
workspace_scope?: WorkspaceScopePayload;
|
||||||
}
|
}
|
||||||
|
| { event: "temporary_chat_discarded"; chat_id: string }
|
||||||
| { event: "transcription_result"; request_id: string; text: string }
|
| { event: "transcription_result"; request_id: string; text: string }
|
||||||
| {
|
| {
|
||||||
event: "transcription_error";
|
event: "transcription_error";
|
||||||
@@ -1329,6 +1334,7 @@ export type Outbound =
|
|||||||
| { type: "new_chat"; workspace_scope?: WorkspaceScopePayload }
|
| { type: "new_chat"; workspace_scope?: WorkspaceScopePayload }
|
||||||
| { type: "fork_chat"; source_chat_id: string; before_user_index: number; title?: string }
|
| { type: "fork_chat"; source_chat_id: string; before_user_index: number; title?: string }
|
||||||
| { type: "attach"; chat_id: string }
|
| { type: "attach"; chat_id: string }
|
||||||
|
| { type: "discard_temporary_chat"; chat_id: string }
|
||||||
| { type: "set_workspace_scope"; chat_id: string; workspace_scope: WorkspaceScopePayload }
|
| { type: "set_workspace_scope"; chat_id: string; workspace_scope: WorkspaceScopePayload }
|
||||||
| { type: "transcribe_audio"; request_id: string; data_url: string; duration_ms?: number }
|
| { type: "transcribe_audio"; request_id: string; data_url: string; duration_ms?: number }
|
||||||
| {
|
| {
|
||||||
@@ -1341,6 +1347,7 @@ export type Outbound =
|
|||||||
quoted_context?: string;
|
quoted_context?: string;
|
||||||
workspace_scope?: WorkspaceScopePayload;
|
workspace_scope?: WorkspaceScopePayload;
|
||||||
turn_id?: string;
|
turn_id?: string;
|
||||||
|
temporary?: true;
|
||||||
/** Marks messages sent by the embedded WebUI, without changing the
|
/** Marks messages sent by the embedded WebUI, without changing the
|
||||||
* generic websocket protocol for other clients. */
|
* generic websocket protocol for other clients. */
|
||||||
webui?: true;
|
webui?: true;
|
||||||
|
|||||||
@@ -413,11 +413,13 @@ describe("webui API helpers", () => {
|
|||||||
provider: "openrouter",
|
provider: "openrouter",
|
||||||
contextWindowTokens: 262144,
|
contextWindowTokens: 262144,
|
||||||
timezone: "Asia/Shanghai",
|
timezone: "Asia/Shanghai",
|
||||||
|
botName: "nanobot",
|
||||||
|
botIcon: "nb",
|
||||||
toolHintMaxLength: 120,
|
toolHintMaxLength: 120,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(fetch).toHaveBeenCalledWith(
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
"/api/settings/update?model_preset=default&model=openrouter%2Ftest&provider=openrouter&context_window_tokens=262144&timezone=Asia%2FShanghai&tool_hint_max_length=120",
|
"/api/settings/update?model_preset=default&model=openrouter%2Ftest&provider=openrouter&context_window_tokens=262144&timezone=Asia%2FShanghai&bot_name=nanobot&bot_icon=nb&tool_hint_max_length=120",
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
headers: { Authorization: "Bearer tok" },
|
headers: { Authorization: "Bearer tok" },
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const getSessionAutomationsSpy = vi.fn<(key: string) => Promise<SessionAutomatio
|
|||||||
const toggleThemeSpy = vi.fn();
|
const toggleThemeSpy = vi.fn();
|
||||||
const updateUrlSpy = vi.fn();
|
const updateUrlSpy = vi.fn();
|
||||||
const attachSpy = vi.fn();
|
const attachSpy = vi.fn();
|
||||||
|
const discardTemporaryChatSpy = vi.fn();
|
||||||
const runStatusHandlers = new Set<(chatId: string, startedAt: number | null) => void>();
|
const runStatusHandlers = new Set<(chatId: string, startedAt: number | null) => void>();
|
||||||
const sessionUpdateHandlers = new Set<(chatId: string, scope?: string) => void>();
|
const sessionUpdateHandlers = new Set<(chatId: string, scope?: string) => void>();
|
||||||
let mockSessions: ChatSummary[] = [];
|
let mockSessions: ChatSummary[] = [];
|
||||||
@@ -62,6 +63,8 @@ function baseSettingsPayload() {
|
|||||||
temperature: 0.1,
|
temperature: 0.1,
|
||||||
reasoning_effort: null,
|
reasoning_effort: null,
|
||||||
timezone: "UTC",
|
timezone: "UTC",
|
||||||
|
bot_name: "nanobot",
|
||||||
|
bot_icon: "nb",
|
||||||
tool_hint_max_length: 40,
|
tool_hint_max_length: 40,
|
||||||
},
|
},
|
||||||
model_presets: [{
|
model_presets: [{
|
||||||
@@ -217,6 +220,7 @@ vi.mock("@/lib/nanobot-client", () => {
|
|||||||
sendMessage = vi.fn();
|
sendMessage = vi.fn();
|
||||||
newChat = vi.fn();
|
newChat = vi.fn();
|
||||||
attach = attachSpy;
|
attach = attachSpy;
|
||||||
|
discardTemporaryChat = discardTemporaryChatSpy;
|
||||||
close = vi.fn();
|
close = vi.fn();
|
||||||
updateUrl = updateUrlSpy;
|
updateUrl = updateUrlSpy;
|
||||||
updateMaxFrameBytes = vi.fn();
|
updateMaxFrameBytes = vi.fn();
|
||||||
@@ -244,6 +248,7 @@ describe("App layout", () => {
|
|||||||
getSessionAutomationsSpy.mockReset().mockResolvedValue([]);
|
getSessionAutomationsSpy.mockReset().mockResolvedValue([]);
|
||||||
toggleThemeSpy.mockReset();
|
toggleThemeSpy.mockReset();
|
||||||
attachSpy.mockReset();
|
attachSpy.mockReset();
|
||||||
|
discardTemporaryChatSpy.mockReset();
|
||||||
runStatusHandlers.clear();
|
runStatusHandlers.clear();
|
||||||
sessionUpdateHandlers.clear();
|
sessionUpdateHandlers.clear();
|
||||||
window.history.replaceState(null, "", "/");
|
window.history.replaceState(null, "", "/");
|
||||||
@@ -363,6 +368,129 @@ describe("App layout", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("opens a single fixed Quick Chat without provisioning a new session", async () => {
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||||
|
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||||
|
const quickChatButton = within(sidebar).getByRole("button", {
|
||||||
|
name: "Quick Chat",
|
||||||
|
});
|
||||||
|
const newTopicButton = within(sidebar).getByRole("button", {
|
||||||
|
name: "New topic",
|
||||||
|
});
|
||||||
|
const actionHighlight = within(sidebar).getByTestId(
|
||||||
|
"actions-selection-highlight",
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(quickChatButton);
|
||||||
|
|
||||||
|
expect(window.location.hash).toBe("#/quick-chat");
|
||||||
|
expect(quickChatButton).toHaveAttribute("aria-current", "page");
|
||||||
|
expect(newTopicButton).not.toHaveAttribute("aria-current");
|
||||||
|
expect(quickChatButton).not.toHaveClass("bg-sidebar-accent");
|
||||||
|
expect(quickChatButton).toHaveClass("transition-[width,padding,color]");
|
||||||
|
expect(actionHighlight).toHaveAttribute("data-active-id", "quick-chat");
|
||||||
|
expect(
|
||||||
|
within(sidebar).queryByTestId("actions-selection-highlight-surface"),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining(
|
||||||
|
"/api/sessions/websocket%3Aquick-chat/webui-thread",
|
||||||
|
),
|
||||||
|
expect.anything(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(createChatSpy).not.toHaveBeenCalled();
|
||||||
|
expect(document.title).toBe("Quick Chat · nanobot");
|
||||||
|
expect(screen.getByText("What's on your mind?")).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(newTopicButton);
|
||||||
|
|
||||||
|
expect(window.location.hash).toBe("#/new");
|
||||||
|
expect(newTopicButton).toHaveAttribute("aria-current", "page");
|
||||||
|
expect(quickChatButton).not.toHaveAttribute("aria-current");
|
||||||
|
expect(actionHighlight).toHaveAttribute("data-active-id", "new-chat");
|
||||||
|
expect(within(sidebar).queryAllByRole("button", { current: "page" })).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enters and destroys Temporary Chat inside Quick Chat", async () => {
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||||
|
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||||
|
fireEvent.click(within(sidebar).getByRole("button", { name: "Quick Chat" }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Temporary" }));
|
||||||
|
|
||||||
|
expect(screen.getByText("Start a temporary chat")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/No history, memory, tools, or project access/))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "Attach image" })).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Exit temporary chat" }));
|
||||||
|
|
||||||
|
expect(discardTemporaryChatSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(discardTemporaryChatSpy.mock.calls[0][0]).toMatch(/^temporary-/);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("What's on your mind?")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores Quick Chat before it has a persisted session", async () => {
|
||||||
|
window.history.replaceState(null, "", "/#/quick-chat");
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||||
|
expect(window.location.hash).toBe("#/quick-chat");
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining(
|
||||||
|
"/api/sessions/websocket%3Aquick-chat/webui-thread",
|
||||||
|
),
|
||||||
|
expect.anything(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
within(screen.getByRole("navigation", { name: "Sidebar navigation" }))
|
||||||
|
.getByRole("button", { name: "Quick Chat" }),
|
||||||
|
).toHaveAttribute("aria-current", "page");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps persisted Quick Chat out of the topic list and topic search", async () => {
|
||||||
|
mockSessions = [
|
||||||
|
{
|
||||||
|
key: "websocket:quick-chat",
|
||||||
|
channel: "websocket",
|
||||||
|
chatId: "quick-chat",
|
||||||
|
createdAt: "2026-07-30T08:00:00Z",
|
||||||
|
updatedAt: "2026-07-30T08:05:00Z",
|
||||||
|
preview: "A private casual message",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "websocket:project-chat",
|
||||||
|
channel: "websocket",
|
||||||
|
chatId: "project-chat",
|
||||||
|
createdAt: "2026-07-30T08:00:00Z",
|
||||||
|
updatedAt: "2026-07-30T08:05:00Z",
|
||||||
|
preview: "Project roadmap",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||||
|
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||||
|
expect(within(sidebar).getByText("Project roadmap")).toBeInTheDocument();
|
||||||
|
expect(within(sidebar).queryByText("A private casual message")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(within(sidebar).getByRole("button", { name: "Search" }));
|
||||||
|
const dialog = await screen.findByRole("dialog", { name: "Search" });
|
||||||
|
expect(within(dialog).getByText("Project roadmap")).toBeInTheDocument();
|
||||||
|
expect(within(dialog).queryByText("A private casual message")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("restores the Settings route after a restart fallback hash", async () => {
|
it("restores the Settings route after a restart fallback hash", async () => {
|
||||||
localStorage.setItem("nanobot-webui.restartStartedAt", String(Date.now()));
|
localStorage.setItem("nanobot-webui.restartStartedAt", String(Date.now()));
|
||||||
localStorage.setItem("nanobot-webui.restartRoute", "#/settings?section=channels");
|
localStorage.setItem("nanobot-webui.restartRoute", "#/settings?section=channels");
|
||||||
@@ -1759,6 +1887,8 @@ describe("App layout", () => {
|
|||||||
temperature: 0.1,
|
temperature: 0.1,
|
||||||
reasoning_effort: null,
|
reasoning_effort: null,
|
||||||
timezone: "UTC",
|
timezone: "UTC",
|
||||||
|
bot_name: "nanobot",
|
||||||
|
bot_icon: "nb",
|
||||||
tool_hint_max_length: 40,
|
tool_hint_max_length: 40,
|
||||||
},
|
},
|
||||||
model_presets: [
|
model_presets: [
|
||||||
@@ -2085,10 +2215,7 @@ describe("App layout", () => {
|
|||||||
expect(screen.queryByDisplayValue("unsaved-brave-key")).not.toBeInTheDocument();
|
expect(screen.queryByDisplayValue("unsaved-brave-key")).not.toBeInTheDocument();
|
||||||
|
|
||||||
fireEvent.click(within(settingsNav).getByRole("button", { name: "System" }));
|
fireEvent.click(within(settingsNav).getByRole("button", { name: "System" }));
|
||||||
expect(screen.getByText("Regional")).toBeInTheDocument();
|
expect(screen.getByText("Bot name")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Timezone")).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText("Bot name")).not.toBeInTheDocument();
|
|
||||||
expect(screen.queryByText("Bot icon")).not.toBeInTheDocument();
|
|
||||||
expect(screen.queryByText("Tool hint length")).not.toBeInTheDocument();
|
expect(screen.queryByText("Tool hint length")).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText("Heartbeat")).not.toBeInTheDocument();
|
expect(screen.queryByText("Heartbeat")).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText("Dream")).not.toBeInTheDocument();
|
expect(screen.queryByText("Dream")).not.toBeInTheDocument();
|
||||||
@@ -2280,6 +2407,8 @@ describe("App layout", () => {
|
|||||||
temperature: 0.1,
|
temperature: 0.1,
|
||||||
reasoning_effort: null,
|
reasoning_effort: null,
|
||||||
timezone: "UTC",
|
timezone: "UTC",
|
||||||
|
bot_name: "nanobot",
|
||||||
|
bot_icon: "nb",
|
||||||
tool_hint_max_length: 40,
|
tool_hint_max_length: 40,
|
||||||
},
|
},
|
||||||
model_presets: [
|
model_presets: [
|
||||||
|
|||||||
@@ -236,72 +236,6 @@ const LOCALIZED_CHANNEL_SHELL_KEYS = [
|
|||||||
"settings.channels.validation.unsupported",
|
"settings.channels.validation.unsupported",
|
||||||
"settings.channels.validationFailed",
|
"settings.channels.validationFailed",
|
||||||
];
|
];
|
||||||
const LOCALIZED_NEW_SURFACE_KEYS = [
|
|
||||||
"chat.activity.running",
|
|
||||||
"chat.activity.complete",
|
|
||||||
"chat.activity.updated",
|
|
||||||
"chat.pin",
|
|
||||||
"chat.unpin",
|
|
||||||
"chat.rename",
|
|
||||||
"chat.renameProjectTitle",
|
|
||||||
"chat.renameProjectDescription",
|
|
||||||
"chat.renameProjectPlaceholder",
|
|
||||||
"chat.renameSave",
|
|
||||||
"chat.archive",
|
|
||||||
"chat.unarchive",
|
|
||||||
"chat.showArchived",
|
|
||||||
"chat.hideArchived",
|
|
||||||
"chat.groups.pinned",
|
|
||||||
"chat.groups.projects",
|
|
||||||
"chat.groups.today",
|
|
||||||
"chat.groups.yesterday",
|
|
||||||
"chat.groups.earlier",
|
|
||||||
"chat.groups.archived",
|
|
||||||
"thread.promptNavigator.railAria",
|
|
||||||
"thread.composer.mentions.cliTitle",
|
|
||||||
"thread.composer.mentions.mcpTitle",
|
|
||||||
"message.openLink",
|
|
||||||
"message.openAttachment",
|
|
||||||
"message.skill",
|
|
||||||
"settings.channels.connectionChecks",
|
|
||||||
"settings.channels.open",
|
|
||||||
];
|
|
||||||
const ACCIDENTALLY_SPANISH_SETTINGS_KEYS = [
|
|
||||||
"settings.help.provider",
|
|
||||||
"settings.help.configPath",
|
|
||||||
"settings.help.selectedPreset",
|
|
||||||
"settings.help.maxResults",
|
|
||||||
"settings.help.timeout",
|
|
||||||
"settings.help.jinaReader",
|
|
||||||
"settings.help.imageGeneration",
|
|
||||||
"settings.help.imageProvider",
|
|
||||||
"settings.help.imageProviderStatus",
|
|
||||||
"settings.help.imageModel",
|
|
||||||
"settings.help.defaultAspectRatio",
|
|
||||||
"settings.help.timezone",
|
|
||||||
"settings.help.securityManagedControls",
|
|
||||||
"settings.help.selectedModelProvider",
|
|
||||||
"settings.help.selectedModelValue",
|
|
||||||
"settings.help.cliAppsCatalog",
|
|
||||||
"settings.help.cliAppsFilter",
|
|
||||||
"settings.help.logs",
|
|
||||||
"settings.help.diagnostics",
|
|
||||||
"settings.help.localServiceAccessNative",
|
|
||||||
"settings.help.webuiDefaultAccessNative",
|
|
||||||
"settings.status.savedRestart",
|
|
||||||
"settings.status.restartAfterSaving",
|
|
||||||
"settings.status.savedRestartApply",
|
|
||||||
"settings.status.imageProviderRestart",
|
|
||||||
"settings.status.hostRestartAfterSaving",
|
|
||||||
"settings.status.hostRestartPending",
|
|
||||||
"settings.status.hostApiUnavailable",
|
|
||||||
"settings.status.logsOpened",
|
|
||||||
"settings.status.logsOpenFailed",
|
|
||||||
"settings.status.diagnosticsExported",
|
|
||||||
"settings.status.diagnosticsExportFailed",
|
|
||||||
"settings.image.missingCredential",
|
|
||||||
"settings.oauth.signInHelp",
|
|
||||||
];
|
|
||||||
const INDEX_HTML = readFileSync(resolve(process.cwd(), "index.html"), "utf8");
|
const INDEX_HTML = readFileSync(resolve(process.cwd(), "index.html"), "utf8");
|
||||||
const PREBOOT_SCRIPT = INDEX_HTML.match(
|
const PREBOOT_SCRIPT = INDEX_HTML.match(
|
||||||
/<script>\s*(\(function \(\) \{\s*var localeKey = "nanobot\.locale";[\s\S]*?\}\)\(\);)\s*<\/script>/,
|
/<script>\s*(\(function \(\) \{\s*var localeKey = "nanobot\.locale";[\s\S]*?\}\)\(\);)\s*<\/script>/,
|
||||||
@@ -545,7 +479,6 @@ describe("webui i18n", () => {
|
|||||||
...LOCALIZED_SETTINGS_COPY_KEYS,
|
...LOCALIZED_SETTINGS_COPY_KEYS,
|
||||||
...LOCALIZED_WORKSPACE_COPY_KEYS,
|
...LOCALIZED_WORKSPACE_COPY_KEYS,
|
||||||
...LOCALIZED_CHANNEL_SHELL_KEYS,
|
...LOCALIZED_CHANNEL_SHELL_KEYS,
|
||||||
...LOCALIZED_NEW_SURFACE_KEYS,
|
|
||||||
].filter(
|
].filter(
|
||||||
(key) => current.get(key) === english.get(key),
|
(key) => current.get(key) === english.get(key),
|
||||||
);
|
);
|
||||||
@@ -557,10 +490,10 @@ describe("webui i18n", () => {
|
|||||||
it("keeps Simplified Chinese settings overview copy localized", () => {
|
it("keeps Simplified Chinese settings overview copy localized", () => {
|
||||||
const settings = resources["zh-CN"].common.settings;
|
const settings = resources["zh-CN"].common.settings;
|
||||||
|
|
||||||
expect(settings.nav.browser).toBe("网络");
|
expect(settings.nav.browser).toBe("网页");
|
||||||
expect(settings.sections.webSearch).toBe("网络搜索");
|
expect(settings.sections.webSearch).toBe("网页搜索");
|
||||||
expect(settings.byok.tabs.webSearch).toBe("网络搜索");
|
expect(settings.byok.tabs.webSearch).toBe("网页搜索");
|
||||||
expect(settings.overview.webSearch).toBe("网络搜索");
|
expect(settings.overview.webSearch).toBe("网页搜索");
|
||||||
expect(settings.overview.workspace).toBe("工作区");
|
expect(settings.overview.workspace).toBe("工作区");
|
||||||
expect(settings.skills.installedTab).toBe("已安装");
|
expect(settings.skills.installedTab).toBe("已安装");
|
||||||
expect(settings.skills.discoverTab).toBe("发现");
|
expect(settings.skills.discoverTab).toBe("发现");
|
||||||
@@ -570,18 +503,6 @@ describe("webui i18n", () => {
|
|||||||
expect(settings.skills.marketplaceTrendingTitle).toBe("各市场热门技能");
|
expect(settings.skills.marketplaceTrendingTitle).toBe("各市场热门技能");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps Indonesian and Vietnamese settings free of copied Spanish help text", () => {
|
|
||||||
const spanish = flattenResource(resources.es.common);
|
|
||||||
|
|
||||||
for (const locale of ["id", "vi"] as const) {
|
|
||||||
const current = flattenResource(resources[locale].common);
|
|
||||||
const copied = ACCIDENTALLY_SPANISH_SETTINGS_KEYS.filter(
|
|
||||||
(key) => current.get(key) === spanish.get(key),
|
|
||||||
);
|
|
||||||
expect({ locale, copied }).toEqual({ locale, copied: [] });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps Brazilian Portuguese settings overview copy localized", () => {
|
it("keeps Brazilian Portuguese settings overview copy localized", () => {
|
||||||
const settings = resources["pt-BR"].common.settings;
|
const settings = resources["pt-BR"].common.settings;
|
||||||
const sidebar = resources["pt-BR"].common.sidebar;
|
const sidebar = resources["pt-BR"].common.sidebar;
|
||||||
@@ -593,6 +514,6 @@ describe("webui i18n", () => {
|
|||||||
expect(settings.sections.webSearch).toBe("Busca na web");
|
expect(settings.sections.webSearch).toBe("Busca na web");
|
||||||
expect(settings.byok.tabs.webSearch).toBe("Busca na web");
|
expect(settings.byok.tabs.webSearch).toBe("Busca na web");
|
||||||
expect(settings.overview.webSearch).toBe("Busca na web");
|
expect(settings.overview.webSearch).toBe("Busca na web");
|
||||||
expect(settings.overview.workspace).toBe("Espaço de trabalho");
|
expect(settings.overview.workspace).toBe("Workspace");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -374,45 +374,6 @@ describe("MessageBubble", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to the assistant creation time when replay has no completion time", () => {
|
|
||||||
const createdAt = Date.UTC(2026, 6, 25, 12, 34, 56);
|
|
||||||
const { container } = render(
|
|
||||||
<MessageBubble
|
|
||||||
message={{
|
|
||||||
id: "a-created-at",
|
|
||||||
role: "assistant",
|
|
||||||
content: "Proactive answer",
|
|
||||||
createdAt,
|
|
||||||
}}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const time = container.querySelector("[data-message-timestamp]");
|
|
||||||
expect(time).toHaveTextContent(formatMessageEndTime(createdAt));
|
|
||||||
expect(time).toHaveAttribute("dateTime", new Date(createdAt).toISOString());
|
|
||||||
expect(time).toHaveAttribute("title", fmtDateTime(createdAt));
|
|
||||||
expect(time).not.toHaveAttribute("data-assistant-completed-at");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders the creation time for user messages", () => {
|
|
||||||
const createdAt = Date.UTC(2026, 6, 25, 12, 34, 56);
|
|
||||||
const { container } = render(
|
|
||||||
<MessageBubble
|
|
||||||
message={{
|
|
||||||
id: "u-created-at",
|
|
||||||
role: "user",
|
|
||||||
content: "A user message",
|
|
||||||
createdAt,
|
|
||||||
}}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const time = container.querySelector("[data-message-created-at]");
|
|
||||||
expect(time).toHaveTextContent(formatMessageEndTime(createdAt));
|
|
||||||
expect(time).toHaveAttribute("dateTime", new Date(createdAt).toISOString());
|
|
||||||
expect(time).toHaveAttribute("title", fmtDateTime(createdAt));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not infer completion time from the assistant creation timestamp", () => {
|
it("does not infer completion time from the assistant creation timestamp", () => {
|
||||||
const createdAt = Date.UTC(2026, 6, 25, 12, 34, 0);
|
const createdAt = Date.UTC(2026, 6, 25, 12, 34, 0);
|
||||||
const latencyMs = 13_000;
|
const latencyMs = 13_000;
|
||||||
|
|||||||
@@ -70,6 +70,42 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("NanobotClient", () => {
|
describe("NanobotClient", () => {
|
||||||
|
it("does not attach or retain temporary chats across reconnects", () => {
|
||||||
|
const client = new NanobotClient({
|
||||||
|
url: "ws://test",
|
||||||
|
reconnect: false,
|
||||||
|
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||||
|
});
|
||||||
|
const handler = vi.fn();
|
||||||
|
client.onChat("temporary-one", handler, { temporary: true });
|
||||||
|
client.connect();
|
||||||
|
lastSocket().fakeOpen();
|
||||||
|
|
||||||
|
expect(lastSocket().sent).toEqual([]);
|
||||||
|
|
||||||
|
client.sendMessage("temporary-one", "hello", undefined, {
|
||||||
|
temporary: true,
|
||||||
|
turnId: "turn-temp",
|
||||||
|
});
|
||||||
|
expect(JSON.parse(lastSocket().sent.at(-1)!)).toMatchObject({
|
||||||
|
type: "message",
|
||||||
|
chat_id: "temporary-one",
|
||||||
|
temporary: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
client.discardTemporaryChat("temporary-one");
|
||||||
|
expect(JSON.parse(lastSocket().sent.at(-1)!)).toEqual({
|
||||||
|
type: "discard_temporary_chat",
|
||||||
|
chat_id: "temporary-one",
|
||||||
|
});
|
||||||
|
lastSocket().fakeMessage({
|
||||||
|
event: "message",
|
||||||
|
chat_id: "temporary-one",
|
||||||
|
text: "late",
|
||||||
|
});
|
||||||
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("routes events to the matching chat handler", () => {
|
it("routes events to the matching chat handler", () => {
|
||||||
const client = new NanobotClient({
|
const client = new NanobotClient({
|
||||||
url: "ws://test",
|
url: "ws://test",
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createTemporaryChatSession,
|
||||||
|
isQuickChatKey,
|
||||||
|
QUICK_CHAT_ID,
|
||||||
|
QUICK_CHAT_KEY,
|
||||||
|
quickChatSession,
|
||||||
|
TEMPORARY_CHAT_ID_PREFIX,
|
||||||
|
} from "@/lib/quick-chat";
|
||||||
|
|
||||||
|
describe("Quick Chat identity", () => {
|
||||||
|
it("uses one stable websocket session", () => {
|
||||||
|
expect(QUICK_CHAT_ID).toBe("quick-chat");
|
||||||
|
expect(QUICK_CHAT_KEY).toBe("websocket:quick-chat");
|
||||||
|
expect(isQuickChatKey(QUICK_CHAT_KEY)).toBe(true);
|
||||||
|
expect(isQuickChatKey("websocket:another-chat")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps persisted metadata behind the fixed identity", () => {
|
||||||
|
expect(quickChatSession({
|
||||||
|
key: "websocket:quick-chat",
|
||||||
|
channel: "websocket",
|
||||||
|
chatId: "quick-chat",
|
||||||
|
createdAt: "2026-07-30T08:00:00Z",
|
||||||
|
updatedAt: "2026-07-30T08:05:00Z",
|
||||||
|
preview: "hello",
|
||||||
|
modelPreset: "fast",
|
||||||
|
})).toMatchObject({
|
||||||
|
key: QUICK_CHAT_KEY,
|
||||||
|
channel: "websocket",
|
||||||
|
chatId: QUICK_CHAT_ID,
|
||||||
|
createdAt: "2026-07-30T08:00:00Z",
|
||||||
|
updatedAt: "2026-07-30T08:05:00Z",
|
||||||
|
preview: "hello",
|
||||||
|
modelPreset: "fast",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates isolated temporary identities without replacing Quick Chat", () => {
|
||||||
|
const first = createTemporaryChatSession();
|
||||||
|
const second = createTemporaryChatSession();
|
||||||
|
|
||||||
|
expect(first.chatId).toMatch(new RegExp(`^${TEMPORARY_CHAT_ID_PREFIX}`));
|
||||||
|
expect(first.key).toBe(`websocket:${first.chatId}`);
|
||||||
|
expect(first.key).not.toBe(second.key);
|
||||||
|
expect(isQuickChatKey(first.key)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -113,43 +113,6 @@ describe("SessionInfoPopover", () => {
|
|||||||
expect(screen.queryByText(/ago/i)).not.toBeInTheDocument();
|
expect(screen.queryByText(/ago/i)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows the actual message received by a local trigger", async () => {
|
|
||||||
vi.stubGlobal(
|
|
||||||
"fetch",
|
|
||||||
vi.fn().mockResolvedValue(
|
|
||||||
automationsResponse([
|
|
||||||
{
|
|
||||||
id: "trg_123",
|
|
||||||
name: "PR monitor",
|
|
||||||
enabled: true,
|
|
||||||
kind: "local_trigger",
|
|
||||||
schedule: { kind: "local" },
|
|
||||||
payload: {
|
|
||||||
kind: "local_trigger",
|
|
||||||
message: "Review PR #4591",
|
|
||||||
command: 'nanobot trigger trg_123 "message"',
|
|
||||||
},
|
|
||||||
state: { pending: false },
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const user = userEvent.setup();
|
|
||||||
|
|
||||||
render(
|
|
||||||
<SessionInfoPopover
|
|
||||||
sessionKey="websocket:chat-1"
|
|
||||||
token="tok"
|
|
||||||
title="Release work"
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
await user.click(screen.getByRole("button", { name: "Session details" }));
|
|
||||||
|
|
||||||
expect(await screen.findByText("Review PR #4591")).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText('nanobot trigger trg_123 "message"')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("refreshes while open so completed one-shot automations disappear", async () => {
|
it("refreshes while open so completed one-shot automations disappear", async () => {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ function settingsPayload(): SettingsPayload {
|
|||||||
temperature: 0.1,
|
temperature: 0.1,
|
||||||
reasoning_effort: null,
|
reasoning_effort: null,
|
||||||
timezone: "UTC",
|
timezone: "UTC",
|
||||||
|
bot_name: "nanobot",
|
||||||
|
bot_icon: "nb",
|
||||||
tool_hint_max_length: 40,
|
tool_hint_max_length: 40,
|
||||||
},
|
},
|
||||||
model_presets: [{
|
model_presets: [{
|
||||||
|
|||||||
@@ -336,41 +336,6 @@ function longPress(badge: HTMLElement, pointerId = 7) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("ThreadComposer", () => {
|
describe("ThreadComposer", () => {
|
||||||
it("dismisses the touch keyboard after a successful send", async () => {
|
|
||||||
vi.stubGlobal("matchMedia", vi.fn((query: string) => ({
|
|
||||||
matches: query === "(hover: none) and (pointer: coarse)",
|
|
||||||
media: query,
|
|
||||||
onchange: null,
|
|
||||||
addEventListener: vi.fn(),
|
|
||||||
removeEventListener: vi.fn(),
|
|
||||||
dispatchEvent: vi.fn(),
|
|
||||||
})));
|
|
||||||
const onSend = vi.fn();
|
|
||||||
render(
|
|
||||||
<ThreadComposer
|
|
||||||
onSend={onSend}
|
|
||||||
placeholder="Type your message..."
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const input = screen.getByLabelText("Message input");
|
|
||||||
await act(async () => {
|
|
||||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
|
||||||
});
|
|
||||||
expect(input).not.toHaveFocus();
|
|
||||||
input.focus();
|
|
||||||
expect(input).toHaveFocus();
|
|
||||||
fireEvent.change(input, { target: { value: "hello from mobile" } });
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
|
||||||
await act(async () => {
|
|
||||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(onSend).toHaveBeenCalledWith("hello from mobile", undefined, undefined);
|
|
||||||
expect(input).toHaveValue("");
|
|
||||||
expect(input).not.toHaveFocus();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("focuses and sends a removable quoted answer excerpt", async () => {
|
it("focuses and sends a removable quoted answer excerpt", async () => {
|
||||||
const onSend = vi.fn();
|
const onSend = vi.fn();
|
||||||
const onQuotedContextChange = vi.fn();
|
const onQuotedContextChange = vi.fn();
|
||||||
@@ -441,33 +406,6 @@ describe("ThreadComposer", () => {
|
|||||||
expect(input.parentElement?.parentElement?.className).toContain("max-w-[58rem]");
|
expect(input.parentElement?.parentElement?.className).toContain("max-w-[58rem]");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("defers textarea autosizing until IME composition commits", () => {
|
|
||||||
render(
|
|
||||||
<ThreadComposer
|
|
||||||
onSend={vi.fn()}
|
|
||||||
placeholder="Type your message..."
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
const input = screen.getByLabelText("Message input") as HTMLTextAreaElement;
|
|
||||||
Object.defineProperty(input, "scrollHeight", {
|
|
||||||
configurable: true,
|
|
||||||
value: 120,
|
|
||||||
});
|
|
||||||
input.style.height = "50px";
|
|
||||||
|
|
||||||
fireEvent.input(input, {
|
|
||||||
target: { value: "zhongwen" },
|
|
||||||
isComposing: true,
|
|
||||||
});
|
|
||||||
expect(input.style.height).toBe("50px");
|
|
||||||
|
|
||||||
fireEvent.input(input, {
|
|
||||||
target: { value: "中文" },
|
|
||||||
isComposing: false,
|
|
||||||
});
|
|
||||||
expect(input.style.height).toBe("120px");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("lets long model preset labels use their intrinsic width", () => {
|
it("lets long model preset labels use their intrinsic width", () => {
|
||||||
render(
|
render(
|
||||||
<ThreadComposer
|
<ThreadComposer
|
||||||
@@ -2638,4 +2576,17 @@ describe("ThreadComposer", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("removes every attachment entry point when attachments are disabled", () => {
|
||||||
|
render(
|
||||||
|
<ThreadComposer
|
||||||
|
onSend={vi.fn()}
|
||||||
|
allowAttachments={false}
|
||||||
|
placeholder="Type your message..."
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByRole("button", { name: "Attach image" })).not.toBeInTheDocument();
|
||||||
|
expect(document.querySelector('input[type="file"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -86,6 +86,22 @@ function makeClient() {
|
|||||||
runStartedAtByChatId.delete(chatId);
|
runStartedAtByChatId.delete(chatId);
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
const onChat = vi.fn((
|
||||||
|
chatId: string,
|
||||||
|
handler: (ev: import("@/lib/types").InboundEvent) => void,
|
||||||
|
options?: { temporary?: boolean },
|
||||||
|
) => {
|
||||||
|
void options;
|
||||||
|
let handlers = chatHandlers.get(chatId);
|
||||||
|
if (!handlers) {
|
||||||
|
handlers = new Set();
|
||||||
|
chatHandlers.set(chatId, handlers);
|
||||||
|
}
|
||||||
|
handlers.add(handler);
|
||||||
|
return () => {
|
||||||
|
handlers?.delete(handler);
|
||||||
|
};
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
get status() {
|
get status() {
|
||||||
return status;
|
return status;
|
||||||
@@ -112,17 +128,7 @@ function makeClient() {
|
|||||||
canReconcileCanonicalCompletion,
|
canReconcileCanonicalCompletion,
|
||||||
reconcileCanonicalCompletion,
|
reconcileCanonicalCompletion,
|
||||||
getGoalState: (chatId: string) => goalStateByChatId.get(chatId),
|
getGoalState: (chatId: string) => goalStateByChatId.get(chatId),
|
||||||
onChat: (chatId: string, handler: (ev: import("@/lib/types").InboundEvent) => void) => {
|
onChat,
|
||||||
let handlers = chatHandlers.get(chatId);
|
|
||||||
if (!handlers) {
|
|
||||||
handlers = new Set();
|
|
||||||
chatHandlers.set(chatId, handlers);
|
|
||||||
}
|
|
||||||
handlers.add(handler);
|
|
||||||
return () => {
|
|
||||||
handlers?.delete(handler);
|
|
||||||
};
|
|
||||||
},
|
|
||||||
onError: (handler: (err: StreamError) => void) => {
|
onError: (handler: (err: StreamError) => void) => {
|
||||||
errorHandlers.add(handler);
|
errorHandlers.add(handler);
|
||||||
return () => {
|
return () => {
|
||||||
@@ -309,6 +315,8 @@ function modelSettings(model: string, provider: string): SettingsPayload {
|
|||||||
temperature: 0.7,
|
temperature: 0.7,
|
||||||
reasoning_effort: null,
|
reasoning_effort: null,
|
||||||
timezone: "UTC",
|
timezone: "UTC",
|
||||||
|
bot_name: "nanobot",
|
||||||
|
bot_icon: "",
|
||||||
tool_hint_max_length: 40,
|
tool_hint_max_length: 40,
|
||||||
},
|
},
|
||||||
model_presets: [{
|
model_presets: [{
|
||||||
@@ -3367,6 +3375,100 @@ describe("ThreadShell", () => {
|
|||||||
expect(screen.getByRole("option", { name: /\/history/i })).toBeInTheDocument();
|
expect(screen.getByRole("option", { name: /\/history/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("marks every temporary chat subscription as temporary", async () => {
|
||||||
|
const client = makeClient();
|
||||||
|
|
||||||
|
render(
|
||||||
|
wrap(
|
||||||
|
client,
|
||||||
|
<ThreadShell
|
||||||
|
session={session("temporary-test")}
|
||||||
|
title="Temporary Chat"
|
||||||
|
onToggleSidebar={() => {}}
|
||||||
|
temporary
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const temporaryCalls = client.onChat.mock.calls.filter(
|
||||||
|
([chatId]) => chatId === "temporary-test",
|
||||||
|
);
|
||||||
|
expect(temporaryCalls.length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(temporaryCalls.every(([, , options]) => (
|
||||||
|
options?.temporary === true
|
||||||
|
))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes session-management affordances from a fixed conversation", async () => {
|
||||||
|
const client = makeClient();
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url.endsWith("/api/commands")) {
|
||||||
|
return httpJson({
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
command: "/new",
|
||||||
|
title: "New chat",
|
||||||
|
description: "Reset this chat and start a fresh conversation.",
|
||||||
|
icon: "square-pen",
|
||||||
|
lifecycle: "finalize_active_turn",
|
||||||
|
accepts_args: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
command: "/history",
|
||||||
|
title: "Show conversation history",
|
||||||
|
description: "Print the last N persisted messages.",
|
||||||
|
icon: "history",
|
||||||
|
arg_hint: "[n]",
|
||||||
|
lifecycle: "side_channel",
|
||||||
|
accepts_args: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
json: async () => ({}),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(
|
||||||
|
wrap(
|
||||||
|
client,
|
||||||
|
<ThreadShell
|
||||||
|
session={session("quick-chat")}
|
||||||
|
title="Quick Chat"
|
||||||
|
onToggleSidebar={() => {}}
|
||||||
|
allowConversationReset={false}
|
||||||
|
showSessionInfo={false}
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(fetch).toHaveBeenCalledWith(
|
||||||
|
"/api/commands",
|
||||||
|
expect.objectContaining({
|
||||||
|
headers: { Authorization: "Bearer tok" },
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("Message input"), {
|
||||||
|
target: { value: "/" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByRole("option", { name: /\/history/i })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("option", { name: /\/new/i })).not.toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: "Session details" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("does not bring back welcome cards when image mode is enabled", async () => {
|
it("does not bring back welcome cards when image mode is enabled", async () => {
|
||||||
const client = makeClient();
|
const client = makeClient();
|
||||||
const settings = modelSettings("deepseek-v4-pro", "deepseek");
|
const settings = modelSettings("deepseek-v4-pro", "deepseek");
|
||||||
|
|||||||
@@ -1116,36 +1116,6 @@ describe("ThreadViewport", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("restores thread scroll after the textarea autosize measurement collapses it", () => {
|
|
||||||
let scroller: HTMLElement | null = null;
|
|
||||||
const { container } = render(
|
|
||||||
<ThreadViewport
|
|
||||||
messages={messages}
|
|
||||||
isStreaming={false}
|
|
||||||
composer={(
|
|
||||||
<textarea
|
|
||||||
aria-label="Message input"
|
|
||||||
onInput={() => {
|
|
||||||
if (scroller) scroller.scrollTop = 692;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
scroller = getScroller(container);
|
|
||||||
Object.defineProperty(scroller, "scrollTop", {
|
|
||||||
configurable: true,
|
|
||||||
writable: true,
|
|
||||||
value: 700,
|
|
||||||
});
|
|
||||||
|
|
||||||
fireEvent.input(screen.getByLabelText("Message input"), {
|
|
||||||
target: { value: "中文" },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(scroller.scrollTop).toBe(700);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the thread scrollport above a mobile soft keyboard", async () => {
|
it("keeps the thread scrollport above a mobile soft keyboard", async () => {
|
||||||
const visualViewport = stubVisualViewport({ innerHeight: 800, height: 480 });
|
const visualViewport = stubVisualViewport({ innerHeight: 800, height: 480 });
|
||||||
try {
|
try {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user