From 1f4e6dfd34e7909290763a6cd19d06b0fa920da8 Mon Sep 17 00:00:00 2001 From: Enrico Ros Date: Wed, 14 Jun 2023 19:48:59 -0700 Subject: [PATCH] Move Publish to tRPC --- pages/api/publish.ts | 50 ----------- src/apps/chat/Chat.tsx | 34 ++++++-- src/modules/pastegg/PublishedModal.tsx | 6 +- src/modules/pastegg/pastegg.client.ts | 71 --------------- src/modules/pastegg/pastegg.server.ts | 50 ----------- src/modules/pastegg/pastegg.types.ts | 25 ------ src/modules/pastegg/publish.router.ts | 114 +++++++++++++++++++++++++ src/modules/trpc/trpc.router.ts | 2 + 8 files changed, 148 insertions(+), 204 deletions(-) delete mode 100644 pages/api/publish.ts delete mode 100644 src/modules/pastegg/pastegg.client.ts delete mode 100644 src/modules/pastegg/pastegg.server.ts create mode 100644 src/modules/pastegg/publish.router.ts diff --git a/pages/api/publish.ts b/pages/api/publish.ts deleted file mode 100644 index 2ef0a0e43..000000000 --- a/pages/api/publish.ts +++ /dev/null @@ -1,50 +0,0 @@ -// noinspection ExceptionCaughtLocallyJS - -import { NextRequest, NextResponse } from 'next/server'; - -import { PasteGG } from '~/modules/pastegg/pastegg.types'; -import { pasteGgPost } from '~/modules/pastegg/pastegg.server'; - - -/** - * 'Proxy' that uploads a file to paste.gg. - * Called by the UI to avoid CORS issues, as the browser cannot post directly to paste.gg. - */ -export default async function handler(req: NextRequest) { - - try { - - const { to, title, fileContent, fileName, origin }: PasteGG.API.Publish.RequestBody = await req.json(); - if (req.method !== 'POST' || to !== 'paste.gg' || !title || !fileContent || !fileName) - throw new Error('Invalid options'); - - const paste = await pasteGgPost(title, fileName, fileContent, origin); - console.log(`Posted to paste.gg`, paste); - - if (paste?.status !== 'success') - throw new Error(`${paste?.error || 'Unknown error'}: ${paste?.message || 'Paste.gg Error'}`); - - return new NextResponse(JSON.stringify({ - type: 'success', - url: `https://paste.gg/${paste.result.id}`, - expires: paste.result.expires || 'never', - deletionKey: paste.result.deletion_key || 'none', - created: paste.result.created_at, - } satisfies PasteGG.API.Publish.Response)); - - } catch (error) { - - console.error('api/publish error:', error); - return new NextResponse(JSON.stringify({ - type: 'error', - error: error?.toString() || 'Network issue', - } satisfies PasteGG.API.Publish.Response), { status: 500 }); - - } - -} - -// noinspection JSUnusedGlobalSymbols -export const config = { - runtime: 'edge', -}; \ No newline at end of file diff --git a/src/apps/chat/Chat.tsx b/src/apps/chat/Chat.tsx index 8c1bee6a6..07fc2d91a 100644 --- a/src/apps/chat/Chat.tsx +++ b/src/apps/chat/Chat.tsx @@ -3,14 +3,15 @@ import { shallow } from 'zustand/shallow'; import { useTheme } from '@mui/joy'; +import type { PublishedSchema } from '~/modules/pastegg/publish.router'; import { CmdRunProdia } from '~/modules/prodia/prodia.client'; import { CmdRunReact } from '~/modules/aifn/react/react'; -import { PasteGG } from '~/modules/pastegg/pastegg.types'; import { PublishedModal } from '~/modules/pastegg/PublishedModal'; -import { callPublish } from '~/modules/pastegg/pastegg.client'; +import { apiAsync } from '~/modules/trpc/trpc.client'; import { imaginePromptFromText } from '~/modules/aifn/imagine/imaginePromptFromText'; import { useModelsStore } from '~/modules/llms/store-llms'; +import { Brand } from '~/common/brand'; import { ConfirmationModal } from '~/common/components/ConfirmationModal'; import { Link } from '~/common/components/Link'; import { conversationToMarkdown } from '~/common/util/conversationToMarkdown'; @@ -36,6 +37,18 @@ const SPECIAL_ID_ALL_CHATS = 'all-chats'; export type SendModeId = 'immediate' | 'react'; +/// Returns a pretty link to the current page, for promo +function linkToOrigin() { + let origin = (typeof window !== 'undefined') ? window.location.href : ''; + if (!origin || origin.includes('//localhost')) + origin = Brand.URIs.OpenRepo; + origin = origin.replace('https://', ''); + if (origin.endsWith('/')) + origin = origin.slice(0, -1); + return origin; +} + + export function Chat() { // state @@ -43,7 +56,7 @@ export function Chat() { const [clearConfirmationId, setClearConfirmationId] = React.useState(null); const [deleteConfirmationId, setDeleteConfirmationId] = React.useState(null); const [publishConversationId, setPublishConversationId] = React.useState(null); - const [publishResponse, setPublishResponse] = React.useState(null); + const [publishResponse, setPublishResponse] = React.useState(null); const [conversationImportOutcome, setConversationImportOutcome] = React.useState(null); const conversationFileInputRef = React.useRef(null); @@ -154,8 +167,19 @@ export function Chat() { setPublishConversationId(null); if (conversation) { const markdownContent = conversationToMarkdown(conversation, !useUIPreferencesStore.getState().showSystemMessages); - const publishResponse = await callPublish('paste.gg', markdownContent); - setPublishResponse(publishResponse); + try { + const paste = await apiAsync.publish.publish.query({ + to: 'paste.gg', + title: '🤖💬 Chat Conversation', + fileContent: markdownContent, + fileName: 'my-chat.md', + origin: linkToOrigin(), + }); + setPublishResponse(paste); + } catch (error: any) { + alert(`Failed to publish conversation: ${error?.message ?? error?.toString() ?? 'unknown error'}`); + setPublishResponse(null); + } } } }; diff --git a/src/modules/pastegg/PublishedModal.tsx b/src/modules/pastegg/PublishedModal.tsx index 87d03ba90..ca8991713 100644 --- a/src/modules/pastegg/PublishedModal.tsx +++ b/src/modules/pastegg/PublishedModal.tsx @@ -4,15 +4,15 @@ import { Alert, Box, Button, Divider, Input, Modal, ModalDialog, Stack, Typograp import { Link } from '~/common/components/Link'; -import { PasteGG } from './pastegg.types'; +import type { PublishedSchema } from './publish.router'; /** * Displays the result of a Paste.gg paste as a modal dialog. * This is to give the user the chance to write down the deletion key, mainly. */ -export function PublishedModal(props: { onClose: () => void, response: PasteGG.API.Publish.Response, open: boolean }) { - if (!props.response || props.response.type !== 'success') +export function PublishedModal(props: { onClose: () => void, response: PublishedSchema, open: boolean }) { + if (!props.response || !props.response.url) return null; const { url, deletionKey, expires } = props.response; diff --git a/src/modules/pastegg/pastegg.client.ts b/src/modules/pastegg/pastegg.client.ts deleted file mode 100644 index a1e1533cf..000000000 --- a/src/modules/pastegg/pastegg.client.ts +++ /dev/null @@ -1,71 +0,0 @@ -// noinspection ExceptionCaughtLocallyJS - -import { Brand } from '~/common/brand'; - -import { PasteGG } from './pastegg.types'; - - -/** - * Publishes a markdown rendering of the conversation to a service of choice - * - * **Called by the UI to render the data and post it to the API** - * - * NOTE: we are calling our own API here, which in turn calls the paste.gg API. We do this - * because the browser wouldn't otherwise allow us to perform a CORS to paste.gg - * - * @param gg Only one service for now - * @param fileContent the markdown content to publish - * @param fileName optional, defaults to 'my-chat.md' - */ -export async function callPublish(gg: 'paste.gg', fileContent: string, fileName: string = 'my-chat.md'): Promise { - - const body: PasteGG.API.Publish.RequestBody = { - to: gg, - title: '🤖💬 Chat Conversation', - fileContent, - fileName, - origin: getOrigin(), - }; - - try { - - const response = await fetch('/api/publish', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - - if (response.ok) { - const paste: PasteGG.API.Publish.Response = await response.json(); - - if (paste.type === 'success') { - // we log this to the console for extra safety - console.log('Data from your paste to \'paste.gg\'', paste); - return paste; - } - - if (paste.type === 'error') - throw new Error(`Failed to send the paste: ${paste.error}`); - } - - throw new Error(`Failed to publish conversation: ${response.status}: ${response.statusText}`); - - } catch (error) { - console.error('Publish issue', error); - alert(`Publish issue: ${error}`); - } - - return null; -} - - -/// Returns a pretty link to the current page, for promo -function getOrigin() { - let origin = (typeof window !== 'undefined') ? window.location.href : ''; - if (!origin || origin.includes('//localhost')) - origin = Brand.URIs.OpenRepo; - origin = origin.replace('https://', ''); - if (origin.endsWith('/')) - origin = origin.slice(0, -1); - return origin; -} diff --git a/src/modules/pastegg/pastegg.server.ts b/src/modules/pastegg/pastegg.server.ts deleted file mode 100644 index 37334a647..000000000 --- a/src/modules/pastegg/pastegg.server.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { PasteGG } from './pastegg.types'; - -/** - * Post a paste to paste.gg - * [called by the API] - * - API description: https://github.com/ascclemens/paste/blob/master/api.md - * - * @param title Title of the paste - * @param fileName File with extension, e.g. 'conversation.md' - * @param fileContent Textual content (e.g. markdown text) - * @param origin the URL of the page that generated the paste - * @param expireDays Number of days after which the paste will expire (0 = never expires, default = 30) - */ -export async function pasteGgPost(title: string, fileName: string, fileContent: string, origin: string, expireDays: number = 30): Promise { - - // Default: expire in 30 days - let expires = null; - if (expireDays && expireDays >= 1) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expireDays); - expires = expirationDate.toISOString(); - } - - const pasteData: PasteGG.Wire.PasteRequest = { - name: title, - description: `Generated by ${origin} 🚀`, - visibility: 'unlisted', - ...(expires && { expires }), - files: [{ - name: fileName, - content: { - format: 'text', - value: fileContent, - }, - }], - }; - - const response = await fetch('https://api.paste.gg/v1/pastes', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(pasteData), - }); - - if (response.ok) - return await response.json(); - - console.error(`Failed to create paste: ${response.status}`, response); - throw new Error(`Failed to create paste: ${response.statusText}`); - -} \ No newline at end of file diff --git a/src/modules/pastegg/pastegg.types.ts b/src/modules/pastegg/pastegg.types.ts index 3d5932ec6..d9bd51bca 100644 --- a/src/modules/pastegg/pastegg.types.ts +++ b/src/modules/pastegg/pastegg.types.ts @@ -1,30 +1,5 @@ export namespace PasteGG { - /// Client (Browser) -> Server (Next.js) - export namespace API { - export namespace Publish { - export interface RequestBody { - to: 'paste.gg'; - title: string; - fileContent: string; - fileName: string; - origin: string; - } - - export type Response = { - type: 'success'; - url: string; - expires: string; - deletionKey: string; - created: string; - } | { - type: 'error'; - error: string - }; - } - } - - /// This is the upstream API, for Server (Next.js) -> Upstream Server export namespace Wire { export interface PasteRequest { name?: string; diff --git a/src/modules/pastegg/publish.router.ts b/src/modules/pastegg/publish.router.ts new file mode 100644 index 000000000..021ba9117 --- /dev/null +++ b/src/modules/pastegg/publish.router.ts @@ -0,0 +1,114 @@ +// noinspection ExceptionCaughtLocallyJS + +import { TRPCError } from '@trpc/server'; +import { z } from 'zod'; + +import { createTRPCRouter, publicProcedure } from '~/modules/trpc/trpc.server'; + +import { PasteGG } from './pastegg.types'; + + +const inputSchema = z.object({ + to: z.enum(['paste.gg']), + title: z.string(), + fileContent: z.string(), + fileName: z.string(), + origin: z.string(), +}); + +const outputSuccessSchema = z.object({ + url: z.string(), + expires: z.string(), + deletionKey: z.string(), + created: z.string(), +}); + +export type PublishedSchema = z.infer; + + +export const publishRouter = createTRPCRouter({ + + /** + * Publish a file (with title, content, name) to a sharing service + * + * For now only 'paste.gg' is supported + */ + publish: publicProcedure + .input(inputSchema) + .output(outputSuccessSchema) + .query(async ({ input }) => { + + const { to, title, fileContent, fileName, origin } = input; + if (to !== 'paste.gg' || !title || !fileContent || !fileName) + throw new Error('Invalid options'); + + const paste = await postToPasteGGOrThrow(title, fileName, fileContent, origin); + if (paste?.status !== 'success') + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `${paste?.error || 'Unknown error'}. ${paste?.message || 'Unknown cause'}`.trim(), + }); + + const result = paste.result; + return { + url: `https://paste.gg/${result.id}`, + expires: result.expires || 'never', + deletionKey: result.deletion_key || 'none', + created: result.created_at, + }; + }), + +}); + + +/** + * Post a paste to paste.gg + * [called by the API] + * - API description: https://github.com/ascclemens/paste/blob/master/api.md + * + * @param title Title of the paste + * @param fileName File with extension, e.g. 'conversation.md' + * @param fileContent Textual content (e.g. markdown text) + * @param origin the URL of the page that generated the paste + * @param expireDays Number of days after which the paste will expire (0 = never expires, default = 30) + */ +async function postToPasteGGOrThrow(title: string, fileName: string, fileContent: string, origin: string, expireDays: number = 30): Promise { + + // Default: expire in 30 days + let expires = null; + if (expireDays && expireDays >= 1) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expireDays); + expires = expirationDate.toISOString(); + } + + const pasteData: PasteGG.Wire.PasteRequest = { + name: title, + description: `Generated by ${origin} 🚀`, + visibility: 'unlisted', + ...(expires && { expires }), + files: [{ + name: fileName, + content: { + format: 'text', + value: fileContent, + }, + }], + }; + + const response = await fetch('https://api.paste.gg/v1/pastes', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(pasteData), + }); + + if (response.ok) + return await response.json(); + + console.error(`Failed to create paste: ${response.status}`, response.statusText); + const errorResponse = await response.json(); + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `Failed to create paste: ${response.statusText}. ${errorResponse?.error || 'Cause'}: ${errorResponse?.message || 'unknown'}.`, + }); +} \ No newline at end of file diff --git a/src/modules/trpc/trpc.router.ts b/src/modules/trpc/trpc.router.ts index 4b1577bb8..a30111a5a 100644 --- a/src/modules/trpc/trpc.router.ts +++ b/src/modules/trpc/trpc.router.ts @@ -4,6 +4,7 @@ import { elevenlabsRouter } from '~/modules/elevenlabs/elevenlabs.router'; import { googleSearchRouter } from '~/modules/google/search.router'; import { openAIRouter } from '~/modules/llms/openai/openai.router'; import { prodiaRouter } from '~/modules/prodia/prodia.router'; +import { publishRouter } from '~/modules/pastegg/publish.router'; /** * This is the primary router for your server. @@ -15,6 +16,7 @@ export const appRouter = createTRPCRouter({ googleSearch: googleSearchRouter, openai: openAIRouter, prodia: prodiaRouter, + publish: publishRouter, }); // export type definition of API