Move Publish to tRPC

This commit is contained in:
Enrico Ros
2023-06-14 19:48:59 -07:00
parent e8cc60dc62
commit 1f4e6dfd34
8 changed files with 148 additions and 204 deletions
-50
View File
@@ -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',
};
+29 -5
View File
@@ -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<string | null>(null);
const [deleteConfirmationId, setDeleteConfirmationId] = React.useState<string | null>(null);
const [publishConversationId, setPublishConversationId] = React.useState<string | null>(null);
const [publishResponse, setPublishResponse] = React.useState<PasteGG.API.Publish.Response | null>(null);
const [publishResponse, setPublishResponse] = React.useState<PublishedSchema | null>(null);
const [conversationImportOutcome, setConversationImportOutcome] = React.useState<ImportedOutcome | null>(null);
const conversationFileInputRef = React.useRef<HTMLInputElement>(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);
}
}
}
};
+3 -3
View File
@@ -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;
-71
View File
@@ -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<PasteGG.API.Publish.Response | null> {
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;
}
-50
View File
@@ -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<PasteGG.Wire.PasteResponse> {
// 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}`);
}
-25
View File
@@ -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;
+114
View File
@@ -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<typeof outputSuccessSchema>;
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<PasteGG.Wire.PasteResponse> {
// 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'}.`,
});
}
+2
View File
@@ -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