mirror of
https://github.com/enricoros/big-AGI.git
synced 2026-05-10 21:50:14 -07:00
Move Publish to tRPC
This commit is contained in:
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`);
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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'}.`,
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user