mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-16 23:01:06 +02:00
Merge commit '1ae927a7db' into dev
This commit is contained in:
commit
b8155c6c0e
116 changed files with 4331 additions and 3713 deletions
|
|
@ -138,7 +138,7 @@ Agrega el servidor MCP de SurfSense a Claude, Cursor o tu propio framework de ag
|
||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"surfsense": {
|
"surfsense": {
|
||||||
"url": "https://mcp.surfsense.com",
|
"url": "https://mcp.surfsense.com/mcp",
|
||||||
"headers": { "Authorization": "Bearer ${SURFSENSE_API_KEY}" }
|
"headers": { "Authorization": "Bearer ${SURFSENSE_API_KEY}" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -138,7 +138,7 @@ SurfSense MCP सर्वर को Claude, Cursor या अपने एज
|
||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"surfsense": {
|
"surfsense": {
|
||||||
"url": "https://mcp.surfsense.com",
|
"url": "https://mcp.surfsense.com/mcp",
|
||||||
"headers": { "Authorization": "Bearer ${SURFSENSE_API_KEY}" }
|
"headers": { "Authorization": "Bearer ${SURFSENSE_API_KEY}" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -138,7 +138,7 @@ Add the SurfSense MCP server to Claude, Cursor, or your own agent framework:
|
||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"surfsense": {
|
"surfsense": {
|
||||||
"url": "https://mcp.surfsense.com",
|
"url": "https://mcp.surfsense.com/mcp",
|
||||||
"headers": { "Authorization": "Bearer ${SURFSENSE_API_KEY}" }
|
"headers": { "Authorization": "Bearer ${SURFSENSE_API_KEY}" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -138,7 +138,7 @@ Adicione o servidor MCP do SurfSense ao Claude, ao Cursor ou ao seu próprio fra
|
||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"surfsense": {
|
"surfsense": {
|
||||||
"url": "https://mcp.surfsense.com",
|
"url": "https://mcp.surfsense.com/mcp",
|
||||||
"headers": { "Authorization": "Bearer ${SURFSENSE_API_KEY}" }
|
"headers": { "Authorization": "Bearer ${SURFSENSE_API_KEY}" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -138,7 +138,7 @@ curl -X POST "$SURFSENSE_API_URL/workspaces/$WORKSPACE_ID/scrapers/reddit/scrape
|
||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"surfsense": {
|
"surfsense": {
|
||||||
"url": "https://mcp.surfsense.com",
|
"url": "https://mcp.surfsense.com/mcp",
|
||||||
"headers": { "Authorization": "Bearer ${SURFSENSE_API_KEY}" }
|
"headers": { "Authorization": "Bearer ${SURFSENSE_API_KEY}" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,7 @@ class CapabilitySummary(BaseModel):
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
description: str
|
description: str
|
||||||
|
docs_url: str | None = None
|
||||||
input_schema: dict
|
input_schema: dict
|
||||||
output_schema: dict
|
output_schema: dict
|
||||||
# Empty list = free (billing disabled or an unmetered verb).
|
# Empty list = free (billing disabled or an unmetered verb).
|
||||||
|
|
@ -145,6 +146,7 @@ def _register_capabilities_list(
|
||||||
CapabilitySummary(
|
CapabilitySummary(
|
||||||
name=capability.name,
|
name=capability.name,
|
||||||
description=capability.description,
|
description=capability.description,
|
||||||
|
docs_url=capability.docs_url,
|
||||||
input_schema=capability.input_schema.model_json_schema(),
|
input_schema=capability.input_schema.model_json_schema(),
|
||||||
output_schema=capability.output_schema.model_json_schema(),
|
output_schema=capability.output_schema.model_json_schema(),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -62,3 +62,4 @@ class Capability:
|
||||||
output_schema: type[BaseModel]
|
output_schema: type[BaseModel]
|
||||||
executor: Executor
|
executor: Executor
|
||||||
billing_unit: BillingUnit | None
|
billing_unit: BillingUnit | None
|
||||||
|
docs_url: str | None = None
|
||||||
|
|
|
||||||
|
|
@ -10,15 +10,14 @@ from app.capabilities.google_maps.reviews.schemas import ReviewsInput, ReviewsOu
|
||||||
GOOGLE_MAPS_REVIEWS = Capability(
|
GOOGLE_MAPS_REVIEWS = Capability(
|
||||||
name="google_maps.reviews",
|
name="google_maps.reviews",
|
||||||
description=(
|
description=(
|
||||||
"Fetch public reviews for one or more Google Maps places. Give it place "
|
"Fetch public Google Maps reviews with authors, ratings, text, and "
|
||||||
"URLs or place IDs; returns structured review items with author, text, "
|
"owner responses. Use urls or place IDs."
|
||||||
"star rating, like count, owner response, and timestamps. Use it to "
|
|
||||||
"gauge sentiment or pull recent feedback on specific places."
|
|
||||||
),
|
),
|
||||||
input_schema=ReviewsInput,
|
input_schema=ReviewsInput,
|
||||||
output_schema=ReviewsOutput,
|
output_schema=ReviewsOutput,
|
||||||
executor=build_reviews_executor(),
|
executor=build_reviews_executor(),
|
||||||
billing_unit=BillingUnit.GOOGLE_MAPS_REVIEW,
|
billing_unit=BillingUnit.GOOGLE_MAPS_REVIEW,
|
||||||
|
docs_url="/docs/connectors/native/google-maps",
|
||||||
)
|
)
|
||||||
|
|
||||||
register_capability(GOOGLE_MAPS_REVIEWS)
|
register_capability(GOOGLE_MAPS_REVIEWS)
|
||||||
|
|
|
||||||
|
|
@ -11,17 +11,14 @@ from app.capabilities.google_maps.scrape.schemas import ScrapeInput, ScrapeOutpu
|
||||||
GOOGLE_MAPS_SCRAPE = Capability(
|
GOOGLE_MAPS_SCRAPE = Capability(
|
||||||
name="google_maps.scrape",
|
name="google_maps.scrape",
|
||||||
description=(
|
description=(
|
||||||
"Scrape public Google Maps places. Give it search queries (optionally "
|
"Scrape public Google Maps places, details, reviews, and photos. Use "
|
||||||
"scoped by location), Google Maps URLs, or place IDs, and it returns "
|
"search_queries, urls, or place IDs."
|
||||||
"structured place items — name, address, category, phone, website, "
|
|
||||||
"rating, review count, coordinates, and opening hours. Set "
|
|
||||||
"include_details for richer detail-page fields, or max_reviews/"
|
|
||||||
"max_images to attach reviews and photos per place."
|
|
||||||
),
|
),
|
||||||
input_schema=ScrapeInput,
|
input_schema=ScrapeInput,
|
||||||
output_schema=ScrapeOutput,
|
output_schema=ScrapeOutput,
|
||||||
executor=build_scrape_executor(),
|
executor=build_scrape_executor(),
|
||||||
billing_unit=BillingUnit.GOOGLE_MAPS_PLACE,
|
billing_unit=BillingUnit.GOOGLE_MAPS_PLACE,
|
||||||
|
docs_url="/docs/connectors/native/google-maps",
|
||||||
)
|
)
|
||||||
|
|
||||||
register_capability(GOOGLE_MAPS_SCRAPE)
|
register_capability(GOOGLE_MAPS_SCRAPE)
|
||||||
|
|
|
||||||
|
|
@ -10,16 +10,14 @@ from app.capabilities.google_search.scrape.schemas import ScrapeInput, ScrapeOut
|
||||||
GOOGLE_SEARCH_SCRAPE = Capability(
|
GOOGLE_SEARCH_SCRAPE = Capability(
|
||||||
name="google_search.scrape",
|
name="google_search.scrape",
|
||||||
description=(
|
description=(
|
||||||
"Search Google and return structured results. Give it search terms "
|
"Search Google and return structured SERP results. Use search_queries "
|
||||||
"(optionally scoped by country/language or to a single site) or full "
|
"or Google Search URLs."
|
||||||
"Google Search URLs, and it returns SERP items — organic results "
|
|
||||||
"(title, url, description), related queries, people-also-ask, and any "
|
|
||||||
"AI overview. Use max_pages_per_query to page deeper."
|
|
||||||
),
|
),
|
||||||
input_schema=ScrapeInput,
|
input_schema=ScrapeInput,
|
||||||
output_schema=ScrapeOutput,
|
output_schema=ScrapeOutput,
|
||||||
executor=build_scrape_executor(),
|
executor=build_scrape_executor(),
|
||||||
billing_unit=BillingUnit.GOOGLE_SEARCH_SERP,
|
billing_unit=BillingUnit.GOOGLE_SEARCH_SERP,
|
||||||
|
docs_url="/docs/connectors/native/google-search",
|
||||||
)
|
)
|
||||||
|
|
||||||
register_capability(GOOGLE_SEARCH_SCRAPE)
|
register_capability(GOOGLE_SEARCH_SCRAPE)
|
||||||
|
|
|
||||||
|
|
@ -10,16 +10,14 @@ from app.capabilities.reddit.scrape.schemas import ScrapeInput, ScrapeOutput
|
||||||
REDDIT_SCRAPE = Capability(
|
REDDIT_SCRAPE = Capability(
|
||||||
name="reddit.scrape",
|
name="reddit.scrape",
|
||||||
description=(
|
description=(
|
||||||
"Scrape public Reddit data. Give it Reddit URLs (post, subreddit, or "
|
"Scrape public Reddit posts, comments, and metadata. Use urls or "
|
||||||
"user) and/or search terms, and it returns structured items — posts "
|
"search_queries."
|
||||||
"(title, body, score, comment count, subreddit, author), their comments, "
|
|
||||||
"and community/user metadata. Use search_queries (optionally scoped to a "
|
|
||||||
"community) to discover posts, or urls to pull a known post/subreddit/user."
|
|
||||||
),
|
),
|
||||||
input_schema=ScrapeInput,
|
input_schema=ScrapeInput,
|
||||||
output_schema=ScrapeOutput,
|
output_schema=ScrapeOutput,
|
||||||
executor=build_scrape_executor(),
|
executor=build_scrape_executor(),
|
||||||
billing_unit=BillingUnit.REDDIT_ITEM,
|
billing_unit=BillingUnit.REDDIT_ITEM,
|
||||||
|
docs_url="/docs/connectors/native/reddit",
|
||||||
)
|
)
|
||||||
|
|
||||||
register_capability(REDDIT_SCRAPE)
|
register_capability(REDDIT_SCRAPE)
|
||||||
|
|
|
||||||
|
|
@ -9,29 +9,14 @@ from app.capabilities.web.crawl.schemas import CrawlInput, CrawlOutput
|
||||||
WEB_CRAWL = Capability(
|
WEB_CRAWL = Capability(
|
||||||
name="web.crawl",
|
name="web.crawl",
|
||||||
description=(
|
description=(
|
||||||
"Scrape a single web page or crawl a whole website. Give it one or more "
|
"Scrape pages or crawl websites for clean markdown, links, metadata, "
|
||||||
"startUrls. Set maxCrawlDepth=0 to fetch just those URLs, or higher to "
|
"and contact signals. Use startUrls and crawl-depth controls."
|
||||||
"also follow the links on each page (depth 1 = the start pages plus the "
|
|
||||||
"pages they link to, and so on) — staying on the same site and stopping "
|
|
||||||
"at maxCrawlPages. On a deeper crawl, narrow which links are followed with "
|
|
||||||
"includeUrlPatterns / excludeUrlPatterns (regexes). Returns one item per "
|
|
||||||
"fetched page with clean markdown content, metadata (title, description), "
|
|
||||||
"crawl provenance, every link with its anchor text and kind "
|
|
||||||
"(internal/external/social/email/tel — use the text/context to tie a "
|
|
||||||
"profile URL to a person or company), and contact signals (emails, phone "
|
|
||||||
"numbers, social profiles). The site-wide contacts summary deduplicates "
|
|
||||||
"them with provenance: siteWide=true marks footer/header values (the "
|
|
||||||
"company's own contacts) vs page-local finds (e.g. team members' "
|
|
||||||
"profiles). Useful for lead generation and competitive intelligence; "
|
|
||||||
"contact details often live on about/contact/privacy pages, so crawl "
|
|
||||||
"with maxCrawlDepth >= 1 to surface them. JS-rendered pages are loaded "
|
|
||||||
"in a real browser and auto-scrolled, so lazy-loaded listings "
|
|
||||||
"(directories, infinite-scroll feeds) are captured too."
|
|
||||||
),
|
),
|
||||||
input_schema=CrawlInput,
|
input_schema=CrawlInput,
|
||||||
output_schema=CrawlOutput,
|
output_schema=CrawlOutput,
|
||||||
executor=build_crawl_executor(),
|
executor=build_crawl_executor(),
|
||||||
billing_unit=BillingUnit.WEB_CRAWL,
|
billing_unit=BillingUnit.WEB_CRAWL,
|
||||||
|
docs_url="/docs/connectors/native/web-crawl",
|
||||||
)
|
)
|
||||||
|
|
||||||
register_capability(WEB_CRAWL)
|
register_capability(WEB_CRAWL)
|
||||||
|
|
|
||||||
|
|
@ -10,15 +10,14 @@ from app.capabilities.youtube.comments.schemas import CommentsInput, CommentsOut
|
||||||
YOUTUBE_COMMENTS = Capability(
|
YOUTUBE_COMMENTS = Capability(
|
||||||
name="youtube.comments",
|
name="youtube.comments",
|
||||||
description=(
|
description=(
|
||||||
"Fetch public comments (and their replies) for one or more YouTube "
|
"Fetch public YouTube comments and replies with authors, text, likes, "
|
||||||
"videos. Give it the video URLs; returns structured comment items with "
|
"and timestamps. Use video URLs."
|
||||||
"author, text, like count, reply relationships, and timestamps. Use it "
|
|
||||||
"to gauge sentiment or pull discussion on specific videos."
|
|
||||||
),
|
),
|
||||||
input_schema=CommentsInput,
|
input_schema=CommentsInput,
|
||||||
output_schema=CommentsOutput,
|
output_schema=CommentsOutput,
|
||||||
executor=build_comments_executor(),
|
executor=build_comments_executor(),
|
||||||
billing_unit=BillingUnit.YOUTUBE_COMMENT,
|
billing_unit=BillingUnit.YOUTUBE_COMMENT,
|
||||||
|
docs_url="/docs/connectors/native/youtube",
|
||||||
)
|
)
|
||||||
|
|
||||||
register_capability(YOUTUBE_COMMENTS)
|
register_capability(YOUTUBE_COMMENTS)
|
||||||
|
|
|
||||||
|
|
@ -10,16 +10,14 @@ from app.capabilities.youtube.scrape.schemas import ScrapeInput, ScrapeOutput
|
||||||
YOUTUBE_SCRAPE = Capability(
|
YOUTUBE_SCRAPE = Capability(
|
||||||
name="youtube.scrape",
|
name="youtube.scrape",
|
||||||
description=(
|
description=(
|
||||||
"Scrape public YouTube data. Give it YouTube URLs (video, channel, "
|
"Scrape public YouTube videos, channels, playlists, and subtitles. Use "
|
||||||
"playlist, shorts, or hashtag) and/or search queries, and it returns "
|
"urls or search_queries."
|
||||||
"structured video items — title, views, likes, publish date, channel "
|
|
||||||
"info, description, and optionally subtitles. Use search_queries to "
|
|
||||||
"discover videos, or urls to pull a known video/channel/playlist."
|
|
||||||
),
|
),
|
||||||
input_schema=ScrapeInput,
|
input_schema=ScrapeInput,
|
||||||
output_schema=ScrapeOutput,
|
output_schema=ScrapeOutput,
|
||||||
executor=build_scrape_executor(),
|
executor=build_scrape_executor(),
|
||||||
billing_unit=BillingUnit.YOUTUBE_VIDEO,
|
billing_unit=BillingUnit.YOUTUBE_VIDEO,
|
||||||
|
docs_url="/docs/connectors/native/youtube",
|
||||||
)
|
)
|
||||||
|
|
||||||
register_capability(YOUTUBE_SCRAPE)
|
register_capability(YOUTUBE_SCRAPE)
|
||||||
|
|
|
||||||
11
surfsense_mcp/.dockerignore
Normal file
11
surfsense_mcp/.dockerignore
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.venv
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.env
|
||||||
|
tests/
|
||||||
36
surfsense_mcp/Dockerfile
Normal file
36
surfsense_mcp/Dockerfile
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
# syntax=docker.io/docker/dockerfile:1
|
||||||
|
# SurfSense MCP Server — remote (streamable-http) image.
|
||||||
|
# Serves /mcp (per-request API key, no baked secret) and a public /health probe.
|
||||||
|
|
||||||
|
# Stage 1: deps frozen from uv.lock so rebuilds never drift.
|
||||||
|
FROM python:3.12-slim AS deps
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# curl is used by the container healthcheck probe against /health.
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY pyproject.toml uv.lock ./
|
||||||
|
RUN pip install --no-cache-dir uv && \
|
||||||
|
uv export --frozen --no-dev --no-emit-project --no-hashes \
|
||||||
|
--format requirements-txt -o /tmp/requirements.txt && \
|
||||||
|
uv pip install --system --no-cache-dir -r /tmp/requirements.txt && \
|
||||||
|
rm /tmp/requirements.txt
|
||||||
|
|
||||||
|
|
||||||
|
# Stage 2: project source; --no-deps since deps are already installed above.
|
||||||
|
FROM deps AS production
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN uv pip install --system --no-cache-dir --no-deps -e .
|
||||||
|
|
||||||
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
|
SURFSENSE_MCP_TRANSPORT=streamable-http \
|
||||||
|
SURFSENSE_MCP_HOST=0.0.0.0 \
|
||||||
|
SURFSENSE_MCP_PORT=8080 \
|
||||||
|
SURFSENSE_BASE_URL=https://api.surfsense.com
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
CMD ["python", "-m", "mcp_server"]
|
||||||
|
|
@ -2,9 +2,15 @@
|
||||||
|
|
||||||
A [Model Context Protocol](https://modelcontextprotocol.io/) server that exposes
|
A [Model Context Protocol](https://modelcontextprotocol.io/) server that exposes
|
||||||
SurfSense to MCP clients like **Claude Code**, **Cursor**, and **Claude Desktop**.
|
SurfSense to MCP clients like **Claude Code**, **Cursor**, and **Claude Desktop**.
|
||||||
It talks to a running SurfSense backend purely over its REST API using a SurfSense
|
It talks to a SurfSense backend purely over its REST API using a SurfSense API
|
||||||
API key — it imports no backend code and can point at any instance (local or
|
key — it imports no backend code.
|
||||||
hosted) by changing two environment variables.
|
|
||||||
|
Connect it two ways:
|
||||||
|
|
||||||
|
- **Hosted** (recommended) — point your client at `https://mcp.surfsense.com/mcp`
|
||||||
|
and pass your API key in a header. Nothing to install or keep running.
|
||||||
|
- **Self-host (stdio)** — run the server yourself against any backend (cloud or
|
||||||
|
your own). Best for self-hosters and clients without remote-server support.
|
||||||
|
|
||||||
## Tools
|
## Tools
|
||||||
|
|
||||||
|
|
@ -29,34 +35,58 @@ Workspace-scoped tools default to the active workspace; pass `workspace` (a name
|
||||||
or id) to override for a single call. Ids never need to be typed by hand — the
|
or id) to override for a single call. Ids never need to be typed by hand — the
|
||||||
model carries them between calls.
|
model carries them between calls.
|
||||||
|
|
||||||
## Prerequisites
|
## Get an API key
|
||||||
|
|
||||||
1. A running SurfSense backend (default `http://localhost:8000`).
|
1. SurfSense → **API Playground → API Keys**: create a personal key (`ss_pat_…`).
|
||||||
2. A **SurfSense API key**: SurfSense → Settings → API → create key (`ss_pat_…`).
|
It is shown only once.
|
||||||
3. **API access enabled** on the workspace(s) you want to use (workspace settings).
|
2. Toggle **API key access** on for the workspace(s) you want to use.
|
||||||
|
|
||||||
## Setup
|
## Connect (hosted)
|
||||||
|
|
||||||
Uses [uv](https://github.com/astral-sh/uv):
|
Point your client at the hosted server and send the key as a Bearer token. For
|
||||||
|
clients that read an `mcpServers` map (Cursor, Claude Desktop, and others):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"surfsense": {
|
||||||
|
"url": "https://mcp.surfsense.com/mcp",
|
||||||
|
"headers": { "Authorization": "Bearer ss_pat_your_key_here" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Claude Code, from a terminal:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
claude mcp add --transport http surfsense https://mcp.surfsense.com/mcp \
|
||||||
|
--header "Authorization: Bearer ss_pat_your_key_here"
|
||||||
|
```
|
||||||
|
|
||||||
|
Most MCP clients accept this `url` + `headers` form; check your client's docs for
|
||||||
|
its exact remote-server field.
|
||||||
|
|
||||||
|
## Self-host (stdio)
|
||||||
|
|
||||||
|
Run the server yourself when you host your own backend or use a client without
|
||||||
|
remote support. It uses [uv](https://github.com/astral-sh/uv):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd surfsense_mcp
|
cd surfsense_mcp
|
||||||
uv sync
|
uv sync
|
||||||
uv run python -m surfsense_mcp.selfcheck # verify tools register correctly
|
uv run python -m mcp_server.selfcheck # verify tools register correctly
|
||||||
```
|
```
|
||||||
|
|
||||||
## Connect it to a client
|
Then add it to your client. Cursor (`~/.cursor/mcp.json` or a project
|
||||||
|
`.cursor/mcp.json`):
|
||||||
### Cursor
|
|
||||||
|
|
||||||
Add to `~/.cursor/mcp.json` (or a project `.cursor/mcp.json`):
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"surfsense": {
|
"surfsense": {
|
||||||
"command": "uv",
|
"command": "uv",
|
||||||
"args": ["run", "--directory", "/absolute/path/to/SurfSense/surfsense_mcp", "python", "-m", "surfsense_mcp"],
|
"args": ["run", "--directory", "/absolute/path/to/SurfSense/surfsense_mcp", "python", "-m", "mcp_server"],
|
||||||
"env": {
|
"env": {
|
||||||
"SURFSENSE_BASE_URL": "http://localhost:8000",
|
"SURFSENSE_BASE_URL": "http://localhost:8000",
|
||||||
"SURFSENSE_API_KEY": "ss_pat_your_token_here"
|
"SURFSENSE_API_KEY": "ss_pat_your_token_here"
|
||||||
|
|
@ -66,24 +96,22 @@ Add to `~/.cursor/mcp.json` (or a project `.cursor/mcp.json`):
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Claude Code
|
Claude Code:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
claude mcp add surfsense \
|
claude mcp add surfsense \
|
||||||
-e SURFSENSE_BASE_URL=http://localhost:8000 \
|
-e SURFSENSE_BASE_URL=http://localhost:8000 \
|
||||||
-e SURFSENSE_API_KEY=ss_pat_your_token_here \
|
-e SURFSENSE_API_KEY=ss_pat_your_token_here \
|
||||||
-- uv run --directory /absolute/path/to/SurfSense/surfsense_mcp python -m surfsense_mcp
|
-- uv run --directory /absolute/path/to/SurfSense/surfsense_mcp python -m mcp_server
|
||||||
```
|
```
|
||||||
|
|
||||||
### Claude Desktop
|
Claude Desktop: add the same `mcpServers` block as Cursor to
|
||||||
|
|
||||||
Add the same `mcpServers` block as Cursor to
|
|
||||||
`claude_desktop_config.json` (Settings → Developer → Edit Config).
|
`claude_desktop_config.json` (Settings → Developer → Edit Config).
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
See `.env.example`. Secrets are passed as environment variables by the client;
|
See `.env.example`. For self-host, secrets are passed as environment variables by
|
||||||
never commit tokens.
|
the client; never commit tokens.
|
||||||
|
|
||||||
## Backend dependency
|
## Backend dependency
|
||||||
|
|
||||||
|
|
|
||||||
58
surfsense_mcp/mcp_server/__main__.py
Normal file
58
surfsense_mcp/mcp_server/__main__.py
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
"""Entry point: load settings from the environment and run the MCP server.
|
||||||
|
|
||||||
|
Two transports share one build:
|
||||||
|
- ``stdio`` (default): Cursor/Claude launch one process per user; the key comes
|
||||||
|
from the environment, so it is required here.
|
||||||
|
- ``streamable-http``: one process serves many users, each passing their own key
|
||||||
|
per request; the key is enforced by the transport's auth middleware instead.
|
||||||
|
|
||||||
|
For stdio, stdout is the protocol channel, so every log line goes to stderr.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
|
from .config import Settings
|
||||||
|
from .server import build_server
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
stream=sys.stderr,
|
||||||
|
format="%(levelname)s %(name)s: %(message)s",
|
||||||
|
)
|
||||||
|
settings = Settings.from_env()
|
||||||
|
transport = os.environ.get("SURFSENSE_MCP_TRANSPORT", "stdio").strip() or "stdio"
|
||||||
|
mcp, _client = build_server(settings)
|
||||||
|
|
||||||
|
if transport in ("streamable-http", "http"):
|
||||||
|
_run_http(mcp, settings)
|
||||||
|
return
|
||||||
|
|
||||||
|
if transport == "stdio" and not settings.api_key:
|
||||||
|
raise SystemExit(
|
||||||
|
"SURFSENSE_API_KEY is required for stdio transport. Create an API "
|
||||||
|
"key in SurfSense (Settings -> API) and pass it via the "
|
||||||
|
"SURFSENSE_API_KEY environment variable."
|
||||||
|
)
|
||||||
|
mcp.run(transport=transport)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_http(mcp: FastMCP, settings: Settings) -> None:
|
||||||
|
"""Serve the streamable-http app directly, so the per-request identity
|
||||||
|
middleware wraps the SDK's MCP endpoint."""
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
from .core.transport import build_http_app
|
||||||
|
|
||||||
|
uvicorn.run(build_http_app(mcp), host=settings.host, port=settings.port)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -12,6 +12,8 @@ from dataclasses import dataclass
|
||||||
DEFAULT_BASE_URL = "http://localhost:8000"
|
DEFAULT_BASE_URL = "http://localhost:8000"
|
||||||
DEFAULT_API_PREFIX = "/api/v1"
|
DEFAULT_API_PREFIX = "/api/v1"
|
||||||
DEFAULT_TIMEOUT_SECONDS = 180.0
|
DEFAULT_TIMEOUT_SECONDS = 180.0
|
||||||
|
DEFAULT_HTTP_HOST = "127.0.0.1"
|
||||||
|
DEFAULT_HTTP_PORT = 8080
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|
@ -19,10 +21,12 @@ class Settings:
|
||||||
"""Resolved configuration for a server process."""
|
"""Resolved configuration for a server process."""
|
||||||
|
|
||||||
base_url: str
|
base_url: str
|
||||||
api_key: str
|
api_key: str | None
|
||||||
api_prefix: str
|
api_prefix: str
|
||||||
timeout: float
|
timeout: float
|
||||||
default_workspace: str | None
|
default_workspace: str | None
|
||||||
|
host: str
|
||||||
|
port: int
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def api_base(self) -> str:
|
def api_base(self) -> str:
|
||||||
|
|
@ -30,13 +34,9 @@ class Settings:
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_env(cls) -> Settings:
|
def from_env(cls) -> Settings:
|
||||||
api_key = os.environ.get("SURFSENSE_API_KEY", "").strip()
|
# Optional here: remote (http) callers pass their own key per request in
|
||||||
if not api_key:
|
# a header. ``__main__`` enforces it for stdio, its only source of a key.
|
||||||
raise SystemExit(
|
api_key = os.environ.get("SURFSENSE_API_KEY", "").strip() or None
|
||||||
"SURFSENSE_API_KEY is required. Create an API key in SurfSense "
|
|
||||||
"(Settings -> API) and pass it via the SURFSENSE_API_KEY "
|
|
||||||
"environment variable."
|
|
||||||
)
|
|
||||||
|
|
||||||
base_url = (
|
base_url = (
|
||||||
os.environ.get("SURFSENSE_BASE_URL", DEFAULT_BASE_URL).strip().rstrip("/")
|
os.environ.get("SURFSENSE_BASE_URL", DEFAULT_BASE_URL).strip().rstrip("/")
|
||||||
|
|
@ -53,10 +53,19 @@ class Settings:
|
||||||
|
|
||||||
default_workspace = os.environ.get("SURFSENSE_WORKSPACE", "").strip() or None
|
default_workspace = os.environ.get("SURFSENSE_WORKSPACE", "").strip() or None
|
||||||
|
|
||||||
|
host = os.environ.get("SURFSENSE_MCP_HOST", "").strip() or DEFAULT_HTTP_HOST
|
||||||
|
raw_port = os.environ.get("SURFSENSE_MCP_PORT", "").strip()
|
||||||
|
try:
|
||||||
|
port = int(raw_port) if raw_port else DEFAULT_HTTP_PORT
|
||||||
|
except ValueError:
|
||||||
|
port = DEFAULT_HTTP_PORT
|
||||||
|
|
||||||
return cls(
|
return cls(
|
||||||
base_url=base_url,
|
base_url=base_url,
|
||||||
api_key=api_key,
|
api_key=api_key,
|
||||||
api_prefix=api_prefix,
|
api_prefix=api_prefix,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
default_workspace=default_workspace,
|
default_workspace=default_workspace,
|
||||||
|
host=host,
|
||||||
|
port=port,
|
||||||
)
|
)
|
||||||
7
surfsense_mcp/mcp_server/core/auth/__init__.py
Normal file
7
surfsense_mcp/mcp_server/core/auth/__init__.py
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
"""Per-request caller identity: header parsing, request-scoped storage, and the
|
||||||
|
ASGI middleware that binds them together for the remote transport."""
|
||||||
|
|
||||||
|
from .identity import current_api_key, current_identity
|
||||||
|
from .middleware import ApiKeyIdentityMiddleware
|
||||||
|
|
||||||
|
__all__ = ["current_api_key", "current_identity", "ApiKeyIdentityMiddleware"]
|
||||||
24
surfsense_mcp/mcp_server/core/auth/headers.py
Normal file
24
surfsense_mcp/mcp_server/core/auth/headers.py
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
"""Extract a SurfSense API key from request headers.
|
||||||
|
|
||||||
|
Pure header parsing, kept separate from transport and state.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from starlette.datastructures import Headers
|
||||||
|
|
||||||
|
_BEARER_PREFIX = "bearer "
|
||||||
|
|
||||||
|
|
||||||
|
def extract_api_key(headers: Headers) -> str | None:
|
||||||
|
"""Return the caller's key from the ``Authorization: Bearer`` slot the
|
||||||
|
backend already expects, falling back to ``X-API-Key`` for clients that can
|
||||||
|
only send custom headers."""
|
||||||
|
authorization = headers.get("authorization", "")
|
||||||
|
if authorization[: len(_BEARER_PREFIX)].lower() == _BEARER_PREFIX:
|
||||||
|
token = authorization[len(_BEARER_PREFIX) :].strip()
|
||||||
|
if token:
|
||||||
|
return token
|
||||||
|
|
||||||
|
fallback = headers.get("x-api-key", "").strip()
|
||||||
|
return fallback or None
|
||||||
34
surfsense_mcp/mcp_server/core/auth/identity.py
Normal file
34
surfsense_mcp/mcp_server/core/auth/identity.py
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
"""Request-scoped caller identity.
|
||||||
|
|
||||||
|
Over streamable-http one process serves many users, so the caller's key lives in
|
||||||
|
a contextvar for the life of a request: the auth middleware binds it and the
|
||||||
|
client reads it when calling the backend. Under stdio there is no request, so the
|
||||||
|
contextvar is empty and the env key is used instead.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextvars import ContextVar, Token
|
||||||
|
|
||||||
|
_LOCAL_IDENTITY = "__local__"
|
||||||
|
|
||||||
|
_api_key: ContextVar[str | None] = ContextVar("surfsense_api_key", default=None)
|
||||||
|
|
||||||
|
|
||||||
|
def bind_api_key(api_key: str | None) -> Token:
|
||||||
|
"""Bind the caller's key to the current request; returns a reset token."""
|
||||||
|
return _api_key.set(api_key)
|
||||||
|
|
||||||
|
|
||||||
|
def unbind_api_key(token: Token) -> None:
|
||||||
|
_api_key.reset(token)
|
||||||
|
|
||||||
|
|
||||||
|
def current_api_key() -> str | None:
|
||||||
|
"""The caller's key for the in-flight request, or ``None`` under stdio."""
|
||||||
|
return _api_key.get()
|
||||||
|
|
||||||
|
|
||||||
|
def current_identity() -> str:
|
||||||
|
"""Stable per-caller key for scoping request state; shared under stdio."""
|
||||||
|
return _api_key.get() or _LOCAL_IDENTITY
|
||||||
58
surfsense_mcp/mcp_server/core/auth/middleware.py
Normal file
58
surfsense_mcp/mcp_server/core/auth/middleware.py
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
"""ASGI middleware that establishes the caller's identity for each request.
|
||||||
|
|
||||||
|
A pure ASGI middleware, deliberately not Starlette's ``BaseHTTPMiddleware``:
|
||||||
|
the latter runs the endpoint in a separate task, so a contextvar set in it does
|
||||||
|
not reach the tool handler. A pure middleware binds the key in the request's own
|
||||||
|
task, from which the SDK's per-request handling inherits it.
|
||||||
|
|
||||||
|
Requests without a key are rejected here so no tool ever runs unauthenticated.
|
||||||
|
Paths in ``public_paths`` (e.g. the health probe) skip the check entirely.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterable
|
||||||
|
|
||||||
|
from starlette.datastructures import Headers
|
||||||
|
from starlette.responses import JSONResponse
|
||||||
|
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||||
|
|
||||||
|
from .headers import extract_api_key
|
||||||
|
from .identity import bind_api_key, unbind_api_key
|
||||||
|
|
||||||
|
|
||||||
|
class ApiKeyIdentityMiddleware:
|
||||||
|
"""Binds the per-request API key into the identity contextvar, or 401s."""
|
||||||
|
|
||||||
|
def __init__(self, app: ASGIApp, public_paths: Iterable[str] = ()) -> None:
|
||||||
|
self._app = app
|
||||||
|
self._public_paths = frozenset(public_paths)
|
||||||
|
|
||||||
|
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||||
|
if scope["type"] != "http" or scope["path"] in self._public_paths:
|
||||||
|
await self._app(scope, receive, send)
|
||||||
|
return
|
||||||
|
|
||||||
|
api_key = extract_api_key(Headers(scope=scope))
|
||||||
|
if api_key is None:
|
||||||
|
await _unauthenticated()(scope, receive, send)
|
||||||
|
return
|
||||||
|
|
||||||
|
token = bind_api_key(api_key)
|
||||||
|
try:
|
||||||
|
await self._app(scope, receive, send)
|
||||||
|
finally:
|
||||||
|
unbind_api_key(token)
|
||||||
|
|
||||||
|
|
||||||
|
def _unauthenticated() -> JSONResponse:
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"error": "unauthorized",
|
||||||
|
"message": (
|
||||||
|
"Missing SurfSense API key. Send 'Authorization: Bearer "
|
||||||
|
"ss_pat_...' (or an X-API-Key header)."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
status_code=401,
|
||||||
|
)
|
||||||
|
|
@ -10,10 +10,11 @@ from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
from .auth.identity import current_api_key
|
||||||
from .errors import ToolError
|
from .errors import ToolError
|
||||||
|
|
||||||
_FAILURE_HINTS: dict[int, str] = {
|
_FAILURE_HINTS: dict[int, str] = {
|
||||||
401: "Authentication failed — check that SURFSENSE_API_KEY is a valid, unexpired key.",
|
401: "Authentication failed — the SurfSense API key is invalid or expired.",
|
||||||
402: "The workspace is out of credits for this operation.",
|
402: "The workspace is out of credits for this operation.",
|
||||||
403: (
|
403: (
|
||||||
"Access denied — the token lacks permission, or API access is disabled "
|
"Access denied — the token lacks permission, or API access is disabled "
|
||||||
|
|
@ -27,17 +28,30 @@ _FAILURE_HINTS: dict[int, str] = {
|
||||||
class SurfSenseClient:
|
class SurfSenseClient:
|
||||||
"""Issues authenticated requests against ``{base_url}{api_prefix}``."""
|
"""Issues authenticated requests against ``{base_url}{api_prefix}``."""
|
||||||
|
|
||||||
def __init__(self, *, api_base: str, api_key: str, timeout: float) -> None:
|
def __init__(
|
||||||
|
self, *, api_base: str, timeout: float, fallback_api_key: str | None = None
|
||||||
|
) -> None:
|
||||||
self._api_base = api_base
|
self._api_base = api_base
|
||||||
|
# Resolved per request, so no key is baked into the shared client. The
|
||||||
|
# fallback is the env key used under stdio, where there is no header.
|
||||||
|
self._fallback_api_key = fallback_api_key
|
||||||
self._http = httpx.AsyncClient(
|
self._http = httpx.AsyncClient(
|
||||||
base_url=api_base,
|
base_url=api_base,
|
||||||
headers={
|
headers={"Accept": "application/json"},
|
||||||
"Authorization": f"Bearer {api_key}",
|
|
||||||
"Accept": "application/json",
|
|
||||||
},
|
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _auth_headers(self) -> dict[str, str]:
|
||||||
|
"""Resolve the caller's key: the per-request header, else the env key."""
|
||||||
|
api_key = current_api_key() or self._fallback_api_key
|
||||||
|
if not api_key:
|
||||||
|
raise ToolError(
|
||||||
|
"No SurfSense API key supplied. Send it as an 'Authorization: "
|
||||||
|
"Bearer ss_pat_...' header (remote server), or set the "
|
||||||
|
"SURFSENSE_API_KEY environment variable (stdio)."
|
||||||
|
)
|
||||||
|
return {"Authorization": f"Bearer {api_key}"}
|
||||||
|
|
||||||
async def request(
|
async def request(
|
||||||
self,
|
self,
|
||||||
method: str,
|
method: str,
|
||||||
|
|
@ -53,9 +67,16 @@ class SurfSenseClient:
|
||||||
# as a value (e.g. int("") on folder_id) and fail.
|
# as a value (e.g. int("") on folder_id) and fail.
|
||||||
if params is not None:
|
if params is not None:
|
||||||
params = {key: value for key, value in params.items() if value is not None}
|
params = {key: value for key, value in params.items() if value is not None}
|
||||||
|
headers = self._auth_headers()
|
||||||
try:
|
try:
|
||||||
response = await self._http.request(
|
response = await self._http.request(
|
||||||
method, path, params=params, json=json, data=data, files=files
|
method,
|
||||||
|
path,
|
||||||
|
params=params,
|
||||||
|
json=json,
|
||||||
|
data=data,
|
||||||
|
files=files,
|
||||||
|
headers=headers,
|
||||||
)
|
)
|
||||||
except httpx.RequestError as exc:
|
except httpx.RequestError as exc:
|
||||||
raise ToolError(
|
raise ToolError(
|
||||||
5
surfsense_mcp/mcp_server/core/transport/__init__.py
Normal file
5
surfsense_mcp/mcp_server/core/transport/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
"""Transport wiring for the MCP server (streamable-http app assembly)."""
|
||||||
|
|
||||||
|
from .http import build_http_app
|
||||||
|
|
||||||
|
__all__ = ["build_http_app"]
|
||||||
36
surfsense_mcp/mcp_server/core/transport/http.py
Normal file
36
surfsense_mcp/mcp_server/core/transport/http.py
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
"""Wrap the SDK's MCP endpoint with identity + CORS for the remote transport.
|
||||||
|
|
||||||
|
CORS is outermost so keyless browser preflight is answered before the identity
|
||||||
|
middleware. ``/health`` is a public path, exempt from the key check.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import JSONResponse
|
||||||
|
from starlette.middleware.cors import CORSMiddleware
|
||||||
|
from starlette.types import ASGIApp
|
||||||
|
|
||||||
|
from ..auth.middleware import ApiKeyIdentityMiddleware
|
||||||
|
|
||||||
|
HEALTH_PATH = "/health"
|
||||||
|
|
||||||
|
|
||||||
|
async def _health(_request: Request) -> JSONResponse:
|
||||||
|
return JSONResponse({"status": "ok"})
|
||||||
|
|
||||||
|
|
||||||
|
def build_http_app(mcp: FastMCP) -> ASGIApp:
|
||||||
|
"""Return the MCP streamable-http app wrapped with identity + CORS."""
|
||||||
|
mcp.custom_route(HEALTH_PATH, methods=["GET"])(_health)
|
||||||
|
app: ASGIApp = ApiKeyIdentityMiddleware(
|
||||||
|
mcp.streamable_http_app(), public_paths={HEALTH_PATH}
|
||||||
|
)
|
||||||
|
return CORSMiddleware(
|
||||||
|
app,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_methods=["GET", "POST", "DELETE", "OPTIONS"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
expose_headers=["Mcp-Session-Id"],
|
||||||
|
)
|
||||||
|
|
@ -7,13 +7,22 @@ speaks a name, we resolve it, and remember the choice for later calls.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import OrderedDict
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
|
|
||||||
|
from .auth.identity import current_identity
|
||||||
from .client import SurfSenseClient
|
from .client import SurfSenseClient
|
||||||
from .errors import ToolError
|
from .errors import ToolError
|
||||||
|
from .workspace_matching import as_int, match_by_name, name_list
|
||||||
|
|
||||||
|
# ponytail: one small entry per distinct caller (API token). Bounded so a flood
|
||||||
|
# of keys can't grow memory without limit; an evicted caller just re-resolves
|
||||||
|
# its default workspace on the next call. Upgrade path: a TTL/LRU store if
|
||||||
|
# per-caller state ever grows past this one field.
|
||||||
|
_MAX_TRACKED_IDENTITIES = 2048
|
||||||
|
|
||||||
# Shared parameter type for every workspace-scoped tool.
|
# Shared parameter type for every workspace-scoped tool.
|
||||||
WorkspaceParam = Annotated[
|
WorkspaceParam = Annotated[
|
||||||
|
|
@ -44,15 +53,21 @@ class WorkspaceContext:
|
||||||
) -> None:
|
) -> None:
|
||||||
self._client = client
|
self._client = client
|
||||||
self._preferred_reference = preferred_reference
|
self._preferred_reference = preferred_reference
|
||||||
self._active: Workspace | None = None
|
# Active selection is per caller: one shared slot would leak one user's
|
||||||
|
# choice to every other user on a shared server.
|
||||||
|
self._active_by_identity: OrderedDict[str, Workspace] = OrderedDict()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def active(self) -> Workspace | None:
|
def active(self) -> Workspace | None:
|
||||||
return self._active
|
return self._active_by_identity.get(current_identity())
|
||||||
|
|
||||||
def remember(self, workspace: Workspace) -> Workspace:
|
def remember(self, workspace: Workspace) -> Workspace:
|
||||||
"""Make ``workspace`` the default for later scoped calls."""
|
"""Make ``workspace`` the default for the current caller's later calls."""
|
||||||
self._active = workspace
|
identity = current_identity()
|
||||||
|
self._active_by_identity[identity] = workspace
|
||||||
|
self._active_by_identity.move_to_end(identity)
|
||||||
|
while len(self._active_by_identity) > _MAX_TRACKED_IDENTITIES:
|
||||||
|
self._active_by_identity.popitem(last=False)
|
||||||
return workspace
|
return workspace
|
||||||
|
|
||||||
async def fetch_all(self) -> list[Workspace]:
|
async def fetch_all(self) -> list[Workspace]:
|
||||||
|
|
@ -67,8 +82,9 @@ class WorkspaceContext:
|
||||||
return self.remember(await self._match(reference))
|
return self.remember(await self._match(reference))
|
||||||
|
|
||||||
async def _resolve_default(self) -> Workspace:
|
async def _resolve_default(self) -> Workspace:
|
||||||
if self._active is not None:
|
active = self.active
|
||||||
return self._active
|
if active is not None:
|
||||||
|
return active
|
||||||
if self._preferred_reference:
|
if self._preferred_reference:
|
||||||
return self.remember(await self._match(self._preferred_reference))
|
return self.remember(await self._match(self._preferred_reference))
|
||||||
return self.remember(await self._only_workspace_or_prompt())
|
return self.remember(await self._only_workspace_or_prompt())
|
||||||
|
|
@ -84,43 +100,20 @@ class WorkspaceContext:
|
||||||
)
|
)
|
||||||
raise ToolError(
|
raise ToolError(
|
||||||
"No workspace selected. Choose one first with surfsense_select_workspace, "
|
"No workspace selected. Choose one first with surfsense_select_workspace, "
|
||||||
f"or pass 'workspace'. Available: {_name_list(workspaces)}."
|
f"or pass 'workspace'. Available: {name_list(workspaces)}."
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _match(self, reference: str | int) -> Workspace:
|
async def _match(self, reference: str | int) -> Workspace:
|
||||||
workspaces = await self.fetch_all()
|
workspaces = await self.fetch_all()
|
||||||
as_id = _as_int(reference)
|
as_id = as_int(reference)
|
||||||
if as_id is not None:
|
if as_id is not None:
|
||||||
found = next((w for w in workspaces if w.id == as_id), None)
|
found = next((w for w in workspaces if w.id == as_id), None)
|
||||||
if found is None:
|
if found is None:
|
||||||
raise ToolError(
|
raise ToolError(
|
||||||
f"No workspace with id {as_id}. Available: {_name_list(workspaces)}."
|
f"No workspace with id {as_id}. Available: {name_list(workspaces)}."
|
||||||
)
|
)
|
||||||
return found
|
return found
|
||||||
return _match_by_name(str(reference), workspaces)
|
return match_by_name(str(reference), workspaces)
|
||||||
|
|
||||||
|
|
||||||
def _match_by_name(reference: str, workspaces: list[Workspace]) -> Workspace:
|
|
||||||
"""Match on name: exact, then case-insensitive, then unique substring."""
|
|
||||||
needle = reference.strip()
|
|
||||||
exact = [w for w in workspaces if w.name == needle]
|
|
||||||
if exact:
|
|
||||||
return exact[0]
|
|
||||||
lowered = needle.casefold()
|
|
||||||
insensitive = [w for w in workspaces if w.name.casefold() == lowered]
|
|
||||||
if insensitive:
|
|
||||||
return insensitive[0]
|
|
||||||
partial = [w for w in workspaces if lowered in w.name.casefold()]
|
|
||||||
if len(partial) == 1:
|
|
||||||
return partial[0]
|
|
||||||
if len(partial) > 1:
|
|
||||||
raise ToolError(
|
|
||||||
f"'{reference}' matches several workspaces: {_name_list(partial)}. "
|
|
||||||
"Use a more specific name or the id."
|
|
||||||
)
|
|
||||||
raise ToolError(
|
|
||||||
f"No workspace named '{reference}'. Available: {_name_list(workspaces)}."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _to_workspace(row: dict) -> Workspace:
|
def _to_workspace(row: dict) -> Workspace:
|
||||||
|
|
@ -131,14 +124,3 @@ def _to_workspace(row: dict) -> Workspace:
|
||||||
is_owner=row.get("is_owner", False),
|
is_owner=row.get("is_owner", False),
|
||||||
member_count=row.get("member_count", 1),
|
member_count=row.get("member_count", 1),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _as_int(reference: str | int) -> int | None:
|
|
||||||
if isinstance(reference, int):
|
|
||||||
return reference
|
|
||||||
text = reference.strip()
|
|
||||||
return int(text) if text.isdigit() else None
|
|
||||||
|
|
||||||
|
|
||||||
def _name_list(workspaces: list[Workspace]) -> str:
|
|
||||||
return ", ".join(f"{w.name} (id {w.id})" for w in workspaces)
|
|
||||||
51
surfsense_mcp/mcp_server/core/workspace_matching.py
Normal file
51
surfsense_mcp/mcp_server/core/workspace_matching.py
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
"""Resolve a user-supplied workspace reference to a single workspace.
|
||||||
|
|
||||||
|
Pure matching over an already-fetched list: name (exact, then case-insensitive,
|
||||||
|
then unique substring) or numeric id. Kept apart from WorkspaceContext so the
|
||||||
|
resolution rules can be read and tested without the network.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from .errors import ToolError
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .workspace_context import Workspace
|
||||||
|
|
||||||
|
|
||||||
|
def match_by_name(reference: str, workspaces: list[Workspace]) -> Workspace:
|
||||||
|
"""Match on name: exact, then case-insensitive, then unique substring."""
|
||||||
|
needle = reference.strip()
|
||||||
|
exact = [w for w in workspaces if w.name == needle]
|
||||||
|
if exact:
|
||||||
|
return exact[0]
|
||||||
|
lowered = needle.casefold()
|
||||||
|
insensitive = [w for w in workspaces if w.name.casefold() == lowered]
|
||||||
|
if insensitive:
|
||||||
|
return insensitive[0]
|
||||||
|
partial = [w for w in workspaces if lowered in w.name.casefold()]
|
||||||
|
if len(partial) == 1:
|
||||||
|
return partial[0]
|
||||||
|
if len(partial) > 1:
|
||||||
|
raise ToolError(
|
||||||
|
f"'{reference}' matches several workspaces: {name_list(partial)}. "
|
||||||
|
"Use a more specific name or the id."
|
||||||
|
)
|
||||||
|
raise ToolError(
|
||||||
|
f"No workspace named '{reference}'. Available: {name_list(workspaces)}."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def as_int(reference: str | int) -> int | None:
|
||||||
|
"""Return the reference as an id, or None when it isn't numeric."""
|
||||||
|
if isinstance(reference, int):
|
||||||
|
return reference
|
||||||
|
text = reference.strip()
|
||||||
|
return int(text) if text.isdigit() else None
|
||||||
|
|
||||||
|
|
||||||
|
def name_list(workspaces: list[Workspace]) -> str:
|
||||||
|
"""Render workspaces as a human-readable 'name (id N)' list."""
|
||||||
|
return ", ".join(f"{w.name} (id {w.id})" for w in workspaces)
|
||||||
22
surfsense_mcp/mcp_server/features/knowledge_base/__init__.py
Normal file
22
surfsense_mcp/mcp_server/features/knowledge_base/__init__.py
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
"""Knowledge-base tools: search the KB and manage its documents.
|
||||||
|
|
||||||
|
Semantic search plus the document lifecycle — list, read, add text, upload a
|
||||||
|
file, update, and delete — over a workspace's knowledge base. Read tools live in
|
||||||
|
search_tools, mutations in document_tools.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
|
from ...core.client import SurfSenseClient
|
||||||
|
from ...core.workspace_context import WorkspaceContext
|
||||||
|
from . import document_tools, search_tools
|
||||||
|
|
||||||
|
|
||||||
|
def register(
|
||||||
|
mcp: FastMCP, client: SurfSenseClient, context: WorkspaceContext
|
||||||
|
) -> None:
|
||||||
|
"""Register every knowledge-base tool on the server."""
|
||||||
|
search_tools.register(mcp, client, context)
|
||||||
|
document_tools.register(mcp, client, context)
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
"""Tool-call policy hints and shared parameter types for knowledge-base tools."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from mcp.types import ToolAnnotations
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
READ = ToolAnnotations(
|
||||||
|
readOnlyHint=True, destructiveHint=False, idempotentHint=True, openWorldHint=False
|
||||||
|
)
|
||||||
|
WRITE = ToolAnnotations(
|
||||||
|
readOnlyHint=False, destructiveHint=False, idempotentHint=False, openWorldHint=False
|
||||||
|
)
|
||||||
|
DELETE = ToolAnnotations(
|
||||||
|
readOnlyHint=False, destructiveHint=True, idempotentHint=False, openWorldHint=False
|
||||||
|
)
|
||||||
|
|
||||||
|
DocumentId = Annotated[
|
||||||
|
int,
|
||||||
|
Field(
|
||||||
|
description="Document id from surfsense_search_knowledge_base or "
|
||||||
|
"surfsense_list_documents results."
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
DocumentTypes = Annotated[
|
||||||
|
list[str] | None,
|
||||||
|
Field(
|
||||||
|
description="Restrict to these document types, e.g. "
|
||||||
|
"['FILE', 'CRAWLED_URL', 'YOUTUBE_VIDEO']. Omit for all types."
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,185 @@
|
||||||
|
"""Knowledge-base write tools: add a note, upload a file, update, and delete.
|
||||||
|
|
||||||
|
Add and upload target the active workspace; update and delete address a document
|
||||||
|
by its account-unique id, so they need no workspace.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import mimetypes
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from ...core.client import SurfSenseClient
|
||||||
|
from ...core.errors import ToolError
|
||||||
|
from ...core.workspace_context import WorkspaceContext, WorkspaceParam
|
||||||
|
from .annotations import DELETE, WRITE, DocumentId
|
||||||
|
from .note_ingestion import build_note_document
|
||||||
|
|
||||||
|
|
||||||
|
def register(
|
||||||
|
mcp: FastMCP, client: SurfSenseClient, context: WorkspaceContext
|
||||||
|
) -> None:
|
||||||
|
"""Register the knowledge-base write and delete tools."""
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
name="surfsense_add_document",
|
||||||
|
title="Add a note",
|
||||||
|
annotations=WRITE,
|
||||||
|
structured_output=False,
|
||||||
|
)
|
||||||
|
async def add_document(
|
||||||
|
title: Annotated[
|
||||||
|
str,
|
||||||
|
Field(min_length=1, description="Short descriptive title for the note."),
|
||||||
|
],
|
||||||
|
content: Annotated[
|
||||||
|
str,
|
||||||
|
Field(
|
||||||
|
min_length=1,
|
||||||
|
description="The note's body; plain text or markdown.",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
source_url: Annotated[
|
||||||
|
str | None,
|
||||||
|
Field(description="Where the text came from, if anywhere."),
|
||||||
|
] = None,
|
||||||
|
workspace: WorkspaceParam = None,
|
||||||
|
) -> str:
|
||||||
|
"""Save a text or markdown note into the workspace's knowledge base.
|
||||||
|
|
||||||
|
Use this to store notes, summaries, or findings so they become
|
||||||
|
searchable later — e.g. after finishing a piece of research. For files
|
||||||
|
on disk use surfsense_upload_file instead. Indexing is asynchronous,
|
||||||
|
so the note may take a moment to appear in search.
|
||||||
|
Example: title='NotebookLM subreddits', content='- r/notebooklm ...'.
|
||||||
|
"""
|
||||||
|
resolved = await context.resolve(workspace)
|
||||||
|
await client.request(
|
||||||
|
"POST",
|
||||||
|
"/documents",
|
||||||
|
json=build_note_document(
|
||||||
|
workspace_id=resolved.id,
|
||||||
|
title=title,
|
||||||
|
content=content,
|
||||||
|
source_url=source_url,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f"Queued '{title}' for indexing in '{resolved.name}'. "
|
||||||
|
"It will be searchable once processing completes."
|
||||||
|
)
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
name="surfsense_upload_file",
|
||||||
|
title="Upload a file",
|
||||||
|
annotations=WRITE,
|
||||||
|
structured_output=False,
|
||||||
|
)
|
||||||
|
async def upload_file(
|
||||||
|
file_path: Annotated[
|
||||||
|
str,
|
||||||
|
Field(
|
||||||
|
description="Path to a local file, e.g. "
|
||||||
|
"'C:/Users/me/report.pdf' or '~/notes/summary.md'."
|
||||||
|
),
|
||||||
|
],
|
||||||
|
use_vision_llm: Annotated[
|
||||||
|
bool,
|
||||||
|
Field(
|
||||||
|
description="True reads scanned or image-heavy files with a "
|
||||||
|
"vision model (slower)."
|
||||||
|
),
|
||||||
|
] = False,
|
||||||
|
workspace: WorkspaceParam = None,
|
||||||
|
) -> str:
|
||||||
|
"""Upload a local file (PDF, docx, markdown, etc.) into the knowledge base.
|
||||||
|
|
||||||
|
Use this to ingest a file from disk so its content becomes searchable;
|
||||||
|
for text you already have in hand use surfsense_add_document instead.
|
||||||
|
The file is parsed, chunked, and indexed asynchronously. Duplicate
|
||||||
|
files are detected and skipped.
|
||||||
|
Example: file_path='C:/Users/me/report.pdf'.
|
||||||
|
"""
|
||||||
|
resolved = await context.resolve(workspace)
|
||||||
|
payload = _read_upload(file_path)
|
||||||
|
result = await client.request(
|
||||||
|
"POST",
|
||||||
|
"/documents/fileupload",
|
||||||
|
data={
|
||||||
|
"workspace_id": str(resolved.id),
|
||||||
|
"use_vision_llm": str(use_vision_llm).lower(),
|
||||||
|
"processing_mode": "basic",
|
||||||
|
},
|
||||||
|
files=[("files", payload)],
|
||||||
|
)
|
||||||
|
pending = (result or {}).get("pending_files", 0)
|
||||||
|
skipped = (result or {}).get("skipped_duplicates", 0)
|
||||||
|
note = " (already present, skipped)" if skipped and not pending else ""
|
||||||
|
return (
|
||||||
|
f"Uploaded '{Path(file_path).name}' to '{resolved.name}'{note}. "
|
||||||
|
"It will be searchable once processing completes."
|
||||||
|
)
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
name="surfsense_update_document",
|
||||||
|
title="Replace a document's content",
|
||||||
|
annotations=WRITE,
|
||||||
|
structured_output=False,
|
||||||
|
)
|
||||||
|
async def update_document(
|
||||||
|
document_id: DocumentId,
|
||||||
|
content: Annotated[
|
||||||
|
str,
|
||||||
|
Field(
|
||||||
|
min_length=1,
|
||||||
|
description="New full text; replaces the existing content "
|
||||||
|
"entirely.",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
) -> str:
|
||||||
|
"""Replace a document's stored content by id.
|
||||||
|
|
||||||
|
Use this to correct or rewrite a document's text. The new content
|
||||||
|
REPLACES the old entirely — to append, read the document first with
|
||||||
|
surfsense_get_document and resend the combined text. Search chunks are
|
||||||
|
not re-indexed by this call.
|
||||||
|
"""
|
||||||
|
existing = await client.request("GET", f"/documents/{document_id}")
|
||||||
|
await client.request(
|
||||||
|
"PUT",
|
||||||
|
f"/documents/{document_id}",
|
||||||
|
json={
|
||||||
|
"document_type": existing["document_type"],
|
||||||
|
"workspace_id": existing["workspace_id"],
|
||||||
|
"content": content,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return f"Updated document {document_id} ('{existing.get('title', '')}')."
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
name="surfsense_delete_document",
|
||||||
|
title="Delete a document",
|
||||||
|
annotations=DELETE,
|
||||||
|
structured_output=False,
|
||||||
|
)
|
||||||
|
async def delete_document(document_id: DocumentId) -> str:
|
||||||
|
"""Permanently delete a document from the knowledge base by id.
|
||||||
|
|
||||||
|
Use this only when the user explicitly asks to remove a document —
|
||||||
|
deletion cannot be undone. The document stops appearing in searches
|
||||||
|
immediately.
|
||||||
|
"""
|
||||||
|
await client.request("DELETE", f"/documents/{document_id}")
|
||||||
|
return f"Deleted document {document_id}."
|
||||||
|
|
||||||
|
|
||||||
|
def _read_upload(file_path: str) -> tuple[str, bytes, str]:
|
||||||
|
path = Path(file_path).expanduser()
|
||||||
|
if not path.is_file():
|
||||||
|
raise ToolError(f"No file at '{file_path}'.")
|
||||||
|
mime, _ = mimetypes.guess_type(path.name)
|
||||||
|
return (path.name, path.read_bytes(), mime or "application/octet-stream")
|
||||||
188
surfsense_mcp/mcp_server/features/knowledge_base/search_tools.py
Normal file
188
surfsense_mcp/mcp_server/features/knowledge_base/search_tools.py
Normal file
|
|
@ -0,0 +1,188 @@
|
||||||
|
"""Knowledge-base read tools: semantic search, list, and read one document.
|
||||||
|
|
||||||
|
Search and list default to the active workspace; a document read is addressed by
|
||||||
|
id, which is unique across the account, so it needs no workspace.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from ...core.client import SurfSenseClient
|
||||||
|
from ...core.rendering import ResponseFormatParam, clip, to_json
|
||||||
|
from ...core.workspace_context import WorkspaceContext, WorkspaceParam
|
||||||
|
from .annotations import READ, DocumentId, DocumentTypes
|
||||||
|
|
||||||
|
|
||||||
|
def register(
|
||||||
|
mcp: FastMCP, client: SurfSenseClient, context: WorkspaceContext
|
||||||
|
) -> None:
|
||||||
|
"""Register the knowledge-base read tools."""
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
name="surfsense_search_knowledge_base",
|
||||||
|
title="Search knowledge base",
|
||||||
|
annotations=READ,
|
||||||
|
structured_output=False,
|
||||||
|
)
|
||||||
|
async def search_knowledge_base(
|
||||||
|
query: Annotated[
|
||||||
|
str,
|
||||||
|
Field(
|
||||||
|
min_length=1,
|
||||||
|
description="Natural-language search, e.g. "
|
||||||
|
"'notebooklm user complaints'.",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
top_k: Annotated[
|
||||||
|
int, Field(ge=1, le=20, description="Maximum documents to return.")
|
||||||
|
] = 5,
|
||||||
|
document_types: DocumentTypes = None,
|
||||||
|
workspace: WorkspaceParam = None,
|
||||||
|
response_format: ResponseFormatParam = "markdown",
|
||||||
|
) -> str:
|
||||||
|
"""Search the workspace's knowledge base by meaning and keywords.
|
||||||
|
|
||||||
|
Use this FIRST when a question might be answered by content already
|
||||||
|
stored in SurfSense — notes, uploaded files, saved pages, past
|
||||||
|
research. Do NOT use it to fetch new data from the web; use the
|
||||||
|
scraper tools for that. Returns the most relevant documents with the
|
||||||
|
passages that matched, ranked by relevance score.
|
||||||
|
Example: query='pricing feedback', top_k=5.
|
||||||
|
"""
|
||||||
|
resolved = await context.resolve(workspace)
|
||||||
|
hits = await client.request(
|
||||||
|
"POST",
|
||||||
|
"/documents/search-semantic",
|
||||||
|
json={
|
||||||
|
"workspace_id": resolved.id,
|
||||||
|
"query": query,
|
||||||
|
"top_k": max(1, min(top_k, 20)),
|
||||||
|
"document_types": document_types,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
items = (hits or {}).get("items", [])
|
||||||
|
if response_format == "json":
|
||||||
|
return to_json(items)
|
||||||
|
return _render_search(query, items)
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
name="surfsense_list_documents",
|
||||||
|
title="List documents",
|
||||||
|
annotations=READ,
|
||||||
|
structured_output=False,
|
||||||
|
)
|
||||||
|
async def list_documents(
|
||||||
|
document_types: DocumentTypes = None,
|
||||||
|
folder_id: Annotated[
|
||||||
|
int | None,
|
||||||
|
Field(description="Only documents in this folder. Omit for all."),
|
||||||
|
] = None,
|
||||||
|
page: Annotated[
|
||||||
|
int, Field(ge=0, description="Zero-based page number.")
|
||||||
|
] = 0,
|
||||||
|
page_size: Annotated[
|
||||||
|
int, Field(ge=1, description="Documents per page.")
|
||||||
|
] = 20,
|
||||||
|
workspace: WorkspaceParam = None,
|
||||||
|
response_format: ResponseFormatParam = "markdown",
|
||||||
|
) -> str:
|
||||||
|
"""List documents in the workspace's knowledge base, newest first.
|
||||||
|
|
||||||
|
Use this to browse or inventory what is stored; to find documents
|
||||||
|
about a topic, prefer surfsense_search_knowledge_base. Returns each
|
||||||
|
document's title, id, type, and update time, plus a has_more flag —
|
||||||
|
request the next page by increasing page.
|
||||||
|
Example: document_types=['FILE'], page=0, page_size=20.
|
||||||
|
"""
|
||||||
|
resolved = await context.resolve(workspace)
|
||||||
|
result = await client.request(
|
||||||
|
"GET",
|
||||||
|
"/documents",
|
||||||
|
params={
|
||||||
|
"workspace_id": resolved.id,
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
"document_types": _join(document_types),
|
||||||
|
"folder_id": folder_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if response_format == "json":
|
||||||
|
return to_json(result)
|
||||||
|
return _render_document_list(result)
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
name="surfsense_get_document",
|
||||||
|
title="Read one document",
|
||||||
|
annotations=READ,
|
||||||
|
structured_output=False,
|
||||||
|
)
|
||||||
|
async def get_document(
|
||||||
|
document_id: DocumentId,
|
||||||
|
response_format: ResponseFormatParam = "markdown",
|
||||||
|
) -> str:
|
||||||
|
"""Read one document's full content and metadata by id.
|
||||||
|
|
||||||
|
Use this after surfsense_search_knowledge_base or
|
||||||
|
surfsense_list_documents to open a specific document — search results
|
||||||
|
only include the matching passages, this returns the whole text.
|
||||||
|
"""
|
||||||
|
document = await client.request("GET", f"/documents/{document_id}")
|
||||||
|
if response_format == "json":
|
||||||
|
return clip(to_json(document))
|
||||||
|
return _render_document(document)
|
||||||
|
|
||||||
|
|
||||||
|
def _join(values: list[str] | None) -> str | None:
|
||||||
|
return ",".join(values) if values else None
|
||||||
|
|
||||||
|
|
||||||
|
def _render_search(query: str, items: list[dict]) -> str:
|
||||||
|
if not items:
|
||||||
|
return f'No matches for "{query}".'
|
||||||
|
lines = [f'# {len(items)} result(s) for "{query}"', ""]
|
||||||
|
for hit in items:
|
||||||
|
lines.append(
|
||||||
|
f"## {hit.get('title', 'Untitled')} "
|
||||||
|
f"(id {hit.get('document_id')}) — score {hit.get('score', 0):.3f}"
|
||||||
|
)
|
||||||
|
for chunk in hit.get("chunks", []):
|
||||||
|
excerpt = clip(chunk.get("content", "").strip(), 500)
|
||||||
|
lines.append(f"> {excerpt}")
|
||||||
|
lines.append("")
|
||||||
|
return "\n".join(lines).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _render_document_list(result: dict | None) -> str:
|
||||||
|
items = (result or {}).get("items", [])
|
||||||
|
if not items:
|
||||||
|
return "No documents found."
|
||||||
|
lines = ["# Documents", ""]
|
||||||
|
for doc in items:
|
||||||
|
lines.append(
|
||||||
|
f"- **{doc.get('title', 'Untitled')}** (id {doc.get('id')}) · "
|
||||||
|
f"{doc.get('document_type')} · updated {doc.get('updated_at')}"
|
||||||
|
)
|
||||||
|
total = (result or {}).get("total", len(items))
|
||||||
|
page = (result or {}).get("page", 0)
|
||||||
|
has_more = (result or {}).get("has_more", False)
|
||||||
|
lines.append("")
|
||||||
|
lines.append(
|
||||||
|
f"_Page {page} · showing {len(items)} of {total}"
|
||||||
|
+ (" · more available_" if has_more else "_")
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_document(document: dict) -> str:
|
||||||
|
content = clip(document.get("content", "") or "(empty)")
|
||||||
|
return (
|
||||||
|
f"# {document.get('title', 'Untitled')} (id {document.get('id')})\n"
|
||||||
|
f"- type: {document.get('document_type')}\n"
|
||||||
|
f"- workspace: {document.get('workspace_id')}\n"
|
||||||
|
f"- updated: {document.get('updated_at')}\n\n"
|
||||||
|
f"{content}"
|
||||||
|
)
|
||||||
26
surfsense_mcp/mcp_server/features/scrapers/__init__.py
Normal file
26
surfsense_mcp/mcp_server/features/scrapers/__init__.py
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
"""Scraper tools: one MCP surface per SurfSense platform capability.
|
||||||
|
|
||||||
|
Web crawl, Google Search, Reddit, YouTube, and Google Maps each get a tool that
|
||||||
|
maps a natural-language request to the workspace's scraper. Two run-history tools
|
||||||
|
list and fetch past runs, so a large result truncated inline can be retrieved in
|
||||||
|
full later. Each platform lives in its own module under platforms/.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
|
from ...core.client import SurfSenseClient
|
||||||
|
from ...core.workspace_context import WorkspaceContext
|
||||||
|
from . import run_history
|
||||||
|
from .platforms import google_maps, google_search, reddit, web, youtube
|
||||||
|
|
||||||
|
_REGISTRARS = (web, google_search, reddit, youtube, google_maps, run_history)
|
||||||
|
|
||||||
|
|
||||||
|
def register(
|
||||||
|
mcp: FastMCP, client: SurfSenseClient, context: WorkspaceContext
|
||||||
|
) -> None:
|
||||||
|
"""Register every scraper and run-history tool on the server."""
|
||||||
|
for module in _REGISTRARS:
|
||||||
|
module.register(mcp, client, context)
|
||||||
13
surfsense_mcp/mcp_server/features/scrapers/annotations.py
Normal file
13
surfsense_mcp/mcp_server/features/scrapers/annotations.py
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
"""Tool-call policy hints shared across scraper tools."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from mcp.types import ToolAnnotations
|
||||||
|
|
||||||
|
SCRAPE = ToolAnnotations(
|
||||||
|
readOnlyHint=False, destructiveHint=False, idempotentHint=False, openWorldHint=True
|
||||||
|
)
|
||||||
|
|
||||||
|
READ_RUNS = ToolAnnotations(
|
||||||
|
readOnlyHint=True, destructiveHint=False, idempotentHint=True, openWorldHint=False
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""One module per scraper platform; each exposes register(mcp, client, context)."""
|
||||||
|
|
@ -0,0 +1,151 @@
|
||||||
|
"""Google Maps scraper tools: places and reviews."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated, Literal
|
||||||
|
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from ....core.client import SurfSenseClient
|
||||||
|
from ....core.rendering import ResponseFormatParam
|
||||||
|
from ....core.workspace_context import WorkspaceContext, WorkspaceParam
|
||||||
|
from ..annotations import SCRAPE
|
||||||
|
from ..capability import run_scraper
|
||||||
|
|
||||||
|
ReviewSort = Literal["newest", "mostRelevant", "highestRanking", "lowestRanking"]
|
||||||
|
|
||||||
|
|
||||||
|
def register(
|
||||||
|
mcp: FastMCP, client: SurfSenseClient, context: WorkspaceContext
|
||||||
|
) -> None:
|
||||||
|
"""Register the Google Maps place and review tools."""
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
name="surfsense_google_maps_scrape",
|
||||||
|
title="Find places on Google Maps",
|
||||||
|
annotations=SCRAPE,
|
||||||
|
structured_output=False,
|
||||||
|
)
|
||||||
|
async def google_maps_scrape(
|
||||||
|
search_queries: Annotated[
|
||||||
|
list[str] | None,
|
||||||
|
Field(
|
||||||
|
description="Place searches, e.g. ['coffee shops']. Provide "
|
||||||
|
"search_queries OR urls OR place_ids."
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
|
urls: Annotated[
|
||||||
|
list[str] | None,
|
||||||
|
Field(description="Google Maps URLs of specific places."),
|
||||||
|
] = None,
|
||||||
|
place_ids: Annotated[
|
||||||
|
list[str] | None,
|
||||||
|
Field(description="Google place ids, e.g. ['ChIJj61dQgK6j4AR...']."),
|
||||||
|
] = None,
|
||||||
|
location: Annotated[
|
||||||
|
str | None,
|
||||||
|
Field(
|
||||||
|
description="Geographic scope for a search, e.g. "
|
||||||
|
"'Seattle, USA'."
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
|
max_places: Annotated[
|
||||||
|
int, Field(ge=1, description="Maximum places to return.")
|
||||||
|
] = 10,
|
||||||
|
include_details: Annotated[
|
||||||
|
bool,
|
||||||
|
Field(
|
||||||
|
description="True adds opening hours and extra contact info "
|
||||||
|
"(slower)."
|
||||||
|
),
|
||||||
|
] = False,
|
||||||
|
workspace: WorkspaceParam = None,
|
||||||
|
response_format: ResponseFormatParam = "markdown",
|
||||||
|
) -> str:
|
||||||
|
"""Find places on Google Maps by search, URL, or place id.
|
||||||
|
|
||||||
|
Use this for local-business and location research: names, addresses,
|
||||||
|
ratings, categories, coordinates, place ids. For a place's customer
|
||||||
|
reviews use surfsense_google_maps_reviews instead.
|
||||||
|
Example: search_queries=['ramen'], location='Osaka, Japan', max_places=5.
|
||||||
|
"""
|
||||||
|
return await run_scraper(
|
||||||
|
client,
|
||||||
|
context,
|
||||||
|
platform="google_maps",
|
||||||
|
verb="scrape",
|
||||||
|
payload={
|
||||||
|
"search_queries": search_queries,
|
||||||
|
"urls": urls,
|
||||||
|
"place_ids": place_ids,
|
||||||
|
"location": location,
|
||||||
|
"max_places": max_places,
|
||||||
|
"include_details": include_details,
|
||||||
|
},
|
||||||
|
workspace=workspace,
|
||||||
|
response_format=response_format,
|
||||||
|
)
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
name="surfsense_google_maps_reviews",
|
||||||
|
title="Fetch Google Maps reviews",
|
||||||
|
annotations=SCRAPE,
|
||||||
|
structured_output=False,
|
||||||
|
)
|
||||||
|
async def google_maps_reviews(
|
||||||
|
urls: Annotated[
|
||||||
|
list[str] | None,
|
||||||
|
Field(
|
||||||
|
description="Google Maps URLs of places. Provide urls OR "
|
||||||
|
"place_ids."
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
|
place_ids: Annotated[
|
||||||
|
list[str] | None,
|
||||||
|
Field(
|
||||||
|
description="Google place ids from surfsense_google_maps_scrape."
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
|
max_reviews: Annotated[
|
||||||
|
int, Field(ge=1, description="Maximum reviews per place.")
|
||||||
|
] = 20,
|
||||||
|
sort_by: Annotated[
|
||||||
|
ReviewSort, Field(description="Review ordering.")
|
||||||
|
] = "newest",
|
||||||
|
language: Annotated[
|
||||||
|
str, Field(description="Reviews language code, e.g. 'en'.")
|
||||||
|
] = "en",
|
||||||
|
start_date: Annotated[
|
||||||
|
str | None,
|
||||||
|
Field(
|
||||||
|
description="ISO date like '2026-01-01'; keeps only reviews on "
|
||||||
|
"or after that day."
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
|
workspace: WorkspaceParam = None,
|
||||||
|
response_format: ResponseFormatParam = "markdown",
|
||||||
|
) -> str:
|
||||||
|
"""Fetch customer reviews for Google Maps places by URL or place id.
|
||||||
|
|
||||||
|
Use this to read feedback on specific places; get urls or place_ids
|
||||||
|
from surfsense_google_maps_scrape first if you only have a name.
|
||||||
|
Returns review text, rating, author, and date per review.
|
||||||
|
Example: place_ids=['ChIJj61dQgK6j4AR...'], sort_by='newest'.
|
||||||
|
"""
|
||||||
|
return await run_scraper(
|
||||||
|
client,
|
||||||
|
context,
|
||||||
|
platform="google_maps",
|
||||||
|
verb="reviews",
|
||||||
|
payload={
|
||||||
|
"urls": urls,
|
||||||
|
"place_ids": place_ids,
|
||||||
|
"max_reviews": max_reviews,
|
||||||
|
"sort_by": sort_by,
|
||||||
|
"language": language,
|
||||||
|
"start_date": start_date,
|
||||||
|
},
|
||||||
|
workspace=workspace,
|
||||||
|
response_format=response_format,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
"""Google Search scraper tool."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from ....core.client import SurfSenseClient
|
||||||
|
from ....core.rendering import ResponseFormatParam
|
||||||
|
from ....core.workspace_context import WorkspaceContext, WorkspaceParam
|
||||||
|
from ..annotations import SCRAPE
|
||||||
|
from ..capability import run_scraper
|
||||||
|
|
||||||
|
|
||||||
|
def register(
|
||||||
|
mcp: FastMCP, client: SurfSenseClient, context: WorkspaceContext
|
||||||
|
) -> None:
|
||||||
|
"""Register the Google Search tool."""
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
name="surfsense_google_search",
|
||||||
|
title="Scrape Google Search",
|
||||||
|
annotations=SCRAPE,
|
||||||
|
structured_output=False,
|
||||||
|
)
|
||||||
|
async def google_search(
|
||||||
|
queries: Annotated[
|
||||||
|
list[str],
|
||||||
|
Field(
|
||||||
|
min_length=1,
|
||||||
|
description="Search terms or full Google Search URLs, e.g. "
|
||||||
|
"['best rss readers 2026'].",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
max_pages_per_query: Annotated[
|
||||||
|
int, Field(ge=1, description="Result pages to fetch per query.")
|
||||||
|
] = 1,
|
||||||
|
country_code: Annotated[
|
||||||
|
str | None,
|
||||||
|
Field(description="Two-letter country to search from, e.g. 'us'."),
|
||||||
|
] = None,
|
||||||
|
language_code: Annotated[
|
||||||
|
str, Field(description="Results language, e.g. 'en'. Empty for default.")
|
||||||
|
] = "",
|
||||||
|
site: Annotated[
|
||||||
|
str | None,
|
||||||
|
Field(
|
||||||
|
description="Restrict results to one domain, e.g. 'example.com'."
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
|
workspace: WorkspaceParam = None,
|
||||||
|
response_format: ResponseFormatParam = "markdown",
|
||||||
|
) -> str:
|
||||||
|
"""Scrape Google Search result pages for one or more queries.
|
||||||
|
|
||||||
|
Use this to discover pages on the open web by topic; follow up with
|
||||||
|
surfsense_web_crawl to read a result in full. Do NOT use it for
|
||||||
|
Reddit, YouTube, or Google Maps research — the dedicated tools return
|
||||||
|
richer data. Returns each query's parsed results: title, url, and
|
||||||
|
snippet per organic result.
|
||||||
|
Example: queries=['notebooklm review'], site='news.ycombinator.com'.
|
||||||
|
"""
|
||||||
|
return await run_scraper(
|
||||||
|
client,
|
||||||
|
context,
|
||||||
|
platform="google_search",
|
||||||
|
verb="scrape",
|
||||||
|
payload={
|
||||||
|
"queries": queries,
|
||||||
|
"max_pages_per_query": max_pages_per_query,
|
||||||
|
"country_code": country_code,
|
||||||
|
"language_code": language_code,
|
||||||
|
"site": site,
|
||||||
|
},
|
||||||
|
workspace=workspace,
|
||||||
|
response_format=response_format,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
"""Reddit scraper tool."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated, Literal
|
||||||
|
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from ....core.client import SurfSenseClient
|
||||||
|
from ....core.rendering import ResponseFormatParam
|
||||||
|
from ....core.workspace_context import WorkspaceContext, WorkspaceParam
|
||||||
|
from ..annotations import SCRAPE
|
||||||
|
from ..capability import run_scraper
|
||||||
|
|
||||||
|
RedditSort = Literal["relevance", "hot", "top", "new", "rising", "comments"]
|
||||||
|
RedditTime = Literal["hour", "day", "week", "month", "year", "all"]
|
||||||
|
|
||||||
|
|
||||||
|
def register(
|
||||||
|
mcp: FastMCP, client: SurfSenseClient, context: WorkspaceContext
|
||||||
|
) -> None:
|
||||||
|
"""Register the Reddit tool."""
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
name="surfsense_reddit_scrape",
|
||||||
|
title="Search or scrape Reddit",
|
||||||
|
annotations=SCRAPE,
|
||||||
|
structured_output=False,
|
||||||
|
)
|
||||||
|
async def reddit_scrape(
|
||||||
|
urls: Annotated[
|
||||||
|
list[str] | None,
|
||||||
|
Field(
|
||||||
|
description="Reddit URLs: a post, a subreddit like "
|
||||||
|
"'https://reddit.com/r/LocalLLaMA', a user page, or a search "
|
||||||
|
"URL. Provide urls OR search_queries."
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
|
search_queries: Annotated[
|
||||||
|
list[str] | None,
|
||||||
|
Field(
|
||||||
|
description="Terms to search Reddit for, e.g. "
|
||||||
|
"['NotebookLM alternatives']. Provide search_queries OR urls."
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
|
community: Annotated[
|
||||||
|
str | None,
|
||||||
|
Field(
|
||||||
|
description="Restrict a search to one subreddit, name without "
|
||||||
|
"'r/', e.g. 'ArtificialInteligence'."
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
|
sort: Annotated[RedditSort, Field(description="Post ordering.")] = "new",
|
||||||
|
time_filter: Annotated[
|
||||||
|
RedditTime | None,
|
||||||
|
Field(description="Time window; only valid with sort='top'."),
|
||||||
|
] = None,
|
||||||
|
max_items: Annotated[
|
||||||
|
int, Field(ge=1, description="Maximum posts to return.")
|
||||||
|
] = 10,
|
||||||
|
skip_comments: Annotated[
|
||||||
|
bool,
|
||||||
|
Field(
|
||||||
|
description="True fetches posts only (faster); False also "
|
||||||
|
"fetches each post's comment thread."
|
||||||
|
),
|
||||||
|
] = False,
|
||||||
|
workspace: WorkspaceParam = None,
|
||||||
|
response_format: ResponseFormatParam = "markdown",
|
||||||
|
) -> str:
|
||||||
|
"""Search or scrape Reddit: posts, comments, subreddits, and users.
|
||||||
|
|
||||||
|
Use this for ANY Reddit research — finding relevant subreddits or
|
||||||
|
communities for a topic, top posts, or discussions — instead of a
|
||||||
|
generic web search. Returns posts (title, text, score, subreddit, url)
|
||||||
|
with comment threads unless skip_comments is set. Every post carries
|
||||||
|
its subreddit, so to find communities for a topic, search posts and
|
||||||
|
aggregate their subreddits.
|
||||||
|
Example: search_queries=['NotebookLM'], sort='top', time_filter='month'.
|
||||||
|
"""
|
||||||
|
return await run_scraper(
|
||||||
|
client,
|
||||||
|
context,
|
||||||
|
platform="reddit",
|
||||||
|
verb="scrape",
|
||||||
|
payload={
|
||||||
|
"urls": urls,
|
||||||
|
"search_queries": search_queries,
|
||||||
|
"community": community,
|
||||||
|
"sort": sort,
|
||||||
|
"time_filter": time_filter,
|
||||||
|
"max_items": max_items,
|
||||||
|
"skip_comments": skip_comments,
|
||||||
|
},
|
||||||
|
workspace=workspace,
|
||||||
|
response_format=response_format,
|
||||||
|
)
|
||||||
89
surfsense_mcp/mcp_server/features/scrapers/platforms/web.py
Normal file
89
surfsense_mcp/mcp_server/features/scrapers/platforms/web.py
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
"""Web crawl scraper tool."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from ....core.client import SurfSenseClient
|
||||||
|
from ....core.rendering import ResponseFormatParam
|
||||||
|
from ....core.workspace_context import WorkspaceContext, WorkspaceParam
|
||||||
|
from ..annotations import SCRAPE
|
||||||
|
from ..capability import run_scraper
|
||||||
|
|
||||||
|
|
||||||
|
def register(
|
||||||
|
mcp: FastMCP, client: SurfSenseClient, context: WorkspaceContext
|
||||||
|
) -> None:
|
||||||
|
"""Register the web crawl tool."""
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
name="surfsense_web_crawl",
|
||||||
|
title="Crawl web pages",
|
||||||
|
annotations=SCRAPE,
|
||||||
|
structured_output=False,
|
||||||
|
)
|
||||||
|
async def web_crawl(
|
||||||
|
start_urls: Annotated[
|
||||||
|
list[str],
|
||||||
|
Field(
|
||||||
|
min_length=1,
|
||||||
|
description="Full URLs to fetch, e.g. "
|
||||||
|
"['https://example.com/blog/post'].",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
max_crawl_depth: Annotated[
|
||||||
|
int,
|
||||||
|
Field(
|
||||||
|
ge=0,
|
||||||
|
description="Link-hops to follow from start_urls within the "
|
||||||
|
"same site. 0 fetches only start_urls.",
|
||||||
|
),
|
||||||
|
] = 0,
|
||||||
|
max_crawl_pages: Annotated[
|
||||||
|
int, Field(ge=1, description="Stop after this many pages in total.")
|
||||||
|
] = 10,
|
||||||
|
max_length: Annotated[
|
||||||
|
int, Field(ge=1, description="Max characters kept per page.")
|
||||||
|
] = 50_000,
|
||||||
|
include_url_patterns: Annotated[
|
||||||
|
list[str] | None,
|
||||||
|
Field(
|
||||||
|
description="Regexes; only discovered links matching one are "
|
||||||
|
"followed, e.g. ['/docs/.*']."
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
|
exclude_url_patterns: Annotated[
|
||||||
|
list[str] | None,
|
||||||
|
Field(description="Regexes; discovered links matching one are skipped."),
|
||||||
|
] = None,
|
||||||
|
workspace: WorkspaceParam = None,
|
||||||
|
response_format: ResponseFormatParam = "markdown",
|
||||||
|
) -> str:
|
||||||
|
"""Fetch specific web pages and return their cleaned content as markdown.
|
||||||
|
|
||||||
|
Use this to read a page the user names, or to spider a site from a
|
||||||
|
starting URL. Do NOT use it to find pages on a topic — use
|
||||||
|
surfsense_google_search for discovery. Returns one item per crawled
|
||||||
|
page: url, title, and the page text as markdown.
|
||||||
|
Example: start_urls=['https://blog.example.com'], max_crawl_depth=1,
|
||||||
|
include_url_patterns=['/2026/'].
|
||||||
|
"""
|
||||||
|
return await run_scraper(
|
||||||
|
client,
|
||||||
|
context,
|
||||||
|
platform="web",
|
||||||
|
verb="crawl",
|
||||||
|
payload={
|
||||||
|
"startUrls": start_urls,
|
||||||
|
"maxCrawlDepth": max_crawl_depth,
|
||||||
|
"maxCrawlPages": max_crawl_pages,
|
||||||
|
"maxLength": max_length,
|
||||||
|
"includeUrlPatterns": include_url_patterns,
|
||||||
|
"excludeUrlPatterns": exclude_url_patterns,
|
||||||
|
},
|
||||||
|
workspace=workspace,
|
||||||
|
response_format=response_format,
|
||||||
|
)
|
||||||
131
surfsense_mcp/mcp_server/features/scrapers/platforms/youtube.py
Normal file
131
surfsense_mcp/mcp_server/features/scrapers/platforms/youtube.py
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
"""YouTube scraper tools: videos and comments."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated, Literal
|
||||||
|
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from ....core.client import SurfSenseClient
|
||||||
|
from ....core.rendering import ResponseFormatParam
|
||||||
|
from ....core.workspace_context import WorkspaceContext, WorkspaceParam
|
||||||
|
from ..annotations import SCRAPE
|
||||||
|
from ..capability import run_scraper
|
||||||
|
|
||||||
|
CommentSort = Literal["TOP_COMMENTS", "NEWEST_FIRST"]
|
||||||
|
|
||||||
|
|
||||||
|
def register(
|
||||||
|
mcp: FastMCP, client: SurfSenseClient, context: WorkspaceContext
|
||||||
|
) -> None:
|
||||||
|
"""Register the YouTube video and comment tools."""
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
name="surfsense_youtube_scrape",
|
||||||
|
title="Search or scrape YouTube",
|
||||||
|
annotations=SCRAPE,
|
||||||
|
structured_output=False,
|
||||||
|
)
|
||||||
|
async def youtube_scrape(
|
||||||
|
urls: Annotated[
|
||||||
|
list[str] | None,
|
||||||
|
Field(
|
||||||
|
description="YouTube URLs: video, channel, playlist, shorts, "
|
||||||
|
"or hashtag pages. Provide urls OR search_queries."
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
|
search_queries: Annotated[
|
||||||
|
list[str] | None,
|
||||||
|
Field(
|
||||||
|
description="Terms to search YouTube for, e.g. "
|
||||||
|
"['NotebookLM tutorial']. Provide search_queries OR urls."
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
|
max_results: Annotated[
|
||||||
|
int, Field(ge=1, description="Maximum videos to return.")
|
||||||
|
] = 10,
|
||||||
|
download_subtitles: Annotated[
|
||||||
|
bool,
|
||||||
|
Field(description="True also fetches each video's transcript."),
|
||||||
|
] = False,
|
||||||
|
subtitles_language: Annotated[
|
||||||
|
str, Field(description="Transcript language code, e.g. 'en'.")
|
||||||
|
] = "en",
|
||||||
|
workspace: WorkspaceParam = None,
|
||||||
|
response_format: ResponseFormatParam = "markdown",
|
||||||
|
) -> str:
|
||||||
|
"""Search or scrape YouTube videos, optionally with transcripts.
|
||||||
|
|
||||||
|
Use this for YouTube research: finding videos on a topic, or reading a
|
||||||
|
video's details or transcript. For a video's comment section use
|
||||||
|
surfsense_youtube_comments instead. Returns per-video metadata (title,
|
||||||
|
channel, views, description, url) and, if requested, the transcript.
|
||||||
|
Example: search_queries=['NotebookLM tutorial'], download_subtitles=True.
|
||||||
|
"""
|
||||||
|
return await run_scraper(
|
||||||
|
client,
|
||||||
|
context,
|
||||||
|
platform="youtube",
|
||||||
|
verb="scrape",
|
||||||
|
payload={
|
||||||
|
"urls": urls,
|
||||||
|
"search_queries": search_queries,
|
||||||
|
"max_results": max_results,
|
||||||
|
"download_subtitles": download_subtitles,
|
||||||
|
"subtitles_language": subtitles_language,
|
||||||
|
},
|
||||||
|
workspace=workspace,
|
||||||
|
response_format=response_format,
|
||||||
|
)
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
name="surfsense_youtube_comments",
|
||||||
|
title="Fetch YouTube comments",
|
||||||
|
annotations=SCRAPE,
|
||||||
|
structured_output=False,
|
||||||
|
)
|
||||||
|
async def youtube_comments(
|
||||||
|
urls: Annotated[
|
||||||
|
list[str],
|
||||||
|
Field(
|
||||||
|
min_length=1,
|
||||||
|
description="YouTube video URLs, e.g. "
|
||||||
|
"['https://www.youtube.com/watch?v=abc123'].",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
max_comments: Annotated[
|
||||||
|
int,
|
||||||
|
Field(
|
||||||
|
ge=1,
|
||||||
|
description="Maximum comments per video, counting top-level "
|
||||||
|
"comments and replies together.",
|
||||||
|
),
|
||||||
|
] = 20,
|
||||||
|
sort_by: Annotated[
|
||||||
|
CommentSort, Field(description="Comment ordering.")
|
||||||
|
] = "NEWEST_FIRST",
|
||||||
|
workspace: WorkspaceParam = None,
|
||||||
|
response_format: ResponseFormatParam = "markdown",
|
||||||
|
) -> str:
|
||||||
|
"""Fetch the comments (and replies) on one or more YouTube videos.
|
||||||
|
|
||||||
|
Use this when the user wants a video's discussion or audience reaction
|
||||||
|
rather than the video itself; get video URLs from
|
||||||
|
surfsense_youtube_scrape if you only have a topic. Returns comment
|
||||||
|
text, author, likes, and replies.
|
||||||
|
Example: urls=['https://www.youtube.com/watch?v=abc123'], max_comments=50.
|
||||||
|
"""
|
||||||
|
return await run_scraper(
|
||||||
|
client,
|
||||||
|
context,
|
||||||
|
platform="youtube",
|
||||||
|
verb="comments",
|
||||||
|
payload={
|
||||||
|
"urls": urls,
|
||||||
|
"max_comments": max_comments,
|
||||||
|
"sort_by": sort_by,
|
||||||
|
},
|
||||||
|
workspace=workspace,
|
||||||
|
response_format=response_format,
|
||||||
|
)
|
||||||
112
surfsense_mcp/mcp_server/features/scrapers/run_history.py
Normal file
112
surfsense_mcp/mcp_server/features/scrapers/run_history.py
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
"""Scraper run history: list past runs and fetch one in full.
|
||||||
|
|
||||||
|
A scrape whose inline result was truncated is retrievable here by run id, so the
|
||||||
|
model never re-runs a scraper just to recover output.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from ...core.client import SurfSenseClient
|
||||||
|
from ...core.rendering import ResponseFormatParam, clip, to_json
|
||||||
|
from ...core.workspace_context import WorkspaceContext, WorkspaceParam
|
||||||
|
from .annotations import READ_RUNS
|
||||||
|
|
||||||
|
|
||||||
|
def register(
|
||||||
|
mcp: FastMCP, client: SurfSenseClient, context: WorkspaceContext
|
||||||
|
) -> None:
|
||||||
|
"""Register the run-history tools."""
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
name="surfsense_list_scraper_runs",
|
||||||
|
title="List past scraper runs",
|
||||||
|
annotations=READ_RUNS,
|
||||||
|
structured_output=False,
|
||||||
|
)
|
||||||
|
async def list_scraper_runs(
|
||||||
|
limit: Annotated[
|
||||||
|
int, Field(ge=1, description="Maximum runs to list.")
|
||||||
|
] = 20,
|
||||||
|
capability: Annotated[
|
||||||
|
str | None,
|
||||||
|
Field(
|
||||||
|
description="Filter by capability slug, e.g. 'web.crawl' or "
|
||||||
|
"'reddit.scrape'."
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
|
status: Annotated[
|
||||||
|
str | None,
|
||||||
|
Field(description="Filter by run status: 'success' or 'error'."),
|
||||||
|
] = None,
|
||||||
|
workspace: WorkspaceParam = None,
|
||||||
|
response_format: ResponseFormatParam = "markdown",
|
||||||
|
) -> str:
|
||||||
|
"""List recent scraper runs in the workspace, newest first.
|
||||||
|
|
||||||
|
Use this to find the run_id of an earlier scrape — for example when an
|
||||||
|
inline result was truncated — then fetch it in full with
|
||||||
|
surfsense_get_scraper_run. Returns each run's id, capability, status,
|
||||||
|
item count, and creation time.
|
||||||
|
Example: capability='reddit.scrape', status='success'.
|
||||||
|
"""
|
||||||
|
resolved = await context.resolve(workspace)
|
||||||
|
runs = await client.request(
|
||||||
|
"GET",
|
||||||
|
f"/workspaces/{resolved.id}/scrapers/runs",
|
||||||
|
params={
|
||||||
|
"limit": limit,
|
||||||
|
"capability": capability,
|
||||||
|
"status": status,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if response_format == "json":
|
||||||
|
return to_json(runs)
|
||||||
|
return _render_runs(runs)
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
name="surfsense_get_scraper_run",
|
||||||
|
title="Fetch one scraper run in full",
|
||||||
|
annotations=READ_RUNS,
|
||||||
|
structured_output=False,
|
||||||
|
)
|
||||||
|
async def get_scraper_run(
|
||||||
|
run_id: Annotated[
|
||||||
|
str,
|
||||||
|
Field(
|
||||||
|
description="Run id from surfsense_list_scraper_runs or a "
|
||||||
|
"prior scrape's output."
|
||||||
|
),
|
||||||
|
],
|
||||||
|
workspace: WorkspaceParam = None,
|
||||||
|
response_format: ResponseFormatParam = "markdown",
|
||||||
|
) -> str:
|
||||||
|
"""Fetch a single scraper run in full, including its stored output.
|
||||||
|
|
||||||
|
Use this to retrieve the complete, untruncated result of an earlier
|
||||||
|
scrape. Do NOT re-run a scraper just to recover a truncated result —
|
||||||
|
fetch the stored run instead.
|
||||||
|
"""
|
||||||
|
resolved = await context.resolve(workspace)
|
||||||
|
run = await client.request(
|
||||||
|
"GET", f"/workspaces/{resolved.id}/scrapers/runs/{run_id}"
|
||||||
|
)
|
||||||
|
if response_format == "json":
|
||||||
|
return clip(to_json(run))
|
||||||
|
return f"# Run {run.get('id', run_id)}\n\n```json\n{clip(to_json(run))}\n```"
|
||||||
|
|
||||||
|
|
||||||
|
def _render_runs(runs: list[dict] | None) -> str:
|
||||||
|
if not runs:
|
||||||
|
return "No scraper runs found."
|
||||||
|
lines = ["# Scraper runs", ""]
|
||||||
|
for run in runs:
|
||||||
|
lines.append(
|
||||||
|
f"- **{run.get('id')}** — {run.get('capability')} · {run.get('status')} · "
|
||||||
|
f"{run.get('item_count', 0)} item(s) · {run.get('created_at')}"
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
@ -47,6 +47,8 @@ async def _collect_tools() -> dict[str, object]:
|
||||||
api_prefix="/api/v1",
|
api_prefix="/api/v1",
|
||||||
timeout=5.0,
|
timeout=5.0,
|
||||||
default_workspace=None,
|
default_workspace=None,
|
||||||
|
host="127.0.0.1",
|
||||||
|
port=8080,
|
||||||
)
|
)
|
||||||
mcp, _client = build_server(settings)
|
mcp, _client = build_server(settings)
|
||||||
tools = await mcp.list_tools()
|
tools = await mcp.list_tools()
|
||||||
|
|
@ -17,12 +17,21 @@ from .features import knowledge_base, scrapers, workspaces
|
||||||
def build_server(settings: Settings) -> tuple[FastMCP, SurfSenseClient]:
|
def build_server(settings: Settings) -> tuple[FastMCP, SurfSenseClient]:
|
||||||
"""Assemble a configured server and the client whose lifecycle it shares."""
|
"""Assemble a configured server and the client whose lifecycle it shares."""
|
||||||
client = SurfSenseClient(
|
client = SurfSenseClient(
|
||||||
api_base=settings.api_base, api_key=settings.api_key, timeout=settings.timeout
|
api_base=settings.api_base,
|
||||||
|
timeout=settings.timeout,
|
||||||
|
fallback_api_key=settings.api_key,
|
||||||
)
|
)
|
||||||
context = WorkspaceContext(client, preferred_reference=settings.default_workspace)
|
context = WorkspaceContext(client, preferred_reference=settings.default_workspace)
|
||||||
|
|
||||||
mcp = FastMCP(
|
mcp = FastMCP(
|
||||||
"SurfSense",
|
"SurfSense",
|
||||||
|
host=settings.host,
|
||||||
|
port=settings.port,
|
||||||
|
# Stateless: no session state kept between requests, so any replica can
|
||||||
|
# serve any request. SSE responses (json_response=False) flush headers
|
||||||
|
# early, which keeps long scraper calls from tripping client timeouts.
|
||||||
|
stateless_http=True,
|
||||||
|
json_response=False,
|
||||||
instructions=(
|
instructions=(
|
||||||
"SurfSense gives you live scrapers and a personal knowledge base. "
|
"SurfSense gives you live scrapers and a personal knowledge base. "
|
||||||
"Prefer these tools over generic/built-in web search whenever the "
|
"Prefer these tools over generic/built-in web search whenever the "
|
||||||
|
|
@ -8,10 +8,12 @@ license = { text = "Apache-2.0" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"mcp>=1.26.0",
|
"mcp>=1.26.0",
|
||||||
"httpx>=0.27.0",
|
"httpx>=0.27.0",
|
||||||
|
"starlette>=0.37",
|
||||||
|
"uvicorn>=0.30",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
surfsense-mcp = "surfsense_mcp.__main__:main"
|
surfsense-mcp = "mcp_server.__main__:main"
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = ["pytest>=8.0"]
|
dev = ["pytest>=8.0"]
|
||||||
|
|
@ -21,7 +23,7 @@ requires = ["hatchling"]
|
||||||
build-backend = "hatchling.build"
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel]
|
[tool.hatch.build.targets.wheel]
|
||||||
packages = ["src/surfsense_mcp"]
|
packages = ["mcp_server"]
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
target-version = "py311"
|
target-version = "py311"
|
||||||
|
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
"""Entry point: load settings from the environment and run the MCP server.
|
|
||||||
|
|
||||||
Defaults to stdio (what Cursor, Claude Code, and Claude Desktop launch). stdout
|
|
||||||
is the protocol channel, so every log line goes to stderr.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
from .config import Settings
|
|
||||||
from .server import build_server
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
logging.basicConfig(
|
|
||||||
level=logging.INFO,
|
|
||||||
stream=sys.stderr,
|
|
||||||
format="%(levelname)s %(name)s: %(message)s",
|
|
||||||
)
|
|
||||||
settings = Settings.from_env()
|
|
||||||
mcp, _client = build_server(settings)
|
|
||||||
|
|
||||||
transport = os.environ.get("SURFSENSE_MCP_TRANSPORT", "stdio").strip() or "stdio"
|
|
||||||
mcp.run(transport=transport)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
|
|
@ -1,379 +0,0 @@
|
||||||
"""Knowledge-base tools: search the KB and manage its documents.
|
|
||||||
|
|
||||||
Semantic search plus the document lifecycle — list, read, add text, upload a
|
|
||||||
file, update, and delete — over a workspace's knowledge base. Search and reads
|
|
||||||
default to the active workspace; document ids identify a single document across
|
|
||||||
the whole account, so id-addressed tools need no workspace.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import mimetypes
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Annotated
|
|
||||||
|
|
||||||
from mcp.server.fastmcp import FastMCP
|
|
||||||
from mcp.types import ToolAnnotations
|
|
||||||
from pydantic import Field
|
|
||||||
|
|
||||||
from ...core.client import SurfSenseClient
|
|
||||||
from ...core.errors import ToolError
|
|
||||||
from ...core.rendering import ResponseFormatParam, clip, to_json
|
|
||||||
from ...core.workspace_context import WorkspaceContext, WorkspaceParam
|
|
||||||
from .note_ingestion import build_note_document
|
|
||||||
|
|
||||||
_READ = ToolAnnotations(
|
|
||||||
readOnlyHint=True, destructiveHint=False, idempotentHint=True, openWorldHint=False
|
|
||||||
)
|
|
||||||
_WRITE = ToolAnnotations(
|
|
||||||
readOnlyHint=False, destructiveHint=False, idempotentHint=False, openWorldHint=False
|
|
||||||
)
|
|
||||||
_DELETE = ToolAnnotations(
|
|
||||||
readOnlyHint=False, destructiveHint=True, idempotentHint=False, openWorldHint=False
|
|
||||||
)
|
|
||||||
|
|
||||||
_DOCUMENT_ID = Annotated[
|
|
||||||
int,
|
|
||||||
Field(
|
|
||||||
description="Document id from surfsense_search_knowledge_base or "
|
|
||||||
"surfsense_list_documents results."
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
_DOCUMENT_TYPES = Annotated[
|
|
||||||
list[str] | None,
|
|
||||||
Field(
|
|
||||||
description="Restrict to these document types, e.g. "
|
|
||||||
"['FILE', 'CRAWLED_URL', 'YOUTUBE_VIDEO']. Omit for all types."
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def register(
|
|
||||||
mcp: FastMCP, client: SurfSenseClient, context: WorkspaceContext
|
|
||||||
) -> None:
|
|
||||||
"""Register the knowledge-base tools on the server."""
|
|
||||||
|
|
||||||
@mcp.tool(
|
|
||||||
name="surfsense_search_knowledge_base",
|
|
||||||
title="Search knowledge base",
|
|
||||||
annotations=_READ,
|
|
||||||
structured_output=False,
|
|
||||||
)
|
|
||||||
async def search_knowledge_base(
|
|
||||||
query: Annotated[
|
|
||||||
str,
|
|
||||||
Field(
|
|
||||||
min_length=1,
|
|
||||||
description="Natural-language search, e.g. "
|
|
||||||
"'notebooklm user complaints'.",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
top_k: Annotated[
|
|
||||||
int, Field(ge=1, le=20, description="Maximum documents to return.")
|
|
||||||
] = 5,
|
|
||||||
document_types: _DOCUMENT_TYPES = None,
|
|
||||||
workspace: WorkspaceParam = None,
|
|
||||||
response_format: ResponseFormatParam = "markdown",
|
|
||||||
) -> str:
|
|
||||||
"""Search the workspace's knowledge base by meaning and keywords.
|
|
||||||
|
|
||||||
Use this FIRST when a question might be answered by content already
|
|
||||||
stored in SurfSense — notes, uploaded files, saved pages, past
|
|
||||||
research. Do NOT use it to fetch new data from the web; use the
|
|
||||||
scraper tools for that. Returns the most relevant documents with the
|
|
||||||
passages that matched, ranked by relevance score.
|
|
||||||
Example: query='pricing feedback', top_k=5.
|
|
||||||
"""
|
|
||||||
resolved = await context.resolve(workspace)
|
|
||||||
hits = await client.request(
|
|
||||||
"POST",
|
|
||||||
"/documents/search-semantic",
|
|
||||||
json={
|
|
||||||
"workspace_id": resolved.id,
|
|
||||||
"query": query,
|
|
||||||
"top_k": max(1, min(top_k, 20)),
|
|
||||||
"document_types": document_types,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
items = (hits or {}).get("items", [])
|
|
||||||
if response_format == "json":
|
|
||||||
return to_json(items)
|
|
||||||
return _render_search(query, items)
|
|
||||||
|
|
||||||
@mcp.tool(
|
|
||||||
name="surfsense_list_documents",
|
|
||||||
title="List documents",
|
|
||||||
annotations=_READ,
|
|
||||||
structured_output=False,
|
|
||||||
)
|
|
||||||
async def list_documents(
|
|
||||||
document_types: _DOCUMENT_TYPES = None,
|
|
||||||
folder_id: Annotated[
|
|
||||||
int | None,
|
|
||||||
Field(description="Only documents in this folder. Omit for all."),
|
|
||||||
] = None,
|
|
||||||
page: Annotated[
|
|
||||||
int, Field(ge=0, description="Zero-based page number.")
|
|
||||||
] = 0,
|
|
||||||
page_size: Annotated[
|
|
||||||
int, Field(ge=1, description="Documents per page.")
|
|
||||||
] = 20,
|
|
||||||
workspace: WorkspaceParam = None,
|
|
||||||
response_format: ResponseFormatParam = "markdown",
|
|
||||||
) -> str:
|
|
||||||
"""List documents in the workspace's knowledge base, newest first.
|
|
||||||
|
|
||||||
Use this to browse or inventory what is stored; to find documents
|
|
||||||
about a topic, prefer surfsense_search_knowledge_base. Returns each
|
|
||||||
document's title, id, type, and update time, plus a has_more flag —
|
|
||||||
request the next page by increasing page.
|
|
||||||
Example: document_types=['FILE'], page=0, page_size=20.
|
|
||||||
"""
|
|
||||||
resolved = await context.resolve(workspace)
|
|
||||||
result = await client.request(
|
|
||||||
"GET",
|
|
||||||
"/documents",
|
|
||||||
params={
|
|
||||||
"workspace_id": resolved.id,
|
|
||||||
"page": page,
|
|
||||||
"page_size": page_size,
|
|
||||||
"document_types": _join(document_types),
|
|
||||||
"folder_id": folder_id,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if response_format == "json":
|
|
||||||
return to_json(result)
|
|
||||||
return _render_document_list(result)
|
|
||||||
|
|
||||||
@mcp.tool(
|
|
||||||
name="surfsense_get_document",
|
|
||||||
title="Read one document",
|
|
||||||
annotations=_READ,
|
|
||||||
structured_output=False,
|
|
||||||
)
|
|
||||||
async def get_document(
|
|
||||||
document_id: _DOCUMENT_ID,
|
|
||||||
response_format: ResponseFormatParam = "markdown",
|
|
||||||
) -> str:
|
|
||||||
"""Read one document's full content and metadata by id.
|
|
||||||
|
|
||||||
Use this after surfsense_search_knowledge_base or
|
|
||||||
surfsense_list_documents to open a specific document — search results
|
|
||||||
only include the matching passages, this returns the whole text.
|
|
||||||
"""
|
|
||||||
document = await client.request("GET", f"/documents/{document_id}")
|
|
||||||
if response_format == "json":
|
|
||||||
return clip(to_json(document))
|
|
||||||
return _render_document(document)
|
|
||||||
|
|
||||||
@mcp.tool(
|
|
||||||
name="surfsense_add_document",
|
|
||||||
title="Add a note",
|
|
||||||
annotations=_WRITE,
|
|
||||||
structured_output=False,
|
|
||||||
)
|
|
||||||
async def add_document(
|
|
||||||
title: Annotated[
|
|
||||||
str,
|
|
||||||
Field(min_length=1, description="Short descriptive title for the note."),
|
|
||||||
],
|
|
||||||
content: Annotated[
|
|
||||||
str,
|
|
||||||
Field(
|
|
||||||
min_length=1,
|
|
||||||
description="The note's body; plain text or markdown.",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
source_url: Annotated[
|
|
||||||
str | None,
|
|
||||||
Field(description="Where the text came from, if anywhere."),
|
|
||||||
] = None,
|
|
||||||
workspace: WorkspaceParam = None,
|
|
||||||
) -> str:
|
|
||||||
"""Save a text or markdown note into the workspace's knowledge base.
|
|
||||||
|
|
||||||
Use this to store notes, summaries, or findings so they become
|
|
||||||
searchable later — e.g. after finishing a piece of research. For files
|
|
||||||
on disk use surfsense_upload_file instead. Indexing is asynchronous,
|
|
||||||
so the note may take a moment to appear in search.
|
|
||||||
Example: title='NotebookLM subreddits', content='- r/notebooklm ...'.
|
|
||||||
"""
|
|
||||||
resolved = await context.resolve(workspace)
|
|
||||||
await client.request(
|
|
||||||
"POST",
|
|
||||||
"/documents",
|
|
||||||
json=build_note_document(
|
|
||||||
workspace_id=resolved.id,
|
|
||||||
title=title,
|
|
||||||
content=content,
|
|
||||||
source_url=source_url,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
f"Queued '{title}' for indexing in '{resolved.name}'. "
|
|
||||||
"It will be searchable once processing completes."
|
|
||||||
)
|
|
||||||
|
|
||||||
@mcp.tool(
|
|
||||||
name="surfsense_upload_file",
|
|
||||||
title="Upload a file",
|
|
||||||
annotations=_WRITE,
|
|
||||||
structured_output=False,
|
|
||||||
)
|
|
||||||
async def upload_file(
|
|
||||||
file_path: Annotated[
|
|
||||||
str,
|
|
||||||
Field(
|
|
||||||
description="Path to a local file, e.g. "
|
|
||||||
"'C:/Users/me/report.pdf' or '~/notes/summary.md'."
|
|
||||||
),
|
|
||||||
],
|
|
||||||
use_vision_llm: Annotated[
|
|
||||||
bool,
|
|
||||||
Field(
|
|
||||||
description="True reads scanned or image-heavy files with a "
|
|
||||||
"vision model (slower)."
|
|
||||||
),
|
|
||||||
] = False,
|
|
||||||
workspace: WorkspaceParam = None,
|
|
||||||
) -> str:
|
|
||||||
"""Upload a local file (PDF, docx, markdown, etc.) into the knowledge base.
|
|
||||||
|
|
||||||
Use this to ingest a file from disk so its content becomes searchable;
|
|
||||||
for text you already have in hand use surfsense_add_document instead.
|
|
||||||
The file is parsed, chunked, and indexed asynchronously. Duplicate
|
|
||||||
files are detected and skipped.
|
|
||||||
Example: file_path='C:/Users/me/report.pdf'.
|
|
||||||
"""
|
|
||||||
resolved = await context.resolve(workspace)
|
|
||||||
payload = _read_upload(file_path)
|
|
||||||
result = await client.request(
|
|
||||||
"POST",
|
|
||||||
"/documents/fileupload",
|
|
||||||
data={
|
|
||||||
"workspace_id": str(resolved.id),
|
|
||||||
"use_vision_llm": str(use_vision_llm).lower(),
|
|
||||||
"processing_mode": "basic",
|
|
||||||
},
|
|
||||||
files=[("files", payload)],
|
|
||||||
)
|
|
||||||
pending = (result or {}).get("pending_files", 0)
|
|
||||||
skipped = (result or {}).get("skipped_duplicates", 0)
|
|
||||||
note = " (already present, skipped)" if skipped and not pending else ""
|
|
||||||
return (
|
|
||||||
f"Uploaded '{Path(file_path).name}' to '{resolved.name}'{note}. "
|
|
||||||
"It will be searchable once processing completes."
|
|
||||||
)
|
|
||||||
|
|
||||||
@mcp.tool(
|
|
||||||
name="surfsense_update_document",
|
|
||||||
title="Replace a document's content",
|
|
||||||
annotations=_WRITE,
|
|
||||||
structured_output=False,
|
|
||||||
)
|
|
||||||
async def update_document(
|
|
||||||
document_id: _DOCUMENT_ID,
|
|
||||||
content: Annotated[
|
|
||||||
str,
|
|
||||||
Field(
|
|
||||||
min_length=1,
|
|
||||||
description="New full text; replaces the existing content "
|
|
||||||
"entirely.",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
) -> str:
|
|
||||||
"""Replace a document's stored content by id.
|
|
||||||
|
|
||||||
Use this to correct or rewrite a document's text. The new content
|
|
||||||
REPLACES the old entirely — to append, read the document first with
|
|
||||||
surfsense_get_document and resend the combined text. Search chunks are
|
|
||||||
not re-indexed by this call.
|
|
||||||
"""
|
|
||||||
existing = await client.request("GET", f"/documents/{document_id}")
|
|
||||||
await client.request(
|
|
||||||
"PUT",
|
|
||||||
f"/documents/{document_id}",
|
|
||||||
json={
|
|
||||||
"document_type": existing["document_type"],
|
|
||||||
"workspace_id": existing["workspace_id"],
|
|
||||||
"content": content,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return f"Updated document {document_id} ('{existing.get('title', '')}')."
|
|
||||||
|
|
||||||
@mcp.tool(
|
|
||||||
name="surfsense_delete_document",
|
|
||||||
title="Delete a document",
|
|
||||||
annotations=_DELETE,
|
|
||||||
structured_output=False,
|
|
||||||
)
|
|
||||||
async def delete_document(document_id: _DOCUMENT_ID) -> str:
|
|
||||||
"""Permanently delete a document from the knowledge base by id.
|
|
||||||
|
|
||||||
Use this only when the user explicitly asks to remove a document —
|
|
||||||
deletion cannot be undone. The document stops appearing in searches
|
|
||||||
immediately.
|
|
||||||
"""
|
|
||||||
await client.request("DELETE", f"/documents/{document_id}")
|
|
||||||
return f"Deleted document {document_id}."
|
|
||||||
|
|
||||||
|
|
||||||
def _read_upload(file_path: str) -> tuple[str, bytes, str]:
|
|
||||||
path = Path(file_path).expanduser()
|
|
||||||
if not path.is_file():
|
|
||||||
raise ToolError(f"No file at '{file_path}'.")
|
|
||||||
mime, _ = mimetypes.guess_type(path.name)
|
|
||||||
return (path.name, path.read_bytes(), mime or "application/octet-stream")
|
|
||||||
|
|
||||||
|
|
||||||
def _join(values: list[str] | None) -> str | None:
|
|
||||||
return ",".join(values) if values else None
|
|
||||||
|
|
||||||
|
|
||||||
def _render_search(query: str, items: list[dict]) -> str:
|
|
||||||
if not items:
|
|
||||||
return f'No matches for "{query}".'
|
|
||||||
lines = [f'# {len(items)} result(s) for "{query}"', ""]
|
|
||||||
for hit in items:
|
|
||||||
lines.append(
|
|
||||||
f"## {hit.get('title', 'Untitled')} "
|
|
||||||
f"(id {hit.get('document_id')}) — score {hit.get('score', 0):.3f}"
|
|
||||||
)
|
|
||||||
for chunk in hit.get("chunks", []):
|
|
||||||
excerpt = clip(chunk.get("content", "").strip(), 500)
|
|
||||||
lines.append(f"> {excerpt}")
|
|
||||||
lines.append("")
|
|
||||||
return "\n".join(lines).strip()
|
|
||||||
|
|
||||||
|
|
||||||
def _render_document_list(result: dict | None) -> str:
|
|
||||||
items = (result or {}).get("items", [])
|
|
||||||
if not items:
|
|
||||||
return "No documents found."
|
|
||||||
lines = ["# Documents", ""]
|
|
||||||
for doc in items:
|
|
||||||
lines.append(
|
|
||||||
f"- **{doc.get('title', 'Untitled')}** (id {doc.get('id')}) · "
|
|
||||||
f"{doc.get('document_type')} · updated {doc.get('updated_at')}"
|
|
||||||
)
|
|
||||||
total = (result or {}).get("total", len(items))
|
|
||||||
page = (result or {}).get("page", 0)
|
|
||||||
has_more = (result or {}).get("has_more", False)
|
|
||||||
lines.append("")
|
|
||||||
lines.append(
|
|
||||||
f"_Page {page} · showing {len(items)} of {total}"
|
|
||||||
+ (" · more available_" if has_more else "_")
|
|
||||||
)
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def _render_document(document: dict) -> str:
|
|
||||||
content = clip(document.get("content", "") or "(empty)")
|
|
||||||
return (
|
|
||||||
f"# {document.get('title', 'Untitled')} (id {document.get('id')})\n"
|
|
||||||
f"- type: {document.get('document_type')}\n"
|
|
||||||
f"- workspace: {document.get('workspace_id')}\n"
|
|
||||||
f"- updated: {document.get('updated_at')}\n\n"
|
|
||||||
f"{content}"
|
|
||||||
)
|
|
||||||
|
|
@ -1,570 +0,0 @@
|
||||||
"""Scraper tools: one MCP surface per SurfSense platform capability.
|
|
||||||
|
|
||||||
Web crawl, Google Search, Reddit, YouTube, and Google Maps each get a tool that
|
|
||||||
maps a natural-language request to the workspace's scraper door. Two more tools
|
|
||||||
list and fetch past runs, so a large result truncated inline can be retrieved in
|
|
||||||
full later.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Annotated, Literal
|
|
||||||
|
|
||||||
from mcp.server.fastmcp import FastMCP
|
|
||||||
from mcp.types import ToolAnnotations
|
|
||||||
from pydantic import Field
|
|
||||||
|
|
||||||
from ...core.client import SurfSenseClient
|
|
||||||
from ...core.rendering import ResponseFormatParam, clip, to_json
|
|
||||||
from ...core.workspace_context import WorkspaceContext, WorkspaceParam
|
|
||||||
from .capability import run_scraper
|
|
||||||
|
|
||||||
# Scrapers reach the open web and record a billable run; they are neither
|
|
||||||
# read-only nor idempotent, but they do not mutate the knowledge base.
|
|
||||||
_SCRAPE = ToolAnnotations(
|
|
||||||
readOnlyHint=False, destructiveHint=False, idempotentHint=False, openWorldHint=True
|
|
||||||
)
|
|
||||||
_READ_RUNS = ToolAnnotations(
|
|
||||||
readOnlyHint=True, destructiveHint=False, idempotentHint=True, openWorldHint=False
|
|
||||||
)
|
|
||||||
|
|
||||||
RedditSort = Literal["relevance", "hot", "top", "new", "rising", "comments"]
|
|
||||||
RedditTime = Literal["hour", "day", "week", "month", "year", "all"]
|
|
||||||
CommentSort = Literal["TOP_COMMENTS", "NEWEST_FIRST"]
|
|
||||||
ReviewSort = Literal["newest", "mostRelevant", "highestRanking", "lowestRanking"]
|
|
||||||
|
|
||||||
|
|
||||||
def register(
|
|
||||||
mcp: FastMCP, client: SurfSenseClient, context: WorkspaceContext
|
|
||||||
) -> None:
|
|
||||||
"""Register the scraper and run-history tools on the server."""
|
|
||||||
|
|
||||||
@mcp.tool(
|
|
||||||
name="surfsense_web_crawl",
|
|
||||||
title="Crawl web pages",
|
|
||||||
annotations=_SCRAPE,
|
|
||||||
structured_output=False,
|
|
||||||
)
|
|
||||||
async def web_crawl(
|
|
||||||
start_urls: Annotated[
|
|
||||||
list[str],
|
|
||||||
Field(
|
|
||||||
min_length=1,
|
|
||||||
description="Full URLs to fetch, e.g. "
|
|
||||||
"['https://example.com/blog/post'].",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
max_crawl_depth: Annotated[
|
|
||||||
int,
|
|
||||||
Field(
|
|
||||||
ge=0,
|
|
||||||
description="Link-hops to follow from start_urls within the "
|
|
||||||
"same site. 0 fetches only start_urls.",
|
|
||||||
),
|
|
||||||
] = 0,
|
|
||||||
max_crawl_pages: Annotated[
|
|
||||||
int, Field(ge=1, description="Stop after this many pages in total.")
|
|
||||||
] = 10,
|
|
||||||
max_length: Annotated[
|
|
||||||
int, Field(ge=1, description="Max characters kept per page.")
|
|
||||||
] = 50_000,
|
|
||||||
include_url_patterns: Annotated[
|
|
||||||
list[str] | None,
|
|
||||||
Field(
|
|
||||||
description="Regexes; only discovered links matching one are "
|
|
||||||
"followed, e.g. ['/docs/.*']."
|
|
||||||
),
|
|
||||||
] = None,
|
|
||||||
exclude_url_patterns: Annotated[
|
|
||||||
list[str] | None,
|
|
||||||
Field(description="Regexes; discovered links matching one are skipped."),
|
|
||||||
] = None,
|
|
||||||
workspace: WorkspaceParam = None,
|
|
||||||
response_format: ResponseFormatParam = "markdown",
|
|
||||||
) -> str:
|
|
||||||
"""Fetch specific web pages and return their cleaned content as markdown.
|
|
||||||
|
|
||||||
Use this to read a page the user names, or to spider a site from a
|
|
||||||
starting URL. Do NOT use it to find pages on a topic — use
|
|
||||||
surfsense_google_search for discovery. Returns one item per crawled
|
|
||||||
page: url, title, and the page text as markdown.
|
|
||||||
Example: start_urls=['https://blog.example.com'], max_crawl_depth=1,
|
|
||||||
include_url_patterns=['/2026/'].
|
|
||||||
"""
|
|
||||||
return await run_scraper(
|
|
||||||
client,
|
|
||||||
context,
|
|
||||||
platform="web",
|
|
||||||
verb="crawl",
|
|
||||||
payload={
|
|
||||||
"startUrls": start_urls,
|
|
||||||
"maxCrawlDepth": max_crawl_depth,
|
|
||||||
"maxCrawlPages": max_crawl_pages,
|
|
||||||
"maxLength": max_length,
|
|
||||||
"includeUrlPatterns": include_url_patterns,
|
|
||||||
"excludeUrlPatterns": exclude_url_patterns,
|
|
||||||
},
|
|
||||||
workspace=workspace,
|
|
||||||
response_format=response_format,
|
|
||||||
)
|
|
||||||
|
|
||||||
@mcp.tool(
|
|
||||||
name="surfsense_google_search",
|
|
||||||
title="Scrape Google Search",
|
|
||||||
annotations=_SCRAPE,
|
|
||||||
structured_output=False,
|
|
||||||
)
|
|
||||||
async def google_search(
|
|
||||||
queries: Annotated[
|
|
||||||
list[str],
|
|
||||||
Field(
|
|
||||||
min_length=1,
|
|
||||||
description="Search terms or full Google Search URLs, e.g. "
|
|
||||||
"['best rss readers 2026'].",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
max_pages_per_query: Annotated[
|
|
||||||
int, Field(ge=1, description="Result pages to fetch per query.")
|
|
||||||
] = 1,
|
|
||||||
country_code: Annotated[
|
|
||||||
str | None,
|
|
||||||
Field(description="Two-letter country to search from, e.g. 'us'."),
|
|
||||||
] = None,
|
|
||||||
language_code: Annotated[
|
|
||||||
str, Field(description="Results language, e.g. 'en'. Empty for default.")
|
|
||||||
] = "",
|
|
||||||
site: Annotated[
|
|
||||||
str | None,
|
|
||||||
Field(
|
|
||||||
description="Restrict results to one domain, e.g. 'example.com'."
|
|
||||||
),
|
|
||||||
] = None,
|
|
||||||
workspace: WorkspaceParam = None,
|
|
||||||
response_format: ResponseFormatParam = "markdown",
|
|
||||||
) -> str:
|
|
||||||
"""Scrape Google Search result pages for one or more queries.
|
|
||||||
|
|
||||||
Use this to discover pages on the open web by topic; follow up with
|
|
||||||
surfsense_web_crawl to read a result in full. Do NOT use it for
|
|
||||||
Reddit, YouTube, or Google Maps research — the dedicated tools return
|
|
||||||
richer data. Returns each query's parsed results: title, url, and
|
|
||||||
snippet per organic result.
|
|
||||||
Example: queries=['notebooklm review'], site='news.ycombinator.com'.
|
|
||||||
"""
|
|
||||||
return await run_scraper(
|
|
||||||
client,
|
|
||||||
context,
|
|
||||||
platform="google_search",
|
|
||||||
verb="scrape",
|
|
||||||
payload={
|
|
||||||
"queries": queries,
|
|
||||||
"max_pages_per_query": max_pages_per_query,
|
|
||||||
"country_code": country_code,
|
|
||||||
"language_code": language_code,
|
|
||||||
"site": site,
|
|
||||||
},
|
|
||||||
workspace=workspace,
|
|
||||||
response_format=response_format,
|
|
||||||
)
|
|
||||||
|
|
||||||
@mcp.tool(
|
|
||||||
name="surfsense_reddit_scrape",
|
|
||||||
title="Search or scrape Reddit",
|
|
||||||
annotations=_SCRAPE,
|
|
||||||
structured_output=False,
|
|
||||||
)
|
|
||||||
async def reddit_scrape(
|
|
||||||
urls: Annotated[
|
|
||||||
list[str] | None,
|
|
||||||
Field(
|
|
||||||
description="Reddit URLs: a post, a subreddit like "
|
|
||||||
"'https://reddit.com/r/LocalLLaMA', a user page, or a search "
|
|
||||||
"URL. Provide urls OR search_queries."
|
|
||||||
),
|
|
||||||
] = None,
|
|
||||||
search_queries: Annotated[
|
|
||||||
list[str] | None,
|
|
||||||
Field(
|
|
||||||
description="Terms to search Reddit for, e.g. "
|
|
||||||
"['NotebookLM alternatives']. Provide search_queries OR urls."
|
|
||||||
),
|
|
||||||
] = None,
|
|
||||||
community: Annotated[
|
|
||||||
str | None,
|
|
||||||
Field(
|
|
||||||
description="Restrict a search to one subreddit, name without "
|
|
||||||
"'r/', e.g. 'ArtificialInteligence'."
|
|
||||||
),
|
|
||||||
] = None,
|
|
||||||
sort: Annotated[RedditSort, Field(description="Post ordering.")] = "new",
|
|
||||||
time_filter: Annotated[
|
|
||||||
RedditTime | None,
|
|
||||||
Field(description="Time window; only valid with sort='top'."),
|
|
||||||
] = None,
|
|
||||||
max_items: Annotated[
|
|
||||||
int, Field(ge=1, description="Maximum posts to return.")
|
|
||||||
] = 10,
|
|
||||||
skip_comments: Annotated[
|
|
||||||
bool,
|
|
||||||
Field(
|
|
||||||
description="True fetches posts only (faster); False also "
|
|
||||||
"fetches each post's comment thread."
|
|
||||||
),
|
|
||||||
] = False,
|
|
||||||
workspace: WorkspaceParam = None,
|
|
||||||
response_format: ResponseFormatParam = "markdown",
|
|
||||||
) -> str:
|
|
||||||
"""Search or scrape Reddit: posts, comments, subreddits, and users.
|
|
||||||
|
|
||||||
Use this for ANY Reddit research — finding relevant subreddits or
|
|
||||||
communities for a topic, top posts, or discussions — instead of a
|
|
||||||
generic web search. Returns posts (title, text, score, subreddit, url)
|
|
||||||
with comment threads unless skip_comments is set. Every post carries
|
|
||||||
its subreddit, so to find communities for a topic, search posts and
|
|
||||||
aggregate their subreddits.
|
|
||||||
Example: search_queries=['NotebookLM'], sort='top', time_filter='month'.
|
|
||||||
"""
|
|
||||||
return await run_scraper(
|
|
||||||
client,
|
|
||||||
context,
|
|
||||||
platform="reddit",
|
|
||||||
verb="scrape",
|
|
||||||
payload={
|
|
||||||
"urls": urls,
|
|
||||||
"search_queries": search_queries,
|
|
||||||
"community": community,
|
|
||||||
"sort": sort,
|
|
||||||
"time_filter": time_filter,
|
|
||||||
"max_items": max_items,
|
|
||||||
"skip_comments": skip_comments,
|
|
||||||
},
|
|
||||||
workspace=workspace,
|
|
||||||
response_format=response_format,
|
|
||||||
)
|
|
||||||
|
|
||||||
@mcp.tool(
|
|
||||||
name="surfsense_youtube_scrape",
|
|
||||||
title="Search or scrape YouTube",
|
|
||||||
annotations=_SCRAPE,
|
|
||||||
structured_output=False,
|
|
||||||
)
|
|
||||||
async def youtube_scrape(
|
|
||||||
urls: Annotated[
|
|
||||||
list[str] | None,
|
|
||||||
Field(
|
|
||||||
description="YouTube URLs: video, channel, playlist, shorts, "
|
|
||||||
"or hashtag pages. Provide urls OR search_queries."
|
|
||||||
),
|
|
||||||
] = None,
|
|
||||||
search_queries: Annotated[
|
|
||||||
list[str] | None,
|
|
||||||
Field(
|
|
||||||
description="Terms to search YouTube for, e.g. "
|
|
||||||
"['NotebookLM tutorial']. Provide search_queries OR urls."
|
|
||||||
),
|
|
||||||
] = None,
|
|
||||||
max_results: Annotated[
|
|
||||||
int, Field(ge=1, description="Maximum videos to return.")
|
|
||||||
] = 10,
|
|
||||||
download_subtitles: Annotated[
|
|
||||||
bool,
|
|
||||||
Field(description="True also fetches each video's transcript."),
|
|
||||||
] = False,
|
|
||||||
subtitles_language: Annotated[
|
|
||||||
str, Field(description="Transcript language code, e.g. 'en'.")
|
|
||||||
] = "en",
|
|
||||||
workspace: WorkspaceParam = None,
|
|
||||||
response_format: ResponseFormatParam = "markdown",
|
|
||||||
) -> str:
|
|
||||||
"""Search or scrape YouTube videos, optionally with transcripts.
|
|
||||||
|
|
||||||
Use this for YouTube research: finding videos on a topic, or reading a
|
|
||||||
video's details or transcript. For a video's comment section use
|
|
||||||
surfsense_youtube_comments instead. Returns per-video metadata (title,
|
|
||||||
channel, views, description, url) and, if requested, the transcript.
|
|
||||||
Example: search_queries=['NotebookLM tutorial'], download_subtitles=True.
|
|
||||||
"""
|
|
||||||
return await run_scraper(
|
|
||||||
client,
|
|
||||||
context,
|
|
||||||
platform="youtube",
|
|
||||||
verb="scrape",
|
|
||||||
payload={
|
|
||||||
"urls": urls,
|
|
||||||
"search_queries": search_queries,
|
|
||||||
"max_results": max_results,
|
|
||||||
"download_subtitles": download_subtitles,
|
|
||||||
"subtitles_language": subtitles_language,
|
|
||||||
},
|
|
||||||
workspace=workspace,
|
|
||||||
response_format=response_format,
|
|
||||||
)
|
|
||||||
|
|
||||||
@mcp.tool(
|
|
||||||
name="surfsense_youtube_comments",
|
|
||||||
title="Fetch YouTube comments",
|
|
||||||
annotations=_SCRAPE,
|
|
||||||
structured_output=False,
|
|
||||||
)
|
|
||||||
async def youtube_comments(
|
|
||||||
urls: Annotated[
|
|
||||||
list[str],
|
|
||||||
Field(
|
|
||||||
min_length=1,
|
|
||||||
description="YouTube video URLs, e.g. "
|
|
||||||
"['https://www.youtube.com/watch?v=abc123'].",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
max_comments: Annotated[
|
|
||||||
int,
|
|
||||||
Field(
|
|
||||||
ge=1,
|
|
||||||
description="Maximum comments per video, counting top-level "
|
|
||||||
"comments and replies together.",
|
|
||||||
),
|
|
||||||
] = 20,
|
|
||||||
sort_by: Annotated[
|
|
||||||
CommentSort, Field(description="Comment ordering.")
|
|
||||||
] = "NEWEST_FIRST",
|
|
||||||
workspace: WorkspaceParam = None,
|
|
||||||
response_format: ResponseFormatParam = "markdown",
|
|
||||||
) -> str:
|
|
||||||
"""Fetch the comments (and replies) on one or more YouTube videos.
|
|
||||||
|
|
||||||
Use this when the user wants a video's discussion or audience reaction
|
|
||||||
rather than the video itself; get video URLs from
|
|
||||||
surfsense_youtube_scrape if you only have a topic. Returns comment
|
|
||||||
text, author, likes, and replies.
|
|
||||||
Example: urls=['https://www.youtube.com/watch?v=abc123'], max_comments=50.
|
|
||||||
"""
|
|
||||||
return await run_scraper(
|
|
||||||
client,
|
|
||||||
context,
|
|
||||||
platform="youtube",
|
|
||||||
verb="comments",
|
|
||||||
payload={
|
|
||||||
"urls": urls,
|
|
||||||
"max_comments": max_comments,
|
|
||||||
"sort_by": sort_by,
|
|
||||||
},
|
|
||||||
workspace=workspace,
|
|
||||||
response_format=response_format,
|
|
||||||
)
|
|
||||||
|
|
||||||
@mcp.tool(
|
|
||||||
name="surfsense_google_maps_scrape",
|
|
||||||
title="Find places on Google Maps",
|
|
||||||
annotations=_SCRAPE,
|
|
||||||
structured_output=False,
|
|
||||||
)
|
|
||||||
async def google_maps_scrape(
|
|
||||||
search_queries: Annotated[
|
|
||||||
list[str] | None,
|
|
||||||
Field(
|
|
||||||
description="Place searches, e.g. ['coffee shops']. Provide "
|
|
||||||
"search_queries OR urls OR place_ids."
|
|
||||||
),
|
|
||||||
] = None,
|
|
||||||
urls: Annotated[
|
|
||||||
list[str] | None,
|
|
||||||
Field(description="Google Maps URLs of specific places."),
|
|
||||||
] = None,
|
|
||||||
place_ids: Annotated[
|
|
||||||
list[str] | None,
|
|
||||||
Field(description="Google place ids, e.g. ['ChIJj61dQgK6j4AR...']."),
|
|
||||||
] = None,
|
|
||||||
location: Annotated[
|
|
||||||
str | None,
|
|
||||||
Field(
|
|
||||||
description="Geographic scope for a search, e.g. "
|
|
||||||
"'Seattle, USA'."
|
|
||||||
),
|
|
||||||
] = None,
|
|
||||||
max_places: Annotated[
|
|
||||||
int, Field(ge=1, description="Maximum places to return.")
|
|
||||||
] = 10,
|
|
||||||
include_details: Annotated[
|
|
||||||
bool,
|
|
||||||
Field(
|
|
||||||
description="True adds opening hours and extra contact info "
|
|
||||||
"(slower)."
|
|
||||||
),
|
|
||||||
] = False,
|
|
||||||
workspace: WorkspaceParam = None,
|
|
||||||
response_format: ResponseFormatParam = "markdown",
|
|
||||||
) -> str:
|
|
||||||
"""Find places on Google Maps by search, URL, or place id.
|
|
||||||
|
|
||||||
Use this for local-business and location research: names, addresses,
|
|
||||||
ratings, categories, coordinates, place ids. For a place's customer
|
|
||||||
reviews use surfsense_google_maps_reviews instead.
|
|
||||||
Example: search_queries=['ramen'], location='Osaka, Japan', max_places=5.
|
|
||||||
"""
|
|
||||||
return await run_scraper(
|
|
||||||
client,
|
|
||||||
context,
|
|
||||||
platform="google_maps",
|
|
||||||
verb="scrape",
|
|
||||||
payload={
|
|
||||||
"search_queries": search_queries,
|
|
||||||
"urls": urls,
|
|
||||||
"place_ids": place_ids,
|
|
||||||
"location": location,
|
|
||||||
"max_places": max_places,
|
|
||||||
"include_details": include_details,
|
|
||||||
},
|
|
||||||
workspace=workspace,
|
|
||||||
response_format=response_format,
|
|
||||||
)
|
|
||||||
|
|
||||||
@mcp.tool(
|
|
||||||
name="surfsense_google_maps_reviews",
|
|
||||||
title="Fetch Google Maps reviews",
|
|
||||||
annotations=_SCRAPE,
|
|
||||||
structured_output=False,
|
|
||||||
)
|
|
||||||
async def google_maps_reviews(
|
|
||||||
urls: Annotated[
|
|
||||||
list[str] | None,
|
|
||||||
Field(
|
|
||||||
description="Google Maps URLs of places. Provide urls OR "
|
|
||||||
"place_ids."
|
|
||||||
),
|
|
||||||
] = None,
|
|
||||||
place_ids: Annotated[
|
|
||||||
list[str] | None,
|
|
||||||
Field(
|
|
||||||
description="Google place ids from surfsense_google_maps_scrape."
|
|
||||||
),
|
|
||||||
] = None,
|
|
||||||
max_reviews: Annotated[
|
|
||||||
int, Field(ge=1, description="Maximum reviews per place.")
|
|
||||||
] = 20,
|
|
||||||
sort_by: Annotated[
|
|
||||||
ReviewSort, Field(description="Review ordering.")
|
|
||||||
] = "newest",
|
|
||||||
language: Annotated[
|
|
||||||
str, Field(description="Reviews language code, e.g. 'en'.")
|
|
||||||
] = "en",
|
|
||||||
start_date: Annotated[
|
|
||||||
str | None,
|
|
||||||
Field(
|
|
||||||
description="ISO date like '2026-01-01'; keeps only reviews on "
|
|
||||||
"or after that day."
|
|
||||||
),
|
|
||||||
] = None,
|
|
||||||
workspace: WorkspaceParam = None,
|
|
||||||
response_format: ResponseFormatParam = "markdown",
|
|
||||||
) -> str:
|
|
||||||
"""Fetch customer reviews for Google Maps places by URL or place id.
|
|
||||||
|
|
||||||
Use this to read feedback on specific places; get urls or place_ids
|
|
||||||
from surfsense_google_maps_scrape first if you only have a name.
|
|
||||||
Returns review text, rating, author, and date per review.
|
|
||||||
Example: place_ids=['ChIJj61dQgK6j4AR...'], sort_by='newest'.
|
|
||||||
"""
|
|
||||||
return await run_scraper(
|
|
||||||
client,
|
|
||||||
context,
|
|
||||||
platform="google_maps",
|
|
||||||
verb="reviews",
|
|
||||||
payload={
|
|
||||||
"urls": urls,
|
|
||||||
"place_ids": place_ids,
|
|
||||||
"max_reviews": max_reviews,
|
|
||||||
"sort_by": sort_by,
|
|
||||||
"language": language,
|
|
||||||
"start_date": start_date,
|
|
||||||
},
|
|
||||||
workspace=workspace,
|
|
||||||
response_format=response_format,
|
|
||||||
)
|
|
||||||
|
|
||||||
@mcp.tool(
|
|
||||||
name="surfsense_list_scraper_runs",
|
|
||||||
title="List past scraper runs",
|
|
||||||
annotations=_READ_RUNS,
|
|
||||||
structured_output=False,
|
|
||||||
)
|
|
||||||
async def list_scraper_runs(
|
|
||||||
limit: Annotated[
|
|
||||||
int, Field(ge=1, description="Maximum runs to list.")
|
|
||||||
] = 20,
|
|
||||||
capability: Annotated[
|
|
||||||
str | None,
|
|
||||||
Field(
|
|
||||||
description="Filter by capability slug, e.g. 'web.crawl' or "
|
|
||||||
"'reddit.scrape'."
|
|
||||||
),
|
|
||||||
] = None,
|
|
||||||
status: Annotated[
|
|
||||||
str | None,
|
|
||||||
Field(description="Filter by run status: 'success' or 'error'."),
|
|
||||||
] = None,
|
|
||||||
workspace: WorkspaceParam = None,
|
|
||||||
response_format: ResponseFormatParam = "markdown",
|
|
||||||
) -> str:
|
|
||||||
"""List recent scraper runs in the workspace, newest first.
|
|
||||||
|
|
||||||
Use this to find the run_id of an earlier scrape — for example when an
|
|
||||||
inline result was truncated — then fetch it in full with
|
|
||||||
surfsense_get_scraper_run. Returns each run's id, capability, status,
|
|
||||||
item count, and creation time.
|
|
||||||
Example: capability='reddit.scrape', status='success'.
|
|
||||||
"""
|
|
||||||
resolved = await context.resolve(workspace)
|
|
||||||
runs = await client.request(
|
|
||||||
"GET",
|
|
||||||
f"/workspaces/{resolved.id}/scrapers/runs",
|
|
||||||
params={
|
|
||||||
"limit": limit,
|
|
||||||
"capability": capability,
|
|
||||||
"status": status,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if response_format == "json":
|
|
||||||
return to_json(runs)
|
|
||||||
return _render_runs(runs)
|
|
||||||
|
|
||||||
@mcp.tool(
|
|
||||||
name="surfsense_get_scraper_run",
|
|
||||||
title="Fetch one scraper run in full",
|
|
||||||
annotations=_READ_RUNS,
|
|
||||||
structured_output=False,
|
|
||||||
)
|
|
||||||
async def get_scraper_run(
|
|
||||||
run_id: Annotated[
|
|
||||||
str,
|
|
||||||
Field(
|
|
||||||
description="Run id from surfsense_list_scraper_runs or a "
|
|
||||||
"prior scrape's output."
|
|
||||||
),
|
|
||||||
],
|
|
||||||
workspace: WorkspaceParam = None,
|
|
||||||
response_format: ResponseFormatParam = "markdown",
|
|
||||||
) -> str:
|
|
||||||
"""Fetch a single scraper run in full, including its stored output.
|
|
||||||
|
|
||||||
Use this to retrieve the complete, untruncated result of an earlier
|
|
||||||
scrape. Do NOT re-run a scraper just to recover a truncated result —
|
|
||||||
fetch the stored run instead.
|
|
||||||
"""
|
|
||||||
resolved = await context.resolve(workspace)
|
|
||||||
run = await client.request(
|
|
||||||
"GET", f"/workspaces/{resolved.id}/scrapers/runs/{run_id}"
|
|
||||||
)
|
|
||||||
if response_format == "json":
|
|
||||||
return clip(to_json(run))
|
|
||||||
return f"# Run {run.get('id', run_id)}\n\n```json\n{clip(to_json(run))}\n```"
|
|
||||||
|
|
||||||
|
|
||||||
def _render_runs(runs: list[dict] | None) -> str:
|
|
||||||
if not runs:
|
|
||||||
return "No scraper runs found."
|
|
||||||
lines = ["# Scraper runs", ""]
|
|
||||||
for run in runs:
|
|
||||||
lines.append(
|
|
||||||
f"- **{run.get('id')}** — {run.get('capability')} · {run.get('status')} · "
|
|
||||||
f"{run.get('item_count', 0)} item(s) · {run.get('created_at')}"
|
|
||||||
)
|
|
||||||
return "\n".join(lines)
|
|
||||||
40
surfsense_mcp/tests/test_auth_headers.py
Normal file
40
surfsense_mcp/tests/test_auth_headers.py
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
"""API key extraction from request headers: Bearer, fallback, and rejection."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from starlette.datastructures import Headers
|
||||||
|
|
||||||
|
from mcp_server.core.auth.headers import extract_api_key
|
||||||
|
|
||||||
|
|
||||||
|
def _headers(**pairs: str) -> Headers:
|
||||||
|
return Headers(pairs)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reads_bearer_token():
|
||||||
|
assert extract_api_key(_headers(authorization="Bearer ss_pat_abc")) == "ss_pat_abc"
|
||||||
|
|
||||||
|
|
||||||
|
def test_bearer_scheme_is_case_insensitive():
|
||||||
|
assert extract_api_key(_headers(authorization="bearer ss_pat_abc")) == "ss_pat_abc"
|
||||||
|
|
||||||
|
|
||||||
|
def test_falls_back_to_x_api_key():
|
||||||
|
assert extract_api_key(Headers({"x-api-key": "ss_pat_xyz"})) == "ss_pat_xyz"
|
||||||
|
|
||||||
|
|
||||||
|
def test_bearer_wins_over_fallback():
|
||||||
|
headers = Headers({"authorization": "Bearer primary", "x-api-key": "secondary"})
|
||||||
|
assert extract_api_key(headers) == "primary"
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_headers_return_none():
|
||||||
|
assert extract_api_key(_headers()) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_bearer_is_rejected():
|
||||||
|
assert extract_api_key(_headers(authorization="Bearer ")) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_bearer_authorization_is_ignored():
|
||||||
|
assert extract_api_key(_headers(authorization="Basic abc123")) is None
|
||||||
|
|
@ -4,7 +4,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from surfsense_mcp.core.client import SurfSenseClient
|
from mcp_server.core.client import SurfSenseClient
|
||||||
|
|
||||||
_REQUEST = httpx.Request("GET", "http://localhost:8000/api/v1/documents")
|
_REQUEST = httpx.Request("GET", "http://localhost:8000/api/v1/documents")
|
||||||
|
|
||||||
|
|
@ -15,7 +15,7 @@ def _response(status: int, **kwargs) -> httpx.Response:
|
||||||
|
|
||||||
def test_explains_401_with_token_hint():
|
def test_explains_401_with_token_hint():
|
||||||
message = SurfSenseClient._explain_failure(_response(401, json={"detail": "bad"}))
|
message = SurfSenseClient._explain_failure(_response(401, json={"detail": "bad"}))
|
||||||
assert "SURFSENSE_API_KEY" in message
|
assert "API key" in message
|
||||||
assert "bad" in message
|
assert "bad" in message
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import asyncio
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from surfsense_mcp.core.client import SurfSenseClient
|
from mcp_server.core.client import SurfSenseClient
|
||||||
|
|
||||||
|
|
||||||
def _capture(client: SurfSenseClient) -> dict:
|
def _capture(client: SurfSenseClient) -> dict:
|
||||||
|
|
@ -25,7 +25,9 @@ def _capture(client: SurfSenseClient) -> dict:
|
||||||
|
|
||||||
|
|
||||||
def test_none_params_are_dropped():
|
def test_none_params_are_dropped():
|
||||||
client = SurfSenseClient(api_base="http://test/api/v1", api_key="ss_pat_x", timeout=5)
|
client = SurfSenseClient(
|
||||||
|
api_base="http://test/api/v1", timeout=5, fallback_api_key="ss_pat_x"
|
||||||
|
)
|
||||||
seen = _capture(client)
|
seen = _capture(client)
|
||||||
asyncio.run(
|
asyncio.run(
|
||||||
client.request(
|
client.request(
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from surfsense_mcp.features.knowledge_base.note_ingestion import build_note_document
|
from mcp_server.features.knowledge_base.note_ingestion import build_note_document
|
||||||
|
|
||||||
|
|
||||||
def test_builds_extension_document_with_content():
|
def test_builds_extension_document_with_content():
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from surfsense_mcp.core.rendering import clip, compact_items, to_json
|
from mcp_server.core.rendering import clip, compact_items, to_json
|
||||||
|
|
||||||
|
|
||||||
def test_clip_leaves_short_text_untouched():
|
def test_clip_leaves_short_text_untouched():
|
||||||
|
|
|
||||||
100
surfsense_mcp/tests/test_request_auth.py
Normal file
100
surfsense_mcp/tests/test_request_auth.py
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
"""Per-request key resolution and the Authorization header the backend receives.
|
||||||
|
|
||||||
|
Covers the security-critical behaviors: the per-request key wins over the env
|
||||||
|
fallback, the fallback covers stdio, a missing key is refused, and concurrent
|
||||||
|
callers never see each other's key.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from mcp_server.core.auth import identity
|
||||||
|
from mcp_server.core.client import SurfSenseClient
|
||||||
|
from mcp_server.core.errors import ToolError
|
||||||
|
|
||||||
|
|
||||||
|
def _client_recording_auth(seen: dict, *, fallback: str | None) -> SurfSenseClient:
|
||||||
|
async def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
seen["authorization"] = request.headers.get("authorization")
|
||||||
|
return httpx.Response(200, json={"ok": True})
|
||||||
|
|
||||||
|
client = SurfSenseClient(
|
||||||
|
api_base="http://test/api/v1", timeout=5, fallback_api_key=fallback
|
||||||
|
)
|
||||||
|
client._http = httpx.AsyncClient(
|
||||||
|
base_url="http://test/api/v1", transport=httpx.MockTransport(handler)
|
||||||
|
)
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
async def _get(client: SurfSenseClient) -> None:
|
||||||
|
await client.request("GET", "/workspaces")
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_key_is_sent_as_bearer():
|
||||||
|
seen: dict = {}
|
||||||
|
client = _client_recording_auth(seen, fallback=None)
|
||||||
|
|
||||||
|
async def run() -> None:
|
||||||
|
token = identity.bind_api_key("ss_pat_request")
|
||||||
|
try:
|
||||||
|
await _get(client)
|
||||||
|
finally:
|
||||||
|
identity.unbind_api_key(token)
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
assert seen["authorization"] == "Bearer ss_pat_request"
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_key_overrides_env_fallback():
|
||||||
|
seen: dict = {}
|
||||||
|
client = _client_recording_auth(seen, fallback="ss_pat_env")
|
||||||
|
|
||||||
|
async def run() -> None:
|
||||||
|
token = identity.bind_api_key("ss_pat_request")
|
||||||
|
try:
|
||||||
|
await _get(client)
|
||||||
|
finally:
|
||||||
|
identity.unbind_api_key(token)
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
assert seen["authorization"] == "Bearer ss_pat_request"
|
||||||
|
|
||||||
|
|
||||||
|
def test_env_fallback_used_without_request_key():
|
||||||
|
seen: dict = {}
|
||||||
|
client = _client_recording_auth(seen, fallback="ss_pat_env")
|
||||||
|
asyncio.run(_get(client))
|
||||||
|
assert seen["authorization"] == "Bearer ss_pat_env"
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_key_is_refused():
|
||||||
|
client = _client_recording_auth({}, fallback=None)
|
||||||
|
with pytest.raises(ToolError):
|
||||||
|
asyncio.run(_get(client))
|
||||||
|
|
||||||
|
|
||||||
|
def test_concurrent_callers_do_not_leak_keys():
|
||||||
|
seen_by_caller: dict[str, str | None] = {}
|
||||||
|
|
||||||
|
async def call_as(key: str) -> None:
|
||||||
|
# Each caller runs in its own task, so the contextvar is isolated.
|
||||||
|
recorded: dict = {}
|
||||||
|
client = _client_recording_auth(recorded, fallback=None)
|
||||||
|
token = identity.bind_api_key(key)
|
||||||
|
try:
|
||||||
|
await _get(client)
|
||||||
|
finally:
|
||||||
|
identity.unbind_api_key(token)
|
||||||
|
seen_by_caller[key] = recorded["authorization"]
|
||||||
|
|
||||||
|
async def run() -> None:
|
||||||
|
await asyncio.gather(call_as("ss_pat_A"), call_as("ss_pat_B"))
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
assert seen_by_caller["ss_pat_A"] == "Bearer ss_pat_A"
|
||||||
|
assert seen_by_caller["ss_pat_B"] == "Bearer ss_pat_B"
|
||||||
|
|
@ -6,8 +6,9 @@ import asyncio
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from surfsense_mcp.core.errors import ToolError
|
from mcp_server.core.auth import identity
|
||||||
from surfsense_mcp.core.workspace_context import WorkspaceContext
|
from mcp_server.core.errors import ToolError
|
||||||
|
from mcp_server.core.workspace_context import WorkspaceContext
|
||||||
|
|
||||||
|
|
||||||
class FakeClient:
|
class FakeClient:
|
||||||
|
|
@ -96,3 +97,30 @@ def test_resolution_is_remembered_as_active():
|
||||||
assert ctx.active is not None and ctx.active.id == 2
|
assert ctx.active is not None and ctx.active.id == 2
|
||||||
# a later default call reuses the active selection without re-choosing
|
# a later default call reuses the active selection without re-choosing
|
||||||
assert asyncio.run(ctx.resolve(None)).id == 2
|
assert asyncio.run(ctx.resolve(None)).id == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_active_workspace_is_isolated_per_identity():
|
||||||
|
ctx = _context(_rows(("A", 1), ("B", 2)))
|
||||||
|
|
||||||
|
async def select_as(key: str, reference: str) -> None:
|
||||||
|
token = identity.bind_api_key(key)
|
||||||
|
try:
|
||||||
|
await ctx.resolve(reference)
|
||||||
|
finally:
|
||||||
|
identity.unbind_api_key(token)
|
||||||
|
|
||||||
|
async def active_for(key: str) -> int | None:
|
||||||
|
token = identity.bind_api_key(key)
|
||||||
|
try:
|
||||||
|
return ctx.active.id if ctx.active else None
|
||||||
|
finally:
|
||||||
|
identity.unbind_api_key(token)
|
||||||
|
|
||||||
|
asyncio.run(select_as("ss_pat_A", "A"))
|
||||||
|
asyncio.run(select_as("ss_pat_B", "B"))
|
||||||
|
|
||||||
|
# Each caller keeps its own selection; no bleed across identities.
|
||||||
|
assert asyncio.run(active_for("ss_pat_A")) == 1
|
||||||
|
assert asyncio.run(active_for("ss_pat_B")) == 2
|
||||||
|
# An unknown caller has no active selection.
|
||||||
|
assert asyncio.run(active_for("ss_pat_C")) is None
|
||||||
|
|
|
||||||
4
surfsense_mcp/uv.lock
generated
4
surfsense_mcp/uv.lock
generated
|
|
@ -718,6 +718,8 @@ source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
{ name = "mcp" },
|
{ name = "mcp" },
|
||||||
|
{ name = "starlette" },
|
||||||
|
{ name = "uvicorn" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
|
|
@ -729,6 +731,8 @@ dev = [
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "httpx", specifier = ">=0.27.0" },
|
{ name = "httpx", specifier = ">=0.27.0" },
|
||||||
{ name = "mcp", specifier = ">=1.26.0" },
|
{ name = "mcp", specifier = ">=1.26.0" },
|
||||||
|
{ name = "starlette", specifier = ">=0.37" },
|
||||||
|
{ name = "uvicorn", specifier = ">=0.30" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
|
|
|
||||||
|
|
@ -50,16 +50,13 @@ export const metadata: Metadata = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/* Mirrors surfsense_mcp/README.md: the real Cursor config. */
|
/* The hosted Cursor config; mirrors lib/mcp/clients.ts. */
|
||||||
const CURSOR_CONFIG = `{
|
const CURSOR_CONFIG = `{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"surfsense": {
|
"surfsense": {
|
||||||
"command": "uv",
|
"url": "https://mcp.surfsense.com/mcp",
|
||||||
"args": ["run", "--directory", ".../surfsense_mcp",
|
"headers": {
|
||||||
"python", "-m", "surfsense_mcp"],
|
"Authorization": "Bearer ss_pat_..."
|
||||||
"env": {
|
|
||||||
"SURFSENSE_BASE_URL": "https://api.surfsense.com",
|
|
||||||
"SURFSENSE_API_KEY": "ss_pat_..."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -76,7 +73,7 @@ const STEPS = [
|
||||||
icon: TerminalSquare,
|
icon: TerminalSquare,
|
||||||
title: "Add the server to your client",
|
title: "Add the server to your client",
|
||||||
description:
|
description:
|
||||||
"Drop the config into Cursor's mcp.json, run claude mcp add for Claude Code, or paste it into Claude Desktop. Point it at the cloud or your own self-hosted instance.",
|
"Point your client at https://mcp.surfsense.com/mcp with your key in an Authorization header — the hosted config for Cursor, Claude Code, and others is one paste. Prefer stdio? Switch to Self-host and run it against your own backend.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Server,
|
icon: Server,
|
||||||
|
|
@ -135,7 +132,7 @@ const FAQ: FaqItem[] = [
|
||||||
{
|
{
|
||||||
question: "Which MCP clients does it work with?",
|
question: "Which MCP clients does it work with?",
|
||||||
answer:
|
answer:
|
||||||
"Any MCP client that supports stdio servers. Claude Code, Codex, OpenCode, Cursor, Claude Desktop, VS Code, Windsurf, and Gemini CLI are documented with copy-paste configs on this page, and the same command works in custom agent harnesses built on the MCP SDK.",
|
"Any MCP client that speaks remote (streamable HTTP) or stdio. Claude Code, Codex, OpenCode, Cursor, Claude Desktop, VS Code, Windsurf, and Gemini CLI all have copy-paste configs on this page — Hosted for the one-paste https://mcp.surfsense.com/mcp endpoint, or Self-host for stdio against your own backend.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: "How is usage billed?",
|
question: "How is usage billed?",
|
||||||
|
|
@ -278,9 +275,9 @@ export default function McpServerPage() {
|
||||||
Step-by-step setup for every agent
|
Step-by-step setup for every agent
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-3 max-w-2xl text-muted-foreground leading-relaxed">
|
<p className="mt-3 max-w-2xl text-muted-foreground leading-relaxed">
|
||||||
Pick your client, follow its two steps, and paste the config. Replace the placeholder
|
Pick your client, choose <strong>Hosted</strong> or <strong>Self-host</strong>, and
|
||||||
path with your surfsense_mcp checkout and the key with one from API Playground → API
|
paste the config. Replace the key with one from API Playground → API Keys — or grab a
|
||||||
Keys — or grab a pre-filled config from the playground itself.
|
pre-filled config from the playground itself.
|
||||||
</p>
|
</p>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
<Reveal>
|
<Reveal>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
import { ApiKeysSection } from "../components/api-keys-section";
|
import { Info } from "lucide-react";
|
||||||
|
import { WorkspaceApiAccessControl } from "@/components/settings/workspace-api-access-control";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { ApiKeyContent } from "../../user-settings/components/ApiKeyContent";
|
||||||
|
|
||||||
export default async function PlaygroundApiKeysPage({
|
export default async function PlaygroundApiKeysPage({
|
||||||
params,
|
params,
|
||||||
|
|
@ -6,10 +10,33 @@ export default async function PlaygroundApiKeysPage({
|
||||||
params: Promise<{ workspace_id: string }>;
|
params: Promise<{ workspace_id: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { workspace_id } = await params;
|
const { workspace_id } = await params;
|
||||||
|
const workspaceId = Number(workspace_id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-3xl">
|
<div className="mx-auto w-full max-w-5xl space-y-6">
|
||||||
<ApiKeysSection workspaceId={Number(workspace_id)} />
|
<div className="space-y-1">
|
||||||
|
<h2 className="text-xl font-semibold tracking-tight">API keys</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Create user API keys and choose whether they can access this workspace.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Alert>
|
||||||
|
<Info />
|
||||||
|
<AlertDescription>
|
||||||
|
External API calls need both a user API key and workspace API key access enabled.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<WorkspaceApiAccessControl workspaceId={workspaceId} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Separator className="bg-border" />
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<ApiKeyContent />
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { updateWorkspaceApiAccessMutationAtom } from "@/atoms/workspaces/workspace-mutation.atoms";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import { workspacesApiService } from "@/lib/apis/workspaces-api.service";
|
|
||||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
|
||||||
import { ApiKeyContent } from "../../user-settings/components/ApiKeyContent";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One-stop API key management for the playground: the workspace API-access
|
|
||||||
* toggle (otherwise buried in workspace settings) plus the personal API key
|
|
||||||
* manager (otherwise buried in user settings).
|
|
||||||
*/
|
|
||||||
export function ApiKeysSection({ workspaceId }: { workspaceId: number }) {
|
|
||||||
const {
|
|
||||||
data: workspace,
|
|
||||||
isLoading,
|
|
||||||
refetch,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: cacheKeys.workspaces.detail(workspaceId.toString()),
|
|
||||||
queryFn: () => workspacesApiService.getWorkspace({ id: workspaceId }),
|
|
||||||
enabled: !!workspaceId,
|
|
||||||
});
|
|
||||||
const { mutateAsync: updateWorkspaceApiAccess } = useAtomValue(
|
|
||||||
updateWorkspaceApiAccessMutationAtom
|
|
||||||
);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
|
|
||||||
const handleToggle = async (enabled: boolean) => {
|
|
||||||
try {
|
|
||||||
setSaving(true);
|
|
||||||
await updateWorkspaceApiAccess({ id: workspaceId, api_access_enabled: enabled });
|
|
||||||
await refetch();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error updating API access:", error);
|
|
||||||
toast.error(error instanceof Error ? error.message : "Failed to update API access");
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const apiAccessEnabled = !!workspace?.api_access_enabled;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-xl font-semibold">API Keys</h1>
|
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
|
||||||
Enable API access for this workspace and manage the keys that use it.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-4 rounded-lg border border-border/60 px-4 py-3">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label htmlFor="playground-api-access">API key access</Label>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Allow API keys to access this workspace.
|
|
||||||
{!isLoading && !apiAccessEnabled && " Currently disabled — keys won't work here."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{isLoading ? (
|
|
||||||
<Skeleton className="h-5 w-9 rounded-full" />
|
|
||||||
) : (
|
|
||||||
<Switch
|
|
||||||
id="playground-api-access"
|
|
||||||
checked={apiAccessEnabled}
|
|
||||||
disabled={saving}
|
|
||||||
onCheckedChange={handleToggle}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ApiKeyContent />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Check, Copy } from "lucide-react";
|
import { Check, ChevronRight, Copy } from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
|
@ -22,10 +22,10 @@ function CopyButton({ text }: { text: string }) {
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={copy}
|
onClick={copy}
|
||||||
className="absolute right-2 top-2 h-7 gap-1.5 px-2 text-xs"
|
aria-label={copied ? "Copied" : "Copy"}
|
||||||
|
className="absolute right-2 top-2 h-7 w-7 p-0"
|
||||||
>
|
>
|
||||||
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||||
{copied ? "Copied" : "Copy"}
|
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -45,8 +45,9 @@ function SchemaBlock({ title, schema }: { title: string; schema: Record<string,
|
||||||
const json = useMemo(() => JSON.stringify(schema, null, 2), [schema]);
|
const json = useMemo(() => JSON.stringify(schema, null, 2), [schema]);
|
||||||
return (
|
return (
|
||||||
<details className="group rounded-md border border-border/60">
|
<details className="group rounded-md border border-border/60">
|
||||||
<summary className="cursor-pointer select-none px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground">
|
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground [&::-webkit-details-marker]:hidden">
|
||||||
{title}
|
<span>{title}</span>
|
||||||
|
<ChevronRight className="h-4 w-4 shrink-0 transition-transform group-open:rotate-90" />
|
||||||
</summary>
|
</summary>
|
||||||
<div className="relative border-t border-border/60">
|
<div className="relative border-t border-border/60">
|
||||||
<CopyButton text={json} />
|
<CopyButton text={json} />
|
||||||
|
|
@ -90,16 +91,13 @@ export function ApiReference({
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-base font-semibold">API reference</h2>
|
<h2 className="text-base font-semibold">API reference</h2>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
Call this API from your own project. Create a key under{" "}
|
Create an API key, enable API access for this workspace, then use the examples below to
|
||||||
<span className="font-medium text-foreground">API Keys</span> (and enable API access for
|
call this endpoint.
|
||||||
this workspace), then send it as a{" "}
|
|
||||||
<code className="rounded bg-muted/40 px-1 py-0.5 text-xs">Authorization: Bearer</code>{" "}
|
|
||||||
header.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs defaultValue="curl">
|
<Tabs defaultValue="curl">
|
||||||
<TabsList className="h-auto flex-wrap">
|
<TabsList className="flex h-auto w-full flex-nowrap justify-start overflow-x-auto overflow-y-hidden">
|
||||||
{snippets.map((snippet) => (
|
{snippets.map((snippet) => (
|
||||||
<TabsTrigger key={snippet.id} value={snippet.id}>
|
<TabsTrigger key={snippet.id} value={snippet.id}>
|
||||||
{snippet.label}
|
{snippet.label}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { Check, Copy, Download } from "lucide-react";
|
import { Check, Copy, Download } from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
|
|
@ -12,7 +13,6 @@ import {
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { downloadCsv, rowsToCsv } from "@/lib/playground/csv";
|
import { downloadCsv, rowsToCsv } from "@/lib/playground/csv";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
const MAX_TABLE_ROWS = 200;
|
const MAX_TABLE_ROWS = 200;
|
||||||
|
|
||||||
|
|
@ -109,34 +109,12 @@ export function OutputViewer({ data, filenameBase }: { data: unknown; filenameBa
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="inline-flex rounded-md border border-border/60 p-0.5">
|
<Tabs value={view} onValueChange={(value) => setView(value as "table" | "json")}>
|
||||||
{items && (
|
<TabsList className="h-auto">
|
||||||
<button
|
{items && <TabsTrigger value="table">Table</TabsTrigger>}
|
||||||
type="button"
|
<TabsTrigger value="json">JSON</TabsTrigger>
|
||||||
onClick={() => setView("table")}
|
</TabsList>
|
||||||
className={cn(
|
</Tabs>
|
||||||
"rounded px-2.5 py-1 text-xs font-medium transition-colors",
|
|
||||||
view === "table"
|
|
||||||
? "bg-accent text-accent-foreground"
|
|
||||||
: "text-muted-foreground hover:text-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Table
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setView("json")}
|
|
||||||
className={cn(
|
|
||||||
"rounded px-2.5 py-1 text-xs font-medium transition-colors",
|
|
||||||
view === "json"
|
|
||||||
? "bg-accent text-accent-foreground"
|
|
||||||
: "text-muted-foreground hover:text-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
JSON
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{items && items.length > 0 && (
|
{items && items.length > 0 && (
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -150,9 +128,15 @@ export function OutputViewer({ data, filenameBase }: { data: unknown; filenameBa
|
||||||
Export CSV
|
Export CSV
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button type="button" variant="ghost" size="sm" onClick={copy} className="gap-1.5">
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={copy}
|
||||||
|
aria-label={copied ? "Copied JSON" : "Copy JSON"}
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
>
|
||||||
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||||
{copied ? "Copied" : "Copy JSON"}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -168,7 +152,7 @@ export function OutputViewer({ data, filenameBase }: { data: unknown; filenameBa
|
||||||
<ResultTable items={items} />
|
<ResultTable items={items} />
|
||||||
{truncated && (
|
{truncated && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Showing first {MAX_TABLE_ROWS} of {items.length} items. Use Copy JSON for the full
|
Showing first {MAX_TABLE_ROWS} of {items.length} items. Switch to JSON for the full
|
||||||
output.
|
output.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ArrowRight, History, KeyRound } from "lucide-react";
|
import { ArrowRight, History, Info, KeyRound } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { useScraperCapabilities } from "@/hooks/use-scraper-capabilities";
|
import { useScraperCapabilities } from "@/hooks/use-scraper-capabilities";
|
||||||
import { PLAYGROUND_PLATFORMS } from "@/lib/playground/catalog";
|
import { PLAYGROUND_PLATFORMS } from "@/lib/playground/catalog";
|
||||||
import { formatPricing } from "@/lib/playground/format";
|
import { formatPricing } from "@/lib/playground/format";
|
||||||
|
|
@ -20,13 +21,21 @@ export function PlaygroundIndex({ workspaceId }: { workspaceId: number }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div>
|
<Alert>
|
||||||
<h1 className="text-xl font-semibold">API Playground</h1>
|
<Info />
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<AlertDescription>
|
||||||
Manually run SurfSense's platform-native APIs and inspect their output. Every run is
|
<p>
|
||||||
captured under Runs.
|
Manually run SurfSense's platform-native APIs and inspect their output. To use these APIs outside SurfSense,{" "}
|
||||||
</p>
|
<Link
|
||||||
</div>
|
href={`${base}/api-keys`}
|
||||||
|
className="font-medium text-foreground underline-offset-4 hover:underline"
|
||||||
|
>
|
||||||
|
create an API key
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<Link
|
<Link
|
||||||
|
|
@ -36,7 +45,7 @@ export function PlaygroundIndex({ workspaceId }: { workspaceId: number }) {
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<History className="h-5 w-5 text-muted-foreground" />
|
<History className="h-5 w-5 text-muted-foreground" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium">Runs</p>
|
<p className="text-sm font-medium">API Runs</p>
|
||||||
<p className="text-xs text-muted-foreground">See every API run in this workspace</p>
|
<p className="text-xs text-muted-foreground">See every API run in this workspace</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -50,9 +59,7 @@ export function PlaygroundIndex({ workspaceId }: { workspaceId: number }) {
|
||||||
<KeyRound className="h-5 w-5 text-muted-foreground" />
|
<KeyRound className="h-5 w-5 text-muted-foreground" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium">API Keys</p>
|
<p className="text-sm font-medium">API Keys</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">Manage keys and workspace API access</p>
|
||||||
Enable workspace access and manage keys
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,16 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { AlertTriangle, Coins, Hash, Loader2, Play, Timer, X } from "lucide-react";
|
import { Check, Coins, Copy, Hash, Info, Timer } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { useRunStream } from "@/hooks/use-run-stream";
|
import { useRunStream } from "@/hooks/use-run-stream";
|
||||||
import { useScraperCapabilities } from "@/hooks/use-scraper-capabilities";
|
import { useScraperCapabilities } from "@/hooks/use-scraper-capabilities";
|
||||||
import { scrapersApiService } from "@/lib/apis/scrapers-api.service";
|
import { scrapersApiService } from "@/lib/apis/scrapers-api.service";
|
||||||
import { AbortedError, AppError } from "@/lib/error";
|
import { AppError } from "@/lib/error";
|
||||||
import { findVerb } from "@/lib/playground/catalog";
|
import { findVerb } from "@/lib/playground/catalog";
|
||||||
import { formatCost, formatDuration, formatPricing } from "@/lib/playground/format";
|
import { formatCost, formatDuration, formatPricing } from "@/lib/playground/format";
|
||||||
import { buildPayload, initialFormValues, parseSchemaFields } from "@/lib/playground/json-schema";
|
import { buildPayload, initialFormValues, parseSchemaFields } from "@/lib/playground/json-schema";
|
||||||
|
|
@ -58,36 +60,44 @@ function RunStat({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ErrorPanel({ error, workspaceId }: { error: unknown; workspaceId: number }) {
|
function getRunErrorMessage(error: unknown): string {
|
||||||
if (error instanceof AbortedError) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const status = error instanceof AppError ? error.status : undefined;
|
const status = error instanceof AppError ? error.status : undefined;
|
||||||
|
|
||||||
if (status === 402) {
|
if (status === 402) {
|
||||||
return (
|
return "Insufficient credits. Add credits to run this API.";
|
||||||
<div className="rounded-md border border-destructive/40 bg-destructive/5 p-4 text-sm">
|
|
||||||
<p className="font-medium text-destructive">Insufficient credits</p>
|
|
||||||
<p className="mt-1 text-muted-foreground">You don't have enough credits to run this API.</p>
|
|
||||||
<Button asChild size="sm" variant="outline" className="mt-3">
|
|
||||||
<Link href={`/dashboard/${workspaceId}/buy-more`}>Buy credits</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const message =
|
if (status === 422) {
|
||||||
status === 422
|
return "Invalid input. Check the fields above and try again.";
|
||||||
? "Invalid input. Check the fields above and try again."
|
}
|
||||||
: error instanceof Error && error.message
|
|
||||||
? error.message
|
return error instanceof Error && error.message
|
||||||
: "Something went wrong running this API.";
|
? error.message
|
||||||
|
: "Something went wrong running this API.";
|
||||||
|
}
|
||||||
|
|
||||||
|
function EndpointCopyButton({ endpoint }: { endpoint: string }) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const handleCopy = () => {
|
||||||
|
navigator.clipboard.writeText(endpoint).then(() => {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1500);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/5 p-4 text-sm text-destructive">
|
<Button
|
||||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
type="button"
|
||||||
<span>{message}</span>
|
variant="ghost"
|
||||||
</div>
|
size="sm"
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="h-auto max-w-full items-start justify-start gap-2 whitespace-normal rounded bg-muted/40 px-2 py-1 font-mono text-xs text-muted-foreground hover:bg-muted hover:text-foreground sm:whitespace-nowrap"
|
||||||
|
>
|
||||||
|
<code className="min-w-0 break-all text-left sm:break-normal">{endpoint}</code>
|
||||||
|
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||||
|
<span className="sr-only">{copied ? "Copied endpoint" : "Copy endpoint"}</span>
|
||||||
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -110,6 +120,8 @@ export function PlaygroundRunner({ workspaceId, platform, verb }: PlaygroundRunn
|
||||||
const [values, setValues] = useState<Record<string, unknown>>({});
|
const [values, setValues] = useState<Record<string, unknown>>({});
|
||||||
const run = useRunStream(workspaceId);
|
const run = useRunStream(workspaceId);
|
||||||
const isRunning = run.status === "running";
|
const isRunning = run.status === "running";
|
||||||
|
const previousStatusRef = useRef(run.status);
|
||||||
|
const notifiedRunRef = useRef<string | null>(null);
|
||||||
|
|
||||||
// Seed form defaults once the schema is available.
|
// Seed form defaults once the schema is available.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -158,6 +170,29 @@ export function PlaygroundRunner({ workspaceId, platform, verb }: PlaygroundRunn
|
||||||
() => (run.detail ? parseJsonlOutput(run.detail.output_text) : null),
|
() => (run.detail ? parseJsonlOutput(run.detail.output_text) : null),
|
||||||
[run.detail]
|
[run.detail]
|
||||||
);
|
);
|
||||||
|
const endpoint = `POST /workspaces/${workspaceId}/scrapers/${platform}/${verb}`;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const previousStatus = previousStatusRef.current;
|
||||||
|
previousStatusRef.current = run.status;
|
||||||
|
|
||||||
|
if (previousStatus !== "running") return;
|
||||||
|
|
||||||
|
if (run.status === "success") {
|
||||||
|
const key = `${run.runId ?? "run"}:success`;
|
||||||
|
if (notifiedRunRef.current === key) return;
|
||||||
|
notifiedRunRef.current = key;
|
||||||
|
toast.success("API run completed.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (run.status === "error") {
|
||||||
|
const key = `${run.runId ?? "run"}:error`;
|
||||||
|
if (notifiedRunRef.current === key) return;
|
||||||
|
notifiedRunRef.current = key;
|
||||||
|
toast.error(getRunErrorMessage(run.error));
|
||||||
|
}
|
||||||
|
}, [run.status, run.runId, run.error]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -186,26 +221,40 @@ export function PlaygroundRunner({ workspaceId, platform, verb }: PlaygroundRunn
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-10">
|
<div className="space-y-10">
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
<div className="space-y-6">
|
||||||
|
{capability.description && (
|
||||||
|
<Alert>
|
||||||
|
<Info />
|
||||||
|
<AlertDescription>
|
||||||
|
<p>
|
||||||
|
{capability.description}
|
||||||
|
{capability.docs_url ? (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
<Link
|
||||||
|
href={capability.docs_url}
|
||||||
|
className="font-medium text-foreground underline-offset-4 hover:underline"
|
||||||
|
>
|
||||||
|
Read docs
|
||||||
|
</Link>{" "}
|
||||||
|
for more info.
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<h1 className="text-lg font-semibold">
|
<EndpointCopyButton endpoint={endpoint} />
|
||||||
{catalogVerb.label} <span className="text-muted-foreground">· {platform}</span>
|
<div className="text-xs text-muted-foreground">
|
||||||
</h1>
|
<span>Pricing: </span>
|
||||||
{capability.description && (
|
|
||||||
<p className="mt-1 text-sm text-muted-foreground">{capability.description}</p>
|
|
||||||
)}
|
|
||||||
<code className="mt-2 inline-block rounded bg-muted/40 px-1.5 py-0.5 text-xs text-muted-foreground">
|
|
||||||
POST /workspaces/{workspaceId}/scrapers/{platform}/{verb}
|
|
||||||
</code>
|
|
||||||
<div className="mt-2 flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
||||||
<Coins className="h-3.5 w-3.5" />
|
|
||||||
<span className="font-medium tabular-nums text-foreground">
|
<span className="font-medium tabular-nums text-foreground">
|
||||||
{formatPricing(capability.pricing)}
|
{formatPricing(capability.pricing)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SchemaForm
|
<SchemaForm
|
||||||
fields={fields}
|
fields={fields}
|
||||||
values={values}
|
values={values}
|
||||||
|
|
@ -214,33 +263,22 @@ export function PlaygroundRunner({ workspaceId, platform, verb }: PlaygroundRunn
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button type="button" onClick={handleRun} disabled={isRunning} className="gap-1.5">
|
<Button type="button" onClick={handleRun} disabled={isRunning} className="relative">
|
||||||
{isRunning ? (
|
<span className={isRunning ? "opacity-0" : ""}>Run</span>
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
{isRunning && <Spinner size="sm" className="absolute" />}
|
||||||
) : (
|
|
||||||
<Play className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
Run
|
|
||||||
</Button>
|
</Button>
|
||||||
{isRunning && (
|
{isRunning && (
|
||||||
<Button type="button" variant="outline" onClick={run.cancel} className="gap-1.5">
|
<Button type="button" variant="secondary" onClick={run.cancel}>
|
||||||
<X className="h-4 w-4" />
|
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{run.status === "error" && <ErrorPanel error={run.error} workspaceId={workspaceId} />}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h2 className="text-sm font-medium text-muted-foreground">Output</h2>
|
<h2 className="text-sm font-medium text-muted-foreground">Output</h2>
|
||||||
{isRunning ? (
|
{isRunning ? (
|
||||||
<RunProgressPanel
|
<RunProgressPanel latest={run.latest} events={run.events} elapsedMs={run.elapsedMs} />
|
||||||
latest={run.latest}
|
|
||||||
events={run.events}
|
|
||||||
elapsedMs={run.elapsedMs}
|
|
||||||
/>
|
|
||||||
) : run.status === "cancelled" ? (
|
) : run.status === "cancelled" ? (
|
||||||
<div className="flex h-64 items-center justify-center rounded-md border border-border/60 px-4 text-center text-sm text-muted-foreground">
|
<div className="flex h-64 items-center justify-center rounded-md border border-border/60 px-4 text-center text-sm text-muted-foreground">
|
||||||
Run cancelled.
|
Run cancelled.
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
import { ChevronDown, ChevronRight, History } from "lucide-react";
|
import { ChevronDown, ChevronRight, History, Info } from "lucide-react";
|
||||||
import { Fragment, useState } from "react";
|
import { Fragment, useState } from "react";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
|
|
@ -67,13 +68,12 @@ export function RunsTable({ workspaceId }: { workspaceId: number }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<Alert>
|
||||||
<h1 className="text-xl font-semibold">Runs</h1>
|
<Info />
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<AlertDescription>
|
||||||
Every platform-native API run in this workspace — from the playground, API keys, and
|
View all API runs for this workspace, including runs from the playground, API keys, and agents.
|
||||||
agents. Newest first.
|
</AlertDescription>
|
||||||
</p>
|
</Alert>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Select value={capability} onValueChange={setCapability}>
|
<Select value={capability} onValueChange={setCapability}>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import { ChevronDown } from "lucide-react";
|
import { ChevronDown } from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import {
|
import {
|
||||||
|
|
@ -133,7 +134,12 @@ function FieldRow({
|
||||||
<Label htmlFor={`field-${field.name}`} className="text-sm font-medium">
|
<Label htmlFor={`field-${field.name}`} className="text-sm font-medium">
|
||||||
{field.title}
|
{field.title}
|
||||||
</Label>
|
</Label>
|
||||||
{field.required && <span className="text-xs text-destructive">required</span>}
|
<Badge
|
||||||
|
variant={field.required ? "destructive" : "secondary"}
|
||||||
|
className="px-1.5 py-0 text-[10px]"
|
||||||
|
>
|
||||||
|
{field.required ? "required" : "optional"}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
{field.description && (
|
{field.description && (
|
||||||
<p className="text-xs text-muted-foreground">{field.description}</p>
|
<p className="text-xs text-muted-foreground">{field.description}</p>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useSelectedLayoutSegments } from "next/navigation";
|
||||||
|
import type React from "react";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import {
|
||||||
|
getPlaygroundNavGroups,
|
||||||
|
getPlaygroundNavItems,
|
||||||
|
getPlaygroundSelectedLabel,
|
||||||
|
RoutedSectionShell,
|
||||||
|
} from "@/components/layout";
|
||||||
|
|
||||||
|
interface PlaygroundLayoutShellProps {
|
||||||
|
workspaceId: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlaygroundLayoutShell({ workspaceId, children }: PlaygroundLayoutShellProps) {
|
||||||
|
const segments = useSelectedLayoutSegments();
|
||||||
|
const base = `/dashboard/${workspaceId}/playground`;
|
||||||
|
|
||||||
|
const topLevelItems = useMemo(() => getPlaygroundNavItems(base), [base]);
|
||||||
|
const providerGroups = useMemo(() => getPlaygroundNavGroups(base), [base]);
|
||||||
|
|
||||||
|
const activeValue =
|
||||||
|
segments.length >= 2
|
||||||
|
? `${segments[0]}/${segments[1]}`
|
||||||
|
: segments[0] && topLevelItems.some((item) => item.value === segments[0])
|
||||||
|
? segments[0]
|
||||||
|
: "overview";
|
||||||
|
|
||||||
|
const selectedLabel = getPlaygroundSelectedLabel(activeValue, topLevelItems, providerGroups);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RoutedSectionShell
|
||||||
|
title="API Playground"
|
||||||
|
items={topLevelItems}
|
||||||
|
groups={providerGroups}
|
||||||
|
activeValue={activeValue}
|
||||||
|
selectedLabel={selectedLabel}
|
||||||
|
mobileNav="drawer"
|
||||||
|
desktopNav={false}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</RoutedSectionShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
import type React from "react";
|
||||||
|
import { use } from "react";
|
||||||
|
import { PlaygroundLayoutShell } from "./layout-shell";
|
||||||
|
|
||||||
|
export default function PlaygroundLayout({
|
||||||
|
params,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ workspace_id: string }>;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const { workspace_id } = use(params);
|
||||||
|
|
||||||
|
return <PlaygroundLayoutShell workspaceId={workspace_id}>{children}</PlaygroundLayoutShell>;
|
||||||
|
}
|
||||||
|
|
@ -108,9 +108,8 @@ function normalizeCreditPurchase(p: CreditPurchase): UnifiedPurchase {
|
||||||
function formatGranted(p: UnifiedPurchase): string {
|
function formatGranted(p: UnifiedPurchase): string {
|
||||||
if (p.kind === "credits") {
|
if (p.kind === "credits") {
|
||||||
const dollars = p.granted / 1_000_000;
|
const dollars = p.granted / 1_000_000;
|
||||||
// Credit packs are always whole dollars at the moment, but future
|
// Credit packs are always whole dollars today, but future fractional grants
|
||||||
// fractional grants (refunds, partial top-ups, auto-reload) shouldn't
|
// such as refunds or low-balance refills shouldn't silently round to "$0".
|
||||||
// silently round to "$0".
|
|
||||||
if (dollars >= 1) return `$${dollars.toFixed(2)} of credit`;
|
if (dollars >= 1) return `$${dollars.toFixed(2)} of credit`;
|
||||||
if (dollars > 0) return `$${dollars.toFixed(3)} of credit`;
|
if (dollars > 0) return `$${dollars.toFixed(3)} of credit`;
|
||||||
return "$0 of credit";
|
return "$0 of credit";
|
||||||
|
|
|
||||||
|
|
@ -11,14 +11,12 @@ import {
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
WandSparkles,
|
WandSparkles,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
|
||||||
import { useSelectedLayoutSegment } from "next/navigation";
|
import { useSelectedLayoutSegment } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useMemo } from "react";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { type RoutedSectionItem, RoutedSectionShell } from "@/components/layout";
|
||||||
import { usePlatform } from "@/hooks/use-platform";
|
import { usePlatform } from "@/hooks/use-platform";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
export type UserSettingsTab =
|
export type UserSettingsTab =
|
||||||
| "profile"
|
| "profile"
|
||||||
|
|
@ -42,50 +40,49 @@ export function UserSettingsLayoutShell({ workspaceId, children }: UserSettingsL
|
||||||
const t = useTranslations("userSettings");
|
const t = useTranslations("userSettings");
|
||||||
const { isDesktop } = usePlatform();
|
const { isDesktop } = usePlatform();
|
||||||
const segment = useSelectedLayoutSegment();
|
const segment = useSelectedLayoutSegment();
|
||||||
const [tabScrollPos, setTabScrollPos] = useState<"start" | "middle" | "end">("start");
|
|
||||||
|
|
||||||
const handleTabScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
const navItems = useMemo<RoutedSectionItem[]>(
|
||||||
const el = e.currentTarget;
|
|
||||||
const atStart = el.scrollLeft <= 2;
|
|
||||||
const atEnd = el.scrollWidth - el.scrollLeft - el.clientWidth <= 2;
|
|
||||||
setTabScrollPos(atStart ? "start" : atEnd ? "end" : "middle");
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const navItems = useMemo(
|
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
value: "profile" as const,
|
value: "profile" as const,
|
||||||
label: t("profile_nav_label"),
|
label: t("profile_nav_label"),
|
||||||
|
href: `/dashboard/${workspaceId}/user-settings/profile`,
|
||||||
icon: <CircleUser className="h-4 w-4" />,
|
icon: <CircleUser className="h-4 w-4" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "api-key" as const,
|
value: "api-key" as const,
|
||||||
label: t("api_key_nav_label"),
|
label: t("api_key_nav_label"),
|
||||||
|
href: `/dashboard/${workspaceId}/user-settings/api-key`,
|
||||||
icon: <KeyRound className="h-4 w-4" />,
|
icon: <KeyRound className="h-4 w-4" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "prompts" as const,
|
value: "prompts" as const,
|
||||||
label: "My Prompts",
|
label: "My Prompts",
|
||||||
|
href: `/dashboard/${workspaceId}/user-settings/prompts`,
|
||||||
icon: <WandSparkles className="h-4 w-4" />,
|
icon: <WandSparkles className="h-4 w-4" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "community-prompts" as const,
|
value: "community-prompts" as const,
|
||||||
label: "Community Prompts",
|
label: "Community Prompts",
|
||||||
|
href: `/dashboard/${workspaceId}/user-settings/community-prompts`,
|
||||||
icon: <Library className="h-4 w-4" />,
|
icon: <Library className="h-4 w-4" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "agent-permissions" as const,
|
value: "agent-permissions" as const,
|
||||||
label: "Agent Permissions",
|
label: "Agent Permissions",
|
||||||
|
href: `/dashboard/${workspaceId}/user-settings/agent-permissions`,
|
||||||
icon: <ShieldCheck className="h-4 w-4" />,
|
icon: <ShieldCheck className="h-4 w-4" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "messaging-channels" as const,
|
value: "messaging-channels" as const,
|
||||||
label: "Messaging Channels",
|
label: "Messaging Channels",
|
||||||
|
href: `/dashboard/${workspaceId}/user-settings/messaging-channels`,
|
||||||
icon: <MessageCircle className="h-4 w-4" />,
|
icon: <MessageCircle className="h-4 w-4" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "purchases" as const,
|
value: "purchases" as const,
|
||||||
label: "Purchase History",
|
label: "Purchase History",
|
||||||
|
href: `/dashboard/${workspaceId}/user-settings/purchases`,
|
||||||
icon: <ReceiptText className="h-4 w-4" />,
|
icon: <ReceiptText className="h-4 w-4" />,
|
||||||
},
|
},
|
||||||
...(isDesktop
|
...(isDesktop
|
||||||
|
|
@ -93,17 +90,19 @@ export function UserSettingsLayoutShell({ workspaceId, children }: UserSettingsL
|
||||||
{
|
{
|
||||||
value: "desktop" as const,
|
value: "desktop" as const,
|
||||||
label: "App Preferences",
|
label: "App Preferences",
|
||||||
|
href: `/dashboard/${workspaceId}/user-settings/desktop`,
|
||||||
icon: <Monitor className="h-4 w-4" />,
|
icon: <Monitor className="h-4 w-4" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "hotkeys" as const,
|
value: "hotkeys" as const,
|
||||||
label: "Hotkeys",
|
label: "Hotkeys",
|
||||||
|
href: `/dashboard/${workspaceId}/user-settings/hotkeys`,
|
||||||
icon: <Keyboard className="h-4 w-4" />,
|
icon: <Keyboard className="h-4 w-4" />,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
],
|
],
|
||||||
[t, isDesktop]
|
[t, isDesktop, workspaceId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const activeTab: UserSettingsTab =
|
const activeTab: UserSettingsTab =
|
||||||
|
|
@ -112,70 +111,15 @@ export function UserSettingsLayoutShell({ workspaceId, children }: UserSettingsL
|
||||||
: DEFAULT_TAB;
|
: DEFAULT_TAB;
|
||||||
const selectedLabel = navItems.find((item) => item.value === activeTab)?.label ?? t("title");
|
const selectedLabel = navItems.find((item) => item.value === activeTab)?.label ?? t("title");
|
||||||
|
|
||||||
const hrefFor = (tab: UserSettingsTab) => `/dashboard/${workspaceId}/user-settings/${tab}`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="flex h-full min-h-[min(680px,calc(100vh-5rem))] w-full select-none flex-col gap-6 md:flex-row">
|
<RoutedSectionShell
|
||||||
<div className="md:w-[220px] md:shrink-0">
|
title={t("title")}
|
||||||
<h1 className="mb-4 px-1 text-2xl font-semibold tracking-tight">{t("title")}</h1>
|
items={navItems}
|
||||||
<nav className="hidden flex-col gap-0.5 md:flex">
|
activeValue={activeTab}
|
||||||
{navItems.map((item) => (
|
selectedLabel={selectedLabel}
|
||||||
<Link
|
contentClassName="md:max-w-3xl"
|
||||||
key={item.value}
|
>
|
||||||
href={hrefFor(item.value)}
|
{children}
|
||||||
replace
|
</RoutedSectionShell>
|
||||||
scroll={false}
|
|
||||||
prefetch
|
|
||||||
className={cn(
|
|
||||||
"inline-flex h-auto items-center justify-start gap-3 rounded-md px-3 py-2.5 text-left text-sm font-medium transition-colors duration-150 focus:outline-none focus-visible:outline-none",
|
|
||||||
activeTab === item.value
|
|
||||||
? "bg-accent text-accent-foreground"
|
|
||||||
: "bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item.icon}
|
|
||||||
{item.label}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
<div
|
|
||||||
className="overflow-x-auto border-b border-border pb-3 md:hidden"
|
|
||||||
onScroll={handleTabScroll}
|
|
||||||
style={{
|
|
||||||
maskImage: `linear-gradient(to right, ${tabScrollPos === "start" ? "black" : "transparent"}, black 24px, black calc(100% - 24px), ${tabScrollPos === "end" ? "black" : "transparent"})`,
|
|
||||||
WebkitMaskImage: `linear-gradient(to right, ${tabScrollPos === "start" ? "black" : "transparent"}, black 24px, black calc(100% - 24px), ${tabScrollPos === "end" ? "black" : "transparent"})`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex gap-1">
|
|
||||||
{navItems.map((item) => (
|
|
||||||
<Link
|
|
||||||
key={item.value}
|
|
||||||
href={hrefFor(item.value)}
|
|
||||||
replace
|
|
||||||
scroll={false}
|
|
||||||
prefetch
|
|
||||||
className={cn(
|
|
||||||
"inline-flex h-auto shrink-0 items-center gap-2 rounded-md px-3 py-1.5 text-xs font-medium transition-colors duration-150 focus:outline-none focus-visible:outline-none",
|
|
||||||
activeTab === item.value
|
|
||||||
? "bg-accent text-accent-foreground"
|
|
||||||
: "bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item.icon}
|
|
||||||
{item.label}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="hidden md:block">
|
|
||||||
<h2 className="text-lg font-semibold">{selectedLabel}</h2>
|
|
||||||
<Separator className="mt-4 bg-border" />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 pt-4 md:max-w-3xl">{children}</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,11 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { BookText, Cpu, Earth, Settings, UserKey } from "lucide-react";
|
import { BookText, Cpu, Earth, Settings, UserKey } from "lucide-react";
|
||||||
import Link from "next/link";
|
|
||||||
import { useSelectedLayoutSegment } from "next/navigation";
|
import { useSelectedLayoutSegment } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useMemo } from "react";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { type RoutedSectionItem, RoutedSectionShell } from "@/components/layout";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
export type WorkspaceSettingsTab = "general" | "models" | "team-roles" | "prompts" | "public-links";
|
export type WorkspaceSettingsTab = "general" | "models" | "team-roles" | "prompts" | "public-links";
|
||||||
|
|
||||||
|
|
@ -24,44 +22,41 @@ export function WorkspaceSettingsLayoutShell({
|
||||||
}: WorkspaceSettingsLayoutShellProps) {
|
}: WorkspaceSettingsLayoutShellProps) {
|
||||||
const t = useTranslations("workspaceSettings");
|
const t = useTranslations("workspaceSettings");
|
||||||
const segment = useSelectedLayoutSegment();
|
const segment = useSelectedLayoutSegment();
|
||||||
const [tabScrollPos, setTabScrollPos] = useState<"start" | "middle" | "end">("start");
|
|
||||||
|
|
||||||
const handleTabScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
const navItems = useMemo<RoutedSectionItem[]>(
|
||||||
const el = e.currentTarget;
|
|
||||||
const atStart = el.scrollLeft <= 2;
|
|
||||||
const atEnd = el.scrollWidth - el.scrollLeft - el.clientWidth <= 2;
|
|
||||||
setTabScrollPos(atStart ? "start" : atEnd ? "end" : "middle");
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const navItems = useMemo(
|
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
value: "general" as const,
|
value: "general" as const,
|
||||||
label: t("nav_general"),
|
label: t("nav_general"),
|
||||||
|
href: `/dashboard/${workspaceId}/workspace-settings/general`,
|
||||||
icon: <Settings className="h-4 w-4" />,
|
icon: <Settings className="h-4 w-4" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "models" as const,
|
value: "models" as const,
|
||||||
label: t("nav_models"),
|
label: t("nav_models"),
|
||||||
|
href: `/dashboard/${workspaceId}/workspace-settings/models`,
|
||||||
icon: <Cpu className="h-4 w-4" />,
|
icon: <Cpu className="h-4 w-4" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "team-roles" as const,
|
value: "team-roles" as const,
|
||||||
label: t("nav_team_roles"),
|
label: t("nav_team_roles"),
|
||||||
|
href: `/dashboard/${workspaceId}/workspace-settings/team-roles`,
|
||||||
icon: <UserKey className="h-4 w-4" />,
|
icon: <UserKey className="h-4 w-4" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "prompts" as const,
|
value: "prompts" as const,
|
||||||
label: t("nav_system_instructions"),
|
label: t("nav_system_instructions"),
|
||||||
|
href: `/dashboard/${workspaceId}/workspace-settings/prompts`,
|
||||||
icon: <BookText className="h-4 w-4" />,
|
icon: <BookText className="h-4 w-4" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "public-links" as const,
|
value: "public-links" as const,
|
||||||
label: t("nav_public_links"),
|
label: t("nav_public_links"),
|
||||||
|
href: `/dashboard/${workspaceId}/workspace-settings/public-links`,
|
||||||
icon: <Earth className="h-4 w-4" />,
|
icon: <Earth className="h-4 w-4" />,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[t]
|
[t, workspaceId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const activeTab: WorkspaceSettingsTab =
|
const activeTab: WorkspaceSettingsTab =
|
||||||
|
|
@ -70,71 +65,15 @@ export function WorkspaceSettingsLayoutShell({
|
||||||
: DEFAULT_TAB;
|
: DEFAULT_TAB;
|
||||||
const selectedLabel = navItems.find((item) => item.value === activeTab)?.label ?? t("title");
|
const selectedLabel = navItems.find((item) => item.value === activeTab)?.label ?? t("title");
|
||||||
|
|
||||||
const hrefFor = (tab: WorkspaceSettingsTab) =>
|
|
||||||
`/dashboard/${workspaceId}/workspace-settings/${tab}`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="flex h-full min-h-[min(680px,calc(100vh-5rem))] w-full select-none flex-col gap-6 md:flex-row">
|
<RoutedSectionShell
|
||||||
<div className="md:w-[220px] md:shrink-0">
|
title={t("title")}
|
||||||
<h1 className="mb-4 px-1 text-2xl font-semibold tracking-tight">{t("title")}</h1>
|
items={navItems}
|
||||||
<nav className="hidden flex-col gap-0.5 md:flex">
|
activeValue={activeTab}
|
||||||
{navItems.map((item) => (
|
selectedLabel={selectedLabel}
|
||||||
<Link
|
contentClassName="md:max-w-3xl"
|
||||||
key={item.value}
|
>
|
||||||
href={hrefFor(item.value)}
|
{children}
|
||||||
replace
|
</RoutedSectionShell>
|
||||||
scroll={false}
|
|
||||||
prefetch
|
|
||||||
className={cn(
|
|
||||||
"inline-flex h-auto items-center justify-start gap-3 rounded-md px-3 py-2.5 text-left text-sm font-medium transition-colors duration-150 focus:outline-none focus-visible:outline-none",
|
|
||||||
activeTab === item.value
|
|
||||||
? "bg-accent text-accent-foreground"
|
|
||||||
: "bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item.icon}
|
|
||||||
{item.label}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
<div
|
|
||||||
className="overflow-x-auto border-b border-border pb-3 md:hidden"
|
|
||||||
onScroll={handleTabScroll}
|
|
||||||
style={{
|
|
||||||
maskImage: `linear-gradient(to right, ${tabScrollPos === "start" ? "black" : "transparent"}, black 24px, black calc(100% - 24px), ${tabScrollPos === "end" ? "black" : "transparent"})`,
|
|
||||||
WebkitMaskImage: `linear-gradient(to right, ${tabScrollPos === "start" ? "black" : "transparent"}, black 24px, black calc(100% - 24px), ${tabScrollPos === "end" ? "black" : "transparent"})`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex gap-1">
|
|
||||||
{navItems.map((item) => (
|
|
||||||
<Link
|
|
||||||
key={item.value}
|
|
||||||
href={hrefFor(item.value)}
|
|
||||||
replace
|
|
||||||
scroll={false}
|
|
||||||
prefetch
|
|
||||||
className={cn(
|
|
||||||
"inline-flex h-auto shrink-0 items-center gap-2 rounded-md px-3 py-1.5 text-xs font-medium transition-colors duration-150 focus:outline-none focus-visible:outline-none",
|
|
||||||
activeTab === item.value
|
|
||||||
? "bg-accent text-accent-foreground"
|
|
||||||
: "bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item.icon}
|
|
||||||
{item.label}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="hidden md:block">
|
|
||||||
<h2 className="text-lg font-semibold">{selectedLabel}</h2>
|
|
||||||
<Separator className="mt-4" />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 pt-4 md:max-w-3xl">{children}</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import { atom } from "jotai";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the second-level API Playground sidebar is open. Toggled by the
|
|
||||||
* Playground nav button and kept in memory for the session, so it survives
|
|
||||||
* in-app navigation (opening a new chat won't close it) and only closes when
|
|
||||||
* the user clicks the toggle. It defaults to open, so a fresh app load — a new
|
|
||||||
* signup or a relogin — always starts with the playground visible.
|
|
||||||
*/
|
|
||||||
export const playgroundSidebarOpenAtom = atom<boolean>(true);
|
|
||||||
|
|
@ -22,7 +22,7 @@ import {
|
||||||
Wrench,
|
Wrench,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
@ -119,7 +119,7 @@ import {
|
||||||
} from "../new-chat/document-mention-picker";
|
} from "../new-chat/document-mention-picker";
|
||||||
|
|
||||||
const COMPOSER_PLACEHOLDER =
|
const COMPOSER_PLACEHOLDER =
|
||||||
"Track competitors, scrape platforms, automate briefs — / for prompts, @ for docs";
|
"Track competitors, scrape platforms, automate briefs. Use / for prompts, @ for docs";
|
||||||
|
|
||||||
type ComposerSuggestionAnchorPoint = {
|
type ComposerSuggestionAnchorPoint = {
|
||||||
left: number;
|
left: number;
|
||||||
|
|
@ -986,14 +986,12 @@ const Composer: FC = () => {
|
||||||
* Full-color brand marks for the platform-native scraper APIs (web, Google
|
* Full-color brand marks for the platform-native scraper APIs (web, Google
|
||||||
* Search, Google Maps, Reddit, YouTube) available in this workspace, shown beside the
|
* Search, Google Maps, Reddit, YouTube) available in this workspace, shown beside the
|
||||||
* composer "+" so the user can see these native endpoints are connected. Laid
|
* composer "+" so the user can see these native endpoints are connected. Laid
|
||||||
* out as a clean row (not stacked) after a hairline divider that separates them
|
* out as the same overlapping avatar group used by the connect-tools tray
|
||||||
* from the composer actions. The capability registry is the source of truth;
|
* from the composer actions. The capability registry is the source of truth;
|
||||||
* icons are display-only with a status tooltip. One-time staggered entrance,
|
* icons are display-only with a status tooltip.
|
||||||
* reduced-motion aware.
|
|
||||||
*/
|
*/
|
||||||
const ConnectedScraperIcons: FC<{ workspaceId: number }> = ({ workspaceId }) => {
|
const ConnectedScraperIcons: FC<{ workspaceId: number }> = ({ workspaceId }) => {
|
||||||
const { data: capabilities } = useScraperCapabilities(workspaceId);
|
const { data: capabilities } = useScraperCapabilities(workspaceId);
|
||||||
const reduceMotion = useReducedMotion();
|
|
||||||
|
|
||||||
const platforms = useMemo<PlaygroundPlatform[]>(() => {
|
const platforms = useMemo<PlaygroundPlatform[]>(() => {
|
||||||
if (!capabilities?.length) return [];
|
if (!capabilities?.length) return [];
|
||||||
|
|
@ -1014,30 +1012,26 @@ const ConnectedScraperIcons: FC<{ workspaceId: number }> = ({ workspaceId }) =>
|
||||||
return (
|
return (
|
||||||
<div className="hidden items-center gap-1 sm:flex">
|
<div className="hidden items-center gap-1 sm:flex">
|
||||||
<div aria-hidden className="h-5 w-px shrink-0 bg-border" />
|
<div aria-hidden className="h-5 w-px shrink-0 bg-border" />
|
||||||
<div className="flex items-center gap-0.5" aria-label="Connected data sources">
|
<AvatarGroup className="shrink-0">
|
||||||
{platforms.map((platform, i) => {
|
{platforms.map((platform, i) => {
|
||||||
const Icon = platform.icon;
|
const Icon = platform.icon;
|
||||||
return (
|
return (
|
||||||
<Tooltip key={platform.id}>
|
<Tooltip key={platform.id}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<motion.span
|
<Avatar
|
||||||
initial={{ opacity: 0, y: reduceMotion ? 0 : 4 }}
|
className="size-5"
|
||||||
animate={{ opacity: 1, y: 0 }}
|
style={{ zIndex: platforms.length - i }}
|
||||||
transition={{
|
|
||||||
duration: 0.2,
|
|
||||||
ease: [0.23, 1, 0.32, 1],
|
|
||||||
delay: reduceMotion ? 0 : i * 0.04,
|
|
||||||
}}
|
|
||||||
className="flex size-5 items-center justify-center"
|
|
||||||
>
|
>
|
||||||
<Icon className="size-3.5" />
|
<AvatarFallback className="bg-popover text-[10px]">
|
||||||
</motion.span>
|
<Icon className="size-3" />
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom">{platform.label} · Connected</TooltipContent>
|
<TooltipContent side="bottom">{platform.label} scraper available</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</AvatarGroup>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -206,7 +206,7 @@ function buildMcp({ mcpTool }: ApiSample): string {
|
||||||
const config = {
|
const config = {
|
||||||
mcpServers: {
|
mcpServers: {
|
||||||
surfsense: {
|
surfsense: {
|
||||||
url: "https://mcp.surfsense.com",
|
url: "https://mcp.surfsense.com/mcp",
|
||||||
headers: { Authorization: "Bearer ${SURFSENSE_API_KEY}" },
|
headers: { Authorization: "Bearer ${SURFSENSE_API_KEY}" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,7 @@ export const FolderNode = React.memo(function FolderNode({
|
||||||
const rowRef = useRef<HTMLDivElement>(null);
|
const rowRef = useRef<HTMLDivElement>(null);
|
||||||
const [dropZone, setDropZone] = useState<DropZone | null>(null);
|
const [dropZone, setDropZone] = useState<DropZone | null>(null);
|
||||||
const [isRescanning, setIsRescanning] = useState(false);
|
const [isRescanning, setIsRescanning] = useState(false);
|
||||||
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||||
|
|
||||||
const handleRescan = useCallback(async () => {
|
const handleRescan = useCallback(async () => {
|
||||||
if (isRescanning) return;
|
if (isRescanning) return;
|
||||||
|
|
@ -349,12 +350,17 @@ export const FolderNode = React.memo(function FolderNode({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isRenaming && (
|
{!isRenaming && (
|
||||||
<DropdownMenu>
|
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="hidden sm:inline-flex h-6 w-6 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
className={cn(
|
||||||
|
"hidden sm:inline-flex h-6 w-6 shrink-0 hover:bg-transparent transition-opacity",
|
||||||
|
dropdownOpen
|
||||||
|
? "opacity-100 bg-accent hover:bg-accent"
|
||||||
|
: "opacity-0 group-hover:opacity-100"
|
||||||
|
)}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
|
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ export type HeroChatDemoScript = {
|
||||||
|
|
||||||
type Stage = "typing" | "steps" | "answer" | "done";
|
type Stage = "typing" | "steps" | "answer" | "done";
|
||||||
|
|
||||||
const PLACEHOLDER = "Track competitors, scrape platforms, automate briefs — / for prompts, @ for docs";
|
const PLACEHOLDER = "Track competitors, scrape platforms, automate briefs. Use / for prompts, @ for docs";
|
||||||
|
|
||||||
/** Blinking caret for the typewriter (overlay only, never inside the real input). */
|
/** Blinking caret for the typewriter (overlay only, never inside the real input). */
|
||||||
function Caret() {
|
function Caret() {
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,15 @@ export type {
|
||||||
User,
|
User,
|
||||||
Workspace,
|
Workspace,
|
||||||
} from "./types/layout.types";
|
} from "./types/layout.types";
|
||||||
|
export type { RoutedSectionGroup, RoutedSectionItem } from "./ui";
|
||||||
export {
|
export {
|
||||||
ChatListItem,
|
ChatListItem,
|
||||||
CreateWorkspaceDialog,
|
CreateWorkspaceDialog,
|
||||||
CreditBalanceDisplay,
|
CreditBalanceDisplay,
|
||||||
|
getPlaygroundActiveValue,
|
||||||
|
getPlaygroundNavGroups,
|
||||||
|
getPlaygroundNavItems,
|
||||||
|
getPlaygroundSelectedLabel,
|
||||||
Header,
|
Header,
|
||||||
IconRail,
|
IconRail,
|
||||||
LayoutShell,
|
LayoutShell,
|
||||||
|
|
@ -20,6 +25,8 @@ export {
|
||||||
MobileSidebarTrigger,
|
MobileSidebarTrigger,
|
||||||
NavIcon,
|
NavIcon,
|
||||||
NavSection,
|
NavSection,
|
||||||
|
PlaygroundSidebar,
|
||||||
|
RoutedSectionShell,
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarCollapseButton,
|
SidebarCollapseButton,
|
||||||
SidebarHeader,
|
SidebarHeader,
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,13 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Inbox } from "lucide-react";
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useAnonymousMode } from "@/contexts/anonymous-mode";
|
import { useAnonymousMode } from "@/contexts/anonymous-mode";
|
||||||
import { useLoginGate } from "@/contexts/login-gate";
|
import { useLoginGate } from "@/contexts/login-gate";
|
||||||
import { useAnnouncements } from "@/hooks/use-announcements";
|
import { useAnnouncements } from "@/hooks/use-announcements";
|
||||||
import { anonymousChatApiService } from "@/lib/apis/anonymous-chat-api.service";
|
import { anonymousChatApiService } from "@/lib/apis/anonymous-chat-api.service";
|
||||||
import type { ChatItem, NavItem, PageUsage, Workspace } from "../types/layout.types";
|
import type { ChatItem, PageUsage, Workspace } from "../types/layout.types";
|
||||||
import { LayoutShell } from "../ui/shell";
|
import { LayoutShell } from "../ui/shell";
|
||||||
|
|
||||||
interface FreeLayoutDataProviderProps {
|
interface FreeLayoutDataProviderProps {
|
||||||
|
|
@ -47,34 +46,12 @@ export function FreeLayoutDataProvider({ children }: FreeLayoutDataProviderProps
|
||||||
|
|
||||||
const gatedAction = useCallback((feature: string) => () => gate(feature), [gate]);
|
const gatedAction = useCallback((feature: string) => () => gate(feature), [gate]);
|
||||||
|
|
||||||
const navItems: NavItem[] = useMemo(
|
|
||||||
() =>
|
|
||||||
(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
title: "Inbox",
|
|
||||||
url: "#inbox",
|
|
||||||
icon: Inbox,
|
|
||||||
isActive: false,
|
|
||||||
},
|
|
||||||
] as (NavItem | null)[]
|
|
||||||
).filter((item): item is NavItem => item !== null),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const pageUsage: PageUsage | undefined = quota
|
const pageUsage: PageUsage | undefined = quota
|
||||||
? { pagesUsed: quota.used, pagesLimit: quota.limit }
|
? { pagesUsed: quota.used, pagesLimit: quota.limit }
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const handleChatSelect = useCallback((_chat: ChatItem) => gate("view chat history"), [gate]);
|
const handleChatSelect = useCallback((_chat: ChatItem) => gate("view chat history"), [gate]);
|
||||||
|
|
||||||
const handleNavItemClick = useCallback(
|
|
||||||
(item: NavItem) => {
|
|
||||||
if (item.title === "Inbox") gate("use the inbox");
|
|
||||||
},
|
|
||||||
[gate]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleAnnouncements = useCallback(() => gate("see what's new"), [gate]);
|
const handleAnnouncements = useCallback(() => gate("see what's new"), [gate]);
|
||||||
|
|
||||||
const handleWorkspaceSelect = useCallback((_id: number) => gate("switch workspaces"), [gate]);
|
const handleWorkspaceSelect = useCallback((_id: number) => gate("switch workspaces"), [gate]);
|
||||||
|
|
@ -87,8 +64,7 @@ export function FreeLayoutDataProvider({ children }: FreeLayoutDataProviderProps
|
||||||
onWorkspaceSettings={gatedAction("workspace settings")}
|
onWorkspaceSettings={gatedAction("workspace settings")}
|
||||||
onAddWorkspace={gatedAction("create workspaces")}
|
onAddWorkspace={gatedAction("create workspaces")}
|
||||||
workspace={GUEST_SPACE}
|
workspace={GUEST_SPACE}
|
||||||
navItems={navItems}
|
navItems={[]}
|
||||||
onNavItemClick={handleNavItemClick}
|
|
||||||
chats={[]}
|
chats={[]}
|
||||||
activeChatId={null}
|
activeChatId={null}
|
||||||
onNewChat={resetChat}
|
onNewChat={resetChat}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
import { useAtomValue, useSetAtom } from "jotai";
|
||||||
import { AlarmClock, AlertTriangle, Boxes, Inbox, SquareTerminal } from "lucide-react";
|
import { AlarmClock, AlertTriangle, Boxes, SquareTerminal } from "lucide-react";
|
||||||
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";
|
||||||
|
|
@ -11,7 +11,6 @@ import { toast } from "sonner";
|
||||||
import { currentThreadAtom, resetCurrentThreadAtom } from "@/atoms/chat/current-thread.atom";
|
import { currentThreadAtom, resetCurrentThreadAtom } from "@/atoms/chat/current-thread.atom";
|
||||||
import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom";
|
import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom";
|
||||||
import { announcementsDialogAtom } from "@/atoms/layout/dialogs.atom";
|
import { announcementsDialogAtom } from "@/atoms/layout/dialogs.atom";
|
||||||
import { playgroundSidebarOpenAtom } from "@/atoms/layout/playground.atom";
|
|
||||||
import { removeChatTabAtom, syncChatTabAtom, type Tab } from "@/atoms/tabs/tabs.atom";
|
import { removeChatTabAtom, syncChatTabAtom, type Tab } from "@/atoms/tabs/tabs.atom";
|
||||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||||
import { deleteWorkspaceMutationAtom } from "@/atoms/workspaces/workspace-mutation.atoms";
|
import { deleteWorkspaceMutationAtom } from "@/atoms/workspaces/workspace-mutation.atoms";
|
||||||
|
|
@ -43,7 +42,6 @@ import { Spinner } from "@/components/ui/spinner";
|
||||||
import { useActivateChatThread } from "@/hooks/use-activate-chat-thread";
|
import { useActivateChatThread } from "@/hooks/use-activate-chat-thread";
|
||||||
import { useAnnouncements } from "@/hooks/use-announcements";
|
import { useAnnouncements } from "@/hooks/use-announcements";
|
||||||
import { useInbox } from "@/hooks/use-inbox";
|
import { useInbox } from "@/hooks/use-inbox";
|
||||||
import { useIsMobile } from "@/hooks/use-mobile";
|
|
||||||
import { useArchiveThread, useDeleteThread, useRenameThread } from "@/hooks/use-thread-mutations";
|
import { useArchiveThread, useDeleteThread, useRenameThread } from "@/hooks/use-thread-mutations";
|
||||||
import { notificationsApiService } from "@/lib/apis/notifications-api.service";
|
import { notificationsApiService } from "@/lib/apis/notifications-api.service";
|
||||||
import { workspacesApiService } from "@/lib/apis/workspaces-api.service";
|
import { workspacesApiService } from "@/lib/apis/workspaces-api.service";
|
||||||
|
|
@ -53,6 +51,7 @@ import { resetUser, trackLogout } from "@/lib/posthog/events";
|
||||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||||
import type { ChatItem, NavItem, Workspace } from "../types/layout.types";
|
import type { ChatItem, NavItem, Workspace } from "../types/layout.types";
|
||||||
import { CreateWorkspaceDialog } from "../ui/dialogs";
|
import { CreateWorkspaceDialog } from "../ui/dialogs";
|
||||||
|
import { PlaygroundSidebar } from "../ui/playground/PlaygroundSidebar";
|
||||||
import { LayoutShell } from "../ui/shell";
|
import { LayoutShell } from "../ui/shell";
|
||||||
|
|
||||||
interface LayoutDataProviderProps {
|
interface LayoutDataProviderProps {
|
||||||
|
|
@ -60,17 +59,6 @@ interface LayoutDataProviderProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Format count for display: shows numbers up to 999, then "1k+", "2k+", etc.
|
|
||||||
*/
|
|
||||||
function formatInboxCount(count: number): string {
|
|
||||||
if (count <= 999) {
|
|
||||||
return count.toString();
|
|
||||||
}
|
|
||||||
const thousands = Math.floor(count / 1000);
|
|
||||||
return `${thousands}k+`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LayoutDataProvider({ workspaceId, children }: LayoutDataProviderProps) {
|
export function LayoutDataProvider({ workspaceId, children }: LayoutDataProviderProps) {
|
||||||
const t = useTranslations("dashboard");
|
const t = useTranslations("dashboard");
|
||||||
const tCommon = useTranslations("common");
|
const tCommon = useTranslations("common");
|
||||||
|
|
@ -79,8 +67,6 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
const isMobile = useIsMobile();
|
|
||||||
const [playgroundSidebarOpen, setPlaygroundSidebarOpen] = useAtom(playgroundSidebarOpenAtom);
|
|
||||||
|
|
||||||
// Announcements
|
// Announcements
|
||||||
const { unreadCount: announcementUnreadCount } = useAnnouncements();
|
const { unreadCount: announcementUnreadCount } = useAnnouncements();
|
||||||
|
|
@ -125,12 +111,6 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
|
||||||
enabled: !!workspaceId,
|
enabled: !!workspaceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Unified slide-out panel state (only one can be open at a time)
|
|
||||||
type SlideoutPanel = "inbox" | null;
|
|
||||||
const [activeSlideoutPanel, setActiveSlideoutPanel] = useState<SlideoutPanel>(null);
|
|
||||||
|
|
||||||
const isInboxSidebarOpen = activeSlideoutPanel === "inbox";
|
|
||||||
|
|
||||||
// Search space dialog state
|
// Search space dialog state
|
||||||
const [isCreateWorkspaceDialogOpen, setIsCreateWorkspaceDialogOpen] = useState(false);
|
const [isCreateWorkspaceDialogOpen, setIsCreateWorkspaceDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
|
@ -228,17 +208,6 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
|
||||||
const [isDeletingWorkspace, setIsDeletingWorkspace] = useState(false);
|
const [isDeletingWorkspace, setIsDeletingWorkspace] = useState(false);
|
||||||
const [isLeavingWorkspace, setIsLeavingWorkspace] = useState(false);
|
const [isLeavingWorkspace, setIsLeavingWorkspace] = useState(false);
|
||||||
|
|
||||||
// Reset transient slide-out panels when switching workspaces.
|
|
||||||
// Tabs intentionally persist across spaces — opening tabs from multiple
|
|
||||||
// workspaces is a supported flow (browser-tab semantics).
|
|
||||||
const prevWorkspaceIdRef = useRef(workspaceId);
|
|
||||||
useEffect(() => {
|
|
||||||
if (prevWorkspaceIdRef.current !== workspaceId) {
|
|
||||||
prevWorkspaceIdRef.current = workspaceId;
|
|
||||||
setActiveSlideoutPanel(null);
|
|
||||||
}
|
|
||||||
}, [workspaceId]);
|
|
||||||
|
|
||||||
const workspaces: Workspace[] = useMemo(() => {
|
const workspaces: Workspace[] = useMemo(() => {
|
||||||
if (!workspacesData || !Array.isArray(workspacesData)) return [];
|
if (!workspacesData || !Array.isArray(workspacesData)) return [];
|
||||||
return workspacesData.map((space) => ({
|
return workspacesData.map((space) => ({
|
||||||
|
|
@ -318,9 +287,9 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
|
||||||
}, [threadsData, workspaceId]);
|
}, [threadsData, workspaceId]);
|
||||||
|
|
||||||
// Navigation items
|
// Navigation items
|
||||||
// Inbox, Automations, and Artifacts are rendered explicitly below "New chat"
|
// Automations and Artifacts are rendered explicitly below "New chat"
|
||||||
// in the sidebar (also surfaced in the icon rail's collapsed mode via this
|
// in the sidebar. Documents is embedded below Recents; notifications and
|
||||||
// list). Documents is embedded below Recents; announcements live in the avatar dropdown.
|
// announcements live in the avatar rail/dropdown.
|
||||||
const isAutomationsActive = pathname?.includes("/automations") === true;
|
const isAutomationsActive = pathname?.includes("/automations") === true;
|
||||||
const isArtifactsActive = pathname?.endsWith("/artifacts") === true;
|
const isArtifactsActive = pathname?.endsWith("/artifacts") === true;
|
||||||
const isPlaygroundRoute = pathname?.includes("/playground") === true;
|
const isPlaygroundRoute = pathname?.includes("/playground") === true;
|
||||||
|
|
@ -328,13 +297,6 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
|
||||||
() =>
|
() =>
|
||||||
(
|
(
|
||||||
[
|
[
|
||||||
{
|
|
||||||
title: "Inbox",
|
|
||||||
url: "#inbox",
|
|
||||||
icon: Inbox,
|
|
||||||
isActive: isInboxSidebarOpen,
|
|
||||||
badge: totalUnreadCount > 0 ? formatInboxCount(totalUnreadCount) : undefined,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: "Automations",
|
title: "Automations",
|
||||||
url: `/dashboard/${workspaceId}/automations`,
|
url: `/dashboard/${workspaceId}/automations`,
|
||||||
|
|
@ -351,22 +313,11 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
|
||||||
title: "Playground",
|
title: "Playground",
|
||||||
url: `/dashboard/${workspaceId}/playground`,
|
url: `/dashboard/${workspaceId}/playground`,
|
||||||
icon: SquareTerminal,
|
icon: SquareTerminal,
|
||||||
// Mobile has no second-level sidebar: Playground is a plain link
|
isActive: isPlaygroundRoute,
|
||||||
// there, so highlight by route. Desktop highlights the toggle state.
|
|
||||||
isActive: isMobile ? isPlaygroundRoute : playgroundSidebarOpen,
|
|
||||||
},
|
},
|
||||||
] as (NavItem | null)[]
|
] as (NavItem | null)[]
|
||||||
).filter((item): item is NavItem => item !== null),
|
).filter((item): item is NavItem => item !== null),
|
||||||
[
|
[workspaceId, isAutomationsActive, isArtifactsActive, isPlaygroundRoute]
|
||||||
isInboxSidebarOpen,
|
|
||||||
totalUnreadCount,
|
|
||||||
workspaceId,
|
|
||||||
isAutomationsActive,
|
|
||||||
isArtifactsActive,
|
|
||||||
isPlaygroundRoute,
|
|
||||||
playgroundSidebarOpen,
|
|
||||||
isMobile,
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handlers
|
// Handlers
|
||||||
|
|
@ -503,22 +454,9 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
|
||||||
|
|
||||||
const handleNavItemClick = useCallback(
|
const handleNavItemClick = useCallback(
|
||||||
(item: NavItem) => {
|
(item: NavItem) => {
|
||||||
if (item.url === "#inbox") {
|
|
||||||
setActiveSlideoutPanel((prev) => (prev === "inbox" ? null : "inbox"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Desktop: Playground is a persistent toggle, not a plain link — it just
|
|
||||||
// opens the second-level sidebar (which holds the whole API playground)
|
|
||||||
// and only closes on a second click, never navigating away from the
|
|
||||||
// current page (e.g. a new chat). Mobile has no second-level sidebar,
|
|
||||||
// so there it navigates to the playground index page instead.
|
|
||||||
if (item.url.endsWith("/playground") && !isMobile) {
|
|
||||||
setPlaygroundSidebarOpen((prev) => !prev);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
router.push(item.url);
|
router.push(item.url);
|
||||||
},
|
},
|
||||||
[router, setPlaygroundSidebarOpen, setActiveSlideoutPanel, isMobile]
|
[router]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleNewChat = useCallback(() => {
|
const handleNewChat = useCallback(() => {
|
||||||
|
|
@ -688,7 +626,6 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
|
||||||
const isPlaygroundPage = pathname?.includes("/playground") === true;
|
const isPlaygroundPage = pathname?.includes("/playground") === true;
|
||||||
const isAllChatsPage = pathname?.endsWith("/chats") === true;
|
const isAllChatsPage = pathname?.endsWith("/chats") === true;
|
||||||
const handleViewAllChats = useCallback(() => {
|
const handleViewAllChats = useCallback(() => {
|
||||||
setActiveSlideoutPanel(null);
|
|
||||||
router.push(
|
router.push(
|
||||||
isAllChatsPage ? `/dashboard/${workspaceId}/new-chat` : `/dashboard/${workspaceId}/chats`
|
isAllChatsPage ? `/dashboard/${workspaceId}/new-chat` : `/dashboard/${workspaceId}/chats`
|
||||||
);
|
);
|
||||||
|
|
@ -741,7 +678,6 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
|
||||||
setTheme={setTheme}
|
setTheme={setTheme}
|
||||||
isChatPage={isChatPage}
|
isChatPage={isChatPage}
|
||||||
isAllChatsPage={isAllChatsPage}
|
isAllChatsPage={isAllChatsPage}
|
||||||
showPlaygroundSidebar={playgroundSidebarOpen}
|
|
||||||
useWorkspacePanel={useWorkspacePanel}
|
useWorkspacePanel={useWorkspacePanel}
|
||||||
workspacePanelViewportClassName={
|
workspacePanelViewportClassName={
|
||||||
isUserSettingsPage ||
|
isUserSettingsPage ||
|
||||||
|
|
@ -754,24 +690,14 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
|
||||||
? "items-start justify-center px-6 py-8 md:px-10 md:pb-10 md:pt-16"
|
? "items-start justify-center px-6 py-8 md:px-10 md:pb-10 md:pt-16"
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
workspacePanelContentClassName={
|
workspacePanelContentClassName={useWorkspacePanel ? "max-w-5xl select-none" : undefined}
|
||||||
isAutomationsPage || isPlaygroundPage
|
|
||||||
? "max-w-none select-none"
|
|
||||||
: isAllChatsPage
|
|
||||||
? "max-w-5xl"
|
|
||||||
: isUserSettingsPage || isWorkspaceSettingsPage || isTeamPage || isArtifactsPage
|
|
||||||
? "max-w-5xl"
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
isLoadingChats={isLoadingThreads}
|
isLoadingChats={isLoadingThreads}
|
||||||
activeSlideoutPanel={activeSlideoutPanel}
|
notifications={{
|
||||||
onSlideoutPanelChange={setActiveSlideoutPanel}
|
|
||||||
inbox={{
|
|
||||||
isOpen: isInboxSidebarOpen,
|
|
||||||
totalUnreadCount,
|
totalUnreadCount,
|
||||||
comments: {
|
comments: {
|
||||||
items: commentsInbox.inboxItems,
|
items: commentsInbox.inboxItems,
|
||||||
unreadCount: commentsInbox.unreadCount,
|
unreadCount: commentsInbox.unreadCount,
|
||||||
|
totalCount: commentsInbox.totalCount,
|
||||||
loading: commentsInbox.loading,
|
loading: commentsInbox.loading,
|
||||||
loadingMore: commentsInbox.loadingMore,
|
loadingMore: commentsInbox.loadingMore,
|
||||||
hasMore: commentsInbox.hasMore,
|
hasMore: commentsInbox.hasMore,
|
||||||
|
|
@ -782,6 +708,7 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
|
||||||
status: {
|
status: {
|
||||||
items: statusInbox.inboxItems,
|
items: statusInbox.inboxItems,
|
||||||
unreadCount: statusInbox.unreadCount,
|
unreadCount: statusInbox.unreadCount,
|
||||||
|
totalCount: statusInbox.totalCount,
|
||||||
loading: statusInbox.loading,
|
loading: statusInbox.loading,
|
||||||
loadingMore: statusInbox.loadingMore,
|
loadingMore: statusInbox.loadingMore,
|
||||||
hasMore: statusInbox.hasMore,
|
hasMore: statusInbox.hasMore,
|
||||||
|
|
@ -792,6 +719,7 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
|
||||||
}}
|
}}
|
||||||
onTabSwitch={handleTabSwitch}
|
onTabSwitch={handleTabSwitch}
|
||||||
onTabPrefetch={handleTabPrefetch}
|
onTabPrefetch={handleTabPrefetch}
|
||||||
|
playgroundSidebar={<PlaygroundSidebar workspaceId={workspaceId} />}
|
||||||
>
|
>
|
||||||
<Fragment key={chatResetKey}>{children}</Fragment>
|
<Fragment key={chatResetKey}>{children}</Fragment>
|
||||||
</LayoutShell>
|
</LayoutShell>
|
||||||
|
|
|
||||||
278
surfsense_web/components/layout/ui/RoutedSectionShell.tsx
Normal file
278
surfsense_web/components/layout/ui/RoutedSectionShell.tsx
Normal file
|
|
@ -0,0 +1,278 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ChevronRight } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import type React from "react";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerHandle,
|
||||||
|
DrawerTitle,
|
||||||
|
DrawerTrigger,
|
||||||
|
} from "@/components/ui/drawer";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export interface RoutedSectionItem {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
href: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoutedSectionGroup {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
items: RoutedSectionItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RoutedSectionShellProps {
|
||||||
|
title: string;
|
||||||
|
items: RoutedSectionItem[];
|
||||||
|
activeValue: string;
|
||||||
|
selectedLabel: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
groups?: RoutedSectionGroup[];
|
||||||
|
mobileNav?: "scroll" | "drawer";
|
||||||
|
contentClassName?: string;
|
||||||
|
desktopNav?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findActiveGroupValue(groups: RoutedSectionGroup[], activeValue: string): string | null {
|
||||||
|
return (
|
||||||
|
groups.find((group) => group.items.some((item) => item.value === activeValue))?.value ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionNavLink({
|
||||||
|
item,
|
||||||
|
activeValue,
|
||||||
|
onNavigate,
|
||||||
|
}: {
|
||||||
|
item: RoutedSectionItem;
|
||||||
|
activeValue: string;
|
||||||
|
onNavigate?: () => void;
|
||||||
|
}) {
|
||||||
|
const isActive = activeValue === item.value;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={item.href}
|
||||||
|
replace
|
||||||
|
scroll={false}
|
||||||
|
prefetch
|
||||||
|
onClick={onNavigate}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-auto items-center justify-start gap-3 rounded-md px-3 py-2.5 text-left text-sm font-medium transition-colors duration-150 focus:outline-none focus-visible:outline-none",
|
||||||
|
isActive
|
||||||
|
? "bg-accent text-accent-foreground"
|
||||||
|
: "bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
<span className="min-w-0 truncate">{item.label}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionNavGroup({
|
||||||
|
group,
|
||||||
|
activeValue,
|
||||||
|
isExpanded,
|
||||||
|
onToggle,
|
||||||
|
onNavigate,
|
||||||
|
}: {
|
||||||
|
group: RoutedSectionGroup;
|
||||||
|
activeValue: string;
|
||||||
|
isExpanded: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
onNavigate?: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-expanded={isExpanded}
|
||||||
|
onClick={onToggle}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-auto items-center justify-start gap-3 rounded-md px-3 py-2.5 text-left text-sm font-medium transition-colors duration-150 focus:outline-none focus-visible:outline-none",
|
||||||
|
isExpanded
|
||||||
|
? "text-accent-foreground"
|
||||||
|
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{group.icon}
|
||||||
|
<span className="min-w-0 truncate">{group.label}</span>
|
||||||
|
<ChevronRight
|
||||||
|
className={cn("ml-auto h-4 w-4 shrink-0 transition-transform", isExpanded && "rotate-90")}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"grid overflow-hidden transition-[grid-template-rows,opacity] duration-200 ease-out",
|
||||||
|
isExpanded ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"
|
||||||
|
)}
|
||||||
|
aria-hidden={!isExpanded}
|
||||||
|
>
|
||||||
|
<div className="min-h-0 overflow-hidden">
|
||||||
|
<div className="flex flex-col gap-0.5 pl-10">
|
||||||
|
{group.items.map((item) => (
|
||||||
|
<SectionNavLink
|
||||||
|
key={item.value}
|
||||||
|
item={item}
|
||||||
|
activeValue={activeValue}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoutedSectionShell({
|
||||||
|
title,
|
||||||
|
items,
|
||||||
|
activeValue,
|
||||||
|
selectedLabel,
|
||||||
|
children,
|
||||||
|
groups = [],
|
||||||
|
mobileNav = "scroll",
|
||||||
|
contentClassName,
|
||||||
|
desktopNav = true,
|
||||||
|
}: RoutedSectionShellProps) {
|
||||||
|
const [tabScrollPos, setTabScrollPos] = useState<"start" | "middle" | "end">("start");
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const [expandedGroup, setExpandedGroup] = useState<string | null>(() =>
|
||||||
|
findActiveGroupValue(groups, activeValue)
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const activeGroup = findActiveGroupValue(groups, activeValue);
|
||||||
|
if (activeGroup) {
|
||||||
|
setExpandedGroup(activeGroup);
|
||||||
|
}
|
||||||
|
}, [activeValue, groups]);
|
||||||
|
|
||||||
|
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 renderNav = (onNavigate?: () => void) => (
|
||||||
|
<>
|
||||||
|
{items.map((item) => (
|
||||||
|
<SectionNavLink
|
||||||
|
key={item.value}
|
||||||
|
item={item}
|
||||||
|
activeValue={activeValue}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{groups.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Separator className="my-3 bg-border" />
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
{groups.map((group) => (
|
||||||
|
<SectionNavGroup
|
||||||
|
key={group.value}
|
||||||
|
group={group}
|
||||||
|
activeValue={activeValue}
|
||||||
|
isExpanded={expandedGroup === group.value}
|
||||||
|
onToggle={() =>
|
||||||
|
setExpandedGroup((current) => (current === group.value ? null : group.value))
|
||||||
|
}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={cn(
|
||||||
|
"flex h-full min-h-[min(680px,calc(100vh-5rem))] w-full select-none flex-col",
|
||||||
|
desktopNav ? "gap-6 md:flex-row" : "gap-4 md:block"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className={cn("md:w-[220px] md:shrink-0", !desktopNav && "md:hidden")}>
|
||||||
|
<h1 className="mb-4 px-1 text-xl font-semibold tracking-tight text-foreground md:text-2xl">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
{desktopNav ? <nav className="hidden flex-col gap-0.5 md:flex">{renderNav()}</nav> : null}
|
||||||
|
{mobileNav === "drawer" ? (
|
||||||
|
<Drawer open={drawerOpen} onOpenChange={setDrawerOpen} shouldScaleBackground={false}>
|
||||||
|
<DrawerTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="flex h-10 w-full justify-between bg-transparent px-3 hover:bg-accent md:hidden"
|
||||||
|
>
|
||||||
|
<span className="truncate">{selectedLabel}</span>
|
||||||
|
<ChevronRight className="h-4 w-4 rotate-90 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</DrawerTrigger>
|
||||||
|
<DrawerContent className="h-[88vh] overflow-hidden rounded-t-2xl border bg-popover text-popover-foreground">
|
||||||
|
<DrawerHandle className="mt-3 h-1.5 w-10" />
|
||||||
|
<DrawerTitle className="sr-only">{title} navigation</DrawerTitle>
|
||||||
|
<nav className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto p-4">
|
||||||
|
{renderNav(() => setDrawerOpen(false))}
|
||||||
|
</nav>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="overflow-x-auto border-b border-border pb-3 md:hidden"
|
||||||
|
onScroll={handleTabScroll}
|
||||||
|
style={{
|
||||||
|
maskImage: `linear-gradient(to right, ${tabScrollPos === "start" ? "black" : "transparent"}, black 24px, black calc(100% - 24px), ${tabScrollPos === "end" ? "black" : "transparent"})`,
|
||||||
|
WebkitMaskImage: `linear-gradient(to right, ${tabScrollPos === "start" ? "black" : "transparent"}, black 24px, black calc(100% - 24px), ${tabScrollPos === "end" ? "black" : "transparent"})`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{items.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.value}
|
||||||
|
href={item.href}
|
||||||
|
replace
|
||||||
|
scroll={false}
|
||||||
|
prefetch
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-auto shrink-0 items-center gap-2 rounded-md px-3 py-1.5 text-xs font-medium transition-colors duration-150 focus:outline-none focus-visible:outline-none",
|
||||||
|
activeValue === item.value
|
||||||
|
? "bg-accent text-accent-foreground"
|
||||||
|
: "bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{desktopNav ? (
|
||||||
|
<div className="hidden md:block">
|
||||||
|
<h2 className="text-lg font-semibold">{selectedLabel}</h2>
|
||||||
|
<Separator className="mt-4 bg-border" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className={cn("min-w-0", desktopNav ? "pt-4" : "pt-4 md:pt-0", contentClassName)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,10 @@ import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { NavItem, User, Workspace } from "../../types/layout.types";
|
import type { NavItem, User, Workspace } from "../../types/layout.types";
|
||||||
|
import {
|
||||||
|
NotificationsDropdown,
|
||||||
|
type NotificationsDropdownData,
|
||||||
|
} from "../sidebar/NotificationsDropdown";
|
||||||
import { SidebarUserProfile } from "../sidebar/SidebarUserProfile";
|
import { SidebarUserProfile } from "../sidebar/SidebarUserProfile";
|
||||||
import { WorkspaceAvatar } from "./WorkspaceAvatar";
|
import { WorkspaceAvatar } from "./WorkspaceAvatar";
|
||||||
|
|
||||||
|
|
@ -24,6 +28,7 @@ interface IconRailProps {
|
||||||
onUserSettings?: () => void;
|
onUserSettings?: () => void;
|
||||||
onAnnouncements?: () => void;
|
onAnnouncements?: () => void;
|
||||||
announcementUnreadCount?: number;
|
announcementUnreadCount?: number;
|
||||||
|
notifications?: NotificationsDropdownData;
|
||||||
onLogout?: () => void;
|
onLogout?: () => void;
|
||||||
theme?: string;
|
theme?: string;
|
||||||
setTheme?: (theme: "light" | "dark" | "system") => void;
|
setTheme?: (theme: "light" | "dark" | "system") => void;
|
||||||
|
|
@ -45,6 +50,7 @@ export function IconRail({
|
||||||
onUserSettings,
|
onUserSettings,
|
||||||
onAnnouncements,
|
onAnnouncements,
|
||||||
announcementUnreadCount = 0,
|
announcementUnreadCount = 0,
|
||||||
|
notifications,
|
||||||
onLogout,
|
onLogout,
|
||||||
theme,
|
theme,
|
||||||
setTheme,
|
setTheme,
|
||||||
|
|
@ -145,6 +151,9 @@ export function IconRail({
|
||||||
isCollapsed
|
isCollapsed
|
||||||
theme={theme}
|
theme={theme}
|
||||||
setTheme={setTheme}
|
setTheme={setTheme}
|
||||||
|
topContent={
|
||||||
|
notifications ? <NotificationsDropdown notifications={notifications} /> : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,15 @@
|
||||||
export { CreateWorkspaceDialog } from "./dialogs";
|
export { CreateWorkspaceDialog } from "./dialogs";
|
||||||
export { Header } from "./header";
|
export { Header } from "./header";
|
||||||
export { IconRail, NavIcon, WorkspaceAvatar } from "./icon-rail";
|
export { IconRail, NavIcon, WorkspaceAvatar } from "./icon-rail";
|
||||||
|
export {
|
||||||
|
getPlaygroundActiveValue,
|
||||||
|
getPlaygroundNavGroups,
|
||||||
|
getPlaygroundNavItems,
|
||||||
|
getPlaygroundSelectedLabel,
|
||||||
|
PlaygroundSidebar,
|
||||||
|
} from "./playground/PlaygroundSidebar";
|
||||||
|
export type { RoutedSectionGroup, RoutedSectionItem } from "./RoutedSectionShell";
|
||||||
|
export { RoutedSectionShell } from "./RoutedSectionShell";
|
||||||
export { LayoutShell } from "./shell";
|
export { LayoutShell } from "./shell";
|
||||||
export {
|
export {
|
||||||
ChatListItem,
|
ChatListItem,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,221 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ChevronRight, History, KeyRound, LayoutGrid } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { PLAYGROUND_PLATFORMS } from "@/lib/playground/catalog";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { RoutedSectionGroup, RoutedSectionItem } from "../RoutedSectionShell";
|
||||||
|
|
||||||
|
interface PlaygroundSidebarProps {
|
||||||
|
workspaceId: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPlaygroundNavItems(base: string): RoutedSectionItem[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
value: "overview",
|
||||||
|
label: "Overview",
|
||||||
|
href: base,
|
||||||
|
icon: <LayoutGrid className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "runs",
|
||||||
|
label: "API Runs",
|
||||||
|
href: `${base}/runs`,
|
||||||
|
icon: <History className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "api-keys",
|
||||||
|
label: "API Keys",
|
||||||
|
href: `${base}/api-keys`,
|
||||||
|
icon: <KeyRound className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPlaygroundNavGroups(base: string): RoutedSectionGroup[] {
|
||||||
|
return PLAYGROUND_PLATFORMS.map((platform) => {
|
||||||
|
const Icon = platform.icon;
|
||||||
|
return {
|
||||||
|
value: platform.id,
|
||||||
|
label: platform.label,
|
||||||
|
icon: <Icon className="h-4 w-4 shrink-0" />,
|
||||||
|
items: platform.verbs.map((verb) => ({
|
||||||
|
value: `${platform.id}/${verb.verb}`,
|
||||||
|
label: verb.label,
|
||||||
|
href: `${base}/${platform.id}/${verb.verb}`,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPlaygroundActiveValue(
|
||||||
|
pathname: string | null,
|
||||||
|
base: string,
|
||||||
|
items: RoutedSectionItem[]
|
||||||
|
): string {
|
||||||
|
if (!pathname?.startsWith(base)) return "";
|
||||||
|
|
||||||
|
const rest = pathname.slice(base.length).replace(/^\/+/, "");
|
||||||
|
if (!rest) return "overview";
|
||||||
|
|
||||||
|
const [first, second] = rest.split("/");
|
||||||
|
if (second) return `${first}/${second}`;
|
||||||
|
if (items.some((item) => item.value === first)) return first;
|
||||||
|
|
||||||
|
return "overview";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPlaygroundSelectedLabel(
|
||||||
|
activeValue: string,
|
||||||
|
items: RoutedSectionItem[],
|
||||||
|
groups: RoutedSectionGroup[]
|
||||||
|
): string {
|
||||||
|
const topLevelItem = items.find((item) => item.value === activeValue);
|
||||||
|
if (topLevelItem) return topLevelItem.label;
|
||||||
|
|
||||||
|
const group = groups.find((item) => item.items.some((child) => child.value === activeValue));
|
||||||
|
const child = group?.items.find((item) => item.value === activeValue);
|
||||||
|
|
||||||
|
if (group && child) return `${group.label}: ${child.label}`;
|
||||||
|
return "API Playground";
|
||||||
|
}
|
||||||
|
|
||||||
|
function findActiveGroupValue(groups: RoutedSectionGroup[], activeValue: string): string | null {
|
||||||
|
return (
|
||||||
|
groups.find((group) => group.items.some((item) => item.value === activeValue))?.value ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlaygroundNavLink({
|
||||||
|
item,
|
||||||
|
activeValue,
|
||||||
|
}: {
|
||||||
|
item: RoutedSectionItem;
|
||||||
|
activeValue: string;
|
||||||
|
}) {
|
||||||
|
const isActive = activeValue === item.value;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={item.href}
|
||||||
|
replace
|
||||||
|
scroll={false}
|
||||||
|
prefetch
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-auto items-center justify-start gap-3 rounded-md px-3 py-2.5 text-left text-sm font-medium transition-colors duration-150 focus:outline-none focus-visible:outline-none",
|
||||||
|
isActive
|
||||||
|
? "bg-accent text-accent-foreground"
|
||||||
|
: "bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
<span className="min-w-0 truncate">{item.label}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlaygroundNavGroup({
|
||||||
|
group,
|
||||||
|
activeValue,
|
||||||
|
isExpanded,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
group: RoutedSectionGroup;
|
||||||
|
activeValue: string;
|
||||||
|
isExpanded: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-expanded={isExpanded}
|
||||||
|
onClick={onToggle}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-auto items-center justify-start gap-3 rounded-md px-3 py-2.5 text-left text-sm font-medium transition-colors duration-150 focus:outline-none focus-visible:outline-none",
|
||||||
|
isExpanded
|
||||||
|
? "text-accent-foreground"
|
||||||
|
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{group.icon}
|
||||||
|
<span className="min-w-0 truncate">{group.label}</span>
|
||||||
|
<ChevronRight
|
||||||
|
className={cn("ml-auto h-4 w-4 shrink-0 transition-transform", isExpanded && "rotate-90")}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"grid overflow-hidden transition-[grid-template-rows,opacity] duration-200 ease-out",
|
||||||
|
isExpanded ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"
|
||||||
|
)}
|
||||||
|
aria-hidden={!isExpanded}
|
||||||
|
>
|
||||||
|
<div className="min-h-0 overflow-hidden">
|
||||||
|
<div className="flex flex-col gap-0.5 pl-10">
|
||||||
|
{group.items.map((item) => (
|
||||||
|
<PlaygroundNavLink key={item.value} item={item} activeValue={activeValue} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlaygroundSidebar({ workspaceId, className }: PlaygroundSidebarProps) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const base = `/dashboard/${workspaceId}/playground`;
|
||||||
|
const items = useMemo(() => getPlaygroundNavItems(base), [base]);
|
||||||
|
const groups = useMemo(() => getPlaygroundNavGroups(base), [base]);
|
||||||
|
const activeValue = getPlaygroundActiveValue(pathname, base, items);
|
||||||
|
const [expandedGroup, setExpandedGroup] = useState<string | null>(() =>
|
||||||
|
findActiveGroupValue(groups, activeValue)
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const activeGroup = findActiveGroupValue(groups, activeValue);
|
||||||
|
if (activeGroup) {
|
||||||
|
setExpandedGroup(activeGroup);
|
||||||
|
}
|
||||||
|
}, [activeValue, groups]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-[240px] shrink-0 flex-col overflow-hidden border-r bg-panel text-sidebar-foreground select-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex h-12 shrink-0 items-center px-3">
|
||||||
|
<h1 className="truncate text-lg font-semibold tracking-tight text-foreground">
|
||||||
|
API Playground
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<nav className="min-h-0 flex-1 overflow-y-auto px-2 pb-4 pt-1.5">
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
{items.map((item) => (
|
||||||
|
<PlaygroundNavLink key={item.value} item={item} activeValue={activeValue} />
|
||||||
|
))}
|
||||||
|
<Separator className="my-3 bg-border" />
|
||||||
|
{groups.map((group) => (
|
||||||
|
<PlaygroundNavGroup
|
||||||
|
key={group.value}
|
||||||
|
group={group}
|
||||||
|
activeValue={activeValue}
|
||||||
|
isExpanded={expandedGroup === group.value}
|
||||||
|
onToggle={() =>
|
||||||
|
setExpandedGroup((current) => (current === group.value ? null : group.value))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,14 +1,12 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { activeTabAtom, type Tab } from "@/atoms/tabs/tabs.atom";
|
import { activeTabAtom, type Tab } from "@/atoms/tabs/tabs.atom";
|
||||||
import { Logo } from "@/components/Logo";
|
import { Logo } from "@/components/Logo";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import type { InboxItem } from "@/hooks/use-inbox";
|
|
||||||
import { useIsMobile } from "@/hooks/use-mobile";
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import { useElectronAPI } from "@/hooks/use-platform";
|
import { useElectronAPI } from "@/hooks/use-platform";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
@ -26,15 +24,8 @@ import {
|
||||||
RightPanelExpandButton,
|
RightPanelExpandButton,
|
||||||
RightPanelToggleButton,
|
RightPanelToggleButton,
|
||||||
} from "../right-panel/RightPanel";
|
} from "../right-panel/RightPanel";
|
||||||
import {
|
import { MobileSidebar, MobileSidebarTrigger, Sidebar, SidebarCollapseButton } from "../sidebar";
|
||||||
InboxSidebarContent,
|
import type { NotificationsDropdownData } from "../sidebar/NotificationsDropdown";
|
||||||
MobileSidebar,
|
|
||||||
MobileSidebarTrigger,
|
|
||||||
PlaygroundSidebar,
|
|
||||||
Sidebar,
|
|
||||||
SidebarCollapseButton,
|
|
||||||
} from "../sidebar";
|
|
||||||
import { SidebarSlideOutPanel } from "../sidebar/SidebarSlideOutPanel";
|
|
||||||
import { TabBar } from "../tabs/TabBar";
|
import { TabBar } from "../tabs/TabBar";
|
||||||
import { WorkspacePanel } from "./WorkspacePanel";
|
import { WorkspacePanel } from "./WorkspacePanel";
|
||||||
|
|
||||||
|
|
@ -80,28 +71,6 @@ function MacDesktopTitleBar({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-tab data source
|
|
||||||
interface TabDataSource {
|
|
||||||
items: InboxItem[];
|
|
||||||
unreadCount: number;
|
|
||||||
loading: boolean;
|
|
||||||
loadingMore: boolean;
|
|
||||||
hasMore: boolean;
|
|
||||||
loadMore: () => void;
|
|
||||||
markAsRead: (id: number) => Promise<boolean>;
|
|
||||||
markAllAsRead: () => Promise<boolean>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ActiveSlideoutPanel = "inbox" | null;
|
|
||||||
|
|
||||||
// Inbox-related props — per-tab data sources with independent loading/pagination
|
|
||||||
interface InboxProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
totalUnreadCount: number;
|
|
||||||
comments: TabDataSource;
|
|
||||||
status: TabDataSource;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LayoutShellProps {
|
interface LayoutShellProps {
|
||||||
workspaces: Workspace[];
|
workspaces: Workspace[];
|
||||||
activeWorkspaceId: number | null;
|
activeWorkspaceId: number | null;
|
||||||
|
|
@ -134,20 +103,16 @@ interface LayoutShellProps {
|
||||||
defaultCollapsed?: boolean;
|
defaultCollapsed?: boolean;
|
||||||
isChatPage?: boolean;
|
isChatPage?: boolean;
|
||||||
isAllChatsPage?: boolean;
|
isAllChatsPage?: boolean;
|
||||||
showPlaygroundSidebar?: boolean;
|
|
||||||
useWorkspacePanel?: boolean;
|
useWorkspacePanel?: boolean;
|
||||||
workspacePanelViewportClassName?: string;
|
workspacePanelViewportClassName?: string;
|
||||||
workspacePanelContentClassName?: string;
|
workspacePanelContentClassName?: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
// Unified slide-out panel state
|
notifications?: NotificationsDropdownData;
|
||||||
activeSlideoutPanel?: ActiveSlideoutPanel;
|
|
||||||
onSlideoutPanelChange?: (panel: ActiveSlideoutPanel) => void;
|
|
||||||
// Inbox props
|
|
||||||
inbox?: InboxProps;
|
|
||||||
isLoadingChats?: boolean;
|
isLoadingChats?: boolean;
|
||||||
onTabSwitch?: (tab: Tab) => void;
|
onTabSwitch?: (tab: Tab) => void;
|
||||||
onTabPrefetch?: (tab: Tab) => void;
|
onTabPrefetch?: (tab: Tab) => void;
|
||||||
|
playgroundSidebar?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
function MainContentPanel({
|
function MainContentPanel({
|
||||||
|
|
@ -239,23 +204,22 @@ export function LayoutShell({
|
||||||
defaultCollapsed = false,
|
defaultCollapsed = false,
|
||||||
isChatPage = false,
|
isChatPage = false,
|
||||||
isAllChatsPage = false,
|
isAllChatsPage = false,
|
||||||
showPlaygroundSidebar = false,
|
|
||||||
useWorkspacePanel = false,
|
useWorkspacePanel = false,
|
||||||
workspacePanelViewportClassName,
|
workspacePanelViewportClassName,
|
||||||
workspacePanelContentClassName,
|
workspacePanelContentClassName,
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
activeSlideoutPanel = null,
|
notifications,
|
||||||
onSlideoutPanelChange,
|
|
||||||
inbox,
|
|
||||||
isLoadingChats = false,
|
isLoadingChats = false,
|
||||||
onTabSwitch,
|
onTabSwitch,
|
||||||
onTabPrefetch,
|
onTabPrefetch,
|
||||||
|
playgroundSidebar,
|
||||||
}: LayoutShellProps) {
|
}: LayoutShellProps) {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const electronAPI = useElectronAPI();
|
const electronAPI = useElectronAPI();
|
||||||
const isMacDesktop = electronAPI?.versions.platform === "darwin";
|
const isMacDesktop = electronAPI?.versions.platform === "darwin";
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
|
const [isPlaygroundSidebarCollapsed, setIsPlaygroundSidebarCollapsed] = useState(false);
|
||||||
const { isCollapsed, setIsCollapsed, toggleCollapsed } = useSidebarState(defaultCollapsed);
|
const { isCollapsed, setIsCollapsed, toggleCollapsed } = useSidebarState(defaultCollapsed);
|
||||||
const {
|
const {
|
||||||
sidebarWidth,
|
sidebarWidth,
|
||||||
|
|
@ -268,17 +232,9 @@ export function LayoutShell({
|
||||||
() => ({ isCollapsed, setIsCollapsed, toggleCollapsed }),
|
() => ({ isCollapsed, setIsCollapsed, toggleCollapsed }),
|
||||||
[isCollapsed, setIsCollapsed, toggleCollapsed]
|
[isCollapsed, setIsCollapsed, toggleCollapsed]
|
||||||
);
|
);
|
||||||
|
const handlePlaygroundSidebarToggle = () => {
|
||||||
const closeSlideout = useCallback(
|
setIsPlaygroundSidebarCollapsed((collapsed) => !collapsed);
|
||||||
(open: boolean) => {
|
};
|
||||||
if (!open) onSlideoutPanelChange?.(null);
|
|
||||||
},
|
|
||||||
[onSlideoutPanelChange]
|
|
||||||
);
|
|
||||||
|
|
||||||
const anySlideOutOpen = activeSlideoutPanel !== null;
|
|
||||||
|
|
||||||
const panelAriaLabel = activeSlideoutPanel === "inbox" ? "Inbox" : "Panel";
|
|
||||||
|
|
||||||
// Mobile layout
|
// Mobile layout
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
|
|
@ -316,6 +272,7 @@ export function LayoutShell({
|
||||||
onUserSettings={onUserSettings}
|
onUserSettings={onUserSettings}
|
||||||
onAnnouncements={onAnnouncements}
|
onAnnouncements={onAnnouncements}
|
||||||
announcementUnreadCount={announcementUnreadCount}
|
announcementUnreadCount={announcementUnreadCount}
|
||||||
|
notifications={notifications}
|
||||||
onLogout={onLogout}
|
onLogout={onLogout}
|
||||||
pageUsage={pageUsage}
|
pageUsage={pageUsage}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
|
|
@ -335,34 +292,6 @@ export function LayoutShell({
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Mobile unified slide-out panel */}
|
|
||||||
<SidebarSlideOutPanel
|
|
||||||
open={anySlideOutOpen}
|
|
||||||
onOpenChange={closeSlideout}
|
|
||||||
ariaLabel={panelAriaLabel}
|
|
||||||
>
|
|
||||||
<AnimatePresence mode="popLayout" initial={false}>
|
|
||||||
{activeSlideoutPanel === "inbox" && inbox && (
|
|
||||||
<motion.div
|
|
||||||
key="inbox"
|
|
||||||
className="h-full flex flex-col"
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
transition={{ duration: 0.15 }}
|
|
||||||
>
|
|
||||||
<InboxSidebarContent
|
|
||||||
onOpenChange={(open) => closeSlideout(open)}
|
|
||||||
comments={inbox.comments}
|
|
||||||
status={inbox.status}
|
|
||||||
totalUnreadCount={inbox.totalUnreadCount}
|
|
||||||
onCloseMobileSidebar={() => setMobileMenuOpen(false)}
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</SidebarSlideOutPanel>
|
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
|
|
@ -400,36 +329,18 @@ export function LayoutShell({
|
||||||
onUserSettings={onUserSettings}
|
onUserSettings={onUserSettings}
|
||||||
onAnnouncements={onAnnouncements}
|
onAnnouncements={onAnnouncements}
|
||||||
announcementUnreadCount={announcementUnreadCount}
|
announcementUnreadCount={announcementUnreadCount}
|
||||||
|
notifications={notifications}
|
||||||
onLogout={onLogout}
|
onLogout={onLogout}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
setTheme={setTheme}
|
setTheme={setTheme}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Playground second-level sidebar — contextual, desktop only. Sits
|
|
||||||
between the icon rail and the main sidebar. On Mac it becomes the
|
|
||||||
leftmost panel, so it takes the rounded-corner/left-border treatment. */}
|
|
||||||
{showPlaygroundSidebar && activeWorkspaceId != null && (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"relative hidden md:flex shrink-0 z-20 -mr-2 bg-panel",
|
|
||||||
isMacDesktop ? "rounded-tl-xl border-t border-r border-l" : "border-r"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<PlaygroundSidebar workspaceId={activeWorkspaceId} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Sidebar + slide-out panels share one container; overflow visible so panels can overlay main content. Negative right margin closes the flex gap so the sidebar sits flush against the main panel, separated only by a border. */}
|
{/* Sidebar + slide-out panels share one container; overflow visible so panels can overlay main content. Negative right margin closes the flex gap so the sidebar sits flush against the main panel, separated only by a border. */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative hidden md:flex shrink-0 z-20 -mr-2 bg-panel",
|
"relative hidden md:flex shrink-0 z-20 -mr-2 bg-panel",
|
||||||
isMacDesktop
|
isMacDesktop ? "rounded-tl-xl border-l border-t border-r" : "border-r"
|
||||||
? cn(
|
|
||||||
"border-t border-r",
|
|
||||||
!showPlaygroundSidebar && "rounded-tl-xl border-l"
|
|
||||||
)
|
|
||||||
: "border-r"
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Sidebar
|
<Sidebar
|
||||||
|
|
@ -438,6 +349,12 @@ export function LayoutShell({
|
||||||
onToggleCollapse={toggleCollapsed}
|
onToggleCollapse={toggleCollapsed}
|
||||||
navItems={navItems}
|
navItems={navItems}
|
||||||
onNavItemClick={onNavItemClick}
|
onNavItemClick={onNavItemClick}
|
||||||
|
onPlaygroundItemClick={
|
||||||
|
playgroundSidebar ? handlePlaygroundSidebarToggle : undefined
|
||||||
|
}
|
||||||
|
isPlaygroundSidebarOpen={
|
||||||
|
playgroundSidebar ? !isPlaygroundSidebarCollapsed : undefined
|
||||||
|
}
|
||||||
chats={chats}
|
chats={chats}
|
||||||
activeChatId={activeChatId}
|
activeChatId={activeChatId}
|
||||||
onNewChat={onNewChat}
|
onNewChat={onNewChat}
|
||||||
|
|
@ -465,10 +382,7 @@ export function LayoutShell({
|
||||||
<Logo disableLink priority className="h-7 w-7 rounded-md" />
|
<Logo disableLink priority className="h-7 w-7 rounded-md" />
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
className={cn(
|
className={cn("flex shrink-0", isMacDesktop && "rounded-tl-xl")}
|
||||||
"flex shrink-0",
|
|
||||||
isMacDesktop && !showPlaygroundSidebar && "rounded-tl-xl"
|
|
||||||
)}
|
|
||||||
isLoadingChats={isLoadingChats}
|
isLoadingChats={isLoadingChats}
|
||||||
sidebarWidth={sidebarWidth}
|
sidebarWidth={sidebarWidth}
|
||||||
isResizing={isResizing}
|
isResizing={isResizing}
|
||||||
|
|
@ -491,35 +405,23 @@ export function LayoutShell({
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Unified slide-out panel — shell stays open, content cross-fades */}
|
|
||||||
<SidebarSlideOutPanel
|
|
||||||
open={anySlideOutOpen}
|
|
||||||
onOpenChange={closeSlideout}
|
|
||||||
ariaLabel={panelAriaLabel}
|
|
||||||
>
|
|
||||||
<AnimatePresence mode="popLayout" initial={false}>
|
|
||||||
{activeSlideoutPanel === "inbox" && inbox && (
|
|
||||||
<motion.div
|
|
||||||
key="inbox"
|
|
||||||
className="h-full flex flex-col"
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
transition={{ duration: 0.15 }}
|
|
||||||
>
|
|
||||||
<InboxSidebarContent
|
|
||||||
onOpenChange={(open) => closeSlideout(open)}
|
|
||||||
comments={inbox.comments}
|
|
||||||
status={inbox.status}
|
|
||||||
totalUnreadCount={inbox.totalUnreadCount}
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</SidebarSlideOutPanel>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{playgroundSidebar ? (
|
||||||
|
<div
|
||||||
|
aria-hidden={isPlaygroundSidebarCollapsed}
|
||||||
|
className={cn(
|
||||||
|
"hidden md:flex shrink-0 overflow-hidden -mr-2 bg-panel transition-[width,opacity] duration-200 ease-out",
|
||||||
|
isPlaygroundSidebarCollapsed
|
||||||
|
? "w-0 opacity-0 pointer-events-none"
|
||||||
|
: "w-[240px] opacity-100",
|
||||||
|
isMacDesktop && !isPlaygroundSidebarCollapsed && "border-t"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="w-[240px] shrink-0">{playgroundSidebar}</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<DesktopWorkspaceRegion>
|
<DesktopWorkspaceRegion>
|
||||||
{useWorkspacePanel ? (
|
{useWorkspacePanel ? (
|
||||||
<WorkspacePanel
|
<WorkspacePanel
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ export function ChatListItem({
|
||||||
const dropdownOpen = controlledOpen ?? internalOpen;
|
const dropdownOpen = controlledOpen ?? internalOpen;
|
||||||
const setDropdownOpen = onDropdownOpenChange ?? setInternalOpen;
|
const setDropdownOpen = onDropdownOpenChange ?? setInternalOpen;
|
||||||
const animatedName = useTypewriter(name);
|
const animatedName = useTypewriter(name);
|
||||||
|
const isHighlighted = isActive || dropdownOpen;
|
||||||
|
|
||||||
const { handlers: longPressHandlers, wasLongPress } = useLongPress(
|
const { handlers: longPressHandlers, wasLongPress } = useLongPress(
|
||||||
useCallback(() => setDropdownOpen(true), [setDropdownOpen])
|
useCallback(() => setDropdownOpen(true), [setDropdownOpen])
|
||||||
|
|
@ -67,9 +68,9 @@ export function ChatListItem({
|
||||||
onFocus={onPrefetch}
|
onFocus={onPrefetch}
|
||||||
{...(isMobile ? longPressHandlers : {})}
|
{...(isMobile ? longPressHandlers : {})}
|
||||||
className={sidebarListItemClassName({
|
className={sidebarListItemClassName({
|
||||||
active: isActive,
|
active: isHighlighted,
|
||||||
className:
|
className:
|
||||||
"justify-start gap-2 overflow-hidden px-2 py-1.5 font-normal focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
"justify-start gap-2 overflow-hidden px-2 py-1.5 font-normal group-hover/item:bg-accent group-hover/item:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<span className="min-w-0 flex-1 truncate">{animatedName}</span>
|
<span className="min-w-0 flex-1 truncate">{animatedName}</span>
|
||||||
|
|
@ -79,7 +80,7 @@ export function ChatListItem({
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"pointer-events-none absolute right-0 top-0 bottom-0 flex items-center pr-1 pl-6 rounded-r-md",
|
"pointer-events-none absolute right-0 top-0 bottom-0 flex items-center pr-1 pl-6 rounded-r-md",
|
||||||
isActive
|
isHighlighted
|
||||||
? "bg-gradient-to-l from-accent from-60% to-transparent"
|
? "bg-gradient-to-l from-accent from-60% to-transparent"
|
||||||
: "bg-gradient-to-l from-sidebar from-60% to-transparent group-hover/item:from-accent",
|
: "bg-gradient-to-l from-sidebar from-60% to-transparent group-hover/item:from-accent",
|
||||||
isMobile
|
isMobile
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ function formatUsd(micros: number): string {
|
||||||
* premium-credit meters. Values come from Zero (live-replicated from Postgres)
|
* premium-credit meters. Values come from Zero (live-replicated from Postgres)
|
||||||
* as integer micro-USD (1_000_000 == $1.00). A low-balance warning highlights
|
* as integer micro-USD (1_000_000 == $1.00). A low-balance warning highlights
|
||||||
* the amount when it falls below $0.50 so the user knows to top up or enable
|
* the amount when it falls below $0.50 so the user knows to top up or enable
|
||||||
* auto-reload.
|
* automatic top-ups.
|
||||||
*/
|
*/
|
||||||
export function CreditBalanceDisplay() {
|
export function CreditBalanceDisplay() {
|
||||||
const isAnonymous = useIsAnonymous();
|
const isAnonymous = useIsAnonymous();
|
||||||
|
|
@ -46,7 +46,7 @@ export function CreditBalanceDisplay() {
|
||||||
"font-medium tabular-nums",
|
"font-medium tabular-nums",
|
||||||
isLow ? "text-amber-600 dark:text-amber-500" : "text-foreground"
|
isLow ? "text-amber-600 dark:text-amber-500" : "text-foreground"
|
||||||
)}
|
)}
|
||||||
title={isLow ? "Low balance — buy credits or enable auto-reload" : undefined}
|
title={isLow ? "Low balance: add credits or enable top-ups" : undefined}
|
||||||
>
|
>
|
||||||
{formatUsd(balanceMicros)}
|
{formatUsd(balanceMicros)}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@
|
||||||
import { useQuery } from "@rocicorp/zero/react";
|
import { useQuery } from "@rocicorp/zero/react";
|
||||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||||
import {
|
import {
|
||||||
FolderInput,
|
Check,
|
||||||
|
FilePlus,
|
||||||
FolderPlus,
|
FolderPlus,
|
||||||
FolderSync,
|
FolderSync,
|
||||||
ListFilter,
|
ListFilter,
|
||||||
|
|
@ -50,6 +51,7 @@ import {
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuCheckboxItem,
|
DropdownMenuCheckboxItem,
|
||||||
|
|
@ -69,6 +71,7 @@ import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||||
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
|
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import { useElectronAPI, usePlatform } from "@/hooks/use-platform";
|
import { useElectronAPI, usePlatform } from "@/hooks/use-platform";
|
||||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||||
import { foldersApiService } from "@/lib/apis/folders-api.service";
|
import { foldersApiService } from "@/lib/apis/folders-api.service";
|
||||||
|
|
@ -127,60 +130,111 @@ export function EmbeddedDocumentsMenu({
|
||||||
onToggleType: (type: DocumentTypeEnum, checked: boolean) => void;
|
onToggleType: (type: DocumentTypeEnum, checked: boolean) => void;
|
||||||
onCreateFolder: () => void;
|
onCreateFolder: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
|
||||||
const documentTypes = useMemo(
|
const documentTypes = useMemo(
|
||||||
() => Object.keys(typeCounts).sort() as DocumentTypeEnum[],
|
() => Object.keys(typeCounts).sort() as DocumentTypeEnum[],
|
||||||
[typeCounts]
|
[typeCounts]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenu>
|
||||||
<Button
|
<DropdownMenuTrigger asChild>
|
||||||
type="button"
|
<Button
|
||||||
variant="ghost"
|
type="button"
|
||||||
size="icon"
|
variant="ghost"
|
||||||
className="relative h-7 w-7 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
size="icon"
|
||||||
aria-label="Document actions"
|
className="relative h-6 w-6 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||||
|
aria-label="Document actions"
|
||||||
|
>
|
||||||
|
<SlidersVertical className="size-3.5" />
|
||||||
|
{activeTypes.length > 0 ? (
|
||||||
|
<span className="absolute right-0.5 top-0.5 h-1.5 w-1.5 rounded-full bg-primary" />
|
||||||
|
) : null}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-44">
|
||||||
|
<DropdownMenuItem onSelect={onCreateFolder}>
|
||||||
|
<FolderPlus className="h-4 w-4" />
|
||||||
|
New folder
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{isMobile ? (
|
||||||
|
<DropdownMenuItem onSelect={() => setFilterDrawerOpen(true)}>
|
||||||
|
<ListFilter className="h-4 w-4" />
|
||||||
|
<span className="flex-1">Filter by type</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : (
|
||||||
|
<DropdownMenuSub>
|
||||||
|
<DropdownMenuSubTrigger>
|
||||||
|
<ListFilter className="h-4 w-4" />
|
||||||
|
Filter by type
|
||||||
|
</DropdownMenuSubTrigger>
|
||||||
|
<DropdownMenuSubContent className="w-52 max-h-72 overflow-y-auto">
|
||||||
|
{documentTypes.length > 0 ? (
|
||||||
|
documentTypes.map((type) => (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
key={type}
|
||||||
|
checked={activeTypes.includes(type)}
|
||||||
|
onCheckedChange={(checked) => onToggleType(type, checked === true)}
|
||||||
|
onSelect={(event) => event.preventDefault()}
|
||||||
|
>
|
||||||
|
{getDocumentTypeIcon(type, "h-4 w-4")}
|
||||||
|
<span className="min-w-0 flex-1 truncate">{getDocumentTypeLabel(type)}</span>
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground">
|
||||||
|
{typeCounts[type] ?? 0}
|
||||||
|
</span>
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<DropdownMenuItem disabled>No document types</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuSub>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<Drawer
|
||||||
|
open={filterDrawerOpen}
|
||||||
|
onOpenChange={setFilterDrawerOpen}
|
||||||
|
shouldScaleBackground={false}
|
||||||
|
>
|
||||||
|
<DrawerContent
|
||||||
|
className="z-80 max-h-[75vh] rounded-t-2xl border bg-popover text-popover-foreground"
|
||||||
|
overlayClassName="z-80"
|
||||||
>
|
>
|
||||||
<SlidersVertical className="h-3.5 w-3.5" />
|
<DrawerHandle className="mt-3 h-1.5 w-10" />
|
||||||
{activeTypes.length > 0 ? (
|
<DrawerTitle className="px-4 pb-2 pt-3 text-center text-base font-semibold">
|
||||||
<span className="absolute right-0.5 top-0.5 h-1.5 w-1.5 rounded-full bg-primary" />
|
|
||||||
) : null}
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" className="w-44">
|
|
||||||
<DropdownMenuItem onSelect={onCreateFolder}>
|
|
||||||
<FolderPlus className="h-4 w-4" />
|
|
||||||
New folder
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSub>
|
|
||||||
<DropdownMenuSubTrigger>
|
|
||||||
<ListFilter className="h-4 w-4" />
|
|
||||||
Filter by type
|
Filter by type
|
||||||
</DropdownMenuSubTrigger>
|
</DrawerTitle>
|
||||||
<DropdownMenuSubContent className="w-52 max-h-72 overflow-y-auto">
|
<div className="px-4 pb-6 pt-1">
|
||||||
{documentTypes.length > 0 ? (
|
{documentTypes.length > 0 ? (
|
||||||
documentTypes.map((type) => (
|
documentTypes.map((type, index) => {
|
||||||
<DropdownMenuCheckboxItem
|
const isActive = activeTypes.includes(type);
|
||||||
key={type}
|
return (
|
||||||
checked={activeTypes.includes(type)}
|
<div key={type}>
|
||||||
onCheckedChange={(checked) => onToggleType(type, checked === true)}
|
{index > 0 && <div className="mx-3 h-px bg-popover-border" />}
|
||||||
onSelect={(event) => event.preventDefault()}
|
<button
|
||||||
>
|
type="button"
|
||||||
{getDocumentTypeIcon(type, "h-4 w-4")}
|
className="flex h-12 w-full items-center gap-3 rounded-lg px-3 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
<span className="min-w-0 flex-1 truncate">{getDocumentTypeLabel(type)}</span>
|
onClick={() => onToggleType(type, !isActive)}
|
||||||
<span className="ml-auto text-xs text-muted-foreground">
|
>
|
||||||
{typeCounts[type] ?? 0}
|
{getDocumentTypeIcon(type, "h-4 w-4")}
|
||||||
</span>
|
<span className="min-w-0 flex-1 truncate">{getDocumentTypeLabel(type)}</span>
|
||||||
</DropdownMenuCheckboxItem>
|
<span className="text-xs text-muted-foreground">{typeCounts[type] ?? 0}</span>
|
||||||
))
|
{isActive && <Check className="h-4 w-4 shrink-0 text-primary" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
) : (
|
) : (
|
||||||
<DropdownMenuItem disabled>No document types</DropdownMenuItem>
|
<p className="px-3 py-4 text-sm text-muted-foreground">No document types</p>
|
||||||
)}
|
)}
|
||||||
</DropdownMenuSubContent>
|
</div>
|
||||||
</DropdownMenuSub>
|
</DrawerContent>
|
||||||
</DropdownMenuContent>
|
</Drawer>
|
||||||
</DropdownMenu>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -230,10 +284,10 @@ export function EmbeddedImportMenu({
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
className="h-6 w-6 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||||
aria-label="Import documents"
|
aria-label="Import documents"
|
||||||
>
|
>
|
||||||
<FolderInput className="h-3.5 w-3.5" />
|
<FilePlus className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-56">
|
<DropdownMenuContent align="end" className="w-56">
|
||||||
|
|
@ -1213,7 +1267,7 @@ function AuthenticatedDocumentsSidebarBase({
|
||||||
defaultOpen={true}
|
defaultOpen={true}
|
||||||
contentClassName="px-0"
|
contentClassName="px-0"
|
||||||
persistentAction={
|
persistentAction={
|
||||||
<div className="flex items-center gap-0.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<EmbeddedImportMenu onFolderWatched={refreshWatchedIds} />
|
<EmbeddedImportMenu onFolderWatched={refreshWatchedIds} />
|
||||||
<EmbeddedDocumentsMenu
|
<EmbeddedDocumentsMenu
|
||||||
typeCounts={typeCounts}
|
typeCounts={typeCounts}
|
||||||
|
|
@ -1389,7 +1443,7 @@ function AnonymousDocumentsSidebar({ embedded = false }: DocumentsSidebarProps)
|
||||||
defaultOpen={true}
|
defaultOpen={true}
|
||||||
contentClassName="px-0"
|
contentClassName="px-0"
|
||||||
persistentAction={
|
persistentAction={
|
||||||
<div className="flex items-center gap-0.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<EmbeddedImportMenu gate={gate} />
|
<EmbeddedImportMenu gate={gate} />
|
||||||
<EmbeddedDocumentsMenu
|
<EmbeddedDocumentsMenu
|
||||||
typeCounts={hasDoc ? { FILE: 1 } : {}}
|
typeCounts={hasDoc ? { FILE: 1 } : {}}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -6,6 +6,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
||||||
import type { ChatItem, NavItem, PageUsage, User, Workspace } from "../../types/layout.types";
|
import type { ChatItem, NavItem, PageUsage, User, Workspace } from "../../types/layout.types";
|
||||||
import { WorkspaceAvatar } from "../icon-rail/WorkspaceAvatar";
|
import { WorkspaceAvatar } from "../icon-rail/WorkspaceAvatar";
|
||||||
|
import { NotificationsDropdown, type NotificationsDropdownData } from "./NotificationsDropdown";
|
||||||
import { Sidebar } from "./Sidebar";
|
import { Sidebar } from "./Sidebar";
|
||||||
import { SidebarUserProfile } from "./SidebarUserProfile";
|
import { SidebarUserProfile } from "./SidebarUserProfile";
|
||||||
|
|
||||||
|
|
@ -35,6 +36,7 @@ interface MobileSidebarProps {
|
||||||
onUserSettings?: () => void;
|
onUserSettings?: () => void;
|
||||||
onAnnouncements?: () => void;
|
onAnnouncements?: () => void;
|
||||||
announcementUnreadCount?: number;
|
announcementUnreadCount?: number;
|
||||||
|
notifications?: NotificationsDropdownData;
|
||||||
onLogout?: () => void;
|
onLogout?: () => void;
|
||||||
pageUsage?: PageUsage;
|
pageUsage?: PageUsage;
|
||||||
theme?: string;
|
theme?: string;
|
||||||
|
|
@ -83,6 +85,7 @@ export function MobileSidebar({
|
||||||
onUserSettings,
|
onUserSettings,
|
||||||
onAnnouncements,
|
onAnnouncements,
|
||||||
announcementUnreadCount = 0,
|
announcementUnreadCount = 0,
|
||||||
|
notifications,
|
||||||
onLogout,
|
onLogout,
|
||||||
pageUsage,
|
pageUsage,
|
||||||
theme,
|
theme,
|
||||||
|
|
@ -161,11 +164,19 @@ export function MobileSidebar({
|
||||||
isCollapsed
|
isCollapsed
|
||||||
theme={theme}
|
theme={theme}
|
||||||
setTheme={setTheme}
|
setTheme={setTheme}
|
||||||
|
topContent={
|
||||||
|
notifications ? (
|
||||||
|
<NotificationsDropdown
|
||||||
|
notifications={notifications}
|
||||||
|
onCloseMobileSidebar={() => onOpenChange(false)}
|
||||||
|
/>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sidebar Content - right side */}
|
{/* Sidebar Content - right side */}
|
||||||
<div className="flex-1 overflow-hidden flex flex-col [&>*]:!w-full">
|
<div className="flex-1 overflow-hidden flex flex-col border-r [&>*]:!w-full">
|
||||||
<Sidebar
|
<Sidebar
|
||||||
workspace={workspace}
|
workspace={workspace}
|
||||||
isCollapsed={false}
|
isCollapsed={false}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,460 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { Bell } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { setTargetCommentIdAtom } from "@/atoms/chat/current-thread.atom";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerHandle,
|
||||||
|
DrawerTitle,
|
||||||
|
DrawerTrigger,
|
||||||
|
} from "@/components/ui/drawer";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import {
|
||||||
|
isCommentReplyMetadata,
|
||||||
|
isInsufficientCreditsMetadata,
|
||||||
|
isNewMentionMetadata,
|
||||||
|
} from "@/contracts/types/inbox.types";
|
||||||
|
import type { InboxItem } from "@/hooks/use-inbox";
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export interface NotificationsDataSource {
|
||||||
|
items: InboxItem[];
|
||||||
|
unreadCount: number;
|
||||||
|
totalCount?: number;
|
||||||
|
loading: boolean;
|
||||||
|
loadingMore?: boolean;
|
||||||
|
hasMore?: boolean;
|
||||||
|
loadMore?: () => void;
|
||||||
|
markAsRead: (id: number) => Promise<boolean>;
|
||||||
|
markAllAsRead: () => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationsDropdownData {
|
||||||
|
totalUnreadCount: number;
|
||||||
|
comments: NotificationsDataSource;
|
||||||
|
status: NotificationsDataSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NotificationsDropdownProps {
|
||||||
|
notifications: NotificationsDropdownData;
|
||||||
|
onCloseMobileSidebar?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type NotificationFilter = "all" | "mentions" | "unread";
|
||||||
|
|
||||||
|
function formatNotificationCount(count: number): string {
|
||||||
|
if (count <= 999) {
|
||||||
|
return count.toString();
|
||||||
|
}
|
||||||
|
const thousands = Math.floor(count / 1000);
|
||||||
|
return `${thousands}k+`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(dateString: string): string {
|
||||||
|
try {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - date.getTime();
|
||||||
|
const diffMins = Math.floor(diffMs / (1000 * 60));
|
||||||
|
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||||
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
if (diffMins < 1) return "now";
|
||||||
|
if (diffMins < 60) return `${diffMins}m ago`;
|
||||||
|
if (diffHours < 24) return `${diffHours}h ago`;
|
||||||
|
if (diffDays < 7) return `${diffDays}d ago`;
|
||||||
|
return `${Math.floor(diffDays / 7)}w ago`;
|
||||||
|
} catch {
|
||||||
|
return "now";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCommentNotification(item: InboxItem): boolean {
|
||||||
|
return item.type === "new_mention" || item.type === "comment_reply";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotificationsDropdown({
|
||||||
|
notifications,
|
||||||
|
onCloseMobileSidebar,
|
||||||
|
}: NotificationsDropdownProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const [, setTargetCommentId] = useAtom(setTargetCommentIdAtom);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [activeFilter, setActiveFilter] = useState<NotificationFilter>("all");
|
||||||
|
const [markingAsReadId, setMarkingAsReadId] = useState<number | null>(null);
|
||||||
|
const [markingAllAsRead, setMarkingAllAsRead] = useState(false);
|
||||||
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const loadMoreTriggerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const unreadLabel = formatNotificationCount(notifications.totalUnreadCount);
|
||||||
|
const allCount =
|
||||||
|
(notifications.comments.totalCount ?? 0) + (notifications.status.totalCount ?? 0);
|
||||||
|
const mentionsCount = notifications.comments.totalCount ?? notifications.comments.items.length;
|
||||||
|
const visibleUnreadCount =
|
||||||
|
activeFilter === "mentions"
|
||||||
|
? notifications.comments.unreadCount
|
||||||
|
: notifications.totalUnreadCount;
|
||||||
|
const isLoading =
|
||||||
|
activeFilter === "mentions"
|
||||||
|
? notifications.comments.loading
|
||||||
|
: notifications.comments.loading || notifications.status.loading;
|
||||||
|
const isLoadingMore =
|
||||||
|
activeFilter === "mentions"
|
||||||
|
? !!notifications.comments.loadingMore
|
||||||
|
: !!notifications.comments.loadingMore || !!notifications.status.loadingMore;
|
||||||
|
const hasMore =
|
||||||
|
activeFilter === "mentions"
|
||||||
|
? !!notifications.comments.hasMore
|
||||||
|
: !!notifications.comments.hasMore || !!notifications.status.hasMore;
|
||||||
|
const items = useMemo(() => {
|
||||||
|
const sourceItems =
|
||||||
|
activeFilter === "mentions"
|
||||||
|
? notifications.comments.items
|
||||||
|
: [...notifications.comments.items, ...notifications.status.items];
|
||||||
|
|
||||||
|
return sourceItems
|
||||||
|
.filter((item) => activeFilter !== "unread" || !item.read)
|
||||||
|
.toSorted((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
|
||||||
|
}, [activeFilter, notifications.comments.items, notifications.status.items]);
|
||||||
|
|
||||||
|
const loadMoreForActiveFilter = useCallback(() => {
|
||||||
|
if (isLoadingMore) return;
|
||||||
|
|
||||||
|
if (activeFilter === "mentions") {
|
||||||
|
if (notifications.comments.hasMore) {
|
||||||
|
notifications.comments.loadMore?.();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notifications.comments.hasMore) {
|
||||||
|
notifications.comments.loadMore?.();
|
||||||
|
}
|
||||||
|
if (notifications.status.hasMore) {
|
||||||
|
notifications.status.loadMore?.();
|
||||||
|
}
|
||||||
|
}, [activeFilter, isLoadingMore, notifications.comments, notifications.status]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || isLoading || isLoadingMore || !hasMore) return;
|
||||||
|
const root = scrollContainerRef.current;
|
||||||
|
const target = loadMoreTriggerRef.current;
|
||||||
|
if (!root || !target) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries[0]?.isIntersecting) {
|
||||||
|
loadMoreForActiveFilter();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
root,
|
||||||
|
rootMargin: "120px",
|
||||||
|
threshold: 0,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.observe(target);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [hasMore, isLoading, isLoadingMore, loadMoreForActiveFilter, open]);
|
||||||
|
|
||||||
|
const markItemAsRead = useCallback(
|
||||||
|
async (item: InboxItem) => {
|
||||||
|
if (item.read) return;
|
||||||
|
setMarkingAsReadId(item.id);
|
||||||
|
try {
|
||||||
|
await (isCommentNotification(item)
|
||||||
|
? notifications.comments.markAsRead(item.id)
|
||||||
|
: notifications.status.markAsRead(item.id));
|
||||||
|
} finally {
|
||||||
|
setMarkingAsReadId(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[notifications.comments, notifications.status]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleItemClick = useCallback(
|
||||||
|
async (item: InboxItem) => {
|
||||||
|
await markItemAsRead(item);
|
||||||
|
|
||||||
|
if (item.type === "new_mention" && isNewMentionMetadata(item.metadata)) {
|
||||||
|
const threadId = item.metadata.thread_id;
|
||||||
|
const commentId = item.metadata.comment_id;
|
||||||
|
if (item.workspace_id && threadId) {
|
||||||
|
if (commentId) setTargetCommentId(commentId);
|
||||||
|
setOpen(false);
|
||||||
|
onCloseMobileSidebar?.();
|
||||||
|
router.push(
|
||||||
|
commentId
|
||||||
|
? `/dashboard/${item.workspace_id}/new-chat/${threadId}?commentId=${commentId}`
|
||||||
|
: `/dashboard/${item.workspace_id}/new-chat/${threadId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.type === "comment_reply" && isCommentReplyMetadata(item.metadata)) {
|
||||||
|
const threadId = item.metadata.thread_id;
|
||||||
|
const replyId = item.metadata.reply_id;
|
||||||
|
if (item.workspace_id && threadId) {
|
||||||
|
if (replyId) setTargetCommentId(replyId);
|
||||||
|
setOpen(false);
|
||||||
|
onCloseMobileSidebar?.();
|
||||||
|
router.push(
|
||||||
|
replyId
|
||||||
|
? `/dashboard/${item.workspace_id}/new-chat/${threadId}?commentId=${replyId}`
|
||||||
|
: `/dashboard/${item.workspace_id}/new-chat/${threadId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.type === "insufficient_credits" && isInsufficientCreditsMetadata(item.metadata)) {
|
||||||
|
if (item.metadata.action_url) {
|
||||||
|
setOpen(false);
|
||||||
|
onCloseMobileSidebar?.();
|
||||||
|
router.push(item.metadata.action_url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[markItemAsRead, onCloseMobileSidebar, router, setTargetCommentId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMarkAllAsRead = useCallback(async () => {
|
||||||
|
if (visibleUnreadCount === 0 || markingAllAsRead) return;
|
||||||
|
setMarkingAllAsRead(true);
|
||||||
|
try {
|
||||||
|
if (activeFilter === "mentions") {
|
||||||
|
await notifications.comments.markAllAsRead();
|
||||||
|
} else {
|
||||||
|
await Promise.all([
|
||||||
|
notifications.comments.markAllAsRead(),
|
||||||
|
notifications.status.markAllAsRead(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setMarkingAllAsRead(false);
|
||||||
|
}
|
||||||
|
}, [activeFilter, markingAllAsRead, notifications, visibleUnreadCount]);
|
||||||
|
|
||||||
|
const emptyStateCopy =
|
||||||
|
activeFilter === "mentions"
|
||||||
|
? {
|
||||||
|
title: "No mentions",
|
||||||
|
description: "Mentions and replies will appear here.",
|
||||||
|
}
|
||||||
|
: activeFilter === "unread"
|
||||||
|
? {
|
||||||
|
title: "No unread notifications",
|
||||||
|
description: "New mentions and status updates will appear here.",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
title: "No notifications",
|
||||||
|
description: "Mentions, replies, and status updates will appear here.",
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabs: { value: NotificationFilter; label: string; count: number }[] = [
|
||||||
|
{ value: "all", label: "All", count: allCount },
|
||||||
|
{ value: "mentions", label: "Mentions", count: mentionsCount },
|
||||||
|
{ value: "unread", label: "Unread", count: notifications.totalUnreadCount },
|
||||||
|
];
|
||||||
|
|
||||||
|
const triggerButton = (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Notifications"
|
||||||
|
className={cn(
|
||||||
|
"relative h-10 w-10 rounded-lg text-muted-foreground hover:bg-accent hover:text-accent-foreground",
|
||||||
|
open && "bg-accent text-accent-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Bell className="h-4 w-4" />
|
||||||
|
{notifications.totalUnreadCount > 0 ? (
|
||||||
|
<span className="absolute right-1 top-1 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-semibold leading-none text-destructive-foreground">
|
||||||
|
{unreadLabel}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
const panelContent = (
|
||||||
|
<>
|
||||||
|
<div className="flex shrink-0 items-center justify-between gap-3 border-b px-4 py-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h2 className="text-base font-semibold">Notifications</h2>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleMarkAllAsRead}
|
||||||
|
disabled={visibleUnreadCount === 0 || markingAllAsRead}
|
||||||
|
className="h-8 shrink-0 gap-1.5 px-2 text-xs text-muted-foreground hover:text-accent-foreground"
|
||||||
|
>
|
||||||
|
{markingAllAsRead ? <Spinner size="xs" /> : null}
|
||||||
|
Mark all read
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative flex shrink-0 items-end gap-4 px-4 after:absolute after:inset-x-0 after:bottom-0 after:z-0 after:h-px after:bg-muted-foreground/25 after:content-['']">
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
const isActive = activeFilter === tab.value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.value}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={isActive}
|
||||||
|
onClick={() => setActiveFilter(tab.value)}
|
||||||
|
className={cn(
|
||||||
|
"relative z-10 flex h-11 items-center gap-2 border-b-2 border-transparent px-0 text-sm font-medium text-muted-foreground transition-colors",
|
||||||
|
"hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||||
|
isActive && "border-primary text-primary"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span>{tab.label}</span>
|
||||||
|
<span
|
||||||
|
className="inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-muted px-1.5 text-[11px] font-semibold text-muted-foreground"
|
||||||
|
>
|
||||||
|
{formatNotificationCount(tab.count)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref={scrollContainerRef} className="min-h-0 flex-1 overflow-y-auto p-2">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{[82, 64, 74].map((width) => (
|
||||||
|
<div key={width} className="flex h-[72px] items-center rounded-lg px-2 py-2">
|
||||||
|
<div className="min-w-0 flex-1 space-y-2">
|
||||||
|
<Skeleton className="h-3 rounded" style={{ width: `${width}%` }} />
|
||||||
|
<Skeleton className="h-2.5 w-1/2 rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : items.length > 0 ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{items.map((item) => {
|
||||||
|
const isMarkingAsRead = markingAsReadId === item.id;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={isMarkingAsRead}
|
||||||
|
onClick={() => handleItemClick(item)}
|
||||||
|
className={cn(
|
||||||
|
"group h-auto w-full justify-start rounded-lg px-2 py-2 text-left",
|
||||||
|
"hover:bg-accent hover:text-accent-foreground",
|
||||||
|
!item.read && "bg-accent/40"
|
||||||
|
)}
|
||||||
|
style={{ contentVisibility: "auto", containIntrinsicSize: "0 72px" }}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex min-w-0 items-start gap-2">
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"line-clamp-1 flex-1 text-sm font-medium",
|
||||||
|
!item.read && "font-semibold"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</p>
|
||||||
|
{!item.read ? (
|
||||||
|
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-primary" />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 line-clamp-2 text-xs font-normal text-muted-foreground group-hover:text-accent-foreground/80">
|
||||||
|
{item.message}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-[11px] font-normal text-muted-foreground/70">
|
||||||
|
{formatTime(item.created_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{isMarkingAsRead ? <Spinner size="xs" className="shrink-0" /> : null}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{hasMore ? (
|
||||||
|
<div
|
||||||
|
ref={loadMoreTriggerRef}
|
||||||
|
className="flex min-h-10 items-center justify-center py-2"
|
||||||
|
>
|
||||||
|
{isLoadingMore ? <Spinner size="xs" /> : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex min-h-full flex-col items-center justify-center px-6 py-10 text-center">
|
||||||
|
<p className="text-sm font-medium">{emptyStateCopy.title}</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{emptyStateCopy.description}</p>
|
||||||
|
{hasMore ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={loadMoreForActiveFilter}
|
||||||
|
disabled={isLoadingMore}
|
||||||
|
className="mt-3 text-xs"
|
||||||
|
>
|
||||||
|
{isLoadingMore ? <Spinner size="xs" /> : null}
|
||||||
|
Load more
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<Drawer open={open} onOpenChange={setOpen} shouldScaleBackground={false}>
|
||||||
|
<DrawerTrigger asChild>{triggerButton}</DrawerTrigger>
|
||||||
|
<DrawerContent
|
||||||
|
className="z-80 h-[78vh] max-h-[90vh] overflow-hidden rounded-t-2xl border bg-popover text-popover-foreground"
|
||||||
|
overlayClassName="z-80"
|
||||||
|
>
|
||||||
|
<DrawerHandle className="mt-3 h-1.5 w-10" />
|
||||||
|
<DrawerTitle className="sr-only">Notifications</DrawerTitle>
|
||||||
|
<div className="flex min-h-0 flex-1 select-none flex-col">{panelContent}</div>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<PopoverTrigger asChild>{triggerButton}</PopoverTrigger>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right" sideOffset={8}>
|
||||||
|
Notifications
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<PopoverContent
|
||||||
|
side="right"
|
||||||
|
align="end"
|
||||||
|
sideOffset={10}
|
||||||
|
className="z-80 flex h-[min(420px,calc(100vh-2rem))] w-[360px] select-none flex-col overflow-hidden rounded-xl border bg-popover p-0 text-popover-foreground shadow-lg"
|
||||||
|
>
|
||||||
|
{panelContent}
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { History, KeyRound } from "lucide-react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { usePathname } from "next/navigation";
|
|
||||||
import { ConnectAgentDialog } from "@/components/mcp/connect-agent-dialog";
|
|
||||||
import { PLAYGROUND_PLATFORMS, type PlatformIcon } from "@/lib/playground/catalog";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
interface PlaygroundSidebarProps {
|
|
||||||
workspaceId: number | string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function PlaygroundNavLink({
|
|
||||||
href,
|
|
||||||
label,
|
|
||||||
icon: Icon,
|
|
||||||
isActive,
|
|
||||||
indented = false,
|
|
||||||
}: {
|
|
||||||
href: string;
|
|
||||||
label: string;
|
|
||||||
icon?: PlatformIcon;
|
|
||||||
isActive: boolean;
|
|
||||||
indented?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
href={href}
|
|
||||||
aria-current={isActive ? "page" : undefined}
|
|
||||||
className={cn(
|
|
||||||
"group/link relative flex h-9 items-center gap-2 rounded-md mx-2 px-2 text-sm text-left",
|
|
||||||
"transition-colors hover:bg-accent hover:text-accent-foreground",
|
|
||||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
|
||||||
indented && "pl-8",
|
|
||||||
isActive && "bg-accent text-accent-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{Icon ? <Icon className="h-3.5 w-3.5 shrink-0" /> : null}
|
|
||||||
<span className="min-w-0 flex-1 truncate">{label}</span>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PlaygroundSidebar({ workspaceId }: PlaygroundSidebarProps) {
|
|
||||||
const pathname = usePathname();
|
|
||||||
const base = `/dashboard/${workspaceId}/playground`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative flex h-full w-[240px] flex-col bg-panel text-sidebar-foreground overflow-hidden select-none">
|
|
||||||
<div className="flex h-12 shrink-0 items-center px-4">
|
|
||||||
<span className="text-sm font-semibold">API Playground</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-0.5 pt-1.5 pb-1.5 after:mx-3 after:mt-1.5 after:block after:h-px after:bg-border">
|
|
||||||
<PlaygroundNavLink
|
|
||||||
href={`${base}/runs`}
|
|
||||||
label="Runs"
|
|
||||||
icon={History}
|
|
||||||
isActive={pathname === `${base}/runs`}
|
|
||||||
/>
|
|
||||||
<PlaygroundNavLink
|
|
||||||
href={`${base}/api-keys`}
|
|
||||||
label="API Keys"
|
|
||||||
icon={KeyRound}
|
|
||||||
isActive={pathname === `${base}/api-keys`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 w-full min-h-0 overflow-y-auto overflow-x-hidden scrollbar-thin scrollbar-thumb-muted-foreground/20 scrollbar-track-transparent pb-2">
|
|
||||||
{PLAYGROUND_PLATFORMS.map((platform) => (
|
|
||||||
<div key={platform.id} className="flex flex-col gap-0.5 pt-2">
|
|
||||||
<div className="flex items-center gap-2 pl-4 pr-2.5 py-1 text-xs font-medium text-muted-foreground">
|
|
||||||
<platform.icon className="h-3.5 w-3.5 shrink-0" />
|
|
||||||
<span className="truncate">{platform.label}</span>
|
|
||||||
</div>
|
|
||||||
{platform.verbs.map((verb) => {
|
|
||||||
const href = `${base}/${platform.id}/${verb.verb}`;
|
|
||||||
return (
|
|
||||||
<PlaygroundNavLink
|
|
||||||
key={verb.name}
|
|
||||||
href={href}
|
|
||||||
label={verb.label}
|
|
||||||
isActive={pathname === href}
|
|
||||||
indented
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="shrink-0 py-1.5 before:mx-3 before:mb-1.5 before:block before:h-px before:bg-border">
|
|
||||||
<ConnectAgentDialog />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -5,7 +5,7 @@ import Link from "next/link";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { type ReactNode, useMemo, useState } from "react";
|
import { type ReactNode, useMemo, useState } from "react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { ConnectAgentDialog } from "@/components/mcp/connect-agent-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
@ -18,7 +18,7 @@ import { ChatListItem } from "./ChatListItem";
|
||||||
import { CreditBalanceDisplay } from "./CreditBalanceDisplay";
|
import { CreditBalanceDisplay } from "./CreditBalanceDisplay";
|
||||||
import { DocumentsSidebar } from "./DocumentsSidebar";
|
import { DocumentsSidebar } from "./DocumentsSidebar";
|
||||||
import { NavSection } from "./NavSection";
|
import { NavSection } from "./NavSection";
|
||||||
import { SidebarButton } from "./SidebarButton";
|
import { SidebarButton, SidebarButtonBadge } from "./SidebarButton";
|
||||||
import { SidebarCollapseButton } from "./SidebarCollapseButton";
|
import { SidebarCollapseButton } from "./SidebarCollapseButton";
|
||||||
import { SidebarHeader } from "./SidebarHeader";
|
import { SidebarHeader } from "./SidebarHeader";
|
||||||
import { SidebarSection } from "./SidebarSection";
|
import { SidebarSection } from "./SidebarSection";
|
||||||
|
|
@ -46,27 +46,14 @@ function ChatListSkeletonRows() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CollapsedInboxIcon({ item }: { item: NavItem }) {
|
|
||||||
const Icon = item.icon;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span className="relative flex h-3.5 w-3.5 items-center justify-center">
|
|
||||||
<Icon className="h-3.5 w-3.5" />
|
|
||||||
{typeof item.badge === "string" ? (
|
|
||||||
<span className="absolute right-0 top-0 flex min-w-3.5 -translate-y-1/2 translate-x-1/2 items-center justify-center rounded-full bg-destructive px-1 text-[9px] font-medium leading-3 text-destructive-foreground">
|
|
||||||
{item.badge}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
workspace: Workspace | null;
|
workspace: Workspace | null;
|
||||||
isCollapsed?: boolean;
|
isCollapsed?: boolean;
|
||||||
onToggleCollapse?: () => void;
|
onToggleCollapse?: () => void;
|
||||||
navItems: NavItem[];
|
navItems: NavItem[];
|
||||||
onNavItemClick?: (item: NavItem) => void;
|
onNavItemClick?: (item: NavItem) => void;
|
||||||
|
onPlaygroundItemClick?: (item: NavItem) => void;
|
||||||
|
isPlaygroundSidebarOpen?: boolean;
|
||||||
chats: ChatItem[];
|
chats: ChatItem[];
|
||||||
activeChatId?: number | null;
|
activeChatId?: number | null;
|
||||||
onNewChat: () => void;
|
onNewChat: () => void;
|
||||||
|
|
@ -104,6 +91,8 @@ export function Sidebar({
|
||||||
onToggleCollapse,
|
onToggleCollapse,
|
||||||
navItems,
|
navItems,
|
||||||
onNavItemClick,
|
onNavItemClick,
|
||||||
|
onPlaygroundItemClick,
|
||||||
|
isPlaygroundSidebarOpen,
|
||||||
chats,
|
chats,
|
||||||
activeChatId,
|
activeChatId,
|
||||||
onNewChat,
|
onNewChat,
|
||||||
|
|
@ -138,10 +127,9 @@ export function Sidebar({
|
||||||
const [openDropdownChatId, setOpenDropdownChatId] = useState<number | null>(null);
|
const [openDropdownChatId, setOpenDropdownChatId] = useState<number | null>(null);
|
||||||
const [isSidebarNavScrolled, setIsSidebarNavScrolled] = useState(false);
|
const [isSidebarNavScrolled, setIsSidebarNavScrolled] = useState(false);
|
||||||
|
|
||||||
// Inbox, Automations, and Artifacts are rendered explicitly right below
|
// Automations, Artifacts, and Playground are rendered explicitly right below
|
||||||
// New Chat. Pull them out of the nav items list so they don't also appear
|
// New Chat. Pull them out of the nav items list so they don't also appear
|
||||||
// in the bottom NavSection. Documents is embedded below Recents.
|
// in the bottom NavSection. Documents is embedded below Recents.
|
||||||
const inboxItem = useMemo(() => navItems.find((item) => item.url === "#inbox"), [navItems]);
|
|
||||||
const automationsItem = useMemo(
|
const automationsItem = useMemo(
|
||||||
() => navItems.find((item) => item.url.endsWith("/automations")),
|
() => navItems.find((item) => item.url.endsWith("/automations")),
|
||||||
[navItems]
|
[navItems]
|
||||||
|
|
@ -158,7 +146,6 @@ export function Sidebar({
|
||||||
() =>
|
() =>
|
||||||
navItems.filter(
|
navItems.filter(
|
||||||
(item) =>
|
(item) =>
|
||||||
item.url !== "#inbox" &&
|
|
||||||
!item.url.endsWith("/automations") &&
|
!item.url.endsWith("/automations") &&
|
||||||
!item.url.endsWith("/artifacts") &&
|
!item.url.endsWith("/artifacts") &&
|
||||||
!item.url.endsWith("/playground")
|
!item.url.endsWith("/playground")
|
||||||
|
|
@ -218,7 +205,7 @@ export function Sidebar({
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex flex-col gap-0.5 pt-1.5 pb-0 after:absolute after:inset-x-3 after:bottom-0 after:h-px after:bg-border after:transition-opacity",
|
"relative flex flex-col gap-0.5 pt-1.5 pb-0 after:absolute after:inset-x-0 after:bottom-0 after:h-px after:bg-border after:transition-opacity",
|
||||||
isSidebarNavScrolled ? "after:opacity-100" : "after:opacity-0"
|
isSidebarNavScrolled ? "after:opacity-100" : "after:opacity-0"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
@ -235,23 +222,6 @@ export function Sidebar({
|
||||||
onScroll={(event) => setIsSidebarNavScrolled(event.currentTarget.scrollTop > 0)}
|
onScroll={(event) => setIsSidebarNavScrolled(event.currentTarget.scrollTop > 0)}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-0.5 pt-0.5 pb-1.5">
|
<div className="flex flex-col gap-0.5 pt-0.5 pb-1.5">
|
||||||
{inboxItem && (
|
|
||||||
<SidebarButton
|
|
||||||
icon={inboxItem.icon}
|
|
||||||
label={inboxItem.title}
|
|
||||||
onClick={() => onNavItemClick?.(inboxItem)}
|
|
||||||
isCollapsed={isCollapsed}
|
|
||||||
isActive={inboxItem.isActive}
|
|
||||||
badge={inboxItem.badge}
|
|
||||||
collapsedIconNode={<CollapsedInboxIcon item={inboxItem} />}
|
|
||||||
tooltipContent={isCollapsed ? inboxItem.title : undefined}
|
|
||||||
buttonProps={
|
|
||||||
{
|
|
||||||
"data-joyride": "inbox-sidebar",
|
|
||||||
} as React.ButtonHTMLAttributes<HTMLButtonElement>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{automationsItem && (
|
{automationsItem && (
|
||||||
<SidebarButton
|
<SidebarButton
|
||||||
icon={automationsItem.icon}
|
icon={automationsItem.icon}
|
||||||
|
|
@ -276,9 +246,10 @@ export function Sidebar({
|
||||||
<SidebarButton
|
<SidebarButton
|
||||||
icon={playgroundItem.icon}
|
icon={playgroundItem.icon}
|
||||||
label={playgroundItem.title}
|
label={playgroundItem.title}
|
||||||
onClick={() => onNavItemClick?.(playgroundItem)}
|
onClick={() => (onPlaygroundItemClick ?? onNavItemClick)?.(playgroundItem)}
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
isActive={playgroundItem.isActive}
|
isActive={isPlaygroundSidebarOpen ?? playgroundItem.isActive}
|
||||||
|
badge={<SidebarButtonBadge>New</SidebarButtonBadge>}
|
||||||
tooltipContent={isCollapsed ? playgroundItem.title : undefined}
|
tooltipContent={isCollapsed ? playgroundItem.title : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -356,10 +327,16 @@ export function Sidebar({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!isCollapsed && (
|
||||||
|
<div className="shrink-0 py-1.5">
|
||||||
|
<ConnectAgentDialog className="w-[calc(100%-1rem)]" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<SidebarUsageFooter
|
<SidebarUsageFooter
|
||||||
pageUsage={pageUsage}
|
pageUsage={pageUsage}
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
hasNavSectionAbove={footerNavItems.length > 0}
|
hasNavSectionAbove={footerNavItems.length > 0 || !isCollapsed}
|
||||||
onNavigate={onNavigate}
|
onNavigate={onNavigate}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
@ -436,29 +413,25 @@ function SidebarUsageFooter({
|
||||||
return (
|
return (
|
||||||
<div className={containerClass}>
|
<div className={containerClass}>
|
||||||
<CreditBalanceDisplay />
|
<CreditBalanceDisplay />
|
||||||
<div className="space-y-0.5">
|
<div className="relative grid grid-cols-2 before:absolute before:inset-y-1 before:left-1/2 before:w-px before:-translate-x-1/2 before:bg-border">
|
||||||
<Link
|
<Link
|
||||||
href={`/dashboard/${workspaceId}/earn-credits`}
|
href={`/dashboard/${workspaceId}/earn-credits`}
|
||||||
onClick={onNavigate}
|
onClick={onNavigate}
|
||||||
className="group flex w-full items-center justify-between rounded-md px-1.5 py-1 transition-colors hover:bg-accent"
|
className="group relative z-10 mx-0.5 flex min-w-0 items-center justify-center gap-1 rounded-md px-1.5 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent 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" />
|
<span className="truncate">Earn</span>
|
||||||
Earn credits
|
<SidebarButtonBadge className="h-4 px-1 text-[10px] bg-emerald-600 text-white hover:bg-emerald-600">
|
||||||
</span>
|
|
||||||
<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>
|
</SidebarButtonBadge>
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href={`/dashboard/${workspaceId}/buy-more`}
|
href={`/dashboard/${workspaceId}/buy-more`}
|
||||||
onClick={onNavigate}
|
onClick={onNavigate}
|
||||||
className="group flex w-full items-center justify-between rounded-md px-1.5 py-1 transition-colors hover:bg-accent"
|
className="group relative z-10 mx-0.5 flex min-w-0 items-center justify-center gap-1 rounded-md px-1.5 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-1.5 text-xs text-muted-foreground group-hover:text-accent-foreground">
|
<CreditCard className="h-3 w-3 shrink-0" />
|
||||||
<CreditCard className="h-3 w-3 shrink-0" />
|
<span className="truncate">Buy</span>
|
||||||
Buy credits
|
|
||||||
</span>
|
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
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 { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
@ -28,6 +29,26 @@ const baseClassName = cn(
|
||||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export function SidebarButtonBadge({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className={cn(
|
||||||
|
"h-5 shrink-0 rounded-sm border-0 bg-popover-foreground/10 px-1.5 text-[11px] text-popover-foreground hover:bg-popover-foreground/10",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function SidebarButton({
|
export function SidebarButton({
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
label,
|
label,
|
||||||
|
|
@ -73,17 +94,19 @@ export function SidebarButton({
|
||||||
isCollapsed ? "max-w-0 opacity-0 ml-0" : "max-w-[260px] flex-1 opacity-100 ml-2"
|
isCollapsed ? "max-w-0 opacity-0 ml-0" : "max-w-[260px] flex-1 opacity-100 ml-2"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="block truncate">{label}</span>
|
<span className="flex min-w-0 items-center gap-1.5">
|
||||||
|
<span className="truncate">{label}</span>
|
||||||
|
{!isCollapsed && badge && typeof badge !== "string" ? badge : null}
|
||||||
|
{!isCollapsed && badge && typeof badge === "string" ? (
|
||||||
|
<span className="inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-medium text-white">
|
||||||
|
{badge}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{!isCollapsed && trailingContent}
|
{!isCollapsed && trailingContent}
|
||||||
{!isCollapsed && badge && typeof badge !== "string" ? badge : null}
|
|
||||||
{!isCollapsed && badge && typeof badge === "string" ? (
|
|
||||||
<span className="ml-1 inline-flex items-center justify-center min-w-4 h-4 px-1 rounded-full bg-red-500 text-white text-[10px] font-medium">
|
|
||||||
{badge}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{collapsedOverlay && (
|
{collapsedOverlay && (
|
||||||
<span
|
<span
|
||||||
|
|
|
||||||
|
|
@ -1,115 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useSetAtom } from "jotai";
|
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
|
||||||
import { useCallback, useEffect } from "react";
|
|
||||||
import { useIsMobile } from "@/hooks/use-mobile";
|
|
||||||
import { slideoutOpenedTickAtom } from "@/lib/layout-events";
|
|
||||||
|
|
||||||
interface SidebarSlideOutPanelProps {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
ariaLabel: string;
|
|
||||||
width?: number;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reusable slide-out panel that extends from the sidebar.
|
|
||||||
*
|
|
||||||
* Desktop: absolutely positioned at the sidebar's right edge, overlaying the main
|
|
||||||
* content with a blur backdrop. Does not push/shrink the main content.
|
|
||||||
*
|
|
||||||
* Mobile: full-width absolute overlay (unchanged).
|
|
||||||
*/
|
|
||||||
export function SidebarSlideOutPanel({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
ariaLabel,
|
|
||||||
width = 360,
|
|
||||||
children,
|
|
||||||
}: SidebarSlideOutPanelProps) {
|
|
||||||
const isMobile = useIsMobile();
|
|
||||||
const bumpSlideoutOpenedTick = useSetAtom(slideoutOpenedTickAtom);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
bumpSlideoutOpenedTick((tick) => tick + 1);
|
|
||||||
}
|
|
||||||
}, [open, bumpSlideoutOpenedTick]);
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<AnimatePresence>
|
|
||||||
{open && (
|
|
||||||
<div className="absolute left-0 inset-y-0 z-30 w-full overflow-hidden pointer-events-none">
|
|
||||||
<motion.div
|
|
||||||
initial={{ x: "-100%" }}
|
|
||||||
animate={{ x: 0 }}
|
|
||||||
exit={{ x: "-100%" }}
|
|
||||||
transition={{ type: "tween", duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
|
|
||||||
className="h-full w-full bg-sidebar text-sidebar-foreground flex flex-col pointer-events-auto select-none"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-label={ariaLabel}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</motion.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 shadow-xl"
|
|
||||||
role="dialog"
|
|
||||||
aria-label={ariaLabel}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
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