mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-23 19:05:16 +02:00
Merge pull request #1419 from MODSetter/dev
Release v0.0.24: UI revamp, multi-agent parallelization, citations & HITL improvements
This commit is contained in:
commit
0f98480096
305 changed files with 9160 additions and 7069 deletions
|
|
@ -372,7 +372,7 @@ test("mock iframe response", async ({ page }) => {
|
||||||
<html>
|
<html>
|
||||||
<body>
|
<body>
|
||||||
<h1>Mocked Widget</h1>
|
<h1>Mocked Widget</h1>
|
||||||
<button>Mocked Button</button>
|
<p>Mocked widget content</p>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
`,
|
`,
|
||||||
|
|
|
||||||
|
|
@ -100,7 +100,7 @@ use: {
|
||||||
Usage:
|
Usage:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// HTML: <button data-testid="submit-btn">Submit</button>
|
// React: <Button data-testid="submit-btn">Submit</Button>
|
||||||
page.getByTestId("submit-btn");
|
page.getByTestId("submit-btn");
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -549,6 +549,8 @@ Preload heavy bundles before they're needed to reduce perceived latency.
|
||||||
**Example: preload on hover/focus**
|
**Example: preload on hover/focus**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
function EditorButton({ onClick }: { onClick: () => void }) {
|
function EditorButton({ onClick }: { onClick: () => void }) {
|
||||||
const preload = () => {
|
const preload = () => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
|
|
@ -557,13 +559,13 @@ function EditorButton({ onClick }: { onClick: () => void }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<Button
|
||||||
onMouseEnter={preload}
|
onMouseEnter={preload}
|
||||||
onFocus={preload}
|
onFocus={preload}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
Open Editor
|
Open Editor
|
||||||
</button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
@ -1239,11 +1241,12 @@ function StaticContent() {
|
||||||
**For mutations:**
|
**For mutations:**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
import { useSWRMutation } from 'swr/mutation'
|
import { useSWRMutation } from 'swr/mutation'
|
||||||
|
|
||||||
function UpdateButton() {
|
function UpdateButton() {
|
||||||
const { trigger } = useSWRMutation('/api/user', updateUser)
|
const { trigger } = useSWRMutation('/api/user', updateUser)
|
||||||
return <button onClick={() => trigger()}>Update</button>
|
return <Button onClick={() => trigger()}>Update</Button>
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -1369,6 +1372,8 @@ Don't subscribe to dynamic state (searchParams, localStorage) if you only read i
|
||||||
**Incorrect: subscribes to all searchParams changes**
|
**Incorrect: subscribes to all searchParams changes**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
function ShareButton({ chatId }: { chatId: string }) {
|
function ShareButton({ chatId }: { chatId: string }) {
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
|
|
||||||
|
|
@ -1377,13 +1382,15 @@ function ShareButton({ chatId }: { chatId: string }) {
|
||||||
shareChat(chatId, { ref })
|
shareChat(chatId, { ref })
|
||||||
}
|
}
|
||||||
|
|
||||||
return <button onClick={handleShare}>Share</button>
|
return <Button onClick={handleShare}>Share</Button>
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Correct: reads on demand, no subscription**
|
**Correct: reads on demand, no subscription**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
function ShareButton({ chatId }: { chatId: string }) {
|
function ShareButton({ chatId }: { chatId: string }) {
|
||||||
const handleShare = () => {
|
const handleShare = () => {
|
||||||
const params = new URLSearchParams(window.location.search)
|
const params = new URLSearchParams(window.location.search)
|
||||||
|
|
@ -1391,7 +1398,7 @@ function ShareButton({ chatId }: { chatId: string }) {
|
||||||
shareChat(chatId, { ref })
|
shareChat(chatId, { ref })
|
||||||
}
|
}
|
||||||
|
|
||||||
return <button onClick={handleShare}>Share</button>
|
return <Button onClick={handleShare}>Share</Button>
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -1549,6 +1556,8 @@ If a side effect is triggered by a specific user action (submit, click, drag), r
|
||||||
**Incorrect: event modeled as state + effect**
|
**Incorrect: event modeled as state + effect**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
function Form() {
|
function Form() {
|
||||||
const [submitted, setSubmitted] = useState(false)
|
const [submitted, setSubmitted] = useState(false)
|
||||||
const theme = useContext(ThemeContext)
|
const theme = useContext(ThemeContext)
|
||||||
|
|
@ -1560,13 +1569,15 @@ function Form() {
|
||||||
}
|
}
|
||||||
}, [submitted, theme])
|
}, [submitted, theme])
|
||||||
|
|
||||||
return <button onClick={() => setSubmitted(true)}>Submit</button>
|
return <Button onClick={() => setSubmitted(true)}>Submit</Button>
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Correct: do it in the handler**
|
**Correct: do it in the handler**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
function Form() {
|
function Form() {
|
||||||
const theme = useContext(ThemeContext)
|
const theme = useContext(ThemeContext)
|
||||||
|
|
||||||
|
|
@ -1575,7 +1586,7 @@ function Form() {
|
||||||
showToast('Registered', theme)
|
showToast('Registered', theme)
|
||||||
}
|
}
|
||||||
|
|
||||||
return <button onClick={handleSubmit}>Submit</button>
|
return <Button onClick={handleSubmit}>Submit</Button>
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ Preload heavy bundles before they're needed to reduce perceived latency.
|
||||||
**Example (preload on hover/focus):**
|
**Example (preload on hover/focus):**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
function EditorButton({ onClick }: { onClick: () => void }) {
|
function EditorButton({ onClick }: { onClick: () => void }) {
|
||||||
const preload = () => {
|
const preload = () => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
|
|
@ -20,13 +22,13 @@ function EditorButton({ onClick }: { onClick: () => void }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<Button
|
||||||
onMouseEnter={preload}
|
onMouseEnter={preload}
|
||||||
onFocus={preload}
|
onFocus={preload}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
Open Editor
|
Open Editor
|
||||||
</button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -45,11 +45,12 @@ function StaticContent() {
|
||||||
**For mutations:**
|
**For mutations:**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
import { useSWRMutation } from 'swr/mutation'
|
import { useSWRMutation } from 'swr/mutation'
|
||||||
|
|
||||||
function UpdateButton() {
|
function UpdateButton() {
|
||||||
const { trigger } = useSWRMutation('/api/user', updateUser)
|
const { trigger } = useSWRMutation('/api/user', updateUser)
|
||||||
return <button onClick={() => trigger()}>Update</button>
|
return <Button onClick={() => trigger()}>Update</Button>
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ Don't subscribe to dynamic state (searchParams, localStorage) if you only read i
|
||||||
**Incorrect (subscribes to all searchParams changes):**
|
**Incorrect (subscribes to all searchParams changes):**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
function ShareButton({ chatId }: { chatId: string }) {
|
function ShareButton({ chatId }: { chatId: string }) {
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
|
|
||||||
|
|
@ -20,13 +22,15 @@ function ShareButton({ chatId }: { chatId: string }) {
|
||||||
shareChat(chatId, { ref })
|
shareChat(chatId, { ref })
|
||||||
}
|
}
|
||||||
|
|
||||||
return <button onClick={handleShare}>Share</button>
|
return <Button onClick={handleShare}>Share</Button>
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Correct (reads on demand, no subscription):**
|
**Correct (reads on demand, no subscription):**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
function ShareButton({ chatId }: { chatId: string }) {
|
function ShareButton({ chatId }: { chatId: string }) {
|
||||||
const handleShare = () => {
|
const handleShare = () => {
|
||||||
const params = new URLSearchParams(window.location.search)
|
const params = new URLSearchParams(window.location.search)
|
||||||
|
|
@ -34,6 +38,6 @@ function ShareButton({ chatId }: { chatId: string }) {
|
||||||
shareChat(chatId, { ref })
|
shareChat(chatId, { ref })
|
||||||
}
|
}
|
||||||
|
|
||||||
return <button onClick={handleShare}>Share</button>
|
return <Button onClick={handleShare}>Share</Button>
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ If a side effect is triggered by a specific user action (submit, click, drag), r
|
||||||
**Incorrect (event modeled as state + effect):**
|
**Incorrect (event modeled as state + effect):**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
function Form() {
|
function Form() {
|
||||||
const [submitted, setSubmitted] = useState(false)
|
const [submitted, setSubmitted] = useState(false)
|
||||||
const theme = useContext(ThemeContext)
|
const theme = useContext(ThemeContext)
|
||||||
|
|
@ -23,13 +25,15 @@ function Form() {
|
||||||
}
|
}
|
||||||
}, [submitted, theme])
|
}, [submitted, theme])
|
||||||
|
|
||||||
return <button onClick={() => setSubmitted(true)}>Submit</button>
|
return <Button onClick={() => setSubmitted(true)}>Submit</Button>
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Correct (do it in the handler):**
|
**Correct (do it in the handler):**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
function Form() {
|
function Form() {
|
||||||
const theme = useContext(ThemeContext)
|
const theme = useContext(ThemeContext)
|
||||||
|
|
||||||
|
|
@ -38,7 +42,7 @@ function Form() {
|
||||||
showToast('Registered', theme)
|
showToast('Registered', theme)
|
||||||
}
|
}
|
||||||
|
|
||||||
return <button onClick={handleSubmit}>Submit</button>
|
return <Button onClick={handleSubmit}>Submit</Button>
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -6,16 +6,15 @@ node_modules/
|
||||||
.venv
|
.venv
|
||||||
.pnpm-store
|
.pnpm-store
|
||||||
.DS_Store
|
.DS_Store
|
||||||
deepagents/
|
|
||||||
debug.log
|
debug.log
|
||||||
opencode/
|
|
||||||
|
references/
|
||||||
|
references
|
||||||
|
|
||||||
# Playwright (E2E test artifacts)
|
# Playwright (E2E test artifacts)
|
||||||
surfsense_web/playwright/.auth/
|
surfsense_web/playwright/.auth/
|
||||||
surfsense_web/playwright-report/
|
surfsense_web/playwright-report/
|
||||||
surfsense_web/test-results/
|
surfsense_web/test-results/
|
||||||
surfsense_web/blob-report/
|
surfsense_web/blob-report/
|
||||||
hermes-agent
|
|
||||||
hermes-agent/
|
|
||||||
|
|
||||||
content_research/
|
content_research/
|
||||||
|
|
|
||||||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.0.23
|
0.0.24
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,18 @@
|
||||||
# - Backend .env: SEARXNG_DEFAULT_HOST=http://localhost:${SEARXNG_PORT:-8888}
|
# - Backend .env: SEARXNG_DEFAULT_HOST=http://localhost:${SEARXNG_PORT:-8888}
|
||||||
# - Backend .env: CELERY_BROKER_URL / REDIS_APP_URL → redis://localhost:6379/0
|
# - Backend .env: CELERY_BROKER_URL / REDIS_APP_URL → redis://localhost:6379/0
|
||||||
# - Web .env: NEXT_PUBLIC_ZERO_CACHE_URL=http://localhost:${ZERO_CACHE_PORT:-4848}
|
# - Web .env: NEXT_PUBLIC_ZERO_CACHE_URL=http://localhost:${ZERO_CACHE_PORT:-4848}
|
||||||
|
#
|
||||||
|
# IMPORTANT — schema migrations:
|
||||||
|
# This compose file does NOT build the backend image and therefore cannot
|
||||||
|
# run a `migrations` service. You MUST run alembic on the host before
|
||||||
|
# bringing zero-cache up, or zero-cache will crash-loop with
|
||||||
|
# `Unknown or invalid publications. Specified: [zero_publication]`.
|
||||||
|
#
|
||||||
|
# First-time / after-pull:
|
||||||
|
# cd surfsense_backend && uv run alembic upgrade head
|
||||||
|
#
|
||||||
|
# The other compose files (docker-compose.yml, docker-compose.dev.yml)
|
||||||
|
# handle this automatically via a dedicated `migrations` service.
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
name: surfsense-deps
|
name: surfsense-deps
|
||||||
|
|
@ -82,8 +94,12 @@ services:
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
|
# NOTE: zero-cache requires the `zero_publication` Postgres publication to
|
||||||
|
# exist before it starts. In this deps-only stack there is no backend
|
||||||
|
# container to run migrations, so you must run `uv run alembic upgrade head`
|
||||||
|
# from `surfsense_backend/` on the host BEFORE `docker compose up -d`.
|
||||||
zero-cache:
|
zero-cache:
|
||||||
image: rocicorp/zero:0.26.2
|
image: rocicorp/zero:1.4.0
|
||||||
ports:
|
ports:
|
||||||
- "${ZERO_CACHE_PORT:-4848}:4848"
|
- "${ZERO_CACHE_PORT:-4848}:4848"
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,25 @@ services:
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
|
# Short-lived schema runner; see docker/docker-compose.yml `migrations`
|
||||||
|
# service for the full rationale. Builds from the same backend context as
|
||||||
|
# the dev backend/celery services.
|
||||||
|
migrations:
|
||||||
|
build: *backend-build
|
||||||
|
env_file:
|
||||||
|
- ../surfsense_backend/.env
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=${DATABASE_URL:-postgresql+asyncpg://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}}
|
||||||
|
- PYTHONPATH=/app
|
||||||
|
- SERVICE_ROLE=migrate
|
||||||
|
- MIGRATION_TIMEOUT=${MIGRATION_TIMEOUT:-900}
|
||||||
|
volumes:
|
||||||
|
- zero_init:/zero-init
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
pgadmin:
|
pgadmin:
|
||||||
image: dpage/pgadmin4
|
image: dpage/pgadmin4
|
||||||
ports:
|
ports:
|
||||||
|
|
@ -111,8 +130,10 @@ services:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
searxng:
|
searxng:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
migrations:
|
||||||
|
condition: service_completed_successfully
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
test: ["CMD", "curl", "-f", "http://localhost:8000/ready"]
|
||||||
interval: 15s
|
interval: 15s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 30
|
retries: 30
|
||||||
|
|
@ -141,6 +162,8 @@ services:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
migrations:
|
||||||
|
condition: service_completed_successfully
|
||||||
backend:
|
backend:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
|
|
@ -160,6 +183,8 @@ services:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
migrations:
|
||||||
|
condition: service_completed_successfully
|
||||||
celery_worker:
|
celery_worker:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
|
|
||||||
|
|
@ -179,14 +204,16 @@ services:
|
||||||
# - celery_worker
|
# - celery_worker
|
||||||
|
|
||||||
zero-cache:
|
zero-cache:
|
||||||
image: rocicorp/zero:0.26.2
|
image: rocicorp/zero:1.4.0
|
||||||
ports:
|
ports:
|
||||||
- "${ZERO_CACHE_PORT:-4848}:4848"
|
- "${ZERO_CACHE_PORT:-4848}:4848"
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
depends_on:
|
depends_on:
|
||||||
backend:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
migrations:
|
||||||
|
condition: service_completed_successfully
|
||||||
environment:
|
environment:
|
||||||
- ZERO_UPSTREAM_DB=${ZERO_UPSTREAM_DB:-postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}?sslmode=${DB_SSLMODE:-disable}}
|
- ZERO_UPSTREAM_DB=${ZERO_UPSTREAM_DB:-postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}?sslmode=${DB_SSLMODE:-disable}}
|
||||||
- ZERO_CVR_DB=${ZERO_CVR_DB:-postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}?sslmode=${DB_SSLMODE:-disable}}
|
- ZERO_CVR_DB=${ZERO_CVR_DB:-postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}?sslmode=${DB_SSLMODE:-disable}}
|
||||||
|
|
@ -201,6 +228,12 @@ services:
|
||||||
- ZERO_MUTATE_URL=${ZERO_MUTATE_URL:-http://frontend:3000/api/zero/mutate}
|
- ZERO_MUTATE_URL=${ZERO_MUTATE_URL:-http://frontend:3000/api/zero/mutate}
|
||||||
volumes:
|
volumes:
|
||||||
- zero_cache_data:/data
|
- zero_cache_data:/data
|
||||||
|
- zero_init:/zero-init
|
||||||
|
# Wrapper: see docker/docker-compose.yml `zero-cache` for rationale.
|
||||||
|
entrypoint: ["sh", "-c"]
|
||||||
|
# Pass the script as a single list element so Compose does not tokenize it.
|
||||||
|
command:
|
||||||
|
- 'if [ -f /zero-init/needs_reset ]; then echo "[zero-init] publication change detected; wiping replica file(s) under /data" && rm -f /data/zero.db /data/zero.db-shm /data/zero.db-wal && rm -f /zero-init/needs_reset; fi; exec zero-cache'
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:4848/keepalive"]
|
test: ["CMD", "curl", "-f", "http://localhost:4848/keepalive"]
|
||||||
|
|
@ -238,3 +271,5 @@ volumes:
|
||||||
name: surfsense-dev-shared-temp
|
name: surfsense-dev-shared-temp
|
||||||
zero_cache_data:
|
zero_cache_data:
|
||||||
name: surfsense-dev-zero-cache
|
name: surfsense-dev-zero-cache
|
||||||
|
zero_init:
|
||||||
|
name: surfsense-dev-zero-init
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,28 @@ services:
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
|
# Short-lived schema runner. Executes `alembic upgrade head` and verifies
|
||||||
|
# that the `zero_publication` Postgres logical-replication publication
|
||||||
|
# exists, then exits 0. Downstream services (backend, celery_*, zero-cache)
|
||||||
|
# gate on this with `condition: service_completed_successfully` so a failed
|
||||||
|
# migration halts the whole stack instead of silently producing a half-built
|
||||||
|
# system that crash-loops zero-cache on missing publications.
|
||||||
|
migrations:
|
||||||
|
image: ghcr.io/modsetter/surfsense-backend:${SURFSENSE_VERSION:-latest}
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://${DB_USER:-surfsense}:${DB_PASSWORD:-surfsense}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}}
|
||||||
|
PYTHONPATH: /app
|
||||||
|
SERVICE_ROLE: migrate
|
||||||
|
MIGRATION_TIMEOUT: ${MIGRATION_TIMEOUT:-900}
|
||||||
|
volumes:
|
||||||
|
- zero_init:/zero-init
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:8-alpine
|
image: redis:8-alpine
|
||||||
volumes:
|
volumes:
|
||||||
|
|
@ -88,9 +110,11 @@ services:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
searxng:
|
searxng:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
migrations:
|
||||||
|
condition: service_completed_successfully
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
test: ["CMD", "curl", "-f", "http://localhost:8000/ready"]
|
||||||
interval: 15s
|
interval: 15s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 30
|
retries: 30
|
||||||
|
|
@ -118,6 +142,8 @@ services:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
migrations:
|
||||||
|
condition: service_completed_successfully
|
||||||
backend:
|
backend:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
labels:
|
labels:
|
||||||
|
|
@ -140,6 +166,8 @@ services:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
migrations:
|
||||||
|
condition: service_completed_successfully
|
||||||
celery_worker:
|
celery_worker:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
labels:
|
labels:
|
||||||
|
|
@ -163,7 +191,7 @@ services:
|
||||||
# restart: unless-stopped
|
# restart: unless-stopped
|
||||||
|
|
||||||
zero-cache:
|
zero-cache:
|
||||||
image: rocicorp/zero:0.26.2
|
image: rocicorp/zero:1.4.0
|
||||||
ports:
|
ports:
|
||||||
- "${ZERO_CACHE_PORT:-5929}:4848"
|
- "${ZERO_CACHE_PORT:-5929}:4848"
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
|
|
@ -182,10 +210,21 @@ services:
|
||||||
ZERO_MUTATE_URL: ${ZERO_MUTATE_URL:-http://frontend:3000/api/zero/mutate}
|
ZERO_MUTATE_URL: ${ZERO_MUTATE_URL:-http://frontend:3000/api/zero/mutate}
|
||||||
volumes:
|
volumes:
|
||||||
- zero_cache_data:/data
|
- zero_cache_data:/data
|
||||||
|
- zero_init:/zero-init
|
||||||
|
# Wrapper: if the migrations service flagged a publication change via
|
||||||
|
# /zero-init/needs_reset, wipe the SQLite replica before starting so
|
||||||
|
# zero-cache does a clean initial sync. Recovers from the half-built
|
||||||
|
# replica state (`_zero.tableMetadata` missing) caused by earlier crashes.
|
||||||
|
entrypoint: ["sh", "-c"]
|
||||||
|
# Pass the script as a single list element so Compose does not tokenize it.
|
||||||
|
command:
|
||||||
|
- 'if [ -f /zero-init/needs_reset ]; then echo "[zero-init] publication change detected; wiping replica file(s) under /data" && rm -f /data/zero.db /data/zero.db-shm /data/zero.db-wal && rm -f /zero-init/needs_reset; fi; exec zero-cache'
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
backend:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
migrations:
|
||||||
|
condition: service_completed_successfully
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:4848/keepalive"]
|
test: ["CMD", "curl", "-f", "http://localhost:4848/keepalive"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
|
|
@ -221,3 +260,5 @@ volumes:
|
||||||
name: surfsense-shared-temp
|
name: surfsense-shared-temp
|
||||||
zero_cache_data:
|
zero_cache_data:
|
||||||
name: surfsense-zero-cache
|
name: surfsense-zero-cache
|
||||||
|
zero_init:
|
||||||
|
name: surfsense-zero-init
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,161 @@ function Wait-ForPostgres {
|
||||||
Write-Ok "PostgreSQL is ready."
|
Write-Ok "PostgreSQL is ready."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ── Stack health helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function Get-ComposeServices {
|
||||||
|
Push-Location $InstallDir
|
||||||
|
try {
|
||||||
|
$raw = Invoke-NativeSafe { docker compose ps -a --format json 2>$null }
|
||||||
|
} finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
|
if ([string]::IsNullOrWhiteSpace($raw)) { return @() }
|
||||||
|
|
||||||
|
# Compose v2.21+ emits a JSON array; older versions emit one object per line.
|
||||||
|
try {
|
||||||
|
$parsed = $raw | ConvertFrom-Json
|
||||||
|
if ($parsed -is [System.Collections.IEnumerable] -and -not ($parsed -is [string])) {
|
||||||
|
return @($parsed)
|
||||||
|
}
|
||||||
|
return @($parsed)
|
||||||
|
} catch {
|
||||||
|
$services = @()
|
||||||
|
foreach ($line in ($raw -split "`r?`n")) {
|
||||||
|
$line = $line.Trim()
|
||||||
|
if (-not $line) { continue }
|
||||||
|
try { $services += ($line | ConvertFrom-Json) } catch { }
|
||||||
|
}
|
||||||
|
return $services
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Wait-StackHealthy {
|
||||||
|
param([int]$TimeoutSec = 300)
|
||||||
|
|
||||||
|
$deadline = (Get-Date).AddSeconds($TimeoutSec)
|
||||||
|
$lastReport = ""
|
||||||
|
|
||||||
|
while ((Get-Date) -lt $deadline) {
|
||||||
|
$services = Get-ComposeServices
|
||||||
|
if (-not $services -or $services.Count -eq 0) {
|
||||||
|
Start-Sleep -Seconds 3
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
$bad = @()
|
||||||
|
$waiting = @()
|
||||||
|
$good = @()
|
||||||
|
|
||||||
|
foreach ($svc in $services) {
|
||||||
|
$name = $svc.Service
|
||||||
|
$state = $svc.State
|
||||||
|
$health = if ($svc.PSObject.Properties.Name -contains 'Health') { $svc.Health } else { '' }
|
||||||
|
$exit = if ($svc.PSObject.Properties.Name -contains 'ExitCode') { $svc.ExitCode } else { $null }
|
||||||
|
|
||||||
|
if ($name -eq 'migrations') {
|
||||||
|
if ($state -eq 'exited' -and $exit -eq 0) { $good += $name }
|
||||||
|
elseif ($state -eq 'exited') { $bad += "${name} (exit=${exit})" }
|
||||||
|
else { $waiting += "${name} (${state})" }
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($state -eq 'running') {
|
||||||
|
if ([string]::IsNullOrEmpty($health) -or $health -eq 'healthy') {
|
||||||
|
$good += $name
|
||||||
|
} elseif ($health -eq 'starting') {
|
||||||
|
$waiting += "${name} (starting)"
|
||||||
|
} elseif ($health -eq 'unhealthy') {
|
||||||
|
$bad += "${name} (unhealthy)"
|
||||||
|
} else {
|
||||||
|
$waiting += "${name} (${health})"
|
||||||
|
}
|
||||||
|
} elseif ($state -eq 'restarting') {
|
||||||
|
$bad += "${name} (restarting)"
|
||||||
|
} elseif ($state -eq 'exited') {
|
||||||
|
$bad += "${name} (exited, code=${exit})"
|
||||||
|
} else {
|
||||||
|
$waiting += "${name} (${state})"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($bad.Count -gt 0) {
|
||||||
|
return @{ Ok = $false; Reason = 'failure'; Bad = $bad; Waiting = $waiting; Good = $good }
|
||||||
|
}
|
||||||
|
if ($waiting.Count -eq 0) {
|
||||||
|
return @{ Ok = $true; Reason = 'all_healthy'; Good = $good }
|
||||||
|
}
|
||||||
|
|
||||||
|
$report = "Waiting on: " + ($waiting -join ', ')
|
||||||
|
if ($report -ne $lastReport) {
|
||||||
|
Write-Info $report
|
||||||
|
$lastReport = $report
|
||||||
|
}
|
||||||
|
Start-Sleep -Seconds 5
|
||||||
|
}
|
||||||
|
|
||||||
|
return @{ Ok = $false; Reason = 'timeout'; Bad = $bad; Waiting = $waiting; Good = $good }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-StaleZeroCacheVolume {
|
||||||
|
$raw = Invoke-NativeSafe { docker volume ls --format '{{.Name}}' 2>$null }
|
||||||
|
if ([string]::IsNullOrWhiteSpace($raw)) { return $false }
|
||||||
|
$names = $raw -split "`r?`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ }
|
||||||
|
$hasZeroCache = $names -contains 'surfsense-zero-cache'
|
||||||
|
$hasZeroInit = $names -contains 'surfsense-zero-init'
|
||||||
|
# Pre-fix installs created surfsense-zero-cache but never surfsense-zero-init.
|
||||||
|
# Such a volume may hold a half-initialized SQLite replica from an earlier
|
||||||
|
# crash-loop. Wiping it forces zero-cache to do a fresh initial sync.
|
||||||
|
return ($hasZeroCache -and -not $hasZeroInit)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-StaleZeroCacheCleanup {
|
||||||
|
if (-not (Test-StaleZeroCacheVolume)) { return }
|
||||||
|
|
||||||
|
Write-Warn "Detected pre-existing 'surfsense-zero-cache' volume from an install that"
|
||||||
|
Write-Warn "predates the migrations-service fix. It may contain a half-initialized"
|
||||||
|
Write-Warn "SQLite replica that would block zero-cache from starting."
|
||||||
|
Write-Warn "The volume will be removed in 5 seconds; press Ctrl+C to cancel."
|
||||||
|
Start-Sleep -Seconds 5
|
||||||
|
|
||||||
|
Push-Location $InstallDir
|
||||||
|
Invoke-NativeSafe { docker compose down --remove-orphans 2>$null } | Out-Null
|
||||||
|
Pop-Location
|
||||||
|
Invoke-NativeSafe { docker volume rm surfsense-zero-cache 2>$null } | Out-Null
|
||||||
|
Write-Ok "Removed surfsense-zero-cache volume; zero-cache will re-sync on next start."
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-Err-NoExit {
|
||||||
|
param([string]$Message)
|
||||||
|
Write-Host "[ERROR] $Message" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-StackFailureReport {
|
||||||
|
param([hashtable]$Result)
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Err-NoExit "Stack did not reach a healthy state."
|
||||||
|
if ($Result.Bad.Count -gt 0) { Write-Host (" Failed: " + ($Result.Bad -join ', ')) }
|
||||||
|
if ($Result.Waiting.Count -gt 0) { Write-Host (" Stuck: " + ($Result.Waiting -join ', ')) }
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Info "Recent logs from migrations / zero-cache / backend:"
|
||||||
|
Push-Location $InstallDir
|
||||||
|
try {
|
||||||
|
Invoke-NativeSafe { docker compose logs --tail=60 migrations zero-cache backend 2>&1 } | Write-Host
|
||||||
|
} finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Recovery hints:" -ForegroundColor Yellow
|
||||||
|
Write-Host " 1. Inspect migrations: cd $InstallDir; docker compose logs migrations"
|
||||||
|
Write-Host " 2. Verify publication: cd $InstallDir; docker compose exec db psql -U surfsense -d surfsense -c 'SELECT pubname FROM pg_publication;'"
|
||||||
|
Write-Host " 3. Hard reset zero db: cd $InstallDir; docker compose down; docker volume rm surfsense-zero-cache; docker compose up -d"
|
||||||
|
Write-Host ""
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
# ── Download files ──────────────────────────────────────────────────────────
|
# ── Download files ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
Write-Step "Downloading SurfSense files"
|
Write-Step "Downloading SurfSense files"
|
||||||
|
|
@ -191,6 +346,8 @@ if (-not (Test-Path $envPath)) {
|
||||||
|
|
||||||
# ── Start containers ────────────────────────────────────────────────────────
|
# ── Start containers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Invoke-StaleZeroCacheCleanup
|
||||||
|
|
||||||
if ($MigrationMode) {
|
if ($MigrationMode) {
|
||||||
$envContent = Get-Content $envPath
|
$envContent = Get-Content $envPath
|
||||||
$DbUser = ($envContent | Select-String '^DB_USER=' | ForEach-Object { ($_ -split '=',2)[1].Trim('"') }) | Select-Object -First 1
|
$DbUser = ($envContent | Select-String '^DB_USER=' | ForEach-Object { ($_ -split '=',2)[1].Trim('"') }) | Select-Object -First 1
|
||||||
|
|
@ -251,7 +408,13 @@ if ($MigrationMode) {
|
||||||
Push-Location $InstallDir
|
Push-Location $InstallDir
|
||||||
Invoke-NativeSafe { docker compose up -d }
|
Invoke-NativeSafe { docker compose up -d }
|
||||||
Pop-Location
|
Pop-Location
|
||||||
Write-Ok "All services started."
|
Write-Ok "All containers started; waiting for stack to become healthy..."
|
||||||
|
|
||||||
|
$waitResult = Wait-StackHealthy -TimeoutSec 300
|
||||||
|
if (-not $waitResult.Ok) {
|
||||||
|
Invoke-StackFailureReport -Result $waitResult
|
||||||
|
}
|
||||||
|
Write-Ok "All services healthy."
|
||||||
|
|
||||||
Remove-Item $KeyFile -ErrorAction SilentlyContinue
|
Remove-Item $KeyFile -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
|
@ -260,7 +423,13 @@ if ($MigrationMode) {
|
||||||
Push-Location $InstallDir
|
Push-Location $InstallDir
|
||||||
Invoke-NativeSafe { docker compose up -d }
|
Invoke-NativeSafe { docker compose up -d }
|
||||||
Pop-Location
|
Pop-Location
|
||||||
Write-Ok "All services started."
|
Write-Ok "All containers started; waiting for stack to become healthy..."
|
||||||
|
|
||||||
|
$waitResult = Wait-StackHealthy -TimeoutSec 300
|
||||||
|
if (-not $waitResult.Ok) {
|
||||||
|
Invoke-StackFailureReport -Result $waitResult
|
||||||
|
}
|
||||||
|
Write-Ok "All services healthy."
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Watchtower (auto-update) ────────────────────────────────────────────────
|
# ── Watchtower (auto-update) ────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,163 @@ wait_for_pg() {
|
||||||
success "PostgreSQL is ready."
|
success "PostgreSQL is ready."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ── Stack health helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Enumerate compose services for project `surfsense` as `service|state|health|exitcode`
|
||||||
|
# lines. Uses `docker inspect` so we don't depend on `jq`, `python3`, or the
|
||||||
|
# exact ordering of fields in `docker compose ps --format json` output.
|
||||||
|
get_compose_services() {
|
||||||
|
local containers
|
||||||
|
containers=$(docker ps -a --filter "label=com.docker.compose.project=surfsense" --format '{{.Names}}' 2>/dev/null) || true
|
||||||
|
[[ -z "$containers" ]] && return 0
|
||||||
|
|
||||||
|
while IFS= read -r container; do
|
||||||
|
[[ -z "$container" ]] && continue
|
||||||
|
local svc state health code
|
||||||
|
svc=$(docker inspect -f '{{index .Config.Labels "com.docker.compose.service"}}' "$container" 2>/dev/null || echo "")
|
||||||
|
state=$(docker inspect -f '{{.State.Status}}' "$container" 2>/dev/null || echo "unknown")
|
||||||
|
health=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$container" 2>/dev/null || echo "")
|
||||||
|
code=$(docker inspect -f '{{.State.ExitCode}}' "$container" 2>/dev/null || echo "")
|
||||||
|
[[ -z "$svc" ]] && continue
|
||||||
|
printf '%s|%s|%s|%s\n' "$svc" "$state" "$health" "$code"
|
||||||
|
done <<< "$containers"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Globals populated by wait_stack_healthy / consumed by stack_failure_report.
|
||||||
|
STACK_BAD=()
|
||||||
|
STACK_WAITING=()
|
||||||
|
STACK_GOOD=()
|
||||||
|
STACK_TIMEOUT=false
|
||||||
|
|
||||||
|
wait_stack_healthy() {
|
||||||
|
local timeout_sec=${1:-300}
|
||||||
|
local deadline=$(($(date +%s) + timeout_sec))
|
||||||
|
local last_report=""
|
||||||
|
local bad=()
|
||||||
|
local waiting=()
|
||||||
|
local good=()
|
||||||
|
|
||||||
|
while [[ $(date +%s) -lt $deadline ]]; do
|
||||||
|
local lines
|
||||||
|
lines=$(get_compose_services)
|
||||||
|
if [[ -z "$lines" ]]; then
|
||||||
|
sleep 3
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
bad=()
|
||||||
|
waiting=()
|
||||||
|
good=()
|
||||||
|
|
||||||
|
while IFS='|' read -r name state health code; do
|
||||||
|
[[ -z "$name" ]] && continue
|
||||||
|
if [[ "$name" == "migrations" ]]; then
|
||||||
|
if [[ "$state" == "exited" && "$code" == "0" ]]; then
|
||||||
|
good+=("$name")
|
||||||
|
elif [[ "$state" == "exited" ]]; then
|
||||||
|
bad+=("${name} (exit=${code})")
|
||||||
|
else
|
||||||
|
waiting+=("${name} (${state})")
|
||||||
|
fi
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$state" == "running" ]]; then
|
||||||
|
if [[ -z "$health" || "$health" == "healthy" ]]; then
|
||||||
|
good+=("$name")
|
||||||
|
elif [[ "$health" == "starting" ]]; then
|
||||||
|
waiting+=("${name} (starting)")
|
||||||
|
elif [[ "$health" == "unhealthy" ]]; then
|
||||||
|
bad+=("${name} (unhealthy)")
|
||||||
|
else
|
||||||
|
waiting+=("${name} (${health})")
|
||||||
|
fi
|
||||||
|
elif [[ "$state" == "restarting" ]]; then
|
||||||
|
bad+=("${name} (restarting)")
|
||||||
|
elif [[ "$state" == "exited" ]]; then
|
||||||
|
bad+=("${name} (exited, code=${code})")
|
||||||
|
else
|
||||||
|
waiting+=("${name} (${state})")
|
||||||
|
fi
|
||||||
|
done <<< "$lines"
|
||||||
|
|
||||||
|
if (( ${#bad[@]} > 0 )); then
|
||||||
|
STACK_BAD=("${bad[@]}")
|
||||||
|
STACK_WAITING=("${waiting[@]}")
|
||||||
|
STACK_GOOD=("${good[@]}")
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if (( ${#waiting[@]} == 0 )); then
|
||||||
|
STACK_GOOD=("${good[@]}")
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local report="Waiting on: ${waiting[*]}"
|
||||||
|
if [[ "$report" != "$last_report" ]]; then
|
||||||
|
info "$report"
|
||||||
|
last_report="$report"
|
||||||
|
fi
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
|
||||||
|
# bad/waiting/good are declared at function scope so referencing them is
|
||||||
|
# safe even if the polling loop never executed its body.
|
||||||
|
STACK_BAD=()
|
||||||
|
[[ ${#bad[@]} -gt 0 ]] && STACK_BAD=("${bad[@]}")
|
||||||
|
STACK_WAITING=()
|
||||||
|
[[ ${#waiting[@]} -gt 0 ]] && STACK_WAITING=("${waiting[@]}")
|
||||||
|
STACK_GOOD=()
|
||||||
|
[[ ${#good[@]} -gt 0 ]] && STACK_GOOD=("${good[@]}")
|
||||||
|
STACK_TIMEOUT=true
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
stack_failure_report() {
|
||||||
|
echo ""
|
||||||
|
echo -e "\033[31m[ERROR]\033[0m Stack did not reach a healthy state."
|
||||||
|
if (( ${#STACK_BAD[@]} > 0 )) && [[ -n "${STACK_BAD[0]}" ]]; then
|
||||||
|
echo " Failed: ${STACK_BAD[*]}"
|
||||||
|
fi
|
||||||
|
if (( ${#STACK_WAITING[@]} > 0 )) && [[ -n "${STACK_WAITING[0]}" ]]; then
|
||||||
|
echo " Stuck: ${STACK_WAITING[*]}"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
info "Recent logs from migrations / zero-cache / backend:"
|
||||||
|
(cd "${INSTALL_DIR}" && ${DC} logs --tail=60 migrations zero-cache backend 2>&1) || true
|
||||||
|
echo ""
|
||||||
|
echo "Recovery hints:"
|
||||||
|
echo " 1. Inspect migrations: cd ${INSTALL_DIR} && ${DC} logs migrations"
|
||||||
|
echo " 2. Verify publication: cd ${INSTALL_DIR} && ${DC} exec db psql -U surfsense -d surfsense -c 'SELECT pubname FROM pg_publication;'"
|
||||||
|
echo " 3. Hard reset zero db: cd ${INSTALL_DIR} && ${DC} down && docker volume rm surfsense-zero-cache && ${DC} up -d"
|
||||||
|
echo ""
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# True if `surfsense-zero-cache` exists but `surfsense-zero-init` does not.
|
||||||
|
# That signals an install that predates the migrations-service fix; the old
|
||||||
|
# replica may be half-initialized and would block zero-cache on next start.
|
||||||
|
test_stale_zero_cache_volume() {
|
||||||
|
local has_zc has_zi
|
||||||
|
has_zc=$(docker volume ls --format '{{.Name}}' 2>/dev/null | grep -Fx 'surfsense-zero-cache' || true)
|
||||||
|
has_zi=$(docker volume ls --format '{{.Name}}' 2>/dev/null | grep -Fx 'surfsense-zero-init' || true)
|
||||||
|
[[ -n "$has_zc" && -z "$has_zi" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
invoke_stale_zero_cache_cleanup() {
|
||||||
|
if ! test_stale_zero_cache_volume; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
warn "Detected pre-existing 'surfsense-zero-cache' volume from an install that"
|
||||||
|
warn "predates the migrations-service fix. It may contain a half-initialized"
|
||||||
|
warn "SQLite replica that would block zero-cache from starting."
|
||||||
|
warn "The volume will be removed in 5 seconds; press Ctrl+C to cancel."
|
||||||
|
sleep 5
|
||||||
|
|
||||||
|
(cd "${INSTALL_DIR}" && ${DC} down --remove-orphans 2>/dev/null) || true
|
||||||
|
docker volume rm surfsense-zero-cache 2>/dev/null || true
|
||||||
|
success "Removed surfsense-zero-cache volume; zero-cache will re-sync on next start."
|
||||||
|
}
|
||||||
|
|
||||||
# ── Download files ───────────────────────────────────────────────────────────
|
# ── Download files ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
step "Downloading SurfSense files"
|
step "Downloading SurfSense files"
|
||||||
|
|
@ -186,6 +343,8 @@ fi
|
||||||
|
|
||||||
# ── Start containers ─────────────────────────────────────────────────────────
|
# ── Start containers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
invoke_stale_zero_cache_cleanup
|
||||||
|
|
||||||
if $MIGRATION_MODE; then
|
if $MIGRATION_MODE; then
|
||||||
# Read DB credentials from .env (fall back to defaults from docker-compose.yml)
|
# Read DB credentials from .env (fall back to defaults from docker-compose.yml)
|
||||||
DB_USER=$(grep '^DB_USER=' "${INSTALL_DIR}/.env" 2>/dev/null | cut -d= -f2 | tr -d '"' | head -1 || true)
|
DB_USER=$(grep '^DB_USER=' "${INSTALL_DIR}/.env" 2>/dev/null | cut -d= -f2 | tr -d '"' | head -1 || true)
|
||||||
|
|
@ -243,7 +402,12 @@ if $MIGRATION_MODE; then
|
||||||
|
|
||||||
step "Starting all SurfSense services"
|
step "Starting all SurfSense services"
|
||||||
(cd "${INSTALL_DIR}" && ${DC} up -d) < /dev/null
|
(cd "${INSTALL_DIR}" && ${DC} up -d) < /dev/null
|
||||||
success "All services started."
|
success "All containers started; waiting for stack to become healthy..."
|
||||||
|
|
||||||
|
if ! wait_stack_healthy 300; then
|
||||||
|
stack_failure_report
|
||||||
|
fi
|
||||||
|
success "All services healthy."
|
||||||
|
|
||||||
# Key file is no longer needed — SECRET_KEY is now in .env
|
# Key file is no longer needed — SECRET_KEY is now in .env
|
||||||
rm -f "${KEY_FILE}"
|
rm -f "${KEY_FILE}"
|
||||||
|
|
@ -251,7 +415,12 @@ if $MIGRATION_MODE; then
|
||||||
else
|
else
|
||||||
step "Starting SurfSense"
|
step "Starting SurfSense"
|
||||||
(cd "${INSTALL_DIR}" && ${DC} up -d) < /dev/null
|
(cd "${INSTALL_DIR}" && ${DC} up -d) < /dev/null
|
||||||
success "All services started."
|
success "All containers started; waiting for stack to become healthy..."
|
||||||
|
|
||||||
|
if ! wait_stack_healthy 300; then
|
||||||
|
stack_failure_report
|
||||||
|
fi
|
||||||
|
success "All services healthy."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Watchtower (auto-update) ─────────────────────────────────────────────────
|
# ── Watchtower (auto-update) ─────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -167,10 +167,14 @@ COPY scripts/docker/entrypoint.sh /app/scripts/docker/entrypoint.sh
|
||||||
RUN dos2unix /app/scripts/docker/entrypoint.sh && chmod +x /app/scripts/docker/entrypoint.sh
|
RUN dos2unix /app/scripts/docker/entrypoint.sh && chmod +x /app/scripts/docker/entrypoint.sh
|
||||||
|
|
||||||
# SERVICE_ROLE controls which process this container runs:
|
# SERVICE_ROLE controls which process this container runs:
|
||||||
# api – FastAPI backend only (runs migrations on startup)
|
# migrate – Run alembic upgrade head, verify zero_publication exists, exit 0.
|
||||||
|
# Used by the dedicated `migrations` service in docker-compose.yml
|
||||||
|
# so downstream services gate on `service_completed_successfully`.
|
||||||
|
# api – FastAPI backend only (does NOT run migrations)
|
||||||
# worker – Celery worker only
|
# worker – Celery worker only
|
||||||
# beat – Celery beat scheduler only
|
# beat – Celery beat scheduler only
|
||||||
# all – All three (legacy / dev default)
|
# all – migrations + api + worker + beat (legacy / dev default;
|
||||||
|
# fails fast on migration error)
|
||||||
ENV SERVICE_ROLE=all
|
ENV SERVICE_ROLE=all
|
||||||
|
|
||||||
# Celery worker tuning (only used when SERVICE_ROLE=worker or all)
|
# Celery worker tuning (only used when SERVICE_ROLE=worker or all)
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,17 @@ queries via Zero, instead of replicating all tables in public schema.
|
||||||
|
|
||||||
See: https://zero.rocicorp.dev/docs/zero-cache-config#app-publications
|
See: https://zero.rocicorp.dev/docs/zero-cache-config#app-publications
|
||||||
|
|
||||||
|
NOTE for future migration authors: this is the ONLY migration allowed
|
||||||
|
to use bare ``CREATE PUBLICATION``. All subsequent mutations of
|
||||||
|
``zero_publication`` MUST use the ``COMMENT ON PUBLICATION`` bookend
|
||||||
|
pattern wrapping an ``ALTER PUBLICATION ... SET TABLE`` -- copy the
|
||||||
|
``upgrade()`` function from migration
|
||||||
|
``143_force_zero_publication_resync.py`` as your starting template.
|
||||||
|
Raw ``DROP``/``CREATE PUBLICATION`` in new migrations would
|
||||||
|
re-introduce bug #1355 (zero-cache stuck on a stale replica snapshot
|
||||||
|
because Zero >= 1.0's change-streamer never sees the schema-change
|
||||||
|
event).
|
||||||
|
|
||||||
Revision ID: 116
|
Revision ID: 116
|
||||||
Revises: 115
|
Revises: 115
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,16 @@ IMPORTANT — before AND after running this migration:
|
||||||
3. Delete / reset the zero-cache data volume
|
3. Delete / reset the zero-cache data volume
|
||||||
4. Restart zero-cache (it will do a fresh initial sync)
|
4. Restart zero-cache (it will do a fresh initial sync)
|
||||||
|
|
||||||
|
DO NOT COPY THIS PATTERN. The ``DROP PUBLICATION`` + ``CREATE
|
||||||
|
PUBLICATION`` dance below is the pre-#1355 anti-pattern: on Zero >=
|
||||||
|
1.0 it does not reliably wake the zero-cache change-streamer and can
|
||||||
|
leave the replica pinned to a stale snapshot. This file is
|
||||||
|
grandfathered in because it has already shipped to users; new
|
||||||
|
publication mutations MUST use the ``COMMENT ON PUBLICATION`` bookend
|
||||||
|
pattern wrapping an ``ALTER PUBLICATION ... SET TABLE`` -- copy the
|
||||||
|
``upgrade()`` function from migration
|
||||||
|
``143_force_zero_publication_resync.py`` as your starting template.
|
||||||
|
|
||||||
Revision ID: 117
|
Revision ID: 117
|
||||||
Revises: 116
|
Revises: 116
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,16 @@
|
||||||
"""Add LOCAL_FOLDER_FILE document type, folder metadata, and document_versions table
|
"""Add LOCAL_FOLDER_FILE document type, folder metadata, and document_versions table
|
||||||
|
|
||||||
|
DO NOT COPY THIS PATTERN. The bare ``ALTER PUBLICATION ... ADD/DROP
|
||||||
|
TABLE`` calls below pre-date the ``COMMENT ON PUBLICATION`` bookend
|
||||||
|
fix for bug #1355: on Zero >= 1.0 they do not reliably wake the
|
||||||
|
zero-cache change-streamer and can leave the replica pinned to a
|
||||||
|
stale snapshot. This file is grandfathered in because it has already
|
||||||
|
shipped to users; new publication mutations MUST use the
|
||||||
|
``COMMENT ON PUBLICATION`` bookend pattern wrapping an
|
||||||
|
``ALTER PUBLICATION ... SET TABLE`` -- copy the ``upgrade()`` function
|
||||||
|
from migration ``143_force_zero_publication_resync.py`` as your
|
||||||
|
starting template.
|
||||||
|
|
||||||
Revision ID: 118
|
Revision ID: 118
|
||||||
Revises: 117
|
Revises: 117
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,16 @@ IMPORTANT - before AND after running this migration:
|
||||||
3. Delete / reset the zero-cache data volume
|
3. Delete / reset the zero-cache data volume
|
||||||
4. Restart zero-cache (it will do a fresh initial sync)
|
4. Restart zero-cache (it will do a fresh initial sync)
|
||||||
|
|
||||||
|
DO NOT COPY THIS PATTERN. The ``DROP PUBLICATION`` + ``CREATE
|
||||||
|
PUBLICATION`` dance below is the pre-#1355 anti-pattern: on Zero >=
|
||||||
|
1.0 it does not reliably wake the zero-cache change-streamer and can
|
||||||
|
leave the replica pinned to a stale snapshot. This file is
|
||||||
|
grandfathered in because it has already shipped to users; new
|
||||||
|
publication mutations MUST use the ``COMMENT ON PUBLICATION`` bookend
|
||||||
|
pattern wrapping an ``ALTER PUBLICATION ... SET TABLE`` -- copy the
|
||||||
|
``upgrade()`` function from migration
|
||||||
|
``143_force_zero_publication_resync.py`` as your starting template.
|
||||||
|
|
||||||
Revision ID: 139
|
Revision ID: 139
|
||||||
Revises: 138
|
Revises: 138
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,16 @@ Skipping the zero-cache stop will deadlock at the ACCESS EXCLUSIVE LOCK on
|
||||||
"user". Skipping the data-volume reset will leave IndexedDB clients seeing
|
"user". Skipping the data-volume reset will leave IndexedDB clients seeing
|
||||||
column-not-found errors from a stale catalog snapshot.
|
column-not-found errors from a stale catalog snapshot.
|
||||||
|
|
||||||
|
DO NOT COPY THIS PATTERN. The ``DROP PUBLICATION`` + ``CREATE
|
||||||
|
PUBLICATION`` dance below is the pre-#1355 anti-pattern: on Zero >=
|
||||||
|
1.0 it does not reliably wake the zero-cache change-streamer and can
|
||||||
|
leave the replica pinned to a stale snapshot. This file is
|
||||||
|
grandfathered in because it has already shipped to users; new
|
||||||
|
publication mutations MUST use the ``COMMENT ON PUBLICATION`` bookend
|
||||||
|
pattern wrapping an ``ALTER PUBLICATION ... SET TABLE`` -- copy the
|
||||||
|
``upgrade()`` function from migration
|
||||||
|
``143_force_zero_publication_resync.py`` as your starting template.
|
||||||
|
|
||||||
Revision ID: 140
|
Revision ID: 140
|
||||||
Revises: 139
|
Revises: 139
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
"""force zero-cache to resync after upgrading to Zero >= 1.0
|
||||||
|
|
||||||
|
Re-emits the current ``zero_publication`` shape using
|
||||||
|
``ALTER PUBLICATION ... SET TABLE`` wrapped in
|
||||||
|
``COMMENT ON PUBLICATION`` bookends. This is the publication-change
|
||||||
|
hook documented for Zero ``>=1.0``:
|
||||||
|
|
||||||
|
https://zero.rocicorp.dev/docs/connecting-to-postgres#publication-changes
|
||||||
|
|
||||||
|
Background
|
||||||
|
----------
|
||||||
|
Migrations 117 / 139 / 140 mutated ``zero_publication`` using
|
||||||
|
``DROP PUBLICATION`` + ``CREATE PUBLICATION``. On Zero 0.26.2 that
|
||||||
|
sequence did not reliably wake the zero-cache change-streamer, so
|
||||||
|
affected installs ended up with a SQLite replica file (in the
|
||||||
|
``surfsense-zero-cache`` volume) that was snapshotted against the
|
||||||
|
pre-``user`` publication. The frontend Zero schema includes a
|
||||||
|
``userTable`` query, which then failed with
|
||||||
|
``SchemaVersionNotSupported`` and triggered the default
|
||||||
|
``onUpdateNeeded`` -> ``location.reload()`` every WebSocket keepalive
|
||||||
|
interval (~60s). See bug #1355.
|
||||||
|
|
||||||
|
This migration emits the canonical publication shape one more time,
|
||||||
|
this time using a pattern that fires Postgres event triggers and
|
||||||
|
Zero's schema-change hook. With ``ZERO_AUTO_RESET=true`` (the default)
|
||||||
|
and Zero ``>=1.0``, zero-cache responds by wiping its replica and
|
||||||
|
doing a fresh initial sync from the corrected publication.
|
||||||
|
|
||||||
|
The publication shape itself is unchanged versus migration 140 -- on
|
||||||
|
installs whose replica is already correct, this is a no-op aside
|
||||||
|
from the harmless event-trigger fire.
|
||||||
|
|
||||||
|
Revision ID: 143
|
||||||
|
Revises: 142
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "143"
|
||||||
|
down_revision: str | None = "142"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
PUBLICATION_NAME = "zero_publication"
|
||||||
|
|
||||||
|
# Must stay in sync with the column lists in migrations 117 / 139 / 140.
|
||||||
|
DOCUMENT_COLS = [
|
||||||
|
"id",
|
||||||
|
"title",
|
||||||
|
"document_type",
|
||||||
|
"search_space_id",
|
||||||
|
"folder_id",
|
||||||
|
"created_by_id",
|
||||||
|
"status",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
]
|
||||||
|
|
||||||
|
USER_COLS = [
|
||||||
|
"id",
|
||||||
|
"pages_limit",
|
||||||
|
"pages_used",
|
||||||
|
"premium_credit_micros_limit",
|
||||||
|
"premium_credit_micros_used",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _has_zero_version(conn, table: str) -> bool:
|
||||||
|
return (
|
||||||
|
conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT 1 FROM information_schema.columns "
|
||||||
|
"WHERE table_name = :tbl AND column_name = '_0_version'"
|
||||||
|
),
|
||||||
|
{"tbl": table},
|
||||||
|
).fetchone()
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_set_table_ddl(
|
||||||
|
*, documents_has_zero_ver: bool, user_has_zero_ver: bool
|
||||||
|
) -> str:
|
||||||
|
doc_cols = DOCUMENT_COLS + (['"_0_version"'] if documents_has_zero_ver else [])
|
||||||
|
user_cols = USER_COLS + (['"_0_version"'] if user_has_zero_ver else [])
|
||||||
|
doc_col_list = ", ".join(doc_cols)
|
||||||
|
user_col_list = ", ".join(user_cols)
|
||||||
|
return (
|
||||||
|
f"ALTER PUBLICATION {PUBLICATION_NAME} SET TABLE "
|
||||||
|
f"notifications, "
|
||||||
|
f"documents ({doc_col_list}), "
|
||||||
|
f"folders, "
|
||||||
|
f"search_source_connectors, "
|
||||||
|
f"new_chat_messages, "
|
||||||
|
f"chat_comments, "
|
||||||
|
f"chat_session_state, "
|
||||||
|
f'"user" ({user_col_list})'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
exists = conn.execute(
|
||||||
|
sa.text("SELECT 1 FROM pg_publication WHERE pubname = :name"),
|
||||||
|
{"name": PUBLICATION_NAME},
|
||||||
|
).fetchone()
|
||||||
|
if not exists:
|
||||||
|
return
|
||||||
|
|
||||||
|
documents_has_zero_ver = _has_zero_version(conn, "documents")
|
||||||
|
user_has_zero_ver = _has_zero_version(conn, "user")
|
||||||
|
|
||||||
|
# The COMMENT-ALTER-COMMENT trio MUST run in a single transaction so
|
||||||
|
# Zero observes them as one schema-change event. Alembic's outer
|
||||||
|
# transaction already covers us, but a SAVEPOINT keeps the trio
|
||||||
|
# atomic with asyncpg, matching the pattern used in migrations
|
||||||
|
# 117 / 139 / 140.
|
||||||
|
tx = conn.begin_nested() if conn.in_transaction() else conn.begin()
|
||||||
|
with tx:
|
||||||
|
conn.execute(
|
||||||
|
sa.text(f"COMMENT ON PUBLICATION {PUBLICATION_NAME} IS 'pre-143-resync'")
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
sa.text(
|
||||||
|
_build_set_table_ddl(
|
||||||
|
documents_has_zero_ver=documents_has_zero_ver,
|
||||||
|
user_has_zero_ver=user_has_zero_ver,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
sa.text(f"COMMENT ON PUBLICATION {PUBLICATION_NAME} IS 'post-143-resync'")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""No-op. The publication shape is unchanged versus migration 140."""
|
||||||
|
|
@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.db import SurfsenseDocsChunk, SurfsenseDocsDocument
|
from app.db import SurfsenseDocsChunk, SurfsenseDocsDocument
|
||||||
from app.utils.document_converters import embed_text
|
from app.utils.document_converters import embed_text
|
||||||
|
from app.utils.surfsense_docs import surfsense_docs_public_url
|
||||||
|
|
||||||
|
|
||||||
def format_surfsense_docs_results(results: list[tuple]) -> str:
|
def format_surfsense_docs_results(results: list[tuple]) -> str:
|
||||||
|
|
@ -19,13 +20,14 @@ def format_surfsense_docs_results(results: list[tuple]) -> str:
|
||||||
# Group chunks by document
|
# Group chunks by document
|
||||||
grouped: dict[int, dict] = {}
|
grouped: dict[int, dict] = {}
|
||||||
for chunk, doc in results:
|
for chunk, doc in results:
|
||||||
|
public_url = surfsense_docs_public_url(doc.source)
|
||||||
if doc.id not in grouped:
|
if doc.id not in grouped:
|
||||||
grouped[doc.id] = {
|
grouped[doc.id] = {
|
||||||
"document_id": f"doc-{doc.id}",
|
"document_id": f"doc-{doc.id}",
|
||||||
"document_type": "SURFSENSE_DOCS",
|
"document_type": "SURFSENSE_DOCS",
|
||||||
"title": doc.title,
|
"title": doc.title,
|
||||||
"url": doc.source,
|
"url": public_url,
|
||||||
"metadata": {"source": doc.source},
|
"metadata": {"source": doc.source, "public_url": public_url},
|
||||||
"chunks": [],
|
"chunks": [],
|
||||||
}
|
}
|
||||||
grouped[doc.id]["chunks"].append(
|
grouped[doc.id]["chunks"].append(
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.db import SurfsenseDocsChunk, SurfsenseDocsDocument, async_session_maker
|
from app.db import SurfsenseDocsChunk, SurfsenseDocsDocument, async_session_maker
|
||||||
from app.utils.document_converters import embed_text
|
from app.utils.document_converters import embed_text
|
||||||
|
from app.utils.surfsense_docs import surfsense_docs_public_url
|
||||||
|
|
||||||
|
|
||||||
def format_surfsense_docs_results(results: list[tuple]) -> str:
|
def format_surfsense_docs_results(results: list[tuple]) -> str:
|
||||||
|
|
@ -40,13 +41,14 @@ def format_surfsense_docs_results(results: list[tuple]) -> str:
|
||||||
# Group chunks by document
|
# Group chunks by document
|
||||||
grouped: dict[int, dict] = {}
|
grouped: dict[int, dict] = {}
|
||||||
for chunk, doc in results:
|
for chunk, doc in results:
|
||||||
|
public_url = surfsense_docs_public_url(doc.source)
|
||||||
if doc.id not in grouped:
|
if doc.id not in grouped:
|
||||||
grouped[doc.id] = {
|
grouped[doc.id] = {
|
||||||
"document_id": f"doc-{doc.id}",
|
"document_id": f"doc-{doc.id}",
|
||||||
"document_type": "SURFSENSE_DOCS",
|
"document_type": "SURFSENSE_DOCS",
|
||||||
"title": doc.title,
|
"title": doc.title,
|
||||||
"url": doc.source,
|
"url": public_url,
|
||||||
"metadata": {"source": doc.source},
|
"metadata": {"source": doc.source, "public_url": public_url},
|
||||||
"chunks": [],
|
"chunks": [],
|
||||||
}
|
}
|
||||||
grouped[doc.id]["chunks"].append(
|
grouped[doc.id]["chunks"].append(
|
||||||
|
|
|
||||||
|
|
@ -945,6 +945,36 @@ async def health_check():
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/ready", tags=["health"])
|
||||||
|
@limiter.exempt
|
||||||
|
async def readiness_check():
|
||||||
|
"""Readiness probe.
|
||||||
|
|
||||||
|
Verifies that the schema state required by downstream services is
|
||||||
|
present. Specifically checks that the ``zero_publication`` Postgres
|
||||||
|
logical-replication publication exists; without it zero-cache crash-loops
|
||||||
|
on `Unknown or invalid publications`.
|
||||||
|
|
||||||
|
Returns 200 when ready, 503 otherwise. Used by the docker-compose
|
||||||
|
backend healthcheck and by ``install.ps1`` / ``install.sh`` post-up
|
||||||
|
verification.
|
||||||
|
"""
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from app.db import async_session_maker
|
||||||
|
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
text("SELECT 1 FROM pg_publication WHERE pubname = 'zero_publication'")
|
||||||
|
)
|
||||||
|
if result.first() is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="zero_publication missing; run alembic upgrade head",
|
||||||
|
)
|
||||||
|
return {"status": "ready"}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/verify-token")
|
@app.get("/verify-token")
|
||||||
async def authenticated_route(
|
async def authenticated_route(
|
||||||
user: User = Depends(current_active_user),
|
user: User = Depends(current_active_user),
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ from app.schemas.surfsense_docs import (
|
||||||
SurfsenseDocsDocumentWithChunksRead,
|
SurfsenseDocsDocumentWithChunksRead,
|
||||||
)
|
)
|
||||||
from app.users import current_active_user
|
from app.users import current_active_user
|
||||||
|
from app.utils.surfsense_docs import surfsense_docs_public_url
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
@ -76,6 +77,7 @@ async def get_surfsense_doc_by_chunk_id(
|
||||||
id=document.id,
|
id=document.id,
|
||||||
title=document.title,
|
title=document.title,
|
||||||
source=document.source,
|
source=document.source,
|
||||||
|
public_url=surfsense_docs_public_url(document.source),
|
||||||
content=document.content,
|
content=document.content,
|
||||||
chunks=[
|
chunks=[
|
||||||
SurfsenseDocsChunkRead(id=c.id, content=c.content)
|
SurfsenseDocsChunkRead(id=c.id, content=c.content)
|
||||||
|
|
@ -146,6 +148,7 @@ async def list_surfsense_docs(
|
||||||
id=doc.id,
|
id=doc.id,
|
||||||
title=doc.title,
|
title=doc.title,
|
||||||
source=doc.source,
|
source=doc.source,
|
||||||
|
public_url=surfsense_docs_public_url(doc.source),
|
||||||
content=doc.content,
|
content=doc.content,
|
||||||
created_at=doc.created_at,
|
created_at=doc.created_at,
|
||||||
updated_at=doc.updated_at,
|
updated_at=doc.updated_at,
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ class SurfsenseDocsDocumentRead(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
title: str
|
title: str
|
||||||
source: str
|
source: str
|
||||||
|
public_url: str
|
||||||
content: str
|
content: str
|
||||||
created_at: datetime | None = None
|
created_at: datetime | None = None
|
||||||
updated_at: datetime | None = None
|
updated_at: datetime | None = None
|
||||||
|
|
@ -35,6 +36,7 @@ class SurfsenseDocsDocumentWithChunksRead(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
title: str
|
title: str
|
||||||
source: str
|
source: str
|
||||||
|
public_url: str
|
||||||
content: str
|
content: str
|
||||||
chunks: list[SurfsenseDocsChunkRead]
|
chunks: list[SurfsenseDocsChunkRead]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,7 @@ from app.tasks.chat.streaming.helpers.interrupt_inspector import (
|
||||||
)
|
)
|
||||||
from app.utils.content_utils import bootstrap_history_from_db
|
from app.utils.content_utils import bootstrap_history_from_db
|
||||||
from app.utils.perf import get_perf_logger, log_system_snapshot, trim_native_heap
|
from app.utils.perf import get_perf_logger, log_system_snapshot, trim_native_heap
|
||||||
|
from app.utils.surfsense_docs import surfsense_docs_public_url
|
||||||
from app.utils.user_message_multimodal import build_human_message_content
|
from app.utils.user_message_multimodal import build_human_message_content
|
||||||
|
|
||||||
_background_tasks: set[asyncio.Task] = set()
|
_background_tasks: set[asyncio.Task] = set()
|
||||||
|
|
@ -216,14 +217,17 @@ def format_mentioned_surfsense_docs_as_context(
|
||||||
)
|
)
|
||||||
|
|
||||||
for doc in documents:
|
for doc in documents:
|
||||||
metadata_json = json.dumps({"source": doc.source}, ensure_ascii=False)
|
public_url = surfsense_docs_public_url(doc.source)
|
||||||
|
metadata_json = json.dumps(
|
||||||
|
{"source": doc.source, "public_url": public_url}, ensure_ascii=False
|
||||||
|
)
|
||||||
|
|
||||||
context_parts.append("<document>")
|
context_parts.append("<document>")
|
||||||
context_parts.append("<document_metadata>")
|
context_parts.append("<document_metadata>")
|
||||||
context_parts.append(f" <document_id>doc-{doc.id}</document_id>")
|
context_parts.append(f" <document_id>doc-{doc.id}</document_id>")
|
||||||
context_parts.append(" <document_type>SURFSENSE_DOCS</document_type>")
|
context_parts.append(" <document_type>SURFSENSE_DOCS</document_type>")
|
||||||
context_parts.append(f" <title><![CDATA[{doc.title}]]></title>")
|
context_parts.append(f" <title><![CDATA[{doc.title}]]></title>")
|
||||||
context_parts.append(f" <url><![CDATA[{doc.source}]]></url>")
|
context_parts.append(f" <url><![CDATA[{public_url}]]></url>")
|
||||||
context_parts.append(
|
context_parts.append(
|
||||||
f" <metadata_json><![CDATA[{metadata_json}]]></metadata_json>"
|
f" <metadata_json><![CDATA[{metadata_json}]]></metadata_json>"
|
||||||
)
|
)
|
||||||
|
|
@ -1487,14 +1491,20 @@ async def stream_new_chat(
|
||||||
|
|
||||||
# Resolve @-mention chips to canonical virtual paths and rewrite
|
# Resolve @-mention chips to canonical virtual paths and rewrite
|
||||||
# the user-typed text so the LLM sees ``\`/documents/...\``` instead
|
# the user-typed text so the LLM sees ``\`/documents/...\``` instead
|
||||||
# of bare ``@title``. The persisted user-message text keeps
|
# of bare ``@title``. The substitution lands in ``agent_user_query``
|
||||||
# ``@title`` so chip rendering on reload is unchanged — see
|
# ONLY — the original ``user_query`` (with ``@title`` tokens) flows
|
||||||
# ``persistence._build_user_content``.
|
# untouched into ``persist_user_turn`` below so chip rendering on
|
||||||
|
# reload still works (``UserTextPart`` → ``parseMentionSegments``
|
||||||
|
# matches ``@title``, not ``\`/documents/...\```). It also feeds
|
||||||
|
# the human-readable surfaces — SSE "Processing X" status, auto
|
||||||
|
# thread title, memory seed — which all want what the user typed.
|
||||||
|
# See ``persistence._build_user_content``.
|
||||||
#
|
#
|
||||||
# Cloud mode only: local-folder mode keeps the legacy
|
# Cloud mode only: local-folder mode keeps the legacy
|
||||||
# ``@title`` text path; mention support there is a follow-up
|
# ``@title`` text path; mention support there is a follow-up
|
||||||
# task because the path scheme (mount-rooted) and the picker
|
# task because the path scheme (mount-rooted) and the picker
|
||||||
# UI both need separate work.
|
# UI both need separate work.
|
||||||
|
agent_user_query = user_query
|
||||||
accepted_folder_ids: list[int] = []
|
accepted_folder_ids: list[int] = []
|
||||||
if fs_mode == FilesystemMode.CLOUD.value and (
|
if fs_mode == FilesystemMode.CLOUD.value and (
|
||||||
mentioned_document_ids
|
mentioned_document_ids
|
||||||
|
|
@ -1529,11 +1539,13 @@ async def stream_new_chat(
|
||||||
mentioned_surfsense_doc_ids=mentioned_surfsense_doc_ids,
|
mentioned_surfsense_doc_ids=mentioned_surfsense_doc_ids,
|
||||||
mentioned_folder_ids=mentioned_folder_ids,
|
mentioned_folder_ids=mentioned_folder_ids,
|
||||||
)
|
)
|
||||||
user_query = substitute_in_text(user_query, resolved.token_to_path)
|
agent_user_query = substitute_in_text(user_query, resolved.token_to_path)
|
||||||
accepted_folder_ids = resolved.mentioned_folder_ids
|
accepted_folder_ids = resolved.mentioned_folder_ids
|
||||||
|
|
||||||
# Format the user query with context (SurfSense docs + reports only)
|
# Format the user query with context (SurfSense docs + reports only).
|
||||||
final_query = user_query
|
# Uses ``agent_user_query`` so the LLM sees backtick-wrapped paths
|
||||||
|
# instead of bare ``@title`` tokens.
|
||||||
|
final_query = agent_user_query
|
||||||
context_parts = []
|
context_parts = []
|
||||||
|
|
||||||
if mentioned_surfsense_docs:
|
if mentioned_surfsense_docs:
|
||||||
|
|
@ -1564,7 +1576,7 @@ async def stream_new_chat(
|
||||||
|
|
||||||
if context_parts:
|
if context_parts:
|
||||||
context = "\n\n".join(context_parts)
|
context = "\n\n".join(context_parts)
|
||||||
final_query = f"{context}\n\n<user_query>{user_query}</user_query>"
|
final_query = f"{context}\n\n<user_query>{agent_user_query}</user_query>"
|
||||||
|
|
||||||
if visibility == ChatVisibility.SEARCH_SPACE and current_user_display_name:
|
if visibility == ChatVisibility.SEARCH_SPACE and current_user_display_name:
|
||||||
final_query = f"**[{current_user_display_name}]:** {final_query}"
|
final_query = f"**[{current_user_display_name}]:** {final_query}"
|
||||||
|
|
|
||||||
13
surfsense_backend/app/utils/surfsense_docs.py
Normal file
13
surfsense_backend/app/utils/surfsense_docs.py
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
"""Utilities for SurfSense's built-in documentation index."""
|
||||||
|
|
||||||
|
from pathlib import PurePosixPath
|
||||||
|
|
||||||
|
DOCS_PUBLIC_ROOT = PurePosixPath("/docs")
|
||||||
|
|
||||||
|
|
||||||
|
def surfsense_docs_public_url(source: str) -> str:
|
||||||
|
"""Return the public docs route for an indexed documentation source path."""
|
||||||
|
docs_path = PurePosixPath(source).with_suffix("")
|
||||||
|
if docs_path.name == "index":
|
||||||
|
docs_path = docs_path.parent
|
||||||
|
return (DOCS_PUBLIC_ROOT / docs_path).as_posix()
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[project]
|
[project]
|
||||||
name = "surf-new-backend"
|
name = "surf-new-backend"
|
||||||
version = "0.0.23"
|
version = "0.0.24"
|
||||||
description = "SurfSense Backend"
|
description = "SurfSense Backend"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,15 @@ set -e
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
# SERVICE_ROLE controls which process(es) this container runs.
|
# SERVICE_ROLE controls which process(es) this container runs.
|
||||||
#
|
#
|
||||||
# api – FastAPI backend only (runs migrations on startup)
|
# migrate – Run `alembic upgrade head`, verify zero_publication,
|
||||||
|
# then exit 0. Used by the dedicated `migrations` service
|
||||||
|
# in docker-compose.yml so downstream services can gate
|
||||||
|
# on `condition: service_completed_successfully`.
|
||||||
|
# api – FastAPI backend only (does NOT run migrations)
|
||||||
# worker – Celery worker only
|
# worker – Celery worker only
|
||||||
# beat – Celery beat scheduler only
|
# beat – Celery beat scheduler only
|
||||||
# all – All three in one container (legacy / dev default)
|
# all – migrations + api + worker + beat in one container
|
||||||
|
# (legacy / dev default; fails fast on migration error)
|
||||||
#
|
#
|
||||||
# Set SERVICE_ROLE as an environment variable in Coolify for
|
# Set SERVICE_ROLE as an environment variable in Coolify for
|
||||||
# each service deployment.
|
# each service deployment.
|
||||||
|
|
@ -41,7 +46,13 @@ cleanup() {
|
||||||
|
|
||||||
trap cleanup SIGTERM SIGINT
|
trap cleanup SIGTERM SIGINT
|
||||||
|
|
||||||
# ── Database migrations (only for api / all) ─────────────────
|
# ── Database migrations (only for migrate / all) ─────────────
|
||||||
|
# Fail-fast contract:
|
||||||
|
# - alembic upgrade head must succeed within ${MIGRATION_TIMEOUT:-900}s
|
||||||
|
# - zero_publication must exist in pg_publication afterwards
|
||||||
|
# Either failure exits non-zero so the dedicated `migrations` compose
|
||||||
|
# service exits non-zero, halting the rest of the stack instead of
|
||||||
|
# silently producing a half-built system that crash-loops zero-cache.
|
||||||
run_migrations() {
|
run_migrations() {
|
||||||
echo "Running database migrations..."
|
echo "Running database migrations..."
|
||||||
for i in {1..30}; do
|
for i in {1..30}; do
|
||||||
|
|
@ -53,11 +64,66 @@ run_migrations() {
|
||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
|
|
||||||
if timeout 300 alembic upgrade head 2>&1; then
|
local timeout_secs="${MIGRATION_TIMEOUT:-900}"
|
||||||
echo "Migrations completed successfully."
|
echo "Running alembic upgrade head (timeout=${timeout_secs}s)..."
|
||||||
else
|
if ! timeout "${timeout_secs}" alembic upgrade head; then
|
||||||
echo "WARNING: Migration failed or timed out. Continuing anyway..."
|
echo "ERROR: alembic upgrade head failed (or exceeded ${timeout_secs}s timeout)." >&2
|
||||||
echo "You may need to run migrations manually: alembic upgrade head"
|
echo "Refusing to start. Inspect the error above and re-run." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Migrations completed successfully."
|
||||||
|
|
||||||
|
echo "Verifying zero_publication exists in Postgres..."
|
||||||
|
local pub_oid
|
||||||
|
pub_oid=$(python <<'PY' 2>/dev/null || true
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
from sqlalchemy import text
|
||||||
|
from app.db import engine
|
||||||
|
|
||||||
|
|
||||||
|
async def get_oid():
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
result = await conn.execute(
|
||||||
|
text("SELECT oid FROM pg_publication WHERE pubname = 'zero_publication'")
|
||||||
|
)
|
||||||
|
row = result.first()
|
||||||
|
if row is None:
|
||||||
|
sys.exit(1)
|
||||||
|
print(int(row[0]))
|
||||||
|
|
||||||
|
|
||||||
|
asyncio.run(get_oid())
|
||||||
|
PY
|
||||||
|
)
|
||||||
|
if [ -z "${pub_oid}" ]; then
|
||||||
|
echo "ERROR: zero_publication is missing from Postgres after running alembic." >&2
|
||||||
|
echo "This usually means migration 116 (or a later publication migration) did not run." >&2
|
||||||
|
echo "Inspect alembic state with:" >&2
|
||||||
|
echo " docker compose exec db psql -U \"\$DB_USER\" -d \"\$DB_NAME\" -c 'SELECT * FROM alembic_version;'" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "zero_publication verified (oid=${pub_oid})."
|
||||||
|
|
||||||
|
# Stale-replica safety net: if /zero-init is mounted (i.e. we are the
|
||||||
|
# dedicated `migrations` compose service), drop a marker file when the
|
||||||
|
# publication oid changed (or on first run) so the wrapped zero-cache
|
||||||
|
# entrypoint can wipe /data/zero.db before starting. This recovers from
|
||||||
|
# the case where a previous zero-cache crashed mid-init and left a
|
||||||
|
# half-built SQLite replica without a `_zero.tableMetadata` table.
|
||||||
|
if [ -d /zero-init ]; then
|
||||||
|
local stored_oid=""
|
||||||
|
[ -f /zero-init/last_pub_oid ] && stored_oid=$(cat /zero-init/last_pub_oid 2>/dev/null || true)
|
||||||
|
if [ -z "${stored_oid}" ] || [ "${stored_oid}" != "${pub_oid}" ]; then
|
||||||
|
echo "Publication oid changed (stored=${stored_oid:-<none>}, current=${pub_oid}); writing /zero-init/needs_reset."
|
||||||
|
: > /zero-init/needs_reset
|
||||||
|
chmod 666 /zero-init/needs_reset 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
echo "${pub_oid}" > /zero-init/last_pub_oid
|
||||||
|
chmod 666 /zero-init/last_pub_oid 2>/dev/null || true
|
||||||
|
# World-writable dir so the (possibly non-root) zero-cache container
|
||||||
|
# can `rm -f /zero-init/needs_reset` after acting on the marker.
|
||||||
|
chmod 777 /zero-init 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -102,8 +168,12 @@ start_beat() {
|
||||||
|
|
||||||
# ── Main: run based on role ──────────────────────────────────
|
# ── Main: run based on role ──────────────────────────────────
|
||||||
case "${SERVICE_ROLE}" in
|
case "${SERVICE_ROLE}" in
|
||||||
api)
|
migrate)
|
||||||
run_migrations
|
run_migrations
|
||||||
|
echo "Migrations complete; exiting cleanly."
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
api)
|
||||||
start_api
|
start_api
|
||||||
;;
|
;;
|
||||||
worker)
|
worker)
|
||||||
|
|
@ -121,7 +191,7 @@ case "${SERVICE_ROLE}" in
|
||||||
start_beat
|
start_beat
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "ERROR: Unknown SERVICE_ROLE '${SERVICE_ROLE}'. Use: api, worker, beat, or all"
|
echo "ERROR: Unknown SERVICE_ROLE '${SERVICE_ROLE}'. Use: migrate, api, worker, beat, or all"
|
||||||
exit 1
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
|
||||||
|
|
@ -350,6 +350,25 @@ def _drive_list_files(args: dict[str, Any]) -> dict[str, Any]:
|
||||||
folder id and serve the matching fixture list.
|
folder id and serve the matching fixture list.
|
||||||
"""
|
"""
|
||||||
q = args.get("q", "")
|
q = args.get("q", "")
|
||||||
|
if "in owners" in q:
|
||||||
|
return {
|
||||||
|
"data": {
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"id": "fake-file-owner-probe",
|
||||||
|
"name": "owner-probe",
|
||||||
|
"owners": [
|
||||||
|
{
|
||||||
|
"me": True,
|
||||||
|
"emailAddress": "e2e-fake@surfsense.example",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"nextPageToken": None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
folder_id = "root"
|
folder_id = "root"
|
||||||
if "in parents" in q:
|
if "in parents" in q:
|
||||||
# q looks like: '<folder_id>' in parents and trashed = false ...
|
# q looks like: '<folder_id>' in parents and trashed = false ...
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,11 @@
|
||||||
"""Composio route integration fixtures.
|
"""Composio route integration fixtures.
|
||||||
|
|
||||||
The sys.modules hijack happens at module import time, before importing
|
The `composio` sys.modules hijack lives in the parent integration conftest
|
||||||
app.app, so production `from composio import Composio` bindings resolve to
|
so it runs before any sibling suite imports `app.routes`.
|
||||||
the strict E2E fake in this pytest process too.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sys
|
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
@ -16,19 +14,15 @@ import pytest_asyncio
|
||||||
from httpx import ASGITransport
|
from httpx import ASGITransport
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from tests.e2e.fakes import composio_module as _fake_composio
|
from app.app import app, limiter
|
||||||
|
from app.config import config
|
||||||
sys.modules["composio"] = _fake_composio
|
from app.db import (
|
||||||
|
|
||||||
from app.app import app, limiter # noqa: E402
|
|
||||||
from app.config import config # noqa: E402
|
|
||||||
from app.db import ( # noqa: E402
|
|
||||||
SearchSourceConnector,
|
SearchSourceConnector,
|
||||||
SearchSourceConnectorType,
|
SearchSourceConnectorType,
|
||||||
User,
|
User,
|
||||||
get_async_session,
|
get_async_session,
|
||||||
)
|
)
|
||||||
from app.users import current_active_user # noqa: E402
|
from app.users import current_active_user
|
||||||
|
|
||||||
pytestmark = pytest.mark.integration
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import importlib
|
||||||
|
import sys
|
||||||
import uuid
|
import uuid
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
|
@ -7,17 +9,27 @@ from sqlalchemy import text
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||||
from sqlalchemy.pool import NullPool
|
from sqlalchemy.pool import NullPool
|
||||||
|
|
||||||
from app.config import config as app_config
|
# Hijack `composio` before any `from app.*` import; the `from composio import
|
||||||
from app.db import (
|
# Composio` in app.services.composio_service binds once at first import.
|
||||||
Base,
|
from tests.e2e.fakes import composio_module as _fake_composio
|
||||||
DocumentType,
|
|
||||||
SearchSourceConnector,
|
sys.modules["composio"] = _fake_composio
|
||||||
SearchSourceConnectorType,
|
|
||||||
SearchSpace,
|
app_config = importlib.import_module("app.config").config
|
||||||
User,
|
app_db = importlib.import_module("app.db")
|
||||||
)
|
Base = app_db.Base
|
||||||
from app.indexing_pipeline.connector_document import ConnectorDocument
|
DocumentType = app_db.DocumentType
|
||||||
from tests.conftest import TEST_DATABASE_URL
|
SearchSourceConnector = app_db.SearchSourceConnector
|
||||||
|
SearchSourceConnectorType = app_db.SearchSourceConnectorType
|
||||||
|
SearchSpace = app_db.SearchSpace
|
||||||
|
User = app_db.User
|
||||||
|
ConnectorDocument = importlib.import_module(
|
||||||
|
"app.indexing_pipeline.connector_document"
|
||||||
|
).ConnectorDocument
|
||||||
|
create_default_roles_and_membership = importlib.import_module(
|
||||||
|
"app.routes.search_spaces_routes"
|
||||||
|
).create_default_roles_and_membership
|
||||||
|
TEST_DATABASE_URL = importlib.import_module("tests.conftest").TEST_DATABASE_URL
|
||||||
|
|
||||||
_EMBEDDING_DIM = app_config.embedding_model_instance.dimension
|
_EMBEDDING_DIM = app_config.embedding_model_instance.dimension
|
||||||
|
|
||||||
|
|
@ -105,6 +117,9 @@ async def db_search_space(db_session: AsyncSession, db_user: User) -> SearchSpac
|
||||||
)
|
)
|
||||||
db_session.add(space)
|
db_session.add(space)
|
||||||
await db_session.flush()
|
await db_session.flush()
|
||||||
|
# Mirror POST /searchspaces so routes guarded by check_permission find a membership.
|
||||||
|
await create_default_roles_and_membership(db_session, space.id, db_user.id)
|
||||||
|
await db_session.flush()
|
||||||
return space
|
return space
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -145,6 +160,10 @@ def patched_chunk_text(monkeypatch) -> MagicMock:
|
||||||
"app.indexing_pipeline.indexing_pipeline_service.chunk_text",
|
"app.indexing_pipeline.indexing_pipeline_service.chunk_text",
|
||||||
mock,
|
mock,
|
||||||
)
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.indexing_pipeline.indexing_pipeline_service.chunk_text_hybrid",
|
||||||
|
mock,
|
||||||
|
)
|
||||||
return mock
|
return mock
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -204,6 +204,7 @@ class TestStripeCheckoutSessionCreation:
|
||||||
assert (
|
assert (
|
||||||
fake_client.last_params["success_url"]
|
fake_client.last_params["success_url"]
|
||||||
== f"http://localhost:3000/dashboard/{search_space_id}/purchase-success"
|
== f"http://localhost:3000/dashboard/{search_space_id}/purchase-success"
|
||||||
|
"?session_id={CHECKOUT_SESSION_ID}"
|
||||||
)
|
)
|
||||||
assert (
|
assert (
|
||||||
fake_client.last_params["cancel_url"]
|
fake_client.last_params["cancel_url"]
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ mocked at their system boundaries.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import ANY, AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
|
|
@ -25,6 +25,7 @@ pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
_COMPOSIO_ACCOUNT_ID = "composio-calendar-test-789"
|
_COMPOSIO_ACCOUNT_ID = "composio-calendar-test-789"
|
||||||
_INDEXER_MODULE = "app.tasks.connector_indexers.google_calendar_indexer"
|
_INDEXER_MODULE = "app.tasks.connector_indexers.google_calendar_indexer"
|
||||||
|
_GET_ACCESS_TOKEN = "app.services.composio_service.ComposioService.get_access_token"
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
@pytest_asyncio.fixture
|
||||||
|
|
@ -69,32 +70,29 @@ async def native_calendar(async_engine):
|
||||||
await cleanup_space(async_engine, data["search_space_id"])
|
await cleanup_space(async_engine, data["search_space_id"])
|
||||||
|
|
||||||
|
|
||||||
|
@patch(_GET_ACCESS_TOKEN)
|
||||||
@patch(f"{_INDEXER_MODULE}.TaskLoggingService")
|
@patch(f"{_INDEXER_MODULE}.TaskLoggingService")
|
||||||
@patch(f"{_INDEXER_MODULE}.GoogleCalendarConnector")
|
@patch(f"{_INDEXER_MODULE}.GoogleCalendarConnector")
|
||||||
@patch(f"{_INDEXER_MODULE}.build_composio_credentials")
|
@patch(f"{_INDEXER_MODULE}.ComposioService")
|
||||||
async def test_composio_calendar_uses_composio_credentials(
|
async def test_composio_calendar_uses_composio_service(
|
||||||
mock_build_creds,
|
mock_composio_service_cls,
|
||||||
mock_cal_cls,
|
mock_cal_cls,
|
||||||
mock_tl_cls,
|
mock_tl_cls,
|
||||||
|
mock_get_access_token,
|
||||||
async_engine,
|
async_engine,
|
||||||
composio_calendar,
|
composio_calendar,
|
||||||
):
|
):
|
||||||
"""Calendar indexer calls build_composio_credentials for a Composio connector."""
|
"""Calendar indexer uses Composio tools directly for a Composio connector."""
|
||||||
from app.tasks.connector_indexers.google_calendar_indexer import (
|
from app.tasks.connector_indexers.google_calendar_indexer import (
|
||||||
index_google_calendar_events,
|
index_google_calendar_events,
|
||||||
)
|
)
|
||||||
|
|
||||||
data = composio_calendar
|
data = composio_calendar
|
||||||
mock_creds = MagicMock(name="composio-creds")
|
mock_composio_service = MagicMock()
|
||||||
mock_build_creds.return_value = mock_creds
|
mock_composio_service.get_calendar_events = AsyncMock(return_value=([], None))
|
||||||
|
mock_composio_service_cls.return_value = mock_composio_service
|
||||||
mock_tl_cls.return_value = mock_task_logger()
|
mock_tl_cls.return_value = mock_task_logger()
|
||||||
|
|
||||||
mock_cal_instance = MagicMock()
|
|
||||||
mock_cal_instance.get_all_primary_calendar_events = AsyncMock(
|
|
||||||
return_value=([], None)
|
|
||||||
)
|
|
||||||
mock_cal_cls.return_value = mock_cal_instance
|
|
||||||
|
|
||||||
maker = make_session_factory(async_engine)
|
maker = make_session_factory(async_engine)
|
||||||
async with maker() as session:
|
async with maker() as session:
|
||||||
await index_google_calendar_events(
|
await index_google_calendar_events(
|
||||||
|
|
@ -104,17 +102,25 @@ async def test_composio_calendar_uses_composio_credentials(
|
||||||
user_id=data["user_id"],
|
user_id=data["user_id"],
|
||||||
)
|
)
|
||||||
|
|
||||||
mock_build_creds.assert_called_once_with(_COMPOSIO_ACCOUNT_ID)
|
mock_composio_service_cls.assert_called_once()
|
||||||
mock_cal_cls.assert_called_once()
|
mock_composio_service.get_calendar_events.assert_called_once_with(
|
||||||
_, kwargs = mock_cal_cls.call_args
|
connected_account_id=_COMPOSIO_ACCOUNT_ID,
|
||||||
assert kwargs.get("credentials") is mock_creds
|
entity_id=f"surfsense_{data['user_id']}",
|
||||||
|
time_min=ANY,
|
||||||
|
time_max=ANY,
|
||||||
|
max_results=250,
|
||||||
|
)
|
||||||
|
mock_cal_cls.assert_not_called()
|
||||||
|
mock_get_access_token.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@patch(_GET_ACCESS_TOKEN)
|
||||||
@patch(f"{_INDEXER_MODULE}.TaskLoggingService")
|
@patch(f"{_INDEXER_MODULE}.TaskLoggingService")
|
||||||
@patch(f"{_INDEXER_MODULE}.build_composio_credentials")
|
@patch(f"{_INDEXER_MODULE}.ComposioService")
|
||||||
async def test_composio_calendar_without_account_id_returns_error(
|
async def test_composio_calendar_without_account_id_returns_error(
|
||||||
mock_build_creds,
|
mock_composio_service_cls,
|
||||||
mock_tl_cls,
|
mock_tl_cls,
|
||||||
|
mock_get_access_token,
|
||||||
async_engine,
|
async_engine,
|
||||||
composio_calendar_no_id,
|
composio_calendar_no_id,
|
||||||
):
|
):
|
||||||
|
|
@ -138,20 +144,23 @@ async def test_composio_calendar_without_account_id_returns_error(
|
||||||
assert count == 0
|
assert count == 0
|
||||||
assert error is not None
|
assert error is not None
|
||||||
assert "composio" in error.lower()
|
assert "composio" in error.lower()
|
||||||
mock_build_creds.assert_not_called()
|
mock_composio_service_cls.assert_not_called()
|
||||||
|
mock_get_access_token.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@patch(_GET_ACCESS_TOKEN)
|
||||||
@patch(f"{_INDEXER_MODULE}.TaskLoggingService")
|
@patch(f"{_INDEXER_MODULE}.TaskLoggingService")
|
||||||
|
@patch(f"{_INDEXER_MODULE}.ComposioService")
|
||||||
@patch(f"{_INDEXER_MODULE}.GoogleCalendarConnector")
|
@patch(f"{_INDEXER_MODULE}.GoogleCalendarConnector")
|
||||||
@patch(f"{_INDEXER_MODULE}.build_composio_credentials")
|
async def test_native_calendar_uses_google_calendar_connector(
|
||||||
async def test_native_calendar_does_not_use_composio_credentials(
|
|
||||||
mock_build_creds,
|
|
||||||
mock_cal_cls,
|
mock_cal_cls,
|
||||||
|
mock_composio_service_cls,
|
||||||
mock_tl_cls,
|
mock_tl_cls,
|
||||||
|
mock_get_access_token,
|
||||||
async_engine,
|
async_engine,
|
||||||
native_calendar,
|
native_calendar,
|
||||||
):
|
):
|
||||||
"""Calendar indexer does NOT call build_composio_credentials for a native connector."""
|
"""Native Calendar connector uses GoogleCalendarConnector with no Composio path."""
|
||||||
from app.tasks.connector_indexers.google_calendar_indexer import (
|
from app.tasks.connector_indexers.google_calendar_indexer import (
|
||||||
index_google_calendar_events,
|
index_google_calendar_events,
|
||||||
)
|
)
|
||||||
|
|
@ -174,4 +183,6 @@ async def test_native_calendar_does_not_use_composio_credentials(
|
||||||
user_id=data["user_id"],
|
user_id=data["user_id"],
|
||||||
)
|
)
|
||||||
|
|
||||||
mock_build_creds.assert_not_called()
|
mock_cal_cls.assert_called_once()
|
||||||
|
mock_composio_service_cls.assert_not_called()
|
||||||
|
mock_get_access_token.assert_not_called()
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ mocked at their system boundaries.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import ANY, AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
|
|
@ -25,6 +25,7 @@ pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
_COMPOSIO_ACCOUNT_ID = "composio-gmail-test-456"
|
_COMPOSIO_ACCOUNT_ID = "composio-gmail-test-456"
|
||||||
_INDEXER_MODULE = "app.tasks.connector_indexers.google_gmail_indexer"
|
_INDEXER_MODULE = "app.tasks.connector_indexers.google_gmail_indexer"
|
||||||
|
_GET_ACCESS_TOKEN = "app.services.composio_service.ComposioService.get_access_token"
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
@pytest_asyncio.fixture
|
||||||
|
|
@ -69,30 +70,32 @@ async def native_gmail(async_engine):
|
||||||
await cleanup_space(async_engine, data["search_space_id"])
|
await cleanup_space(async_engine, data["search_space_id"])
|
||||||
|
|
||||||
|
|
||||||
|
@patch(_GET_ACCESS_TOKEN)
|
||||||
@patch(f"{_INDEXER_MODULE}.TaskLoggingService")
|
@patch(f"{_INDEXER_MODULE}.TaskLoggingService")
|
||||||
@patch(f"{_INDEXER_MODULE}.GoogleGmailConnector")
|
@patch(f"{_INDEXER_MODULE}.GoogleGmailConnector")
|
||||||
@patch(f"{_INDEXER_MODULE}.build_composio_credentials")
|
@patch(f"{_INDEXER_MODULE}.ComposioService")
|
||||||
async def test_composio_gmail_uses_composio_credentials(
|
async def test_composio_gmail_uses_composio_service(
|
||||||
mock_build_creds,
|
mock_composio_service_cls,
|
||||||
mock_gmail_cls,
|
mock_gmail_cls,
|
||||||
mock_tl_cls,
|
mock_tl_cls,
|
||||||
|
mock_get_access_token,
|
||||||
async_engine,
|
async_engine,
|
||||||
composio_gmail,
|
composio_gmail,
|
||||||
):
|
):
|
||||||
"""Gmail indexer calls build_composio_credentials for a Composio connector."""
|
"""Gmail indexer uses Composio tools directly for a Composio connector."""
|
||||||
from app.tasks.connector_indexers.google_gmail_indexer import (
|
from app.tasks.connector_indexers.google_gmail_indexer import (
|
||||||
index_google_gmail_messages,
|
index_google_gmail_messages,
|
||||||
)
|
)
|
||||||
|
|
||||||
data = composio_gmail
|
data = composio_gmail
|
||||||
mock_creds = MagicMock(name="composio-creds")
|
mock_composio_service = MagicMock()
|
||||||
mock_build_creds.return_value = mock_creds
|
mock_composio_service.get_gmail_messages = AsyncMock(
|
||||||
|
return_value=([], None, None, None)
|
||||||
|
)
|
||||||
|
mock_composio_service.get_gmail_message_detail = AsyncMock(return_value=({}, None))
|
||||||
|
mock_composio_service_cls.return_value = mock_composio_service
|
||||||
mock_tl_cls.return_value = mock_task_logger()
|
mock_tl_cls.return_value = mock_task_logger()
|
||||||
|
|
||||||
mock_gmail_instance = MagicMock()
|
|
||||||
mock_gmail_instance.get_recent_messages = AsyncMock(return_value=([], None))
|
|
||||||
mock_gmail_cls.return_value = mock_gmail_instance
|
|
||||||
|
|
||||||
maker = make_session_factory(async_engine)
|
maker = make_session_factory(async_engine)
|
||||||
async with maker() as session:
|
async with maker() as session:
|
||||||
await index_google_gmail_messages(
|
await index_google_gmail_messages(
|
||||||
|
|
@ -102,17 +105,25 @@ async def test_composio_gmail_uses_composio_credentials(
|
||||||
user_id=data["user_id"],
|
user_id=data["user_id"],
|
||||||
)
|
)
|
||||||
|
|
||||||
mock_build_creds.assert_called_once_with(_COMPOSIO_ACCOUNT_ID)
|
mock_composio_service_cls.assert_called_once()
|
||||||
mock_gmail_cls.assert_called_once()
|
mock_composio_service.get_gmail_messages.assert_called_once_with(
|
||||||
args, _ = mock_gmail_cls.call_args
|
connected_account_id=_COMPOSIO_ACCOUNT_ID,
|
||||||
assert args[0] is mock_creds
|
entity_id=f"surfsense_{data['user_id']}",
|
||||||
|
query=ANY,
|
||||||
|
max_results=ANY,
|
||||||
|
page_token=None,
|
||||||
|
)
|
||||||
|
mock_gmail_cls.assert_not_called()
|
||||||
|
mock_get_access_token.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@patch(_GET_ACCESS_TOKEN)
|
||||||
@patch(f"{_INDEXER_MODULE}.TaskLoggingService")
|
@patch(f"{_INDEXER_MODULE}.TaskLoggingService")
|
||||||
@patch(f"{_INDEXER_MODULE}.build_composio_credentials")
|
@patch(f"{_INDEXER_MODULE}.ComposioService")
|
||||||
async def test_composio_gmail_without_account_id_returns_error(
|
async def test_composio_gmail_without_account_id_returns_error(
|
||||||
mock_build_creds,
|
mock_composio_service_cls,
|
||||||
mock_tl_cls,
|
mock_tl_cls,
|
||||||
|
mock_get_access_token,
|
||||||
async_engine,
|
async_engine,
|
||||||
composio_gmail_no_id,
|
composio_gmail_no_id,
|
||||||
):
|
):
|
||||||
|
|
@ -136,20 +147,23 @@ async def test_composio_gmail_without_account_id_returns_error(
|
||||||
assert count == 0
|
assert count == 0
|
||||||
assert error is not None
|
assert error is not None
|
||||||
assert "composio" in error.lower()
|
assert "composio" in error.lower()
|
||||||
mock_build_creds.assert_not_called()
|
mock_composio_service_cls.assert_not_called()
|
||||||
|
mock_get_access_token.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@patch(_GET_ACCESS_TOKEN)
|
||||||
@patch(f"{_INDEXER_MODULE}.TaskLoggingService")
|
@patch(f"{_INDEXER_MODULE}.TaskLoggingService")
|
||||||
|
@patch(f"{_INDEXER_MODULE}.ComposioService")
|
||||||
@patch(f"{_INDEXER_MODULE}.GoogleGmailConnector")
|
@patch(f"{_INDEXER_MODULE}.GoogleGmailConnector")
|
||||||
@patch(f"{_INDEXER_MODULE}.build_composio_credentials")
|
async def test_native_gmail_uses_google_gmail_connector(
|
||||||
async def test_native_gmail_does_not_use_composio_credentials(
|
|
||||||
mock_build_creds,
|
|
||||||
mock_gmail_cls,
|
mock_gmail_cls,
|
||||||
|
mock_composio_service_cls,
|
||||||
mock_tl_cls,
|
mock_tl_cls,
|
||||||
|
mock_get_access_token,
|
||||||
async_engine,
|
async_engine,
|
||||||
native_gmail,
|
native_gmail,
|
||||||
):
|
):
|
||||||
"""Gmail indexer does NOT call build_composio_credentials for a native connector."""
|
"""Native Gmail connector uses GoogleGmailConnector with no Composio path."""
|
||||||
from app.tasks.connector_indexers.google_gmail_indexer import (
|
from app.tasks.connector_indexers.google_gmail_indexer import (
|
||||||
index_google_gmail_messages,
|
index_google_gmail_messages,
|
||||||
)
|
)
|
||||||
|
|
@ -170,4 +184,6 @@ async def test_native_gmail_does_not_use_composio_credentials(
|
||||||
user_id=data["user_id"],
|
user_id=data["user_id"],
|
||||||
)
|
)
|
||||||
|
|
||||||
mock_build_creds.assert_not_called()
|
mock_gmail_cls.assert_called_once()
|
||||||
|
mock_composio_service_cls.assert_not_called()
|
||||||
|
mock_get_access_token.assert_not_called()
|
||||||
|
|
|
||||||
|
|
@ -200,7 +200,7 @@ async def test_reindex_sets_status_ready(db_session, db_search_space, db_user, m
|
||||||
async def test_reindex_replaces_chunks(db_session, db_search_space, db_user, mocker):
|
async def test_reindex_replaces_chunks(db_session, db_search_space, db_user, mocker):
|
||||||
"""Reindexing replaces old chunks with new content rather than appending."""
|
"""Reindexing replaces old chunks with new content rather than appending."""
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
"app.indexing_pipeline.indexing_pipeline_service.chunk_text",
|
"app.indexing_pipeline.indexing_pipeline_service.chunk_text_hybrid",
|
||||||
side_effect=[["Original chunk."], ["Updated chunk."]],
|
side_effect=[["Original chunk."], ["Updated chunk."]],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,12 @@ def _make_orm_doc(connector_doc, doc_id):
|
||||||
async def test_index_calls_embed_and_chunk_via_to_thread(
|
async def test_index_calls_embed_and_chunk_via_to_thread(
|
||||||
pipeline, make_connector_document, monkeypatch
|
pipeline, make_connector_document, monkeypatch
|
||||||
):
|
):
|
||||||
"""index() runs embed_texts and the chunker via asyncio.to_thread, not blocking the loop."""
|
"""index() runs the chunker and embed_texts via asyncio.to_thread, not blocking the loop.
|
||||||
|
|
||||||
|
Routing between ``chunk_text`` (code path) and ``chunk_text_hybrid`` (default
|
||||||
|
path, see issue #1334) is verified separately in
|
||||||
|
``test_non_code_documents_use_hybrid_chunker``.
|
||||||
|
"""
|
||||||
to_thread_calls = []
|
to_thread_calls = []
|
||||||
original_to_thread = asyncio.to_thread
|
original_to_thread = asyncio.to_thread
|
||||||
|
|
||||||
|
|
@ -51,12 +56,6 @@ async def test_index_calls_embed_and_chunk_via_to_thread(
|
||||||
"app.indexing_pipeline.indexing_pipeline_service.summarize_document",
|
"app.indexing_pipeline.indexing_pipeline_service.summarize_document",
|
||||||
AsyncMock(return_value="Summary."),
|
AsyncMock(return_value="Summary."),
|
||||||
)
|
)
|
||||||
mock_chunk = MagicMock(return_value=["chunk1"])
|
|
||||||
mock_chunk.__name__ = "chunk_text"
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"app.indexing_pipeline.indexing_pipeline_service.chunk_text",
|
|
||||||
mock_chunk,
|
|
||||||
)
|
|
||||||
mock_chunk_hybrid = MagicMock(return_value=["chunk1"])
|
mock_chunk_hybrid = MagicMock(return_value=["chunk1"])
|
||||||
mock_chunk_hybrid.__name__ = "chunk_text_hybrid"
|
mock_chunk_hybrid.__name__ = "chunk_text_hybrid"
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
|
|
@ -71,6 +70,11 @@ async def test_index_calls_embed_and_chunk_via_to_thread(
|
||||||
"app.indexing_pipeline.indexing_pipeline_service.embed_texts",
|
"app.indexing_pipeline.indexing_pipeline_service.embed_texts",
|
||||||
mock_embed,
|
mock_embed,
|
||||||
)
|
)
|
||||||
|
# Bypass set_committed_value, which requires a real ORM instance (not MagicMock).
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.indexing_pipeline.indexing_pipeline_service.attach_chunks_to_document",
|
||||||
|
MagicMock(),
|
||||||
|
)
|
||||||
|
|
||||||
connector_doc = make_connector_document(
|
connector_doc = make_connector_document(
|
||||||
document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR,
|
document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR,
|
||||||
|
|
@ -83,11 +87,62 @@ async def test_index_calls_embed_and_chunk_via_to_thread(
|
||||||
|
|
||||||
await pipeline.index(document, connector_doc, llm=MagicMock())
|
await pipeline.index(document, connector_doc, llm=MagicMock())
|
||||||
|
|
||||||
# Non-code documents now route through the table-aware hybrid chunker
|
# Either chunker entry point satisfies the "chunking runs off the event
|
||||||
# (see commit 2f3a33c9). Either chunker entry point satisfies the
|
# loop" contract this test guards. Routing between the two is verified
|
||||||
# "chunking runs off the event loop" contract this test guards.
|
# in test_non_code_documents_use_hybrid_chunker.
|
||||||
assert {"chunk_text", "chunk_text_hybrid"} & set(to_thread_calls)
|
assert {"chunk_text", "chunk_text_hybrid"} & set(to_thread_calls)
|
||||||
assert "embed_texts" in to_thread_calls
|
assert "embed_texts" in to_thread_calls
|
||||||
|
assert document.status == DocumentStatus.ready()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_non_code_documents_use_hybrid_chunker(
|
||||||
|
pipeline, make_connector_document, monkeypatch
|
||||||
|
):
|
||||||
|
"""Non-code documents route through ``chunk_text_hybrid`` (issue #1334).
|
||||||
|
|
||||||
|
The hybrid chunker preserves Markdown table integrity by avoiding splits
|
||||||
|
mid-row. Only documents flagged with ``should_use_code_chunker=True``
|
||||||
|
should take the ``chunk_text`` path.
|
||||||
|
"""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.indexing_pipeline.indexing_pipeline_service.summarize_document",
|
||||||
|
AsyncMock(return_value="Summary."),
|
||||||
|
)
|
||||||
|
mock_chunk_hybrid = MagicMock(return_value=["chunk1"])
|
||||||
|
mock_chunk_hybrid.__name__ = "chunk_text_hybrid"
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.indexing_pipeline.indexing_pipeline_service.chunk_text_hybrid",
|
||||||
|
mock_chunk_hybrid,
|
||||||
|
)
|
||||||
|
mock_chunk_code = MagicMock(return_value=["chunk1"])
|
||||||
|
mock_chunk_code.__name__ = "chunk_text"
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.indexing_pipeline.indexing_pipeline_service.chunk_text",
|
||||||
|
mock_chunk_code,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.indexing_pipeline.indexing_pipeline_service.embed_texts",
|
||||||
|
MagicMock(side_effect=lambda texts: [[0.1] * _EMBEDDING_DIM for _ in texts]),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.indexing_pipeline.indexing_pipeline_service.attach_chunks_to_document",
|
||||||
|
MagicMock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
connector_doc = make_connector_document(
|
||||||
|
document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR,
|
||||||
|
unique_id="msg-1",
|
||||||
|
search_space_id=1,
|
||||||
|
should_use_code_chunker=False,
|
||||||
|
)
|
||||||
|
document = MagicMock(spec=Document)
|
||||||
|
document.id = 1
|
||||||
|
document.status = DocumentStatus.pending()
|
||||||
|
|
||||||
|
await pipeline.index(document, connector_doc, llm=MagicMock())
|
||||||
|
|
||||||
|
mock_chunk_hybrid.assert_called_once()
|
||||||
|
mock_chunk_code.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
def _mock_session_factory(orm_docs_by_id):
|
def _mock_session_factory(orm_docs_by_id):
|
||||||
|
|
|
||||||
2
surfsense_backend/uv.lock
generated
2
surfsense_backend/uv.lock
generated
|
|
@ -7947,7 +7947,7 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "surf-new-backend"
|
name = "surf-new-backend"
|
||||||
version = "0.0.23"
|
version = "0.0.24"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "alembic" },
|
{ name = "alembic" },
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "surfsense_browser_extension",
|
"name": "surfsense_browser_extension",
|
||||||
"displayName": "Surfsense Browser Extension",
|
"displayName": "Surfsense Browser Extension",
|
||||||
"version": "0.0.23",
|
"version": "0.0.24",
|
||||||
"description": "Extension to collect Browsing History for SurfSense.",
|
"description": "Extension to collect Browsing History for SurfSense.",
|
||||||
"author": "https://github.com/MODSetter",
|
"author": "https://github.com/MODSetter",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|
|
||||||
BIN
surfsense_desktop/assets/icon-128.png
Normal file
BIN
surfsense_desktop/assets/icon-128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 287 KiB |
BIN
surfsense_desktop/assets/iconTemplate.png
Normal file
BIN
surfsense_desktop/assets/iconTemplate.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 684 B |
BIN
surfsense_desktop/assets/iconTemplate@2x.png
Normal file
BIN
surfsense_desktop/assets/iconTemplate@2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "surfsense-desktop",
|
"name": "surfsense-desktop",
|
||||||
"version": "0.0.23",
|
"version": "0.0.24",
|
||||||
"description": "SurfSense Desktop App",
|
"description": "SurfSense Desktop App",
|
||||||
"main": "dist/main.js",
|
"main": "dist/main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,20 @@ let registeredGeneralAssist: string | null = null;
|
||||||
let registeredScreenshotAssist: string | null = null;
|
let registeredScreenshotAssist: string | null = null;
|
||||||
|
|
||||||
function getTrayIcon(): NativeImage {
|
function getTrayIcon(): NativeImage {
|
||||||
const iconName = process.platform === 'win32' ? 'icon.ico' : 'icon.png';
|
const iconName =
|
||||||
|
process.platform === 'darwin'
|
||||||
|
? 'iconTemplate.png'
|
||||||
|
: process.platform === 'win32'
|
||||||
|
? 'icon.ico'
|
||||||
|
: 'icon.png';
|
||||||
const iconPath = app.isPackaged
|
const iconPath = app.isPackaged
|
||||||
? path.join(process.resourcesPath, 'assets', iconName)
|
? path.join(process.resourcesPath, 'assets', iconName)
|
||||||
: path.join(__dirname, '..', 'assets', iconName);
|
: path.join(__dirname, '..', 'assets', iconName);
|
||||||
const img = nativeImage.createFromPath(iconPath);
|
const img = nativeImage.createFromPath(iconPath);
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
img.setTemplateImage(true);
|
||||||
|
return img;
|
||||||
|
}
|
||||||
return img.resize({ width: 16, height: 16 });
|
return img.resize({ width: 16, height: 16 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { setActiveSearchSpaceId } from './active-search-space';
|
||||||
|
|
||||||
const isDev = !app.isPackaged;
|
const isDev = !app.isPackaged;
|
||||||
const HOSTED_FRONTEND_URL = process.env.HOSTED_FRONTEND_URL as string;
|
const HOSTED_FRONTEND_URL = process.env.HOSTED_FRONTEND_URL as string;
|
||||||
|
const isMac = process.platform === 'darwin';
|
||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null;
|
let mainWindow: BrowserWindow | null = null;
|
||||||
let isQuitting = false;
|
let isQuitting = false;
|
||||||
|
|
@ -35,7 +36,12 @@ export function createMainWindow(initialPath = '/dashboard'): BrowserWindow {
|
||||||
webviewTag: false,
|
webviewTag: false,
|
||||||
},
|
},
|
||||||
show: false,
|
show: false,
|
||||||
titleBarStyle: 'hiddenInset',
|
...(isMac
|
||||||
|
? {
|
||||||
|
titleBarStyle: 'hidden' as const,
|
||||||
|
trafficLightPosition: { x: 12, y: 10 },
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
mainWindow.once('ready-to-show', () => {
|
mainWindow.once('ready-to-show', () => {
|
||||||
|
|
|
||||||
|
|
@ -2,20 +2,20 @@ import type { Metadata } from "next";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Announcements | SurfSense",
|
title: "What's New | SurfSense",
|
||||||
description: "Latest product updates, feature releases, and news from SurfSense.",
|
description: "Latest product updates, feature releases, and news from SurfSense.",
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: "https://www.surfsense.com/announcements",
|
canonical: "https://www.surfsense.com/announcements",
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: "Announcements | SurfSense",
|
title: "What's New | SurfSense",
|
||||||
description: "Latest product updates, feature releases, and news from SurfSense.",
|
description: "Latest product updates, feature releases, and news from SurfSense.",
|
||||||
url: "https://www.surfsense.com/announcements",
|
url: "https://www.surfsense.com/announcements",
|
||||||
type: "website",
|
type: "website",
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: "summary_large_image",
|
card: "summary_large_image",
|
||||||
title: "Announcements | SurfSense",
|
title: "What's New | SurfSense",
|
||||||
description: "Latest product updates, feature releases, and news from SurfSense.",
|
description: "Latest product updates, feature releases, and news from SurfSense.",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ export default function AnnouncementsPage() {
|
||||||
<div className="max-w-5xl mx-auto relative">
|
<div className="max-w-5xl mx-auto relative">
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<h1 className="text-4xl font-bold tracking-tight bg-linear-to-r from-gray-900 to-gray-600 dark:from-white dark:to-gray-400 bg-clip-text text-transparent">
|
<h1 className="text-4xl font-bold tracking-tight bg-linear-to-r from-gray-900 to-gray-600 dark:from-white dark:to-gray-400 bg-clip-text text-transparent">
|
||||||
Announcements
|
What's New
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,47 @@
|
||||||
"use client";
|
"use client";
|
||||||
import { IconBrandGoogleFilled } from "@tabler/icons-react";
|
|
||||||
import { motion } from "motion/react";
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useState } from "react";
|
||||||
import { Logo } from "@/components/Logo";
|
import { Logo } from "@/components/Logo";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { trackLoginAttempt } from "@/lib/posthog/events";
|
import { trackLoginAttempt } from "@/lib/posthog/events";
|
||||||
import { AmbientBackground } from "./AmbientBackground";
|
import { AmbientBackground } from "./AmbientBackground";
|
||||||
|
|
||||||
|
function GoogleGLogo({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={className}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 48 48"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="#EA4335"
|
||||||
|
d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#4285F4"
|
||||||
|
d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#FBBC05"
|
||||||
|
d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#34A853"
|
||||||
|
d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function GoogleLoginButton() {
|
export function GoogleLoginButton() {
|
||||||
const t = useTranslations("auth");
|
const t = useTranslations("auth");
|
||||||
|
const [isRedirecting, setIsRedirecting] = useState(false);
|
||||||
|
|
||||||
const handleGoogleLogin = () => {
|
const handleGoogleLogin = () => {
|
||||||
|
if (isRedirecting) return;
|
||||||
|
setIsRedirecting(true);
|
||||||
|
|
||||||
// Track Google login attempt
|
// Track Google login attempt
|
||||||
trackLoginAttempt("google");
|
trackLoginAttempt("google");
|
||||||
|
|
||||||
|
|
@ -73,21 +105,15 @@ export function GoogleLoginButton() {
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div> */}
|
</motion.div> */}
|
||||||
|
|
||||||
<motion.button
|
<Button
|
||||||
whileHover={{ scale: 1.02 }}
|
variant="outline"
|
||||||
whileTap={{ scale: 0.98 }}
|
className="w-full max-w-md gap-2 rounded-lg border-white bg-white px-6 py-5 font-medium text-[#1f1f1f] shadow-sm hover:bg-zinc-100 hover:text-[#1f1f1f] dark:border-white md:py-5"
|
||||||
className="group/btn relative flex w-full items-center justify-center space-x-2 rounded-lg bg-white px-6 py-3 md:py-4 text-neutral-700 shadow-lg transition-all duration-200 hover:shadow-xl dark:bg-neutral-800 dark:text-neutral-200"
|
disabled={isRedirecting}
|
||||||
onClick={handleGoogleLogin}
|
onClick={handleGoogleLogin}
|
||||||
>
|
>
|
||||||
<div className="absolute inset-0 h-full w-full transform opacity-0 transition duration-200 group-hover/btn:opacity-100">
|
<GoogleGLogo className="h-5 w-5" />
|
||||||
<div className="absolute -left-px -top-px h-4 w-4 rounded-tl-lg border-l-2 border-t-2 border-blue-500 bg-transparent transition-all duration-200 group-hover/btn:-left-2 group-hover/btn:-top-2"></div>
|
|
||||||
<div className="absolute -right-px -top-px h-4 w-4 rounded-tr-lg border-r-2 border-t-2 border-blue-500 bg-transparent transition-all duration-200 group-hover/btn:-right-2 group-hover/btn:-top-2"></div>
|
|
||||||
<div className="absolute -bottom-px -left-px h-4 w-4 rounded-bl-lg border-b-2 border-l-2 border-blue-500 bg-transparent transition-all duration-200 group-hover/btn:-bottom-2 group-hover/btn:-left-2"></div>
|
|
||||||
<div className="absolute -bottom-px -right-px h-4 w-4 rounded-br-lg border-b-2 border-r-2 border-blue-500 bg-transparent transition-all duration-200 group-hover/btn:-bottom-2 group-hover/btn:-right-2"></div>
|
|
||||||
</div>
|
|
||||||
<IconBrandGoogleFilled className="h-5 w-5 text-neutral-700 dark:text-neutral-200" />
|
|
||||||
<span className="text-base font-medium">{t("continue_with_google")}</span>
|
<span className="text-base font-medium">{t("continue_with_google")}</span>
|
||||||
</motion.button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { loginMutationAtom } from "@/atoms/auth/auth-mutation.atoms";
|
import { loginMutationAtom } from "@/atoms/auth/auth-mutation.atoms";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { getAuthErrorDetails, isNetworkError } from "@/lib/auth-errors";
|
import { getAuthErrorDetails, isNetworkError } from "@/lib/auth-errors";
|
||||||
import { AUTH_TYPE } from "@/lib/env-config";
|
import { AUTH_TYPE } from "@/lib/env-config";
|
||||||
|
|
@ -120,11 +121,13 @@ export function LocalLoginForm() {
|
||||||
<p className="text-sm font-semibold mb-1">{error.title}</p>
|
<p className="text-sm font-semibold mb-1">{error.title}</p>
|
||||||
<p className="text-sm text-destructive">{error.message}</p>
|
<p className="text-sm text-destructive">{error.message}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setError({ title: null, message: null });
|
setError({ title: null, message: null });
|
||||||
}}
|
}}
|
||||||
className="flex-shrink-0 text-destructive hover:text-destructive/90 transition-colors"
|
className="size-6 flex-shrink-0 text-destructive hover:bg-transparent hover:text-destructive/90"
|
||||||
aria-label="Dismiss error"
|
aria-label="Dismiss error"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
|
|
@ -143,7 +146,7 @@ export function LocalLoginForm() {
|
||||||
<line x1="18" y1="6" x2="6" y2="18" />
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
<line x1="6" y1="6" x2="18" y2="18" />
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -191,21 +194,23 @@ export function LocalLoginForm() {
|
||||||
}`}
|
}`}
|
||||||
disabled={isLoggingIn}
|
disabled={isLoggingIn}
|
||||||
/>
|
/>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
onClick={() => setShowPassword((prev) => !prev)}
|
onClick={() => setShowPassword((prev) => !prev)}
|
||||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground"
|
className="absolute inset-y-0 right-0 h-full w-10 text-muted-foreground hover:bg-transparent hover:text-foreground"
|
||||||
aria-label={showPassword ? t("hide_password") : t("show_password")}
|
aria-label={showPassword ? t("hide_password") : t("show_password")}
|
||||||
>
|
>
|
||||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isLoggingIn}
|
disabled={isLoggingIn}
|
||||||
className="relative w-full rounded-md bg-primary px-4 py-1.5 md:py-2 text-primary-foreground shadow-sm hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 transition-all text-sm md:text-base flex items-center justify-center gap-2"
|
className="relative h-auto w-full px-4 py-1.5 text-sm md:py-2 md:text-base"
|
||||||
>
|
>
|
||||||
<span className={isLoggingIn ? "invisible" : ""}>{t("sign_in")}</span>
|
<span className={isLoggingIn ? "invisible" : ""}>{t("sign_in")}</span>
|
||||||
{isLoggingIn && (
|
{isLoggingIn && (
|
||||||
|
|
@ -213,7 +218,7 @@ export function LocalLoginForm() {
|
||||||
<Spinner size="sm" className="text-primary-foreground" />
|
<Spinner size="sm" className="text-primary-foreground" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{authType === "LOCAL" && (
|
{authType === "LOCAL" && (
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { useTranslations } from "next-intl";
|
||||||
import { Suspense, useEffect, useState } from "react";
|
import { Suspense, useEffect, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Logo } from "@/components/Logo";
|
import { Logo } from "@/components/Logo";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
|
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
|
||||||
import { getAuthErrorDetails, shouldRetry } from "@/lib/auth-errors";
|
import { getAuthErrorDetails, shouldRetry } from "@/lib/auth-errors";
|
||||||
import { setRedirectPath } from "@/lib/auth-utils";
|
import { setRedirectPath } from "@/lib/auth-utils";
|
||||||
|
|
@ -154,10 +155,12 @@ function LoginContent() {
|
||||||
<p className="text-sm font-semibold mb-1">{urlError.title}</p>
|
<p className="text-sm font-semibold mb-1">{urlError.title}</p>
|
||||||
<p className="text-sm text-red-700 dark:text-red-300">{urlError.message}</p>
|
<p className="text-sm text-red-700 dark:text-red-300">{urlError.message}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
onClick={() => setUrlError(null)}
|
onClick={() => setUrlError(null)}
|
||||||
className="flex-shrink-0 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-200 transition-colors"
|
className="size-6 flex-shrink-0 text-red-500 hover:bg-transparent hover:text-red-700 dark:text-red-400 dark:hover:text-red-200"
|
||||||
aria-label="Dismiss error"
|
aria-label="Dismiss error"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
|
|
@ -175,7 +178,7 @@ function LoginContent() {
|
||||||
<line x1="18" y1="6" x2="6" y2="18" />
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
<line x1="6" y1="6" x2="18" y2="18" />
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { useEffect, useState } from "react";
|
||||||
import { type ExternalToast, toast } from "sonner";
|
import { type ExternalToast, toast } from "sonner";
|
||||||
import { registerMutationAtom } from "@/atoms/auth/auth-mutation.atoms";
|
import { registerMutationAtom } from "@/atoms/auth/auth-mutation.atoms";
|
||||||
import { Logo } from "@/components/Logo";
|
import { Logo } from "@/components/Logo";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
|
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
|
||||||
import { getBearerToken } from "@/lib/auth-utils";
|
import { getBearerToken } from "@/lib/auth-utils";
|
||||||
|
|
@ -199,11 +200,13 @@ export default function RegisterPage() {
|
||||||
<p className="text-sm font-semibold mb-1">{error.title}</p>
|
<p className="text-sm font-semibold mb-1">{error.title}</p>
|
||||||
<p className="text-sm text-red-700 dark:text-red-300">{error.message}</p>
|
<p className="text-sm text-red-700 dark:text-red-300">{error.message}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setError({ title: null, message: null });
|
setError({ title: null, message: null });
|
||||||
}}
|
}}
|
||||||
className="flex-shrink-0 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-200 transition-colors"
|
className="size-6 flex-shrink-0 text-red-500 hover:bg-transparent hover:text-red-700 dark:text-red-400 dark:hover:text-red-200"
|
||||||
aria-label="Dismiss error"
|
aria-label="Dismiss error"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
|
|
@ -222,7 +225,7 @@ export default function RegisterPage() {
|
||||||
<line x1="18" y1="6" x2="6" y2="18" />
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
<line x1="6" y1="6" x2="18" y2="18" />
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -295,18 +298,18 @@ export default function RegisterPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isRegistering}
|
disabled={isRegistering}
|
||||||
className="relative w-full rounded-md bg-primary px-4 py-1.5 md:py-2 text-primary-foreground shadow-sm hover:bg-primary/90 focus:outline-none focus:ring-1 focus:ring-primary/40 disabled:cursor-not-allowed disabled:opacity-50 transition-all text-sm md:text-base flex items-center justify-center gap-2"
|
className="relative h-auto w-full px-4 py-1.5 text-sm md:py-2 md:text-base"
|
||||||
>
|
>
|
||||||
<span className={isRegistering ? "invisible" : ""}>{t("register")}</span>
|
<span className={isRegistering ? "invisible" : ""}>{t("register")}</span>
|
||||||
{isRegistering && (
|
{isRegistering && (
|
||||||
<span className="absolute inset-0 flex items-center justify-center gap-2">
|
<span className="absolute inset-0 flex items-center justify-center gap-2">
|
||||||
<Spinner size="sm" className="text-white" />
|
<Spinner size="sm" className="text-primary-foreground" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="mt-4 text-center text-sm">
|
<div className="mt-4 text-center text-sm">
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { motion } from "motion/react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { BuyPagesContent } from "@/components/settings/buy-pages-content";
|
import { BuyPagesContent } from "@/components/settings/buy-pages-content";
|
||||||
import { BuyTokensContent } from "@/components/settings/buy-tokens-content";
|
import { BuyTokensContent } from "@/components/settings/buy-tokens-content";
|
||||||
import { cn } from "@/lib/utils";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ id: "pages", label: "Pages" },
|
{ id: "pages", label: "Pages" },
|
||||||
|
|
@ -17,33 +17,38 @@ export default function BuyMorePage() {
|
||||||
const [activeTab, setActiveTab] = useState<TabId>("pages");
|
const [activeTab, setActiveTab] = useState<TabId>("pages");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[calc(100vh-64px)] select-none items-center justify-center px-4 py-8">
|
<motion.div
|
||||||
<motion.div
|
initial={{ opacity: 0, y: 20 }}
|
||||||
initial={{ opacity: 0, y: 20 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
transition={{ duration: 0.3 }}
|
||||||
transition={{ duration: 0.3 }}
|
className="w-full select-none"
|
||||||
className="w-full max-w-md space-y-6"
|
>
|
||||||
|
<Tabs
|
||||||
|
value={activeTab}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setActiveTab(value as TabId);
|
||||||
|
}}
|
||||||
|
className="relative min-h-[37rem] w-full"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-center rounded-lg border bg-muted/30 p-1">
|
<TabsList className="absolute top-20 left-1/2 -translate-x-1/2 rounded-xl bg-accent p-1">
|
||||||
{TABS.map((tab) => (
|
{TABS.map((tab) => (
|
||||||
<button
|
<TabsTrigger
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
type="button"
|
value={tab.id}
|
||||||
onClick={() => setActiveTab(tab.id)}
|
className="h-8 rounded-lg px-4 text-sm font-semibold text-accent-foreground transition-colors hover:bg-transparent hover:text-white data-[state=active]:bg-[#4a4a4a] data-[state=active]:text-white data-[state=active]:shadow-none"
|
||||||
className={cn(
|
|
||||||
"flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
|
|
||||||
activeTab === tab.id
|
|
||||||
? "bg-background text-foreground shadow-sm"
|
|
||||||
: "text-muted-foreground hover:text-foreground"
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
</button>
|
</TabsTrigger>
|
||||||
))}
|
))}
|
||||||
</div>
|
</TabsList>
|
||||||
|
|
||||||
{activeTab === "pages" ? <BuyPagesContent /> : <BuyTokensContent />}
|
<TabsContent value="pages" className="mt-0 flex min-h-[37rem] items-center pt-14">
|
||||||
</motion.div>
|
<BuyPagesContent />
|
||||||
</div>
|
</TabsContent>
|
||||||
|
<TabsContent value="tokens" className="mt-0 flex min-h-[37rem] items-center pt-14">
|
||||||
|
<BuyTokensContent />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,19 @@ export function DashboardClientLayout({
|
||||||
|
|
||||||
const electronAPI = useElectronAPI();
|
const electronAPI = useElectronAPI();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const htmlBackground = document.documentElement.style.backgroundColor;
|
||||||
|
const bodyBackground = document.body.style.backgroundColor;
|
||||||
|
|
||||||
|
document.documentElement.style.backgroundColor = "var(--panel)";
|
||||||
|
document.body.style.backgroundColor = "var(--panel)";
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.documentElement.style.backgroundColor = htmlBackground;
|
||||||
|
document.body.style.backgroundColor = bodyBackground;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!electronAPI?.onChatScreenCapture) return;
|
if (!electronAPI?.onChatScreenCapture) return;
|
||||||
return electronAPI.onChatScreenCapture((dataUrl: string) => {
|
return electronAPI.onChatScreenCapture((dataUrl: string) => {
|
||||||
|
|
@ -163,12 +176,13 @@ export function DashboardClientLayout({
|
||||||
setActiveSearchSpaceIdState(activeSeacrhSpaceId);
|
setActiveSearchSpaceIdState(activeSeacrhSpaceId);
|
||||||
|
|
||||||
// Sync to Electron store if stored value is null (first navigation)
|
// Sync to Electron store if stored value is null (first navigation)
|
||||||
if (electronAPI?.setActiveSearchSpace) {
|
if (electronAPI?.getActiveSearchSpace && electronAPI.setActiveSearchSpace) {
|
||||||
|
const setActiveSearchSpace = electronAPI.setActiveSearchSpace;
|
||||||
electronAPI
|
electronAPI
|
||||||
.getActiveSearchSpace?.()
|
.getActiveSearchSpace()
|
||||||
.then((stored) => {
|
.then((stored: string | null) => {
|
||||||
if (!stored) {
|
if (!stored) {
|
||||||
electronAPI.setActiveSearchSpace!(activeSeacrhSpaceId);
|
setActiveSearchSpace(activeSeacrhSpaceId);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ import {
|
||||||
} from "@tanstack/react-table";
|
} from "@tanstack/react-table";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import {
|
import {
|
||||||
Activity,
|
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Bug,
|
Bug,
|
||||||
|
|
@ -38,6 +37,7 @@ import {
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Terminal,
|
Terminal,
|
||||||
Trash,
|
Trash,
|
||||||
|
Workflow,
|
||||||
X,
|
X,
|
||||||
Zap,
|
Zap,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
@ -133,7 +133,6 @@ const logStatusConfig = {
|
||||||
function MessageDetails({
|
function MessageDetails({
|
||||||
message,
|
message,
|
||||||
taskName,
|
taskName,
|
||||||
metadata,
|
|
||||||
createdAt,
|
createdAt,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
|
|
@ -623,7 +622,7 @@ function LogsSummaryDashboard({
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">{t("total_logs")}</CardTitle>
|
<CardTitle className="text-sm font-medium">{t("total_logs")}</CardTitle>
|
||||||
<Activity className="h-4 w-4 text-muted-foreground" />
|
<Workflow className="h-4 w-4 text-muted-foreground" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{summary.total_logs}</div>
|
<div className="text-2xl font-bold">{summary.total_logs}</div>
|
||||||
|
|
@ -739,7 +738,7 @@ function LogsFilters({
|
||||||
</div>
|
</div>
|
||||||
{Boolean(filterInput) && (
|
{Boolean(filterInput) && (
|
||||||
<Button
|
<Button
|
||||||
className="absolute inset-y-0 end-0 flex h-full w-9 items-center justify-center rounded-e-lg text-muted-foreground/80 hover:text-foreground"
|
className="absolute inset-y-0 end-0 flex h-full w-9 items-center justify-center rounded-e-lg text-muted-foreground/80 hover:text-accent-foreground"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|
@ -1045,7 +1044,7 @@ function LogsTable({
|
||||||
}}
|
}}
|
||||||
exit={{ opacity: 0, y: -10 }}
|
exit={{ opacity: 0, y: -10 }}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-b transition-colors hover:bg-muted/50",
|
"border-b transition-colors hover:bg-accent hover:text-accent-foreground",
|
||||||
row.getIsSelected() ? "bg-muted/50" : ""
|
row.getIsSelected() ? "bg-muted/50" : ""
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,10 @@ export default function Loading() {
|
||||||
|
|
||||||
{/* Table Rows */}
|
{/* Table Rows */}
|
||||||
{[...Array(6)].map((_, i) => (
|
{[...Array(6)].map((_, i) => (
|
||||||
<div key={i} className="border-b px-4 py-3 flex items-center gap-4 hover:bg-muted/50">
|
<div
|
||||||
|
key={i}
|
||||||
|
className="border-b px-4 py-3 flex items-center gap-4 hover:bg-accent hover:text-accent-foreground"
|
||||||
|
>
|
||||||
<Skeleton className="h-4 w-4" />
|
<Skeleton className="h-4 w-4" />
|
||||||
<Skeleton className="h-6 w-12 rounded-full" />
|
<Skeleton className="h-6 w-12 rounded-full" />
|
||||||
<Skeleton className="h-6 w-16 rounded-full" />
|
<Skeleton className="h-6 w-16 rounded-full" />
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,11 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { motion } from "motion/react";
|
|
||||||
import { MorePagesContent } from "@/components/settings/more-pages-content";
|
import { MorePagesContent } from "@/components/settings/more-pages-content";
|
||||||
|
|
||||||
export default function MorePagesPage() {
|
export default function MorePagesPage() {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[calc(100vh-64px)] select-none items-center justify-center px-4 py-8">
|
<div className="w-full select-none space-y-6">
|
||||||
<motion.div
|
<MorePagesContent />
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.3 }}
|
|
||||||
className="w-full max-w-md space-y-6"
|
|
||||||
>
|
|
||||||
<MorePagesContent />
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ import {
|
||||||
type TokenUsageData,
|
type TokenUsageData,
|
||||||
TokenUsageProvider,
|
TokenUsageProvider,
|
||||||
} from "@/components/assistant-ui/token-usage-context";
|
} from "@/components/assistant-ui/token-usage-context";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
type HitlDecision,
|
type HitlDecision,
|
||||||
PendingInterruptProvider,
|
PendingInterruptProvider,
|
||||||
|
|
@ -78,12 +79,7 @@ import {
|
||||||
setActivePodcastTaskId,
|
setActivePodcastTaskId,
|
||||||
} from "@/lib/chat/podcast-state";
|
} from "@/lib/chat/podcast-state";
|
||||||
import { createStreamFlushHelpers } from "@/lib/chat/stream-flush";
|
import { createStreamFlushHelpers } from "@/lib/chat/stream-flush";
|
||||||
import {
|
import { consumeSseEvents, processSharedStreamEvent } from "@/lib/chat/stream-pipeline";
|
||||||
consumeSseEvents,
|
|
||||||
hasPersistableContent,
|
|
||||||
markInterruptsCompleted,
|
|
||||||
processSharedStreamEvent,
|
|
||||||
} from "@/lib/chat/stream-pipeline";
|
|
||||||
import {
|
import {
|
||||||
applyTurnIdToAssistantMessageList,
|
applyTurnIdToAssistantMessageList,
|
||||||
mergeChatTurnIdIntoMessage,
|
mergeChatTurnIdIntoMessage,
|
||||||
|
|
@ -92,7 +88,6 @@ import {
|
||||||
} from "@/lib/chat/stream-side-effects";
|
} from "@/lib/chat/stream-side-effects";
|
||||||
import {
|
import {
|
||||||
addToolCall,
|
addToolCall,
|
||||||
buildContentForPersistence,
|
|
||||||
buildContentForUI,
|
buildContentForUI,
|
||||||
type ContentPartsState,
|
type ContentPartsState,
|
||||||
type FrameBatchedUpdater,
|
type FrameBatchedUpdater,
|
||||||
|
|
@ -453,7 +448,7 @@ export default function NewChatPage() {
|
||||||
}, [params.search_space_id]);
|
}, [params.search_space_id]);
|
||||||
|
|
||||||
// Unified store for agent-action rows (the same react-query cache
|
// Unified store for agent-action rows (the same react-query cache
|
||||||
// the agent-actions sheet, the inline Revert button, and the
|
// the agent-actions dialog, the inline Revert button, and the
|
||||||
// per-turn Revert button all read). Hydrates from
|
// per-turn Revert button all read). Hydrates from
|
||||||
// ``GET /threads/{id}/actions`` and is updated incrementally by the
|
// ``GET /threads/{id}/actions`` and is updated incrementally by the
|
||||||
// SSE handlers + revert-batch results below — no atom side-channel.
|
// SSE handlers + revert-batch results below — no atom side-channel.
|
||||||
|
|
@ -1762,8 +1757,19 @@ export default function NewChatPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const byTcId = new Map<string, (typeof incoming)[number]>();
|
const byTcId = new Map<string, (typeof incoming)[number]>();
|
||||||
for (let i = 0; i < tcIds.length; i++) byTcId.set(tcIds[i], incoming[i]);
|
const submittedDecisions: typeof incoming = [];
|
||||||
const submittedDecisions = tcIds.map((id) => byTcId.get(id)!);
|
for (let i = 0; i < tcIds.length; i++) {
|
||||||
|
const tcId = tcIds[i];
|
||||||
|
const decision = incoming[i];
|
||||||
|
if (tcId === undefined || decision === undefined) {
|
||||||
|
toast.error(
|
||||||
|
`Cannot resume: ${incoming.length} decision(s) submitted for ${N} pending actions.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
byTcId.set(tcId, decision);
|
||||||
|
submittedDecisions.push(decision);
|
||||||
|
}
|
||||||
|
|
||||||
// All pending cards belong to the same assistant message, so a
|
// All pending cards belong to the same assistant message, so a
|
||||||
// single content-update pass suffices.
|
// single content-update pass suffices.
|
||||||
|
|
@ -2407,16 +2413,15 @@ export default function NewChatPage() {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col items-center justify-center gap-4">
|
<div className="flex h-full flex-col items-center justify-center gap-4">
|
||||||
<div className="text-destructive">Failed to load chat</div>
|
<div className="text-destructive">Failed to load chat</div>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsInitializing(true);
|
setIsInitializing(true);
|
||||||
initializeThread();
|
initializeThread();
|
||||||
}}
|
}}
|
||||||
className="rounded-md bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
|
|
||||||
>
|
>
|
||||||
Try Again
|
Try Again
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,42 +2,59 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
export default function Loading() {
|
export default function Loading() {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col bg-main-panel px-4">
|
<div
|
||||||
<div className="mx-auto w-full max-w-[44rem] flex flex-1 flex-col gap-6 py-8">
|
className="aui-root aui-thread-root @container flex h-full min-h-0 flex-col bg-main-panel"
|
||||||
{/* User message */}
|
style={{
|
||||||
<div className="flex justify-end">
|
["--thread-max-width" as string]: "42rem",
|
||||||
<Skeleton className="h-12 w-56 rounded-2xl" />
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="aui-thread-viewport relative flex flex-1 min-h-0 flex-col overflow-y-auto px-4 scroll-smooth"
|
||||||
|
style={{ scrollbarGutter: "stable" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="aui-chat-viewport-top-fade pointer-events-none sticky top-0 z-10 -mx-4 h-2 shrink-0 bg-gradient-to-b from-main-panel from-20% to-transparent"
|
||||||
|
/>
|
||||||
|
<div className="mx-auto w-full max-w-(--thread-max-width) flex flex-1 flex-col gap-6 py-8">
|
||||||
|
{/* User message */}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Skeleton className="h-12 w-56 rounded-2xl" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Assistant message */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
<Skeleton className="h-4 w-[85%]" />
|
||||||
|
<Skeleton className="h-18 w-[40%]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User message */}
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<Skeleton className="h-12 w-72 rounded-2xl" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Assistant message */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Skeleton className="h-10 w-[30%]" />
|
||||||
|
<Skeleton className="h-4 w-[90%]" />
|
||||||
|
<Skeleton className="h-6 w-[60%]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User message */}
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<Skeleton className="h-12 w-96 rounded-2xl" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Assistant message */}
|
{/* Input bar */}
|
||||||
<div className="flex flex-col gap-2">
|
<div
|
||||||
<Skeleton className="h-4 w-full" />
|
className="aui-chat-composer-footer sticky bottom-0 z-20 -mx-4 mt-auto flex flex-col items-stretch bg-gradient-to-t from-main-panel from-60% to-transparent px-4 pt-6"
|
||||||
<Skeleton className="h-4 w-[85%]" />
|
style={{ paddingBottom: "max(0.5rem, env(safe-area-inset-bottom))" }}
|
||||||
<Skeleton className="h-18 w-[40%]" />
|
>
|
||||||
</div>
|
<div className="aui-chat-composer-area relative mx-auto flex w-full max-w-(--thread-max-width) flex-col gap-3 overflow-visible">
|
||||||
|
<Skeleton className="h-28 w-full rounded-3xl" />
|
||||||
{/* User message */}
|
</div>
|
||||||
<div className="flex gap-2 justify-end">
|
|
||||||
<Skeleton className="h-12 w-72 rounded-2xl" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Assistant message */}
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Skeleton className="h-10 w-[30%]" />
|
|
||||||
<Skeleton className="h-4 w-[90%]" />
|
|
||||||
<Skeleton className="h-6 w-[60%]" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* User message */}
|
|
||||||
<div className="flex gap-2 justify-end">
|
|
||||||
<Skeleton className="h-12 w-96 rounded-2xl" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Input bar */}
|
|
||||||
<div className="sticky bottom-0 pb-6 bg-main-panel">
|
|
||||||
<div className="mx-auto w-full max-w-[44rem]">
|
|
||||||
<Skeleton className="h-24 w-full rounded-2xl" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -151,7 +151,7 @@ export default function OnboardPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex flex-col items-center p-4 bg-background dark:bg-neutral-900 select-none overflow-hidden">
|
<div className="h-screen flex flex-col items-center p-4 bg-main-panel select-none overflow-hidden">
|
||||||
<div className="w-full max-w-lg flex flex-col min-h-0 h-full gap-6 py-8">
|
<div className="w-full max-w-lg flex flex-col min-h-0 h-full gap-6 py-8">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="text-center space-y-3 shrink-0">
|
<div className="text-center space-y-3 shrink-0">
|
||||||
|
|
@ -165,7 +165,7 @@ export default function OnboardPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Form card */}
|
{/* Form card */}
|
||||||
<div className="rounded-xl border bg-background dark:bg-neutral-900 flex-1 min-h-0 overflow-y-auto px-6 py-6">
|
<div className="rounded-xl border bg-main-panel flex-1 min-h-0 overflow-y-auto px-6 py-6">
|
||||||
<LLMConfigForm
|
<LLMConfigForm
|
||||||
searchSpaceId={searchSpaceId}
|
searchSpaceId={searchSpaceId}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { GeneralSettingsManager } from "@/components/settings/general-settings-manager";
|
||||||
|
|
||||||
|
export default async function Page({ params }: { params: Promise<{ search_space_id: string }> }) {
|
||||||
|
const { search_space_id } = await params;
|
||||||
|
return <GeneralSettingsManager searchSpaceId={Number(search_space_id)} />;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { ImageModelManager } from "@/components/settings/image-model-manager";
|
||||||
|
|
||||||
|
export default async function Page({ params }: { params: Promise<{ search_space_id: string }> }) {
|
||||||
|
const { search_space_id } = await params;
|
||||||
|
return <ImageModelManager searchSpaceId={Number(search_space_id)} />;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,179 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
BookText,
|
||||||
|
Bot,
|
||||||
|
Brain,
|
||||||
|
CircleUser,
|
||||||
|
Earth,
|
||||||
|
ImageIcon,
|
||||||
|
ListChecks,
|
||||||
|
ScanEye,
|
||||||
|
UserKey,
|
||||||
|
} from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useSelectedLayoutSegment } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import type React from "react";
|
||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export type SearchSpaceSettingsTab =
|
||||||
|
| "general"
|
||||||
|
| "roles"
|
||||||
|
| "models"
|
||||||
|
| "image-models"
|
||||||
|
| "vision-models"
|
||||||
|
| "team-roles"
|
||||||
|
| "prompts"
|
||||||
|
| "team-memory"
|
||||||
|
| "public-links";
|
||||||
|
|
||||||
|
const DEFAULT_TAB: SearchSpaceSettingsTab = "general";
|
||||||
|
|
||||||
|
interface SearchSpaceSettingsLayoutShellProps {
|
||||||
|
searchSpaceId: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchSpaceSettingsLayoutShell({
|
||||||
|
searchSpaceId,
|
||||||
|
children,
|
||||||
|
}: SearchSpaceSettingsLayoutShellProps) {
|
||||||
|
const t = useTranslations("searchSpaceSettings");
|
||||||
|
const segment = useSelectedLayoutSegment();
|
||||||
|
const [tabScrollPos, setTabScrollPos] = useState<"start" | "middle" | "end">("start");
|
||||||
|
|
||||||
|
const handleTabScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||||
|
const el = e.currentTarget;
|
||||||
|
const atStart = el.scrollLeft <= 2;
|
||||||
|
const atEnd = el.scrollWidth - el.scrollLeft - el.clientWidth <= 2;
|
||||||
|
setTabScrollPos(atStart ? "start" : atEnd ? "end" : "middle");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const navItems = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
value: "general" as const,
|
||||||
|
label: t("nav_general"),
|
||||||
|
icon: <CircleUser className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "roles" as const,
|
||||||
|
label: t("nav_role_assignments"),
|
||||||
|
icon: <ListChecks className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "models" as const,
|
||||||
|
label: t("nav_agent_models"),
|
||||||
|
icon: <Bot className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "image-models" as const,
|
||||||
|
label: t("nav_image_models"),
|
||||||
|
icon: <ImageIcon className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "vision-models" as const,
|
||||||
|
label: t("nav_vision_models"),
|
||||||
|
icon: <ScanEye className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "team-roles" as const,
|
||||||
|
label: t("nav_team_roles"),
|
||||||
|
icon: <UserKey className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "prompts" as const,
|
||||||
|
label: t("nav_system_instructions"),
|
||||||
|
icon: <BookText className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "team-memory" as const,
|
||||||
|
label: "Team Memory",
|
||||||
|
icon: <Brain className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "public-links" as const,
|
||||||
|
label: t("nav_public_links"),
|
||||||
|
icon: <Earth className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeTab: SearchSpaceSettingsTab =
|
||||||
|
segment && navItems.some((item) => item.value === segment)
|
||||||
|
? (segment as SearchSpaceSettingsTab)
|
||||||
|
: DEFAULT_TAB;
|
||||||
|
const selectedLabel = navItems.find((item) => item.value === activeTab)?.label ?? t("title");
|
||||||
|
|
||||||
|
const hrefFor = (tab: SearchSpaceSettingsTab) =>
|
||||||
|
`/dashboard/${searchSpaceId}/search-space-settings/${tab}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="flex h-full min-h-[min(680px,calc(100vh-5rem))] w-full select-none flex-col gap-6 md:pt-6 md:flex-row">
|
||||||
|
<div className="md:w-[220px] md:shrink-0">
|
||||||
|
<h1 className="mb-4 px-1 text-2xl font-semibold tracking-tight">{t("title")}</h1>
|
||||||
|
<nav className="hidden flex-col gap-0.5 md:flex">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.value}
|
||||||
|
href={hrefFor(item.value)}
|
||||||
|
replace
|
||||||
|
scroll={false}
|
||||||
|
prefetch
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-auto items-center justify-start gap-3 rounded-md px-3 py-2.5 text-left text-sm font-medium transition-colors duration-150 focus:outline-none focus-visible:outline-none",
|
||||||
|
activeTab === item.value
|
||||||
|
? "bg-accent text-accent-foreground"
|
||||||
|
: "bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<div
|
||||||
|
className="overflow-x-auto border-b border-border pb-3 md:hidden"
|
||||||
|
onScroll={handleTabScroll}
|
||||||
|
style={{
|
||||||
|
maskImage: `linear-gradient(to right, ${tabScrollPos === "start" ? "black" : "transparent"}, black 24px, black calc(100% - 24px), ${tabScrollPos === "end" ? "black" : "transparent"})`,
|
||||||
|
WebkitMaskImage: `linear-gradient(to right, ${tabScrollPos === "start" ? "black" : "transparent"}, black 24px, black calc(100% - 24px), ${tabScrollPos === "end" ? "black" : "transparent"})`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.value}
|
||||||
|
href={hrefFor(item.value)}
|
||||||
|
replace
|
||||||
|
scroll={false}
|
||||||
|
prefetch
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-auto shrink-0 items-center gap-2 rounded-md px-3 py-1.5 text-xs font-medium transition-colors duration-150 focus:outline-none focus-visible:outline-none",
|
||||||
|
activeTab === item.value
|
||||||
|
? "bg-accent text-accent-foreground"
|
||||||
|
: "bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="hidden md:block">
|
||||||
|
<h2 className="text-lg font-semibold">{selectedLabel}</h2>
|
||||||
|
<Separator className="mt-4" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 pt-4 md:max-w-3xl">{children}</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
import type React from "react";
|
||||||
|
import { use } from "react";
|
||||||
|
import { SearchSpaceSettingsLayoutShell } from "./layout-shell";
|
||||||
|
|
||||||
|
export default function SearchSpaceSettingsLayout({
|
||||||
|
params,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ search_space_id: string }>;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const { search_space_id } = use(params);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SearchSpaceSettingsLayoutShell searchSpaceId={search_space_id}>
|
||||||
|
{children}
|
||||||
|
</SearchSpaceSettingsLayoutShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { AgentModelManager } from "@/components/settings/agent-model-manager";
|
||||||
|
|
||||||
|
export default async function Page({ params }: { params: Promise<{ search_space_id: string }> }) {
|
||||||
|
const { search_space_id } = await params;
|
||||||
|
return <AgentModelManager searchSpaceId={Number(search_space_id)} />;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default async function SearchSpaceSettingsPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ search_space_id: string }>;
|
||||||
|
}) {
|
||||||
|
const { search_space_id } = await params;
|
||||||
|
redirect(`/dashboard/${search_space_id}/search-space-settings/general`);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { PromptConfigManager } from "@/components/settings/prompt-config-manager";
|
||||||
|
|
||||||
|
export default async function Page({ params }: { params: Promise<{ search_space_id: string }> }) {
|
||||||
|
const { search_space_id } = await params;
|
||||||
|
return <PromptConfigManager searchSpaceId={Number(search_space_id)} />;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { PublicChatSnapshotsManager } from "@/components/public-chat-snapshots/public-chat-snapshots-manager";
|
||||||
|
|
||||||
|
export default async function Page({ params }: { params: Promise<{ search_space_id: string }> }) {
|
||||||
|
const { search_space_id } = await params;
|
||||||
|
return <PublicChatSnapshotsManager searchSpaceId={Number(search_space_id)} />;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { LLMRoleManager } from "@/components/settings/llm-role-manager";
|
||||||
|
|
||||||
|
export default async function Page({ params }: { params: Promise<{ search_space_id: string }> }) {
|
||||||
|
const { search_space_id } = await params;
|
||||||
|
return <LLMRoleManager key={search_space_id} searchSpaceId={Number(search_space_id)} />;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { TeamMemoryManager } from "@/components/settings/team-memory-manager";
|
||||||
|
|
||||||
|
export default async function Page({ params }: { params: Promise<{ search_space_id: string }> }) {
|
||||||
|
const { search_space_id } = await params;
|
||||||
|
return <TeamMemoryManager searchSpaceId={Number(search_space_id)} />;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { RolesManager } from "@/components/settings/roles-manager";
|
||||||
|
|
||||||
|
export default async function Page({ params }: { params: Promise<{ search_space_id: string }> }) {
|
||||||
|
const { search_space_id } = await params;
|
||||||
|
return <RolesManager searchSpaceId={Number(search_space_id)} />;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { VisionModelManager } from "@/components/settings/vision-model-manager";
|
||||||
|
|
||||||
|
export default async function Page({ params }: { params: Promise<{ search_space_id: string }> }) {
|
||||||
|
const { search_space_id } = await params;
|
||||||
|
return <VisionModelManager searchSpaceId={Number(search_space_id)} />;
|
||||||
|
}
|
||||||
15
surfsense_web/app/dashboard/[search_space_id]/team/page.tsx
Normal file
15
surfsense_web/app/dashboard/[search_space_id]/team/page.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { TeamContent } from "./team-content";
|
||||||
|
|
||||||
|
export default async function TeamPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ search_space_id: string }>;
|
||||||
|
}) {
|
||||||
|
const { search_space_id } = await params;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full select-none space-y-6">
|
||||||
|
<TeamContent searchSpaceId={Number(search_space_id)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAtomValue, useSetAtom } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import {
|
import {
|
||||||
Calendar,
|
Calendar,
|
||||||
Check,
|
Check,
|
||||||
|
|
@ -20,6 +20,7 @@ import {
|
||||||
UserPlus,
|
UserPlus,
|
||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
|
|
@ -31,7 +32,6 @@ import {
|
||||||
updateMemberMutationAtom,
|
updateMemberMutationAtom,
|
||||||
} from "@/atoms/members/members-mutation.atoms";
|
} from "@/atoms/members/members-mutation.atoms";
|
||||||
import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms";
|
import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms";
|
||||||
import { searchSpaceSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
|
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
|
|
@ -240,46 +240,77 @@ export function TeamContent({ searchSpaceId }: TeamContentProps) {
|
||||||
|
|
||||||
if (accessLoading || membersLoading) {
|
if (accessLoading || membersLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-4 md:space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<Skeleton className="h-9 w-36 rounded-md" />
|
<Button
|
||||||
<Skeleton className="h-4 w-20" />
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
aria-disabled="true"
|
||||||
|
tabIndex={-1}
|
||||||
|
className="pointer-events-none gap-1.5 md:gap-2 text-xs md:text-sm bg-black text-white dark:bg-white dark:text-black"
|
||||||
|
>
|
||||||
|
<UserPlus className="h-3.5 w-3.5 md:h-4 md:w-4" />
|
||||||
|
Invite members
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
aria-disabled="true"
|
||||||
|
tabIndex={-1}
|
||||||
|
className="pointer-events-none gap-1.5 md:gap-2 text-xs md:text-sm"
|
||||||
|
>
|
||||||
|
<Link2 className="h-3.5 w-3.5 md:h-4 md:w-4 rotate-315" />
|
||||||
|
Active invites
|
||||||
|
<span className="inline-flex items-center justify-center h-4 md:h-5 min-w-4 md:min-w-5 px-1 rounded-full bg-neutral-700 text-neutral-200">
|
||||||
|
<Skeleton className="h-2.5 w-2.5 rounded-sm bg-neutral-500/60" />
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
<div className="flex items-center gap-1 text-xs md:text-sm text-muted-foreground whitespace-nowrap">
|
||||||
|
<Skeleton className="h-3 w-2 rounded-sm" />
|
||||||
|
members
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border border-border/40 bg-background overflow-hidden">
|
<div className="rounded-lg border border-border/60 bg-accent overflow-hidden">
|
||||||
<Table className="table-fixed w-full">
|
<Table className="table-fixed w-full">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="hover:bg-transparent border-b border-border/40">
|
<TableRow className="hover:bg-transparent border-b border-border/60">
|
||||||
<TableHead className="w-[45%] px-4 md:px-6 border-r border-border/40">
|
<TableHead className="w-[45%] px-4 md:px-6 border-r border-border/60">
|
||||||
<Skeleton className="h-3 w-16" />
|
<span className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground/70">
|
||||||
|
<User size={14} className="opacity-60 text-muted-foreground" />
|
||||||
|
Name
|
||||||
|
</span>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="hidden md:table-cell w-[25%] border-r border-border/40">
|
<TableHead className="hidden md:table-cell w-[25%] border-r border-border/60">
|
||||||
<Skeleton className="h-3 w-24" />
|
<span className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground/70">
|
||||||
|
<Clock size={14} className="opacity-60 text-muted-foreground" />
|
||||||
|
Last logged in
|
||||||
|
</span>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="w-[30%] px-4 md:px-6">
|
<TableHead className="w-[30%] px-4 md:px-6">
|
||||||
<div className="flex justify-end">
|
<span className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground/70 justify-end">
|
||||||
<Skeleton className="h-3 w-12" />
|
<ShieldUser size={14} className="opacity-60 text-muted-foreground" />
|
||||||
</div>
|
Role
|
||||||
|
</span>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{SKELETON_KEYS.map((id) => (
|
{SKELETON_KEYS.slice(0, 2).map((id) => (
|
||||||
<TableRow key={id} className="border-b border-border/40 hover:bg-transparent">
|
<TableRow key={id} className="border-b border-border/60 hover:bg-transparent">
|
||||||
<TableCell className="w-[45%] py-2.5 px-4 md:px-6 border-r border-border/40">
|
<TableCell className="w-[45%] py-2.5 px-4 md:px-6 border-r border-border/60">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Skeleton className="h-10 w-10 rounded-full shrink-0" />
|
<Skeleton className="h-10 w-10 rounded-full shrink-0" />
|
||||||
<div className="flex-1 min-w-0 space-y-1.5">
|
<Skeleton className="h-4 w-28 md:w-32" />
|
||||||
<Skeleton className="h-4 w-[60%]" />
|
|
||||||
<Skeleton className="h-3 w-[40%]" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="hidden md:table-cell w-[25%] py-2.5 border-r border-border/40">
|
<TableCell className="hidden md:table-cell w-[25%] py-2.5 border-r border-border/60">
|
||||||
<Skeleton className="h-4 w-24" />
|
<Skeleton className="h-4 w-24" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="w-[30%] py-2.5 px-4 md:px-6">
|
<TableCell className="w-[30%] py-2.5 px-4 md:px-6">
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Skeleton className="h-4 w-16" />
|
<Skeleton className="h-4 w-12" />
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|
@ -294,41 +325,63 @@ export function TeamContent({ searchSpaceId }: TeamContentProps) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 md:space-y-6">
|
<div className="space-y-4 md:space-y-6">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
{rolesLoading ? (
|
{canInvite &&
|
||||||
<Skeleton className="h-9 w-32 rounded-md" />
|
(rolesLoading ? (
|
||||||
) : (
|
<Button
|
||||||
canInvite && (
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
aria-disabled="true"
|
||||||
|
tabIndex={-1}
|
||||||
|
className="pointer-events-none gap-1.5 md:gap-2 text-xs md:text-sm bg-black text-white dark:bg-white dark:text-black"
|
||||||
|
>
|
||||||
|
<UserPlus className="h-3.5 w-3.5 md:h-4 md:w-4" />
|
||||||
|
Invite members
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
<CreateInviteDialog
|
<CreateInviteDialog
|
||||||
roles={roles}
|
roles={roles}
|
||||||
onCreateInvite={handleCreateInvite}
|
onCreateInvite={handleCreateInvite}
|
||||||
searchSpaceId={searchSpaceId}
|
searchSpaceId={searchSpaceId}
|
||||||
/>
|
/>
|
||||||
)
|
))}
|
||||||
)}
|
{canInvite &&
|
||||||
{invitesLoading ? (
|
(invitesLoading ? (
|
||||||
<Skeleton className="h-9 w-32 rounded-md" />
|
<Button
|
||||||
) : (
|
type="button"
|
||||||
canInvite &&
|
variant="secondary"
|
||||||
activeInvites.length > 0 && (
|
size="sm"
|
||||||
<AllInvitesDialog invites={activeInvites} onRevokeInvite={handleRevokeInvite} />
|
aria-disabled="true"
|
||||||
)
|
tabIndex={-1}
|
||||||
)}
|
className="pointer-events-none gap-1.5 md:gap-2 text-xs md:text-sm"
|
||||||
|
>
|
||||||
|
<Link2 className="h-3.5 w-3.5 md:h-4 md:w-4 rotate-315" />
|
||||||
|
Active invites
|
||||||
|
<span className="inline-flex items-center justify-center h-4 md:h-5 min-w-4 md:min-w-5 px-1 rounded-full bg-neutral-700 text-neutral-200">
|
||||||
|
<Skeleton className="h-2.5 w-2.5 rounded-sm bg-neutral-500/60" />
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
activeInvites.length > 0 && (
|
||||||
|
<AllInvitesDialog invites={activeInvites} onRevokeInvite={handleRevokeInvite} />
|
||||||
|
)
|
||||||
|
))}
|
||||||
<p className="text-xs md:text-sm text-muted-foreground whitespace-nowrap">
|
<p className="text-xs md:text-sm text-muted-foreground whitespace-nowrap">
|
||||||
{members.length} {members.length === 1 ? "member" : "members"}
|
{members.length} {members.length === 1 ? "member" : "members"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg border border-border/40 bg-background overflow-hidden">
|
<div className="rounded-lg border border-border/60 bg-accent overflow-hidden">
|
||||||
<Table className="table-fixed w-full">
|
<Table className="table-fixed w-full">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="hover:bg-transparent border-b border-border/40">
|
<TableRow className="hover:bg-transparent border-b border-border/60">
|
||||||
<TableHead className="w-[45%] px-4 md:px-6 border-r border-border/40">
|
<TableHead className="w-[45%] px-4 md:px-6 border-r border-border/60">
|
||||||
<span className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground/70">
|
<span className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground/70">
|
||||||
<User size={14} className="opacity-60 text-muted-foreground" />
|
<User size={14} className="opacity-60 text-muted-foreground" />
|
||||||
Name
|
Name
|
||||||
</span>
|
</span>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="hidden md:table-cell w-[25%] border-r border-border/40">
|
<TableHead className="hidden md:table-cell w-[25%] border-r border-border/60">
|
||||||
<span className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground/70">
|
<span className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground/70">
|
||||||
<Clock size={14} className="opacity-60 text-muted-foreground" />
|
<Clock size={14} className="opacity-60 text-muted-foreground" />
|
||||||
Last logged in
|
Last logged in
|
||||||
|
|
@ -346,6 +399,7 @@ export function TeamContent({ searchSpaceId }: TeamContentProps) {
|
||||||
{owners.map((member) => (
|
{owners.map((member) => (
|
||||||
<MemberRow
|
<MemberRow
|
||||||
key={`member-${member.id}`}
|
key={`member-${member.id}`}
|
||||||
|
searchSpaceId={searchSpaceId}
|
||||||
member={member}
|
member={member}
|
||||||
roles={roles}
|
roles={roles}
|
||||||
canManageRoles={canManageRoles}
|
canManageRoles={canManageRoles}
|
||||||
|
|
@ -357,6 +411,7 @@ export function TeamContent({ searchSpaceId }: TeamContentProps) {
|
||||||
{paginatedMembers.map((member) => (
|
{paginatedMembers.map((member) => (
|
||||||
<MemberRow
|
<MemberRow
|
||||||
key={`member-${member.id}`}
|
key={`member-${member.id}`}
|
||||||
|
searchSpaceId={searchSpaceId}
|
||||||
member={member}
|
member={member}
|
||||||
roles={roles}
|
roles={roles}
|
||||||
canManageRoles={canManageRoles}
|
canManageRoles={canManageRoles}
|
||||||
|
|
@ -433,6 +488,7 @@ export function TeamContent({ searchSpaceId }: TeamContentProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function MemberRow({
|
function MemberRow({
|
||||||
|
searchSpaceId,
|
||||||
member,
|
member,
|
||||||
roles,
|
roles,
|
||||||
canManageRoles,
|
canManageRoles,
|
||||||
|
|
@ -440,6 +496,7 @@ function MemberRow({
|
||||||
onUpdateRole,
|
onUpdateRole,
|
||||||
onRemoveMember,
|
onRemoveMember,
|
||||||
}: {
|
}: {
|
||||||
|
searchSpaceId: number;
|
||||||
member: Membership;
|
member: Membership;
|
||||||
roles: Role[];
|
roles: Role[];
|
||||||
canManageRoles: boolean;
|
canManageRoles: boolean;
|
||||||
|
|
@ -447,21 +504,23 @@ function MemberRow({
|
||||||
onUpdateRole: (membershipId: number, roleId: number | null) => Promise<Membership>;
|
onUpdateRole: (membershipId: number, roleId: number | null) => Promise<Membership>;
|
||||||
onRemoveMember: (membershipId: number) => Promise<boolean>;
|
onRemoveMember: (membershipId: number) => Promise<boolean>;
|
||||||
}) {
|
}) {
|
||||||
const setSearchSpaceSettingsDialog = useSetAtom(searchSpaceSettingsDialogAtom);
|
const router = useRouter();
|
||||||
const initials = getAvatarInitials(member);
|
const initials = getAvatarInitials(member);
|
||||||
const displayName = member.user_display_name || member.user_email || "Unknown";
|
const displayName = member.user_display_name || member.user_email || "Unknown";
|
||||||
const roleName = member.is_owner ? "Owner" : member.role?.name || "No role";
|
const roleName = member.is_owner ? "Owner" : member.role?.name || "No role";
|
||||||
const showActions = !member.is_owner && (canManageRoles || canRemove);
|
const showActions = !member.is_owner && (canManageRoles || canRemove);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow className="border-b border-border/40 transition-colors hover:bg-muted/30">
|
<TableRow className="border-b border-border/60 transition-colors hover:bg-accent hover:text-accent-foreground">
|
||||||
<TableCell className="w-[45%] py-2.5 px-4 md:px-6 max-w-0 border-r border-border/40">
|
<TableCell className="w-[45%] py-2.5 px-4 md:px-6 max-w-0 border-r border-border/60">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Avatar className="size-10 shrink-0">
|
<Avatar className="size-10 shrink-0">
|
||||||
{member.user_avatar_url && (
|
{member.user_avatar_url && (
|
||||||
<AvatarImage src={member.user_avatar_url} alt={displayName} />
|
<AvatarImage src={member.user_avatar_url} alt={displayName} />
|
||||||
)}
|
)}
|
||||||
<AvatarFallback className="text-sm">{initials}</AvatarFallback>
|
<AvatarFallback className="bg-popover text-sm text-popover-foreground">
|
||||||
|
{initials}
|
||||||
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="font-medium text-sm truncate select-text">{displayName}</p>
|
<p className="font-medium text-sm truncate select-text">{displayName}</p>
|
||||||
|
|
@ -474,7 +533,7 @@ function MemberRow({
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
<TableCell className="hidden md:table-cell w-[25%] py-2.5 text-sm text-foreground border-r border-border/40">
|
<TableCell className="hidden md:table-cell w-[25%] py-2.5 text-sm text-foreground border-r border-border/60">
|
||||||
{member.user_last_login ? formatRelativeDate(member.user_last_login) : "Never"}
|
{member.user_last_login ? formatRelativeDate(member.user_last_login) : "Never"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
|
|
@ -482,18 +541,20 @@ function MemberRow({
|
||||||
{showActions ? (
|
{showActions ? (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-auto w-[74px] justify-end gap-1.5 px-0 py-0 text-sm text-muted-foreground hover:bg-transparent hover:text-accent-foreground has-[>svg]:px-0"
|
||||||
>
|
>
|
||||||
{roleName}
|
{roleName}
|
||||||
<ChevronDown className="h-4 w-4" />
|
<ChevronDown className="h-4 w-4" />
|
||||||
</button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
align="end"
|
align="end"
|
||||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||||
className="min-w-[120px] dark:bg-neutral-900 dark:border dark:border-white/5"
|
className="min-w-[120px]"
|
||||||
>
|
>
|
||||||
{canManageRoles &&
|
{canManageRoles &&
|
||||||
roles
|
roles
|
||||||
|
|
@ -536,13 +597,10 @@ function MemberRow({
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
)}
|
)}
|
||||||
<DropdownMenuSeparator className="dark:bg-white/5" />
|
<DropdownMenuSeparator className="bg-popover-border" />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setSearchSpaceSettingsDialog({
|
router.push(`/dashboard/${searchSpaceId}/search-space-settings/team-roles`)
|
||||||
open: true,
|
|
||||||
initialTab: "team-roles",
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Manage Roles
|
Manage Roles
|
||||||
|
|
@ -707,7 +765,7 @@ function CreateInviteDialog({
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="invite-role">Role</Label>
|
<Label htmlFor="invite-role">Role</Label>
|
||||||
<Select value={roleId} onValueChange={setRoleId}>
|
<Select value={roleId} onValueChange={setRoleId}>
|
||||||
<SelectTrigger>
|
<SelectTrigger className="border-popover-border">
|
||||||
<SelectValue placeholder="Assign a role" />
|
<SelectValue placeholder="Assign a role" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|
@ -743,7 +801,7 @@ function CreateInviteDialog({
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-start text-left font-normal bg-transparent",
|
"w-full justify-start text-left font-normal bg-transparent border-popover-border",
|
||||||
!expiresAt && "text-muted-foreground"
|
!expiresAt && "text-muted-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { AgentPermissionsContent } from "../components/AgentPermissionsContent";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <AgentPermissionsContent />;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { AgentStatusContent } from "../components/AgentStatusContent";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <AgentStatusContent />;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { ApiKeyContent } from "../components/ApiKeyContent";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <ApiKeyContent />;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { CommunityPromptsContent } from "../components/CommunityPromptsContent";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <CommunityPromptsContent />;
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { AlertTriangle, Check, Plus, ShieldCheck, Trash2, X } from "lucide-react";
|
import { AlertTriangle, Info, ShieldCheck, Trash2 } from "lucide-react";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { agentFlagsAtom } from "@/atoms/agent/agent-flags-query.atom";
|
import { agentFlagsAtom } from "@/atoms/agent/agent-flags-query.atom";
|
||||||
|
|
@ -20,6 +20,15 @@ import {
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import {
|
import {
|
||||||
|
|
@ -29,6 +38,7 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import {
|
import {
|
||||||
type AgentPermissionAction,
|
type AgentPermissionAction,
|
||||||
|
|
@ -67,20 +77,29 @@ function permissionRulesQueryKey(searchSpaceId: number) {
|
||||||
function ScopeBadge({ rule }: { rule: AgentPermissionRule }) {
|
function ScopeBadge({ rule }: { rule: AgentPermissionRule }) {
|
||||||
if (rule.thread_id !== null) {
|
if (rule.thread_id !== null) {
|
||||||
return (
|
return (
|
||||||
<Badge variant="outline" className="text-[10px]">
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="text-[10px] px-1.5 py-0.5 border-0 text-muted-foreground bg-muted"
|
||||||
|
>
|
||||||
Thread #{rule.thread_id}
|
Thread #{rule.thread_id}
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (rule.user_id !== null) {
|
if (rule.user_id !== null) {
|
||||||
return (
|
return (
|
||||||
<Badge variant="outline" className="text-[10px]">
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="text-[10px] px-1.5 py-0.5 border-0 text-muted-foreground bg-muted"
|
||||||
|
>
|
||||||
User-specific
|
User-specific
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Badge variant="outline" className="text-[10px]">
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="text-[10px] px-1.5 py-0.5 border-0 text-muted-foreground bg-muted"
|
||||||
|
>
|
||||||
Search space
|
Search space
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
|
|
@ -170,8 +189,8 @@ export function AgentPermissionsContent() {
|
||||||
permission: formData.permission.trim(),
|
permission: formData.permission.trim(),
|
||||||
pattern: formData.pattern.trim() || "*",
|
pattern: formData.pattern.trim() || "*",
|
||||||
});
|
});
|
||||||
setShowForm(false);
|
|
||||||
setFormData(EMPTY_FORM);
|
setFormData(EMPTY_FORM);
|
||||||
|
setShowForm(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof AppError && err.message) {
|
if (err instanceof AppError && err.message) {
|
||||||
// already toasted by onError
|
// already toasted by onError
|
||||||
|
|
@ -190,13 +209,17 @@ export function AgentPermissionsContent() {
|
||||||
|
|
||||||
if (!featureEnabled) {
|
if (!featureEnabled) {
|
||||||
return (
|
return (
|
||||||
<Alert className="border-dashed">
|
<Alert>
|
||||||
<ShieldCheck className="size-4" />
|
<Info />
|
||||||
<AlertTitle>Permission middleware is disabled</AlertTitle>
|
<AlertTitle>Permission middleware is disabled</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Flip{" "}
|
<p>
|
||||||
<code className="rounded bg-muted px-1 text-[10px]">SURFSENSE_ENABLE_PERMISSION</code> on
|
Flip{" "}
|
||||||
the backend to manage allow/deny/ask rules from this panel.
|
<code className="rounded bg-popover px-1 py-0.5 text-[10px] text-popover-foreground">
|
||||||
|
SURFSENSE_ENABLE_PERMISSION
|
||||||
|
</code>{" "}
|
||||||
|
on the backend to manage allow/deny/ask rules from this panel.
|
||||||
|
</p>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
);
|
);
|
||||||
|
|
@ -208,28 +231,8 @@ export function AgentPermissionsContent() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<Spinner className="size-6" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isError) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border border-dashed border-destructive/40 p-8 text-center">
|
|
||||||
<AlertTriangle className="mx-auto size-8 text-destructive/60" />
|
|
||||||
<p className="mt-2 text-sm text-destructive">Failed to load rules</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{error instanceof Error ? error.message : "Unknown error."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-w-0 space-y-6 overflow-hidden">
|
<div className="min-w-0 space-y-6 overflow-visible">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
|
|
@ -237,27 +240,36 @@ export function AgentPermissionsContent() {
|
||||||
patterns and are evaluated at the most specific scope first.
|
patterns and are evaluated at the most specific scope first.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{!showForm && (
|
<Button
|
||||||
<Button
|
size="sm"
|
||||||
size="sm"
|
onClick={() => {
|
||||||
onClick={() => {
|
setShowForm(true);
|
||||||
setShowForm(true);
|
setFormData(EMPTY_FORM);
|
||||||
setFormData(EMPTY_FORM);
|
}}
|
||||||
}}
|
className="shrink-0 gap-1.5"
|
||||||
className="shrink-0 gap-1.5"
|
>
|
||||||
>
|
New rule
|
||||||
<Plus className="size-3.5" />
|
</Button>
|
||||||
New rule
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showForm && (
|
<Dialog
|
||||||
<div className="rounded-lg border border-border/60 bg-card p-6">
|
open={showForm}
|
||||||
<div className="space-y-4">
|
onOpenChange={(open) => {
|
||||||
<h3 className="text-sm font-semibold tracking-tight">New permission rule</h3>
|
setShowForm(open);
|
||||||
|
if (!open) setFormData(EMPTY_FORM);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="max-w-lg bg-popover text-popover-foreground">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>New permission rule</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Tell the agent whether matching tool calls should be allowed, denied, or paused for
|
||||||
|
approval.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="space-y-4">
|
||||||
|
<div className="grid gap-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="permission-name">Permission</Label>
|
<Label htmlFor="permission-name">Permission</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -297,43 +309,69 @@ export function AgentPermissionsContent() {
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="allow">Allow — run without asking</SelectItem>
|
<SelectItem value="allow">Allow (run without asking)</SelectItem>
|
||||||
<SelectItem value="ask">Ask — pause for approval</SelectItem>
|
<SelectItem value="ask">Ask (pause for approval)</SelectItem>
|
||||||
<SelectItem value="deny">Deny — block silently</SelectItem>
|
<SelectItem value="deny">Deny (block silently)</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<p className="text-[11px] text-muted-foreground">
|
<p className="text-[11px] text-muted-foreground">
|
||||||
{ACTION_DESCRIPTIONS[formData.action]}
|
{ACTION_DESCRIPTIONS[formData.action]}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-2 pt-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
setShowForm(false);
|
|
||||||
setFormData(EMPTY_FORM);
|
|
||||||
}}
|
|
||||||
disabled={createMutation.isPending}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={handleCreate}
|
|
||||||
disabled={createMutation.isPending || !formData.permission.trim()}
|
|
||||||
className="relative"
|
|
||||||
>
|
|
||||||
<span className={createMutation.isPending ? "opacity-0" : ""}>Create</span>
|
|
||||||
{createMutation.isPending && <Spinner className="absolute size-3.5" />}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setShowForm(false);
|
||||||
|
setFormData(EMPTY_FORM);
|
||||||
|
}}
|
||||||
|
disabled={createMutation.isPending}
|
||||||
|
className="text-sm h-9"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={createMutation.isPending || !formData.permission.trim()}
|
||||||
|
className="relative text-sm h-9 min-w-[96px]"
|
||||||
|
>
|
||||||
|
<span className={createMutation.isPending ? "opacity-0" : ""}>Create</span>
|
||||||
|
{createMutation.isPending && <Spinner size="sm" className="absolute" />}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div className="-m-1 space-y-2 p-1">
|
||||||
|
{["skeleton-a", "skeleton-b", "skeleton-c"].map((key) => (
|
||||||
|
<Card key={key} className="border-accent bg-accent/20">
|
||||||
|
<CardContent className="p-4 flex flex-col gap-3 min-h-24">
|
||||||
|
<Skeleton className="h-4 w-32 md:w-40 bg-accent" />
|
||||||
|
<Skeleton className="h-3 w-full bg-accent" />
|
||||||
|
<Skeleton className="h-3 w-24 md:w-28 bg-accent mt-auto" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{sortedRules.length === 0 && !showForm && (
|
{isError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTriangle />
|
||||||
|
<AlertTitle>Failed to load rules</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
{error instanceof Error ? error.message : "Unknown error."}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && !isError && sortedRules.length === 0 && !showForm && (
|
||||||
<div className="rounded-lg border border-dashed border-border/60 p-8 text-center">
|
<div className="rounded-lg border border-dashed border-border/60 p-8 text-center">
|
||||||
<ShieldCheck className="mx-auto size-8 text-muted-foreground/40" />
|
<ShieldCheck className="mx-auto size-8 text-muted-foreground/40" />
|
||||||
<p className="mt-2 text-sm text-muted-foreground">No rules yet</p>
|
<p className="mt-2 text-sm text-muted-foreground">No rules yet</p>
|
||||||
|
|
@ -343,8 +381,8 @@ export function AgentPermissionsContent() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{sortedRules.length > 0 && (
|
{!isLoading && !isError && sortedRules.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="-m-1 space-y-2 p-1">
|
||||||
{sortedRules.map((rule) => {
|
{sortedRules.map((rule) => {
|
||||||
const badge = ACTION_BADGE[rule.action];
|
const badge = ACTION_BADGE[rule.action];
|
||||||
const isUpdating =
|
const isUpdating =
|
||||||
|
|
@ -352,14 +390,14 @@ export function AgentPermissionsContent() {
|
||||||
const isDeleting = deleteMutation.isPending && deleteMutation.variables === rule.id;
|
const isDeleting = deleteMutation.isPending && deleteMutation.variables === rule.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Card
|
||||||
key={rule.id}
|
key={rule.id}
|
||||||
className="group flex flex-col gap-3 rounded-lg border border-border/60 bg-card p-4"
|
className="group relative overflow-hidden transition-all duration-200 border-accent bg-accent/20 hover:shadow-md h-full"
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<CardContent className="p-4 flex items-center justify-between gap-3 h-full">
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
<code className="truncate rounded bg-muted px-1.5 py-0.5 font-mono text-xs">
|
<code className="truncate font-mono text-sm font-medium text-foreground">
|
||||||
{rule.permission}
|
{rule.permission}
|
||||||
</code>
|
</code>
|
||||||
{rule.pattern !== "*" && (
|
{rule.pattern !== "*" && (
|
||||||
|
|
@ -374,7 +412,7 @@ export function AgentPermissionsContent() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
<div className="flex shrink-0 items-center self-center gap-1">
|
||||||
<Select
|
<Select
|
||||||
value={rule.action}
|
value={rule.action}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
|
|
@ -389,11 +427,7 @@ export function AgentPermissionsContent() {
|
||||||
className={cn("h-8 gap-1 border px-2 text-[11px]", badge.className)}
|
className={cn("h-8 gap-1 border px-2 text-[11px]", badge.className)}
|
||||||
>
|
>
|
||||||
<SelectValue>
|
<SelectValue>
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">{badge.label}</span>
|
||||||
{rule.action === "allow" && <Check className="size-3" />}
|
|
||||||
{rule.action === "deny" && <X className="size-3" />}
|
|
||||||
{badge.label}
|
|
||||||
</span>
|
|
||||||
</SelectValue>
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|
@ -406,7 +440,7 @@ export function AgentPermissionsContent() {
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="size-8 p-0 text-muted-foreground hover:text-destructive"
|
className="h-7 w-7 rounded-lg p-0 text-muted-foreground hover:text-destructive"
|
||||||
onClick={() => setDeleteTarget(rule.id)}
|
onClick={() => setDeleteTarget(rule.id)}
|
||||||
disabled={isUpdating || isDeleting}
|
disabled={isUpdating || isDeleting}
|
||||||
aria-label="Delete rule"
|
aria-label="Delete rule"
|
||||||
|
|
@ -414,8 +448,8 @@ export function AgentPermissionsContent() {
|
||||||
<Trash2 className="size-3.5" />
|
<Trash2 className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { CircleCheck, CircleSlash, Cog, RotateCcw } from "lucide-react";
|
import { AlertTriangle, CircleCheck, CircleSlash, Info } from "lucide-react";
|
||||||
import { useMemo } from "react";
|
import { Fragment, useMemo } from "react";
|
||||||
import { agentFlagsAtom } from "@/atoms/agent/agent-flags-query.atom";
|
import { agentFlagsAtom } from "@/atoms/agent/agent-flags-query.atom";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
@ -136,7 +136,7 @@ const FLAG_GROUPS: FlagGroup[] = [
|
||||||
{
|
{
|
||||||
id: "tier5",
|
id: "tier5",
|
||||||
title: "Tier 5 — Audit + revert",
|
title: "Tier 5 — Audit + revert",
|
||||||
subtitle: "Action log + revert route used by the Agent Actions sheet.",
|
subtitle: "Action log + revert route used by the Agent Actions dialog.",
|
||||||
flags: [
|
flags: [
|
||||||
{
|
{
|
||||||
key: "enable_action_log",
|
key: "enable_action_log",
|
||||||
|
|
@ -222,7 +222,7 @@ function FlagRow({ def, value }: { def: FlagDef; value: boolean }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AgentStatusContent() {
|
export function AgentStatusContent() {
|
||||||
const { data: flags, isLoading, isError, error, refetch } = useAtomValue(agentFlagsAtom);
|
const { data: flags, isLoading, isError, error } = useAtomValue(agentFlagsAtom);
|
||||||
|
|
||||||
const enabledCount = useMemo(() => {
|
const enabledCount = useMemo(() => {
|
||||||
if (!flags) return 0;
|
if (!flags) return 0;
|
||||||
|
|
@ -243,17 +243,10 @@ export function AgentStatusContent() {
|
||||||
if (isError || !flags) {
|
if (isError || !flags) {
|
||||||
return (
|
return (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
|
<AlertTriangle />
|
||||||
<AlertTitle>Failed to load agent status</AlertTitle>
|
<AlertTitle>Failed to load agent status</AlertTitle>
|
||||||
<AlertDescription className="flex items-center gap-2">
|
<AlertDescription>
|
||||||
{error instanceof Error ? error.message : "Unknown error."}
|
{error instanceof Error ? error.message : "Unknown error."}
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => refetch()}
|
|
||||||
className="ml-auto inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs hover:bg-background"
|
|
||||||
>
|
|
||||||
<RotateCcw className="size-3" />
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
);
|
);
|
||||||
|
|
@ -265,28 +258,36 @@ export function AgentStatusContent() {
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{masterOff ? (
|
{masterOff ? (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<Cog className="size-4" />
|
<AlertTriangle />
|
||||||
<AlertTitle>Master kill-switch is on</AlertTitle>
|
<AlertTitle>Master kill-switch is on</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
<code className="rounded bg-muted px-1 text-[10px]">
|
<p>
|
||||||
SURFSENSE_DISABLE_NEW_AGENT_STACK=true
|
Showing that{" "}
|
||||||
</code>
|
<code className="rounded bg-muted px-1 text-[10px]">
|
||||||
forces every new middleware off, regardless of the individual flags below. Restart the
|
SURFSENSE_DISABLE_NEW_AGENT_STACK=true
|
||||||
backend after changing it.
|
</code>
|
||||||
|
, which forces every new middleware off, regardless of the individual flags below.
|
||||||
|
Restart the backend after changing it.
|
||||||
|
</p>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
) : (
|
) : (
|
||||||
<Alert>
|
<Alert>
|
||||||
<Cog className="size-4" />
|
<Info />
|
||||||
<AlertTitle className="flex items-center gap-2">
|
<AlertTitle className="flex items-center gap-2">
|
||||||
Agent stack
|
Agent stack
|
||||||
<Badge variant="secondary" className="text-[10px]">
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="rounded bg-popover px-1 py-0.5 text-[9px] text-popover-foreground"
|
||||||
|
>
|
||||||
{enabledCount} on
|
{enabledCount} on
|
||||||
</Badge>
|
</Badge>
|
||||||
</AlertTitle>
|
</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Read-only mirror of the backend's <code>AgentFeatureFlags</code>. Flip an env var and
|
<p>
|
||||||
restart the backend to change a value.
|
Showing a read-only mirror of the backend's <code>AgentFeatureFlags</code>. Flip an
|
||||||
|
env var and restart the backend to change a value.
|
||||||
|
</p>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
@ -295,9 +296,9 @@ export function AgentStatusContent() {
|
||||||
const allOff = group.flags.every((f) => !flags[f.key]);
|
const allOff = group.flags.every((f) => !flags[f.key]);
|
||||||
return (
|
return (
|
||||||
<div key={group.id}>
|
<div key={group.id}>
|
||||||
{groupIdx > 0 && <Separator className="my-4" />}
|
{groupIdx > 0 && <Separator className="my-4 bg-border" />}
|
||||||
<div className="rounded-lg border border-border/60 bg-card">
|
<div className="rounded-lg border border-border/60 bg-card">
|
||||||
<div className="flex items-start justify-between gap-3 border-b px-4 py-3">
|
<div className="flex items-start justify-between gap-3 px-4 py-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-semibold">{group.title}</p>
|
<p className="text-sm font-semibold">{group.title}</p>
|
||||||
<p className="text-xs text-muted-foreground">{group.subtitle}</p>
|
<p className="text-xs text-muted-foreground">{group.subtitle}</p>
|
||||||
|
|
@ -308,9 +309,13 @@ export function AgentStatusContent() {
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="divide-y divide-border/50 px-4">
|
<Separator className="bg-border" />
|
||||||
{group.flags.map((def) => (
|
<div className="px-4">
|
||||||
<FlagRow key={def.key} def={def} value={flags[def.key]} />
|
{group.flags.map((def, flagIdx) => (
|
||||||
|
<Fragment key={def.key}>
|
||||||
|
{flagIdx > 0 && <Separator className="bg-border" />}
|
||||||
|
<FlagRow def={def} value={flags[def.key]} />
|
||||||
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
|
||||||
import { useCallback, useRef, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { useApiKey } from "@/hooks/use-api-key";
|
import { useApiKey } from "@/hooks/use-api-key";
|
||||||
import { copyToClipboard as copyToClipboardUtil } from "@/lib/utils";
|
import { copyToClipboard as copyToClipboardUtil } from "@/lib/utils";
|
||||||
|
|
@ -27,17 +28,20 @@ export function ApiKeyContent() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 min-w-0 overflow-hidden">
|
<div className="space-y-6 min-w-0 overflow-hidden">
|
||||||
<Alert className="bg-muted/50 py-3 md:py-4">
|
<Alert>
|
||||||
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
|
<Info />
|
||||||
<AlertDescription className="text-xs md:text-sm">
|
<AlertDescription>{t("api_key_warning_description")}</AlertDescription>
|
||||||
{t("api_key_warning_description")}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
<div className="rounded-lg border border-border/60 bg-card p-6 min-w-0 overflow-hidden">
|
<div className="min-w-0 overflow-hidden">
|
||||||
<h3 className="mb-4 text-sm font-semibold tracking-tight">{t("your_api_key")}</h3>
|
<h3 className="mb-4 text-sm font-semibold tracking-tight">{t("your_api_key")}</h3>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="h-12 w-full animate-pulse rounded-md border border-border/60 bg-muted/30" />
|
<div className="flex items-center gap-2 rounded-md border border-border/60 bg-muted/30 px-2.5 py-1.5">
|
||||||
|
<div className="min-w-0 flex-1 overflow-hidden">
|
||||||
|
<Skeleton className="h-3 w-full bg-accent" />
|
||||||
|
</div>
|
||||||
|
<div className="h-6 w-6 shrink-0" />
|
||||||
|
</div>
|
||||||
) : apiKey ? (
|
) : apiKey ? (
|
||||||
<div className="flex items-center gap-2 rounded-md border border-border/60 bg-muted/30 px-2.5 py-1.5">
|
<div className="flex items-center gap-2 rounded-md border border-border/60 bg-muted/30 px-2.5 py-1.5">
|
||||||
<div className="min-w-0 flex-1 overflow-x-auto scrollbar-hide">
|
<div className="min-w-0 flex-1 overflow-x-auto scrollbar-hide">
|
||||||
|
|
@ -52,7 +56,7 @@ export function ApiKeyContent() {
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={copyToClipboard}
|
onClick={copyToClipboard}
|
||||||
className="h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground"
|
className="h-6 w-6 shrink-0 text-muted-foreground hover:text-accent-foreground"
|
||||||
>
|
>
|
||||||
{copied ? (
|
{copied ? (
|
||||||
<Check className="h-3 w-3 text-green-500" />
|
<Check className="h-3 w-3 text-green-500" />
|
||||||
|
|
@ -70,7 +74,7 @@ export function ApiKeyContent() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg border border-border/60 bg-card p-6 min-w-0 overflow-hidden">
|
<div className="min-w-0 overflow-hidden">
|
||||||
<h3 className="mb-2 text-sm font-semibold tracking-tight">{t("usage_title")}</h3>
|
<h3 className="mb-2 text-sm font-semibold tracking-tight">{t("usage_title")}</h3>
|
||||||
<p className="mb-4 text-[11px] text-muted-foreground/60">{t("usage_description")}</p>
|
<p className="mb-4 text-[11px] text-muted-foreground/60">{t("usage_description")}</p>
|
||||||
<div className="flex items-center gap-2 rounded-md border border-border/60 bg-muted/30 px-2.5 py-1.5">
|
<div className="flex items-center gap-2 rounded-md border border-border/60 bg-muted/30 px-2.5 py-1.5">
|
||||||
|
|
@ -86,7 +90,7 @@ export function ApiKeyContent() {
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={copyUsageToClipboard}
|
onClick={copyUsageToClipboard}
|
||||||
className="h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground"
|
className="h-6 w-6 shrink-0 text-muted-foreground hover:text-accent-foreground"
|
||||||
>
|
>
|
||||||
{copiedUsage ? (
|
{copiedUsage ? (
|
||||||
<Check className="h-3 w-3 text-green-500" />
|
<Check className="h-3 w-3 text-green-500" />
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { AlertTriangle, Copy, Globe, Sparkles } from "lucide-react";
|
import { AlertTriangle, Copy, Library } from "lucide-react";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { copyPromptMutationAtom } from "@/atoms/prompts/prompts-mutation.atoms";
|
import { copyPromptMutationAtom } from "@/atoms/prompts/prompts-mutation.atoms";
|
||||||
import { publicPromptsAtom } from "@/atoms/prompts/prompts-query.atoms";
|
import { publicPromptsAtom } from "@/atoms/prompts/prompts-query.atoms";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
|
||||||
export function CommunityPromptsContent() {
|
export function CommunityPromptsContent() {
|
||||||
|
|
@ -34,33 +37,37 @@ export function CommunityPromptsContent() {
|
||||||
|
|
||||||
const list = prompts ?? [];
|
const list = prompts ?? [];
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<Spinner className="size-6" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isError) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border border-dashed border-destructive/40 p-8 text-center">
|
|
||||||
<AlertTriangle className="mx-auto size-8 text-destructive/60" />
|
|
||||||
<p className="mt-2 text-sm text-destructive">Failed to load community prompts</p>
|
|
||||||
<p className="text-xs text-muted-foreground">Please try refreshing the page.</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 min-w-0 overflow-hidden">
|
<div className="space-y-6 min-w-0 overflow-hidden">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Prompts shared by other users. Add any to your collection with one click.
|
Prompts shared by other users. Add any to your collection with one click.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{list.length === 0 && (
|
{isLoading && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{["skeleton-a", "skeleton-b", "skeleton-c"].map((key) => (
|
||||||
|
<Card key={key} className="border-accent bg-accent/20">
|
||||||
|
<CardContent className="p-4 flex flex-col gap-3 min-h-24">
|
||||||
|
<Skeleton className="h-4 w-32 md:w-40 bg-accent" />
|
||||||
|
<Skeleton className="h-3 w-full bg-accent" />
|
||||||
|
<Skeleton className="h-3 w-24 md:w-28 bg-accent mt-auto" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTriangle />
|
||||||
|
<AlertTitle>Failed to load community prompts</AlertTitle>
|
||||||
|
<AlertDescription>Please try refreshing the page.</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && !isError && list.length === 0 && (
|
||||||
<div className="rounded-lg border border-dashed border-border/60 p-8 text-center">
|
<div className="rounded-lg border border-dashed border-border/60 p-8 text-center">
|
||||||
<Globe className="mx-auto size-8 text-muted-foreground" />
|
<Library className="mx-auto size-8 text-muted-foreground" />
|
||||||
<p className="mt-2 text-sm text-muted-foreground">No community prompts yet</p>
|
<p className="mt-2 text-sm text-muted-foreground">No community prompts yet</p>
|
||||||
<p className="text-xs text-muted-foreground/60">
|
<p className="text-xs text-muted-foreground/60">
|
||||||
Share your own prompts from the My Prompts tab
|
Share your own prompts from the My Prompts tab
|
||||||
|
|
@ -68,58 +75,58 @@ export function CommunityPromptsContent() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{list.length > 0 && (
|
{!isLoading && !isError && list.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{list.map((prompt) => (
|
{list.map((prompt) => (
|
||||||
<div
|
<Card
|
||||||
key={prompt.id}
|
key={prompt.id}
|
||||||
className="group flex items-start gap-3 rounded-lg border border-border/60 bg-card p-4"
|
className="group relative overflow-hidden transition-all duration-200 border-accent bg-accent/20 hover:shadow-md h-full"
|
||||||
>
|
>
|
||||||
<div className="mt-0.5 shrink-0 text-muted-foreground">
|
<CardContent className="p-4 flex items-start gap-3 h-full">
|
||||||
<Sparkles className="size-4" />
|
<div className="flex-1 min-w-0">
|
||||||
</div>
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex-1 min-w-0">
|
<span className="text-sm font-medium">{prompt.name}</span>
|
||||||
<div className="flex items-center gap-2">
|
<span className="rounded-md border-0 bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||||
<span className="text-sm font-medium">{prompt.name}</span>
|
{prompt.mode}
|
||||||
<span className="rounded-full border px-2 py-0.5 text-[10px] text-muted-foreground">
|
|
||||||
{prompt.mode}
|
|
||||||
</span>
|
|
||||||
{prompt.author_name && (
|
|
||||||
<span className="text-[11px] text-muted-foreground/60">
|
|
||||||
by {prompt.author_name}
|
|
||||||
</span>
|
</span>
|
||||||
|
{prompt.author_name && (
|
||||||
|
<span className="text-[11px] text-muted-foreground/60">
|
||||||
|
by {prompt.author_name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className={`mt-1 text-xs text-muted-foreground ${expandedId === prompt.id ? "whitespace-pre-wrap" : "line-clamp-2"}`}
|
||||||
|
>
|
||||||
|
{prompt.prompt}
|
||||||
|
</p>
|
||||||
|
{prompt.prompt.length > 100 && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="link"
|
||||||
|
onClick={() => setExpandedId(expandedId === prompt.id ? null : prompt.id)}
|
||||||
|
className="mt-1 h-auto cursor-pointer px-0 py-0 text-[11px] text-primary"
|
||||||
|
>
|
||||||
|
{expandedId === prompt.id ? "See less" : "See more"}
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p
|
<Button
|
||||||
className={`mt-1 text-xs text-muted-foreground ${expandedId === prompt.id ? "whitespace-pre-wrap" : "line-clamp-2"}`}
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 shrink-0 gap-1.5 rounded-lg px-2 text-muted-foreground hover:text-accent-foreground"
|
||||||
|
disabled={copyingIds.has(prompt.id)}
|
||||||
|
onClick={() => handleCopy(prompt.id)}
|
||||||
>
|
>
|
||||||
{prompt.prompt}
|
{copyingIds.has(prompt.id) ? (
|
||||||
</p>
|
<Spinner className="size-3" />
|
||||||
{prompt.prompt.length > 100 && (
|
) : (
|
||||||
<button
|
<Copy className="size-3" />
|
||||||
type="button"
|
)}
|
||||||
onClick={() => setExpandedId(expandedId === prompt.id ? null : prompt.id)}
|
Add to mine
|
||||||
className="mt-1 text-[11px] text-primary hover:underline cursor-pointer"
|
</Button>
|
||||||
>
|
</CardContent>
|
||||||
{expandedId === prompt.id ? "See less" : "See more"}
|
</Card>
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="shrink-0 gap-1.5"
|
|
||||||
disabled={copyingIds.has(prompt.id)}
|
|
||||||
onClick={() => handleCopy(prompt.id)}
|
|
||||||
>
|
|
||||||
{copyingIds.has(prompt.id) ? (
|
|
||||||
<Spinner className="size-3" />
|
|
||||||
) : (
|
|
||||||
<Copy className="size-3" />
|
|
||||||
)}
|
|
||||||
Add to mine
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
|
|
@ -11,7 +10,8 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import type { SearchSpace } from "@/contracts/types/search-space.types";
|
import type { SearchSpace } from "@/contracts/types/search-space.types";
|
||||||
import { useElectronAPI } from "@/hooks/use-platform";
|
import { useElectronAPI } from "@/hooks/use-platform";
|
||||||
|
|
@ -77,8 +77,27 @@ export function DesktopContent() {
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex flex-col gap-4 md:gap-6">
|
||||||
<Spinner size="md" className="text-muted-foreground" />
|
<section>
|
||||||
|
<div className="flex flex-col gap-2 pb-2 md:pb-3">
|
||||||
|
<Skeleton className="h-6 w-48 bg-accent" />
|
||||||
|
<Skeleton className="h-4 w-full max-w-2xl bg-accent" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-10 w-full bg-accent" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Separator className="bg-border" />
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div className="flex flex-col gap-2 pb-2 md:pb-3">
|
||||||
|
<Skeleton className="h-6 w-44 bg-accent" />
|
||||||
|
<Skeleton className="h-4 w-full max-w-3xl bg-accent" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<Skeleton className="h-20 w-full bg-accent" />
|
||||||
|
<Skeleton className="h-20 w-full bg-accent" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -124,16 +143,16 @@ export function DesktopContent() {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 md:space-y-6">
|
<div className="flex flex-col gap-4 md:gap-6">
|
||||||
<Card>
|
<section>
|
||||||
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
|
<div className="pb-2 md:pb-3">
|
||||||
<CardTitle className="text-base md:text-lg">Default Search Space</CardTitle>
|
<h2 className="text-base md:text-lg font-semibold">Default Search Space</h2>
|
||||||
<CardDescription className="text-xs md:text-sm">
|
<p className="text-xs md:text-sm text-muted-foreground">
|
||||||
Choose which search space General Assist, Screenshot Assist, and Quick Assist use by
|
Choose which search space General Assist, Screenshot Assist, and Quick Assist use by
|
||||||
default.
|
default.
|
||||||
</CardDescription>
|
</p>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent className="px-3 md:px-6 pb-3 md:pb-6">
|
<div>
|
||||||
{searchSpaces.length > 0 ? (
|
{searchSpaces.length > 0 ? (
|
||||||
<Select value={activeSpaceId ?? undefined} onValueChange={handleSearchSpaceChange}>
|
<Select value={activeSpaceId ?? undefined} onValueChange={handleSearchSpaceChange}>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger className="w-full">
|
||||||
|
|
@ -152,21 +171,23 @@ export function DesktopContent() {
|
||||||
No search spaces found. Create one first.
|
No search spaces found. Create one first.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</section>
|
||||||
|
|
||||||
<Card>
|
<Separator className="bg-border" />
|
||||||
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
|
|
||||||
<CardTitle className="text-base md:text-lg flex items-center gap-2">
|
<section>
|
||||||
|
<div className="pb-2 md:pb-3">
|
||||||
|
<h2 className="text-base md:text-lg font-semibold flex items-center gap-2">
|
||||||
Launch on Startup
|
Launch on Startup
|
||||||
</CardTitle>
|
</h2>
|
||||||
<CardDescription className="text-xs md:text-sm">
|
<p className="text-xs md:text-sm text-muted-foreground">
|
||||||
Automatically start SurfSense when you sign in to your computer so global shortcuts and
|
Automatically start SurfSense when you sign in to your computer so global shortcuts and
|
||||||
folder sync are always available.
|
folder sync are always available.
|
||||||
</CardDescription>
|
</p>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent className="px-3 md:px-6 pb-3 md:pb-6 space-y-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
<div className="flex items-center justify-between rounded-lg bg-accent p-4">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<Label htmlFor="auto-launch-toggle" className="text-sm font-medium cursor-pointer">
|
<Label htmlFor="auto-launch-toggle" className="text-sm font-medium cursor-pointer">
|
||||||
Open SurfSense at login
|
Open SurfSense at login
|
||||||
|
|
@ -184,7 +205,7 @@ export function DesktopContent() {
|
||||||
disabled={!autoLaunchSupported}
|
disabled={!autoLaunchSupported}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
<div className="flex items-center justify-between rounded-lg bg-accent p-4">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<Label
|
<Label
|
||||||
htmlFor="auto-launch-hidden-toggle"
|
htmlFor="auto-launch-hidden-toggle"
|
||||||
|
|
@ -193,7 +214,7 @@ export function DesktopContent() {
|
||||||
Start minimized to tray
|
Start minimized to tray
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Skip the main window on boot — SurfSense lives in the system tray until you need it.
|
Skip the main window on boot. SurfSense lives in the system tray until you need it.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
|
|
@ -203,8 +224,8 @@ export function DesktopContent() {
|
||||||
disabled={!autoLaunchSupported || !autoLaunchEnabled}
|
disabled={!autoLaunchSupported || !autoLaunchEnabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { DEFAULT_SHORTCUTS, keyEventToAccelerator } from "@/components/desktop/shortcut-recorder";
|
import { DEFAULT_SHORTCUTS, keyEventToAccelerator } from "@/components/desktop/shortcut-recorder";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { ShortcutKbd } from "@/components/ui/shortcut-kbd";
|
import { ShortcutKbd } from "@/components/ui/shortcut-kbd";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { useElectronAPI } from "@/hooks/use-platform";
|
import { useElectronAPI } from "@/hooks/use-platform";
|
||||||
|
|
@ -78,7 +79,7 @@ function HotkeyRow({
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-2.5 border-border/60 border-b py-3 last:border-b-0">
|
<div className="flex items-center justify-between gap-2.5 py-3">
|
||||||
<div className="flex items-center gap-2.5 min-w-0">
|
<div className="flex items-center gap-2.5 min-w-0">
|
||||||
<div className="flex size-7 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
|
<div className="flex size-7 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
|
||||||
<Icon className="size-3.5" />
|
<Icon className="size-3.5" />
|
||||||
|
|
@ -90,38 +91,39 @@ function HotkeyRow({
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="size-7 text-muted-foreground hover:text-foreground"
|
className="size-7 text-muted-foreground hover:text-accent-foreground"
|
||||||
onClick={onReset}
|
onClick={onReset}
|
||||||
title="Reset to default"
|
title="Reset to default"
|
||||||
>
|
>
|
||||||
<RotateCcw className="size-3" />
|
<RotateCcw className="size-3" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<button
|
<Button
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
title={recording ? "Press shortcut keys" : "Click to edit shortcut"}
|
title={recording ? "Press shortcut keys" : "Click to edit shortcut"}
|
||||||
onClick={() => setRecording(true)}
|
onClick={() => setRecording(true)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onBlur={() => setRecording(false)}
|
onBlur={() => setRecording(false)}
|
||||||
className={
|
className={
|
||||||
recording
|
recording
|
||||||
? "flex h-7 items-center rounded-md border border-transparent bg-primary/5 outline-none ring-0 focus:outline-none focus-visible:outline-none focus-visible:ring-0"
|
? "h-7 border border-transparent bg-primary/5 px-0 outline-none ring-0 focus:outline-none focus-visible:outline-none focus-visible:ring-0"
|
||||||
: "flex h-7 cursor-pointer items-center rounded-md border border-transparent bg-transparent outline-none ring-0 transition-colors hover:bg-accent hover:text-accent-foreground focus:outline-none focus-visible:outline-none focus-visible:ring-0"
|
: "h-7 cursor-pointer border border-transparent bg-transparent px-0 outline-none ring-0 transition-colors hover:bg-accent hover:text-accent-foreground focus:outline-none focus-visible:outline-none focus-visible:ring-0"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{recording ? (
|
{recording ? (
|
||||||
<span className="px-2 text-[9px] text-primary whitespace-nowrap">Press hotkeys...</span>
|
<span className="px-2 text-[9px] text-primary whitespace-nowrap">Press hotkeys</span>
|
||||||
) : (
|
) : (
|
||||||
<ShortcutKbd keys={displayKeys} className="ml-0 px-1.5 text-foreground/85" />
|
<ShortcutKbd keys={displayKeys} className="ml-0 px-1.5 text-foreground/85" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DesktopShortcutsContent() {
|
export function HotkeysContent() {
|
||||||
const api = useElectronAPI();
|
const api = useElectronAPI();
|
||||||
const [shortcuts, setShortcuts] = useState(DEFAULT_SHORTCUTS);
|
const [shortcuts, setShortcuts] = useState(DEFAULT_SHORTCUTS);
|
||||||
const [shortcutsLoaded, setShortcutsLoaded] = useState(false);
|
const [shortcutsLoaded, setShortcutsLoaded] = useState(false);
|
||||||
|
|
@ -178,17 +180,19 @@ export function DesktopShortcutsContent() {
|
||||||
return shortcutsLoaded ? (
|
return shortcutsLoaded ? (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div>
|
<div>
|
||||||
{HOTKEY_ROWS.map((row) => (
|
{HOTKEY_ROWS.map((row, index) => (
|
||||||
<HotkeyRow
|
<div key={row.key}>
|
||||||
key={row.key}
|
<HotkeyRow
|
||||||
label={row.label}
|
label={row.label}
|
||||||
value={shortcuts[row.key]}
|
value={shortcuts[row.key]}
|
||||||
defaultValue={DEFAULT_SHORTCUTS[row.key]}
|
defaultValue={DEFAULT_SHORTCUTS[row.key]}
|
||||||
icon={row.icon}
|
icon={row.icon}
|
||||||
isMac={isMac}
|
isMac={isMac}
|
||||||
onChange={(accel) => updateShortcut(row.key, accel)}
|
onChange={(accel) => updateShortcut(row.key, accel)}
|
||||||
onReset={() => resetShortcut(row.key)}
|
onReset={() => resetShortcut(row.key)}
|
||||||
/>
|
/>
|
||||||
|
{index < HOTKEY_ROWS.length - 1 ? <Separator className="bg-border" /> : null}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -177,9 +177,9 @@ export function MemoryContent() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Alert className="bg-muted/50 py-3 md:py-4">
|
<Alert>
|
||||||
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
|
<Info />
|
||||||
<AlertDescription className="text-xs md:text-sm">
|
<AlertDescription>
|
||||||
<p>
|
<p>
|
||||||
SurfSense uses this personal memory to personalize your responses across all
|
SurfSense uses this personal memory to personalize your responses across all
|
||||||
conversations.
|
conversations.
|
||||||
|
|
@ -222,7 +222,9 @@ export function MemoryContent() {
|
||||||
onClick={handleEdit}
|
onClick={handleEdit}
|
||||||
disabled={editing || !editQuery.trim()}
|
disabled={editing || !editQuery.trim()}
|
||||||
className={`h-11 w-11 shrink-0 rounded-full ${
|
className={`h-11 w-11 shrink-0 rounded-full ${
|
||||||
editing ? "" : "bg-muted-foreground/15 hover:bg-muted-foreground/20"
|
editing
|
||||||
|
? ""
|
||||||
|
: "bg-muted-foreground/15 hover:bg-accent hover:text-accent-foreground"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{editing ? (
|
{editing ? (
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,17 @@ import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { getUserAvatarColor, getUserInitials } from "@/lib/user-avatar";
|
||||||
|
|
||||||
function AvatarDisplay({ url, fallback }: { url?: string; fallback: string }) {
|
function AvatarDisplay({
|
||||||
|
url,
|
||||||
|
fallback,
|
||||||
|
bgColor,
|
||||||
|
}: {
|
||||||
|
url?: string;
|
||||||
|
fallback: string;
|
||||||
|
bgColor: string;
|
||||||
|
}) {
|
||||||
const [errorUrl, setErrorUrl] = useState<string>();
|
const [errorUrl, setErrorUrl] = useState<string>();
|
||||||
const hasError = errorUrl === url;
|
const hasError = errorUrl === url;
|
||||||
|
|
||||||
|
|
@ -23,15 +32,19 @@ function AvatarDisplay({ url, fallback }: { url?: string; fallback: string }) {
|
||||||
alt="Avatar"
|
alt="Avatar"
|
||||||
width={64}
|
width={64}
|
||||||
height={64}
|
height={64}
|
||||||
className="h-16 w-16 rounded-xl object-cover"
|
className="h-16 w-16 rounded-full object-cover select-none"
|
||||||
onError={() => setErrorUrl(url)}
|
onError={() => setErrorUrl(url)}
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
unoptimized
|
unoptimized
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-16 w-16 items-center justify-center rounded-xl bg-muted text-xl font-semibold text-muted-foreground">
|
<div
|
||||||
|
className="flex h-16 w-16 shrink-0 items-center justify-center rounded-full text-xl font-semibold text-white select-none"
|
||||||
|
style={{ backgroundColor: bgColor }}
|
||||||
|
>
|
||||||
{fallback}
|
{fallback}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -50,11 +63,6 @@ export function ProfileContent() {
|
||||||
}
|
}
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
const getInitials = (email: string) => {
|
|
||||||
const name = email.split("@")[0];
|
|
||||||
return name.slice(0, 2).toUpperCase();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
|
@ -69,6 +77,7 @@ export function ProfileContent() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasChanges = displayName !== (user?.display_name || "");
|
const hasChanges = displayName !== (user?.display_name || "");
|
||||||
|
const avatarBgColor = getUserAvatarColor(user?.email || "");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -78,13 +87,13 @@ export function ProfileContent() {
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
<div className="rounded-lg bg-card">
|
<div className="rounded-lg bg-main-panel">
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t("profile_avatar")}</Label>
|
|
||||||
<AvatarDisplay
|
<AvatarDisplay
|
||||||
url={user?.avatar_url || undefined}
|
url={user?.avatar_url || undefined}
|
||||||
fallback={getInitials(user?.email || "")}
|
fallback={getUserInitials(user?.email || "")}
|
||||||
|
bgColor={avatarBgColor}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -114,7 +123,7 @@ export function ProfileContent() {
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={isPending || !hasChanges}
|
disabled={isPending || !hasChanges}
|
||||||
className="relative gap-2 bg-white text-black hover:bg-neutral-100 dark:bg-white dark:text-black dark:hover:bg-neutral-200"
|
className="relative gap-2 bg-white text-black hover:bg-accent hover:text-accent-foreground dark:bg-white dark:text-black"
|
||||||
>
|
>
|
||||||
<span className={isPending ? "opacity-0" : ""}>{t("profile_save")}</span>
|
<span className={isPending ? "opacity-0" : ""}>{t("profile_save")}</span>
|
||||||
{isPending && <Spinner size="sm" className="absolute" />}
|
{isPending && <Spinner size="sm" className="absolute" />}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { AlertTriangle, Globe, Lock, Pencil, Sparkles, Trash2 } from "lucide-react";
|
import { AlertTriangle, Globe, Lock, MoreHorizontal, Pencil, Sparkles, Trash2 } from "lucide-react";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
updatePromptMutationAtom,
|
updatePromptMutationAtom,
|
||||||
} from "@/atoms/prompts/prompts-mutation.atoms";
|
} from "@/atoms/prompts/prompts-mutation.atoms";
|
||||||
import { promptsAtom } from "@/atoms/prompts/prompts-query.atoms";
|
import { promptsAtom } from "@/atoms/prompts/prompts-query.atoms";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
|
|
@ -21,9 +22,32 @@ import {
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import { ShortcutKbd } from "@/components/ui/shortcut-kbd";
|
import { ShortcutKbd } from "@/components/ui/shortcut-kbd";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import type { PromptRead } from "@/contracts/types/prompts.types";
|
import type { PromptRead } from "@/contracts/types/prompts.types";
|
||||||
|
|
@ -123,24 +147,6 @@ export function PromptsContent() {
|
||||||
|
|
||||||
const list = prompts ?? [];
|
const list = prompts ?? [];
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<Spinner className="size-6" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isError) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border border-dashed border-destructive/40 p-8 text-center">
|
|
||||||
<AlertTriangle className="mx-auto size-8 text-destructive/60" />
|
|
||||||
<p className="mt-2 text-sm text-destructive">Failed to load prompts</p>
|
|
||||||
<p className="text-xs text-muted-foreground">Please try refreshing the page.</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 min-w-0 overflow-hidden">
|
<div className="space-y-6 min-w-0 overflow-hidden">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|
@ -148,97 +154,150 @@ export function PromptsContent() {
|
||||||
Create prompt templates triggered with <ShortcutKbd keys={["/"]} className="ml-0" /> in
|
Create prompt templates triggered with <ShortcutKbd keys={["/"]} className="ml-0" /> in
|
||||||
the chat composer.
|
the chat composer.
|
||||||
</p>
|
</p>
|
||||||
{!showForm && (
|
<Button
|
||||||
<Button
|
size="sm"
|
||||||
size="sm"
|
onClick={() => {
|
||||||
onClick={() => {
|
setShowForm(true);
|
||||||
setShowForm(true);
|
setEditingId(null);
|
||||||
setEditingId(null);
|
setFormData(EMPTY_FORM);
|
||||||
setFormData(EMPTY_FORM);
|
}}
|
||||||
}}
|
className="shrink-0 gap-1.5"
|
||||||
className="shrink-0 gap-1.5"
|
>
|
||||||
>
|
New
|
||||||
New
|
</Button>
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showForm && (
|
<Dialog
|
||||||
<div className="rounded-lg border border-border/60 bg-card p-6 space-y-4">
|
open={showForm}
|
||||||
<h3 className="text-sm font-semibold tracking-tight">
|
onOpenChange={(open) => {
|
||||||
{editingId !== null ? "Edit prompt" : "New prompt"}
|
setShowForm(open);
|
||||||
</h3>
|
if (!open) {
|
||||||
|
setFormData(EMPTY_FORM);
|
||||||
|
setEditingId(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="max-w-lg bg-popover text-popover-foreground">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editingId !== null ? "Edit prompt" : "New prompt"}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Create prompt templates triggered with / in the chat composer.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-4">
|
||||||
<Label htmlFor="prompt-name">Name</Label>
|
<div className="space-y-2">
|
||||||
<Input
|
<Label htmlFor="prompt-name">Name</Label>
|
||||||
id="prompt-name"
|
<Input
|
||||||
value={formData.name}
|
id="prompt-name"
|
||||||
onChange={(e) => setFormData((p) => ({ ...p, name: e.target.value }))}
|
value={formData.name}
|
||||||
placeholder="e.g. Fix grammar"
|
onChange={(e) => setFormData((p) => ({ ...p, name: e.target.value }))}
|
||||||
/>
|
placeholder="e.g. Fix grammar"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="prompt-template">Prompt template</Label>
|
||||||
|
<textarea
|
||||||
|
id="prompt-template"
|
||||||
|
value={formData.prompt}
|
||||||
|
onChange={(e) => setFormData((p) => ({ ...p, prompt: e.target.value }))}
|
||||||
|
placeholder="e.g. Fix the grammar in the following text:\n\n{selection}"
|
||||||
|
rows={4}
|
||||||
|
className="w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm outline-none resize-none focus:ring-1 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Use{" "}
|
||||||
|
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px]">
|
||||||
|
{"{selection}"}
|
||||||
|
</code>{" "}
|
||||||
|
to insert the input text. If omitted, the text is appended automatically.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="prompt-mode">Mode</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.mode}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setFormData((p) => ({ ...p, mode: value as "transform" | "explore" }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="prompt-mode" className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="transform">
|
||||||
|
Transform — rewrites or modifies your text
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="explore">
|
||||||
|
Explore — answers a question about your text
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
id="prompt-public"
|
||||||
|
checked={formData.is_public}
|
||||||
|
onCheckedChange={(checked) => setFormData((p) => ({ ...p, is_public: checked }))}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="prompt-public" className="text-sm font-normal">
|
||||||
|
Share with community
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<DialogFooter>
|
||||||
<Label htmlFor="prompt-template">Prompt template</Label>
|
<Button
|
||||||
<textarea
|
type="button"
|
||||||
id="prompt-template"
|
variant="secondary"
|
||||||
value={formData.prompt}
|
size="sm"
|
||||||
onChange={(e) => setFormData((p) => ({ ...p, prompt: e.target.value }))}
|
onClick={handleCancel}
|
||||||
placeholder="e.g. Fix the grammar in the following text:\n\n{selection}"
|
disabled={isSaving}
|
||||||
rows={4}
|
className="text-sm h-9"
|
||||||
className="w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm outline-none resize-none focus:ring-1 focus:ring-ring"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Use{" "}
|
|
||||||
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px]">
|
|
||||||
{"{selection}"}
|
|
||||||
</code>{" "}
|
|
||||||
to insert the input text. If omitted, the text is appended automatically.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="prompt-mode">Mode</Label>
|
|
||||||
<select
|
|
||||||
id="prompt-mode"
|
|
||||||
value={formData.mode}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData((p) => ({ ...p, mode: e.target.value as "transform" | "explore" }))
|
|
||||||
}
|
|
||||||
className="w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring"
|
|
||||||
>
|
>
|
||||||
<option value="transform">Transform — rewrites or modifies your text</option>
|
|
||||||
<option value="explore">Explore — answers a question about your text</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Switch
|
|
||||||
id="prompt-public"
|
|
||||||
checked={formData.is_public}
|
|
||||||
onCheckedChange={(checked) => setFormData((p) => ({ ...p, is_public: checked }))}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="prompt-public" className="text-sm font-normal">
|
|
||||||
Share with community
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-2 pt-2">
|
|
||||||
<Button variant="ghost" size="sm" onClick={handleCancel}>
|
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" onClick={handleSave} disabled={isSaving} className="relative">
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="relative text-sm h-9 min-w-[96px]"
|
||||||
|
>
|
||||||
<span className={isSaving ? "opacity-0" : ""}>
|
<span className={isSaving ? "opacity-0" : ""}>
|
||||||
{editingId !== null ? "Update" : "Create"}
|
{editingId !== null ? "Update" : "Create"}
|
||||||
</span>
|
</span>
|
||||||
{isSaving && <Spinner className="size-3.5 absolute" />}
|
{isSaving && <Spinner size="sm" className="absolute" />}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{["skeleton-a", "skeleton-b", "skeleton-c"].map((key) => (
|
||||||
|
<Card key={key} className="border-accent bg-accent/20">
|
||||||
|
<CardContent className="p-4 flex flex-col gap-3 min-h-24">
|
||||||
|
<Skeleton className="h-4 w-32 md:w-40 bg-accent" />
|
||||||
|
<Skeleton className="h-3 w-full bg-accent" />
|
||||||
|
<Skeleton className="h-3 w-24 md:w-28 bg-accent mt-auto" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{list.length === 0 && !showForm && (
|
{isError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTriangle />
|
||||||
|
<AlertTitle>Failed to load prompts</AlertTitle>
|
||||||
|
<AlertDescription>Please try refreshing the page.</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && !isError && list.length === 0 && !showForm && (
|
||||||
<div className="rounded-lg border border-dashed border-border/60 p-8 text-center">
|
<div className="rounded-lg border border-dashed border-border/60 p-8 text-center">
|
||||||
<Sparkles className="mx-auto size-8 text-muted-foreground/40" />
|
<Sparkles className="mx-auto size-8 text-muted-foreground/40" />
|
||||||
<p className="mt-2 text-sm text-muted-foreground">No prompts yet</p>
|
<p className="mt-2 text-sm text-muted-foreground">No prompts yet</p>
|
||||||
|
|
@ -248,24 +307,21 @@ export function PromptsContent() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{list.length > 0 && (
|
{!isLoading && !isError && list.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{list.map((prompt) => (
|
{list.map((prompt) => (
|
||||||
<div
|
<div
|
||||||
key={prompt.id}
|
key={prompt.id}
|
||||||
className="group flex items-start gap-3 rounded-lg border border-border/60 bg-card p-4"
|
className="group relative flex items-start gap-3 overflow-hidden rounded-lg border border-accent bg-accent/20 p-4 transition-all duration-200 hover:shadow-md"
|
||||||
>
|
>
|
||||||
<div className="mt-0.5 shrink-0 text-muted-foreground">
|
|
||||||
<Sparkles className="size-4" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-medium">{prompt.name}</span>
|
<span className="text-sm font-medium">{prompt.name}</span>
|
||||||
<span className="rounded-full border px-2 py-0.5 text-[10px] text-muted-foreground">
|
<span className="rounded-md border-0 bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||||
{prompt.mode}
|
{prompt.mode}
|
||||||
</span>
|
</span>
|
||||||
{prompt.is_public && (
|
{prompt.is_public && (
|
||||||
<span className="flex items-center gap-1 rounded-full border border-primary/20 bg-primary/5 px-2 py-0.5 text-[10px] text-primary">
|
<span className="flex items-center gap-1 rounded-md border-0 bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||||
<Globe className="size-2.5" />
|
<Globe className="size-2.5" />
|
||||||
Public
|
Public
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -277,48 +333,55 @@ export function PromptsContent() {
|
||||||
{prompt.prompt}
|
{prompt.prompt}
|
||||||
</p>
|
</p>
|
||||||
{prompt.prompt.length > 100 && (
|
{prompt.prompt.length > 100 && (
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="link"
|
||||||
onClick={() => setExpandedId(expandedId === prompt.id ? null : prompt.id)}
|
onClick={() => setExpandedId(expandedId === prompt.id ? null : prompt.id)}
|
||||||
className="mt-1 text-[11px] text-primary hover:underline cursor-pointer"
|
className="mt-1 h-auto cursor-pointer px-0 py-0 text-[11px] text-primary"
|
||||||
>
|
>
|
||||||
{expandedId === prompt.id ? "See less" : "See more"}
|
{expandedId === prompt.id ? "See less" : "See more"}
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden group-hover:flex items-center gap-1 shrink-0">
|
<DropdownMenu>
|
||||||
<button
|
<DropdownMenuTrigger asChild>
|
||||||
type="button"
|
<Button
|
||||||
title={prompt.is_public ? "Make private" : "Share with community"}
|
type="button"
|
||||||
onClick={() => handleTogglePublic(prompt)}
|
variant="ghost"
|
||||||
disabled={togglingPublicIds.has(prompt.id)}
|
size="icon"
|
||||||
className="flex items-center justify-center size-7 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors disabled:opacity-50 disabled:pointer-events-none"
|
className="h-7 w-7 shrink-0 self-center rounded-lg text-muted-foreground opacity-100 pointer-events-auto transition-opacity duration-150 hover:text-accent-foreground sm:opacity-0 sm:pointer-events-none sm:group-hover:opacity-100 sm:group-hover:pointer-events-auto"
|
||||||
>
|
>
|
||||||
{togglingPublicIds.has(prompt.id) ? (
|
<MoreHorizontal className="size-3.5" />
|
||||||
<Spinner className="size-3.5" />
|
<span className="sr-only">Prompt actions</span>
|
||||||
) : prompt.is_public ? (
|
</Button>
|
||||||
<Lock className="size-3.5" />
|
</DropdownMenuTrigger>
|
||||||
) : (
|
<DropdownMenuContent align="end">
|
||||||
<Globe className="size-3.5" />
|
<DropdownMenuItem
|
||||||
)}
|
onClick={() => handleTogglePublic(prompt)}
|
||||||
</button>
|
disabled={togglingPublicIds.has(prompt.id)}
|
||||||
<Button
|
>
|
||||||
variant="ghost"
|
{togglingPublicIds.has(prompt.id) ? (
|
||||||
size="icon"
|
<Spinner className="size-4" />
|
||||||
className="size-7"
|
) : prompt.is_public ? (
|
||||||
onClick={() => handleEdit(prompt)}
|
<Lock className="size-4" />
|
||||||
>
|
) : (
|
||||||
<Pencil className="size-3.5" />
|
<Globe className="size-4" />
|
||||||
</Button>
|
)}
|
||||||
<Button
|
{prompt.is_public ? "Make private" : "Share with community"}
|
||||||
variant="ghost"
|
</DropdownMenuItem>
|
||||||
size="icon"
|
<DropdownMenuItem onClick={() => handleEdit(prompt)}>
|
||||||
className="size-7 text-destructive hover:text-destructive"
|
<Pencil className="size-4" />
|
||||||
onClick={() => setDeleteTarget(prompt.id)}
|
Edit
|
||||||
>
|
</DropdownMenuItem>
|
||||||
<Trash2 className="size-3.5" />
|
<DropdownMenuItem
|
||||||
</Button>
|
onClick={() => setDeleteTarget(prompt.id)}
|
||||||
</div>
|
className="text-destructive focus:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { DesktopContent } from "../components/DesktopContent";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <DesktopContent />;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { HotkeysContent } from "../components/HotkeysContent";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <HotkeysContent />;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,188 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Brain,
|
||||||
|
CircleUser,
|
||||||
|
Keyboard,
|
||||||
|
KeyRound,
|
||||||
|
Library,
|
||||||
|
Monitor,
|
||||||
|
ReceiptText,
|
||||||
|
ShieldCheck,
|
||||||
|
WandSparkles,
|
||||||
|
Workflow,
|
||||||
|
} from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useSelectedLayoutSegment } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import type React from "react";
|
||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { usePlatform } from "@/hooks/use-platform";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export type UserSettingsTab =
|
||||||
|
| "profile"
|
||||||
|
| "api-key"
|
||||||
|
| "prompts"
|
||||||
|
| "community-prompts"
|
||||||
|
| "memory"
|
||||||
|
| "agent-permissions"
|
||||||
|
| "agent-status"
|
||||||
|
| "purchases"
|
||||||
|
| "desktop"
|
||||||
|
| "hotkeys";
|
||||||
|
|
||||||
|
const DEFAULT_TAB: UserSettingsTab = "profile";
|
||||||
|
|
||||||
|
interface UserSettingsLayoutShellProps {
|
||||||
|
searchSpaceId: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserSettingsLayoutShell({ searchSpaceId, children }: UserSettingsLayoutShellProps) {
|
||||||
|
const t = useTranslations("userSettings");
|
||||||
|
const { isDesktop } = usePlatform();
|
||||||
|
const segment = useSelectedLayoutSegment();
|
||||||
|
const [tabScrollPos, setTabScrollPos] = useState<"start" | "middle" | "end">("start");
|
||||||
|
|
||||||
|
const handleTabScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||||
|
const el = e.currentTarget;
|
||||||
|
const atStart = el.scrollLeft <= 2;
|
||||||
|
const atEnd = el.scrollWidth - el.scrollLeft - el.clientWidth <= 2;
|
||||||
|
setTabScrollPos(atStart ? "start" : atEnd ? "end" : "middle");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const navItems = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
value: "profile" as const,
|
||||||
|
label: t("profile_nav_label"),
|
||||||
|
icon: <CircleUser className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "api-key" as const,
|
||||||
|
label: t("api_key_nav_label"),
|
||||||
|
icon: <KeyRound className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "prompts" as const,
|
||||||
|
label: "My Prompts",
|
||||||
|
icon: <WandSparkles className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "community-prompts" as const,
|
||||||
|
label: "Community Prompts",
|
||||||
|
icon: <Library className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "memory" as const,
|
||||||
|
label: "Memory",
|
||||||
|
icon: <Brain className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "agent-permissions" as const,
|
||||||
|
label: "Agent Permissions",
|
||||||
|
icon: <ShieldCheck className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "agent-status" as const,
|
||||||
|
label: "Agent Status",
|
||||||
|
icon: <Workflow className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "purchases" as const,
|
||||||
|
label: "Purchase History",
|
||||||
|
icon: <ReceiptText className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
...(isDesktop
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
value: "desktop" as const,
|
||||||
|
label: "App Preferences",
|
||||||
|
icon: <Monitor className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "hotkeys" as const,
|
||||||
|
label: "Hotkeys",
|
||||||
|
icon: <Keyboard className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
[t, isDesktop]
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeTab: UserSettingsTab =
|
||||||
|
segment && navItems.some((item) => item.value === segment)
|
||||||
|
? (segment as UserSettingsTab)
|
||||||
|
: DEFAULT_TAB;
|
||||||
|
const selectedLabel = navItems.find((item) => item.value === activeTab)?.label ?? t("title");
|
||||||
|
|
||||||
|
const hrefFor = (tab: UserSettingsTab) => `/dashboard/${searchSpaceId}/user-settings/${tab}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="flex h-full min-h-[min(680px,calc(100vh-5rem))] w-full select-none flex-col gap-6 md:pt-10 md:flex-row">
|
||||||
|
<div className="md:w-[220px] md:shrink-0">
|
||||||
|
<h1 className="mb-4 px-1 text-2xl font-semibold tracking-tight">{t("title")}</h1>
|
||||||
|
<nav className="hidden flex-col gap-0.5 md:flex">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.value}
|
||||||
|
href={hrefFor(item.value)}
|
||||||
|
replace
|
||||||
|
scroll={false}
|
||||||
|
prefetch
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-auto items-center justify-start gap-3 rounded-md px-3 py-2.5 text-left text-sm font-medium transition-colors duration-150 focus:outline-none focus-visible:outline-none",
|
||||||
|
activeTab === item.value
|
||||||
|
? "bg-accent text-accent-foreground"
|
||||||
|
: "bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<div
|
||||||
|
className="overflow-x-auto border-b border-border pb-3 md:hidden"
|
||||||
|
onScroll={handleTabScroll}
|
||||||
|
style={{
|
||||||
|
maskImage: `linear-gradient(to right, ${tabScrollPos === "start" ? "black" : "transparent"}, black 24px, black calc(100% - 24px), ${tabScrollPos === "end" ? "black" : "transparent"})`,
|
||||||
|
WebkitMaskImage: `linear-gradient(to right, ${tabScrollPos === "start" ? "black" : "transparent"}, black 24px, black calc(100% - 24px), ${tabScrollPos === "end" ? "black" : "transparent"})`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.value}
|
||||||
|
href={hrefFor(item.value)}
|
||||||
|
replace
|
||||||
|
scroll={false}
|
||||||
|
prefetch
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-auto shrink-0 items-center gap-2 rounded-md px-3 py-1.5 text-xs font-medium transition-colors duration-150 focus:outline-none focus-visible:outline-none",
|
||||||
|
activeTab === item.value
|
||||||
|
? "bg-accent text-accent-foreground"
|
||||||
|
: "bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="hidden md:block">
|
||||||
|
<h2 className="text-lg font-semibold">{selectedLabel}</h2>
|
||||||
|
<Separator className="mt-4 bg-border" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 pt-4 md:max-w-3xl">{children}</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
import type React from "react";
|
||||||
|
import { use } from "react";
|
||||||
|
import { UserSettingsLayoutShell } from "./layout-shell";
|
||||||
|
|
||||||
|
export default function UserSettingsLayout({
|
||||||
|
params,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ search_space_id: string }>;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const { search_space_id } = use(params);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UserSettingsLayoutShell searchSpaceId={search_space_id}>{children}</UserSettingsLayoutShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { MemoryContent } from "../components/MemoryContent";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <MemoryContent />;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default async function UserSettingsPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ search_space_id: string }>;
|
||||||
|
}) {
|
||||||
|
const { search_space_id } = await params;
|
||||||
|
redirect(`/dashboard/${search_space_id}/user-settings/profile`);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { ProfileContent } from "../components/ProfileContent";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <ProfileContent />;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { PromptsContent } from "../components/PromptsContent";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <PromptsContent />;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { PurchaseHistoryContent } from "../components/PurchaseHistoryContent";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <PurchaseHistoryContent />;
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { ExternalLink } from "lucide-react";
|
import { ExternalLink } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { buildIssueUrl } from "@/lib/error-toast";
|
import { buildIssueUrl } from "@/lib/error-toast";
|
||||||
|
|
||||||
export default function DashboardError({
|
export default function DashboardError({
|
||||||
|
|
@ -39,13 +40,9 @@ export default function DashboardError({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<Button type="button" onClick={reset}>
|
||||||
type="button"
|
|
||||||
onClick={reset}
|
|
||||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
|
||||||
>
|
|
||||||
Try again
|
Try again
|
||||||
</button>
|
</Button>
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard"
|
href="/dashboard"
|
||||||
className="rounded-md border border-input bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground transition-colors"
|
className="rounded-md border border-input bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { IconBrandGoogleFilled } from "@tabler/icons-react";
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { Crop, Eye, EyeOff, Rocket, RotateCcw, Zap } from "lucide-react";
|
import { Crop, Eye, EyeOff, Rocket, RotateCcw, Zap } from "lucide-react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
|
@ -24,6 +23,34 @@ const isGoogleAuth = AUTH_TYPE === "GOOGLE";
|
||||||
type ShortcutKey = "generalAssist" | "quickAsk" | "screenshotAssist";
|
type ShortcutKey = "generalAssist" | "quickAsk" | "screenshotAssist";
|
||||||
type ShortcutMap = typeof DEFAULT_SHORTCUTS;
|
type ShortcutMap = typeof DEFAULT_SHORTCUTS;
|
||||||
|
|
||||||
|
function GoogleGLogo({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={className}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 48 48"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="#EA4335"
|
||||||
|
d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#4285F4"
|
||||||
|
d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#FBBC05"
|
||||||
|
d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#34A853"
|
||||||
|
d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const HOTKEY_ROWS: Array<{
|
const HOTKEY_ROWS: Array<{
|
||||||
key: ShortcutKey;
|
key: ShortcutKey;
|
||||||
label: string;
|
label: string;
|
||||||
|
|
@ -134,25 +161,26 @@ function HotkeyRow({
|
||||||
<RotateCcw className="size-3" />
|
<RotateCcw className="size-3" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<button
|
<Button
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
title={recording ? "Press shortcut keys" : "Click to edit shortcut"}
|
title={recording ? "Press shortcut keys" : "Click to edit shortcut"}
|
||||||
onClick={() => setRecording(true)}
|
onClick={() => setRecording(true)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onBlur={() => setRecording(false)}
|
onBlur={() => setRecording(false)}
|
||||||
className={
|
className={
|
||||||
recording
|
recording
|
||||||
? "flex h-7 items-center rounded-md border border-transparent bg-primary/5 outline-none ring-0 focus:outline-none focus-visible:outline-none focus-visible:ring-0"
|
? "h-7 border border-transparent bg-primary/5 px-0 outline-none ring-0 focus:outline-none focus-visible:outline-none focus-visible:ring-0"
|
||||||
: "flex h-7 cursor-pointer items-center rounded-md border border-transparent bg-transparent outline-none ring-0 transition-colors hover:bg-accent hover:text-accent-foreground focus:outline-none focus-visible:outline-none focus-visible:ring-0"
|
: "h-7 cursor-pointer border border-transparent bg-transparent px-0 outline-none ring-0 transition-colors hover:bg-accent hover:text-accent-foreground focus:outline-none focus-visible:outline-none focus-visible:ring-0"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{recording ? (
|
{recording ? (
|
||||||
<span className="px-2 text-[9px] text-primary whitespace-nowrap">Press hotkeys...</span>
|
<span className="px-2 text-[9px] text-primary whitespace-nowrap">Press hotkeys</span>
|
||||||
) : (
|
) : (
|
||||||
<ShortcutKbd keys={displayKeys} className="ml-0 px-1.5 text-foreground/85" />
|
<ShortcutKbd keys={displayKeys} className="ml-0 px-1.5 text-foreground/85" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -167,6 +195,7 @@ export default function DesktopLoginPage() {
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [loginError, setLoginError] = useState<string | null>(null);
|
const [loginError, setLoginError] = useState<string | null>(null);
|
||||||
|
const [isGoogleRedirecting, setIsGoogleRedirecting] = useState(false);
|
||||||
|
|
||||||
const [shortcuts, setShortcuts] = useState(DEFAULT_SHORTCUTS);
|
const [shortcuts, setShortcuts] = useState(DEFAULT_SHORTCUTS);
|
||||||
const [shortcutsLoaded, setShortcutsLoaded] = useState(false);
|
const [shortcutsLoaded, setShortcutsLoaded] = useState(false);
|
||||||
|
|
@ -208,6 +237,8 @@ export default function DesktopLoginPage() {
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleGoogleLogin = () => {
|
const handleGoogleLogin = () => {
|
||||||
|
if (isGoogleRedirecting) return;
|
||||||
|
setIsGoogleRedirecting(true);
|
||||||
window.location.href = `${BACKEND_URL}/auth/google/authorize-redirect`;
|
window.location.href = `${BACKEND_URL}/auth/google/authorize-redirect`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -255,8 +286,8 @@ export default function DesktopLoginPage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex min-h-svh items-center justify-center bg-background p-4 sm:p-6 select-none">
|
<div className="relative flex min-h-svh items-center justify-center bg-main-panel p-4 sm:p-6 select-none">
|
||||||
<div className="relative flex w-full max-w-md flex-col overflow-hidden bg-card shadow-lg">
|
<div className="relative flex w-full max-w-md flex-col overflow-hidden bg-main-panel">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex flex-col items-center px-6 pt-6 pb-2 text-center">
|
<div className="flex flex-col items-center px-6 pt-6 pb-2 text-center">
|
||||||
<Image
|
<Image
|
||||||
|
|
@ -313,8 +344,13 @@ export default function DesktopLoginPage() {
|
||||||
</p> */}
|
</p> */}
|
||||||
|
|
||||||
{isGoogleAuth ? (
|
{isGoogleAuth ? (
|
||||||
<Button variant="outline" className="w-full gap-2 h-10" onClick={handleGoogleLogin}>
|
<Button
|
||||||
<IconBrandGoogleFilled className="size-4" />
|
variant="outline"
|
||||||
|
className="w-full gap-2 h-10 bg-white text-[#1f1f1f] border-white hover:bg-zinc-100 hover:text-[#1f1f1f] dark:border-white shadow-sm font-medium"
|
||||||
|
disabled={isGoogleRedirecting}
|
||||||
|
onClick={handleGoogleLogin}
|
||||||
|
>
|
||||||
|
<GoogleGLogo className="size-4" />
|
||||||
Continue with Google
|
Continue with Google
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -357,10 +393,11 @@ export default function DesktopLoginPage() {
|
||||||
disabled={isLoggingIn}
|
disabled={isLoggingIn}
|
||||||
className="h-9 pr-9"
|
className="h-9 pr-9"
|
||||||
/>
|
/>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
onClick={() => setShowPassword((v) => !v)}
|
onClick={() => setShowPassword((v) => !v)}
|
||||||
className="absolute inset-y-0 right-0 flex items-center pr-2.5 text-muted-foreground hover:text-foreground"
|
className="absolute inset-y-0 right-0 h-auto bg-transparent px-2.5 py-0 text-muted-foreground hover:bg-transparent hover:text-foreground"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
{showPassword ? (
|
{showPassword ? (
|
||||||
|
|
@ -368,7 +405,7 @@ export default function DesktopLoginPage() {
|
||||||
) : (
|
) : (
|
||||||
<Eye className="size-3.5" />
|
<Eye className="size-3.5" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -207,13 +207,14 @@ export default function DesktopPermissionsPage() {
|
||||||
<Button disabled className="text-sm h-9 min-w-[180px]">
|
<Button disabled className="text-sm h-9 min-w-[180px]">
|
||||||
Grant permissions to continue
|
Grant permissions to continue
|
||||||
</Button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="link"
|
||||||
onClick={handleSkip}
|
onClick={handleSkip}
|
||||||
className="block mx-auto text-xs text-muted-foreground hover:text-foreground transition-colors"
|
className="mx-auto h-auto px-0 py-0 text-xs text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
Skip for now
|
Skip for now
|
||||||
</button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import { ExternalLink } from "lucide-react";
|
import { ExternalLink } from "lucide-react";
|
||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { buildIssueUrl } from "@/lib/error-toast";
|
import { buildIssueUrl } from "@/lib/error-toast";
|
||||||
|
|
||||||
export default function ErrorPage({
|
export default function ErrorPage({
|
||||||
|
|
@ -37,13 +38,9 @@ export default function ErrorPage({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<Button type="button" onClick={reset}>
|
||||||
type="button"
|
|
||||||
onClick={reset}
|
|
||||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
|
||||||
>
|
|
||||||
Try again
|
Try again
|
||||||
</button>
|
</Button>
|
||||||
<a
|
<a
|
||||||
href={issueUrl}
|
href={issueUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue