mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-22 13:34:24 +00:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e5207d9131 | |||
| 4538fca901 | |||
| b02c9517f6 | |||
| 511b5a7bf4 | |||
| 65fbf4db59 | |||
| 3d5b70cc5d | |||
| 83623f6afe | |||
| a020ca680f | |||
| 3a2edf9753 | |||
| 5fe63ce822 | |||
| 6b15a732e4 | |||
| a1e6eccdeb | |||
| b3c6de2072 | |||
| 4e45886647 | |||
| f592656680 | |||
| e9db858dcc | |||
| 2d6faf9d5e | |||
| d4699547e9 | |||
| 716d7aca94 | |||
| b3c00fe6da | |||
| f4a6edf7ec | |||
| f390980d0a |
+1
-1
@@ -70,7 +70,7 @@ dependencies = [
|
||||
"chromadb>=1.0.0,<2.0.0",
|
||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||
"pyseekdb==1.1.0.post3",
|
||||
"langbot-plugin==0.4.4",
|
||||
"langbot-plugin==0.4.5",
|
||||
"asyncpg>=0.30.0",
|
||||
"line-bot-sdk>=3.19.0",
|
||||
"matrix-nio>=0.25.2",
|
||||
|
||||
@@ -105,6 +105,7 @@ class BoxService:
|
||||
f'LangBot Box runtime initialized: profile={self.profile.name} '
|
||||
f'default_workspace={self.default_workspace or "(none)"}'
|
||||
)
|
||||
await self._purge_attachment_dirs()
|
||||
except Exception as exc:
|
||||
self.ap.logger.warning(f'LangBot Box runtime unavailable, sandbox features disabled: {exc}')
|
||||
self._available = False
|
||||
@@ -335,6 +336,507 @@ class BoxService:
|
||||
|
||||
return await self.execute_spec_payload(spec_payload, query)
|
||||
|
||||
# ── Attachment passthrough (inbound / outbound) ──────────────────
|
||||
#
|
||||
# IM/webchat attachments (images, voices, files) reach the LLM as
|
||||
# multimodal content, but historically never landed on the sandbox
|
||||
# filesystem, so the agent's exec/read/write tools could not operate on
|
||||
# them. Conversely, files the agent produced inside the sandbox were
|
||||
# never surfaced back to the user. These two helpers close both gaps:
|
||||
#
|
||||
# inbound : message_chain attachments -> /workspace/inbox/<query_id>/
|
||||
# outbound : /workspace/outbox/<query_id>/ -> reply MessageChain
|
||||
#
|
||||
# Transfer prefers DIRECT HOST FILESYSTEM access to the bind-mounted
|
||||
# workspace (default_workspace on the host maps to /workspace inside the
|
||||
# container), which has no size limit. This covers the local docker /
|
||||
# nsjail / stdio backends. For backends where the workspace is NOT visible
|
||||
# on the LangBot host (E2B, an external remote runtime.endpoint), it falls
|
||||
# back to a base64-through-exec round-trip. The exec channel can only move
|
||||
# small files reliably — the docker backend passes the command as a single
|
||||
# argv (ARG_MAX) and exec stdout is truncated by output_limit_chars — so
|
||||
# the host path is strongly preferred and used whenever available.
|
||||
|
||||
INBOX_MOUNT_DIR = '/workspace/inbox'
|
||||
OUTBOX_MOUNT_DIR = '/workspace/outbox'
|
||||
INBOX_SUBDIR = 'inbox'
|
||||
OUTBOX_SUBDIR = 'outbox'
|
||||
# Hard cap on a single attachment. The HTTP upload endpoints already cap
|
||||
# uploads at 10MiB; keep parity.
|
||||
_ATTACHMENT_MAX_BYTES = 10 * _MIB
|
||||
# Conservative cap for the exec FALLBACK path only (ARG_MAX / stdout
|
||||
# truncation). The host-filesystem path has no such limit.
|
||||
_EXEC_FALLBACK_MAX_BYTES = 256 * 1024
|
||||
|
||||
def _host_query_dir(self, subdir: str, query_id) -> str | None:
|
||||
"""Host path for ``/workspace/<subdir>/<query_id>`` when LangBot can
|
||||
access the bind-mounted workspace directly, else ``None``.
|
||||
|
||||
``default_workspace`` is the host directory bind-mounted to
|
||||
``/workspace`` for the local docker/nsjail backends and shared
|
||||
outright in stdio mode, so a file written there by LangBot is visible
|
||||
to the sandbox (and vice-versa). It is ``None`` / not a local dir for
|
||||
E2B and remote runtimes, where we must fall back to the exec channel.
|
||||
"""
|
||||
root = self.default_workspace
|
||||
if not root or not os.path.isdir(root):
|
||||
return None
|
||||
return os.path.join(root, subdir, str(query_id))
|
||||
|
||||
async def _purge_attachment_dirs(self) -> None:
|
||||
"""Remove leftover inbox/outbox directories on startup.
|
||||
|
||||
``query_id`` is a process-local counter (see pipeline query pool) that
|
||||
resets to 0 on every restart, so per-query attachment directories from
|
||||
a previous process would otherwise be silently reused — leaking a prior
|
||||
run's inbound files and re-sending stale outbound files.
|
||||
|
||||
Outbox files are written by the sandbox **container**, which runs as
|
||||
root over the bind-mount, so the LangBot host process (a non-root user)
|
||||
cannot ``rmtree`` them. We therefore try a host-side delete first (fast,
|
||||
works for host-owned inbox files) and, for anything that survives,
|
||||
delete from *inside* the sandbox via exec where the container's root can
|
||||
remove its own files. Best-effort: never block startup.
|
||||
"""
|
||||
root = self.default_workspace
|
||||
if not root or not os.path.isdir(root):
|
||||
return
|
||||
|
||||
import shutil
|
||||
|
||||
host_survivors: list[str] = []
|
||||
|
||||
def _host_purge() -> list[str]:
|
||||
survivors: list[str] = []
|
||||
for subdir in (self.INBOX_SUBDIR, self.OUTBOX_SUBDIR):
|
||||
path = os.path.join(root, subdir)
|
||||
if not os.path.isdir(path):
|
||||
continue
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
if os.path.exists(path):
|
||||
survivors.append(subdir)
|
||||
return survivors
|
||||
|
||||
try:
|
||||
host_survivors = await asyncio.to_thread(_host_purge)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
self.ap.logger.warning(f'Host-side purge of sandbox attachment dirs failed: {exc}')
|
||||
host_survivors = [self.INBOX_SUBDIR, self.OUTBOX_SUBDIR]
|
||||
|
||||
if not host_survivors:
|
||||
self.ap.logger.info('Purged leftover sandbox attachment dirs from a previous process.')
|
||||
return
|
||||
|
||||
# Root-owned leftovers (container output): delete from inside the box.
|
||||
targets = ' '.join(f'/workspace/{sub}' for sub in host_survivors)
|
||||
try:
|
||||
spec = self.build_spec({'cmd': f'rm -rf {targets}', 'session_id': '__startup_purge__', 'timeout_sec': 30})
|
||||
await self.client.execute(spec)
|
||||
self.ap.logger.info(
|
||||
f'Purged root-owned leftover sandbox attachment dirs via sandbox exec: {host_survivors}'
|
||||
)
|
||||
except Exception as exc:
|
||||
self.ap.logger.warning(
|
||||
f'Failed to purge root-owned sandbox attachment dirs {host_survivors} via exec: {exc}'
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_attachment_name(name: str, fallback: str) -> str:
|
||||
"""Reduce an arbitrary attachment name to a safe basename.
|
||||
|
||||
Strips directory separators and parent refs so a crafted file name
|
||||
can never escape the inbox/outbox directory.
|
||||
"""
|
||||
base = os.path.basename(str(name or '').replace('\\', '/').strip())
|
||||
base = base.lstrip('.') or ''
|
||||
# Drop anything that is not a conservative filename charset.
|
||||
cleaned = ''.join(c for c in base if c.isalnum() or c in ('.', '_', '-', ' ')).strip()
|
||||
cleaned = cleaned.replace(' ', '_')
|
||||
return cleaned or fallback
|
||||
|
||||
@staticmethod
|
||||
async def _component_to_bytes(component) -> tuple[bytes, str] | None:
|
||||
"""Best-effort extraction of (bytes, mime) from a platform component.
|
||||
|
||||
Handles base64, http(s) url and local path sources. Returns None when
|
||||
no payload can be resolved.
|
||||
"""
|
||||
import base64 as _b64
|
||||
|
||||
b64 = getattr(component, 'base64', None)
|
||||
if b64:
|
||||
data = b64
|
||||
mime = 'application/octet-stream'
|
||||
if isinstance(data, str) and data.startswith('data:'):
|
||||
split_index = data.find(';base64,')
|
||||
if split_index != -1:
|
||||
mime = data[5:split_index]
|
||||
data = data[split_index + 8 :]
|
||||
try:
|
||||
return _b64.b64decode(data), mime
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
url = getattr(component, 'url', None)
|
||||
if url:
|
||||
try:
|
||||
import httpx
|
||||
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
resp = await client.get(url)
|
||||
resp.raise_for_status()
|
||||
return resp.content, resp.headers.get('Content-Type', 'application/octet-stream')
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
path = getattr(component, 'path', None)
|
||||
if path:
|
||||
try:
|
||||
import aiofiles
|
||||
|
||||
async with aiofiles.open(path, 'rb') as f:
|
||||
return await f.read(), 'application/octet-stream'
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
async def _write_files_into_sandbox(
|
||||
self,
|
||||
query: pipeline_query.Query,
|
||||
subdir: str,
|
||||
target_mount_dir: str,
|
||||
files: list[tuple[str, bytes]],
|
||||
) -> list[str]:
|
||||
"""Write *files* (name, bytes) into the per-query directory.
|
||||
|
||||
Prefers a direct host-filesystem write to the bind-mounted workspace
|
||||
(no size limit). Falls back to a base64-through-exec round-trip only
|
||||
when the workspace is not visible on the LangBot host (E2B / remote).
|
||||
Returns the list of in-sandbox paths actually written.
|
||||
"""
|
||||
if not files:
|
||||
return []
|
||||
|
||||
host_dir = self._host_query_dir(subdir, query.query_id)
|
||||
if host_dir is not None:
|
||||
return await asyncio.to_thread(self._write_files_host, host_dir, target_mount_dir, files)
|
||||
|
||||
return await self._write_files_via_exec(query, target_mount_dir, files)
|
||||
|
||||
def _write_files_host(
|
||||
self,
|
||||
host_dir: str,
|
||||
target_mount_dir: str,
|
||||
files: list[tuple[str, bytes]],
|
||||
) -> list[str]:
|
||||
"""Write attachments straight onto the bind-mounted host directory.
|
||||
|
||||
Recreates the per-query directory from scratch so a reused query_id
|
||||
(the webchat session uses small sequential ids) never inherits stale
|
||||
files from an earlier turn.
|
||||
"""
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(host_dir, ignore_errors=True)
|
||||
os.makedirs(host_dir, exist_ok=True)
|
||||
written: list[str] = []
|
||||
for name, data in files:
|
||||
with open(os.path.join(host_dir, name), 'wb') as fh:
|
||||
fh.write(data)
|
||||
written.append(f'{target_mount_dir}/{name}')
|
||||
return written
|
||||
|
||||
async def _write_files_via_exec(
|
||||
self,
|
||||
query: pipeline_query.Query,
|
||||
target_dir: str,
|
||||
files: list[tuple[str, bytes]],
|
||||
) -> list[str]:
|
||||
"""Fallback: ship files into the sandbox over the exec channel.
|
||||
|
||||
Only used for backends without host-filesystem access (E2B / remote).
|
||||
Each file is base64-decoded inside the sandbox. Files larger than the
|
||||
conservative exec cap are skipped (ARG_MAX / stdout limits).
|
||||
"""
|
||||
import base64 as _b64
|
||||
import json as _json
|
||||
|
||||
manifest = []
|
||||
for name, data in files:
|
||||
if len(data) > self._EXEC_FALLBACK_MAX_BYTES:
|
||||
self.ap.logger.warning(
|
||||
f'Attachment "{name}" ({len(data)} bytes) exceeds the exec-channel '
|
||||
f'fallback limit ({self._EXEC_FALLBACK_MAX_BYTES} bytes); skipping. '
|
||||
f'Configure a host-shared workspace to transfer large files.'
|
||||
)
|
||||
continue
|
||||
manifest.append({'name': name, 'b64': _b64.b64encode(data).decode('ascii')})
|
||||
if not manifest:
|
||||
return []
|
||||
|
||||
manifest_b64 = _b64.b64encode(_json.dumps(manifest).encode('utf-8')).decode('ascii')
|
||||
script = (
|
||||
'import base64, json, os, shutil\n'
|
||||
f'target = {target_dir!r}\n'
|
||||
'shutil.rmtree(target, ignore_errors=True)\n'
|
||||
'os.makedirs(target, exist_ok=True)\n'
|
||||
f'manifest = json.loads(base64.b64decode({manifest_b64!r}))\n'
|
||||
'written = []\n'
|
||||
'for item in manifest:\n'
|
||||
" p = os.path.join(target, item['name'])\n"
|
||||
" with open(p, 'wb') as f:\n"
|
||||
" f.write(base64.b64decode(item['b64']))\n"
|
||||
' written.append(p)\n'
|
||||
'print(json.dumps(written))\n'
|
||||
)
|
||||
result = await self.execute_tool(
|
||||
{'command': f"python3 - <<'LBPY'\n{script}\nLBPY", 'timeout_sec': 120},
|
||||
query,
|
||||
)
|
||||
if not result.get('ok'):
|
||||
self.ap.logger.warning(
|
||||
f'Failed to write inbound attachments into sandbox via exec: '
|
||||
f'query_id={query.query_id} stderr={result.get("stderr", "")[:200]}'
|
||||
)
|
||||
return []
|
||||
try:
|
||||
return _json.loads(str(result.get('stdout') or '').strip().splitlines()[-1])
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
async def materialize_inbound_attachments(self, query: pipeline_query.Query) -> list[dict]:
|
||||
"""Persist message-chain attachments into the sandbox inbox.
|
||||
|
||||
Returns a list of ``{path, name, type, size}`` describing what was
|
||||
written, so the runner can tell the LLM the exact in-sandbox paths.
|
||||
Returns ``[]`` when sandbox is unavailable or there are no attachments.
|
||||
"""
|
||||
if not self._available:
|
||||
return []
|
||||
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
|
||||
message_chain = getattr(query, 'message_chain', None)
|
||||
if not message_chain:
|
||||
return []
|
||||
|
||||
type_map = [
|
||||
(platform_message.Image, 'Image', 'image', 'png'),
|
||||
(platform_message.Voice, 'Voice', 'voice', 'wav'),
|
||||
(platform_message.File, 'File', 'file', 'bin'),
|
||||
]
|
||||
|
||||
pending: list[tuple[str, bytes]] = []
|
||||
descriptors: list[dict] = []
|
||||
index = 0
|
||||
for component in message_chain:
|
||||
matched = None
|
||||
for cls, kind, prefix, default_ext in type_map:
|
||||
if isinstance(component, cls):
|
||||
matched = (kind, prefix, default_ext)
|
||||
break
|
||||
if matched is None:
|
||||
continue
|
||||
kind, prefix, default_ext = matched
|
||||
|
||||
payload = await self._component_to_bytes(component)
|
||||
if payload is None:
|
||||
continue
|
||||
data, _mime = payload
|
||||
if not data or len(data) > self._ATTACHMENT_MAX_BYTES:
|
||||
continue
|
||||
|
||||
index += 1
|
||||
raw_name = getattr(component, 'name', None) or f'{prefix}_{index}.{default_ext}'
|
||||
safe_name = self._sanitize_attachment_name(raw_name, f'{prefix}_{index}.{default_ext}')
|
||||
pending.append((safe_name, data))
|
||||
descriptors.append(
|
||||
{
|
||||
'name': safe_name,
|
||||
'type': kind,
|
||||
'size': len(data),
|
||||
}
|
||||
)
|
||||
|
||||
if not pending:
|
||||
return []
|
||||
|
||||
target_dir = f'{self.INBOX_MOUNT_DIR}/{query.query_id}'
|
||||
written = await self._write_files_into_sandbox(query, self.INBOX_SUBDIR, target_dir, pending)
|
||||
written_basenames = {os.path.basename(p) for p in written}
|
||||
|
||||
result: list[dict] = []
|
||||
for desc in descriptors:
|
||||
if desc['name'] in written_basenames:
|
||||
desc['path'] = f'{target_dir}/{desc["name"]}'
|
||||
result.append(desc)
|
||||
if result:
|
||||
self.ap.logger.info(
|
||||
f'Materialized {len(result)} inbound attachment(s) into sandbox: '
|
||||
f'query_id={query.query_id} dir={target_dir}'
|
||||
)
|
||||
return result
|
||||
|
||||
async def collect_outbound_attachments(self, query: pipeline_query.Query) -> list[dict]:
|
||||
"""Collect files the agent produced in the sandbox outbox.
|
||||
|
||||
Reads ``/workspace/outbox/<query_id>/`` (recursively) — directly from
|
||||
the bind-mounted host directory when available (no size limit), else
|
||||
via the exec channel — returns a list of ``{type, name, base64}``
|
||||
ready to become platform message components, then clears the outbox so
|
||||
a later turn in the same session does not re-send stale files. Returns
|
||||
``[]`` when nothing was produced.
|
||||
"""
|
||||
if not self._available:
|
||||
return []
|
||||
|
||||
host_dir = self._host_query_dir(self.OUTBOX_SUBDIR, query.query_id)
|
||||
if host_dir is not None:
|
||||
entries = await asyncio.to_thread(self._read_outbox_host, host_dir)
|
||||
else:
|
||||
entries = await self._read_outbox_via_exec(query)
|
||||
|
||||
attachments = self._classify_outbound_entries(entries)
|
||||
|
||||
# Always clear the per-query outbox after reading — even when nothing
|
||||
# was collected — so a later turn that reuses the same query_id (the
|
||||
# counter resets across restarts) never inherits stale files.
|
||||
await self._clear_outbox(query, host_dir)
|
||||
if attachments:
|
||||
self.ap.logger.info(
|
||||
f'Collected {len(attachments)} outbound attachment(s) from sandbox: query_id={query.query_id}'
|
||||
)
|
||||
return attachments
|
||||
|
||||
def _read_outbox_host(self, host_dir: str) -> list[dict]:
|
||||
"""Read outbox files straight off the bind-mounted host directory."""
|
||||
import base64 as _b64
|
||||
|
||||
entries: list[dict] = []
|
||||
if not os.path.isdir(host_dir):
|
||||
return entries
|
||||
for root, _dirs, names in os.walk(host_dir):
|
||||
for name in sorted(names):
|
||||
path = os.path.join(root, name)
|
||||
try:
|
||||
if os.path.getsize(path) > self._ATTACHMENT_MAX_BYTES:
|
||||
continue
|
||||
with open(path, 'rb') as fh:
|
||||
data = fh.read()
|
||||
except OSError:
|
||||
continue
|
||||
rel = os.path.relpath(path, host_dir)
|
||||
entries.append({'name': rel, 'b64': _b64.b64encode(data).decode('ascii')})
|
||||
return entries
|
||||
|
||||
async def _read_outbox_via_exec(self, query: pipeline_query.Query) -> list[dict]:
|
||||
"""Fallback: read the outbox over the exec channel (E2B / remote).
|
||||
|
||||
Note: exec stdout is truncated by ``output_limit_chars``, so this path
|
||||
only reliably transfers small files. The host path is preferred.
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
target_dir = f'{self.OUTBOX_MOUNT_DIR}/{query.query_id}'
|
||||
max_bytes = self._EXEC_FALLBACK_MAX_BYTES
|
||||
script = (
|
||||
'import base64, json, os\n'
|
||||
f'target = {target_dir!r}\n'
|
||||
f'max_bytes = {max_bytes}\n'
|
||||
'out = []\n'
|
||||
'if os.path.isdir(target):\n'
|
||||
' for root, _dirs, names in os.walk(target):\n'
|
||||
' for n in sorted(names):\n'
|
||||
' p = os.path.join(root, n)\n'
|
||||
' try:\n'
|
||||
' if os.path.getsize(p) > max_bytes:\n'
|
||||
' continue\n'
|
||||
" with open(p, 'rb') as f:\n"
|
||||
' data = f.read()\n'
|
||||
' except OSError:\n'
|
||||
' continue\n'
|
||||
' rel = os.path.relpath(p, target)\n'
|
||||
" out.append({'name': rel, 'b64': base64.b64encode(data).decode('ascii')})\n"
|
||||
'print(json.dumps(out))\n'
|
||||
)
|
||||
result = await self.execute_tool(
|
||||
{'command': f"python3 - <<'LBPY'\n{script}\nLBPY", 'timeout_sec': 120},
|
||||
query,
|
||||
)
|
||||
if not result.get('ok'):
|
||||
return []
|
||||
try:
|
||||
return _json.loads(str(result.get('stdout') or '').strip().splitlines()[-1])
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
async def _clear_outbox(self, query: pipeline_query.Query, host_dir: str | None) -> None:
|
||||
"""Empty the per-query outbox after collection.
|
||||
|
||||
Tries a host-side ``rmtree`` first (fast, no container round-trip).
|
||||
Outbox files are created by the sandbox container as root over the
|
||||
bind-mount, so when LangBot runs as a non-root user the host delete
|
||||
fails silently and the files survive — they would then be re-collected
|
||||
on the next turn that reuses the same query_id. So if anything survives
|
||||
the host delete, clear it from *inside* the sandbox via exec, where the
|
||||
container's root can remove its own files. Best-effort: never raise
|
||||
into the pipeline.
|
||||
"""
|
||||
target_dir = f'{self.OUTBOX_MOUNT_DIR}/{query.query_id}'
|
||||
|
||||
if host_dir is not None:
|
||||
import shutil
|
||||
|
||||
def _clear() -> bool:
|
||||
shutil.rmtree(host_dir, ignore_errors=True)
|
||||
survived = os.path.exists(host_dir) and bool(os.listdir(host_dir))
|
||||
os.makedirs(host_dir, exist_ok=True)
|
||||
return survived
|
||||
|
||||
survived = await asyncio.to_thread(_clear)
|
||||
if not survived:
|
||||
return
|
||||
# Root-owned container files survived the host delete — fall through.
|
||||
|
||||
try:
|
||||
await self.execute_tool(
|
||||
{'command': f'rm -rf {target_dir} && mkdir -p {target_dir}', 'timeout_sec': 30},
|
||||
query,
|
||||
)
|
||||
except Exception as exc:
|
||||
self.ap.logger.warning(f'Failed to clear sandbox outbox {target_dir}: {exc}')
|
||||
|
||||
@staticmethod
|
||||
def _classify_outbound_entries(entries: list[dict]) -> list[dict]:
|
||||
"""Classify outbox files into Image/Voice/File component descriptors."""
|
||||
image_exts = {'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'}
|
||||
voice_exts = {'wav', 'mp3', 'silk', 'amr', 'ogg', 'm4a', 'aac'}
|
||||
mime_by_ext = {
|
||||
'png': 'image/png',
|
||||
'jpg': 'image/jpeg',
|
||||
'jpeg': 'image/jpeg',
|
||||
'gif': 'image/gif',
|
||||
'webp': 'image/webp',
|
||||
'bmp': 'image/bmp',
|
||||
}
|
||||
attachments: list[dict] = []
|
||||
for entry in entries or []:
|
||||
name = str(entry.get('name', '') or '')
|
||||
b64 = entry.get('b64')
|
||||
if not name or not b64:
|
||||
continue
|
||||
ext = name.rsplit('.', 1)[-1].lower() if '.' in name else ''
|
||||
base_name = os.path.basename(name)
|
||||
if ext in image_exts:
|
||||
mime = mime_by_ext.get(ext, 'image/png')
|
||||
attachments.append({'type': 'Image', 'name': base_name, 'base64': f'data:{mime};base64,{b64}'})
|
||||
elif ext in voice_exts:
|
||||
attachments.append({'type': 'Voice', 'name': base_name, 'base64': f'data:audio/{ext};base64,{b64}'})
|
||||
else:
|
||||
attachments.append({'type': 'File', 'name': base_name, 'base64': b64})
|
||||
return attachments
|
||||
|
||||
async def shutdown(self):
|
||||
await self.client.shutdown()
|
||||
|
||||
@@ -800,11 +1302,19 @@ class BoxService:
|
||||
def get_recent_errors(self) -> list[dict]:
|
||||
return list(self._recent_errors)
|
||||
|
||||
def get_system_guidance(self) -> str:
|
||||
def get_system_guidance(self, query_id=None) -> str:
|
||||
"""Return LLM system-prompt guidance for the exec tool.
|
||||
|
||||
All execution-specific prompt text is kept here so that callers
|
||||
(e.g. LocalAgentRunner) stay free of box domain knowledge.
|
||||
|
||||
``query_id`` is the current turn's pipeline query id. When provided,
|
||||
the guidance ALWAYS advertises the per-query outbox path so the agent
|
||||
knows how to deliver generated files back to the user — even on turns
|
||||
where the user sent no inbound attachment (e.g. "generate a QR code"),
|
||||
which is exactly when the inbound-attachment note never fires. Outbound
|
||||
collection in the wrapper runs on every turn regardless of inbound
|
||||
files, so without this the file would be produced and silently dropped.
|
||||
"""
|
||||
guidance = (
|
||||
'When the exec tool is available, use it for exact calculations, statistics, structured data parsing, '
|
||||
@@ -819,6 +1329,13 @@ class BoxService:
|
||||
'modify local files in the working directory, use exec with /workspace paths directly; do not ask the '
|
||||
'user for directory parameters unless they explicitly need a different directory.'
|
||||
)
|
||||
if query_id is not None:
|
||||
outbox_dir = f'{self.OUTBOX_MOUNT_DIR}/{query_id}'
|
||||
guidance += (
|
||||
f' If you produce any file (image, audio, document, etc.) that should be sent back to the user, '
|
||||
f'write it into {outbox_dir}/ (create the directory if needed). Every file placed there will be '
|
||||
'delivered to the user automatically; do not paste file contents or base64 into your reply.'
|
||||
)
|
||||
return guidance
|
||||
|
||||
async def get_status(self) -> dict:
|
||||
|
||||
@@ -146,13 +146,19 @@ def wrap_python_command_with_env(command: str, *, mount_path: str = '/workspace'
|
||||
_LB_PIP_CACHE_DIR="{mount_path}/.cache/pip"
|
||||
|
||||
mkdir -p "$_LB_META_DIR" "$_LB_TMP_DIR" "$_LB_PIP_CACHE_DIR"
|
||||
_LB_SYSTEM_PYTHON="$(command -v python3 || command -v python || true)"
|
||||
if [ -z "$_LB_SYSTEM_PYTHON" ]; then
|
||||
echo "python3 or python is required to prepare the workspace Python environment" >&2
|
||||
exit 127
|
||||
fi
|
||||
|
||||
export TMPDIR="$_LB_TMP_DIR"
|
||||
export TEMP="$_LB_TMP_DIR"
|
||||
export TMP="$_LB_TMP_DIR"
|
||||
export PIP_CACHE_DIR="$_LB_PIP_CACHE_DIR"
|
||||
|
||||
_lb_python_meta() {{
|
||||
python - <<'PY'
|
||||
"$_LB_SYSTEM_PYTHON" - <<'PY'
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
@@ -201,15 +207,26 @@ def wrap_python_command_with_env(command: str, *, mount_path: str = '/workspace'
|
||||
_LB_LOCK_WAIT=0
|
||||
while ! mkdir "$_LB_LOCK_DIR" 2>/dev/null; do
|
||||
if [ "$_LB_LOCK_WAIT" -ge 120 ]; then
|
||||
_LB_LOCK_OWNER="$(cat "$_LB_LOCK_DIR/pid" 2>/dev/null || true)"
|
||||
if [ -n "$_LB_LOCK_OWNER" ] && kill -0 "$_LB_LOCK_OWNER" 2>/dev/null; then
|
||||
echo "Timed out waiting for active Python environment lock: $_LB_LOCK_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Timed out waiting for Python environment lock, clearing stale lock: $_LB_LOCK_DIR" >&2
|
||||
rm -rf "$_LB_LOCK_DIR" 2>/dev/null || true
|
||||
if mkdir "$_LB_LOCK_DIR" 2>/dev/null; then
|
||||
break
|
||||
fi
|
||||
echo "Timed out waiting for Python environment lock: $_LB_LOCK_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
_LB_LOCK_WAIT=$((_LB_LOCK_WAIT + 1))
|
||||
done
|
||||
printf '%s\\n' "$$" > "$_LB_LOCK_DIR/pid" 2>/dev/null || true
|
||||
|
||||
_lb_cleanup_lock() {{
|
||||
rmdir "$_LB_LOCK_DIR" >/dev/null 2>&1 || true
|
||||
rm -rf "$_LB_LOCK_DIR" >/dev/null 2>&1 || true
|
||||
}}
|
||||
trap _lb_cleanup_lock EXIT INT TERM
|
||||
|
||||
@@ -225,7 +242,7 @@ def wrap_python_command_with_env(command: str, *, mount_path: str = '/workspace'
|
||||
|
||||
if [ "$_LB_NEEDS_BOOTSTRAP" -eq 1 ]; then
|
||||
rm -rf "$_LB_VENV_DIR"
|
||||
python -m venv "$_LB_VENV_DIR"
|
||||
"$_LB_SYSTEM_PYTHON" -m venv "$_LB_VENV_DIR"
|
||||
. "$_LB_VENV_DIR/bin/activate"
|
||||
python -m pip install --upgrade pip setuptools wheel
|
||||
if [ -f "{mount_path}/requirements.txt" ]; then
|
||||
|
||||
@@ -7,6 +7,7 @@ from .. import stage
|
||||
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
import langbot_plugin.api.entities.events as events
|
||||
|
||||
|
||||
@@ -23,6 +24,50 @@ class ResponseWrapper(stage.PipelineStage):
|
||||
async def initialize(self, pipeline_config: dict):
|
||||
pass
|
||||
|
||||
def _is_final_assistant_message(self, result) -> bool:
|
||||
"""Whether *result* is the agent's final, tool-call-free answer.
|
||||
|
||||
Intermediate streaming chunks and tool-call rounds must NOT trigger
|
||||
outbound attachment collection — only the terminal assistant message.
|
||||
"""
|
||||
if getattr(result, 'role', None) != 'assistant':
|
||||
return False
|
||||
if result.tool_calls:
|
||||
return False
|
||||
if isinstance(result, provider_message.MessageChunk):
|
||||
return bool(result.is_final)
|
||||
return True
|
||||
|
||||
async def _append_outbound_attachments(
|
||||
self,
|
||||
query: pipeline_query.Query,
|
||||
message_chain: platform_message.MessageChain,
|
||||
) -> None:
|
||||
"""Collect sandbox outbox files and append them to *message_chain*.
|
||||
|
||||
Runs at most once per query (guarded by a query variable) and never
|
||||
raises into the pipeline — attachment delivery is best-effort.
|
||||
"""
|
||||
if query.variables.get('_sandbox_outbound_collected'):
|
||||
return
|
||||
box_service = getattr(self.ap, 'box_service', None)
|
||||
if box_service is None or not getattr(box_service, 'available', False):
|
||||
return
|
||||
query.variables['_sandbox_outbound_collected'] = True
|
||||
try:
|
||||
attachments = await box_service.collect_outbound_attachments(query)
|
||||
except Exception as e:
|
||||
self.ap.logger.warning(f'Outbound attachment collection failed: {e}')
|
||||
return
|
||||
for att in attachments:
|
||||
att_type = att.get('type')
|
||||
if att_type == 'Image':
|
||||
message_chain.append(platform_message.Image(base64=att['base64']))
|
||||
elif att_type == 'Voice':
|
||||
message_chain.append(platform_message.Voice(base64=att['base64']))
|
||||
else:
|
||||
message_chain.append(platform_message.File(name=att.get('name', 'file'), base64=att['base64']))
|
||||
|
||||
async def process(
|
||||
self,
|
||||
query: pipeline_query.Query,
|
||||
@@ -83,10 +128,16 @@ class ResponseWrapper(stage.PipelineStage):
|
||||
)
|
||||
else:
|
||||
if event_ctx.event.reply_message_chain is not None:
|
||||
query.resp_message_chain.append(event_ctx.event.reply_message_chain)
|
||||
|
||||
reply_chain = event_ctx.event.reply_message_chain
|
||||
else:
|
||||
query.resp_message_chain.append(result.get_content_platform_message_chain())
|
||||
reply_chain = result.get_content_platform_message_chain()
|
||||
|
||||
# Attach files the agent produced in the sandbox
|
||||
# outbox, but only on the terminal assistant message.
|
||||
if self._is_final_assistant_message(result):
|
||||
await self._append_outbound_attachments(query, reply_chain)
|
||||
|
||||
query.resp_message_chain.append(reply_chain)
|
||||
|
||||
yield entities.StageProcessResult(
|
||||
result_type=entities.ResultType.CONTINUE,
|
||||
|
||||
@@ -312,12 +312,18 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
|
||||
async def _process_image_components(self, message_chain_obj: list):
|
||||
"""
|
||||
处理消息链中的图片和文件组件,将path转换为base64
|
||||
处理消息链中的图片、语音和文件组件,将 path 转换为 base64
|
||||
|
||||
Image / Voice / File components uploaded from the web client carry a
|
||||
storage key in ``path``. Resolve it to a base64 data URI so downstream
|
||||
stages (multimodal LLM input and the Box sandbox inbox) have a usable
|
||||
payload, then drop the now-consumed storage object.
|
||||
|
||||
Args:
|
||||
message_chain_obj: 消息链对象列表
|
||||
"""
|
||||
import base64
|
||||
import mimetypes
|
||||
|
||||
storage_mgr = self.ap.storage_mgr
|
||||
|
||||
@@ -325,31 +331,33 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
comp_type = component.get('type', '')
|
||||
comp_path = component.get('path', '')
|
||||
|
||||
if not comp_path:
|
||||
if not comp_path or comp_type not in ('Image', 'Voice', 'File'):
|
||||
continue
|
||||
|
||||
if comp_type == 'Image':
|
||||
try:
|
||||
file_content = await storage_mgr.storage_provider.load(comp_path)
|
||||
base64_str = base64.b64encode(file_content).decode('utf-8')
|
||||
try:
|
||||
file_content = await storage_mgr.storage_provider.load(comp_path)
|
||||
base64_str = base64.b64encode(file_content).decode('utf-8')
|
||||
|
||||
file_key = comp_path
|
||||
if file_key.lower().endswith(('.jpg', '.jpeg')):
|
||||
lowered = comp_path.lower()
|
||||
if comp_type == 'Image':
|
||||
if lowered.endswith(('.jpg', '.jpeg')):
|
||||
mime_type = 'image/jpeg'
|
||||
elif file_key.lower().endswith('.png'):
|
||||
mime_type = 'image/png'
|
||||
elif file_key.lower().endswith('.gif'):
|
||||
elif lowered.endswith('.gif'):
|
||||
mime_type = 'image/gif'
|
||||
elif file_key.lower().endswith('.webp'):
|
||||
elif lowered.endswith('.webp'):
|
||||
mime_type = 'image/webp'
|
||||
else:
|
||||
mime_type = 'image/png'
|
||||
elif comp_type == 'Voice':
|
||||
mime_type = mimetypes.guess_type(comp_path)[0] or 'audio/wav'
|
||||
else: # File
|
||||
mime_type = mimetypes.guess_type(comp_path)[0] or 'application/octet-stream'
|
||||
|
||||
component['base64'] = f'data:{mime_type};base64,{base64_str}'
|
||||
await storage_mgr.storage_provider.delete(comp_path)
|
||||
component['path'] = ''
|
||||
except Exception as e:
|
||||
await self.logger.error(f'Failed to load image file {comp_path}: {e}')
|
||||
component['base64'] = f'data:{mime_type};base64,{base64_str}'
|
||||
await storage_mgr.storage_provider.delete(comp_path)
|
||||
component['path'] = ''
|
||||
except Exception as e:
|
||||
await self.logger.error(f'Failed to load {comp_type} file {comp_path}: {e}')
|
||||
|
||||
async def handle_websocket_message(
|
||||
self,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sqlalchemy
|
||||
import traceback
|
||||
|
||||
@@ -84,8 +85,17 @@ class ModelManager:
|
||||
self.ap.logger.info('LangBot Space Models service is disabled, skipping sync.')
|
||||
return
|
||||
|
||||
sync_timeout = space_config.get('models_sync_timeout')
|
||||
try:
|
||||
await self.sync_new_models_from_space()
|
||||
if sync_timeout:
|
||||
await asyncio.wait_for(
|
||||
self.sync_new_models_from_space(),
|
||||
timeout=float(sync_timeout),
|
||||
)
|
||||
else:
|
||||
await self.sync_new_models_from_space()
|
||||
except asyncio.TimeoutError:
|
||||
self.ap.logger.warning(f'LangBot Space model sync timed out after {sync_timeout}s, skipping startup sync.')
|
||||
except Exception as e:
|
||||
self.ap.logger.warning('Failed to sync new models from LangBot Space, model list may not be updated.')
|
||||
self.ap.logger.warning(f' - Error: {e}')
|
||||
|
||||
@@ -216,11 +216,22 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
||||
content = msg_dict.get('content')
|
||||
|
||||
if isinstance(content, list):
|
||||
converted_parts = []
|
||||
for part in content:
|
||||
if isinstance(part, dict) and part.get('type') == 'image_base64':
|
||||
part['image_url'] = {'url': part['image_base64']}
|
||||
part['type'] = 'image_url'
|
||||
del part['image_base64']
|
||||
# OpenAI-compatible chat models reject non-image file parts
|
||||
# (audio/document base64 or url). These originate from Voice /
|
||||
# File attachments — including ones replayed from conversation
|
||||
# history — and the agent already accesses their bytes via the
|
||||
# sandbox. Drop them from the model payload to avoid
|
||||
# "Invalid user message ... invalid content type=file_base64".
|
||||
if isinstance(part, dict) and part.get('type') in ('file_base64', 'file_url'):
|
||||
continue
|
||||
converted_parts.append(part)
|
||||
msg_dict['content'] = converted_parts
|
||||
|
||||
req_messages.append(msg_dict)
|
||||
|
||||
@@ -352,9 +363,13 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
||||
def _normalize_stream_tool_calls(
|
||||
self,
|
||||
raw_tool_calls: typing.Any,
|
||||
tool_call_state: dict[int, dict[str, str]],
|
||||
tool_call_state: dict[int, dict[str, typing.Any]],
|
||||
) -> list[dict] | None:
|
||||
"""Fill OpenAI-style streaming tool-call deltas so MessageChunk can validate them."""
|
||||
"""Fill OpenAI-style streaming tool-call deltas so MessageChunk can validate them.
|
||||
|
||||
Also preserves provider_specific_fields (e.g., Gemini thought_signature) for
|
||||
round-tripping to the next request.
|
||||
"""
|
||||
if not raw_tool_calls:
|
||||
return None
|
||||
|
||||
@@ -365,35 +380,72 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
||||
if not isinstance(index, int):
|
||||
index = fallback_index
|
||||
|
||||
state = tool_call_state.setdefault(index, {'id': '', 'type': 'function', 'name': ''})
|
||||
state = tool_call_state.setdefault(
|
||||
index,
|
||||
{
|
||||
'id': '',
|
||||
'type': 'function',
|
||||
'name': '',
|
||||
'provider_specific_fields': None,
|
||||
},
|
||||
)
|
||||
if tool_call.get('id'):
|
||||
state['id'] = tool_call['id']
|
||||
if tool_call.get('type'):
|
||||
state['type'] = tool_call['type']
|
||||
|
||||
# Preserve provider_specific_fields from the raw tool call
|
||||
if 'provider_specific_fields' in tool_call:
|
||||
state['provider_specific_fields'] = tool_call['provider_specific_fields']
|
||||
|
||||
function = self._as_dict(tool_call.get('function'))
|
||||
if function.get('name'):
|
||||
state['name'] = function['name']
|
||||
|
||||
# Also check function-level provider_specific_fields
|
||||
if 'provider_specific_fields' in function:
|
||||
# Merge function-level into tool-level, function-level takes precedence
|
||||
func_psf = function['provider_specific_fields']
|
||||
if state['provider_specific_fields']:
|
||||
merged = {**state['provider_specific_fields'], **func_psf}
|
||||
state['provider_specific_fields'] = merged
|
||||
else:
|
||||
state['provider_specific_fields'] = func_psf
|
||||
|
||||
arguments = function.get('arguments')
|
||||
if arguments is None:
|
||||
arguments = ''
|
||||
elif not isinstance(arguments, str):
|
||||
arguments = str(arguments)
|
||||
|
||||
# Some OpenAI-compatible providers (notably Ollama's
|
||||
# /v1/chat/completions) stream a tool-call delta with an `index` and
|
||||
# a `function` payload but never emit an OpenAI-style `id`. Without
|
||||
# an id the call used to be dropped here, so the whole tool call
|
||||
# silently vanished: a tool-only turn then yielded no content and no
|
||||
# tool call, the stream "completed" with 0 chars, and the chat
|
||||
# appeared stuck. Synthesize a stable per-index id so named-but-idless
|
||||
# tool calls survive. Providers that do send ids keep theirs.
|
||||
if not state['id'] and state['name']:
|
||||
state['id'] = f'call_{index}'
|
||||
|
||||
if not state['id'] or not state['name']:
|
||||
continue
|
||||
|
||||
normalized.append(
|
||||
{
|
||||
'id': state['id'],
|
||||
'type': state['type'] or 'function',
|
||||
'function': {
|
||||
'name': state['name'],
|
||||
'arguments': arguments,
|
||||
},
|
||||
}
|
||||
)
|
||||
tool_call_dict: dict[str, typing.Any] = {
|
||||
'id': state['id'],
|
||||
'type': state['type'] or 'function',
|
||||
'function': {
|
||||
'name': state['name'],
|
||||
'arguments': arguments,
|
||||
},
|
||||
}
|
||||
|
||||
# Include provider_specific_fields if present
|
||||
if state['provider_specific_fields']:
|
||||
tool_call_dict['provider_specific_fields'] = state['provider_specific_fields']
|
||||
|
||||
normalized.append(tool_call_dict)
|
||||
|
||||
return normalized or None
|
||||
|
||||
@@ -517,7 +569,7 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
||||
|
||||
chunk_idx = 0
|
||||
role = 'assistant'
|
||||
tool_call_state: dict[int, dict[str, str]] = {}
|
||||
tool_call_state: dict[int, dict[str, typing.Any]] = {}
|
||||
|
||||
try:
|
||||
response = await acompletion(**args)
|
||||
@@ -567,13 +619,17 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
||||
chunk_idx += 1
|
||||
continue
|
||||
|
||||
chunk_data = {
|
||||
chunk_data: dict[str, typing.Any] = {
|
||||
'role': role,
|
||||
'content': delta_content if delta_content else None,
|
||||
'tool_calls': tool_calls,
|
||||
'is_final': bool(finish_reason),
|
||||
}
|
||||
|
||||
# Preserve provider_specific_fields from delta (e.g., Gemini thought_signatures)
|
||||
if delta.get('provider_specific_fields'):
|
||||
chunk_data['provider_specific_fields'] = delta['provider_specific_fields']
|
||||
|
||||
chunk_data = {k: v for k, v in chunk_data.items() if v is not None}
|
||||
yield provider_message.MessageChunk(**chunk_data)
|
||||
chunk_idx += 1
|
||||
|
||||
@@ -3,8 +3,8 @@ kind: LLMAPIRequester
|
||||
metadata:
|
||||
name: moonshot-chat-completions
|
||||
label:
|
||||
en_US: Moonshot
|
||||
zh_Hans: 月之暗面
|
||||
en_US: Moonshot / Kimi (Global · api.moonshot.ai)
|
||||
zh_Hans: 月之暗面 / Kimi(国际站 · api.moonshot.ai)
|
||||
icon: moonshot.png
|
||||
spec:
|
||||
litellm_provider: openai
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
apiVersion: v1
|
||||
kind: LLMAPIRequester
|
||||
metadata:
|
||||
name: moonshot-cn-chat-completions
|
||||
label:
|
||||
en_US: Moonshot / Kimi (China · api.moonshot.cn)
|
||||
zh_Hans: 月之暗面 / Kimi(国内站 · api.moonshot.cn)
|
||||
icon: moonshot.png
|
||||
spec:
|
||||
litellm_provider: openai
|
||||
config:
|
||||
- name: base_url
|
||||
label:
|
||||
en_US: Base URL
|
||||
zh_Hans: 基础 URL
|
||||
type: string
|
||||
required: true
|
||||
default: https://api.moonshot.cn/v1
|
||||
- name: timeout
|
||||
label:
|
||||
en_US: Timeout
|
||||
zh_Hans: 超时时间
|
||||
type: integer
|
||||
required: true
|
||||
default: 120
|
||||
alias: "moonshot Moonshot 月之暗面 月暗 kimi Kimi 月之 暗面 moonshot-v1 k2 cn 国内 国内站"
|
||||
support_type:
|
||||
- llm
|
||||
provider_category: manufacturer
|
||||
execution:
|
||||
python:
|
||||
path: ./moonshotchatcmpl.py
|
||||
attr: MoonshotChatCompletions
|
||||
@@ -104,6 +104,68 @@ class _StreamAccumulator:
|
||||
class LocalAgentRunner(runner.RequestRunner):
|
||||
"""Local agent request runner"""
|
||||
|
||||
async def _inject_inbound_attachments(
|
||||
self,
|
||||
query: pipeline_query.Query,
|
||||
user_message: provider_message.Message,
|
||||
) -> None:
|
||||
"""Persist inbound attachments into the sandbox and tell the model.
|
||||
|
||||
No-op when the box service is unavailable or there are no attachments.
|
||||
On success, appends an extra text ContentElement to the user message
|
||||
listing the in-sandbox paths and the outbox convention, and stashes the
|
||||
descriptors in ``query.variables['_sandbox_inbound_attachments']``.
|
||||
"""
|
||||
box_service = getattr(self.ap, 'box_service', None)
|
||||
if box_service is None or not getattr(box_service, 'available', False):
|
||||
return
|
||||
try:
|
||||
attachments = await box_service.materialize_inbound_attachments(query)
|
||||
except Exception as e: # never break the chat turn over attachment IO
|
||||
self.ap.logger.warning(f'Inbound attachment materialization failed: {e}')
|
||||
return
|
||||
if not attachments:
|
||||
return
|
||||
|
||||
query.variables['_sandbox_inbound_attachments'] = attachments
|
||||
|
||||
lines = [
|
||||
'The user sent attachments. They have been saved into the sandbox and are '
|
||||
'available to the exec/read/write tools at these paths:'
|
||||
]
|
||||
for att in attachments:
|
||||
lines.append(f'- {att["type"]}: {att["path"]} ({att["size"]} bytes)')
|
||||
outbox_dir = f'{box_service.OUTBOX_MOUNT_DIR}/{query.query_id}'
|
||||
lines.append(
|
||||
'If you produce any file (image, audio, document, etc.) that should be sent '
|
||||
f'back to the user, write it into {outbox_dir}/ (create the directory if '
|
||||
'needed). Every file placed there will be delivered to the user automatically.'
|
||||
)
|
||||
note = '\n'.join(lines)
|
||||
|
||||
# Voice/File attachments are now available to the agent via the sandbox
|
||||
# (exec/read/write tools). Their raw bytes must NOT be forwarded to the
|
||||
# chat model as multimodal content: providers reject non-image file
|
||||
# parts ("Invalid user message ... ensure all user messages are valid
|
||||
# OpenAI chat completion messages"). Strip those content elements and
|
||||
# rely on the sandbox-path note instead. Images are kept so vision
|
||||
# models can still see them.
|
||||
_model_unsafe_types = {'file_base64', 'file_url'}
|
||||
if isinstance(user_message.content, list):
|
||||
user_message.content = [
|
||||
ce for ce in user_message.content if getattr(ce, 'type', None) not in _model_unsafe_types
|
||||
]
|
||||
|
||||
if isinstance(user_message.content, str):
|
||||
user_message.content = [
|
||||
provider_message.ContentElement.from_text(user_message.content),
|
||||
provider_message.ContentElement.from_text(note),
|
||||
]
|
||||
elif isinstance(user_message.content, list):
|
||||
user_message.content.append(provider_message.ContentElement.from_text(note))
|
||||
else:
|
||||
user_message.content = [provider_message.ContentElement.from_text(note)]
|
||||
|
||||
def _build_request_messages(
|
||||
self,
|
||||
query: pipeline_query.Query,
|
||||
@@ -115,7 +177,7 @@ class LocalAgentRunner(runner.RequestRunner):
|
||||
req_messages.append(
|
||||
provider_message.Message(
|
||||
role='system',
|
||||
content=self.ap.box_service.get_system_guidance(),
|
||||
content=self.ap.box_service.get_system_guidance(query.query_id),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -232,6 +294,12 @@ class LocalAgentRunner(runner.RequestRunner):
|
||||
|
||||
user_message = copy.deepcopy(query.user_message)
|
||||
|
||||
# Materialize inbound attachments (images / voices / files) into the
|
||||
# sandbox so the agent's exec/read/write tools can operate on the real
|
||||
# bytes — not just the multimodal copy the model sees. The exact
|
||||
# in-sandbox paths are announced to the model as a system note.
|
||||
await self._inject_inbound_attachments(query, user_message)
|
||||
|
||||
user_message_text = ''
|
||||
|
||||
if isinstance(user_message.content, str):
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
async def is_box_backend_available(ap: Any) -> bool:
|
||||
"""Return whether the configured Box backend is ready for tool execution."""
|
||||
box_service = getattr(ap, 'box_service', None)
|
||||
if box_service is None:
|
||||
return False
|
||||
if not getattr(box_service, 'available', False):
|
||||
return False
|
||||
try:
|
||||
status = await box_service.get_status()
|
||||
backend_info = status.get('backend', {})
|
||||
return bool(backend_info.get('available', False))
|
||||
except Exception:
|
||||
return False
|
||||
@@ -5,6 +5,8 @@ import asyncio
|
||||
import os
|
||||
import shutil
|
||||
import shlex
|
||||
import threading
|
||||
from contextlib import suppress
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pydantic
|
||||
@@ -18,12 +20,26 @@ from ....box.workspace import (
|
||||
rewrite_mounted_path,
|
||||
rewrite_venv_command,
|
||||
unwrap_venv_path,
|
||||
wrap_python_command_with_env,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .mcp import RuntimeMCPSession
|
||||
|
||||
|
||||
_WORKSPACE_COPY_LOCKS: dict[str, threading.Lock] = {}
|
||||
_WORKSPACE_COPY_LOCKS_GUARD = threading.Lock()
|
||||
|
||||
|
||||
def _workspace_copy_lock(path: str) -> threading.Lock:
|
||||
with _WORKSPACE_COPY_LOCKS_GUARD:
|
||||
lock = _WORKSPACE_COPY_LOCKS.get(path)
|
||||
if lock is None:
|
||||
lock = threading.Lock()
|
||||
_WORKSPACE_COPY_LOCKS[path] = lock
|
||||
return lock
|
||||
|
||||
|
||||
class MCPSessionErrorPhase(enum.Enum):
|
||||
"""Which phase of the MCP lifecycle failed."""
|
||||
|
||||
@@ -49,7 +65,7 @@ class MCPServerBoxConfig(pydantic.BaseModel):
|
||||
host_path: str | None = None
|
||||
host_path_mode: str = 'ro' # MCP servers default to read-write mount only when explicitly requested
|
||||
env: dict[str, str] = pydantic.Field(default_factory=dict)
|
||||
startup_timeout_sec: int = 120 # Longer default to allow dependency bootstrap
|
||||
startup_timeout_sec: int = 300 # First Docker bootstrap may need to build a venv and install MCP deps.
|
||||
cpus: float | None = None
|
||||
memory_mb: int | None = None
|
||||
pids_limit: int | None = None
|
||||
@@ -128,6 +144,7 @@ class BoxStdioSessionRuntime:
|
||||
workspace = self._build_workspace(host_path=None)
|
||||
host_path = self.resolve_host_path()
|
||||
process_cwd = '/workspace'
|
||||
install_cmd: str | None = None
|
||||
|
||||
try:
|
||||
await workspace.create_session()
|
||||
@@ -168,6 +185,8 @@ class BoxStdioSessionRuntime:
|
||||
env=self.server_config.get('env', {}),
|
||||
cwd=process_cwd,
|
||||
)
|
||||
if install_cmd:
|
||||
payload = self._wrap_process_payload_with_python_env(payload, process_cwd)
|
||||
payload['process_id'] = self.process_id
|
||||
await workspace.box_service.start_managed_process(workspace.session_id, payload)
|
||||
except Exception:
|
||||
@@ -253,14 +272,42 @@ class BoxStdioSessionRuntime:
|
||||
|
||||
@staticmethod
|
||||
def _copy_workspace_tree(source_path: str, process_host_root: str, process_host_workspace: str) -> None:
|
||||
shutil.rmtree(process_host_root, ignore_errors=True)
|
||||
os.makedirs(process_host_root, exist_ok=True)
|
||||
shutil.copytree(
|
||||
source_path,
|
||||
process_host_workspace,
|
||||
symlinks=True,
|
||||
ignore=shutil.ignore_patterns('.git', '__pycache__', '.pytest_cache', '.mypy_cache', '.ruff_cache'),
|
||||
)
|
||||
# Docker-backed bootstrap writes root-owned runtime directories such as
|
||||
# .venv/.tmp into the staged workspace. The host process may not be able
|
||||
# to delete them, so refresh source files in place and preserve runtime
|
||||
# directories instead of rmtree'ing the whole staging root.
|
||||
with _workspace_copy_lock(process_host_root):
|
||||
preserved_names = {'.venv', 'venv', 'env', '.cache', '.tmp', '.langbot'}
|
||||
os.makedirs(process_host_workspace, exist_ok=True)
|
||||
for name in os.listdir(process_host_workspace):
|
||||
if name in preserved_names:
|
||||
continue
|
||||
path = os.path.join(process_host_workspace, name)
|
||||
if os.path.isdir(path) and not os.path.islink(path):
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
else:
|
||||
# The entry may disappear between listdir and unlink if cleanup races us.
|
||||
with suppress(FileNotFoundError):
|
||||
os.unlink(path)
|
||||
shutil.copytree(
|
||||
source_path,
|
||||
process_host_workspace,
|
||||
symlinks=True,
|
||||
dirs_exist_ok=True,
|
||||
ignore=shutil.ignore_patterns(
|
||||
'.git',
|
||||
'__pycache__',
|
||||
'.pytest_cache',
|
||||
'.mypy_cache',
|
||||
'.ruff_cache',
|
||||
'.venv',
|
||||
'venv',
|
||||
'env',
|
||||
'.cache',
|
||||
'.tmp',
|
||||
'.langbot',
|
||||
),
|
||||
)
|
||||
|
||||
async def _cleanup_staged_workspace(self) -> None:
|
||||
if not self.resolve_host_path():
|
||||
@@ -343,23 +390,25 @@ class BoxStdioSessionRuntime:
|
||||
@staticmethod
|
||||
def detect_install_command(host_path: str, workspace_path: str = '/workspace') -> str | None:
|
||||
workspace_kind = classify_python_workspace(host_path)
|
||||
quoted_workspace_path = shlex.quote(workspace_path)
|
||||
if workspace_kind == 'package':
|
||||
return (
|
||||
'mkdir -p /opt/_lb_src'
|
||||
f' && tar -C {quoted_workspace_path}'
|
||||
' --exclude=.venv --exclude=.git --exclude=__pycache__'
|
||||
' --exclude=node_modules --exclude=.tox --exclude=.nox'
|
||||
' --exclude="*.egg-info" --exclude=.uv-cache'
|
||||
' -cf - .'
|
||||
' | tar -C /opt/_lb_src -xf -'
|
||||
' && pip install --no-cache-dir /opt/_lb_src'
|
||||
' && rm -rf /opt/_lb_src'
|
||||
)
|
||||
if workspace_kind == 'requirements':
|
||||
return f'pip install --no-cache-dir -r {quoted_workspace_path}/requirements.txt'
|
||||
if workspace_kind in {'package', 'requirements'}:
|
||||
return wrap_python_command_with_env('python -c "pass"', mount_path=workspace_path).rstrip()
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _wrap_process_payload_with_python_env(payload: dict[str, Any], workspace_path: str) -> dict[str, Any]:
|
||||
"""Start a prepared Python workspace without writing bootstrap output to MCP stdio."""
|
||||
workspace_root = workspace_path.rstrip('/') or '/workspace'
|
||||
venv_dir = f'{workspace_root}/.venv'
|
||||
venv_bin = f'{venv_dir}/bin'
|
||||
command = ' '.join([shlex.quote(payload['command']), *[shlex.quote(arg) for arg in payload.get('args', [])]])
|
||||
wrapped = dict(payload)
|
||||
wrapped['command'] = 'sh'
|
||||
wrapped['args'] = [
|
||||
'-lc',
|
||||
(f'export VIRTUAL_ENV={shlex.quote(venv_dir)}; export PATH={shlex.quote(venv_bin)}:$PATH; exec {command}'),
|
||||
]
|
||||
return wrapped
|
||||
|
||||
def build_box_session_payload(self, session_id: str, host_path: str | None = None) -> dict[str, Any]:
|
||||
workspace = self._build_workspace()
|
||||
workspace.session_id = session_id
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
|
||||
@@ -8,6 +9,7 @@ from langbot_plugin.api.entities.events import pipeline_query
|
||||
|
||||
from .. import loader
|
||||
from ..errors import ToolNotFoundError
|
||||
from .availability import is_box_backend_available
|
||||
from . import skill as skill_loader
|
||||
|
||||
EXEC_TOOL_NAME = 'exec'
|
||||
@@ -22,6 +24,15 @@ _ALL_TOOL_NAMES = {EXEC_TOOL_NAME, READ_TOOL_NAME, WRITE_TOOL_NAME, EDIT_TOOL_NA
|
||||
# Skip these dirs during grep walk to avoid noise
|
||||
_SKIP_DIRS = {'.git', 'node_modules', '__pycache__', '.venv', 'venv', '.tox', 'dist', 'build'}
|
||||
|
||||
_DEFAULT_READ_MAX_LINES = 2000
|
||||
_MAX_READ_MAX_LINES = 10000
|
||||
_DEFAULT_TOOL_RESULT_MAX_BYTES = 50 * 1024
|
||||
_BOX_FILE_SCRIPT_MAX_BYTES = 2048
|
||||
_GLOB_MAX_MATCHES = 100
|
||||
_GREP_MAX_MATCHES = 200
|
||||
_GREP_MAX_FILES = 5000
|
||||
_GREP_MAX_LINE_CHARS = 500
|
||||
|
||||
|
||||
class NativeToolLoader(loader.ToolLoader):
|
||||
def __init__(self, ap):
|
||||
@@ -43,18 +54,7 @@ class NativeToolLoader(loader.ToolLoader):
|
||||
|
||||
async def _check_backend_available(self) -> bool:
|
||||
"""Check if the box backend is truly available (not just the runtime)."""
|
||||
box_service = getattr(self.ap, 'box_service', None)
|
||||
if box_service is None:
|
||||
return False
|
||||
if not getattr(box_service, 'available', False):
|
||||
return False
|
||||
# Check if backend is truly available via get_status
|
||||
try:
|
||||
status = await box_service.get_status()
|
||||
backend_info = status.get('backend', {})
|
||||
return backend_info.get('available', False)
|
||||
except Exception:
|
||||
return False
|
||||
return await is_box_backend_available(self.ap)
|
||||
|
||||
async def get_tools(self, bound_plugins: list[str] | None = None) -> list[resource_tool.LLMTool]:
|
||||
if not self._is_sandbox_available():
|
||||
@@ -139,6 +139,7 @@ class NativeToolLoader(loader.ToolLoader):
|
||||
# via execute_tool. Skills are mounted at /workspace/.skills/{name}/
|
||||
# via extra_mounts built by BoxService.
|
||||
result = await self.ap.box_service.execute_tool(parameters, query)
|
||||
result = self._normalize_exec_result(result)
|
||||
|
||||
if selected_skill is not None:
|
||||
self._refresh_skill_from_disk(selected_skill)
|
||||
@@ -227,34 +228,121 @@ class NativeToolLoader(loader.ToolLoader):
|
||||
except Exception:
|
||||
return {'ok': False, 'error': stdout or 'Box file operation returned no result'}
|
||||
|
||||
async def _read_workspace_via_box(self, path: str, query: pipeline_query.Query) -> dict:
|
||||
async def _read_workspace_via_box(self, path: str, parameters: dict, query: pipeline_query.Query) -> dict:
|
||||
offset = self._positive_int(parameters.get('offset'), default=1)
|
||||
byte_offset = self._non_negative_int(parameters.get('byte_offset'), default=0)
|
||||
max_lines = self._positive_int(
|
||||
parameters.get('limit'),
|
||||
default=_DEFAULT_READ_MAX_LINES,
|
||||
max_value=_MAX_READ_MAX_LINES,
|
||||
)
|
||||
# Box file fallback returns through exec stdout, which is already capped
|
||||
# by BoxService. Keep this payload small enough to remain valid JSON.
|
||||
max_bytes = min(
|
||||
self._positive_int(parameters.get('max_bytes'), default=_DEFAULT_TOOL_RESULT_MAX_BYTES),
|
||||
_BOX_FILE_SCRIPT_MAX_BYTES,
|
||||
)
|
||||
encoding = self._read_encoding(parameters)
|
||||
script = f"""
|
||||
import json, os
|
||||
import base64, json, os
|
||||
path = {json.dumps(path)}
|
||||
offset = {offset}
|
||||
byte_offset = {byte_offset}
|
||||
max_lines = {max_lines}
|
||||
max_bytes = {max_bytes}
|
||||
encoding = {json.dumps(encoding)}
|
||||
if not path.startswith('/workspace'):
|
||||
print(json.dumps({{'ok': False, 'error': 'Path must be under /workspace.'}}))
|
||||
elif not os.path.exists(path):
|
||||
print(json.dumps({{'ok': False, 'error': f'File not found: {{path}}'}}))
|
||||
elif os.path.isdir(path):
|
||||
print(json.dumps({{'ok': True, 'content': '\\n'.join(sorted(os.listdir(path))), 'is_directory': True}}))
|
||||
entries = sorted(os.listdir(path))
|
||||
content = '\\n'.join(entries)
|
||||
print(json.dumps({{'ok': True, 'content': content, 'is_directory': True, 'total': len(entries), 'truncated': False}}))
|
||||
elif encoding == 'base64':
|
||||
size_bytes = os.path.getsize(path)
|
||||
with open(path, 'rb') as f:
|
||||
f.seek(byte_offset)
|
||||
data = f.read(max_bytes + 1)
|
||||
chunk = data[:max_bytes]
|
||||
has_more = len(data) > max_bytes
|
||||
print(json.dumps({{
|
||||
'ok': True,
|
||||
'content': base64.b64encode(chunk).decode('ascii'),
|
||||
'encoding': 'base64',
|
||||
'byte_offset': byte_offset,
|
||||
'length': len(chunk),
|
||||
'size_bytes': size_bytes,
|
||||
'has_more': has_more,
|
||||
'next_byte_offset': byte_offset + len(chunk) if has_more else None,
|
||||
'max_bytes': max_bytes,
|
||||
}}))
|
||||
else:
|
||||
lines = []
|
||||
output_bytes = 0
|
||||
end_line = offset - 1
|
||||
truncated = False
|
||||
next_offset = None
|
||||
with open(path, 'r', encoding='utf-8', errors='replace') as f:
|
||||
print(json.dumps({{'ok': True, 'content': f.read()}}))
|
||||
for line_number, line in enumerate(f, 1):
|
||||
if line_number < offset:
|
||||
continue
|
||||
if len(lines) >= max_lines:
|
||||
truncated = True
|
||||
next_offset = line_number
|
||||
break
|
||||
line_bytes = len(line.encode('utf-8'))
|
||||
if output_bytes + line_bytes > max_bytes:
|
||||
truncated = True
|
||||
next_offset = line_number
|
||||
break
|
||||
lines.append(line.rstrip('\\n'))
|
||||
output_bytes += line_bytes
|
||||
end_line = line_number
|
||||
print(json.dumps({{
|
||||
'ok': True,
|
||||
'content': '\\n'.join(lines),
|
||||
'truncated': truncated,
|
||||
'start_line': offset,
|
||||
'end_line': end_line,
|
||||
'next_offset': next_offset,
|
||||
'max_lines': max_lines,
|
||||
'max_bytes': max_bytes,
|
||||
}}))
|
||||
""".strip()
|
||||
return await self._run_workspace_file_script(script, query)
|
||||
|
||||
async def _write_workspace_via_box(self, path: str, content: str, query: pipeline_query.Query) -> dict:
|
||||
async def _write_workspace_via_box(
|
||||
self,
|
||||
path: str,
|
||||
content: str,
|
||||
parameters: dict,
|
||||
query: pipeline_query.Query,
|
||||
) -> dict:
|
||||
encoding, mode = self._write_options(parameters)
|
||||
script = f"""
|
||||
import json, os
|
||||
import base64, json, os
|
||||
path = {json.dumps(path)}
|
||||
content = {json.dumps(content)}
|
||||
encoding = {json.dumps(encoding)}
|
||||
mode = {json.dumps(mode)}
|
||||
if not path.startswith('/workspace'):
|
||||
print(json.dumps({{'ok': False, 'error': 'Path must be under /workspace.'}}))
|
||||
else:
|
||||
os.makedirs(os.path.dirname(path) or '/workspace', exist_ok=True)
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
print(json.dumps({{'ok': True, 'path': path}}))
|
||||
if encoding == 'base64':
|
||||
try:
|
||||
data = base64.b64decode(content, validate=True)
|
||||
except Exception as exc:
|
||||
print(json.dumps({{'ok': False, 'error': f'invalid base64 content: {{exc}}'}}))
|
||||
else:
|
||||
with open(path, 'ab' if mode == 'append' else 'wb') as f:
|
||||
f.write(data)
|
||||
print(json.dumps({{'ok': True, 'path': path}}))
|
||||
else:
|
||||
with open(path, 'a' if mode == 'append' else 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
print(json.dumps({{'ok': True, 'path': path}}))
|
||||
""".strip()
|
||||
return await self._run_workspace_file_script(script, query)
|
||||
|
||||
@@ -307,12 +395,27 @@ else:
|
||||
if not any(part in skip_dirs for part in item.parts)
|
||||
]
|
||||
hits.sort(key=lambda item: item.stat().st_mtime if item.exists() else 0, reverse=True)
|
||||
shown = hits[:100]
|
||||
shown = hits[:{_GLOB_MAX_MATCHES}]
|
||||
matches = []
|
||||
output_bytes = 0
|
||||
truncated_by_bytes = False
|
||||
for item in shown:
|
||||
rel = os.path.relpath(str(item), path)
|
||||
matches.append(os.path.join(path, rel).replace(os.sep, '/'))
|
||||
print(json.dumps({{'ok': True, 'matches': matches, 'total': len(hits), 'truncated': len(hits) > 100}}))
|
||||
sandbox_path = os.path.join(path, rel).replace(os.sep, '/')
|
||||
entry_bytes = len(sandbox_path.encode('utf-8')) + (1 if matches else 0)
|
||||
if output_bytes + entry_bytes > {_DEFAULT_TOOL_RESULT_MAX_BYTES}:
|
||||
truncated_by_bytes = True
|
||||
break
|
||||
matches.append(sandbox_path)
|
||||
output_bytes += entry_bytes
|
||||
print(json.dumps({{
|
||||
'ok': True,
|
||||
'matches': matches,
|
||||
'preview': '\\n'.join(matches),
|
||||
'total': len(hits),
|
||||
'truncated': len(hits) > len(matches) or truncated_by_bytes,
|
||||
'truncated_by': 'bytes' if truncated_by_bytes else ('matches' if len(hits) > len(matches) else None),
|
||||
}}))
|
||||
""".strip()
|
||||
return await self._run_workspace_file_script(script, query)
|
||||
|
||||
@@ -350,29 +453,54 @@ else:
|
||||
continue
|
||||
if item.is_file():
|
||||
files.append(item)
|
||||
if len(files) >= 5000:
|
||||
if len(files) >= {_GREP_MAX_FILES}:
|
||||
break
|
||||
|
||||
matches = []
|
||||
output_bytes = 0
|
||||
truncated_by = None
|
||||
for fp in files:
|
||||
try:
|
||||
text = fp.read_text(errors='ignore')
|
||||
handle = fp.open('r', encoding='utf-8', errors='ignore')
|
||||
except OSError:
|
||||
continue
|
||||
for lineno, line in enumerate(text.splitlines(), 1):
|
||||
if regex.search(line):
|
||||
if base.is_file():
|
||||
file_path = path
|
||||
else:
|
||||
rel = os.path.relpath(str(fp), path)
|
||||
file_path = os.path.join(path, rel).replace(os.sep, '/')
|
||||
matches.append({{'file': file_path, 'line': lineno, 'content': line.rstrip()}})
|
||||
if len(matches) >= 200:
|
||||
break
|
||||
if len(matches) >= 200:
|
||||
with handle:
|
||||
for lineno, line in enumerate(handle, 1):
|
||||
if regex.search(line):
|
||||
if base.is_file():
|
||||
file_path = path
|
||||
else:
|
||||
rel = os.path.relpath(str(fp), path)
|
||||
file_path = os.path.join(path, rel).replace(os.sep, '/')
|
||||
content = line.rstrip()
|
||||
line_truncated = False
|
||||
if len(content) > {_GREP_MAX_LINE_CHARS}:
|
||||
content = content[:{_GREP_MAX_LINE_CHARS}] + '... [truncated]'
|
||||
line_truncated = True
|
||||
entry = {{'file': file_path, 'line': lineno, 'content': content}}
|
||||
entry_bytes = len(json.dumps(entry, ensure_ascii=False).encode('utf-8')) + 1
|
||||
if output_bytes + entry_bytes > {_DEFAULT_TOOL_RESULT_MAX_BYTES}:
|
||||
truncated_by = 'bytes'
|
||||
break
|
||||
if line_truncated and truncated_by is None:
|
||||
truncated_by = 'line'
|
||||
matches.append(entry)
|
||||
output_bytes += entry_bytes
|
||||
if len(matches) >= {_GREP_MAX_MATCHES}:
|
||||
truncated_by = truncated_by or 'matches'
|
||||
break
|
||||
if truncated_by == 'bytes' or len(matches) >= {_GREP_MAX_MATCHES}:
|
||||
break
|
||||
if truncated_by == 'bytes' or len(matches) >= {_GREP_MAX_MATCHES}:
|
||||
break
|
||||
|
||||
print(json.dumps({{'ok': True, 'matches': matches, 'total': len(matches), 'truncated': len(matches) >= 200}}))
|
||||
print(json.dumps({{
|
||||
'ok': True,
|
||||
'matches': matches,
|
||||
'total': len(matches),
|
||||
'truncated': truncated_by is not None,
|
||||
'truncated_by': truncated_by,
|
||||
}}))
|
||||
""".strip()
|
||||
return await self._run_workspace_file_script(script, query)
|
||||
|
||||
@@ -387,14 +515,20 @@ else:
|
||||
)
|
||||
if skill_request is not None and hasattr(self.ap.box_service, 'read_skill_file'):
|
||||
selected_skill, relative = skill_request
|
||||
host_path = self._resolve_skill_host_path(selected_skill, relative)
|
||||
if host_path and os.path.exists(host_path):
|
||||
if os.path.isdir(host_path):
|
||||
return self._build_directory_result(os.listdir(host_path))
|
||||
return self._read_text_file_preview(host_path, parameters)
|
||||
|
||||
try:
|
||||
result = await self.ap.box_service.read_skill_file(selected_skill['name'], relative)
|
||||
return {'ok': True, 'content': result.get('content', '')}
|
||||
return self._build_read_result_from_text(str(result.get('content', '')), parameters)
|
||||
except Exception:
|
||||
try:
|
||||
result = await self.ap.box_service.list_skill_files(selected_skill['name'], relative)
|
||||
entries = [entry['name'] for entry in result.get('entries', [])]
|
||||
return {'ok': True, 'content': '\n'.join(sorted(entries)), 'is_directory': True}
|
||||
return self._build_directory_result(entries)
|
||||
except Exception as exc:
|
||||
return {'ok': False, 'error': str(exc)}
|
||||
|
||||
@@ -405,20 +539,19 @@ else:
|
||||
include_activated=True,
|
||||
)
|
||||
if self._should_use_box_workspace_files(selected_skill):
|
||||
return await self._read_workspace_via_box(path, query)
|
||||
return await self._read_workspace_via_box(path, parameters, query)
|
||||
if not os.path.exists(host_path):
|
||||
return {'ok': False, 'error': f'File not found: {path}'}
|
||||
if os.path.isdir(host_path):
|
||||
entries = os.listdir(host_path)
|
||||
return {'ok': True, 'content': '\n'.join(sorted(entries)), 'is_directory': True}
|
||||
with open(host_path, 'r', errors='replace') as f:
|
||||
content = f.read()
|
||||
return {'ok': True, 'content': content}
|
||||
return self._build_directory_result(entries)
|
||||
return self._read_text_file_preview(host_path, parameters)
|
||||
|
||||
async def _invoke_write(self, parameters: dict, query: pipeline_query.Query) -> dict:
|
||||
path = parameters['path']
|
||||
content = parameters['content']
|
||||
self.ap.logger.info(f'write tool invoked: query_id={query.query_id} path={path} length={len(content)}')
|
||||
encoding, _mode = self._write_options(parameters)
|
||||
skill_request = self._resolve_skill_relative_path(
|
||||
query,
|
||||
path,
|
||||
@@ -426,6 +559,8 @@ else:
|
||||
include_activated=True,
|
||||
)
|
||||
if skill_request is not None and hasattr(self.ap.box_service, 'write_skill_file'):
|
||||
if encoding != 'text':
|
||||
return {'ok': False, 'error': 'base64 writes to skill packages are not supported.'}
|
||||
selected_skill, relative = skill_request
|
||||
await self.ap.box_service.write_skill_file(selected_skill['name'], relative, content)
|
||||
await self.ap.skill_mgr.reload_skills()
|
||||
@@ -438,10 +573,12 @@ else:
|
||||
include_activated=True,
|
||||
)
|
||||
if self._should_use_box_workspace_files(selected_skill):
|
||||
return await self._write_workspace_via_box(path, content, query)
|
||||
return await self._write_workspace_via_box(path, content, parameters, query)
|
||||
os.makedirs(os.path.dirname(host_path), exist_ok=True)
|
||||
with open(host_path, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
try:
|
||||
self._write_host_file(host_path, content, parameters)
|
||||
except ValueError as exc:
|
||||
return {'ok': False, 'error': str(exc)}
|
||||
self._refresh_skill_from_disk(selected_skill)
|
||||
return {'ok': True, 'path': path}
|
||||
|
||||
@@ -584,6 +721,40 @@ else:
|
||||
'type': 'string',
|
||||
'description': 'Absolute path to the file (must be under /workspace).',
|
||||
},
|
||||
'offset': {
|
||||
'type': 'integer',
|
||||
'description': '1-indexed line number to start reading from. Defaults to 1.',
|
||||
'default': 1,
|
||||
'minimum': 1,
|
||||
},
|
||||
'limit': {
|
||||
'type': 'integer',
|
||||
'description': f'Maximum number of lines to return. Defaults to {_DEFAULT_READ_MAX_LINES}.',
|
||||
'default': _DEFAULT_READ_MAX_LINES,
|
||||
'minimum': 1,
|
||||
'maximum': _MAX_READ_MAX_LINES,
|
||||
},
|
||||
'max_bytes': {
|
||||
'type': 'integer',
|
||||
'description': (
|
||||
f'Maximum bytes of file content to return. Defaults to {_DEFAULT_TOOL_RESULT_MAX_BYTES}.'
|
||||
),
|
||||
'default': _DEFAULT_TOOL_RESULT_MAX_BYTES,
|
||||
'minimum': 1,
|
||||
'maximum': _DEFAULT_TOOL_RESULT_MAX_BYTES,
|
||||
},
|
||||
'encoding': {
|
||||
'type': 'string',
|
||||
'description': 'Return text by default, or base64 for binary byte-range reads.',
|
||||
'enum': ['text', 'base64'],
|
||||
'default': 'text',
|
||||
},
|
||||
'byte_offset': {
|
||||
'type': 'integer',
|
||||
'description': '0-indexed byte offset used when encoding is base64. Defaults to 0.',
|
||||
'default': 0,
|
||||
'minimum': 0,
|
||||
},
|
||||
},
|
||||
'required': ['path'],
|
||||
'additionalProperties': False,
|
||||
@@ -609,7 +780,19 @@ else:
|
||||
},
|
||||
'content': {
|
||||
'type': 'string',
|
||||
'description': 'Content to write to the file.',
|
||||
'description': 'Text content, or base64 content when encoding is base64.',
|
||||
},
|
||||
'encoding': {
|
||||
'type': 'string',
|
||||
'description': 'Write content as text by default, or decode it from base64 for binary files.',
|
||||
'enum': ['text', 'base64'],
|
||||
'default': 'text',
|
||||
},
|
||||
'mode': {
|
||||
'type': 'string',
|
||||
'description': 'Overwrite the file by default, or append to it.',
|
||||
'enum': ['overwrite', 'append'],
|
||||
'default': 'overwrite',
|
||||
},
|
||||
},
|
||||
'required': ['path', 'content'],
|
||||
@@ -740,22 +923,30 @@ else:
|
||||
hits.sort(key=lambda p: p.stat().st_mtime if p.exists() else 0, reverse=True)
|
||||
|
||||
total = len(hits)
|
||||
shown = hits[:100]
|
||||
shown = hits[:_GLOB_MAX_MATCHES]
|
||||
|
||||
# Convert back to sandbox paths
|
||||
sandbox_paths = []
|
||||
output_bytes = 0
|
||||
truncated_by_bytes = False
|
||||
for h in shown:
|
||||
rel = os.path.relpath(str(h), host_path)
|
||||
sandbox_path = os.path.join(path, rel)
|
||||
entry_bytes = len(sandbox_path.encode('utf-8')) + (1 if sandbox_paths else 0)
|
||||
if output_bytes + entry_bytes > _DEFAULT_TOOL_RESULT_MAX_BYTES:
|
||||
truncated_by_bytes = True
|
||||
break
|
||||
sandbox_paths.append(sandbox_path)
|
||||
output_bytes += entry_bytes
|
||||
|
||||
result_lines = sandbox_paths
|
||||
result = '\n'.join(result_lines)
|
||||
|
||||
if total > 100:
|
||||
result += f'\n... ({total} matches, showing first 100)'
|
||||
|
||||
return {'ok': True, 'matches': result_lines, 'total': total, 'truncated': total > 100}
|
||||
return {
|
||||
'ok': True,
|
||||
'matches': sandbox_paths,
|
||||
'preview': '\n'.join(sandbox_paths),
|
||||
'total': total,
|
||||
'truncated': total > len(sandbox_paths) or truncated_by_bytes,
|
||||
'truncated_by': 'bytes' if truncated_by_bytes else ('matches' if total > len(sandbox_paths) else None),
|
||||
}
|
||||
|
||||
async def _invoke_grep(self, parameters: dict, query: pipeline_query.Query) -> dict:
|
||||
pattern = parameters['pattern']
|
||||
@@ -791,32 +982,46 @@ else:
|
||||
files = self._grep_walk(base, include)
|
||||
|
||||
matches = []
|
||||
output_bytes = 0
|
||||
truncated_by = None
|
||||
for fp in files:
|
||||
try:
|
||||
text = fp.read_text(errors='ignore')
|
||||
handle = fp.open('r', encoding='utf-8', errors='ignore')
|
||||
except OSError:
|
||||
continue
|
||||
for lineno, line in enumerate(text.splitlines(), 1):
|
||||
if regex.search(line):
|
||||
rel = os.path.relpath(str(fp), host_path)
|
||||
sandbox_path = os.path.join(path, rel)
|
||||
matches.append(
|
||||
{
|
||||
with handle:
|
||||
for lineno, line in enumerate(handle, 1):
|
||||
if regex.search(line):
|
||||
rel = os.path.relpath(str(fp), host_path)
|
||||
sandbox_path = os.path.join(path, rel)
|
||||
content, line_truncated = self._truncate_grep_line(line.rstrip())
|
||||
entry = {
|
||||
'file': sandbox_path,
|
||||
'line': lineno,
|
||||
'content': line.rstrip(),
|
||||
'content': content,
|
||||
}
|
||||
)
|
||||
if len(matches) >= 200:
|
||||
break
|
||||
if len(matches) >= 200:
|
||||
entry_bytes = len(json.dumps(entry, ensure_ascii=False).encode('utf-8')) + 1
|
||||
if output_bytes + entry_bytes > _DEFAULT_TOOL_RESULT_MAX_BYTES:
|
||||
truncated_by = 'bytes'
|
||||
break
|
||||
if line_truncated and truncated_by is None:
|
||||
truncated_by = 'line'
|
||||
matches.append(entry)
|
||||
output_bytes += entry_bytes
|
||||
if len(matches) >= _GREP_MAX_MATCHES:
|
||||
truncated_by = truncated_by or 'matches'
|
||||
break
|
||||
if truncated_by == 'bytes' or len(matches) >= _GREP_MAX_MATCHES:
|
||||
break
|
||||
if truncated_by == 'bytes' or len(matches) >= _GREP_MAX_MATCHES:
|
||||
break
|
||||
|
||||
return {
|
||||
'ok': True,
|
||||
'matches': matches,
|
||||
'total': len(matches),
|
||||
'truncated': len(matches) >= 200,
|
||||
'truncated': truncated_by is not None,
|
||||
'truncated_by': truncated_by,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
@@ -828,10 +1033,266 @@ else:
|
||||
continue
|
||||
if item.is_file():
|
||||
results.append(item)
|
||||
if len(results) >= 5000:
|
||||
if len(results) >= _GREP_MAX_FILES:
|
||||
break
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
def _resolve_skill_host_path(selected_skill: dict, relative: str) -> str | None:
|
||||
package_root = str(selected_skill.get('package_root', '') or '').strip()
|
||||
if not package_root:
|
||||
return None
|
||||
|
||||
host_root = os.path.realpath(package_root)
|
||||
host_path = os.path.realpath(os.path.join(host_root, relative))
|
||||
if not (host_path == host_root or host_path.startswith(host_root + os.sep)):
|
||||
raise ValueError('Path escapes the skill package boundary.')
|
||||
return host_path
|
||||
|
||||
def _normalize_exec_result(self, result: dict) -> dict:
|
||||
normalized = dict(result)
|
||||
stdout = str(normalized.get('stdout') or '')
|
||||
stderr = str(normalized.get('stderr') or '')
|
||||
stdout, stdout_capped = self._truncate_text_to_bytes_with_flag(stdout, _DEFAULT_TOOL_RESULT_MAX_BYTES)
|
||||
stderr, stderr_capped = self._truncate_text_to_bytes_with_flag(stderr, _DEFAULT_TOOL_RESULT_MAX_BYTES)
|
||||
normalized['stdout'] = stdout
|
||||
normalized['stderr'] = stderr
|
||||
normalized['stdout_truncated'] = bool(normalized.get('stdout_truncated') or stdout_capped)
|
||||
normalized['stderr_truncated'] = bool(normalized.get('stderr_truncated') or stderr_capped)
|
||||
|
||||
if stdout and stderr:
|
||||
preview_raw = f'stdout:\n{stdout}\n\nstderr:\n{stderr}'
|
||||
else:
|
||||
preview_raw = stdout or stderr
|
||||
preview, preview_capped = self._truncate_text_to_bytes_with_flag(preview_raw, _DEFAULT_TOOL_RESULT_MAX_BYTES)
|
||||
normalized['preview'] = preview
|
||||
normalized['truncated'] = bool(
|
||||
normalized['stdout_truncated'] or normalized['stderr_truncated'] or preview_capped
|
||||
)
|
||||
if preview_capped and not normalized.get('truncated_by'):
|
||||
normalized['truncated_by'] = 'bytes'
|
||||
return normalized
|
||||
|
||||
def _build_directory_result(self, entries: list[str]) -> dict:
|
||||
sorted_entries = sorted(str(entry) for entry in entries)
|
||||
content = '\n'.join(sorted_entries)
|
||||
preview = self._truncate_text_to_bytes(content, _DEFAULT_TOOL_RESULT_MAX_BYTES)
|
||||
truncated = preview != content
|
||||
return {
|
||||
'ok': True,
|
||||
'content': preview,
|
||||
'is_directory': True,
|
||||
'total': len(sorted_entries),
|
||||
'truncated': truncated,
|
||||
'truncated_by': 'bytes' if truncated else None,
|
||||
}
|
||||
|
||||
def _read_text_file_preview(self, host_path: str, parameters: dict) -> dict:
|
||||
if self._read_encoding(parameters) == 'base64':
|
||||
return self._read_binary_file_chunk(host_path, parameters)
|
||||
|
||||
offset = self._positive_int(parameters.get('offset'), default=1)
|
||||
max_lines = self._positive_int(
|
||||
parameters.get('limit'),
|
||||
default=_DEFAULT_READ_MAX_LINES,
|
||||
max_value=_MAX_READ_MAX_LINES,
|
||||
)
|
||||
max_bytes = self._positive_int(
|
||||
parameters.get('max_bytes'),
|
||||
default=_DEFAULT_TOOL_RESULT_MAX_BYTES,
|
||||
max_value=_DEFAULT_TOOL_RESULT_MAX_BYTES,
|
||||
)
|
||||
lines: list[str] = []
|
||||
output_bytes = 0
|
||||
end_line = offset - 1
|
||||
truncated = False
|
||||
truncated_by: str | None = None
|
||||
next_offset: int | None = None
|
||||
|
||||
with open(host_path, 'r', encoding='utf-8', errors='replace') as f:
|
||||
for line_number, line in enumerate(f, 1):
|
||||
if line_number < offset:
|
||||
continue
|
||||
if len(lines) >= max_lines:
|
||||
truncated = True
|
||||
truncated_by = 'lines'
|
||||
next_offset = line_number
|
||||
break
|
||||
|
||||
line_bytes = len(line.encode('utf-8'))
|
||||
if output_bytes + line_bytes > max_bytes:
|
||||
truncated = True
|
||||
truncated_by = 'bytes'
|
||||
next_offset = line_number
|
||||
break
|
||||
|
||||
lines.append(line.rstrip('\n'))
|
||||
output_bytes += line_bytes
|
||||
end_line = line_number
|
||||
|
||||
if not lines and truncated_by == 'bytes':
|
||||
content = (
|
||||
f'[Line {next_offset or offset} exceeds the {self._format_size(max_bytes)} read limit. '
|
||||
'Use exec with a byte-range command for this line, or read a different offset.]'
|
||||
)
|
||||
else:
|
||||
content = '\n'.join(lines)
|
||||
|
||||
return {
|
||||
'ok': True,
|
||||
'content': content,
|
||||
'truncated': truncated,
|
||||
'truncated_by': truncated_by,
|
||||
'start_line': offset,
|
||||
'end_line': end_line,
|
||||
'next_offset': next_offset,
|
||||
'max_lines': max_lines,
|
||||
'max_bytes': max_bytes,
|
||||
}
|
||||
|
||||
def _read_binary_file_chunk(self, host_path: str, parameters: dict) -> dict:
|
||||
byte_offset = self._non_negative_int(parameters.get('byte_offset'), default=0)
|
||||
max_bytes = self._positive_int(
|
||||
parameters.get('max_bytes'),
|
||||
default=_DEFAULT_TOOL_RESULT_MAX_BYTES,
|
||||
max_value=_DEFAULT_TOOL_RESULT_MAX_BYTES,
|
||||
)
|
||||
size_bytes = os.path.getsize(host_path)
|
||||
with open(host_path, 'rb') as f:
|
||||
f.seek(byte_offset)
|
||||
data = f.read(max_bytes + 1)
|
||||
chunk = data[:max_bytes]
|
||||
has_more = len(data) > max_bytes
|
||||
return {
|
||||
'ok': True,
|
||||
'content': base64.b64encode(chunk).decode('ascii'),
|
||||
'encoding': 'base64',
|
||||
'byte_offset': byte_offset,
|
||||
'length': len(chunk),
|
||||
'size_bytes': size_bytes,
|
||||
'has_more': has_more,
|
||||
'next_byte_offset': byte_offset + len(chunk) if has_more else None,
|
||||
'max_bytes': max_bytes,
|
||||
}
|
||||
|
||||
def _write_host_file(self, host_path: str, content: str, parameters: dict) -> None:
|
||||
encoding, mode = self._write_options(parameters)
|
||||
if encoding == 'base64':
|
||||
try:
|
||||
data = base64.b64decode(content, validate=True)
|
||||
except Exception as exc:
|
||||
raise ValueError(f'invalid base64 content: {exc}') from exc
|
||||
with open(host_path, 'ab' if mode == 'append' else 'wb') as f:
|
||||
f.write(data)
|
||||
return
|
||||
with open(host_path, 'a' if mode == 'append' else 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
|
||||
@staticmethod
|
||||
def _read_encoding(parameters: dict) -> str:
|
||||
return 'base64' if parameters.get('encoding') == 'base64' else 'text'
|
||||
|
||||
@staticmethod
|
||||
def _write_options(parameters: dict) -> tuple[str, str]:
|
||||
encoding = 'base64' if parameters.get('encoding') == 'base64' else 'text'
|
||||
mode = 'append' if parameters.get('mode') == 'append' else 'overwrite'
|
||||
return encoding, mode
|
||||
|
||||
def _build_read_result_from_text(self, content: str, parameters: dict) -> dict:
|
||||
offset = self._positive_int(parameters.get('offset'), default=1)
|
||||
max_lines = self._positive_int(
|
||||
parameters.get('limit'),
|
||||
default=_DEFAULT_READ_MAX_LINES,
|
||||
max_value=_MAX_READ_MAX_LINES,
|
||||
)
|
||||
max_bytes = self._positive_int(
|
||||
parameters.get('max_bytes'),
|
||||
default=_DEFAULT_TOOL_RESULT_MAX_BYTES,
|
||||
max_value=_DEFAULT_TOOL_RESULT_MAX_BYTES,
|
||||
)
|
||||
all_lines = content.splitlines()
|
||||
start_index = offset - 1
|
||||
if start_index >= len(all_lines) and all_lines:
|
||||
return {'ok': False, 'error': f'Offset {offset} is beyond end of file ({len(all_lines)} lines total)'}
|
||||
output_lines: list[str] = []
|
||||
output_bytes = 0
|
||||
truncated = False
|
||||
truncated_by: str | None = None
|
||||
next_offset: int | None = None
|
||||
for index, line in enumerate(all_lines[start_index:], start_index + 1):
|
||||
if len(output_lines) >= max_lines:
|
||||
truncated = True
|
||||
truncated_by = 'lines'
|
||||
next_offset = index
|
||||
break
|
||||
line_bytes = len(line.encode('utf-8')) + (1 if output_lines else 0)
|
||||
if output_bytes + line_bytes > max_bytes:
|
||||
truncated = True
|
||||
truncated_by = 'bytes'
|
||||
next_offset = index
|
||||
break
|
||||
output_lines.append(line)
|
||||
output_bytes += line_bytes
|
||||
|
||||
end_line = offset + len(output_lines) - 1
|
||||
return {
|
||||
'ok': True,
|
||||
'content': '\n'.join(output_lines),
|
||||
'truncated': truncated,
|
||||
'truncated_by': truncated_by,
|
||||
'start_line': offset,
|
||||
'end_line': end_line,
|
||||
'next_offset': next_offset,
|
||||
'max_lines': max_lines,
|
||||
'max_bytes': max_bytes,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _positive_int(value, *, default: int, max_value: int | None = None) -> int:
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError):
|
||||
parsed = default
|
||||
if parsed <= 0:
|
||||
parsed = default
|
||||
if max_value is not None:
|
||||
parsed = min(parsed, max_value)
|
||||
return parsed
|
||||
|
||||
@staticmethod
|
||||
def _non_negative_int(value, *, default: int) -> int:
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError):
|
||||
parsed = default
|
||||
return parsed if parsed >= 0 else default
|
||||
|
||||
@staticmethod
|
||||
def _truncate_grep_line(line: str) -> tuple[str, bool]:
|
||||
if len(line) <= _GREP_MAX_LINE_CHARS:
|
||||
return line, False
|
||||
return f'{line[:_GREP_MAX_LINE_CHARS]}... [truncated]', True
|
||||
|
||||
@staticmethod
|
||||
def _truncate_text_to_bytes(text: str, max_bytes: int) -> str:
|
||||
return NativeToolLoader._truncate_text_to_bytes_with_flag(text, max_bytes)[0]
|
||||
|
||||
@staticmethod
|
||||
def _truncate_text_to_bytes_with_flag(text: str, max_bytes: int) -> tuple[str, bool]:
|
||||
data = text.encode('utf-8')
|
||||
if len(data) <= max_bytes:
|
||||
return text, False
|
||||
truncated = data[:max_bytes]
|
||||
while truncated and (truncated[-1] & 0xC0) == 0x80:
|
||||
truncated = truncated[:-1]
|
||||
return truncated.decode('utf-8', errors='ignore'), True
|
||||
|
||||
@staticmethod
|
||||
def _format_size(bytes_count: int) -> str:
|
||||
if bytes_count < 1024:
|
||||
return f'{bytes_count}B'
|
||||
return f'{bytes_count / 1024:.1f}KB'
|
||||
|
||||
def _summarize_parameters(self, parameters: dict) -> dict:
|
||||
summary = dict(parameters)
|
||||
cmd = str(summary.get('command', '')).strip()
|
||||
|
||||
@@ -72,6 +72,45 @@ def register_activated_skill(query: pipeline_query.Query, skill_data: dict) -> N
|
||||
activated[skill_name] = skill_data
|
||||
|
||||
|
||||
def normalize_skill_names(value: typing.Any) -> list[str]:
|
||||
"""Return a de-duplicated list of non-empty skill names."""
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
|
||||
names: list[str] = []
|
||||
for item in value:
|
||||
skill_name = str(item or '').strip()
|
||||
if skill_name and skill_name not in names:
|
||||
names.append(skill_name)
|
||||
return names
|
||||
|
||||
|
||||
def get_activated_skill_names(query: pipeline_query.Query) -> list[str]:
|
||||
"""Return activated skill names for callers that own persistence policy."""
|
||||
return normalize_skill_names(list(get_activated_skills(query).keys()))
|
||||
|
||||
|
||||
def restore_activated_skills(
|
||||
ap: app.Application,
|
||||
query: pipeline_query.Query,
|
||||
skill_names: typing.Any,
|
||||
) -> list[str]:
|
||||
"""Restore caller-provided activated skill names into Query variables.
|
||||
|
||||
Persistence and state scope ownership belong to higher-level flows. This
|
||||
helper only rebuilds current Query state from pipeline-visible skills, so
|
||||
removed or unbound skills stay unavailable to native exec/write/edit.
|
||||
"""
|
||||
restored: list[str] = []
|
||||
for skill_name in normalize_skill_names(skill_names):
|
||||
skill_data = get_visible_skill(ap, query, skill_name)
|
||||
if skill_data is None:
|
||||
continue
|
||||
register_activated_skill(query, skill_data)
|
||||
restored.append(skill_name)
|
||||
return restored
|
||||
|
||||
|
||||
def parse_skill_mount_path(sandbox_path: str) -> tuple[str | None, str]:
|
||||
normalized_path = str(sandbox_path or '/workspace').strip() or '/workspace'
|
||||
if normalized_path == SKILL_MOUNT_PREFIX:
|
||||
|
||||
@@ -6,6 +6,7 @@ import typing
|
||||
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
||||
|
||||
from .. import loader
|
||||
from .availability import is_box_backend_available
|
||||
|
||||
# Align with Claude Code's Skill tool design:
|
||||
# - activate: Activate a skill via Tool Call, returns SKILL.md content
|
||||
@@ -45,18 +46,7 @@ class SkillToolLoader(loader.ToolLoader):
|
||||
|
||||
async def _check_sandbox_available(self) -> bool:
|
||||
"""Check if the box backend is truly available (not just the runtime)."""
|
||||
box_service = getattr(self.ap, 'box_service', None)
|
||||
if box_service is None:
|
||||
return False
|
||||
if not getattr(box_service, 'available', False):
|
||||
return False
|
||||
# Check if backend is truly available via get_status
|
||||
try:
|
||||
status = await box_service.get_status()
|
||||
backend_info = status.get('backend', {})
|
||||
return backend_info.get('available', False)
|
||||
except Exception:
|
||||
return False
|
||||
return await is_box_backend_available(self.ap)
|
||||
|
||||
async def get_tools(self, bound_plugins: list[str] | None = None) -> list[resource_tool.LLMTool]:
|
||||
if not self._is_available():
|
||||
@@ -92,16 +82,15 @@ class SkillToolLoader(loader.ToolLoader):
|
||||
if not skill_name:
|
||||
raise ValueError('skill_name is required')
|
||||
|
||||
skill_mgr = self.ap.skill_mgr
|
||||
skill_data = skill_mgr.get_skill_by_name(skill_name)
|
||||
from . import skill as skill_loader
|
||||
|
||||
skill_data = skill_loader.get_visible_skill(self.ap, query, skill_name)
|
||||
if skill_data is None:
|
||||
visible_skills = getattr(skill_mgr, 'skills', {})
|
||||
visible_skills = skill_loader.get_visible_skills(self.ap, query)
|
||||
available_names = ', '.join(sorted(visible_skills.keys())) or 'none'
|
||||
raise ValueError(f'Skill "{skill_name}" not found. Available skills: {available_names}')
|
||||
|
||||
# Register activated skill for sandbox mount path resolution
|
||||
from . import skill as skill_loader
|
||||
|
||||
skill_loader.register_activated_skill(query, skill_data)
|
||||
|
||||
# Return SKILL.md content as Tool Result (injects into context)
|
||||
@@ -127,6 +116,7 @@ class SkillToolLoader(loader.ToolLoader):
|
||||
'activated': True,
|
||||
'skill_name': skill_name,
|
||||
'mount_path': mount_path,
|
||||
'activated_skill_names': skill_loader.get_activated_skill_names(query),
|
||||
'content': result_content,
|
||||
}
|
||||
|
||||
@@ -201,13 +191,13 @@ class SkillToolLoader(loader.ToolLoader):
|
||||
return resource_tool.LLMTool(
|
||||
name=ACTIVATE_SKILL_TOOL_NAME,
|
||||
human_desc='Activate a skill',
|
||||
description=self._build_activate_tool_description(),
|
||||
description='Activate a pipeline-visible skill by name and return its instructions as a tool result.',
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'skill_name': {
|
||||
'type': 'string',
|
||||
'description': 'The skill name to activate (no arguments). E.g., "pdf" or "data-analysis"',
|
||||
'description': 'The skill name to activate.',
|
||||
},
|
||||
},
|
||||
'required': ['skill_name'],
|
||||
@@ -255,50 +245,3 @@ class SkillToolLoader(loader.ToolLoader):
|
||||
},
|
||||
func=lambda parameters: parameters,
|
||||
)
|
||||
|
||||
def _build_activate_tool_description(self) -> str:
|
||||
"""Build tool description with embedded available_skills list."""
|
||||
skill_mgr = getattr(self.ap, 'skill_mgr', None)
|
||||
if skill_mgr is None:
|
||||
return 'Activate a skill. No skills are currently available.'
|
||||
|
||||
skills = getattr(skill_mgr, 'skills', {})
|
||||
if not skills:
|
||||
return 'Activate a skill. No skills are currently available.'
|
||||
|
||||
# Build <available_skills> section
|
||||
available_skills_lines = ['<available_skills>']
|
||||
for skill_name, skill_data in sorted(skills.items()):
|
||||
description = skill_data.get('description', '')
|
||||
available_skills_lines.append('<skill>')
|
||||
available_skills_lines.append(f'<name>{skill_name}</name>')
|
||||
available_skills_lines.append(f'<description>{description}</description>')
|
||||
available_skills_lines.append('</skill>')
|
||||
available_skills_lines.append('</available_skills>')
|
||||
|
||||
available_skills_block = '\n'.join(available_skills_lines)
|
||||
|
||||
return f"""Activate a skill within the main conversation.
|
||||
|
||||
<skills_instructions>
|
||||
When users ask you to perform tasks, check if any of the available skills
|
||||
below can help complete the task more effectively. Skills provide specialized
|
||||
capabilities and domain knowledge.
|
||||
|
||||
How to use skills:
|
||||
- Invoke skills using this tool with the skill name only (no arguments)
|
||||
- When you invoke a skill, you will see <command-message>
|
||||
The skill is activated
|
||||
</command-message>
|
||||
- The skill's instructions will be provided in the tool result
|
||||
- Examples:
|
||||
- skill_name: "pdf" - invoke the pdf skill
|
||||
- skill_name: "data-analysis" - invoke the data-analysis skill
|
||||
|
||||
Important:
|
||||
- Only use skills listed in <available_skills> below
|
||||
- Do not invoke a skill that is already running
|
||||
- To create a new skill: prepare it in /workspace, then use register_skill tool
|
||||
</skills_instructions>
|
||||
|
||||
{available_skills_block}"""
|
||||
|
||||
@@ -546,6 +546,41 @@ async def test_box_service_rejects_host_mount_outside_allowed_roots(tmp_path):
|
||||
)
|
||||
|
||||
|
||||
class TestGetSystemGuidance:
|
||||
"""``get_system_guidance`` must ALWAYS advertise the per-query outbox path
|
||||
when given a ``query_id`` — even with no inbound attachment — so files the
|
||||
agent generates (QR codes, charts, rendered docs) are actually delivered.
|
||||
|
||||
The wrapper collects the outbox on every turn regardless of inbound files;
|
||||
before this, the agent was only told the outbox path inside the
|
||||
inbound-attachment note, so pure-generation turns produced files that were
|
||||
silently dropped.
|
||||
"""
|
||||
|
||||
def _service(self, logger=None):
|
||||
logger = logger or Mock()
|
||||
runtime = BoxRuntime(logger=logger, backends=[FakeBackend(logger)], session_ttl_sec=300)
|
||||
return BoxService(make_app(logger), client=_InProcessBoxRuntimeClient(logger, runtime))
|
||||
|
||||
def test_guidance_includes_outbox_when_query_id_given(self):
|
||||
service = self._service()
|
||||
guidance = service.get_system_guidance(42)
|
||||
assert f'{service.OUTBOX_MOUNT_DIR}/42' in guidance
|
||||
assert 'delivered to the user automatically' in guidance
|
||||
|
||||
def test_guidance_omits_outbox_without_query_id(self):
|
||||
service = self._service()
|
||||
guidance = service.get_system_guidance()
|
||||
assert service.OUTBOX_MOUNT_DIR not in guidance
|
||||
# core exec guidance is still present
|
||||
assert 'exec tool' in guidance
|
||||
|
||||
def test_guidance_outbox_independent_of_inbound_attachments(self):
|
||||
# A bare query_id (the pure-generation case) still gets the outbox note.
|
||||
service = self._service()
|
||||
assert f'{service.OUTBOX_MOUNT_DIR}/0' in service.get_system_guidance(0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_box_runtime_rejects_host_mount_conflict_in_same_session(tmp_path):
|
||||
logger = Mock()
|
||||
@@ -1556,3 +1591,347 @@ class TestBuildSkillExtraMounts:
|
||||
service = BoxService(app, client=Mock(spec=BoxRuntimeClient))
|
||||
|
||||
assert service.build_skill_extra_mounts(make_query()) == []
|
||||
|
||||
|
||||
# ── Attachment passthrough (inbound / outbound) ─────────────────────────────
|
||||
|
||||
|
||||
class TestAttachmentHelpers:
|
||||
def test_sanitize_attachment_name_strips_traversal(self):
|
||||
assert BoxService._sanitize_attachment_name('../../etc/passwd', 'fb') == 'passwd'
|
||||
assert BoxService._sanitize_attachment_name('/a/b/c.png', 'fb') == 'c.png'
|
||||
assert BoxService._sanitize_attachment_name('a b c.txt', 'fb') == 'a_b_c.txt'
|
||||
assert BoxService._sanitize_attachment_name('', 'fallback.bin') == 'fallback.bin'
|
||||
assert BoxService._sanitize_attachment_name('...', 'fb.bin') == 'fb.bin'
|
||||
# weird unicode / shell chars dropped, but keeps a usable name
|
||||
out = BoxService._sanitize_attachment_name('rm -rf $(x).png', 'fb')
|
||||
assert '/' not in out and '$' not in out and out.endswith('.png')
|
||||
|
||||
def test_classify_outbound_entries_by_extension(self):
|
||||
entries = [
|
||||
{'name': 'chart.png', 'b64': 'AAA'},
|
||||
{'name': 'clip.mp3', 'b64': 'BBB'},
|
||||
{'name': 'report.pdf', 'b64': 'CCC'},
|
||||
{'name': 'sub/dir/photo.JPG', 'b64': 'DDD'},
|
||||
{'name': 'noext', 'b64': 'EEE'},
|
||||
{'name': 'skip', 'b64': ''}, # dropped (no payload)
|
||||
]
|
||||
out = BoxService._classify_outbound_entries(entries)
|
||||
by_name = {a['name']: a for a in out}
|
||||
assert by_name['chart.png']['type'] == 'Image'
|
||||
assert by_name['chart.png']['base64'].startswith('data:image/png;base64,')
|
||||
assert by_name['clip.mp3']['type'] == 'Voice'
|
||||
assert by_name['clip.mp3']['base64'].startswith('data:audio/mp3;base64,')
|
||||
assert by_name['report.pdf']['type'] == 'File'
|
||||
assert by_name['report.pdf']['base64'] == 'CCC' # raw b64, no data: prefix
|
||||
# nested path collapses to basename, case-insensitive ext
|
||||
assert by_name['photo.JPG']['type'] == 'Image'
|
||||
assert by_name['noext']['type'] == 'File'
|
||||
assert 'skip' not in by_name
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_component_to_bytes_from_data_uri(self):
|
||||
import base64
|
||||
|
||||
raw = b'hello-bytes'
|
||||
data_uri = 'data:text/plain;base64,' + base64.b64encode(raw).decode()
|
||||
component = SimpleNamespace(base64=data_uri, url=None, path=None)
|
||||
result = await BoxService._component_to_bytes(component)
|
||||
assert result is not None
|
||||
data, mime = result
|
||||
assert data == raw
|
||||
assert mime == 'text/plain'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_component_to_bytes_returns_none_when_empty(self):
|
||||
component = SimpleNamespace(base64=None, url=None, path=None)
|
||||
assert await BoxService._component_to_bytes(component) is None
|
||||
|
||||
|
||||
class TestInboundOutboundRoundTrip:
|
||||
def _service(self) -> BoxService:
|
||||
service = BoxService(make_app(Mock()), client=Mock(spec=BoxRuntimeClient))
|
||||
service._available = True
|
||||
return service
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_materialize_inbound_writes_and_describes(self):
|
||||
import base64
|
||||
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
|
||||
service = self._service()
|
||||
|
||||
img_bytes = b'\x89PNG\r\n\x1a\n fake png'
|
||||
img_b64 = 'data:image/png;base64,' + base64.b64encode(img_bytes).decode()
|
||||
|
||||
query = make_query()
|
||||
query.message_chain = platform_message.MessageChain(
|
||||
[
|
||||
platform_message.Plain(text='please resize this'),
|
||||
platform_message.Image(base64=img_b64),
|
||||
]
|
||||
)
|
||||
|
||||
# Mock the sandbox write path: echo back the written paths.
|
||||
async def fake_execute_tool(parameters, q):
|
||||
assert '/workspace/inbox/' in parameters['command']
|
||||
return {
|
||||
'ok': True,
|
||||
'stdout': '["/workspace/inbox/42/image_1.png"]',
|
||||
'stderr': '',
|
||||
}
|
||||
|
||||
service.execute_tool = AsyncMock(side_effect=fake_execute_tool)
|
||||
|
||||
descriptors = await service.materialize_inbound_attachments(query)
|
||||
assert len(descriptors) == 1
|
||||
d = descriptors[0]
|
||||
assert d['type'] == 'Image'
|
||||
assert d['path'] == '/workspace/inbox/42/image_1.png'
|
||||
assert d['size'] == len(img_bytes)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_materialize_inbound_noop_without_attachments(self):
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
|
||||
service = self._service()
|
||||
query = make_query()
|
||||
query.message_chain = platform_message.MessageChain([platform_message.Plain(text='just text')])
|
||||
service.execute_tool = AsyncMock()
|
||||
assert await service.materialize_inbound_attachments(query) == []
|
||||
service.execute_tool.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collect_outbound_reads_and_clears(self):
|
||||
service = self._service()
|
||||
query = make_query()
|
||||
|
||||
calls = []
|
||||
|
||||
async def fake_execute_tool(parameters, q):
|
||||
calls.append(parameters['command'])
|
||||
if 'os.walk' in parameters['command']:
|
||||
return {
|
||||
'ok': True,
|
||||
'stdout': '[{"name": "out.png", "b64": "QUJD"}]',
|
||||
'stderr': '',
|
||||
}
|
||||
# the rm -rf cleanup call
|
||||
return {'ok': True, 'stdout': '', 'stderr': ''}
|
||||
|
||||
service.execute_tool = AsyncMock(side_effect=fake_execute_tool)
|
||||
|
||||
attachments = await service.collect_outbound_attachments(query)
|
||||
assert len(attachments) == 1
|
||||
assert attachments[0]['type'] == 'Image'
|
||||
assert attachments[0]['name'] == 'out.png'
|
||||
# cleanup (rm -rf) must have been issued after a successful collection
|
||||
assert any('rm -rf' in c for c in calls)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collect_outbound_empty_still_clears(self):
|
||||
# An empty collection MUST still clear the per-query outbox, so a later
|
||||
# turn reusing the same query_id (the counter resets across restarts)
|
||||
# cannot inherit stale files left from a prior run.
|
||||
service = self._service()
|
||||
query = make_query()
|
||||
|
||||
calls = []
|
||||
|
||||
async def fake_execute_tool(parameters, q):
|
||||
calls.append(parameters['command'])
|
||||
if 'os.walk' in parameters['command']:
|
||||
return {'ok': True, 'stdout': '[]', 'stderr': ''}
|
||||
return {'ok': True, 'stdout': '', 'stderr': ''}
|
||||
|
||||
service.execute_tool = AsyncMock(side_effect=fake_execute_tool)
|
||||
assert await service.collect_outbound_attachments(query) == []
|
||||
# cleanup (rm -rf) is issued unconditionally now
|
||||
assert any('rm -rf' in c for c in calls)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_passthrough_noop_when_unavailable(self):
|
||||
service = BoxService(make_app(Mock()), client=Mock(spec=BoxRuntimeClient))
|
||||
service._available = False
|
||||
query = make_query()
|
||||
assert await service.materialize_inbound_attachments(query) == []
|
||||
assert await service.collect_outbound_attachments(query) == []
|
||||
|
||||
|
||||
class TestAttachmentHostPath:
|
||||
"""Direct host-filesystem transfer path (bind-mounted workspace).
|
||||
|
||||
When ``default_workspace`` is a real local dir, inbound/outbound bypass the
|
||||
exec channel entirely (no ARG_MAX / stdout-truncation limits) and read/write
|
||||
the bind-mounted host dir directly.
|
||||
"""
|
||||
|
||||
def _service_with_workspace(self, tmp_path):
|
||||
ws = str(tmp_path / 'box' / 'default')
|
||||
os.makedirs(ws, exist_ok=True)
|
||||
app = make_app(Mock(), allowed_mount_roots=[str(tmp_path)], host_root=str(tmp_path / 'box'))
|
||||
service = BoxService(app, client=Mock(spec=BoxRuntimeClient))
|
||||
service._available = True
|
||||
# Force the default_workspace to our tmp dir so _host_query_dir resolves.
|
||||
service.default_workspace = ws
|
||||
return service, ws
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inbound_writes_to_host_no_exec(self, tmp_path):
|
||||
import base64
|
||||
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
|
||||
service, ws = self._service_with_workspace(tmp_path)
|
||||
# Big payload that would blow ARG_MAX on the exec path:
|
||||
big = b'\x89PNG\r\n\x1a\n' + b'x' * (300 * 1024)
|
||||
b64 = 'data:image/png;base64,' + base64.b64encode(big).decode()
|
||||
query = make_query()
|
||||
query.message_chain = platform_message.MessageChain([platform_message.Image(base64=b64)])
|
||||
# execute_tool must NOT be called on the host path.
|
||||
service.execute_tool = AsyncMock(side_effect=AssertionError('exec must not be used on host path'))
|
||||
|
||||
descriptors = await service.materialize_inbound_attachments(query)
|
||||
assert len(descriptors) == 1
|
||||
d = descriptors[0]
|
||||
assert d['type'] == 'Image'
|
||||
assert d['size'] == len(big)
|
||||
# File actually landed on the host workspace.
|
||||
host_file = os.path.join(ws, 'inbox', str(query.query_id), d['name'])
|
||||
assert os.path.isfile(host_file)
|
||||
assert open(host_file, 'rb').read() == big
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inbound_host_clears_stale_query_dir(self, tmp_path):
|
||||
import base64
|
||||
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
|
||||
service, ws = self._service_with_workspace(tmp_path)
|
||||
# Seed a stale file under the same query_id (simulates webchat id reuse).
|
||||
stale_dir = os.path.join(ws, 'inbox', '42')
|
||||
os.makedirs(stale_dir, exist_ok=True)
|
||||
open(os.path.join(stale_dir, 'image_1.png'), 'wb').write(b'STALE-OLD-IMAGE')
|
||||
|
||||
new = b'\x89PNG\r\n\x1a\n NEW'
|
||||
b64 = 'data:image/png;base64,' + base64.b64encode(new).decode()
|
||||
query = make_query(query_id=42)
|
||||
query.message_chain = platform_message.MessageChain([platform_message.Image(base64=b64)])
|
||||
service.execute_tool = AsyncMock()
|
||||
descriptors = await service.materialize_inbound_attachments(query)
|
||||
# The new write recreated the dir; the stale file is gone, new bytes present.
|
||||
host_file = os.path.join(stale_dir, descriptors[0]['name'])
|
||||
assert open(host_file, 'rb').read() == new
|
||||
# No leftover content from the stale image.
|
||||
assert b'STALE-OLD-IMAGE' not in open(host_file, 'rb').read()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_outbound_reads_host_and_clears(self, tmp_path):
|
||||
service, ws = self._service_with_workspace(tmp_path)
|
||||
query = make_query()
|
||||
outbox = os.path.join(ws, 'outbox', str(query.query_id))
|
||||
os.makedirs(outbox, exist_ok=True)
|
||||
# A large file that would be truncated on the exec/stdout path:
|
||||
big_png = b'\x89PNG\r\n\x1a\n' + b'y' * (400 * 1024)
|
||||
open(os.path.join(outbox, 'result.png'), 'wb').write(big_png)
|
||||
open(os.path.join(outbox, 'notes.txt'), 'wb').write(b'hello')
|
||||
|
||||
service.execute_tool = AsyncMock(side_effect=AssertionError('exec must not be used on host path'))
|
||||
attachments = await service.collect_outbound_attachments(query)
|
||||
by_name = {a['name']: a for a in attachments}
|
||||
assert by_name['result.png']['type'] == 'Image'
|
||||
assert by_name['notes.txt']['type'] == 'File'
|
||||
# Full image survived (no truncation).
|
||||
import base64
|
||||
|
||||
raw = base64.b64decode(by_name['result.png']['base64'].split(',', 1)[-1])
|
||||
assert raw == big_png
|
||||
# Outbox cleared after collection.
|
||||
assert os.listdir(outbox) == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_outbound_empty_clears_stale_host_dir(self, tmp_path):
|
||||
# Reusing a query_id (counter resets on restart) must not re-send files
|
||||
# a previous run left in the outbox: an empty collection still clears it.
|
||||
service, ws = self._service_with_workspace(tmp_path)
|
||||
query = make_query()
|
||||
outbox = os.path.join(ws, 'outbox', str(query.query_id))
|
||||
os.makedirs(outbox, exist_ok=True)
|
||||
# Stale file from a prior turn; the agent produced nothing this turn —
|
||||
# but _read_outbox_host would still pick it up, so collection must drop
|
||||
# it and then wipe the dir. Simulate "nothing produced this turn" by
|
||||
# treating any present file as stale and asserting it is not re-sent
|
||||
# across a second, genuinely-empty collection.
|
||||
open(os.path.join(outbox, 'stale.png'), 'wb').write(b'\x89PNG\r\n\x1a\n old')
|
||||
service.execute_tool = AsyncMock(side_effect=AssertionError('exec must not be used on host path'))
|
||||
|
||||
# First collection drains + clears the dir.
|
||||
first = await service.collect_outbound_attachments(query)
|
||||
assert {a['name'] for a in first} == {'stale.png'}
|
||||
assert os.listdir(outbox) == []
|
||||
|
||||
# Second collection (no new files) returns nothing and leaves a clean dir.
|
||||
second = await service.collect_outbound_attachments(query)
|
||||
assert second == []
|
||||
assert os.listdir(outbox) == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_purge_attachment_dirs_wipes_host_owned_leftovers_on_init(self, tmp_path):
|
||||
# Leftover inbox/outbox dirs from a previous process (same reset
|
||||
# query_id counter) must be removed at startup. Host-owned files are
|
||||
# cleared without any sandbox exec.
|
||||
service, ws = self._service_with_workspace(tmp_path)
|
||||
for sub in ('inbox', 'outbox'):
|
||||
d = os.path.join(ws, sub, '0')
|
||||
os.makedirs(d, exist_ok=True)
|
||||
open(os.path.join(d, 'leftover.bin'), 'wb').write(b'from a previous process')
|
||||
service.execute_tool = AsyncMock(side_effect=AssertionError('exec must not be used for host-owned files'))
|
||||
|
||||
await service._purge_attachment_dirs()
|
||||
|
||||
assert not os.path.exists(os.path.join(ws, 'inbox'))
|
||||
assert not os.path.exists(os.path.join(ws, 'outbox'))
|
||||
# The workspace root itself survives.
|
||||
assert os.path.isdir(ws)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_purge_attachment_dirs_falls_back_to_exec_for_root_owned(self, tmp_path, monkeypatch):
|
||||
# When the host delete cannot remove a dir (root-owned container output),
|
||||
# purge must fall back to deleting from inside the sandbox via exec.
|
||||
service, ws = self._service_with_workspace(tmp_path)
|
||||
outbox = os.path.join(ws, 'outbox')
|
||||
os.makedirs(os.path.join(outbox, '0'), exist_ok=True)
|
||||
|
||||
# Simulate a host delete that cannot remove the root-owned outbox.
|
||||
import shutil as _shutil
|
||||
|
||||
real_rmtree = _shutil.rmtree
|
||||
|
||||
def fake_rmtree(path, *a, **k):
|
||||
if os.path.abspath(path) == os.path.abspath(outbox):
|
||||
return # "permission denied" — silently leaves the dir
|
||||
return real_rmtree(path, *a, **k)
|
||||
|
||||
monkeypatch.setattr(_shutil, 'rmtree', fake_rmtree)
|
||||
|
||||
executed = {}
|
||||
spec_obj = object()
|
||||
service.build_spec = Mock(return_value=spec_obj)
|
||||
service.client.execute = AsyncMock(side_effect=lambda s: executed.setdefault('spec', s))
|
||||
|
||||
await service._purge_attachment_dirs()
|
||||
|
||||
# build_spec was asked to rm the surviving outbox via exec.
|
||||
cmd = service.build_spec.call_args.args[0]['cmd']
|
||||
assert 'rm -rf' in cmd and '/workspace/outbox' in cmd
|
||||
assert '/workspace/inbox' not in cmd # inbox was host-deletable
|
||||
service.client.execute.assert_awaited_once_with(spec_obj)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_purge_attachment_dirs_noop_without_workspace(self):
|
||||
# No bind-mounted workspace (E2B / remote): purge is a safe no-op.
|
||||
service = BoxService(make_app(Mock()), client=Mock(spec=BoxRuntimeClient))
|
||||
service.default_workspace = None
|
||||
# Must not raise.
|
||||
await service._purge_attachment_dirs()
|
||||
|
||||
@@ -54,7 +54,9 @@ def test_classify_python_workspace_detects_package_and_requirements():
|
||||
def test_wrap_python_command_with_env_contains_bootstrap_and_command():
|
||||
command = wrap_python_command_with_env('python script.py')
|
||||
|
||||
assert 'python -m venv "$_LB_VENV_DIR"' in command
|
||||
assert '_LB_SYSTEM_PYTHON="$(command -v python3 || command -v python || true)"' in command
|
||||
assert '"$_LB_SYSTEM_PYTHON" -m venv "$_LB_VENV_DIR"' in command
|
||||
assert 'kill -0 "$_LB_LOCK_OWNER"' in command
|
||||
assert 'export VIRTUAL_ENV="$_LB_VENV_DIR"' in command
|
||||
assert command.rstrip().endswith('python script.py')
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Unit tests for ResponseWrapper outbound-attachment helpers.
|
||||
|
||||
Covers the sandbox -> user attachment path added for the Box attachment
|
||||
round-trip:
|
||||
|
||||
* ``_is_final_assistant_message`` — only the terminal, tool-call-free assistant
|
||||
message (or a final MessageChunk) should trigger collection.
|
||||
* ``_append_outbound_attachments`` — collects sandbox outbox files exactly once
|
||||
per query and maps each descriptor to the right platform component, swallowing
|
||||
collection errors.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
|
||||
from langbot.pkg.pipeline.wrapper.wrapper import ResponseWrapper
|
||||
|
||||
|
||||
def _make_wrapper(box_service) -> ResponseWrapper:
|
||||
app = SimpleNamespace(logger=Mock())
|
||||
wrapper = ResponseWrapper.__new__(ResponseWrapper)
|
||||
wrapper.ap = app
|
||||
return wrapper
|
||||
|
||||
|
||||
def _make_query():
|
||||
return SimpleNamespace(variables={})
|
||||
|
||||
|
||||
def test_is_final_assistant_message_plain_assistant():
|
||||
wrapper = _make_wrapper(box_service=None)
|
||||
msg = provider_message.Message(role='assistant', content='done')
|
||||
assert wrapper._is_final_assistant_message(msg) is True
|
||||
|
||||
|
||||
def test_is_final_assistant_message_rejects_non_assistant():
|
||||
wrapper = _make_wrapper(box_service=None)
|
||||
msg = provider_message.Message(role='tool', content='{}')
|
||||
assert wrapper._is_final_assistant_message(msg) is False
|
||||
|
||||
|
||||
def test_is_final_assistant_message_rejects_tool_call_round():
|
||||
wrapper = _make_wrapper(box_service=None)
|
||||
msg = provider_message.Message(
|
||||
role='assistant',
|
||||
content='calling',
|
||||
tool_calls=[
|
||||
provider_message.ToolCall(
|
||||
id='c1',
|
||||
type='function',
|
||||
function=provider_message.FunctionCall(name='exec', arguments='{}'),
|
||||
)
|
||||
],
|
||||
)
|
||||
assert wrapper._is_final_assistant_message(msg) is False
|
||||
|
||||
|
||||
def test_is_final_assistant_message_non_final_chunk():
|
||||
wrapper = _make_wrapper(box_service=None)
|
||||
chunk = provider_message.MessageChunk(role='assistant', content='partial', is_final=False)
|
||||
assert wrapper._is_final_assistant_message(chunk) is False
|
||||
|
||||
final_chunk = provider_message.MessageChunk(role='assistant', content='partial', is_final=True)
|
||||
assert wrapper._is_final_assistant_message(final_chunk) is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_outbound_attachments_maps_each_type():
|
||||
box_service = SimpleNamespace(
|
||||
available=True,
|
||||
collect_outbound_attachments=AsyncMock(
|
||||
return_value=[
|
||||
{'type': 'Image', 'base64': 'data:image/png;base64,iVBORw0K'},
|
||||
{'type': 'Voice', 'base64': 'data:audio/wav;base64,UklGRg=='},
|
||||
{'type': 'File', 'name': 'report.xlsx', 'base64': 'data:app;base64,UEsDBA=='},
|
||||
]
|
||||
),
|
||||
)
|
||||
wrapper = _make_wrapper(box_service)
|
||||
wrapper.ap.box_service = box_service
|
||||
query = _make_query()
|
||||
chain = platform_message.MessageChain([])
|
||||
|
||||
await wrapper._append_outbound_attachments(query, chain)
|
||||
|
||||
kinds = [type(c).__name__ for c in chain]
|
||||
assert kinds == ['Image', 'Voice', 'File']
|
||||
assert query.variables['_sandbox_outbound_collected'] is True
|
||||
# File keeps its name
|
||||
file_comp = chain[2]
|
||||
assert getattr(file_comp, 'name', None) == 'report.xlsx'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_outbound_attachments_runs_once_per_query():
|
||||
box_service = SimpleNamespace(
|
||||
available=True,
|
||||
collect_outbound_attachments=AsyncMock(return_value=[]),
|
||||
)
|
||||
wrapper = _make_wrapper(box_service)
|
||||
wrapper.ap.box_service = box_service
|
||||
query = _make_query()
|
||||
query.variables['_sandbox_outbound_collected'] = True
|
||||
chain = platform_message.MessageChain([])
|
||||
|
||||
await wrapper._append_outbound_attachments(query, chain)
|
||||
|
||||
box_service.collect_outbound_attachments.assert_not_awaited()
|
||||
assert len(chain) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_outbound_attachments_noop_without_box_service():
|
||||
wrapper = _make_wrapper(box_service=None)
|
||||
wrapper.ap.box_service = None
|
||||
query = _make_query()
|
||||
chain = platform_message.MessageChain([])
|
||||
|
||||
await wrapper._append_outbound_attachments(query, chain)
|
||||
assert len(chain) == 0
|
||||
# not marked collected, since service is unavailable
|
||||
assert '_sandbox_outbound_collected' not in query.variables
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_outbound_attachments_swallows_collection_error():
|
||||
box_service = SimpleNamespace(
|
||||
available=True,
|
||||
collect_outbound_attachments=AsyncMock(side_effect=RuntimeError('boom')),
|
||||
)
|
||||
wrapper = _make_wrapper(box_service)
|
||||
wrapper.ap.box_service = box_service
|
||||
query = _make_query()
|
||||
chain = platform_message.MessageChain([])
|
||||
|
||||
# must not raise
|
||||
await wrapper._append_outbound_attachments(query, chain)
|
||||
assert len(chain) == 0
|
||||
wrapper.ap.logger.warning.assert_called_once()
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Unit tests for WebSocketAdapter._process_image_components.
|
||||
|
||||
The web debug client uploads Image / Voice / File components carrying a storage
|
||||
key in ``path``. This helper resolves each to a base64 data URI (so multimodal
|
||||
LLM input and the Box sandbox inbox have usable bytes), then deletes the
|
||||
consumed storage object and clears ``path``. Covers mimetype selection per
|
||||
type and graceful error handling.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.platform.sources.websocket_adapter import WebSocketAdapter
|
||||
|
||||
|
||||
def _make_adapter(load_return=b'hello', load_side_effect=None):
|
||||
provider = Mock()
|
||||
provider.load = AsyncMock(return_value=load_return, side_effect=load_side_effect)
|
||||
provider.delete = AsyncMock()
|
||||
ap = Mock()
|
||||
ap.storage_mgr.storage_provider = provider
|
||||
logger = Mock()
|
||||
logger.error = AsyncMock()
|
||||
# WebSocketAdapter is a pydantic model; bypass full __init__/validation.
|
||||
adapter = WebSocketAdapter.model_construct(ap=ap, logger=logger)
|
||||
return adapter, provider
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_image_jpeg_mimetype_and_cleanup():
|
||||
adapter, provider = _make_adapter(load_return=b'\xff\xd8\xff')
|
||||
chain = [{'type': 'Image', 'path': 'storage://abc/photo.jpg'}]
|
||||
|
||||
await adapter._process_image_components(chain)
|
||||
|
||||
expected_b64 = base64.b64encode(b'\xff\xd8\xff').decode('utf-8')
|
||||
assert chain[0]['base64'] == f'data:image/jpeg;base64,{expected_b64}'
|
||||
assert chain[0]['path'] == '' # consumed
|
||||
provider.delete.assert_awaited_once_with('storage://abc/photo.jpg')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_image_defaults_to_png():
|
||||
adapter, _ = _make_adapter()
|
||||
chain = [{'type': 'Image', 'path': 'storage://abc/blob'}]
|
||||
await adapter._process_image_components(chain)
|
||||
assert chain[0]['base64'].startswith('data:image/png;base64,')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_voice_uses_guessed_or_wav_mimetype():
|
||||
adapter, _ = _make_adapter()
|
||||
chain = [{'type': 'Voice', 'path': 'storage://abc/clip.wav'}]
|
||||
await adapter._process_image_components(chain)
|
||||
assert chain[0]['base64'].startswith('data:audio/')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_uses_octet_stream_fallback():
|
||||
adapter, _ = _make_adapter()
|
||||
chain = [{'type': 'File', 'path': 'storage://abc/unknownblob'}]
|
||||
await adapter._process_image_components(chain)
|
||||
assert chain[0]['base64'].startswith('data:application/octet-stream;base64,')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_components_without_path_or_unknown_type():
|
||||
adapter, provider = _make_adapter()
|
||||
chain = [
|
||||
{'type': 'Image', 'path': ''}, # no path
|
||||
{'type': 'Plain', 'path': 'storage://abc/x'}, # not a file component
|
||||
{'type': 'At', 'target': '123'}, # no path key at all
|
||||
]
|
||||
await adapter._process_image_components(chain)
|
||||
provider.load.assert_not_awaited()
|
||||
assert 'base64' not in chain[0]
|
||||
assert 'base64' not in chain[1]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_failure_is_logged_not_raised():
|
||||
adapter, _ = _make_adapter(load_side_effect=RuntimeError('storage down'))
|
||||
chain = [{'type': 'File', 'path': 'storage://abc/doc.pdf'}]
|
||||
|
||||
# must not raise
|
||||
await adapter._process_image_components(chain)
|
||||
assert 'base64' not in chain[0]
|
||||
adapter.logger.error.assert_awaited_once()
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Unit tests for LiteLLMRequester._convert_messages.
|
||||
|
||||
Focus: the content-part normalization that (a) converts image_base64 parts to
|
||||
the OpenAI image_url shape and (b) drops non-image file parts (file_base64 /
|
||||
file_url) which OpenAI-compatible chat models reject. The latter is essential
|
||||
for Voice/File attachments — including ones replayed from conversation history —
|
||||
since the agent consumes their bytes via the sandbox, not the model payload.
|
||||
"""
|
||||
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
|
||||
from langbot.pkg.provider.modelmgr.requesters.litellmchat import LiteLLMRequester
|
||||
|
||||
|
||||
def _make_requester() -> LiteLLMRequester:
|
||||
# _convert_messages does not touch instance config, so bypass __init__.
|
||||
return LiteLLMRequester.__new__(LiteLLMRequester)
|
||||
|
||||
|
||||
def test_convert_messages_drops_file_base64_part():
|
||||
req = _make_requester()
|
||||
msg = provider_message.Message(
|
||||
role='user',
|
||||
content=[
|
||||
provider_message.ContentElement.from_text('analyze this audio'),
|
||||
provider_message.ContentElement.from_file_base64('data:audio/wav;base64,AAAA', 'voice.wav'),
|
||||
],
|
||||
)
|
||||
out = req._convert_messages([msg])
|
||||
parts = out[0]['content']
|
||||
types = [p.get('type') for p in parts]
|
||||
assert 'file_base64' not in types
|
||||
assert types == ['text']
|
||||
assert parts[0]['text'] == 'analyze this audio'
|
||||
|
||||
|
||||
def test_convert_messages_drops_file_url_part():
|
||||
req = _make_requester()
|
||||
msg = provider_message.Message(
|
||||
role='user',
|
||||
content=[
|
||||
provider_message.ContentElement.from_text('here is a doc'),
|
||||
provider_message.ContentElement.from_file_url('http://example.com/report.xlsx', 'report.xlsx'),
|
||||
],
|
||||
)
|
||||
out = req._convert_messages([msg])
|
||||
types = [p.get('type') for p in out[0]['content']]
|
||||
assert types == ['text']
|
||||
|
||||
|
||||
def test_convert_messages_keeps_image_and_converts_to_image_url():
|
||||
req = _make_requester()
|
||||
msg = provider_message.Message(
|
||||
role='user',
|
||||
content=[
|
||||
provider_message.ContentElement.from_text('look'),
|
||||
provider_message.ContentElement.from_image_base64('data:image/png;base64,AAAA'),
|
||||
],
|
||||
)
|
||||
out = req._convert_messages([msg])
|
||||
parts = out[0]['content']
|
||||
types = [p.get('type') for p in parts]
|
||||
# image is preserved and reshaped to the OpenAI image_url form
|
||||
assert types == ['text', 'image_url']
|
||||
img_part = parts[1]
|
||||
assert img_part['image_url'] == {'url': 'data:image/png;base64,AAAA'}
|
||||
assert 'image_base64' not in img_part
|
||||
|
||||
|
||||
def test_convert_messages_mixed_history_strips_only_files():
|
||||
req = _make_requester()
|
||||
# Simulate replayed history: an old voice turn + a current text turn.
|
||||
history_voice = provider_message.Message(
|
||||
role='user',
|
||||
content=[
|
||||
provider_message.ContentElement.from_text('old audio turn'),
|
||||
provider_message.ContentElement.from_file_base64('data:audio/wav;base64,BBBB', 'voice.wav'),
|
||||
],
|
||||
)
|
||||
current = provider_message.Message(
|
||||
role='user',
|
||||
content=[provider_message.ContentElement.from_text('now do the csv')],
|
||||
)
|
||||
out = req._convert_messages([history_voice, current])
|
||||
assert [p.get('type') for p in out[0]['content']] == ['text']
|
||||
assert [p.get('type') for p in out[1]['content']] == ['text']
|
||||
|
||||
|
||||
def test_convert_messages_plain_string_content_untouched():
|
||||
req = _make_requester()
|
||||
msg = provider_message.Message(role='user', content='just text')
|
||||
out = req._convert_messages([msg])
|
||||
assert out[0]['content'] == 'just text'
|
||||
@@ -352,6 +352,117 @@ class TestInvokeLLMStreamUsage:
|
||||
assert tool_chunks[1].tool_calls[0].function.arguments == '{"text":'
|
||||
assert tool_chunks[2].tool_calls[0].function.arguments == '"plugin-tool-ok"}'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_tool_call_without_id_is_not_dropped(self):
|
||||
"""Regression for #2261.
|
||||
|
||||
Ollama's OpenAI-compatible streaming endpoint emits a tool-call delta
|
||||
carrying an ``index`` and a ``function`` payload but never an
|
||||
OpenAI-style ``id``. The requester used to drop any id-less tool call,
|
||||
so a tool-only turn yielded nothing, the stream "completed" with 0
|
||||
chars, and the chat got stuck. A stable per-index id must be
|
||||
synthesized so the tool call survives.
|
||||
"""
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
|
||||
mock_ap = Mock()
|
||||
mock_ap.tool_mgr = Mock()
|
||||
mock_ap.tool_mgr.generate_tools_for_openai = AsyncMock(
|
||||
return_value=[{'type': 'function', 'function': {'name': 'zotero_search_items'}}]
|
||||
)
|
||||
requester = litellmchat.LiteLLMRequester(ap=mock_ap, config={'custom_llm_provider': 'openai'})
|
||||
model = MockRuntimeModel('gpt-oss:20b', 'ollama')
|
||||
|
||||
# Ollama delivers the whole tool call in a single delta, with no id.
|
||||
chunks = [
|
||||
self._make_chunk(
|
||||
tool_calls=[
|
||||
{
|
||||
'index': 0,
|
||||
'function': {'name': 'zotero_search_items', 'arguments': '{"query":"hello"}'},
|
||||
}
|
||||
]
|
||||
),
|
||||
self._make_chunk(finish_reason='tool_calls'),
|
||||
]
|
||||
|
||||
async def _aiter(*args, **kwargs):
|
||||
for c in chunks:
|
||||
yield c
|
||||
|
||||
query = Mock(spec=pipeline_query.Query)
|
||||
query.variables = {}
|
||||
messages = [provider_message.Message(role='user', content='hello?')]
|
||||
funcs = [Mock()]
|
||||
|
||||
with patch.object(litellmchat, 'acompletion', new=AsyncMock(side_effect=lambda **kw: _aiter())):
|
||||
collected = [
|
||||
chunk
|
||||
async for chunk in requester.invoke_llm_stream(
|
||||
query=query,
|
||||
model=model,
|
||||
messages=messages,
|
||||
funcs=funcs,
|
||||
)
|
||||
]
|
||||
|
||||
tool_chunks = [chunk for chunk in collected if chunk.tool_calls]
|
||||
assert len(tool_chunks) == 1, 'id-less Ollama tool call must not be dropped'
|
||||
tc = tool_chunks[0].tool_calls[0]
|
||||
assert tc.id == 'call_0'
|
||||
assert tc.function.name == 'zotero_search_items'
|
||||
assert tc.function.arguments == '{"query":"hello"}'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_multiple_tool_calls_without_id_get_distinct_ids(self):
|
||||
"""Two parallel id-less tool calls must keep distinct synthesized ids."""
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
|
||||
mock_ap = Mock()
|
||||
mock_ap.tool_mgr = Mock()
|
||||
mock_ap.tool_mgr.generate_tools_for_openai = AsyncMock(
|
||||
return_value=[{'type': 'function', 'function': {'name': 'zotero_search_items'}}]
|
||||
)
|
||||
requester = litellmchat.LiteLLMRequester(ap=mock_ap, config={'custom_llm_provider': 'openai'})
|
||||
model = MockRuntimeModel('gpt-oss:20b', 'ollama')
|
||||
|
||||
chunks = [
|
||||
self._make_chunk(
|
||||
tool_calls=[
|
||||
{'index': 0, 'function': {'name': 'zotero_search_items', 'arguments': '{"q":"a"}'}},
|
||||
{'index': 1, 'function': {'name': 'zotero_get_notes', 'arguments': '{"q":"b"}'}},
|
||||
]
|
||||
),
|
||||
self._make_chunk(finish_reason='tool_calls'),
|
||||
]
|
||||
|
||||
async def _aiter(*args, **kwargs):
|
||||
for c in chunks:
|
||||
yield c
|
||||
|
||||
query = Mock(spec=pipeline_query.Query)
|
||||
query.variables = {}
|
||||
messages = [provider_message.Message(role='user', content='hello?')]
|
||||
funcs = [Mock()]
|
||||
|
||||
with patch.object(litellmchat, 'acompletion', new=AsyncMock(side_effect=lambda **kw: _aiter())):
|
||||
collected = [
|
||||
chunk
|
||||
async for chunk in requester.invoke_llm_stream(
|
||||
query=query,
|
||||
model=model,
|
||||
messages=messages,
|
||||
funcs=funcs,
|
||||
)
|
||||
]
|
||||
|
||||
tool_chunks = [chunk for chunk in collected if chunk.tool_calls]
|
||||
assert len(tool_chunks) == 1
|
||||
ids = {tc.id for tc in tool_chunks[0].tool_calls}
|
||||
assert ids == {'call_0', 'call_1'}
|
||||
|
||||
|
||||
class TestProcessThinkingContent:
|
||||
"""Test _process_thinking_content method"""
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Unit tests for LocalAgentRunner._inject_inbound_attachments.
|
||||
|
||||
Covers the user -> sandbox attachment path added for the Box attachment
|
||||
round-trip:
|
||||
|
||||
* materialized descriptors are stashed on the query and described to the model
|
||||
via an appended text note (in-sandbox paths + outbox convention);
|
||||
* non-image file parts (file_base64 / file_url) are stripped from the user
|
||||
message content because OpenAI-compatible chat models reject them, while
|
||||
image and text parts are kept for vision models;
|
||||
* the helper is a no-op when the box service is unavailable or yields nothing,
|
||||
and never raises into the chat turn on materialization failure.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
|
||||
from langbot.pkg.provider.runners.localagent import LocalAgentRunner
|
||||
|
||||
|
||||
def _make_runner(box_service) -> LocalAgentRunner:
|
||||
runner = LocalAgentRunner.__new__(LocalAgentRunner)
|
||||
runner.ap = SimpleNamespace(logger=Mock(), box_service=box_service)
|
||||
return runner
|
||||
|
||||
|
||||
def _make_query():
|
||||
return SimpleNamespace(variables={}, query_id='q-123')
|
||||
|
||||
|
||||
def _box_service(attachments):
|
||||
svc = SimpleNamespace(
|
||||
available=True,
|
||||
OUTBOX_MOUNT_DIR='/outbox',
|
||||
materialize_inbound_attachments=AsyncMock(return_value=attachments),
|
||||
)
|
||||
return svc
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inject_strips_file_parts_and_appends_note():
|
||||
box = _box_service([{'type': 'Voice', 'path': '/inbox/q-123/voice.wav', 'size': 176000}])
|
||||
runner = _make_runner(box)
|
||||
query = _make_query()
|
||||
user_message = provider_message.Message(
|
||||
role='user',
|
||||
content=[
|
||||
provider_message.ContentElement.from_text('transcribe this'),
|
||||
provider_message.ContentElement.from_file_base64('data:audio/wav;base64,AAAA', 'voice.wav'),
|
||||
],
|
||||
)
|
||||
|
||||
await runner._inject_inbound_attachments(query, user_message)
|
||||
|
||||
types = [getattr(ce, 'type', None) for ce in user_message.content]
|
||||
# file_base64 dropped; text kept; sandbox-path note appended as text
|
||||
assert 'file_base64' not in types
|
||||
assert types.count('text') == 2
|
||||
note = user_message.content[-1].text
|
||||
assert '/inbox/q-123/voice.wav' in note
|
||||
assert '/outbox/q-123' in note
|
||||
# descriptors stashed for downstream stages
|
||||
assert query.variables['_sandbox_inbound_attachments'] == box.materialize_inbound_attachments.return_value
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inject_keeps_image_parts():
|
||||
box = _box_service([{'type': 'Image', 'path': '/inbox/q-123/pic.png', 'size': 1234}])
|
||||
runner = _make_runner(box)
|
||||
query = _make_query()
|
||||
user_message = provider_message.Message(
|
||||
role='user',
|
||||
content=[
|
||||
provider_message.ContentElement.from_text('what is this'),
|
||||
provider_message.ContentElement.from_image_base64('data:image/png;base64,iVBORw0K'),
|
||||
],
|
||||
)
|
||||
|
||||
await runner._inject_inbound_attachments(query, user_message)
|
||||
|
||||
types = [getattr(ce, 'type', None) for ce in user_message.content]
|
||||
assert 'image_base64' in types # vision part preserved
|
||||
assert types[-1] == 'text' # note appended last
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inject_promotes_string_content_to_list_with_note():
|
||||
box = _box_service([{'type': 'File', 'path': '/inbox/q-123/data.csv', 'size': 42}])
|
||||
runner = _make_runner(box)
|
||||
query = _make_query()
|
||||
user_message = provider_message.Message(role='user', content='clean this csv')
|
||||
|
||||
await runner._inject_inbound_attachments(query, user_message)
|
||||
|
||||
assert isinstance(user_message.content, list)
|
||||
assert [getattr(ce, 'type', None) for ce in user_message.content] == ['text', 'text']
|
||||
assert user_message.content[0].text == 'clean this csv'
|
||||
assert '/inbox/q-123/data.csv' in user_message.content[1].text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inject_noop_without_box_service():
|
||||
runner = _make_runner(box_service=None)
|
||||
query = _make_query()
|
||||
user_message = provider_message.Message(role='user', content='hello')
|
||||
|
||||
await runner._inject_inbound_attachments(query, user_message)
|
||||
|
||||
assert user_message.content == 'hello'
|
||||
assert '_sandbox_inbound_attachments' not in query.variables
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inject_noop_when_no_attachments():
|
||||
box = _box_service([])
|
||||
runner = _make_runner(box)
|
||||
query = _make_query()
|
||||
user_message = provider_message.Message(role='user', content='hello')
|
||||
|
||||
await runner._inject_inbound_attachments(query, user_message)
|
||||
|
||||
assert user_message.content == 'hello'
|
||||
assert '_sandbox_inbound_attachments' not in query.variables
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inject_swallows_materialization_error():
|
||||
box = SimpleNamespace(
|
||||
available=True,
|
||||
OUTBOX_MOUNT_DIR='/outbox',
|
||||
materialize_inbound_attachments=AsyncMock(side_effect=RuntimeError('disk full')),
|
||||
)
|
||||
runner = _make_runner(box)
|
||||
query = _make_query()
|
||||
user_message = provider_message.Message(role='user', content='hello')
|
||||
|
||||
# must not raise
|
||||
await runner._inject_inbound_attachments(query, user_message)
|
||||
assert user_message.content == 'hello'
|
||||
runner.ap.logger.warning.assert_called_once()
|
||||
@@ -180,7 +180,7 @@ class TestMCPServerBoxConfig:
|
||||
assert cfg.host_path is None
|
||||
assert cfg.host_path_mode == 'ro'
|
||||
assert cfg.env == {}
|
||||
assert cfg.startup_timeout_sec == 120
|
||||
assert cfg.startup_timeout_sec == 300
|
||||
assert cfg.cpus is None
|
||||
assert cfg.memory_mb is None
|
||||
assert cfg.pids_limit is None
|
||||
@@ -494,6 +494,84 @@ class TestBuildBoxProcessPayload:
|
||||
assert payload['args'] == ['/opt/other/server.py', '--flag']
|
||||
|
||||
|
||||
# ── Python Workspace Preparation ────────────────────────────────────
|
||||
|
||||
|
||||
class TestPythonWorkspacePreparation:
|
||||
def test_requirements_workspace_uses_venv_bootstrap(self, mcp_module, tmp_path):
|
||||
host_path = tmp_path / 'mcp-source'
|
||||
host_path.mkdir()
|
||||
(host_path / 'requirements.txt').write_text('mcp==1.26.0\n', encoding='utf-8')
|
||||
|
||||
command = mcp_module.BoxStdioSessionRuntime.detect_install_command(
|
||||
str(host_path),
|
||||
'/workspace/.mcp/u1/workspace',
|
||||
)
|
||||
|
||||
assert command is not None
|
||||
assert '_LB_SYSTEM_PYTHON="$(command -v python3 || command -v python || true)"' in command
|
||||
assert '"$_LB_SYSTEM_PYTHON" -m venv "$_LB_VENV_DIR"' in command
|
||||
assert 'python -m pip install -r "/workspace/.mcp/u1/workspace/requirements.txt"' in command
|
||||
assert 'pip install --no-cache-dir -r' not in command
|
||||
|
||||
def test_staging_refresh_removes_stale_source_files_but_preserves_runtime_dirs(self, mcp_module, tmp_path):
|
||||
source = tmp_path / 'source'
|
||||
source.mkdir()
|
||||
(source / 'server.py').write_text('print("new")\n', encoding='utf-8')
|
||||
(source / 'requirements.txt').write_text('mcp==1.26.0\n', encoding='utf-8')
|
||||
(source / '.env').write_text('TOKEN=new\n', encoding='utf-8')
|
||||
|
||||
process_root = tmp_path / 'shared' / '.mcp' / 'u1'
|
||||
workspace = process_root / 'workspace'
|
||||
(workspace / '.venv' / 'bin').mkdir(parents=True)
|
||||
(workspace / '.venv' / 'bin' / 'python').write_text('', encoding='utf-8')
|
||||
(workspace / '.langbot').mkdir()
|
||||
(workspace / '.langbot' / 'python-env.lock').mkdir()
|
||||
(workspace / '.env').write_text('TOKEN=old\n', encoding='utf-8')
|
||||
(workspace / 'server.py').write_text('print("old")\n', encoding='utf-8')
|
||||
(workspace / 'removed.py').write_text('stale\n', encoding='utf-8')
|
||||
(workspace / 'removed_dir').mkdir()
|
||||
(workspace / 'removed_dir' / 'old.txt').write_text('stale\n', encoding='utf-8')
|
||||
|
||||
mcp_module.BoxStdioSessionRuntime._copy_workspace_tree(str(source), str(process_root), str(workspace))
|
||||
|
||||
assert (workspace / 'server.py').read_text(encoding='utf-8') == 'print("new")\n'
|
||||
assert (workspace / 'requirements.txt').read_text(encoding='utf-8') == 'mcp==1.26.0\n'
|
||||
assert (workspace / '.env').read_text(encoding='utf-8') == 'TOKEN=new\n'
|
||||
assert not (workspace / 'removed.py').exists()
|
||||
assert not (workspace / 'removed_dir').exists()
|
||||
assert (workspace / '.venv' / 'bin' / 'python').exists()
|
||||
assert (workspace / '.langbot' / 'python-env.lock').is_dir()
|
||||
|
||||
def test_staging_refresh_ignores_unlink_race(self, mcp_module, tmp_path, monkeypatch):
|
||||
mcp_stdio_module = sys.modules['langbot.pkg.provider.tools.loaders.mcp_stdio']
|
||||
|
||||
source = tmp_path / 'source'
|
||||
source.mkdir()
|
||||
(source / 'server.py').write_text('print("new")\n', encoding='utf-8')
|
||||
|
||||
process_root = tmp_path / 'shared' / '.mcp' / 'u1'
|
||||
workspace = process_root / 'workspace'
|
||||
workspace.mkdir(parents=True)
|
||||
stale_file = workspace / 'removed.py'
|
||||
stale_file.write_text('stale\n', encoding='utf-8')
|
||||
|
||||
real_unlink = os.unlink
|
||||
|
||||
def unlink_with_race(path):
|
||||
if os.fspath(path) == str(stale_file):
|
||||
real_unlink(path)
|
||||
raise FileNotFoundError(path)
|
||||
real_unlink(path)
|
||||
|
||||
monkeypatch.setattr(mcp_stdio_module.os, 'unlink', unlink_with_race)
|
||||
|
||||
mcp_module.BoxStdioSessionRuntime._copy_workspace_tree(str(source), str(process_root), str(workspace))
|
||||
|
||||
assert not stale_file.exists()
|
||||
assert (workspace / 'server.py').read_text(encoding='utf-8') == 'print("new")\n'
|
||||
|
||||
|
||||
# ── get_runtime_info_dict ───────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
@@ -88,6 +89,28 @@ def test_token_manager_next_token_ignores_empty_token_list():
|
||||
assert token_mgr.using_token_index == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_manager_initialize_skips_space_sync_after_timeout():
|
||||
ap = SimpleNamespace()
|
||||
ap.discover = SimpleNamespace(get_components_by_kind=Mock(return_value=[]))
|
||||
ap.instance_config = SimpleNamespace(data={'space': {'models_sync_timeout': 0.01}})
|
||||
ap.logger = Mock()
|
||||
|
||||
mgr = ModelManager(ap)
|
||||
mgr.load_models_from_db = AsyncMock()
|
||||
|
||||
async def slow_sync():
|
||||
await asyncio.sleep(1)
|
||||
|
||||
mgr.sync_new_models_from_space = AsyncMock(side_effect=slow_sync)
|
||||
|
||||
await mgr.initialize()
|
||||
|
||||
mgr.load_models_from_db.assert_awaited_once()
|
||||
mgr.sync_new_models_from_space.assert_awaited_once()
|
||||
ap.logger.warning.assert_any_call('LangBot Space model sync timed out after 0.01s, skipping startup sync.')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_updated_llm_model_is_immediately_usable_by_local_agent_pipeline():
|
||||
from langbot.pkg.api.http.service.model import LLMModelsService
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
"""Unit tests for provider_specific_fields round-trip in LiteLLMRequester.
|
||||
|
||||
This tests the fix for GitHub issue #1899: Gemini requires thought_signature
|
||||
to be preserved across tool call rounds for function calls to work correctly.
|
||||
"""
|
||||
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
|
||||
from langbot.pkg.provider.modelmgr.requesters.litellmchat import LiteLLMRequester
|
||||
|
||||
|
||||
def _make_requester() -> LiteLLMRequester:
|
||||
# _convert_messages and _normalize_stream_tool_calls do not touch instance config.
|
||||
return LiteLLMRequester.__new__(LiteLLMRequester)
|
||||
|
||||
|
||||
def test_convert_messages_preserves_tool_call_provider_specific_fields():
|
||||
"""Tool calls should retain provider_specific_fields through _convert_messages."""
|
||||
req = _make_requester()
|
||||
msg = provider_message.Message(
|
||||
role='assistant',
|
||||
content=None,
|
||||
tool_calls=[
|
||||
provider_message.ToolCall(
|
||||
id='call_123',
|
||||
type='function',
|
||||
function=provider_message.FunctionCall(
|
||||
name='search',
|
||||
arguments='{"query": "test"}',
|
||||
),
|
||||
provider_specific_fields={
|
||||
'thought_signature': 'c2tpcF90aG91Z2h0X3NpZ25hdHVyZQ==',
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
out = req._convert_messages([msg])
|
||||
assert len(out) == 1
|
||||
assert out[0]['tool_calls'] is not None
|
||||
assert len(out[0]['tool_calls']) == 1
|
||||
|
||||
tc = out[0]['tool_calls'][0]
|
||||
assert tc['id'] == 'call_123'
|
||||
assert tc['function']['name'] == 'search'
|
||||
assert 'provider_specific_fields' in tc
|
||||
assert tc['provider_specific_fields']['thought_signature'] == 'c2tpcF90aG91Z2h0X3NpZ25hdHVyZQ=='
|
||||
|
||||
|
||||
def test_convert_messages_preserves_message_provider_specific_fields():
|
||||
"""Messages should retain provider_specific_fields through _convert_messages."""
|
||||
req = _make_requester()
|
||||
msg = provider_message.Message(
|
||||
role='assistant',
|
||||
content='Hello',
|
||||
provider_specific_fields={
|
||||
'thought_signatures': ['sig1', 'sig2'],
|
||||
},
|
||||
)
|
||||
out = req._convert_messages([msg])
|
||||
assert len(out) == 1
|
||||
assert 'provider_specific_fields' in out[0]
|
||||
assert out[0]['provider_specific_fields']['thought_signatures'] == ['sig1', 'sig2']
|
||||
|
||||
|
||||
def test_normalize_stream_tool_calls_preserves_provider_specific_fields():
|
||||
"""Streaming tool calls should retain provider_specific_fields."""
|
||||
req = _make_requester()
|
||||
tool_call_state: dict[int, dict] = {}
|
||||
|
||||
# Simulate first chunk with id and type
|
||||
raw_tool_calls_1 = [
|
||||
{
|
||||
'index': 0,
|
||||
'id': 'call_abc',
|
||||
'type': 'function',
|
||||
'function': {
|
||||
'name': 'get_weather',
|
||||
'arguments': '',
|
||||
},
|
||||
'provider_specific_fields': {
|
||||
'thought_signature': 'dGVzdF9zaWduYXR1cmU=',
|
||||
},
|
||||
},
|
||||
]
|
||||
result_1 = req._normalize_stream_tool_calls(raw_tool_calls_1, tool_call_state)
|
||||
assert result_1 is not None
|
||||
assert len(result_1) == 1
|
||||
assert result_1[0]['provider_specific_fields']['thought_signature'] == 'dGVzdF9zaWduYXR1cmU='
|
||||
|
||||
# Simulate second chunk without provider_specific_fields (should be retained from state)
|
||||
raw_tool_calls_2 = [
|
||||
{
|
||||
'index': 0,
|
||||
'function': {
|
||||
'arguments': '{"city": "Tokyo"}',
|
||||
},
|
||||
},
|
||||
]
|
||||
result_2 = req._normalize_stream_tool_calls(raw_tool_calls_2, tool_call_state)
|
||||
assert result_2 is not None
|
||||
assert len(result_2) == 1
|
||||
# Should retain the provider_specific_fields from the first chunk
|
||||
assert result_2[0]['provider_specific_fields']['thought_signature'] == 'dGVzdF9zaWduYXR1cmU='
|
||||
assert result_2[0]['function']['arguments'] == '{"city": "Tokyo"}'
|
||||
|
||||
|
||||
def test_normalize_stream_tool_calls_merges_function_level_psf():
|
||||
"""Function-level provider_specific_fields should be merged into tool-level."""
|
||||
req = _make_requester()
|
||||
tool_call_state: dict[int, dict] = {}
|
||||
|
||||
raw_tool_calls = [
|
||||
{
|
||||
'index': 0,
|
||||
'id': 'call_xyz',
|
||||
'type': 'function',
|
||||
'function': {
|
||||
'name': 'search',
|
||||
'arguments': '{}',
|
||||
'provider_specific_fields': {
|
||||
'thought_signature': 'ZnVuY19sZXZlbF9zaWc=',
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
result = req._normalize_stream_tool_calls(raw_tool_calls, tool_call_state)
|
||||
assert result is not None
|
||||
assert result[0]['provider_specific_fields']['thought_signature'] == 'ZnVuY19sZXZlbF9zaWc='
|
||||
|
||||
|
||||
def test_tool_call_roundtrip_through_message_entity():
|
||||
"""Full round-trip: LiteLLM response dict -> Message entity -> _convert_messages."""
|
||||
# Simulate what LiteLLM returns for a Gemini tool call response
|
||||
message_data = {
|
||||
'role': 'assistant',
|
||||
'content': None,
|
||||
'tool_calls': [
|
||||
{
|
||||
'id': 'call_gemini_123',
|
||||
'type': 'function',
|
||||
'function': {
|
||||
'name': 'web_search',
|
||||
'arguments': '{"query": "test"}',
|
||||
},
|
||||
'provider_specific_fields': {
|
||||
'thought_signature': 'Z2VtaW5pX3NpZ25hdHVyZQ==',
|
||||
},
|
||||
},
|
||||
],
|
||||
'provider_specific_fields': {
|
||||
'thought_signatures': ['Z2VtaW5pX3NpZ25hdHVyZQ=='],
|
||||
},
|
||||
}
|
||||
|
||||
# Parse into Message entity (this is what invoke_llm does)
|
||||
msg = provider_message.Message(**message_data)
|
||||
|
||||
# Verify the entity has the fields
|
||||
assert msg.tool_calls is not None
|
||||
assert len(msg.tool_calls) == 1
|
||||
assert msg.tool_calls[0].provider_specific_fields is not None
|
||||
assert msg.tool_calls[0].provider_specific_fields['thought_signature'] == 'Z2VtaW5pX3NpZ25hdHVyZQ=='
|
||||
assert msg.provider_specific_fields is not None
|
||||
assert msg.provider_specific_fields['thought_signatures'] == ['Z2VtaW5pX3NpZ25hdHVyZQ==']
|
||||
|
||||
# Convert back to dict for LiteLLM (this is what _convert_messages does)
|
||||
req = _make_requester()
|
||||
out = req._convert_messages([msg])
|
||||
|
||||
# Verify the fields are preserved in the output
|
||||
assert out[0]['tool_calls'][0]['provider_specific_fields']['thought_signature'] == 'Z2VtaW5pX3NpZ25hdHVyZQ=='
|
||||
assert out[0]['provider_specific_fields']['thought_signatures'] == ['Z2VtaW5pX3NpZ25hdHVyZQ==']
|
||||
@@ -193,6 +193,29 @@ class TestSkillPathHelpers:
|
||||
|
||||
assert list(result.keys()) == ['visible']
|
||||
|
||||
def test_restore_activated_skills_uses_caller_provided_names_and_visibility(self):
|
||||
from langbot.pkg.provider.tools.loaders.skill import (
|
||||
ACTIVATED_SKILLS_KEY,
|
||||
PIPELINE_BOUND_SKILLS_KEY,
|
||||
get_activated_skill_names,
|
||||
restore_activated_skills,
|
||||
)
|
||||
|
||||
ap = _make_ap()
|
||||
ap.skill_mgr = SimpleNamespace(
|
||||
skills={
|
||||
'visible': _make_skill_data(name='visible'),
|
||||
'hidden': _make_skill_data(name='hidden'),
|
||||
}
|
||||
)
|
||||
query = SimpleNamespace(variables={PIPELINE_BOUND_SKILLS_KEY: ['visible']})
|
||||
|
||||
restored = restore_activated_skills(ap, query, ['visible', 'hidden', 'visible', ''])
|
||||
|
||||
assert restored == ['visible']
|
||||
assert list(query.variables[ACTIVATED_SKILLS_KEY].keys()) == ['visible']
|
||||
assert get_activated_skill_names(query) == ['visible']
|
||||
|
||||
def test_resolve_virtual_skill_path_allows_visible_skill_reads(self):
|
||||
from langbot.pkg.provider.tools.loaders.skill import (
|
||||
PIPELINE_BOUND_SKILLS_KEY,
|
||||
@@ -245,7 +268,8 @@ class TestSkillPathHelpers:
|
||||
|
||||
command = wrap_skill_command_with_python_env('python scripts/run.py')
|
||||
|
||||
assert 'python -m venv "$_LB_VENV_DIR"' in command
|
||||
assert '_LB_SYSTEM_PYTHON="$(command -v python3 || command -v python || true)"' in command
|
||||
assert '"$_LB_SYSTEM_PYTHON" -m venv "$_LB_VENV_DIR"' in command
|
||||
assert 'export VIRTUAL_ENV="$_LB_VENV_DIR"' in command
|
||||
assert command.rstrip().endswith('python scripts/run.py')
|
||||
|
||||
@@ -281,6 +305,7 @@ class TestSkillToolLoader:
|
||||
assert result['activated'] is True
|
||||
assert result['skill_name'] == 'demo'
|
||||
assert result['mount_path'] == '/workspace/.skills/demo'
|
||||
assert result['activated_skill_names'] == ['demo']
|
||||
assert 'Step 1' in result['content']
|
||||
assert set(query.variables[ACTIVATED_SKILLS_KEY].keys()) == {'demo'}
|
||||
|
||||
@@ -456,7 +481,9 @@ class TestNativeToolLoaderSkillPaths:
|
||||
SimpleNamespace(query_id='q1', variables={PIPELINE_BOUND_SKILLS_KEY: ['demo']}),
|
||||
)
|
||||
|
||||
assert result == {'ok': True, 'content': 'demo instructions'}
|
||||
assert result['ok'] is True
|
||||
assert result['content'] == 'demo instructions'
|
||||
assert result['truncated'] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_in_activated_skill_mount_rewrites_command_and_refreshes(self):
|
||||
@@ -485,7 +512,7 @@ class TestNativeToolLoaderSkillPaths:
|
||||
query,
|
||||
)
|
||||
|
||||
assert result == {'ok': True}
|
||||
assert result['ok'] is True
|
||||
tool_parameters = ap.box_service.execute_tool.await_args.args[0]
|
||||
assert tool_parameters['command'] == 'python /workspace/.skills/demo/scripts/run.py'
|
||||
assert tool_parameters['workdir'] == '/workspace/.skills/demo'
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
@@ -189,6 +190,78 @@ async def test_write_creates_subdirectories():
|
||||
assert f.read() == 'nested'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_binary_file_as_base64_chunk():
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
loader, _ = _make_loader_with_workspace(tmpdir)
|
||||
with open(os.path.join(tmpdir, 'blob.bin'), 'wb') as f:
|
||||
f.write(b'\x00\x01\x02\x03\x04')
|
||||
|
||||
result = await loader.invoke_tool(
|
||||
'read',
|
||||
{
|
||||
'path': '/workspace/blob.bin',
|
||||
'encoding': 'base64',
|
||||
'byte_offset': 1,
|
||||
'max_bytes': 2,
|
||||
},
|
||||
_make_query(),
|
||||
)
|
||||
|
||||
assert result['ok'] is True
|
||||
assert result['content'] == base64.b64encode(b'\x01\x02').decode('ascii')
|
||||
assert result['encoding'] == 'base64'
|
||||
assert result['byte_offset'] == 1
|
||||
assert result['length'] == 2
|
||||
assert result['size_bytes'] == 5
|
||||
assert result['has_more'] is True
|
||||
assert result['next_byte_offset'] == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_base64_file_append():
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
loader, _ = _make_loader_with_workspace(tmpdir)
|
||||
|
||||
first = base64.b64encode(b'\x00\x01').decode('ascii')
|
||||
second = base64.b64encode(b'\x02\x03').decode('ascii')
|
||||
await loader.invoke_tool(
|
||||
'write',
|
||||
{'path': '/workspace/blob.bin', 'content': first, 'encoding': 'base64'},
|
||||
_make_query(),
|
||||
)
|
||||
result = await loader.invoke_tool(
|
||||
'write',
|
||||
{
|
||||
'path': '/workspace/blob.bin',
|
||||
'content': second,
|
||||
'encoding': 'base64',
|
||||
'mode': 'append',
|
||||
},
|
||||
_make_query(),
|
||||
)
|
||||
|
||||
assert result['ok'] is True
|
||||
with open(os.path.join(tmpdir, 'blob.bin'), 'rb') as f:
|
||||
assert f.read() == b'\x00\x01\x02\x03'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_base64_rejects_invalid_content():
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
loader, _ = _make_loader_with_workspace(tmpdir)
|
||||
|
||||
result = await loader.invoke_tool(
|
||||
'write',
|
||||
{'path': '/workspace/blob.bin', 'content': 'not base64!', 'encoding': 'base64'},
|
||||
_make_query(),
|
||||
)
|
||||
|
||||
assert result['ok'] is False
|
||||
assert 'invalid base64' in result['error']
|
||||
assert not os.path.exists(os.path.join(tmpdir, 'blob.bin'))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_replaces_unique_string():
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
@@ -248,3 +321,135 @@ async def test_path_escape_blocked():
|
||||
|
||||
with pytest.raises(ValueError, match='escapes'):
|
||||
await loader.invoke_tool('read', {'path': '/workspace/../../etc/passwd'}, _make_query())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_box_availability_helper_handles_unavailable_and_errors():
|
||||
from langbot.pkg.provider.tools.loaders.availability import is_box_backend_available
|
||||
|
||||
assert await is_box_backend_available(SimpleNamespace()) is False
|
||||
assert await is_box_backend_available(SimpleNamespace(box_service=SimpleNamespace(available=False))) is False
|
||||
|
||||
unavailable_backend = SimpleNamespace(
|
||||
available=True,
|
||||
get_status=AsyncMock(return_value={'backend': {'available': False}}),
|
||||
)
|
||||
assert await is_box_backend_available(SimpleNamespace(box_service=unavailable_backend)) is False
|
||||
|
||||
failing_backend = SimpleNamespace(
|
||||
available=True,
|
||||
get_status=AsyncMock(side_effect=RuntimeError('box unavailable')),
|
||||
)
|
||||
assert await is_box_backend_available(SimpleNamespace(box_service=failing_backend)) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_file_supports_offset_limit_and_truncation_metadata():
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
loader, _ = _make_loader_with_workspace(tmpdir)
|
||||
with open(os.path.join(tmpdir, 'lines.txt'), 'w', encoding='utf-8') as f:
|
||||
f.write('one\ntwo\nthree\nfour\n')
|
||||
|
||||
result = await loader.invoke_tool(
|
||||
'read',
|
||||
{'path': '/workspace/lines.txt', 'offset': 2, 'limit': 2},
|
||||
_make_query(),
|
||||
)
|
||||
|
||||
assert result == {
|
||||
'ok': True,
|
||||
'content': 'two\nthree',
|
||||
'truncated': True,
|
||||
'truncated_by': 'lines',
|
||||
'start_line': 2,
|
||||
'end_line': 3,
|
||||
'next_offset': 4,
|
||||
'max_lines': 2,
|
||||
'max_bytes': 50 * 1024,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_file_handles_line_larger_than_byte_limit():
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
loader, _ = _make_loader_with_workspace(tmpdir)
|
||||
with open(os.path.join(tmpdir, 'long-line.txt'), 'w', encoding='utf-8') as f:
|
||||
f.write('abcdef\n')
|
||||
|
||||
result = await loader.invoke_tool(
|
||||
'read',
|
||||
{'path': '/workspace/long-line.txt', 'max_bytes': 3},
|
||||
_make_query(),
|
||||
)
|
||||
|
||||
assert result['ok'] is True
|
||||
assert result['truncated'] is True
|
||||
assert result['truncated_by'] == 'bytes'
|
||||
assert result['next_offset'] == 1
|
||||
assert 'exceeds the 3B read limit' in result['content']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_result_is_capped_and_exposes_preview_metadata():
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
box_service = SimpleNamespace(
|
||||
available=True,
|
||||
default_workspace=tmpdir,
|
||||
execute_tool=AsyncMock(
|
||||
return_value={
|
||||
'ok': True,
|
||||
'stdout': 'a' * 60000,
|
||||
'stderr': 'b' * 60000,
|
||||
'exit_code': 0,
|
||||
}
|
||||
),
|
||||
)
|
||||
loader = NativeToolLoader(SimpleNamespace(box_service=box_service, logger=Mock()))
|
||||
|
||||
result = await loader.invoke_tool('exec', {'command': 'python -V'}, _make_query())
|
||||
|
||||
assert result['ok'] is True
|
||||
assert len(result['stdout'].encode('utf-8')) == 50 * 1024
|
||||
assert len(result['stderr'].encode('utf-8')) == 50 * 1024
|
||||
assert len(result['preview'].encode('utf-8')) == 50 * 1024
|
||||
assert result['stdout_truncated'] is True
|
||||
assert result['stderr_truncated'] is True
|
||||
assert result['truncated'] is True
|
||||
assert result['truncated_by'] == 'bytes'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_glob_caps_match_count_and_returns_preview():
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
loader, _ = _make_loader_with_workspace(tmpdir)
|
||||
for index in range(105):
|
||||
with open(os.path.join(tmpdir, f'file-{index:03d}.txt'), 'w', encoding='utf-8') as f:
|
||||
f.write(str(index))
|
||||
|
||||
result = await loader.invoke_tool('glob', {'path': '/workspace', 'pattern': '*.txt'}, _make_query())
|
||||
|
||||
assert result['ok'] is True
|
||||
assert result['total'] == 105
|
||||
assert len(result['matches']) == 100
|
||||
assert result['preview'] == '\n'.join(result['matches'])
|
||||
assert result['truncated'] is True
|
||||
assert result['truncated_by'] == 'matches'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grep_reports_invalid_regex_and_truncates_long_matching_lines():
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
loader, _ = _make_loader_with_workspace(tmpdir)
|
||||
with open(os.path.join(tmpdir, 'data.txt'), 'w', encoding='utf-8') as f:
|
||||
f.write('needle ' + ('x' * 600) + '\n')
|
||||
|
||||
invalid = await loader.invoke_tool('grep', {'path': '/workspace', 'pattern': '['}, _make_query())
|
||||
result = await loader.invoke_tool('grep', {'path': '/workspace', 'pattern': 'needle'}, _make_query())
|
||||
|
||||
assert invalid['ok'] is False
|
||||
assert 'Invalid regex' in invalid['error']
|
||||
assert result['ok'] is True
|
||||
assert result['truncated'] is True
|
||||
assert result['truncated_by'] == 'line'
|
||||
assert result['matches'][0]['file'] == '/workspace/data.txt'
|
||||
assert result['matches'][0]['content'].endswith('... [truncated]')
|
||||
|
||||
@@ -2082,7 +2082,7 @@ requires-dist = [
|
||||
{ name = "ebooklib", specifier = ">=0.18" },
|
||||
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
||||
{ name = "html2text", specifier = ">=2024.2.26" },
|
||||
{ name = "langbot-plugin", specifier = "==0.4.4" },
|
||||
{ name = "langbot-plugin", specifier = "==0.4.5" },
|
||||
{ name = "langchain", specifier = ">=0.2.0" },
|
||||
{ name = "langchain-core", specifier = ">=1.3.3" },
|
||||
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
|
||||
@@ -2146,7 +2146,7 @@ dev = [
|
||||
|
||||
[[package]]
|
||||
name = "langbot-plugin"
|
||||
version = "0.4.4"
|
||||
version = "0.4.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiofiles" },
|
||||
@@ -2167,9 +2167,9 @@ dependencies = [
|
||||
{ name = "watchdog" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/68/1a/636c057f6e07a0c87dc7b9c1a373d73df82787b7706ba3ba1a95f633ce7c/langbot_plugin-0.4.4.tar.gz", hash = "sha256:8fdad2d22fe8360d2911557fac17f258f57e85f1a36bd50cd488cb44f61225a4", size = 312741, upload-time = "2026-06-13T11:59:36.772Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f3/db/db33ec42b3242ea7de0c93b0523a8d32a3df76b13de177fd31671db0ba3f/langbot_plugin-0.4.5.tar.gz", hash = "sha256:3cafa5694f31e9e4b4a3d870c1bc23ee7ac6e8d271a0140c5198993471f220ec", size = 326504, upload-time = "2026-06-19T14:53:51.687Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/c6/3c313e4ec431fca68326f348bd2c7a61777d43c940bb46ae6c8ebfb66973/langbot_plugin-0.4.4-py3-none-any.whl", hash = "sha256:c91f082ca431539f34790e497e2f056f4e7030e46e0d2bf01a6114b055dd2feb", size = 214164, upload-time = "2026-06-13T11:59:38.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/92/8a08f8793de479fffa12a1906a25b6ff5b67a018520fa72d981569e1a6e4/langbot_plugin-0.4.5-py3-none-any.whl", hash = "sha256:12ab9aff0fb2459f75a11ba6999d2b5dfc753dcc7d265b078777b24e04b23c83", size = 215602, upload-time = "2026-06-19T14:53:50.021Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -74,6 +74,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide scrollbar while keeping scroll behaviour (horizontal tag/filter rows). */
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none; /* IE / Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none; /* Chrome / Safari / WebKit */
|
||||
}
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
|
||||
@@ -48,7 +48,6 @@ interface PipelineOption {
|
||||
}
|
||||
|
||||
interface RoutingRulesEditorProps {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
form: UseFormReturn<any>;
|
||||
pipelineNameList: PipelineOption[];
|
||||
}
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Item,
|
||||
ItemMedia,
|
||||
ItemContent,
|
||||
ItemTitle,
|
||||
ItemDescription,
|
||||
ItemActions,
|
||||
} from '@/components/ui/item';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { systemInfo } from '@/app/infra/http';
|
||||
import { Loader2, ExternalLink, KeyRound, Layers } from 'lucide-react';
|
||||
import PasswordChangeDialog from '../password-change-dialog/PasswordChangeDialog';
|
||||
|
||||
interface AccountSettingsDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export default function AccountSettingsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: AccountSettingsDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [accountType, setAccountType] = useState<'local' | 'space'>('local');
|
||||
const [hasPassword, setHasPassword] = useState(false);
|
||||
const [userEmail, setUserEmail] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [spaceBindLoading, setSpaceBindLoading] = useState(false);
|
||||
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadUserInfo();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
async function loadUserInfo() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const info = await httpClient.getUserInfo();
|
||||
setAccountType(info.account_type);
|
||||
setHasPassword(info.has_password);
|
||||
setUserEmail(info.user);
|
||||
} catch {
|
||||
toast.error(t('common.error'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const handleBindSpace = async () => {
|
||||
setSpaceBindLoading(true);
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
toast.error(t('common.error'));
|
||||
setSpaceBindLoading(false);
|
||||
return;
|
||||
}
|
||||
const currentOrigin = window.location.origin;
|
||||
const redirectUri = `${currentOrigin}/auth/space/callback?mode=bind`;
|
||||
// Pass token as state for security verification
|
||||
const response = await httpClient.getSpaceAuthorizeUrl(
|
||||
redirectUri,
|
||||
token,
|
||||
);
|
||||
window.location.href = response.authorize_url;
|
||||
} catch {
|
||||
toast.error(t('common.spaceLoginFailed'));
|
||||
setSpaceBindLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasswordDialogClose = (dialogOpen: boolean) => {
|
||||
setPasswordDialogOpen(dialogOpen);
|
||||
if (!dialogOpen) {
|
||||
// Reload user info to update password status
|
||||
loadUserInfo();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('account.settings')}</DialogTitle>
|
||||
<DialogDescription>{userEmail}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{/* Password Item */}
|
||||
<Item size="sm" variant="muted" className="rounded-lg">
|
||||
<ItemMedia variant="icon">
|
||||
<KeyRound className="h-4 w-4" />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('account.passwordStatus')}</ItemTitle>
|
||||
<ItemDescription>
|
||||
{hasPassword
|
||||
? t('account.passwordSetDescription')
|
||||
: t('account.setPasswordHint')}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPasswordDialogOpen(true)}
|
||||
disabled={!systemInfo.allow_modify_login_info}
|
||||
>
|
||||
{hasPassword
|
||||
? t('common.changePassword')
|
||||
: t('account.setPassword')}
|
||||
</Button>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
|
||||
{/* Space Account Item */}
|
||||
<Item size="sm" variant="muted" className="rounded-lg">
|
||||
<ItemMedia variant="icon">
|
||||
<Layers className="h-4 w-4" />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('account.spaceStatus')}</ItemTitle>
|
||||
<ItemDescription>
|
||||
{accountType === 'space'
|
||||
? t('account.spaceBoundDescription')
|
||||
: t('account.bindSpaceDescription')}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
{accountType === 'local' && (
|
||||
<ItemActions>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleBindSpace}
|
||||
disabled={
|
||||
spaceBindLoading || !systemInfo.allow_modify_login_info
|
||||
}
|
||||
>
|
||||
{spaceBindLoading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{t('account.bindSpaceButton')}
|
||||
</Button>
|
||||
</ItemActions>
|
||||
)}
|
||||
</Item>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<PasswordChangeDialog
|
||||
open={passwordDialogOpen}
|
||||
onOpenChange={handlePasswordDialogClose}
|
||||
hasPassword={hasPassword}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Item,
|
||||
ItemMedia,
|
||||
ItemContent,
|
||||
ItemTitle,
|
||||
ItemDescription,
|
||||
ItemActions,
|
||||
} from '@/components/ui/item';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { systemInfo } from '@/app/infra/http';
|
||||
import { Loader2, ExternalLink, KeyRound, Layers } from 'lucide-react';
|
||||
import PasswordChangeDialog from '../password-change-dialog/PasswordChangeDialog';
|
||||
import { PanelBody } from '../settings-dialog/panel-layout';
|
||||
|
||||
interface AccountSettingsPanelProps {
|
||||
// True when this panel is the active section and the dialog is open.
|
||||
active: boolean;
|
||||
onEmailResolved?: (email: string) => void;
|
||||
}
|
||||
|
||||
export default function AccountSettingsPanel({
|
||||
active,
|
||||
onEmailResolved,
|
||||
}: AccountSettingsPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const [accountType, setAccountType] = useState<'local' | 'space'>('local');
|
||||
const [hasPassword, setHasPassword] = useState(false);
|
||||
const [userEmail, setUserEmail] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [spaceBindLoading, setSpaceBindLoading] = useState(false);
|
||||
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (active) {
|
||||
loadUserInfo();
|
||||
}
|
||||
}, [active]);
|
||||
|
||||
async function loadUserInfo() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const info = await httpClient.getUserInfo();
|
||||
setAccountType(info.account_type);
|
||||
setHasPassword(info.has_password);
|
||||
setUserEmail(info.user);
|
||||
onEmailResolved?.(info.user);
|
||||
} catch {
|
||||
toast.error(t('common.error'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const handleBindSpace = async () => {
|
||||
setSpaceBindLoading(true);
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
toast.error(t('common.error'));
|
||||
setSpaceBindLoading(false);
|
||||
return;
|
||||
}
|
||||
const currentOrigin = window.location.origin;
|
||||
const redirectUri = `${currentOrigin}/auth/space/callback?mode=bind`;
|
||||
// Pass token as state for security verification
|
||||
const response = await httpClient.getSpaceAuthorizeUrl(
|
||||
redirectUri,
|
||||
token,
|
||||
);
|
||||
window.location.href = response.authorize_url;
|
||||
} catch {
|
||||
toast.error(t('common.spaceLoginFailed'));
|
||||
setSpaceBindLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasswordDialogClose = (dialogOpen: boolean) => {
|
||||
setPasswordDialogOpen(dialogOpen);
|
||||
if (!dialogOpen) {
|
||||
// Reload user info to update password status
|
||||
loadUserInfo();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PanelBody>
|
||||
{userEmail && (
|
||||
<p className="mb-4 text-sm text-muted-foreground">{userEmail}</p>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{/* Password Item */}
|
||||
<Item size="sm" variant="muted" className="rounded-lg">
|
||||
<ItemMedia variant="icon">
|
||||
<KeyRound className="h-4 w-4" />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('account.passwordStatus')}</ItemTitle>
|
||||
<ItemDescription>
|
||||
{hasPassword
|
||||
? t('account.passwordSetDescription')
|
||||
: t('account.setPasswordHint')}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPasswordDialogOpen(true)}
|
||||
disabled={!systemInfo.allow_modify_login_info}
|
||||
>
|
||||
{hasPassword
|
||||
? t('common.changePassword')
|
||||
: t('account.setPassword')}
|
||||
</Button>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
|
||||
{/* Space Account Item */}
|
||||
<Item size="sm" variant="muted" className="rounded-lg">
|
||||
<ItemMedia variant="icon">
|
||||
<Layers className="h-4 w-4" />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('account.spaceStatus')}</ItemTitle>
|
||||
<ItemDescription>
|
||||
{accountType === 'space'
|
||||
? t('account.spaceBoundDescription')
|
||||
: t('account.bindSpaceDescription')}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
{accountType === 'local' && (
|
||||
<ItemActions>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleBindSpace}
|
||||
disabled={
|
||||
spaceBindLoading || !systemInfo.allow_modify_login_info
|
||||
}
|
||||
>
|
||||
{spaceBindLoading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{t('account.bindSpaceButton')}
|
||||
</Button>
|
||||
</ItemActions>
|
||||
)}
|
||||
</Item>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PasswordChangeDialog
|
||||
open={passwordDialogOpen}
|
||||
onOpenChange={handlePasswordDialogClose}
|
||||
hasPassword={hasPassword}
|
||||
/>
|
||||
</PanelBody>
|
||||
);
|
||||
}
|
||||
+211
-266
@@ -3,7 +3,6 @@ import { useState, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
import { Copy, Check, Trash2, Plus } from 'lucide-react';
|
||||
import { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -37,6 +36,7 @@ import {
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
|
||||
import { backendClient } from '@/app/infra/http';
|
||||
import { PanelToolbar } from '../settings-dialog/panel-layout';
|
||||
|
||||
interface ApiKey {
|
||||
id: number;
|
||||
@@ -55,20 +55,15 @@ interface Webhook {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface ApiIntegrationDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
interface ApiIntegrationPanelProps {
|
||||
// True when this panel is the active section and the dialog is open.
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export default function ApiIntegrationDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: ApiIntegrationDialogProps) {
|
||||
export default function ApiIntegrationPanel({
|
||||
active,
|
||||
}: ApiIntegrationPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const pathname = location.pathname;
|
||||
const [searchParams] = useSearchParams();
|
||||
const [activeTab, setActiveTab] = useState('apikeys');
|
||||
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
|
||||
const [webhooks, setWebhooks] = useState<Webhook[]>([]);
|
||||
@@ -91,33 +86,7 @@ export default function ApiIntegrationDialog({
|
||||
);
|
||||
const [copiedKey, setCopiedKey] = useState<string | null>(null);
|
||||
|
||||
// Sync URL with dialog state
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set('action', 'showApiIntegrationSettings');
|
||||
navigate(`${pathname}?${params.toString()}`, {
|
||||
preventScrollReset: true,
|
||||
});
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (!newOpen && (deleteKeyId || deleteWebhookId)) {
|
||||
return;
|
||||
}
|
||||
if (!newOpen) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.delete('action');
|
||||
const newUrl = params.toString()
|
||||
? `${pathname}?${params.toString()}`
|
||||
: pathname;
|
||||
navigate(newUrl, { preventScrollReset: true });
|
||||
}
|
||||
onOpenChange(newOpen);
|
||||
};
|
||||
|
||||
// 清理 body 样式,防止对话框关闭后页面无法交互
|
||||
// 清理 body 样式,防止嵌套对话框关闭后页面无法交互
|
||||
useEffect(() => {
|
||||
if (!deleteKeyId && !deleteWebhookId) {
|
||||
const cleanup = () => {
|
||||
@@ -131,11 +100,11 @@ export default function ApiIntegrationDialog({
|
||||
}, [deleteKeyId, deleteWebhookId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (active) {
|
||||
loadApiKeys();
|
||||
loadWebhooks();
|
||||
}
|
||||
}, [open]);
|
||||
}, [active]);
|
||||
|
||||
const loadApiKeys = async () => {
|
||||
setLoading(true);
|
||||
@@ -284,233 +253,209 @@ export default function ApiIntegrationDialog({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-[800px] h-[26rem] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('common.manageApiIntegration')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="w-full flex-1 flex flex-col overflow-hidden"
|
||||
>
|
||||
<TabsList className="shadow-md py-3 bg-[#f0f0f0] dark:bg-[#2a2a2e]">
|
||||
<TabsTrigger className="px-5 py-4 cursor-pointer" value="apikeys">
|
||||
{t('common.apiKeys')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
className="px-5 py-4 cursor-pointer"
|
||||
value="webhooks"
|
||||
>
|
||||
{t('common.webhooks')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* API Keys Tab */}
|
||||
<TabsContent
|
||||
value="apikeys"
|
||||
className="space-y-4 flex-1 flex flex-col overflow-hidden"
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="flex h-full min-h-0 w-full flex-col overflow-hidden"
|
||||
>
|
||||
<PanelToolbar>
|
||||
<TabsList>
|
||||
<TabsTrigger value="apikeys">{t('common.apiKeys')}</TabsTrigger>
|
||||
<TabsTrigger value="webhooks">{t('common.webhooks')}</TabsTrigger>
|
||||
</TabsList>
|
||||
{activeTab === 'apikeys' ? (
|
||||
<Button
|
||||
onClick={() => setShowCreateDialog(true)}
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
>
|
||||
<div className="flex items-start gap-2 text-sm text-muted-foreground">
|
||||
{t('common.apiKeyHint')}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={() => setShowCreateDialog(true)}
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t('common.createApiKey')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
) : apiKeys.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{t('common.noApiKeys')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-md overflow-auto flex-1">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="min-w-[120px]">
|
||||
{t('common.name')}
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[200px]">
|
||||
{t('common.apiKeyValue')}
|
||||
</TableHead>
|
||||
<TableHead className="w-[100px]">
|
||||
{t('common.actions')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{apiKeys.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="font-medium">{item.name}</div>
|
||||
{item.description && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{item.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-sm bg-muted px-2 py-1 rounded">
|
||||
{maskApiKey(item.key)}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => handleCopyKey(item.key)}
|
||||
title={t('common.copyApiKey')}
|
||||
>
|
||||
{copiedKey === item.key ? (
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDeleteKeyId(item.id)}
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Webhooks Tab */}
|
||||
<TabsContent
|
||||
value="webhooks"
|
||||
className="space-y-4 flex-1 flex flex-col overflow-hidden"
|
||||
>
|
||||
<div className="flex items-start gap-2 text-sm text-muted-foreground">
|
||||
{t('common.webhookHint')}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={() => setShowCreateWebhookDialog(true)}
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t('common.createWebhook')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
) : webhooks.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{t('common.noWebhooks')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-md overflow-auto flex-1 max-w-full">
|
||||
<Table className="table-fixed w-full">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[150px]">
|
||||
{t('common.name')}
|
||||
</TableHead>
|
||||
<TableHead className="w-[380px]">
|
||||
{t('common.webhookUrl')}
|
||||
</TableHead>
|
||||
<TableHead className="w-[80px]">
|
||||
{t('common.webhookEnabled')}
|
||||
</TableHead>
|
||||
<TableHead className="w-[80px]">
|
||||
{t('common.actions')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{webhooks.map((webhook) => (
|
||||
<TableRow key={webhook.id}>
|
||||
<TableCell className="truncate">
|
||||
<div className="truncate">
|
||||
<div
|
||||
className="font-medium truncate"
|
||||
title={webhook.name}
|
||||
>
|
||||
{webhook.name}
|
||||
</div>
|
||||
{webhook.description && (
|
||||
<div
|
||||
className="text-sm text-muted-foreground truncate"
|
||||
title={webhook.description}
|
||||
>
|
||||
{webhook.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="overflow-x-auto max-w-[380px]">
|
||||
<code className="text-sm bg-muted px-2 py-1 rounded whitespace-nowrap inline-block">
|
||||
{webhook.url}
|
||||
</code>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Switch
|
||||
checked={webhook.enabled}
|
||||
onCheckedChange={() =>
|
||||
handleToggleWebhook(webhook)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDeleteWebhookId(webhook.id)}
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t('common.close')}
|
||||
<Plus className="h-4 w-4" />
|
||||
{t('common.createApiKey')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => setShowCreateWebhookDialog(true)}
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t('common.createWebhook')}
|
||||
</Button>
|
||||
)}
|
||||
</PanelToolbar>
|
||||
|
||||
{/* API Keys Tab */}
|
||||
<TabsContent
|
||||
value="apikeys"
|
||||
className="min-h-0 flex-1 space-y-4 overflow-auto px-6 py-5"
|
||||
>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('common.apiKeyHint')}
|
||||
</p>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
) : apiKeys.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{t('common.noApiKeys')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-auto rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="min-w-[120px]">
|
||||
{t('common.name')}
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[200px]">
|
||||
{t('common.apiKeyValue')}
|
||||
</TableHead>
|
||||
<TableHead className="w-[100px]">
|
||||
{t('common.actions')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{apiKeys.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="font-medium">{item.name}</div>
|
||||
{item.description && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{item.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-sm bg-muted px-2 py-1 rounded">
|
||||
{maskApiKey(item.key)}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => handleCopyKey(item.key)}
|
||||
title={t('common.copyApiKey')}
|
||||
>
|
||||
{copiedKey === item.key ? (
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDeleteKeyId(item.id)}
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Webhooks Tab */}
|
||||
<TabsContent
|
||||
value="webhooks"
|
||||
className="min-h-0 flex-1 space-y-4 overflow-auto px-6 py-5"
|
||||
>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('common.webhookHint')}
|
||||
</p>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
) : webhooks.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{t('common.noWebhooks')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-w-full flex-1 overflow-auto rounded-md border">
|
||||
<Table className="table-fixed w-full">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[150px]">
|
||||
{t('common.name')}
|
||||
</TableHead>
|
||||
<TableHead className="w-[380px]">
|
||||
{t('common.webhookUrl')}
|
||||
</TableHead>
|
||||
<TableHead className="w-[80px]">
|
||||
{t('common.webhookEnabled')}
|
||||
</TableHead>
|
||||
<TableHead className="w-[80px]">
|
||||
{t('common.actions')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{webhooks.map((webhook) => (
|
||||
<TableRow key={webhook.id}>
|
||||
<TableCell className="truncate">
|
||||
<div className="truncate">
|
||||
<div
|
||||
className="font-medium truncate"
|
||||
title={webhook.name}
|
||||
>
|
||||
{webhook.name}
|
||||
</div>
|
||||
{webhook.description && (
|
||||
<div
|
||||
className="text-sm text-muted-foreground truncate"
|
||||
title={webhook.description}
|
||||
>
|
||||
{webhook.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="overflow-x-auto max-w-[380px]">
|
||||
<code className="text-sm bg-muted px-2 py-1 rounded whitespace-nowrap inline-block">
|
||||
{webhook.url}
|
||||
</code>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Switch
|
||||
checked={webhook.enabled}
|
||||
onCheckedChange={() => handleToggleWebhook(webhook)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDeleteWebhookId(webhook.id)}
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Create API Key Dialog */}
|
||||
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
|
||||
@@ -61,7 +61,9 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import ModelsDialog from '@/app/home/components/models-dialog/ModelsDialog';
|
||||
import SettingsDialog, {
|
||||
SettingsSection,
|
||||
} from '@/app/home/components/settings-dialog/SettingsDialog';
|
||||
|
||||
export default function DynamicFormItemComponent({
|
||||
config,
|
||||
@@ -87,6 +89,8 @@ export default function DynamicFormItemComponent({
|
||||
);
|
||||
const { t } = useTranslation();
|
||||
const [modelsDialogOpen, setModelsDialogOpen] = useState(false);
|
||||
const [settingsSection, setSettingsSection] =
|
||||
useState<SettingsSection>('models');
|
||||
|
||||
const fetchLlmModels = () => {
|
||||
httpClient
|
||||
@@ -561,9 +565,11 @@ export default function DynamicFormItemComponent({
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{t('models.title')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<ModelsDialog
|
||||
<SettingsDialog
|
||||
open={modelsDialogOpen}
|
||||
onOpenChange={handleModelsDialogChange}
|
||||
section={settingsSection}
|
||||
onSectionChange={setSettingsSection}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -913,9 +919,11 @@ export default function DynamicFormItemComponent({
|
||||
{t('models.title')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<ModelsDialog
|
||||
<SettingsDialog
|
||||
open={modelsDialogOpen}
|
||||
onOpenChange={handleModelsDialogChange}
|
||||
section={settingsSection}
|
||||
onSectionChange={setSettingsSection}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -47,7 +47,6 @@ export function parseDynamicFormItemType(value: string): DynamicFormItemType {
|
||||
|
||||
export function getDefaultValues(
|
||||
itemConfigList: IDynamicFormItemSchema[],
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
): Record<string, any> {
|
||||
return itemConfigList.reduce(
|
||||
(acc, item) => {
|
||||
@@ -59,7 +58,7 @@ export function getDefaultValues(
|
||||
acc[item.name] = item.default;
|
||||
return acc;
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
{} as Record<string, any>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,11 +57,12 @@ import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { LanguageSelector } from '@/components/ui/language-selector';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import AccountSettingsDialog from '@/app/home/components/account-settings-dialog/AccountSettingsDialog';
|
||||
import ApiIntegrationDialog from '@/app/home/components/api-integration-dialog/ApiIntegrationDialog';
|
||||
import NewVersionDialog from '@/app/home/components/new-version-dialog/NewVersionDialog';
|
||||
import ModelsDialog from '@/app/home/components/models-dialog/ModelsDialog';
|
||||
import StorageAnalysisDialog from '@/app/home/components/storage-analysis-dialog/StorageAnalysisDialog';
|
||||
import SettingsDialog, {
|
||||
SettingsSection,
|
||||
SETTINGS_ACTION_BY_SECTION,
|
||||
SETTINGS_SECTION_BY_ACTION,
|
||||
} from '@/app/home/components/settings-dialog/SettingsDialog';
|
||||
import { GitHubRelease } from '@/app/infra/http/CloudServiceClient';
|
||||
import { useAsyncTask, AsyncTaskStatus } from '@/hooks/useAsyncTask';
|
||||
import { toast } from 'sonner';
|
||||
@@ -1548,17 +1549,10 @@ export default function HomeSidebar({
|
||||
}, [pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get('action') === 'showModelSettings') {
|
||||
setModelsDialogOpen(true);
|
||||
}
|
||||
if (searchParams.get('action') === 'showAccountSettings') {
|
||||
setAccountSettingsOpen(true);
|
||||
}
|
||||
if (searchParams.get('action') === 'showApiIntegrationSettings') {
|
||||
setApiKeyDialogOpen(true);
|
||||
}
|
||||
if (searchParams.get('action') === 'showStorageAnalysis') {
|
||||
setStorageAnalysisOpen(true);
|
||||
const action = searchParams.get('action');
|
||||
if (action && SETTINGS_SECTION_BY_ACTION[action]) {
|
||||
setSettingsSection(SETTINGS_SECTION_BY_ACTION[action]);
|
||||
setSettingsOpen(true);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
@@ -1567,15 +1561,14 @@ export default function HomeSidebar({
|
||||
useState<Record<string, boolean>>(loadSectionState);
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const [accountSettingsOpen, setAccountSettingsOpen] = useState(false);
|
||||
const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [settingsSection, setSettingsSection] =
|
||||
useState<SettingsSection>('models');
|
||||
const [latestRelease, setLatestRelease] = useState<GitHubRelease | null>(
|
||||
null,
|
||||
);
|
||||
const [hasNewVersion, setHasNewVersion] = useState(false);
|
||||
const [versionDialogOpen, setVersionDialogOpen] = useState(false);
|
||||
const [modelsDialogOpen, setModelsDialogOpen] = useState(false);
|
||||
const [storageAnalysisOpen, setStorageAnalysisOpen] = useState(false);
|
||||
const [userEmail, setUserEmail] = useState<string>('');
|
||||
const [starCount, setStarCount] = useState<number | null>(null);
|
||||
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||
@@ -1600,51 +1593,28 @@ export default function HomeSidebar({
|
||||
setShowScrollHint(false);
|
||||
}, 250);
|
||||
}
|
||||
function handleModelsDialogChange(open: boolean) {
|
||||
setModelsDialogOpen(open);
|
||||
if (open) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set('action', 'showModelSettings');
|
||||
navigate(`${pathname}?${params.toString()}`, {
|
||||
preventScrollReset: true,
|
||||
});
|
||||
} else {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.delete('action');
|
||||
const newUrl = params.toString()
|
||||
? `${pathname}?${params.toString()}`
|
||||
: pathname;
|
||||
navigate(newUrl, { preventScrollReset: true });
|
||||
}
|
||||
function openSettings(section: SettingsSection) {
|
||||
setSettingsSection(section);
|
||||
setSettingsOpen(true);
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set('action', SETTINGS_ACTION_BY_SECTION[section]);
|
||||
navigate(`${pathname}?${params.toString()}`, {
|
||||
preventScrollReset: true,
|
||||
});
|
||||
}
|
||||
|
||||
function handleAccountSettingsChange(open: boolean) {
|
||||
setAccountSettingsOpen(open);
|
||||
if (open) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set('action', 'showAccountSettings');
|
||||
navigate(`${pathname}?${params.toString()}`, {
|
||||
preventScrollReset: true,
|
||||
});
|
||||
} else {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.delete('action');
|
||||
const newUrl = params.toString()
|
||||
? `${pathname}?${params.toString()}`
|
||||
: pathname;
|
||||
navigate(newUrl, { preventScrollReset: true });
|
||||
}
|
||||
function handleSettingsSectionChange(section: SettingsSection) {
|
||||
setSettingsSection(section);
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set('action', SETTINGS_ACTION_BY_SECTION[section]);
|
||||
navigate(`${pathname}?${params.toString()}`, {
|
||||
preventScrollReset: true,
|
||||
});
|
||||
}
|
||||
|
||||
function handleStorageAnalysisChange(open: boolean) {
|
||||
setStorageAnalysisOpen(open);
|
||||
if (open) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set('action', 'showStorageAnalysis');
|
||||
navigate(`${pathname}?${params.toString()}`, {
|
||||
preventScrollReset: true,
|
||||
});
|
||||
} else {
|
||||
function handleSettingsOpenChange(open: boolean) {
|
||||
setSettingsOpen(open);
|
||||
if (!open) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.delete('action');
|
||||
const newUrl = params.toString()
|
||||
@@ -1913,24 +1883,11 @@ export default function HomeSidebar({
|
||||
|
||||
{/* Footer */}
|
||||
<SidebarFooter>
|
||||
{/* API Integration entry */}
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
onClick={() => setApiKeyDialogOpen(true)}
|
||||
tooltip={t('common.apiIntegration')}
|
||||
>
|
||||
<KeyRound className="size-4 text-blue-500" />
|
||||
<span>{t('common.apiIntegration')}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
|
||||
{/* Models entry */}
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
onClick={() => handleModelsDialogChange(true)}
|
||||
onClick={() => openSettings('models')}
|
||||
tooltip={t('models.title')}
|
||||
>
|
||||
<Sparkles className="text-blue-500" />
|
||||
@@ -1939,6 +1896,19 @@ export default function HomeSidebar({
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
|
||||
{/* API Integration entry */}
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
onClick={() => openSettings('apiIntegration')}
|
||||
tooltip={t('common.apiIntegration')}
|
||||
>
|
||||
<KeyRound className="size-4 text-blue-500" />
|
||||
<span>{t('common.apiIntegration')}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
|
||||
{/* User menu using sidebar-07 nav-user DropdownMenu pattern */}
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
@@ -2018,7 +1988,10 @@ export default function HomeSidebar({
|
||||
{/* Account actions */}
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleAccountSettingsChange(true)}
|
||||
onClick={() => {
|
||||
setUserMenuOpen(false);
|
||||
openSettings('account');
|
||||
}}
|
||||
>
|
||||
<Settings />
|
||||
{t('account.settings')}
|
||||
@@ -2026,7 +1999,7 @@ export default function HomeSidebar({
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setUserMenuOpen(false);
|
||||
handleStorageAnalysisChange(true);
|
||||
openSettings('storageAnalysis');
|
||||
}}
|
||||
>
|
||||
<HardDrive />
|
||||
@@ -2123,27 +2096,17 @@ export default function HomeSidebar({
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
|
||||
<AccountSettingsDialog
|
||||
open={accountSettingsOpen}
|
||||
onOpenChange={handleAccountSettingsChange}
|
||||
/>
|
||||
<ApiIntegrationDialog
|
||||
open={apiKeyDialogOpen}
|
||||
onOpenChange={setApiKeyDialogOpen}
|
||||
<SettingsDialog
|
||||
open={settingsOpen}
|
||||
onOpenChange={handleSettingsOpenChange}
|
||||
section={settingsSection}
|
||||
onSectionChange={handleSettingsSectionChange}
|
||||
/>
|
||||
<NewVersionDialog
|
||||
open={versionDialogOpen}
|
||||
onOpenChange={setVersionDialogOpen}
|
||||
release={latestRelease}
|
||||
/>
|
||||
<ModelsDialog
|
||||
open={modelsDialogOpen}
|
||||
onOpenChange={handleModelsDialogChange}
|
||||
/>
|
||||
<StorageAnalysisDialog
|
||||
open={storageAnalysisOpen}
|
||||
onOpenChange={handleStorageAnalysisChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+46
-57
@@ -23,10 +23,13 @@ import {
|
||||
LANGBOT_MODELS_PROVIDER_REQUESTER,
|
||||
} from './types';
|
||||
import { CustomApiError } from '@/app/infra/entities/common';
|
||||
import { PanelBody } from '../settings-dialog/panel-layout';
|
||||
|
||||
interface ModelsDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
interface ModelsPanelProps {
|
||||
// True when this panel is the active section and the dialog is open.
|
||||
active: boolean;
|
||||
// Notify parent when a nested modal (provider form) should block outer-close.
|
||||
onBlockingChange?: (blocking: boolean) => void;
|
||||
}
|
||||
|
||||
type ExtraArgValue = string | number | boolean | Record<string, unknown>;
|
||||
@@ -75,10 +78,10 @@ function parseContextLength(
|
||||
return value;
|
||||
}
|
||||
|
||||
export default function ModelsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: ModelsDialogProps) {
|
||||
export default function ModelsPanel({
|
||||
active,
|
||||
onBlockingChange,
|
||||
}: ModelsPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [providers, setProviders] = useState<ModelProvider[]>([]);
|
||||
@@ -136,12 +139,17 @@ export default function ModelsDialog({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (active) {
|
||||
loadUserInfo();
|
||||
loadProviders();
|
||||
loadRequesterSupportTypes();
|
||||
}
|
||||
}, [open]);
|
||||
}, [active]);
|
||||
|
||||
// Notify parent of blocking state so it can guard outer-close.
|
||||
useEffect(() => {
|
||||
onBlockingChange?.(providerFormOpen);
|
||||
}, [providerFormOpen, onBlockingChange]);
|
||||
|
||||
// Auto-expand LangBot Models when no external providers exist
|
||||
useEffect(() => {
|
||||
@@ -604,57 +612,38 @@ export default function ModelsDialog({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(newOpen) => {
|
||||
if (!newOpen && providerFormOpen) return;
|
||||
onOpenChange(newOpen);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="overflow-hidden p-0 h-[80vh] flex flex-col !max-w-[37rem]">
|
||||
<DialogHeader className="px-6 pt-6 pb-0 flex-shrink-0">
|
||||
<DialogTitle>{t('models.title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<PanelBody>
|
||||
{/* LangBot Models (Space) provider card is intentionally pinned to the
|
||||
top, above the "add custom provider" action row. */}
|
||||
{langbotProvider && renderProviderCard(langbotProvider, true)}
|
||||
|
||||
<div className="flex-1 overflow-auto px-6 pb-6 mt-0">
|
||||
{/* LangBot Models Card */}
|
||||
{langbotProvider && renderProviderCard(langbotProvider, true)}
|
||||
{/* Add-provider row: stays below the pinned card by design. */}
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{otherProviders.length === 0
|
||||
? t(
|
||||
systemInfo.disable_models_service
|
||||
? 'models.addProviderHintSimple'
|
||||
: 'models.addProviderHint',
|
||||
)
|
||||
: t('models.providerCount', { count: otherProviders.length })}
|
||||
</span>
|
||||
<Button size="sm" variant="outline" onClick={handleCreateProvider}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
{t('models.addProvider')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Add Provider Button */}
|
||||
<div className="mb-3 flex justify-between items-center sticky top-0 bg-background py-2 z-10">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{otherProviders.length === 0
|
||||
? t(
|
||||
systemInfo.disable_models_service
|
||||
? 'models.addProviderHintSimple'
|
||||
: 'models.addProviderHint',
|
||||
)
|
||||
: t('models.providerCount', { count: otherProviders.length })}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleCreateProvider}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
{t('models.addProvider')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Provider List */}
|
||||
{otherProviders.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<Boxes className="h-12 w-12 mb-3 opacity-50" />
|
||||
<p className="text-sm">{t('models.noProviders')}</p>
|
||||
</div>
|
||||
) : (
|
||||
otherProviders.map((p) => renderProviderCard(p))
|
||||
)}
|
||||
{/* Provider List */}
|
||||
{otherProviders.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<Boxes className="h-12 w-12 mb-3 opacity-50" />
|
||||
<p className="text-sm">{t('models.noProviders')}</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : (
|
||||
otherProviders.map((p) => renderProviderCard(p))
|
||||
)}
|
||||
</PanelBody>
|
||||
|
||||
<Dialog open={providerFormOpen} onOpenChange={setProviderFormOpen}>
|
||||
<DialogContent className="w-[600px] p-6">
|
||||
@@ -0,0 +1,229 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { KeyRound, Sparkles, Settings, HardDrive } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarProvider,
|
||||
} from '@/components/ui/sidebar';
|
||||
import { cn } from '@/lib/utils';
|
||||
import AccountSettingsPanel from '@/app/home/components/account-settings-dialog/AccountSettingsPanel';
|
||||
import ApiIntegrationPanel from '@/app/home/components/api-integration-dialog/ApiIntegrationPanel';
|
||||
import ModelsPanel from '@/app/home/components/models-dialog/ModelsPanel';
|
||||
import StorageAnalysisPanel from '@/app/home/components/storage-analysis-dialog/StorageAnalysisPanel';
|
||||
|
||||
// The set of settings sections shown in the unified dialog. The string values
|
||||
// are also reused as the ?action= query param suffix so deep links keep working.
|
||||
export type SettingsSection =
|
||||
| 'account'
|
||||
| 'apiIntegration'
|
||||
| 'models'
|
||||
| 'storageAnalysis';
|
||||
|
||||
// Map between a section id and its ?action= query value, so existing deep links
|
||||
// (showAccountSettings, showApiIntegrationSettings, showModelSettings,
|
||||
// showStorageAnalysis) continue to resolve to the right section.
|
||||
export const SETTINGS_ACTION_BY_SECTION: Record<SettingsSection, string> = {
|
||||
account: 'showAccountSettings',
|
||||
apiIntegration: 'showApiIntegrationSettings',
|
||||
models: 'showModelSettings',
|
||||
storageAnalysis: 'showStorageAnalysis',
|
||||
};
|
||||
|
||||
export const SETTINGS_SECTION_BY_ACTION: Record<string, SettingsSection> =
|
||||
Object.fromEntries(
|
||||
Object.entries(SETTINGS_ACTION_BY_SECTION).map(([section, action]) => [
|
||||
action,
|
||||
section as SettingsSection,
|
||||
]),
|
||||
);
|
||||
|
||||
interface SettingsDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
section: SettingsSection;
|
||||
onSectionChange: (section: SettingsSection) => void;
|
||||
}
|
||||
|
||||
export default function SettingsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
section,
|
||||
onSectionChange,
|
||||
}: SettingsDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
// A nested modal (e.g. the provider form) can request that we ignore
|
||||
// outer-close until it is dismissed.
|
||||
const [blocking, setBlocking] = useState(false);
|
||||
|
||||
// Only the Models panel can raise a blocking nested modal. When we navigate
|
||||
// away from it (or close the dialog) the panel unmounts without resetting,
|
||||
// so clear the flag here to avoid getting stuck unable to close.
|
||||
useEffect(() => {
|
||||
if (section !== 'models' || !open) {
|
||||
setBlocking(false);
|
||||
}
|
||||
}, [section, open]);
|
||||
|
||||
const navItems: {
|
||||
id: SettingsSection;
|
||||
label: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
}[] = [
|
||||
{
|
||||
id: 'models',
|
||||
label: t('settingsDialog.nav.models'),
|
||||
title: t('models.title'),
|
||||
description: t('models.description'),
|
||||
icon: <Sparkles className="size-4" />,
|
||||
},
|
||||
{
|
||||
id: 'apiIntegration',
|
||||
label: t('settingsDialog.nav.api'),
|
||||
title: t('common.apiIntegration'),
|
||||
description: t('common.apiIntegrationDescription'),
|
||||
icon: <KeyRound className="size-4" />,
|
||||
},
|
||||
{
|
||||
id: 'storageAnalysis',
|
||||
label: t('settingsDialog.nav.storage'),
|
||||
title: t('storageAnalysis.title'),
|
||||
description: t('storageAnalysis.description'),
|
||||
icon: <HardDrive className="size-4" />,
|
||||
},
|
||||
{
|
||||
id: 'account',
|
||||
label: t('settingsDialog.nav.account'),
|
||||
title: t('account.settings'),
|
||||
description: t('account.settingsDescription'),
|
||||
icon: <Settings className="size-4" />,
|
||||
},
|
||||
];
|
||||
|
||||
const activeItem = navItems.find((item) => item.id === section);
|
||||
const activeLabel = activeItem?.title ?? t('settingsDialog.title');
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(newOpen) => {
|
||||
if (!newOpen && blocking) return;
|
||||
onOpenChange(newOpen);
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="h-[80vh] max-h-[800px] overflow-hidden p-0 sm:max-w-[52rem] [&>button:last-child]:z-20"
|
||||
// Fixed height so switching sections never resizes the dialog; each
|
||||
// panel scrolls its own content internally.
|
||||
>
|
||||
<DialogTitle className="sr-only">
|
||||
{t('settingsDialog.title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">{activeLabel}</DialogDescription>
|
||||
|
||||
{/* Override the SidebarProvider wrapper's default h-svh so the two
|
||||
columns fill the dialog's fixed height instead of the viewport. */}
|
||||
<SidebarProvider className="!min-h-0 h-full">
|
||||
<Sidebar
|
||||
collapsible="none"
|
||||
className="hidden h-full w-44 shrink-0 border-r md:flex"
|
||||
>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<div className="px-2 py-3 text-sm font-semibold">
|
||||
{t('settingsDialog.title')}
|
||||
</div>
|
||||
<SidebarMenu>
|
||||
{navItems.map((item) => (
|
||||
<SidebarMenuItem key={item.id}>
|
||||
<SidebarMenuButton
|
||||
isActive={section === item.id}
|
||||
onClick={() => onSectionChange(item.id)}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{item.label}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
|
||||
<main className="flex h-full min-w-0 flex-1 flex-col overflow-hidden">
|
||||
{/* Mobile section switcher (sidebar is hidden on small screens) */}
|
||||
<div className="flex shrink-0 items-center gap-1 overflow-x-auto border-b px-3 py-2 md:hidden">
|
||||
{navItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => onSectionChange(item.id)}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 whitespace-nowrap rounded-md px-3 py-1.5 text-sm',
|
||||
section === item.id
|
||||
? 'bg-sidebar-accent text-sidebar-accent-foreground'
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Unified section header (shared across all tabs). The extra
|
||||
right padding keeps the title clear of the dialog's close X. */}
|
||||
<div className="flex shrink-0 flex-col gap-0.5 border-b px-6 py-4 pr-12">
|
||||
<h2 className="flex items-center gap-2 text-base font-semibold">
|
||||
{activeItem?.icon}
|
||||
{activeItem?.title}
|
||||
</h2>
|
||||
{activeItem?.description && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{activeItem.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
{section === 'models' && (
|
||||
<ModelsPanel
|
||||
active={open && section === 'models'}
|
||||
onBlockingChange={setBlocking}
|
||||
/>
|
||||
)}
|
||||
{section === 'apiIntegration' && (
|
||||
<ApiIntegrationPanel
|
||||
active={open && section === 'apiIntegration'}
|
||||
/>
|
||||
)}
|
||||
{section === 'storageAnalysis' && (
|
||||
<StorageAnalysisPanel
|
||||
active={open && section === 'storageAnalysis'}
|
||||
/>
|
||||
)}
|
||||
{section === 'account' && (
|
||||
<AccountSettingsPanel active={open && section === 'account'} />
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</SidebarProvider>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Shared layout primitives for the settings-dialog panels.
|
||||
*
|
||||
* Every section renders under the dialog's unified header, so the panels
|
||||
* themselves should share the same vertical rhythm: an optional top toolbar
|
||||
* (meta on the left, primary action on the right) followed by a scrollable
|
||||
* body with consistent padding. Keeping these in one place is what makes the
|
||||
* tabs feel like one cohesive surface instead of four separately-styled views.
|
||||
*/
|
||||
|
||||
export function PanelToolbar({
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex shrink-0 items-center justify-between gap-3 border-b px-6 py-3',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PanelBody({
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('min-h-0 flex-1 overflow-auto px-6 py-5', className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,410 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
AlertCircle,
|
||||
Archive,
|
||||
Clock,
|
||||
Database,
|
||||
FileWarning,
|
||||
HardDrive,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { backendClient } from '@/app/infra/http';
|
||||
|
||||
interface StorageSection {
|
||||
key: string;
|
||||
path: string;
|
||||
exists: boolean;
|
||||
size_bytes: number;
|
||||
file_count: number;
|
||||
}
|
||||
|
||||
interface CleanupCandidate {
|
||||
key?: string;
|
||||
name?: string;
|
||||
size_bytes: number;
|
||||
modified_at?: string;
|
||||
date?: string;
|
||||
}
|
||||
|
||||
interface StorageAnalysis {
|
||||
generated_at: string;
|
||||
cleanup_policy: {
|
||||
uploaded_file_retention_days: number;
|
||||
log_retention_days: number;
|
||||
};
|
||||
sections: StorageSection[];
|
||||
database: {
|
||||
type: string;
|
||||
monitoring_counts: Record<string, number>;
|
||||
binary_storage: {
|
||||
count: number;
|
||||
size_bytes: number | null;
|
||||
};
|
||||
};
|
||||
cleanup_candidates: {
|
||||
uploaded_files: CleanupCandidate[];
|
||||
log_files: CleanupCandidate[];
|
||||
};
|
||||
tasks: Record<string, number | undefined>;
|
||||
}
|
||||
|
||||
interface StorageAnalysisDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number | null | undefined): string {
|
||||
if (bytes === null || bytes === undefined) {
|
||||
return '-';
|
||||
}
|
||||
if (bytes < 1024) {
|
||||
return `${bytes} B`;
|
||||
}
|
||||
const units = ['KB', 'MB', 'GB', 'TB'];
|
||||
let value = bytes / 1024;
|
||||
let unitIndex = 0;
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
return `${value.toFixed(value >= 10 ? 1 : 2)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
export default function StorageAnalysisDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: StorageAnalysisDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [analysis, setAnalysis] = useState<StorageAnalysis | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadAnalysis = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await backendClient.get<StorageAnalysis>(
|
||||
'/api/v1/system/storage-analysis',
|
||||
);
|
||||
setAnalysis(result);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadAnalysis();
|
||||
}
|
||||
}, [loadAnalysis, open]);
|
||||
|
||||
const totalBytes = useMemo(() => {
|
||||
return (
|
||||
analysis?.sections.reduce((sum, item) => sum + item.size_bytes, 0) ?? 0
|
||||
);
|
||||
}, [analysis]);
|
||||
|
||||
const uploadedCandidateBytes = useMemo(() => {
|
||||
return (
|
||||
analysis?.cleanup_candidates.uploaded_files.reduce(
|
||||
(sum, item) => sum + item.size_bytes,
|
||||
0,
|
||||
) ?? 0
|
||||
);
|
||||
}, [analysis]);
|
||||
|
||||
const logCandidateBytes = useMemo(() => {
|
||||
return (
|
||||
analysis?.cleanup_candidates.log_files.reduce(
|
||||
(sum, item) => sum + item.size_bytes,
|
||||
0,
|
||||
) ?? 0
|
||||
);
|
||||
}, [analysis]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="!flex h-[86vh] max-h-[86vh] max-w-5xl flex-col gap-0 p-0">
|
||||
<DialogHeader className="shrink-0 px-6 pt-6">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<HardDrive className="size-5 text-blue-500" />
|
||||
{t('storageAnalysis.dialogTitle')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('storageAnalysis.description')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex shrink-0 items-center justify-between gap-3 border-b px-6 pb-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{analysis
|
||||
? t('storageAnalysis.generatedAt', {
|
||||
time: new Date(analysis.generated_at).toLocaleString(),
|
||||
})
|
||||
: t('storageAnalysis.loading')}
|
||||
</div>
|
||||
<Button
|
||||
onClick={loadAnalysis}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`mr-2 size-4 ${loading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
{t('storageAnalysis.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="min-h-0 flex-1 overflow-hidden">
|
||||
<div className="space-y-5 px-6 py-5">
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{analysis && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
|
||||
<SummaryItem
|
||||
label={t('storageAnalysis.totalSize')}
|
||||
value={formatBytes(totalBytes)}
|
||||
icon={<HardDrive className="size-4" />}
|
||||
/>
|
||||
<SummaryItem
|
||||
label={t('storageAnalysis.binaryStorage')}
|
||||
value={formatBytes(
|
||||
analysis.database.binary_storage.size_bytes,
|
||||
)}
|
||||
meta={`${analysis.database.binary_storage.count}`}
|
||||
icon={<Database className="size-4" />}
|
||||
/>
|
||||
<SummaryItem
|
||||
label={t('storageAnalysis.uploadCleanup')}
|
||||
value={formatBytes(uploadedCandidateBytes)}
|
||||
meta={`${analysis.cleanup_candidates.uploaded_files.length}`}
|
||||
icon={<FileWarning className="size-4" />}
|
||||
/>
|
||||
<SummaryItem
|
||||
label={t('storageAnalysis.logCleanup')}
|
||||
value={formatBytes(logCandidateBytes)}
|
||||
meta={`${analysis.cleanup_candidates.log_files.length}`}
|
||||
icon={<FileWarning className="size-4" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="rounded-md border px-3 py-3">
|
||||
<h2 className="mb-3 flex items-center gap-2 text-sm font-medium">
|
||||
<Clock className="size-4 text-muted-foreground" />
|
||||
{t('storageAnalysis.cleanupPolicy')}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 gap-2 text-sm md:grid-cols-3">
|
||||
<PolicyItem
|
||||
label={t('storageAnalysis.uploadRetention')}
|
||||
value={`${analysis.cleanup_policy.uploaded_file_retention_days} ${t('storageAnalysis.days')}`}
|
||||
/>
|
||||
<PolicyItem
|
||||
label={t('storageAnalysis.logRetention')}
|
||||
value={`${analysis.cleanup_policy.log_retention_days} ${t('storageAnalysis.days')}`}
|
||||
/>
|
||||
<PolicyItem
|
||||
label={t('storageAnalysis.databaseType')}
|
||||
value={analysis.database.type}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="mb-2 text-sm font-medium">
|
||||
{t('storageAnalysis.sections')}
|
||||
</h2>
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
{analysis.sections.map((section) => (
|
||||
<div
|
||||
key={section.key}
|
||||
className="grid grid-cols-[1fr_auto_auto_auto] gap-3 border-b px-3 py-2 text-sm last:border-b-0"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium">
|
||||
{t(`storageAnalysis.sectionNames.${section.key}`)}
|
||||
</div>
|
||||
<div className="break-all text-xs text-muted-foreground">
|
||||
{section.path || '-'}
|
||||
</div>
|
||||
</div>
|
||||
{section.exists ? (
|
||||
<span />
|
||||
) : (
|
||||
<Badge variant="outline" className="self-center">
|
||||
{t('storageAnalysis.missing')}
|
||||
</Badge>
|
||||
)}
|
||||
<div className="self-center tabular-nums">
|
||||
{formatBytes(section.size_bytes)}
|
||||
</div>
|
||||
<div className="self-center text-muted-foreground tabular-nums">
|
||||
{section.file_count}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<MetricPanel
|
||||
title={t('storageAnalysis.monitoringTables')}
|
||||
values={analysis.database.monitoring_counts}
|
||||
/>
|
||||
<MetricPanel
|
||||
title={t('storageAnalysis.runtimeTasks')}
|
||||
values={analysis.tasks}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<CandidatePanel
|
||||
title={t('storageAnalysis.expiredUploads')}
|
||||
emptyText={t('storageAnalysis.noExpiredUploads')}
|
||||
candidates={analysis.cleanup_candidates.uploaded_files}
|
||||
/>
|
||||
<CandidatePanel
|
||||
title={t('storageAnalysis.expiredLogs')}
|
||||
emptyText={t('storageAnalysis.noExpiredLogs')}
|
||||
candidates={analysis.cleanup_candidates.log_files}
|
||||
/>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryItem({
|
||||
label,
|
||||
value,
|
||||
icon,
|
||||
meta,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
icon: ReactNode;
|
||||
meta?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-md border px-3 py-3">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{icon}
|
||||
{label}
|
||||
</div>
|
||||
<div className="mt-2 flex items-end justify-between gap-2">
|
||||
<span className="text-xl font-semibold tabular-nums">{value}</span>
|
||||
{meta && <span className="text-xs text-muted-foreground">{meta}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PolicyItem({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-md bg-muted/40 px-3 py-2">
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
<div className="mt-1 font-medium">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricPanel({
|
||||
title,
|
||||
values,
|
||||
}: {
|
||||
title: string;
|
||||
values: Record<string, number | undefined>;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="mb-2 text-sm font-medium">{title}</h2>
|
||||
<div className="rounded-md border">
|
||||
{Object.entries(values).map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center justify-between border-b px-3 py-2 text-sm last:border-b-0"
|
||||
>
|
||||
<span className="text-muted-foreground">{key}</span>
|
||||
<span className="font-medium tabular-nums">{value ?? '-'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CandidatePanel({
|
||||
title,
|
||||
emptyText,
|
||||
candidates,
|
||||
}: {
|
||||
title: string;
|
||||
emptyText: string;
|
||||
candidates: CleanupCandidate[];
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="mb-2 flex items-center gap-2 text-sm font-medium">
|
||||
<Archive className="size-4 text-muted-foreground" />
|
||||
{title}
|
||||
</h2>
|
||||
<div className="rounded-md border">
|
||||
{candidates.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
{emptyText}
|
||||
</div>
|
||||
) : (
|
||||
candidates.slice(0, 8).map((candidate, index) => (
|
||||
<div
|
||||
key={`${candidate.key ?? candidate.name}-${index}`}
|
||||
className="grid grid-cols-[1fr_auto] gap-3 border-b px-3 py-2 text-sm last:border-b-0"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">
|
||||
{candidate.key ?? candidate.name}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{candidate.modified_at ?? candidate.date ?? '-'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="self-center tabular-nums">
|
||||
{formatBytes(candidate.size_bytes)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
AlertCircle,
|
||||
Archive,
|
||||
Clock,
|
||||
Database,
|
||||
FileWarning,
|
||||
HardDrive,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { backendClient } from '@/app/infra/http';
|
||||
import { PanelToolbar } from '../settings-dialog/panel-layout';
|
||||
|
||||
interface StorageSection {
|
||||
key: string;
|
||||
path: string;
|
||||
exists: boolean;
|
||||
size_bytes: number;
|
||||
file_count: number;
|
||||
}
|
||||
|
||||
interface CleanupCandidate {
|
||||
key?: string;
|
||||
name?: string;
|
||||
size_bytes: number;
|
||||
modified_at?: string;
|
||||
date?: string;
|
||||
}
|
||||
|
||||
interface StorageAnalysis {
|
||||
generated_at: string;
|
||||
cleanup_policy: {
|
||||
uploaded_file_retention_days: number;
|
||||
log_retention_days: number;
|
||||
};
|
||||
sections: StorageSection[];
|
||||
database: {
|
||||
type: string;
|
||||
monitoring_counts: Record<string, number>;
|
||||
binary_storage: {
|
||||
count: number;
|
||||
size_bytes: number | null;
|
||||
};
|
||||
};
|
||||
cleanup_candidates: {
|
||||
uploaded_files: CleanupCandidate[];
|
||||
log_files: CleanupCandidate[];
|
||||
};
|
||||
tasks: Record<string, number | undefined>;
|
||||
}
|
||||
|
||||
interface StorageAnalysisPanelProps {
|
||||
// True when this panel is the active section and the dialog is open.
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number | null | undefined): string {
|
||||
if (bytes === null || bytes === undefined) {
|
||||
return '-';
|
||||
}
|
||||
if (bytes < 1024) {
|
||||
return `${bytes} B`;
|
||||
}
|
||||
const units = ['KB', 'MB', 'GB', 'TB'];
|
||||
let value = bytes / 1024;
|
||||
let unitIndex = 0;
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
return `${value.toFixed(value >= 10 ? 1 : 2)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
export default function StorageAnalysisPanel({
|
||||
active,
|
||||
}: StorageAnalysisPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const [analysis, setAnalysis] = useState<StorageAnalysis | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadAnalysis = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await backendClient.get<StorageAnalysis>(
|
||||
'/api/v1/system/storage-analysis',
|
||||
);
|
||||
setAnalysis(result);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (active) {
|
||||
loadAnalysis();
|
||||
}
|
||||
}, [loadAnalysis, active]);
|
||||
|
||||
const totalBytes = useMemo(() => {
|
||||
return (
|
||||
analysis?.sections.reduce((sum, item) => sum + item.size_bytes, 0) ?? 0
|
||||
);
|
||||
}, [analysis]);
|
||||
|
||||
const uploadedCandidateBytes = useMemo(() => {
|
||||
return (
|
||||
analysis?.cleanup_candidates.uploaded_files.reduce(
|
||||
(sum, item) => sum + item.size_bytes,
|
||||
0,
|
||||
) ?? 0
|
||||
);
|
||||
}, [analysis]);
|
||||
|
||||
const logCandidateBytes = useMemo(() => {
|
||||
return (
|
||||
analysis?.cleanup_candidates.log_files.reduce(
|
||||
(sum, item) => sum + item.size_bytes,
|
||||
0,
|
||||
) ?? 0
|
||||
);
|
||||
}, [analysis]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<PanelToolbar>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{analysis
|
||||
? t('storageAnalysis.generatedAt', {
|
||||
time: new Date(analysis.generated_at).toLocaleString(),
|
||||
})
|
||||
: t('storageAnalysis.loading')}
|
||||
</div>
|
||||
<Button
|
||||
onClick={loadAnalysis}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`mr-2 size-4 ${loading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
{t('storageAnalysis.refresh')}
|
||||
</Button>
|
||||
</PanelToolbar>
|
||||
|
||||
<ScrollArea className="min-h-0 flex-1 overflow-hidden">
|
||||
<div className="space-y-5 px-6 py-5">
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{analysis && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
|
||||
<SummaryItem
|
||||
label={t('storageAnalysis.totalSize')}
|
||||
value={formatBytes(totalBytes)}
|
||||
icon={<HardDrive className="size-4" />}
|
||||
/>
|
||||
<SummaryItem
|
||||
label={t('storageAnalysis.binaryStorage')}
|
||||
value={formatBytes(
|
||||
analysis.database.binary_storage.size_bytes,
|
||||
)}
|
||||
meta={`${analysis.database.binary_storage.count}`}
|
||||
icon={<Database className="size-4" />}
|
||||
/>
|
||||
<SummaryItem
|
||||
label={t('storageAnalysis.uploadCleanup')}
|
||||
value={formatBytes(uploadedCandidateBytes)}
|
||||
meta={`${analysis.cleanup_candidates.uploaded_files.length}`}
|
||||
icon={<FileWarning className="size-4" />}
|
||||
/>
|
||||
<SummaryItem
|
||||
label={t('storageAnalysis.logCleanup')}
|
||||
value={formatBytes(logCandidateBytes)}
|
||||
meta={`${analysis.cleanup_candidates.log_files.length}`}
|
||||
icon={<FileWarning className="size-4" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="rounded-md border px-3 py-3">
|
||||
<h2 className="mb-3 flex items-center gap-2 text-sm font-medium">
|
||||
<Clock className="size-4 text-muted-foreground" />
|
||||
{t('storageAnalysis.cleanupPolicy')}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 gap-2 text-sm md:grid-cols-3">
|
||||
<PolicyItem
|
||||
label={t('storageAnalysis.uploadRetention')}
|
||||
value={`${analysis.cleanup_policy.uploaded_file_retention_days} ${t('storageAnalysis.days')}`}
|
||||
/>
|
||||
<PolicyItem
|
||||
label={t('storageAnalysis.logRetention')}
|
||||
value={`${analysis.cleanup_policy.log_retention_days} ${t('storageAnalysis.days')}`}
|
||||
/>
|
||||
<PolicyItem
|
||||
label={t('storageAnalysis.databaseType')}
|
||||
value={analysis.database.type}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="mb-2 text-sm font-medium">
|
||||
{t('storageAnalysis.sections')}
|
||||
</h2>
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
{analysis.sections.map((section) => (
|
||||
<div
|
||||
key={section.key}
|
||||
className="grid grid-cols-[1fr_auto_auto_auto] gap-3 border-b px-3 py-2 text-sm last:border-b-0"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium">
|
||||
{t(`storageAnalysis.sectionNames.${section.key}`)}
|
||||
</div>
|
||||
<div className="break-all text-xs text-muted-foreground">
|
||||
{section.path || '-'}
|
||||
</div>
|
||||
</div>
|
||||
{section.exists ? (
|
||||
<span />
|
||||
) : (
|
||||
<Badge variant="outline" className="self-center">
|
||||
{t('storageAnalysis.missing')}
|
||||
</Badge>
|
||||
)}
|
||||
<div className="self-center tabular-nums">
|
||||
{formatBytes(section.size_bytes)}
|
||||
</div>
|
||||
<div className="self-center text-muted-foreground tabular-nums">
|
||||
{section.file_count}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<MetricPanel
|
||||
title={t('storageAnalysis.monitoringTables')}
|
||||
values={analysis.database.monitoring_counts}
|
||||
/>
|
||||
<MetricPanel
|
||||
title={t('storageAnalysis.runtimeTasks')}
|
||||
values={analysis.tasks}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<CandidatePanel
|
||||
title={t('storageAnalysis.expiredUploads')}
|
||||
emptyText={t('storageAnalysis.noExpiredUploads')}
|
||||
candidates={analysis.cleanup_candidates.uploaded_files}
|
||||
/>
|
||||
<CandidatePanel
|
||||
title={t('storageAnalysis.expiredLogs')}
|
||||
emptyText={t('storageAnalysis.noExpiredLogs')}
|
||||
candidates={analysis.cleanup_candidates.log_files}
|
||||
/>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryItem({
|
||||
label,
|
||||
value,
|
||||
icon,
|
||||
meta,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
icon: ReactNode;
|
||||
meta?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-md border px-3 py-3">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{icon}
|
||||
{label}
|
||||
</div>
|
||||
<div className="mt-2 flex items-end justify-between gap-2">
|
||||
<span className="text-xl font-semibold tabular-nums">{value}</span>
|
||||
{meta && <span className="text-xs text-muted-foreground">{meta}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PolicyItem({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-md bg-muted/40 px-3 py-2">
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
<div className="mt-1 font-medium">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricPanel({
|
||||
title,
|
||||
values,
|
||||
}: {
|
||||
title: string;
|
||||
values: Record<string, number | undefined>;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="mb-2 text-sm font-medium">{title}</h2>
|
||||
<div className="rounded-md border">
|
||||
{Object.entries(values).map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center justify-between border-b px-3 py-2 text-sm last:border-b-0"
|
||||
>
|
||||
<span className="text-muted-foreground">{key}</span>
|
||||
<span className="font-medium tabular-nums">{value ?? '-'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CandidatePanel({
|
||||
title,
|
||||
emptyText,
|
||||
candidates,
|
||||
}: {
|
||||
title: string;
|
||||
emptyText: string;
|
||||
candidates: CleanupCandidate[];
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="mb-2 flex items-center gap-2 text-sm font-medium">
|
||||
<Archive className="size-4 text-muted-foreground" />
|
||||
{title}
|
||||
</h2>
|
||||
<div className="rounded-md border">
|
||||
{candidates.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
{emptyText}
|
||||
</div>
|
||||
) : (
|
||||
candidates.slice(0, 8).map((candidate, index) => (
|
||||
<div
|
||||
key={`${candidate.key ?? candidate.name}-${index}`}
|
||||
className="grid grid-cols-[1fr_auto] gap-3 border-b px-3 py-2 text-sm last:border-b-0"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">
|
||||
{candidate.key ?? candidate.name}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{candidate.modified_at ?? candidate.date ?? '-'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="self-center tabular-nums">
|
||||
{formatBytes(candidate.size_bytes)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -310,6 +310,7 @@ function SingleSelectField({
|
||||
{options.map((opt) => (
|
||||
<div key={opt.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(opt.id)}
|
||||
className={`w-full text-left text-sm px-3 py-2 rounded-lg border transition-colors ${
|
||||
value === opt.id
|
||||
@@ -361,8 +362,16 @@ function MultiSelectField({
|
||||
const selected = value.includes(opt.id);
|
||||
return (
|
||||
<div key={opt.id}>
|
||||
<button
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => toggle(opt.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
toggle(opt.id);
|
||||
}
|
||||
}}
|
||||
className={`w-full text-left text-sm px-3 py-2 rounded-lg border transition-colors flex items-center gap-2 ${
|
||||
selected
|
||||
? 'border-primary bg-primary/5 text-primary'
|
||||
@@ -371,7 +380,7 @@ function MultiSelectField({
|
||||
>
|
||||
<Checkbox checked={selected} className="pointer-events-none" />
|
||||
{getI18nText(opt.label)}
|
||||
</button>
|
||||
</div>
|
||||
{opt.has_input && selected && (
|
||||
<input
|
||||
type="text"
|
||||
|
||||
@@ -82,7 +82,6 @@ export default function SystemStatusCard({
|
||||
fetchStatus();
|
||||
const interval = setInterval(fetchStatus, 30_000);
|
||||
return () => clearInterval(interval);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fetchStatus, refreshKey]);
|
||||
|
||||
const pluginOk = pluginStatus
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
At,
|
||||
Quote,
|
||||
Voice,
|
||||
File as FileComponent,
|
||||
Source,
|
||||
} from '@/app/infra/entities/message';
|
||||
import { toast } from 'sonner';
|
||||
@@ -64,7 +65,12 @@ export default function DebugDialog({
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [selectedImages, setSelectedImages] = useState<
|
||||
Array<{ file: File; preview: string; fileKey?: string }>
|
||||
Array<{
|
||||
file: File;
|
||||
preview: string;
|
||||
fileKey?: string;
|
||||
kind: 'image' | 'voice' | 'file';
|
||||
}>
|
||||
>([]);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [previewImageUrl, setPreviewImageUrl] = useState<string>('');
|
||||
@@ -292,23 +298,38 @@ export default function DebugDialog({
|
||||
const files = e.target.files;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const newImages: Array<{ file: File; preview: string }> = [];
|
||||
const newImages: Array<{
|
||||
file: File;
|
||||
preview: string;
|
||||
kind: 'image' | 'voice' | 'file';
|
||||
}> = [];
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
if (file.type.startsWith('image/')) {
|
||||
const preview = URL.createObjectURL(file);
|
||||
newImages.push({ file, preview });
|
||||
newImages.push({
|
||||
file,
|
||||
preview: URL.createObjectURL(file),
|
||||
kind: 'image',
|
||||
});
|
||||
} else if (file.type.startsWith('audio/')) {
|
||||
newImages.push({ file, preview: '', kind: 'voice' });
|
||||
} else {
|
||||
newImages.push({ file, preview: '', kind: 'file' });
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedImages((prev) => [...prev, ...newImages]);
|
||||
// reset the input so selecting the same file again re-triggers onChange
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const handleRemoveImage = (index: number) => {
|
||||
setSelectedImages((prev) => {
|
||||
const newImages = [...prev];
|
||||
URL.revokeObjectURL(newImages[index].preview);
|
||||
if (newImages[index].preview) {
|
||||
URL.revokeObjectURL(newImages[index].preview);
|
||||
}
|
||||
newImages.splice(index, 1);
|
||||
return newImages;
|
||||
});
|
||||
@@ -372,19 +393,33 @@ export default function DebugDialog({
|
||||
});
|
||||
}
|
||||
|
||||
// Upload images and add to message chain
|
||||
for (const image of selectedImages) {
|
||||
// Upload attachments and add to message chain
|
||||
for (const attachment of selectedImages) {
|
||||
try {
|
||||
const result = await httpClient.uploadWebSocketImage(
|
||||
selectedPipelineId,
|
||||
image.file,
|
||||
);
|
||||
messageChain.push({
|
||||
type: 'Image',
|
||||
path: result.file_key,
|
||||
});
|
||||
if (attachment.kind === 'image') {
|
||||
const result = await httpClient.uploadWebSocketImage(
|
||||
selectedPipelineId,
|
||||
attachment.file,
|
||||
);
|
||||
messageChain.push({
|
||||
type: 'Image',
|
||||
path: result.file_key,
|
||||
});
|
||||
} else {
|
||||
// Voice / File go through the generic document upload endpoint,
|
||||
// which returns a storage key the backend resolves into the
|
||||
// sandbox inbox just like images.
|
||||
const result = await httpClient.uploadDocumentFile(attachment.file);
|
||||
messageChain.push({
|
||||
type: attachment.kind === 'voice' ? 'Voice' : 'File',
|
||||
path: result.file_id,
|
||||
...(attachment.kind === 'file'
|
||||
? { name: attachment.file.name }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Image upload failed:', error);
|
||||
console.error('Attachment upload failed:', error);
|
||||
toast.error(t('pipelines.debugDialog.imageUploadFailed'));
|
||||
}
|
||||
}
|
||||
@@ -393,7 +428,9 @@ export default function DebugDialog({
|
||||
setInputValue('');
|
||||
setHasAt(false);
|
||||
setQuotedMessage(null);
|
||||
selectedImages.forEach((img) => URL.revokeObjectURL(img.preview));
|
||||
selectedImages.forEach((img) => {
|
||||
if (img.preview) URL.revokeObjectURL(img.preview);
|
||||
});
|
||||
setSelectedImages([]);
|
||||
|
||||
// Send message via WebSocket
|
||||
@@ -460,13 +497,29 @@ export default function DebugDialog({
|
||||
}
|
||||
|
||||
case 'File': {
|
||||
const file = component as MessageChainComponent & { name?: string };
|
||||
const file = component as FileComponent;
|
||||
const downloadHref = file.base64
|
||||
? file.base64.startsWith('data:')
|
||||
? file.base64
|
||||
: `data:application/octet-stream;base64,${file.base64}`
|
||||
: file.url || '';
|
||||
const fileName = file.name || 'Unknown';
|
||||
return (
|
||||
<div key={index} className="my-2 flex items-center gap-2 text-sm">
|
||||
<Paperclip className="size-4" />
|
||||
<span>
|
||||
[{t('pipelines.debugDialog.file')}] {file.name || 'Unknown'}
|
||||
</span>
|
||||
{downloadHref ? (
|
||||
<a
|
||||
href={downloadHref}
|
||||
download={fileName}
|
||||
className="text-primary underline hover:opacity-80"
|
||||
>
|
||||
[{t('pipelines.debugDialog.file')}] {fileName}
|
||||
</a>
|
||||
) : (
|
||||
<span>
|
||||
[{t('pipelines.debugDialog.file')}] {fileName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -844,17 +897,30 @@ export default function DebugDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Image preview area */}
|
||||
{/* Attachment preview area */}
|
||||
{selectedImages.length > 0 && (
|
||||
<div className="px-4 pb-2">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{selectedImages.map((image, index) => (
|
||||
<div key={index} className="relative group">
|
||||
<img
|
||||
src={image.preview}
|
||||
alt={`preview-${index}`}
|
||||
className="w-20 h-20 object-cover rounded-lg border"
|
||||
/>
|
||||
{image.kind === 'image' ? (
|
||||
<img
|
||||
src={image.preview}
|
||||
alt={`preview-${index}`}
|
||||
className="w-20 h-20 object-cover rounded-lg border"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-36 h-20 px-2 rounded-lg border bg-muted/40 flex items-center gap-2 overflow-hidden">
|
||||
{image.kind === 'voice' ? (
|
||||
<Music className="size-5 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<Paperclip className="size-5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{image.file.name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveImage(index)}
|
||||
@@ -883,7 +949,7 @@ export default function DebugDialog({
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
accept="image/*,audio/*,*/*"
|
||||
multiple
|
||||
onChange={handleImageSelect}
|
||||
className="hidden"
|
||||
|
||||
@@ -323,7 +323,6 @@ export default function PipelineFormComponent({
|
||||
const isFirstEmission = !initializedStagesRef.current.has(stageKey);
|
||||
|
||||
const currentValues =
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(form.getValues(formName) as Record<string, any>) || {};
|
||||
form.setValue(formName, {
|
||||
...currentValues,
|
||||
@@ -368,7 +367,6 @@ export default function PipelineFormComponent({
|
||||
<DynamicFormComponent
|
||||
itemConfigList={stage.config}
|
||||
initialValues={
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(form.watch(formName) as Record<string, any>)?.[stage.name] ||
|
||||
{}
|
||||
}
|
||||
@@ -402,7 +400,6 @@ export default function PipelineFormComponent({
|
||||
<N8nAuthFormComponent
|
||||
itemConfigList={stage.config}
|
||||
initialValues={
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(form.watch(formName) as Record<string, any>)?.[stage.name] ||
|
||||
{}
|
||||
}
|
||||
@@ -445,7 +442,7 @@ export default function PipelineFormComponent({
|
||||
// make the locked selector display a scope that is NOT the one actually in
|
||||
// effect. Coerce the displayed/saved value to the forced template so the UI
|
||||
// truthfully reflects runtime behavior.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
const stageInitialValues: Record<string, any> =
|
||||
(form.watch(formName) as Record<string, any>)?.[stage.name] || {};
|
||||
const effectiveInitialValues =
|
||||
|
||||
@@ -9,7 +9,7 @@ export interface IPluginCardVO {
|
||||
enabled: boolean;
|
||||
priority: number;
|
||||
install_source: string;
|
||||
install_info: Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
install_info: Record<string, any>;
|
||||
status: string;
|
||||
components: PluginComponent[];
|
||||
debug: boolean;
|
||||
@@ -27,7 +27,7 @@ export class PluginCardVO implements IPluginCardVO {
|
||||
priority: number;
|
||||
debug: boolean;
|
||||
install_source: string;
|
||||
install_info: Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
install_info: Record<string, any>;
|
||||
status: string;
|
||||
components: PluginComponent[];
|
||||
hasUpdate?: boolean;
|
||||
|
||||
@@ -787,38 +787,42 @@ function MarketPageContent({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 用真实标签做快速筛选 */}
|
||||
<div className="mx-auto flex w-full max-w-4xl items-center gap-2 overflow-x-auto pb-1 sm:flex-wrap sm:justify-center sm:overflow-visible">
|
||||
<Button
|
||||
type="button"
|
||||
variant={selectedTags.length === 0 ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-8 shrink-0"
|
||||
onClick={() => handleTagsChange([])}
|
||||
>
|
||||
{t('market.allExtensions')}
|
||||
</Button>
|
||||
{availableTags.map((tag) => {
|
||||
const selected = selectedTags.includes(tag.tag);
|
||||
return (
|
||||
<Button
|
||||
key={tag.tag}
|
||||
type="button"
|
||||
variant={selected ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-8 shrink-0"
|
||||
onClick={() => {
|
||||
const newTags = selected
|
||||
? selectedTags.filter((t) => t !== tag.tag)
|
||||
: [...selectedTags, tag.tag];
|
||||
handleTagsChange(newTags);
|
||||
}}
|
||||
>
|
||||
{tagNames[tag.tag] || tag.tag}
|
||||
{selected && <X className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
{/* 用真实标签做快速筛选 —— 始终单行横向滚动,避免标签变多时换行错位 */}
|
||||
<div className="relative mx-auto w-full max-w-4xl">
|
||||
<div className="scrollbar-hide flex items-center gap-1.5 overflow-x-auto pb-1 pr-6">
|
||||
<Button
|
||||
type="button"
|
||||
variant={selectedTags.length === 0 ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-7 shrink-0 px-2.5 text-xs"
|
||||
onClick={() => handleTagsChange([])}
|
||||
>
|
||||
{t('market.allExtensions')}
|
||||
</Button>
|
||||
{availableTags.map((tag) => {
|
||||
const selected = selectedTags.includes(tag.tag);
|
||||
return (
|
||||
<Button
|
||||
key={tag.tag}
|
||||
type="button"
|
||||
variant={selected ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-7 shrink-0 px-2.5 text-xs"
|
||||
onClick={() => {
|
||||
const newTags = selected
|
||||
? selectedTags.filter((t) => t !== tag.tag)
|
||||
: [...selectedTags, tag.tag];
|
||||
handleTagsChange(newTags);
|
||||
}}
|
||||
>
|
||||
{tagNames[tag.tag] || tag.tag}
|
||||
{selected && <X className="h-3 w-3" />}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* 右侧渐隐,提示还有更多标签可横向滚动查看 */}
|
||||
<div className="pointer-events-none absolute right-0 top-0 bottom-1 w-8 bg-gradient-to-l from-background to-transparent" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ export interface ComponentManifest {
|
||||
version?: string;
|
||||
author?: string;
|
||||
};
|
||||
spec: Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
spec: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface CustomApiError {
|
||||
|
||||
@@ -8,7 +8,7 @@ export const SYSTEM_FIELD_PREFIX = '__system.';
|
||||
export interface IShowIfCondition {
|
||||
field: string;
|
||||
operator: 'eq' | 'neq' | 'in';
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
value: any;
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,8 @@ export interface File extends MessageComponent {
|
||||
name?: string;
|
||||
size?: number;
|
||||
url?: string;
|
||||
path?: string;
|
||||
base64?: string;
|
||||
}
|
||||
|
||||
// Unknown component
|
||||
|
||||
@@ -10,7 +10,7 @@ export interface Plugin {
|
||||
debug: boolean;
|
||||
enabled: boolean;
|
||||
install_source: string;
|
||||
install_info: Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
install_info: Record<string, any>;
|
||||
components: PluginComponent[];
|
||||
}
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ export default function WizardPage() {
|
||||
const [selectedAdapter, setSelectedAdapter] = useState<string | null>(null);
|
||||
const [selectedRunner, setSelectedRunner] = useState<string | null>(null);
|
||||
const [botName, setBotName] = useState('');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
|
||||
const [botDescription, _setBotDescription] = useState('');
|
||||
const [adapterConfig, setAdapterConfig] = useState<Record<string, unknown>>(
|
||||
{},
|
||||
|
||||
@@ -122,6 +122,8 @@ const enUS = {
|
||||
changePasswordFailed:
|
||||
'Failed to change password, please check your current password',
|
||||
apiIntegration: 'API Integration',
|
||||
apiIntegrationDescription:
|
||||
'Manage API keys and webhooks for external access',
|
||||
apiKeys: 'API Keys',
|
||||
manageApiIntegration: 'Manage API Integration',
|
||||
manageApiKeys: 'Manage API Keys',
|
||||
@@ -1149,6 +1151,7 @@ const enUS = {
|
||||
},
|
||||
account: {
|
||||
settings: 'Account Settings',
|
||||
settingsDescription: 'Manage your password and linked accounts',
|
||||
setPassword: 'Set Password',
|
||||
passwordSetSuccess: 'Password set successfully',
|
||||
passwordStatus: 'Local Password',
|
||||
@@ -1386,6 +1389,15 @@ const enUS = {
|
||||
boxSessionCreated: 'Created',
|
||||
boxSessionLastUsed: 'Last used',
|
||||
},
|
||||
settingsDialog: {
|
||||
title: 'Settings',
|
||||
nav: {
|
||||
models: 'Models',
|
||||
api: 'API',
|
||||
storage: 'Storage',
|
||||
account: 'Account',
|
||||
},
|
||||
},
|
||||
storageAnalysis: {
|
||||
title: 'Storage Analysis',
|
||||
description: 'Inspect storage usage and cleanup candidates',
|
||||
|
||||
@@ -126,6 +126,8 @@ const esES = {
|
||||
changePasswordFailed:
|
||||
'Error al cambiar la contraseña, por favor verifica tu contraseña actual',
|
||||
apiIntegration: 'Integración API',
|
||||
apiIntegrationDescription:
|
||||
'Gestiona las claves API y los webhooks para el acceso externo',
|
||||
apiKeys: 'Claves API',
|
||||
manageApiIntegration: 'Gestionar integración API',
|
||||
manageApiKeys: 'Gestionar claves API',
|
||||
@@ -844,7 +846,7 @@ const esES = {
|
||||
'Una vez eliminada, la configuración de este servidor MCP no se podrá recuperar.',
|
||||
},
|
||||
pipelines: {
|
||||
title: 'Pipelines',
|
||||
title: 'Flujos',
|
||||
description:
|
||||
'Los Pipelines definen el flujo de procesamiento de eventos de mensajes, se usan para vincular a los Bots',
|
||||
createPipeline: 'Crear Pipeline',
|
||||
@@ -1178,6 +1180,7 @@ const esES = {
|
||||
},
|
||||
account: {
|
||||
settings: 'Configuración de la cuenta',
|
||||
settingsDescription: 'Gestiona tu contraseña y las cuentas vinculadas',
|
||||
setPassword: 'Establecer contraseña',
|
||||
passwordSetSuccess: 'Contraseña establecida correctamente',
|
||||
passwordStatus: 'Contraseña local',
|
||||
@@ -1419,6 +1422,15 @@ const esES = {
|
||||
boxSessionCreated: 'Creado',
|
||||
boxSessionLastUsed: 'Último uso',
|
||||
},
|
||||
settingsDialog: {
|
||||
title: 'Configuración',
|
||||
nav: {
|
||||
models: 'Modelos',
|
||||
api: 'API',
|
||||
storage: 'Almacenamiento',
|
||||
account: 'Cuenta',
|
||||
},
|
||||
},
|
||||
storageAnalysis: {
|
||||
title: 'Análisis de almacenamiento',
|
||||
description:
|
||||
|
||||
@@ -124,6 +124,8 @@ const jaJP = {
|
||||
changePasswordFailed:
|
||||
'パスワードの変更に失敗しました。現在のパスワードを確認してください',
|
||||
apiIntegration: 'API統合',
|
||||
apiIntegrationDescription:
|
||||
'外部アクセス用の API キーと Webhook を管理します',
|
||||
apiKeys: 'API キー',
|
||||
manageApiIntegration: 'API統合の管理',
|
||||
manageApiKeys: 'API キーの管理',
|
||||
@@ -1153,6 +1155,7 @@ const jaJP = {
|
||||
},
|
||||
account: {
|
||||
settings: 'アカウント設定',
|
||||
settingsDescription: 'パスワードと連携アカウントを管理します',
|
||||
setPassword: 'パスワードを設定',
|
||||
passwordSetSuccess: 'パスワードの設定に成功しました',
|
||||
passwordStatus: 'ローカルパスワード',
|
||||
@@ -1392,6 +1395,15 @@ const jaJP = {
|
||||
boxSessionCreated: '作成日時',
|
||||
boxSessionLastUsed: '最終使用',
|
||||
},
|
||||
settingsDialog: {
|
||||
title: '設定',
|
||||
nav: {
|
||||
models: 'モデル',
|
||||
api: 'API',
|
||||
storage: 'ストレージ',
|
||||
account: 'アカウント',
|
||||
},
|
||||
},
|
||||
storageAnalysis: {
|
||||
title: 'ストレージ分析',
|
||||
description: 'ストレージ使用量とクリーンアップ候補を確認します',
|
||||
|
||||
@@ -122,6 +122,8 @@ const ruRU = {
|
||||
changePasswordFailed:
|
||||
'Не удалось изменить пароль, проверьте текущий пароль',
|
||||
apiIntegration: 'API-интеграция',
|
||||
apiIntegrationDescription:
|
||||
'Управление API-ключами и вебхуками для внешнего доступа',
|
||||
apiKeys: 'API-ключи',
|
||||
manageApiIntegration: 'Управление API-интеграцией',
|
||||
manageApiKeys: 'Управление API-ключами',
|
||||
@@ -1156,6 +1158,7 @@ const ruRU = {
|
||||
},
|
||||
account: {
|
||||
settings: 'Настройки аккаунта',
|
||||
settingsDescription: 'Управление паролем и связанными аккаунтами',
|
||||
setPassword: 'Установить пароль',
|
||||
passwordSetSuccess: 'Пароль успешно установлен',
|
||||
passwordStatus: 'Локальный пароль',
|
||||
@@ -1395,6 +1398,15 @@ const ruRU = {
|
||||
boxSessionCreated: 'Создано',
|
||||
boxSessionLastUsed: 'Последнее использование',
|
||||
},
|
||||
settingsDialog: {
|
||||
title: 'Настройки',
|
||||
nav: {
|
||||
models: 'Модели',
|
||||
api: 'API',
|
||||
storage: 'Хранилище',
|
||||
account: 'Аккаунт',
|
||||
},
|
||||
},
|
||||
storageAnalysis: {
|
||||
title: 'Анализ хранилища',
|
||||
description: 'Проверьте использование хранилища и кандидатов на очистку',
|
||||
|
||||
@@ -120,6 +120,8 @@ const thTH = {
|
||||
changePasswordSuccess: 'เปลี่ยนรหัสผ่านสำเร็จ',
|
||||
changePasswordFailed: 'เปลี่ยนรหัสผ่านล้มเหลว กรุณาตรวจสอบรหัสผ่านปัจจุบัน',
|
||||
apiIntegration: 'การเชื่อมต่อ API',
|
||||
apiIntegrationDescription:
|
||||
'จัดการ API key และ webhook สำหรับการเข้าถึงจากภายนอก',
|
||||
apiKeys: 'คีย์ API',
|
||||
manageApiIntegration: 'จัดการการเชื่อมต่อ API',
|
||||
manageApiKeys: 'จัดการคีย์ API',
|
||||
@@ -300,7 +302,7 @@ const thTH = {
|
||||
},
|
||||
},
|
||||
bots: {
|
||||
title: 'Bot',
|
||||
title: 'บอท',
|
||||
description:
|
||||
'สร้างและจัดการ Bot ซึ่งเป็นจุดเชื่อมต่อของ LangBot กับแพลตฟอร์มต่างๆ',
|
||||
createBot: 'สร้าง Bot',
|
||||
@@ -819,7 +821,7 @@ const thTH = {
|
||||
'เมื่อลบแล้ว การกำหนดค่าเซิร์ฟเวอร์ MCP นี้จะไม่สามารถกู้คืนได้',
|
||||
},
|
||||
pipelines: {
|
||||
title: 'Pipeline',
|
||||
title: 'ไปป์ไลน์',
|
||||
description:
|
||||
'Pipeline กำหนดกระบวนการประมวลผลเหตุการณ์ข้อความ ใช้เพื่อผูกกับ Bot',
|
||||
createPipeline: 'สร้าง Pipeline',
|
||||
@@ -1130,6 +1132,7 @@ const thTH = {
|
||||
},
|
||||
account: {
|
||||
settings: 'การตั้งค่าบัญชี',
|
||||
settingsDescription: 'จัดการรหัสผ่านและบัญชีที่เชื่อมโยงของคุณ',
|
||||
setPassword: 'ตั้งรหัสผ่าน',
|
||||
passwordSetSuccess: 'ตั้งรหัสผ่านสำเร็จ',
|
||||
passwordStatus: 'รหัสผ่านท้องถิ่น',
|
||||
@@ -1364,6 +1367,15 @@ const thTH = {
|
||||
boxSessionCreated: 'สร้างเมื่อ',
|
||||
boxSessionLastUsed: 'ใช้ล่าสุด',
|
||||
},
|
||||
settingsDialog: {
|
||||
title: 'การตั้งค่า',
|
||||
nav: {
|
||||
models: 'โมเดล',
|
||||
api: 'API',
|
||||
storage: 'พื้นที่จัดเก็บ',
|
||||
account: 'บัญชี',
|
||||
},
|
||||
},
|
||||
storageAnalysis: {
|
||||
title: 'วิเคราะห์พื้นที่จัดเก็บ',
|
||||
description: 'ตรวจสอบการใช้พื้นที่จัดเก็บและรายการที่สามารถล้างได้',
|
||||
|
||||
@@ -123,6 +123,8 @@ const viVN = {
|
||||
changePasswordFailed:
|
||||
'Đổi mật khẩu thất bại, vui lòng kiểm tra mật khẩu hiện tại',
|
||||
apiIntegration: 'Tích hợp API',
|
||||
apiIntegrationDescription:
|
||||
'Quản lý API key và webhook cho truy cập từ bên ngoài',
|
||||
apiKeys: 'Khóa API',
|
||||
manageApiIntegration: 'Quản lý tích hợp API',
|
||||
manageApiKeys: 'Quản lý khóa API',
|
||||
@@ -833,7 +835,7 @@ const viVN = {
|
||||
deleteMCPHint: 'Sau khi xóa, cấu hình máy chủ MCP này không thể khôi phục.',
|
||||
},
|
||||
pipelines: {
|
||||
title: 'Pipeline',
|
||||
title: 'Quy trình',
|
||||
description:
|
||||
'Pipeline xác định luồng xử lý sự kiện tin nhắn, dùng để liên kết với Bot',
|
||||
createPipeline: 'Tạo Pipeline',
|
||||
@@ -1150,6 +1152,7 @@ const viVN = {
|
||||
},
|
||||
account: {
|
||||
settings: 'Cài đặt tài khoản',
|
||||
settingsDescription: 'Quản lý mật khẩu và các tài khoản liên kết của bạn',
|
||||
setPassword: 'Đặt mật khẩu',
|
||||
passwordSetSuccess: 'Đặt mật khẩu thành công',
|
||||
passwordStatus: 'Mật khẩu cục bộ',
|
||||
@@ -1388,6 +1391,15 @@ const viVN = {
|
||||
boxSessionCreated: 'Đã tạo',
|
||||
boxSessionLastUsed: 'Lần cuối sử dụng',
|
||||
},
|
||||
settingsDialog: {
|
||||
title: 'Cài đặt',
|
||||
nav: {
|
||||
models: 'Mô hình',
|
||||
api: 'API',
|
||||
storage: 'Lưu trữ',
|
||||
account: 'Tài khoản',
|
||||
},
|
||||
},
|
||||
storageAnalysis: {
|
||||
title: 'Phân tích lưu trữ',
|
||||
description: 'Kiểm tra dung lượng lưu trữ và các mục có thể dọn dẹp',
|
||||
|
||||
@@ -116,6 +116,7 @@ const zhHans = {
|
||||
changePasswordSuccess: '密码修改成功',
|
||||
changePasswordFailed: '密码修改失败,请检查当前密码是否正确',
|
||||
apiIntegration: 'API 集成',
|
||||
apiIntegrationDescription: '管理用于外部访问的 API 密钥和 Webhook',
|
||||
apiKeys: 'API 密钥',
|
||||
manageApiIntegration: '管理 API 集成',
|
||||
manageApiKeys: '管理 API 密钥',
|
||||
@@ -1096,6 +1097,7 @@ const zhHans = {
|
||||
},
|
||||
account: {
|
||||
settings: '账户设置',
|
||||
settingsDescription: '管理你的密码和关联账户',
|
||||
setPassword: '设置密码',
|
||||
passwordSetSuccess: '密码设置成功',
|
||||
passwordStatus: '本地密码',
|
||||
@@ -1328,6 +1330,15 @@ const zhHans = {
|
||||
boxSessionCreated: '创建时间',
|
||||
boxSessionLastUsed: '最后使用',
|
||||
},
|
||||
settingsDialog: {
|
||||
title: '设置',
|
||||
nav: {
|
||||
models: '模型',
|
||||
api: 'API',
|
||||
storage: '存储',
|
||||
account: '账户',
|
||||
},
|
||||
},
|
||||
storageAnalysis: {
|
||||
title: '存储分析',
|
||||
description: '查看存储占用和可清理文件',
|
||||
|
||||
@@ -116,6 +116,7 @@ const zhHant = {
|
||||
changePasswordSuccess: '密碼修改成功',
|
||||
changePasswordFailed: '密碼修改失敗,請檢查當前密碼是否正確',
|
||||
apiIntegration: 'API 整合',
|
||||
apiIntegrationDescription: '管理用於外部存取的 API 金鑰和 Webhook',
|
||||
apiKeys: 'API 金鑰',
|
||||
manageApiIntegration: '管理 API 整合',
|
||||
manageApiKeys: '管理 API 金鑰',
|
||||
@@ -1095,6 +1096,7 @@ const zhHant = {
|
||||
},
|
||||
account: {
|
||||
settings: '帳戶設定',
|
||||
settingsDescription: '管理你的密碼和關聯帳戶',
|
||||
setPassword: '設定密碼',
|
||||
passwordSetSuccess: '密碼設定成功',
|
||||
passwordStatus: '本地密碼',
|
||||
@@ -1327,6 +1329,15 @@ const zhHant = {
|
||||
boxSessionCreated: '建立時間',
|
||||
boxSessionLastUsed: '最後使用',
|
||||
},
|
||||
settingsDialog: {
|
||||
title: '設定',
|
||||
nav: {
|
||||
models: '模型',
|
||||
api: 'API',
|
||||
storage: '儲存',
|
||||
account: '帳戶',
|
||||
},
|
||||
},
|
||||
storageAnalysis: {
|
||||
title: '儲存分析',
|
||||
description: '查看儲存占用和可清理檔案',
|
||||
|
||||
@@ -0,0 +1,455 @@
|
||||
import { expect, Page, test } from '@playwright/test';
|
||||
|
||||
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
||||
|
||||
async function save(page: Page) {
|
||||
const button = page.getByRole('button', { name: /^Save$/ });
|
||||
await expect(button).toBeEnabled();
|
||||
await button.click();
|
||||
}
|
||||
|
||||
async function submit(page: Page) {
|
||||
await page.getByRole('button', { name: /^Submit$/ }).click();
|
||||
}
|
||||
|
||||
async function confirmDelete(page: Page) {
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.getByRole('button', { name: /^Confirm Delete$/ })
|
||||
.click();
|
||||
}
|
||||
|
||||
test.describe('frontend CRUD smoke flows', () => {
|
||||
test('creates, edits, and deletes a bot', async ({ page }) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
await page.goto('/home/bots?id=new');
|
||||
|
||||
await expect(page.locator('input[name="name"]')).toBeVisible();
|
||||
await page.locator('input[name="name"]').fill('Support Bot');
|
||||
await page
|
||||
.locator('input[name="description"]')
|
||||
.fill('Answers customer support questions.');
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Playwright Adapter' }).click();
|
||||
await submit(page);
|
||||
|
||||
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
|
||||
await page.reload();
|
||||
await expect(page.locator('input[name="name"]')).toHaveValue('Support Bot');
|
||||
|
||||
await page
|
||||
.locator('input[name="description"]')
|
||||
.fill('Answers customer support questions with context.');
|
||||
await save(page);
|
||||
await expect(page.locator('input[name="description"]')).toHaveValue(
|
||||
'Answers customer support questions with context.',
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: /^Delete$/ }).click();
|
||||
await confirmDelete(page);
|
||||
|
||||
await expect(page).toHaveURL(/\/home\/bots$/);
|
||||
await expect(page.getByText('Select a bot from the sidebar')).toBeVisible();
|
||||
});
|
||||
|
||||
test('creates, edits, and deletes a pipeline', async ({ page }) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
await page.goto('/home/pipelines?id=new');
|
||||
|
||||
await expect(page.locator('input[name="basic.name"]')).toBeVisible();
|
||||
await page.locator('input[name="basic.name"]').fill('Escalation Pipeline');
|
||||
await page
|
||||
.locator('input[name="basic.description"]')
|
||||
.fill('Routes urgent customer issues.');
|
||||
await submit(page);
|
||||
|
||||
await expect(page).toHaveURL(/\/home\/pipelines\?id=pipeline-1$/);
|
||||
await page.reload();
|
||||
await expect(page.locator('input[name="basic.name"]')).toHaveValue(
|
||||
'Escalation Pipeline',
|
||||
);
|
||||
|
||||
await page
|
||||
.locator('input[name="basic.description"]')
|
||||
.fill('Routes urgent customer issues to operators.');
|
||||
await save(page);
|
||||
await expect(page.locator('input[name="basic.description"]')).toHaveValue(
|
||||
'Routes urgent customer issues to operators.',
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: /^Delete$/ }).click();
|
||||
await confirmDelete(page);
|
||||
|
||||
await expect(page).toHaveURL(/\/home\/pipelines$/);
|
||||
await expect(
|
||||
page.getByText('Select a pipeline from the sidebar'),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('creates, edits, and deletes a knowledge base', async ({ page }) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
await page.goto('/home/knowledge?id=new');
|
||||
|
||||
await expect(page.locator('input[name="name"]')).toBeVisible();
|
||||
await page.locator('input[name="name"]').fill('Support Knowledge');
|
||||
await page
|
||||
.locator('input[name="description"]')
|
||||
.fill('Source material for support answers.');
|
||||
await submit(page);
|
||||
|
||||
await expect(page).toHaveURL(/\/home\/knowledge\?id=knowledge-1$/);
|
||||
await page.reload();
|
||||
await expect(page.locator('input[name="name"]')).toHaveValue(
|
||||
'Support Knowledge',
|
||||
);
|
||||
await page.waitForTimeout(600);
|
||||
|
||||
await page
|
||||
.locator('input[name="description"]')
|
||||
.fill('Updated source material for support answers.');
|
||||
await save(page);
|
||||
await expect(page.locator('input[name="description"]')).toHaveValue(
|
||||
'Updated source material for support answers.',
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: /^Delete$/ }).click();
|
||||
await confirmDelete(page);
|
||||
|
||||
await expect(page).toHaveURL(/\/home\/knowledge$/);
|
||||
await expect(
|
||||
page.getByText('Select a knowledge base from the sidebar'),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('creates, edits, and deletes an MCP server', async ({ page }) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
await page.goto('/home/mcp?id=new');
|
||||
|
||||
await expect(page.locator('input[name="name"]')).toBeVisible();
|
||||
await page.locator('input[name="name"]').fill('playwright-mcp');
|
||||
await page
|
||||
.locator('input[name="url"]')
|
||||
.fill('https://mcp.example.test/sse');
|
||||
await submit(page);
|
||||
|
||||
await expect(page).toHaveURL(/\/home\/mcp\?id=playwright-mcp$/);
|
||||
await page.reload();
|
||||
await expect(page.locator('input[name="name"]')).toHaveValue(
|
||||
'playwright-mcp',
|
||||
);
|
||||
|
||||
await page
|
||||
.locator('input[name="url"]')
|
||||
.fill('https://mcp.example.test/updated-sse');
|
||||
await save(page);
|
||||
await expect(page.locator('input[name="url"]')).toHaveValue(
|
||||
'https://mcp.example.test/updated-sse',
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: /^Delete$/ }).click();
|
||||
await confirmDelete(page);
|
||||
|
||||
await expect(page).toHaveURL(/\/home\/mcp$/);
|
||||
await expect(
|
||||
page.getByText('Select an MCP server from the sidebar'),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('updates and deletes a manually-created skill', async ({ page }) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
await page.goto('/home/skills?action=create');
|
||||
|
||||
await page.locator('#display_name').fill('Release Notes');
|
||||
await page.locator('#name').fill('release_notes');
|
||||
await page.locator('#description').fill('Drafts release notes.');
|
||||
await page
|
||||
.locator('#instructions')
|
||||
.fill('Summarize merged changes for the next release.');
|
||||
await save(page);
|
||||
|
||||
await expect(page).toHaveURL(/\/home\/skills\?id=release_notes$/);
|
||||
await page.reload();
|
||||
await expect(page.locator('#description')).toHaveValue(
|
||||
'Drafts release notes.',
|
||||
);
|
||||
|
||||
await page
|
||||
.locator('#description')
|
||||
.fill('Drafts concise release notes for maintainers.');
|
||||
await expect(page.locator('#description')).toHaveValue(
|
||||
'Drafts concise release notes for maintainers.',
|
||||
);
|
||||
await save(page);
|
||||
await page.reload();
|
||||
await expect(page.locator('#description')).toHaveValue(
|
||||
'Drafts concise release notes for maintainers.',
|
||||
);
|
||||
await expect(page.locator('#instructions')).toHaveValue(
|
||||
'Summarize merged changes for the next release.',
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: /^Delete$/ }).click();
|
||||
await confirmDelete(page);
|
||||
|
||||
await expect(page).toHaveURL(/\/home\/add-extension$/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('bot advanced flows', () => {
|
||||
test('toggles bot enable/disable state', async ({ page }) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
// Create a bot first
|
||||
await page.goto('/home/bots?id=new');
|
||||
await page.locator('input[name="name"]').fill('Toggle Test Bot');
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Playwright Adapter' }).click();
|
||||
await submit(page);
|
||||
|
||||
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
|
||||
|
||||
// Wait for the enable switch to load (it's fetched via getBot)
|
||||
await expect(page.locator('#bot-enable-switch')).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
// Verify initial state is enabled
|
||||
await expect(page.locator('#bot-enable-switch')).toBeChecked();
|
||||
|
||||
// Toggle to disabled
|
||||
await page.locator('#bot-enable-switch').click();
|
||||
await expect(page.locator('#bot-enable-switch')).not.toBeChecked();
|
||||
|
||||
// Reload and verify state persisted
|
||||
await page.reload();
|
||||
await expect(page.locator('#bot-enable-switch')).not.toBeChecked();
|
||||
});
|
||||
|
||||
test('switches between bot detail tabs', async ({ page }) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
// Create a bot
|
||||
await page.goto('/home/bots?id=new');
|
||||
await page.locator('input[name="name"]').fill('Tab Test Bot');
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Playwright Adapter' }).click();
|
||||
await submit(page);
|
||||
|
||||
// Verify we're on the Configuration tab
|
||||
await expect(
|
||||
page.getByRole('tab', { name: /Configuration/ }),
|
||||
).toHaveAttribute('data-state', 'active');
|
||||
await expect(page.locator('input[name="name"]')).toBeVisible();
|
||||
|
||||
// Switch to Logs tab
|
||||
await page.getByRole('tab', { name: /Logs/ }).click();
|
||||
await expect(page.getByRole('tab', { name: /Logs/ })).toHaveAttribute(
|
||||
'data-state',
|
||||
'active',
|
||||
);
|
||||
|
||||
// Switch to Sessions tab
|
||||
await page.getByRole('tab', { name: /Sessions/ }).click();
|
||||
await expect(page.getByRole('tab', { name: /Sessions/ })).toHaveAttribute(
|
||||
'data-state',
|
||||
'active',
|
||||
);
|
||||
|
||||
// Switch back to Configuration
|
||||
await page.getByRole('tab', { name: /Configuration/ }).click();
|
||||
await expect(page.locator('input[name="name"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('save button is disabled when form is clean', async ({ page }) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
// Create a bot
|
||||
await page.goto('/home/bots?id=new');
|
||||
await page.locator('input[name="name"]').fill('Clean Form Bot');
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Playwright Adapter' }).click();
|
||||
await submit(page);
|
||||
|
||||
// After creation, save button should be disabled (form is clean)
|
||||
const saveButton = page.getByRole('button', { name: /^Save$/ });
|
||||
await expect(saveButton).toBeDisabled();
|
||||
|
||||
// Edit the form
|
||||
await page.locator('input[name="description"]').fill('New description');
|
||||
await expect(saveButton).toBeEnabled();
|
||||
|
||||
// Save
|
||||
await saveButton.click();
|
||||
await expect(saveButton).toBeDisabled();
|
||||
});
|
||||
|
||||
test('shows validation error when bot name is empty', async ({ page }) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
await page.goto('/home/bots?id=new');
|
||||
|
||||
// Select adapter but leave name empty
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Playwright Adapter' }).click();
|
||||
await submit(page);
|
||||
|
||||
// Should show validation error for name (zod validation)
|
||||
await expect(page.getByText(/cannot be empty/i)).toBeVisible();
|
||||
await expect(page).toHaveURL(/\/home\/bots\?id=new$/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('pipeline advanced flows', () => {
|
||||
test('switches to monitoring tab from pipeline detail', async ({ page }) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
// Create a pipeline
|
||||
await page.goto('/home/pipelines?id=new');
|
||||
await page.locator('input[name="basic.name"]').fill('Tab Test Pipeline');
|
||||
await submit(page);
|
||||
|
||||
// Verify we're on the Configuration tab
|
||||
await expect(
|
||||
page.getByRole('tab', { name: /Configuration/ }),
|
||||
).toHaveAttribute('data-state', 'active');
|
||||
|
||||
// Switch to Monitoring tab (labeled "Dashboard" in the pipeline context)
|
||||
// Skip Debug tab as it requires WebSocket connection
|
||||
await page.getByRole('tab', { name: /Dashboard/ }).click();
|
||||
await expect(page.getByRole('tab', { name: /Dashboard/ })).toHaveAttribute(
|
||||
'data-state',
|
||||
'active',
|
||||
);
|
||||
|
||||
// Switch back to Configuration
|
||||
await page.getByRole('tab', { name: /Configuration/ }).click();
|
||||
await expect(page.locator('input[name="basic.name"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('save button reflects form dirty state', async ({ page }) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
// Create a pipeline
|
||||
await page.goto('/home/pipelines?id=new');
|
||||
await page.locator('input[name="basic.name"]').fill('Dirty Form Pipeline');
|
||||
await submit(page);
|
||||
|
||||
// Wait for the page to fully load and form to reset
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Edit the form - use the name field which definitely triggers dirty state
|
||||
await page
|
||||
.locator('input[name="basic.name"]')
|
||||
.fill('Dirty Form Pipeline Updated');
|
||||
const saveButton = page.getByRole('button', { name: /^Save$/ });
|
||||
await expect(saveButton).toBeEnabled();
|
||||
|
||||
// Save
|
||||
await saveButton.click();
|
||||
// Wait for save to complete
|
||||
await page.waitForTimeout(500);
|
||||
});
|
||||
|
||||
test('shows validation error when pipeline name is empty', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
await page.goto('/home/pipelines?id=new');
|
||||
|
||||
// Submit without filling name
|
||||
await submit(page);
|
||||
|
||||
// Should show validation error for name (zod validation)
|
||||
await expect(page.getByText(/cannot be empty/i)).toBeVisible();
|
||||
await expect(page).toHaveURL(/\/home\/pipelines\?id=new$/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('cross-resource flows', () => {
|
||||
test('creates a pipeline then binds it to a bot', async ({ page }) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
// Create a pipeline first
|
||||
await page.goto('/home/pipelines?id=new');
|
||||
await page.locator('input[name="basic.name"]').fill('Production Pipeline');
|
||||
await submit(page);
|
||||
await expect(page).toHaveURL(/\/home\/pipelines\?id=pipeline-1$/);
|
||||
|
||||
// Create a bot
|
||||
await page.goto('/home/bots?id=new');
|
||||
await page.locator('input[name="name"]').fill('Bound Bot');
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Playwright Adapter' }).click();
|
||||
await submit(page);
|
||||
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
|
||||
|
||||
// Wait for form to fully load
|
||||
await expect(page.locator('input[name="name"]')).toHaveValue('Bound Bot');
|
||||
|
||||
// Find the pipeline select by its label "Bind Pipeline"
|
||||
const pipelineCard = page.getByText('Bind Pipeline').locator('..');
|
||||
await expect(pipelineCard).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Click on the select trigger within the pipeline binding card
|
||||
// The select trigger shows "Select Pipeline" placeholder initially
|
||||
const pipelineSelectTrigger = page.getByText('Select Pipeline').first();
|
||||
await pipelineSelectTrigger.click();
|
||||
|
||||
// Select the pipeline option
|
||||
await page.getByRole('option', { name: 'Production Pipeline' }).click();
|
||||
|
||||
// Save the bot
|
||||
await save(page);
|
||||
|
||||
// Reload and verify binding persisted
|
||||
await page.reload();
|
||||
// The pipeline name should appear in the select trigger (not in sidebar or options)
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-slot="select-trigger"]')
|
||||
.filter({ hasText: 'Production Pipeline' }),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('empty states', () => {
|
||||
test('shows empty state when no bots exist', async ({ page }) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
await page.goto('/home/bots');
|
||||
await expect(page.getByText('Select a bot from the sidebar')).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows empty state when no pipelines exist', async ({ page }) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
await page.goto('/home/pipelines');
|
||||
await expect(
|
||||
page.getByText('Select a pipeline from the sidebar'),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows empty state when no knowledge bases exist', async ({ page }) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
await page.goto('/home/knowledge');
|
||||
await expect(
|
||||
page.getByText('Select a knowledge base from the sidebar'),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows empty state when no MCP servers exist', async ({ page }) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
await page.goto('/home/mcp');
|
||||
await expect(
|
||||
page.getByText('Select an MCP server from the sidebar'),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,68 @@ interface SkillMock {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface PipelineMock {
|
||||
uuid: string;
|
||||
name: string;
|
||||
description: string;
|
||||
config: JsonRecord;
|
||||
emoji: string;
|
||||
is_default: boolean;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface KnowledgeBaseMock {
|
||||
uuid: string;
|
||||
name: string;
|
||||
description: string;
|
||||
emoji: string;
|
||||
knowledge_engine_plugin_id: string;
|
||||
creation_settings: JsonRecord;
|
||||
retrieval_settings: JsonRecord;
|
||||
knowledge_engine: {
|
||||
plugin_id: string;
|
||||
name: {
|
||||
en_US: string;
|
||||
zh_Hans: string;
|
||||
};
|
||||
capabilities: string[];
|
||||
};
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface MCPServerMock {
|
||||
name: string;
|
||||
mode: 'sse' | 'stdio' | 'http';
|
||||
enable: boolean;
|
||||
extra_args: JsonRecord;
|
||||
runtime_info: {
|
||||
status: 'connected';
|
||||
tool_count: number;
|
||||
tools: unknown[];
|
||||
};
|
||||
readme: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface BotMock {
|
||||
uuid: string;
|
||||
name: string;
|
||||
description: string;
|
||||
enable: boolean;
|
||||
adapter: string;
|
||||
adapter_config: JsonRecord;
|
||||
use_pipeline_uuid?: string;
|
||||
pipeline_routing_rules: unknown[];
|
||||
adapter_runtime_values: JsonRecord;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface LangBotApiMockState {
|
||||
bots: BotMock[];
|
||||
counters: Record<string, number>;
|
||||
knowledgeBases: KnowledgeBaseMock[];
|
||||
mcpServers: MCPServerMock[];
|
||||
pipelines: PipelineMock[];
|
||||
skills: SkillMock[];
|
||||
}
|
||||
|
||||
@@ -36,6 +97,19 @@ function routePath(route: Route) {
|
||||
return new URL(route.request().url()).pathname;
|
||||
}
|
||||
|
||||
function parseJsonBody(route: Route): JsonRecord {
|
||||
return JSON.parse(route.request().postData() || '{}') as JsonRecord;
|
||||
}
|
||||
|
||||
function now() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function nextId(state: LangBotApiMockState, prefix: string) {
|
||||
state.counters[prefix] = (state.counters[prefix] || 0) + 1;
|
||||
return `${prefix}-${state.counters[prefix]}`;
|
||||
}
|
||||
|
||||
function emptyMonitoringData() {
|
||||
return {
|
||||
overview: {
|
||||
@@ -93,6 +167,131 @@ function makeSkill(data: JsonRecord): SkillMock {
|
||||
};
|
||||
}
|
||||
|
||||
function makePipeline(
|
||||
state: LangBotApiMockState,
|
||||
data: JsonRecord,
|
||||
uuid = nextId(state, 'pipeline'),
|
||||
): PipelineMock {
|
||||
return {
|
||||
uuid,
|
||||
name: String(data.name || ''),
|
||||
description: String(data.description || ''),
|
||||
config: (data.config as JsonRecord | undefined) || {
|
||||
ai: {},
|
||||
trigger: {},
|
||||
safety: {},
|
||||
output: {},
|
||||
},
|
||||
emoji: String(data.emoji || '⚙️'),
|
||||
is_default: false,
|
||||
updated_at: now(),
|
||||
};
|
||||
}
|
||||
|
||||
function knowledgeEngine() {
|
||||
return {
|
||||
plugin_id: 'builtin/minimal-knowledge',
|
||||
name: {
|
||||
en_US: 'Minimal Knowledge Engine',
|
||||
zh_Hans: '最小知识库引擎',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Minimal mocked engine for frontend smoke tests.',
|
||||
zh_Hans: '用于前端冒烟测试的最小模拟引擎。',
|
||||
},
|
||||
capabilities: ['text_retrieval'],
|
||||
creation_schema: [],
|
||||
retrieval_schema: [],
|
||||
};
|
||||
}
|
||||
|
||||
function makeKnowledgeBase(
|
||||
state: LangBotApiMockState,
|
||||
data: JsonRecord,
|
||||
uuid = nextId(state, 'knowledge'),
|
||||
): KnowledgeBaseMock {
|
||||
const engine = knowledgeEngine();
|
||||
return {
|
||||
uuid,
|
||||
name: String(data.name || ''),
|
||||
description: String(data.description || ''),
|
||||
emoji: String(data.emoji || '📚'),
|
||||
knowledge_engine_plugin_id: String(
|
||||
data.knowledge_engine_plugin_id || engine.plugin_id,
|
||||
),
|
||||
creation_settings: (data.creation_settings as JsonRecord | undefined) || {},
|
||||
retrieval_settings:
|
||||
(data.retrieval_settings as JsonRecord | undefined) || {},
|
||||
knowledge_engine: {
|
||||
plugin_id: engine.plugin_id,
|
||||
name: engine.name,
|
||||
capabilities: engine.capabilities,
|
||||
},
|
||||
updated_at: now(),
|
||||
};
|
||||
}
|
||||
|
||||
function makeMCPServer(data: JsonRecord): MCPServerMock {
|
||||
return {
|
||||
name: String(data.name || ''),
|
||||
mode: (data.mode as MCPServerMock['mode']) || 'sse',
|
||||
enable: data.enable !== false,
|
||||
extra_args: (data.extra_args as JsonRecord | undefined) || {},
|
||||
runtime_info: {
|
||||
status: 'connected',
|
||||
tool_count: 0,
|
||||
tools: [],
|
||||
},
|
||||
readme: '',
|
||||
updated_at: now(),
|
||||
};
|
||||
}
|
||||
|
||||
function makeBot(
|
||||
state: LangBotApiMockState,
|
||||
data: JsonRecord,
|
||||
uuid = nextId(state, 'bot'),
|
||||
): BotMock {
|
||||
return {
|
||||
uuid,
|
||||
name: String(data.name || ''),
|
||||
description: String(data.description || ''),
|
||||
enable: data.enable !== false,
|
||||
adapter: String(data.adapter || 'playwright-adapter'),
|
||||
adapter_config: (data.adapter_config as JsonRecord | undefined) || {},
|
||||
use_pipeline_uuid: data.use_pipeline_uuid
|
||||
? String(data.use_pipeline_uuid)
|
||||
: undefined,
|
||||
pipeline_routing_rules:
|
||||
(data.pipeline_routing_rules as unknown[] | undefined) || [],
|
||||
adapter_runtime_values: {
|
||||
webhook_full_url: `https://playwright.test/bots/${uuid}/webhook`,
|
||||
extra_webhook_full_url: '',
|
||||
},
|
||||
updated_at: now(),
|
||||
};
|
||||
}
|
||||
|
||||
function mockAdapters() {
|
||||
return [
|
||||
{
|
||||
name: 'playwright-adapter',
|
||||
label: {
|
||||
en_US: 'Playwright Adapter',
|
||||
zh_Hans: 'Playwright 适配器',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Minimal adapter for frontend E2E tests.',
|
||||
zh_Hans: '用于前端 E2E 测试的最小适配器。',
|
||||
},
|
||||
spec: {
|
||||
categories: ['testing'],
|
||||
config: [],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async function handleBackendApi(route: Route, state: LangBotApiMockState) {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
@@ -147,16 +346,160 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
|
||||
return fulfillJson(route, { credits: null });
|
||||
}
|
||||
|
||||
if (path === '/api/v1/platform/adapters') {
|
||||
return fulfillJson(route, { adapters: mockAdapters() });
|
||||
}
|
||||
|
||||
if (path === '/api/v1/platform/bots') {
|
||||
return fulfillJson(route, { bots: [] });
|
||||
if (method === 'POST') {
|
||||
const bot = makeBot(state, parseJsonBody(route));
|
||||
state.bots = [
|
||||
...state.bots.filter((item) => item.uuid !== bot.uuid),
|
||||
bot,
|
||||
];
|
||||
return fulfillJson(route, { uuid: bot.uuid });
|
||||
}
|
||||
|
||||
return fulfillJson(route, { bots: state.bots });
|
||||
}
|
||||
|
||||
const botLogsMatch = path.match(/^\/api\/v1\/platform\/bots\/([^/]+)\/logs$/);
|
||||
if (botLogsMatch) {
|
||||
return fulfillJson(route, { logs: [], total: 0 });
|
||||
}
|
||||
|
||||
const botMatch = path.match(/^\/api\/v1\/platform\/bots\/([^/]+)$/);
|
||||
if (botMatch) {
|
||||
const botId = decodeURIComponent(botMatch[1]);
|
||||
|
||||
if (method === 'PUT') {
|
||||
const bot = makeBot(state, parseJsonBody(route), botId);
|
||||
state.bots = [...state.bots.filter((item) => item.uuid !== botId), bot];
|
||||
return fulfillJson(route, {});
|
||||
}
|
||||
|
||||
if (method === 'DELETE') {
|
||||
state.bots = state.bots.filter((item) => item.uuid !== botId);
|
||||
return fulfillJson(route, {});
|
||||
}
|
||||
|
||||
const bot = state.bots.find((item) => item.uuid === botId);
|
||||
return fulfillJson(route, {
|
||||
bot: bot || makeBot(state, { name: botId }, botId),
|
||||
});
|
||||
}
|
||||
|
||||
if (path === '/api/v1/pipelines/_/metadata') {
|
||||
return fulfillJson(route, { configs: [] });
|
||||
}
|
||||
|
||||
if (path === '/api/v1/pipelines') {
|
||||
return fulfillJson(route, { pipelines: [] });
|
||||
if (method === 'POST') {
|
||||
const pipeline = makePipeline(state, parseJsonBody(route));
|
||||
state.pipelines = [
|
||||
...state.pipelines.filter((item) => item.uuid !== pipeline.uuid),
|
||||
pipeline,
|
||||
];
|
||||
return fulfillJson(route, { uuid: pipeline.uuid });
|
||||
}
|
||||
|
||||
return fulfillJson(route, { pipelines: state.pipelines });
|
||||
}
|
||||
|
||||
const pipelineMatch = path.match(/^\/api\/v1\/pipelines\/([^/]+)$/);
|
||||
if (pipelineMatch) {
|
||||
const pipelineId = decodeURIComponent(pipelineMatch[1]);
|
||||
|
||||
if (method === 'PUT') {
|
||||
const pipeline = makePipeline(state, parseJsonBody(route), pipelineId);
|
||||
state.pipelines = [
|
||||
...state.pipelines.filter((item) => item.uuid !== pipelineId),
|
||||
pipeline,
|
||||
];
|
||||
return fulfillJson(route, {});
|
||||
}
|
||||
|
||||
if (method === 'DELETE') {
|
||||
state.pipelines = state.pipelines.filter(
|
||||
(item) => item.uuid !== pipelineId,
|
||||
);
|
||||
return fulfillJson(route, {});
|
||||
}
|
||||
|
||||
const pipeline = state.pipelines.find((item) => item.uuid === pipelineId);
|
||||
return fulfillJson(route, {
|
||||
pipeline:
|
||||
pipeline || makePipeline(state, { name: pipelineId }, pipelineId),
|
||||
});
|
||||
}
|
||||
|
||||
const pipelineExtensionsMatch = path.match(
|
||||
/^\/api\/v1\/pipelines\/([^/]+)\/extensions$/,
|
||||
);
|
||||
if (pipelineExtensionsMatch) {
|
||||
return fulfillJson(route, {
|
||||
enable_all_plugins: true,
|
||||
enable_all_mcp_servers: true,
|
||||
enable_all_skills: true,
|
||||
bound_plugins: [],
|
||||
available_plugins: [],
|
||||
bound_mcp_servers: [],
|
||||
available_mcp_servers: state.mcpServers,
|
||||
bound_skills: [],
|
||||
available_skills: state.skills,
|
||||
});
|
||||
}
|
||||
|
||||
if (path === '/api/v1/knowledge/bases') {
|
||||
return fulfillJson(route, { bases: [] });
|
||||
if (method === 'POST') {
|
||||
const base = makeKnowledgeBase(state, parseJsonBody(route));
|
||||
state.knowledgeBases = [
|
||||
...state.knowledgeBases.filter((item) => item.uuid !== base.uuid),
|
||||
base,
|
||||
];
|
||||
return fulfillJson(route, { uuid: base.uuid });
|
||||
}
|
||||
|
||||
return fulfillJson(route, { bases: state.knowledgeBases });
|
||||
}
|
||||
|
||||
const knowledgeBaseFilesMatch = path.match(
|
||||
/^\/api\/v1\/knowledge\/bases\/([^/]+)\/files$/,
|
||||
);
|
||||
if (knowledgeBaseFilesMatch) {
|
||||
return fulfillJson(route, { files: [] });
|
||||
}
|
||||
|
||||
const knowledgeBaseMatch = path.match(
|
||||
/^\/api\/v1\/knowledge\/bases\/([^/]+)$/,
|
||||
);
|
||||
if (knowledgeBaseMatch) {
|
||||
const baseId = decodeURIComponent(knowledgeBaseMatch[1]);
|
||||
|
||||
if (method === 'PUT') {
|
||||
const base = makeKnowledgeBase(state, parseJsonBody(route), baseId);
|
||||
state.knowledgeBases = [
|
||||
...state.knowledgeBases.filter((item) => item.uuid !== baseId),
|
||||
base,
|
||||
];
|
||||
return fulfillJson(route, { uuid: base.uuid });
|
||||
}
|
||||
|
||||
if (method === 'DELETE') {
|
||||
state.knowledgeBases = state.knowledgeBases.filter(
|
||||
(item) => item.uuid !== baseId,
|
||||
);
|
||||
return fulfillJson(route, {});
|
||||
}
|
||||
|
||||
const base = state.knowledgeBases.find((item) => item.uuid === baseId);
|
||||
return fulfillJson(route, {
|
||||
base: base || makeKnowledgeBase(state, { name: baseId }, baseId),
|
||||
});
|
||||
}
|
||||
|
||||
if (path === '/api/v1/knowledge/engines') {
|
||||
return fulfillJson(route, { engines: [knowledgeEngine()] });
|
||||
}
|
||||
|
||||
if (path === '/api/v1/knowledge/migration/status') {
|
||||
@@ -176,7 +519,60 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
|
||||
}
|
||||
|
||||
if (path === '/api/v1/mcp/servers') {
|
||||
return fulfillJson(route, { servers: [] });
|
||||
if (method === 'POST') {
|
||||
const server = makeMCPServer(parseJsonBody(route));
|
||||
state.mcpServers = [
|
||||
...state.mcpServers.filter((item) => item.name !== server.name),
|
||||
server,
|
||||
];
|
||||
return fulfillJson(route, { task_id: nextId(state, 'task') });
|
||||
}
|
||||
|
||||
return fulfillJson(route, { servers: state.mcpServers });
|
||||
}
|
||||
|
||||
const mcpTestMatch = path.match(/^\/api\/v1\/mcp\/servers\/([^/]+)\/test$/);
|
||||
if (mcpTestMatch) {
|
||||
return fulfillJson(route, {
|
||||
runtime_info: {
|
||||
status: 'connected',
|
||||
tool_count: 0,
|
||||
tools: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const mcpServerMatch = path.match(/^\/api\/v1\/mcp\/servers\/([^/]+)$/);
|
||||
if (mcpServerMatch) {
|
||||
const serverName = decodeURIComponent(mcpServerMatch[1]);
|
||||
|
||||
if (method === 'PUT') {
|
||||
const existing = state.mcpServers.find(
|
||||
(item) => item.name === serverName,
|
||||
);
|
||||
const server = makeMCPServer({
|
||||
...(existing || {}),
|
||||
...parseJsonBody(route),
|
||||
name: serverName,
|
||||
});
|
||||
state.mcpServers = [
|
||||
...state.mcpServers.filter((item) => item.name !== serverName),
|
||||
server,
|
||||
];
|
||||
return fulfillJson(route, { task_id: nextId(state, 'task') });
|
||||
}
|
||||
|
||||
if (method === 'DELETE') {
|
||||
state.mcpServers = state.mcpServers.filter(
|
||||
(item) => item.name !== serverName,
|
||||
);
|
||||
return fulfillJson(route, { task_id: nextId(state, 'task') });
|
||||
}
|
||||
|
||||
const server = state.mcpServers.find((item) => item.name === serverName);
|
||||
return fulfillJson(route, {
|
||||
server: server || makeMCPServer({ name: serverName }),
|
||||
});
|
||||
}
|
||||
|
||||
if (path === '/api/v1/skills') {
|
||||
@@ -229,6 +625,23 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
|
||||
const skillMatch = path.match(/^\/api\/v1\/skills\/([^/]+)$/);
|
||||
if (skillMatch) {
|
||||
const skillName = decodeURIComponent(skillMatch[1]);
|
||||
if (method === 'PUT') {
|
||||
const skill = makeSkill({
|
||||
...parseJsonBody(route),
|
||||
name: skillName,
|
||||
});
|
||||
state.skills = [
|
||||
...state.skills.filter((item) => item.name !== skillName),
|
||||
skill,
|
||||
];
|
||||
return fulfillJson(route, { skill });
|
||||
}
|
||||
|
||||
if (method === 'DELETE') {
|
||||
state.skills = state.skills.filter((item) => item.name !== skillName);
|
||||
return fulfillJson(route, {});
|
||||
}
|
||||
|
||||
const skill = state.skills.find((item) => item.name === skillName) || {
|
||||
name: skillName,
|
||||
display_name: '',
|
||||
@@ -389,6 +802,11 @@ export async function installLangBotApiMocks(
|
||||
) {
|
||||
const { authenticated = false, storage = {} } = options;
|
||||
const state: LangBotApiMockState = {
|
||||
bots: [],
|
||||
counters: {},
|
||||
knowledgeBases: [],
|
||||
mcpServers: [],
|
||||
pipelines: [],
|
||||
skills: [],
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user