mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-25 08:46:22 +02:00
Merge pull request #884 from AnishSarkar22/feat/web-search
feat: add web search (SearXNG) & UI changes
This commit is contained in:
commit
9d4945c8a5
104 changed files with 3831 additions and 2801 deletions
136
.cursor/skills/system-architecture/SKILL.md
Executable file
136
.cursor/skills/system-architecture/SKILL.md
Executable file
|
|
@ -0,0 +1,136 @@
|
||||||
|
---
|
||||||
|
name: system-architecture
|
||||||
|
description: Design systems with appropriate complexity - no more, no less. Use when the user asks to architect applications, design system boundaries, plan service decomposition, evaluate monolith vs microservices, make scaling decisions, or review structural trade-offs. Applies to new system design, refactoring, and migration planning.
|
||||||
|
---
|
||||||
|
|
||||||
|
# System Architecture
|
||||||
|
|
||||||
|
Design real structures with clear boundaries, explicit trade-offs, and appropriate complexity. Match architecture to actual requirements, not imagined future needs.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
When the user requests an architecture, follow these steps:
|
||||||
|
|
||||||
|
```
|
||||||
|
Task Progress:
|
||||||
|
- [ ] Step 1: Clarify constraints
|
||||||
|
- [ ] Step 2: Identify domains
|
||||||
|
- [ ] Step 3: Map data flow
|
||||||
|
- [ ] Step 4: Draw boundaries with rationale
|
||||||
|
- [ ] Step 5: Run complexity checklist
|
||||||
|
- [ ] Step 6: Present architecture with trade-offs
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 1 - Clarify constraints.** Ask about:
|
||||||
|
|
||||||
|
| Constraint | Question | Why it matters |
|
||||||
|
|------------|----------|----------------|
|
||||||
|
| Scale | What's the real load? (users, requests/sec, data size) | Design for 10x current, not 1000x |
|
||||||
|
| Team | How many developers? How many teams? | Deployable units ≤ number of teams |
|
||||||
|
| Lifespan | Prototype? MVP? Long-term product? | Temporary systems need temporary solutions |
|
||||||
|
| Change vectors | What actually varies? | Abstract only where you have evidence of variation |
|
||||||
|
|
||||||
|
**Step 2 - Identify domains.** Group by business capability, not technical layer. Look for things that change for different reasons and at different rates.
|
||||||
|
|
||||||
|
**Step 3 - Map data flow.** Trace: where does data enter → how does it transform → where does it exit? Make the flow obvious.
|
||||||
|
|
||||||
|
**Step 4 - Draw boundaries.** Every boundary needs a reason: different team, different change rate, different compliance requirement, or different scaling need.
|
||||||
|
|
||||||
|
**Step 5 - Run complexity checklist.** Before adding any non-trivial pattern:
|
||||||
|
|
||||||
|
```
|
||||||
|
[ ] Have I tried the simple solution?
|
||||||
|
[ ] Do I have evidence it's insufficient?
|
||||||
|
[ ] Can my team operate this?
|
||||||
|
[ ] Will this still make sense in 6 months?
|
||||||
|
[ ] Can I explain why this complexity is necessary?
|
||||||
|
```
|
||||||
|
|
||||||
|
If any answer is "no", keep it simple.
|
||||||
|
|
||||||
|
**Step 6 - Present the architecture** using the output template below.
|
||||||
|
|
||||||
|
## Output Template
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
### System: [Name]
|
||||||
|
|
||||||
|
**Constraints**:
|
||||||
|
- Scale: [current and expected load]
|
||||||
|
- Team: [size and structure]
|
||||||
|
- Lifespan: [prototype / MVP / long-term]
|
||||||
|
|
||||||
|
**Architecture**:
|
||||||
|
[Component diagram or description of components and their relationships]
|
||||||
|
|
||||||
|
**Data Flow**:
|
||||||
|
[How data enters → transforms → exits]
|
||||||
|
|
||||||
|
**Key Boundaries**:
|
||||||
|
| Boundary | Reason | Change Rate |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| ... | ... | ... |
|
||||||
|
|
||||||
|
**Trade-offs**:
|
||||||
|
- Chose X over Y because [reason]
|
||||||
|
- Accepted [limitation] to gain [benefit]
|
||||||
|
|
||||||
|
**Complexity Justification**:
|
||||||
|
- [Each non-trivial pattern] → [why it's needed, with evidence]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Core Principles
|
||||||
|
|
||||||
|
1. **Boundaries at real differences.** Separate concerns that change for different reasons and at different rates.
|
||||||
|
2. **Dependencies flow inward.** Core logic depends on nothing. Infrastructure depends on core.
|
||||||
|
3. **Follow the data.** Architecture should make data flow obvious.
|
||||||
|
4. **Design for failure.** Network fails. Databases timeout. Build compensation into the structure.
|
||||||
|
5. **Design for operations.** You will debug this at 3am. Every request needs a trace. Every error needs context for replay.
|
||||||
|
|
||||||
|
For concrete good/bad examples of each principle, see [examples.md](examples.md).
|
||||||
|
|
||||||
|
## Anti-Patterns
|
||||||
|
|
||||||
|
| Don't | Do Instead |
|
||||||
|
|-------|------------|
|
||||||
|
| Microservices for a 3-person team | Well-structured monolith |
|
||||||
|
| Event sourcing for CRUD | Simple state storage |
|
||||||
|
| Message queues within the same process | Just call the function |
|
||||||
|
| Distributed transactions | Redesign to avoid, or accept eventual consistency |
|
||||||
|
| Repository wrapping an ORM | Use the ORM directly |
|
||||||
|
| Interfaces with one implementation | Mock at boundaries only |
|
||||||
|
| AbstractFactoryFactoryBean | Just instantiate the thing |
|
||||||
|
| DI containers for simple graphs | Constructor injection is enough |
|
||||||
|
| Clean Architecture for a TODO app | Match layers to actual complexity |
|
||||||
|
| DDD tactics without strategic design | Aggregates need bounded contexts |
|
||||||
|
| Hexagonal ports with one adapter | Just call the database |
|
||||||
|
| CQRS when reads = writes | Add when they diverge |
|
||||||
|
| "We might swap databases" | You won't; rewrite if you do |
|
||||||
|
| "Multi-tenant someday" | Build it when you have tenant #2 |
|
||||||
|
| "Microservices for team scale" | Helps at 50+ engineers, not 4 |
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
Your architecture is right-sized when:
|
||||||
|
|
||||||
|
1. **You can draw it** - dependency graph fits on a whiteboard
|
||||||
|
2. **You can explain it** - new team member understands data flow in 30 minutes
|
||||||
|
3. **You can change it** - adding a feature touches 1-3 modules, not 10
|
||||||
|
4. **You can delete it** - removing a component needs no archaeology
|
||||||
|
5. **You can debug it** - tracing a request takes minutes, not hours
|
||||||
|
6. **It matches your team** - deployable units ≤ number of teams
|
||||||
|
|
||||||
|
## When the Simple Solution Isn't Enough
|
||||||
|
|
||||||
|
If the complexity checklist says "yes, scale is real", see [scaling-checklist.md](scaling-checklist.md) for concrete techniques covering caching, async processing, partitioning, horizontal scaling, and multi-region.
|
||||||
|
|
||||||
|
## Iterative Architecture
|
||||||
|
|
||||||
|
Architecture is discovered, not designed upfront:
|
||||||
|
|
||||||
|
1. **Start obvious** - group by domain, not by technical layer
|
||||||
|
2. **Let hotspots emerge** - monitor which modules change together
|
||||||
|
3. **Extract when painful** - split only when the current form causes measurable problems
|
||||||
|
4. **Document decisions** - record why boundaries exist so future you knows what's load-bearing
|
||||||
|
|
||||||
|
Every senior engineer has a graveyard of over-engineered systems they regret. Learn from their pain. Build boring systems that work.
|
||||||
120
.cursor/skills/system-architecture/examples.md
Normal file
120
.cursor/skills/system-architecture/examples.md
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
# Architecture Examples
|
||||||
|
|
||||||
|
Concrete good/bad examples for each core principle in SKILL.md.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Boundaries at Real Differences
|
||||||
|
|
||||||
|
**Good** - Meaningful boundary:
|
||||||
|
```
|
||||||
|
# Users and Billing are separate bounded contexts
|
||||||
|
# - Different teams own them
|
||||||
|
# - Different change cadences (users: weekly, billing: quarterly)
|
||||||
|
# - Different compliance requirements
|
||||||
|
|
||||||
|
src/
|
||||||
|
users/ # User management domain
|
||||||
|
models.py
|
||||||
|
services.py
|
||||||
|
api.py
|
||||||
|
billing/ # Billing domain
|
||||||
|
models.py
|
||||||
|
services.py
|
||||||
|
api.py
|
||||||
|
shared/ # Truly shared utilities
|
||||||
|
auth.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**Bad** - Ceremony without purpose:
|
||||||
|
```
|
||||||
|
# UserService → UserRepository → UserRepositoryImpl
|
||||||
|
# ...when you'll never swap the database
|
||||||
|
|
||||||
|
src/
|
||||||
|
interfaces/
|
||||||
|
IUserRepository.py # One implementation exists
|
||||||
|
repositories/
|
||||||
|
UserRepositoryImpl.py # Wraps SQLAlchemy, which is already a repository
|
||||||
|
services/
|
||||||
|
UserService.py # Just calls the repository
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies Flow Inward
|
||||||
|
|
||||||
|
**Good** - Clear dependency direction:
|
||||||
|
```
|
||||||
|
# Dependency flows inward: infrastructure → application → domain
|
||||||
|
|
||||||
|
domain/ # Pure business logic, no imports from outer layers
|
||||||
|
order.py # Order entity with business rules
|
||||||
|
|
||||||
|
application/ # Use cases, orchestrates domain
|
||||||
|
place_order.py # Imports from domain/, not infrastructure/
|
||||||
|
|
||||||
|
infrastructure/ # External concerns
|
||||||
|
postgres.py # Implements persistence, imports from application/
|
||||||
|
stripe.py # Implements payments
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Follow the Data
|
||||||
|
|
||||||
|
**Good** - Obvious data flow:
|
||||||
|
```
|
||||||
|
Request → Validate → Transform → Store → Respond
|
||||||
|
|
||||||
|
# Each step is a clear function/module:
|
||||||
|
api/routes.py # Request enters
|
||||||
|
validators.py # Validation
|
||||||
|
transformers.py # Business logic transformation
|
||||||
|
repositories.py # Storage
|
||||||
|
serializers.py # Response shaping
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design for Failure
|
||||||
|
|
||||||
|
**Good** - Failure-aware design with compensation:
|
||||||
|
```python
|
||||||
|
class OrderService:
|
||||||
|
def place_order(self, order: Order) -> Result:
|
||||||
|
inventory = self.inventory.reserve(order.items)
|
||||||
|
if inventory.failed:
|
||||||
|
return Result.failure("Items unavailable", retry=False)
|
||||||
|
|
||||||
|
payment = self.payments.charge(order.total)
|
||||||
|
if payment.failed:
|
||||||
|
self.inventory.release(inventory.reservation_id) # Compensate
|
||||||
|
return Result.failure("Payment failed", retry=True)
|
||||||
|
|
||||||
|
return Result.success(order)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design for Operations
|
||||||
|
|
||||||
|
**Good** - Observable architecture:
|
||||||
|
```python
|
||||||
|
@trace
|
||||||
|
def handle_request(request):
|
||||||
|
log.info("Processing", request_id=request.id, user=request.user_id)
|
||||||
|
try:
|
||||||
|
result = process(request)
|
||||||
|
log.info("Completed", request_id=request.id, result=result.status)
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
log.error("Failed", request_id=request.id, error=str(e),
|
||||||
|
context=request.to_dict()) # Full context for replay
|
||||||
|
raise
|
||||||
|
```
|
||||||
|
|
||||||
|
Key elements:
|
||||||
|
- Every request gets a correlation ID
|
||||||
|
- Every service logs with that ID
|
||||||
|
- Every error includes full context for reproduction
|
||||||
76
.cursor/skills/system-architecture/scaling-checklist.md
Normal file
76
.cursor/skills/system-architecture/scaling-checklist.md
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
# Scaling Checklist
|
||||||
|
|
||||||
|
Concrete techniques for when the complexity checklist in SKILL.md confirms scale is a real problem. Apply in order - each level solves the previous level's bottleneck.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Level 0: Optimize First
|
||||||
|
|
||||||
|
Before adding infrastructure, exhaust these:
|
||||||
|
|
||||||
|
- [ ] Database queries have proper indexes
|
||||||
|
- [ ] N+1 queries eliminated
|
||||||
|
- [ ] Connection pooling configured
|
||||||
|
- [ ] Slow endpoints profiled and optimized
|
||||||
|
- [ ] Static assets served via CDN
|
||||||
|
|
||||||
|
## Level 1: Read-Heavy
|
||||||
|
|
||||||
|
**Symptom**: Database reads are the bottleneck.
|
||||||
|
|
||||||
|
| Technique | When | Trade-off |
|
||||||
|
|-----------|------|-----------|
|
||||||
|
| Application cache (in-memory) | Small, frequently accessed data | Stale data, memory pressure |
|
||||||
|
| Redis/Memcached | Shared cache across instances | Network hop, cache invalidation complexity |
|
||||||
|
| Read replicas | High read volume, slight staleness OK | Replication lag, eventual consistency |
|
||||||
|
| CDN | Static or semi-static content | Cache invalidation delay |
|
||||||
|
|
||||||
|
## Level 2: Write-Heavy
|
||||||
|
|
||||||
|
**Symptom**: Database writes or processing are the bottleneck.
|
||||||
|
|
||||||
|
| Technique | When | Trade-off |
|
||||||
|
|-----------|------|-----------|
|
||||||
|
| Async task queue (Celery, SQS) | Work can be deferred | Eventual consistency, failure handling |
|
||||||
|
| Write-behind cache | Batch frequent writes | Data loss risk on crash |
|
||||||
|
| Event streaming (Kafka) | Multiple consumers of same data | Operational complexity, ordering guarantees |
|
||||||
|
| CQRS | Reads and writes have diverged significantly | Two models to maintain |
|
||||||
|
|
||||||
|
## Level 3: Traffic Spikes
|
||||||
|
|
||||||
|
**Symptom**: Individual instances can't handle peak load.
|
||||||
|
|
||||||
|
| Technique | When | Trade-off |
|
||||||
|
|-----------|------|-----------|
|
||||||
|
| Horizontal scaling + load balancer | Stateless services | Session management, deploy complexity |
|
||||||
|
| Auto-scaling | Unpredictable traffic patterns | Cold start latency, cost spikes |
|
||||||
|
| Rate limiting | Protect against abuse/spikes | Legitimate users may be throttled |
|
||||||
|
| Circuit breakers | Downstream services degrade | Partial functionality during failures |
|
||||||
|
|
||||||
|
## Level 4: Data Growth
|
||||||
|
|
||||||
|
**Symptom**: Single database can't hold or query all the data efficiently.
|
||||||
|
|
||||||
|
| Technique | When | Trade-off |
|
||||||
|
|-----------|------|-----------|
|
||||||
|
| Table partitioning | Time-series or naturally partitioned data | Query complexity, partition management |
|
||||||
|
| Archival / cold storage | Old data rarely accessed | Access latency for archived data |
|
||||||
|
| Database sharding | Partitioning insufficient, clear shard key exists | Cross-shard queries, operational burden |
|
||||||
|
| Search index (Elasticsearch) | Full-text or complex queries on large datasets | Index lag, another system to operate |
|
||||||
|
|
||||||
|
## Level 5: Multi-Region
|
||||||
|
|
||||||
|
**Symptom**: Users are geographically distributed, latency matters.
|
||||||
|
|
||||||
|
| Technique | When | Trade-off |
|
||||||
|
|-----------|------|-----------|
|
||||||
|
| CDN + edge caching | Static/semi-static content | Cache invalidation |
|
||||||
|
| Read replicas per region | Read-heavy, slight staleness OK | Replication lag |
|
||||||
|
| Active-passive failover | Disaster recovery | Failover time, cost of standby |
|
||||||
|
| Active-active multi-region | True global low-latency required | Conflict resolution, extreme complexity |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision Rule
|
||||||
|
|
||||||
|
Always start at Level 0. Move to the next level only when you have **measured evidence** that the current level is insufficient. Skipping levels is how you end up with Kafka for a TODO app.
|
||||||
|
|
@ -36,6 +36,7 @@ EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2
|
||||||
# BACKEND_PORT=8929
|
# BACKEND_PORT=8929
|
||||||
# FRONTEND_PORT=3929
|
# FRONTEND_PORT=3929
|
||||||
# ELECTRIC_PORT=5929
|
# ELECTRIC_PORT=5929
|
||||||
|
# SEARXNG_PORT=8888
|
||||||
# FLOWER_PORT=5555
|
# FLOWER_PORT=5555
|
||||||
|
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
|
|
@ -199,6 +200,16 @@ STT_SERVICE=local/base
|
||||||
# COMPOSIO_ENABLED=TRUE
|
# COMPOSIO_ENABLED=TRUE
|
||||||
# COMPOSIO_REDIRECT_URI=http://localhost:8000/api/v1/auth/composio/connector/callback
|
# COMPOSIO_REDIRECT_URI=http://localhost:8000/api/v1/auth/composio/connector/callback
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# SearXNG (bundled web search — works out of the box, no config needed)
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# SearXNG provides web search to all search spaces automatically.
|
||||||
|
# To access the SearXNG UI directly: http://localhost:8888
|
||||||
|
# To disable the service entirely: docker compose up --scale searxng=0
|
||||||
|
# To point at your own SearXNG instance instead of the bundled one:
|
||||||
|
# SEARXNG_DEFAULT_HOST=http://your-searxng:8080
|
||||||
|
# SEARXNG_SECRET=surfsense-searxng-secret
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
# Daytona Sandbox (optional — cloud code execution for the deep agent)
|
# Daytona Sandbox (optional — cloud code execution for the deep agent)
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,20 @@ services:
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
|
searxng:
|
||||||
|
image: searxng/searxng:2026.3.13-3c1f68c59
|
||||||
|
ports:
|
||||||
|
- "${SEARXNG_PORT:-8888}:8080"
|
||||||
|
volumes:
|
||||||
|
- ./searxng:/etc/searxng
|
||||||
|
environment:
|
||||||
|
- SEARXNG_SECRET=${SEARXNG_SECRET:-surfsense-searxng-secret}
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/healthz"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
build: ../surfsense_backend
|
build: ../surfsense_backend
|
||||||
ports:
|
ports:
|
||||||
|
|
@ -81,6 +95,7 @@ services:
|
||||||
- ELECTRIC_DB_PASSWORD=${ELECTRIC_DB_PASSWORD:-electric_password}
|
- ELECTRIC_DB_PASSWORD=${ELECTRIC_DB_PASSWORD:-electric_password}
|
||||||
- AUTH_TYPE=${AUTH_TYPE:-LOCAL}
|
- AUTH_TYPE=${AUTH_TYPE:-LOCAL}
|
||||||
- NEXT_FRONTEND_URL=${NEXT_FRONTEND_URL:-http://localhost:3000}
|
- NEXT_FRONTEND_URL=${NEXT_FRONTEND_URL:-http://localhost:3000}
|
||||||
|
- SEARXNG_DEFAULT_HOST=${SEARXNG_DEFAULT_HOST:-http://searxng:8080}
|
||||||
# Daytona Sandbox – uncomment and set credentials to enable cloud code execution
|
# Daytona Sandbox – uncomment and set credentials to enable cloud code execution
|
||||||
# - DAYTONA_SANDBOX_ENABLED=TRUE
|
# - DAYTONA_SANDBOX_ENABLED=TRUE
|
||||||
# - DAYTONA_API_KEY=${DAYTONA_API_KEY:-}
|
# - DAYTONA_API_KEY=${DAYTONA_API_KEY:-}
|
||||||
|
|
@ -92,6 +107,8 @@ services:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
searxng:
|
||||||
|
condition: service_healthy
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||||
interval: 15s
|
interval: 15s
|
||||||
|
|
@ -115,6 +132,7 @@ services:
|
||||||
- PYTHONPATH=/app
|
- PYTHONPATH=/app
|
||||||
- ELECTRIC_DB_USER=${ELECTRIC_DB_USER:-electric}
|
- ELECTRIC_DB_USER=${ELECTRIC_DB_USER:-electric}
|
||||||
- ELECTRIC_DB_PASSWORD=${ELECTRIC_DB_PASSWORD:-electric_password}
|
- ELECTRIC_DB_PASSWORD=${ELECTRIC_DB_PASSWORD:-electric_password}
|
||||||
|
- SEARXNG_DEFAULT_HOST=${SEARXNG_DEFAULT_HOST:-http://searxng:8080}
|
||||||
- SERVICE_ROLE=worker
|
- SERVICE_ROLE=worker
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,19 @@ services:
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
|
searxng:
|
||||||
|
image: searxng/searxng:2026.3.13-3c1f68c59
|
||||||
|
volumes:
|
||||||
|
- ./searxng:/etc/searxng
|
||||||
|
environment:
|
||||||
|
SEARXNG_SECRET: ${SEARXNG_SECRET:-surfsense-searxng-secret}
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/healthz"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
image: ghcr.io/modsetter/surfsense-backend:${SURFSENSE_VERSION:-latest}
|
image: ghcr.io/modsetter/surfsense-backend:${SURFSENSE_VERSION:-latest}
|
||||||
ports:
|
ports:
|
||||||
|
|
@ -62,6 +75,7 @@ services:
|
||||||
ELECTRIC_DB_USER: ${ELECTRIC_DB_USER:-electric}
|
ELECTRIC_DB_USER: ${ELECTRIC_DB_USER:-electric}
|
||||||
ELECTRIC_DB_PASSWORD: ${ELECTRIC_DB_PASSWORD:-electric_password}
|
ELECTRIC_DB_PASSWORD: ${ELECTRIC_DB_PASSWORD:-electric_password}
|
||||||
NEXT_FRONTEND_URL: ${NEXT_FRONTEND_URL:-http://localhost:${FRONTEND_PORT:-3929}}
|
NEXT_FRONTEND_URL: ${NEXT_FRONTEND_URL:-http://localhost:${FRONTEND_PORT:-3929}}
|
||||||
|
SEARXNG_DEFAULT_HOST: ${SEARXNG_DEFAULT_HOST:-http://searxng:8080}
|
||||||
# Daytona Sandbox – uncomment and set credentials to enable cloud code execution
|
# Daytona Sandbox – uncomment and set credentials to enable cloud code execution
|
||||||
# DAYTONA_SANDBOX_ENABLED: "TRUE"
|
# DAYTONA_SANDBOX_ENABLED: "TRUE"
|
||||||
# DAYTONA_API_KEY: ${DAYTONA_API_KEY:-}
|
# DAYTONA_API_KEY: ${DAYTONA_API_KEY:-}
|
||||||
|
|
@ -75,6 +89,8 @@ services:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
searxng:
|
||||||
|
condition: service_healthy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||||
|
|
@ -98,6 +114,7 @@ services:
|
||||||
PYTHONPATH: /app
|
PYTHONPATH: /app
|
||||||
ELECTRIC_DB_USER: ${ELECTRIC_DB_USER:-electric}
|
ELECTRIC_DB_USER: ${ELECTRIC_DB_USER:-electric}
|
||||||
ELECTRIC_DB_PASSWORD: ${ELECTRIC_DB_PASSWORD:-electric_password}
|
ELECTRIC_DB_PASSWORD: ${ELECTRIC_DB_PASSWORD:-electric_password}
|
||||||
|
SEARXNG_DEFAULT_HOST: ${SEARXNG_DEFAULT_HOST:-http://searxng:8080}
|
||||||
SERVICE_ROLE: worker
|
SERVICE_ROLE: worker
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,7 @@ Write-Step "Downloading SurfSense files"
|
||||||
Write-Info "Installation directory: $InstallDir"
|
Write-Info "Installation directory: $InstallDir"
|
||||||
|
|
||||||
New-Item -ItemType Directory -Path "$InstallDir\scripts" -Force | Out-Null
|
New-Item -ItemType Directory -Path "$InstallDir\scripts" -Force | Out-Null
|
||||||
|
New-Item -ItemType Directory -Path "$InstallDir\searxng" -Force | Out-Null
|
||||||
|
|
||||||
$Files = @(
|
$Files = @(
|
||||||
@{ Src = "docker/docker-compose.yml"; Dest = "docker-compose.yml" }
|
@{ Src = "docker/docker-compose.yml"; Dest = "docker-compose.yml" }
|
||||||
|
|
@ -110,6 +111,8 @@ $Files = @(
|
||||||
@{ Src = "docker/postgresql.conf"; Dest = "postgresql.conf" }
|
@{ Src = "docker/postgresql.conf"; Dest = "postgresql.conf" }
|
||||||
@{ Src = "docker/scripts/init-electric-user.sh"; Dest = "scripts/init-electric-user.sh" }
|
@{ Src = "docker/scripts/init-electric-user.sh"; Dest = "scripts/init-electric-user.sh" }
|
||||||
@{ Src = "docker/scripts/migrate-database.ps1"; Dest = "scripts/migrate-database.ps1" }
|
@{ Src = "docker/scripts/migrate-database.ps1"; Dest = "scripts/migrate-database.ps1" }
|
||||||
|
@{ Src = "docker/searxng/settings.yml"; Dest = "searxng/settings.yml" }
|
||||||
|
@{ Src = "docker/searxng/limiter.toml"; Dest = "searxng/limiter.toml" }
|
||||||
)
|
)
|
||||||
|
|
||||||
foreach ($f in $Files) {
|
foreach ($f in $Files) {
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,7 @@ wait_for_pg() {
|
||||||
step "Downloading SurfSense files"
|
step "Downloading SurfSense files"
|
||||||
info "Installation directory: ${INSTALL_DIR}"
|
info "Installation directory: ${INSTALL_DIR}"
|
||||||
mkdir -p "${INSTALL_DIR}/scripts"
|
mkdir -p "${INSTALL_DIR}/scripts"
|
||||||
|
mkdir -p "${INSTALL_DIR}/searxng"
|
||||||
|
|
||||||
FILES=(
|
FILES=(
|
||||||
"docker/docker-compose.yml:docker-compose.yml"
|
"docker/docker-compose.yml:docker-compose.yml"
|
||||||
|
|
@ -109,6 +110,8 @@ FILES=(
|
||||||
"docker/postgresql.conf:postgresql.conf"
|
"docker/postgresql.conf:postgresql.conf"
|
||||||
"docker/scripts/init-electric-user.sh:scripts/init-electric-user.sh"
|
"docker/scripts/init-electric-user.sh:scripts/init-electric-user.sh"
|
||||||
"docker/scripts/migrate-database.sh:scripts/migrate-database.sh"
|
"docker/scripts/migrate-database.sh:scripts/migrate-database.sh"
|
||||||
|
"docker/searxng/settings.yml:searxng/settings.yml"
|
||||||
|
"docker/searxng/limiter.toml:searxng/limiter.toml"
|
||||||
)
|
)
|
||||||
|
|
||||||
for entry in "${FILES[@]}"; do
|
for entry in "${FILES[@]}"; do
|
||||||
|
|
|
||||||
5
docker/searxng/limiter.toml
Normal file
5
docker/searxng/limiter.toml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
[botdetection.ip_limit]
|
||||||
|
link_token = false
|
||||||
|
|
||||||
|
[botdetection.ip_lists]
|
||||||
|
pass_ip = ["0.0.0.0/0"]
|
||||||
90
docker/searxng/settings.yml
Normal file
90
docker/searxng/settings.yml
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
use_default_settings:
|
||||||
|
engines:
|
||||||
|
remove:
|
||||||
|
- ahmia
|
||||||
|
- torch
|
||||||
|
- qwant
|
||||||
|
- qwant news
|
||||||
|
- qwant images
|
||||||
|
- qwant videos
|
||||||
|
- mojeek
|
||||||
|
- mojeek images
|
||||||
|
- mojeek news
|
||||||
|
|
||||||
|
server:
|
||||||
|
secret_key: "override-me-via-env"
|
||||||
|
limiter: false
|
||||||
|
image_proxy: false
|
||||||
|
method: "GET"
|
||||||
|
default_http_headers:
|
||||||
|
X-Robots-Tag: "noindex, nofollow"
|
||||||
|
|
||||||
|
search:
|
||||||
|
formats:
|
||||||
|
- html
|
||||||
|
- json
|
||||||
|
default_lang: "auto"
|
||||||
|
autocomplete: ""
|
||||||
|
safe_search: 0
|
||||||
|
ban_time_on_fail: 5
|
||||||
|
max_ban_time_on_fail: 120
|
||||||
|
suspended_times:
|
||||||
|
SearxEngineAccessDenied: 3600
|
||||||
|
SearxEngineCaptcha: 3600
|
||||||
|
SearxEngineTooManyRequests: 600
|
||||||
|
cf_SearxEngineCaptcha: 7200
|
||||||
|
cf_SearxEngineAccessDenied: 3600
|
||||||
|
recaptcha_SearxEngineCaptcha: 7200
|
||||||
|
|
||||||
|
ui:
|
||||||
|
static_use_hash: true
|
||||||
|
|
||||||
|
outgoing:
|
||||||
|
request_timeout: 12.0
|
||||||
|
max_request_timeout: 20.0
|
||||||
|
pool_connections: 100
|
||||||
|
pool_maxsize: 20
|
||||||
|
enable_http2: true
|
||||||
|
extra_proxy_timeout: 10
|
||||||
|
retries: 1
|
||||||
|
# Uncomment and set your residential proxy URL to route search engine requests through it.
|
||||||
|
# Format: http://<username>:<base64_password>@<hostname>:<port>/
|
||||||
|
#
|
||||||
|
# proxies:
|
||||||
|
# all://:
|
||||||
|
# - http://user:pass@proxy-host:port/
|
||||||
|
|
||||||
|
engines:
|
||||||
|
- name: google
|
||||||
|
disabled: false
|
||||||
|
weight: 1.2
|
||||||
|
retry_on_http_error: [429, 503]
|
||||||
|
- name: duckduckgo
|
||||||
|
disabled: false
|
||||||
|
weight: 1.1
|
||||||
|
retry_on_http_error: [429, 503]
|
||||||
|
- name: brave
|
||||||
|
disabled: false
|
||||||
|
weight: 1.0
|
||||||
|
retry_on_http_error: [429, 503]
|
||||||
|
- name: bing
|
||||||
|
disabled: false
|
||||||
|
weight: 0.9
|
||||||
|
retry_on_http_error: [429, 503]
|
||||||
|
- name: wikipedia
|
||||||
|
disabled: false
|
||||||
|
weight: 0.8
|
||||||
|
- name: stackoverflow
|
||||||
|
disabled: false
|
||||||
|
weight: 0.7
|
||||||
|
- name: yahoo
|
||||||
|
disabled: false
|
||||||
|
weight: 0.7
|
||||||
|
retry_on_http_error: [429, 503]
|
||||||
|
- name: wikidata
|
||||||
|
disabled: false
|
||||||
|
weight: 0.6
|
||||||
|
- name: currency
|
||||||
|
disabled: false
|
||||||
|
- name: ddg definitions
|
||||||
|
disabled: false
|
||||||
|
|
@ -12,6 +12,11 @@ REDIS_APP_URL=redis://localhost:6379/0
|
||||||
# Optional: TTL in seconds for connector indexing lock key
|
# Optional: TTL in seconds for connector indexing lock key
|
||||||
# CONNECTOR_INDEXING_LOCK_TTL_SECONDS=28800
|
# CONNECTOR_INDEXING_LOCK_TTL_SECONDS=28800
|
||||||
|
|
||||||
|
# Platform Web Search (SearXNG)
|
||||||
|
# Set this to enable built-in web search. Docker Compose sets it automatically.
|
||||||
|
# Only uncomment if running the backend outside Docker (e.g. uvicorn on host).
|
||||||
|
# SEARXNG_DEFAULT_HOST=http://localhost:8888
|
||||||
|
|
||||||
#Electric(for migrations only)
|
#Electric(for migrations only)
|
||||||
ELECTRIC_DB_USER=electric
|
ELECTRIC_DB_USER=electric
|
||||||
ELECTRIC_DB_PASSWORD=electric_password
|
ELECTRIC_DB_PASSWORD=electric_password
|
||||||
|
|
|
||||||
|
|
@ -37,13 +37,15 @@ _perf_log = get_perf_logger()
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# Maps SearchSourceConnectorType enum values to the searchable document/connector types
|
# Maps SearchSourceConnectorType enum values to the searchable document/connector types
|
||||||
# used by the knowledge_base tool. Some connectors map to different document types.
|
# used by the knowledge_base and web_search tools.
|
||||||
|
# Live search connectors (TAVILY_API, LINKUP_API, BAIDU_SEARCH_API) are routed to
|
||||||
|
# the web_search tool; all others go to search_knowledge_base.
|
||||||
_CONNECTOR_TYPE_TO_SEARCHABLE: dict[str, str] = {
|
_CONNECTOR_TYPE_TO_SEARCHABLE: dict[str, str] = {
|
||||||
# Direct mappings (connector type == searchable type)
|
# Live search connectors (handled by web_search tool)
|
||||||
"TAVILY_API": "TAVILY_API",
|
"TAVILY_API": "TAVILY_API",
|
||||||
"SEARXNG_API": "SEARXNG_API",
|
|
||||||
"LINKUP_API": "LINKUP_API",
|
"LINKUP_API": "LINKUP_API",
|
||||||
"BAIDU_SEARCH_API": "BAIDU_SEARCH_API",
|
"BAIDU_SEARCH_API": "BAIDU_SEARCH_API",
|
||||||
|
# Local/indexed connectors (handled by search_knowledge_base tool)
|
||||||
"SLACK_CONNECTOR": "SLACK_CONNECTOR",
|
"SLACK_CONNECTOR": "SLACK_CONNECTOR",
|
||||||
"TEAMS_CONNECTOR": "TEAMS_CONNECTOR",
|
"TEAMS_CONNECTOR": "TEAMS_CONNECTOR",
|
||||||
"NOTION_CONNECTOR": "NOTION_CONNECTOR",
|
"NOTION_CONNECTOR": "NOTION_CONNECTOR",
|
||||||
|
|
@ -233,6 +235,7 @@ async def create_surfsense_deep_agent(
|
||||||
available_document_types = await connector_service.get_available_document_types(
|
available_document_types = await connector_service.get_available_document_types(
|
||||||
search_space_id
|
search_space_id
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.warning(f"Failed to discover available connectors/document types: {e}")
|
logging.warning(f"Failed to discover available connectors/document types: {e}")
|
||||||
_perf_log.info(
|
_perf_log.info(
|
||||||
|
|
|
||||||
|
|
@ -99,14 +99,8 @@ _TOOL_INSTRUCTIONS["search_knowledge_base"] = """
|
||||||
- IMPORTANT: When searching for information (meetings, schedules, notes, tasks, etc.), ALWAYS search broadly
|
- IMPORTANT: When searching for information (meetings, schedules, notes, tasks, etc.), ALWAYS search broadly
|
||||||
across ALL sources first by omitting connectors_to_search. The user may store information in various places
|
across ALL sources first by omitting connectors_to_search. The user may store information in various places
|
||||||
including calendar apps, note-taking apps (Obsidian, Notion), chat apps (Slack, Discord), and more.
|
including calendar apps, note-taking apps (Obsidian, Notion), chat apps (Slack, Discord), and more.
|
||||||
- IMPORTANT (REAL-TIME / PUBLIC WEB QUERIES): For questions that require current public web data
|
- This tool searches ONLY local/indexed data (uploaded files, Notion, Slack, browser extension captures, etc.).
|
||||||
(e.g., live exchange rates, stock prices, breaking news, weather, current events), you MUST call
|
For real-time web search (current events, news, live data), use the `web_search` tool instead.
|
||||||
`search_knowledge_base` using live web connectors via `connectors_to_search`:
|
|
||||||
["LINKUP_API", "TAVILY_API", "SEARXNG_API", "BAIDU_SEARCH_API"].
|
|
||||||
- For these real-time/public web queries, DO NOT answer from memory and DO NOT say you lack internet
|
|
||||||
access before attempting a live connector search.
|
|
||||||
- If the live connectors return no relevant results, explain that live web sources did not return enough
|
|
||||||
data and ask the user if they want you to retry with a refined query.
|
|
||||||
- FALLBACK BEHAVIOR: If the search returns no relevant results, you MAY then answer using your own
|
- FALLBACK BEHAVIOR: If the search returns no relevant results, you MAY then answer using your own
|
||||||
general knowledge, but clearly indicate that no matching information was found in the knowledge base.
|
general knowledge, but clearly indicate that no matching information was found in the knowledge base.
|
||||||
- Only narrow to specific connectors if the user explicitly asks (e.g., "check my Slack" or "in my calendar").
|
- Only narrow to specific connectors if the user explicitly asks (e.g., "check my Slack" or "in my calendar").
|
||||||
|
|
@ -271,6 +265,24 @@ _TOOL_INSTRUCTIONS["scrape_webpage"] = """
|
||||||
* Don't show every image - just the most relevant 1-3 images that enhance understanding.
|
* Don't show every image - just the most relevant 1-3 images that enhance understanding.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
_TOOL_INSTRUCTIONS["web_search"] = """
|
||||||
|
- web_search: Search the web for real-time information using all configured search engines.
|
||||||
|
- Use this for current events, news, prices, weather, public facts, or any question requiring
|
||||||
|
up-to-date information from the internet.
|
||||||
|
- This tool dispatches to all configured search engines (SearXNG, Tavily, Linkup, Baidu) in
|
||||||
|
parallel and merges the results.
|
||||||
|
- IMPORTANT (REAL-TIME / PUBLIC WEB QUERIES): For questions that require current public web data
|
||||||
|
(e.g., live exchange rates, stock prices, breaking news, weather, current events), you MUST call
|
||||||
|
`web_search` instead of answering from memory.
|
||||||
|
- For these real-time/public web queries, DO NOT answer from memory and DO NOT say you lack internet
|
||||||
|
access before attempting a web search.
|
||||||
|
- If the search returns no relevant results, explain that web sources did not return enough
|
||||||
|
data and ask the user if they want you to retry with a refined query.
|
||||||
|
- Args:
|
||||||
|
- query: The search query - use specific, descriptive terms
|
||||||
|
- top_k: Number of results to retrieve (default: 10, max: 50)
|
||||||
|
"""
|
||||||
|
|
||||||
# Memory tool instructions have private and shared variants.
|
# Memory tool instructions have private and shared variants.
|
||||||
# We store them keyed as "save_memory" / "recall_memory" with sub-keys.
|
# We store them keyed as "save_memory" / "recall_memory" with sub-keys.
|
||||||
_MEMORY_TOOL_INSTRUCTIONS: dict[str, dict[str, str]] = {
|
_MEMORY_TOOL_INSTRUCTIONS: dict[str, dict[str, str]] = {
|
||||||
|
|
@ -401,7 +413,7 @@ _TOOL_EXAMPLES["search_knowledge_base"] = """
|
||||||
- User: "Check my Obsidian notes for meeting notes"
|
- User: "Check my Obsidian notes for meeting notes"
|
||||||
- Call: `search_knowledge_base(query="meeting notes", connectors_to_search=["OBSIDIAN_CONNECTOR"])`
|
- Call: `search_knowledge_base(query="meeting notes", connectors_to_search=["OBSIDIAN_CONNECTOR"])`
|
||||||
- User: "search me current usd to inr rate"
|
- User: "search me current usd to inr rate"
|
||||||
- Call: `search_knowledge_base(query="current USD to INR exchange rate", connectors_to_search=["LINKUP_API", "TAVILY_API", "SEARXNG_API", "BAIDU_SEARCH_API"])`
|
- Call: `web_search(query="current USD to INR exchange rate")`
|
||||||
- Then answer using the returned live web results with citations.
|
- Then answer using the returned live web results with citations.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -471,10 +483,21 @@ _TOOL_EXAMPLES["generate_image"] = """
|
||||||
- Step 2: `display_image(src="<returned_url>", alt="Bean Dream coffee shop logo", title="Generated Image")`
|
- Step 2: `display_image(src="<returned_url>", alt="Bean Dream coffee shop logo", title="Generated Image")`
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
_TOOL_EXAMPLES["web_search"] = """
|
||||||
|
- User: "What's the current USD to INR exchange rate?"
|
||||||
|
- Call: `web_search(query="current USD to INR exchange rate")`
|
||||||
|
- Then answer using the returned web results with citations.
|
||||||
|
- User: "What's the latest news about AI?"
|
||||||
|
- Call: `web_search(query="latest AI news today")`
|
||||||
|
- User: "What's the weather in New York?"
|
||||||
|
- Call: `web_search(query="weather New York today")`
|
||||||
|
"""
|
||||||
|
|
||||||
# All tool names that have prompt instructions (order matters for prompt readability)
|
# All tool names that have prompt instructions (order matters for prompt readability)
|
||||||
_ALL_TOOL_NAMES_ORDERED = [
|
_ALL_TOOL_NAMES_ORDERED = [
|
||||||
"search_surfsense_docs",
|
"search_surfsense_docs",
|
||||||
"search_knowledge_base",
|
"search_knowledge_base",
|
||||||
|
"web_search",
|
||||||
"generate_podcast",
|
"generate_podcast",
|
||||||
"generate_report",
|
"generate_report",
|
||||||
"link_preview",
|
"link_preview",
|
||||||
|
|
@ -543,7 +566,7 @@ DISABLED TOOLS (by user):
|
||||||
The following tools are available in SurfSense but have been disabled by the user for this session: {disabled_list}.
|
The following tools are available in SurfSense but have been disabled by the user for this session: {disabled_list}.
|
||||||
You do NOT have access to these tools and MUST NOT claim you can use them.
|
You do NOT have access to these tools and MUST NOT claim you can use them.
|
||||||
If the user asks about a capability provided by a disabled tool, let them know the relevant tool
|
If the user asks about a capability provided by a disabled tool, let them know the relevant tool
|
||||||
is currently disabled and they can re-enable it from the tools menu (wrench icon) in the composer toolbar.
|
is currently disabled and they can re-enable it.
|
||||||
""")
|
""")
|
||||||
|
|
||||||
parts.append("\n</tools>\n")
|
parts.append("\n</tools>\n")
|
||||||
|
|
@ -595,11 +618,10 @@ The documents you receive are structured like this:
|
||||||
</document_content>
|
</document_content>
|
||||||
</document>
|
</document>
|
||||||
|
|
||||||
**Live web search results (URL chunk IDs):**
|
**Web search results (URL chunk IDs):**
|
||||||
<document>
|
<document>
|
||||||
<document_metadata>
|
<document_metadata>
|
||||||
<document_id>TAVILY_API::Some Title::https://example.com/article</document_id>
|
<document_type>WEB_SEARCH</document_type>
|
||||||
<document_type>TAVILY_API</document_type>
|
|
||||||
<title><![CDATA[Some web search result]]></title>
|
<title><![CDATA[Some web search result]]></title>
|
||||||
<url><![CDATA[https://example.com/article]]></url>
|
<url><![CDATA[https://example.com/article]]></url>
|
||||||
</document_metadata>
|
</document_metadata>
|
||||||
|
|
|
||||||
|
|
@ -23,11 +23,10 @@ from app.db import shielded_async_session
|
||||||
from app.services.connector_service import ConnectorService
|
from app.services.connector_service import ConnectorService
|
||||||
from app.utils.perf import get_perf_logger
|
from app.utils.perf import get_perf_logger
|
||||||
|
|
||||||
# Connectors that call external live-search APIs (no local DB / embedding needed).
|
# Connectors that call external live-search APIs. These are handled by the
|
||||||
# These are never filtered by available_document_types.
|
# ``web_search`` tool and must be excluded from knowledge-base searches.
|
||||||
_LIVE_SEARCH_CONNECTORS: set[str] = {
|
_LIVE_SEARCH_CONNECTORS: set[str] = {
|
||||||
"TAVILY_API",
|
"TAVILY_API",
|
||||||
"SEARXNG_API",
|
|
||||||
"LINKUP_API",
|
"LINKUP_API",
|
||||||
"BAIDU_SEARCH_API",
|
"BAIDU_SEARCH_API",
|
||||||
}
|
}
|
||||||
|
|
@ -190,10 +189,6 @@ _ALL_CONNECTORS: list[str] = [
|
||||||
"GOOGLE_DRIVE_FILE",
|
"GOOGLE_DRIVE_FILE",
|
||||||
"DISCORD_CONNECTOR",
|
"DISCORD_CONNECTOR",
|
||||||
"AIRTABLE_CONNECTOR",
|
"AIRTABLE_CONNECTOR",
|
||||||
"TAVILY_API",
|
|
||||||
"SEARXNG_API",
|
|
||||||
"LINKUP_API",
|
|
||||||
"BAIDU_SEARCH_API",
|
|
||||||
"LUMA_CONNECTOR",
|
"LUMA_CONNECTOR",
|
||||||
"NOTE",
|
"NOTE",
|
||||||
"BOOKSTACK_CONNECTOR",
|
"BOOKSTACK_CONNECTOR",
|
||||||
|
|
@ -227,10 +222,6 @@ CONNECTOR_DESCRIPTIONS: dict[str, str] = {
|
||||||
"GOOGLE_DRIVE_FILE": "Google Drive files and documents (personal cloud storage)",
|
"GOOGLE_DRIVE_FILE": "Google Drive files and documents (personal cloud storage)",
|
||||||
"DISCORD_CONNECTOR": "Discord server conversations and shared content (personal community)",
|
"DISCORD_CONNECTOR": "Discord server conversations and shared content (personal community)",
|
||||||
"AIRTABLE_CONNECTOR": "Airtable records, tables, and database content (personal data)",
|
"AIRTABLE_CONNECTOR": "Airtable records, tables, and database content (personal data)",
|
||||||
"TAVILY_API": "Tavily web search API results (real-time web search)",
|
|
||||||
"SEARXNG_API": "SearxNG search API results (privacy-focused web search)",
|
|
||||||
"LINKUP_API": "Linkup search API results (web search)",
|
|
||||||
"BAIDU_SEARCH_API": "Baidu search API results (Chinese web search)",
|
|
||||||
"LUMA_CONNECTOR": "Luma events and meetings",
|
"LUMA_CONNECTOR": "Luma events and meetings",
|
||||||
"WEBCRAWLER_CONNECTOR": "Webpages indexed by SurfSense (personally selected websites)",
|
"WEBCRAWLER_CONNECTOR": "Webpages indexed by SurfSense (personally selected websites)",
|
||||||
"CRAWLED_URL": "Webpages indexed by SurfSense (personally selected websites)",
|
"CRAWLED_URL": "Webpages indexed by SurfSense (personally selected websites)",
|
||||||
|
|
@ -268,14 +259,15 @@ def _normalize_connectors(
|
||||||
valid_set = (
|
valid_set = (
|
||||||
set(available_connectors) if available_connectors else set(_ALL_CONNECTORS)
|
set(available_connectors) if available_connectors else set(_ALL_CONNECTORS)
|
||||||
)
|
)
|
||||||
|
valid_set -= _LIVE_SEARCH_CONNECTORS
|
||||||
|
|
||||||
if not connectors_to_search:
|
if not connectors_to_search:
|
||||||
# Search all available connectors if none specified
|
base = (
|
||||||
return (
|
|
||||||
list(available_connectors)
|
list(available_connectors)
|
||||||
if available_connectors
|
if available_connectors
|
||||||
else list(_ALL_CONNECTORS)
|
else list(_ALL_CONNECTORS)
|
||||||
)
|
)
|
||||||
|
return [c for c in base if c not in _LIVE_SEARCH_CONNECTORS]
|
||||||
|
|
||||||
normalized: list[str] = []
|
normalized: list[str] = []
|
||||||
for raw in connectors_to_search:
|
for raw in connectors_to_search:
|
||||||
|
|
@ -302,15 +294,14 @@ def _normalize_connectors(
|
||||||
out.append(c)
|
out.append(c)
|
||||||
|
|
||||||
# Fallback to all available if nothing matched
|
# Fallback to all available if nothing matched
|
||||||
return (
|
if not out:
|
||||||
out
|
base = (
|
||||||
if out
|
|
||||||
else (
|
|
||||||
list(available_connectors)
|
list(available_connectors)
|
||||||
if available_connectors
|
if available_connectors
|
||||||
else list(_ALL_CONNECTORS)
|
else list(_ALL_CONNECTORS)
|
||||||
)
|
)
|
||||||
)
|
return [c for c in base if c not in _LIVE_SEARCH_CONNECTORS]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
@ -479,7 +470,6 @@ def format_documents_for_context(
|
||||||
# a numeric chunk_id (the numeric IDs are meaningless auto-incremented counters).
|
# a numeric chunk_id (the numeric IDs are meaningless auto-incremented counters).
|
||||||
live_search_connectors = {
|
live_search_connectors = {
|
||||||
"TAVILY_API",
|
"TAVILY_API",
|
||||||
"SEARXNG_API",
|
|
||||||
"LINKUP_API",
|
"LINKUP_API",
|
||||||
"BAIDU_SEARCH_API",
|
"BAIDU_SEARCH_API",
|
||||||
}
|
}
|
||||||
|
|
@ -623,13 +613,11 @@ async def search_knowledge_base_async(
|
||||||
|
|
||||||
connectors = _normalize_connectors(connectors_to_search, available_connectors)
|
connectors = _normalize_connectors(connectors_to_search, available_connectors)
|
||||||
|
|
||||||
# --- Optimization 1: skip local connectors that have zero indexed documents ---
|
# --- Optimization 1: skip connectors that have zero indexed documents ---
|
||||||
if available_document_types:
|
if available_document_types:
|
||||||
doc_types_set = set(available_document_types)
|
doc_types_set = set(available_document_types)
|
||||||
before_count = len(connectors)
|
before_count = len(connectors)
|
||||||
connectors = [
|
connectors = [c for c in connectors if c in doc_types_set]
|
||||||
c for c in connectors if c in _LIVE_SEARCH_CONNECTORS or c in doc_types_set
|
|
||||||
]
|
|
||||||
skipped = before_count - len(connectors)
|
skipped = before_count - len(connectors)
|
||||||
if skipped:
|
if skipped:
|
||||||
perf.info(
|
perf.info(
|
||||||
|
|
@ -664,9 +652,7 @@ async def search_knowledge_base_async(
|
||||||
"[kb_search] degenerate query %r detected - falling back to recency browse",
|
"[kb_search] degenerate query %r detected - falling back to recency browse",
|
||||||
query,
|
query,
|
||||||
)
|
)
|
||||||
local_connectors = [c for c in connectors if c not in _LIVE_SEARCH_CONNECTORS]
|
browse_connectors = connectors if connectors else [None] # type: ignore[list-item]
|
||||||
if not local_connectors:
|
|
||||||
local_connectors = [None] # type: ignore[list-item]
|
|
||||||
|
|
||||||
browse_results = await asyncio.gather(
|
browse_results = await asyncio.gather(
|
||||||
*[
|
*[
|
||||||
|
|
@ -677,7 +663,7 @@ async def search_knowledge_base_async(
|
||||||
start_date=resolved_start_date,
|
start_date=resolved_start_date,
|
||||||
end_date=resolved_end_date,
|
end_date=resolved_end_date,
|
||||||
)
|
)
|
||||||
for c in local_connectors
|
for c in browse_connectors
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
for docs in browse_results:
|
for docs in browse_results:
|
||||||
|
|
@ -702,18 +688,7 @@ async def search_knowledge_base_async(
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# Specs for live-search connectors (external APIs, no local DB/embedding).
|
|
||||||
live_connector_specs: dict[str, tuple[str, bool, bool, dict[str, Any]]] = {
|
|
||||||
"TAVILY_API": ("search_tavily", False, True, {}),
|
|
||||||
"SEARXNG_API": ("search_searxng", False, True, {}),
|
|
||||||
"LINKUP_API": ("search_linkup", False, False, {"mode": "standard"}),
|
|
||||||
"BAIDU_SEARCH_API": ("search_baidu", False, True, {}),
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- Optimization 2: compute the query embedding once, share across all local searches ---
|
# --- Optimization 2: compute the query embedding once, share across all local searches ---
|
||||||
precomputed_embedding: list[float] | None = None
|
|
||||||
has_local_connectors = any(c not in _LIVE_SEARCH_CONNECTORS for c in connectors)
|
|
||||||
if has_local_connectors:
|
|
||||||
from app.config import config as app_config
|
from app.config import config as app_config
|
||||||
|
|
||||||
t_embed = time.perf_counter()
|
t_embed = time.perf_counter()
|
||||||
|
|
@ -727,41 +702,6 @@ async def search_knowledge_base_async(
|
||||||
semaphore = asyncio.Semaphore(max_parallel_searches)
|
semaphore = asyncio.Semaphore(max_parallel_searches)
|
||||||
|
|
||||||
async def _search_one_connector(connector: str) -> list[dict[str, Any]]:
|
async def _search_one_connector(connector: str) -> list[dict[str, Any]]:
|
||||||
is_live = connector in _LIVE_SEARCH_CONNECTORS
|
|
||||||
|
|
||||||
if is_live:
|
|
||||||
spec = live_connector_specs.get(connector)
|
|
||||||
if spec is None:
|
|
||||||
return []
|
|
||||||
method_name, includes_date_range, includes_top_k, extra_kwargs = spec
|
|
||||||
kwargs: dict[str, Any] = {
|
|
||||||
"user_query": query,
|
|
||||||
"search_space_id": search_space_id,
|
|
||||||
**extra_kwargs,
|
|
||||||
}
|
|
||||||
if includes_top_k:
|
|
||||||
kwargs["top_k"] = top_k
|
|
||||||
if includes_date_range:
|
|
||||||
kwargs["start_date"] = resolved_start_date
|
|
||||||
kwargs["end_date"] = resolved_end_date
|
|
||||||
|
|
||||||
try:
|
|
||||||
t_conn = time.perf_counter()
|
|
||||||
async with semaphore, shielded_async_session() as isolated_session:
|
|
||||||
svc = ConnectorService(isolated_session, search_space_id)
|
|
||||||
_, chunks = await getattr(svc, method_name)(**kwargs)
|
|
||||||
perf.info(
|
|
||||||
"[kb_search] connector=%s results=%d in %.3fs",
|
|
||||||
connector,
|
|
||||||
len(chunks),
|
|
||||||
time.perf_counter() - t_conn,
|
|
||||||
)
|
|
||||||
return chunks
|
|
||||||
except Exception as e:
|
|
||||||
perf.warning("[kb_search] connector=%s FAILED: %s", connector, e)
|
|
||||||
return []
|
|
||||||
|
|
||||||
# --- Optimization 3: call _combined_rrf_search directly with shared embedding ---
|
|
||||||
try:
|
try:
|
||||||
t_conn = time.perf_counter()
|
t_conn = time.perf_counter()
|
||||||
async with semaphore, shielded_async_session() as isolated_session:
|
async with semaphore, shielded_async_session() as isolated_session:
|
||||||
|
|
@ -967,7 +907,9 @@ Focus searches on these types for best results."""
|
||||||
# This is what the LLM sees when deciding whether/how to use the tool
|
# This is what the LLM sees when deciding whether/how to use the tool
|
||||||
dynamic_description = f"""Search the user's personal knowledge base for relevant information.
|
dynamic_description = f"""Search the user's personal knowledge base for relevant information.
|
||||||
|
|
||||||
Use this tool to find documents, notes, files, web pages, and other content that may help answer the user's question.
|
Use this tool to find documents, notes, files, web pages, and other content the user has indexed.
|
||||||
|
This searches ONLY local/indexed data (uploaded files, Notion, Slack, browser extension captures, etc.).
|
||||||
|
For real-time web search (current events, news, live data), use the `web_search` tool instead.
|
||||||
|
|
||||||
IMPORTANT:
|
IMPORTANT:
|
||||||
- Always craft specific, descriptive search queries using natural language keywords.
|
- Always craft specific, descriptive search queries using natural language keywords.
|
||||||
|
|
@ -977,9 +919,6 @@ IMPORTANT:
|
||||||
- If the user requests a specific source type (e.g. "my notes", "Slack messages"), pass `connectors_to_search=[...]` using the enums below.
|
- If the user requests a specific source type (e.g. "my notes", "Slack messages"), pass `connectors_to_search=[...]` using the enums below.
|
||||||
- If `connectors_to_search` is omitted/empty, the system will search broadly.
|
- If `connectors_to_search` is omitted/empty, the system will search broadly.
|
||||||
- Only connectors that are enabled/configured for this search space are available.{doc_types_info}
|
- Only connectors that are enabled/configured for this search space are available.{doc_types_info}
|
||||||
- For real-time/public web queries (e.g., current exchange rates, stock prices, breaking news, weather),
|
|
||||||
explicitly include live web connectors in `connectors_to_search`, prioritizing:
|
|
||||||
["LINKUP_API", "TAVILY_API", "SEARXNG_API", "BAIDU_SEARCH_API"].
|
|
||||||
|
|
||||||
## Available connector enums for `connectors_to_search`
|
## Available connector enums for `connectors_to_search`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,7 @@ from .shared_memory import (
|
||||||
create_save_shared_memory_tool,
|
create_save_shared_memory_tool,
|
||||||
)
|
)
|
||||||
from .user_memory import create_recall_memory_tool, create_save_memory_tool
|
from .user_memory import create_recall_memory_tool, create_save_memory_tool
|
||||||
|
from .web_search import create_web_search_tool
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Tool Definition
|
# Tool Definition
|
||||||
|
|
@ -186,7 +187,16 @@ BUILTIN_TOOLS: list[ToolDefinition] = [
|
||||||
),
|
),
|
||||||
requires=[], # firecrawl_api_key is optional
|
requires=[], # firecrawl_api_key is optional
|
||||||
),
|
),
|
||||||
# Note: write_todos is now provided by TodoListMiddleware from deepagents
|
# Web search tool — real-time web search via SearXNG + user-configured engines
|
||||||
|
ToolDefinition(
|
||||||
|
name="web_search",
|
||||||
|
description="Search the web for real-time information using configured search engines",
|
||||||
|
factory=lambda deps: create_web_search_tool(
|
||||||
|
search_space_id=deps.get("search_space_id"),
|
||||||
|
available_connectors=deps.get("available_connectors"),
|
||||||
|
),
|
||||||
|
requires=[],
|
||||||
|
),
|
||||||
# Surfsense documentation search tool
|
# Surfsense documentation search tool
|
||||||
ToolDefinition(
|
ToolDefinition(
|
||||||
name="search_surfsense_docs",
|
name="search_surfsense_docs",
|
||||||
|
|
|
||||||
247
surfsense_backend/app/agents/new_chat/tools/web_search.py
Normal file
247
surfsense_backend/app/agents/new_chat/tools/web_search.py
Normal file
|
|
@ -0,0 +1,247 @@
|
||||||
|
"""
|
||||||
|
Web search tool for the SurfSense agent.
|
||||||
|
|
||||||
|
Provides a unified tool for real-time web searches that dispatches to all
|
||||||
|
configured search engines: the platform SearXNG instance (always available)
|
||||||
|
plus any user-configured live-search connectors (Tavily, Linkup, Baidu).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from langchain_core.tools import StructuredTool
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.db import shielded_async_session
|
||||||
|
from app.services.connector_service import ConnectorService
|
||||||
|
from app.utils.perf import get_perf_logger
|
||||||
|
|
||||||
|
_LIVE_SEARCH_CONNECTORS: set[str] = {
|
||||||
|
"TAVILY_API",
|
||||||
|
"LINKUP_API",
|
||||||
|
"BAIDU_SEARCH_API",
|
||||||
|
}
|
||||||
|
|
||||||
|
_LIVE_CONNECTOR_SPECS: dict[str, tuple[str, bool, bool, dict[str, Any]]] = {
|
||||||
|
"TAVILY_API": ("search_tavily", False, True, {}),
|
||||||
|
"LINKUP_API": ("search_linkup", False, False, {"mode": "standard"}),
|
||||||
|
"BAIDU_SEARCH_API": ("search_baidu", False, True, {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
_CONNECTOR_LABELS: dict[str, str] = {
|
||||||
|
"TAVILY_API": "Tavily",
|
||||||
|
"LINKUP_API": "Linkup",
|
||||||
|
"BAIDU_SEARCH_API": "Baidu",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class WebSearchInput(BaseModel):
|
||||||
|
"""Input schema for the web_search tool."""
|
||||||
|
|
||||||
|
query: str = Field(
|
||||||
|
description="The search query to look up on the web. Use specific, descriptive terms.",
|
||||||
|
)
|
||||||
|
top_k: int = Field(
|
||||||
|
default=10,
|
||||||
|
description="Number of results to retrieve (default: 10, max: 50).",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_web_results(
|
||||||
|
documents: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
max_chars: int = 50_000,
|
||||||
|
) -> str:
|
||||||
|
"""Format web search results into XML suitable for the LLM context."""
|
||||||
|
if not documents:
|
||||||
|
return "No web search results found."
|
||||||
|
|
||||||
|
parts: list[str] = []
|
||||||
|
total_chars = 0
|
||||||
|
|
||||||
|
for doc in documents:
|
||||||
|
doc_info = doc.get("document") or {}
|
||||||
|
metadata = doc_info.get("metadata") or {}
|
||||||
|
title = doc_info.get("title") or "Web Result"
|
||||||
|
url = metadata.get("url") or ""
|
||||||
|
content = (doc.get("content") or "").strip()
|
||||||
|
source = metadata.get("document_type") or doc.get("source") or "WEB_SEARCH"
|
||||||
|
if not content:
|
||||||
|
continue
|
||||||
|
|
||||||
|
metadata_json = json.dumps(metadata, ensure_ascii=False)
|
||||||
|
doc_xml = "\n".join(
|
||||||
|
[
|
||||||
|
"<document>",
|
||||||
|
"<document_metadata>",
|
||||||
|
f" <document_type>{source}</document_type>",
|
||||||
|
f" <title><![CDATA[{title}]]></title>",
|
||||||
|
f" <url><![CDATA[{url}]]></url>",
|
||||||
|
f" <metadata_json><![CDATA[{metadata_json}]]></metadata_json>",
|
||||||
|
"</document_metadata>",
|
||||||
|
"<document_content>",
|
||||||
|
f" <chunk id='{url}'><![CDATA[{content}]]></chunk>",
|
||||||
|
"</document_content>",
|
||||||
|
"</document>",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
if total_chars + len(doc_xml) > max_chars:
|
||||||
|
parts.append("<!-- Output truncated to fit context window -->")
|
||||||
|
break
|
||||||
|
|
||||||
|
parts.append(doc_xml)
|
||||||
|
total_chars += len(doc_xml)
|
||||||
|
|
||||||
|
return "\n".join(parts).strip() or "No web search results found."
|
||||||
|
|
||||||
|
|
||||||
|
async def _search_live_connector(
|
||||||
|
connector: str,
|
||||||
|
query: str,
|
||||||
|
search_space_id: int,
|
||||||
|
top_k: int,
|
||||||
|
semaphore: asyncio.Semaphore,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Dispatch a single live-search connector (Tavily / Linkup / Baidu)."""
|
||||||
|
perf = get_perf_logger()
|
||||||
|
spec = _LIVE_CONNECTOR_SPECS.get(connector)
|
||||||
|
if spec is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
method_name, _includes_date_range, includes_top_k, extra_kwargs = spec
|
||||||
|
kwargs: dict[str, Any] = {
|
||||||
|
"user_query": query,
|
||||||
|
"search_space_id": search_space_id,
|
||||||
|
**extra_kwargs,
|
||||||
|
}
|
||||||
|
if includes_top_k:
|
||||||
|
kwargs["top_k"] = top_k
|
||||||
|
|
||||||
|
try:
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
async with semaphore, shielded_async_session() as session:
|
||||||
|
svc = ConnectorService(session, search_space_id)
|
||||||
|
_, chunks = await getattr(svc, method_name)(**kwargs)
|
||||||
|
perf.info(
|
||||||
|
"[web_search] connector=%s results=%d in %.3fs",
|
||||||
|
connector,
|
||||||
|
len(chunks),
|
||||||
|
time.perf_counter() - t0,
|
||||||
|
)
|
||||||
|
return chunks
|
||||||
|
except Exception as e:
|
||||||
|
perf.warning("[web_search] connector=%s FAILED: %s", connector, e)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def create_web_search_tool(
|
||||||
|
search_space_id: int | None = None,
|
||||||
|
available_connectors: list[str] | None = None,
|
||||||
|
) -> StructuredTool:
|
||||||
|
"""Factory for the ``web_search`` tool.
|
||||||
|
|
||||||
|
Dispatches in parallel to the platform SearXNG instance and any
|
||||||
|
user-configured live-search connectors (Tavily, Linkup, Baidu).
|
||||||
|
"""
|
||||||
|
active_live_connectors: list[str] = []
|
||||||
|
if available_connectors:
|
||||||
|
active_live_connectors = [
|
||||||
|
c for c in available_connectors if c in _LIVE_SEARCH_CONNECTORS
|
||||||
|
]
|
||||||
|
|
||||||
|
engine_names = ["SearXNG (platform default)"]
|
||||||
|
engine_names.extend(_CONNECTOR_LABELS.get(c, c) for c in active_live_connectors)
|
||||||
|
engines_summary = ", ".join(engine_names)
|
||||||
|
|
||||||
|
description = (
|
||||||
|
"Search the web for real-time information. "
|
||||||
|
"Use this for current events, news, prices, weather, public facts, or any "
|
||||||
|
"question that requires up-to-date information from the internet.\n\n"
|
||||||
|
f"Active search engines: {engines_summary}.\n"
|
||||||
|
"All configured engines are queried in parallel and results are merged."
|
||||||
|
)
|
||||||
|
|
||||||
|
_search_space_id = search_space_id
|
||||||
|
_active_live = active_live_connectors
|
||||||
|
|
||||||
|
async def _web_search_impl(query: str, top_k: int = 10) -> str:
|
||||||
|
from app.services import web_search_service
|
||||||
|
|
||||||
|
perf = get_perf_logger()
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
clamped_top_k = min(max(1, top_k), 50)
|
||||||
|
|
||||||
|
semaphore = asyncio.Semaphore(4)
|
||||||
|
tasks: list[asyncio.Task[list[dict[str, Any]]]] = []
|
||||||
|
|
||||||
|
if web_search_service.is_available():
|
||||||
|
|
||||||
|
async def _searxng() -> list[dict[str, Any]]:
|
||||||
|
async with semaphore:
|
||||||
|
_result_obj, docs = await web_search_service.search(
|
||||||
|
query=query,
|
||||||
|
top_k=clamped_top_k,
|
||||||
|
)
|
||||||
|
return docs
|
||||||
|
|
||||||
|
tasks.append(asyncio.ensure_future(_searxng()))
|
||||||
|
|
||||||
|
if _search_space_id is not None:
|
||||||
|
for connector in _active_live:
|
||||||
|
tasks.append(
|
||||||
|
asyncio.ensure_future(
|
||||||
|
_search_live_connector(
|
||||||
|
connector=connector,
|
||||||
|
query=query,
|
||||||
|
search_space_id=_search_space_id,
|
||||||
|
top_k=clamped_top_k,
|
||||||
|
semaphore=semaphore,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not tasks:
|
||||||
|
return "Web search is not available — no search engines are configured."
|
||||||
|
|
||||||
|
results_lists = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
all_documents: list[dict[str, Any]] = []
|
||||||
|
for result in results_lists:
|
||||||
|
if isinstance(result, BaseException):
|
||||||
|
perf.warning("[web_search] a search engine failed: %s", result)
|
||||||
|
continue
|
||||||
|
all_documents.extend(result)
|
||||||
|
|
||||||
|
seen_urls: set[str] = set()
|
||||||
|
deduplicated: list[dict[str, Any]] = []
|
||||||
|
for doc in all_documents:
|
||||||
|
url = ((doc.get("document") or {}).get("metadata") or {}).get("url", "")
|
||||||
|
if url and url in seen_urls:
|
||||||
|
continue
|
||||||
|
if url:
|
||||||
|
seen_urls.add(url)
|
||||||
|
deduplicated.append(doc)
|
||||||
|
|
||||||
|
formatted = _format_web_results(deduplicated)
|
||||||
|
|
||||||
|
perf.info(
|
||||||
|
"[web_search] query=%r engines=%d results=%d deduped=%d chars=%d in %.3fs",
|
||||||
|
query[:60],
|
||||||
|
len(tasks),
|
||||||
|
len(all_documents),
|
||||||
|
len(deduplicated),
|
||||||
|
len(formatted),
|
||||||
|
time.perf_counter() - t0,
|
||||||
|
)
|
||||||
|
return formatted
|
||||||
|
|
||||||
|
return StructuredTool(
|
||||||
|
name="web_search",
|
||||||
|
description=description,
|
||||||
|
coroutine=_web_search_impl,
|
||||||
|
args_schema=WebSearchInput,
|
||||||
|
)
|
||||||
|
|
@ -224,6 +224,9 @@ class Config:
|
||||||
os.getenv("CONNECTOR_INDEXING_LOCK_TTL_SECONDS", str(8 * 60 * 60))
|
os.getenv("CONNECTOR_INDEXING_LOCK_TTL_SECONDS", str(8 * 60 * 60))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Platform web search (SearXNG)
|
||||||
|
SEARXNG_DEFAULT_HOST = os.getenv("SEARXNG_DEFAULT_HOST")
|
||||||
|
|
||||||
NEXT_FRONTEND_URL = os.getenv("NEXT_FRONTEND_URL")
|
NEXT_FRONTEND_URL = os.getenv("NEXT_FRONTEND_URL")
|
||||||
# Backend URL to override the http to https in the OAuth redirect URI
|
# Backend URL to override the http to https in the OAuth redirect URI
|
||||||
BACKEND_URL = os.getenv("BACKEND_URL")
|
BACKEND_URL = os.getenv("BACKEND_URL")
|
||||||
|
|
|
||||||
|
|
@ -12,13 +12,11 @@ class SearchSpaceBase(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class SearchSpaceCreate(SearchSpaceBase):
|
class SearchSpaceCreate(SearchSpaceBase):
|
||||||
# Optional on create, will use defaults if not provided
|
|
||||||
citations_enabled: bool = True
|
citations_enabled: bool = True
|
||||||
qna_custom_instructions: str | None = None
|
qna_custom_instructions: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class SearchSpaceUpdate(BaseModel):
|
class SearchSpaceUpdate(BaseModel):
|
||||||
# All fields optional on update - only send what you want to change
|
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
citations_enabled: bool | None = None
|
citations_enabled: bool | None = None
|
||||||
|
|
@ -29,7 +27,6 @@ class SearchSpaceRead(SearchSpaceBase, IDModel, TimestampModel):
|
||||||
id: int
|
id: int
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
user_id: uuid.UUID
|
user_id: uuid.UUID
|
||||||
# QnA configuration
|
|
||||||
citations_enabled: bool
|
citations_enabled: bool
|
||||||
qna_custom_instructions: str | None = None
|
qna_custom_instructions: str | None = None
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import asyncio
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import urljoin
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from linkup import LinkupClient
|
from linkup import LinkupClient
|
||||||
|
|
@ -577,185 +576,27 @@ class ConnectorService:
|
||||||
search_space_id: int,
|
search_space_id: int,
|
||||||
top_k: int = 20,
|
top_k: int = 20,
|
||||||
) -> tuple:
|
) -> tuple:
|
||||||
|
"""Search using the platform SearXNG instance.
|
||||||
|
|
||||||
|
Delegates to ``WebSearchService`` which handles caching, circuit
|
||||||
|
breaking, and retries. SearXNG configuration comes from the
|
||||||
|
docker/searxng/settings.yml file.
|
||||||
"""
|
"""
|
||||||
Search using a configured SearxNG instance and return both sources and documents.
|
from app.services import web_search_service
|
||||||
"""
|
|
||||||
searx_connector = await self.get_connector_by_type(
|
if not web_search_service.is_available():
|
||||||
SearchSourceConnectorType.SEARXNG_API, search_space_id
|
return {
|
||||||
|
"id": 11,
|
||||||
|
"name": "Web Search",
|
||||||
|
"type": "SEARXNG_API",
|
||||||
|
"sources": [],
|
||||||
|
}, []
|
||||||
|
|
||||||
|
return await web_search_service.search(
|
||||||
|
query=user_query,
|
||||||
|
top_k=top_k,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not searx_connector:
|
|
||||||
return {
|
|
||||||
"id": 11,
|
|
||||||
"name": "SearxNG Search",
|
|
||||||
"type": "SEARXNG_API",
|
|
||||||
"sources": [],
|
|
||||||
}, []
|
|
||||||
|
|
||||||
config = searx_connector.config or {}
|
|
||||||
host = config.get("SEARXNG_HOST")
|
|
||||||
|
|
||||||
if not host:
|
|
||||||
print("SearxNG connector is missing SEARXNG_HOST configuration")
|
|
||||||
return {
|
|
||||||
"id": 11,
|
|
||||||
"name": "SearxNG Search",
|
|
||||||
"type": "SEARXNG_API",
|
|
||||||
"sources": [],
|
|
||||||
}, []
|
|
||||||
|
|
||||||
api_key = config.get("SEARXNG_API_KEY")
|
|
||||||
engines = config.get("SEARXNG_ENGINES")
|
|
||||||
categories = config.get("SEARXNG_CATEGORIES")
|
|
||||||
language = config.get("SEARXNG_LANGUAGE")
|
|
||||||
safesearch = config.get("SEARXNG_SAFESEARCH")
|
|
||||||
|
|
||||||
def _parse_bool(value: Any, default: bool = True) -> bool:
|
|
||||||
if isinstance(value, bool):
|
|
||||||
return value
|
|
||||||
if isinstance(value, str):
|
|
||||||
lowered = value.strip().lower()
|
|
||||||
if lowered in {"true", "1", "yes", "on"}:
|
|
||||||
return True
|
|
||||||
if lowered in {"false", "0", "no", "off"}:
|
|
||||||
return False
|
|
||||||
return default
|
|
||||||
|
|
||||||
verify_ssl = _parse_bool(config.get("SEARXNG_VERIFY_SSL", True))
|
|
||||||
|
|
||||||
safesearch_value: int | None = None
|
|
||||||
if isinstance(safesearch, str):
|
|
||||||
safesearch_clean = safesearch.strip()
|
|
||||||
if safesearch_clean.isdigit():
|
|
||||||
safesearch_value = int(safesearch_clean)
|
|
||||||
elif isinstance(safesearch, int | float):
|
|
||||||
safesearch_value = int(safesearch)
|
|
||||||
|
|
||||||
if safesearch_value is not None and not (0 <= safesearch_value <= 2):
|
|
||||||
safesearch_value = None
|
|
||||||
|
|
||||||
def _format_list(value: Any) -> str | None:
|
|
||||||
if value is None:
|
|
||||||
return None
|
|
||||||
if isinstance(value, str):
|
|
||||||
value = value.strip()
|
|
||||||
return value or None
|
|
||||||
if isinstance(value, list | tuple | set):
|
|
||||||
cleaned = [str(item).strip() for item in value if str(item).strip()]
|
|
||||||
return ",".join(cleaned) if cleaned else None
|
|
||||||
return str(value)
|
|
||||||
|
|
||||||
params: dict[str, Any] = {
|
|
||||||
"q": user_query,
|
|
||||||
"format": "json",
|
|
||||||
"language": language or "",
|
|
||||||
"limit": max(1, min(top_k, 50)),
|
|
||||||
}
|
|
||||||
|
|
||||||
engines_param = _format_list(engines)
|
|
||||||
if engines_param:
|
|
||||||
params["engines"] = engines_param
|
|
||||||
|
|
||||||
categories_param = _format_list(categories)
|
|
||||||
if categories_param:
|
|
||||||
params["categories"] = categories_param
|
|
||||||
|
|
||||||
if safesearch_value is not None:
|
|
||||||
params["safesearch"] = safesearch_value
|
|
||||||
|
|
||||||
if not params.get("language"):
|
|
||||||
params.pop("language")
|
|
||||||
|
|
||||||
headers = {"Accept": "application/json"}
|
|
||||||
if api_key:
|
|
||||||
headers["X-API-KEY"] = api_key
|
|
||||||
|
|
||||||
searx_endpoint = urljoin(host if host.endswith("/") else f"{host}/", "search")
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=20.0, verify=verify_ssl) as client:
|
|
||||||
response = await client.get(
|
|
||||||
searx_endpoint,
|
|
||||||
params=params,
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
except httpx.HTTPError as exc:
|
|
||||||
print(f"Error searching with SearxNG: {exc!s}")
|
|
||||||
return {
|
|
||||||
"id": 11,
|
|
||||||
"name": "SearxNG Search",
|
|
||||||
"type": "SEARXNG_API",
|
|
||||||
"sources": [],
|
|
||||||
}, []
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = response.json()
|
|
||||||
except ValueError:
|
|
||||||
print("Failed to decode JSON response from SearxNG")
|
|
||||||
return {
|
|
||||||
"id": 11,
|
|
||||||
"name": "SearxNG Search",
|
|
||||||
"type": "SEARXNG_API",
|
|
||||||
"sources": [],
|
|
||||||
}, []
|
|
||||||
|
|
||||||
searx_results = data.get("results", [])
|
|
||||||
if not searx_results:
|
|
||||||
return {
|
|
||||||
"id": 11,
|
|
||||||
"name": "SearxNG Search",
|
|
||||||
"type": "SEARXNG_API",
|
|
||||||
"sources": [],
|
|
||||||
}, []
|
|
||||||
|
|
||||||
sources_list: list[dict[str, Any]] = []
|
|
||||||
documents: list[dict[str, Any]] = []
|
|
||||||
|
|
||||||
async with self.counter_lock:
|
|
||||||
for result in searx_results:
|
|
||||||
description = result.get("content") or result.get("snippet") or ""
|
|
||||||
if len(description) > 160:
|
|
||||||
description = f"{description}"
|
|
||||||
|
|
||||||
source = {
|
|
||||||
"id": self.source_id_counter,
|
|
||||||
"title": result.get("title", "SearxNG Result"),
|
|
||||||
"description": description,
|
|
||||||
"url": result.get("url", ""),
|
|
||||||
}
|
|
||||||
sources_list.append(source)
|
|
||||||
|
|
||||||
metadata = {
|
|
||||||
"url": result.get("url", ""),
|
|
||||||
"engines": result.get("engines", []),
|
|
||||||
"category": result.get("category"),
|
|
||||||
"source": "SEARXNG_API",
|
|
||||||
}
|
|
||||||
|
|
||||||
document = {
|
|
||||||
"chunk_id": self.source_id_counter,
|
|
||||||
"content": description or result.get("content", ""),
|
|
||||||
"score": result.get("score", 0.0),
|
|
||||||
"document": {
|
|
||||||
"id": self.source_id_counter,
|
|
||||||
"title": result.get("title", "SearxNG Result"),
|
|
||||||
"document_type": "SEARXNG_API",
|
|
||||||
"metadata": metadata,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
documents.append(document)
|
|
||||||
self.source_id_counter += 1
|
|
||||||
|
|
||||||
result_object = {
|
|
||||||
"id": 11,
|
|
||||||
"name": "SearxNG Search",
|
|
||||||
"type": "SEARXNG_API",
|
|
||||||
"sources": sources_list,
|
|
||||||
}
|
|
||||||
|
|
||||||
return result_object, documents
|
|
||||||
|
|
||||||
async def search_baidu(
|
async def search_baidu(
|
||||||
self,
|
self,
|
||||||
user_query: str,
|
user_query: str,
|
||||||
|
|
|
||||||
290
surfsense_backend/app/services/web_search_service.py
Normal file
290
surfsense_backend/app/services/web_search_service.py
Normal file
|
|
@ -0,0 +1,290 @@
|
||||||
|
"""
|
||||||
|
Platform-level web search service backed by SearXNG.
|
||||||
|
|
||||||
|
Redis is used only for result caching (graceful degradation if unavailable).
|
||||||
|
The circuit breaker is fully in-process — no external dependency, zero
|
||||||
|
latency overhead.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import redis
|
||||||
|
|
||||||
|
from app.config import config
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_EMPTY_RESULT: dict[str, Any] = {
|
||||||
|
"id": 11,
|
||||||
|
"name": "Web Search",
|
||||||
|
"type": "SEARXNG_API",
|
||||||
|
"sources": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Redis — used only for result caching
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_redis_client: redis.Redis | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_redis() -> redis.Redis:
|
||||||
|
global _redis_client
|
||||||
|
if _redis_client is None:
|
||||||
|
_redis_client = redis.from_url(config.REDIS_APP_URL, decode_responses=True)
|
||||||
|
return _redis_client
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# In-process Circuit Breaker (no Redis dependency)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_CB_FAILURE_THRESHOLD = 5
|
||||||
|
_CB_FAILURE_WINDOW_SECONDS = 60
|
||||||
|
_CB_COOLDOWN_SECONDS = 30
|
||||||
|
|
||||||
|
_cb_lock = threading.Lock()
|
||||||
|
_cb_failure_count: int = 0
|
||||||
|
_cb_last_failure_time: float = 0.0
|
||||||
|
_cb_open_until: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _circuit_is_open() -> bool:
|
||||||
|
return time.monotonic() < _cb_open_until
|
||||||
|
|
||||||
|
|
||||||
|
def _record_failure() -> None:
|
||||||
|
global _cb_failure_count, _cb_last_failure_time, _cb_open_until
|
||||||
|
now = time.monotonic()
|
||||||
|
with _cb_lock:
|
||||||
|
if now - _cb_last_failure_time > _CB_FAILURE_WINDOW_SECONDS:
|
||||||
|
_cb_failure_count = 0
|
||||||
|
_cb_failure_count += 1
|
||||||
|
_cb_last_failure_time = now
|
||||||
|
if _cb_failure_count >= _CB_FAILURE_THRESHOLD:
|
||||||
|
_cb_open_until = now + _CB_COOLDOWN_SECONDS
|
||||||
|
logger.warning(
|
||||||
|
"Circuit breaker OPENED after %d failures — "
|
||||||
|
"SearXNG calls paused for %ds",
|
||||||
|
_cb_failure_count,
|
||||||
|
_CB_COOLDOWN_SECONDS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _record_success() -> None:
|
||||||
|
global _cb_failure_count, _cb_open_until
|
||||||
|
with _cb_lock:
|
||||||
|
_cb_failure_count = 0
|
||||||
|
_cb_open_until = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Result Caching (Redis, graceful degradation)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_CACHE_TTL_SECONDS = 300 # 5 minutes
|
||||||
|
_CACHE_PREFIX = "websearch:cache:"
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_key(query: str, engines: str | None, language: str | None) -> str:
|
||||||
|
raw = f"{query}|{engines or ''}|{language or ''}"
|
||||||
|
digest = hashlib.sha256(raw.encode()).hexdigest()[:24]
|
||||||
|
return f"{_CACHE_PREFIX}{digest}"
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_get(key: str) -> dict | None:
|
||||||
|
try:
|
||||||
|
data = _get_redis().get(key)
|
||||||
|
if data:
|
||||||
|
return json.loads(data)
|
||||||
|
except (redis.RedisError, json.JSONDecodeError):
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_set(key: str, value: dict) -> None:
|
||||||
|
with contextlib.suppress(redis.RedisError):
|
||||||
|
_get_redis().setex(key, _CACHE_TTL_SECONDS, json.dumps(value))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def is_available() -> bool:
|
||||||
|
"""Return ``True`` when the platform SearXNG host is configured."""
|
||||||
|
return bool(config.SEARXNG_DEFAULT_HOST)
|
||||||
|
|
||||||
|
|
||||||
|
async def health_check() -> dict[str, Any]:
|
||||||
|
"""Ping the SearXNG ``/healthz`` endpoint and return status info."""
|
||||||
|
host = config.SEARXNG_DEFAULT_HOST
|
||||||
|
if not host:
|
||||||
|
return {"status": "unavailable", "error": "SEARXNG_DEFAULT_HOST not set"}
|
||||||
|
|
||||||
|
healthz_url = urljoin(host if host.endswith("/") else f"{host}/", "healthz")
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5.0, verify=False) as client:
|
||||||
|
resp = await client.get(healthz_url)
|
||||||
|
resp.raise_for_status()
|
||||||
|
elapsed_ms = round((time.perf_counter() - t0) * 1000)
|
||||||
|
return {
|
||||||
|
"status": "healthy",
|
||||||
|
"response_time_ms": elapsed_ms,
|
||||||
|
"circuit_breaker": "open" if _circuit_is_open() else "closed",
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
elapsed_ms = round((time.perf_counter() - t0) * 1000)
|
||||||
|
return {
|
||||||
|
"status": "unhealthy",
|
||||||
|
"error": str(exc),
|
||||||
|
"response_time_ms": elapsed_ms,
|
||||||
|
"circuit_breaker": "open" if _circuit_is_open() else "closed",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def search(
|
||||||
|
query: str,
|
||||||
|
top_k: int = 20,
|
||||||
|
*,
|
||||||
|
engines: str | None = None,
|
||||||
|
language: str | None = None,
|
||||||
|
safesearch: int | None = None,
|
||||||
|
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
||||||
|
"""Execute a web search against the platform SearXNG instance.
|
||||||
|
|
||||||
|
Returns the standard ``(result_object, documents)`` tuple expected by
|
||||||
|
``ConnectorService.search_searxng``.
|
||||||
|
"""
|
||||||
|
host = config.SEARXNG_DEFAULT_HOST
|
||||||
|
if not host:
|
||||||
|
return dict(_EMPTY_RESULT), []
|
||||||
|
|
||||||
|
if _circuit_is_open():
|
||||||
|
logger.info("Web search skipped — circuit breaker is open")
|
||||||
|
result = dict(_EMPTY_RESULT)
|
||||||
|
result["error"] = "Web search temporarily unavailable (circuit open)"
|
||||||
|
result["status"] = "degraded"
|
||||||
|
return result, []
|
||||||
|
|
||||||
|
ck = _cache_key(query, engines, language)
|
||||||
|
cached = _cache_get(ck)
|
||||||
|
if cached is not None:
|
||||||
|
logger.debug("Web search cache HIT for query=%r", query[:60])
|
||||||
|
return cached["result"], cached["documents"]
|
||||||
|
|
||||||
|
params: dict[str, Any] = {
|
||||||
|
"q": query,
|
||||||
|
"format": "json",
|
||||||
|
"limit": max(1, min(top_k, 50)),
|
||||||
|
}
|
||||||
|
if engines:
|
||||||
|
params["engines"] = engines
|
||||||
|
if language:
|
||||||
|
params["language"] = language
|
||||||
|
if safesearch is not None and 0 <= safesearch <= 2:
|
||||||
|
params["safesearch"] = safesearch
|
||||||
|
|
||||||
|
searx_endpoint = urljoin(host if host.endswith("/") else f"{host}/", "search")
|
||||||
|
headers = {"Accept": "application/json"}
|
||||||
|
|
||||||
|
data: dict[str, Any] | None = None
|
||||||
|
last_error: Exception | None = None
|
||||||
|
|
||||||
|
for attempt in range(2):
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=15.0, verify=False) as client:
|
||||||
|
response = await client.get(
|
||||||
|
searx_endpoint,
|
||||||
|
params=params,
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
break
|
||||||
|
except (httpx.HTTPStatusError, httpx.TimeoutException) as exc:
|
||||||
|
last_error = exc
|
||||||
|
if attempt == 0 and (
|
||||||
|
isinstance(exc, httpx.TimeoutException)
|
||||||
|
or (
|
||||||
|
isinstance(exc, httpx.HTTPStatusError)
|
||||||
|
and exc.response.status_code >= 500
|
||||||
|
)
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
break
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
last_error = exc
|
||||||
|
break
|
||||||
|
except ValueError as exc:
|
||||||
|
last_error = exc
|
||||||
|
break
|
||||||
|
|
||||||
|
if data is None:
|
||||||
|
_record_failure()
|
||||||
|
logger.warning("Web search failed after retries: %s", last_error)
|
||||||
|
return dict(_EMPTY_RESULT), []
|
||||||
|
|
||||||
|
_record_success()
|
||||||
|
|
||||||
|
searx_results = data.get("results", [])
|
||||||
|
if not searx_results:
|
||||||
|
return dict(_EMPTY_RESULT), []
|
||||||
|
|
||||||
|
sources_list: list[dict[str, Any]] = []
|
||||||
|
documents: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
for idx, result in enumerate(searx_results):
|
||||||
|
source_id = 200_000 + idx
|
||||||
|
description = result.get("content") or result.get("snippet") or ""
|
||||||
|
|
||||||
|
sources_list.append(
|
||||||
|
{
|
||||||
|
"id": source_id,
|
||||||
|
"title": result.get("title", "Web Search Result"),
|
||||||
|
"description": description,
|
||||||
|
"url": result.get("url", ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
documents.append(
|
||||||
|
{
|
||||||
|
"chunk_id": source_id,
|
||||||
|
"content": description or result.get("content", ""),
|
||||||
|
"score": result.get("score", 0.0),
|
||||||
|
"document": {
|
||||||
|
"id": source_id,
|
||||||
|
"title": result.get("title", "Web Search Result"),
|
||||||
|
"document_type": "SEARXNG_API",
|
||||||
|
"metadata": {
|
||||||
|
"url": result.get("url", ""),
|
||||||
|
"engines": result.get("engines", []),
|
||||||
|
"category": result.get("category"),
|
||||||
|
"source": "SEARXNG_API",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
result_object: dict[str, Any] = {
|
||||||
|
"id": 11,
|
||||||
|
"name": "Web Search",
|
||||||
|
"type": "SEARXNG_API",
|
||||||
|
"sources": sources_list,
|
||||||
|
}
|
||||||
|
|
||||||
|
_cache_set(ck, {"result": result_object, "documents": documents})
|
||||||
|
|
||||||
|
return result_object, documents
|
||||||
|
|
@ -51,9 +51,7 @@ async def safe_set_chunks(
|
||||||
from app.db import Chunk
|
from app.db import Chunk
|
||||||
|
|
||||||
if document.id is not None:
|
if document.id is not None:
|
||||||
await session.execute(
|
await session.execute(delete(Chunk).where(Chunk.document_id == document.id))
|
||||||
delete(Chunk).where(Chunk.document_id == document.id)
|
|
||||||
)
|
|
||||||
for chunk in chunks:
|
for chunk in chunks:
|
||||||
chunk.document_id = document.id
|
chunk.document_id = document.id
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,9 +37,7 @@ async def safe_set_chunks(
|
||||||
from app.db import Chunk
|
from app.db import Chunk
|
||||||
|
|
||||||
if document.id is not None:
|
if document.id is not None:
|
||||||
await session.execute(
|
await session.execute(delete(Chunk).where(Chunk.document_id == document.id))
|
||||||
delete(Chunk).where(Chunk.document_id == document.id)
|
|
||||||
)
|
|
||||||
for chunk in chunks:
|
for chunk in chunks:
|
||||||
chunk.document_id = document.id
|
chunk.document_id = document.id
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,9 @@ import re
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import delete as sa_delete, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from sqlalchemy import delete as sa_delete
|
|
||||||
from sqlalchemy.orm.attributes import set_committed_value
|
from sqlalchemy.orm.attributes import set_committed_value
|
||||||
|
|
||||||
from app.config import config
|
from app.config import config
|
||||||
|
|
@ -39,6 +37,7 @@ async def _safe_set_docs_chunks(
|
||||||
set_committed_value(document, "chunks", chunks)
|
set_committed_value(document, "chunks", chunks)
|
||||||
session.add_all(chunks)
|
session.add_all(chunks)
|
||||||
|
|
||||||
|
|
||||||
# Path to docs relative to project root
|
# Path to docs relative to project root
|
||||||
DOCS_DIR = (
|
DOCS_DIR = (
|
||||||
Path(__file__).resolve().parent.parent.parent.parent
|
Path(__file__).resolve().parent.parent.parent.parent
|
||||||
|
|
|
||||||
|
|
@ -76,10 +76,10 @@ export function DocumentsFilters({
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-64 !p-0 overflow-hidden" align="end">
|
<PopoverContent className="w-56 md:w-52 !p-0 overflow-hidden" align="end">
|
||||||
<div>
|
<div>
|
||||||
{/* Search input */}
|
{/* Search input */}
|
||||||
<div className="p-2 border-b border-border dark:border-neutral-700">
|
<div className="p-2">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-0.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-0.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -196,6 +196,7 @@ export function DocumentsFilters({
|
||||||
|
|
||||||
{/* Upload Button */}
|
{/* Upload Button */}
|
||||||
<Button
|
<Button
|
||||||
|
data-joyride="upload-button"
|
||||||
onClick={openUploadDialog}
|
onClick={openUploadDialog}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
|
||||||
|
|
@ -9,14 +9,18 @@ import {
|
||||||
Eye,
|
Eye,
|
||||||
FileText,
|
FileText,
|
||||||
FileX,
|
FileX,
|
||||||
|
MoreHorizontal,
|
||||||
Network,
|
Network,
|
||||||
PenLine,
|
PenLine,
|
||||||
SearchX,
|
SearchX,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
User,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useAtomValue, useSetAtom } from "jotai";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { membersAtom } from "@/atoms/members/members-query.atoms";
|
||||||
|
import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
|
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
|
||||||
import { JsonMetadataViewer } from "@/components/json-metadata-viewer";
|
import { JsonMetadataViewer } from "@/components/json-metadata-viewer";
|
||||||
|
|
@ -34,12 +38,11 @@ import {
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
DropdownMenu,
|
||||||
ContextMenuContent,
|
DropdownMenuContent,
|
||||||
ContextMenuItem,
|
DropdownMenuItem,
|
||||||
ContextMenuSeparator,
|
DropdownMenuTrigger,
|
||||||
ContextMenuTrigger,
|
} from "@/components/ui/dropdown-menu";
|
||||||
} from "@/components/ui/context-menu";
|
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import {
|
import {
|
||||||
Drawer,
|
Drawer,
|
||||||
|
|
@ -48,6 +51,7 @@ import {
|
||||||
DrawerHeader,
|
DrawerHeader,
|
||||||
DrawerTitle,
|
DrawerTitle,
|
||||||
} from "@/components/ui/drawer";
|
} from "@/components/ui/drawer";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import {
|
import {
|
||||||
|
|
@ -61,12 +65,20 @@ import {
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { useLongPress } from "@/hooks/use-long-press";
|
import { useLongPress } from "@/hooks/use-long-press";
|
||||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||||
import { getDocumentTypeIcon, getDocumentTypeLabel } from "./DocumentTypeIcon";
|
import { getDocumentTypeIcon } from "./DocumentTypeIcon";
|
||||||
import type { Document, DocumentStatus } from "./types";
|
import type { Document, DocumentStatus } from "./types";
|
||||||
|
|
||||||
const EDITABLE_DOCUMENT_TYPES = ["FILE", "NOTE"] as const;
|
const EDITABLE_DOCUMENT_TYPES = ["FILE", "NOTE"] as const;
|
||||||
const NON_DELETABLE_DOCUMENT_TYPES = ["SURFSENSE_DOCS"] as const;
|
const NON_DELETABLE_DOCUMENT_TYPES = ["SURFSENSE_DOCS"] as const;
|
||||||
|
|
||||||
|
function getInitials(name: string): string {
|
||||||
|
const parts = name.trim().split(/\s+/);
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
return (parts[0][0] + parts[1][0]).toUpperCase();
|
||||||
|
}
|
||||||
|
return name.slice(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
function StatusIndicator({ status }: { status?: DocumentStatus }) {
|
function StatusIndicator({ status }: { status?: DocumentStatus }) {
|
||||||
const state = status?.state ?? "ready";
|
const state = status?.state ?? "ready";
|
||||||
|
|
||||||
|
|
@ -145,45 +157,6 @@ function formatAbsoluteDate(dateStr: string): string {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function DocumentNameTooltip({ doc, className }: { doc: Document; className?: string }) {
|
|
||||||
const textRef = useRef<HTMLSpanElement>(null);
|
|
||||||
const [isTruncated, setIsTruncated] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const checkTruncation = () => {
|
|
||||||
if (textRef.current) {
|
|
||||||
setIsTruncated(textRef.current.scrollWidth > textRef.current.clientWidth);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
checkTruncation();
|
|
||||||
window.addEventListener("resize", checkTruncation);
|
|
||||||
return () => window.removeEventListener("resize", checkTruncation);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<span ref={textRef} className={className}>
|
|
||||||
{doc.title}
|
|
||||||
</span>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top" align="start" className="max-w-sm">
|
|
||||||
<div className="space-y-1 text-xs">
|
|
||||||
{isTruncated && <p className="font-medium text-sm break-words">{doc.title}</p>}
|
|
||||||
<p>
|
|
||||||
<span className="text-muted-foreground">Owner:</span>{" "}
|
|
||||||
{doc.created_by_name || doc.created_by_email || "—"}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<span className="text-muted-foreground">Created:</span>{" "}
|
|
||||||
{formatAbsoluteDate(doc.created_at)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SortableHeader({
|
function SortableHeader({
|
||||||
children,
|
children,
|
||||||
sortKey,
|
sortKey,
|
||||||
|
|
@ -217,73 +190,6 @@ function SortableHeader({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RowContextMenu({
|
|
||||||
doc,
|
|
||||||
children,
|
|
||||||
onPreview,
|
|
||||||
onDelete,
|
|
||||||
searchSpaceId,
|
|
||||||
onEditNavigate,
|
|
||||||
}: {
|
|
||||||
doc: Document;
|
|
||||||
children: React.ReactNode;
|
|
||||||
onPreview: (doc: Document) => void;
|
|
||||||
onDelete: (doc: Document) => void;
|
|
||||||
searchSpaceId: string;
|
|
||||||
onEditNavigate?: () => void;
|
|
||||||
}) {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const isEditable = EDITABLE_DOCUMENT_TYPES.includes(
|
|
||||||
doc.document_type as (typeof EDITABLE_DOCUMENT_TYPES)[number]
|
|
||||||
);
|
|
||||||
const isBeingProcessed = doc.status?.state === "pending" || doc.status?.state === "processing";
|
|
||||||
const isFileFailed = doc.document_type === "FILE" && doc.status?.state === "failed";
|
|
||||||
const shouldShowDelete = !NON_DELETABLE_DOCUMENT_TYPES.includes(
|
|
||||||
doc.document_type as (typeof NON_DELETABLE_DOCUMENT_TYPES)[number]
|
|
||||||
);
|
|
||||||
const isEditDisabled = isBeingProcessed || isFileFailed;
|
|
||||||
const isDeleteDisabled = isBeingProcessed;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ContextMenu>
|
|
||||||
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
|
|
||||||
<ContextMenuContent className="w-48">
|
|
||||||
<ContextMenuItem onClick={() => onPreview(doc)}>
|
|
||||||
<Eye className="h-4 w-4" />
|
|
||||||
Preview
|
|
||||||
</ContextMenuItem>
|
|
||||||
{isEditable && (
|
|
||||||
<ContextMenuItem
|
|
||||||
onClick={() => {
|
|
||||||
if (!isEditDisabled) {
|
|
||||||
onEditNavigate?.();
|
|
||||||
router.push(`/dashboard/${searchSpaceId}/editor/${doc.id}`);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={isEditDisabled}
|
|
||||||
>
|
|
||||||
<PenLine className="h-4 w-4" />
|
|
||||||
Edit
|
|
||||||
</ContextMenuItem>
|
|
||||||
)}
|
|
||||||
{shouldShowDelete && (
|
|
||||||
<>
|
|
||||||
<ContextMenuSeparator />
|
|
||||||
<ContextMenuItem
|
|
||||||
onClick={() => !isDeleteDisabled && onDelete(doc)}
|
|
||||||
disabled={isDeleteDisabled}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
Delete
|
|
||||||
</ContextMenuItem>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</ContextMenuContent>
|
|
||||||
</ContextMenu>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MobileCardWrapper({
|
function MobileCardWrapper({
|
||||||
onLongPress,
|
onLongPress,
|
||||||
children,
|
children,
|
||||||
|
|
@ -327,7 +233,6 @@ export function DocumentsTableShell({
|
||||||
onLoadMore,
|
onLoadMore,
|
||||||
mentionedDocIds,
|
mentionedDocIds,
|
||||||
onToggleChatMention,
|
onToggleChatMention,
|
||||||
onEditNavigate,
|
|
||||||
isSearchMode = false,
|
isSearchMode = false,
|
||||||
}: {
|
}: {
|
||||||
documents: Document[];
|
documents: Document[];
|
||||||
|
|
@ -346,8 +251,6 @@ export function DocumentsTableShell({
|
||||||
mentionedDocIds?: Set<number>;
|
mentionedDocIds?: Set<number>;
|
||||||
/** Toggle a document's mention in the chat (add if not mentioned, remove if mentioned) */
|
/** Toggle a document's mention in the chat (add if not mentioned, remove if mentioned) */
|
||||||
onToggleChatMention?: (doc: Document, mentioned: boolean) => void;
|
onToggleChatMention?: (doc: Document, mentioned: boolean) => void;
|
||||||
/** Called when user navigates to the editor via Edit — use to close containing sidebar/panel */
|
|
||||||
onEditNavigate?: () => void;
|
|
||||||
/** Whether results are filtered by a search query or type filters */
|
/** Whether results are filtered by a search query or type filters */
|
||||||
isSearchMode?: boolean;
|
isSearchMode?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -374,7 +277,23 @@ export function DocumentsTableShell({
|
||||||
const [mobileActionDoc, setMobileActionDoc] = useState<Document | null>(null);
|
const [mobileActionDoc, setMobileActionDoc] = useState<Document | null>(null);
|
||||||
const [bulkDeleteConfirmOpen, setBulkDeleteConfirmOpen] = useState(false);
|
const [bulkDeleteConfirmOpen, setBulkDeleteConfirmOpen] = useState(false);
|
||||||
const [isBulkDeleting, setIsBulkDeleting] = useState(false);
|
const [isBulkDeleting, setIsBulkDeleting] = useState(false);
|
||||||
const router = useRouter();
|
const openEditor = useSetAtom(openEditorPanelAtom);
|
||||||
|
const [openMenuDocId, setOpenMenuDocId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const { data: members } = useAtomValue(membersAtom);
|
||||||
|
const memberMap = useMemo(() => {
|
||||||
|
const map = new Map<string, { name: string; email?: string; avatarUrl?: string }>();
|
||||||
|
if (members) {
|
||||||
|
for (const m of members) {
|
||||||
|
map.set(m.user_id, {
|
||||||
|
name: m.user_display_name || m.user_email || "Unknown",
|
||||||
|
email: m.user_email || undefined,
|
||||||
|
avatarUrl: m.user_avatar_url || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [members]);
|
||||||
|
|
||||||
const desktopSentinelRef = useRef<HTMLDivElement>(null);
|
const desktopSentinelRef = useRef<HTMLDivElement>(null);
|
||||||
const mobileSentinelRef = useRef<HTMLDivElement>(null);
|
const mobileSentinelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -554,7 +473,20 @@ export function DocumentsTableShell({
|
||||||
}, [deletableSelectedIds, bulkDeleteDocuments, deleteDocument]);
|
}, [deletableSelectedIds, bulkDeleteDocuments, deleteDocument]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-sidebar overflow-hidden select-none border-t border-border/50 flex-1 flex flex-col min-h-0">
|
<div className="bg-sidebar overflow-hidden select-none border-t border-border/50 flex-1 flex flex-col min-h-0 relative">
|
||||||
|
{/* Floating bulk delete pill */}
|
||||||
|
{hasDeletableSelection && (
|
||||||
|
<div className="absolute left-1/2 -translate-x-1/2 top-2 md:top-9 z-20 animate-in fade-in slide-in-from-top-1 duration-150">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setBulkDeleteConfirmOpen(true)}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-destructive text-destructive-foreground shadow-md text-xs font-medium"
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
Delete ({deletableSelectedIds.length} selected)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* Desktop Table View */}
|
{/* Desktop Table View */}
|
||||||
<div className="hidden md:flex md:flex-col flex-1 min-h-0">
|
<div className="hidden md:flex md:flex-col flex-1 min-h-0">
|
||||||
<Table className="table-fixed w-full">
|
<Table className="table-fixed w-full">
|
||||||
|
|
@ -586,23 +518,10 @@ export function DocumentsTableShell({
|
||||||
<Network size={14} className="text-muted-foreground" />
|
<Network size={14} className="text-muted-foreground" />
|
||||||
</span>
|
</span>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="w-12 text-center h-8 pl-0 pr-3">
|
<TableHead className="w-10 text-center h-8 px-0 pr-2">
|
||||||
{hasDeletableSelection ? (
|
<span className="flex items-center justify-center">
|
||||||
<Tooltip>
|
<User size={14} className="text-muted-foreground" />
|
||||||
<TooltipTrigger asChild>
|
</span>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setBulkDeleteConfirmOpen(true)}
|
|
||||||
className="inline-flex items-center justify-center h-6 w-6 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
|
|
||||||
>
|
|
||||||
<Trash2 size={14} />
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Delete {deletableSelectedIds.length} selected</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">Status</span>
|
|
||||||
)}
|
|
||||||
</TableHead>
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
|
|
@ -627,7 +546,7 @@ export function DocumentsTableShell({
|
||||||
<TableCell className="w-10 px-0 py-1.5 text-center">
|
<TableCell className="w-10 px-0 py-1.5 text-center">
|
||||||
<Skeleton className="h-4 w-4 mx-auto rounded" />
|
<Skeleton className="h-4 w-4 mx-auto rounded" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="w-12 pl-0 pr-3 py-1.5 text-center">
|
<TableCell className="w-10 px-0 pr-2 py-1.5 text-center">
|
||||||
<Skeleton className="h-5 w-5 mx-auto rounded-full" />
|
<Skeleton className="h-5 w-5 mx-auto rounded-full" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|
@ -680,6 +599,17 @@ export function DocumentsTableShell({
|
||||||
{sorted.map((doc) => {
|
{sorted.map((doc) => {
|
||||||
const isMentioned = mentionedDocIds?.has(doc.id) ?? false;
|
const isMentioned = mentionedDocIds?.has(doc.id) ?? false;
|
||||||
const canInteract = isSelectable(doc);
|
const canInteract = isSelectable(doc);
|
||||||
|
const isBeingProcessed =
|
||||||
|
doc.status?.state === "pending" || doc.status?.state === "processing";
|
||||||
|
const isFileFailed =
|
||||||
|
doc.document_type === "FILE" && doc.status?.state === "failed";
|
||||||
|
const isEditable = EDITABLE_DOCUMENT_TYPES.includes(
|
||||||
|
doc.document_type as (typeof EDITABLE_DOCUMENT_TYPES)[number]
|
||||||
|
);
|
||||||
|
const shouldShowDelete = !NON_DELETABLE_DOCUMENT_TYPES.includes(
|
||||||
|
doc.document_type as (typeof NON_DELETABLE_DOCUMENT_TYPES)[number]
|
||||||
|
);
|
||||||
|
const isMenuOpen = openMenuDocId === doc.id;
|
||||||
const handleRowToggle = () => {
|
const handleRowToggle = () => {
|
||||||
if (canInteract && onToggleChatMention) {
|
if (canInteract && onToggleChatMention) {
|
||||||
onToggleChatMention(doc, isMentioned);
|
onToggleChatMention(doc, isMentioned);
|
||||||
|
|
@ -695,16 +625,9 @@ export function DocumentsTableShell({
|
||||||
handleRowToggle();
|
handleRowToggle();
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<RowContextMenu
|
|
||||||
key={doc.id}
|
|
||||||
doc={doc}
|
|
||||||
onPreview={handleViewDocument}
|
|
||||||
onDelete={setDeleteDoc}
|
|
||||||
searchSpaceId={searchSpaceId}
|
|
||||||
onEditNavigate={onEditNavigate}
|
|
||||||
>
|
|
||||||
<tr
|
<tr
|
||||||
className={`border-b border-border/50 transition-colors ${
|
key={doc.id}
|
||||||
|
className={`group border-b border-border/50 transition-colors ${
|
||||||
isMentioned ? "bg-primary/5 hover:bg-primary/8" : "hover:bg-muted/30"
|
isMentioned ? "bg-primary/5 hover:bg-primary/8" : "hover:bg-muted/30"
|
||||||
} ${canInteract && hasChatMode ? "cursor-pointer" : ""}`}
|
} ${canInteract && hasChatMode ? "cursor-pointer" : ""}`}
|
||||||
onClick={handleRowClick}
|
onClick={handleRowClick}
|
||||||
|
|
@ -714,38 +637,137 @@ export function DocumentsTableShell({
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
|
{(() => {
|
||||||
|
const state = doc.status?.state ?? "ready";
|
||||||
|
if (state === "pending" || state === "processing") {
|
||||||
|
return <StatusIndicator status={doc.status} />;
|
||||||
|
}
|
||||||
|
if (state === "failed") {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className="group-hover:hidden">
|
||||||
|
<StatusIndicator status={doc.status} />
|
||||||
|
</span>
|
||||||
|
<span className="hidden group-hover:inline-flex">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={isMentioned}
|
checked={isMentioned}
|
||||||
onCheckedChange={() => handleRowToggle()}
|
onCheckedChange={() => handleRowToggle()}
|
||||||
disabled={!canInteract}
|
|
||||||
aria-label={isMentioned ? "Remove from chat" : "Add to chat"}
|
aria-label={isMentioned ? "Remove from chat" : "Add to chat"}
|
||||||
className={`border-foreground data-[state=checked]:bg-primary data-[state=checked]:border-primary ${!canInteract ? "opacity-40 cursor-not-allowed" : ""}`}
|
className="border-foreground data-[state=checked]:bg-primary data-[state=checked]:border-primary"
|
||||||
/>
|
/>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Checkbox
|
||||||
|
checked={isMentioned}
|
||||||
|
onCheckedChange={() => handleRowToggle()}
|
||||||
|
aria-label={isMentioned ? "Remove from chat" : "Add to chat"}
|
||||||
|
className="border-foreground data-[state=checked]:bg-primary data-[state=checked]:border-primary"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-2 py-1.5 max-w-0">
|
<TableCell className="px-2 py-1.5 max-w-0">
|
||||||
<DocumentNameTooltip
|
<span className="truncate block text-sm text-foreground cursor-default">
|
||||||
doc={doc}
|
{doc.title}
|
||||||
className="truncate block text-sm text-foreground cursor-default"
|
</span>
|
||||||
/>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="w-10 px-0 py-1.5 text-center">
|
<TableCell className="w-10 px-0 py-1.5 text-center">
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<span className="flex items-center justify-center">
|
<span className="flex items-center justify-center">
|
||||||
{getDocumentTypeIcon(doc.document_type, "h-4 w-4")}
|
{getDocumentTypeIcon(doc.document_type, "h-4 w-4")}
|
||||||
</span>
|
</span>
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top">
|
|
||||||
{getDocumentTypeLabel(doc.document_type)}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="w-12 pl-0 pr-3 py-1.5 text-center">
|
<TableCell
|
||||||
<StatusIndicator status={doc.status} />
|
className="w-10 px-0 pr-2 py-1.5 text-center"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="relative flex items-center justify-center">
|
||||||
|
{(() => {
|
||||||
|
const member = doc.created_by_id
|
||||||
|
? memberMap.get(doc.created_by_id)
|
||||||
|
: null;
|
||||||
|
const displayName =
|
||||||
|
member?.name ||
|
||||||
|
doc.created_by_name ||
|
||||||
|
doc.created_by_email ||
|
||||||
|
"Unknown";
|
||||||
|
const avatarUrl = member?.avatarUrl;
|
||||||
|
const email = member?.email || doc.created_by_email || displayName;
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span
|
||||||
|
className={`flex items-center justify-center ${isMenuOpen ? "invisible" : "group-hover:invisible"}`}
|
||||||
|
>
|
||||||
|
<Avatar className="size-5 shrink-0">
|
||||||
|
{avatarUrl && (
|
||||||
|
<AvatarImage src={avatarUrl} alt={displayName} />
|
||||||
|
)}
|
||||||
|
<AvatarFallback className="text-[9px]">
|
||||||
|
{getInitials(displayName)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">{email}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
<div
|
||||||
|
className={`absolute inset-0 flex items-center justify-center ${isMenuOpen ? "visible" : "invisible group-hover:visible"}`}
|
||||||
|
>
|
||||||
|
<DropdownMenu
|
||||||
|
onOpenChange={(open) => setOpenMenuDocId(open ? doc.id : null)}
|
||||||
|
>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center justify-center h-6 w-6 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
|
||||||
|
>
|
||||||
|
<MoreHorizontal size={14} />
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-48">
|
||||||
|
<DropdownMenuItem onClick={() => handleViewDocument(doc)}>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
Preview
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{isEditable && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
if (!(isBeingProcessed || isFileFailed)) {
|
||||||
|
openEditor({
|
||||||
|
documentId: doc.id,
|
||||||
|
searchSpaceId: Number(searchSpaceId),
|
||||||
|
title: doc.title,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isBeingProcessed || isFileFailed}
|
||||||
|
>
|
||||||
|
<PenLine className="h-4 w-4" />
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{shouldShowDelete && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => !isBeingProcessed && setDeleteDoc(doc)}
|
||||||
|
disabled={isBeingProcessed}
|
||||||
|
className=""
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</tr>
|
</tr>
|
||||||
</RowContextMenu>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|
@ -765,12 +787,10 @@ export function DocumentsTableShell({
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<Skeleton className="h-4" style={{ width: `${widthPercent}%` }} />
|
<Skeleton className="h-4" style={{ width: `${widthPercent}%` }} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Skeleton className="h-4 w-4 rounded shrink-0" />
|
<Skeleton className="h-4 w-4 rounded shrink-0" />
|
||||||
<Skeleton className="h-5 w-5 rounded-full shrink-0" />
|
<Skeleton className="h-5 w-5 rounded-full shrink-0" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
|
|
@ -814,25 +834,11 @@ export function DocumentsTableShell({
|
||||||
ref={mobileScrollRef}
|
ref={mobileScrollRef}
|
||||||
className="md:hidden divide-y divide-border/50 flex-1 overflow-auto"
|
className="md:hidden divide-y divide-border/50 flex-1 overflow-auto"
|
||||||
>
|
>
|
||||||
{hasDeletableSelection && (
|
|
||||||
<div className="flex items-center justify-between px-3 py-2 bg-muted/50 border-b border-border/50 sticky top-0 z-10">
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{deletableSelectedIds.length} deletable selected
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 px-2.5 text-xs"
|
|
||||||
onClick={() => setBulkDeleteConfirmOpen(true)}
|
|
||||||
>
|
|
||||||
<Trash2 size={12} className="mr-1" />
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{sorted.map((doc) => {
|
{sorted.map((doc) => {
|
||||||
const isMentioned = mentionedDocIds?.has(doc.id) ?? false;
|
const isMentioned = mentionedDocIds?.has(doc.id) ?? false;
|
||||||
const canInteract = isSelectable(doc);
|
const statusState = doc.status?.state ?? "ready";
|
||||||
|
const showCheckbox = statusState === "ready";
|
||||||
|
const canInteract = showCheckbox;
|
||||||
const handleCardClick = (e?: React.MouseEvent) => {
|
const handleCardClick = (e?: React.MouseEvent) => {
|
||||||
if (e && (e.ctrlKey || e.metaKey)) {
|
if (e && (e.ctrlKey || e.metaKey)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -862,24 +868,40 @@ export function DocumentsTableShell({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="relative z-10 flex items-center gap-3 pointer-events-none">
|
<div className="relative z-10 flex items-center gap-3 pointer-events-none">
|
||||||
<span className="pointer-events-auto">
|
<span className="pointer-events-auto shrink-0">
|
||||||
|
{showCheckbox ? (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={isMentioned}
|
checked={isMentioned}
|
||||||
onCheckedChange={() => handleCardClick()}
|
onCheckedChange={() => handleCardClick()}
|
||||||
disabled={!canInteract}
|
|
||||||
aria-label={isMentioned ? "Remove from chat" : "Add to chat"}
|
aria-label={isMentioned ? "Remove from chat" : "Add to chat"}
|
||||||
className={`border-foreground data-[state=checked]:bg-primary data-[state=checked]:border-primary shrink-0 ${!canInteract ? "opacity-40 cursor-not-allowed" : ""}`}
|
className="border-foreground data-[state=checked]:bg-primary data-[state=checked]:border-primary"
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<StatusIndicator status={doc.status} />
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<span className="truncate block text-sm text-foreground">{doc.title}</span>
|
<span className="truncate block text-sm text-foreground">{doc.title}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<span className="flex items-center justify-center shrink-0">
|
||||||
<span className="flex items-center justify-center">
|
|
||||||
{getDocumentTypeIcon(doc.document_type, "h-4 w-4")}
|
{getDocumentTypeIcon(doc.document_type, "h-4 w-4")}
|
||||||
</span>
|
</span>
|
||||||
<StatusIndicator status={doc.status} />
|
{(() => {
|
||||||
</div>
|
const member = doc.created_by_id ? memberMap.get(doc.created_by_id) : null;
|
||||||
|
const displayName =
|
||||||
|
member?.name || doc.created_by_name || doc.created_by_email || "Unknown";
|
||||||
|
const avatarUrl = member?.avatarUrl;
|
||||||
|
return (
|
||||||
|
<span className="flex items-center justify-center shrink-0">
|
||||||
|
<Avatar className="size-5">
|
||||||
|
{avatarUrl && <AvatarImage src={avatarUrl} alt={displayName} />}
|
||||||
|
<AvatarFallback className="text-[9px]">
|
||||||
|
{getInitials(displayName)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</MobileCardWrapper>
|
</MobileCardWrapper>
|
||||||
|
|
@ -985,7 +1007,7 @@ export function DocumentsTableShell({
|
||||||
</DrawerHeader>
|
</DrawerHeader>
|
||||||
<div className="px-4 pb-6 flex flex-col gap-2">
|
<div className="px-4 pb-6 flex flex-col gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="secondary"
|
||||||
className="justify-start gap-2"
|
className="justify-start gap-2"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (mobileActionDoc) handleViewDocument(mobileActionDoc);
|
if (mobileActionDoc) handleViewDocument(mobileActionDoc);
|
||||||
|
|
@ -1000,7 +1022,7 @@ export function DocumentsTableShell({
|
||||||
mobileActionDoc.document_type as (typeof EDITABLE_DOCUMENT_TYPES)[number]
|
mobileActionDoc.document_type as (typeof EDITABLE_DOCUMENT_TYPES)[number]
|
||||||
) && (
|
) && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="secondary"
|
||||||
className="justify-start gap-2"
|
className="justify-start gap-2"
|
||||||
disabled={
|
disabled={
|
||||||
mobileActionDoc.status?.state === "pending" ||
|
mobileActionDoc.status?.state === "pending" ||
|
||||||
|
|
@ -1010,8 +1032,11 @@ export function DocumentsTableShell({
|
||||||
}
|
}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (mobileActionDoc) {
|
if (mobileActionDoc) {
|
||||||
onEditNavigate?.();
|
openEditor({
|
||||||
router.push(`/dashboard/${searchSpaceId}/editor/${mobileActionDoc.id}`);
|
documentId: mobileActionDoc.id,
|
||||||
|
searchSpaceId: Number(searchSpaceId),
|
||||||
|
title: mobileActionDoc.title,
|
||||||
|
});
|
||||||
setMobileActionDoc(null);
|
setMobileActionDoc(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useSetAtom } from "jotai";
|
||||||
import { MoreHorizontal, PenLine, Trash2 } from "lucide-react";
|
import { MoreHorizontal, PenLine, Trash2 } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
|
|
@ -40,7 +41,7 @@ export function RowActions({
|
||||||
}) {
|
}) {
|
||||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
const router = useRouter();
|
const openEditorPanel = useSetAtom(openEditorPanelAtom);
|
||||||
|
|
||||||
const isEditable = EDITABLE_DOCUMENT_TYPES.includes(
|
const isEditable = EDITABLE_DOCUMENT_TYPES.includes(
|
||||||
document.document_type as (typeof EDITABLE_DOCUMENT_TYPES)[number]
|
document.document_type as (typeof EDITABLE_DOCUMENT_TYPES)[number]
|
||||||
|
|
@ -87,7 +88,11 @@ export function RowActions({
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEdit = () => {
|
const handleEdit = () => {
|
||||||
router.push(`/dashboard/${searchSpaceId}/editor/${document.id}`);
|
openEditorPanel({
|
||||||
|
documentId: document.id,
|
||||||
|
searchSpaceId: Number(searchSpaceId),
|
||||||
|
title: document.title,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,505 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { AlertCircle, ArrowLeft, FileText } from "lucide-react";
|
|
||||||
import { motion } from "motion/react";
|
|
||||||
import dynamic from "next/dynamic";
|
|
||||||
import { useParams, useRouter } from "next/navigation";
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { hasUnsavedEditorChangesAtom, pendingEditorNavigationAtom } from "@/atoms/editor/ui.atoms";
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from "@/components/ui/alert-dialog";
|
|
||||||
import { Button, buttonVariants } from "@/components/ui/button";
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
|
||||||
import { notesApiService } from "@/lib/apis/notes-api.service";
|
|
||||||
import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils";
|
|
||||||
|
|
||||||
// Dynamically import PlateEditor (uses 'use client' internally)
|
|
||||||
const PlateEditor = dynamic(
|
|
||||||
() => import("@/components/editor/plate-editor").then((mod) => ({ default: mod.PlateEditor })),
|
|
||||||
{
|
|
||||||
ssr: false,
|
|
||||||
loading: () => (
|
|
||||||
<div className="mx-auto w-full max-w-[900px] px-6 md:px-12 pt-10 space-y-4">
|
|
||||||
<Skeleton className="h-8 w-3/5 rounded" />
|
|
||||||
<div className="space-y-3 pt-4">
|
|
||||||
<Skeleton className="h-4 w-full rounded" />
|
|
||||||
<Skeleton className="h-4 w-full rounded" />
|
|
||||||
<Skeleton className="h-4 w-4/5 rounded" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3 pt-3">
|
|
||||||
<Skeleton className="h-4 w-full rounded" />
|
|
||||||
<Skeleton className="h-4 w-5/6 rounded" />
|
|
||||||
<Skeleton className="h-4 w-3/4 rounded" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3 pt-3">
|
|
||||||
<Skeleton className="h-4 w-full rounded" />
|
|
||||||
<Skeleton className="h-4 w-2/3 rounded" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
interface EditorContent {
|
|
||||||
document_id: number;
|
|
||||||
title: string;
|
|
||||||
document_type?: string;
|
|
||||||
source_markdown: string;
|
|
||||||
updated_at: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Extract title from markdown: first # heading, or first non-empty line. */
|
|
||||||
function extractTitleFromMarkdown(markdown: string | null | undefined): string {
|
|
||||||
if (!markdown) return "Untitled";
|
|
||||||
for (const line of markdown.split("\n")) {
|
|
||||||
const trimmed = line.trim();
|
|
||||||
if (trimmed.startsWith("# ")) return trimmed.slice(2).trim() || "Untitled";
|
|
||||||
if (trimmed) return trimmed.slice(0, 100);
|
|
||||||
}
|
|
||||||
return "Untitled";
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function EditorPage() {
|
|
||||||
const params = useParams();
|
|
||||||
const router = useRouter();
|
|
||||||
const documentId = params.documentId as string;
|
|
||||||
const searchSpaceId = Number(params.search_space_id);
|
|
||||||
const isNewNote = documentId === "new";
|
|
||||||
|
|
||||||
const [document, setDocument] = useState<EditorContent | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
|
||||||
const [showUnsavedDialog, setShowUnsavedDialog] = useState(false);
|
|
||||||
const [editorTitle, setEditorTitle] = useState<string>("Untitled");
|
|
||||||
|
|
||||||
// Store the latest markdown from the editor
|
|
||||||
const markdownRef = useRef<string>("");
|
|
||||||
const initialLoadDone = useRef(false);
|
|
||||||
|
|
||||||
// Global state for cross-component communication
|
|
||||||
const [, setGlobalHasUnsavedChanges] = useAtom(hasUnsavedEditorChangesAtom);
|
|
||||||
const [pendingNavigation, setPendingNavigation] = useAtom(pendingEditorNavigationAtom);
|
|
||||||
|
|
||||||
// Sync local unsaved changes state with global atom
|
|
||||||
useEffect(() => {
|
|
||||||
setGlobalHasUnsavedChanges(hasUnsavedChanges);
|
|
||||||
}, [hasUnsavedChanges, setGlobalHasUnsavedChanges]);
|
|
||||||
|
|
||||||
// Cleanup global state when component unmounts
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
setGlobalHasUnsavedChanges(false);
|
|
||||||
setPendingNavigation(null);
|
|
||||||
};
|
|
||||||
}, [setGlobalHasUnsavedChanges, setPendingNavigation]);
|
|
||||||
|
|
||||||
// Handle pending navigation from sidebar
|
|
||||||
useEffect(() => {
|
|
||||||
if (pendingNavigation) {
|
|
||||||
if (hasUnsavedChanges) {
|
|
||||||
setShowUnsavedDialog(true);
|
|
||||||
} else {
|
|
||||||
router.push(pendingNavigation);
|
|
||||||
setPendingNavigation(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [pendingNavigation, hasUnsavedChanges, router, setPendingNavigation]);
|
|
||||||
|
|
||||||
// Reset state and fetch document content when documentId changes
|
|
||||||
useEffect(() => {
|
|
||||||
setDocument(null);
|
|
||||||
setError(null);
|
|
||||||
setHasUnsavedChanges(false);
|
|
||||||
setLoading(true);
|
|
||||||
initialLoadDone.current = false;
|
|
||||||
|
|
||||||
async function fetchDocument() {
|
|
||||||
if (isNewNote) {
|
|
||||||
markdownRef.current = "";
|
|
||||||
setEditorTitle("Untitled");
|
|
||||||
setDocument({
|
|
||||||
document_id: 0,
|
|
||||||
title: "Untitled",
|
|
||||||
document_type: "NOTE",
|
|
||||||
source_markdown: "",
|
|
||||||
updated_at: null,
|
|
||||||
});
|
|
||||||
setLoading(false);
|
|
||||||
initialLoadDone.current = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = getBearerToken();
|
|
||||||
if (!token) {
|
|
||||||
redirectToLogin();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await authenticatedFetch(
|
|
||||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${params.search_space_id}/documents/${documentId}/editor-content`,
|
|
||||||
{ method: "GET" }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response
|
|
||||||
.json()
|
|
||||||
.catch(() => ({ detail: "Failed to fetch document" }));
|
|
||||||
throw new Error(errorData.detail || "Failed to fetch document");
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.source_markdown === undefined || data.source_markdown === null) {
|
|
||||||
setError(
|
|
||||||
"This document does not have editable content. Please re-upload to enable editing."
|
|
||||||
);
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
markdownRef.current = data.source_markdown;
|
|
||||||
setEditorTitle(extractTitleFromMarkdown(data.source_markdown));
|
|
||||||
setDocument(data);
|
|
||||||
setError(null);
|
|
||||||
initialLoadDone.current = true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching document:", error);
|
|
||||||
setError(
|
|
||||||
error instanceof Error ? error.message : "Failed to fetch document. Please try again."
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (documentId) {
|
|
||||||
fetchDocument();
|
|
||||||
}
|
|
||||||
}, [documentId, params.search_space_id, isNewNote]);
|
|
||||||
|
|
||||||
const isNote = isNewNote || document?.document_type === "NOTE";
|
|
||||||
|
|
||||||
const displayTitle = useMemo(() => {
|
|
||||||
if (isNote) return editorTitle;
|
|
||||||
return document?.title || "Untitled";
|
|
||||||
}, [isNote, document?.title, editorTitle]);
|
|
||||||
|
|
||||||
// Handle markdown changes from the Plate editor
|
|
||||||
const handleMarkdownChange = useCallback((md: string) => {
|
|
||||||
markdownRef.current = md;
|
|
||||||
if (initialLoadDone.current) {
|
|
||||||
setHasUnsavedChanges(true);
|
|
||||||
setEditorTitle(extractTitleFromMarkdown(md));
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Save handler
|
|
||||||
const handleSave = useCallback(async () => {
|
|
||||||
const token = getBearerToken();
|
|
||||||
if (!token) {
|
|
||||||
toast.error("Please login to save");
|
|
||||||
redirectToLogin();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSaving(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const currentMarkdown = markdownRef.current;
|
|
||||||
|
|
||||||
if (isNewNote) {
|
|
||||||
const title = extractTitleFromMarkdown(currentMarkdown);
|
|
||||||
|
|
||||||
// Create the note
|
|
||||||
const note = await notesApiService.createNote({
|
|
||||||
search_space_id: searchSpaceId,
|
|
||||||
title,
|
|
||||||
source_markdown: currentMarkdown || undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
// If there's content, save & trigger reindexing
|
|
||||||
if (currentMarkdown) {
|
|
||||||
const response = await authenticatedFetch(
|
|
||||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${note.id}/save`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ source_markdown: currentMarkdown }),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response
|
|
||||||
.json()
|
|
||||||
.catch(() => ({ detail: "Failed to save document" }));
|
|
||||||
throw new Error(errorData.detail || "Failed to save document");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setHasUnsavedChanges(false);
|
|
||||||
toast.success("Note created successfully! Reindexing in background...");
|
|
||||||
router.push(`/dashboard/${searchSpaceId}/new-chat`);
|
|
||||||
} else {
|
|
||||||
// Existing document — save
|
|
||||||
const response = await authenticatedFetch(
|
|
||||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${params.search_space_id}/documents/${documentId}/save`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ source_markdown: currentMarkdown }),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response
|
|
||||||
.json()
|
|
||||||
.catch(() => ({ detail: "Failed to save document" }));
|
|
||||||
throw new Error(errorData.detail || "Failed to save document");
|
|
||||||
}
|
|
||||||
|
|
||||||
setHasUnsavedChanges(false);
|
|
||||||
toast.success("Document saved! Reindexing in background...");
|
|
||||||
router.push(`/dashboard/${searchSpaceId}/new-chat`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error saving document:", error);
|
|
||||||
const errorMessage =
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: isNewNote
|
|
||||||
? "Failed to create note. Please try again."
|
|
||||||
: "Failed to save document. Please try again.";
|
|
||||||
setError(errorMessage);
|
|
||||||
toast.error(errorMessage);
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
}, [isNewNote, searchSpaceId, documentId, params.search_space_id, router]);
|
|
||||||
|
|
||||||
const handleBack = () => {
|
|
||||||
if (hasUnsavedChanges) {
|
|
||||||
setShowUnsavedDialog(true);
|
|
||||||
} else {
|
|
||||||
router.push(`/dashboard/${searchSpaceId}/new-chat`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConfirmLeave = () => {
|
|
||||||
setShowUnsavedDialog(false);
|
|
||||||
setGlobalHasUnsavedChanges(false);
|
|
||||||
setHasUnsavedChanges(false);
|
|
||||||
|
|
||||||
if (pendingNavigation) {
|
|
||||||
router.push(pendingNavigation);
|
|
||||||
setPendingNavigation(null);
|
|
||||||
} else {
|
|
||||||
router.push(`/dashboard/${searchSpaceId}/new-chat`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveAndLeave = async () => {
|
|
||||||
setShowUnsavedDialog(false);
|
|
||||||
setPendingNavigation(null);
|
|
||||||
await handleSave();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCancelLeave = () => {
|
|
||||||
setShowUnsavedDialog(false);
|
|
||||||
setPendingNavigation(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-screen w-full overflow-hidden">
|
|
||||||
{/* Top bar skeleton — real back button & file icon, skeleton title */}
|
|
||||||
<div className="flex h-14 md:h-16 shrink-0 items-center border-b bg-background pl-1.5 pr-3 md:pl-3 md:pr-6">
|
|
||||||
<div className="flex items-center gap-1.5 md:gap-2 flex-1 min-w-0">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/documents`)}
|
|
||||||
className="h-7 w-7 shrink-0 p-0"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
<span className="sr-only">Back</span>
|
|
||||||
</Button>
|
|
||||||
<FileText className="h-4 w-4 md:h-5 md:w-5 text-muted-foreground shrink-0" />
|
|
||||||
<Skeleton className="h-5 w-40 rounded" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Fixed toolbar placeholder — matches real toolbar styling */}
|
|
||||||
<div className="sticky top-0 left-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 h-10" />
|
|
||||||
|
|
||||||
{/* Content area skeleton — mimics document text lines */}
|
|
||||||
<div className="flex-1 min-h-0 overflow-hidden">
|
|
||||||
<div className="mx-auto w-full max-w-[900px] px-6 md:px-12 pt-10 space-y-4">
|
|
||||||
{/* Title-like line */}
|
|
||||||
<Skeleton className="h-8 w-3/5 rounded" />
|
|
||||||
{/* Paragraph lines */}
|
|
||||||
<div className="space-y-3 pt-4">
|
|
||||||
<Skeleton className="h-4 w-full rounded" />
|
|
||||||
<Skeleton className="h-4 w-full rounded" />
|
|
||||||
<Skeleton className="h-4 w-4/5 rounded" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3 pt-3">
|
|
||||||
<Skeleton className="h-4 w-full rounded" />
|
|
||||||
<Skeleton className="h-4 w-5/6 rounded" />
|
|
||||||
<Skeleton className="h-4 w-3/4 rounded" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3 pt-3">
|
|
||||||
<Skeleton className="h-4 w-full rounded" />
|
|
||||||
<Skeleton className="h-4 w-2/3 rounded" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error && !document) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center min-h-[400px] p-6">
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
className="w-full max-w-md"
|
|
||||||
>
|
|
||||||
<Card className="border-destructive/50">
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<AlertCircle className="h-5 w-5 text-destructive" />
|
|
||||||
<CardTitle className="text-destructive">Error</CardTitle>
|
|
||||||
</div>
|
|
||||||
<CardDescription>{error}</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Button
|
|
||||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/documents`)}
|
|
||||||
variant="outline"
|
|
||||||
className="gap-2"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!document && !isNewNote) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center min-h-[400px] p-6">
|
|
||||||
<Card className="w-full max-w-md">
|
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
|
||||||
<FileText className="h-12 w-12 text-muted-foreground mb-4" />
|
|
||||||
<p className="text-muted-foreground">Document not found</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
className="flex flex-col h-screen w-full overflow-hidden"
|
|
||||||
>
|
|
||||||
{/* Toolbar */}
|
|
||||||
<div className="flex h-14 md:h-16 shrink-0 items-center border-b bg-background pl-1.5 pr-3 md:pl-3 md:pr-6">
|
|
||||||
<div className="flex items-center gap-1.5 md:gap-2 flex-1 min-w-0">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={handleBack}
|
|
||||||
disabled={saving}
|
|
||||||
className="h-7 w-7 shrink-0 p-0"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
<span className="sr-only">Back</span>
|
|
||||||
</Button>
|
|
||||||
<FileText className="h-4 w-4 md:h-5 md:w-5 text-muted-foreground shrink-0" />
|
|
||||||
<div className="flex flex-col min-w-0">
|
|
||||||
<h1 className="text-base md:text-lg font-semibold truncate">{displayTitle}</h1>
|
|
||||||
{hasUnsavedChanges && (
|
|
||||||
<p className="text-[10px] md:text-xs text-muted-foreground">Unsaved changes</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Editor Container */}
|
|
||||||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden relative">
|
|
||||||
{error && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: -10 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
className="px-3 md:px-6 pt-3 md:pt-6"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 p-4 rounded-lg border border-destructive/50 bg-destructive/10 text-destructive max-w-4xl mx-auto">
|
|
||||||
<AlertCircle className="h-5 w-5 shrink-0" />
|
|
||||||
<p className="text-sm">{error}</p>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
<div className="flex-1 min-h-0">
|
|
||||||
<PlateEditor
|
|
||||||
key={documentId}
|
|
||||||
preset="full"
|
|
||||||
markdown={document?.source_markdown ?? ""}
|
|
||||||
onMarkdownChange={handleMarkdownChange}
|
|
||||||
onSave={handleSave}
|
|
||||||
hasUnsavedChanges={hasUnsavedChanges}
|
|
||||||
isSaving={saving}
|
|
||||||
defaultEditing={true}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Unsaved Changes Dialog */}
|
|
||||||
<AlertDialog
|
|
||||||
open={showUnsavedDialog}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) handleCancelLeave();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Unsaved Changes</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
You have unsaved changes. Are you sure you want to leave?
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel onClick={handleCancelLeave}>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
onClick={handleConfirmLeave}
|
|
||||||
className={buttonVariants({ variant: "secondary" })}
|
|
||||||
>
|
|
||||||
Leave without saving
|
|
||||||
</AlertDialogAction>
|
|
||||||
<AlertDialogAction onClick={handleSaveAndLeave}>Save</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,80 +1,9 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { IconCalendar, IconMailFilled } from "@tabler/icons-react";
|
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { Check, ExternalLink, Gift, Mail, Star, Zap } from "lucide-react";
|
|
||||||
import { motion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
import Link from "next/link";
|
import { MorePagesContent } from "@/components/settings/more-pages-content";
|
||||||
import { useEffect } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Separator } from "@/components/ui/separator";
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
|
||||||
import type { IncentiveTaskInfo } from "@/contracts/types/incentive-tasks.types";
|
|
||||||
import { incentiveTasksApiService } from "@/lib/apis/incentive-tasks-api.service";
|
|
||||||
import {
|
|
||||||
trackIncentiveContactOpened,
|
|
||||||
trackIncentivePageViewed,
|
|
||||||
trackIncentiveTaskClicked,
|
|
||||||
trackIncentiveTaskCompleted,
|
|
||||||
} from "@/lib/posthog/events";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
export default function MorePagesPage() {
|
export default function MorePagesPage() {
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
trackIncentivePageViewed();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
|
||||||
queryKey: ["incentive-tasks"],
|
|
||||||
queryFn: () => incentiveTasksApiService.getTasks(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const completeMutation = useMutation({
|
|
||||||
mutationFn: incentiveTasksApiService.completeTask,
|
|
||||||
onSuccess: (response, taskType) => {
|
|
||||||
if (response.success) {
|
|
||||||
toast.success(response.message);
|
|
||||||
const task = data?.tasks.find((t) => t.task_type === taskType);
|
|
||||||
if (task) {
|
|
||||||
trackIncentiveTaskCompleted(taskType, task.pages_reward);
|
|
||||||
}
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["incentive-tasks"] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["user"] });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast.error("Failed to complete task. Please try again.");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleTaskClick = (task: IncentiveTaskInfo) => {
|
|
||||||
if (!task.completed) {
|
|
||||||
trackIncentiveTaskClicked(task.task_type);
|
|
||||||
completeMutation.mutate(task.task_type);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[calc(100vh-64px)] select-none items-center justify-center px-4 py-8">
|
<div className="flex min-h-[calc(100vh-64px)] select-none items-center justify-center px-4 py-8">
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|
@ -83,140 +12,7 @@ export default function MorePagesPage() {
|
||||||
transition={{ duration: 0.3 }}
|
transition={{ duration: 0.3 }}
|
||||||
className="w-full max-w-md space-y-6"
|
className="w-full max-w-md space-y-6"
|
||||||
>
|
>
|
||||||
{/* Header */}
|
<MorePagesContent />
|
||||||
<div className="text-center">
|
|
||||||
<Gift className="mx-auto mb-3 h-8 w-8 text-primary" />
|
|
||||||
<h2 className="text-xl font-bold tracking-tight">Get More Pages</h2>
|
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
|
||||||
Complete tasks to earn additional pages
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tasks */}
|
|
||||||
{isLoading ? (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex items-center gap-3 p-3">
|
|
||||||
<Skeleton className="h-9 w-9 rounded-full" />
|
|
||||||
<div className="flex-1 space-y-2">
|
|
||||||
<Skeleton className="h-4 w-3/4" />
|
|
||||||
<Skeleton className="h-3 w-1/4" />
|
|
||||||
</div>
|
|
||||||
<Skeleton className="h-8 w-16" />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{data?.tasks.map((task) => (
|
|
||||||
<Card
|
|
||||||
key={task.task_type}
|
|
||||||
className={cn("transition-colors", task.completed && "bg-muted/50")}
|
|
||||||
>
|
|
||||||
<CardContent className="flex items-center gap-3 p-3">
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full",
|
|
||||||
task.completed ? "bg-primary text-primary-foreground" : "bg-muted"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{task.completed ? <Check className="h-4 w-4" /> : <Star className="h-4 w-4" />}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p
|
|
||||||
className={cn(
|
|
||||||
"text-sm font-medium",
|
|
||||||
task.completed && "text-muted-foreground line-through"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{task.title}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground">+{task.pages_reward} pages</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant={task.completed ? "ghost" : "outline"}
|
|
||||||
size="sm"
|
|
||||||
disabled={task.completed || completeMutation.isPending}
|
|
||||||
onClick={() => handleTaskClick(task)}
|
|
||||||
asChild={!task.completed}
|
|
||||||
>
|
|
||||||
{task.completed ? (
|
|
||||||
<span>Done</span>
|
|
||||||
) : (
|
|
||||||
<a
|
|
||||||
href={task.action_url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="gap-1"
|
|
||||||
>
|
|
||||||
{completeMutation.isPending ? (
|
|
||||||
<Spinner size="xs" />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
Go
|
|
||||||
<ExternalLink className="h-3 w-3" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* PRO Upgrade */}
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<Card className="overflow-hidden border-emerald-500/20">
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Zap className="h-4 w-4 text-emerald-500" />
|
|
||||||
<CardTitle className="text-base">Upgrade to PRO</CardTitle>
|
|
||||||
<Badge className="bg-emerald-600 text-white border-transparent hover:bg-emerald-600">
|
|
||||||
FREE
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<CardDescription>
|
|
||||||
For a limited time, get{" "}
|
|
||||||
<span className="font-semibold text-foreground">6,000 additional pages</span> at no
|
|
||||||
cost. Contact us and we'll upgrade your account instantly.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardFooter className="pt-2">
|
|
||||||
<Dialog onOpenChange={(open) => open && trackIncentiveContactOpened()}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button className="w-full bg-emerald-600 text-white hover:bg-emerald-700">
|
|
||||||
<Mail className="h-4 w-4" />
|
|
||||||
Contact Us to Upgrade
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="select-none sm:max-w-sm">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Get in Touch</DialogTitle>
|
|
||||||
<DialogDescription>Pick the option that works best for you.</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Button asChild>
|
|
||||||
<Link
|
|
||||||
href="https://cal.com/mod-rohan"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<IconCalendar className="h-4 w-4" />
|
|
||||||
Schedule a Meeting
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" asChild>
|
|
||||||
<Link href="mailto:rohan@surfsense.com">
|
|
||||||
<IconMailFilled className="h-4 w-4" />
|
|
||||||
rohan@surfsense.com
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -30,9 +30,11 @@ import {
|
||||||
// extractWriteTodosFromContent,
|
// extractWriteTodosFromContent,
|
||||||
} from "@/atoms/chat/plan-state.atom";
|
} from "@/atoms/chat/plan-state.atom";
|
||||||
import { closeReportPanelAtom } from "@/atoms/chat/report-panel.atom";
|
import { closeReportPanelAtom } from "@/atoms/chat/report-panel.atom";
|
||||||
|
import { closeEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
|
||||||
import { membersAtom } from "@/atoms/members/members-query.atoms";
|
import { membersAtom } from "@/atoms/members/members-query.atoms";
|
||||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||||
import { Thread } from "@/components/assistant-ui/thread";
|
import { Thread } from "@/components/assistant-ui/thread";
|
||||||
|
import { MobileEditorPanel } from "@/components/editor-panel/editor-panel";
|
||||||
import { MobileReportPanel } from "@/components/report-panel/report-panel";
|
import { MobileReportPanel } from "@/components/report-panel/report-panel";
|
||||||
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
|
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
|
||||||
import { DisplayImageToolUI } from "@/components/tool-ui/display-image";
|
import { DisplayImageToolUI } from "@/components/tool-ui/display-image";
|
||||||
|
|
@ -195,6 +197,7 @@ export default function NewChatPage() {
|
||||||
const setTargetCommentId = useSetAtom(setTargetCommentIdAtom);
|
const setTargetCommentId = useSetAtom(setTargetCommentIdAtom);
|
||||||
const clearTargetCommentId = useSetAtom(clearTargetCommentIdAtom);
|
const clearTargetCommentId = useSetAtom(clearTargetCommentIdAtom);
|
||||||
const closeReportPanel = useSetAtom(closeReportPanelAtom);
|
const closeReportPanel = useSetAtom(closeReportPanelAtom);
|
||||||
|
const closeEditorPanel = useSetAtom(closeEditorPanelAtom);
|
||||||
|
|
||||||
// Get current user for author info in shared chats
|
// Get current user for author info in shared chats
|
||||||
const { data: currentUser } = useAtomValue(currentUserAtom);
|
const { data: currentUser } = useAtomValue(currentUserAtom);
|
||||||
|
|
@ -286,6 +289,7 @@ export default function NewChatPage() {
|
||||||
setMessageDocumentsMap({});
|
setMessageDocumentsMap({});
|
||||||
clearPlanOwnerRegistry();
|
clearPlanOwnerRegistry();
|
||||||
closeReportPanel();
|
closeReportPanel();
|
||||||
|
closeEditorPanel();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (urlChatId > 0) {
|
if (urlChatId > 0) {
|
||||||
|
|
@ -351,6 +355,7 @@ export default function NewChatPage() {
|
||||||
setMentionedDocuments,
|
setMentionedDocuments,
|
||||||
setSidebarDocuments,
|
setSidebarDocuments,
|
||||||
closeReportPanel,
|
closeReportPanel,
|
||||||
|
closeEditorPanel,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Initialize on mount
|
// Initialize on mount
|
||||||
|
|
@ -1596,7 +1601,7 @@ export default function NewChatPage() {
|
||||||
// Show loading state only when loading an existing thread
|
// Show loading state only when loading an existing thread
|
||||||
if (isInitializing) {
|
if (isInitializing) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[calc(100dvh-64px)] flex-col bg-background px-4">
|
<div className="flex h-[calc(100dvh-64px)] flex-col bg-main-panel px-4">
|
||||||
<div className="mx-auto w-full max-w-[44rem] flex flex-1 flex-col gap-6 py-8">
|
<div className="mx-auto w-full max-w-[44rem] flex flex-1 flex-col gap-6 py-8">
|
||||||
{/* User message */}
|
{/* User message */}
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
|
|
@ -1624,7 +1629,7 @@ export default function NewChatPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Input bar */}
|
{/* Input bar */}
|
||||||
<div className="sticky bottom-0 pb-6 bg-background">
|
<div className="sticky bottom-0 pb-6 bg-main-panel">
|
||||||
<div className="mx-auto w-full max-w-[44rem]">
|
<div className="mx-auto w-full max-w-[44rem]">
|
||||||
<Skeleton className="h-24 w-full rounded-2xl" />
|
<Skeleton className="h-24 w-full rounded-2xl" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1677,6 +1682,7 @@ export default function NewChatPage() {
|
||||||
<Thread messageThinkingSteps={messageThinkingSteps} />
|
<Thread messageThinkingSteps={messageThinkingSteps} />
|
||||||
</div>
|
</div>
|
||||||
<MobileReportPanel />
|
<MobileReportPanel />
|
||||||
|
<MobileEditorPanel />
|
||||||
</div>
|
</div>
|
||||||
</AssistantRuntimeProvider>
|
</AssistantRuntimeProvider>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue, useSetAtom } from "jotai";
|
||||||
import { motion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
globalNewLLMConfigsAtom,
|
globalNewLLMConfigsAtom,
|
||||||
llmPreferencesAtom,
|
llmPreferencesAtom,
|
||||||
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
|
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
|
||||||
|
import { searchSpaceSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
|
||||||
import { Logo } from "@/components/Logo";
|
import { Logo } from "@/components/Logo";
|
||||||
import { LLMConfigForm, type LLMConfigFormData } from "@/components/shared/llm-config-form";
|
import { LLMConfigForm, type LLMConfigFormData } from "@/components/shared/llm-config-form";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
@ -23,6 +24,7 @@ export default function OnboardPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const searchSpaceId = Number(params.search_space_id);
|
const searchSpaceId = Number(params.search_space_id);
|
||||||
|
const setSearchSpaceSettingsDialog = useSetAtom(searchSpaceSettingsDialogAtom);
|
||||||
|
|
||||||
// Queries
|
// Queries
|
||||||
const {
|
const {
|
||||||
|
|
@ -259,7 +261,7 @@ export default function OnboardPage() {
|
||||||
You can add more configurations and customize settings anytime in{" "}
|
You can add more configurations and customize settings anytime in{" "}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/settings?tab=general`)}
|
onClick={() => setSearchSpaceSettingsDialog({ open: true, initialTab: "general" })}
|
||||||
className="text-violet-500 hover:underline"
|
className="text-violet-500 hover:underline"
|
||||||
>
|
>
|
||||||
Settings
|
Settings
|
||||||
|
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
import type React from "react";
|
|
||||||
|
|
||||||
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
|
|
@ -1,113 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { Bot, Brain, FileText, Globe, ImageIcon, MessageSquare, Shield } from "lucide-react";
|
|
||||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { useCallback, useEffect } from "react";
|
|
||||||
import { PublicChatSnapshotsManager } from "@/components/public-chat-snapshots/public-chat-snapshots-manager";
|
|
||||||
import { GeneralSettingsManager } from "@/components/settings/general-settings-manager";
|
|
||||||
import { ImageModelManager } from "@/components/settings/image-model-manager";
|
|
||||||
import { LLMRoleManager } from "@/components/settings/llm-role-manager";
|
|
||||||
import { ModelConfigManager } from "@/components/settings/model-config-manager";
|
|
||||||
import { PromptConfigManager } from "@/components/settings/prompt-config-manager";
|
|
||||||
import { RolesManager } from "@/components/settings/roles-manager";
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/animated-tabs";
|
|
||||||
import { trackSettingsViewed } from "@/lib/posthog/events";
|
|
||||||
|
|
||||||
const VALID_TABS = [
|
|
||||||
"general",
|
|
||||||
"models",
|
|
||||||
"roles",
|
|
||||||
"image-models",
|
|
||||||
"prompts",
|
|
||||||
"public-links",
|
|
||||||
"team-roles",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const DEFAULT_TAB = "general";
|
|
||||||
|
|
||||||
export default function SettingsPage() {
|
|
||||||
const t = useTranslations("searchSpaceSettings");
|
|
||||||
const router = useRouter();
|
|
||||||
const params = useParams();
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const searchSpaceId = Number(params.search_space_id);
|
|
||||||
|
|
||||||
const tabParam = searchParams.get("tab") ?? "";
|
|
||||||
const activeTab = VALID_TABS.includes(tabParam as (typeof VALID_TABS)[number])
|
|
||||||
? tabParam
|
|
||||||
: DEFAULT_TAB;
|
|
||||||
|
|
||||||
const handleTabChange = useCallback(
|
|
||||||
(value: string) => {
|
|
||||||
const p = new URLSearchParams(searchParams.toString());
|
|
||||||
p.set("tab", value);
|
|
||||||
router.replace(`?${p.toString()}`, { scroll: false });
|
|
||||||
},
|
|
||||||
[router, searchParams]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
trackSettingsViewed(searchSpaceId, activeTab);
|
|
||||||
}, [searchSpaceId, activeTab]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full overflow-y-auto">
|
|
||||||
<div className="mx-auto w-full max-w-4xl px-4 py-10">
|
|
||||||
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
|
|
||||||
<TabsList showBottomBorder>
|
|
||||||
<TabsTrigger value="general">
|
|
||||||
<FileText className="mr-2 h-4 w-4" />
|
|
||||||
{t("nav_general")}
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="models">
|
|
||||||
<Bot className="mr-2 h-4 w-4" />
|
|
||||||
{t("nav_agent_configs")}
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="roles">
|
|
||||||
<Brain className="mr-2 h-4 w-4" />
|
|
||||||
{t("nav_role_assignments")}
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="image-models">
|
|
||||||
<ImageIcon className="mr-2 h-4 w-4" />
|
|
||||||
{t("nav_image_models")}
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="team-roles">
|
|
||||||
<Shield className="mr-2 h-4 w-4" />
|
|
||||||
{t("nav_team_roles")}
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="prompts">
|
|
||||||
<MessageSquare className="mr-2 h-4 w-4" />
|
|
||||||
{t("nav_system_instructions")}
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="public-links">
|
|
||||||
<Globe className="mr-2 h-4 w-4" />
|
|
||||||
{t("nav_public_links")}
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
<TabsContent value="general" className="mt-6">
|
|
||||||
<GeneralSettingsManager searchSpaceId={searchSpaceId} />
|
|
||||||
</TabsContent>
|
|
||||||
<TabsContent value="models" className="mt-6">
|
|
||||||
<ModelConfigManager searchSpaceId={searchSpaceId} />
|
|
||||||
</TabsContent>
|
|
||||||
<TabsContent value="roles" className="mt-6">
|
|
||||||
<LLMRoleManager searchSpaceId={searchSpaceId} />
|
|
||||||
</TabsContent>
|
|
||||||
<TabsContent value="image-models" className="mt-6">
|
|
||||||
<ImageModelManager searchSpaceId={searchSpaceId} />
|
|
||||||
</TabsContent>
|
|
||||||
<TabsContent value="prompts" className="mt-6">
|
|
||||||
<PromptConfigManager searchSpaceId={searchSpaceId} />
|
|
||||||
</TabsContent>
|
|
||||||
<TabsContent value="public-links" className="mt-6">
|
|
||||||
<PublicChatSnapshotsManager searchSpaceId={searchSpaceId} />
|
|
||||||
</TabsContent>
|
|
||||||
<TabsContent value="team-roles" className="mt-6">
|
|
||||||
<RolesManager searchSpaceId={searchSpaceId} />
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue, useSetAtom } from "jotai";
|
||||||
import {
|
import {
|
||||||
Calendar,
|
Calendar,
|
||||||
Check,
|
Check,
|
||||||
|
|
@ -20,9 +20,7 @@ import {
|
||||||
UserPlus,
|
UserPlus,
|
||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { motion } from "motion/react";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import Image from "next/image";
|
|
||||||
import { useParams, 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 {
|
||||||
|
|
@ -34,6 +32,7 @@ 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,
|
||||||
|
|
@ -101,27 +100,6 @@ import { trackSearchSpaceInviteSent, trackSearchSpaceUsersViewed } from "@/lib/p
|
||||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const AVATAR_COLORS = [
|
|
||||||
"bg-amber-600",
|
|
||||||
"bg-blue-600",
|
|
||||||
"bg-emerald-600",
|
|
||||||
"bg-violet-600",
|
|
||||||
"bg-rose-600",
|
|
||||||
"bg-cyan-600",
|
|
||||||
"bg-orange-600",
|
|
||||||
"bg-teal-600",
|
|
||||||
"bg-pink-600",
|
|
||||||
"bg-indigo-600",
|
|
||||||
];
|
|
||||||
|
|
||||||
function getAvatarColor(identifier: string): string {
|
|
||||||
let hash = 0;
|
|
||||||
for (let i = 0; i < identifier.length; i++) {
|
|
||||||
hash = identifier.charCodeAt(i) + ((hash << 5) - hash);
|
|
||||||
}
|
|
||||||
return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length];
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAvatarInitials(member: Membership): string {
|
function getAvatarInitials(member: Membership): string {
|
||||||
if (member.user_display_name) {
|
if (member.user_display_name) {
|
||||||
const parts = member.user_display_name.trim().split(/\s+/);
|
const parts = member.user_display_name.trim().split(/\s+/);
|
||||||
|
|
@ -140,10 +118,11 @@ function getAvatarInitials(member: Membership): string {
|
||||||
const PAGE_SIZE = 5;
|
const PAGE_SIZE = 5;
|
||||||
const SKELETON_KEYS = Array.from({ length: PAGE_SIZE }, (_, i) => `skeleton-${i}`);
|
const SKELETON_KEYS = Array.from({ length: PAGE_SIZE }, (_, i) => `skeleton-${i}`);
|
||||||
|
|
||||||
export default function TeamManagementPage() {
|
interface TeamContentProps {
|
||||||
const params = useParams();
|
searchSpaceId: number;
|
||||||
const searchSpaceId = Number(params.search_space_id);
|
}
|
||||||
|
|
||||||
|
export function TeamContent({ searchSpaceId }: TeamContentProps) {
|
||||||
const { data: access = null, isLoading: accessLoading } = useAtomValue(myAccessAtom);
|
const { data: access = null, isLoading: accessLoading } = useAtomValue(myAccessAtom);
|
||||||
|
|
||||||
const hasPermission = useCallback(
|
const hasPermission = useCallback(
|
||||||
|
|
@ -261,13 +240,6 @@ export default function TeamManagementPage() {
|
||||||
|
|
||||||
if (accessLoading || membersLoading) {
|
if (accessLoading || membersLoading) {
|
||||||
return (
|
return (
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
transition={{ duration: 0.3 }}
|
|
||||||
className="bg-background select-none"
|
|
||||||
>
|
|
||||||
<div className="container max-w-5xl mx-auto p-4 md:p-6 lg:p-8 pt-20 md:pt-24 lg:pt-28">
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Skeleton className="h-9 w-36 rounded-md" />
|
<Skeleton className="h-9 w-36 rounded-md" />
|
||||||
|
|
@ -316,23 +288,12 @@ export default function TeamManagementPage() {
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<div className="space-y-4 md:space-y-6">
|
||||||
initial={{ opacity: 0 }}
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
transition={{ duration: 0.3 }}
|
|
||||||
className="bg-background select-none"
|
|
||||||
>
|
|
||||||
<div className="container max-w-5xl mx-auto p-4 md:p-6 lg:p-8 pt-20 md:pt-24 lg:pt-28">
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Header row: Invite button on left, member count on right */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{canInvite && (
|
{canInvite && (
|
||||||
<CreateInviteDialog
|
<CreateInviteDialog
|
||||||
roles={roles}
|
roles={roles}
|
||||||
|
|
@ -343,13 +304,11 @@ export default function TeamManagementPage() {
|
||||||
{canInvite && activeInvites.length > 0 && (
|
{canInvite && activeInvites.length > 0 && (
|
||||||
<AllInvitesDialog invites={activeInvites} onRevokeInvite={handleRevokeInvite} />
|
<AllInvitesDialog invites={activeInvites} onRevokeInvite={handleRevokeInvite} />
|
||||||
)}
|
)}
|
||||||
</div>
|
<p className="text-xs md:text-sm text-muted-foreground whitespace-nowrap">
|
||||||
<p className="hidden md:block text-sm text-muted-foreground">
|
|
||||||
{members.length} {members.length === 1 ? "member" : "members"}
|
{members.length} {members.length === 1 ? "member" : "members"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Members & Invites Table */}
|
|
||||||
<div className="rounded-lg border border-border/40 bg-background overflow-hidden">
|
<div className="rounded-lg border border-border/40 bg-background overflow-hidden">
|
||||||
<Table className="table-fixed w-full">
|
<Table className="table-fixed w-full">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
|
|
@ -375,7 +334,7 @@ export default function TeamManagementPage() {
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{owners.map((member, index) => (
|
{owners.map((member) => (
|
||||||
<MemberRow
|
<MemberRow
|
||||||
key={`member-${member.id}`}
|
key={`member-${member.id}`}
|
||||||
member={member}
|
member={member}
|
||||||
|
|
@ -384,11 +343,9 @@ export default function TeamManagementPage() {
|
||||||
canRemove={canRemove}
|
canRemove={canRemove}
|
||||||
onUpdateRole={handleUpdateMember}
|
onUpdateRole={handleUpdateMember}
|
||||||
onRemoveMember={handleRemoveMember}
|
onRemoveMember={handleRemoveMember}
|
||||||
searchSpaceId={searchSpaceId}
|
|
||||||
index={index}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{paginatedMembers.map((member, index) => (
|
{paginatedMembers.map((member) => (
|
||||||
<MemberRow
|
<MemberRow
|
||||||
key={`member-${member.id}`}
|
key={`member-${member.id}`}
|
||||||
member={member}
|
member={member}
|
||||||
|
|
@ -397,8 +354,6 @@ export default function TeamManagementPage() {
|
||||||
canRemove={canRemove}
|
canRemove={canRemove}
|
||||||
onUpdateRole={handleUpdateMember}
|
onUpdateRole={handleUpdateMember}
|
||||||
onRemoveMember={handleRemoveMember}
|
onRemoveMember={handleRemoveMember}
|
||||||
searchSpaceId={searchSpaceId}
|
|
||||||
index={owners.length + index}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{members.length === 0 && (
|
{members.length === 0 && (
|
||||||
|
|
@ -415,14 +370,8 @@ export default function TeamManagementPage() {
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pagination */}
|
|
||||||
{totalItems > PAGE_SIZE && (
|
{totalItems > PAGE_SIZE && (
|
||||||
<motion.div
|
<div className="flex items-center justify-end gap-3 py-3 px-2">
|
||||||
className="flex items-center justify-end gap-3 py-3 px-2"
|
|
||||||
initial={{ opacity: 0, y: 10 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ type: "spring", stiffness: 300, damping: 30, delay: 0.3 }}
|
|
||||||
>
|
|
||||||
<span className="text-sm text-muted-foreground tabular-nums">
|
<span className="text-sm text-muted-foreground tabular-nums">
|
||||||
{displayStart}-{displayEnd} of {totalItems}
|
{displayStart}-{displayEnd} of {totalItems}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -468,16 +417,12 @@ export default function TeamManagementPage() {
|
||||||
<ChevronLast size={18} strokeWidth={2} />
|
<ChevronLast size={18} strokeWidth={2} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Member Row ============
|
|
||||||
|
|
||||||
function MemberRow({
|
function MemberRow({
|
||||||
member,
|
member,
|
||||||
roles,
|
roles,
|
||||||
|
|
@ -485,8 +430,6 @@ function MemberRow({
|
||||||
canRemove,
|
canRemove,
|
||||||
onUpdateRole,
|
onUpdateRole,
|
||||||
onRemoveMember,
|
onRemoveMember,
|
||||||
searchSpaceId,
|
|
||||||
index,
|
|
||||||
}: {
|
}: {
|
||||||
member: Membership;
|
member: Membership;
|
||||||
roles: Role[];
|
roles: Role[];
|
||||||
|
|
@ -494,44 +437,23 @@ function MemberRow({
|
||||||
canRemove: boolean;
|
canRemove: boolean;
|
||||||
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>;
|
||||||
searchSpaceId: number;
|
|
||||||
index: number;
|
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const setSearchSpaceSettingsDialog = useSetAtom(searchSpaceSettingsDialogAtom);
|
||||||
const initials = getAvatarInitials(member);
|
const initials = getAvatarInitials(member);
|
||||||
const avatarColor = getAvatarColor(member.user_id);
|
|
||||||
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 (
|
||||||
<motion.tr
|
<TableRow className="border-b border-border/40 transition-colors hover:bg-muted/30">
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1, transition: { duration: 0.2, delay: index * 0.02 } }}
|
|
||||||
className="border-b border-border/40 transition-colors hover:bg-muted/30"
|
|
||||||
>
|
|
||||||
<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/40">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="shrink-0">
|
<Avatar className="size-10 shrink-0">
|
||||||
{member.user_avatar_url ? (
|
{member.user_avatar_url && (
|
||||||
<Image
|
<AvatarImage src={member.user_avatar_url} alt={displayName} />
|
||||||
src={member.user_avatar_url}
|
|
||||||
alt={displayName}
|
|
||||||
width={40}
|
|
||||||
height={40}
|
|
||||||
className="h-10 w-10 rounded-full object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"h-10 w-10 rounded-full flex items-center justify-center text-white font-medium text-sm",
|
|
||||||
avatarColor
|
|
||||||
)}
|
)}
|
||||||
>
|
<AvatarFallback className="text-sm">{initials}</AvatarFallback>
|
||||||
{initials}
|
</Avatar>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<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>
|
||||||
{member.user_display_name && member.user_email && (
|
{member.user_display_name && member.user_email && (
|
||||||
|
|
@ -607,7 +529,12 @@ function MemberRow({
|
||||||
)}
|
)}
|
||||||
<DropdownMenuSeparator className="dark:bg-white/5" />
|
<DropdownMenuSeparator className="dark:bg-white/5" />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/settings?tab=team-roles`)}
|
onClick={() =>
|
||||||
|
setSearchSpaceSettingsDialog({
|
||||||
|
open: true,
|
||||||
|
initialTab: "team-roles",
|
||||||
|
})
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Manage Roles
|
Manage Roles
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
@ -617,12 +544,10 @@ function MemberRow({
|
||||||
<span className="text-sm text-foreground">{roleName}</span>
|
<span className="text-sm text-foreground">{roleName}</span>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</motion.tr>
|
</TableRow>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Create Invite Dialog ============
|
|
||||||
|
|
||||||
function CreateInviteDialog({
|
function CreateInviteDialog({
|
||||||
roles,
|
roles,
|
||||||
onCreateInvite,
|
onCreateInvite,
|
||||||
|
|
@ -698,9 +623,10 @@ function CreateInviteDialog({
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="gap-2 bg-black text-white dark:bg-white dark:text-black hover:bg-black/90 dark:hover:bg-white/90"
|
size="sm"
|
||||||
|
className="gap-1.5 md:gap-2 text-xs md:text-sm bg-black text-white dark:bg-white dark:text-black hover:bg-black/90 dark:hover:bg-white/90"
|
||||||
>
|
>
|
||||||
<UserPlus className="h-4 w-4" />
|
<UserPlus className="h-3.5 w-3.5 md:h-4 md:w-4" />
|
||||||
Invite members
|
Invite members
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
|
@ -850,8 +776,6 @@ function CreateInviteDialog({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ All Invites Dialog ============
|
|
||||||
|
|
||||||
function AllInvitesDialog({
|
function AllInvitesDialog({
|
||||||
invites,
|
invites,
|
||||||
onRevokeInvite,
|
onRevokeInvite,
|
||||||
|
|
@ -872,10 +796,10 @@ function AllInvitesDialog({
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="secondary" className="gap-2">
|
<Button variant="secondary" size="sm" className="gap-1.5 md:gap-2 text-xs md:text-sm">
|
||||||
<Link2 className="h-4 w-4 rotate-315" />
|
<Link2 className="h-3.5 w-3.5 md:h-4 md:w-4 rotate-315" />
|
||||||
Active invites
|
Active invites
|
||||||
<span className="inline-flex items-center justify-center h-5 min-w-5 px-1 rounded-full bg-neutral-700 text-neutral-200 text-xs font-medium">
|
<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 text-[10px] md:text-xs font-medium">
|
||||||
{invites.length}
|
{invites.length}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Check, Copy, Info } from "lucide-react";
|
import { Check, Copy, Info } from "lucide-react";
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useCallback, useRef, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
|
|
@ -27,15 +26,7 @@ export function ApiKeyContent() {
|
||||||
}, [apiKey]);
|
}, [apiKey]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence mode="wait">
|
<div className="space-y-6 min-w-0 overflow-hidden">
|
||||||
<motion.div
|
|
||||||
key="api-key-content"
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, y: -20 }}
|
|
||||||
transition={{ duration: 0.35, ease: [0.4, 0, 0.2, 1] }}
|
|
||||||
className="space-y-6"
|
|
||||||
>
|
|
||||||
<Alert className="border-border/60 bg-muted/30 text-muted-foreground">
|
<Alert className="border-border/60 bg-muted/30 text-muted-foreground">
|
||||||
<Info className="h-4 w-4 text-muted-foreground" />
|
<Info className="h-4 w-4 text-muted-foreground" />
|
||||||
<AlertTitle className="text-muted-foreground">{t("api_key_warning_title")}</AlertTitle>
|
<AlertTitle className="text-muted-foreground">{t("api_key_warning_title")}</AlertTitle>
|
||||||
|
|
@ -44,7 +35,7 @@ export function ApiKeyContent() {
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
<div className="rounded-lg border border-border/60 bg-card p-6">
|
<div className="rounded-lg border border-border/60 bg-card p-6 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="h-12 w-full animate-pulse rounded-md border border-border/60 bg-muted/30" />
|
||||||
|
|
@ -80,7 +71,7 @@ export function ApiKeyContent() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg border border-border/60 bg-card p-6">
|
<div className="rounded-lg border border-border/60 bg-card p-6 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">
|
||||||
|
|
@ -110,7 +101,6 @@ export function ApiKeyContent() {
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</div>
|
||||||
</AnimatePresence>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
@ -72,14 +71,7 @@ export function ProfileContent() {
|
||||||
const hasChanges = displayName !== (user?.display_name || "");
|
const hasChanges = displayName !== (user?.display_name || "");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence mode="wait">
|
<div>
|
||||||
<motion.div
|
|
||||||
key="profile-content"
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, y: -20 }}
|
|
||||||
transition={{ duration: 0.35, ease: [0.4, 0, 0.2, 1] }}
|
|
||||||
>
|
|
||||||
{isUserLoading ? (
|
{isUserLoading ? (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<Spinner size="md" className="text-muted-foreground" />
|
<Spinner size="md" className="text-muted-foreground" />
|
||||||
|
|
@ -116,14 +108,18 @@ export function ProfileContent() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button type="submit" disabled={isPending || !hasChanges}>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="outline"
|
||||||
|
disabled={isPending || !hasChanges}
|
||||||
|
className="gap-2 bg-white text-black hover:bg-neutral-100 dark:bg-white dark:text-black dark:hover:bg-neutral-200"
|
||||||
|
>
|
||||||
{isPending && <Spinner size="sm" className="mr-2" />}
|
{isPending && <Spinner size="sm" className="mr-2" />}
|
||||||
{t("profile_save")}
|
{t("profile_save")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</div>
|
||||||
</AnimatePresence>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { User, UserKey } from "lucide-react";
|
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { useCallback } from "react";
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/animated-tabs";
|
|
||||||
import { ApiKeyContent } from "./components/ApiKeyContent";
|
|
||||||
import { ProfileContent } from "./components/ProfileContent";
|
|
||||||
|
|
||||||
const VALID_TABS = ["profile", "api-key"] as const;
|
|
||||||
const DEFAULT_TAB = "profile";
|
|
||||||
|
|
||||||
export default function UserSettingsPage() {
|
|
||||||
const t = useTranslations("userSettings");
|
|
||||||
const router = useRouter();
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
|
|
||||||
const tabParam = searchParams.get("tab") ?? "";
|
|
||||||
const activeTab = VALID_TABS.includes(tabParam as (typeof VALID_TABS)[number])
|
|
||||||
? tabParam
|
|
||||||
: DEFAULT_TAB;
|
|
||||||
|
|
||||||
const handleTabChange = useCallback(
|
|
||||||
(value: string) => {
|
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
|
||||||
params.set("tab", value);
|
|
||||||
router.replace(`?${params.toString()}`, { scroll: false });
|
|
||||||
},
|
|
||||||
[router, searchParams]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full overflow-y-auto">
|
|
||||||
<div className="mx-auto w-full max-w-4xl px-4 py-10">
|
|
||||||
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
|
|
||||||
<TabsList showBottomBorder>
|
|
||||||
<TabsTrigger value="profile">
|
|
||||||
<User className="mr-2 h-4 w-4" />
|
|
||||||
{t("profile_nav_label")}
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="api-key">
|
|
||||||
<UserKey className="mr-2 h-4 w-4" />
|
|
||||||
{t("api_key_nav_label")}
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
<TabsContent value="profile" className="mt-6">
|
|
||||||
<ProfileContent />
|
|
||||||
</TabsContent>
|
|
||||||
<TabsContent value="api-key" className="mt-6">
|
|
||||||
<ApiKeyContent />
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -47,6 +47,7 @@
|
||||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
--sidebar-border: oklch(0.922 0 0);
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
--sidebar-ring: oklch(0.708 0 0);
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
|
--main-panel: oklch(1 0 0);
|
||||||
--syntax-bg: #f5f5f5;
|
--syntax-bg: #f5f5f5;
|
||||||
--brand: oklch(0.623 0.214 259.815);
|
--brand: oklch(0.623 0.214 259.815);
|
||||||
--highlight: oklch(0.852 0.199 91.936);
|
--highlight: oklch(0.852 0.199 91.936);
|
||||||
|
|
@ -85,6 +86,7 @@
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-border: oklch(0.269 0 0);
|
--sidebar-border: oklch(0.269 0 0);
|
||||||
--sidebar-ring: oklch(0.439 0 0);
|
--sidebar-ring: oklch(0.439 0 0);
|
||||||
|
--main-panel: oklch(0.18 0 0);
|
||||||
--syntax-bg: #1e1e1e;
|
--syntax-bg: #1e1e1e;
|
||||||
--brand: oklch(0.707 0.165 254.624);
|
--brand: oklch(0.707 0.165 254.624);
|
||||||
--highlight: oklch(0.852 0.199 91.936);
|
--highlight: oklch(0.852 0.199 91.936);
|
||||||
|
|
@ -115,6 +117,7 @@
|
||||||
--color-chart-3: var(--chart-3);
|
--color-chart-3: var(--chart-3);
|
||||||
--color-chart-4: var(--chart-4);
|
--color-chart-4: var(--chart-4);
|
||||||
--color-chart-5: var(--chart-5);
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-main-panel: var(--main-panel);
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
--radius-lg: var(--radius);
|
--radius-lg: var(--radius);
|
||||||
|
|
|
||||||
|
|
@ -224,6 +224,12 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
||||||
changeFrequency: "daily",
|
changeFrequency: "daily",
|
||||||
priority: 0.8,
|
priority: 0.8,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
url: "https://www.surfsense.com/docs/how-to/web-search",
|
||||||
|
lastModified,
|
||||||
|
changeFrequency: "daily",
|
||||||
|
priority: 0.8,
|
||||||
|
},
|
||||||
// Developer documentation
|
// Developer documentation
|
||||||
{
|
{
|
||||||
url: "https://www.surfsense.com/docs/testing",
|
url: "https://www.surfsense.com/docs/testing",
|
||||||
|
|
|
||||||
57
surfsense_web/atoms/editor/editor-panel.atom.ts
Normal file
57
surfsense_web/atoms/editor/editor-panel.atom.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { atom } from "jotai";
|
||||||
|
import { rightPanelCollapsedAtom, rightPanelTabAtom } from "@/atoms/layout/right-panel.atom";
|
||||||
|
|
||||||
|
interface EditorPanelState {
|
||||||
|
isOpen: boolean;
|
||||||
|
documentId: number | null;
|
||||||
|
searchSpaceId: number | null;
|
||||||
|
title: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: EditorPanelState = {
|
||||||
|
isOpen: false,
|
||||||
|
documentId: null,
|
||||||
|
searchSpaceId: null,
|
||||||
|
title: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const editorPanelAtom = atom<EditorPanelState>(initialState);
|
||||||
|
|
||||||
|
export const editorPanelOpenAtom = atom((get) => get(editorPanelAtom).isOpen);
|
||||||
|
|
||||||
|
const preEditorCollapsedAtom = atom<boolean | null>(null);
|
||||||
|
|
||||||
|
export const openEditorPanelAtom = atom(
|
||||||
|
null,
|
||||||
|
(
|
||||||
|
get,
|
||||||
|
set,
|
||||||
|
{
|
||||||
|
documentId,
|
||||||
|
searchSpaceId,
|
||||||
|
title,
|
||||||
|
}: { documentId: number; searchSpaceId: number; title?: string }
|
||||||
|
) => {
|
||||||
|
if (!get(editorPanelAtom).isOpen) {
|
||||||
|
set(preEditorCollapsedAtom, get(rightPanelCollapsedAtom));
|
||||||
|
}
|
||||||
|
set(editorPanelAtom, {
|
||||||
|
isOpen: true,
|
||||||
|
documentId,
|
||||||
|
searchSpaceId,
|
||||||
|
title: title ?? null,
|
||||||
|
});
|
||||||
|
set(rightPanelTabAtom, "editor");
|
||||||
|
set(rightPanelCollapsedAtom, false);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const closeEditorPanelAtom = atom(null, (get, set) => {
|
||||||
|
set(editorPanelAtom, initialState);
|
||||||
|
set(rightPanelTabAtom, "sources");
|
||||||
|
const prev = get(preEditorCollapsedAtom);
|
||||||
|
if (prev !== null) {
|
||||||
|
set(rightPanelCollapsedAtom, prev);
|
||||||
|
set(preEditorCollapsedAtom, null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
import { atom } from "jotai";
|
|
||||||
|
|
||||||
interface EditorUIState {
|
|
||||||
hasUnsavedChanges: boolean;
|
|
||||||
pendingNavigation: string | null; // URL to navigate to after user confirms
|
|
||||||
}
|
|
||||||
|
|
||||||
export const editorUIAtom = atom<EditorUIState>({
|
|
||||||
hasUnsavedChanges: false,
|
|
||||||
pendingNavigation: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Derived atom for just the unsaved changes state
|
|
||||||
export const hasUnsavedEditorChangesAtom = atom(
|
|
||||||
(get) => get(editorUIAtom).hasUnsavedChanges,
|
|
||||||
(get, set, value: boolean) => {
|
|
||||||
set(editorUIAtom, { ...get(editorUIAtom), hasUnsavedChanges: value });
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Derived atom for pending navigation
|
|
||||||
export const pendingEditorNavigationAtom = atom(
|
|
||||||
(get) => get(editorUIAtom).pendingNavigation,
|
|
||||||
(get, set, value: string | null) => {
|
|
||||||
set(editorUIAtom, { ...get(editorUIAtom), pendingNavigation: value });
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { atom } from "jotai";
|
import { atom } from "jotai";
|
||||||
|
|
||||||
export type RightPanelTab = "sources" | "report";
|
export type RightPanelTab = "sources" | "report" | "editor";
|
||||||
|
|
||||||
export const rightPanelTabAtom = atom<RightPanelTab>("sources");
|
export const rightPanelTabAtom = atom<RightPanelTab>("sources");
|
||||||
|
|
||||||
|
|
|
||||||
25
surfsense_web/atoms/settings/settings-dialog.atoms.ts
Normal file
25
surfsense_web/atoms/settings/settings-dialog.atoms.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { atom } from "jotai";
|
||||||
|
|
||||||
|
export interface SearchSpaceSettingsDialogState {
|
||||||
|
open: boolean;
|
||||||
|
initialTab: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserSettingsDialogState {
|
||||||
|
open: boolean;
|
||||||
|
initialTab: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const searchSpaceSettingsDialogAtom = atom<SearchSpaceSettingsDialogState>({
|
||||||
|
open: false,
|
||||||
|
initialTab: "general",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const userSettingsDialogAtom = atom<UserSettingsDialogState>({
|
||||||
|
open: false,
|
||||||
|
initialTab: "profile",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const teamDialogAtom = atom<boolean>(false);
|
||||||
|
|
||||||
|
export const morePagesDialogAtom = atom<boolean>(false);
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue, useSetAtom } from "jotai";
|
||||||
import { AlertTriangle, Cable, Settings } from "lucide-react";
|
import { AlertTriangle, Cable, Settings } from "lucide-react";
|
||||||
import Link from "next/link";
|
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { type FC, forwardRef, useEffect, useImperativeHandle, useMemo, useState } from "react";
|
import { type FC, forwardRef, useEffect, useImperativeHandle, useMemo, useState } from "react";
|
||||||
import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
|
import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
|
||||||
|
|
@ -12,6 +11,7 @@ import {
|
||||||
llmPreferencesAtom,
|
llmPreferencesAtom,
|
||||||
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
|
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
|
||||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||||
|
import { searchSpaceSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
|
||||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
|
|
@ -50,6 +50,7 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
|
||||||
({ showTrigger = true }, ref) => {
|
({ showTrigger = true }, ref) => {
|
||||||
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
const setSearchSpaceSettingsDialog = useSetAtom(searchSpaceSettingsDialogAtom);
|
||||||
const { data: currentUser } = useAtomValue(currentUserAtom);
|
const { data: currentUser } = useAtomValue(currentUserAtom);
|
||||||
const { data: preferences = {}, isFetching: preferencesLoading } =
|
const { data: preferences = {}, isFetching: preferencesLoading } =
|
||||||
useAtomValue(llmPreferencesAtom);
|
useAtomValue(llmPreferencesAtom);
|
||||||
|
|
@ -417,11 +418,19 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
|
||||||
? "Auto mode is selected but no global LLM configurations are available. Please configure a custom LLM in Settings to process and summarize documents from your connected sources."
|
? "Auto mode is selected but no global LLM configurations are available. Please configure a custom LLM in Settings to process and summarize documents from your connected sources."
|
||||||
: "You need to configure a Document Summary LLM before adding connectors. This LLM is used to process and summarize documents from your connected sources."}
|
: "You need to configure a Document Summary LLM before adding connectors. This LLM is used to process and summarize documents from your connected sources."}
|
||||||
</p>
|
</p>
|
||||||
<Button asChild size="sm" variant="outline">
|
<Button
|
||||||
<Link href={`/dashboard/${searchSpaceId}/settings?tab=models`}>
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
handleOpenChange(false);
|
||||||
|
setSearchSpaceSettingsDialog({
|
||||||
|
open: true,
|
||||||
|
initialTab: "models",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Settings className="mr-2 h-4 w-4" />
|
<Settings className="mr-2 h-4 w-4" />
|
||||||
Go to Settings
|
Go to Settings
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
|
||||||
|
|
@ -23,11 +23,6 @@ export function getConnectorBenefits(connectorType: string): string[] | null {
|
||||||
"Real-time information from the web",
|
"Real-time information from the web",
|
||||||
"Enhanced search capabilities for your projects",
|
"Enhanced search capabilities for your projects",
|
||||||
],
|
],
|
||||||
SEARXNG_API: [
|
|
||||||
"Privacy-focused meta-search across multiple engines",
|
|
||||||
"Self-hosted search instance for full control",
|
|
||||||
"Real-time web search results from multiple sources",
|
|
||||||
],
|
|
||||||
LINKUP_API: [
|
LINKUP_API: [
|
||||||
"AI-powered search results tailored to your queries",
|
"AI-powered search results tailored to your queries",
|
||||||
"Real-time information from the web",
|
"Real-time information from the web",
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import { LinkupApiConnectForm } from "./components/linkup-api-connect-form";
|
||||||
import { LumaConnectForm } from "./components/luma-connect-form";
|
import { LumaConnectForm } from "./components/luma-connect-form";
|
||||||
import { MCPConnectForm } from "./components/mcp-connect-form";
|
import { MCPConnectForm } from "./components/mcp-connect-form";
|
||||||
import { ObsidianConnectForm } from "./components/obsidian-connect-form";
|
import { ObsidianConnectForm } from "./components/obsidian-connect-form";
|
||||||
import { SearxngConnectForm } from "./components/searxng-connect-form";
|
|
||||||
import { TavilyApiConnectForm } from "./components/tavily-api-connect-form";
|
import { TavilyApiConnectForm } from "./components/tavily-api-connect-form";
|
||||||
|
|
||||||
export interface ConnectFormProps {
|
export interface ConnectFormProps {
|
||||||
|
|
@ -41,8 +40,6 @@ export function getConnectFormComponent(connectorType: string): ConnectFormCompo
|
||||||
switch (connectorType) {
|
switch (connectorType) {
|
||||||
case "TAVILY_API":
|
case "TAVILY_API":
|
||||||
return TavilyApiConnectForm;
|
return TavilyApiConnectForm;
|
||||||
case "SEARXNG_API":
|
|
||||||
return SearxngConnectForm;
|
|
||||||
case "LINKUP_API":
|
case "LINKUP_API":
|
||||||
return LinkupApiConnectForm;
|
return LinkupApiConnectForm;
|
||||||
case "BAIDU_SEARCH_API":
|
case "BAIDU_SEARCH_API":
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ import { LinkupApiConfig } from "./components/linkup-api-config";
|
||||||
import { LumaConfig } from "./components/luma-config";
|
import { LumaConfig } from "./components/luma-config";
|
||||||
import { MCPConfig } from "./components/mcp-config";
|
import { MCPConfig } from "./components/mcp-config";
|
||||||
import { ObsidianConfig } from "./components/obsidian-config";
|
import { ObsidianConfig } from "./components/obsidian-config";
|
||||||
import { SearxngConfig } from "./components/searxng-config";
|
|
||||||
import { SlackConfig } from "./components/slack-config";
|
import { SlackConfig } from "./components/slack-config";
|
||||||
import { TavilyApiConfig } from "./components/tavily-api-config";
|
import { TavilyApiConfig } from "./components/tavily-api-config";
|
||||||
import { TeamsConfig } from "./components/teams-config";
|
import { TeamsConfig } from "./components/teams-config";
|
||||||
|
|
@ -45,8 +44,6 @@ export function getConnectorConfigComponent(
|
||||||
return GoogleDriveConfig;
|
return GoogleDriveConfig;
|
||||||
case "TAVILY_API":
|
case "TAVILY_API":
|
||||||
return TavilyApiConfig;
|
return TavilyApiConfig;
|
||||||
case "SEARXNG_API":
|
|
||||||
return SearxngConfig;
|
|
||||||
case "LINKUP_API":
|
case "LINKUP_API":
|
||||||
return LinkupApiConfig;
|
return LinkupApiConfig;
|
||||||
case "BAIDU_SEARCH_API":
|
case "BAIDU_SEARCH_API":
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ import { getConnectFormComponent } from "../../connect-forms";
|
||||||
|
|
||||||
const FORM_ID_MAP: Record<string, string> = {
|
const FORM_ID_MAP: Record<string, string> = {
|
||||||
TAVILY_API: "tavily-connect-form",
|
TAVILY_API: "tavily-connect-form",
|
||||||
SEARXNG_API: "searxng-connect-form",
|
|
||||||
LINKUP_API: "linkup-api-connect-form",
|
LINKUP_API: "linkup-api-connect-form",
|
||||||
BAIDU_SEARCH_API: "baidu-search-api-connect-form",
|
BAIDU_SEARCH_API: "baidu-search-api-connect-form",
|
||||||
ELASTICSEARCH_CONNECTOR: "elasticsearch-connect-form",
|
ELASTICSEARCH_CONNECTOR: "elasticsearch-connect-form",
|
||||||
|
|
|
||||||
|
|
@ -136,12 +136,6 @@ export const OTHER_CONNECTORS = [
|
||||||
description: "Search with Tavily",
|
description: "Search with Tavily",
|
||||||
connectorType: EnumConnectorName.TAVILY_API,
|
connectorType: EnumConnectorName.TAVILY_API,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "searxng",
|
|
||||||
title: "SearxNG",
|
|
||||||
description: "Search with SearxNG",
|
|
||||||
connectorType: EnumConnectorName.SEARXNG_API,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "linkup-api",
|
id: "linkup-api",
|
||||||
title: "Linkup API",
|
title: "Linkup API",
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue, useSetAtom } from "jotai";
|
||||||
import { AlertTriangle, Settings, Upload } from "lucide-react";
|
import { AlertTriangle, Settings } from "lucide-react";
|
||||||
import Link from "next/link";
|
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
type FC,
|
type FC,
|
||||||
|
|
@ -17,6 +16,7 @@ import {
|
||||||
llmPreferencesAtom,
|
llmPreferencesAtom,
|
||||||
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
|
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
|
||||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||||
|
import { searchSpaceSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
|
||||||
import { DocumentUploadTab } from "@/components/sources/DocumentUploadTab";
|
import { DocumentUploadTab } from "@/components/sources/DocumentUploadTab";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -91,6 +91,7 @@ const DocumentUploadPopupContent: FC<{
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
}> = ({ isOpen, onOpenChange }) => {
|
}> = ({ isOpen, onOpenChange }) => {
|
||||||
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
||||||
|
const setSearchSpaceSettingsDialog = useSetAtom(searchSpaceSettingsDialogAtom);
|
||||||
const { data: preferences = {}, isFetching: preferencesLoading } =
|
const { data: preferences = {}, isFetching: preferencesLoading } =
|
||||||
useAtomValue(llmPreferencesAtom);
|
useAtomValue(llmPreferencesAtom);
|
||||||
const { data: globalConfigs = [], isFetching: globalConfigsLoading } =
|
const { data: globalConfigs = [], isFetching: globalConfigsLoading } =
|
||||||
|
|
@ -157,11 +158,19 @@ const DocumentUploadPopupContent: FC<{
|
||||||
? "Auto mode is selected but no global LLM configurations are available. Please configure a custom LLM in Settings to process and summarize your uploaded documents."
|
? "Auto mode is selected but no global LLM configurations are available. Please configure a custom LLM in Settings to process and summarize your uploaded documents."
|
||||||
: "You need to configure a Document Summary LLM before uploading files. This LLM is used to process and summarize your uploaded documents."}
|
: "You need to configure a Document Summary LLM before uploading files. This LLM is used to process and summarize your uploaded documents."}
|
||||||
</p>
|
</p>
|
||||||
<Button asChild size="sm" variant="outline">
|
<Button
|
||||||
<Link href={`/dashboard/${searchSpaceId}/settings?tab=models`}>
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
onOpenChange(false);
|
||||||
|
setSearchSpaceSettingsDialog({
|
||||||
|
open: true,
|
||||||
|
initialTab: "models",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Settings className="mr-2 h-4 w-4" />
|
<Settings className="mr-2 h-4 w-4" />
|
||||||
Go to Settings
|
Go to Settings
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ export const ThreadScrollToBottom: FC = () => {
|
||||||
<TooltipIconButton
|
<TooltipIconButton
|
||||||
tooltip="Scroll to bottom"
|
tooltip="Scroll to bottom"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="aui-thread-scroll-to-bottom -top-12 absolute z-10 self-center rounded-full p-4 disabled:invisible dark:bg-background dark:hover:bg-accent"
|
className="aui-thread-scroll-to-bottom -top-12 absolute z-10 self-center rounded-full p-4 disabled:invisible dark:bg-main-panel dark:hover:bg-accent"
|
||||||
>
|
>
|
||||||
<ArrowDownIcon />
|
<ArrowDownIcon />
|
||||||
</TooltipIconButton>
|
</TooltipIconButton>
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import {
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
CopyIcon,
|
CopyIcon,
|
||||||
DownloadIcon,
|
DownloadIcon,
|
||||||
|
Globe,
|
||||||
Plus,
|
Plus,
|
||||||
RefreshCwIcon,
|
RefreshCwIcon,
|
||||||
Settings2,
|
Settings2,
|
||||||
|
|
@ -27,13 +28,13 @@ import {
|
||||||
Upload,
|
Upload,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { type FC, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
import { type FC, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import {
|
import {
|
||||||
agentToolsAtom,
|
agentToolsAtom,
|
||||||
disabledToolsAtom,
|
disabledToolsAtom,
|
||||||
enabledToolCountAtom,
|
|
||||||
hydrateDisabledToolsAtom,
|
hydrateDisabledToolsAtom,
|
||||||
toggleToolAtom,
|
toggleToolAtom,
|
||||||
} from "@/atoms/agent-tools/agent-tools.atoms";
|
} from "@/atoms/agent-tools/agent-tools.atoms";
|
||||||
|
|
@ -87,6 +88,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||||
|
import { getToolIcon } from "@/contracts/enums/toolIcons";
|
||||||
import type { Document } from "@/contracts/types/document.types";
|
import type { Document } from "@/contracts/types/document.types";
|
||||||
import { useBatchCommentsPreload } from "@/hooks/use-comments";
|
import { useBatchCommentsPreload } from "@/hooks/use-comments";
|
||||||
import { useCommentsElectric } from "@/hooks/use-comments-electric";
|
import { useCommentsElectric } from "@/hooks/use-comments-electric";
|
||||||
|
|
@ -118,7 +120,7 @@ export const Thread: FC<ThreadProps> = ({ messageThinkingSteps = new Map() }) =>
|
||||||
const ThreadContent: FC = () => {
|
const ThreadContent: FC = () => {
|
||||||
return (
|
return (
|
||||||
<ThreadPrimitive.Root
|
<ThreadPrimitive.Root
|
||||||
className="aui-root aui-thread-root @container flex h-full min-h-0 flex-col bg-background"
|
className="aui-root aui-thread-root @container flex h-full min-h-0 flex-col bg-main-panel"
|
||||||
style={{
|
style={{
|
||||||
["--thread-max-width" as string]: "44rem",
|
["--thread-max-width" as string]: "44rem",
|
||||||
}}
|
}}
|
||||||
|
|
@ -140,7 +142,7 @@ const ThreadContent: FC = () => {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ThreadPrimitive.ViewportFooter
|
<ThreadPrimitive.ViewportFooter
|
||||||
className="aui-thread-viewport-footer sticky bottom-0 z-10 mx-auto mt-auto flex w-full max-w-(--thread-max-width) flex-col gap-4 overflow-visible rounded-t-3xl bg-background pb-4 md:pb-6"
|
className="aui-thread-viewport-footer sticky bottom-0 z-10 mx-auto mt-auto flex w-full max-w-(--thread-max-width) flex-col gap-4 overflow-visible rounded-t-3xl bg-main-panel pb-4 md:pb-6"
|
||||||
style={{ paddingBottom: "max(1rem, env(safe-area-inset-bottom))" }}
|
style={{ paddingBottom: "max(1rem, env(safe-area-inset-bottom))" }}
|
||||||
>
|
>
|
||||||
<ThreadScrollToBottom />
|
<ThreadScrollToBottom />
|
||||||
|
|
@ -161,7 +163,7 @@ const ThreadScrollToBottom: FC = () => {
|
||||||
<TooltipIconButton
|
<TooltipIconButton
|
||||||
tooltip="Scroll to bottom"
|
tooltip="Scroll to bottom"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="aui-thread-scroll-to-bottom -top-12 absolute z-10 self-center rounded-full p-4 disabled:invisible dark:bg-background dark:hover:bg-accent"
|
className="aui-thread-scroll-to-bottom -top-12 absolute z-10 self-center rounded-full p-4 disabled:invisible dark:bg-main-panel dark:hover:bg-accent"
|
||||||
>
|
>
|
||||||
<ArrowDownIcon />
|
<ArrowDownIcon />
|
||||||
</TooltipIconButton>
|
</TooltipIconButton>
|
||||||
|
|
@ -232,7 +234,9 @@ const ThreadWelcome: FC = () => {
|
||||||
<div className="aui-thread-welcome-root mx-auto flex w-full max-w-(--thread-max-width) grow flex-col items-center px-4 relative">
|
<div className="aui-thread-welcome-root mx-auto flex w-full max-w-(--thread-max-width) grow flex-col items-center px-4 relative">
|
||||||
{/* Greeting positioned above the composer */}
|
{/* Greeting positioned above the composer */}
|
||||||
<div className="aui-thread-welcome-message absolute bottom-[calc(50%+5rem)] left-0 right-0 flex flex-col items-center text-center">
|
<div className="aui-thread-welcome-message absolute bottom-[calc(50%+5rem)] left-0 right-0 flex flex-col items-center text-center">
|
||||||
<h1 className="aui-thread-welcome-message-inner text-3xl md:text-5xl">{greeting}</h1>
|
<h1 className="aui-thread-welcome-message-inner text-3xl md:text-5xl select-none">
|
||||||
|
{greeting}
|
||||||
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
{/* Composer - top edge fixed, expands downward only */}
|
{/* Composer - top edge fixed, expands downward only */}
|
||||||
<div className="w-full flex items-start justify-center absolute top-[calc(50%-3.5rem)] left-0 right-0">
|
<div className="w-full flex items-start justify-center absolute top-[calc(50%-3.5rem)] left-0 right-0">
|
||||||
|
|
@ -271,33 +275,39 @@ const ConnectToolsBanner: FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="md:hidden border-t border-border/50 bg-muted-foreground/[0.04]">
|
<div className="border-t border-border/50">
|
||||||
|
<div className="flex w-full items-center gap-2.5 px-4 py-2.5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex w-full items-center gap-2.5 px-4 py-2.5 text-left transition-colors hover:bg-muted-foreground/[0.06] active:bg-muted-foreground/[0.1]"
|
className="flex flex-1 items-center gap-2.5 text-left cursor-pointer"
|
||||||
onClick={() => setConnectorDialogOpen(true)}
|
onClick={() => setConnectorDialogOpen(true)}
|
||||||
>
|
>
|
||||||
<Unplug className="size-4 text-muted-foreground/70 shrink-0" />
|
<Unplug className="size-4 text-muted-foreground shrink-0" />
|
||||||
<span className="text-[13px] text-muted-foreground/80 flex-1">Connect your tools</span>
|
<span className="text-[13px] text-muted-foreground/80 flex-1">Connect your tools</span>
|
||||||
<AvatarGroup className="shrink-0">
|
<AvatarGroup className="shrink-0">
|
||||||
{BANNER_CONNECTORS.map(({ type, label }, i) => (
|
{BANNER_CONNECTORS.map(({ type }, i) => (
|
||||||
<Avatar key={type} className="size-6" style={{ zIndex: BANNER_CONNECTORS.length - i }}>
|
<Avatar
|
||||||
|
key={type}
|
||||||
|
className="size-6"
|
||||||
|
style={{ zIndex: BANNER_CONNECTORS.length - i }}
|
||||||
|
>
|
||||||
<AvatarFallback className="bg-muted text-[10px]">
|
<AvatarFallback className="bg-muted text-[10px]">
|
||||||
{getConnectorIcon(type, "size-3.5")}
|
{getConnectorIcon(type, "size-3.5")}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
))}
|
))}
|
||||||
</AvatarGroup>
|
</AvatarGroup>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleDismiss}
|
onClick={handleDismiss}
|
||||||
className="shrink-0 ml-0.5 p-0.5 text-muted-foreground/40 hover:text-foreground transition-colors"
|
className="shrink-0 ml-0.5 p-1.5 -mr-1 text-muted-foreground/40 hover:text-foreground transition-colors cursor-pointer"
|
||||||
aria-label="Dismiss"
|
aria-label="Dismiss"
|
||||||
>
|
>
|
||||||
<X className="size-3.5" />
|
<X className="size-3.5 text-muted-foreground" />
|
||||||
</button>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -589,7 +599,46 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
|
||||||
const disabledTools = useAtomValue(disabledToolsAtom);
|
const disabledTools = useAtomValue(disabledToolsAtom);
|
||||||
const toggleTool = useSetAtom(toggleToolAtom);
|
const toggleTool = useSetAtom(toggleToolAtom);
|
||||||
const hydrateDisabled = useSetAtom(hydrateDisabledToolsAtom);
|
const hydrateDisabled = useSetAtom(hydrateDisabledToolsAtom);
|
||||||
const enabledCount = useAtomValue(enabledToolCountAtom);
|
|
||||||
|
const hasWebSearchTool = agentTools?.some((t) => t.name === "web_search") ?? false;
|
||||||
|
const isWebSearchEnabled = hasWebSearchTool && !disabledTools.includes("web_search");
|
||||||
|
const filteredTools = useMemo(
|
||||||
|
() => agentTools?.filter((t) => t.name !== "web_search"),
|
||||||
|
[agentTools]
|
||||||
|
);
|
||||||
|
const filteredEnabledCount = useMemo(() => {
|
||||||
|
if (!filteredTools) return 0;
|
||||||
|
return (
|
||||||
|
filteredTools.length -
|
||||||
|
disabledTools.filter((d) => filteredTools.some((t) => t.name === d)).length
|
||||||
|
);
|
||||||
|
}, [filteredTools, disabledTools]);
|
||||||
|
|
||||||
|
const groupedTools = useMemo(() => {
|
||||||
|
if (!filteredTools) return [];
|
||||||
|
const toolsByName = new Map(filteredTools.map((t) => [t.name, t]));
|
||||||
|
const result: { label: string; tools: typeof filteredTools }[] = [];
|
||||||
|
const placed = new Set<string>();
|
||||||
|
|
||||||
|
for (const group of TOOL_GROUPS) {
|
||||||
|
const matched = group.tools.flatMap((name) => {
|
||||||
|
const tool = toolsByName.get(name);
|
||||||
|
if (!tool) return [];
|
||||||
|
placed.add(name);
|
||||||
|
return [tool];
|
||||||
|
});
|
||||||
|
if (matched.length > 0) {
|
||||||
|
result.push({ label: group.label, tools: matched });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ungrouped = filteredTools.filter((t) => !placed.has(t.name));
|
||||||
|
if (ungrouped.length > 0) {
|
||||||
|
result.push({ label: "Other", tools: ungrouped });
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [filteredTools]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
hydrateDisabled();
|
hydrateDisabled();
|
||||||
|
|
@ -625,7 +674,7 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent side="top" align="start" sideOffset={8}>
|
<DropdownMenuContent side="bottom" align="start" sideOffset={8}>
|
||||||
<DropdownMenuItem onSelect={() => setToolsPopoverOpen(true)}>
|
<DropdownMenuItem onSelect={() => setToolsPopoverOpen(true)}>
|
||||||
<Settings2 className="size-4" />
|
<Settings2 className="size-4" />
|
||||||
Manage Tools
|
Manage Tools
|
||||||
|
|
@ -642,17 +691,24 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
|
||||||
<div className="flex items-center justify-between px-4 py-2">
|
<div className="flex items-center justify-between px-4 py-2">
|
||||||
<DrawerTitle className="text-sm font-medium">Agent Tools</DrawerTitle>
|
<DrawerTitle className="text-sm font-medium">Agent Tools</DrawerTitle>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{enabledCount}/{agentTools?.length ?? 0} enabled
|
{filteredEnabledCount}/{filteredTools?.length ?? 0} enabled
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-y-auto pb-6" onScroll={handleToolsScroll}>
|
<div className="overflow-y-auto pb-6" onScroll={handleToolsScroll}>
|
||||||
{agentTools?.map((tool) => {
|
{groupedTools.map((group) => (
|
||||||
|
<div key={group.label}>
|
||||||
|
<div className="px-4 pt-3 pb-1 text-xs text-muted-foreground/80 font-medium select-none">
|
||||||
|
{group.label}
|
||||||
|
</div>
|
||||||
|
{group.tools.map((tool) => {
|
||||||
const isDisabled = disabledTools.includes(tool.name);
|
const isDisabled = disabledTools.includes(tool.name);
|
||||||
|
const ToolIcon = getToolIcon(tool.name);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={tool.name}
|
key={tool.name}
|
||||||
className="flex w-full items-center gap-3 px-4 py-2 hover:bg-muted-foreground/10 transition-colors"
|
className="flex w-full items-center gap-3 px-4 py-2 hover:bg-muted-foreground/10 transition-colors"
|
||||||
>
|
>
|
||||||
|
<ToolIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||||
<span className="flex-1 min-w-0 text-sm font-medium truncate">
|
<span className="flex-1 min-w-0 text-sm font-medium truncate">
|
||||||
{formatToolName(tool.name)}
|
{formatToolName(tool.name)}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -664,7 +720,9 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{!agentTools?.length && (
|
</div>
|
||||||
|
))}
|
||||||
|
{!filteredTools?.length && (
|
||||||
<div className="px-4 py-6 text-center text-sm text-muted-foreground">
|
<div className="px-4 py-6 text-center text-sm text-muted-foreground">
|
||||||
Loading tools...
|
Loading tools...
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -688,6 +746,7 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
|
||||||
<TooltipIconButton
|
<TooltipIconButton
|
||||||
tooltip="Manage tools"
|
tooltip="Manage tools"
|
||||||
side="bottom"
|
side="bottom"
|
||||||
|
disableTooltip={toolsPopoverOpen}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="size-[34px] rounded-full p-1 font-semibold text-xs hover:bg-muted-foreground/15 dark:border-muted-foreground/15 dark:hover:bg-muted-foreground/30"
|
className="size-[34px] rounded-full p-1 font-semibold text-xs hover:bg-muted-foreground/15 dark:border-muted-foreground/15 dark:hover:bg-muted-foreground/30"
|
||||||
|
|
@ -707,7 +766,7 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
|
||||||
<div className="flex items-center justify-between px-2.5 py-2 sm:px-3 sm:py-2.5 border-b">
|
<div className="flex items-center justify-between px-2.5 py-2 sm:px-3 sm:py-2.5 border-b">
|
||||||
<span className="text-xs sm:text-sm font-medium">Agent Tools</span>
|
<span className="text-xs sm:text-sm font-medium">Agent Tools</span>
|
||||||
<span className="text-[10px] sm:text-xs text-muted-foreground">
|
<span className="text-[10px] sm:text-xs text-muted-foreground">
|
||||||
{enabledCount}/{agentTools?.length ?? 0} enabled
|
{filteredEnabledCount}/{filteredTools?.length ?? 0} enabled
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|
@ -718,10 +777,17 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
|
||||||
WebkitMaskImage: `linear-gradient(to bottom, ${toolsScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${toolsScrollPos === "bottom" ? "black" : "transparent"})`,
|
WebkitMaskImage: `linear-gradient(to bottom, ${toolsScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${toolsScrollPos === "bottom" ? "black" : "transparent"})`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{agentTools?.map((tool) => {
|
{groupedTools.map((group) => (
|
||||||
|
<div key={group.label}>
|
||||||
|
<div className="px-2.5 sm:px-3 pt-2 pb-0.5 text-[10px] sm:text-xs text-muted-foreground/80 font-normal select-none">
|
||||||
|
{group.label}
|
||||||
|
</div>
|
||||||
|
{group.tools.map((tool) => {
|
||||||
const isDisabled = disabledTools.includes(tool.name);
|
const isDisabled = disabledTools.includes(tool.name);
|
||||||
|
const ToolIcon = getToolIcon(tool.name);
|
||||||
const row = (
|
const row = (
|
||||||
<div className="flex w-full items-center gap-2 sm:gap-3 px-2.5 sm:px-3 py-1 sm:py-1.5 hover:bg-muted-foreground/10 transition-colors">
|
<div className="flex w-full items-center gap-2 sm:gap-3 px-2.5 sm:px-3 py-1 sm:py-1.5 hover:bg-muted-foreground/10 transition-colors">
|
||||||
|
<ToolIcon className="size-3.5 sm:size-4 shrink-0 text-muted-foreground" />
|
||||||
<span className="flex-1 min-w-0 text-xs sm:text-sm font-medium truncate">
|
<span className="flex-1 min-w-0 text-xs sm:text-sm font-medium truncate">
|
||||||
{formatToolName(tool.name)}
|
{formatToolName(tool.name)}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -741,7 +807,9 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{!agentTools?.length && (
|
</div>
|
||||||
|
))}
|
||||||
|
{!filteredTools?.length && (
|
||||||
<div className="px-3 py-4 text-center text-xs text-muted-foreground">
|
<div className="px-3 py-4 text-center text-xs text-muted-foreground">
|
||||||
Loading tools...
|
Loading tools...
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -750,6 +818,46 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
)}
|
)}
|
||||||
|
{hasWebSearchTool && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleTool("web_search")}
|
||||||
|
className={cn(
|
||||||
|
"rounded-full transition-all flex items-center gap-1 px-2 py-1 border h-8 select-none",
|
||||||
|
isWebSearchEnabled
|
||||||
|
? "bg-sky-500/15 border-sky-500/60 text-sky-500"
|
||||||
|
: "bg-transparent border-transparent text-muted-foreground hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
animate={{
|
||||||
|
rotate: isWebSearchEnabled ? 360 : 0,
|
||||||
|
scale: isWebSearchEnabled ? 1.1 : 1,
|
||||||
|
}}
|
||||||
|
whileHover={{
|
||||||
|
rotate: isWebSearchEnabled ? 360 : 15,
|
||||||
|
scale: 1.1,
|
||||||
|
transition: { type: "spring", stiffness: 300, damping: 10 },
|
||||||
|
}}
|
||||||
|
transition={{ type: "spring", stiffness: 260, damping: 25 }}
|
||||||
|
>
|
||||||
|
<Globe className="size-4" />
|
||||||
|
</motion.div>
|
||||||
|
<AnimatePresence>
|
||||||
|
{isWebSearchEnabled && (
|
||||||
|
<motion.span
|
||||||
|
initial={{ width: 0, opacity: 0 }}
|
||||||
|
animate={{ width: "auto", opacity: 1 }}
|
||||||
|
exit={{ width: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="text-xs overflow-hidden whitespace-nowrap"
|
||||||
|
>
|
||||||
|
Search
|
||||||
|
</motion.span>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{sidebarDocs.length > 0 && (
|
{sidebarDocs.length > 0 && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -823,6 +931,21 @@ function formatToolName(name: string): string {
|
||||||
.join(" ");
|
.join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TOOL_GROUPS: { label: string; tools: string[] }[] = [
|
||||||
|
{
|
||||||
|
label: "Research",
|
||||||
|
tools: ["search_knowledge_base", "search_surfsense_docs", "scrape_webpage", "link_preview"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Generate",
|
||||||
|
tools: ["generate_podcast", "generate_report", "generate_image", "display_image"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Memory",
|
||||||
|
tools: ["save_memory", "recall_memory"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const MessageError: FC = () => {
|
const MessageError: FC = () => {
|
||||||
return (
|
return (
|
||||||
<MessagePrimitive.Error>
|
<MessagePrimitive.Error>
|
||||||
|
|
|
||||||
|
|
@ -4,17 +4,22 @@ import { Slottable } from "@radix-ui/react-slot";
|
||||||
import { type ComponentPropsWithRef, forwardRef, type ReactNode } from "react";
|
import { type ComponentPropsWithRef, forwardRef, type ReactNode } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export type TooltipIconButtonProps = ComponentPropsWithRef<typeof Button> & {
|
export type TooltipIconButtonProps = ComponentPropsWithRef<typeof Button> & {
|
||||||
tooltip: ReactNode;
|
tooltip: ReactNode;
|
||||||
side?: "top" | "bottom" | "left" | "right";
|
side?: "top" | "bottom" | "left" | "right";
|
||||||
|
disableTooltip?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TooltipIconButton = forwardRef<HTMLButtonElement, TooltipIconButtonProps>(
|
export const TooltipIconButton = forwardRef<HTMLButtonElement, TooltipIconButtonProps>(
|
||||||
({ children, tooltip, side = "bottom", className, ...rest }, ref) => {
|
({ children, tooltip, side = "bottom", className, disableTooltip, ...rest }, ref) => {
|
||||||
|
const isTouchDevice = useMediaQuery("(pointer: coarse)");
|
||||||
|
const suppressTooltip = disableTooltip || isTouchDevice;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip>
|
<Tooltip open={suppressTooltip ? false : undefined}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
|
||||||
295
surfsense_web/components/editor-panel/editor-panel.tsx
Normal file
295
surfsense_web/components/editor-panel/editor-panel.tsx
Normal file
|
|
@ -0,0 +1,295 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useAtomValue, useSetAtom } from "jotai";
|
||||||
|
import { AlertCircle, XIcon } from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { closeEditorPanelAtom, editorPanelAtom } from "@/atoms/editor/editor-panel.atom";
|
||||||
|
import { PlateEditor } from "@/components/editor/plate-editor";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer";
|
||||||
|
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||||
|
import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils";
|
||||||
|
|
||||||
|
interface EditorContent {
|
||||||
|
document_id: number;
|
||||||
|
title: string;
|
||||||
|
document_type?: string;
|
||||||
|
source_markdown: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditorPanelSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 p-6">
|
||||||
|
<div className="h-6 w-3/4 rounded-md bg-muted/60 animate-pulse" />
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
<div className="h-3 w-full rounded-md bg-muted/60 animate-pulse" />
|
||||||
|
<div className="h-3 w-[95%] rounded-md bg-muted/60 animate-pulse [animation-delay:100ms]" />
|
||||||
|
<div className="h-3 w-[88%] rounded-md bg-muted/60 animate-pulse [animation-delay:200ms]" />
|
||||||
|
<div className="h-3 w-[60%] rounded-md bg-muted/60 animate-pulse [animation-delay:300ms]" />
|
||||||
|
</div>
|
||||||
|
<div className="h-5 w-2/5 rounded-md bg-muted/60 animate-pulse [animation-delay:400ms]" />
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
<div className="h-3 w-full rounded-md bg-muted/60 animate-pulse [animation-delay:500ms]" />
|
||||||
|
<div className="h-3 w-[92%] rounded-md bg-muted/60 animate-pulse [animation-delay:600ms]" />
|
||||||
|
<div className="h-3 w-[75%] rounded-md bg-muted/60 animate-pulse [animation-delay:700ms]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditorPanelContent({
|
||||||
|
documentId,
|
||||||
|
searchSpaceId,
|
||||||
|
title,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
documentId: number;
|
||||||
|
searchSpaceId: number;
|
||||||
|
title: string | null;
|
||||||
|
onClose?: () => void;
|
||||||
|
}) {
|
||||||
|
const [editorDoc, setEditorDoc] = useState<EditorContent | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const [editedMarkdown, setEditedMarkdown] = useState<string | null>(null);
|
||||||
|
const markdownRef = useRef<string>("");
|
||||||
|
const initialLoadDone = useRef(false);
|
||||||
|
const changeCountRef = useRef(0);
|
||||||
|
const [displayTitle, setDisplayTitle] = useState(title || "Untitled");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setEditorDoc(null);
|
||||||
|
setEditedMarkdown(null);
|
||||||
|
initialLoadDone.current = false;
|
||||||
|
changeCountRef.current = 0;
|
||||||
|
|
||||||
|
const fetchContent = async () => {
|
||||||
|
const token = getBearerToken();
|
||||||
|
if (!token) {
|
||||||
|
redirectToLogin();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await authenticatedFetch(
|
||||||
|
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/editor-content`,
|
||||||
|
{ method: "GET" }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response
|
||||||
|
.json()
|
||||||
|
.catch(() => ({ detail: "Failed to fetch document" }));
|
||||||
|
throw new Error(errorData.detail || "Failed to fetch document");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.source_markdown === undefined || data.source_markdown === null) {
|
||||||
|
setError(
|
||||||
|
"This document does not have editable content. Please re-upload to enable editing."
|
||||||
|
);
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
markdownRef.current = data.source_markdown;
|
||||||
|
setDisplayTitle(data.title || title || "Untitled");
|
||||||
|
setEditorDoc(data);
|
||||||
|
initialLoadDone.current = true;
|
||||||
|
} catch (err) {
|
||||||
|
if (cancelled) return;
|
||||||
|
console.error("Error fetching document:", err);
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to fetch document");
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchContent();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [documentId, searchSpaceId, title]);
|
||||||
|
|
||||||
|
const handleMarkdownChange = useCallback((md: string) => {
|
||||||
|
markdownRef.current = md;
|
||||||
|
if (!initialLoadDone.current) return;
|
||||||
|
changeCountRef.current += 1;
|
||||||
|
if (changeCountRef.current <= 1) return;
|
||||||
|
setEditedMarkdown(md);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
const token = getBearerToken();
|
||||||
|
if (!token) {
|
||||||
|
toast.error("Please login to save");
|
||||||
|
redirectToLogin();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const response = await authenticatedFetch(
|
||||||
|
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/save`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ source_markdown: markdownRef.current }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response
|
||||||
|
.json()
|
||||||
|
.catch(() => ({ detail: "Failed to save document" }));
|
||||||
|
throw new Error(errorData.detail || "Failed to save document");
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditorDoc((prev) => (prev ? { ...prev, source_markdown: markdownRef.current } : prev));
|
||||||
|
setEditedMarkdown(null);
|
||||||
|
toast.success("Document saved! Reindexing in background...");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error saving document:", err);
|
||||||
|
toast.error(err instanceof Error ? err.message : "Failed to save document");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}, [documentId, searchSpaceId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between px-4 py-2 shrink-0 border-b">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h2 className="text-sm font-semibold truncate">{displayTitle}</h2>
|
||||||
|
{editedMarkdown !== null && (
|
||||||
|
<p className="text-[10px] text-muted-foreground">Unsaved changes</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{onClose && (
|
||||||
|
<Button variant="ghost" size="icon" onClick={onClose} className="size-7 shrink-0">
|
||||||
|
<XIcon className="size-4" />
|
||||||
|
<span className="sr-only">Close editor panel</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
{isLoading ? (
|
||||||
|
<EditorPanelSkeleton />
|
||||||
|
) : error || !editorDoc ? (
|
||||||
|
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-6 text-center">
|
||||||
|
<AlertCircle className="size-8 text-destructive" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-foreground">Failed to load document</p>
|
||||||
|
<p className="text-sm text-red-500 mt-1">{error || "An unknown error occurred"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<PlateEditor
|
||||||
|
key={documentId}
|
||||||
|
preset="full"
|
||||||
|
markdown={editorDoc.source_markdown}
|
||||||
|
onMarkdownChange={handleMarkdownChange}
|
||||||
|
readOnly={false}
|
||||||
|
placeholder="Start writing..."
|
||||||
|
editorVariant="default"
|
||||||
|
onSave={handleSave}
|
||||||
|
hasUnsavedChanges={editedMarkdown !== null}
|
||||||
|
isSaving={saving}
|
||||||
|
defaultEditing={true}
|
||||||
|
className="[&_[role=toolbar]]:!bg-sidebar"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DesktopEditorPanel() {
|
||||||
|
const panelState = useAtomValue(editorPanelAtom);
|
||||||
|
const closePanel = useSetAtom(closeEditorPanelAtom);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") closePanel();
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [closePanel]);
|
||||||
|
|
||||||
|
if (!panelState.isOpen || !panelState.documentId || !panelState.searchSpaceId) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex w-[50%] max-w-[700px] min-w-[380px] flex-col border-l bg-sidebar text-sidebar-foreground animate-in slide-in-from-right-4 duration-300 ease-out">
|
||||||
|
<EditorPanelContent
|
||||||
|
documentId={panelState.documentId}
|
||||||
|
searchSpaceId={panelState.searchSpaceId}
|
||||||
|
title={panelState.title}
|
||||||
|
onClose={closePanel}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileEditorDrawer() {
|
||||||
|
const panelState = useAtomValue(editorPanelAtom);
|
||||||
|
const closePanel = useSetAtom(closeEditorPanelAtom);
|
||||||
|
|
||||||
|
if (!panelState.documentId || !panelState.searchSpaceId) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
open={panelState.isOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) closePanel();
|
||||||
|
}}
|
||||||
|
shouldScaleBackground={false}
|
||||||
|
>
|
||||||
|
<DrawerContent
|
||||||
|
className="h-[95vh] max-h-[95vh] z-80 bg-sidebar overflow-hidden"
|
||||||
|
overlayClassName="z-80"
|
||||||
|
>
|
||||||
|
<DrawerHandle />
|
||||||
|
<DrawerTitle className="sr-only">{panelState.title || "Editor"}</DrawerTitle>
|
||||||
|
<div className="min-h-0 flex-1 flex flex-col overflow-hidden">
|
||||||
|
<EditorPanelContent
|
||||||
|
documentId={panelState.documentId}
|
||||||
|
searchSpaceId={panelState.searchSpaceId}
|
||||||
|
title={panelState.title}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditorPanel() {
|
||||||
|
const panelState = useAtomValue(editorPanelAtom);
|
||||||
|
const isDesktop = useMediaQuery("(min-width: 1024px)");
|
||||||
|
|
||||||
|
if (!panelState.isOpen || !panelState.documentId) return null;
|
||||||
|
|
||||||
|
if (isDesktop) {
|
||||||
|
return <DesktopEditorPanel />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <MobileEditorDrawer />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobileEditorPanel() {
|
||||||
|
const panelState = useAtomValue(editorPanelAtom);
|
||||||
|
const isDesktop = useMediaQuery("(min-width: 1024px)");
|
||||||
|
|
||||||
|
if (isDesktop || !panelState.isOpen || !panelState.documentId) return null;
|
||||||
|
|
||||||
|
return <MobileEditorDrawer />;
|
||||||
|
}
|
||||||
|
|
@ -272,17 +272,19 @@ function NavbarGitHubStars({
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className={cn(
|
className={cn(
|
||||||
"group flex items-center gap-1.5 rounded-full px-3 py-1.5 hover:bg-gray-100 dark:hover:bg-neutral-800 transition-colors",
|
"group flex items-center gap-1.5 rounded-lg px-1 py-1 hover:bg-gray-100 dark:hover:bg-neutral-800/50 transition-colors",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<IconBrandGithub className="h-5 w-5 text-neutral-600 dark:text-neutral-300 shrink-0" />
|
<IconBrandGithub className="h-5 w-5 text-neutral-600 dark:text-neutral-300 shrink-0" />
|
||||||
|
<span className="rounded-lg bg-[#282828] px-2 py-0 group-hover:bg-neutral-800/80 transition-colors inline-flex items-center">
|
||||||
<AnimatedStarCount
|
<AnimatedStarCount
|
||||||
value={isLoading ? 10000 : stars}
|
value={isLoading ? 10000 : stars}
|
||||||
itemSize={ITEM_SIZE}
|
itemSize={ITEM_SIZE}
|
||||||
isRolling={isLoading}
|
isRolling={isLoading}
|
||||||
className="text-sm font-semibold tabular-nums text-neutral-600 dark:text-neutral-400 group-hover:text-neutral-800 dark:group-hover:text-neutral-200 transition-colors"
|
className="text-sm font-semibold tabular-nums text-neutral-500 group-hover:text-neutral-400 transition-colors"
|
||||||
/>
|
/>
|
||||||
|
</span>
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||||
import { AlertTriangle, Inbox, Megaphone, SquareLibrary } from "lucide-react";
|
import { AlertTriangle, Inbox, Megaphone, SquareLibrary } from "lucide-react";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { useParams, usePathname, useRouter } from "next/navigation";
|
import { useParams, usePathname, useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
|
|
@ -14,6 +15,12 @@ import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom";
|
||||||
import { rightPanelCollapsedAtom } from "@/atoms/layout/right-panel.atom";
|
import { rightPanelCollapsedAtom } from "@/atoms/layout/right-panel.atom";
|
||||||
import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
|
import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
|
||||||
import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||||
|
import {
|
||||||
|
morePagesDialogAtom,
|
||||||
|
searchSpaceSettingsDialogAtom,
|
||||||
|
teamDialogAtom,
|
||||||
|
userSettingsDialogAtom,
|
||||||
|
} from "@/atoms/settings/settings-dialog.atoms";
|
||||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
|
|
@ -35,7 +42,7 @@ import {
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { isPageLimitExceededMetadata } from "@/contracts/types/inbox.types";
|
|
||||||
import { useAnnouncements } from "@/hooks/use-announcements";
|
import { useAnnouncements } from "@/hooks/use-announcements";
|
||||||
import { useDocumentsProcessing } from "@/hooks/use-documents-processing";
|
import { useDocumentsProcessing } from "@/hooks/use-documents-processing";
|
||||||
import { useInbox } from "@/hooks/use-inbox";
|
import { useInbox } from "@/hooks/use-inbox";
|
||||||
|
|
@ -47,6 +54,10 @@ import { deleteThread, fetchThreads, updateThread } from "@/lib/chat/thread-pers
|
||||||
import { cleanupElectric } from "@/lib/electric/client";
|
import { cleanupElectric } from "@/lib/electric/client";
|
||||||
import { resetUser, trackLogout } from "@/lib/posthog/events";
|
import { resetUser, trackLogout } from "@/lib/posthog/events";
|
||||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||||
|
import { MorePagesDialog } from "@/components/settings/more-pages-dialog";
|
||||||
|
import { SearchSpaceSettingsDialog } from "@/components/settings/search-space-settings-dialog";
|
||||||
|
import { TeamDialog } from "@/components/settings/team-dialog";
|
||||||
|
import { UserSettingsDialog } from "@/components/settings/user-settings-dialog";
|
||||||
import type { ChatItem, NavItem, SearchSpace } from "../types/layout.types";
|
import type { ChatItem, NavItem, SearchSpace } from "../types/layout.types";
|
||||||
import { CreateSearchSpaceDialog } from "../ui/dialogs";
|
import { CreateSearchSpaceDialog } from "../ui/dialogs";
|
||||||
import { LayoutShell } from "../ui/shell";
|
import { LayoutShell } from "../ui/shell";
|
||||||
|
|
@ -193,6 +204,8 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
||||||
const seenPageLimitNotifications = useRef<Set<number>>(new Set());
|
const seenPageLimitNotifications = useRef<Set<number>>(new Set());
|
||||||
const isInitialLoad = useRef(true);
|
const isInitialLoad = useRef(true);
|
||||||
|
|
||||||
|
const setMorePagesOpen = useSetAtom(morePagesDialogAtom);
|
||||||
|
|
||||||
// Effect to show toast for new page_limit_exceeded notifications
|
// Effect to show toast for new page_limit_exceeded notifications
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (statusInbox.loading) return;
|
if (statusInbox.loading) return;
|
||||||
|
|
@ -216,21 +229,17 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
||||||
for (const notification of newNotifications) {
|
for (const notification of newNotifications) {
|
||||||
seenPageLimitNotifications.current.add(notification.id);
|
seenPageLimitNotifications.current.add(notification.id);
|
||||||
|
|
||||||
const actionUrl = isPageLimitExceededMetadata(notification.metadata)
|
|
||||||
? notification.metadata.action_url
|
|
||||||
: `/dashboard/${searchSpaceId}/more-pages`;
|
|
||||||
|
|
||||||
toast.error(notification.title, {
|
toast.error(notification.title, {
|
||||||
description: notification.message,
|
description: notification.message,
|
||||||
duration: 8000,
|
duration: 8000,
|
||||||
icon: <AlertTriangle className="h-5 w-5 text-amber-500" />,
|
icon: <AlertTriangle className="h-5 w-5 text-amber-500" />,
|
||||||
action: {
|
action: {
|
||||||
label: "View Plans",
|
label: "View Plans",
|
||||||
onClick: () => router.push(actionUrl),
|
onClick: () => setMorePagesOpen(true),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [statusInbox.inboxItems, statusInbox.loading, searchSpaceId, router]);
|
}, [statusInbox.inboxItems, statusInbox.loading, searchSpaceId, setMorePagesOpen]);
|
||||||
|
|
||||||
// Delete dialogs state
|
// Delete dialogs state
|
||||||
const [showDeleteChatDialog, setShowDeleteChatDialog] = useState(false);
|
const [showDeleteChatDialog, setShowDeleteChatDialog] = useState(false);
|
||||||
|
|
@ -390,15 +399,19 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
||||||
setIsCreateSearchSpaceDialogOpen(true);
|
setIsCreateSearchSpaceDialogOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const setUserSettingsDialog = useSetAtom(userSettingsDialogAtom);
|
||||||
|
const setSearchSpaceSettingsDialog = useSetAtom(searchSpaceSettingsDialogAtom);
|
||||||
|
const setTeamDialogOpen = useSetAtom(teamDialogAtom);
|
||||||
|
|
||||||
const handleUserSettings = useCallback(() => {
|
const handleUserSettings = useCallback(() => {
|
||||||
router.push(`/dashboard/${searchSpaceId}/user-settings?tab=profile`);
|
setUserSettingsDialog({ open: true, initialTab: "profile" });
|
||||||
}, [router, searchSpaceId]);
|
}, [setUserSettingsDialog]);
|
||||||
|
|
||||||
const handleSearchSpaceSettings = useCallback(
|
const handleSearchSpaceSettings = useCallback(
|
||||||
(space: SearchSpace) => {
|
(_space: SearchSpace) => {
|
||||||
router.push(`/dashboard/${space.id}/settings?tab=general`);
|
setSearchSpaceSettingsDialog({ open: true, initialTab: "general" });
|
||||||
},
|
},
|
||||||
[router]
|
[setSearchSpaceSettingsDialog]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSearchSpaceDeleteClick = useCallback((space: SearchSpace) => {
|
const handleSearchSpaceDeleteClick = useCallback((space: SearchSpace) => {
|
||||||
|
|
@ -582,12 +595,12 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSettings = useCallback(() => {
|
const handleSettings = useCallback(() => {
|
||||||
router.push(`/dashboard/${searchSpaceId}/settings?tab=general`);
|
setSearchSpaceSettingsDialog({ open: true, initialTab: "general" });
|
||||||
}, [router, searchSpaceId]);
|
}, [setSearchSpaceSettingsDialog]);
|
||||||
|
|
||||||
const handleManageMembers = useCallback(() => {
|
const handleManageMembers = useCallback(() => {
|
||||||
router.push(`/dashboard/${searchSpaceId}/team`);
|
setTeamDialogOpen(true);
|
||||||
}, [router, searchSpaceId]);
|
}, [setTeamDialogOpen]);
|
||||||
|
|
||||||
const handleLogout = useCallback(async () => {
|
const handleLogout = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -616,17 +629,25 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
const handleViewAllSharedChats = useCallback(() => {
|
const handleViewAllSharedChats = useCallback(() => {
|
||||||
setIsAllSharedChatsSidebarOpen(true);
|
setIsAllSharedChatsSidebarOpen((prev) => {
|
||||||
|
if (!prev) {
|
||||||
setIsAllPrivateChatsSidebarOpen(false);
|
setIsAllPrivateChatsSidebarOpen(false);
|
||||||
setIsInboxSidebarOpen(false);
|
setIsInboxSidebarOpen(false);
|
||||||
setIsAnnouncementsSidebarOpen(false);
|
setIsAnnouncementsSidebarOpen(false);
|
||||||
|
}
|
||||||
|
return !prev;
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleViewAllPrivateChats = useCallback(() => {
|
const handleViewAllPrivateChats = useCallback(() => {
|
||||||
setIsAllPrivateChatsSidebarOpen(true);
|
setIsAllPrivateChatsSidebarOpen((prev) => {
|
||||||
|
if (!prev) {
|
||||||
setIsAllSharedChatsSidebarOpen(false);
|
setIsAllSharedChatsSidebarOpen(false);
|
||||||
setIsInboxSidebarOpen(false);
|
setIsInboxSidebarOpen(false);
|
||||||
setIsAnnouncementsSidebarOpen(false);
|
setIsAnnouncementsSidebarOpen(false);
|
||||||
|
}
|
||||||
|
return !prev;
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Delete handlers
|
// Delete handlers
|
||||||
|
|
@ -803,7 +824,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
||||||
>
|
>
|
||||||
{isDeletingChat ? (
|
{isDeletingChat ? (
|
||||||
<>
|
<>
|
||||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
<Spinner size="sm" />
|
||||||
{t("deleting")}
|
{t("deleting")}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -934,6 +955,12 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
||||||
open={isCreateSearchSpaceDialogOpen}
|
open={isCreateSearchSpaceDialogOpen}
|
||||||
onOpenChange={setIsCreateSearchSpaceDialogOpen}
|
onOpenChange={setIsCreateSearchSpaceDialogOpen}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Settings Dialogs */}
|
||||||
|
<SearchSpaceSettingsDialog searchSpaceId={Number(searchSpaceId)} />
|
||||||
|
<UserSettingsDialog />
|
||||||
|
<TeamDialog searchSpaceId={Number(searchSpaceId)} />
|
||||||
|
<MorePagesDialog />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ export function Header({ mobileMenuTrigger }: HeaderProps) {
|
||||||
const showExpandButton = !isMobile && collapsed && hasRightPanelContent;
|
const showExpandButton = !isMobile && collapsed && hasRightPanelContent;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-10 flex h-14 shrink-0 items-center gap-2 bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 px-4">
|
<header className="sticky top-0 z-10 flex h-14 shrink-0 items-center gap-2 bg-main-panel/95 backdrop-blur supports-backdrop-filter:bg-main-panel/60 px-4">
|
||||||
{/* Left side - Mobile menu trigger + Model selector */}
|
{/* Left side - Mobile menu trigger + Model selector */}
|
||||||
<div className="flex flex-1 items-center gap-2 min-w-0">
|
<div className="flex flex-1 items-center gap-2 min-w-0">
|
||||||
{mobileMenuTrigger}
|
{mobileMenuTrigger}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,9 @@ import { PanelRight, PanelRightClose } from "lucide-react";
|
||||||
import { startTransition, useEffect } from "react";
|
import { startTransition, useEffect } from "react";
|
||||||
import { closeReportPanelAtom, reportPanelAtom } from "@/atoms/chat/report-panel.atom";
|
import { closeReportPanelAtom, reportPanelAtom } from "@/atoms/chat/report-panel.atom";
|
||||||
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
|
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
|
||||||
|
import { closeEditorPanelAtom, editorPanelAtom } from "@/atoms/editor/editor-panel.atom";
|
||||||
import { rightPanelCollapsedAtom, rightPanelTabAtom } from "@/atoms/layout/right-panel.atom";
|
import { rightPanelCollapsedAtom, rightPanelTabAtom } from "@/atoms/layout/right-panel.atom";
|
||||||
|
import { EditorPanelContent } from "@/components/editor-panel/editor-panel";
|
||||||
import { ReportPanelContent } from "@/components/report-panel/report-panel";
|
import { ReportPanelContent } from "@/components/report-panel/report-panel";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
|
@ -41,8 +43,10 @@ export function RightPanelExpandButton() {
|
||||||
const [collapsed, setCollapsed] = useAtom(rightPanelCollapsedAtom);
|
const [collapsed, setCollapsed] = useAtom(rightPanelCollapsedAtom);
|
||||||
const documentsOpen = useAtomValue(documentsSidebarOpenAtom);
|
const documentsOpen = useAtomValue(documentsSidebarOpenAtom);
|
||||||
const reportState = useAtomValue(reportPanelAtom);
|
const reportState = useAtomValue(reportPanelAtom);
|
||||||
|
const editorState = useAtomValue(editorPanelAtom);
|
||||||
const reportOpen = reportState.isOpen && !!reportState.reportId;
|
const reportOpen = reportState.isOpen && !!reportState.reportId;
|
||||||
const hasContent = documentsOpen || reportOpen;
|
const editorOpen = editorState.isOpen && !!editorState.documentId;
|
||||||
|
const hasContent = documentsOpen || reportOpen || editorOpen;
|
||||||
|
|
||||||
if (!collapsed || !hasContent) return null;
|
if (!collapsed || !hasContent) return null;
|
||||||
|
|
||||||
|
|
@ -66,34 +70,42 @@ export function RightPanelExpandButton() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const PANEL_WIDTHS = { sources: 420, report: 640 } as const;
|
const PANEL_WIDTHS = { sources: 420, report: 640, editor: 640 } as const;
|
||||||
|
|
||||||
export function RightPanel({ documentsPanel }: RightPanelProps) {
|
export function RightPanel({ documentsPanel }: RightPanelProps) {
|
||||||
const [activeTab] = useAtom(rightPanelTabAtom);
|
const [activeTab] = useAtom(rightPanelTabAtom);
|
||||||
const reportState = useAtomValue(reportPanelAtom);
|
const reportState = useAtomValue(reportPanelAtom);
|
||||||
const closeReport = useSetAtom(closeReportPanelAtom);
|
const closeReport = useSetAtom(closeReportPanelAtom);
|
||||||
|
const editorState = useAtomValue(editorPanelAtom);
|
||||||
|
const closeEditor = useSetAtom(closeEditorPanelAtom);
|
||||||
const [collapsed, setCollapsed] = useAtom(rightPanelCollapsedAtom);
|
const [collapsed, setCollapsed] = useAtom(rightPanelCollapsedAtom);
|
||||||
|
|
||||||
const documentsOpen = documentsPanel?.open ?? false;
|
const documentsOpen = documentsPanel?.open ?? false;
|
||||||
const reportOpen = reportState.isOpen && !!reportState.reportId;
|
const reportOpen = reportState.isOpen && !!reportState.reportId;
|
||||||
|
const editorOpen = editorState.isOpen && !!editorState.documentId;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!reportOpen) return;
|
if (!reportOpen && !editorOpen) return;
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") closeReport();
|
if (e.key === "Escape") {
|
||||||
|
if (editorOpen) closeEditor();
|
||||||
|
else if (reportOpen) closeReport();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [reportOpen, closeReport]);
|
}, [reportOpen, editorOpen, closeReport, closeEditor]);
|
||||||
|
|
||||||
const isVisible = (documentsOpen || reportOpen) && !collapsed;
|
const isVisible = (documentsOpen || reportOpen || editorOpen) && !collapsed;
|
||||||
|
|
||||||
const effectiveTab =
|
let effectiveTab = activeTab;
|
||||||
activeTab === "report" && !reportOpen
|
if (effectiveTab === "editor" && !editorOpen) {
|
||||||
? "sources"
|
effectiveTab = reportOpen ? "report" : "sources";
|
||||||
: activeTab === "sources" && !documentsOpen
|
} else if (effectiveTab === "report" && !reportOpen) {
|
||||||
? "report"
|
effectiveTab = editorOpen ? "editor" : "sources";
|
||||||
: activeTab;
|
} else if (effectiveTab === "sources" && !documentsOpen) {
|
||||||
|
effectiveTab = editorOpen ? "editor" : reportOpen ? "report" : "sources";
|
||||||
|
}
|
||||||
|
|
||||||
const targetWidth = PANEL_WIDTHS[effectiveTab];
|
const targetWidth = PANEL_WIDTHS[effectiveTab];
|
||||||
const collapseButton = <CollapseButton onClick={() => setCollapsed(true)} />;
|
const collapseButton = <CollapseButton onClick={() => setCollapsed(true)} />;
|
||||||
|
|
@ -103,7 +115,7 @@ export function RightPanel({ documentsPanel }: RightPanelProps) {
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
style={{ width: targetWidth }}
|
style={{ width: targetWidth }}
|
||||||
className="flex h-full shrink-0 flex-col border-l bg-background overflow-hidden transition-[width] duration-200 ease-out"
|
className="flex h-full shrink-0 flex-col rounded-xl border bg-sidebar text-sidebar-foreground overflow-hidden transition-[width] duration-200 ease-out"
|
||||||
>
|
>
|
||||||
<div className="relative flex-1 min-h-0 overflow-hidden">
|
<div className="relative flex-1 min-h-0 overflow-hidden">
|
||||||
{effectiveTab === "sources" && documentsOpen && documentsPanel && (
|
{effectiveTab === "sources" && documentsOpen && documentsPanel && (
|
||||||
|
|
@ -117,15 +129,25 @@ export function RightPanel({ documentsPanel }: RightPanelProps) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{effectiveTab === "report" && reportOpen && (
|
{effectiveTab === "report" && reportOpen && (
|
||||||
<div className="h-full">
|
<div className="h-full flex flex-col">
|
||||||
<ReportPanelContent
|
<ReportPanelContent
|
||||||
reportId={reportState.reportId!}
|
reportId={reportState.reportId as number}
|
||||||
title={reportState.title || "Report"}
|
title={reportState.title || "Report"}
|
||||||
onClose={closeReport}
|
onClose={closeReport}
|
||||||
shareToken={reportState.shareToken}
|
shareToken={reportState.shareToken}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{effectiveTab === "editor" && editorOpen && (
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
<EditorPanelContent
|
||||||
|
documentId={editorState.documentId as number}
|
||||||
|
searchSpaceId={editorState.searchSpaceId as number}
|
||||||
|
title={editorState.title}
|
||||||
|
onClose={closeEditor}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -160,7 +160,7 @@ export function LayoutShell({
|
||||||
return (
|
return (
|
||||||
<SidebarProvider value={sidebarContextValue}>
|
<SidebarProvider value={sidebarContextValue}>
|
||||||
<TooltipProvider delayDuration={0}>
|
<TooltipProvider delayDuration={0}>
|
||||||
<div className={cn("flex h-screen w-full flex-col bg-background", className)}>
|
<div className={cn("flex h-screen w-full flex-col bg-main-panel", className)}>
|
||||||
<Header
|
<Header
|
||||||
mobileMenuTrigger={<MobileSidebarTrigger onClick={() => setMobileMenuOpen(true)} />}
|
mobileMenuTrigger={<MobileSidebarTrigger onClick={() => setMobileMenuOpen(true)} />}
|
||||||
/>
|
/>
|
||||||
|
|
@ -187,6 +187,8 @@ export function LayoutShell({
|
||||||
onChatArchive={onChatArchive}
|
onChatArchive={onChatArchive}
|
||||||
onViewAllSharedChats={onViewAllSharedChats}
|
onViewAllSharedChats={onViewAllSharedChats}
|
||||||
onViewAllPrivateChats={onViewAllPrivateChats}
|
onViewAllPrivateChats={onViewAllPrivateChats}
|
||||||
|
isSharedChatsPanelOpen={allSharedChatsPanel?.open}
|
||||||
|
isPrivateChatsPanelOpen={allPrivateChatsPanel?.open}
|
||||||
user={user}
|
user={user}
|
||||||
onSettings={onSettings}
|
onSettings={onSettings}
|
||||||
onManageMembers={onManageMembers}
|
onManageMembers={onManageMembers}
|
||||||
|
|
@ -256,6 +258,12 @@ export function LayoutShell({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const anySlideOutOpen =
|
||||||
|
inbox?.isOpen ||
|
||||||
|
announcementsPanel?.open ||
|
||||||
|
allSharedChatsPanel?.open ||
|
||||||
|
allPrivateChatsPanel?.open;
|
||||||
|
|
||||||
// Desktop layout
|
// Desktop layout
|
||||||
return (
|
return (
|
||||||
<SidebarProvider value={sidebarContextValue}>
|
<SidebarProvider value={sidebarContextValue}>
|
||||||
|
|
@ -274,8 +282,13 @@ export function LayoutShell({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main container with sidebar and content - relative for inbox positioning */}
|
{/* Sidebar + slide-out panels share one container; overflow visible so panels can overlay main content */}
|
||||||
<div className="relative flex flex-1 rounded-xl border bg-background overflow-hidden">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative hidden md:flex shrink-0 border bg-sidebar z-20 transition-[border-radius,border-color] duration-200",
|
||||||
|
anySlideOutOpen ? "rounded-l-xl border-r-0 delay-0" : "rounded-xl delay-150"
|
||||||
|
)}
|
||||||
|
>
|
||||||
<Sidebar
|
<Sidebar
|
||||||
searchSpace={searchSpace}
|
searchSpace={searchSpace}
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
|
|
@ -292,6 +305,8 @@ export function LayoutShell({
|
||||||
onChatArchive={onChatArchive}
|
onChatArchive={onChatArchive}
|
||||||
onViewAllSharedChats={onViewAllSharedChats}
|
onViewAllSharedChats={onViewAllSharedChats}
|
||||||
onViewAllPrivateChats={onViewAllPrivateChats}
|
onViewAllPrivateChats={onViewAllPrivateChats}
|
||||||
|
isSharedChatsPanelOpen={allSharedChatsPanel?.open}
|
||||||
|
isPrivateChatsPanelOpen={allPrivateChatsPanel?.open}
|
||||||
user={user}
|
user={user}
|
||||||
onSettings={onSettings}
|
onSettings={onSettings}
|
||||||
onManageMembers={onManageMembers}
|
onManageMembers={onManageMembers}
|
||||||
|
|
@ -300,32 +315,16 @@ export function LayoutShell({
|
||||||
pageUsage={pageUsage}
|
pageUsage={pageUsage}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
setTheme={setTheme}
|
setTheme={setTheme}
|
||||||
className="hidden md:flex border-r shrink-0"
|
className={cn(
|
||||||
|
"flex shrink-0 transition-[border-radius] duration-200",
|
||||||
|
anySlideOutOpen ? "rounded-l-xl delay-0" : "rounded-xl delay-150"
|
||||||
|
)}
|
||||||
isLoadingChats={isLoadingChats}
|
isLoadingChats={isLoadingChats}
|
||||||
sidebarWidth={sidebarWidth}
|
sidebarWidth={sidebarWidth}
|
||||||
onResizeMouseDown={onResizeMouseDown}
|
|
||||||
isResizing={isResizing}
|
isResizing={isResizing}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<main className="flex-1 flex flex-col min-w-0">
|
{/* Slide-out panels render as siblings next to the sidebar */}
|
||||||
<Header />
|
|
||||||
|
|
||||||
<div className={cn("flex-1", isChatPage ? "overflow-hidden" : "overflow-auto")}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
{/* Right panel — tabbed Sources/Report (desktop only) */}
|
|
||||||
{documentsPanel && (
|
|
||||||
<RightPanel
|
|
||||||
documentsPanel={{
|
|
||||||
open: documentsPanel.open,
|
|
||||||
onOpenChange: documentsPanel.onOpenChange,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Inbox Sidebar - slide-out panel */}
|
|
||||||
{inbox && (
|
{inbox && (
|
||||||
<InboxSidebar
|
<InboxSidebar
|
||||||
open={inbox.isOpen}
|
open={inbox.isOpen}
|
||||||
|
|
@ -336,7 +335,6 @@ export function LayoutShell({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Announcements Sidebar */}
|
|
||||||
{announcementsPanel && (
|
{announcementsPanel && (
|
||||||
<AnnouncementsSidebar
|
<AnnouncementsSidebar
|
||||||
open={announcementsPanel.open}
|
open={announcementsPanel.open}
|
||||||
|
|
@ -344,7 +342,6 @@ export function LayoutShell({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* All Shared Chats - slide-out panel */}
|
|
||||||
{allSharedChatsPanel && (
|
{allSharedChatsPanel && (
|
||||||
<AllSharedChatsSidebar
|
<AllSharedChatsSidebar
|
||||||
open={allSharedChatsPanel.open}
|
open={allSharedChatsPanel.open}
|
||||||
|
|
@ -353,7 +350,6 @@ export function LayoutShell({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* All Private Chats - slide-out panel */}
|
|
||||||
{allPrivateChatsPanel && (
|
{allPrivateChatsPanel && (
|
||||||
<AllPrivateChatsSidebar
|
<AllPrivateChatsSidebar
|
||||||
open={allPrivateChatsPanel.open}
|
open={allPrivateChatsPanel.open}
|
||||||
|
|
@ -362,6 +358,40 @@ export function LayoutShell({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Resize handle — negative margins eat the flex gap so spacing stays unchanged */}
|
||||||
|
{!isCollapsed && (
|
||||||
|
<div
|
||||||
|
role="slider"
|
||||||
|
aria-label="Resize sidebar"
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
aria-valuenow={50}
|
||||||
|
tabIndex={0}
|
||||||
|
onMouseDown={onResizeMouseDown}
|
||||||
|
className="hidden md:block h-full cursor-col-resize z-30 focus:outline-none"
|
||||||
|
style={{ width: 8, marginLeft: -8, marginRight: -8 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main content panel */}
|
||||||
|
<div className="relative flex flex-1 flex-col rounded-xl border bg-main-panel overflow-hidden min-w-0">
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<div className={cn("flex-1", isChatPage ? "overflow-hidden" : "overflow-auto")}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right panel — tabbed Sources/Report (desktop only) */}
|
||||||
|
{documentsPanel && (
|
||||||
|
<RightPanel
|
||||||
|
documentsPanel={{
|
||||||
|
open: documentsPanel.open,
|
||||||
|
onOpenChange: documentsPanel.onOpenChange,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
|
|
|
||||||
|
|
@ -31,13 +31,12 @@ import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/animated-tabs";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||||
import { useLongPress } from "@/hooks/use-long-press";
|
import { useLongPress } from "@/hooks/use-long-press";
|
||||||
|
|
@ -300,14 +299,11 @@ export function AllPrivateChatsSidebar({
|
||||||
<Tabs
|
<Tabs
|
||||||
value={showArchived ? "archived" : "active"}
|
value={showArchived ? "archived" : "active"}
|
||||||
onValueChange={(value) => setShowArchived(value === "archived")}
|
onValueChange={(value) => setShowArchived(value === "archived")}
|
||||||
className="shrink-0 mx-4"
|
className="shrink-0 mx-4 mt-2"
|
||||||
>
|
>
|
||||||
<TabsList className="w-full h-auto p-0 bg-transparent rounded-none border-b">
|
<TabsList stretch showBottomBorder size="sm">
|
||||||
<TabsTrigger
|
<TabsTrigger value="active">
|
||||||
value="active"
|
<span className="inline-flex items-center gap-1.5">
|
||||||
className="flex-1 rounded-none border-b-2 border-transparent px-1 py-2 text-xs font-medium data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
|
|
||||||
>
|
|
||||||
<span className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-muted transition-colors">
|
|
||||||
<MessageCircleMore className="h-4 w-4" />
|
<MessageCircleMore className="h-4 w-4" />
|
||||||
<span>Active</span>
|
<span>Active</span>
|
||||||
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
|
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
|
||||||
|
|
@ -315,11 +311,8 @@ export function AllPrivateChatsSidebar({
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger value="archived">
|
||||||
value="archived"
|
<span className="inline-flex items-center gap-1.5">
|
||||||
className="flex-1 rounded-none border-b-2 border-transparent px-1 py-2 text-xs font-medium data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
|
|
||||||
>
|
|
||||||
<span className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-muted transition-colors">
|
|
||||||
<ArchiveIcon className="h-4 w-4" />
|
<ArchiveIcon className="h-4 w-4" />
|
||||||
<span>Archived</span>
|
<span>Archived</span>
|
||||||
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
|
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
|
||||||
|
|
@ -334,8 +327,11 @@ export function AllPrivateChatsSidebar({
|
||||||
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
|
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{[75, 90, 55, 80, 65, 85].map((titleWidth, i) => (
|
{[75, 90, 55, 80, 65, 85].map((titleWidth) => (
|
||||||
<div key={`skeleton-${i}`} className="flex items-center gap-2 rounded-md px-2 py-1.5">
|
<div
|
||||||
|
key={`skeleton-${titleWidth}`}
|
||||||
|
className="flex items-center gap-2 rounded-md px-2 py-1.5"
|
||||||
|
>
|
||||||
<Skeleton className="h-4 w-4 shrink-0 rounded" />
|
<Skeleton className="h-4 w-4 shrink-0 rounded" />
|
||||||
<Skeleton className="h-4 rounded" style={{ width: `${titleWidth}%` }} />
|
<Skeleton className="h-4 rounded" style={{ width: `${titleWidth}%` }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -380,7 +376,6 @@ export function AllPrivateChatsSidebar({
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
|
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
|
||||||
>
|
>
|
||||||
<MessageCircleMore className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
||||||
<span className="truncate">{thread.title || "New Chat"}</span>
|
<span className="truncate">{thread.title || "New Chat"}</span>
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -392,7 +387,6 @@ export function AllPrivateChatsSidebar({
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
|
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
|
||||||
>
|
>
|
||||||
<MessageCircleMore className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
||||||
<span className="truncate">{thread.title || "New Chat"}</span>
|
<span className="truncate">{thread.title || "New Chat"}</span>
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
|
|
@ -455,7 +449,6 @@ export function AllPrivateChatsSidebar({
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem onClick={() => handleDeleteThread(thread.id)}>
|
<DropdownMenuItem onClick={() => handleDeleteThread(thread.id)}>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
<span>{t("delete") || "Delete"}</span>
|
<span>{t("delete") || "Delete"}</span>
|
||||||
|
|
|
||||||
|
|
@ -31,13 +31,12 @@ import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/animated-tabs";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||||
import { useLongPress } from "@/hooks/use-long-press";
|
import { useLongPress } from "@/hooks/use-long-press";
|
||||||
|
|
@ -300,14 +299,11 @@ export function AllSharedChatsSidebar({
|
||||||
<Tabs
|
<Tabs
|
||||||
value={showArchived ? "archived" : "active"}
|
value={showArchived ? "archived" : "active"}
|
||||||
onValueChange={(value) => setShowArchived(value === "archived")}
|
onValueChange={(value) => setShowArchived(value === "archived")}
|
||||||
className="shrink-0 mx-4"
|
className="shrink-0 mx-4 mt-2"
|
||||||
>
|
>
|
||||||
<TabsList className="w-full h-auto p-0 bg-transparent rounded-none border-b">
|
<TabsList stretch showBottomBorder size="sm">
|
||||||
<TabsTrigger
|
<TabsTrigger value="active">
|
||||||
value="active"
|
<span className="inline-flex items-center gap-1.5">
|
||||||
className="flex-1 rounded-none border-b-2 border-transparent px-1 py-2 text-xs font-medium data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
|
|
||||||
>
|
|
||||||
<span className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-muted transition-colors">
|
|
||||||
<MessageCircleMore className="h-4 w-4" />
|
<MessageCircleMore className="h-4 w-4" />
|
||||||
<span>Active</span>
|
<span>Active</span>
|
||||||
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
|
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
|
||||||
|
|
@ -315,11 +311,8 @@ export function AllSharedChatsSidebar({
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger value="archived">
|
||||||
value="archived"
|
<span className="inline-flex items-center gap-1.5">
|
||||||
className="flex-1 rounded-none border-b-2 border-transparent px-1 py-2 text-xs font-medium data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
|
|
||||||
>
|
|
||||||
<span className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-muted transition-colors">
|
|
||||||
<ArchiveIcon className="h-4 w-4" />
|
<ArchiveIcon className="h-4 w-4" />
|
||||||
<span>Archived</span>
|
<span>Archived</span>
|
||||||
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
|
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
|
||||||
|
|
@ -334,8 +327,11 @@ export function AllSharedChatsSidebar({
|
||||||
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
|
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{[75, 90, 55, 80, 65, 85].map((titleWidth, i) => (
|
{[75, 90, 55, 80, 65, 85].map((titleWidth) => (
|
||||||
<div key={`skeleton-${i}`} className="flex items-center gap-2 rounded-md px-2 py-1.5">
|
<div
|
||||||
|
key={`skeleton-${titleWidth}`}
|
||||||
|
className="flex items-center gap-2 rounded-md px-2 py-1.5"
|
||||||
|
>
|
||||||
<Skeleton className="h-4 w-4 shrink-0 rounded" />
|
<Skeleton className="h-4 w-4 shrink-0 rounded" />
|
||||||
<Skeleton className="h-4 rounded" style={{ width: `${titleWidth}%` }} />
|
<Skeleton className="h-4 rounded" style={{ width: `${titleWidth}%` }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -380,7 +376,6 @@ export function AllSharedChatsSidebar({
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
|
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
|
||||||
>
|
>
|
||||||
<MessageCircleMore className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
||||||
<span className="truncate">{thread.title || "New Chat"}</span>
|
<span className="truncate">{thread.title || "New Chat"}</span>
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -392,7 +387,6 @@ export function AllSharedChatsSidebar({
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
|
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
|
||||||
>
|
>
|
||||||
<MessageCircleMore className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
||||||
<span className="truncate">{thread.title || "New Chat"}</span>
|
<span className="truncate">{thread.title || "New Chat"}</span>
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
|
|
@ -455,7 +449,6 @@ export function AllSharedChatsSidebar({
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem onClick={() => handleDeleteThread(thread.id)}>
|
<DropdownMenuItem onClick={() => handleDeleteThread(thread.id)}>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
<span>{t("delete") || "Delete"}</span>
|
<span>{t("delete") || "Delete"}</span>
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ export function ChatListItem({
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
{...(isMobile ? longPressHandlers : {})}
|
{...(isMobile ? longPressHandlers : {})}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-sm text-left transition-colors",
|
"flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-sm text-left",
|
||||||
"group-hover/item:bg-accent group-hover/item:text-accent-foreground",
|
"group-hover/item:bg-accent group-hover/item:text-accent-foreground",
|
||||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||||
isActive && "bg-accent text-accent-foreground"
|
isActive && "bg-accent text-accent-foreground"
|
||||||
|
|
|
||||||
|
|
@ -358,7 +358,6 @@ export function DocumentsSidebar({
|
||||||
onLoadMore={onLoadMore}
|
onLoadMore={onLoadMore}
|
||||||
mentionedDocIds={mentionedDocIds}
|
mentionedDocIds={mentionedDocIds}
|
||||||
onToggleChatMention={handleToggleChatMention}
|
onToggleChatMention={handleToggleChatMention}
|
||||||
onEditNavigate={() => onOpenChange(false)}
|
|
||||||
isSearchMode={isSearchMode || activeTypes.length > 0}
|
isSearchMode={isSearchMode || activeTypes.length > 0}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -856,9 +856,9 @@ export function InboxSidebar({
|
||||||
<TabsList className="w-full h-auto p-0 bg-transparent rounded-none border-b">
|
<TabsList className="w-full h-auto p-0 bg-transparent rounded-none border-b">
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="comments"
|
value="comments"
|
||||||
className="flex-1 rounded-none border-b-2 border-transparent px-1 py-2 text-xs font-medium data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
|
className="group flex-1 rounded-none border-b-2 border-transparent px-1 py-2 text-xs font-medium data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
|
||||||
>
|
>
|
||||||
<span className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-muted transition-colors">
|
<span className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-muted group-data-[state=active]:bg-muted transition-colors">
|
||||||
<MessageSquare className="h-4 w-4" />
|
<MessageSquare className="h-4 w-4" />
|
||||||
<span>{t("comments") || "Comments"}</span>
|
<span>{t("comments") || "Comments"}</span>
|
||||||
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
|
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
|
||||||
|
|
@ -868,9 +868,9 @@ export function InboxSidebar({
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="status"
|
value="status"
|
||||||
className="flex-1 rounded-none border-b-2 border-transparent px-1 py-2 text-xs font-medium data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
|
className="group flex-1 rounded-none border-b-2 border-transparent px-1 py-2 text-xs font-medium data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
|
||||||
>
|
>
|
||||||
<span className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-muted transition-colors">
|
<span className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-muted group-data-[state=active]:bg-muted transition-colors">
|
||||||
<History className="h-4 w-4" />
|
<History className="h-4 w-4" />
|
||||||
<span>{t("status") || "Status"}</span>
|
<span>{t("status") || "Status"}</span>
|
||||||
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
|
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,6 @@ interface MobileSidebarProps {
|
||||||
searchSpaces: SearchSpace[];
|
searchSpaces: SearchSpace[];
|
||||||
activeSearchSpaceId: number | null;
|
activeSearchSpaceId: number | null;
|
||||||
onSearchSpaceSelect: (id: number) => void;
|
onSearchSpaceSelect: (id: number) => void;
|
||||||
onSearchSpaceDelete?: (searchSpace: SearchSpace) => void;
|
|
||||||
onSearchSpaceSettings?: (searchSpace: SearchSpace) => void;
|
|
||||||
onAddSearchSpace: () => void;
|
onAddSearchSpace: () => void;
|
||||||
searchSpace: SearchSpace | null;
|
searchSpace: SearchSpace | null;
|
||||||
navItems: NavItem[];
|
navItems: NavItem[];
|
||||||
|
|
@ -30,6 +28,8 @@ interface MobileSidebarProps {
|
||||||
onChatArchive?: (chat: ChatItem) => void;
|
onChatArchive?: (chat: ChatItem) => void;
|
||||||
onViewAllSharedChats?: () => void;
|
onViewAllSharedChats?: () => void;
|
||||||
onViewAllPrivateChats?: () => void;
|
onViewAllPrivateChats?: () => void;
|
||||||
|
isSharedChatsPanelOpen?: boolean;
|
||||||
|
isPrivateChatsPanelOpen?: boolean;
|
||||||
user: User;
|
user: User;
|
||||||
onSettings?: () => void;
|
onSettings?: () => void;
|
||||||
onManageMembers?: () => void;
|
onManageMembers?: () => void;
|
||||||
|
|
@ -56,8 +56,7 @@ export function MobileSidebar({
|
||||||
searchSpaces,
|
searchSpaces,
|
||||||
activeSearchSpaceId,
|
activeSearchSpaceId,
|
||||||
onSearchSpaceSelect,
|
onSearchSpaceSelect,
|
||||||
onSearchSpaceDelete,
|
|
||||||
onSearchSpaceSettings,
|
|
||||||
onAddSearchSpace,
|
onAddSearchSpace,
|
||||||
searchSpace,
|
searchSpace,
|
||||||
navItems,
|
navItems,
|
||||||
|
|
@ -72,6 +71,8 @@ export function MobileSidebar({
|
||||||
onChatArchive,
|
onChatArchive,
|
||||||
onViewAllSharedChats,
|
onViewAllSharedChats,
|
||||||
onViewAllPrivateChats,
|
onViewAllPrivateChats,
|
||||||
|
isSharedChatsPanelOpen = false,
|
||||||
|
isPrivateChatsPanelOpen = false,
|
||||||
user,
|
user,
|
||||||
onSettings,
|
onSettings,
|
||||||
onManageMembers,
|
onManageMembers,
|
||||||
|
|
@ -165,6 +166,8 @@ export function MobileSidebar({
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
isSharedChatsPanelOpen={isSharedChatsPanelOpen}
|
||||||
|
isPrivateChatsPanelOpen={isPrivateChatsPanelOpen}
|
||||||
user={user}
|
user={user}
|
||||||
onSettings={
|
onSettings={
|
||||||
onSettings
|
onSettings
|
||||||
|
|
|
||||||
|
|
@ -74,9 +74,7 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
|
||||||
const indicator = item.statusIndicator;
|
const indicator = item.statusIndicator;
|
||||||
|
|
||||||
const joyrideAttr =
|
const joyrideAttr =
|
||||||
item.title === "Documents" || item.title.toLowerCase().includes("documents")
|
item.title === "Inbox" || item.title.toLowerCase().includes("inbox")
|
||||||
? { "data-joyride": "documents-sidebar" }
|
|
||||||
: item.title === "Inbox" || item.title.toLowerCase().includes("inbox")
|
|
||||||
? { "data-joyride": "inbox-sidebar" }
|
? { "data-joyride": "inbox-sidebar" }
|
||||||
: {};
|
: {};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useSetAtom } from "jotai";
|
||||||
import { Zap } from "lucide-react";
|
import { Zap } from "lucide-react";
|
||||||
import Link from "next/link";
|
import { morePagesDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
|
||||||
import { useParams } from "next/navigation";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
|
||||||
|
|
@ -12,8 +12,7 @@ interface PageUsageDisplayProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PageUsageDisplay({ pagesUsed, pagesLimit }: PageUsageDisplayProps) {
|
export function PageUsageDisplay({ pagesUsed, pagesLimit }: PageUsageDisplayProps) {
|
||||||
const params = useParams();
|
const setMorePagesOpen = useSetAtom(morePagesDialogAtom);
|
||||||
const searchSpaceId = params.search_space_id;
|
|
||||||
const usagePercentage = (pagesUsed / pagesLimit) * 100;
|
const usagePercentage = (pagesUsed / pagesLimit) * 100;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -26,9 +25,10 @@ export function PageUsageDisplay({ pagesUsed, pagesLimit }: PageUsageDisplayProp
|
||||||
<span className="font-medium">{usagePercentage.toFixed(0)}%</span>
|
<span className="font-medium">{usagePercentage.toFixed(0)}%</span>
|
||||||
</div>
|
</div>
|
||||||
<Progress value={usagePercentage} className="h-1.5" />
|
<Progress value={usagePercentage} className="h-1.5" />
|
||||||
<Link
|
<button
|
||||||
href={`/dashboard/${searchSpaceId}/more-pages`}
|
type="button"
|
||||||
className="group flex items-center justify-between rounded-md px-1.5 py-1 -mx-1.5 transition-colors hover:bg-accent"
|
onClick={() => setMorePagesOpen(true)}
|
||||||
|
className="group flex w-full items-center justify-between rounded-md px-1.5 py-1 -mx-1.5 transition-colors hover:bg-accent"
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-1.5 text-xs text-muted-foreground group-hover:text-accent-foreground">
|
<span className="flex items-center gap-1.5 text-xs text-muted-foreground group-hover:text-accent-foreground">
|
||||||
<Zap className="h-3 w-3 shrink-0" />
|
<Zap className="h-3 w-3 shrink-0" />
|
||||||
|
|
@ -37,7 +37,7 @@ export function PageUsageDisplay({ pagesUsed, pagesLimit }: PageUsageDisplayProp
|
||||||
<Badge className="h-4 rounded px-1 text-[10px] font-semibold leading-none bg-emerald-600 text-white border-transparent hover:bg-emerald-600">
|
<Badge className="h-4 rounded px-1 text-[10px] font-semibold leading-none bg-emerald-600 text-white border-transparent hover:bg-emerald-600">
|
||||||
FREE
|
FREE
|
||||||
</Badge>
|
</Badge>
|
||||||
</Link>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { FolderOpen, PenSquare } from "lucide-react";
|
import { PenSquare } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -42,6 +42,8 @@ interface SidebarProps {
|
||||||
onChatArchive?: (chat: ChatItem) => void;
|
onChatArchive?: (chat: ChatItem) => void;
|
||||||
onViewAllSharedChats?: () => void;
|
onViewAllSharedChats?: () => void;
|
||||||
onViewAllPrivateChats?: () => void;
|
onViewAllPrivateChats?: () => void;
|
||||||
|
isSharedChatsPanelOpen?: boolean;
|
||||||
|
isPrivateChatsPanelOpen?: boolean;
|
||||||
user: User;
|
user: User;
|
||||||
onSettings?: () => void;
|
onSettings?: () => void;
|
||||||
onManageMembers?: () => void;
|
onManageMembers?: () => void;
|
||||||
|
|
@ -54,7 +56,6 @@ interface SidebarProps {
|
||||||
isLoadingChats?: boolean;
|
isLoadingChats?: boolean;
|
||||||
disableTooltips?: boolean;
|
disableTooltips?: boolean;
|
||||||
sidebarWidth?: number;
|
sidebarWidth?: number;
|
||||||
onResizeMouseDown?: (e: React.MouseEvent) => void;
|
|
||||||
isResizing?: boolean;
|
isResizing?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -74,6 +75,8 @@ export function Sidebar({
|
||||||
onChatArchive,
|
onChatArchive,
|
||||||
onViewAllSharedChats,
|
onViewAllSharedChats,
|
||||||
onViewAllPrivateChats,
|
onViewAllPrivateChats,
|
||||||
|
isSharedChatsPanelOpen = false,
|
||||||
|
isPrivateChatsPanelOpen = false,
|
||||||
user,
|
user,
|
||||||
onSettings,
|
onSettings,
|
||||||
onManageMembers,
|
onManageMembers,
|
||||||
|
|
@ -86,7 +89,6 @@ export function Sidebar({
|
||||||
isLoadingChats = false,
|
isLoadingChats = false,
|
||||||
disableTooltips = false,
|
disableTooltips = false,
|
||||||
sidebarWidth = SIDEBAR_MIN_WIDTH,
|
sidebarWidth = SIDEBAR_MIN_WIDTH,
|
||||||
onResizeMouseDown,
|
|
||||||
isResizing = false,
|
isResizing = false,
|
||||||
}: SidebarProps) {
|
}: SidebarProps) {
|
||||||
const t = useTranslations("sidebar");
|
const t = useTranslations("sidebar");
|
||||||
|
|
@ -102,19 +104,6 @@ export function Sidebar({
|
||||||
)}
|
)}
|
||||||
style={!isCollapsed ? { width: sidebarWidth } : undefined}
|
style={!isCollapsed ? { width: sidebarWidth } : undefined}
|
||||||
>
|
>
|
||||||
{/* Resize handle on right border */}
|
|
||||||
{!isCollapsed && onResizeMouseDown && (
|
|
||||||
<div
|
|
||||||
role="slider"
|
|
||||||
aria-label="Resize sidebar"
|
|
||||||
aria-valuemin={0}
|
|
||||||
aria-valuemax={100}
|
|
||||||
aria-valuenow={50}
|
|
||||||
tabIndex={0}
|
|
||||||
onMouseDown={onResizeMouseDown}
|
|
||||||
className="absolute right-0 top-0 h-full w-1 cursor-col-resize hover:bg-border active:bg-border z-10"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{/* Header - search space name or collapse button when collapsed */}
|
{/* Header - search space name or collapse button when collapsed */}
|
||||||
{isCollapsed ? (
|
{isCollapsed ? (
|
||||||
<div className="flex h-14 shrink-0 items-center justify-center border-b">
|
<div className="flex h-14 shrink-0 items-center justify-center border-b">
|
||||||
|
|
@ -173,34 +162,16 @@ export function Sidebar({
|
||||||
defaultOpen={true}
|
defaultOpen={true}
|
||||||
fillHeight={false}
|
fillHeight={false}
|
||||||
className="shrink-0 max-h-[50%] flex flex-col"
|
className="shrink-0 max-h-[50%] flex flex-col"
|
||||||
|
alwaysShowAction={!disableTooltips && isSharedChatsPanelOpen}
|
||||||
action={
|
action={
|
||||||
onViewAllSharedChats ? (
|
onViewAllSharedChats ? (
|
||||||
disableTooltips ? (
|
<button
|
||||||
<Button
|
type="button"
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 shrink-0 hover:bg-transparent hover:text-current focus-visible:ring-0"
|
|
||||||
onClick={onViewAllSharedChats}
|
onClick={onViewAllSharedChats}
|
||||||
|
className="text-[10px] text-muted-foreground/70 hover:text-muted-foreground transition-colors whitespace-nowrap cursor-pointer bg-transparent border-none p-0 focus:outline-none"
|
||||||
>
|
>
|
||||||
<FolderOpen className="h-4 w-4" />
|
{!disableTooltips && isSharedChatsPanelOpen ? t("hide") : t("show_all")}
|
||||||
</Button>
|
</button>
|
||||||
) : (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 shrink-0 hover:bg-transparent hover:text-current focus-visible:ring-0"
|
|
||||||
onClick={onViewAllSharedChats}
|
|
||||||
>
|
|
||||||
<FolderOpen className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top">
|
|
||||||
{t("view_all_shared_chats") || "View all shared chats"}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
@ -247,34 +218,16 @@ export function Sidebar({
|
||||||
title={t("chats")}
|
title={t("chats")}
|
||||||
defaultOpen={true}
|
defaultOpen={true}
|
||||||
fillHeight={true}
|
fillHeight={true}
|
||||||
|
alwaysShowAction={!disableTooltips && isPrivateChatsPanelOpen}
|
||||||
action={
|
action={
|
||||||
onViewAllPrivateChats ? (
|
onViewAllPrivateChats ? (
|
||||||
disableTooltips ? (
|
<button
|
||||||
<Button
|
type="button"
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 shrink-0 hover:bg-transparent hover:text-current focus-visible:ring-0"
|
|
||||||
onClick={onViewAllPrivateChats}
|
onClick={onViewAllPrivateChats}
|
||||||
|
className="text-[10px] text-muted-foreground/70 hover:text-muted-foreground transition-colors whitespace-nowrap cursor-pointer bg-transparent border-none p-0 focus:outline-none"
|
||||||
>
|
>
|
||||||
<FolderOpen className="h-4 w-4" />
|
{!disableTooltips && isPrivateChatsPanelOpen ? t("hide") : t("show_all")}
|
||||||
</Button>
|
</button>
|
||||||
) : (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 shrink-0 hover:bg-transparent hover:text-current focus-visible:ring-0"
|
|
||||||
onClick={onViewAllPrivateChats}
|
|
||||||
>
|
|
||||||
<FolderOpen className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top">
|
|
||||||
{t("view_all_private_chats") || "View all private chats"}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ interface SidebarSectionProps {
|
||||||
defaultOpen?: boolean;
|
defaultOpen?: boolean;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
action?: React.ReactNode;
|
action?: React.ReactNode;
|
||||||
|
alwaysShowAction?: boolean;
|
||||||
persistentAction?: React.ReactNode;
|
persistentAction?: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
fillHeight?: boolean;
|
fillHeight?: boolean;
|
||||||
|
|
@ -20,6 +21,7 @@ export function SidebarSection({
|
||||||
defaultOpen = true,
|
defaultOpen = true,
|
||||||
children,
|
children,
|
||||||
action,
|
action,
|
||||||
|
alwaysShowAction = false,
|
||||||
persistentAction,
|
persistentAction,
|
||||||
className,
|
className,
|
||||||
fillHeight = false,
|
fillHeight = false,
|
||||||
|
|
@ -37,27 +39,32 @@ export function SidebarSection({
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center group/section shrink-0">
|
<div className="flex items-center group/section shrink-0 px-2 py-1.5">
|
||||||
<CollapsibleTrigger className="flex flex-1 items-center gap-1.5 px-2 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors min-w-0">
|
<CollapsibleTrigger className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors min-w-0">
|
||||||
|
<span className="truncate">{title}</span>
|
||||||
<ChevronRight
|
<ChevronRight
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-3.5 w-3.5 shrink-0 transition-transform duration-200",
|
"h-3.5 w-3.5 shrink-0 transition-transform duration-200",
|
||||||
isOpen && "rotate-90"
|
isOpen && "rotate-90"
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<span className="uppercase tracking-wider truncate">{title}</span>
|
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
|
|
||||||
{/* Action button - visible on hover (always visible on mobile) */}
|
|
||||||
{action && (
|
{action && (
|
||||||
<div className="shrink-0 opacity-100 md:opacity-0 md:group-hover/section:opacity-100 transition-opacity pr-1 flex items-center">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"transition-opacity ml-1.5 flex items-center",
|
||||||
|
alwaysShowAction
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-100 md:opacity-0 md:group-hover/section:opacity-100"
|
||||||
|
)}
|
||||||
|
>
|
||||||
{action}
|
{action}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Persistent action - always visible */}
|
|
||||||
{persistentAction && (
|
{persistentAction && (
|
||||||
<div className="shrink-0 pr-1 flex items-center">{persistentAction}</div>
|
<div className="shrink-0 ml-auto flex items-center">{persistentAction}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,11 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { useEffect } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { useSidebarContextSafe } from "../../hooks";
|
|
||||||
|
|
||||||
export const SLIDEOUT_PANEL_OPENED_EVENT = "slideout-panel-opened";
|
export const SLIDEOUT_PANEL_OPENED_EVENT = "slideout-panel-opened";
|
||||||
|
|
||||||
const SIDEBAR_COLLAPSED_WIDTH = 60;
|
|
||||||
|
|
||||||
interface SidebarSlideOutPanelProps {
|
interface SidebarSlideOutPanelProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
|
|
@ -19,11 +15,12 @@ interface SidebarSlideOutPanelProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reusable slide-out panel that appears from the right edge of the sidebar.
|
* Reusable slide-out panel that extends from the sidebar.
|
||||||
* Used by InboxSidebar (floating mode), AllSharedChatsSidebar, and AllPrivateChatsSidebar.
|
|
||||||
*
|
*
|
||||||
* Must be rendered inside a positioned container (the LayoutShell's relative flex container)
|
* Desktop: absolutely positioned at the sidebar's right edge, overlaying the main
|
||||||
* and within the SidebarProvider context.
|
* content with a blur backdrop. Does not push/shrink the main content.
|
||||||
|
*
|
||||||
|
* Mobile: full-width absolute overlay (unchanged).
|
||||||
*/
|
*/
|
||||||
export function SidebarSlideOutPanel({
|
export function SidebarSlideOutPanel({
|
||||||
open,
|
open,
|
||||||
|
|
@ -33,11 +30,6 @@ export function SidebarSlideOutPanel({
|
||||||
children,
|
children,
|
||||||
}: SidebarSlideOutPanelProps) {
|
}: SidebarSlideOutPanelProps) {
|
||||||
const isMobile = !useMediaQuery("(min-width: 640px)");
|
const isMobile = !useMediaQuery("(min-width: 640px)");
|
||||||
const sidebarContext = useSidebarContextSafe();
|
|
||||||
const isCollapsed = sidebarContext?.isCollapsed ?? false;
|
|
||||||
const sidebarWidth = isCollapsed
|
|
||||||
? SIDEBAR_COLLAPSED_WIDTH
|
|
||||||
: (sidebarContext?.sidebarWidth ?? 240);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
|
|
@ -45,41 +37,30 @@ export function SidebarSlideOutPanel({
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
|
const handleEscape = useCallback(
|
||||||
|
(e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") onOpenChange(false);
|
||||||
|
},
|
||||||
|
[onOpenChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
document.addEventListener("keydown", handleEscape);
|
||||||
|
return () => document.removeEventListener("keydown", handleEscape);
|
||||||
|
}, [open, handleEscape]);
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{open && (
|
{open && (
|
||||||
<>
|
<div className="absolute left-0 inset-y-0 z-30 w-full overflow-hidden pointer-events-none">
|
||||||
{/* Backdrop overlay with blur — desktop only, covers main content area (right of sidebar) */}
|
|
||||||
{!isMobile && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
transition={{ duration: 0.15 }}
|
|
||||||
style={{ left: sidebarWidth }}
|
|
||||||
className="absolute inset-y-0 right-0 z-20 bg-black/30 backdrop-blur-sm"
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Clip container - positioned at sidebar edge with overflow hidden */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
left: isMobile ? 0 : sidebarWidth,
|
|
||||||
width: isMobile ? "100%" : width,
|
|
||||||
}}
|
|
||||||
className={cn("absolute z-30 overflow-hidden pointer-events-none", "inset-y-0")}
|
|
||||||
>
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ x: "-100%" }}
|
initial={{ x: "-100%" }}
|
||||||
animate={{ x: 0 }}
|
animate={{ x: 0 }}
|
||||||
exit={{ x: "-100%" }}
|
exit={{ x: "-100%" }}
|
||||||
transition={{ type: "tween", duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
|
transition={{ type: "tween", duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
|
||||||
className={cn(
|
className="h-full w-full bg-sidebar text-sidebar-foreground flex flex-col pointer-events-auto select-none"
|
||||||
"h-full w-full bg-sidebar text-sidebar-foreground flex flex-col pointer-events-auto select-none",
|
|
||||||
"sm:border-r sm:shadow-xl"
|
|
||||||
)}
|
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
|
|
@ -87,6 +68,45 @@ export function SidebarSlideOutPanel({
|
||||||
{children}
|
{children}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{open && (
|
||||||
|
<>
|
||||||
|
{/* Blur backdrop covering the main content area (right of sidebar) */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
className="absolute z-10 bg-black/30 backdrop-blur-sm rounded-xl"
|
||||||
|
style={{ top: -9, bottom: -9, left: "calc(100% + 1px)", width: "200vw" }}
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Panel extending from sidebar's right edge, flush with the wrapper border */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ width: 0 }}
|
||||||
|
animate={{ width }}
|
||||||
|
exit={{ width: 0 }}
|
||||||
|
transition={{ type: "tween", duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
|
||||||
|
className="absolute z-20 overflow-hidden"
|
||||||
|
style={{ left: "100%", top: -1, bottom: -1 }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{ width }}
|
||||||
|
className="h-full bg-sidebar text-sidebar-foreground flex flex-col select-none border rounded-r-xl shadow-xl"
|
||||||
|
role="dialog"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
|
||||||
|
|
@ -122,7 +122,7 @@ function UserAvatar({
|
||||||
alt="User avatar"
|
alt="User avatar"
|
||||||
width={32}
|
width={32}
|
||||||
height={32}
|
height={32}
|
||||||
className="h-8 w-8 shrink-0 rounded-lg object-cover"
|
className="h-8 w-8 shrink-0 rounded-lg object-cover select-none"
|
||||||
referrerPolicy="no-referrer"
|
referrerPolicy="no-referrer"
|
||||||
unoptimized
|
unoptimized
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,13 @@
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useAtomValue, useSetAtom } from "jotai";
|
import { useAtomValue, useSetAtom } from "jotai";
|
||||||
import { Earth, User, Users } from "lucide-react";
|
import { Earth, User, Users } from "lucide-react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { currentThreadAtom, setThreadVisibilityAtom } from "@/atoms/chat/current-thread.atom";
|
import { currentThreadAtom, setThreadVisibilityAtom } from "@/atoms/chat/current-thread.atom";
|
||||||
import { myAccessAtom } from "@/atoms/members/members-query.atoms";
|
import { myAccessAtom } from "@/atoms/members/members-query.atoms";
|
||||||
import { createPublicChatSnapshotMutationAtom } from "@/atoms/public-chat-snapshots/public-chat-snapshots-mutation.atoms";
|
import { createPublicChatSnapshotMutationAtom } from "@/atoms/public-chat-snapshots/public-chat-snapshots-mutation.atoms";
|
||||||
|
import { searchSpaceSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
|
@ -48,9 +49,8 @@ const visibilityOptions: {
|
||||||
|
|
||||||
export function ChatShareButton({ thread, onVisibilityChange, className }: ChatShareButtonProps) {
|
export function ChatShareButton({ thread, onVisibilityChange, className }: ChatShareButtonProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const router = useRouter();
|
|
||||||
const params = useParams();
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const setSearchSpaceSettingsDialog = useSetAtom(searchSpaceSettingsDialogAtom);
|
||||||
|
|
||||||
// Use Jotai atom for visibility (single source of truth)
|
// Use Jotai atom for visibility (single source of truth)
|
||||||
const currentThreadState = useAtomValue(currentThreadAtom);
|
const currentThreadState = useAtomValue(currentThreadAtom);
|
||||||
|
|
@ -148,7 +148,10 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
router.push(`/dashboard/${params.search_space_id}/settings?tab=public-links`)
|
setSearchSpaceSettingsDialog({
|
||||||
|
open: true,
|
||||||
|
initialTab: "public-links",
|
||||||
|
})
|
||||||
}
|
}
|
||||||
className="flex items-center justify-center h-8 w-8 rounded-md bg-muted/50 hover:bg-muted transition-colors"
|
className="flex items-center justify-center h-8 w-8 rounded-md bg-muted/50 hover:bg-muted transition-colors"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -233,11 +233,14 @@ export function ModelSelector({
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
role="combobox"
|
role="combobox"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
className={cn("h-8 gap-2 px-3 text-sm border-border/60 select-none", className)}
|
className={cn(
|
||||||
|
"h-8 gap-2 px-3 text-sm bg-main-panel hover:bg-accent/50 dark:hover:bg-white/[0.06] border border-border/40 select-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<>
|
<>
|
||||||
|
|
@ -281,12 +284,7 @@ export function ModelSelector({
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<ChevronDown
|
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground ml-1 shrink-0" />
|
||||||
className={cn(
|
|
||||||
"h-3.5 w-3.5 text-muted-foreground ml-1 shrink-0 transition-transform duration-200",
|
|
||||||
open && "rotate-180"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,15 +23,15 @@ interface TourStep {
|
||||||
const TOUR_STEPS: TourStep[] = [
|
const TOUR_STEPS: TourStep[] = [
|
||||||
{
|
{
|
||||||
target: '[data-joyride="connector-icon"]',
|
target: '[data-joyride="connector-icon"]',
|
||||||
title: "Connect your data sources",
|
title: "Manage your tools",
|
||||||
content: "Connect and sync data from Gmail, Drive, Slack, Notion, Jira, Confluence, and more.",
|
content: "Enable or disable AI tools and configure capabilities.",
|
||||||
placement: "bottom",
|
placement: "bottom",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
target: '[data-joyride="documents-sidebar"]',
|
target: '[data-joyride="upload-button"]',
|
||||||
title: "Manage your documents",
|
title: "Upload documents",
|
||||||
content: "Access and manage all your uploaded documents.",
|
content: "Upload files to your search space.",
|
||||||
placement: "right",
|
placement: "left",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
target: '[data-joyride="inbox-sidebar"]',
|
target: '[data-joyride="inbox-sidebar"]',
|
||||||
|
|
@ -100,7 +100,7 @@ function Spotlight({
|
||||||
}) {
|
}) {
|
||||||
const rect = targetEl.getBoundingClientRect();
|
const rect = targetEl.getBoundingClientRect();
|
||||||
const padding = 6;
|
const padding = 6;
|
||||||
const shadowColor = isDarkMode ? "#172554" : "#3b82f6";
|
const shadowColor = isDarkMode ? "#3f3f46" : "#3b82f6";
|
||||||
|
|
||||||
// Check if this is the connector icon step - verify both the selector matches AND the element matches
|
// Check if this is the connector icon step - verify both the selector matches AND the element matches
|
||||||
// This prevents the shape from changing before targetEl updates
|
// This prevents the shape from changing before targetEl updates
|
||||||
|
|
@ -187,7 +187,7 @@ function TourTooltip({
|
||||||
}
|
}
|
||||||
}, [stepIndex]);
|
}, [stepIndex]);
|
||||||
|
|
||||||
const bgColor = isDarkMode ? "#18181b" : "#ffffff";
|
const bgColor = isDarkMode ? "#27272a" : "#ffffff";
|
||||||
const textColor = isDarkMode ? "#ffffff" : "#18181b";
|
const textColor = isDarkMode ? "#ffffff" : "#18181b";
|
||||||
const mutedTextColor = isDarkMode ? "#a1a1aa" : "#71717a";
|
const mutedTextColor = isDarkMode ? "#a1a1aa" : "#71717a";
|
||||||
|
|
||||||
|
|
@ -195,15 +195,24 @@ function TourTooltip({
|
||||||
const getPointerStyles = (): React.CSSProperties => {
|
const getPointerStyles = (): React.CSSProperties => {
|
||||||
const lineLength = 16;
|
const lineLength = 16;
|
||||||
const dotSize = 6;
|
const dotSize = 6;
|
||||||
// Check if this is the documents step (stepIndex === 1) or inbox step (stepIndex === 2)
|
const isUploadStep = stepIndex === 1;
|
||||||
const isDocumentsStep = stepIndex === 1;
|
|
||||||
const isInboxStep = stepIndex === 2;
|
const isInboxStep = stepIndex === 2;
|
||||||
|
|
||||||
if (position.pointerPosition === "left") {
|
if (position.pointerPosition === "left") {
|
||||||
return {
|
return {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
left: -lineLength - dotSize,
|
left: -lineLength - dotSize,
|
||||||
top: isDocumentsStep || isInboxStep ? "calc(50% - 8px)" : "50%",
|
top: isInboxStep ? "calc(50% - 8px)" : "50%",
|
||||||
|
transform: "translateY(-50%)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (position.pointerPosition === "right") {
|
||||||
|
return {
|
||||||
|
position: "absolute",
|
||||||
|
right: -lineLength - dotSize,
|
||||||
|
top: isUploadStep ? "calc(50% - 12px)" : "50%",
|
||||||
transform: "translateY(-50%)",
|
transform: "translateY(-50%)",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|
@ -224,7 +233,7 @@ function TourTooltip({
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderPointer = () => {
|
const renderPointer = () => {
|
||||||
const lineColor = isDarkMode ? "#18181B" : "#ffffff";
|
const lineColor = isDarkMode ? "#27272a" : "#ffffff";
|
||||||
|
|
||||||
if (position.pointerPosition === "left") {
|
if (position.pointerPosition === "left") {
|
||||||
return (
|
return (
|
||||||
|
|
@ -247,6 +256,27 @@ function TourTooltip({
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (position.pointerPosition === "right") {
|
||||||
|
return (
|
||||||
|
<div style={getPointerStyles()}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 16,
|
||||||
|
height: 2,
|
||||||
|
backgroundColor: lineColor,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: "50%",
|
||||||
|
backgroundColor: lineColor,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (position.pointerPosition === "top") {
|
if (position.pointerPosition === "top") {
|
||||||
return (
|
return (
|
||||||
<div style={getPointerStyles()}>
|
<div style={getPointerStyles()}>
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Check, Copy, ExternalLink, MessageSquare, Trash2 } from "lucide-react";
|
import { Check, Copy, ExternalLink, MessageSquare, Trash2 } from "lucide-react";
|
||||||
import Image from "next/image";
|
|
||||||
import { useCallback, useRef, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
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 { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import type { PublicChatSnapshotDetail } from "@/contracts/types/chat-threads.types";
|
import type { PublicChatSnapshotDetail } from "@/contracts/types/chat-threads.types";
|
||||||
|
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||||
|
|
||||||
function getInitials(name: string): string {
|
function getInitials(name: string): string {
|
||||||
const parts = name.trim().split(/\s+/);
|
const parts = name.trim().split(/\s+/);
|
||||||
|
|
@ -36,6 +37,7 @@ export function PublicChatSnapshotRow({
|
||||||
}: PublicChatSnapshotRowProps) {
|
}: PublicChatSnapshotRowProps) {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout>>(null);
|
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout>>(null);
|
||||||
|
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||||
|
|
||||||
const handleCopyClick = useCallback(() => {
|
const handleCopyClick = useCallback(() => {
|
||||||
onCopy(snapshot);
|
onCopy(snapshot);
|
||||||
|
|
@ -56,8 +58,8 @@ export function PublicChatSnapshotRow({
|
||||||
<Card className="group relative overflow-hidden transition-all duration-200 border-border/60 hover:shadow-md h-full">
|
<Card className="group relative overflow-hidden transition-all duration-200 border-border/60 hover:shadow-md h-full">
|
||||||
<CardContent className="p-4 flex flex-col gap-3 h-full">
|
<CardContent className="p-4 flex flex-col gap-3 h-full">
|
||||||
{/* Header: Title + Actions */}
|
{/* Header: Title + Actions */}
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="relative">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 pr-16 sm:pr-0 sm:group-hover:pr-16">
|
||||||
<h4
|
<h4
|
||||||
className="text-sm font-semibold tracking-tight truncate"
|
className="text-sm font-semibold tracking-tight truncate"
|
||||||
title={snapshot.thread_title}
|
title={snapshot.thread_title}
|
||||||
|
|
@ -65,9 +67,9 @@ export function PublicChatSnapshotRow({
|
||||||
{snapshot.thread_title}
|
{snapshot.thread_title}
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-0.5 shrink-0 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity duration-150">
|
<div className="flex items-center gap-0.5 shrink-0 sm:hidden sm:group-hover:flex absolute right-0 top-0">
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip open={isDesktop ? undefined : false}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
@ -85,7 +87,7 @@ export function PublicChatSnapshotRow({
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
{canDelete && (
|
{canDelete && (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip open={isDesktop ? undefined : false}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
@ -126,7 +128,7 @@ export function PublicChatSnapshotRow({
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip open={isDesktop ? undefined : false}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
@ -153,24 +155,17 @@ export function PublicChatSnapshotRow({
|
||||||
<>
|
<>
|
||||||
<span className="text-muted-foreground/30">·</span>
|
<span className="text-muted-foreground/30">·</span>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip open={isDesktop ? undefined : false}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div className="flex items-center gap-1.5 cursor-default">
|
<div className="flex items-center gap-1.5 cursor-default">
|
||||||
{member.avatarUrl ? (
|
<Avatar className="size-4.5 shrink-0">
|
||||||
<Image
|
{member.avatarUrl && (
|
||||||
src={member.avatarUrl}
|
<AvatarImage src={member.avatarUrl} alt={member.name} />
|
||||||
alt={member.name}
|
|
||||||
width={18}
|
|
||||||
height={18}
|
|
||||||
className="h-4.5 w-4.5 rounded-full object-cover shrink-0"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-4.5 w-4.5 items-center justify-center rounded-full bg-gradient-to-br from-primary/20 to-primary/5 shrink-0">
|
|
||||||
<span className="text-[9px] font-semibold text-primary">
|
|
||||||
{getInitials(member.name)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
<AvatarFallback className="text-[9px]">
|
||||||
|
{getInitials(member.name)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
<span className="text-[11px] text-muted-foreground/60 truncate max-w-[120px]">
|
<span className="text-[11px] text-muted-foreground/60 truncate max-w-[120px]">
|
||||||
{member.name}
|
{member.name}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -307,7 +307,7 @@ export function ReportPanelContent({
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
disabled={isLoading || !reportContent?.content}
|
disabled={isLoading || !reportContent?.content}
|
||||||
className="h-8 min-w-[80px] px-3.5 py-4 text-[15px]"
|
className="h-8 min-w-[80px] px-3.5 py-4 text-[15px] bg-sidebar select-none"
|
||||||
>
|
>
|
||||||
{copied ? "Copied" : "Copy"}
|
{copied ? "Copied" : "Copy"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -319,7 +319,7 @@ export function ReportPanelContent({
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={isLoading || !reportContent?.content}
|
disabled={isLoading || !reportContent?.content}
|
||||||
className="h-8 px-3.5 py-4 text-[15px] gap-1.5"
|
className="h-8 px-3.5 py-4 text-[15px] gap-1.5 bg-sidebar select-none"
|
||||||
>
|
>
|
||||||
Export
|
Export
|
||||||
<ChevronDownIcon className="size-3" />
|
<ChevronDownIcon className="size-3" />
|
||||||
|
|
@ -327,7 +327,7 @@ export function ReportPanelContent({
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
align="start"
|
align="start"
|
||||||
className={`min-w-[200px] dark:bg-neutral-900 dark:border dark:border-white/5${insideDrawer ? " z-[100]" : ""}`}
|
className={`min-w-[200px] select-none${insideDrawer ? " z-[100]" : ""}`}
|
||||||
>
|
>
|
||||||
{!shareToken && (
|
{!shareToken && (
|
||||||
<>
|
<>
|
||||||
|
|
@ -398,14 +398,18 @@ export function ReportPanelContent({
|
||||||
{versions.length > 1 && (
|
{versions.length > 1 && (
|
||||||
<DropdownMenu modal={insideDrawer ? false : undefined}>
|
<DropdownMenu modal={insideDrawer ? false : undefined}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="outline" size="sm" className="h-8 px-3.5 py-4 text-[15px] gap-1.5">
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 px-3.5 py-4 text-[15px] gap-1.5 bg-sidebar select-none"
|
||||||
|
>
|
||||||
v{activeVersionIndex + 1}
|
v{activeVersionIndex + 1}
|
||||||
<ChevronDownIcon className="size-3" />
|
<ChevronDownIcon className="size-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
align="start"
|
align="start"
|
||||||
className={`min-w-[120px] dark:bg-neutral-900 dark:border dark:border-white/5${insideDrawer ? " z-[100]" : ""}`}
|
className={`min-w-[120px] select-none${insideDrawer ? " z-[100]" : ""}`}
|
||||||
>
|
>
|
||||||
{versions.map((v, i) => (
|
{versions.map((v, i) => (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
|
|
@ -455,6 +459,7 @@ export function ReportPanelContent({
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
hasUnsavedChanges={editedMarkdown !== null}
|
hasUnsavedChanges={editedMarkdown !== null}
|
||||||
isSaving={saving}
|
isSaving={saving}
|
||||||
|
className="[&_[role=toolbar]]:!bg-sidebar"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -491,7 +496,7 @@ function DesktopReportPanel() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={panelRef}
|
ref={panelRef}
|
||||||
className="flex w-[50%] max-w-[700px] min-w-[380px] flex-col border-l bg-background animate-in slide-in-from-right-4 duration-300 ease-out"
|
className="flex w-[50%] max-w-[700px] min-w-[380px] flex-col border-l bg-sidebar text-sidebar-foreground animate-in slide-in-from-right-4 duration-300 ease-out"
|
||||||
>
|
>
|
||||||
<ReportPanelContent
|
<ReportPanelContent
|
||||||
reportId={panelState.reportId}
|
reportId={panelState.reportId}
|
||||||
|
|
@ -521,7 +526,7 @@ function MobileReportDrawer() {
|
||||||
shouldScaleBackground={false}
|
shouldScaleBackground={false}
|
||||||
>
|
>
|
||||||
<DrawerContent
|
<DrawerContent
|
||||||
className="h-[90vh] max-h-[90vh] z-80 !rounded-none border-none"
|
className="h-[95vh] max-h-[95vh] z-80 bg-sidebar overflow-hidden"
|
||||||
overlayClassName="z-80"
|
overlayClassName="z-80"
|
||||||
>
|
>
|
||||||
<DrawerHandle />
|
<DrawerHandle />
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { Info, RotateCcw, Save } from "lucide-react";
|
import { Info } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
@ -81,14 +81,6 @@ export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManager
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReset = () => {
|
|
||||||
if (searchSpace) {
|
|
||||||
setName(searchSpace.name || "");
|
|
||||||
setDescription(searchSpace.description || "");
|
|
||||||
setHasChanges(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 md:space-y-6">
|
<div className="space-y-4 md:space-y-6">
|
||||||
|
|
@ -161,37 +153,16 @@ export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManager
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<div className="flex items-center justify-between pt-3 md:pt-4 gap-2">
|
<div className="flex justify-end pt-3 md:pt-4">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handleReset}
|
|
||||||
disabled={!hasChanges || saving}
|
|
||||||
className="flex items-center gap-2 text-xs md:text-sm h-9 md:h-10"
|
|
||||||
>
|
|
||||||
<RotateCcw className="h-3.5 w-3.5 md:h-4 md:w-4" />
|
|
||||||
{t("general_reset")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={!hasChanges || saving || !name.trim()}
|
disabled={!hasChanges || saving || !name.trim()}
|
||||||
className="flex items-center gap-2 text-xs md:text-sm h-9 md:h-10"
|
className="gap-2 bg-white text-black hover:bg-neutral-100 dark:bg-white dark:text-black dark:hover:bg-neutral-200"
|
||||||
>
|
>
|
||||||
<Save className="h-3.5 w-3.5 md:h-4 md:w-4" />
|
|
||||||
{saving ? t("general_saving") : t("general_save")}
|
{saving ? t("general_saving") : t("general_save")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasChanges && (
|
|
||||||
<Alert
|
|
||||||
variant="default"
|
|
||||||
className="bg-blue-50 dark:bg-blue-950/20 border-blue-200 dark:border-blue-800 py-3 md:py-4"
|
|
||||||
>
|
|
||||||
<Info className="h-3 w-3 md:h-4 md:w-4 text-blue-600 dark:text-blue-500 shrink-0" />
|
|
||||||
<AlertDescription className="text-blue-800 dark:text-blue-300 text-xs md:text-sm">
|
|
||||||
{t("general_unsaved_changes")}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,9 @@ import {
|
||||||
Trash2,
|
Trash2,
|
||||||
Wand2,
|
Wand2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
|
||||||
import Image from "next/image";
|
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import {
|
import {
|
||||||
createImageGenConfigMutationAtom,
|
createImageGenConfigMutationAtom,
|
||||||
deleteImageGenConfigMutationAtom,
|
deleteImageGenConfigMutationAtom,
|
||||||
|
|
@ -70,6 +69,7 @@ import { Separator } from "@/components/ui/separator";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||||
import {
|
import {
|
||||||
getImageGenModelsByProvider,
|
getImageGenModelsByProvider,
|
||||||
IMAGE_GEN_PROVIDERS,
|
IMAGE_GEN_PROVIDERS,
|
||||||
|
|
@ -82,16 +82,6 @@ interface ImageModelManagerProps {
|
||||||
searchSpaceId: number;
|
searchSpaceId: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const container = {
|
|
||||||
hidden: { opacity: 0 },
|
|
||||||
show: { opacity: 1, transition: { staggerChildren: 0.05 } },
|
|
||||||
};
|
|
||||||
|
|
||||||
const item = {
|
|
||||||
hidden: { opacity: 0, y: 20 },
|
|
||||||
show: { opacity: 1, y: 0 },
|
|
||||||
};
|
|
||||||
|
|
||||||
function getInitials(name: string): string {
|
function getInitials(name: string): string {
|
||||||
const parts = name.trim().split(/\s+/);
|
const parts = name.trim().split(/\s+/);
|
||||||
if (parts.length >= 2) {
|
if (parts.length >= 2) {
|
||||||
|
|
@ -101,6 +91,7 @@ function getInitials(name: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
||||||
|
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||||
// Image gen config atoms
|
// Image gen config atoms
|
||||||
const {
|
const {
|
||||||
mutateAsync: createConfig,
|
mutateAsync: createConfig,
|
||||||
|
|
@ -282,19 +273,20 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
|
<div className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => refreshConfigs()}
|
onClick={() => refreshConfigs()}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="flex items-center gap-2 text-xs md:text-sm h-8 md:h-9"
|
className="gap-2"
|
||||||
>
|
>
|
||||||
<RefreshCw className={cn("h-3 w-3 md:h-4 md:w-4", configsLoading && "animate-spin")} />
|
<RefreshCw className={cn("h-3.5 w-3.5", configsLoading && "animate-spin")} />
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
{canCreate && (
|
{canCreate && (
|
||||||
<Button
|
<Button
|
||||||
|
variant="outline"
|
||||||
onClick={openNewDialog}
|
onClick={openNewDialog}
|
||||||
className="flex items-center gap-2 text-xs md:text-sm h-8 md:h-9"
|
className="gap-2 bg-white text-black hover:bg-neutral-100 dark:bg-white dark:text-black dark:hover:bg-neutral-200"
|
||||||
>
|
>
|
||||||
Add Image Model
|
Add Image Model
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -302,25 +294,18 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Errors */}
|
{/* Errors */}
|
||||||
<AnimatePresence>
|
|
||||||
{errors.map((err) => (
|
{errors.map((err) => (
|
||||||
<motion.div
|
<div key={err?.message}>
|
||||||
key={err?.message}
|
|
||||||
initial={{ opacity: 0, y: -10 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, y: -10 }}
|
|
||||||
>
|
|
||||||
<Alert variant="destructive" className="py-3">
|
<Alert variant="destructive" className="py-3">
|
||||||
<AlertCircle className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
|
<AlertCircle className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
|
||||||
<AlertDescription className="text-xs md:text-sm">{err?.message}</AlertDescription>
|
<AlertDescription className="text-xs md:text-sm">{err?.message}</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
</motion.div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
{/* Read-only / Limited permissions notice */}
|
{/* Read-only / Limited permissions notice */}
|
||||||
{access && !isLoading && isReadOnly && (
|
{access && !isLoading && isReadOnly && (
|
||||||
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }}>
|
<div>
|
||||||
<Alert className="bg-muted/50 py-3 md:py-4">
|
<Alert className="bg-muted/50 py-3 md:py-4">
|
||||||
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
|
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
|
||||||
<AlertDescription className="text-xs md:text-sm">
|
<AlertDescription className="text-xs md:text-sm">
|
||||||
|
|
@ -328,10 +313,10 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
||||||
configurations. Contact a space owner to request additional permissions.
|
configurations. Contact a space owner to request additional permissions.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
</motion.div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{access && !isLoading && !isReadOnly && (!canCreate || !canDelete) && (
|
{access && !isLoading && !isReadOnly && (!canCreate || !canDelete) && (
|
||||||
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }}>
|
<div>
|
||||||
<Alert className="bg-muted/50 py-3 md:py-4">
|
<Alert className="bg-muted/50 py-3 md:py-4">
|
||||||
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
|
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
|
||||||
<AlertDescription className="text-xs md:text-sm">
|
<AlertDescription className="text-xs md:text-sm">
|
||||||
|
|
@ -343,7 +328,7 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
||||||
{!canDelete && ", but cannot delete them"}.
|
{!canDelete && ", but cannot delete them"}.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
</motion.div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Global info */}
|
{/* Global info */}
|
||||||
|
|
@ -429,23 +414,12 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<motion.div
|
<div className="grid gap-3 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
variants={container}
|
|
||||||
initial="hidden"
|
|
||||||
animate="show"
|
|
||||||
className="grid gap-3 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3"
|
|
||||||
>
|
|
||||||
<AnimatePresence mode="popLayout">
|
|
||||||
{userConfigs?.map((config) => {
|
{userConfigs?.map((config) => {
|
||||||
const member = config.user_id ? memberMap.get(config.user_id) : null;
|
const member = config.user_id ? memberMap.get(config.user_id) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<div key={config.id}>
|
||||||
key={config.id}
|
|
||||||
variants={item}
|
|
||||||
layout
|
|
||||||
exit={{ opacity: 0, scale: 0.95 }}
|
|
||||||
>
|
|
||||||
<Card className="group relative overflow-hidden transition-all duration-200 border-border/60 hover:shadow-md h-full">
|
<Card className="group relative overflow-hidden transition-all duration-200 border-border/60 hover:shadow-md h-full">
|
||||||
<CardContent className="p-4 flex flex-col gap-3 h-full">
|
<CardContent className="p-4 flex flex-col gap-3 h-full">
|
||||||
{/* Header: Name + Actions */}
|
{/* Header: Name + Actions */}
|
||||||
|
|
@ -464,7 +438,7 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
||||||
<div className="flex items-center gap-0.5 shrink-0 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity duration-150">
|
<div className="flex items-center gap-0.5 shrink-0 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity duration-150">
|
||||||
{canUpdate && (
|
{canUpdate && (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip open={isDesktop ? undefined : false}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
@ -481,7 +455,7 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
||||||
)}
|
)}
|
||||||
{canDelete && (
|
{canDelete && (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip open={isDesktop ? undefined : false}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
@ -521,24 +495,17 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
||||||
<>
|
<>
|
||||||
<span className="text-muted-foreground/30">·</span>
|
<span className="text-muted-foreground/30">·</span>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip open={isDesktop ? undefined : false}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div className="flex items-center gap-1.5 cursor-default">
|
<div className="flex items-center gap-1.5 cursor-default">
|
||||||
{member.avatarUrl ? (
|
<Avatar className="size-4.5 shrink-0">
|
||||||
<Image
|
{member.avatarUrl && (
|
||||||
src={member.avatarUrl}
|
<AvatarImage src={member.avatarUrl} alt={member.name} />
|
||||||
alt={member.name}
|
|
||||||
width={18}
|
|
||||||
height={18}
|
|
||||||
className="h-4.5 w-4.5 rounded-full object-cover shrink-0"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-4.5 w-4.5 items-center justify-center rounded-full bg-gradient-to-br from-primary/20 to-primary/5 shrink-0">
|
|
||||||
<span className="text-[9px] font-semibold text-primary">
|
|
||||||
{getInitials(member.name)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
<AvatarFallback className="text-[9px]">
|
||||||
|
{getInitials(member.name)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
<span className="text-[11px] text-muted-foreground/60 truncate max-w-[120px]">
|
<span className="text-[11px] text-muted-foreground/60 truncate max-w-[120px]">
|
||||||
{member.name}
|
{member.name}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -554,11 +521,10 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</motion.div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</AnimatePresence>
|
</div>
|
||||||
</motion.div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -733,10 +699,9 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex gap-3 pt-4 border-t">
|
<div className="flex justify-end gap-3 pt-4 border-t">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="secondary"
|
||||||
className="flex-1"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsDialogOpen(false);
|
setIsDialogOpen(false);
|
||||||
setEditingConfig(null);
|
setEditingConfig(null);
|
||||||
|
|
@ -746,7 +711,6 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="flex-1"
|
|
||||||
onClick={handleFormSubmit}
|
onClick={handleFormSubmit}
|
||||||
disabled={
|
disabled={
|
||||||
isSubmitting ||
|
isSubmitting ||
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ import {
|
||||||
Save,
|
Save,
|
||||||
Shuffle,
|
Shuffle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
|
|
@ -229,13 +228,13 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
||||||
{/* Header actions */}
|
{/* Header actions */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => refreshConfigs()}
|
onClick={() => refreshConfigs()}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="flex items-center gap-2 text-xs md:text-sm h-8 md:h-9"
|
className="gap-2"
|
||||||
>
|
>
|
||||||
<RefreshCw className="h-3 w-3 md:h-4 md:w-4" />
|
<RefreshCw className="h-3.5 w-3.5" />
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
{isAssignmentComplete && !isLoading && !hasError && (
|
{isAssignmentComplete && !isLoading && !hasError && (
|
||||||
|
|
@ -250,14 +249,8 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error Alert */}
|
{/* Error Alert */}
|
||||||
<AnimatePresence>
|
|
||||||
{hasError && (
|
{hasError && (
|
||||||
<motion.div
|
<div>
|
||||||
key="error-alert"
|
|
||||||
initial={{ opacity: 0, y: -10 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, y: -10 }}
|
|
||||||
>
|
|
||||||
<Alert variant="destructive" className="py-3 md:py-4">
|
<Alert variant="destructive" className="py-3 md:py-4">
|
||||||
<AlertCircle className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
|
<AlertCircle className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
|
||||||
<AlertDescription className="text-xs md:text-sm">
|
<AlertDescription className="text-xs md:text-sm">
|
||||||
|
|
@ -266,9 +259,8 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
||||||
(globalConfigsError?.message ?? "Failed to load global configurations")}
|
(globalConfigsError?.message ?? "Failed to load global configurations")}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
</motion.div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
{/* Loading Skeleton */}
|
{/* Loading Skeleton */}
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
|
|
@ -322,13 +314,8 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
||||||
|
|
||||||
{/* Role Assignment Cards */}
|
{/* Role Assignment Cards */}
|
||||||
{!isLoading && !hasError && hasAnyConfigs && (
|
{!isLoading && !hasError && hasAnyConfigs && (
|
||||||
<motion.div
|
<div className="grid gap-4 grid-cols-1 lg:grid-cols-2">
|
||||||
initial={{ opacity: 0 }}
|
{Object.entries(ROLE_DESCRIPTIONS).map(([key, role]) => {
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
transition={{ duration: 0.3 }}
|
|
||||||
className="grid gap-4 grid-cols-1 lg:grid-cols-2"
|
|
||||||
>
|
|
||||||
{Object.entries(ROLE_DESCRIPTIONS).map(([key, role], index) => {
|
|
||||||
const IconComponent = role.icon;
|
const IconComponent = role.icon;
|
||||||
const isImageRole = role.configType === "image";
|
const isImageRole = role.configType === "image";
|
||||||
const currentAssignment = assignments[role.prefKey as keyof typeof assignments];
|
const currentAssignment = assignments[role.prefKey as keyof typeof assignments];
|
||||||
|
|
@ -349,12 +336,7 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
||||||
assignedConfig && "is_auto_mode" in assignedConfig && assignedConfig.is_auto_mode;
|
assignedConfig && "is_auto_mode" in assignedConfig && assignedConfig.is_auto_mode;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<div key={key}>
|
||||||
key={key}
|
|
||||||
initial={{ opacity: 0, y: 15 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: index * 0.08, duration: 0.3 }}
|
|
||||||
>
|
|
||||||
<Card className="group relative overflow-hidden transition-all duration-200 border-border/60 hover:shadow-md h-full">
|
<Card className="group relative overflow-hidden transition-all duration-200 border-border/60 hover:shadow-md h-full">
|
||||||
<CardContent className="p-4 md:p-5 space-y-4">
|
<CardContent className="p-4 md:p-5 space-y-4">
|
||||||
{/* Role Header */}
|
{/* Role Header */}
|
||||||
|
|
@ -542,22 +524,15 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</motion.div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</motion.div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Save / Reset Bar */}
|
{/* Save / Reset Bar */}
|
||||||
<AnimatePresence>
|
|
||||||
{hasChanges && (
|
{hasChanges && (
|
||||||
<motion.div
|
<div className="flex items-center justify-between gap-3 rounded-lg border border-border bg-muted/50 p-3 md:p-4">
|
||||||
initial={{ opacity: 0, y: 10 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, y: 10 }}
|
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
className="flex items-center justify-between gap-3 rounded-lg border border-border bg-muted/50 p-3 md:p-4"
|
|
||||||
>
|
|
||||||
<p className="text-xs md:text-sm text-muted-foreground">You have unsaved changes</p>
|
<p className="text-xs md:text-sm text-muted-foreground">You have unsaved changes</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -580,9 +555,8 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
||||||
{isSaving ? "Saving…" : "Save Changes"}
|
{isSaving ? "Saving…" : "Save Changes"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,8 @@ import {
|
||||||
Trash2,
|
Trash2,
|
||||||
Wand2,
|
Wand2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
|
||||||
import Image from "next/image";
|
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms";
|
import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms";
|
||||||
import {
|
import {
|
||||||
createNewLLMConfigMutationAtom,
|
createNewLLMConfigMutationAtom,
|
||||||
|
|
@ -51,6 +50,7 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import type { NewLLMConfig } from "@/contracts/types/new-llm-config.types";
|
import type { NewLLMConfig } from "@/contracts/types/new-llm-config.types";
|
||||||
|
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||||
import { getProviderIcon } from "@/lib/provider-icons";
|
import { getProviderIcon } from "@/lib/provider-icons";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|
@ -58,21 +58,6 @@ interface ModelConfigManagerProps {
|
||||||
searchSpaceId: number;
|
searchSpaceId: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const container = {
|
|
||||||
hidden: { opacity: 0 },
|
|
||||||
show: {
|
|
||||||
opacity: 1,
|
|
||||||
transition: {
|
|
||||||
staggerChildren: 0.05,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const item = {
|
|
||||||
hidden: { opacity: 0, y: 20 },
|
|
||||||
show: { opacity: 1, y: 0 },
|
|
||||||
};
|
|
||||||
|
|
||||||
function getInitials(name: string): string {
|
function getInitials(name: string): string {
|
||||||
const parts = name.trim().split(/\s+/);
|
const parts = name.trim().split(/\s+/);
|
||||||
if (parts.length >= 2) {
|
if (parts.length >= 2) {
|
||||||
|
|
@ -82,6 +67,7 @@ function getInitials(name: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
||||||
|
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||||
// Mutations
|
// Mutations
|
||||||
const { mutateAsync: createConfig, isPending: isCreating } = useAtomValue(
|
const { mutateAsync: createConfig, isPending: isCreating } = useAtomValue(
|
||||||
createNewLLMConfigMutationAtom
|
createNewLLMConfigMutationAtom
|
||||||
|
|
@ -195,20 +181,20 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
||||||
{/* Header actions */}
|
{/* Header actions */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => refreshConfigs()}
|
onClick={() => refreshConfigs()}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="flex items-center gap-2 text-xs md:text-sm h-8 md:h-9"
|
className="gap-2"
|
||||||
>
|
>
|
||||||
<RefreshCw className={cn("h-3 w-3 md:h-4 md:w-4", isLoading && "animate-spin")} />
|
<RefreshCw className={cn("h-3.5 w-3.5", isLoading && "animate-spin")} />
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
{canCreate && (
|
{canCreate && (
|
||||||
<Button
|
<Button
|
||||||
|
variant="outline"
|
||||||
onClick={openNewDialog}
|
onClick={openNewDialog}
|
||||||
size="sm"
|
className="gap-2 bg-white text-black hover:bg-neutral-100 dark:bg-white dark:text-black dark:hover:bg-neutral-200"
|
||||||
className="flex items-center gap-2 text-xs md:text-sm h-8 md:h-9"
|
|
||||||
>
|
>
|
||||||
Add Configuration
|
Add Configuration
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -216,27 +202,20 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Fetch Error Alert */}
|
{/* Fetch Error Alert */}
|
||||||
<AnimatePresence>
|
|
||||||
{fetchError && (
|
{fetchError && (
|
||||||
<motion.div
|
<div>
|
||||||
key="fetch-error"
|
|
||||||
initial={{ opacity: 0, y: -10 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, y: -10 }}
|
|
||||||
>
|
|
||||||
<Alert variant="destructive" className="py-3 md:py-4">
|
<Alert variant="destructive" className="py-3 md:py-4">
|
||||||
<AlertCircle className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
|
<AlertCircle className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
|
||||||
<AlertDescription className="text-xs md:text-sm">
|
<AlertDescription className="text-xs md:text-sm">
|
||||||
{fetchError?.message ?? "Failed to load configurations"}
|
{fetchError?.message ?? "Failed to load configurations"}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
</motion.div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
{/* Read-only / Limited permissions notice */}
|
{/* Read-only / Limited permissions notice */}
|
||||||
{access && !isLoading && isReadOnly && (
|
{access && !isLoading && isReadOnly && (
|
||||||
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }}>
|
<div>
|
||||||
<Alert className="bg-muted/50 py-3 md:py-4">
|
<Alert className="bg-muted/50 py-3 md:py-4">
|
||||||
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
|
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
|
||||||
<AlertDescription className="text-xs md:text-sm">
|
<AlertDescription className="text-xs md:text-sm">
|
||||||
|
|
@ -244,10 +223,10 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
||||||
Contact a space owner to request additional permissions.
|
Contact a space owner to request additional permissions.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
</motion.div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{access && !isLoading && !isReadOnly && (!canCreate || !canUpdate || !canDelete) && (
|
{access && !isLoading && !isReadOnly && (!canCreate || !canUpdate || !canDelete) && (
|
||||||
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }}>
|
<div>
|
||||||
<Alert className="bg-muted/50 py-3 md:py-4">
|
<Alert className="bg-muted/50 py-3 md:py-4">
|
||||||
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
|
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
|
||||||
<AlertDescription className="text-xs md:text-sm">
|
<AlertDescription className="text-xs md:text-sm">
|
||||||
|
|
@ -259,12 +238,12 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
||||||
{!canDelete && ", but cannot delete them"}.
|
{!canDelete && ", but cannot delete them"}.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
</motion.div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Global Configs Info */}
|
{/* Global Configs Info */}
|
||||||
{globalConfigs.length > 0 && (
|
{globalConfigs.length > 0 && (
|
||||||
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }}>
|
<div>
|
||||||
<Alert className="bg-muted/50 py-3 md:py-4">
|
<Alert className="bg-muted/50 py-3 md:py-4">
|
||||||
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
|
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
|
||||||
<AlertDescription className="text-xs md:text-sm">
|
<AlertDescription className="text-xs md:text-sm">
|
||||||
|
|
@ -275,7 +254,7 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
||||||
</span>
|
</span>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
</motion.div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Loading Skeleton */}
|
{/* Loading Skeleton */}
|
||||||
|
|
@ -317,7 +296,7 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
||||||
{!isLoading && (
|
{!isLoading && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{configs?.length === 0 ? (
|
{configs?.length === 0 ? (
|
||||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }}>
|
<div>
|
||||||
<Card className="border-dashed border-2 border-muted-foreground/25">
|
<Card className="border-dashed border-2 border-muted-foreground/25">
|
||||||
<CardContent className="flex flex-col items-center justify-center py-10 md:py-16 text-center">
|
<CardContent className="flex flex-col items-center justify-center py-10 md:py-16 text-center">
|
||||||
<div className="rounded-full bg-gradient-to-br from-violet-500/10 to-purple-500/10 p-4 md:p-6 mb-4 md:mb-6">
|
<div className="rounded-full bg-gradient-to-br from-violet-500/10 to-purple-500/10 p-4 md:p-6 mb-4 md:mb-6">
|
||||||
|
|
@ -343,25 +322,14 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</motion.div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<motion.div
|
<div className="grid gap-3 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
variants={container}
|
|
||||||
initial="hidden"
|
|
||||||
animate="show"
|
|
||||||
className="grid gap-3 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3"
|
|
||||||
>
|
|
||||||
<AnimatePresence mode="popLayout">
|
|
||||||
{configs?.map((config) => {
|
{configs?.map((config) => {
|
||||||
const member = config.user_id ? memberMap.get(config.user_id) : null;
|
const member = config.user_id ? memberMap.get(config.user_id) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<div key={config.id}>
|
||||||
key={config.id}
|
|
||||||
variants={item}
|
|
||||||
layout
|
|
||||||
exit={{ opacity: 0, scale: 0.95 }}
|
|
||||||
>
|
|
||||||
<Card className="group relative overflow-hidden transition-all duration-200 border-border/60 hover:shadow-md h-full">
|
<Card className="group relative overflow-hidden transition-all duration-200 border-border/60 hover:shadow-md h-full">
|
||||||
<CardContent className="p-4 flex flex-col gap-3 h-full">
|
<CardContent className="p-4 flex flex-col gap-3 h-full">
|
||||||
{/* Header: Name + Actions */}
|
{/* Header: Name + Actions */}
|
||||||
|
|
@ -380,7 +348,7 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
||||||
<div className="flex items-center gap-0.5 shrink-0 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity duration-150">
|
<div className="flex items-center gap-0.5 shrink-0 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity duration-150">
|
||||||
{canUpdate && (
|
{canUpdate && (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip open={isDesktop ? undefined : false}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
@ -397,7 +365,7 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
||||||
)}
|
)}
|
||||||
{canDelete && (
|
{canDelete && (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip open={isDesktop ? undefined : false}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
@ -460,24 +428,17 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
||||||
<>
|
<>
|
||||||
<span className="text-muted-foreground/30">·</span>
|
<span className="text-muted-foreground/30">·</span>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip open={isDesktop ? undefined : false}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div className="flex items-center gap-1.5 cursor-default">
|
<div className="flex items-center gap-1.5 cursor-default">
|
||||||
{member.avatarUrl ? (
|
<Avatar className="size-4.5 shrink-0">
|
||||||
<Image
|
{member.avatarUrl && (
|
||||||
src={member.avatarUrl}
|
<AvatarImage src={member.avatarUrl} alt={member.name} />
|
||||||
alt={member.name}
|
|
||||||
width={18}
|
|
||||||
height={18}
|
|
||||||
className="h-4.5 w-4.5 rounded-full object-cover shrink-0"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-4.5 w-4.5 items-center justify-center rounded-full bg-gradient-to-br from-primary/20 to-primary/5 shrink-0">
|
|
||||||
<span className="text-[9px] font-semibold text-primary">
|
|
||||||
{getInitials(member.name)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
<AvatarFallback className="text-[9px]">
|
||||||
|
{getInitials(member.name)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
<span className="text-[11px] text-muted-foreground/60 truncate max-w-[120px]">
|
<span className="text-[11px] text-muted-foreground/60 truncate max-w-[120px]">
|
||||||
{member.name}
|
{member.name}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -493,11 +454,10 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</motion.div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</AnimatePresence>
|
</div>
|
||||||
</motion.div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
206
surfsense_web/components/settings/more-pages-content.tsx
Normal file
206
surfsense_web/components/settings/more-pages-content.tsx
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { IconCalendar, IconMailFilled } from "@tabler/icons-react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Check, ExternalLink, Gift, Mail, Star, Zap } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import type { IncentiveTaskInfo } from "@/contracts/types/incentive-tasks.types";
|
||||||
|
import { incentiveTasksApiService } from "@/lib/apis/incentive-tasks-api.service";
|
||||||
|
import {
|
||||||
|
trackIncentiveContactOpened,
|
||||||
|
trackIncentivePageViewed,
|
||||||
|
trackIncentiveTaskClicked,
|
||||||
|
trackIncentiveTaskCompleted,
|
||||||
|
} from "@/lib/posthog/events";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function MorePagesContent() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
trackIncentivePageViewed();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ["incentive-tasks"],
|
||||||
|
queryFn: () => incentiveTasksApiService.getTasks(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const completeMutation = useMutation({
|
||||||
|
mutationFn: incentiveTasksApiService.completeTask,
|
||||||
|
onSuccess: (response, taskType) => {
|
||||||
|
if (response.success) {
|
||||||
|
toast.success(response.message);
|
||||||
|
const task = data?.tasks.find((t) => t.task_type === taskType);
|
||||||
|
if (task) {
|
||||||
|
trackIncentiveTaskCompleted(taskType, task.pages_reward);
|
||||||
|
}
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["incentive-tasks"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["user"] });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error("Failed to complete task. Please try again.");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleTaskClick = (task: IncentiveTaskInfo) => {
|
||||||
|
if (!task.completed) {
|
||||||
|
trackIncentiveTaskClicked(task.task_type);
|
||||||
|
completeMutation.mutate(task.task_type);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full space-y-6">
|
||||||
|
<div className="text-center">
|
||||||
|
<Gift className="mx-auto mb-3 h-8 w-8 text-primary" />
|
||||||
|
<h2 className="text-xl font-bold tracking-tight">Get More Pages</h2>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Complete tasks to earn additional pages
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center gap-3 p-3">
|
||||||
|
<Skeleton className="h-9 w-9 rounded-full bg-muted" />
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<Skeleton className="h-4 w-3/4 bg-muted" />
|
||||||
|
<Skeleton className="h-3 w-1/4 bg-muted" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-8 w-16 bg-muted" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{data?.tasks.map((task) => (
|
||||||
|
<Card
|
||||||
|
key={task.task_type}
|
||||||
|
className={cn("transition-colors bg-transparent", task.completed && "bg-muted/50")}
|
||||||
|
>
|
||||||
|
<CardContent className="flex items-center gap-3 p-3">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full",
|
||||||
|
task.completed ? "bg-primary text-primary-foreground" : "bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{task.completed ? <Check className="h-4 w-4" /> : <Star className="h-4 w-4" />}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"text-sm font-medium",
|
||||||
|
task.completed && "text-muted-foreground line-through"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{task.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">+{task.pages_reward} pages</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={task.completed || completeMutation.isPending}
|
||||||
|
onClick={() => handleTaskClick(task)}
|
||||||
|
asChild={!task.completed}
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{task.completed ? (
|
||||||
|
<span>Done</span>
|
||||||
|
) : (
|
||||||
|
<a
|
||||||
|
href={task.action_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="gap-1"
|
||||||
|
>
|
||||||
|
{completeMutation.isPending ? (
|
||||||
|
<Spinner size="xs" />
|
||||||
|
) : (
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<Card className="overflow-hidden border-emerald-500/20 bg-transparent">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Zap className="h-4 w-4 text-emerald-500" />
|
||||||
|
<CardTitle className="text-base">Upgrade to PRO</CardTitle>
|
||||||
|
<Badge className="bg-emerald-600 text-white border-transparent hover:bg-emerald-600">
|
||||||
|
FREE
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<CardDescription>
|
||||||
|
For a limited time, get{" "}
|
||||||
|
<span className="font-semibold text-foreground">6,000 additional pages</span> at no
|
||||||
|
cost. Contact us and we'll upgrade your account instantly.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardFooter className="pt-2">
|
||||||
|
<Dialog onOpenChange={(open) => open && trackIncentiveContactOpened()}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button className="w-full bg-emerald-600 text-white hover:bg-emerald-700">
|
||||||
|
<Mail className="h-4 w-4" />
|
||||||
|
Contact Us to Upgrade
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="select-none sm:max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Get in Touch</DialogTitle>
|
||||||
|
<DialogDescription>Pick the option that works best for you.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="https://cal.com/mod-rohan" target="_blank" rel="noopener noreferrer">
|
||||||
|
<IconCalendar className="h-4 w-4" />
|
||||||
|
Schedule a Meeting
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link href="mailto:rohan@surfsense.com">
|
||||||
|
<IconMailFilled className="h-4 w-4" />
|
||||||
|
rohan@surfsense.com
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
surfsense_web/components/settings/more-pages-dialog.tsx
Normal file
24
surfsense_web/components/settings/more-pages-dialog.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { morePagesDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
|
||||||
|
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { MorePagesContent } from "./more-pages-content";
|
||||||
|
|
||||||
|
export function MorePagesDialog() {
|
||||||
|
const [open, setOpen] = useAtom(morePagesDialogAtom);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent
|
||||||
|
className="select-none max-w-md w-[95vw] max-h-[90vh] flex flex-col p-0 gap-0 overflow-hidden"
|
||||||
|
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<DialogTitle className="sr-only">Get More Pages</DialogTitle>
|
||||||
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
|
<MorePagesContent />
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { AlertTriangle, Info, RotateCcw, Save } from "lucide-react";
|
import { AlertTriangle, Info } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
|
@ -83,13 +83,6 @@ export function PromptConfigManager({ searchSpaceId }: PromptConfigManagerProps)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReset = () => {
|
|
||||||
if (searchSpace) {
|
|
||||||
setCustomInstructions(searchSpace.qna_custom_instructions || "");
|
|
||||||
setHasChanges(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 md:space-y-6">
|
<div className="space-y-4 md:space-y-6">
|
||||||
|
|
@ -184,37 +177,16 @@ export function PromptConfigManager({ searchSpaceId }: PromptConfigManagerProps)
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<div className="flex items-center justify-between pt-3 md:pt-4 gap-2">
|
<div className="flex justify-end pt-3 md:pt-4">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handleReset}
|
|
||||||
disabled={!hasChanges || saving}
|
|
||||||
className="flex items-center gap-2 text-xs md:text-sm h-9 md:h-10"
|
|
||||||
>
|
|
||||||
<RotateCcw className="h-3.5 w-3.5 md:h-4 md:w-4" />
|
|
||||||
Reset Changes
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={!hasChanges || saving}
|
disabled={!hasChanges || saving}
|
||||||
className="flex items-center gap-2 text-xs md:text-sm h-9 md:h-10"
|
className="gap-2 bg-white text-black hover:bg-neutral-100 dark:bg-white dark:text-black dark:hover:bg-neutral-200"
|
||||||
>
|
>
|
||||||
<Save className="h-3.5 w-3.5 md:h-4 md:w-4" />
|
|
||||||
{saving ? "Saving" : "Save Instructions"}
|
{saving ? "Saving" : "Save Instructions"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasChanges && (
|
|
||||||
<Alert
|
|
||||||
variant="default"
|
|
||||||
className="bg-blue-50 dark:bg-blue-950/20 border-blue-200 dark:border-blue-800 py-3 md:py-4"
|
|
||||||
>
|
|
||||||
<Info className="h-3 w-3 md:h-4 md:w-4 text-blue-600 dark:text-blue-500 shrink-0" />
|
|
||||||
<AlertDescription className="text-blue-800 dark:text-blue-300 text-xs md:text-sm">
|
|
||||||
You have unsaved changes. Click "Save Instructions" to apply them.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,13 +14,11 @@ import {
|
||||||
Mic,
|
Mic,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Plug,
|
Plug,
|
||||||
Plus,
|
|
||||||
Settings,
|
Settings,
|
||||||
Shield,
|
Shield,
|
||||||
Trash2,
|
Trash2,
|
||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { motion } from "motion/react";
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { myAccessAtom } from "@/atoms/members/members-query.atoms";
|
import { myAccessAtom } from "@/atoms/members/members-query.atoms";
|
||||||
|
|
@ -477,12 +475,7 @@ function RolesContent({
|
||||||
const editingRole = editingRoleId !== null ? roles.find((r) => r.id === editingRoleId) : null;
|
const editingRole = editingRoleId !== null ? roles.find((r) => r.id === editingRoleId) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<div className="space-y-6">
|
||||||
initial={{ opacity: 0, y: 10 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, y: -10 }}
|
|
||||||
className="space-y-6"
|
|
||||||
>
|
|
||||||
{canCreate && (
|
{canCreate && (
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -490,7 +483,6 @@ function RolesContent({
|
||||||
onClick={() => setShowCreateRole(true)}
|
onClick={() => setShowCreateRole(true)}
|
||||||
className="gap-2 bg-white text-black hover:bg-neutral-100 dark:bg-white dark:text-black dark:hover:bg-neutral-200"
|
className="gap-2 bg-white text-black hover:bg-neutral-100 dark:bg-white dark:text-black dark:hover:bg-neutral-200"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
Create Custom Role
|
Create Custom Role
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -516,13 +508,8 @@ function RolesContent({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{roles.map((role, index) => (
|
{roles.map((role) => (
|
||||||
<motion.div
|
<div key={role.id}>
|
||||||
key={role.id}
|
|
||||||
initial={{ opacity: 0, y: 6 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: index * 0.04 }}
|
|
||||||
>
|
|
||||||
<RolePermissionsDialog permissions={role.permissions} roleName={role.name}>
|
<RolePermissionsDialog permissions={role.permissions} roleName={role.name}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -610,10 +597,10 @@ function RolesContent({
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</RolePermissionsDialog>
|
</RolePermissionsDialog>
|
||||||
</motion.div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -695,17 +682,10 @@ function PermissionsEditor({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={category} className="rounded-lg border border-border/60 overflow-hidden">
|
<div key={category} className="rounded-lg border border-border/60 overflow-hidden">
|
||||||
<div
|
<button
|
||||||
role="button"
|
type="button"
|
||||||
tabIndex={0}
|
|
||||||
className="w-full flex items-center justify-between px-3 py-2.5 cursor-pointer hover:bg-muted/40 transition-colors"
|
className="w-full flex items-center justify-between px-3 py-2.5 cursor-pointer hover:bg-muted/40 transition-colors"
|
||||||
onClick={() => toggleCategoryExpanded(category)}
|
onClick={() => toggleCategoryExpanded(category)}
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
e.preventDefault();
|
|
||||||
toggleCategoryExpanded(category);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<IconComponent className="h-4 w-4 text-muted-foreground shrink-0" />
|
<IconComponent className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
|
|
@ -721,9 +701,8 @@ function PermissionsEditor({
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
aria-label={`Select all ${config.label} permissions`}
|
aria-label={`Select all ${config.label} permissions`}
|
||||||
/>
|
/>
|
||||||
<motion.div
|
<div
|
||||||
animate={{ rotate: isExpanded ? 180 : 0 }}
|
className={cn("transition-transform duration-200", isExpanded && "rotate-180")}
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="h-4 w-4 text-muted-foreground"
|
className="h-4 w-4 text-muted-foreground"
|
||||||
|
|
@ -740,18 +719,12 @@ function PermissionsEditor({
|
||||||
d="M19 9l-7 7-7-7"
|
d="M19 9l-7 7-7-7"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</motion.div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<motion.div
|
<div className="border-t border-border/60">
|
||||||
initial={{ height: 0, opacity: 0 }}
|
|
||||||
animate={{ height: "auto", opacity: 1 }}
|
|
||||||
exit={{ height: 0, opacity: 0 }}
|
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
className="border-t border-border/60"
|
|
||||||
>
|
|
||||||
<div className="p-2 space-y-0.5">
|
<div className="p-2 space-y-0.5">
|
||||||
{perms.map((perm) => {
|
{perms.map((perm) => {
|
||||||
const action = perm.value.split(":")[1];
|
const action = perm.value.split(":")[1];
|
||||||
|
|
@ -759,21 +732,14 @@ function PermissionsEditor({
|
||||||
const isSelected = selectedPermissions.includes(perm.value);
|
const isSelected = selectedPermissions.includes(perm.value);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<button
|
||||||
key={perm.value}
|
key={perm.value}
|
||||||
role="button"
|
type="button"
|
||||||
tabIndex={0}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full flex items-center justify-between gap-3 px-2.5 py-2 rounded-md cursor-pointer transition-colors",
|
"w-full flex items-center justify-between gap-3 px-2.5 py-2 rounded-md cursor-pointer transition-colors",
|
||||||
isSelected ? "bg-muted/60 hover:bg-muted/80" : "hover:bg-muted/40"
|
isSelected ? "bg-muted/60 hover:bg-muted/80" : "hover:bg-muted/40"
|
||||||
)}
|
)}
|
||||||
onClick={() => onTogglePermission(perm.value)}
|
onClick={() => onTogglePermission(perm.value)}
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
e.preventDefault();
|
|
||||||
onTogglePermission(perm.value);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="flex-1 min-w-0 text-left">
|
<div className="flex-1 min-w-0 text-left">
|
||||||
<span className="text-sm font-medium">{actionLabel}</span>
|
<span className="text-sm font-medium">{actionLabel}</span>
|
||||||
|
|
@ -787,11 +753,11 @@ function PermissionsEditor({
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -965,7 +931,7 @@ function CreateRoleDialog({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-end gap-3 px-5 py-3 shrink-0">
|
<div className="flex items-center justify-end gap-3 px-5 py-3 shrink-0">
|
||||||
<Button variant="outline" onClick={handleClose}>
|
<Button variant="secondary" onClick={handleClose}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleCreate} disabled={creating || !name.trim()}>
|
<Button onClick={handleCreate} disabled={creating || !name.trim()}>
|
||||||
|
|
@ -1123,7 +1089,7 @@ function EditRoleDialog({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-end gap-3 px-5 py-3 border-t shrink-0">
|
<div className="flex items-center justify-end gap-3 px-5 py-3 border-t shrink-0">
|
||||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
<Button variant="secondary" onClick={() => onOpenChange(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSave} disabled={saving || !name.trim()}>
|
<Button onClick={handleSave} disabled={saving || !name.trim()}>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { Bot, Brain, FileText, Globe, ImageIcon, MessageSquare, Shield } from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import type React from "react";
|
||||||
|
import { searchSpaceSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
|
||||||
|
import { PublicChatSnapshotsManager } from "@/components/public-chat-snapshots/public-chat-snapshots-manager";
|
||||||
|
import { GeneralSettingsManager } from "@/components/settings/general-settings-manager";
|
||||||
|
import { ImageModelManager } from "@/components/settings/image-model-manager";
|
||||||
|
import { LLMRoleManager } from "@/components/settings/llm-role-manager";
|
||||||
|
import { ModelConfigManager } from "@/components/settings/model-config-manager";
|
||||||
|
import { PromptConfigManager } from "@/components/settings/prompt-config-manager";
|
||||||
|
import { RolesManager } from "@/components/settings/roles-manager";
|
||||||
|
import { SettingsDialog } from "@/components/settings/settings-dialog";
|
||||||
|
|
||||||
|
interface SearchSpaceSettingsDialogProps {
|
||||||
|
searchSpaceId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchSpaceSettingsDialog({ searchSpaceId }: SearchSpaceSettingsDialogProps) {
|
||||||
|
const t = useTranslations("searchSpaceSettings");
|
||||||
|
const [state, setState] = useAtom(searchSpaceSettingsDialogAtom);
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ value: "general", label: t("nav_general"), icon: <FileText className="h-4 w-4" /> },
|
||||||
|
{ value: "models", label: t("nav_agent_configs"), icon: <Bot className="h-4 w-4" /> },
|
||||||
|
{ value: "roles", label: t("nav_role_assignments"), icon: <Brain className="h-4 w-4" /> },
|
||||||
|
{
|
||||||
|
value: "image-models",
|
||||||
|
label: t("nav_image_models"),
|
||||||
|
icon: <ImageIcon className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{ value: "team-roles", label: t("nav_team_roles"), icon: <Shield className="h-4 w-4" /> },
|
||||||
|
{
|
||||||
|
value: "prompts",
|
||||||
|
label: t("nav_system_instructions"),
|
||||||
|
icon: <MessageSquare className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{ value: "public-links", label: t("nav_public_links"), icon: <Globe className="h-4 w-4" /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
const content: Record<string, React.ReactNode> = {
|
||||||
|
general: <GeneralSettingsManager searchSpaceId={searchSpaceId} />,
|
||||||
|
models: <ModelConfigManager searchSpaceId={searchSpaceId} />,
|
||||||
|
roles: <LLMRoleManager searchSpaceId={searchSpaceId} />,
|
||||||
|
"image-models": <ImageModelManager searchSpaceId={searchSpaceId} />,
|
||||||
|
"team-roles": <RolesManager searchSpaceId={searchSpaceId} />,
|
||||||
|
prompts: <PromptConfigManager searchSpaceId={searchSpaceId} />,
|
||||||
|
"public-links": <PublicChatSnapshotsManager searchSpaceId={searchSpaceId} />,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsDialog
|
||||||
|
open={state.open}
|
||||||
|
onOpenChange={(open) => setState((prev) => ({ ...prev, open }))}
|
||||||
|
title={t("title")}
|
||||||
|
navItems={navItems}
|
||||||
|
activeItem={state.initialTab}
|
||||||
|
onItemChange={(tab) => setState((prev) => ({ ...prev, initialTab: tab }))}
|
||||||
|
>
|
||||||
|
<div className="pt-4">{content[state.initialTab]}</div>
|
||||||
|
</SettingsDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
126
surfsense_web/components/settings/settings-dialog.tsx
Normal file
126
surfsense_web/components/settings/settings-dialog.tsx
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import type * as React from "react";
|
||||||
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SettingsDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
title: string;
|
||||||
|
navItems: NavItem[];
|
||||||
|
activeItem: string;
|
||||||
|
onItemChange: (value: string) => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
title,
|
||||||
|
navItems,
|
||||||
|
activeItem,
|
||||||
|
onItemChange,
|
||||||
|
children,
|
||||||
|
}: SettingsDialogProps) {
|
||||||
|
const activeRef = useRef<HTMLButtonElement>(null);
|
||||||
|
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 handleItemChange = (value: string) => {
|
||||||
|
onItemChange(value);
|
||||||
|
activeRef.current?.scrollIntoView({ inline: "center", block: "nearest", behavior: "smooth" });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="select-none max-w-[900px] w-[95vw] md:w-[90vw] h-[90vh] md:h-[80vh] max-h-[640px] flex flex-col md:flex-row p-0 gap-0 overflow-hidden [--card:var(--background)] dark:[--card:oklch(0.205_0_0)] dark:[--background:oklch(0.205_0_0)]">
|
||||||
|
<DialogTitle className="sr-only">{title}</DialogTitle>
|
||||||
|
|
||||||
|
{/* Desktop: Left sidebar */}
|
||||||
|
<nav className="hidden md:flex w-[220px] shrink-0 flex-col border-r border-border p-3 pt-6">
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onItemChange(item.value)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors text-left focus:outline-none focus-visible:outline-none",
|
||||||
|
activeItem === item.value
|
||||||
|
? "bg-accent text-accent-foreground"
|
||||||
|
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Mobile: Top header + horizontal tabs */}
|
||||||
|
<div className="flex md:hidden flex-col shrink-0">
|
||||||
|
<div className="px-4 pt-4 pb-2">
|
||||||
|
<h2 className="text-base font-semibold">{title}</h2>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="overflow-x-auto scrollbar-hide border-b border-border"
|
||||||
|
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 px-4 pb-2">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.value}
|
||||||
|
ref={activeItem === item.value ? activeRef : undefined}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleItemChange(item.value)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 whitespace-nowrap rounded-full px-3 py-1.5 text-xs font-medium transition-colors shrink-0 focus:outline-none focus-visible:outline-none",
|
||||||
|
activeItem === item.value
|
||||||
|
? "bg-accent text-accent-foreground"
|
||||||
|
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content area */}
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden min-w-0">
|
||||||
|
<div className="hidden md:block px-8 pt-6 pb-2">
|
||||||
|
<h2 className="text-lg font-semibold">
|
||||||
|
{navItems.find((i) => i.value === activeItem)?.label ?? title}
|
||||||
|
</h2>
|
||||||
|
<Separator className="mt-4" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto overflow-x-hidden">
|
||||||
|
<div className="px-4 md:px-8 pb-6 pt-4 md:pt-0 min-w-0">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
surfsense_web/components/settings/team-dialog.tsx
Normal file
32
surfsense_web/components/settings/team-dialog.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { teamDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
|
||||||
|
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { TeamContent } from "@/app/dashboard/[search_space_id]/team/team-content";
|
||||||
|
|
||||||
|
interface TeamDialogProps {
|
||||||
|
searchSpaceId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TeamDialog({ searchSpaceId }: TeamDialogProps) {
|
||||||
|
const t = useTranslations("sidebar");
|
||||||
|
const [open, setOpen] = useAtom(teamDialogAtom);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent
|
||||||
|
className="select-none max-w-[900px] w-[95vw] md:w-[90vw] h-[90vh] md:h-[80vh] max-h-[640px] flex flex-col p-0 gap-0 overflow-hidden [--card:var(--background)] dark:[--card:oklch(0.205_0_0)] dark:[--background:oklch(0.205_0_0)]"
|
||||||
|
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<DialogTitle className="sr-only">{t("manage_members")}</DialogTitle>
|
||||||
|
<div className="flex-1 overflow-y-auto overflow-x-hidden">
|
||||||
|
<div className="px-6 md:px-8 py-6 min-w-0">
|
||||||
|
<TeamContent searchSpaceId={searchSpaceId} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
surfsense_web/components/settings/user-settings-dialog.tsx
Normal file
39
surfsense_web/components/settings/user-settings-dialog.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { KeyRound, User } from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { userSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
|
||||||
|
import { ApiKeyContent } from "@/app/dashboard/[search_space_id]/user-settings/components/ApiKeyContent";
|
||||||
|
import { ProfileContent } from "@/app/dashboard/[search_space_id]/user-settings/components/ProfileContent";
|
||||||
|
import { SettingsDialog } from "@/components/settings/settings-dialog";
|
||||||
|
|
||||||
|
export function UserSettingsDialog() {
|
||||||
|
const t = useTranslations("userSettings");
|
||||||
|
const [state, setState] = useAtom(userSettingsDialogAtom);
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ value: "profile", label: t("profile_nav_label"), icon: <User className="h-4 w-4" /> },
|
||||||
|
{
|
||||||
|
value: "api-key",
|
||||||
|
label: t("api_key_nav_label"),
|
||||||
|
icon: <KeyRound className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsDialog
|
||||||
|
open={state.open}
|
||||||
|
onOpenChange={(open) => setState((prev) => ({ ...prev, open }))}
|
||||||
|
title={t("title")}
|
||||||
|
navItems={navItems}
|
||||||
|
activeItem={state.initialTab}
|
||||||
|
onItemChange={(tab) => setState((prev) => ({ ...prev, initialTab: tab }))}
|
||||||
|
>
|
||||||
|
<div className="pt-4">
|
||||||
|
{state.initialTab === "profile" && <ProfileContent />}
|
||||||
|
{state.initialTab === "api-key" && <ApiKeyContent />}
|
||||||
|
</div>
|
||||||
|
</SettingsDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -565,7 +565,7 @@ export function LLMConfigForm({
|
||||||
{onCancel && (
|
{onCancel && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="secondary"
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
className="text-xs sm:text-sm h-9 sm:h-10"
|
className="text-xs sm:text-sm h-9 sm:h-10"
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,11 @@ import { makeAssistantToolUI } from "@assistant-ui/react";
|
||||||
import { useAtomValue, useSetAtom } from "jotai";
|
import { useAtomValue, useSetAtom } from "jotai";
|
||||||
import { Dot, FileTextIcon } from "lucide-react";
|
import { Dot, FileTextIcon } from "lucide-react";
|
||||||
import { useParams, usePathname } from "next/navigation";
|
import { useParams, usePathname } from "next/navigation";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { openReportPanelAtom, reportPanelAtom } from "@/atoms/chat/report-panel.atom";
|
import { openReportPanelAtom, reportPanelAtom } from "@/atoms/chat/report-panel.atom";
|
||||||
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
||||||
|
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||||
import { baseApiService } from "@/lib/apis/base-api.service";
|
import { baseApiService } from "@/lib/apis/base-api.service";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -110,15 +111,20 @@ function ReportCard({
|
||||||
title,
|
title,
|
||||||
wordCount,
|
wordCount,
|
||||||
shareToken,
|
shareToken,
|
||||||
|
autoOpen = false,
|
||||||
}: {
|
}: {
|
||||||
reportId: number;
|
reportId: number;
|
||||||
title: string;
|
title: string;
|
||||||
wordCount?: number;
|
wordCount?: number;
|
||||||
/** When set, uses public endpoint for fetching report data */
|
/** When set, uses public endpoint for fetching report data */
|
||||||
shareToken?: string | null;
|
shareToken?: string | null;
|
||||||
|
/** When true, auto-opens the report panel on desktop after metadata loads */
|
||||||
|
autoOpen?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const openPanel = useSetAtom(openReportPanelAtom);
|
const openPanel = useSetAtom(openReportPanelAtom);
|
||||||
const panelState = useAtomValue(reportPanelAtom);
|
const panelState = useAtomValue(reportPanelAtom);
|
||||||
|
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||||
|
const autoOpenedRef = useRef(false);
|
||||||
const [metadata, setMetadata] = useState<{
|
const [metadata, setMetadata] = useState<{
|
||||||
title: string;
|
title: string;
|
||||||
wordCount: number | null;
|
wordCount: number | null;
|
||||||
|
|
@ -154,13 +160,21 @@ function ReportCard({
|
||||||
versionLabel = `version ${idx + 1}`;
|
versionLabel = `version ${idx + 1}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setMetadata({
|
const resolvedTitle = parsed.data.title || title;
|
||||||
title: parsed.data.title || title,
|
const resolvedWordCount = parsed.data.report_metadata?.word_count ?? wordCount ?? null;
|
||||||
wordCount: parsed.data.report_metadata?.word_count ?? wordCount ?? null,
|
setMetadata({ title: resolvedTitle, wordCount: resolvedWordCount, versionLabel });
|
||||||
versionLabel,
|
|
||||||
|
if (autoOpen && isDesktop && !autoOpenedRef.current) {
|
||||||
|
autoOpenedRef.current = true;
|
||||||
|
openPanel({
|
||||||
|
reportId,
|
||||||
|
title: resolvedTitle,
|
||||||
|
wordCount: resolvedWordCount ?? undefined,
|
||||||
|
shareToken,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) setError("No report found");
|
if (!cancelled) setError("No report found");
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -171,7 +185,7 @@ function ReportCard({
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [reportId, title, wordCount, shareToken]);
|
}, [reportId, title, wordCount, shareToken, autoOpen, isDesktop, openPanel]);
|
||||||
|
|
||||||
// Show non-clickable error card for any error (failed status, not found, etc.)
|
// Show non-clickable error card for any error (failed status, not found, etc.)
|
||||||
if (!isLoading && error) {
|
if (!isLoading && error) {
|
||||||
|
|
@ -243,6 +257,13 @@ export const GenerateReportToolUI = makeAssistantToolUI<GenerateReportArgs, Gene
|
||||||
|
|
||||||
const topic = args.topic || "Report";
|
const topic = args.topic || "Report";
|
||||||
|
|
||||||
|
// Track whether we witnessed the generation (running state).
|
||||||
|
// If we mount directly with a result, this stays false → it's a revisit.
|
||||||
|
const sawRunningRef = useRef(false);
|
||||||
|
if (status.type === "running" || status.type === "requires-action") {
|
||||||
|
sawRunningRef.current = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Loading state - tool is still running (LLM generating report)
|
// Loading state - tool is still running (LLM generating report)
|
||||||
if (status.type === "running" || status.type === "requires-action") {
|
if (status.type === "running" || status.type === "requires-action") {
|
||||||
return <ReportGeneratingState topic={topic} />;
|
return <ReportGeneratingState topic={topic} />;
|
||||||
|
|
@ -293,6 +314,7 @@ export const GenerateReportToolUI = makeAssistantToolUI<GenerateReportArgs, Gene
|
||||||
title={result.title || topic}
|
title={result.title || topic}
|
||||||
wordCount={result.word_count ?? undefined}
|
wordCount={result.word_count ?? undefined}
|
||||||
shareToken={shareToken}
|
shareToken={shareToken}
|
||||||
|
autoOpen={sawRunningRef.current}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -77,14 +77,14 @@ const XScrollable = forwardRef<
|
||||||
const dragging = useRef(false);
|
const dragging = useRef(false);
|
||||||
const startX = useRef(0);
|
const startX = useRef(0);
|
||||||
const startScrollLeft = useRef(0);
|
const startScrollLeft = useRef(0);
|
||||||
const [scrollPos, setScrollPos] = useState<"start" | "middle" | "end">("start");
|
const [scrollPos, setScrollPos] = useState<"start" | "middle" | "end" | "none">("none");
|
||||||
|
|
||||||
const updateScrollPos = useCallback(() => {
|
const updateScrollPos = useCallback(() => {
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const canScroll = el.scrollWidth > el.clientWidth + 1;
|
const canScroll = el.scrollWidth > el.clientWidth + 1;
|
||||||
if (!canScroll) {
|
if (!canScroll) {
|
||||||
setScrollPos("start");
|
setScrollPos("none");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const atStart = el.scrollLeft <= 2;
|
const atStart = el.scrollLeft <= 2;
|
||||||
|
|
@ -130,9 +130,12 @@ const XScrollable = forwardRef<
|
||||||
updateScrollPos();
|
updateScrollPos();
|
||||||
}, [updateScrollPos]);
|
}, [updateScrollPos]);
|
||||||
|
|
||||||
const maskStart = scrollPos === "start" ? "black" : "transparent";
|
const needsMask = scrollPos !== "none";
|
||||||
const maskEnd = scrollPos === "end" ? "black" : "transparent";
|
const maskStart = scrollPos === "start" || scrollPos === "none" ? "black" : "transparent";
|
||||||
const maskImage = `linear-gradient(to right, ${maskStart}, black 24px, black calc(100% - 24px), ${maskEnd})`;
|
const maskEnd = scrollPos === "end" || scrollPos === "none" ? "black" : "transparent";
|
||||||
|
const maskImage = needsMask
|
||||||
|
? `linear-gradient(to right, ${maskStart}, black 24px, black calc(100% - 24px), ${maskEnd})`
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// biome-ignore lint/a11y/noStaticElementInteractions: drag-scroll container needs mouse events
|
// biome-ignore lint/a11y/noStaticElementInteractions: drag-scroll container needs mouse events
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ function DrawerContent({
|
||||||
<DrawerOverlay className={overlayClassName} />
|
<DrawerOverlay className={overlayClassName} />
|
||||||
<DrawerPrimitive.Content
|
<DrawerPrimitive.Content
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
|
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-3xl bg-main-panel",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ export function FixedToolbar({
|
||||||
return (
|
return (
|
||||||
<Toolbar
|
<Toolbar
|
||||||
className={cn(
|
className={cn(
|
||||||
"scrollbar-hide sticky top-0 left-0 z-10 w-full justify-between overflow-x-auto rounded-t-lg border-b bg-background/95 p-1 backdrop-blur supports-backdrop-filter:bg-background/60",
|
"scrollbar-hide sticky top-0 left-0 z-10 w-full justify-between overflow-x-auto border-b bg-background/95 p-1 backdrop-blur supports-backdrop-filter:bg-background/60",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
|
|
@ -18,4 +18,9 @@ Practical guides to help you get the most out of SurfSense.
|
||||||
description="Invite teammates, share chats, and collaborate in realtime"
|
description="Invite teammates, share chats, and collaborate in realtime"
|
||||||
href="/docs/how-to/realtime-collaboration"
|
href="/docs/how-to/realtime-collaboration"
|
||||||
/>
|
/>
|
||||||
|
<Card
|
||||||
|
title="Web Search"
|
||||||
|
description="Configure SearXNG web search and residential proxies for production"
|
||||||
|
href="/docs/how-to/web-search"
|
||||||
|
/>
|
||||||
</Cards>
|
</Cards>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"title": "How to",
|
"title": "How to",
|
||||||
"pages": ["electric-sql", "realtime-collaboration"],
|
"pages": ["electric-sql", "realtime-collaboration", "web-search"],
|
||||||
"icon": "Compass",
|
"icon": "Compass",
|
||||||
"defaultOpen": false
|
"defaultOpen": false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
173
surfsense_web/content/docs/how-to/web-search.mdx
Normal file
173
surfsense_web/content/docs/how-to/web-search.mdx
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
---
|
||||||
|
title: Web Search
|
||||||
|
description: How SurfSense web search works and how to configure it for production with residential proxies
|
||||||
|
---
|
||||||
|
|
||||||
|
# Web Search
|
||||||
|
|
||||||
|
SurfSense uses [SearXNG](https://docs.searxng.org/) as a bundled meta-search engine to provide web search across all search spaces. SearXNG aggregates results from multiple search engines (Google, DuckDuckGo, Brave, Bing, and more) without requiring any API keys.
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
When a user triggers a web search in SurfSense:
|
||||||
|
|
||||||
|
1. The backend sends a query to the bundled SearXNG instance via its JSON API
|
||||||
|
2. SearXNG fans out the query to all enabled search engines simultaneously
|
||||||
|
3. Results are aggregated, deduplicated, and ranked by engine weight
|
||||||
|
4. The backend receives merged results and presents them to the user
|
||||||
|
|
||||||
|
SearXNG runs as a Docker container alongside the backend. It is never exposed to the internet. Only the backend communicates with it over the internal Docker network.
|
||||||
|
|
||||||
|
## Docker Setup
|
||||||
|
|
||||||
|
SearXNG is included in both `docker-compose.yml` and `docker-compose.dev.yml` and works out of the box with no configuration needed.
|
||||||
|
|
||||||
|
The backend connects to SearXNG automatically via the `SEARXNG_DEFAULT_HOST` environment variable (defaults to `http://searxng:8080`).
|
||||||
|
|
||||||
|
### Disabling SearXNG
|
||||||
|
|
||||||
|
If you don't need web search, you can skip the SearXNG container entirely:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up --scale searxng=0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using Your Own SearXNG Instance
|
||||||
|
|
||||||
|
To point SurfSense at an external SearXNG instance instead of the bundled one, set in your `docker/.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SEARXNG_DEFAULT_HOST=http://your-searxng:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
SearXNG is configured via `docker/searxng/settings.yml`. The key sections are:
|
||||||
|
|
||||||
|
### Engines
|
||||||
|
|
||||||
|
SearXNG queries multiple search engines in parallel. Each engine has a **weight** that influences how its results rank in the merged output:
|
||||||
|
|
||||||
|
| Engine | Weight | Notes |
|
||||||
|
|--------|--------|-------|
|
||||||
|
| Google | 1.2 | Highest priority, best general results |
|
||||||
|
| DuckDuckGo | 1.1 | Strong privacy-focused alternative |
|
||||||
|
| Brave | 1.0 | Independent search index |
|
||||||
|
| Bing | 0.9 | Different index from Google |
|
||||||
|
| Wikipedia | 0.8 | Encyclopedic results |
|
||||||
|
| StackOverflow | 0.7 | Technical/programming results |
|
||||||
|
| Yahoo | 0.7 | Powered by Bing's index |
|
||||||
|
| Wikidata | 0.6 | Structured data results |
|
||||||
|
| Currency | default | Currency conversion |
|
||||||
|
| DDG Definitions | default | Instant answers from DuckDuckGo |
|
||||||
|
|
||||||
|
All engines are free. SearXNG scrapes public search pages, no API keys required.
|
||||||
|
|
||||||
|
### Engine Suspension
|
||||||
|
|
||||||
|
When a search engine returns an error (CAPTCHA, rate limit, access denied), SearXNG suspends it for a configurable duration. After the suspension expires, the engine is automatically retried.
|
||||||
|
|
||||||
|
The default suspension times are tuned for use with rotating residential proxies (shorter bans since each retry goes through a different IP):
|
||||||
|
|
||||||
|
| Error Type | Suspension | Default (without override) |
|
||||||
|
|------------|-----------|---------------------------|
|
||||||
|
| Access Denied (403) | 1 hour | 24 hours |
|
||||||
|
| CAPTCHA | 1 hour | 24 hours |
|
||||||
|
| Too Many Requests (429) | 10 minutes | 1 hour |
|
||||||
|
| Cloudflare CAPTCHA | 2 hours | 15 days |
|
||||||
|
| Cloudflare Access Denied | 1 hour | 24 hours |
|
||||||
|
| reCAPTCHA | 2 hours | 7 days |
|
||||||
|
|
||||||
|
### Timeouts
|
||||||
|
|
||||||
|
| Setting | Value | Description |
|
||||||
|
|---------|-------|-------------|
|
||||||
|
| `request_timeout` | 12s | Default timeout per engine request |
|
||||||
|
| `max_request_timeout` | 20s | Maximum allowed timeout (must be ≥ `request_timeout`) |
|
||||||
|
| `extra_proxy_timeout` | 10s | Extra seconds added when using a proxy |
|
||||||
|
| `retries` | 1 | Retries on HTTP error (uses a different proxy IP per retry) |
|
||||||
|
|
||||||
|
## Production: Residential Proxies
|
||||||
|
|
||||||
|
In production, search engines may rate-limit or block your server's IP. To avoid this, configure a residential proxy so SearXNG's outgoing requests appear to come from rotating residential IPs.
|
||||||
|
|
||||||
|
### Step 1: Build the Proxy URL
|
||||||
|
|
||||||
|
SurfSense uses [anonymous-proxies.net](https://anonymous-proxies.net/) style residential proxies where the password is a base64-encoded JSON object. Build the URL using your proxy credentials:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Encode the password (replace with your actual values)
|
||||||
|
echo -n '{"p": "YOUR_PASSWORD", "l": "LOCATION", "t": PROXY_TYPE}' | base64
|
||||||
|
```
|
||||||
|
|
||||||
|
The full proxy URL format is:
|
||||||
|
|
||||||
|
```
|
||||||
|
http://<username>:<base64_password>@<hostname>:<port>/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Add to SearXNG Settings
|
||||||
|
|
||||||
|
In `docker/searxng/settings.yml`, add the proxy URL under `outgoing.proxies`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
outgoing:
|
||||||
|
proxies:
|
||||||
|
all://:
|
||||||
|
- http://username:base64password@proxy-host:port/
|
||||||
|
```
|
||||||
|
|
||||||
|
The `all://:` key routes both HTTP and HTTPS requests through the proxy. If you have multiple proxy endpoints, list them and SearXNG will round-robin between them:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
proxies:
|
||||||
|
all://:
|
||||||
|
- http://user:pass@proxy1:port/
|
||||||
|
- http://user:pass@proxy2:port/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Restart SearXNG
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose restart searxng
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verify
|
||||||
|
|
||||||
|
Check that SearXNG is healthy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8888/healthz
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### SearXNG Fails to Start
|
||||||
|
|
||||||
|
**`ValueError: Invalid settings.yml`** - Check the error line above the traceback. Common causes:
|
||||||
|
- `extra_proxy_timeout` must be an integer (use `10`, not `10.0`)
|
||||||
|
- `KeyError: 'engine_name'` means an engine was removed but other engines reference its network. Remove all variants (e.g., removing `qwant` also requires removing `qwant news`, `qwant images`, `qwant videos`)
|
||||||
|
|
||||||
|
### Engines Getting Suspended
|
||||||
|
|
||||||
|
If an engine is suspended (visible in SearXNG logs as `suspended_time=N`), it will automatically recover after the suspension period. With residential proxies, the next request after recovery goes through a different IP and typically succeeds.
|
||||||
|
|
||||||
|
### No Web Search Results
|
||||||
|
|
||||||
|
1. Check SearXNG health: `curl http://localhost:8888/healthz`
|
||||||
|
2. Check SearXNG logs: `docker compose logs searxng`
|
||||||
|
3. Verify the backend can reach SearXNG: the `SEARXNG_DEFAULT_HOST` env var should point to `http://searxng:8080` (Docker) or `http://localhost:8888` (local dev)
|
||||||
|
|
||||||
|
### Proxy Not Working
|
||||||
|
|
||||||
|
- Verify the base64 password is correctly encoded
|
||||||
|
- Check that `extra_proxy_timeout` is set (proxies add latency)
|
||||||
|
- Ensure `max_request_timeout` is high enough to accommodate `request_timeout + extra_proxy_timeout`
|
||||||
|
|
||||||
|
## Environment Variables Reference
|
||||||
|
|
||||||
|
| Variable | Location | Description | Default |
|
||||||
|
|----------|----------|-------------|---------|
|
||||||
|
| `SEARXNG_DEFAULT_HOST` | `docker/.env` | URL of the SearXNG instance | `http://searxng:8080` |
|
||||||
|
| `SEARXNG_SECRET` | `docker/.env` | Secret key for SearXNG | `surfsense-searxng-secret` |
|
||||||
|
| `SEARXNG_PORT` | `docker/.env` | Port to expose SearXNG UI on the host | `8888` |
|
||||||
32
surfsense_web/contracts/enums/toolIcons.tsx
Normal file
32
surfsense_web/contracts/enums/toolIcons.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import {
|
||||||
|
BookOpen,
|
||||||
|
Brain,
|
||||||
|
Database,
|
||||||
|
FileText,
|
||||||
|
Globe,
|
||||||
|
ImageIcon,
|
||||||
|
Link2,
|
||||||
|
type LucideIcon,
|
||||||
|
Podcast,
|
||||||
|
ScanLine,
|
||||||
|
Sparkles,
|
||||||
|
Wrench,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
const TOOL_ICONS: Record<string, LucideIcon> = {
|
||||||
|
search_knowledge_base: Database,
|
||||||
|
generate_podcast: Podcast,
|
||||||
|
generate_report: FileText,
|
||||||
|
link_preview: Link2,
|
||||||
|
display_image: ImageIcon,
|
||||||
|
generate_image: Sparkles,
|
||||||
|
scrape_webpage: ScanLine,
|
||||||
|
web_search: Globe,
|
||||||
|
search_surfsense_docs: BookOpen,
|
||||||
|
save_memory: Brain,
|
||||||
|
recall_memory: Brain,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getToolIcon(name: string): LucideIcon {
|
||||||
|
return TOOL_ICONS[name] ?? Wrench;
|
||||||
|
}
|
||||||
|
|
@ -49,7 +49,12 @@ export const getSearchSpaceResponse = searchSpace.omit({ member_count: true, is_
|
||||||
export const updateSearchSpaceRequest = z.object({
|
export const updateSearchSpaceRequest = z.object({
|
||||||
id: z.number(),
|
id: z.number(),
|
||||||
data: searchSpace
|
data: searchSpace
|
||||||
.pick({ name: true, description: true, citations_enabled: true, qna_custom_instructions: true })
|
.pick({
|
||||||
|
name: true,
|
||||||
|
description: true,
|
||||||
|
citations_enabled: true,
|
||||||
|
qna_custom_instructions: true,
|
||||||
|
})
|
||||||
.partial(),
|
.partial(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -655,6 +655,8 @@
|
||||||
"no_shared_chats": "No shared chats",
|
"no_shared_chats": "No shared chats",
|
||||||
"view_all_shared_chats": "View all shared chats",
|
"view_all_shared_chats": "View all shared chats",
|
||||||
"view_all_private_chats": "View all private chats",
|
"view_all_private_chats": "View all private chats",
|
||||||
|
"show_all": "Show all",
|
||||||
|
"hide": "Hide",
|
||||||
"no_chats": "No chats yet",
|
"no_chats": "No chats yet",
|
||||||
"start_new_chat_hint": "Start a new chat",
|
"start_new_chat_hint": "Start a new chat",
|
||||||
"error_loading_chats": "Error loading chats",
|
"error_loading_chats": "Error loading chats",
|
||||||
|
|
@ -757,7 +759,27 @@
|
||||||
"general_reset": "Reset Changes",
|
"general_reset": "Reset Changes",
|
||||||
"general_save": "Save Changes",
|
"general_save": "Save Changes",
|
||||||
"general_saving": "Saving",
|
"general_saving": "Saving",
|
||||||
"general_unsaved_changes": "You have unsaved changes. Click \"Save Changes\" to apply them."
|
"general_unsaved_changes": "You have unsaved changes. Click \"Save Changes\" to apply them.",
|
||||||
|
"nav_web_search": "Web Search",
|
||||||
|
"nav_web_search_desc": "Built-in web search settings",
|
||||||
|
"web_search_title": "Web Search",
|
||||||
|
"web_search_description": "Web search is powered by a built-in SearXNG instance. All queries are proxied through your server — no data is sent to third parties.",
|
||||||
|
"web_search_enabled_label": "Enable Web Search",
|
||||||
|
"web_search_enabled_description": "When enabled, the AI agent can search the web for real-time information like news, prices, and current events.",
|
||||||
|
"web_search_status_healthy": "Web search service is healthy",
|
||||||
|
"web_search_status_unhealthy": "Web search service is unavailable",
|
||||||
|
"web_search_status_not_configured": "Web search service is not configured",
|
||||||
|
"web_search_engines_label": "Search Engines",
|
||||||
|
"web_search_engines_placeholder": "google,brave,duckduckgo",
|
||||||
|
"web_search_engines_description": "Comma-separated list of SearXNG engines to use. Leave empty for defaults.",
|
||||||
|
"web_search_language_label": "Preferred Language",
|
||||||
|
"web_search_language_placeholder": "en",
|
||||||
|
"web_search_language_description": "IETF language tag (e.g. en, en-US). Leave empty for auto-detect.",
|
||||||
|
"web_search_safesearch_label": "SafeSearch Level",
|
||||||
|
"web_search_safesearch_description": "0 = off, 1 = moderate, 2 = strict",
|
||||||
|
"web_search_save": "Save Web Search Settings",
|
||||||
|
"web_search_saving": "Saving...",
|
||||||
|
"web_search_saved": "Web search settings saved"
|
||||||
},
|
},
|
||||||
"homepage": {
|
"homepage": {
|
||||||
"hero_title_part1": "The AI Workspace",
|
"hero_title_part1": "The AI Workspace",
|
||||||
|
|
|
||||||
|
|
@ -655,6 +655,8 @@
|
||||||
"no_shared_chats": "No hay chats compartidos",
|
"no_shared_chats": "No hay chats compartidos",
|
||||||
"view_all_shared_chats": "Ver todos los chats compartidos",
|
"view_all_shared_chats": "Ver todos los chats compartidos",
|
||||||
"view_all_private_chats": "Ver todos los chats privados",
|
"view_all_private_chats": "Ver todos los chats privados",
|
||||||
|
"show_all": "Ver todo",
|
||||||
|
"hide": "Ocultar",
|
||||||
"no_chats": "Aún no hay chats",
|
"no_chats": "Aún no hay chats",
|
||||||
"start_new_chat_hint": "Iniciar un nuevo chat",
|
"start_new_chat_hint": "Iniciar un nuevo chat",
|
||||||
"error_loading_chats": "Error al cargar chats",
|
"error_loading_chats": "Error al cargar chats",
|
||||||
|
|
|
||||||
|
|
@ -655,6 +655,8 @@
|
||||||
"no_shared_chats": "कोई साझा चैट नहीं",
|
"no_shared_chats": "कोई साझा चैट नहीं",
|
||||||
"view_all_shared_chats": "सभी साझा चैट देखें",
|
"view_all_shared_chats": "सभी साझा चैट देखें",
|
||||||
"view_all_private_chats": "सभी निजी चैट देखें",
|
"view_all_private_chats": "सभी निजी चैट देखें",
|
||||||
|
"show_all": "सभी देखें",
|
||||||
|
"hide": "छिपाएँ",
|
||||||
"no_chats": "अभी तक कोई चैट नहीं",
|
"no_chats": "अभी तक कोई चैट नहीं",
|
||||||
"start_new_chat_hint": "नई चैट शुरू करें",
|
"start_new_chat_hint": "नई चैट शुरू करें",
|
||||||
"error_loading_chats": "चैट लोड करने में त्रुटि",
|
"error_loading_chats": "चैट लोड करने में त्रुटि",
|
||||||
|
|
|
||||||
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