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
|
||||
web/
|
||||
|
||||
# ignore frontend backup
|
||||
frontend_backup/
|
||||
|
|
|
|||
|
|
@ -14,17 +14,17 @@ http {
|
|||
access_log /dev/stdout;
|
||||
error_log /dev/stderr;
|
||||
|
||||
# Upstream servers (using Docker service names for Docker Compose)
|
||||
# Upstream servers (using localhost for local development)
|
||||
upstream gateway {
|
||||
server gateway:8001;
|
||||
server localhost:8001;
|
||||
}
|
||||
|
||||
upstream langgraph {
|
||||
server langgraph:2024;
|
||||
server localhost:2024;
|
||||
}
|
||||
|
||||
upstream frontend {
|
||||
server frontend:3000;
|
||||
server localhost:3000;
|
||||
}
|
||||
|
||||
server {
|
||||
|
|
|
|||
|
|
@ -1,22 +1,35 @@
|
|||
# Frontend Development Dockerfile
|
||||
FROM node:22-alpine
|
||||
# Frontend Dockerfile
|
||||
# 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
|
||||
|
||||
# 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 pnpm config set store-dir ${PNPM_STORE_PATH}
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy frontend source code
|
||||
COPY frontend ./frontend
|
||||
|
||||
# Install dependencies
|
||||
RUN sh -c "cd /app/frontend && pnpm install --frozen-lockfile"
|
||||
|
||||
# Expose Next.js dev server port
|
||||
# ── Dev: install only, CMD is overridden by docker-compose ───────────────────
|
||||
FROM base AS dev
|
||||
RUN cd /app/frontend && pnpm install --frozen-lockfile
|
||||
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==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-arm@1.2.4':
|
||||
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-ppc64@1.2.4':
|
||||
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-riscv64@1.2.4':
|
||||
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-s390x@1.2.4':
|
||||
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-x64@1.2.4':
|
||||
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
|
||||
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
|
||||
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-linux-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-arm@0.34.5':
|
||||
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-ppc64@0.34.5':
|
||||
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-riscv64@0.34.5':
|
||||
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-s390x@0.34.5':
|
||||
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-x64@0.34.5':
|
||||
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linuxmusl-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-linuxmusl-x64@0.34.5':
|
||||
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-wasm32@0.34.5':
|
||||
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
|
||||
|
|
@ -956,28 +940,24 @@ packages:
|
|||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@next/swc-linux-arm64-musl@16.1.6':
|
||||
resolution: {integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@next/swc-linux-x64-gnu@16.1.6':
|
||||
resolution: {integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@next/swc-linux-x64-musl@16.1.6':
|
||||
resolution: {integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@next/swc-win32-arm64-msvc@16.1.6':
|
||||
resolution: {integrity: sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==}
|
||||
|
|
@ -1571,28 +1551,24 @@ packages:
|
|||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@resvg/resvg-js-linux-arm64-musl@2.6.2':
|
||||
resolution: {integrity: sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@resvg/resvg-js-linux-x64-gnu@2.6.2':
|
||||
resolution: {integrity: sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@resvg/resvg-js-linux-x64-musl@2.6.2':
|
||||
resolution: {integrity: sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@resvg/resvg-js-win32-arm64-msvc@2.6.2':
|
||||
resolution: {integrity: sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==}
|
||||
|
|
@ -1620,141 +1596,128 @@ packages:
|
|||
resolution: {integrity: sha512-FqALmHI8D4o6lk/LRWDnhw95z5eO+eAa6ORjVg09YRR7BkcM6oPHU9uyC0gtQG5vpFLvgpeU4+zEAz2H8APHNw==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.57.1':
|
||||
resolution: {integrity: sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==}
|
||||
'@rollup/rollup-android-arm-eabi@4.59.0':
|
||||
resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@rollup/rollup-android-arm64@4.57.1':
|
||||
resolution: {integrity: sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==}
|
||||
'@rollup/rollup-android-arm64@4.59.0':
|
||||
resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@rollup/rollup-darwin-arm64@4.57.1':
|
||||
resolution: {integrity: sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==}
|
||||
'@rollup/rollup-darwin-arm64@4.59.0':
|
||||
resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@rollup/rollup-darwin-x64@4.57.1':
|
||||
resolution: {integrity: sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==}
|
||||
'@rollup/rollup-darwin-x64@4.59.0':
|
||||
resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@rollup/rollup-freebsd-arm64@4.57.1':
|
||||
resolution: {integrity: sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==}
|
||||
'@rollup/rollup-freebsd-arm64@4.59.0':
|
||||
resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@rollup/rollup-freebsd-x64@4.57.1':
|
||||
resolution: {integrity: sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==}
|
||||
'@rollup/rollup-freebsd-x64@4.59.0':
|
||||
resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.57.1':
|
||||
resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==}
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.59.0':
|
||||
resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.57.1':
|
||||
resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==}
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.59.0':
|
||||
resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.57.1':
|
||||
resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==}
|
||||
'@rollup/rollup-linux-arm64-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.57.1':
|
||||
resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==}
|
||||
'@rollup/rollup-linux-arm64-musl@4.59.0':
|
||||
resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.57.1':
|
||||
resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==}
|
||||
'@rollup/rollup-linux-loong64-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-loong64-musl@4.57.1':
|
||||
resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==}
|
||||
'@rollup/rollup-linux-loong64-musl@4.59.0':
|
||||
resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.57.1':
|
||||
resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==}
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-musl@4.57.1':
|
||||
resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==}
|
||||
'@rollup/rollup-linux-ppc64-musl@4.59.0':
|
||||
resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.57.1':
|
||||
resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==}
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.57.1':
|
||||
resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==}
|
||||
'@rollup/rollup-linux-riscv64-musl@4.59.0':
|
||||
resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.57.1':
|
||||
resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==}
|
||||
'@rollup/rollup-linux-s390x-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.57.1':
|
||||
resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==}
|
||||
'@rollup/rollup-linux-x64-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.57.1':
|
||||
resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==}
|
||||
'@rollup/rollup-linux-x64-musl@4.59.0':
|
||||
resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-openbsd-x64@4.57.1':
|
||||
resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==}
|
||||
'@rollup/rollup-openbsd-x64@4.59.0':
|
||||
resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@rollup/rollup-openharmony-arm64@4.57.1':
|
||||
resolution: {integrity: sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==}
|
||||
'@rollup/rollup-openharmony-arm64@4.59.0':
|
||||
resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@rollup/rollup-win32-arm64-msvc@4.57.1':
|
||||
resolution: {integrity: sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==}
|
||||
'@rollup/rollup-win32-arm64-msvc@4.59.0':
|
||||
resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@rollup/rollup-win32-ia32-msvc@4.57.1':
|
||||
resolution: {integrity: sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==}
|
||||
'@rollup/rollup-win32-ia32-msvc@4.59.0':
|
||||
resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@rollup/rollup-win32-x64-gnu@4.57.1':
|
||||
resolution: {integrity: sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==}
|
||||
'@rollup/rollup-win32-x64-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@rollup/rollup-win32-x64-msvc@4.57.1':
|
||||
resolution: {integrity: sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==}
|
||||
'@rollup/rollup-win32-x64-msvc@4.59.0':
|
||||
resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
|
|
@ -1869,28 +1832,24 @@ packages:
|
|||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tailwindcss/oxide-linux-arm64-musl@4.1.18':
|
||||
resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-gnu@4.1.18':
|
||||
resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-musl@4.1.18':
|
||||
resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tailwindcss/oxide-wasm32-wasi@4.1.18':
|
||||
resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==}
|
||||
|
|
@ -2253,49 +2212,41 @@ packages:
|
|||
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
|
||||
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
|
||||
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
|
||||
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
|
||||
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
|
||||
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
|
||||
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
|
||||
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
|
||||
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
|
||||
|
|
@ -3783,28 +3734,24 @@ packages:
|
|||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-arm64-musl@1.30.2:
|
||||
resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-linux-x64-gnu@1.30.2:
|
||||
resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-x64-musl@1.30.2:
|
||||
resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-win32-arm64-msvc@1.30.2:
|
||||
resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==}
|
||||
|
|
@ -3845,8 +3792,8 @@ packages:
|
|||
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
|
||||
hasBin: true
|
||||
|
||||
lru-cache@11.2.5:
|
||||
resolution: {integrity: sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==}
|
||||
lru-cache@11.2.6:
|
||||
resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
lucide-react@0.542.0:
|
||||
|
|
@ -4561,8 +4508,8 @@ packages:
|
|||
robust-predicates@3.0.2:
|
||||
resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==}
|
||||
|
||||
rollup@4.57.1:
|
||||
resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==}
|
||||
rollup@4.59.0:
|
||||
resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==}
|
||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||
hasBin: true
|
||||
|
||||
|
|
@ -6602,79 +6549,79 @@ snapshots:
|
|||
|
||||
'@resvg/resvg-wasm@2.6.2': {}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.57.1':
|
||||
'@rollup/rollup-android-arm-eabi@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-android-arm64@4.57.1':
|
||||
'@rollup/rollup-android-arm64@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-darwin-arm64@4.57.1':
|
||||
'@rollup/rollup-darwin-arm64@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-darwin-x64@4.57.1':
|
||||
'@rollup/rollup-darwin-x64@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-freebsd-arm64@4.57.1':
|
||||
'@rollup/rollup-freebsd-arm64@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-freebsd-x64@4.57.1':
|
||||
'@rollup/rollup-freebsd-x64@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.57.1':
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.57.1':
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.57.1':
|
||||
'@rollup/rollup-linux-arm64-gnu@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.57.1':
|
||||
'@rollup/rollup-linux-arm64-musl@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.57.1':
|
||||
'@rollup/rollup-linux-loong64-gnu@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-loong64-musl@4.57.1':
|
||||
'@rollup/rollup-linux-loong64-musl@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.57.1':
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-ppc64-musl@4.57.1':
|
||||
'@rollup/rollup-linux-ppc64-musl@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.57.1':
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.57.1':
|
||||
'@rollup/rollup-linux-riscv64-musl@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.57.1':
|
||||
'@rollup/rollup-linux-s390x-gnu@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.57.1':
|
||||
'@rollup/rollup-linux-x64-gnu@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.57.1':
|
||||
'@rollup/rollup-linux-x64-musl@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-openbsd-x64@4.57.1':
|
||||
'@rollup/rollup-openbsd-x64@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-openharmony-arm64@4.57.1':
|
||||
'@rollup/rollup-openharmony-arm64@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-arm64-msvc@4.57.1':
|
||||
'@rollup/rollup-win32-arm64-msvc@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-ia32-msvc@4.57.1':
|
||||
'@rollup/rollup-win32-ia32-msvc@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-x64-gnu@4.57.1':
|
||||
'@rollup/rollup-win32-x64-gnu@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-x64-msvc@4.57.1':
|
||||
'@rollup/rollup-win32-x64-msvc@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rtsao/scc@1.1.0': {}
|
||||
|
|
@ -9008,7 +8955,7 @@ snapshots:
|
|||
dependencies:
|
||||
js-tokens: 4.0.0
|
||||
|
||||
lru-cache@11.2.5: {}
|
||||
lru-cache@11.2.6: {}
|
||||
|
||||
lucide-react@0.542.0(react@19.2.4):
|
||||
dependencies:
|
||||
|
|
@ -10002,35 +9949,35 @@ snapshots:
|
|||
|
||||
robust-predicates@3.0.2: {}
|
||||
|
||||
rollup@4.57.1:
|
||||
rollup@4.59.0:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
optionalDependencies:
|
||||
'@rollup/rollup-android-arm-eabi': 4.57.1
|
||||
'@rollup/rollup-android-arm64': 4.57.1
|
||||
'@rollup/rollup-darwin-arm64': 4.57.1
|
||||
'@rollup/rollup-darwin-x64': 4.57.1
|
||||
'@rollup/rollup-freebsd-arm64': 4.57.1
|
||||
'@rollup/rollup-freebsd-x64': 4.57.1
|
||||
'@rollup/rollup-linux-arm-gnueabihf': 4.57.1
|
||||
'@rollup/rollup-linux-arm-musleabihf': 4.57.1
|
||||
'@rollup/rollup-linux-arm64-gnu': 4.57.1
|
||||
'@rollup/rollup-linux-arm64-musl': 4.57.1
|
||||
'@rollup/rollup-linux-loong64-gnu': 4.57.1
|
||||
'@rollup/rollup-linux-loong64-musl': 4.57.1
|
||||
'@rollup/rollup-linux-ppc64-gnu': 4.57.1
|
||||
'@rollup/rollup-linux-ppc64-musl': 4.57.1
|
||||
'@rollup/rollup-linux-riscv64-gnu': 4.57.1
|
||||
'@rollup/rollup-linux-riscv64-musl': 4.57.1
|
||||
'@rollup/rollup-linux-s390x-gnu': 4.57.1
|
||||
'@rollup/rollup-linux-x64-gnu': 4.57.1
|
||||
'@rollup/rollup-linux-x64-musl': 4.57.1
|
||||
'@rollup/rollup-openbsd-x64': 4.57.1
|
||||
'@rollup/rollup-openharmony-arm64': 4.57.1
|
||||
'@rollup/rollup-win32-arm64-msvc': 4.57.1
|
||||
'@rollup/rollup-win32-ia32-msvc': 4.57.1
|
||||
'@rollup/rollup-win32-x64-gnu': 4.57.1
|
||||
'@rollup/rollup-win32-x64-msvc': 4.57.1
|
||||
'@rollup/rollup-android-arm-eabi': 4.59.0
|
||||
'@rollup/rollup-android-arm64': 4.59.0
|
||||
'@rollup/rollup-darwin-arm64': 4.59.0
|
||||
'@rollup/rollup-darwin-x64': 4.59.0
|
||||
'@rollup/rollup-freebsd-arm64': 4.59.0
|
||||
'@rollup/rollup-freebsd-x64': 4.59.0
|
||||
'@rollup/rollup-linux-arm-gnueabihf': 4.59.0
|
||||
'@rollup/rollup-linux-arm-musleabihf': 4.59.0
|
||||
'@rollup/rollup-linux-arm64-gnu': 4.59.0
|
||||
'@rollup/rollup-linux-arm64-musl': 4.59.0
|
||||
'@rollup/rollup-linux-loong64-gnu': 4.59.0
|
||||
'@rollup/rollup-linux-loong64-musl': 4.59.0
|
||||
'@rollup/rollup-linux-ppc64-gnu': 4.59.0
|
||||
'@rollup/rollup-linux-ppc64-musl': 4.59.0
|
||||
'@rollup/rollup-linux-riscv64-gnu': 4.59.0
|
||||
'@rollup/rollup-linux-riscv64-musl': 4.59.0
|
||||
'@rollup/rollup-linux-s390x-gnu': 4.59.0
|
||||
'@rollup/rollup-linux-x64-gnu': 4.59.0
|
||||
'@rollup/rollup-linux-x64-musl': 4.59.0
|
||||
'@rollup/rollup-openbsd-x64': 4.59.0
|
||||
'@rollup/rollup-openharmony-arm64': 4.59.0
|
||||
'@rollup/rollup-win32-arm64-msvc': 4.59.0
|
||||
'@rollup/rollup-win32-ia32-msvc': 4.59.0
|
||||
'@rollup/rollup-win32-x64-gnu': 4.59.0
|
||||
'@rollup/rollup-win32-x64-msvc': 4.59.0
|
||||
fsevents: 2.3.3
|
||||
|
||||
rou3@0.7.12: {}
|
||||
|
|
@ -10553,7 +10500,7 @@ snapshots:
|
|||
chokidar: 5.0.0
|
||||
destr: 2.0.5
|
||||
h3: 1.15.5
|
||||
lru-cache: 11.2.5
|
||||
lru-cache: 11.2.6
|
||||
node-fetch-native: 1.6.7
|
||||
ofetch: 1.5.1
|
||||
ufo: 1.6.3
|
||||
|
|
@ -10629,7 +10576,7 @@ snapshots:
|
|||
fdir: 6.5.0(picomatch@4.0.3)
|
||||
picomatch: 4.0.3
|
||||
postcss: 8.5.6
|
||||
rollup: 4.57.1
|
||||
rollup: 4.59.0
|
||||
tinyglobby: 0.2.15
|
||||
optionalDependencies:
|
||||
'@types/node': 20.19.33
|
||||
|
|
|
|||
|
|
@ -1,27 +1,84 @@
|
|||
import fs from "fs";
|
||||
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(
|
||||
path.resolve(process.cwd(), "public/demo/threads"),
|
||||
{
|
||||
withFileTypes: true,
|
||||
},
|
||||
);
|
||||
|
||||
const threadData = threadsDir
|
||||
.map((threadId) => {
|
||||
.map<MockThreadSearchResult | null>((threadId) => {
|
||||
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`),
|
||||
"utf8",
|
||||
);
|
||||
),
|
||||
) as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
...threadData,
|
||||
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);
|
||||
return Response.json(threadData);
|
||||
.filter((thread): thread is MockThreadSearchResult => thread !== null)
|
||||
.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";
|
||||
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
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 { useCallback } from "react";
|
||||
|
||||
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
|
||||
import { usePromptInputController } from "@/components/ai-elements/prompt-input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||
import { ArtifactTrigger } from "@/components/workspace/artifacts";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "@/components/ui/resizable";
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import {
|
||||
ArtifactFileDetail,
|
||||
ArtifactFileList,
|
||||
useArtifacts,
|
||||
} from "@/components/workspace/artifacts";
|
||||
ChatBox,
|
||||
useSpecificChatMode,
|
||||
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 { Welcome } from "@/components/workspace/welcome";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useNotification } from "@/core/notification/hooks";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { bootstrapRemoteSkill } from "@/core/skills";
|
||||
import { type AgentThread, type AgentThreadState } from "@/core/threads";
|
||||
import { useSubmitThread, useThreadStream } from "@/core/threads/hooks";
|
||||
import {
|
||||
pathOfThread,
|
||||
textOfMessage,
|
||||
titleOfThread,
|
||||
} from "@/core/threads/utils";
|
||||
import { uuid } from "@/core/utils/uuid";
|
||||
import { useThreadStream } from "@/core/threads/hooks";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default function ChatPage() {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
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.
|
||||
// - isnew=false: reuse existing thread
|
||||
// - 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 { threadId, isNewThread, setIsNewThread, isMock } = useThreadChat();
|
||||
useSpecificChatMode();
|
||||
|
||||
const { showNotification } = useNotification();
|
||||
const [isSkillBootstrapping, setIsSkillBootstrapping] = useState(false);
|
||||
const [skillBootstrapError, setSkillBootstrapError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const skillBootstrappedKeyRef = useRef<string | null>(null);
|
||||
const [finalState, setFinalState] = useState<AgentThreadState | null>(null);
|
||||
const thread = useThreadStream({
|
||||
// Keep UI in new-page mode, but runtime may reuse existing thread
|
||||
isNewThread: reuseExistingThread ? false : isNewThread,
|
||||
threadId,
|
||||
fetchStateHistory: true,
|
||||
|
||||
const [thread, sendMessage] = useThreadStream({
|
||||
threadId: isNewThread ? undefined : threadId,
|
||||
context: settings.context,
|
||||
isMock,
|
||||
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/chats/${threadId}`);
|
||||
},
|
||||
onFinish: (state) => {
|
||||
setFinalState(state);
|
||||
if (document.hidden || !document.hasFocus()) {
|
||||
let body = "Conversation finished";
|
||||
const lastMessage = state.messages[state.messages.length - 1];
|
||||
const lastMessage = state.messages.at(-1);
|
||||
if (lastMessage) {
|
||||
const textContent = textOfMessage(lastMessage);
|
||||
if (textContent) {
|
||||
if (textContent.length > 200) {
|
||||
body = textContent.substring(0, 200) + "...";
|
||||
} else {
|
||||
body = textContent;
|
||||
body =
|
||||
textContent.length > 200
|
||||
? textContent.substring(0, 200) + "..."
|
||||
: 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(
|
||||
(message: Parameters<typeof submitThread>[0]) => {
|
||||
if (isSkillBootstrapping) {
|
||||
return;
|
||||
}
|
||||
setHasSubmitted(true);
|
||||
void submitThread(message);
|
||||
(message: PromptInputMessage) => {
|
||||
void sendMessage(threadId, message);
|
||||
},
|
||||
[isSkillBootstrapping, submitThread],
|
||||
[sendMessage, threadId],
|
||||
);
|
||||
const handleStop = useCallback(async () => {
|
||||
await thread.stop();
|
||||
}, [thread]);
|
||||
|
||||
if (!threadId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ThreadContext.Provider value={{ threadId, thread }}>
|
||||
<ResizablePanelGroup orientation="horizontal">
|
||||
<ResizablePanel
|
||||
className="relative"
|
||||
defaultSize={artifactPanelOpen ? 46 : 100}
|
||||
minSize={artifactPanelOpen ? 30 : 100}
|
||||
>
|
||||
<ThreadContext.Provider value={{ thread, isMock }}>
|
||||
<ChatBox threadId={threadId}>
|
||||
<div className="relative flex size-full min-h-0 justify-between">
|
||||
<header
|
||||
className={cn(
|
||||
|
|
@ -338,26 +82,10 @@ export default function ChatPage() {
|
|||
)}
|
||||
>
|
||||
<div className="flex w-full items-center text-sm font-medium">
|
||||
{title !== "Untitled" && (
|
||||
<ThreadTitle threadId={threadId} threadTitle={title} />
|
||||
)}
|
||||
<ThreadTitle threadId={threadId} thread={thread} />
|
||||
</div>
|
||||
<div>
|
||||
{artifacts?.length > 0 && !artifactsOpen && (
|
||||
<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>
|
||||
)}
|
||||
<ArtifactTrigger />
|
||||
</div>
|
||||
</header>
|
||||
<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")}
|
||||
threadId={threadId}
|
||||
thread={thread}
|
||||
suppressThreadLoading={suppressExistingThreadPrefetchUi}
|
||||
messagesOverride={
|
||||
suppressExistingThreadPrefetchUi
|
||||
? []
|
||||
: !thread.isLoading && finalState?.messages
|
||||
? (finalState.messages as Message[])
|
||||
: undefined
|
||||
}
|
||||
paddingBottom={todoListCollapsed ? 160 : 280}
|
||||
/>
|
||||
</div>
|
||||
<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
|
||||
className="bg-background/5"
|
||||
todos={thread.values.todos ?? []}
|
||||
collapsed={todoListCollapsed}
|
||||
hidden={
|
||||
!thread.values.todos ||
|
||||
thread.values.todos.length === 0
|
||||
}
|
||||
onToggle={() =>
|
||||
setTodoListCollapsed(!todoListCollapsed)
|
||||
!thread.values.todos || thread.values.todos.length === 0
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -406,35 +120,18 @@ export default function ChatPage() {
|
|||
<InputBox
|
||||
className={cn("bg-background/5 w-full -translate-y-4")}
|
||||
isNewThread={isNewThread}
|
||||
threadId={threadId}
|
||||
autoFocus={isNewThread}
|
||||
status={
|
||||
suppressExistingThreadPrefetchUi
|
||||
? "ready"
|
||||
: thread.isLoading
|
||||
? "streaming"
|
||||
: "ready"
|
||||
}
|
||||
status={thread.isLoading ? "streaming" : "ready"}
|
||||
context={settings.context}
|
||||
extraHeader={
|
||||
isNewThread && <Welcome mode={settings.context.mode} />
|
||||
}
|
||||
disabled={
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
|
||||
isSkillBootstrapping
|
||||
}
|
||||
onContextChange={(context) =>
|
||||
setSettings("context", context)
|
||||
}
|
||||
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
|
||||
onContextChange={(context) => setSettings("context", context)}
|
||||
onSubmit={handleSubmit}
|
||||
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" && (
|
||||
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
|
||||
{t.common.notAvailableInDemoMode}
|
||||
|
|
@ -444,72 +141,7 @@ export default function ChatPage() {
|
|||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</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",
|
||||
)}
|
||||
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>
|
||||
</ChatBox>
|
||||
</ThreadContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
"use client";
|
||||
|
||||
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 { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { getLocalSettings, useLocalSettings } from "@/core/settings";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
|
|
@ -14,7 +14,11 @@ export default function WorkspaceLayout({
|
|||
children,
|
||||
}: Readonly<{ children: React.ReactNode }>) {
|
||||
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(() => {
|
||||
setOpen(!settings.layout.sidebar_collapsed);
|
||||
}, [settings.layout.sidebar_collapsed]);
|
||||
|
|
|
|||
|
|
@ -44,7 +44,8 @@ export const MessageContent = ({
|
|||
}: MessageContentProps) => (
|
||||
<div
|
||||
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-assistant]:text-foreground",
|
||||
className,
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ export function CaseStudySection({ className }: { className?: string }) {
|
|||
{caseStudies.map((caseStudy) => (
|
||||
<Link
|
||||
key={caseStudy.title}
|
||||
href={pathOfThread(caseStudy.threadId)}
|
||||
href={pathOfThread(caseStudy.threadId) + "?mock=true"}
|
||||
target="_blank"
|
||||
>
|
||||
<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 { CitationLink } from "../citations/citation-link";
|
||||
import { useThread } from "../messages/context";
|
||||
import { Tooltip } from "../tooltip";
|
||||
|
||||
import { useArtifacts } from "./context";
|
||||
|
|
@ -79,9 +80,9 @@ export function ArtifactFileDetail({
|
|||
}
|
||||
return checkCodeFile(filepath);
|
||||
}, [filepath, isWriteFile, isSkillFile]);
|
||||
const previewable = useMemo(() => {
|
||||
return (language === "html" && !isWriteFile) || language === "markdown";
|
||||
}, [isWriteFile, language]);
|
||||
const isSupportPreview = useMemo(() => {
|
||||
return language === "html" || language === "markdown";
|
||||
}, [language]);
|
||||
const { content } = useArtifactContent({
|
||||
threadId,
|
||||
filepath: filepathFromProps,
|
||||
|
|
@ -92,14 +93,14 @@ export function ArtifactFileDetail({
|
|||
|
||||
const [viewMode, setViewMode] = useState<"code" | "preview">("code");
|
||||
const [isInstalling, setIsInstalling] = useState(false);
|
||||
|
||||
const { isMock } = useThread();
|
||||
useEffect(() => {
|
||||
if (previewable) {
|
||||
if (isSupportPreview) {
|
||||
setViewMode("preview");
|
||||
} else {
|
||||
setViewMode("code");
|
||||
}
|
||||
}, [previewable]);
|
||||
}, [isSupportPreview]);
|
||||
|
||||
const handleInstallSkill = useCallback(async () => {
|
||||
if (isInstalling) return;
|
||||
|
|
@ -148,16 +149,18 @@ export function ArtifactFileDetail({
|
|||
</ArtifactTitle>
|
||||
</div>
|
||||
<div className="flex min-w-0 grow items-center justify-center">
|
||||
{previewable && (
|
||||
{isSupportPreview && (
|
||||
<ToggleGroup
|
||||
className="mx-auto"
|
||||
type="single"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
value={viewMode}
|
||||
onValueChange={(value) =>
|
||||
setViewMode(value as "code" | "preview")
|
||||
onValueChange={(value) => {
|
||||
if (value) {
|
||||
setViewMode(value as "code" | "preview");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ToggleGroupItem value="code">
|
||||
<Code2Icon />
|
||||
|
|
@ -232,12 +235,10 @@ export function ArtifactFileDetail({
|
|||
</div>
|
||||
</ArtifactHeader>
|
||||
<ArtifactContent className="p-0">
|
||||
{previewable &&
|
||||
{isSupportPreview &&
|
||||
viewMode === "preview" &&
|
||||
(language === "markdown" || language === "html") && (
|
||||
<ArtifactFilePreview
|
||||
filepath={filepath}
|
||||
threadId={threadId}
|
||||
content={displayContent}
|
||||
language={language ?? "text"}
|
||||
/>
|
||||
|
|
@ -252,7 +253,7 @@ export function ArtifactFileDetail({
|
|||
{!isCodeFile && (
|
||||
<iframe
|
||||
className="size-full"
|
||||
src={urlOfArtifact({ filepath, threadId })}
|
||||
src={urlOfArtifact({ filepath, threadId, isMock })}
|
||||
/>
|
||||
)}
|
||||
</ArtifactContent>
|
||||
|
|
@ -261,13 +262,9 @@ export function ArtifactFileDetail({
|
|||
}
|
||||
|
||||
export function ArtifactFilePreview({
|
||||
filepath,
|
||||
threadId,
|
||||
content,
|
||||
language,
|
||||
}: {
|
||||
filepath: string;
|
||||
threadId: string;
|
||||
content: string;
|
||||
language: string;
|
||||
}) {
|
||||
|
|
@ -288,10 +285,11 @@ export function ArtifactFilePreview({
|
|||
return (
|
||||
<iframe
|
||||
className="size-full"
|
||||
src={urlOfArtifact({ filepath, threadId })}
|
||||
title="Artifact preview"
|
||||
srcDoc={content}
|
||||
sandbox="allow-scripts allow-forms"
|
||||
/>
|
||||
);
|
||||
}
|
||||
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 { env } from "@/env";
|
||||
|
|
@ -35,7 +41,8 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
|||
const [autoOpen, setAutoOpen] = useState(true);
|
||||
const { setOpen: setSidebarOpen } = useSidebar();
|
||||
|
||||
const select = (artifact: string, autoSelect = false) => {
|
||||
const select = useCallback(
|
||||
(artifact: string, autoSelect = false) => {
|
||||
setSelectedArtifact(artifact);
|
||||
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true") {
|
||||
setSidebarOpen(false);
|
||||
|
|
@ -43,12 +50,15 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
|||
if (!autoSelect) {
|
||||
setAutoSelect(false);
|
||||
}
|
||||
};
|
||||
},
|
||||
[setSidebarOpen, setSelectedArtifact, setAutoSelect],
|
||||
);
|
||||
|
||||
const deselect = () => {
|
||||
const deselect = useCallback(() => {
|
||||
setSelectedArtifact(null);
|
||||
setAutoSelect(true);
|
||||
};
|
||||
setOpen(false);
|
||||
}, []);
|
||||
|
||||
const value: ArtifactsContextType = {
|
||||
artifacts,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
export * from "./artifact-file-detail";
|
||||
export * from "./artifact-file-list";
|
||||
export * from "./artifact-trigger";
|
||||
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,
|
||||
SparklesIcon,
|
||||
RocketIcon,
|
||||
XIcon,
|
||||
ZapIcon,
|
||||
} from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useCallback, useMemo, useState, type ComponentProps } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentProps,
|
||||
} from "react";
|
||||
|
||||
import {
|
||||
PromptInput,
|
||||
|
|
@ -32,15 +40,26 @@ import {
|
|||
usePromptInputController,
|
||||
type PromptInputMessage,
|
||||
} from "@/components/ai-elements/prompt-input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfettiButton } from "@/components/ui/confetti-button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import type { AgentThreadContext } from "@/core/threads";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import {
|
||||
|
|
@ -60,9 +79,25 @@ import {
|
|||
DropdownMenuTrigger,
|
||||
} from "../ui/dropdown-menu";
|
||||
|
||||
import { useThread } from "./messages/context";
|
||||
import { ModeHoverGuide } from "./mode-hover-guide";
|
||||
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({
|
||||
className,
|
||||
disabled,
|
||||
|
|
@ -71,6 +106,7 @@ export function InputBox({
|
|||
context,
|
||||
extraHeader,
|
||||
isNewThread,
|
||||
threadId,
|
||||
initialValue,
|
||||
onContextChange,
|
||||
onSubmit,
|
||||
|
|
@ -85,9 +121,11 @@ export function InputBox({
|
|||
"thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled"
|
||||
> & {
|
||||
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
|
||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||
};
|
||||
extraHeader?: React.ReactNode;
|
||||
isNewThread?: boolean;
|
||||
threadId: string;
|
||||
initialValue?: string;
|
||||
onContextChange?: (
|
||||
context: Omit<
|
||||
|
|
@ -95,6 +133,7 @@ export function InputBox({
|
|||
"thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled"
|
||||
> & {
|
||||
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
|
||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||
},
|
||||
) => void;
|
||||
onSubmit?: (message: PromptInputMessage) => void;
|
||||
|
|
@ -104,43 +143,97 @@ export function InputBox({
|
|||
const searchParams = useSearchParams();
|
||||
const [modelDialogOpen, setModelDialogOpen] = useState(false);
|
||||
const { models } = useModels();
|
||||
const selectedModel = useMemo(() => {
|
||||
if (!context.model_name && models.length > 0) {
|
||||
const model = models[0]!;
|
||||
setTimeout(() => {
|
||||
const { thread, isMock } = useThread();
|
||||
const { textInput } = usePromptInputController();
|
||||
const promptRootRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
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?.({
|
||||
...context,
|
||||
model_name: model.name,
|
||||
mode: model.supports_thinking ? "pro" : "flash",
|
||||
model_name: nextModelName,
|
||||
mode: nextMode,
|
||||
});
|
||||
}, 0);
|
||||
return model;
|
||||
}
|
||||
return models.find((m) => m.name === context.model_name);
|
||||
}, [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(
|
||||
() => selectedModel?.supports_thinking ?? false,
|
||||
[selectedModel],
|
||||
);
|
||||
|
||||
const supportReasoningEffort = useMemo(
|
||||
() => selectedModel?.supports_reasoning_effort ?? false,
|
||||
[selectedModel],
|
||||
);
|
||||
|
||||
const handleModelSelect = useCallback(
|
||||
(model_name: string) => {
|
||||
const model = models.find((m) => m.name === model_name);
|
||||
if (!model) {
|
||||
return;
|
||||
}
|
||||
onContextChange?.({
|
||||
...context,
|
||||
model_name,
|
||||
mode: getResolvedMode(context.mode, model.supports_thinking ?? false),
|
||||
reasoning_effort: context.reasoning_effort,
|
||||
});
|
||||
setModelDialogOpen(false);
|
||||
},
|
||||
[onContextChange, context],
|
||||
[onContextChange, context, models],
|
||||
);
|
||||
|
||||
const handleModeSelect = useCallback(
|
||||
(mode: "flash" | "thinking" | "pro" | "ultra") => {
|
||||
(mode: InputMode) => {
|
||||
onContextChange?.({
|
||||
...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],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (message: PromptInputMessage) => {
|
||||
if (status === "streaming") {
|
||||
|
|
@ -150,11 +243,136 @@ export function InputBox({
|
|||
if (!message.text) {
|
||||
return;
|
||||
}
|
||||
setFollowups([]);
|
||||
setFollowupsHidden(false);
|
||||
setFollowupsLoading(false);
|
||||
onSubmit?.(message);
|
||||
},
|
||||
[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 (
|
||||
<div ref={promptRootRef} className="relative">
|
||||
<PromptInput
|
||||
className={cn(
|
||||
"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>
|
||||
</PromptInputActionMenuContent>
|
||||
</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>
|
||||
<ModelSelector
|
||||
|
|
@ -416,6 +744,64 @@ export function InputBox({
|
|||
<div className="bg-background absolute right-0 -bottom-[17px] left-0 z-0 h-4"></div>
|
||||
)}
|
||||
</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 type { AgentThreadState } from "@/core/threads";
|
||||
|
||||
export interface ThreadContextType {
|
||||
threadId: string;
|
||||
thread: UseStream<AgentThreadState>;
|
||||
thread: BaseStream<AgentThreadState>;
|
||||
isMock?: boolean;
|
||||
}
|
||||
|
||||
export const ThreadContext = createContext<ThreadContextType | undefined>(
|
||||
|
|
|
|||
|
|
@ -1,25 +1,32 @@
|
|||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import { FileIcon } from "lucide-react";
|
||||
import { FileIcon, Loader2Icon } from "lucide-react";
|
||||
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 { Loader } from "@/components/ai-elements/loader";
|
||||
import {
|
||||
Message as AIElementMessage,
|
||||
MessageContent as AIElementMessageContent,
|
||||
MessageResponse as AIElementMessageResponse,
|
||||
MessageToolbar,
|
||||
} 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 { Button } from "@/components/ui/button";
|
||||
import { resolveArtifactURL } from "@/core/artifacts/utils";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
extractContentFromMessage,
|
||||
extractReasoningContentFromMessage,
|
||||
parseUploadedFiles,
|
||||
type UploadedFile,
|
||||
stripUploadedFilesTag,
|
||||
type FileInMessage,
|
||||
} from "@/core/messages/utils";
|
||||
import { materializeSkillYaml } from "@/core/skills";
|
||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||
import { humanMessagePlugins } from "@/core/streamdown";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -48,6 +55,7 @@ export function MessageListItem({
|
|||
message={message}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
{!isLoading && (
|
||||
<MessageToolbar
|
||||
className={cn(
|
||||
isHuman ? "-bottom-9 justify-end" : "-bottom-8",
|
||||
|
|
@ -64,6 +72,7 @@ export function MessageListItem({
|
|||
/>
|
||||
</div>
|
||||
</MessageToolbar>
|
||||
)}
|
||||
</AIElementMessage>
|
||||
);
|
||||
}
|
||||
|
|
@ -121,37 +130,67 @@ function MessageContent_({
|
|||
|
||||
const rawContent = extractContentFromMessage(message);
|
||||
const reasoningContent = extractReasoningContentFromMessage(message);
|
||||
const { contentToParse, uploadedFiles } = useMemo(() => {
|
||||
if (!isLoading && reasoningContent && !rawContent) {
|
||||
return {
|
||||
contentToParse: reasoningContent,
|
||||
uploadedFiles: [] as UploadedFile[],
|
||||
};
|
||||
|
||||
const files = useMemo(() => {
|
||||
const files = message.additional_kwargs?.files;
|
||||
if (!Array.isArray(files) || files.length === 0) {
|
||||
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) {
|
||||
const { files, cleanContent: contentWithoutFiles } =
|
||||
parseUploadedFiles(rawContent);
|
||||
return { contentToParse: contentWithoutFiles, uploadedFiles: files };
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
contentToParse: rawContent ?? "",
|
||||
uploadedFiles: [] as UploadedFile[],
|
||||
};
|
||||
}, [isLoading, rawContent, reasoningContent, isHuman]);
|
||||
return files as FileInMessage[];
|
||||
}, [message.additional_kwargs?.files, rawContent]);
|
||||
|
||||
const contentToDisplay = useMemo(() => {
|
||||
if (isHuman) {
|
||||
return rawContent ? stripUploadedFilesTag(rawContent) : "";
|
||||
}
|
||||
return rawContent ?? "";
|
||||
}, [rawContent, isHuman]);
|
||||
|
||||
const filesList =
|
||||
uploadedFiles.length > 0 && thread_id ? (
|
||||
<UploadedFilesList files={uploadedFiles} threadId={thread_id} />
|
||||
files && files.length > 0 && thread_id ? (
|
||||
<RichFilesList files={files} threadId={thread_id} />
|
||||
) : 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) {
|
||||
const messageResponse = contentToParse ? (
|
||||
const messageResponse = contentToDisplay ? (
|
||||
<AIElementMessageResponse
|
||||
remarkPlugins={humanMessagePlugins.remarkPlugins}
|
||||
rehypePlugins={humanMessagePlugins.rehypePlugins}
|
||||
components={components}
|
||||
>
|
||||
{contentToParse}
|
||||
{contentToDisplay}
|
||||
</AIElementMessageResponse>
|
||||
) : null;
|
||||
return (
|
||||
|
|
@ -170,7 +209,7 @@ function MessageContent_({
|
|||
<AIElementMessageContent className={className}>
|
||||
{filesList}
|
||||
<MarkdownContent
|
||||
content={contentToParse}
|
||||
content={contentToDisplay}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: "html" }]]}
|
||||
className="my-3"
|
||||
|
|
@ -223,28 +262,32 @@ function isImageFile(filename: string): boolean {
|
|||
return IMAGE_EXTENSIONS.includes(getFileExt(filename));
|
||||
}
|
||||
|
||||
function isYamlFile(filename: string): boolean {
|
||||
const ext = getFileExt(filename);
|
||||
return ext === "yaml" || ext === "yml";
|
||||
/**
|
||||
* Format bytes to human-readable size string
|
||||
*/
|
||||
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,
|
||||
threadId,
|
||||
}: {
|
||||
files: UploadedFile[];
|
||||
files: FileInMessage[];
|
||||
threadId: string;
|
||||
}) {
|
||||
if (files.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-2 flex flex-wrap justify-end gap-2">
|
||||
{files.map((file, index) => (
|
||||
<UploadedFileCard
|
||||
key={`${file.path}-${index}`}
|
||||
<RichFileCard
|
||||
key={`${file.filename}-${index}`}
|
||||
file={file}
|
||||
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,
|
||||
threadId,
|
||||
}: {
|
||||
file: UploadedFile;
|
||||
file: FileInMessage;
|
||||
threadId: string;
|
||||
}) {
|
||||
const [isMaterializing, setIsMaterializing] = useState(false);
|
||||
const [materializeMessage, setMaterializeMessage] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
if (!threadId) return null;
|
||||
|
||||
const { t } = useI18n();
|
||||
const isUploading = file.status === "uploading";
|
||||
const isImage = isImageFile(file.filename);
|
||||
const isYaml = isYamlFile(file.filename);
|
||||
const fileUrl = resolveArtifactURL(file.path, threadId);
|
||||
|
||||
const handleMaterializeYaml = async () => {
|
||||
if (isMaterializing) return;
|
||||
setIsMaterializing(true);
|
||||
setMaterializeMessage(null);
|
||||
try {
|
||||
const result = await materializeSkillYaml({
|
||||
thread_id: threadId,
|
||||
path: file.path,
|
||||
target_dir: "/mnt/user-data/uploads/skill",
|
||||
clear_target: true,
|
||||
});
|
||||
setMaterializeMessage(
|
||||
`已创建 ${result.created_files} 个文件 / ${result.created_directories} 个目录`,
|
||||
if (isUploading) {
|
||||
return (
|
||||
<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">
|
||||
<div className="flex items-start gap-2">
|
||||
<Loader2Icon className="text-muted-foreground mt-0.5 size-4 shrink-0 animate-spin" />
|
||||
<span
|
||||
className="text-foreground truncate text-sm font-medium"
|
||||
title={file.filename}
|
||||
>
|
||||
{file.filename}
|
||||
</span>
|
||||
</div>
|
||||
<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) {
|
||||
return (
|
||||
|
|
@ -307,14 +352,14 @@ function UploadedFileCard({
|
|||
<img
|
||||
src={fileUrl}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
<FileIcon className="text-muted-foreground mt-0.5 size-4 shrink-0" />
|
||||
<span
|
||||
|
|
@ -331,29 +376,10 @@ function UploadedFileCard({
|
|||
>
|
||||
{getFileTypeLabel(file.filename)}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-[10px]">{file.size}</span>
|
||||
</div>
|
||||
{/* 注释掉测试按钮,后续根据需求再决定是否保留 */}
|
||||
{/* {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 className="text-muted-foreground text-[10px]">
|
||||
{formatBytes(file.size)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)} */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import type { UseStream } from "@langchain/langgraph-sdk/react";
|
||||
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
||||
|
||||
import {
|
||||
Conversation,
|
||||
|
|
@ -34,23 +33,18 @@ export function MessageList({
|
|||
className,
|
||||
threadId,
|
||||
thread,
|
||||
messagesOverride,
|
||||
suppressThreadLoading = false,
|
||||
paddingBottom = 160,
|
||||
}: {
|
||||
className?: string;
|
||||
threadId: string;
|
||||
thread: UseStream<AgentThreadState>;
|
||||
/** When set (e.g. from onFinish), use instead of thread.messages so SSE end shows complete state. */
|
||||
messagesOverride?: Message[];
|
||||
suppressThreadLoading?: boolean;
|
||||
thread: BaseStream<AgentThreadState>;
|
||||
paddingBottom?: number;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
|
||||
const updateSubtask = useUpdateSubtask();
|
||||
const messages = messagesOverride ?? thread.messages;
|
||||
if (thread.isThreadLoading && !suppressThreadLoading) {
|
||||
const messages = thread.messages;
|
||||
if (thread.isThreadLoading && messages.length === 0) {
|
||||
return <MessageListSkeleton />;
|
||||
}
|
||||
return (
|
||||
|
|
@ -60,13 +54,15 @@ export function MessageList({
|
|||
<ConversationContent className="mx-auto w-full max-w-(--container-width-md) gap-8 pt-12">
|
||||
{groupMessages(messages, (group) => {
|
||||
if (group.type === "human" || group.type === "assistant") {
|
||||
return group.messages.map((msg) => {
|
||||
return (
|
||||
<MessageListItem
|
||||
key={group.id}
|
||||
message={group.messages[0]!}
|
||||
key={`${group.id}/${msg.id}`}
|
||||
message={msg}
|
||||
isLoading={thread.isLoading}
|
||||
/>
|
||||
);
|
||||
});
|
||||
} else if (group.type === "assistant:clarification") {
|
||||
const message = group.messages[0];
|
||||
if (message && hasContent(message)) {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import {
|
|||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
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 { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -89,7 +89,11 @@ export function AppearanceSettingsPage() {
|
|||
>
|
||||
<Select
|
||||
value={locale}
|
||||
onValueChange={(value) => changeLocale(value as Locale)}
|
||||
onValueChange={(value) => {
|
||||
if (isLocale(value)) {
|
||||
changeLocale(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[220px]">
|
||||
<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";
|
||||
|
||||
export function ThreadTitle({
|
||||
threadTitle,
|
||||
threadId,
|
||||
thread,
|
||||
}: {
|
||||
className?: 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 { useState } from "react";
|
||||
|
||||
import type { Todo } from "@/core/todos";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -13,7 +14,7 @@ import {
|
|||
export function TodoList({
|
||||
className,
|
||||
todos,
|
||||
collapsed = false,
|
||||
collapsed: controlledCollapsed,
|
||||
hidden = false,
|
||||
onToggle,
|
||||
}: {
|
||||
|
|
@ -23,6 +24,18 @@ export function TodoList({
|
|||
hidden?: boolean;
|
||||
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 (
|
||||
<div
|
||||
className={cn(
|
||||
|
|
@ -35,9 +48,7 @@ export function TodoList({
|
|||
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",
|
||||
)}
|
||||
onClick={() => {
|
||||
onToggle?.();
|
||||
}}
|
||||
onClick={handleToggle}
|
||||
>
|
||||
<div className="text-muted-foreground">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { MessagesSquare } from "lucide-react";
|
||||
import { BotIcon, MessagesSquare } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
|
|
@ -26,6 +26,17 @@ export function WorkspaceNavChatList() {
|
|||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</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>
|
||||
</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";
|
||||
|
||||
let _singleton: LangGraphClient | null = null;
|
||||
export function getAPIClient(): LangGraphClient {
|
||||
_singleton ??= new LangGraphClient({
|
||||
apiUrl: getLangGraphBaseURL(),
|
||||
import { sanitizeRunStreamOptions } from "./stream-mode";
|
||||
|
||||
function createCompatibleClient(isMock?: boolean): LangGraphClient {
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
return filepath.startsWith("write-file:");
|
||||
}, [filepath]);
|
||||
const { thread } = useThread();
|
||||
const { thread, isMock } = useThread();
|
||||
const content = useMemo(() => {
|
||||
if (isWriteFile) {
|
||||
return loadArtifactContentFromToolCall({ url: filepath, thread });
|
||||
}
|
||||
return null;
|
||||
}, [filepath, isWriteFile, thread]);
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["artifact", filepath, threadId],
|
||||
queryKey: ["artifact", filepath, threadId, isMock],
|
||||
queryFn: () => {
|
||||
return loadArtifactContent({ filepath, threadId });
|
||||
return loadArtifactContent({ filepath, threadId, isMock });
|
||||
},
|
||||
enabled,
|
||||
// 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";
|
||||
|
||||
|
|
@ -7,15 +7,17 @@ import { urlOfArtifact } from "./utils";
|
|||
export async function loadArtifactContent({
|
||||
filepath,
|
||||
threadId,
|
||||
isMock,
|
||||
}: {
|
||||
filepath: string;
|
||||
threadId: string;
|
||||
isMock?: boolean;
|
||||
}) {
|
||||
let enhancedFilepath = filepath;
|
||||
if (filepath.endsWith(".skill")) {
|
||||
enhancedFilepath = filepath + "/SKILL.md";
|
||||
}
|
||||
const url = urlOfArtifact({ filepath: enhancedFilepath, threadId });
|
||||
const url = urlOfArtifact({ filepath: enhancedFilepath, threadId, isMock });
|
||||
const response = await fetch(url);
|
||||
const text = await response.text();
|
||||
return text;
|
||||
|
|
@ -26,7 +28,7 @@ export function loadArtifactContentFromToolCall({
|
|||
thread,
|
||||
}: {
|
||||
url: string;
|
||||
thread: UseStream<AgentThreadState>;
|
||||
thread: BaseStream<AgentThreadState>;
|
||||
}) {
|
||||
const url = new URL(urlString);
|
||||
const toolCallId = url.searchParams.get("tool_call_id");
|
||||
|
|
|
|||
|
|
@ -5,11 +5,16 @@ export function urlOfArtifact({
|
|||
filepath,
|
||||
threadId,
|
||||
download = false,
|
||||
isMock = false,
|
||||
}: {
|
||||
filepath: string;
|
||||
threadId: string;
|
||||
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" : ""}`;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,9 +8,14 @@ export function getBackendBaseURL() {
|
|||
}
|
||||
}
|
||||
|
||||
export function getLangGraphBaseURL() {
|
||||
export function getLangGraphBaseURL(isMock?: boolean) {
|
||||
if (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 {
|
||||
// LangGraph SDK requires a full URL, construct it from current origin
|
||||
if (typeof window !== "undefined") {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,13 @@ import { getLocaleFromCookie, setLocaleInCookie } from "./cookies";
|
|||
import { enUS } from "./locales/en-US";
|
||||
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> = {
|
||||
"en-US": enUS,
|
||||
|
|
@ -17,7 +23,7 @@ const translations: Record<Locale, Translations> = {
|
|||
export function useI18n() {
|
||||
const { locale, setLocale } = useI18nContext();
|
||||
|
||||
const t = translations[locale];
|
||||
const t = translations[locale] ?? translations[DEFAULT_LOCALE];
|
||||
|
||||
const changeLocale = (newLocale: Locale) => {
|
||||
setLocale(newLocale);
|
||||
|
|
@ -26,12 +32,19 @@ export function useI18n() {
|
|||
|
||||
// Initialize locale on mount
|
||||
useEffect(() => {
|
||||
const saved = getLocaleFromCookie() as Locale | null;
|
||||
if (!saved) {
|
||||
const saved = getLocaleFromCookie();
|
||||
if (saved) {
|
||||
const normalizedSaved = normalizeLocale(saved);
|
||||
setLocale(normalizedSaved);
|
||||
if (saved !== normalizedSaved) {
|
||||
setLocaleInCookie(normalizedSaved);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const detected = detectLocale();
|
||||
setLocale(detected);
|
||||
setLocaleInCookie(detected);
|
||||
}
|
||||
}, [setLocale]);
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1,23 +1,11 @@
|
|||
export { enUS } from "./locales/en-US";
|
||||
export { zhCN } from "./locales/zh-CN";
|
||||
export type { Translations } from "./locales/types";
|
||||
|
||||
export type Locale = "en-US" | "zh-CN";
|
||||
|
||||
// Helper function to detect browser locale
|
||||
export function detectLocale(): Locale {
|
||||
if (typeof window === "undefined") {
|
||||
return "en-US";
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
export {
|
||||
DEFAULT_LOCALE,
|
||||
SUPPORTED_LOCALES,
|
||||
detectLocale,
|
||||
isLocale,
|
||||
normalizeLocale,
|
||||
} from "./locale";
|
||||
export type { Locale } from "./locale";
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
ultraModeDescription:
|
||||
"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...",
|
||||
surpriseMe: "Surprise",
|
||||
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: [
|
||||
{
|
||||
suggestion: "Write",
|
||||
|
|
@ -142,6 +159,41 @@ export const enUS: Translations = {
|
|||
chats: "Chats",
|
||||
recentChats: "Recent 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
|
||||
|
|
@ -204,6 +256,11 @@ export const enUS: Translations = {
|
|||
},
|
||||
|
||||
// Subtasks
|
||||
uploads: {
|
||||
uploading: "Uploading...",
|
||||
uploadingFiles: "Uploading files, please wait...",
|
||||
},
|
||||
|
||||
subtasks: {
|
||||
subtask: "Subtask",
|
||||
executing: (count: number) =>
|
||||
|
|
|
|||
|
|
@ -64,9 +64,23 @@ export interface Translations {
|
|||
proModeDescription: string;
|
||||
ultraMode: string;
|
||||
ultraModeDescription: string;
|
||||
reasoningEffort: string;
|
||||
reasoningEffortMinimal: string;
|
||||
reasoningEffortMinimalDescription: string;
|
||||
reasoningEffortLow: string;
|
||||
reasoningEffortLowDescription: string;
|
||||
reasoningEffortMedium: string;
|
||||
reasoningEffortMediumDescription: string;
|
||||
reasoningEffortHigh: string;
|
||||
reasoningEffortHighDescription: string;
|
||||
searchModels: string;
|
||||
surpriseMe: string;
|
||||
surpriseMePrompt: string;
|
||||
followupLoading: string;
|
||||
followupConfirmTitle: string;
|
||||
followupConfirmDescription: string;
|
||||
followupConfirmAppend: string;
|
||||
followupConfirmReplace: string;
|
||||
suggestions: {
|
||||
suggestion: string;
|
||||
prompt: string;
|
||||
|
|
@ -90,6 +104,34 @@ export interface Translations {
|
|||
newChat: string;
|
||||
chats: 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
|
||||
|
|
@ -150,6 +192,12 @@ export interface Translations {
|
|||
skillInstallTooltip: string;
|
||||
};
|
||||
|
||||
// Uploads
|
||||
uploads: {
|
||||
uploading: string;
|
||||
uploadingFiles: string;
|
||||
};
|
||||
|
||||
// Subtasks
|
||||
subtasks: {
|
||||
subtask: string;
|
||||
|
|
|
|||
|
|
@ -80,9 +80,23 @@ export const zhCN: Translations = {
|
|||
ultraMode: "Ultra",
|
||||
ultraModeDescription:
|
||||
"继承自 Pro 模式,可调用子代理分工协作,适合复杂多步骤任务,能力最强",
|
||||
reasoningEffort: "推理深度",
|
||||
reasoningEffortMinimal: "最低",
|
||||
reasoningEffortMinimalDescription: "检索 + 直接输出",
|
||||
reasoningEffortLow: "低",
|
||||
reasoningEffortLowDescription: "简单逻辑校验 + 浅层推演",
|
||||
reasoningEffortMedium: "中",
|
||||
reasoningEffortMediumDescription: "多层逻辑分析 + 基础验证",
|
||||
reasoningEffortHigh: "高",
|
||||
reasoningEffortHighDescription: "全维度逻辑推演 + 多路径验证 + 反推校验",
|
||||
searchModels: "搜索模型...",
|
||||
surpriseMe: "小惊喜",
|
||||
surpriseMePrompt: "给我一个小惊喜吧",
|
||||
followupLoading: "正在生成可能的后续问题...",
|
||||
followupConfirmTitle: "发送建议问题?",
|
||||
followupConfirmDescription: "当前输入框已有内容,选择发送方式。",
|
||||
followupConfirmAppend: "追加并发送",
|
||||
followupConfirmReplace: "替换并发送",
|
||||
suggestions: [
|
||||
{
|
||||
suggestion: "写作",
|
||||
|
|
@ -139,6 +153,36 @@ export const zhCN: Translations = {
|
|||
chats: "对话",
|
||||
recentChats: "最近的对话",
|
||||
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
|
||||
|
|
@ -199,6 +243,11 @@ export const zhCN: Translations = {
|
|||
skillInstallTooltip: "安装技能并使其可在 DeerFlow 中使用",
|
||||
},
|
||||
|
||||
uploads: {
|
||||
uploading: "上传中...",
|
||||
uploadingFiles: "文件上传中,请稍候...",
|
||||
},
|
||||
|
||||
subtasks: {
|
||||
subtask: "子任务",
|
||||
executing: (count: number) =>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,17 @@
|
|||
import { cookies } from "next/headers";
|
||||
|
||||
export type Locale = "en-US" | "zh-CN";
|
||||
import { normalizeLocale, type Locale } from "./locale";
|
||||
|
||||
export async function detectLocaleServer(): Promise<Locale> {
|
||||
const cookieStore = await cookies();
|
||||
const locale = cookieStore.get("locale")?.value ?? "en-US";
|
||||
return locale as Locale;
|
||||
let locale = cookieStore.get("locale")?.value;
|
||||
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) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const groups: MessageGroup[] = [];
|
||||
|
||||
for (const message of messages) {
|
||||
const lastGroup = groups[groups.length - 1];
|
||||
if (message.type === "human") {
|
||||
groups.push({
|
||||
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)
|
||||
// Returns the last group if it can still accept tool messages
|
||||
// (i.e. it's an in-flight processing group, not a terminal human/assistant group).
|
||||
function lastOpenGroup() {
|
||||
const last = groups[groups.length - 1];
|
||||
if (
|
||||
lastGroup &&
|
||||
lastGroup.type !== "human" &&
|
||||
lastGroup.type !== "assistant" &&
|
||||
lastGroup.type !== "assistant:clarification"
|
||||
last &&
|
||||
last.type !== "human" &&
|
||||
last.type !== "assistant" &&
|
||||
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({
|
||||
id: message.id,
|
||||
type: "assistant:clarification",
|
||||
messages: [message],
|
||||
});
|
||||
} else if (
|
||||
lastGroup &&
|
||||
lastGroup.type !== "human" &&
|
||||
lastGroup.type !== "assistant" &&
|
||||
lastGroup.type !== "assistant:clarification"
|
||||
) {
|
||||
lastGroup.messages.push(message);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Tool message must be matched with a previous assistant message with tool calls",
|
||||
const open = lastOpenGroup();
|
||||
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)) {
|
||||
groups.push({
|
||||
id: message.id,
|
||||
|
|
@ -87,42 +98,31 @@ export function groupMessages<T>(
|
|||
type: "assistant:subagent",
|
||||
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") {
|
||||
groups.push({
|
||||
id: message.id,
|
||||
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],
|
||||
});
|
||||
} 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[] = [];
|
||||
for (const group of groups) {
|
||||
const resultOfGroup = mapper(group);
|
||||
if (resultOfGroup !== undefined && resultOfGroup !== null) {
|
||||
resultsOfGroups.push(resultOfGroup);
|
||||
}
|
||||
}
|
||||
return resultsOfGroups;
|
||||
return groups
|
||||
.map(mapper)
|
||||
.filter((result) => result !== undefined && result !== null) as T[];
|
||||
}
|
||||
|
||||
export function extractTextFromMessage(message: Message) {
|
||||
|
|
@ -162,12 +162,21 @@ export function extractContentFromMessage(message: Message) {
|
|||
}
|
||||
|
||||
export function extractReasoningContentFromMessage(message: Message) {
|
||||
if (message.type !== "ai" || !message.additional_kwargs) {
|
||||
if (message.type !== "ai") {
|
||||
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;
|
||||
}
|
||||
if (Array.isArray(message.content)) {
|
||||
const part = message.content[0];
|
||||
if (part && "thinking" in part) {
|
||||
return part.thinking as string;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -202,10 +211,18 @@ export function hasContent(message: Message) {
|
|||
}
|
||||
|
||||
export function hasReasoning(message: Message) {
|
||||
return (
|
||||
message.type === "ai" &&
|
||||
typeof message.additional_kwargs?.reasoning_content === "string"
|
||||
);
|
||||
if (message.type !== "ai") {
|
||||
return false;
|
||||
}
|
||||
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) {
|
||||
|
|
@ -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;
|
||||
size: string;
|
||||
path: string;
|
||||
size: number; // bytes
|
||||
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 {
|
||||
files: UploadedFile[];
|
||||
cleanContent: string;
|
||||
export function stripUploadedFilesTag(content: string): string {
|
||||
return content
|
||||
.replace(/<uploaded_files>[\s\S]*?<\/uploaded_files>/g, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
export function parseUploadedFiles(content: string): FileInMessage[] {
|
||||
// Match <uploaded_files>...</uploaded_files> tag
|
||||
const uploadedFilesRegex = /<uploaded_files>([\s\S]*?)<\/uploaded_files>/;
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-regexp-exec
|
||||
const match = content.match(uploadedFilesRegex);
|
||||
|
||||
if (!match) {
|
||||
return { files: [], cleanContent: content };
|
||||
return [];
|
||||
}
|
||||
|
||||
const uploadedFilesContent = match[1];
|
||||
const cleanContent = content.replace(uploadedFilesRegex, "").trim();
|
||||
|
||||
// Check if it's "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
|
||||
// Format: - filename (size)\n Path: /path/to/file
|
||||
const fileRegex = /- ([^\n(]+)\s*\(([^)]+)\)\s*\n\s*Path:\s*([^\n]+)/g;
|
||||
const files: UploadedFile[] = [];
|
||||
const files: FileInMessage[] = [];
|
||||
let fileMatch;
|
||||
|
||||
while ((fileMatch = fileRegex.exec(uploadedFilesContent ?? "")) !== null) {
|
||||
files.push({
|
||||
filename: fileMatch[1].trim(),
|
||||
size: fileMatch[2].trim(),
|
||||
size: parseInt(fileMatch[2].trim(), 10) ?? 0,
|
||||
path: fileMatch[3].trim(),
|
||||
});
|
||||
}
|
||||
|
||||
return { files, cleanContent };
|
||||
return files;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { getBackendBaseURL } from "../config";
|
|||
import type { Model } from "./types";
|
||||
|
||||
export async function loadModels() {
|
||||
const res = fetch(`${getBackendBaseURL()}/api/models`);
|
||||
const { models } = (await (await res).json()) as { models: Model[] };
|
||||
const res = await fetch(`${getBackendBaseURL()}/api/models`);
|
||||
const { models } = (await res.json()) as { models: Model[] };
|
||||
return models;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ export function useModels({ enabled = true }: { enabled?: boolean } = {}) {
|
|||
queryKey: ["models"],
|
||||
queryFn: () => loadModels(),
|
||||
enabled,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
return { models: data ?? [], isLoading, error };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,4 +4,5 @@ export interface Model {
|
|||
display_name: string;
|
||||
description?: string | null;
|
||||
supports_thinking?: boolean;
|
||||
supports_reasoning_effort?: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { useCallback, useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useCallback, useLayoutEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
DEFAULT_LOCAL_SETTINGS,
|
||||
|
|
@ -17,7 +16,7 @@ export function useLocalSettings(): [
|
|||
] {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [state, setState] = useState<LocalSettings>(DEFAULT_LOCAL_SETTINGS);
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
if (!mounted) {
|
||||
setState(getLocalSettings());
|
||||
}
|
||||
|
|
@ -28,6 +27,7 @@ export function useLocalSettings(): [
|
|||
key: keyof LocalSettings,
|
||||
value: Partial<LocalSettings[keyof LocalSettings]>,
|
||||
) => {
|
||||
if (!mounted) return;
|
||||
setState((prev) => {
|
||||
const newState = {
|
||||
...prev,
|
||||
|
|
@ -40,7 +40,7 @@ export function useLocalSettings(): [
|
|||
return newState;
|
||||
});
|
||||
},
|
||||
[],
|
||||
[mounted],
|
||||
);
|
||||
return [state, setter];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
|
|||
context: {
|
||||
model_name: undefined,
|
||||
mode: undefined,
|
||||
reasoning_effort: undefined,
|
||||
},
|
||||
layout: {
|
||||
sidebar_collapsed: false,
|
||||
|
|
@ -24,6 +25,7 @@ export interface LocalSettings {
|
|||
"thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled"
|
||||
> & {
|
||||
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
|
||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||
};
|
||||
layout: {
|
||||
sidebar_collapsed: boolean;
|
||||
|
|
|
|||
|
|
@ -35,38 +35,6 @@ export interface InstallSkillResponse {
|
|||
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(
|
||||
request: InstallSkillRequest,
|
||||
): Promise<InstallSkillResponse> {
|
||||
|
|
@ -92,51 +60,3 @@ export async function installSkill(
|
|||
|
||||
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 } from "@langchain/langgraph-sdk";
|
||||
import type { AIMessage, Message } from "@langchain/langgraph-sdk";
|
||||
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 { useCallback } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||
|
||||
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 type { UploadedFileInfo } from "../uploads";
|
||||
import { uploadFiles } from "../uploads";
|
||||
import type { UploadTarget } from "../uploads/api";
|
||||
|
||||
import type {
|
||||
AgentThread,
|
||||
AgentThreadContext,
|
||||
AgentThreadState,
|
||||
} from "./types";
|
||||
import type { AgentThread, AgentThreadState } from "./types";
|
||||
|
||||
export type ToolEndEvent = {
|
||||
name: string;
|
||||
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({
|
||||
threadId,
|
||||
isNewThread,
|
||||
fetchStateHistory = true,
|
||||
context,
|
||||
isMock,
|
||||
onStart,
|
||||
onFinish,
|
||||
}: {
|
||||
isNewThread: boolean;
|
||||
threadId: string | null | undefined;
|
||||
fetchStateHistory?: boolean;
|
||||
onFinish?: (state: AgentThreadState) => void;
|
||||
}) {
|
||||
onToolEnd,
|
||||
}: ThreadStreamOptions) {
|
||||
const { t } = useI18n();
|
||||
// Track the thread ID that is currently streaming to handle thread changes during streaming
|
||||
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 updateSubtask = useUpdateSubtask();
|
||||
|
||||
const thread = useStream<AgentThreadState>({
|
||||
client: getAPIClient(),
|
||||
client: getAPIClient(isMock),
|
||||
assistantId: "lead_agent",
|
||||
threadId: isNewThread ? undefined : threadId,
|
||||
threadId: onStreamThreadId,
|
||||
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) {
|
||||
console.info(event);
|
||||
if (
|
||||
typeof event === "object" &&
|
||||
event !== null &&
|
||||
|
|
@ -54,72 +149,72 @@ export function useThreadStream({
|
|||
}
|
||||
},
|
||||
onFinish(state) {
|
||||
onFinish?.(state.values);
|
||||
// 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;
|
||||
});
|
||||
},
|
||||
);
|
||||
listeners.current.onFinish?.(state.values);
|
||||
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
|
||||
},
|
||||
});
|
||||
return thread;
|
||||
}
|
||||
|
||||
export function useSubmitThread({
|
||||
threadId,
|
||||
thread,
|
||||
threadContext,
|
||||
isNewThread,
|
||||
createNewSession,
|
||||
uploadTarget,
|
||||
afterSubmit,
|
||||
}: {
|
||||
isNewThread: boolean;
|
||||
createNewSession: boolean;
|
||||
threadId: string | null | undefined;
|
||||
thread: UseStream<AgentThreadState>;
|
||||
threadContext: Omit<AgentThreadContext, "thread_id">;
|
||||
uploadTarget?: UploadTarget;
|
||||
afterSubmit?: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const apiClient = getAPIClient();
|
||||
const callback = useCallback(
|
||||
async (message: PromptInputMessage) => {
|
||||
// Optimistic messages shown before the server stream responds
|
||||
const [optimisticMessages, setOptimisticMessages] = useState<Message[]>([]);
|
||||
// Track message count before sending so we know when server has responded
|
||||
const prevMsgCountRef = useRef(thread.messages.length);
|
||||
|
||||
// Clear optimistic when server messages arrive (count increases)
|
||||
useEffect(() => {
|
||||
if (
|
||||
optimisticMessages.length > 0 &&
|
||||
thread.messages.length > prevMsgCountRef.current
|
||||
) {
|
||||
setOptimisticMessages([]);
|
||||
}
|
||||
}, [thread.messages.length, optimisticMessages.length]);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
async (
|
||||
threadId: string,
|
||||
message: PromptInputMessage,
|
||||
extraContext?: Record<string, unknown>,
|
||||
) => {
|
||||
const text = message.text.trim();
|
||||
|
||||
// Guard: ignore empty submits (avoids unintended side effects during page init).
|
||||
const hasFiles = !!(message.files && message.files.length > 0);
|
||||
if (!text && !hasFiles) {
|
||||
return;
|
||||
}
|
||||
// Capture current count before showing optimistic messages
|
||||
prevMsgCountRef.current = thread.messages.length;
|
||||
|
||||
// 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 {
|
||||
await apiClient.threads.delete(threadId);
|
||||
} catch {
|
||||
// Ignore delete errors (e.g. thread does not exist yet)
|
||||
}
|
||||
}
|
||||
|
||||
// Upload files first if any
|
||||
if (message.files && message.files.length > 0) {
|
||||
try {
|
||||
|
|
@ -146,20 +241,71 @@ export function useSubmitThread({
|
|||
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,
|
||||
);
|
||||
const failedConversions = conversionResults.length - files.length;
|
||||
|
||||
if (files.length > 0 && threadId) {
|
||||
await uploadFiles(threadId, files, { target: uploadTarget });
|
||||
if (failedConversions > 0) {
|
||||
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) {
|
||||
console.error("Failed to upload files:", error);
|
||||
// Continue with message submission even if upload fails
|
||||
// You might want to show an error toast here
|
||||
const errorMessage =
|
||||
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(
|
||||
{
|
||||
messages: [
|
||||
|
|
@ -171,39 +317,47 @@ export function useSubmitThread({
|
|||
text,
|
||||
},
|
||||
],
|
||||
additional_kwargs:
|
||||
filesForSubmit.length > 0 ? { files: filesForSubmit } : {},
|
||||
},
|
||||
] as HumanMessage[],
|
||||
],
|
||||
},
|
||||
{
|
||||
threadId: createNewSession ? threadId! : undefined,
|
||||
threadId: threadId,
|
||||
streamSubgraphs: true,
|
||||
streamResumable: true,
|
||||
streamMode: ["values", "messages-tuple", "custom"],
|
||||
config: {
|
||||
recursion_limit: 1000,
|
||||
},
|
||||
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,
|
||||
},
|
||||
},
|
||||
);
|
||||
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
|
||||
afterSubmit?.();
|
||||
} catch (error) {
|
||||
setOptimisticMessages([]);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[
|
||||
thread,
|
||||
isNewThread,
|
||||
createNewSession,
|
||||
threadId,
|
||||
threadContext,
|
||||
uploadTarget,
|
||||
queryClient,
|
||||
apiClient,
|
||||
afterSubmit,
|
||||
],
|
||||
[thread, _handleOnStart, t.uploads.uploadingFiles, context, queryClient],
|
||||
);
|
||||
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(
|
||||
|
|
@ -211,15 +365,64 @@ export function useThreads(
|
|||
limit: 50,
|
||||
sortBy: "updated_at",
|
||||
sortOrder: "desc",
|
||||
select: ["thread_id", "updated_at", "values"],
|
||||
},
|
||||
) {
|
||||
const apiClient = getAPIClient();
|
||||
return useQuery<AgentThread[]>({
|
||||
queryKey: ["threads", "search", params],
|
||||
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);
|
||||
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 { Thread } from "@langchain/langgraph-sdk";
|
||||
import type { Message, Thread } from "@langchain/langgraph-sdk";
|
||||
|
||||
import type { Todo } from "../todos";
|
||||
|
||||
export interface AgentThreadState extends Record<string, unknown> {
|
||||
title: string;
|
||||
messages: BaseMessage[];
|
||||
messages: Message[];
|
||||
artifacts: string[];
|
||||
todos?: Todo[];
|
||||
}
|
||||
|
|
@ -18,4 +17,6 @@ export interface AgentThreadContext extends Record<string, unknown> {
|
|||
thinking_enabled: boolean;
|
||||
is_plan_mode: 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";
|
||||
|
||||
|
|
@ -6,19 +6,19 @@ export function pathOfThread(threadId: string) {
|
|||
return `/workspace/chats/${threadId}`;
|
||||
}
|
||||
|
||||
export function textOfMessage(message: BaseMessage) {
|
||||
export function textOfMessage(message: Message) {
|
||||
if (typeof message.content === "string") {
|
||||
return message.content;
|
||||
} else if (Array.isArray(message.content)) {
|
||||
return message.content.find((part) => part.type === "text" && part.text)
|
||||
?.text as string;
|
||||
for (const part of message.content) {
|
||||
if (part.type === "text") {
|
||||
return part.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function titleOfThread(thread: AgentThread) {
|
||||
if (thread.values && "title" in thread.values) {
|
||||
return thread.values.title;
|
||||
}
|
||||
return "Untitled";
|
||||
return thread.values?.title ?? "Untitled";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,15 +29,12 @@ export interface ListFilesResponse {
|
|||
count: number;
|
||||
}
|
||||
|
||||
export type UploadTarget = "skill";
|
||||
|
||||
/**
|
||||
* Upload files to a thread
|
||||
*/
|
||||
export async function uploadFiles(
|
||||
threadId: string,
|
||||
files: File[],
|
||||
options?: { target?: UploadTarget },
|
||||
): Promise<UploadResponse> {
|
||||
const formData = new FormData();
|
||||
|
||||
|
|
@ -45,10 +42,6 @@ export async function uploadFiles(
|
|||
formData.append("files", file);
|
||||
});
|
||||
|
||||
if (options?.target) {
|
||||
formData.append("upload_target", options.target);
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${getBackendBaseURL()}/api/threads/${threadId}/uploads`,
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in New Issue