Refactor to use newver v4 version of Vercel AI package

This commit is contained in:
eduardruzga 2024-12-09 17:26:33 +02:00
parent 6e61a4fb95
commit fcb61ba499
9 changed files with 441 additions and 608 deletions

View File

@ -112,13 +112,22 @@ export const ChatImpl = memo(
body: { body: {
apiKeys, apiKeys,
}, },
sendExtraMessageFields: true,
onError: (error) => { onError: (error) => {
logger.error('Request failed\n\n', error); logger.error('Request failed\n\n', error);
toast.error( toast.error(
'There was an error processing your request: ' + (error.message ? error.message : 'No details were returned'), 'There was an error processing your request: ' + (error.message ? error.message : 'No details were returned'),
); );
}, },
onFinish: () => { onFinish: (message, response) => {
const usage = response.usage;
if (usage) {
console.log('Token usage:', usage);
// You can now use the usage data as needed
}
logger.debug('Finished streaming'); logger.debug('Finished streaming');
}, },
initialMessages, initialMessages,

View File

@ -1,8 +1,8 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import type { Message } from 'ai'; import type { Message } from 'ai';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { MAX_FILES, isBinaryFile, shouldIncludeFile } from '../../utils/fileUtils'; import { MAX_FILES, isBinaryFile, shouldIncludeFile } from '~/utils/fileUtils';
import { createChatFromFolder } from '../../utils/folderImport'; import { createChatFromFolder } from '~/utils/folderImport';
interface ImportFolderButtonProps { interface ImportFolderButtonProps {
className?: string; className?: string;
@ -17,12 +17,14 @@ export const ImportFolderButton: React.FC<ImportFolderButtonProps> = ({ classNam
if (allFiles.length > MAX_FILES) { if (allFiles.length > MAX_FILES) {
toast.error( toast.error(
`This folder contains ${allFiles.length.toLocaleString()} files. This product is not yet optimized for very large projects. Please select a folder with fewer than ${MAX_FILES.toLocaleString()} files.` `This folder contains ${allFiles.length.toLocaleString()} files. This product is not yet optimized for very large projects. Please select a folder with fewer than ${MAX_FILES.toLocaleString()} files.`,
); );
return; return;
} }
const folderName = allFiles[0]?.webkitRelativePath.split('/')[0] || 'Unknown Folder'; const folderName = allFiles[0]?.webkitRelativePath.split('/')[0] || 'Unknown Folder';
setIsLoading(true); setIsLoading(true);
const loadingToast = toast.loading(`Importing ${folderName}...`); const loadingToast = toast.loading(`Importing ${folderName}...`);
try { try {

View File

@ -23,6 +23,7 @@ export const SendButton = ({ show, isStreaming, disabled, onClick }: SendButtonP
disabled={disabled} disabled={disabled}
onClick={(event) => { onClick={(event) => {
event.preventDefault(); event.preventDefault();
if (!disabled) { if (!disabled) {
onClick?.(event); onClick?.(event);
} }

View File

@ -8,17 +8,15 @@ export async function action(args: ActionFunctionArgs) {
return chatAction(args); return chatAction(args);
} }
function parseCookies(cookieHeader: string) { function parseCookies(cookieHeader: string): Record<string, string> {
const cookies: any = {}; const cookies: Record<string, string> = {};
// Split the cookie string by semicolons and spaces
const items = cookieHeader.split(';').map((cookie) => cookie.trim()); const items = cookieHeader.split(';').map((cookie) => cookie.trim());
items.forEach((item) => { items.forEach((item) => {
const [name, ...rest] = item.split('='); const [name, ...rest] = item.split('=');
if (name && rest) { if (name && rest) {
// Decode the name and value, and join value parts in case it contains '='
const decodedName = decodeURIComponent(name.trim()); const decodedName = decodeURIComponent(name.trim());
const decodedValue = decodeURIComponent(rest.join('=').trim()); const decodedValue = decodeURIComponent(rest.join('=').trim());
cookies[decodedName] = decodedValue; cookies[decodedName] = decodedValue;
@ -35,16 +33,15 @@ async function chatAction({ context, request }: ActionFunctionArgs) {
}>(); }>();
const cookieHeader = request.headers.get('Cookie'); const cookieHeader = request.headers.get('Cookie');
// Parse the cookie's value (returns an object or null if no cookie exists)
const apiKeys = JSON.parse(parseCookies(cookieHeader || '').apiKeys || '{}'); const apiKeys = JSON.parse(parseCookies(cookieHeader || '').apiKeys || '{}');
const stream = new SwitchableStream(); const stream = new SwitchableStream();
try { try {
const options: StreamingOptions = { const options: StreamingOptions = {
toolChoice: 'none', toolChoice: 'none',
onFinish: async ({ text: content, finishReason }) => { onFinish: async ({ text: content, finishReason, usage }) => {
console.log('usage', usage);
if (finishReason !== 'length') { if (finishReason !== 'length') {
return stream.close(); return stream.close();
} }
@ -62,13 +59,12 @@ async function chatAction({ context, request }: ActionFunctionArgs) {
const result = await streamText(messages, context.cloudflare.env, options, apiKeys); const result = await streamText(messages, context.cloudflare.env, options, apiKeys);
return stream.switchSource(result.toAIStream()); return stream.switchSource(result.toDataStream());
}, },
}; };
const result = await streamText(messages, context.cloudflare.env, options, apiKeys); const result = await streamText(messages, context.cloudflare.env, options, apiKeys);
stream.switchSource(result.toDataStream());
stream.switchSource(result.toAIStream());
return new Response(stream.readable, { return new Response(stream.readable, {
status: 200, status: 200,
@ -77,7 +73,7 @@ async function chatAction({ context, request }: ActionFunctionArgs) {
}, },
}); });
} catch (error: any) { } catch (error: any) {
console.log(error); console.error(error);
if (error.message?.includes('API key')) { if (error.message?.includes('API key')) {
throw new Response('Invalid or missing API key', { throw new Response('Invalid or missing API key', {

View File

@ -1,5 +1,6 @@
import { type ActionFunctionArgs } from '@remix-run/cloudflare'; import { type ActionFunctionArgs } from '@remix-run/cloudflare';
import { StreamingTextResponse, parseStreamPart } from 'ai';
//import { StreamingTextResponse, parseStreamPart } from 'ai';
import { streamText } from '~/lib/.server/llm/stream-text'; import { streamText } from '~/lib/.server/llm/stream-text';
import { stripIndents } from '~/utils/stripIndent'; import { stripIndents } from '~/utils/stripIndent';
import type { ProviderInfo } from '~/types/model'; import type { ProviderInfo } from '~/types/model';
@ -45,32 +46,32 @@ async function enhancerAction({ context, request }: ActionFunctionArgs) {
`[Model: ${model}]\n\n[Provider: ${providerName}]\n\n` + `[Model: ${model}]\n\n[Provider: ${providerName}]\n\n` +
stripIndents` stripIndents`
You are a professional prompt engineer specializing in crafting precise, effective prompts. You are a professional prompt engineer specializing in crafting precise, effective prompts.
Your task is to enhance prompts by making them more specific, actionable, and effective. Your task is to enhance prompts by making them more specific, actionable, and effective.
I want you to improve the user prompt that is wrapped in \`<original_prompt>\` tags. I want you to improve the user prompt that is wrapped in \`<original_prompt>\` tags.
For valid prompts: For valid prompts:
- Make instructions explicit and unambiguous - Make instructions explicit and unambiguous
- Add relevant context and constraints - Add relevant context and constraints
- Remove redundant information - Remove redundant information
- Maintain the core intent - Maintain the core intent
- Ensure the prompt is self-contained - Ensure the prompt is self-contained
- Use professional language - Use professional language
For invalid or unclear prompts: For invalid or unclear prompts:
- Respond with a clear, professional guidance message - Respond with clear, professional guidance
- Keep responses concise and actionable - Keep responses concise and actionable
- Maintain a helpful, constructive tone - Maintain a helpful, constructive tone
- Focus on what the user should provide - Focus on what the user should provide
- Use a standard template for consistency - Use a standard template for consistency
IMPORTANT: Your response must ONLY contain the enhanced prompt text. IMPORTANT: Your response must ONLY contain the enhanced prompt text.
Do not include any explanations, metadata, or wrapper tags. Do not include any explanations, metadata, or wrapper tags.
<original_prompt> <original_prompt>
${message} ${message}
</original_prompt> </original_prompt>
`, `,
}, },
], ],
context.cloudflare.env, context.cloudflare.env,
@ -85,7 +86,7 @@ async function enhancerAction({ context, request }: ActionFunctionArgs) {
for (const line of lines) { for (const line of lines) {
try { try {
const parsed = parseStreamPart(line); const parsed = JSON.parse(line);
if (parsed.type === 'text') { if (parsed.type === 'text') {
controller.enqueue(encoder.encode(parsed.value)); controller.enqueue(encoder.encode(parsed.value));
@ -100,7 +101,12 @@ async function enhancerAction({ context, request }: ActionFunctionArgs) {
const transformedStream = result.toDataStream().pipeThrough(transformStream); const transformedStream = result.toDataStream().pipeThrough(transformStream);
return new StreamingTextResponse(transformedStream); return new Response(transformedStream, {
status: 200,
headers: {
'Content-Type': 'text/plain; charset=utf-8',
},
});
} catch (error: unknown) { } catch (error: unknown) {
console.log(error); console.log(error);

View File

@ -29,10 +29,12 @@ export const isBinaryFile = async (file: File): Promise<boolean> => {
for (let i = 0; i < buffer.length; i++) { for (let i = 0; i < buffer.length; i++) {
const byte = buffer[i]; const byte = buffer[i];
if (byte === 0 || (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13)) { if (byte === 0 || (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13)) {
return true; return true;
} }
} }
return false; return false;
}; };
@ -41,8 +43,11 @@ export const shouldIncludeFile = (path: string): boolean => {
}; };
const readPackageJson = async (files: File[]): Promise<{ scripts?: Record<string, string> } | null> => { const readPackageJson = async (files: File[]): Promise<{ scripts?: Record<string, string> } | null> => {
const packageJsonFile = files.find(f => f.webkitRelativePath.endsWith('package.json')); const packageJsonFile = files.find((f) => f.webkitRelativePath.endsWith('package.json'));
if (!packageJsonFile) return null;
if (!packageJsonFile) {
return null;
}
try { try {
const content = await new Promise<string>((resolve, reject) => { const content = await new Promise<string>((resolve, reject) => {
@ -59,29 +64,32 @@ const readPackageJson = async (files: File[]): Promise<{ scripts?: Record<string
} }
}; };
export const detectProjectType = async (files: File[]): Promise<{ type: string; setupCommand: string; followupMessage: string }> => { export const detectProjectType = async (
const hasFile = (name: string) => files.some(f => f.webkitRelativePath.endsWith(name)); files: File[],
): Promise<{ type: string; setupCommand: string; followupMessage: string }> => {
const hasFile = (name: string) => files.some((f) => f.webkitRelativePath.endsWith(name));
if (hasFile('package.json')) { if (hasFile('package.json')) {
const packageJson = await readPackageJson(files); const packageJson = await readPackageJson(files);
const scripts = packageJson?.scripts || {}; const scripts = packageJson?.scripts || {};
// Check for preferred commands in priority order // Check for preferred commands in priority order
const preferredCommands = ['dev', 'start', 'preview']; const preferredCommands = ['dev', 'start', 'preview'];
const availableCommand = preferredCommands.find(cmd => scripts[cmd]); const availableCommand = preferredCommands.find((cmd) => scripts[cmd]);
if (availableCommand) { if (availableCommand) {
return { return {
type: 'Node.js', type: 'Node.js',
setupCommand: `npm install && npm run ${availableCommand}`, setupCommand: `npm install && npm run ${availableCommand}`,
followupMessage: `Found "${availableCommand}" script in package.json. Running "npm run ${availableCommand}" after installation.` followupMessage: `Found "${availableCommand}" script in package.json. Running "npm run ${availableCommand}" after installation.`,
}; };
} }
return { return {
type: 'Node.js', type: 'Node.js',
setupCommand: 'npm install', setupCommand: 'npm install',
followupMessage: 'Would you like me to inspect package.json to determine the available scripts for running this project?' followupMessage:
'Would you like me to inspect package.json to determine the available scripts for running this project?',
}; };
} }
@ -89,7 +97,7 @@ export const detectProjectType = async (files: File[]): Promise<{ type: string;
return { return {
type: 'Static', type: 'Static',
setupCommand: 'npx --yes serve', setupCommand: 'npx --yes serve',
followupMessage: '' followupMessage: '',
}; };
} }

View File

@ -4,12 +4,13 @@ import { generateId, detectProjectType } from './fileUtils';
export const createChatFromFolder = async ( export const createChatFromFolder = async (
files: File[], files: File[],
binaryFiles: string[], binaryFiles: string[],
folderName: string folderName: string,
): Promise<Message[]> => { ): Promise<Message[]> => {
const fileArtifacts = await Promise.all( const fileArtifacts = await Promise.all(
files.map(async (file) => { files.map(async (file) => {
return new Promise<string>((resolve, reject) => { return new Promise<string>((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = () => { reader.onload = () => {
const content = reader.result as string; const content = reader.result as string;
const relativePath = file.webkitRelativePath.split('/').slice(1).join('/'); const relativePath = file.webkitRelativePath.split('/').slice(1).join('/');
@ -26,31 +27,37 @@ ${content}
); );
const project = await detectProjectType(files); const project = await detectProjectType(files);
const setupCommand = project.setupCommand ? `\n\n<boltAction type="shell">\n${project.setupCommand}\n</boltAction>` : ''; const setupCommand = project.setupCommand
? `\n\n<boltAction type="shell">\n${project.setupCommand}\n</boltAction>`
: '';
const followupMessage = project.followupMessage ? `\n\n${project.followupMessage}` : ''; const followupMessage = project.followupMessage ? `\n\n${project.followupMessage}` : '';
const binaryFilesMessage = binaryFiles.length > 0 const binaryFilesMessage =
? `\n\nSkipped ${binaryFiles.length} binary files:\n${binaryFiles.map((f) => `- ${f}`).join('\n')}` binaryFiles.length > 0
: ''; ? `\n\nSkipped ${binaryFiles.length} binary files:\n${binaryFiles.map((f) => `- ${f}`).join('\n')}`
: '';
const assistantMessages: Message[] = [{ const assistantMessages: Message[] = [
role: 'assistant', {
content: `I've imported the contents of the "${folderName}" folder.${binaryFilesMessage} role: 'assistant',
content: `I've imported the contents of the "${folderName}" folder.${binaryFilesMessage}
<boltArtifact id="imported-files" title="Imported Files"> <boltArtifact id="imported-files" title="Imported Files">
${fileArtifacts.join('\n\n')} ${fileArtifacts.join('\n\n')}
</boltArtifact>`, </boltArtifact>`,
id: generateId(), id: generateId(),
createdAt: new Date(), createdAt: new Date(),
},{ },
role: 'assistant', {
content: ` role: 'assistant',
content: `
<boltArtifact id="imported-files" title="Imported Files"> <boltArtifact id="imported-files" title="Imported Files">
${setupCommand} ${setupCommand}
</boltArtifact>${followupMessage}`, </boltArtifact>${followupMessage}`,
id: generateId(), id: generateId(),
createdAt: new Date(), createdAt: new Date(),
}]; },
];
const userMessage: Message = { const userMessage: Message = {
role: 'user', role: 'user',
@ -59,5 +66,5 @@ ${setupCommand}
createdAt: new Date(), createdAt: new Date(),
}; };
return [ userMessage, ...assistantMessages ]; return [userMessage, ...assistantMessages];
}; };

View File

@ -69,7 +69,7 @@
"@xterm/addon-fit": "^0.10.0", "@xterm/addon-fit": "^0.10.0",
"@xterm/addon-web-links": "^0.11.0", "@xterm/addon-web-links": "^0.11.0",
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^5.5.0",
"ai": "^3.4.33", "ai": "^4.0.13",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"diff": "^5.2.0", "diff": "^5.2.0",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",

File diff suppressed because it is too large Load Diff