Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,18 @@ NUXT_ADMIN_GITHUB_LOGINS=
AI_GATEWAY_API_KEY=
MCP_URL=http://localhost:3000/mcp

# Eve agent internal API (required for Nuxi on Eve — same value on web + eve Vercel services)
INTERNAL_API_SECRET=

# Vercel Connect Slack client UID (e.g. slack/nuxi) — eve service only
SLACK_CONNECTOR=

# Bearer token for /mcp/admin — web service (handler) + eve service (admin-mcp connection)
NUXT_MCP_ADMIN_TOKEN=

# Comma-separated Slack team IDs allowed to use admin MCP tools via Slack (eve service)
NUXT_ADMIN_SLACK_TEAM_IDS=

# for fetching sponsors
NUXT_OPEN_COLLECTIVE_API_KEY=

Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ jobs:
- run: pnpm install
- run: pnpm nuxt prepare
- run: pnpm vitest run
env:
EVE_BASE_URL: http://127.0.0.1:1

typecheck:
runs-on: ubuntu-latest
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ dist
.cache
.data
.eslintcache
.eve
.workflow-data

# Local Netlify folder
.netlify
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,22 @@ To run the evals for the MCP server, follow these steps:
pnpm eval:ui
```

## Nuxi (Eve agent)

Nuxi lives in [`layers/nuxi/`](./layers/nuxi/) — Eve runtime (`agent/`), UI, and internal APIs in one layer. For local development:

```bash
# Required — shared secret between the Nuxt app and Eve runtime
INTERNAL_API_SECRET=$(openssl rand -base64 32)

# Optional — canonical site URL for MCP + internal API callbacks
NUXT_PUBLIC_SITE_URL=http://localhost:3000

pnpm dev
```

On Vercel, configure **both** the `web` and `eve` services (`vercel.json`) with the same `INTERNAL_API_SECRET`, `AI_GATEWAY_API_KEY`, and database env vars.

## License

[MIT License](./LICENSE)
9 changes: 4 additions & 5 deletions app/composables/useNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,8 +180,8 @@ const _useNavigation = () => {
const { articles: blogArticles, fetchList: fetchBlog } = useBlog()

const searchLinks = computed(() => [{
label: 'Ask Agent',
icon: 'i-lucide-wand',
label: 'Ask Nuxi',
icon: 'i-custom-nuxi',
to: 'javascript:void(0);',
onSelect: () => {
track('Nuxi Opened', { source: 'search-links' })
Expand Down Expand Up @@ -284,9 +284,8 @@ const _useNavigation = () => {
ignoreFilter: true,
postFilter,
items: [{
label: 'Ask Agent',
icon: 'i-lucide-wand',
to: 'javascript:void(0);',
label: 'Ask Nuxi',
icon: 'i-custom-nuxi',
onSelect() {
track('Nuxi Opened', { source: 'search-palette', query: searchTerm.value })
openAgent(searchTerm.value)
Expand Down
26 changes: 26 additions & 0 deletions layers/nuxi/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Nuxi layer

Everything for the Nuxi assistant on nuxt.com lives here.

```text
layers/nuxi/
├── agent/ # Eve runtime (channels, tools, hooks) — deployed as the `eve` Vercel service
├── app/ # Chat UI (panel, dashboard, composables)
├── server/ # Nuxt APIs + internal routes the agent calls over HTTP
└── shared/ # Types and utils shared by app + server
```

## Boundaries

| Layer | Runs in | Talks to |
|-------|---------|----------|
| `agent/` | Eve (`eve dev` / Vercel `eve` service) | Nuxt via `/api/internal/*` (`INTERNAL_API_SECRET`) |
| `server/api/internal/*` | Nuxt Nitro | DB, GitHub, content — never exposed publicly |
| `server/api/chats/*`, `agent/*` | Nuxt Nitro | Browser (session auth) |
| `app/` | Browser | Nuxt APIs + Eve transport (`/_eve_internal/eve`) |

The agent never imports Nuxt server code directly. Shared logic that both sides need either lives in `shared/` (app + server) or is exposed through an internal API route.

## Config

`eve.eveRoot` points Eve at this layer so `agent/` is discovered at `layers/nuxi/agent/`. The layer includes a minimal `package.json` (Eve project marker for nested layout discovery). Root `vercel.json` declares the dual `web` + `eve` services with `entrypoint: "layers/nuxi"` for Eve.
18 changes: 18 additions & 0 deletions layers/nuxi/agent/agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { defineAgent } from 'eve'

export default defineAgent({
model: 'anthropic/claude-sonnet-4.6',
modelOptions: {
providerOptions: {
gateway: {
caching: 'auto'
},
anthropic: {
thinking: {
type: 'enabled',
budgetTokens: 2048
}
}
}
}
})
96 changes: 96 additions & 0 deletions layers/nuxi/agent/channels/eve.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import type { AuthFn } from 'eve/channels/auth'
import { localDev, vercelOidc } from 'eve/channels/auth'
import { defaultEveAuth, eveChannel } from 'eve/channels/eve'
import { appOrigin, internalHeaders } from '../lib/internal-api.js'

const PAGE_PATH_PATTERN = /^\/[\w./-]*$/

interface SessionPrincipal {
principalId: string
principalType: 'user' | 'anonymous'
authenticated: boolean
attributes?: {
login?: string
name?: string
avatar?: string
role?: string
}
}

function nuxtSessionAuth(): AuthFn<Request> {
return async (request) => {
const cookie = request.headers.get('cookie') ?? ''
if (!cookie) return null

try {
const response = await fetch(`${appOrigin()}/api/internal/session`, {
headers: internalHeaders({ cookie })
})

if (!response.ok) return null

const data = await response.json() as SessionPrincipal

return {
attributes: data.attributes ?? {},
authenticator: data.authenticated ? 'github' : 'anonymous',
issuer: 'nuxt.com',
principalId: data.principalId,
principalType: data.principalType === 'user' ? 'user' : 'anonymous'
}
} catch {
return null
}
}
}

function parsePagePath(request: Request): string | null {
const raw = request.headers.get('x-page-path')?.trim() ?? null
if (!raw || !PAGE_PATH_PATTERN.test(raw) || raw.length > 256) return null
return raw
}

export default eveChannel({
auth: [
nuxtSessionAuth(),
localDev(),
vercelOidc()
],
async onMessage(ctx, message) {
const context: string[] = []
const pagePath = parsePagePath(ctx.eve.request)
const chatId = ctx.eve.request.headers.get('x-nuxi-chat-id')?.trim()
const isNewSession = !ctx.eve.sessionId

if (pagePath) {
context.push(`Current page: ${pagePath}`)
}

if (isNewSession && chatId) {
try {
const cookie = ctx.eve.request.headers.get('cookie') ?? ''
const response = await fetch(`${appOrigin()}/api/internal/chats/${encodeURIComponent(chatId)}/context`, {
headers: internalHeaders(cookie ? { cookie } : undefined)
})

if (response.ok) {
const data = await response.json() as { summary?: string }
if (data.summary) {
context.push(`Prior conversation (for context):\n${data.summary}`)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
} catch {
// Non-fatal — continue without prior context
}
}

if (typeof message === 'string' && message.trim()) {
context.push(`User message: ${message}`)
}

return {
auth: defaultEveAuth(ctx),
context: context.length ? context : undefined
}
}
})
57 changes: 57 additions & 0 deletions layers/nuxi/agent/channels/slack.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { connectSlackCredentials } from '@vercel/connect/eve'
import {
defaultSlackAuth,
loadThreadContextMessages,
slackChannel,
type SlackContext,
type SlackMessage
} from 'eve/channels/slack'

function isHookConflictFailure(event: { code?: string, message?: string }) {
const message = event.message ?? ''
return event.code === 'HookConflictError'
|| message.includes('HookConflict')
|| message.includes('already in use by another workflow')
}

async function dispatchSlackMessage(ctx: SlackContext, message: SlackMessage) {
await ctx.thread.startTyping('Thinking...')

const auth = defaultSlackAuth(message, ctx)
if (!auth) return null

const context = ['The user is talking to Nuxi on Slack.']

const prior = await loadThreadContextMessages(ctx.thread, message, {
since: 'last-agent-reply'
})

if (prior.length > 0) {
const transcript = prior
.map(m => `${m.isMe ? 'you' : (m.user ?? 'user')}: ${m.markdown}`)
.join('\n')
context.push(`Recent thread messages since your last reply:\n\n${transcript}`)
}

return { auth, context }
}

export default slackChannel({
credentials: connectSlackCredentials(
process.env.SLACK_CONNECTOR ?? 'slack/nuxi'
),
botName: 'Nuxi',
onAppMention: dispatchSlackMessage,
onDirectMessage: dispatchSlackMessage,
events: {
async 'session.failed'(event, _channel) {
// DM + @mention (or any double dispatch on the same thread) races on one
// continuation token — the winning run already handles the user message.
if (isHookConflictFailure(event)) return

await _channel.thread.post(
'Something went wrong and I cannot continue in this thread. Start a new thread to try again.'
)
}
}
})
7 changes: 7 additions & 0 deletions layers/nuxi/agent/connections/nuxt-mcp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { defineMcpClientConnection } from 'eve/connections'
import { appOrigin } from '../lib/internal-api.js'

export default defineMcpClientConnection({
url: `${appOrigin()}/mcp`,
description: 'Nuxt.com documentation, blog, modules catalog, deploy providers, and changelog.'
})
36 changes: 36 additions & 0 deletions layers/nuxi/agent/hooks/chat-title.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { defineHook } from 'eve/hooks'
import { appOrigin, chatIdFromContinuationToken, internalHeaders } from '../lib/internal-api.js'

export default defineHook({
events: {
async 'message.received'(event, ctx) {
const chatId = chatIdFromContinuationToken(ctx.channel.continuationToken)
const auth = ctx.session.auth.current
if (!chatId || !auth || auth.principalType !== 'user') return

const message = event.data.message
if (!message?.trim()) return

try {
const response = await fetch(`${appOrigin()}/api/internal/chats/${encodeURIComponent(chatId)}/title`, {
method: 'POST',
headers: internalHeaders(),
body: JSON.stringify({
userId: auth.principalId,
message: {
role: 'user',
parts: [{ type: 'text', text: message }]
}
})
})

if (!response.ok) {
const text = await response.text().catch(() => '')
console.warn(`[chat-title] title generation failed (${response.status}): ${text}`)
}
} catch (error) {
console.warn('[chat-title] title generation request errored', error)
}
}
}
})
42 changes: 42 additions & 0 deletions layers/nuxi/agent/hooks/rate-limit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { defineHook } from 'eve/hooks'
import { appOrigin, internalHeaders } from '../lib/internal-api.js'

type TurnStartedContext = {
session: {
auth: {
current?: {
principalId?: string
} | null
}
}
eve?: {
request?: Request
}
}

export default defineHook({
events: {
async 'turn.started'(_event, ctx) {
const hookCtx = ctx as TurnStartedContext
const principalId = hookCtx.session.auth.current?.principalId
if (!principalId) return

const cookie = hookCtx.eve?.request?.headers.get('cookie') ?? ''
const response = await fetch(`${appOrigin()}/api/internal/agent/rate-limit/consume`, {
method: 'POST',
headers: internalHeaders(cookie ? { cookie } : undefined),
body: JSON.stringify({ userId: principalId })
})

if (response.status === 429) {
const data = await response.json().catch(() => ({})) as { message?: string }
throw new Error(data.message ?? 'Daily message limit reached.')
}

if (!response.ok) {
const text = await response.text()
throw new Error(`Rate limit check failed: ${text}`)
}
}
}
})
16 changes: 16 additions & 0 deletions layers/nuxi/agent/instructions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { defineDynamic, defineInstructions } from 'eve/instructions'
import { ADMIN_MCP_INSTRUCTIONS, canAccessAdminMcp } from './tools/admin-mcp.js'
import { buildInstructionsWithDate } from './lib/base-instructions.js'

export default defineDynamic({
events: {
'session.started': async (_event, ctx) => {
const auth = ctx.session.auth.current
const markdown = canAccessAdminMcp(auth)
? `${buildInstructionsWithDate()}\n\n${ADMIN_MCP_INSTRUCTIONS}`
: buildInstructionsWithDate()

return defineInstructions({ markdown })
}
}
})
Loading