diff --git a/SELF-HOSTING.md b/SELF-HOSTING.md index 15db2a0..2468e0f 100644 --- a/SELF-HOSTING.md +++ b/SELF-HOSTING.md @@ -33,7 +33,7 @@ Please note the following limitations when self-hosting: - **Billing**: Currently only supported through Stripe integration - **Custom Domains**: Only supported when deployed on Vercel -- **AI Features**: All AI functionality is channeled through ManagePrompt and requires their service +- **AI Features**: All AI functionality is channeled through OpenRouter ## Environment Configuration @@ -42,8 +42,6 @@ Make sure to configure the following in your environment files: - Database connection details (Supabase) - Authentication keys - Stripe keys (if using billing features) -- ManagePrompt API keys (if using AI features) - Any other third-party service credentials For detailed environment variable setup, refer to the `.env.example` files in the respective app directories. - diff --git a/apps/web/.env.example b/apps/web/.env.example index 7634391..a835c26 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -1,5 +1,3 @@ -NEXT_PUBLIC_PAGES_DOMAIN=http://localhost:3000 - # Supabase details from https://app.supabase.io NEXT_PUBLIC_SUPABASE_URL= NEXT_PUBLIC_SUPABASE_ANON_KEY= @@ -31,7 +29,12 @@ ARCJET_KEY= # CMS NEXT_PUBLIC_SANITY_PROJECT_ID=jeixxcw8 -# ManagePrompt -MANAGEPROMPT_SECRET= -MANAGEPROMPT_CHANGEGPT_WORKFLOW_ID= +# OpenRouter AI +OPENROUTER_API_KEY= +# GitHub changelog agent +GITHUB_APP_ID= +GITHUB_APP_PRIVATE_KEY= +GITHUB_WEBHOOK_SECRET= +GITHUB_APP_SLUG= +NEXT_PUBLIC_GITHUB_APP_URL= diff --git a/apps/web/components/dialogs/ai-expand-concept-prompt-dialog.component.tsx b/apps/web/components/dialogs/ai-expand-concept-prompt-dialog.component.tsx index 4637c9c..2da892a 100644 --- a/apps/web/components/dialogs/ai-expand-concept-prompt-dialog.component.tsx +++ b/apps/web/components/dialogs/ai-expand-concept-prompt-dialog.component.tsx @@ -1,9 +1,9 @@ +import { useCompletion } from "@ai-sdk/react"; import { SpinnerWithSpacing } from "@changes-page/ui"; -import { convertMarkdownToPlainText } from "@changes-page/utils"; import { Dialog, Transition } from "@headlessui/react"; import { LightningBoltIcon } from "@heroicons/react/solid"; -import { Fragment, useCallback, useEffect, useRef, useState } from "react"; -import { getStreamingUrl } from "../../utils/useAiAssistant"; +import { Fragment, useEffect, useRef } from "react"; +import { Streamdown } from "streamdown"; import { PrimaryButton } from "../core/buttons.component"; import { notifyError } from "../core/toast.component"; @@ -13,61 +13,21 @@ export default function AiExpandConceptPromptDialogComponent({ content, insertContentCallback, }) { - const [loading, setLoading] = useState(false); - const [result, setResult] = useState(null); const cancelButtonRef = useRef(null); - const expandConcept = useCallback(async (text) => { - setLoading(true); - - const { url } = await getStreamingUrl( - "wf_0075a2a911339f610bcfc404051cce3e" - ); - - const response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - content: text, - }), - }); - - if (!response.ok) { - notifyError("Too many requests"); - } - - // This data is a ReadableStream - const data = response.body; - if (!data) { - return; - } - - const reader = data.getReader(); - const decoder = new TextDecoder(); - let done = false; - - setLoading(false); - - while (!done) { - const { value, done: doneReading } = await reader.read(); - done = doneReading; - const chunkValue = decoder.decode(value); - setResult((prev) => (prev ?? "") + chunkValue); - } - }, []); + const { completion, complete, isLoading, setCompletion } = useCompletion({ + api: "/api/ai/expand-concept", + streamProtocol: "text", + onError: () => { + setOpen(false); + notifyError("Failed to process request, please contact support."); + }, + }); useEffect(() => { if (open && content) { - setLoading(true); - setResult(null); - - expandConcept(convertMarkdownToPlainText(content)).catch(() => { - setLoading(false); - setOpen(false); - notifyError("Failed to process request, please contact support."); - }); + setCompletion(""); + complete(content); } }, [open, content]); @@ -124,18 +84,22 @@ export default function AiExpandConceptPromptDialogComponent({ aria-hidden="true" /> - {loading ? "Loading..." : `Check out this draft`} + {isLoading && !completion + ? "Loading..." + : isLoading + ? "Expanding..." + : "Check out this draft"}
- {loading && } + {isLoading && !completion && } -

- {result} -

+
+ {completion} +
@@ -155,10 +119,10 @@ export default function AiExpandConceptPromptDialogComponent({ insertContentCallback(result)} + onClick={() => insertContentCallback(completion)} />
diff --git a/apps/web/components/dialogs/ai-prood-read-dialog.component.tsx b/apps/web/components/dialogs/ai-prood-read-dialog.component.tsx index 1a8eae9..8632fb0 100644 --- a/apps/web/components/dialogs/ai-prood-read-dialog.component.tsx +++ b/apps/web/components/dialogs/ai-prood-read-dialog.component.tsx @@ -1,66 +1,27 @@ +import { useCompletion } from "@ai-sdk/react"; import { SpinnerWithSpacing } from "@changes-page/ui"; -import { convertMarkdownToPlainText } from "@changes-page/utils"; import { Dialog, Transition } from "@headlessui/react"; import { LightningBoltIcon } from "@heroicons/react/solid"; -import { Fragment, useCallback, useEffect, useRef, useState } from "react"; -import { getStreamingUrl } from "../../utils/useAiAssistant"; +import { Fragment, useEffect, useRef } from "react"; +import { Streamdown } from "streamdown"; import { notifyError } from "../core/toast.component"; export default function AiProofReadDialogComponent({ open, setOpen, content }) { - const [loading, setLoading] = useState(false); - const [result, setResult] = useState(null); const cancelButtonRef = useRef(null); - const proofRead = useCallback(async (text) => { - setLoading(true); - - const { url } = await getStreamingUrl( - "wf_5a7eaceda859ee21c07771aaaecc9826" - ); - - const response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - content: text, - }), - }); - - if (!response.ok) { - notifyError("Too many requests"); - } - - const data = response.body; - if (!data) { - return; - } - - const reader = data.getReader(); - const decoder = new TextDecoder(); - let done = false; - - setLoading(false); - - while (!done) { - const { value, done: doneReading } = await reader.read(); - done = doneReading; - const chunkValue = decoder.decode(value); - setResult((prev) => (prev ?? "") + chunkValue); - } - }, []); + const { completion, complete, isLoading, setCompletion } = useCompletion({ + api: "/api/ai/proof-read", + streamProtocol: "text", + onError: () => { + setOpen(false); + notifyError("Failed to process request, please contact support."); + }, + }); useEffect(() => { if (open && content) { - setLoading(true); - setResult(null); - - proofRead(convertMarkdownToPlainText(content)).catch(() => { - setLoading(false); - setOpen(false); - notifyError("Failed to process request, please contact support."); - }); + setCompletion(""); + complete(content); } }, [open, content]); @@ -117,18 +78,22 @@ export default function AiProofReadDialogComponent({ open, setOpen, content }) { aria-hidden="true" /> - {loading ? "Loading..." : "Here is the proofread result!"} + {isLoading && !completion + ? "Loading..." + : isLoading + ? "Proofreading..." + : "Here is the proofread result!"}
- {loading && } + {isLoading && !completion && } -

- {result} -

+
+ {completion} +
diff --git a/apps/web/components/dialogs/ai-suggest-title-prompt-dialog.component.tsx b/apps/web/components/dialogs/ai-suggest-title-prompt-dialog.component.tsx index c2b24d9..36944f8 100644 --- a/apps/web/components/dialogs/ai-suggest-title-prompt-dialog.component.tsx +++ b/apps/web/components/dialogs/ai-suggest-title-prompt-dialog.component.tsx @@ -1,9 +1,7 @@ import { SpinnerWithSpacing } from "@changes-page/ui"; -import { convertMarkdownToPlainText } from "@changes-page/utils"; import { Dialog, Transition } from "@headlessui/react"; import { LightningBoltIcon } from "@heroicons/react/solid"; import { Fragment, useEffect, useRef, useState } from "react"; -import { promptSuggestTitle } from "../../utils/useAiAssistant"; import { notifyError } from "../core/toast.component"; export default function AiSuggestTitlePromptDialogComponent({ @@ -21,9 +19,12 @@ export default function AiSuggestTitlePromptDialogComponent({ setLoading(true); setSuggestions([]); - const text = convertMarkdownToPlainText(content); - - promptSuggestTitle(text) + fetch("/api/ai/suggest-title", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content }), + }) + .then((res) => res.json()) .then((suggestions) => { setSuggestions(suggestions); setLoading(false); diff --git a/apps/web/components/page-settings/github-agent.tsx b/apps/web/components/page-settings/github-agent.tsx new file mode 100644 index 0000000..3ec5c0a --- /dev/null +++ b/apps/web/components/page-settings/github-agent.tsx @@ -0,0 +1,284 @@ +import { IGitHubInstallations } from "@changes-page/supabase/types/github"; +import { Spinner } from "@changes-page/ui"; +import { ExternalLinkIcon, TrashIcon } from "@heroicons/react/outline"; +import { useEffect, useState } from "react"; +import { notifyError, notifySuccess } from "../core/toast.component"; +import WarningDialog from "../dialogs/warning-dialog.component"; + +export default function GitHubAgentSettings({ pageId }: { pageId: string }) { + const [installations, setInstallations] = useState( + [], + ); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(null); + const [deleteTarget, setDeleteTarget] = useState( + null, + ); + const [deleting, setDeleting] = useState(false); + + useEffect(() => { + fetchInstallations(); + }, [pageId]); + + async function fetchInstallations() { + try { + const res = await fetch( + `/api/integrations/github/installations?page_id=${pageId}`, + ); + const data = await res.json(); + if (data.error) { + notifyError(data.error.message); + } else { + setInstallations(data); + } + } catch (err) { + notifyError("Failed to load GitHub installations"); + } finally { + setLoading(false); + } + } + + async function updateInstallation( + installation: IGitHubInstallations, + updates: { enabled?: boolean; ai_instructions?: string | null }, + ) { + setSaving(installation.id); + try { + const res = await fetch("/api/integrations/github/installations", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + id: installation.id, + page_id: pageId, + ...updates, + }), + }); + const data = await res.json(); + if (data.error) { + notifyError(data.error.message); + } else { + setInstallations((prev) => + prev.map((i) => (i.id === installation.id ? data : i)), + ); + notifySuccess("Settings saved"); + } + } catch (err) { + notifyError("Failed to update settings"); + } finally { + setSaving(null); + } + } + + async function deleteInstallation() { + if (!deleteTarget) return; + setDeleting(true); + try { + const res = await fetch( + `/api/integrations/github/installations?id=${deleteTarget.id}&page_id=${pageId}`, + { method: "DELETE" }, + ); + const data = await res.json(); + if (data.error) { + notifyError(data.error.message); + } else { + setInstallations((prev) => + prev.filter((i) => i.id !== deleteTarget.id), + ); + notifySuccess("Repository disconnected"); + } + } catch (err) { + notifyError("Failed to disconnect repository"); + } finally { + setDeleting(false); + setDeleteTarget(null); + } + } + + const githubAppUrl = + process.env.NEXT_PUBLIC_GITHUB_APP_URL || + "https://github.com/apps/changespage"; + + return ( + <> + !open && setDeleteTarget(null)} + confirmCallback={deleteInstallation} + processing={deleting} + /> + +
+
+
+
+

+ GitHub Changelog Agent +

+

+ Automatically generate changelog drafts from your PRs by + mentioning @changespage. +

+
+
+
+
+
+ {loading ? ( +
+ +
+ ) : installations.length === 0 ? ( +
+ + + +

+ No repositories connected +

+

+ Connect a GitHub repository to start generating changelogs + from PRs. +

+ +
+ ) : ( +
+ {installations.map((installation) => ( +
+
+
+ + + +
+

+ {installation.repository_owner}/ + {installation.repository_name} +

+

+ Connected{" "} + {new Date( + installation.created_at, + ).toLocaleDateString()} +

+
+
+
+