Update frontend from deer-flow and add frontend_backup to gitignore

This commit is contained in:
肖应宇 2026-03-13 20:08:11 +08:00
parent ef9a071aa1
commit dbef018fd1
61 changed files with 3039 additions and 1278 deletions

3
.gitignore vendored
View File

@ -46,3 +46,6 @@ sandbox_image_cache.tar
# ignore the legacy `web` folder # ignore the legacy `web` folder
web/ web/
# ignore frontend backup
frontend_backup/

View File

@ -14,17 +14,17 @@ http {
access_log /dev/stdout; access_log /dev/stdout;
error_log /dev/stderr; error_log /dev/stderr;
# Upstream servers (using Docker service names for Docker Compose) # Upstream servers (using localhost for local development)
upstream gateway { upstream gateway {
server gateway:8001; server localhost:8001;
} }
upstream langgraph { upstream langgraph {
server langgraph:2024; server localhost:2024;
} }
upstream frontend { upstream frontend {
server frontend:3000; server localhost:3000;
} }
server { server {

View File

@ -1,22 +1,35 @@
# Frontend Development Dockerfile # Frontend Dockerfile
FROM node:22-alpine # Supports two targets:
# --target dev — install deps only, run `pnpm dev` at container start
# --target prod — full build baked in, run `pnpm start` at container start (default if no --target is specified)
# Accept build argument for pnpm store path
ARG PNPM_STORE_PATH=/root/.local/share/pnpm/store ARG PNPM_STORE_PATH=/root/.local/share/pnpm/store
# Install pnpm at specific version (matching package.json) # ── Base: shared setup ────────────────────────────────────────────────────────
FROM node:22-alpine AS base
ARG PNPM_STORE_PATH
RUN corepack enable && corepack install -g pnpm@10.26.2 RUN corepack enable && corepack install -g pnpm@10.26.2
RUN pnpm config set store-dir ${PNPM_STORE_PATH} RUN pnpm config set store-dir ${PNPM_STORE_PATH}
# Set working directory
WORKDIR /app WORKDIR /app
# Copy frontend source code
COPY frontend ./frontend COPY frontend ./frontend
# Install dependencies # ── Dev: install only, CMD is overridden by docker-compose ───────────────────
RUN sh -c "cd /app/frontend && pnpm install --frozen-lockfile" FROM base AS dev
RUN cd /app/frontend && pnpm install --frozen-lockfile
# Expose Next.js dev server port
EXPOSE 3000 EXPOSE 3000
# ── Builder: install + compile Next.js ───────────────────────────────────────
FROM base AS builder
RUN cd /app/frontend && pnpm install --frozen-lockfile
# Skip env validation — runtime vars are injected by nginx/container
RUN cd /app/frontend && SKIP_ENV_VALIDATION=1 pnpm build
# ── Prod: minimal runtime with pre-built output ───────────────────────────────
FROM node:22-alpine AS prod
ARG PNPM_STORE_PATH
RUN corepack enable && corepack install -g pnpm@10.26.2
RUN pnpm config set store-dir ${PNPM_STORE_PATH}
WORKDIR /app
COPY --from=builder /app/frontend ./frontend
EXPOSE 3000
CMD ["sh", "-c", "cd /app/frontend && pnpm start"]

View File

@ -716,105 +716,89 @@ packages:
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.4': '@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.4': '@img/sharp-libvips-linux-ppc64@1.2.4':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.2.4': '@img/sharp-libvips-linux-riscv64@1.2.4':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.4': '@img/sharp-libvips-linux-s390x@1.2.4':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.4': '@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.2.4': '@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.4': '@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.34.5': '@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.34.5': '@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.34.5': '@img/sharp-linux-ppc64@0.34.5':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-linux-riscv64@0.34.5': '@img/sharp-linux-riscv64@0.34.5':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.34.5': '@img/sharp-linux-s390x@0.34.5':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.34.5': '@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.34.5': '@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.5': '@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.34.5': '@img/sharp-wasm32@0.34.5':
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
@ -956,28 +940,24 @@ packages:
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@next/swc-linux-arm64-musl@16.1.6': '@next/swc-linux-arm64-musl@16.1.6':
resolution: {integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==} resolution: {integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@next/swc-linux-x64-gnu@16.1.6': '@next/swc-linux-x64-gnu@16.1.6':
resolution: {integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==} resolution: {integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@next/swc-linux-x64-musl@16.1.6': '@next/swc-linux-x64-musl@16.1.6':
resolution: {integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==} resolution: {integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@next/swc-win32-arm64-msvc@16.1.6': '@next/swc-win32-arm64-msvc@16.1.6':
resolution: {integrity: sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==} resolution: {integrity: sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==}
@ -1571,28 +1551,24 @@ packages:
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@resvg/resvg-js-linux-arm64-musl@2.6.2': '@resvg/resvg-js-linux-arm64-musl@2.6.2':
resolution: {integrity: sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==} resolution: {integrity: sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@resvg/resvg-js-linux-x64-gnu@2.6.2': '@resvg/resvg-js-linux-x64-gnu@2.6.2':
resolution: {integrity: sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==} resolution: {integrity: sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@resvg/resvg-js-linux-x64-musl@2.6.2': '@resvg/resvg-js-linux-x64-musl@2.6.2':
resolution: {integrity: sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==} resolution: {integrity: sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@resvg/resvg-js-win32-arm64-msvc@2.6.2': '@resvg/resvg-js-win32-arm64-msvc@2.6.2':
resolution: {integrity: sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==} resolution: {integrity: sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==}
@ -1620,141 +1596,128 @@ packages:
resolution: {integrity: sha512-FqALmHI8D4o6lk/LRWDnhw95z5eO+eAa6ORjVg09YRR7BkcM6oPHU9uyC0gtQG5vpFLvgpeU4+zEAz2H8APHNw==} resolution: {integrity: sha512-FqALmHI8D4o6lk/LRWDnhw95z5eO+eAa6ORjVg09YRR7BkcM6oPHU9uyC0gtQG5vpFLvgpeU4+zEAz2H8APHNw==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
'@rollup/rollup-android-arm-eabi@4.57.1': '@rollup/rollup-android-arm-eabi@4.59.0':
resolution: {integrity: sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==} resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==}
cpu: [arm] cpu: [arm]
os: [android] os: [android]
'@rollup/rollup-android-arm64@4.57.1': '@rollup/rollup-android-arm64@4.59.0':
resolution: {integrity: sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==} resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==}
cpu: [arm64] cpu: [arm64]
os: [android] os: [android]
'@rollup/rollup-darwin-arm64@4.57.1': '@rollup/rollup-darwin-arm64@4.59.0':
resolution: {integrity: sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==} resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@rollup/rollup-darwin-x64@4.57.1': '@rollup/rollup-darwin-x64@4.59.0':
resolution: {integrity: sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==} resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==}
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
'@rollup/rollup-freebsd-arm64@4.57.1': '@rollup/rollup-freebsd-arm64@4.59.0':
resolution: {integrity: sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==} resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==}
cpu: [arm64] cpu: [arm64]
os: [freebsd] os: [freebsd]
'@rollup/rollup-freebsd-x64@4.57.1': '@rollup/rollup-freebsd-x64@4.59.0':
resolution: {integrity: sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==} resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==}
cpu: [x64] cpu: [x64]
os: [freebsd] os: [freebsd]
'@rollup/rollup-linux-arm-gnueabihf@4.57.1': '@rollup/rollup-linux-arm-gnueabihf@4.59.0':
resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==} resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.57.1': '@rollup/rollup-linux-arm-musleabihf@4.59.0':
resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==} resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.57.1': '@rollup/rollup-linux-arm64-gnu@4.59.0':
resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==} resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.57.1': '@rollup/rollup-linux-arm64-musl@4.59.0':
resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==} resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.57.1': '@rollup/rollup-linux-loong64-gnu@4.59.0':
resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==} resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==}
cpu: [loong64] cpu: [loong64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.57.1': '@rollup/rollup-linux-loong64-musl@4.59.0':
resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==} resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==}
cpu: [loong64] cpu: [loong64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.57.1': '@rollup/rollup-linux-ppc64-gnu@4.59.0':
resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==} resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.57.1': '@rollup/rollup-linux-ppc64-musl@4.59.0':
resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==} resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.57.1': '@rollup/rollup-linux-riscv64-gnu@4.59.0':
resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==} resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.57.1': '@rollup/rollup-linux-riscv64-musl@4.59.0':
resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==} resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.57.1': '@rollup/rollup-linux-s390x-gnu@4.59.0':
resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==} resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.57.1': '@rollup/rollup-linux-x64-gnu@4.59.0':
resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==} resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.57.1': '@rollup/rollup-linux-x64-musl@4.59.0':
resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==} resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.57.1': '@rollup/rollup-openbsd-x64@4.59.0':
resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==} resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==}
cpu: [x64] cpu: [x64]
os: [openbsd] os: [openbsd]
'@rollup/rollup-openharmony-arm64@4.57.1': '@rollup/rollup-openharmony-arm64@4.59.0':
resolution: {integrity: sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==} resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==}
cpu: [arm64] cpu: [arm64]
os: [openharmony] os: [openharmony]
'@rollup/rollup-win32-arm64-msvc@4.57.1': '@rollup/rollup-win32-arm64-msvc@4.59.0':
resolution: {integrity: sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==} resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==}
cpu: [arm64] cpu: [arm64]
os: [win32] os: [win32]
'@rollup/rollup-win32-ia32-msvc@4.57.1': '@rollup/rollup-win32-ia32-msvc@4.59.0':
resolution: {integrity: sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==} resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==}
cpu: [ia32] cpu: [ia32]
os: [win32] os: [win32]
'@rollup/rollup-win32-x64-gnu@4.57.1': '@rollup/rollup-win32-x64-gnu@4.59.0':
resolution: {integrity: sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==} resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@rollup/rollup-win32-x64-msvc@4.57.1': '@rollup/rollup-win32-x64-msvc@4.59.0':
resolution: {integrity: sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==} resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
@ -1869,28 +1832,24 @@ packages:
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.1.18': '@tailwindcss/oxide-linux-arm64-musl@4.1.18':
resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.1.18': '@tailwindcss/oxide-linux-x64-gnu@4.1.18':
resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.1.18': '@tailwindcss/oxide-linux-x64-musl@4.1.18':
resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.1.18': '@tailwindcss/oxide-wasm32-wasi@4.1.18':
resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==}
@ -2253,49 +2212,41 @@ packages:
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-arm64-musl@1.11.1': '@unrs/resolver-binding-linux-arm64-musl@1.11.1':
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1': '@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [musl]
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1': '@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-x64-gnu@1.11.1': '@unrs/resolver-binding-linux-x64-gnu@1.11.1':
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-x64-musl@1.11.1': '@unrs/resolver-binding-linux-x64-musl@1.11.1':
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@unrs/resolver-binding-wasm32-wasi@1.11.1': '@unrs/resolver-binding-wasm32-wasi@1.11.1':
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
@ -3783,28 +3734,24 @@ packages:
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.30.2: lightningcss-linux-arm64-musl@1.30.2:
resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.30.2: lightningcss-linux-x64-gnu@1.30.2:
resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.30.2: lightningcss-linux-x64-musl@1.30.2:
resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.30.2: lightningcss-win32-arm64-msvc@1.30.2:
resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==}
@ -3845,8 +3792,8 @@ packages:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true hasBin: true
lru-cache@11.2.5: lru-cache@11.2.6:
resolution: {integrity: sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==} resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==}
engines: {node: 20 || >=22} engines: {node: 20 || >=22}
lucide-react@0.542.0: lucide-react@0.542.0:
@ -4561,8 +4508,8 @@ packages:
robust-predicates@3.0.2: robust-predicates@3.0.2:
resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==} resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==}
rollup@4.57.1: rollup@4.59.0:
resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==} resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'} engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true hasBin: true
@ -6602,79 +6549,79 @@ snapshots:
'@resvg/resvg-wasm@2.6.2': {} '@resvg/resvg-wasm@2.6.2': {}
'@rollup/rollup-android-arm-eabi@4.57.1': '@rollup/rollup-android-arm-eabi@4.59.0':
optional: true optional: true
'@rollup/rollup-android-arm64@4.57.1': '@rollup/rollup-android-arm64@4.59.0':
optional: true optional: true
'@rollup/rollup-darwin-arm64@4.57.1': '@rollup/rollup-darwin-arm64@4.59.0':
optional: true optional: true
'@rollup/rollup-darwin-x64@4.57.1': '@rollup/rollup-darwin-x64@4.59.0':
optional: true optional: true
'@rollup/rollup-freebsd-arm64@4.57.1': '@rollup/rollup-freebsd-arm64@4.59.0':
optional: true optional: true
'@rollup/rollup-freebsd-x64@4.57.1': '@rollup/rollup-freebsd-x64@4.59.0':
optional: true optional: true
'@rollup/rollup-linux-arm-gnueabihf@4.57.1': '@rollup/rollup-linux-arm-gnueabihf@4.59.0':
optional: true optional: true
'@rollup/rollup-linux-arm-musleabihf@4.57.1': '@rollup/rollup-linux-arm-musleabihf@4.59.0':
optional: true optional: true
'@rollup/rollup-linux-arm64-gnu@4.57.1': '@rollup/rollup-linux-arm64-gnu@4.59.0':
optional: true optional: true
'@rollup/rollup-linux-arm64-musl@4.57.1': '@rollup/rollup-linux-arm64-musl@4.59.0':
optional: true optional: true
'@rollup/rollup-linux-loong64-gnu@4.57.1': '@rollup/rollup-linux-loong64-gnu@4.59.0':
optional: true optional: true
'@rollup/rollup-linux-loong64-musl@4.57.1': '@rollup/rollup-linux-loong64-musl@4.59.0':
optional: true optional: true
'@rollup/rollup-linux-ppc64-gnu@4.57.1': '@rollup/rollup-linux-ppc64-gnu@4.59.0':
optional: true optional: true
'@rollup/rollup-linux-ppc64-musl@4.57.1': '@rollup/rollup-linux-ppc64-musl@4.59.0':
optional: true optional: true
'@rollup/rollup-linux-riscv64-gnu@4.57.1': '@rollup/rollup-linux-riscv64-gnu@4.59.0':
optional: true optional: true
'@rollup/rollup-linux-riscv64-musl@4.57.1': '@rollup/rollup-linux-riscv64-musl@4.59.0':
optional: true optional: true
'@rollup/rollup-linux-s390x-gnu@4.57.1': '@rollup/rollup-linux-s390x-gnu@4.59.0':
optional: true optional: true
'@rollup/rollup-linux-x64-gnu@4.57.1': '@rollup/rollup-linux-x64-gnu@4.59.0':
optional: true optional: true
'@rollup/rollup-linux-x64-musl@4.57.1': '@rollup/rollup-linux-x64-musl@4.59.0':
optional: true optional: true
'@rollup/rollup-openbsd-x64@4.57.1': '@rollup/rollup-openbsd-x64@4.59.0':
optional: true optional: true
'@rollup/rollup-openharmony-arm64@4.57.1': '@rollup/rollup-openharmony-arm64@4.59.0':
optional: true optional: true
'@rollup/rollup-win32-arm64-msvc@4.57.1': '@rollup/rollup-win32-arm64-msvc@4.59.0':
optional: true optional: true
'@rollup/rollup-win32-ia32-msvc@4.57.1': '@rollup/rollup-win32-ia32-msvc@4.59.0':
optional: true optional: true
'@rollup/rollup-win32-x64-gnu@4.57.1': '@rollup/rollup-win32-x64-gnu@4.59.0':
optional: true optional: true
'@rollup/rollup-win32-x64-msvc@4.57.1': '@rollup/rollup-win32-x64-msvc@4.59.0':
optional: true optional: true
'@rtsao/scc@1.1.0': {} '@rtsao/scc@1.1.0': {}
@ -9008,7 +8955,7 @@ snapshots:
dependencies: dependencies:
js-tokens: 4.0.0 js-tokens: 4.0.0
lru-cache@11.2.5: {} lru-cache@11.2.6: {}
lucide-react@0.542.0(react@19.2.4): lucide-react@0.542.0(react@19.2.4):
dependencies: dependencies:
@ -10002,35 +9949,35 @@ snapshots:
robust-predicates@3.0.2: {} robust-predicates@3.0.2: {}
rollup@4.57.1: rollup@4.59.0:
dependencies: dependencies:
'@types/estree': 1.0.8 '@types/estree': 1.0.8
optionalDependencies: optionalDependencies:
'@rollup/rollup-android-arm-eabi': 4.57.1 '@rollup/rollup-android-arm-eabi': 4.59.0
'@rollup/rollup-android-arm64': 4.57.1 '@rollup/rollup-android-arm64': 4.59.0
'@rollup/rollup-darwin-arm64': 4.57.1 '@rollup/rollup-darwin-arm64': 4.59.0
'@rollup/rollup-darwin-x64': 4.57.1 '@rollup/rollup-darwin-x64': 4.59.0
'@rollup/rollup-freebsd-arm64': 4.57.1 '@rollup/rollup-freebsd-arm64': 4.59.0
'@rollup/rollup-freebsd-x64': 4.57.1 '@rollup/rollup-freebsd-x64': 4.59.0
'@rollup/rollup-linux-arm-gnueabihf': 4.57.1 '@rollup/rollup-linux-arm-gnueabihf': 4.59.0
'@rollup/rollup-linux-arm-musleabihf': 4.57.1 '@rollup/rollup-linux-arm-musleabihf': 4.59.0
'@rollup/rollup-linux-arm64-gnu': 4.57.1 '@rollup/rollup-linux-arm64-gnu': 4.59.0
'@rollup/rollup-linux-arm64-musl': 4.57.1 '@rollup/rollup-linux-arm64-musl': 4.59.0
'@rollup/rollup-linux-loong64-gnu': 4.57.1 '@rollup/rollup-linux-loong64-gnu': 4.59.0
'@rollup/rollup-linux-loong64-musl': 4.57.1 '@rollup/rollup-linux-loong64-musl': 4.59.0
'@rollup/rollup-linux-ppc64-gnu': 4.57.1 '@rollup/rollup-linux-ppc64-gnu': 4.59.0
'@rollup/rollup-linux-ppc64-musl': 4.57.1 '@rollup/rollup-linux-ppc64-musl': 4.59.0
'@rollup/rollup-linux-riscv64-gnu': 4.57.1 '@rollup/rollup-linux-riscv64-gnu': 4.59.0
'@rollup/rollup-linux-riscv64-musl': 4.57.1 '@rollup/rollup-linux-riscv64-musl': 4.59.0
'@rollup/rollup-linux-s390x-gnu': 4.57.1 '@rollup/rollup-linux-s390x-gnu': 4.59.0
'@rollup/rollup-linux-x64-gnu': 4.57.1 '@rollup/rollup-linux-x64-gnu': 4.59.0
'@rollup/rollup-linux-x64-musl': 4.57.1 '@rollup/rollup-linux-x64-musl': 4.59.0
'@rollup/rollup-openbsd-x64': 4.57.1 '@rollup/rollup-openbsd-x64': 4.59.0
'@rollup/rollup-openharmony-arm64': 4.57.1 '@rollup/rollup-openharmony-arm64': 4.59.0
'@rollup/rollup-win32-arm64-msvc': 4.57.1 '@rollup/rollup-win32-arm64-msvc': 4.59.0
'@rollup/rollup-win32-ia32-msvc': 4.57.1 '@rollup/rollup-win32-ia32-msvc': 4.59.0
'@rollup/rollup-win32-x64-gnu': 4.57.1 '@rollup/rollup-win32-x64-gnu': 4.59.0
'@rollup/rollup-win32-x64-msvc': 4.57.1 '@rollup/rollup-win32-x64-msvc': 4.59.0
fsevents: 2.3.3 fsevents: 2.3.3
rou3@0.7.12: {} rou3@0.7.12: {}
@ -10553,7 +10500,7 @@ snapshots:
chokidar: 5.0.0 chokidar: 5.0.0
destr: 2.0.5 destr: 2.0.5
h3: 1.15.5 h3: 1.15.5
lru-cache: 11.2.5 lru-cache: 11.2.6
node-fetch-native: 1.6.7 node-fetch-native: 1.6.7
ofetch: 1.5.1 ofetch: 1.5.1
ufo: 1.6.3 ufo: 1.6.3
@ -10629,7 +10576,7 @@ snapshots:
fdir: 6.5.0(picomatch@4.0.3) fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3 picomatch: 4.0.3
postcss: 8.5.6 postcss: 8.5.6
rollup: 4.57.1 rollup: 4.59.0
tinyglobby: 0.2.15 tinyglobby: 0.2.15
optionalDependencies: optionalDependencies:
'@types/node': 20.19.33 '@types/node': 20.19.33

View File

@ -1,27 +1,84 @@
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
export function POST() { type ThreadSearchRequest = {
limit?: number;
offset?: number;
sortBy?: "updated_at" | "created_at";
sortOrder?: "asc" | "desc";
};
type MockThreadSearchResult = Record<string, unknown> & {
thread_id: string;
updated_at: string | undefined;
};
export async function POST(request: Request) {
const body = ((await request.json().catch(() => ({}))) ?? {}) as ThreadSearchRequest;
const rawLimit = body.limit;
let limit = 50;
if (typeof rawLimit === "number") {
const normalizedLimit = Math.max(0, Math.floor(rawLimit));
if (!Number.isNaN(normalizedLimit)) {
limit = normalizedLimit;
}
}
const rawOffset = body.offset;
let offset = 0;
if (typeof rawOffset === "number") {
const normalizedOffset = Math.max(0, Math.floor(rawOffset));
if (!Number.isNaN(normalizedOffset)) {
offset = normalizedOffset;
}
}
const sortBy = body.sortBy ?? "updated_at";
const sortOrder = body.sortOrder ?? "desc";
const threadsDir = fs.readdirSync( const threadsDir = fs.readdirSync(
path.resolve(process.cwd(), "public/demo/threads"), path.resolve(process.cwd(), "public/demo/threads"),
{ {
withFileTypes: true, withFileTypes: true,
}, },
); );
const threadData = threadsDir const threadData = threadsDir
.map((threadId) => { .map<MockThreadSearchResult | null>((threadId) => {
if (threadId.isDirectory() && !threadId.name.startsWith(".")) { if (threadId.isDirectory() && !threadId.name.startsWith(".")) {
const threadData = fs.readFileSync( const threadData = JSON.parse(
fs.readFileSync(
path.resolve(`public/demo/threads/${threadId.name}/thread.json`), path.resolve(`public/demo/threads/${threadId.name}/thread.json`),
"utf8", "utf8",
); ),
) as Record<string, unknown>;
return { return {
...threadData,
thread_id: threadId.name, thread_id: threadId.name,
values: JSON.parse(threadData).values, updated_at:
typeof threadData.updated_at === "string"
? threadData.updated_at
: typeof threadData.created_at === "string"
? threadData.created_at
: undefined,
}; };
} }
return false; return null;
}) })
.filter(Boolean); .filter((thread): thread is MockThreadSearchResult => thread !== null)
return Response.json(threadData); .sort((a, b) => {
const aTimestamp = a[sortBy];
const bTimestamp = b[sortBy];
const aParsed =
typeof aTimestamp === "string" ? Date.parse(aTimestamp) : 0;
const bParsed =
typeof bTimestamp === "string" ? Date.parse(bTimestamp) : 0;
const aValue = Number.isNaN(aParsed) ? 0 : aParsed;
const bValue = Number.isNaN(bParsed) ? 0 : bParsed;
return sortOrder === "asc" ? aValue - bValue : bValue - aValue;
});
const pagedThreads = threadData.slice(offset, offset + limit);
return Response.json(pagedThreads);
} }

View File

@ -0,0 +1,19 @@
"use client";
import { PromptInputProvider } from "@/components/ai-elements/prompt-input";
import { ArtifactsProvider } from "@/components/workspace/artifacts";
import { SubtasksProvider } from "@/core/tasks/context";
export default function AgentChatLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<SubtasksProvider>
<ArtifactsProvider>
<PromptInputProvider>{children}</PromptInputProvider>
</ArtifactsProvider>
</SubtasksProvider>
);
}

View File

@ -0,0 +1,181 @@
"use client";
import { BotIcon, PlusSquare } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useCallback } from "react";
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { Button } from "@/components/ui/button";
import { AgentWelcome } from "@/components/workspace/agent-welcome";
import { ArtifactTrigger } from "@/components/workspace/artifacts";
import { ChatBox, useThreadChat } from "@/components/workspace/chats";
import { InputBox } from "@/components/workspace/input-box";
import { MessageList } from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context";
import { ThreadTitle } from "@/components/workspace/thread-title";
import { TodoList } from "@/components/workspace/todo-list";
import { Tooltip } from "@/components/workspace/tooltip";
import { useAgent } from "@/core/agents";
import { useI18n } from "@/core/i18n/hooks";
import { useNotification } from "@/core/notification/hooks";
import { useLocalSettings } from "@/core/settings";
import { useThreadStream } from "@/core/threads/hooks";
import { textOfMessage } from "@/core/threads/utils";
import { env } from "@/env";
import { cn } from "@/lib/utils";
export default function AgentChatPage() {
const { t } = useI18n();
const [settings, setSettings] = useLocalSettings();
const router = useRouter();
const { agent_name } = useParams<{
agent_name: string;
}>();
const { agent } = useAgent(agent_name);
const { threadId, isNewThread, setIsNewThread } = useThreadChat();
const { showNotification } = useNotification();
const [thread, sendMessage] = useThreadStream({
threadId: isNewThread ? undefined : threadId,
context: { ...settings.context, agent_name: agent_name },
onStart: () => {
setIsNewThread(false);
// ! Important: Never use next.js router for navigation in this case, otherwise it will cause the thread to re-mount and lose all states. Use native history API instead.
history.replaceState(
null,
"",
`/workspace/agents/${agent_name}/chats/${threadId}`,
);
},
onFinish: (state) => {
if (document.hidden || !document.hasFocus()) {
let body = "Conversation finished";
const lastMessage = state.messages[state.messages.length - 1];
if (lastMessage) {
const textContent = textOfMessage(lastMessage);
if (textContent) {
body =
textContent.length > 200
? textContent.substring(0, 200) + "..."
: textContent;
}
}
showNotification(state.title, { body });
}
},
});
const handleSubmit = useCallback(
(message: PromptInputMessage) => {
void sendMessage(threadId, message, { agent_name });
},
[sendMessage, threadId, agent_name],
);
const handleStop = useCallback(async () => {
await thread.stop();
}, [thread]);
return (
<ThreadContext.Provider value={{ thread }}>
<ChatBox threadId={threadId}>
<div className="relative flex size-full min-h-0 justify-between">
<header
className={cn(
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center gap-2 px-4",
isNewThread
? "bg-background/0 backdrop-blur-none"
: "bg-background/80 shadow-xs backdrop-blur",
)}
>
{/* Agent badge */}
<div className="flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1">
<BotIcon className="text-primary h-3.5 w-3.5" />
<span className="text-xs font-medium">
{agent?.name ?? agent_name}
</span>
</div>
<div className="flex w-full items-center text-sm font-medium">
<ThreadTitle threadId={threadId} thread={thread} />
</div>
<div className="mr-4 flex items-center">
<Tooltip content={t.agents.newChat}>
<Button
size="sm"
variant="secondary"
onClick={() => {
router.push(`/workspace/agents/${agent_name}/chats/new`);
}}
>
<PlusSquare /> {t.agents.newChat}
</Button>
</Tooltip>
<ArtifactTrigger />
</div>
</header>
<main className="flex min-h-0 max-w-full grow flex-col">
<div className="flex size-full justify-center">
<MessageList
className={cn("size-full", !isNewThread && "pt-10")}
threadId={threadId}
thread={thread}
/>
</div>
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
<div
className={cn(
"relative w-full",
isNewThread && "-translate-y-[calc(50vh-96px)]",
isNewThread
? "max-w-(--container-width-sm)"
: "max-w-(--container-width-md)",
)}
>
<div className="absolute -top-4 right-0 left-0 z-0">
<div className="absolute right-0 bottom-0 left-0">
<TodoList
className="bg-background/5"
todos={thread.values.todos ?? []}
hidden={
!thread.values.todos || thread.values.todos.length === 0
}
/>
</div>
</div>
<InputBox
className={cn("bg-background/5 w-full -translate-y-4")}
isNewThread={isNewThread}
threadId={threadId}
autoFocus={isNewThread}
status={thread.isLoading ? "streaming" : "ready"}
context={settings.context}
extraHeader={
isNewThread && (
<AgentWelcome agent={agent} agentName={agent_name} />
)
}
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
onContextChange={(context) => setSettings("context", context)}
onSubmit={handleSubmit}
onStop={handleStop}
/>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
{t.common.notAvailableInDemoMode}
</div>
)}
</div>
</div>
</main>
</div>
</ChatBox>
</ThreadContext.Provider>
);
}

View File

@ -0,0 +1,252 @@
"use client";
import { ArrowLeftIcon, BotIcon, CheckCircleIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useMemo, useState } from "react";
import {
PromptInput,
PromptInputFooter,
PromptInputSubmit,
PromptInputTextarea,
} from "@/components/ai-elements/prompt-input";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ArtifactsProvider } from "@/components/workspace/artifacts";
import { MessageList } from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context";
import type { Agent } from "@/core/agents";
import { checkAgentName, getAgent } from "@/core/agents/api";
import { useI18n } from "@/core/i18n/hooks";
import { useThreadStream } from "@/core/threads/hooks";
import { uuid } from "@/core/utils/uuid";
import { cn } from "@/lib/utils";
type Step = "name" | "chat";
const NAME_RE = /^[A-Za-z0-9-]+$/;
export default function NewAgentPage() {
const { t } = useI18n();
const router = useRouter();
// ── Step 1: name form ──────────────────────────────────────────────────────
const [step, setStep] = useState<Step>("name");
const [nameInput, setNameInput] = useState("");
const [nameError, setNameError] = useState("");
const [isCheckingName, setIsCheckingName] = useState(false);
const [agentName, setAgentName] = useState("");
const [agent, setAgent] = useState<Agent | null>(null);
// ── Step 2: chat ───────────────────────────────────────────────────────────
// Stable thread ID — all turns belong to the same thread
const threadId = useMemo(() => uuid(), []);
const [thread, sendMessage] = useThreadStream({
threadId: step === "chat" ? threadId : undefined,
context: {
mode: "flash",
is_bootstrap: true,
},
onToolEnd({ name }) {
if (name !== "setup_agent" || !agentName) return;
getAgent(agentName)
.then((fetched) => setAgent(fetched))
.catch(() => {
// agent write may not be flushed yet — ignore silently
});
},
});
// ── Handlers ───────────────────────────────────────────────────────────────
const handleConfirmName = useCallback(async () => {
const trimmed = nameInput.trim();
if (!trimmed) return;
if (!NAME_RE.test(trimmed)) {
setNameError(t.agents.nameStepInvalidError);
return;
}
setNameError("");
setIsCheckingName(true);
try {
const result = await checkAgentName(trimmed);
if (!result.available) {
setNameError(t.agents.nameStepAlreadyExistsError);
return;
}
} catch {
setNameError(t.agents.nameStepCheckError);
return;
} finally {
setIsCheckingName(false);
}
setAgentName(trimmed);
setStep("chat");
await sendMessage(threadId, {
text: t.agents.nameStepBootstrapMessage.replace("{name}", trimmed),
files: [],
});
}, [
nameInput,
sendMessage,
threadId,
t.agents.nameStepBootstrapMessage,
t.agents.nameStepInvalidError,
t.agents.nameStepAlreadyExistsError,
t.agents.nameStepCheckError,
]);
const handleNameKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
void handleConfirmName();
}
};
const handleChatSubmit = useCallback(
async (text: string) => {
const trimmed = text.trim();
if (!trimmed || thread.isLoading) return;
await sendMessage(
threadId,
{ text: trimmed, files: [] },
{ agent_name: agentName },
);
},
[thread.isLoading, sendMessage, threadId, agentName],
);
// ── Shared header ──────────────────────────────────────────────────────────
const header = (
<header className="flex shrink-0 items-center gap-3 border-b px-4 py-3">
<Button
variant="ghost"
size="icon-sm"
onClick={() => router.push("/workspace/agents")}
>
<ArrowLeftIcon className="h-4 w-4" />
</Button>
<h1 className="text-sm font-semibold">{t.agents.createPageTitle}</h1>
</header>
);
// ── Step 1: name form ──────────────────────────────────────────────────────
if (step === "name") {
return (
<div className="flex size-full flex-col">
{header}
<main className="flex flex-1 flex-col items-center justify-center px-4">
<div className="w-full max-w-sm space-y-8">
<div className="space-y-3 text-center">
<div className="bg-primary/10 mx-auto flex h-14 w-14 items-center justify-center rounded-full">
<BotIcon className="text-primary h-7 w-7" />
</div>
<div className="space-y-1">
<h2 className="text-xl font-semibold">
{t.agents.nameStepTitle}
</h2>
<p className="text-muted-foreground text-sm">
{t.agents.nameStepHint}
</p>
</div>
</div>
<div className="space-y-3">
<Input
autoFocus
placeholder={t.agents.nameStepPlaceholder}
value={nameInput}
onChange={(e) => {
setNameInput(e.target.value);
setNameError("");
}}
onKeyDown={handleNameKeyDown}
className={cn(nameError && "border-destructive")}
/>
{nameError && (
<p className="text-destructive text-sm">{nameError}</p>
)}
<Button
className="w-full"
onClick={() => void handleConfirmName()}
disabled={!nameInput.trim() || isCheckingName}
>
{t.agents.nameStepContinue}
</Button>
</div>
</div>
</main>
</div>
);
}
// ── Step 2: chat ───────────────────────────────────────────────────────────
return (
<ThreadContext.Provider value={{ thread }}>
<ArtifactsProvider>
<div className="flex size-full flex-col">
{header}
<main className="flex min-h-0 flex-1 flex-col">
{/* ── Message area ── */}
<div className="flex min-h-0 flex-1 justify-center">
<MessageList
className="size-full pt-10"
threadId={threadId}
thread={thread}
/>
</div>
{/* ── Bottom action area ── */}
<div className="bg-background flex shrink-0 justify-center border-t px-4 py-4">
<div className="w-full max-w-(--container-width-md)">
{agent ? (
// ✅ Success card
<div className="flex flex-col items-center gap-4 rounded-2xl border py-8 text-center">
<CheckCircleIcon className="text-primary h-10 w-10" />
<p className="font-semibold">{t.agents.agentCreated}</p>
<div className="flex gap-2">
<Button
onClick={() =>
router.push(
`/workspace/agents/${agentName}/chats/new`,
)
}
>
{t.agents.startChatting}
</Button>
<Button
variant="outline"
onClick={() => router.push("/workspace/agents")}
>
{t.agents.backToGallery}
</Button>
</div>
</div>
) : (
// 📝 Normal input
<PromptInput
onSubmit={({ text }) => void handleChatSubmit(text)}
>
<PromptInputTextarea
autoFocus
placeholder={t.agents.createPageSubtitle}
disabled={thread.isLoading}
/>
<PromptInputFooter className="justify-end">
<PromptInputSubmit disabled={thread.isLoading} />
</PromptInputFooter>
</PromptInput>
)}
</div>
</div>
</main>
</div>
</ArtifactsProvider>
</ThreadContext.Provider>
);
}

View File

@ -0,0 +1,5 @@
import { AgentGallery } from "@/components/workspace/agents/agent-gallery";
export default function AgentsPage() {
return <AgentGallery />;
}

View File

@ -1,333 +1,77 @@
"use client"; "use client";
import type { Message } from "@langchain/langgraph-sdk"; import { useCallback } from "react";
import type { UseStream } from "@langchain/langgraph-sdk/react";
import { FilesIcon, XIcon } from "lucide-react";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ConversationEmptyState } from "@/components/ai-elements/conversation"; import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { usePromptInputController } from "@/components/ai-elements/prompt-input"; import { ArtifactTrigger } from "@/components/workspace/artifacts";
import { Button } from "@/components/ui/button";
import { import {
ResizableHandle, ChatBox,
ResizablePanel, useSpecificChatMode,
ResizablePanelGroup, useThreadChat,
} from "@/components/ui/resizable"; } from "@/components/workspace/chats";
import { useSidebar } from "@/components/ui/sidebar";
import {
ArtifactFileDetail,
ArtifactFileList,
useArtifacts,
} from "@/components/workspace/artifacts";
import { InputBox } from "@/components/workspace/input-box"; import { InputBox } from "@/components/workspace/input-box";
import { MessageList } from "@/components/workspace/messages"; import { MessageList } from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context"; import { ThreadContext } from "@/components/workspace/messages/context";
import { ThreadTitle } from "@/components/workspace/thread-title"; import { ThreadTitle } from "@/components/workspace/thread-title";
import { TodoList } from "@/components/workspace/todo-list"; import { TodoList } from "@/components/workspace/todo-list";
import { Tooltip } from "@/components/workspace/tooltip";
import { Welcome } from "@/components/workspace/welcome"; import { Welcome } from "@/components/workspace/welcome";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { useNotification } from "@/core/notification/hooks"; import { useNotification } from "@/core/notification/hooks";
import { useLocalSettings } from "@/core/settings"; import { useLocalSettings } from "@/core/settings";
import { bootstrapRemoteSkill } from "@/core/skills"; import { useThreadStream } from "@/core/threads/hooks";
import { type AgentThread, type AgentThreadState } from "@/core/threads"; import { textOfMessage } from "@/core/threads/utils";
import { useSubmitThread, useThreadStream } from "@/core/threads/hooks";
import {
pathOfThread,
textOfMessage,
titleOfThread,
} from "@/core/threads/utils";
import { uuid } from "@/core/utils/uuid";
import { env } from "@/env"; import { env } from "@/env";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export default function ChatPage() { export default function ChatPage() {
const { t } = useI18n(); const { t } = useI18n();
const router = useRouter();
const [settings, setSettings] = useLocalSettings(); const [settings, setSettings] = useLocalSettings();
const { setOpen: setSidebarOpen } = useSidebar();
const {
artifacts,
open: artifactsOpen,
setOpen: setArtifactsOpen,
setArtifacts,
select: selectArtifact,
selectedArtifact,
} = useArtifacts();
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
const searchParams = useSearchParams();
const promptInputController = usePromptInputController();
const inputInitialValue = useMemo(() => {
if (threadIdFromPath !== "new" || searchParams.get("mode") !== "skill") {
return undefined;
}
return t.inputBox.createSkillPrompt;
}, [threadIdFromPath, searchParams, t.inputBox.createSkillPrompt]);
const lastInitialValueRef = useRef<string | undefined>(undefined);
const setInputRef = useRef(promptInputController.textInput.setInput);
setInputRef.current = promptInputController.textInput.setInput;
useEffect(() => {
if (inputInitialValue && inputInitialValue !== lastInitialValueRef.current) {
lastInitialValueRef.current = inputInitialValue;
setTimeout(() => {
setInputRef.current(inputInitialValue);
const textarea = document.querySelector("textarea");
if (textarea) {
textarea.focus();
textarea.selectionStart = textarea.value.length;
textarea.selectionEnd = textarea.value.length;
}
}, 100);
}
}, [inputInitialValue]);
// UI mode depends only on route: /workspace/chats/new is always "new page" mode.
const isNewThread = useMemo(() => threadIdFromPath === "new", [threadIdFromPath]);
// Submission strategy is controlled by `isnew` query param only. const { threadId, isNewThread, setIsNewThread, isMock } = useThreadChat();
// - isnew=false: reuse existing thread useSpecificChatMode();
// - otherwise: create/start a new session
const createNewSession = useMemo(() => {
if (threadIdFromPath !== "new") {
return false;
}
return searchParams.get("isnew")?.trim().toLowerCase() !== "false";
}, [threadIdFromPath, searchParams]);
const uploadTarget = useMemo(() => {
const target = searchParams.get("upload_target")?.trim().toLowerCase();
return target === "skill" ? "skill" : undefined;
}, [searchParams]);
const skillBootstrap = useMemo(() => {
const skillIdRaw = searchParams.get("skill_id")?.trim();
if (!skillIdRaw) return undefined;
const contentId = Number(skillIdRaw);
if (!Number.isFinite(contentId)) return undefined;
const languageTypeRaw =
searchParams.get("languageType")?.trim() ??
searchParams.get("language_type")?.trim();
const languageType = languageTypeRaw
? Number(languageTypeRaw)
: 0;
return {
contentId,
languageType: Number.isFinite(languageType) ? languageType : 0,
};
}, [threadIdFromPath, searchParams]);
const [threadId, setThreadId] = useState<string | null>(null);
useEffect(() => {
if (threadIdFromPath !== "new") {
setThreadId(threadIdFromPath);
} else {
const queryThreadId = searchParams.get("thread_id")?.trim();
setThreadId(queryThreadId || uuid());
}
}, [threadIdFromPath, searchParams]);
// Runtime strategy for /new page:
// - UI remains new-page mode
// - if isnew=false, execute against existing thread_id without creating a new one
const reuseExistingThread = useMemo(
() => threadIdFromPath === "new" && !createNewSession && !!threadId,
[threadIdFromPath, createNewSession, threadId],
);
const { showNotification } = useNotification(); const { showNotification } = useNotification();
const [isSkillBootstrapping, setIsSkillBootstrapping] = useState(false);
const [skillBootstrapError, setSkillBootstrapError] = useState<string | null>( const [thread, sendMessage] = useThreadStream({
null, threadId: isNewThread ? undefined : threadId,
); context: settings.context,
const skillBootstrappedKeyRef = useRef<string | null>(null); isMock,
const [finalState, setFinalState] = useState<AgentThreadState | null>(null); onStart: () => {
const thread = useThreadStream({ setIsNewThread(false);
// Keep UI in new-page mode, but runtime may reuse existing thread // ! Important: Never use next.js router for navigation in this case, otherwise it will cause the thread to re-mount and lose all states. Use native history API instead.
isNewThread: reuseExistingThread ? false : isNewThread, history.replaceState(null, "", `/workspace/chats/${threadId}`);
threadId, },
fetchStateHistory: true,
onFinish: (state) => { onFinish: (state) => {
setFinalState(state);
if (document.hidden || !document.hasFocus()) { if (document.hidden || !document.hasFocus()) {
let body = "Conversation finished"; let body = "Conversation finished";
const lastMessage = state.messages[state.messages.length - 1]; const lastMessage = state.messages.at(-1);
if (lastMessage) { if (lastMessage) {
const textContent = textOfMessage(lastMessage); const textContent = textOfMessage(lastMessage);
if (textContent) { if (textContent) {
if (textContent.length > 200) { body =
body = textContent.substring(0, 200) + "..."; textContent.length > 200
} else { ? textContent.substring(0, 200) + "..."
body = textContent; : textContent;
} }
} }
} showNotification(state.title, { body });
showNotification(state.title, {
body,
});
} }
}, },
}) as unknown as UseStream<AgentThreadState>;
useEffect(() => {
if (thread.isLoading) setFinalState(null);
}, [thread.isLoading]);
const title = useMemo(() => {
let result = isNewThread
? ""
: titleOfThread(thread as unknown as AgentThread);
if (result === "Untitled") {
result = "";
}
return result;
}, [thread, isNewThread]);
const [hasSubmitted, setHasSubmitted] = useState(false);
const suppressExistingThreadPrefetchUi = reuseExistingThread && !hasSubmitted;
useEffect(() => {
const pageTitle = isNewThread
? t.pages.newChat
: thread.values?.title && thread.values.title !== "Untitled"
? thread.values.title
: t.pages.untitled;
if (thread.isThreadLoading && !suppressExistingThreadPrefetchUi) {
document.title = `Loading... - ${t.pages.appName}`;
} else {
document.title = `${pageTitle} - ${t.pages.appName}`;
}
}, [
isNewThread,
t.pages.newChat,
t.pages.untitled,
t.pages.appName,
thread.values.title,
thread.isThreadLoading,
suppressExistingThreadPrefetchUi,
]);
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
useEffect(() => {
setArtifacts(thread.values.artifacts);
if (
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" &&
autoSelectFirstArtifact
) {
if (thread?.values?.artifacts?.length > 0) {
setAutoSelectFirstArtifact(false);
selectArtifact(thread.values.artifacts[0]!);
}
}
}, [
autoSelectFirstArtifact,
selectArtifact,
setArtifacts,
thread.values.artifacts,
]);
const artifactPanelOpen = useMemo(() => {
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true") {
return artifactsOpen && artifacts?.length > 0;
}
return artifactsOpen;
}, [artifactsOpen, artifacts]);
const [todoListCollapsed, setTodoListCollapsed] = useState(true);
useEffect(() => {
if (!threadId || !skillBootstrap?.contentId) {
setIsSkillBootstrapping(false);
setSkillBootstrapError(null);
return;
}
const languageType = skillBootstrap.languageType ?? 0;
const initKey = `${threadId}:${skillBootstrap.contentId}:${languageType}`;
if (skillBootstrappedKeyRef.current === initKey) {
return;
}
let cancelled = false;
const runBootstrap = async () => {
setIsSkillBootstrapping(true);
setSkillBootstrapError(null);
try {
await bootstrapRemoteSkill({
thread_id: threadId,
content_id: skillBootstrap.contentId,
language_type: languageType,
target_dir: "/mnt/user-data/uploads/skill",
clear_target: true,
}); });
if (!cancelled) {
skillBootstrappedKeyRef.current = initKey;
setIsSkillBootstrapping(false);
}
} catch (error) {
if (!cancelled) {
const message = error instanceof Error ? error.message : "Skill 初始化失败";
setSkillBootstrapError(message);
setIsSkillBootstrapping(false);
showNotification("Skill 初始化失败", { body: message });
}
}
};
void runBootstrap();
return () => {
cancelled = true;
};
}, [threadId, skillBootstrap, showNotification]);
const submitThread = useSubmitThread({
isNewThread,
createNewSession,
threadId,
thread,
uploadTarget,
threadContext: {
...settings.context,
thinking_enabled: settings.context.mode !== "flash",
is_plan_mode:
settings.context.mode === "pro" || settings.context.mode === "ultra",
subagent_enabled: settings.context.mode === "ultra",
},
afterSubmit() {
router.push(pathOfThread(threadId!));
},
});
const handleSubmit = useCallback( const handleSubmit = useCallback(
(message: Parameters<typeof submitThread>[0]) => { (message: PromptInputMessage) => {
if (isSkillBootstrapping) { void sendMessage(threadId, message);
return;
}
setHasSubmitted(true);
void submitThread(message);
}, },
[isSkillBootstrapping, submitThread], [sendMessage, threadId],
); );
const handleStop = useCallback(async () => { const handleStop = useCallback(async () => {
await thread.stop(); await thread.stop();
}, [thread]); }, [thread]);
if (!threadId) {
return null;
}
return ( return (
<ThreadContext.Provider value={{ threadId, thread }}> <ThreadContext.Provider value={{ thread, isMock }}>
<ResizablePanelGroup orientation="horizontal"> <ChatBox threadId={threadId}>
<ResizablePanel
className="relative"
defaultSize={artifactPanelOpen ? 46 : 100}
minSize={artifactPanelOpen ? 30 : 100}
>
<div className="relative flex size-full min-h-0 justify-between"> <div className="relative flex size-full min-h-0 justify-between">
<header <header
className={cn( className={cn(
@ -338,26 +82,10 @@ export default function ChatPage() {
)} )}
> >
<div className="flex w-full items-center text-sm font-medium"> <div className="flex w-full items-center text-sm font-medium">
{title !== "Untitled" && ( <ThreadTitle threadId={threadId} thread={thread} />
<ThreadTitle threadId={threadId} threadTitle={title} />
)}
</div> </div>
<div> <div>
{artifacts?.length > 0 && !artifactsOpen && ( <ArtifactTrigger />
<Tooltip content="Show artifacts of this conversation">
<Button
className="text-muted-foreground hover:text-foreground"
variant="ghost"
onClick={() => {
setArtifactsOpen(true);
setSidebarOpen(false);
}}
>
<FilesIcon />
{t.common.artifacts}
</Button>
</Tooltip>
)}
</div> </div>
</header> </header>
<main className="flex min-h-0 max-w-full grow flex-col"> <main className="flex min-h-0 max-w-full grow flex-col">
@ -366,15 +94,6 @@ export default function ChatPage() {
className={cn("size-full", !isNewThread && "pt-10")} className={cn("size-full", !isNewThread && "pt-10")}
threadId={threadId} threadId={threadId}
thread={thread} thread={thread}
suppressThreadLoading={suppressExistingThreadPrefetchUi}
messagesOverride={
suppressExistingThreadPrefetchUi
? []
: !thread.isLoading && finalState?.messages
? (finalState.messages as Message[])
: undefined
}
paddingBottom={todoListCollapsed ? 160 : 280}
/> />
</div> </div>
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4"> <div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
@ -392,13 +111,8 @@ export default function ChatPage() {
<TodoList <TodoList
className="bg-background/5" className="bg-background/5"
todos={thread.values.todos ?? []} todos={thread.values.todos ?? []}
collapsed={todoListCollapsed}
hidden={ hidden={
!thread.values.todos || !thread.values.todos || thread.values.todos.length === 0
thread.values.todos.length === 0
}
onToggle={() =>
setTodoListCollapsed(!todoListCollapsed)
} }
/> />
</div> </div>
@ -406,35 +120,18 @@ export default function ChatPage() {
<InputBox <InputBox
className={cn("bg-background/5 w-full -translate-y-4")} className={cn("bg-background/5 w-full -translate-y-4")}
isNewThread={isNewThread} isNewThread={isNewThread}
threadId={threadId}
autoFocus={isNewThread} autoFocus={isNewThread}
status={ status={thread.isLoading ? "streaming" : "ready"}
suppressExistingThreadPrefetchUi
? "ready"
: thread.isLoading
? "streaming"
: "ready"
}
context={settings.context} context={settings.context}
extraHeader={ extraHeader={
isNewThread && <Welcome mode={settings.context.mode} /> isNewThread && <Welcome mode={settings.context.mode} />
} }
disabled={ disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" || onContextChange={(context) => setSettings("context", context)}
isSkillBootstrapping
}
onContextChange={(context) =>
setSettings("context", context)
}
onSubmit={handleSubmit} onSubmit={handleSubmit}
onStop={handleStop} onStop={handleStop}
/> />
{(isSkillBootstrapping || skillBootstrapError) && (
<div className="text-muted-foreground w-full translate-y-8 text-center text-xs">
{isSkillBootstrapping
? "正在初始化 Skill 文件..."
: `Skill 初始化失败:${skillBootstrapError}`}
</div>
)}
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && ( {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs"> <div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
{t.common.notAvailableInDemoMode} {t.common.notAvailableInDemoMode}
@ -444,72 +141,7 @@ export default function ChatPage() {
</div> </div>
</main> </main>
</div> </div>
</ResizablePanel> </ChatBox>
<ResizableHandle
className={cn(
"opacity-33 hover:opacity-100",
!artifactPanelOpen && "pointer-events-none opacity-0",
)}
/>
<ResizablePanel
className={cn(
"transition-all duration-300 ease-in-out",
!artifactsOpen && "opacity-0",
)}
defaultSize={artifactPanelOpen ? 64 : 0}
minSize={0}
maxSize={artifactPanelOpen ? undefined : 0}
>
<div
className={cn(
"h-full p-4 transition-transform duration-300 ease-in-out",
artifactPanelOpen ? "translate-x-0" : "translate-x-full",
)}
>
{selectedArtifact ? (
<ArtifactFileDetail
className="size-full"
filepath={selectedArtifact}
threadId={threadId}
/>
) : (
<div className="relative flex size-full justify-center">
<div className="absolute top-1 right-1 z-30">
<Button
size="icon-sm"
variant="ghost"
onClick={() => {
setArtifactsOpen(false);
}}
>
<XIcon />
</Button>
</div>
{thread.values.artifacts?.length === 0 ? (
<ConversationEmptyState
icon={<FilesIcon />}
title="No artifact selected"
description="Select an artifact to view its details"
/>
) : (
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center p-4 pt-8">
<header className="shrink-0">
<h2 className="text-lg font-medium">Artifacts</h2>
</header>
<main className="min-h-0 grow">
<ArtifactFileList
className="max-w-(--container-width-sm) p-4 pt-12"
files={thread.values.artifacts ?? []}
threadId={threadId}
/>
</main>
</div>
)}
</div>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
</ThreadContext.Provider> </ThreadContext.Provider>
); );
} }

View File

@ -1,12 +1,12 @@
"use client"; "use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useLayoutEffect, useState } from "react";
import { Toaster } from "sonner"; import { Toaster } from "sonner";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar"; import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar";
import { useLocalSettings } from "@/core/settings"; import { getLocalSettings, useLocalSettings } from "@/core/settings";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@ -14,7 +14,11 @@ export default function WorkspaceLayout({
children, children,
}: Readonly<{ children: React.ReactNode }>) { }: Readonly<{ children: React.ReactNode }>) {
const [settings, setSettings] = useLocalSettings(); const [settings, setSettings] = useLocalSettings();
const [open, setOpen] = useState(() => !settings.layout.sidebar_collapsed); const [open, setOpen] = useState(false); // SSR default: open (matches server render)
useLayoutEffect(() => {
// Runs synchronously before first paint on the client — no visual flash
setOpen(!getLocalSettings().layout.sidebar_collapsed);
}, []);
useEffect(() => { useEffect(() => {
setOpen(!settings.layout.sidebar_collapsed); setOpen(!settings.layout.sidebar_collapsed);
}, [settings.layout.sidebar_collapsed]); }, [settings.layout.sidebar_collapsed]);

View File

@ -44,7 +44,8 @@ export const MessageContent = ({
}: MessageContentProps) => ( }: MessageContentProps) => (
<div <div
className={cn( className={cn(
"is-user:dark flex w-fit max-w-full min-w-0 flex-col gap-2 overflow-hidden", "is-user:dark flex w-fit max-w-full min-w-0 flex-col gap-2 overflow-visible",
"group-[.is-user]:overflow-hidden",
"group-[.is-user]:bg-secondary group-[.is-user]:text-foreground group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:px-4 group-[.is-user]:py-3", "group-[.is-user]:bg-secondary group-[.is-user]:text-foreground group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:px-4 group-[.is-user]:py-3",
"group-[.is-assistant]:text-foreground", "group-[.is-assistant]:text-foreground",
className, className,

View File

@ -55,7 +55,7 @@ export function CaseStudySection({ className }: { className?: string }) {
{caseStudies.map((caseStudy) => ( {caseStudies.map((caseStudy) => (
<Link <Link
key={caseStudy.title} key={caseStudy.title}
href={pathOfThread(caseStudy.threadId)} href={pathOfThread(caseStudy.threadId) + "?mock=true"}
target="_blank" target="_blank"
> >
<Card className="group/card relative h-64 overflow-hidden"> <Card className="group/card relative h-64 overflow-hidden">

View File

@ -0,0 +1,36 @@
"use client";
import { BotIcon } from "lucide-react";
import { type Agent } from "@/core/agents";
import { cn } from "@/lib/utils";
export function AgentWelcome({
className,
agent,
agentName,
}: {
className?: string;
agent: Agent | null | undefined;
agentName: string;
}) {
const displayName = agent?.name ?? agentName;
const description = agent?.description;
return (
<div
className={cn(
"mx-auto flex w-full flex-col items-center justify-center gap-2 px-8 py-4 text-center",
className,
)}
>
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-full">
<BotIcon className="text-primary h-6 w-6" />
</div>
<div className="text-2xl font-bold">{displayName}</div>
{description && (
<p className="text-muted-foreground max-w-sm text-sm">{description}</p>
)}
</div>
);
}

View File

@ -0,0 +1,140 @@
"use client";
import { BotIcon, MessageSquareIcon, Trash2Icon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useDeleteAgent } from "@/core/agents";
import type { Agent } from "@/core/agents";
import { useI18n } from "@/core/i18n/hooks";
interface AgentCardProps {
agent: Agent;
}
export function AgentCard({ agent }: AgentCardProps) {
const { t } = useI18n();
const router = useRouter();
const deleteAgent = useDeleteAgent();
const [deleteOpen, setDeleteOpen] = useState(false);
function handleChat() {
router.push(`/workspace/agents/${agent.name}/chats/new`);
}
async function handleDelete() {
try {
await deleteAgent.mutateAsync(agent.name);
toast.success(t.agents.deleteSuccess);
setDeleteOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
return (
<>
<Card className="group flex flex-col transition-shadow hover:shadow-md">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2">
<div className="bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
<BotIcon className="h-5 w-5" />
</div>
<div className="min-w-0">
<CardTitle className="truncate text-base">
{agent.name}
</CardTitle>
{agent.model && (
<Badge variant="secondary" className="mt-0.5 text-xs">
{agent.model}
</Badge>
)}
</div>
</div>
</div>
{agent.description && (
<CardDescription className="mt-2 line-clamp-2 text-sm">
{agent.description}
</CardDescription>
)}
</CardHeader>
{agent.tool_groups && agent.tool_groups.length > 0 && (
<CardContent className="pt-0 pb-3">
<div className="flex flex-wrap gap-1">
{agent.tool_groups.map((group) => (
<Badge key={group} variant="outline" className="text-xs">
{group}
</Badge>
))}
</div>
</CardContent>
)}
<CardFooter className="mt-auto flex items-center justify-between gap-2 pt-3">
<Button size="sm" className="flex-1" onClick={handleChat}>
<MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" />
{t.agents.chat}
</Button>
<div className="flex gap-1">
<Button
size="icon"
variant="ghost"
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
onClick={() => setDeleteOpen(true)}
title={t.agents.delete}
>
<Trash2Icon className="h-3.5 w-3.5" />
</Button>
</div>
</CardFooter>
</Card>
{/* Delete Confirm */}
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t.agents.delete}</DialogTitle>
<DialogDescription>{t.agents.deleteConfirm}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDeleteOpen(false)}
disabled={deleteAgent.isPending}
>
{t.common.cancel}
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={deleteAgent.isPending}
>
{deleteAgent.isPending ? t.common.loading : t.common.delete}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@ -0,0 +1,69 @@
"use client";
import { BotIcon, PlusIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { useAgents } from "@/core/agents";
import { useI18n } from "@/core/i18n/hooks";
import { AgentCard } from "./agent-card";
export function AgentGallery() {
const { t } = useI18n();
const { agents, isLoading } = useAgents();
const router = useRouter();
const handleNewAgent = () => {
router.push("/workspace/agents/new");
};
return (
<div className="flex size-full flex-col">
{/* Page header */}
<div className="flex items-center justify-between border-b px-6 py-4">
<div>
<h1 className="text-xl font-semibold">{t.agents.title}</h1>
<p className="text-muted-foreground mt-0.5 text-sm">
{t.agents.description}
</p>
</div>
<Button onClick={handleNewAgent}>
<PlusIcon className="mr-1.5 h-4 w-4" />
{t.agents.newAgent}
</Button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
{isLoading ? (
<div className="text-muted-foreground flex h-40 items-center justify-center text-sm">
{t.common.loading}
</div>
) : agents.length === 0 ? (
<div className="flex h-64 flex-col items-center justify-center gap-3 text-center">
<div className="bg-muted flex h-14 w-14 items-center justify-center rounded-full">
<BotIcon className="text-muted-foreground h-7 w-7" />
</div>
<div>
<p className="font-medium">{t.agents.emptyTitle}</p>
<p className="text-muted-foreground mt-1 text-sm">
{t.agents.emptyDescription}
</p>
</div>
<Button variant="outline" className="mt-2" onClick={handleNewAgent}>
<PlusIcon className="mr-1.5 h-4 w-4" />
{t.agents.newAgent}
</Button>
</div>
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{agents.map((agent) => (
<AgentCard key={agent.name} agent={agent} />
))}
</div>
)}
</div>
</div>
);
}

View File

@ -39,6 +39,7 @@ import { env } from "@/env";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { CitationLink } from "../citations/citation-link"; import { CitationLink } from "../citations/citation-link";
import { useThread } from "../messages/context";
import { Tooltip } from "../tooltip"; import { Tooltip } from "../tooltip";
import { useArtifacts } from "./context"; import { useArtifacts } from "./context";
@ -79,9 +80,9 @@ export function ArtifactFileDetail({
} }
return checkCodeFile(filepath); return checkCodeFile(filepath);
}, [filepath, isWriteFile, isSkillFile]); }, [filepath, isWriteFile, isSkillFile]);
const previewable = useMemo(() => { const isSupportPreview = useMemo(() => {
return (language === "html" && !isWriteFile) || language === "markdown"; return language === "html" || language === "markdown";
}, [isWriteFile, language]); }, [language]);
const { content } = useArtifactContent({ const { content } = useArtifactContent({
threadId, threadId,
filepath: filepathFromProps, filepath: filepathFromProps,
@ -92,14 +93,14 @@ export function ArtifactFileDetail({
const [viewMode, setViewMode] = useState<"code" | "preview">("code"); const [viewMode, setViewMode] = useState<"code" | "preview">("code");
const [isInstalling, setIsInstalling] = useState(false); const [isInstalling, setIsInstalling] = useState(false);
const { isMock } = useThread();
useEffect(() => { useEffect(() => {
if (previewable) { if (isSupportPreview) {
setViewMode("preview"); setViewMode("preview");
} else { } else {
setViewMode("code"); setViewMode("code");
} }
}, [previewable]); }, [isSupportPreview]);
const handleInstallSkill = useCallback(async () => { const handleInstallSkill = useCallback(async () => {
if (isInstalling) return; if (isInstalling) return;
@ -148,16 +149,18 @@ export function ArtifactFileDetail({
</ArtifactTitle> </ArtifactTitle>
</div> </div>
<div className="flex min-w-0 grow items-center justify-center"> <div className="flex min-w-0 grow items-center justify-center">
{previewable && ( {isSupportPreview && (
<ToggleGroup <ToggleGroup
className="mx-auto" className="mx-auto"
type="single" type="single"
variant="outline" variant="outline"
size="sm" size="sm"
value={viewMode} value={viewMode}
onValueChange={(value) => onValueChange={(value) => {
setViewMode(value as "code" | "preview") if (value) {
setViewMode(value as "code" | "preview");
} }
}}
> >
<ToggleGroupItem value="code"> <ToggleGroupItem value="code">
<Code2Icon /> <Code2Icon />
@ -232,12 +235,10 @@ export function ArtifactFileDetail({
</div> </div>
</ArtifactHeader> </ArtifactHeader>
<ArtifactContent className="p-0"> <ArtifactContent className="p-0">
{previewable && {isSupportPreview &&
viewMode === "preview" && viewMode === "preview" &&
(language === "markdown" || language === "html") && ( (language === "markdown" || language === "html") && (
<ArtifactFilePreview <ArtifactFilePreview
filepath={filepath}
threadId={threadId}
content={displayContent} content={displayContent}
language={language ?? "text"} language={language ?? "text"}
/> />
@ -252,7 +253,7 @@ export function ArtifactFileDetail({
{!isCodeFile && ( {!isCodeFile && (
<iframe <iframe
className="size-full" className="size-full"
src={urlOfArtifact({ filepath, threadId })} src={urlOfArtifact({ filepath, threadId, isMock })}
/> />
)} )}
</ArtifactContent> </ArtifactContent>
@ -261,13 +262,9 @@ export function ArtifactFileDetail({
} }
export function ArtifactFilePreview({ export function ArtifactFilePreview({
filepath,
threadId,
content, content,
language, language,
}: { }: {
filepath: string;
threadId: string;
content: string; content: string;
language: string; language: string;
}) { }) {
@ -288,10 +285,11 @@ export function ArtifactFilePreview({
return ( return (
<iframe <iframe
className="size-full" className="size-full"
src={urlOfArtifact({ filepath, threadId })} title="Artifact preview"
srcDoc={content}
sandbox="allow-scripts allow-forms"
/> />
); );
} }
return null; return null;
} }

View File

@ -0,0 +1,30 @@
import { FilesIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Tooltip } from "@/components/workspace/tooltip";
import { useI18n } from "@/core/i18n/hooks";
import { useArtifacts } from "./context";
export const ArtifactTrigger = () => {
const { t } = useI18n();
const { artifacts, setOpen: setArtifactsOpen } = useArtifacts();
if (!artifacts || artifacts.length === 0) {
return null;
}
return (
<Tooltip content="Show artifacts of this conversation">
<Button
className="text-muted-foreground hover:text-foreground"
variant="ghost"
onClick={() => {
setArtifactsOpen(true);
}}
>
<FilesIcon />
{t.common.artifacts}
</Button>
</Tooltip>
);
};

View File

@ -1,4 +1,10 @@
import { createContext, useContext, useState, type ReactNode } from "react"; import {
createContext,
useCallback,
useContext,
useState,
type ReactNode,
} from "react";
import { useSidebar } from "@/components/ui/sidebar"; import { useSidebar } from "@/components/ui/sidebar";
import { env } from "@/env"; import { env } from "@/env";
@ -35,7 +41,8 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
const [autoOpen, setAutoOpen] = useState(true); const [autoOpen, setAutoOpen] = useState(true);
const { setOpen: setSidebarOpen } = useSidebar(); const { setOpen: setSidebarOpen } = useSidebar();
const select = (artifact: string, autoSelect = false) => { const select = useCallback(
(artifact: string, autoSelect = false) => {
setSelectedArtifact(artifact); setSelectedArtifact(artifact);
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true") { if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true") {
setSidebarOpen(false); setSidebarOpen(false);
@ -43,12 +50,15 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
if (!autoSelect) { if (!autoSelect) {
setAutoSelect(false); setAutoSelect(false);
} }
}; },
[setSidebarOpen, setSelectedArtifact, setAutoSelect],
);
const deselect = () => { const deselect = useCallback(() => {
setSelectedArtifact(null); setSelectedArtifact(null);
setAutoSelect(true); setAutoSelect(true);
}; setOpen(false);
}, []);
const value: ArtifactsContextType = { const value: ArtifactsContextType = {
artifacts, artifacts,

View File

@ -1,3 +1,4 @@
export * from "./artifact-file-detail"; export * from "./artifact-file-detail";
export * from "./artifact-file-list"; export * from "./artifact-file-list";
export * from "./artifact-trigger";
export * from "./context"; export * from "./context";

View File

@ -0,0 +1,172 @@
import { FilesIcon, XIcon } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import type { GroupImperativeHandle } from "react-resizable-panels";
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
import { Button } from "@/components/ui/button";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import { env } from "@/env";
import { cn } from "@/lib/utils";
import {
ArtifactFileDetail,
ArtifactFileList,
useArtifacts,
} from "../artifacts";
import { useThread } from "../messages/context";
const CLOSE_MODE = { chat: 100, artifacts: 0 };
const OPEN_MODE = { chat: 60, artifacts: 40 };
const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
children,
threadId,
}) => {
const { thread } = useThread();
const threadIdRef = useRef(threadId);
const layoutRef = useRef<GroupImperativeHandle>(null);
const {
artifacts,
open: artifactsOpen,
setOpen: setArtifactsOpen,
setArtifacts,
select: selectArtifact,
deselect,
selectedArtifact,
} = useArtifacts();
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
useEffect(() => {
if (threadIdRef.current !== threadId) {
threadIdRef.current = threadId;
deselect();
}
// Update artifacts from the current thread
setArtifacts(thread.values.artifacts);
// DO NOT automatically deselect the artifact when switching threads, because the artifacts auto discovering is not work now.
// if (
// selectedArtifact &&
// !thread.values.artifacts?.includes(selectedArtifact)
// ) {
// deselect();
// }
if (
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" &&
autoSelectFirstArtifact
) {
if (thread?.values?.artifacts?.length > 0) {
setAutoSelectFirstArtifact(false);
selectArtifact(thread.values.artifacts[0]!);
}
}
}, [
threadId,
autoSelectFirstArtifact,
deselect,
selectArtifact,
selectedArtifact,
setArtifacts,
thread.values.artifacts,
]);
const artifactPanelOpen = useMemo(() => {
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true") {
return artifactsOpen && artifacts?.length > 0;
}
return artifactsOpen;
}, [artifactsOpen, artifacts]);
useEffect(() => {
if (layoutRef.current) {
if (artifactPanelOpen) {
layoutRef.current.setLayout(OPEN_MODE);
} else {
layoutRef.current.setLayout(CLOSE_MODE);
}
}
}, [artifactPanelOpen]);
return (
<ResizablePanelGroup
orientation="horizontal"
defaultLayout={{ chat: 100, artifacts: 0 }}
groupRef={layoutRef}
>
<ResizablePanel className="relative" defaultSize={100} id="chat">
{children}
</ResizablePanel>
<ResizableHandle
className={cn(
"opacity-33 hover:opacity-100",
!artifactPanelOpen && "pointer-events-none opacity-0",
)}
/>
<ResizablePanel
className={cn(
"transition-all duration-300 ease-in-out",
!artifactsOpen && "opacity-0",
)}
id="artifacts"
>
<div
className={cn(
"h-full p-4 transition-transform duration-300 ease-in-out",
artifactPanelOpen ? "translate-x-0" : "translate-x-full",
)}
>
{selectedArtifact ? (
<ArtifactFileDetail
className="size-full"
filepath={selectedArtifact}
threadId={threadId}
/>
) : (
<div className="relative flex size-full justify-center">
<div className="absolute top-1 right-1 z-30">
<Button
size="icon-sm"
variant="ghost"
onClick={() => {
setArtifactsOpen(false);
}}
>
<XIcon />
</Button>
</div>
{thread.values.artifacts?.length === 0 ? (
<ConversationEmptyState
icon={<FilesIcon />}
title="No artifact selected"
description="Select an artifact to view its details"
/>
) : (
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center p-4 pt-8">
<header className="shrink-0">
<h2 className="text-lg font-medium">Artifacts</h2>
</header>
<main className="min-h-0 grow">
<ArtifactFileList
className="max-w-(--container-width-sm) p-4 pt-12"
files={thread.values.artifacts ?? []}
threadId={threadId}
/>
</main>
</div>
)}
</div>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
);
};
export { ChatBox };

View File

@ -0,0 +1,3 @@
export * from "./chat-box";
export * from "./use-chat-mode";
export * from "./use-thread-chat";

View File

@ -0,0 +1,41 @@
import { useParams, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useRef } from "react";
import { usePromptInputController } from "@/components/ai-elements/prompt-input";
import { useI18n } from "@/core/i18n/hooks";
/**
* Hook to determine if the chat is in a specific mode based on URL parameters, and to set an initial prompt input value accordingly.
*/
export function useSpecificChatMode() {
const { t } = useI18n();
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
const searchParams = useSearchParams();
const promptInputController = usePromptInputController();
const inputInitialValue = useMemo(() => {
if (threadIdFromPath !== "new" || searchParams.get("mode") !== "skill") {
return undefined;
}
return t.inputBox.createSkillPrompt;
}, [threadIdFromPath, searchParams, t.inputBox.createSkillPrompt]);
const lastInitialValueRef = useRef<string | undefined>(undefined);
const setInputRef = useRef(promptInputController.textInput.setInput);
setInputRef.current = promptInputController.textInput.setInput;
useEffect(() => {
if (
inputInitialValue &&
inputInitialValue !== lastInitialValueRef.current
) {
lastInitialValueRef.current = inputInitialValue;
setTimeout(() => {
setInputRef.current(inputInitialValue);
const textarea = document.querySelector("textarea");
if (textarea) {
textarea.focus();
textarea.selectionStart = textarea.value.length;
textarea.selectionEnd = textarea.value.length;
}
}, 100);
}
}, [inputInitialValue]);
}

View File

@ -0,0 +1,29 @@
"use client";
import { useParams, usePathname, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { uuid } from "@/core/utils/uuid";
export function useThreadChat() {
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
const pathname = usePathname();
const searchParams = useSearchParams();
const [threadId, setThreadId] = useState(() => {
return threadIdFromPath === "new" ? uuid() : threadIdFromPath;
});
const [isNewThread, setIsNewThread] = useState(
() => threadIdFromPath === "new",
);
useEffect(() => {
if (pathname.endsWith("/new")) {
setIsNewThread(true);
setThreadId(uuid());
}
}, [pathname]);
const isMock = searchParams.get("mock") === "true";
return { threadId, isNewThread, setIsNewThread, isMock };
}

View File

@ -9,10 +9,18 @@ import {
PlusIcon, PlusIcon,
SparklesIcon, SparklesIcon,
RocketIcon, RocketIcon,
XIcon,
ZapIcon, ZapIcon,
} from "lucide-react"; } from "lucide-react";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { useCallback, useMemo, useState, type ComponentProps } from "react"; import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ComponentProps,
} from "react";
import { import {
PromptInput, PromptInput,
@ -32,15 +40,26 @@ import {
usePromptInputController, usePromptInputController,
type PromptInputMessage, type PromptInputMessage,
} from "@/components/ai-elements/prompt-input"; } from "@/components/ai-elements/prompt-input";
import { Button } from "@/components/ui/button";
import { ConfettiButton } from "@/components/ui/confetti-button"; import { ConfettiButton } from "@/components/ui/confetti-button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { import {
DropdownMenuGroup, DropdownMenuGroup,
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuSeparator, DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { getBackendBaseURL } from "@/core/config";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/hooks"; import { useModels } from "@/core/models/hooks";
import type { AgentThreadContext } from "@/core/threads"; import type { AgentThreadContext } from "@/core/threads";
import { textOfMessage } from "@/core/threads/utils";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
@ -60,9 +79,25 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "../ui/dropdown-menu"; } from "../ui/dropdown-menu";
import { useThread } from "./messages/context";
import { ModeHoverGuide } from "./mode-hover-guide"; import { ModeHoverGuide } from "./mode-hover-guide";
import { Tooltip } from "./tooltip"; import { Tooltip } from "./tooltip";
type InputMode = "flash" | "thinking" | "pro" | "ultra";
function getResolvedMode(
mode: InputMode | undefined,
supportsThinking: boolean,
): InputMode {
if (!supportsThinking && mode !== "flash") {
return "flash";
}
if (mode) {
return mode;
}
return supportsThinking ? "pro" : "flash";
}
export function InputBox({ export function InputBox({
className, className,
disabled, disabled,
@ -71,6 +106,7 @@ export function InputBox({
context, context,
extraHeader, extraHeader,
isNewThread, isNewThread,
threadId,
initialValue, initialValue,
onContextChange, onContextChange,
onSubmit, onSubmit,
@ -85,9 +121,11 @@ export function InputBox({
"thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled" "thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled"
> & { > & {
mode: "flash" | "thinking" | "pro" | "ultra" | undefined; mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
}; };
extraHeader?: React.ReactNode; extraHeader?: React.ReactNode;
isNewThread?: boolean; isNewThread?: boolean;
threadId: string;
initialValue?: string; initialValue?: string;
onContextChange?: ( onContextChange?: (
context: Omit< context: Omit<
@ -95,6 +133,7 @@ export function InputBox({
"thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled" "thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled"
> & { > & {
mode: "flash" | "thinking" | "pro" | "ultra" | undefined; mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
}, },
) => void; ) => void;
onSubmit?: (message: PromptInputMessage) => void; onSubmit?: (message: PromptInputMessage) => void;
@ -104,43 +143,97 @@ export function InputBox({
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [modelDialogOpen, setModelDialogOpen] = useState(false); const [modelDialogOpen, setModelDialogOpen] = useState(false);
const { models } = useModels(); const { models } = useModels();
const selectedModel = useMemo(() => { const { thread, isMock } = useThread();
if (!context.model_name && models.length > 0) { const { textInput } = usePromptInputController();
const model = models[0]!; const promptRootRef = useRef<HTMLDivElement | null>(null);
setTimeout(() => {
const [followups, setFollowups] = useState<string[]>([]);
const [followupsHidden, setFollowupsHidden] = useState(false);
const [followupsLoading, setFollowupsLoading] = useState(false);
const lastGeneratedForAiIdRef = useRef<string | null>(null);
const wasStreamingRef = useRef(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const [pendingSuggestion, setPendingSuggestion] = useState<string | null>(
null,
);
useEffect(() => {
if (models.length === 0) {
return;
}
const currentModel = models.find((m) => m.name === context.model_name);
const fallbackModel = currentModel ?? models[0]!;
const supportsThinking = fallbackModel.supports_thinking ?? false;
const nextModelName = fallbackModel.name;
const nextMode = getResolvedMode(context.mode, supportsThinking);
if (context.model_name === nextModelName && context.mode === nextMode) {
return;
}
onContextChange?.({ onContextChange?.({
...context, ...context,
model_name: model.name, model_name: nextModelName,
mode: model.supports_thinking ? "pro" : "flash", mode: nextMode,
}); });
}, 0);
return model;
}
return models.find((m) => m.name === context.model_name);
}, [context, models, onContextChange]); }, [context, models, onContextChange]);
const selectedModel = useMemo(() => {
if (models.length === 0) {
return undefined;
}
return models.find((m) => m.name === context.model_name) ?? models[0];
}, [context.model_name, models]);
const supportThinking = useMemo( const supportThinking = useMemo(
() => selectedModel?.supports_thinking ?? false, () => selectedModel?.supports_thinking ?? false,
[selectedModel], [selectedModel],
); );
const supportReasoningEffort = useMemo(
() => selectedModel?.supports_reasoning_effort ?? false,
[selectedModel],
);
const handleModelSelect = useCallback( const handleModelSelect = useCallback(
(model_name: string) => { (model_name: string) => {
const model = models.find((m) => m.name === model_name);
if (!model) {
return;
}
onContextChange?.({ onContextChange?.({
...context, ...context,
model_name, model_name,
mode: getResolvedMode(context.mode, model.supports_thinking ?? false),
reasoning_effort: context.reasoning_effort,
}); });
setModelDialogOpen(false); setModelDialogOpen(false);
}, },
[onContextChange, context], [onContextChange, context, models],
); );
const handleModeSelect = useCallback( const handleModeSelect = useCallback(
(mode: "flash" | "thinking" | "pro" | "ultra") => { (mode: InputMode) => {
onContextChange?.({ onContextChange?.({
...context, ...context,
mode, mode: getResolvedMode(mode, supportThinking),
reasoning_effort: mode === "ultra" ? "high" : mode === "pro" ? "medium" : mode === "thinking" ? "low" : "minimal",
});
},
[onContextChange, context, supportThinking],
);
const handleReasoningEffortSelect = useCallback(
(effort: "minimal" | "low" | "medium" | "high") => {
onContextChange?.({
...context,
reasoning_effort: effort,
}); });
}, },
[onContextChange, context], [onContextChange, context],
); );
const handleSubmit = useCallback( const handleSubmit = useCallback(
async (message: PromptInputMessage) => { async (message: PromptInputMessage) => {
if (status === "streaming") { if (status === "streaming") {
@ -150,11 +243,136 @@ export function InputBox({
if (!message.text) { if (!message.text) {
return; return;
} }
setFollowups([]);
setFollowupsHidden(false);
setFollowupsLoading(false);
onSubmit?.(message); onSubmit?.(message);
}, },
[onSubmit, onStop, status], [onSubmit, onStop, status],
); );
const requestFormSubmit = useCallback(() => {
const form = promptRootRef.current?.querySelector("form");
form?.requestSubmit();
}, []);
const handleFollowupClick = useCallback(
(suggestion: string) => {
if (status === "streaming") {
return;
}
const current = (textInput.value ?? "").trim();
if (current) {
setPendingSuggestion(suggestion);
setConfirmOpen(true);
return;
}
textInput.setInput(suggestion);
setFollowupsHidden(true);
setTimeout(() => requestFormSubmit(), 0);
},
[requestFormSubmit, status, textInput],
);
const confirmReplaceAndSend = useCallback(() => {
if (!pendingSuggestion) {
setConfirmOpen(false);
return;
}
textInput.setInput(pendingSuggestion);
setFollowupsHidden(true);
setConfirmOpen(false);
setPendingSuggestion(null);
setTimeout(() => requestFormSubmit(), 0);
}, [pendingSuggestion, requestFormSubmit, textInput]);
const confirmAppendAndSend = useCallback(() => {
if (!pendingSuggestion) {
setConfirmOpen(false);
return;
}
const current = (textInput.value ?? "").trim();
const next = current ? `${current}\n${pendingSuggestion}` : pendingSuggestion;
textInput.setInput(next);
setFollowupsHidden(true);
setConfirmOpen(false);
setPendingSuggestion(null);
setTimeout(() => requestFormSubmit(), 0);
}, [pendingSuggestion, requestFormSubmit, textInput]);
useEffect(() => {
const streaming = status === "streaming";
const wasStreaming = wasStreamingRef.current;
wasStreamingRef.current = streaming;
if (!wasStreaming || streaming) {
return;
}
if (disabled || isMock) {
return;
}
const lastAi = [...thread.messages].reverse().find((m) => m.type === "ai");
const lastAiId = lastAi?.id ?? null;
if (!lastAiId || lastAiId === lastGeneratedForAiIdRef.current) {
return;
}
lastGeneratedForAiIdRef.current = lastAiId;
const recent = thread.messages
.filter((m) => m.type === "human" || m.type === "ai")
.map((m) => {
const role = m.type === "human" ? "user" : "assistant";
const content = textOfMessage(m) ?? "";
return { role, content };
})
.filter((m) => m.content.trim().length > 0)
.slice(-6);
if (recent.length === 0) {
return;
}
const controller = new AbortController();
setFollowupsHidden(false);
setFollowupsLoading(true);
setFollowups([]);
fetch(`${getBackendBaseURL()}/api/threads/${threadId}/suggestions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
messages: recent,
n: 3,
model_name: context.model_name ?? undefined,
}),
signal: controller.signal,
})
.then(async (res) => {
if (!res.ok) {
return { suggestions: [] as string[] };
}
return (await res.json()) as { suggestions?: string[] };
})
.then((data) => {
const suggestions = (data.suggestions ?? [])
.map((s) => (typeof s === "string" ? s.trim() : ""))
.filter((s) => s.length > 0)
.slice(0, 5);
setFollowups(suggestions);
})
.catch(() => {
setFollowups([]);
})
.finally(() => {
setFollowupsLoading(false);
});
return () => controller.abort();
}, [context.model_name, disabled, isMock, status, thread.messages, threadId]);
return ( return (
<div ref={promptRootRef} className="relative">
<PromptInput <PromptInput
className={cn( className={cn(
"bg-background/85 rounded-2xl backdrop-blur-sm transition-all duration-300 ease-out *:data-[slot='input-group']:rounded-2xl", "bg-background/85 rounded-2xl backdrop-blur-sm transition-all duration-300 ease-out *:data-[slot='input-group']:rounded-2xl",
@ -366,6 +584,116 @@ export function InputBox({
</DropdownMenuGroup> </DropdownMenuGroup>
</PromptInputActionMenuContent> </PromptInputActionMenuContent>
</PromptInputActionMenu> </PromptInputActionMenu>
{supportReasoningEffort && context.mode !== "flash" && (
<PromptInputActionMenu>
<PromptInputActionMenuTrigger className="gap-1! px-2!">
<div className="text-xs font-normal">
{t.inputBox.reasoningEffort}:
{context.reasoning_effort === "minimal" && " " + t.inputBox.reasoningEffortMinimal}
{context.reasoning_effort === "low" && " " + t.inputBox.reasoningEffortLow}
{context.reasoning_effort === "medium" && " " + t.inputBox.reasoningEffortMedium}
{context.reasoning_effort === "high" && " " + t.inputBox.reasoningEffortHigh}
</div>
</PromptInputActionMenuTrigger>
<PromptInputActionMenuContent className="w-70">
<DropdownMenuGroup>
<DropdownMenuLabel className="text-muted-foreground text-xs">
{t.inputBox.reasoningEffort}
</DropdownMenuLabel>
<PromptInputActionMenu>
<PromptInputActionMenuItem
className={cn(
context.reasoning_effort === "minimal"
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
onSelect={() => handleReasoningEffortSelect("minimal")}
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1 font-bold">
{t.inputBox.reasoningEffortMinimal}
</div>
<div className="pl-2 text-xs">
{t.inputBox.reasoningEffortMinimalDescription}
</div>
</div>
{context.reasoning_effort === "minimal" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</PromptInputActionMenuItem>
<PromptInputActionMenuItem
className={cn(
context.reasoning_effort === "low"
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
onSelect={() => handleReasoningEffortSelect("low")}
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1 font-bold">
{t.inputBox.reasoningEffortLow}
</div>
<div className="pl-2 text-xs">
{t.inputBox.reasoningEffortLowDescription}
</div>
</div>
{context.reasoning_effort === "low" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</PromptInputActionMenuItem>
<PromptInputActionMenuItem
className={cn(
context.reasoning_effort === "medium" || !context.reasoning_effort
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
onSelect={() => handleReasoningEffortSelect("medium")}
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1 font-bold">
{t.inputBox.reasoningEffortMedium}
</div>
<div className="pl-2 text-xs">
{t.inputBox.reasoningEffortMediumDescription}
</div>
</div>
{context.reasoning_effort === "medium" || !context.reasoning_effort ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</PromptInputActionMenuItem>
<PromptInputActionMenuItem
className={cn(
context.reasoning_effort === "high"
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
onSelect={() => handleReasoningEffortSelect("high")}
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1 font-bold">
{t.inputBox.reasoningEffortHigh}
</div>
<div className="pl-2 text-xs">
{t.inputBox.reasoningEffortHighDescription}
</div>
</div>
{context.reasoning_effort === "high" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</PromptInputActionMenuItem>
</PromptInputActionMenu>
</DropdownMenuGroup>
</PromptInputActionMenuContent>
</PromptInputActionMenu>
)}
</PromptInputTools> </PromptInputTools>
<PromptInputTools> <PromptInputTools>
<ModelSelector <ModelSelector
@ -416,6 +744,64 @@ export function InputBox({
<div className="bg-background absolute right-0 -bottom-[17px] left-0 z-0 h-4"></div> <div className="bg-background absolute right-0 -bottom-[17px] left-0 z-0 h-4"></div>
)} )}
</PromptInput> </PromptInput>
{!disabled &&
!isNewThread &&
!followupsHidden &&
(followupsLoading || followups.length > 0) && (
<div className="absolute right-0 -top-20 left-0 z-20 flex items-center justify-center">
<div className="flex items-center gap-2">
{followupsLoading ? (
<div className="text-muted-foreground bg-background/80 rounded-full border px-4 py-2 text-xs backdrop-blur-sm">
{t.inputBox.followupLoading}
</div>
) : (
<Suggestions className="min-h-16 w-fit items-start">
{followups.map((s) => (
<Suggestion
key={s}
suggestion={s}
onClick={() => handleFollowupClick(s)}
/>
))}
<Button
aria-label={t.common.close}
className="text-muted-foreground cursor-pointer rounded-full px-3 text-xs font-normal"
variant="outline"
size="sm"
type="button"
onClick={() => setFollowupsHidden(true)}
>
<XIcon className="size-4" />
</Button>
</Suggestions>
)}
</div>
</div>
)}
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t.inputBox.followupConfirmTitle}</DialogTitle>
<DialogDescription>
{t.inputBox.followupConfirmDescription}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setConfirmOpen(false)}>
{t.common.cancel}
</Button>
<Button variant="secondary" onClick={confirmAppendAndSend}>
{t.inputBox.followupConfirmAppend}
</Button>
<Button onClick={confirmReplaceAndSend}>
{t.inputBox.followupConfirmReplace}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
); );
} }

View File

@ -1,11 +1,11 @@
import type { UseStream } from "@langchain/langgraph-sdk/react"; import type { BaseStream } from "@langchain/langgraph-sdk/react";
import { createContext, useContext } from "react"; import { createContext, useContext } from "react";
import type { AgentThreadState } from "@/core/threads"; import type { AgentThreadState } from "@/core/threads";
export interface ThreadContextType { export interface ThreadContextType {
threadId: string; thread: BaseStream<AgentThreadState>;
thread: UseStream<AgentThreadState>; isMock?: boolean;
} }
export const ThreadContext = createContext<ThreadContextType | undefined>( export const ThreadContext = createContext<ThreadContextType | undefined>(

View File

@ -1,25 +1,32 @@
import type { Message } from "@langchain/langgraph-sdk"; import type { Message } from "@langchain/langgraph-sdk";
import { FileIcon } from "lucide-react"; import { FileIcon, Loader2Icon } from "lucide-react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { memo, useMemo, useState, type ImgHTMLAttributes } from "react"; import { memo, useMemo, type ImgHTMLAttributes } from "react";
import rehypeKatex from "rehype-katex"; import rehypeKatex from "rehype-katex";
import { Loader } from "@/components/ai-elements/loader";
import { import {
Message as AIElementMessage, Message as AIElementMessage,
MessageContent as AIElementMessageContent, MessageContent as AIElementMessageContent,
MessageResponse as AIElementMessageResponse, MessageResponse as AIElementMessageResponse,
MessageToolbar, MessageToolbar,
} from "@/components/ai-elements/message"; } from "@/components/ai-elements/message";
import {
Reasoning,
ReasoningContent,
ReasoningTrigger,
} from "@/components/ai-elements/reasoning";
import { Task, TaskTrigger } from "@/components/ai-elements/task";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { resolveArtifactURL } from "@/core/artifacts/utils"; import { resolveArtifactURL } from "@/core/artifacts/utils";
import { useI18n } from "@/core/i18n/hooks";
import { import {
extractContentFromMessage, extractContentFromMessage,
extractReasoningContentFromMessage, extractReasoningContentFromMessage,
parseUploadedFiles, parseUploadedFiles,
type UploadedFile, stripUploadedFilesTag,
type FileInMessage,
} from "@/core/messages/utils"; } from "@/core/messages/utils";
import { materializeSkillYaml } from "@/core/skills";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { humanMessagePlugins } from "@/core/streamdown"; import { humanMessagePlugins } from "@/core/streamdown";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -48,6 +55,7 @@ export function MessageListItem({
message={message} message={message}
isLoading={isLoading} isLoading={isLoading}
/> />
{!isLoading && (
<MessageToolbar <MessageToolbar
className={cn( className={cn(
isHuman ? "-bottom-9 justify-end" : "-bottom-8", isHuman ? "-bottom-9 justify-end" : "-bottom-8",
@ -64,6 +72,7 @@ export function MessageListItem({
/> />
</div> </div>
</MessageToolbar> </MessageToolbar>
)}
</AIElementMessage> </AIElementMessage>
); );
} }
@ -121,37 +130,67 @@ function MessageContent_({
const rawContent = extractContentFromMessage(message); const rawContent = extractContentFromMessage(message);
const reasoningContent = extractReasoningContentFromMessage(message); const reasoningContent = extractReasoningContentFromMessage(message);
const { contentToParse, uploadedFiles } = useMemo(() => {
if (!isLoading && reasoningContent && !rawContent) { const files = useMemo(() => {
return { const files = message.additional_kwargs?.files;
contentToParse: reasoningContent, if (!Array.isArray(files) || files.length === 0) {
uploadedFiles: [] as UploadedFile[], if (rawContent.includes("<uploaded_files>")) {
}; // If the content contains the <uploaded_files> tag, we return the parsed files from the content for backward compatibility.
return parseUploadedFiles(rawContent);
} }
if (isHuman && rawContent) { return null;
const { files, cleanContent: contentWithoutFiles } =
parseUploadedFiles(rawContent);
return { contentToParse: contentWithoutFiles, uploadedFiles: files };
} }
return { return files as FileInMessage[];
contentToParse: rawContent ?? "", }, [message.additional_kwargs?.files, rawContent]);
uploadedFiles: [] as UploadedFile[],
}; const contentToDisplay = useMemo(() => {
}, [isLoading, rawContent, reasoningContent, isHuman]); if (isHuman) {
return rawContent ? stripUploadedFilesTag(rawContent) : "";
}
return rawContent ?? "";
}, [rawContent, isHuman]);
const filesList = const filesList =
uploadedFiles.length > 0 && thread_id ? ( files && files.length > 0 && thread_id ? (
<UploadedFilesList files={uploadedFiles} threadId={thread_id} /> <RichFilesList files={files} threadId={thread_id} />
) : null; ) : null;
// Uploading state: mock AI message shown while files upload
if (message.additional_kwargs?.element === "task") {
return (
<AIElementMessageContent className={className}>
<Task defaultOpen={false}>
<TaskTrigger title="">
<div className="text-muted-foreground flex w-full cursor-default items-center gap-2 text-sm select-none">
<Loader className="size-4" />
<span>{contentToDisplay}</span>
</div>
</TaskTrigger>
</Task>
</AIElementMessageContent>
);
}
// Reasoning-only AI message (no main response content yet)
if (!isHuman && reasoningContent && !rawContent) {
return (
<AIElementMessageContent className={className}>
<Reasoning isStreaming={isLoading}>
<ReasoningTrigger />
<ReasoningContent>{reasoningContent}</ReasoningContent>
</Reasoning>
</AIElementMessageContent>
);
}
if (isHuman) { if (isHuman) {
const messageResponse = contentToParse ? ( const messageResponse = contentToDisplay ? (
<AIElementMessageResponse <AIElementMessageResponse
remarkPlugins={humanMessagePlugins.remarkPlugins} remarkPlugins={humanMessagePlugins.remarkPlugins}
rehypePlugins={humanMessagePlugins.rehypePlugins} rehypePlugins={humanMessagePlugins.rehypePlugins}
components={components} components={components}
> >
{contentToParse} {contentToDisplay}
</AIElementMessageResponse> </AIElementMessageResponse>
) : null; ) : null;
return ( return (
@ -170,7 +209,7 @@ function MessageContent_({
<AIElementMessageContent className={className}> <AIElementMessageContent className={className}>
{filesList} {filesList}
<MarkdownContent <MarkdownContent
content={contentToParse} content={contentToDisplay}
isLoading={isLoading} isLoading={isLoading}
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: "html" }]]} rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: "html" }]]}
className="my-3" className="my-3"
@ -223,28 +262,32 @@ function isImageFile(filename: string): boolean {
return IMAGE_EXTENSIONS.includes(getFileExt(filename)); return IMAGE_EXTENSIONS.includes(getFileExt(filename));
} }
function isYamlFile(filename: string): boolean { /**
const ext = getFileExt(filename); * Format bytes to human-readable size string
return ext === "yaml" || ext === "yml"; */
function formatBytes(bytes: number): string {
if (bytes === 0) return "—";
const kb = bytes / 1024;
if (kb < 1024) return `${kb.toFixed(1)} KB`;
return `${(kb / 1024).toFixed(1)} MB`;
} }
/** /**
* Uploaded files list component * List of files from additional_kwargs.files (with optional upload status)
*/ */
function UploadedFilesList({ function RichFilesList({
files, files,
threadId, threadId,
}: { }: {
files: UploadedFile[]; files: FileInMessage[];
threadId: string; threadId: string;
}) { }) {
if (files.length === 0) return null; if (files.length === 0) return null;
return ( return (
<div className="mb-2 flex flex-wrap justify-end gap-2"> <div className="mb-2 flex flex-wrap justify-end gap-2">
{files.map((file, index) => ( {files.map((file, index) => (
<UploadedFileCard <RichFileCard
key={`${file.path}-${index}`} key={`${file.filename}-${index}`}
file={file} file={file}
threadId={threadId} threadId={threadId}
/> />
@ -254,47 +297,49 @@ function UploadedFilesList({
} }
/** /**
* Single uploaded file card component * Single file card that handles FileInMessage (supports uploading state)
*/ */
function UploadedFileCard({ function RichFileCard({
file, file,
threadId, threadId,
}: { }: {
file: UploadedFile; file: FileInMessage;
threadId: string; threadId: string;
}) { }) {
const [isMaterializing, setIsMaterializing] = useState(false); const { t } = useI18n();
const [materializeMessage, setMaterializeMessage] = useState<string | null>( const isUploading = file.status === "uploading";
null,
);
if (!threadId) return null;
const isImage = isImageFile(file.filename); const isImage = isImageFile(file.filename);
const isYaml = isYamlFile(file.filename);
const fileUrl = resolveArtifactURL(file.path, threadId);
const handleMaterializeYaml = async () => { if (isUploading) {
if (isMaterializing) return; return (
setIsMaterializing(true); <div className="bg-background border-border/40 flex max-w-50 min-w-30 flex-col gap-1 rounded-lg border p-3 opacity-60 shadow-sm">
setMaterializeMessage(null); <div className="flex items-start gap-2">
try { <Loader2Icon className="text-muted-foreground mt-0.5 size-4 shrink-0 animate-spin" />
const result = await materializeSkillYaml({ <span
thread_id: threadId, className="text-foreground truncate text-sm font-medium"
path: file.path, title={file.filename}
target_dir: "/mnt/user-data/uploads/skill", >
clear_target: true, {file.filename}
}); </span>
setMaterializeMessage( </div>
`已创建 ${result.created_files} 个文件 / ${result.created_directories} 个目录`, <div className="flex items-center justify-between gap-2">
<Badge
variant="secondary"
className="rounded px-1.5 py-0.5 text-[10px] font-normal"
>
{getFileTypeLabel(file.filename)}
</Badge>
<span className="text-muted-foreground text-[10px]">
{t.uploads.uploading}
</span>
</div>
</div>
); );
} catch (error) {
const message = error instanceof Error ? error.message : "解析失败";
setMaterializeMessage(`失败: ${message}`);
} finally {
setIsMaterializing(false);
} }
};
if (!file.path) return null;
const fileUrl = resolveArtifactURL(file.path, threadId);
if (isImage) { if (isImage) {
return ( return (
@ -307,14 +352,14 @@ function UploadedFileCard({
<img <img
src={fileUrl} src={fileUrl}
alt={file.filename} alt={file.filename}
className="h-32 w-auto max-w-[240px] object-cover transition-transform group-hover:scale-105" className="h-32 w-auto max-w-60 object-cover transition-transform group-hover:scale-105"
/> />
</a> </a>
); );
} }
return ( return (
<div className="bg-background border-border/40 flex max-w-[200px] min-w-[120px] flex-col gap-1 rounded-lg border p-3 shadow-sm"> <div className="bg-background border-border/40 flex max-w-50 min-w-30 flex-col gap-1 rounded-lg border p-3 shadow-sm">
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<FileIcon className="text-muted-foreground mt-0.5 size-4 shrink-0" /> <FileIcon className="text-muted-foreground mt-0.5 size-4 shrink-0" />
<span <span
@ -331,29 +376,10 @@ function UploadedFileCard({
> >
{getFileTypeLabel(file.filename)} {getFileTypeLabel(file.filename)}
</Badge> </Badge>
<span className="text-muted-foreground text-[10px]">{file.size}</span> <span className="text-muted-foreground text-[10px]">
</div> {formatBytes(file.size)}
{/* 注释掉测试按钮,后续根据需求再决定是否保留 */}
{/* {isYaml && (
<div className="mt-1 flex flex-col gap-1">
<Button
size="sm"
variant="secondary"
className="h-7 text-xs"
onClick={() => {
void handleMaterializeYaml();
}}
disabled={isMaterializing}
>
{isMaterializing ? "解析中..." : "一键导入为 Skill 目录"}
</Button>
{materializeMessage && (
<span className="text-muted-foreground text-[10px] leading-tight">
{materializeMessage}
</span> </span>
)}
</div> </div>
)} */}
</div> </div>
); );
} }

View File

@ -1,5 +1,4 @@
import type { Message } from "@langchain/langgraph-sdk"; import type { BaseStream } from "@langchain/langgraph-sdk/react";
import type { UseStream } from "@langchain/langgraph-sdk/react";
import { import {
Conversation, Conversation,
@ -34,23 +33,18 @@ export function MessageList({
className, className,
threadId, threadId,
thread, thread,
messagesOverride,
suppressThreadLoading = false,
paddingBottom = 160, paddingBottom = 160,
}: { }: {
className?: string; className?: string;
threadId: string; threadId: string;
thread: UseStream<AgentThreadState>; thread: BaseStream<AgentThreadState>;
/** When set (e.g. from onFinish), use instead of thread.messages so SSE end shows complete state. */
messagesOverride?: Message[];
suppressThreadLoading?: boolean;
paddingBottom?: number; paddingBottom?: number;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading); const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
const updateSubtask = useUpdateSubtask(); const updateSubtask = useUpdateSubtask();
const messages = messagesOverride ?? thread.messages; const messages = thread.messages;
if (thread.isThreadLoading && !suppressThreadLoading) { if (thread.isThreadLoading && messages.length === 0) {
return <MessageListSkeleton />; return <MessageListSkeleton />;
} }
return ( return (
@ -60,13 +54,15 @@ export function MessageList({
<ConversationContent className="mx-auto w-full max-w-(--container-width-md) gap-8 pt-12"> <ConversationContent className="mx-auto w-full max-w-(--container-width-md) gap-8 pt-12">
{groupMessages(messages, (group) => { {groupMessages(messages, (group) => {
if (group.type === "human" || group.type === "assistant") { if (group.type === "human" || group.type === "assistant") {
return group.messages.map((msg) => {
return ( return (
<MessageListItem <MessageListItem
key={group.id} key={`${group.id}/${msg.id}`}
message={group.messages[0]!} message={msg}
isLoading={thread.isLoading} isLoading={thread.isLoading}
/> />
); );
});
} else if (group.type === "assistant:clarification") { } else if (group.type === "assistant:clarification") {
const message = group.messages[0]; const message = group.messages[0];
if (message && hasContent(message)) { if (message && hasContent(message)) {

View File

@ -12,7 +12,7 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { enUS, zhCN, type Locale } from "@/core/i18n"; import { enUS, isLocale, zhCN, type Locale } from "@/core/i18n";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -89,7 +89,11 @@ export function AppearanceSettingsPage() {
> >
<Select <Select
value={locale} value={locale}
onValueChange={(value) => changeLocale(value as Locale)} onValueChange={(value) => {
if (isLocale(value)) {
changeLocale(value);
}
}}
> >
<SelectTrigger className="w-[220px]"> <SelectTrigger className="w-[220px]">
<SelectValue /> <SelectValue />

View File

@ -1,11 +1,50 @@
import type { BaseStream } from "@langchain/langgraph-sdk";
import { useEffect } from "react";
import { useI18n } from "@/core/i18n/hooks";
import type { AgentThreadState } from "@/core/threads";
import { useThreadChat } from "./chats";
import { FlipDisplay } from "./flip-display"; import { FlipDisplay } from "./flip-display";
export function ThreadTitle({ export function ThreadTitle({
threadTitle, threadId,
thread,
}: { }: {
className?: string; className?: string;
threadId: string; threadId: string;
threadTitle: string; thread: BaseStream<AgentThreadState>;
}) { }) {
return <FlipDisplay uniqueKey={threadTitle}>{threadTitle}</FlipDisplay>; const { t } = useI18n();
const { isNewThread } = useThreadChat();
useEffect(() => {
let _title = t.pages.untitled;
if (thread.values?.title) {
_title = thread.values.title;
} else if (isNewThread) {
_title = t.pages.newChat;
}
if (thread.isThreadLoading) {
document.title = `Loading... - ${t.pages.appName}`;
} else {
document.title = `${_title} - ${t.pages.appName}`;
}
}, [
isNewThread,
t.pages.newChat,
t.pages.untitled,
t.pages.appName,
thread.isThreadLoading,
thread.values,
]);
if (!thread.values?.title) {
return null;
}
return (
<FlipDisplay uniqueKey={threadId}>
{thread.values.title ?? "Untitled"}
</FlipDisplay>
);
} }

View File

@ -1,4 +1,5 @@
import { ChevronUpIcon, ListTodoIcon } from "lucide-react"; import { ChevronUpIcon, ListTodoIcon } from "lucide-react";
import { useState } from "react";
import type { Todo } from "@/core/todos"; import type { Todo } from "@/core/todos";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -13,7 +14,7 @@ import {
export function TodoList({ export function TodoList({
className, className,
todos, todos,
collapsed = false, collapsed: controlledCollapsed,
hidden = false, hidden = false,
onToggle, onToggle,
}: { }: {
@ -23,6 +24,18 @@ export function TodoList({
hidden?: boolean; hidden?: boolean;
onToggle?: () => void; onToggle?: () => void;
}) { }) {
const [internalCollapsed, setInternalCollapsed] = useState(true);
const isControlled = controlledCollapsed !== undefined;
const collapsed = isControlled ? controlledCollapsed : internalCollapsed;
const handleToggle = () => {
if (isControlled) {
onToggle?.();
} else {
setInternalCollapsed((prev) => !prev);
}
};
return ( return (
<div <div
className={cn( className={cn(
@ -35,9 +48,7 @@ export function TodoList({
className={cn( className={cn(
"bg-accent flex min-h-8 shrink-0 cursor-pointer items-center justify-between px-4 text-sm transition-all duration-300 ease-out", "bg-accent flex min-h-8 shrink-0 cursor-pointer items-center justify-between px-4 text-sm transition-all duration-300 ease-out",
)} )}
onClick={() => { onClick={handleToggle}
onToggle?.();
}}
> >
<div className="text-muted-foreground"> <div className="text-muted-foreground">
<div className="flex items-center justify-center gap-2"> <div className="flex items-center justify-center gap-2">

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { MessagesSquare } from "lucide-react"; import { BotIcon, MessagesSquare } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
@ -26,6 +26,17 @@ export function WorkspaceNavChatList() {
</Link> </Link>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/workspace/agents")}
asChild
>
<Link className="text-muted-foreground" href="/workspace/agents">
<BotIcon />
<span>{t.sidebar.agents}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
</SidebarGroup> </SidebarGroup>
); );

View File

@ -0,0 +1,67 @@
import { getBackendBaseURL } from "@/core/config";
import type { Agent, CreateAgentRequest, UpdateAgentRequest } from "./types";
export async function listAgents(): Promise<Agent[]> {
const res = await fetch(`${getBackendBaseURL()}/api/agents`);
if (!res.ok) throw new Error(`Failed to load agents: ${res.statusText}`);
const data = (await res.json()) as { agents: Agent[] };
return data.agents;
}
export async function getAgent(name: string): Promise<Agent> {
const res = await fetch(`${getBackendBaseURL()}/api/agents/${name}`);
if (!res.ok) throw new Error(`Agent '${name}' not found`);
return res.json() as Promise<Agent>;
}
export async function createAgent(request: CreateAgentRequest): Promise<Agent> {
const res = await fetch(`${getBackendBaseURL()}/api/agents`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(request),
});
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { detail?: string };
throw new Error(err.detail ?? `Failed to create agent: ${res.statusText}`);
}
return res.json() as Promise<Agent>;
}
export async function updateAgent(
name: string,
request: UpdateAgentRequest,
): Promise<Agent> {
const res = await fetch(`${getBackendBaseURL()}/api/agents/${name}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(request),
});
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { detail?: string };
throw new Error(err.detail ?? `Failed to update agent: ${res.statusText}`);
}
return res.json() as Promise<Agent>;
}
export async function deleteAgent(name: string): Promise<void> {
const res = await fetch(`${getBackendBaseURL()}/api/agents/${name}`, {
method: "DELETE",
});
if (!res.ok) throw new Error(`Failed to delete agent: ${res.statusText}`);
}
export async function checkAgentName(
name: string,
): Promise<{ available: boolean; name: string }> {
const res = await fetch(
`${getBackendBaseURL()}/api/agents/check?name=${encodeURIComponent(name)}`,
);
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { detail?: string };
throw new Error(
err.detail ?? `Failed to check agent name: ${res.statusText}`,
);
}
return res.json() as Promise<{ available: boolean; name: string }>;
}

View File

@ -0,0 +1,64 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
createAgent,
deleteAgent,
getAgent,
listAgents,
updateAgent,
} from "./api";
import type { CreateAgentRequest, UpdateAgentRequest } from "./types";
export function useAgents() {
const { data, isLoading, error } = useQuery({
queryKey: ["agents"],
queryFn: () => listAgents(),
});
return { agents: data ?? [], isLoading, error };
}
export function useAgent(name: string | null | undefined) {
const { data, isLoading, error } = useQuery({
queryKey: ["agents", name],
queryFn: () => getAgent(name!),
enabled: !!name,
});
return { agent: data ?? null, isLoading, error };
}
export function useCreateAgent() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (request: CreateAgentRequest) => createAgent(request),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["agents"] });
},
});
}
export function useUpdateAgent() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
name,
request,
}: {
name: string;
request: UpdateAgentRequest;
}) => updateAgent(name, request),
onSuccess: (_data, { name }) => {
void queryClient.invalidateQueries({ queryKey: ["agents"] });
void queryClient.invalidateQueries({ queryKey: ["agents", name] });
},
});
}
export function useDeleteAgent() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (name: string) => deleteAgent(name),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["agents"] });
},
});
}

View File

@ -0,0 +1,3 @@
export * from "./api";
export * from "./hooks";
export * from "./types";

View File

@ -0,0 +1,22 @@
export interface Agent {
name: string;
description: string;
model: string | null;
tool_groups: string[] | null;
soul?: string | null;
}
export interface CreateAgentRequest {
name: string;
description?: string;
model?: string | null;
tool_groups?: string[] | null;
soul?: string;
}
export interface UpdateAgentRequest {
description?: string | null;
model?: string | null;
tool_groups?: string[] | null;
soul?: string | null;
}

View File

@ -4,10 +4,34 @@ import { Client as LangGraphClient } from "@langchain/langgraph-sdk/client";
import { getLangGraphBaseURL } from "../config"; import { getLangGraphBaseURL } from "../config";
let _singleton: LangGraphClient | null = null; import { sanitizeRunStreamOptions } from "./stream-mode";
export function getAPIClient(): LangGraphClient {
_singleton ??= new LangGraphClient({ function createCompatibleClient(isMock?: boolean): LangGraphClient {
apiUrl: getLangGraphBaseURL(), const client = new LangGraphClient({
apiUrl: getLangGraphBaseURL(isMock),
}); });
const originalRunStream = client.runs.stream.bind(client.runs);
client.runs.stream = ((threadId, assistantId, payload) =>
originalRunStream(
threadId,
assistantId,
sanitizeRunStreamOptions(payload),
)) as typeof client.runs.stream;
const originalJoinStream = client.runs.joinStream.bind(client.runs);
client.runs.joinStream = ((threadId, runId, options) =>
originalJoinStream(
threadId,
runId,
sanitizeRunStreamOptions(options),
)) as typeof client.runs.joinStream;
return client;
}
let _singleton: LangGraphClient | null = null;
export function getAPIClient(isMock?: boolean): LangGraphClient {
_singleton ??= createCompatibleClient(isMock);
return _singleton; return _singleton;
} }

View File

@ -0,0 +1,43 @@
import assert from "node:assert/strict";
import test from "node:test";
const { sanitizeRunStreamOptions } = await import(
new URL("./stream-mode.ts", import.meta.url).href
);
void test("drops unsupported stream modes from array payloads", () => {
const sanitized = sanitizeRunStreamOptions({
streamMode: [
"values",
"messages-tuple",
"custom",
"updates",
"events",
"tools",
],
});
assert.deepEqual(sanitized.streamMode, [
"values",
"messages-tuple",
"custom",
"updates",
"events",
]);
});
void test("drops unsupported stream modes from scalar payloads", () => {
const sanitized = sanitizeRunStreamOptions({
streamMode: "tools",
});
assert.equal(sanitized.streamMode, undefined);
});
void test("keeps payloads without streamMode untouched", () => {
const options = {
streamSubgraphs: true,
};
assert.equal(sanitizeRunStreamOptions(options), options);
});

View File

@ -0,0 +1,68 @@
const SUPPORTED_RUN_STREAM_MODES = new Set([
"values",
"messages",
"messages-tuple",
"updates",
"events",
"debug",
"tasks",
"checkpoints",
"custom",
] as const);
const warnedUnsupportedStreamModes = new Set<string>();
export function warnUnsupportedStreamModes(
modes: string[],
warn: (message: string) => void = console.warn,
) {
const unseenModes = modes.filter((mode) => {
if (warnedUnsupportedStreamModes.has(mode)) {
return false;
}
warnedUnsupportedStreamModes.add(mode);
return true;
});
if (unseenModes.length === 0) {
return;
}
warn(
`[deer-flow] Dropped unsupported LangGraph stream mode(s): ${unseenModes.join(", ")}`,
);
}
export function sanitizeRunStreamOptions<T>(options: T): T {
if (
typeof options !== "object" ||
options === null ||
!("streamMode" in options)
) {
return options;
}
const streamMode = options.streamMode;
if (streamMode == null) {
return options;
}
const requestedModes = Array.isArray(streamMode) ? streamMode : [streamMode];
const sanitizedModes = requestedModes.filter((mode) =>
SUPPORTED_RUN_STREAM_MODES.has(mode),
);
if (sanitizedModes.length === requestedModes.length) {
return options;
}
const droppedModes = requestedModes.filter(
(mode) => !SUPPORTED_RUN_STREAM_MODES.has(mode),
);
warnUnsupportedStreamModes(droppedModes);
return {
...options,
streamMode: Array.isArray(streamMode) ? sanitizedModes : sanitizedModes[0],
};
}

View File

@ -17,17 +17,18 @@ export function useArtifactContent({
const isWriteFile = useMemo(() => { const isWriteFile = useMemo(() => {
return filepath.startsWith("write-file:"); return filepath.startsWith("write-file:");
}, [filepath]); }, [filepath]);
const { thread } = useThread(); const { thread, isMock } = useThread();
const content = useMemo(() => { const content = useMemo(() => {
if (isWriteFile) { if (isWriteFile) {
return loadArtifactContentFromToolCall({ url: filepath, thread }); return loadArtifactContentFromToolCall({ url: filepath, thread });
} }
return null; return null;
}, [filepath, isWriteFile, thread]); }, [filepath, isWriteFile, thread]);
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
queryKey: ["artifact", filepath, threadId], queryKey: ["artifact", filepath, threadId, isMock],
queryFn: () => { queryFn: () => {
return loadArtifactContent({ filepath, threadId }); return loadArtifactContent({ filepath, threadId, isMock });
}, },
enabled, enabled,
// Cache artifact content for 5 minutes to avoid repeated fetches (especially for .skill ZIP extraction) // Cache artifact content for 5 minutes to avoid repeated fetches (especially for .skill ZIP extraction)

View File

@ -1,4 +1,4 @@
import type { UseStream } from "@langchain/langgraph-sdk/react"; import type { BaseStream } from "@langchain/langgraph-sdk/react";
import type { AgentThreadState } from "../threads"; import type { AgentThreadState } from "../threads";
@ -7,15 +7,17 @@ import { urlOfArtifact } from "./utils";
export async function loadArtifactContent({ export async function loadArtifactContent({
filepath, filepath,
threadId, threadId,
isMock,
}: { }: {
filepath: string; filepath: string;
threadId: string; threadId: string;
isMock?: boolean;
}) { }) {
let enhancedFilepath = filepath; let enhancedFilepath = filepath;
if (filepath.endsWith(".skill")) { if (filepath.endsWith(".skill")) {
enhancedFilepath = filepath + "/SKILL.md"; enhancedFilepath = filepath + "/SKILL.md";
} }
const url = urlOfArtifact({ filepath: enhancedFilepath, threadId }); const url = urlOfArtifact({ filepath: enhancedFilepath, threadId, isMock });
const response = await fetch(url); const response = await fetch(url);
const text = await response.text(); const text = await response.text();
return text; return text;
@ -26,7 +28,7 @@ export function loadArtifactContentFromToolCall({
thread, thread,
}: { }: {
url: string; url: string;
thread: UseStream<AgentThreadState>; thread: BaseStream<AgentThreadState>;
}) { }) {
const url = new URL(urlString); const url = new URL(urlString);
const toolCallId = url.searchParams.get("tool_call_id"); const toolCallId = url.searchParams.get("tool_call_id");

View File

@ -5,11 +5,16 @@ export function urlOfArtifact({
filepath, filepath,
threadId, threadId,
download = false, download = false,
isMock = false,
}: { }: {
filepath: string; filepath: string;
threadId: string; threadId: string;
download?: boolean; download?: boolean;
isMock?: boolean;
}) { }) {
if (isMock) {
return `${getBackendBaseURL()}/mock/api/threads/${threadId}/artifacts${filepath}${download ? "?download=true" : ""}`;
}
return `${getBackendBaseURL()}/api/threads/${threadId}/artifacts${filepath}${download ? "?download=true" : ""}`; return `${getBackendBaseURL()}/api/threads/${threadId}/artifacts${filepath}${download ? "?download=true" : ""}`;
} }

View File

@ -8,9 +8,14 @@ export function getBackendBaseURL() {
} }
} }
export function getLangGraphBaseURL() { export function getLangGraphBaseURL(isMock?: boolean) {
if (env.NEXT_PUBLIC_LANGGRAPH_BASE_URL) { if (env.NEXT_PUBLIC_LANGGRAPH_BASE_URL) {
return env.NEXT_PUBLIC_LANGGRAPH_BASE_URL; return env.NEXT_PUBLIC_LANGGRAPH_BASE_URL;
} else if (isMock) {
if (typeof window !== "undefined") {
return `${window.location.origin}/mock/api`;
}
return "http://localhost:3000/mock/api";
} else { } else {
// LangGraph SDK requires a full URL, construct it from current origin // LangGraph SDK requires a full URL, construct it from current origin
if (typeof window !== "undefined") { if (typeof window !== "undefined") {

View File

@ -7,7 +7,13 @@ import { getLocaleFromCookie, setLocaleInCookie } from "./cookies";
import { enUS } from "./locales/en-US"; import { enUS } from "./locales/en-US";
import { zhCN } from "./locales/zh-CN"; import { zhCN } from "./locales/zh-CN";
import { detectLocale, type Locale, type Translations } from "./index"; import {
DEFAULT_LOCALE,
detectLocale,
normalizeLocale,
type Locale,
type Translations,
} from "./index";
const translations: Record<Locale, Translations> = { const translations: Record<Locale, Translations> = {
"en-US": enUS, "en-US": enUS,
@ -17,7 +23,7 @@ const translations: Record<Locale, Translations> = {
export function useI18n() { export function useI18n() {
const { locale, setLocale } = useI18nContext(); const { locale, setLocale } = useI18nContext();
const t = translations[locale]; const t = translations[locale] ?? translations[DEFAULT_LOCALE];
const changeLocale = (newLocale: Locale) => { const changeLocale = (newLocale: Locale) => {
setLocale(newLocale); setLocale(newLocale);
@ -26,12 +32,19 @@ export function useI18n() {
// Initialize locale on mount // Initialize locale on mount
useEffect(() => { useEffect(() => {
const saved = getLocaleFromCookie() as Locale | null; const saved = getLocaleFromCookie();
if (!saved) { if (saved) {
const normalizedSaved = normalizeLocale(saved);
setLocale(normalizedSaved);
if (saved !== normalizedSaved) {
setLocaleInCookie(normalizedSaved);
}
return;
}
const detected = detectLocale(); const detected = detectLocale();
setLocale(detected); setLocale(detected);
setLocaleInCookie(detected); setLocaleInCookie(detected);
}
}, [setLocale]); }, [setLocale]);
return { return {

View File

@ -1,23 +1,11 @@
export { enUS } from "./locales/en-US"; export { enUS } from "./locales/en-US";
export { zhCN } from "./locales/zh-CN"; export { zhCN } from "./locales/zh-CN";
export type { Translations } from "./locales/types"; export type { Translations } from "./locales/types";
export {
export type Locale = "en-US" | "zh-CN"; DEFAULT_LOCALE,
SUPPORTED_LOCALES,
// Helper function to detect browser locale detectLocale,
export function detectLocale(): Locale { isLocale,
if (typeof window === "undefined") { normalizeLocale,
return "en-US"; } from "./locale";
} export type { Locale } from "./locale";
const browserLang =
navigator.language ||
(navigator as unknown as { userLanguage: string }).userLanguage;
// Check if browser language is Chinese (zh, zh-CN, zh-TW, etc.)
if (browserLang.toLowerCase().startsWith("zh")) {
return "zh-CN";
}
return "en-US";
}

View File

@ -0,0 +1,36 @@
export const SUPPORTED_LOCALES = ["en-US", "zh-CN"] as const;
export type Locale = (typeof SUPPORTED_LOCALES)[number];
export const DEFAULT_LOCALE: Locale = "en-US";
export function isLocale(value: string): value is Locale {
return (SUPPORTED_LOCALES as readonly string[]).includes(value);
}
export function normalizeLocale(locale: string | null | undefined): Locale {
if (!locale) {
return DEFAULT_LOCALE;
}
if (isLocale(locale)) {
return locale;
}
if (locale.toLowerCase().startsWith("zh")) {
return "zh-CN";
}
return DEFAULT_LOCALE;
}
// Helper function to detect browser locale
export function detectLocale(): Locale {
if (typeof window === "undefined") {
return DEFAULT_LOCALE;
}
const browserLang =
navigator.language ||
(navigator as unknown as { userLanguage: string }).userLanguage;
return normalizeLocale(browserLang);
}

View File

@ -82,9 +82,26 @@ export const enUS: Translations = {
ultraMode: "Ultra", ultraMode: "Ultra",
ultraModeDescription: ultraModeDescription:
"Pro mode with subagents to divide work; best for complex multi-step tasks", "Pro mode with subagents to divide work; best for complex multi-step tasks",
reasoningEffort: "Reasoning Effort",
reasoningEffortMinimal: "Minimal",
reasoningEffortMinimalDescription: "Retrieval + Direct Output",
reasoningEffortLow: "Low",
reasoningEffortLowDescription: "Simple Logic Check + Shallow Deduction",
reasoningEffortMedium: "Medium",
reasoningEffortMediumDescription:
"Multi-layer Logic Analysis + Basic Verification",
reasoningEffortHigh: "High",
reasoningEffortHighDescription:
"Full-dimensional Logic Deduction + Multi-path Verification + Backward Check",
searchModels: "Search models...", searchModels: "Search models...",
surpriseMe: "Surprise", surpriseMe: "Surprise",
surpriseMePrompt: "Surprise me", surpriseMePrompt: "Surprise me",
followupLoading: "Generating follow-up questions...",
followupConfirmTitle: "Send suggestion?",
followupConfirmDescription:
"You already have text in the input. Choose how to send it.",
followupConfirmAppend: "Append & send",
followupConfirmReplace: "Replace & send",
suggestions: [ suggestions: [
{ {
suggestion: "Write", suggestion: "Write",
@ -142,6 +159,41 @@ export const enUS: Translations = {
chats: "Chats", chats: "Chats",
recentChats: "Recent chats", recentChats: "Recent chats",
demoChats: "Demo chats", demoChats: "Demo chats",
agents: "Agents",
},
// Agents
agents: {
title: "Agents",
description:
"Create and manage custom agents with specialized prompts and capabilities.",
newAgent: "New Agent",
emptyTitle: "No custom agents yet",
emptyDescription:
"Create your first custom agent with a specialized system prompt.",
chat: "Chat",
delete: "Delete",
deleteConfirm:
"Are you sure you want to delete this agent? This action cannot be undone.",
deleteSuccess: "Agent deleted",
newChat: "New chat",
createPageTitle: "Design your Agent",
createPageSubtitle:
"Describe the agent you want — I'll help you create it through conversation.",
nameStepTitle: "Name your new Agent",
nameStepHint:
"Letters, digits, and hyphens only — stored lowercase (e.g. code-reviewer)",
nameStepPlaceholder: "e.g. code-reviewer",
nameStepContinue: "Continue",
nameStepInvalidError:
"Invalid name — use only letters, digits, and hyphens",
nameStepAlreadyExistsError: "An agent with this name already exists",
nameStepCheckError: "Could not verify name availability — please try again",
nameStepBootstrapMessage:
"The new custom agent name is {name}. Let's bootstrap it's **SOUL**.",
agentCreated: "Agent created!",
startChatting: "Start chatting",
backToGallery: "Back to Gallery",
}, },
// Breadcrumb // Breadcrumb
@ -204,6 +256,11 @@ export const enUS: Translations = {
}, },
// Subtasks // Subtasks
uploads: {
uploading: "Uploading...",
uploadingFiles: "Uploading files, please wait...",
},
subtasks: { subtasks: {
subtask: "Subtask", subtask: "Subtask",
executing: (count: number) => executing: (count: number) =>

View File

@ -64,9 +64,23 @@ export interface Translations {
proModeDescription: string; proModeDescription: string;
ultraMode: string; ultraMode: string;
ultraModeDescription: string; ultraModeDescription: string;
reasoningEffort: string;
reasoningEffortMinimal: string;
reasoningEffortMinimalDescription: string;
reasoningEffortLow: string;
reasoningEffortLowDescription: string;
reasoningEffortMedium: string;
reasoningEffortMediumDescription: string;
reasoningEffortHigh: string;
reasoningEffortHighDescription: string;
searchModels: string; searchModels: string;
surpriseMe: string; surpriseMe: string;
surpriseMePrompt: string; surpriseMePrompt: string;
followupLoading: string;
followupConfirmTitle: string;
followupConfirmDescription: string;
followupConfirmAppend: string;
followupConfirmReplace: string;
suggestions: { suggestions: {
suggestion: string; suggestion: string;
prompt: string; prompt: string;
@ -90,6 +104,34 @@ export interface Translations {
newChat: string; newChat: string;
chats: string; chats: string;
demoChats: string; demoChats: string;
agents: string;
};
// Agents
agents: {
title: string;
description: string;
newAgent: string;
emptyTitle: string;
emptyDescription: string;
chat: string;
delete: string;
deleteConfirm: string;
deleteSuccess: string;
newChat: string;
createPageTitle: string;
createPageSubtitle: string;
nameStepTitle: string;
nameStepHint: string;
nameStepPlaceholder: string;
nameStepContinue: string;
nameStepInvalidError: string;
nameStepAlreadyExistsError: string;
nameStepCheckError: string;
nameStepBootstrapMessage: string;
agentCreated: string;
startChatting: string;
backToGallery: string;
}; };
// Breadcrumb // Breadcrumb
@ -150,6 +192,12 @@ export interface Translations {
skillInstallTooltip: string; skillInstallTooltip: string;
}; };
// Uploads
uploads: {
uploading: string;
uploadingFiles: string;
};
// Subtasks // Subtasks
subtasks: { subtasks: {
subtask: string; subtask: string;

View File

@ -80,9 +80,23 @@ export const zhCN: Translations = {
ultraMode: "Ultra", ultraMode: "Ultra",
ultraModeDescription: ultraModeDescription:
"继承自 Pro 模式,可调用子代理分工协作,适合复杂多步骤任务,能力最强", "继承自 Pro 模式,可调用子代理分工协作,适合复杂多步骤任务,能力最强",
reasoningEffort: "推理深度",
reasoningEffortMinimal: "最低",
reasoningEffortMinimalDescription: "检索 + 直接输出",
reasoningEffortLow: "低",
reasoningEffortLowDescription: "简单逻辑校验 + 浅层推演",
reasoningEffortMedium: "中",
reasoningEffortMediumDescription: "多层逻辑分析 + 基础验证",
reasoningEffortHigh: "高",
reasoningEffortHighDescription: "全维度逻辑推演 + 多路径验证 + 反推校验",
searchModels: "搜索模型...", searchModels: "搜索模型...",
surpriseMe: "小惊喜", surpriseMe: "小惊喜",
surpriseMePrompt: "给我一个小惊喜吧", surpriseMePrompt: "给我一个小惊喜吧",
followupLoading: "正在生成可能的后续问题...",
followupConfirmTitle: "发送建议问题?",
followupConfirmDescription: "当前输入框已有内容,选择发送方式。",
followupConfirmAppend: "追加并发送",
followupConfirmReplace: "替换并发送",
suggestions: [ suggestions: [
{ {
suggestion: "写作", suggestion: "写作",
@ -139,6 +153,36 @@ export const zhCN: Translations = {
chats: "对话", chats: "对话",
recentChats: "最近的对话", recentChats: "最近的对话",
demoChats: "演示对话", demoChats: "演示对话",
agents: "智能体",
},
// Agents
agents: {
title: "智能体",
description: "创建和管理具有专属 Prompt 与能力的自定义智能体。",
newAgent: "新建智能体",
emptyTitle: "还没有自定义智能体",
emptyDescription: "创建你的第一个自定义智能体,设置专属系统提示词。",
chat: "对话",
delete: "删除",
deleteConfirm: "确定要删除该智能体吗?此操作不可撤销。",
deleteSuccess: "智能体已删除",
newChat: "新对话",
createPageTitle: "设计你的智能体",
createPageSubtitle: "描述你想要的智能体,我来帮你通过对话创建。",
nameStepTitle: "给新智能体起个名字",
nameStepHint:
"只允许字母、数字和连字符,存储时自动转为小写(例如 code-reviewer",
nameStepPlaceholder: "例如 code-reviewer",
nameStepContinue: "继续",
nameStepInvalidError: "名称无效,只允许字母、数字和连字符",
nameStepAlreadyExistsError: "已存在同名智能体",
nameStepCheckError: "无法验证名称可用性,请稍后重试",
nameStepBootstrapMessage:
"新智能体的名称是 {name},现在开始为它生成 **SOUL**。",
agentCreated: "智能体已创建!",
startChatting: "开始对话",
backToGallery: "返回 Gallery",
}, },
// Breadcrumb // Breadcrumb
@ -199,6 +243,11 @@ export const zhCN: Translations = {
skillInstallTooltip: "安装技能并使其可在 DeerFlow 中使用", skillInstallTooltip: "安装技能并使其可在 DeerFlow 中使用",
}, },
uploads: {
uploading: "上传中...",
uploadingFiles: "文件上传中,请稍候...",
},
subtasks: { subtasks: {
subtask: "子任务", subtask: "子任务",
executing: (count: number) => executing: (count: number) =>

View File

@ -1,9 +1,17 @@
import { cookies } from "next/headers"; import { cookies } from "next/headers";
export type Locale = "en-US" | "zh-CN"; import { normalizeLocale, type Locale } from "./locale";
export async function detectLocaleServer(): Promise<Locale> { export async function detectLocaleServer(): Promise<Locale> {
const cookieStore = await cookies(); const cookieStore = await cookies();
const locale = cookieStore.get("locale")?.value ?? "en-US"; let locale = cookieStore.get("locale")?.value;
return locale as Locale; if (locale !== undefined) {
try {
locale = decodeURIComponent(locale);
} catch {
// Keep raw cookie value when decoding fails.
}
}
return normalizeLocale(locale);
} }

View File

@ -33,48 +33,59 @@ export function groupMessages<T>(
if (messages.length === 0) { if (messages.length === 0) {
return []; return [];
} }
const groups: MessageGroup[] = []; const groups: MessageGroup[] = [];
for (const message of messages) { // Returns the last group if it can still accept tool messages
const lastGroup = groups[groups.length - 1]; // (i.e. it's an in-flight processing group, not a terminal human/assistant group).
if (message.type === "human") { function lastOpenGroup() {
groups.push({ const last = groups[groups.length - 1];
id: message.id,
type: "human",
messages: [message],
});
} else if (message.type === "tool") {
// Check if this is a clarification tool message
if (isClarificationToolMessage(message)) {
// Add to processing group if available (to maintain tool call association)
if ( if (
lastGroup && last &&
lastGroup.type !== "human" && last.type !== "human" &&
lastGroup.type !== "assistant" && last.type !== "assistant" &&
lastGroup.type !== "assistant:clarification" last.type !== "assistant:clarification"
) { ) {
lastGroup.messages.push(message); return last;
} }
// Also create a separate clarification group for prominent display return null;
}
for (const message of messages) {
if (message.name === "todo_reminder") {
continue;
}
if (message.type === "human") {
groups.push({ id: message.id, type: "human", messages: [message] });
continue;
}
if (message.type === "tool") {
if (isClarificationToolMessage(message)) {
// Add to the preceding processing group to preserve tool-call association,
// then also open a standalone clarification group for prominent display.
lastOpenGroup()?.messages.push(message);
groups.push({ groups.push({
id: message.id, id: message.id,
type: "assistant:clarification", type: "assistant:clarification",
messages: [message], messages: [message],
}); });
} else if (
lastGroup &&
lastGroup.type !== "human" &&
lastGroup.type !== "assistant" &&
lastGroup.type !== "assistant:clarification"
) {
lastGroup.messages.push(message);
} else { } else {
throw new Error( const open = lastOpenGroup();
"Tool message must be matched with a previous assistant message with tool calls", if (open) {
open.messages.push(message);
} else {
console.error(
"Unexpected tool message outside a processing group",
message,
); );
} }
} else if (message.type === "ai") { }
if (hasReasoning(message) || hasToolCalls(message)) { continue;
}
if (message.type === "ai") {
if (hasPresentFiles(message)) { if (hasPresentFiles(message)) {
groups.push({ groups.push({
id: message.id, id: message.id,
@ -87,42 +98,31 @@ export function groupMessages<T>(
type: "assistant:subagent", type: "assistant:subagent",
messages: [message], messages: [message],
}); });
} else { } else if (hasReasoning(message) || hasToolCalls(message)) {
const lastGroup = groups[groups.length - 1];
// Accumulate consecutive intermediate AI messages into one processing group.
if (lastGroup?.type !== "assistant:processing") { if (lastGroup?.type !== "assistant:processing") {
groups.push({ groups.push({
id: message.id, id: message.id,
type: "assistant:processing", type: "assistant:processing",
messages: [],
});
}
const currentGroup = groups[groups.length - 1];
if (currentGroup?.type === "assistant:processing") {
currentGroup.messages.push(message);
} else {
throw new Error(
"Assistant message with reasoning or tool calls must be preceded by a processing group",
);
}
}
}
if (hasContent(message) && !hasToolCalls(message)) {
groups.push({
id: message.id,
type: "assistant",
messages: [message], messages: [message],
}); });
} else {
lastGroup.messages.push(message);
}
}
// Not an else-if: a message with reasoning + content (but no tool calls) goes
// into the processing group above AND gets its own assistant bubble here.
if (hasContent(message) && !hasToolCalls(message)) {
groups.push({ id: message.id, type: "assistant", messages: [message] });
} }
} }
} }
const resultsOfGroups: T[] = []; return groups
for (const group of groups) { .map(mapper)
const resultOfGroup = mapper(group); .filter((result) => result !== undefined && result !== null) as T[];
if (resultOfGroup !== undefined && resultOfGroup !== null) {
resultsOfGroups.push(resultOfGroup);
}
}
return resultsOfGroups;
} }
export function extractTextFromMessage(message: Message) { export function extractTextFromMessage(message: Message) {
@ -162,12 +162,21 @@ export function extractContentFromMessage(message: Message) {
} }
export function extractReasoningContentFromMessage(message: Message) { export function extractReasoningContentFromMessage(message: Message) {
if (message.type !== "ai" || !message.additional_kwargs) { if (message.type !== "ai") {
return null; return null;
} }
if ("reasoning_content" in message.additional_kwargs) { if (
message.additional_kwargs &&
"reasoning_content" in message.additional_kwargs
) {
return message.additional_kwargs.reasoning_content as string | null; return message.additional_kwargs.reasoning_content as string | null;
} }
if (Array.isArray(message.content)) {
const part = message.content[0];
if (part && "thinking" in part) {
return part.thinking as string;
}
}
return null; return null;
} }
@ -202,10 +211,18 @@ export function hasContent(message: Message) {
} }
export function hasReasoning(message: Message) { export function hasReasoning(message: Message) {
return ( if (message.type !== "ai") {
message.type === "ai" && return false;
typeof message.additional_kwargs?.reasoning_content === "string" }
); if (typeof message.additional_kwargs?.reasoning_content === "string") {
return true;
}
if (Array.isArray(message.content)) {
const part = message.content[0];
// Compatible with the Anthropic gateway
return (part as unknown as { type: "thinking" })?.type === "thinking";
}
return false;
} }
export function hasToolCalls(message: Message) { export function hasToolCalls(message: Message) {
@ -263,57 +280,61 @@ export function findToolCallResult(toolCallId: string, messages: Message[]) {
} }
/** /**
* Represents an uploaded file parsed from the <uploaded_files> tag * Represents a file stored in message additional_kwargs.files.
* Used for optimistic UI (uploading state) and structured file metadata.
*/ */
export interface UploadedFile { export interface FileInMessage {
filename: string; filename: string;
size: string; size: number; // bytes
path: string; path?: string; // virtual path, may not be set during upload
status?: "uploading" | "uploaded";
} }
/** /**
* Result of parsing uploaded files from message content * Strip <uploaded_files> tag from message content.
* Returns the content with the tag removed.
*/ */
export interface ParsedUploadedFiles { export function stripUploadedFilesTag(content: string): string {
files: UploadedFile[]; return content
cleanContent: string; .replace(/<uploaded_files>[\s\S]*?<\/uploaded_files>/g, "")
.trim();
} }
/** export function parseUploadedFiles(content: string): FileInMessage[] {
* Parse <uploaded_files> tag from message content and extract file information.
* Returns the list of uploaded files and the content with the tag removed.
*/
export function parseUploadedFiles(content: string): ParsedUploadedFiles {
// Match <uploaded_files>...</uploaded_files> tag // Match <uploaded_files>...</uploaded_files> tag
const uploadedFilesRegex = /<uploaded_files>([\s\S]*?)<\/uploaded_files>/; const uploadedFilesRegex = /<uploaded_files>([\s\S]*?)<\/uploaded_files>/;
// eslint-disable-next-line @typescript-eslint/prefer-regexp-exec // eslint-disable-next-line @typescript-eslint/prefer-regexp-exec
const match = content.match(uploadedFilesRegex); const match = content.match(uploadedFilesRegex);
if (!match) { if (!match) {
return { files: [], cleanContent: content }; return [];
} }
const uploadedFilesContent = match[1]; const uploadedFilesContent = match[1];
const cleanContent = content.replace(uploadedFilesRegex, "").trim();
// Check if it's "No files have been uploaded yet." // Check if it's "No files have been uploaded yet."
if (uploadedFilesContent?.includes("No files have been uploaded yet.")) { if (uploadedFilesContent?.includes("No files have been uploaded yet.")) {
return { files: [], cleanContent }; return [];
}
// Check if the backend reported no new files were uploaded in this message
if (uploadedFilesContent?.includes("(empty)")) {
return [];
} }
// Parse file list // Parse file list
// Format: - filename (size)\n Path: /path/to/file // Format: - filename (size)\n Path: /path/to/file
const fileRegex = /- ([^\n(]+)\s*\(([^)]+)\)\s*\n\s*Path:\s*([^\n]+)/g; const fileRegex = /- ([^\n(]+)\s*\(([^)]+)\)\s*\n\s*Path:\s*([^\n]+)/g;
const files: UploadedFile[] = []; const files: FileInMessage[] = [];
let fileMatch; let fileMatch;
while ((fileMatch = fileRegex.exec(uploadedFilesContent ?? "")) !== null) { while ((fileMatch = fileRegex.exec(uploadedFilesContent ?? "")) !== null) {
files.push({ files.push({
filename: fileMatch[1].trim(), filename: fileMatch[1].trim(),
size: fileMatch[2].trim(), size: parseInt(fileMatch[2].trim(), 10) ?? 0,
path: fileMatch[3].trim(), path: fileMatch[3].trim(),
}); });
} }
return { files, cleanContent }; return files;
} }

View File

@ -3,7 +3,7 @@ import { getBackendBaseURL } from "../config";
import type { Model } from "./types"; import type { Model } from "./types";
export async function loadModels() { export async function loadModels() {
const res = fetch(`${getBackendBaseURL()}/api/models`); const res = await fetch(`${getBackendBaseURL()}/api/models`);
const { models } = (await (await res).json()) as { models: Model[] }; const { models } = (await res.json()) as { models: Model[] };
return models; return models;
} }

View File

@ -7,6 +7,7 @@ export function useModels({ enabled = true }: { enabled?: boolean } = {}) {
queryKey: ["models"], queryKey: ["models"],
queryFn: () => loadModels(), queryFn: () => loadModels(),
enabled, enabled,
refetchOnWindowFocus: false,
}); });
return { models: data ?? [], isLoading, error }; return { models: data ?? [], isLoading, error };
} }

View File

@ -4,4 +4,5 @@ export interface Model {
display_name: string; display_name: string;
description?: string | null; description?: string | null;
supports_thinking?: boolean; supports_thinking?: boolean;
supports_reasoning_effort?: boolean;
} }

View File

@ -1,5 +1,4 @@
import { useCallback, useState } from "react"; import { useCallback, useLayoutEffect, useState } from "react";
import { useEffect } from "react";
import { import {
DEFAULT_LOCAL_SETTINGS, DEFAULT_LOCAL_SETTINGS,
@ -17,7 +16,7 @@ export function useLocalSettings(): [
] { ] {
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const [state, setState] = useState<LocalSettings>(DEFAULT_LOCAL_SETTINGS); const [state, setState] = useState<LocalSettings>(DEFAULT_LOCAL_SETTINGS);
useEffect(() => { useLayoutEffect(() => {
if (!mounted) { if (!mounted) {
setState(getLocalSettings()); setState(getLocalSettings());
} }
@ -28,6 +27,7 @@ export function useLocalSettings(): [
key: keyof LocalSettings, key: keyof LocalSettings,
value: Partial<LocalSettings[keyof LocalSettings]>, value: Partial<LocalSettings[keyof LocalSettings]>,
) => { ) => {
if (!mounted) return;
setState((prev) => { setState((prev) => {
const newState = { const newState = {
...prev, ...prev,
@ -40,7 +40,7 @@ export function useLocalSettings(): [
return newState; return newState;
}); });
}, },
[], [mounted],
); );
return [state, setter]; return [state, setter];
} }

View File

@ -7,6 +7,7 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
context: { context: {
model_name: undefined, model_name: undefined,
mode: undefined, mode: undefined,
reasoning_effort: undefined,
}, },
layout: { layout: {
sidebar_collapsed: false, sidebar_collapsed: false,
@ -24,6 +25,7 @@ export interface LocalSettings {
"thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled" "thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled"
> & { > & {
mode: "flash" | "thinking" | "pro" | "ultra" | undefined; mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
}; };
layout: { layout: {
sidebar_collapsed: boolean; sidebar_collapsed: boolean;

View File

@ -35,38 +35,6 @@ export interface InstallSkillResponse {
message: string; message: string;
} }
export interface MaterializeSkillYamlRequest {
thread_id: string;
path: string;
target_dir?: string;
clear_target?: boolean;
}
export interface MaterializeSkillYamlResponse {
success: boolean;
target_dir: string;
created_directories: number;
created_files: number;
message: string;
}
export interface BootstrapRemoteSkillRequest {
thread_id: string;
content_id: number;
language_type?: number;
target_dir?: string;
clear_target?: boolean;
}
export interface BootstrapRemoteSkillResponse {
success: boolean;
target_dir: string;
created_directories: number;
created_files: number;
sandbox_id: string;
message: string;
}
export async function installSkill( export async function installSkill(
request: InstallSkillRequest, request: InstallSkillRequest,
): Promise<InstallSkillResponse> { ): Promise<InstallSkillResponse> {
@ -92,51 +60,3 @@ export async function installSkill(
return response.json(); return response.json();
} }
export async function materializeSkillYaml(
request: MaterializeSkillYamlRequest,
): Promise<MaterializeSkillYamlResponse> {
const response = await fetch(
`${getBackendBaseURL()}/api/skills/materialize-yaml`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(request),
},
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
const errorMessage =
errorData.detail ?? `HTTP ${response.status}: ${response.statusText}`;
throw new Error(errorMessage);
}
return response.json();
}
export async function bootstrapRemoteSkill(
request: BootstrapRemoteSkillRequest,
): Promise<BootstrapRemoteSkillResponse> {
const response = await fetch(
`${getBackendBaseURL()}/api/skills/bootstrap-remote`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(request),
},
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
const errorMessage =
errorData.detail ?? `HTTP ${response.status}: ${response.statusText}`;
throw new Error(errorMessage);
}
return response.json();
}

View File

@ -1,44 +1,139 @@
import type { HumanMessage } from "@langchain/core/messages"; import type { AIMessage, Message } from "@langchain/langgraph-sdk";
import type { AIMessage } from "@langchain/langgraph-sdk";
import type { ThreadsClient } from "@langchain/langgraph-sdk/client"; import type { ThreadsClient } from "@langchain/langgraph-sdk/client";
import { useStream, type UseStream } from "@langchain/langgraph-sdk/react"; import { useStream } from "@langchain/langgraph-sdk/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input"; import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { getAPIClient } from "../api"; import { getAPIClient } from "../api";
import { useI18n } from "../i18n/hooks";
import type { FileInMessage } from "../messages/utils";
import type { LocalSettings } from "../settings";
import { useUpdateSubtask } from "../tasks/context"; import { useUpdateSubtask } from "../tasks/context";
import type { UploadedFileInfo } from "../uploads";
import { uploadFiles } from "../uploads"; import { uploadFiles } from "../uploads";
import type { UploadTarget } from "../uploads/api";
import type { import type { AgentThread, AgentThreadState } from "./types";
AgentThread,
AgentThreadContext, export type ToolEndEvent = {
AgentThreadState, name: string;
} from "./types"; data: unknown;
};
export type ThreadStreamOptions = {
threadId?: string | null | undefined;
context: LocalSettings["context"];
isMock?: boolean;
onStart?: (threadId: string) => void;
onFinish?: (state: AgentThreadState) => void;
onToolEnd?: (event: ToolEndEvent) => void;
};
export function useThreadStream({ export function useThreadStream({
threadId, threadId,
isNewThread, context,
fetchStateHistory = true, isMock,
onStart,
onFinish, onFinish,
}: { onToolEnd,
isNewThread: boolean; }: ThreadStreamOptions) {
threadId: string | null | undefined; const { t } = useI18n();
fetchStateHistory?: boolean; // Track the thread ID that is currently streaming to handle thread changes during streaming
onFinish?: (state: AgentThreadState) => void; const [onStreamThreadId, setOnStreamThreadId] = useState(() => threadId);
}) { // Ref to track current thread ID across async callbacks without causing re-renders,
// and to allow access to the current thread id in onUpdateEvent
const threadIdRef = useRef<string | null>(threadId ?? null);
const startedRef = useRef(false);
const listeners = useRef({
onStart,
onFinish,
onToolEnd,
});
// Keep listeners ref updated with latest callbacks
useEffect(() => {
listeners.current = { onStart, onFinish, onToolEnd };
}, [onStart, onFinish, onToolEnd]);
useEffect(() => {
const normalizedThreadId = threadId ?? null;
if (!normalizedThreadId) {
// Just reset for new thread creation when threadId becomes null/undefined
startedRef.current = false;
setOnStreamThreadId(normalizedThreadId);
}
threadIdRef.current = normalizedThreadId;
}, [threadId]);
const _handleOnStart = useCallback((id: string) => {
if (!startedRef.current) {
listeners.current.onStart?.(id);
startedRef.current = true;
}
}, []);
const handleStreamStart = useCallback(
(_threadId: string) => {
threadIdRef.current = _threadId;
_handleOnStart(_threadId);
},
[_handleOnStart],
);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const updateSubtask = useUpdateSubtask(); const updateSubtask = useUpdateSubtask();
const thread = useStream<AgentThreadState>({ const thread = useStream<AgentThreadState>({
client: getAPIClient(), client: getAPIClient(isMock),
assistantId: "lead_agent", assistantId: "lead_agent",
threadId: isNewThread ? undefined : threadId, threadId: onStreamThreadId,
reconnectOnMount: true, reconnectOnMount: true,
fetchStateHistory, fetchStateHistory: { limit: 1 },
onCreated(meta) {
handleStreamStart(meta.thread_id);
setOnStreamThreadId(meta.thread_id);
},
onLangChainEvent(event) {
if (event.event === "on_tool_end") {
listeners.current.onToolEnd?.({
name: event.name,
data: event.data,
});
}
},
onUpdateEvent(data) {
const updates: Array<Partial<AgentThreadState> | null> = Object.values(
data || {},
);
for (const update of updates) {
if (update && "title" in update && update.title) {
void queryClient.setQueriesData(
{
queryKey: ["threads", "search"],
exact: false,
},
(oldData: Array<AgentThread> | undefined) => {
return oldData?.map((t) => {
if (t.thread_id === threadIdRef.current) {
return {
...t,
values: {
...t.values,
title: update.title,
},
};
}
return t;
});
},
);
}
}
},
onCustomEvent(event: unknown) { onCustomEvent(event: unknown) {
console.info(event);
if ( if (
typeof event === "object" && typeof event === "object" &&
event !== null && event !== null &&
@ -54,72 +149,72 @@ export function useThreadStream({
} }
}, },
onFinish(state) { onFinish(state) {
onFinish?.(state.values); listeners.current.onFinish?.(state.values);
// void queryClient.invalidateQueries({ queryKey: ["threads", "search"] }); void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
queryClient.setQueriesData(
{
queryKey: ["threads", "search"],
exact: false,
},
(oldData: Array<AgentThread>) => {
return oldData.map((t) => {
if (t.thread_id === threadId) {
return {
...t,
values: {
...t.values,
title: state.values.title,
},
};
}
return t;
});
},
);
}, },
}); });
return thread;
}
export function useSubmitThread({ // Optimistic messages shown before the server stream responds
threadId, const [optimisticMessages, setOptimisticMessages] = useState<Message[]>([]);
thread, // Track message count before sending so we know when server has responded
threadContext, const prevMsgCountRef = useRef(thread.messages.length);
isNewThread,
createNewSession, // Clear optimistic when server messages arrive (count increases)
uploadTarget, useEffect(() => {
afterSubmit, if (
}: { optimisticMessages.length > 0 &&
isNewThread: boolean; thread.messages.length > prevMsgCountRef.current
createNewSession: boolean; ) {
threadId: string | null | undefined; setOptimisticMessages([]);
thread: UseStream<AgentThreadState>; }
threadContext: Omit<AgentThreadContext, "thread_id">; }, [thread.messages.length, optimisticMessages.length]);
uploadTarget?: UploadTarget;
afterSubmit?: () => void; const sendMessage = useCallback(
}) { async (
const queryClient = useQueryClient(); threadId: string,
const apiClient = getAPIClient(); message: PromptInputMessage,
const callback = useCallback( extraContext?: Record<string, unknown>,
async (message: PromptInputMessage) => { ) => {
const text = message.text.trim(); const text = message.text.trim();
// Guard: ignore empty submits (avoids unintended side effects during page init). // Capture current count before showing optimistic messages
const hasFiles = !!(message.files && message.files.length > 0); prevMsgCountRef.current = thread.messages.length;
if (!text && !hasFiles) {
return; // Build optimistic files list with uploading status
} const optimisticFiles: FileInMessage[] = (message.files ?? []).map(
(f) => ({
filename: f.filename ?? "",
size: 0,
status: "uploading" as const,
}),
);
// Create optimistic human message (shown immediately)
const optimisticHumanMsg: Message = {
type: "human",
id: `opt-human-${Date.now()}`,
content: text ? [{ type: "text", text }] : "",
additional_kwargs:
optimisticFiles.length > 0 ? { files: optimisticFiles } : {},
};
const newOptimistic: Message[] = [optimisticHumanMsg];
if (optimisticFiles.length > 0) {
// Mock AI message while files are being uploaded
newOptimistic.push({
type: "ai",
id: `opt-ai-${Date.now()}`,
content: t.uploads.uploadingFiles,
additional_kwargs: { element: "task" },
});
}
setOptimisticMessages(newOptimistic);
_handleOnStart(threadId);
let uploadedFileInfo: UploadedFileInfo[] = [];
// For "new session" semantics, ensure the target thread id starts fresh.
// If the same id already exists, delete it first and let submit recreate it.
if (createNewSession && threadId) {
try { try {
await apiClient.threads.delete(threadId);
} catch {
// Ignore delete errors (e.g. thread does not exist yet)
}
}
// Upload files first if any // Upload files first if any
if (message.files && message.files.length > 0) { if (message.files && message.files.length > 0) {
try { try {
@ -146,20 +241,71 @@ export function useSubmitThread({
return null; return null;
}); });
const files = (await Promise.all(filePromises)).filter( const conversionResults = await Promise.all(filePromises);
const files = conversionResults.filter(
(file): file is File => file !== null, (file): file is File => file !== null,
); );
const failedConversions = conversionResults.length - files.length;
if (files.length > 0 && threadId) { if (failedConversions > 0) {
await uploadFiles(threadId, files, { target: uploadTarget }); throw new Error(
`Failed to prepare ${failedConversions} attachment(s) for upload. Please retry.`,
);
}
if (!threadId) {
throw new Error("Thread is not ready for file upload.");
}
if (files.length > 0) {
const uploadResponse = await uploadFiles(threadId, files);
uploadedFileInfo = uploadResponse.files;
// Update optimistic human message with uploaded status + paths
const uploadedFiles: FileInMessage[] = uploadedFileInfo.map(
(info) => ({
filename: info.filename,
size: info.size,
path: info.virtual_path,
status: "uploaded" as const,
}),
);
setOptimisticMessages((messages) => {
if (messages.length > 1 && messages[0]) {
const humanMessage: Message = messages[0];
return [
{
...humanMessage,
additional_kwargs: { files: uploadedFiles },
},
...messages.slice(1),
];
}
return messages;
});
} }
} catch (error) { } catch (error) {
console.error("Failed to upload files:", error); console.error("Failed to upload files:", error);
// Continue with message submission even if upload fails const errorMessage =
// You might want to show an error toast here error instanceof Error
? error.message
: "Failed to upload files.";
toast.error(errorMessage);
setOptimisticMessages([]);
throw error;
} }
} }
// Build files metadata for submission (included in additional_kwargs)
const filesForSubmit: FileInMessage[] = uploadedFileInfo.map(
(info) => ({
filename: info.filename,
size: info.size,
path: info.virtual_path,
status: "uploaded" as const,
}),
);
await thread.submit( await thread.submit(
{ {
messages: [ messages: [
@ -171,39 +317,47 @@ export function useSubmitThread({
text, text,
}, },
], ],
additional_kwargs:
filesForSubmit.length > 0 ? { files: filesForSubmit } : {},
}, },
] as HumanMessage[], ],
}, },
{ {
threadId: createNewSession ? threadId! : undefined, threadId: threadId,
streamSubgraphs: true, streamSubgraphs: true,
streamResumable: true, streamResumable: true,
streamMode: ["values", "messages-tuple", "custom"],
config: { config: {
recursion_limit: 1000, recursion_limit: 1000,
}, },
context: { context: {
...threadContext, ...extraContext,
...context,
thinking_enabled: context.mode !== "flash",
is_plan_mode: context.mode === "pro" || context.mode === "ultra",
subagent_enabled: context.mode === "ultra",
thread_id: threadId, thread_id: threadId,
}, },
}, },
); );
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] }); void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
afterSubmit?.(); } catch (error) {
setOptimisticMessages([]);
throw error;
}
}, },
[ [thread, _handleOnStart, t.uploads.uploadingFiles, context, queryClient],
thread,
isNewThread,
createNewSession,
threadId,
threadContext,
uploadTarget,
queryClient,
apiClient,
afterSubmit,
],
); );
return callback;
// Merge thread with optimistic messages for display
const mergedThread =
optimisticMessages.length > 0
? ({
...thread,
messages: [...thread.messages, ...optimisticMessages],
} as typeof thread)
: thread;
return [mergedThread, sendMessage] as const;
} }
export function useThreads( export function useThreads(
@ -211,15 +365,64 @@ export function useThreads(
limit: 50, limit: 50,
sortBy: "updated_at", sortBy: "updated_at",
sortOrder: "desc", sortOrder: "desc",
select: ["thread_id", "updated_at", "values"],
}, },
) { ) {
const apiClient = getAPIClient(); const apiClient = getAPIClient();
return useQuery<AgentThread[]>({ return useQuery<AgentThread[]>({
queryKey: ["threads", "search", params], queryKey: ["threads", "search", params],
queryFn: async () => { queryFn: async () => {
const maxResults = params.limit;
const initialOffset = params.offset ?? 0;
const DEFAULT_PAGE_SIZE = 50;
// Preserve prior semantics: if a non-positive limit is explicitly provided,
// delegate to a single search call with the original parameters.
if (maxResults !== undefined && maxResults <= 0) {
const response = await apiClient.threads.search<AgentThreadState>(params); const response = await apiClient.threads.search<AgentThreadState>(params);
return response as AgentThread[]; return response as AgentThread[];
}
const pageSize =
typeof maxResults === "number" && maxResults > 0
? Math.min(DEFAULT_PAGE_SIZE, maxResults)
: DEFAULT_PAGE_SIZE;
const threads: AgentThread[] = [];
let offset = initialOffset;
while (true) {
if (typeof maxResults === "number" && threads.length >= maxResults) {
break;
}
const currentLimit =
typeof maxResults === "number"
? Math.min(pageSize, maxResults - threads.length)
: pageSize;
if (typeof maxResults === "number" && currentLimit <= 0) {
break;
}
const response = (await apiClient.threads.search<AgentThreadState>({
...params,
limit: currentLimit,
offset,
})) as AgentThread[];
threads.push(...response);
if (response.length < currentLimit) {
break;
}
offset += response.length;
}
return threads;
}, },
refetchOnWindowFocus: false,
}); });
} }

View File

@ -1,11 +1,10 @@
import { type BaseMessage } from "@langchain/core/messages"; import type { Message, Thread } from "@langchain/langgraph-sdk";
import type { Thread } from "@langchain/langgraph-sdk";
import type { Todo } from "../todos"; import type { Todo } from "../todos";
export interface AgentThreadState extends Record<string, unknown> { export interface AgentThreadState extends Record<string, unknown> {
title: string; title: string;
messages: BaseMessage[]; messages: Message[];
artifacts: string[]; artifacts: string[];
todos?: Todo[]; todos?: Todo[];
} }
@ -18,4 +17,6 @@ export interface AgentThreadContext extends Record<string, unknown> {
thinking_enabled: boolean; thinking_enabled: boolean;
is_plan_mode: boolean; is_plan_mode: boolean;
subagent_enabled: boolean; subagent_enabled: boolean;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
agent_name?: string;
} }

View File

@ -1,4 +1,4 @@
import type { BaseMessage } from "@langchain/core/messages"; import type { Message } from "@langchain/langgraph-sdk";
import type { AgentThread } from "./types"; import type { AgentThread } from "./types";
@ -6,19 +6,19 @@ export function pathOfThread(threadId: string) {
return `/workspace/chats/${threadId}`; return `/workspace/chats/${threadId}`;
} }
export function textOfMessage(message: BaseMessage) { export function textOfMessage(message: Message) {
if (typeof message.content === "string") { if (typeof message.content === "string") {
return message.content; return message.content;
} else if (Array.isArray(message.content)) { } else if (Array.isArray(message.content)) {
return message.content.find((part) => part.type === "text" && part.text) for (const part of message.content) {
?.text as string; if (part.type === "text") {
return part.text;
}
}
} }
return null; return null;
} }
export function titleOfThread(thread: AgentThread) { export function titleOfThread(thread: AgentThread) {
if (thread.values && "title" in thread.values) { return thread.values?.title ?? "Untitled";
return thread.values.title;
}
return "Untitled";
} }

View File

@ -29,15 +29,12 @@ export interface ListFilesResponse {
count: number; count: number;
} }
export type UploadTarget = "skill";
/** /**
* Upload files to a thread * Upload files to a thread
*/ */
export async function uploadFiles( export async function uploadFiles(
threadId: string, threadId: string,
files: File[], files: File[],
options?: { target?: UploadTarget },
): Promise<UploadResponse> { ): Promise<UploadResponse> {
const formData = new FormData(); const formData = new FormData();
@ -45,10 +42,6 @@ export async function uploadFiles(
formData.append("files", file); formData.append("files", file);
}); });
if (options?.target) {
formData.append("upload_target", options.target);
}
const response = await fetch( const response = await fetch(
`${getBackendBaseURL()}/api/threads/${threadId}/uploads`, `${getBackendBaseURL()}/api/threads/${threadId}/uploads`,
{ {