Update frontend from deer-flow and add frontend_backup to gitignore
This commit is contained in:
parent
ef9a071aa1
commit
dbef018fd1
|
|
@ -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/
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { AgentGallery } from "@/components/workspace/agents/agent-gallery";
|
||||||
|
|
||||||
|
export default function AgentsPage() {
|
||||||
|
return <AgentGallery />;
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export * from "./chat-box";
|
||||||
|
export * from "./use-chat-mode";
|
||||||
|
export * from "./use-thread-chat";
|
||||||
|
|
@ -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]);
|
||||||
|
}
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>(
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)) {
|
||||||
|
|
|
||||||
|
|
@ -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 />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 }>;
|
||||||
|
}
|
||||||
|
|
@ -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"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export * from "./api";
|
||||||
|
export * from "./hooks";
|
||||||
|
export * from "./types";
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
@ -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],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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" : ""}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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") {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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";
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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) =>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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) =>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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";
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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`,
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue