diff --git a/.gitignore b/.gitignore index 5158299..80533e9 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,5 @@ logs # Workflow files .workflow-data/ .swc/ +.vercel +.env*.local diff --git a/app/composables/useEditorCompletion.ts b/app/composables/useEditorCompletion.ts index 6086aca..551cd79 100644 --- a/app/composables/useEditorCompletion.ts +++ b/app/composables/useEditorCompletion.ts @@ -5,7 +5,7 @@ import { Completion } from '~/components/editor/CompletionExtension' import type { CompletionStorage } from '~/components/editor/CompletionExtension' import { ref, computed, watch, useToast } from '#imports' -type CompletionMode = 'continue' | 'fix' | 'extend' | 'reduce' | 'simplify' | 'summarize' | 'translate' | 'reply' +type CompletionMode = 'continue' | 'fix' | 'extend' | 'reduce' | 'simplify' | 'summarize' | 'translate' | 'reply' | 'savoir-reply' export interface UseEditorCompletionOptions { api?: string @@ -226,6 +226,45 @@ export function useEditorCompletion(editorRef: Ref<{ editor: Editor | undefined complete('Generate a helpful reply to this issue/PR') } + async function triggerSavoirReply(editor: Editor) { + if (isLoading.value) return + if (!options.api) return + + mode.value = 'savoir-reply' + isLoading.value = true + getCompletionStorage()?.clearSuggestion() + + // Clear editor content + editor.commands.clearContent() + + try { + // Derive savoir endpoint from completion API (same base path) + const savoirApi = options.api.replace(/\/completion$/, '/savoir') + const { response } = await $fetch<{ response: string }>(savoirApi, { method: 'POST' }) + + // Insert with markdown parsing + editor.chain() + .focus() + .insertContentAt(0, response, { contentType: 'markdown' }) + .run() + } catch (error: any) { + let description = 'An error occurred while generating Savoir reply.' + try { + const parsed = JSON.parse(error.message) + description = parsed.message || parsed.statusMessage || description + } catch { + description = error.data?.message || error.message || description + } + toast.add({ + title: 'Savoir reply failed', + description, + color: 'error' + }) + } finally { + isLoading.value = false + } + } + // Configure Completion extension const extension = Completion.configure({ onTrigger: (editor) => { @@ -316,6 +355,15 @@ export function useEditorCompletion(editorRef: Ref<{ editor: Editor | undefined }, isActive: () => !!(isLoading.value && mode.value === 'reply'), isDisabled: () => !!isLoading.value + }, + aiSavoirReply: { + canExecute: () => !isLoading.value, + execute: (editor: Editor) => { + triggerSavoirReply(editor) + return editor.chain() + }, + isActive: () => !!(isLoading.value && mode.value === 'savoir-reply'), + isDisabled: () => !!isLoading.value } } diff --git a/app/composables/useEditorToolbar.ts b/app/composables/useEditorToolbar.ts index 0c2a4e1..28c0149 100644 --- a/app/composables/useEditorToolbar.ts +++ b/app/composables/useEditorToolbar.ts @@ -70,9 +70,13 @@ export function useEditorToolbar(_customHandlers }] }] - // Add "Suggest reply" option if enabled + // Add "Suggest reply" options if enabled if (suggestReply) { aiItems.unshift({ + kind: 'aiSavoirReply', + icon: 'i-lucide-book-open', + label: 'Suggest reply (with Savoir)' + }, { kind: 'aiReply', icon: 'i-lucide-reply', label: 'Suggest reply' diff --git a/nuxt.config.ts b/nuxt.config.ts index 3eadbb7..5165ef6 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -70,6 +70,10 @@ export default defineNuxtConfig({ privateKey: '', webhookSecret: '' }, + savoir: { + apiUrl: '', + apiKey: '' + }, public: { github: { appSlug: '' diff --git a/package.json b/package.json index 9a35ef9..6592759 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@tiptap/extension-table": "^3.19.0", "@tiptap/pm": "^3.19.0", "@tiptap/vue-3": "^3.19.0", + "@savoir/sdk": "0.1.0-alpha.2", "@vercel/analytics": "^1.6.1", "@vite-pwa/nuxt": "1.1.1", "@vueuse/core": "^14.2.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f686be0..1a2a06f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: '@octokit/auth-app': specifier: ^8.2.0 version: 8.2.0 + '@savoir/sdk': + specifier: 0.1.0-alpha.2 + version: 0.1.0-alpha.2(ai@6.0.77(zod@4.3.6)) '@tiptap/core': specifier: ^3.19.0 version: 3.19.0(@tiptap/pm@3.19.0) @@ -2927,6 +2930,15 @@ packages: cpu: [x64] os: [win32] + '@savoir/sdk@0.1.0-alpha.2': + resolution: {integrity: sha512-0MhOHPBwrAoFDupH9MVGPlrrQAxHI9TS20qkNdjd6X6FvgmKQdvy+VMTw15OiY1GhU/jitTvKA7Ocp871xEORA==} + engines: {node: '>=20.0.0'} + peerDependencies: + ai: '>=6.0.77' + peerDependenciesMeta: + ai: + optional: true + '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} @@ -11983,6 +11995,12 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.57.1': optional: true + '@savoir/sdk@0.1.0-alpha.2(ai@6.0.77(zod@4.3.6))': + dependencies: + zod: 4.3.6 + optionalDependencies: + ai: 6.0.77(zod@4.3.6) + '@sec-ant/readable-stream@0.4.1': {} '@shikijs/core@3.22.0': diff --git a/server/api/repositories/[owner]/[name]/issues/[number]/savoir.post.ts b/server/api/repositories/[owner]/[name]/issues/[number]/savoir.post.ts new file mode 100644 index 0000000..7508b39 --- /dev/null +++ b/server/api/repositories/[owner]/[name]/issues/[number]/savoir.post.ts @@ -0,0 +1,181 @@ +import { ToolLoopAgent, stepCountIs } from 'ai' +import { createSavoir } from '@savoir/sdk' +import { eq, and } from 'drizzle-orm' +import { db, schema } from '@nuxthub/db' + +export default defineEventHandler(async (event) => { + const { user } = await requireUserSession(event) + const owner = getRouterParam(event, 'owner') + const name = getRouterParam(event, 'name') + const numberParam = getRouterParam(event, 'number') + + if (!owner || !name || !numberParam) { + throw createError({ statusCode: 400, message: 'Owner, name, and issue number are required' }) + } + + const issueNumber = parseInt(numberParam) + if (isNaN(issueNumber)) { + throw createError({ statusCode: 400, message: 'Invalid issue number' }) + } + + // Find repository + const fullName = `${owner}/${name}` + const repository = await db.query.repositories.findFirst({ + where: eq(schema.repositories.fullName, fullName) + }) + + if (!repository) { + throw createError({ statusCode: 404, message: 'Repository not found' }) + } + + // Check user has access + await requireRepositoryAccess(user.id, repository.id) + + // Find issue with labels and comments + const issue = await db.query.issues.findFirst({ + where: and( + eq(schema.issues.repositoryId, repository.id), + eq(schema.issues.number, issueNumber) + ), + with: { + labels: { + with: { + label: true + } + }, + comments: { + with: { + user: true + }, + orderBy: (comments, { asc }) => [asc(comments.createdAt)], + limit: 20 + } + } + }) + + if (!issue) { + throw createError({ statusCode: 404, message: 'Issue not found' }) + } + + // Get user's AI settings (token and model) + const { token: userToken, model: userModel } = await getUserAiSettings(user.id) + const userGateway = createUserGateway(userToken) + + if (!userGateway) { + throw createError({ + statusCode: 403, + message: 'Vercel AI Gateway token not configured. Add your token in Settings to enable AI features.' + }) + } + + // Get Savoir config from runtime + const { savoir: savoirConfig } = useRuntimeConfig() + + if (!savoirConfig.apiUrl) { + throw createError({ statusCode: 500, message: 'Savoir API is not configured' }) + } + + // Init Savoir SDK + const savoir = createSavoir({ + apiUrl: savoirConfig.apiUrl, + source: 'volta' + }) + + // Optionally fetch agent config for custom instructions + let agentInstructions = '' + try { + const agentConfig = await savoir.getAgentConfig() + if (agentConfig?.additionalPrompt) { + agentInstructions = agentConfig.additionalPrompt + } + } catch { + // Ignore - agent config is optional + } + + // Build issue context + const issueContext = buildIssueContext(issue, repository) + + // Create agent with Savoir tools + const agent = new ToolLoopAgent({ + model: userGateway(userModel), + instructions: `You are a documentation assistant that helps answer GitHub issues by searching project documentation. + +${agentInstructions} + +Use the bash tool to search the documentation and find relevant information. Common commands: +- \`find . -name "*.md" | head -20\` to discover documentation files +- \`grep -r "keyword" --include="*.md" -l\` to find files mentioning a topic +- \`cat path/to/file.md\` to read documentation content + +CRITICAL OUTPUT RULES: +- Your final response will be posted DIRECTLY as a GitHub comment. Write ONLY the comment body. +- Do NOT include any preamble, thinking, or meta-commentary (e.g. "Let me search...", "Perfect! Now I have...", "Response to Issue #X"). +- Do NOT include a title or heading at the top of your response. +- Start directly with the helpful content addressing the issue. +- Use markdown formatting appropriate for a GitHub comment. +- Be concise and actionable. +- Include relevant code examples or configuration snippets from the docs. +- Reference specific documentation pages when applicable. + +Here is the issue context: + +${issueContext}`, + tools: savoir.tools, + stopWhen: stepCountIs(12) + }) + + const startTime = Date.now() + + const result = await agent.generate({ + prompt: `Search the documentation to find information relevant to this issue and write a helpful response that can be posted directly as a GitHub comment. + +Issue #${issue.number}: ${issue.title} +${issue.body ? `\n${issue.body}` : ''}` + }) + + savoir.reportUsage(result, { + durationMs: Date.now() - startTime + }) + + return { response: result.text } +}) + +function buildIssueContext( + issue: { + number: number + title: string + body: string | null + pullRequest: boolean + labels: { label: { name: string } }[] + comments: { user: { login: string } | null, body: string }[] + }, + repository: { fullName: string, description: string | null } +): string { + const parts: string[] = [] + + parts.push(`Repository: ${repository.fullName}`) + if (repository.description) { + parts.push(`Description: ${repository.description}`) + } + + const type = issue.pullRequest ? 'Pull Request' : 'Issue' + parts.push(`\n${type} #${issue.number}: ${issue.title}`) + + if (issue.body) { + parts.push(`\nBody:\n${issue.body}`) + } + + if (issue.labels.length > 0) { + parts.push(`\nCurrent labels: ${issue.labels.map(l => l.label.name).join(', ')}`) + } + + if (issue.comments.length > 0) { + parts.push(`\nComments (${issue.comments.length}):`) + for (const comment of issue.comments.slice(0, 10)) { + const author = comment.user?.login ?? 'unknown' + parts.push(`@${author}: ${comment.body.slice(0, 500)}${comment.body.length > 500 ? '...' : ''}`) + } + } + + return parts.join('\n') +}