Compare commits

...

19 Commits

Author SHA1 Message Date
ItzCrazyKns
7c97df98c7 Update perplexica-screenshot.png 2025-10-07 16:45:55 +05:30
ItzCrazyKns
b084c42aca Update perplexica-screenshot.png 2025-10-07 16:21:31 +05:30
ItzCrazyKns
fdfa2f3ea6 Update perplexica-screenshot.png 2025-10-07 16:13:56 +05:30
ItzCrazyKns
3323e7a0ed feat(package): bump version, update screenshot 2025-10-07 16:11:10 +05:30
ItzCrazyKns
d4f9da34c6 feat(tailwind-config): update theme 2025-10-07 15:07:26 +05:30
ItzCrazyKns
10ed67c753 feat(workflow): build images for canary 2025-10-06 20:00:31 +05:30
ItzCrazyKns
cf3cc4e638 feat(migrator): use DATA_DIR env var 2025-10-06 10:13:34 +05:30
ItzCrazyKns
46b9e41100 feat(db-table): add default values for createdAt 2025-10-06 08:59:01 +05:30
sjiampojamarn
02adafbd4b Handling double stringify JSON parsing 2025-10-05 10:46:01 -07:00
ItzCrazyKns
f141d4719c feat(assets): update preview image 2025-10-05 22:32:17 +05:30
ItzCrazyKns
f18e132d1b feat(news-widget): fix loading animation 2025-10-02 17:55:56 +05:30
ItzCrazyKns
37317992b4 feat(app): lint & beautify 2025-10-02 17:17:52 +05:30
ItzCrazyKns
8b588824f2 feat(css): add line-clamp-2 class 2025-10-02 17:17:44 +05:30
ItzCrazyKns
a19cf00873 feat(lineOutputParser): return undefined on invalid tags 2025-10-02 17:17:31 +05:30
ItzCrazyKns
5cc11ac0bf feat(message-handling): fix repeated first token, think tag handling
closes #889
2025-10-02 17:15:54 +05:30
Kushagra Srivastava
d611ddaab9 Merge pull request #880 from ruturaj-rathod/fix/body-validation-579
validate the request body of api/chat to prevent malformed request body
2025-09-27 19:52:14 +05:30
ItzCrazyKns
3b41905abb feat(app): lint & beautify 2025-09-27 19:51:36 +05:30
ruturaj
fabb48cc2f move schema validation into route file and remove validation file 2025-09-23 14:57:55 +05:30
ruturaj
c46b421219 validate the request body of api/chat to prevent malformed request body 2025-09-19 16:06:46 +05:30
12 changed files with 163 additions and 69 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 641 KiB

After

Width:  |  Height:  |  Size: 2.1 MiB

View File

@@ -4,6 +4,7 @@ on:
push: push:
branches: branches:
- master - master
- canary
release: release:
types: [published] types: [published]
@@ -43,6 +44,19 @@ jobs:
-t itzcrazykns1337/${IMAGE_NAME}:amd64 \ -t itzcrazykns1337/${IMAGE_NAME}:amd64 \
--push . --push .
- name: Build and push AMD64 Canary Docker image
if: github.ref == 'refs/heads/canary' && github.event_name == 'push'
run: |
DOCKERFILE=app.dockerfile
IMAGE_NAME=perplexica
docker buildx build --platform linux/amd64 \
--cache-from=type=registry,ref=itzcrazykns1337/${IMAGE_NAME}:canary-amd64 \
--cache-to=type=inline \
--provenance false \
-f $DOCKERFILE \
-t itzcrazykns1337/${IMAGE_NAME}:canary-amd64 \
--push .
- name: Build and push AMD64 release Docker image - name: Build and push AMD64 release Docker image
if: github.event_name == 'release' if: github.event_name == 'release'
run: | run: |
@@ -91,6 +105,19 @@ jobs:
-t itzcrazykns1337/${IMAGE_NAME}:arm64 \ -t itzcrazykns1337/${IMAGE_NAME}:arm64 \
--push . --push .
- name: Build and push ARM64 Canary Docker image
if: github.ref == 'refs/heads/canary' && github.event_name == 'push'
run: |
DOCKERFILE=app.dockerfile
IMAGE_NAME=perplexica
docker buildx build --platform linux/arm64 \
--cache-from=type=registry,ref=itzcrazykns1337/${IMAGE_NAME}:canary-arm64 \
--cache-to=type=inline \
--provenance false \
-f $DOCKERFILE \
-t itzcrazykns1337/${IMAGE_NAME}:canary-arm64 \
--push .
- name: Build and push ARM64 release Docker image - name: Build and push ARM64 release Docker image
if: github.event_name == 'release' if: github.event_name == 'release'
run: | run: |
@@ -128,6 +155,15 @@ jobs:
--amend itzcrazykns1337/${IMAGE_NAME}:arm64 --amend itzcrazykns1337/${IMAGE_NAME}:arm64
docker manifest push itzcrazykns1337/${IMAGE_NAME}:main docker manifest push itzcrazykns1337/${IMAGE_NAME}:main
- name: Create and push multi-arch manifest for canary
if: github.ref == 'refs/heads/canary' && github.event_name == 'push'
run: |
IMAGE_NAME=perplexica
docker manifest create itzcrazykns1337/${IMAGE_NAME}:canary \
--amend itzcrazykns1337/${IMAGE_NAME}:canary-amd64 \
--amend itzcrazykns1337/${IMAGE_NAME}:canary-arm64
docker manifest push itzcrazykns1337/${IMAGE_NAME}:canary
- name: Create and push multi-arch manifest for releases - name: Create and push multi-arch manifest for releases
if: github.event_name == 'release' if: github.event_name == 'release'
run: | run: |

View File

@@ -1,6 +1,6 @@
{ {
"name": "perplexica-frontend", "name": "perplexica-frontend",
"version": "1.11.0-rc2", "version": "1.11.0-rc3",
"license": "MIT", "license": "MIT",
"author": "ItzCrazyKns", "author": "ItzCrazyKns",
"scripts": { "scripts": {

View File

@@ -17,35 +17,71 @@ import {
getCustomOpenaiModelName, getCustomOpenaiModelName,
} from '@/lib/config'; } from '@/lib/config';
import { searchHandlers } from '@/lib/search'; import { searchHandlers } from '@/lib/search';
import { z } from 'zod';
export const runtime = 'nodejs'; export const runtime = 'nodejs';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
type Message = { const messageSchema = z.object({
messageId: string; messageId: z.string().min(1, 'Message ID is required'),
chatId: string; chatId: z.string().min(1, 'Chat ID is required'),
content: string; content: z.string().min(1, 'Message content is required'),
}; });
type ChatModel = { const chatModelSchema = z.object({
provider: string; provider: z.string().optional(),
name: string; name: z.string().optional(),
}; });
type EmbeddingModel = { const embeddingModelSchema = z.object({
provider: string; provider: z.string().optional(),
name: string; name: z.string().optional(),
}; });
type Body = { const bodySchema = z.object({
message: Message; message: messageSchema,
optimizationMode: 'speed' | 'balanced' | 'quality'; optimizationMode: z.enum(['speed', 'balanced', 'quality'], {
focusMode: string; errorMap: () => ({
history: Array<[string, string]>; message: 'Optimization mode must be one of: speed, balanced, quality',
files: Array<string>; }),
chatModel: ChatModel; }),
embeddingModel: EmbeddingModel; focusMode: z.string().min(1, 'Focus mode is required'),
systemInstructions: string; history: z
.array(
z.tuple([z.string(), z.string()], {
errorMap: () => ({
message: 'History items must be tuples of two strings',
}),
}),
)
.optional()
.default([]),
files: z.array(z.string()).optional().default([]),
chatModel: chatModelSchema.optional().default({}),
embeddingModel: embeddingModelSchema.optional().default({}),
systemInstructions: z.string().nullable().optional().default(''),
});
type Message = z.infer<typeof messageSchema>;
type Body = z.infer<typeof bodySchema>;
const safeValidateBody = (data: unknown) => {
const result = bodySchema.safeParse(data);
if (!result.success) {
return {
success: false,
error: result.error.errors.map((e) => ({
path: e.path.join('.'),
message: e.message,
})),
};
}
return {
success: true,
data: result.data,
};
}; };
const handleEmitterEvents = async ( const handleEmitterEvents = async (
@@ -190,7 +226,17 @@ const handleHistorySave = async (
export const POST = async (req: Request) => { export const POST = async (req: Request) => {
try { try {
const body = (await req.json()) as Body; const reqBody = (await req.json()) as Body;
const parseBody = safeValidateBody(reqBody);
if (!parseBody.success) {
return Response.json(
{ message: 'Invalid request body', error: parseBody.error },
{ status: 400 },
);
}
const body = parseBody.data as Body;
const { message } = body; const { message } = body;
if (message.content === '') { if (message.content === '') {
@@ -285,7 +331,7 @@ export const POST = async (req: Request) => {
embedding, embedding,
body.optimizationMode, body.optimizationMode,
body.files, body.files,
body.systemInstructions, body.systemInstructions as string,
); );
const responseStream = new TransformStream(); const responseStream = new TransformStream();

View File

@@ -20,6 +20,15 @@
} }
} }
@layer utilities {
.line-clamp-2 {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
}
@media screen and (-webkit-min-device-pixel-ratio: 0) { @media screen and (-webkit-min-device-pixel-ratio: 0) {
select, select,
textarea, textarea,

View File

@@ -69,9 +69,7 @@ const MessageBox = ({
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div <div className={'w-full pt-8 break-words'}>
className={'w-full pt-8 break-words'}
>
<h2 className="text-black dark:text-white font-medium text-3xl lg:w-9/12"> <h2 className="text-black dark:text-white font-medium text-3xl lg:w-9/12">
{section.userMessage.content} {section.userMessage.content}
</h2> </h2>

View File

@@ -29,15 +29,13 @@ const NewsArticleWidget = () => {
return ( return (
<div className="bg-light-secondary dark:bg-dark-secondary rounded-2xl border border-light-200 dark:border-dark-200 shadow-sm shadow-light-200/10 dark:shadow-black/25 flex flex-row items-stretch w-full h-24 min-h-[96px] max-h-[96px] p-0 overflow-hidden"> <div className="bg-light-secondary dark:bg-dark-secondary rounded-2xl border border-light-200 dark:border-dark-200 shadow-sm shadow-light-200/10 dark:shadow-black/25 flex flex-row items-stretch w-full h-24 min-h-[96px] max-h-[96px] p-0 overflow-hidden">
{loading ? ( {loading ? (
<> <div className="animate-pulse flex flex-row items-stretch w-full h-full">
<div className="animate-pulse flex flex-row items-center w-full h-full"> <div className="w-24 min-w-24 max-w-24 h-full bg-light-200 dark:bg-dark-200" />
<div className="rounded-lg w-16 min-w-16 max-w-16 h-16 min-h-16 max-h-16 bg-light-200 dark:bg-dark-200 mr-3" /> <div className="flex flex-col justify-center flex-1 px-3 py-2 gap-2">
<div className="flex flex-col justify-center flex-1 h-full w-0 gap-2"> <div className="h-4 w-3/4 rounded bg-light-200 dark:bg-dark-200" />
<div className="h-4 w-3/4 rounded bg-light-200 dark:bg-dark-200" /> <div className="h-3 w-1/2 rounded bg-light-200 dark:bg-dark-200" />
<div className="h-3 w-1/2 rounded bg-light-200 dark:bg-dark-200" />
</div>
</div> </div>
</> </div>
) : error ? ( ) : error ? (
<div className="w-full text-xs text-red-400">Could not load news.</div> <div className="w-full text-xs text-red-400">Could not load news.</div>
) : article ? ( ) : article ? (

View File

@@ -2,9 +2,12 @@ import Database from 'better-sqlite3';
import path from 'path'; import path from 'path';
import fs from 'fs'; import fs from 'fs';
const db = new Database(path.join(process.cwd(), 'data', 'db.sqlite')); const DATA_DIR = process.env.DATA_DIR || process.cwd();
const dbPath = path.join(DATA_DIR, './data/db.sqlite');
const migrationsFolder = path.join(process.cwd(), 'drizzle'); const db = new Database(dbPath);
const migrationsFolder = path.join(DATA_DIR, 'drizzle');
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS ran_migrations ( CREATE TABLE IF NOT EXISTS ran_migrations (
@@ -54,7 +57,7 @@ fs.readdirSync(migrationsFolder)
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
type TEXT NOT NULL, type TEXT NOT NULL,
chatId TEXT NOT NULL, chatId TEXT NOT NULL,
createdAt TEXT NOT NULL, createdAt TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
messageId TEXT NOT NULL, messageId TEXT NOT NULL,
content TEXT, content TEXT,
sources TEXT DEFAULT '[]' sources TEXT DEFAULT '[]'
@@ -67,8 +70,10 @@ fs.readdirSync(migrationsFolder)
`); `);
messages.forEach((msg: any) => { messages.forEach((msg: any) => {
if (msg.type === 'user') { while (typeof msg.metadata === 'string') {
msg.metadata = JSON.parse(msg.metadata || '{}'); msg.metadata = JSON.parse(msg.metadata || '{}');
}
if (msg.type === 'user') {
insertMessage.run( insertMessage.run(
'user', 'user',
msg.chatId, msg.chatId,
@@ -78,7 +83,6 @@ fs.readdirSync(migrationsFolder)
'[]', '[]',
); );
} else if (msg.type === 'assistant') { } else if (msg.type === 'assistant') {
msg.metadata = JSON.parse(msg.metadata || '{}');
insertMessage.run( insertMessage.run(
'assistant', 'assistant',
msg.chatId, msg.chatId,

View File

@@ -399,6 +399,7 @@ export const ChatProvider = ({
(m, j) => (m, j) =>
j > i && j > i &&
m.role === 'source' && m.role === 'source' &&
m.sources &&
(nextUserMessageIndex === -1 || j < nextUserMessageIndex), (nextUserMessageIndex === -1 || j < nextUserMessageIndex),
) as SourceMessage | undefined; ) as SourceMessage | undefined;
@@ -417,7 +418,7 @@ export const ChatProvider = ({
const closeThinkTag = const closeThinkTag =
processedMessage.match(/<\/think>/g)?.length || 0; processedMessage.match(/<\/think>/g)?.length || 0;
if (openThinkTag > closeThinkTag) { if (openThinkTag && !closeThinkTag) {
processedMessage += '</think> <a> </a>'; processedMessage += '</think> <a> </a>';
} }
} }
@@ -426,7 +427,11 @@ export const ChatProvider = ({
thinkingEnded = true; thinkingEnded = true;
} }
if (sourceMessage && sourceMessage.sources.length > 0) { if (
sourceMessage &&
sourceMessage.sources &&
sourceMessage.sources.length > 0
) {
processedMessage = processedMessage.replace( processedMessage = processedMessage.replace(
citationRegex, citationRegex,
(_, capturedContent: string) => { (_, capturedContent: string) => {
@@ -635,23 +640,22 @@ export const ChatProvider = ({
}, },
]); ]);
added = true; added = true;
setMessageAppeared(true);
} else {
setMessages((prev) =>
prev.map((message) => {
if (
message.messageId === data.messageId &&
message.role === 'assistant'
) {
return { ...message, content: message.content + data.data };
}
return message;
}),
);
} }
setMessages((prev) =>
prev.map((message) => {
if (
message.messageId === data.messageId &&
message.role === 'assistant'
) {
return { ...message, content: message.content + data.data };
}
return message;
}),
);
recievedMessage += data.data; recievedMessage += data.data;
setMessageAppeared(true);
} }
if (data.type === 'messageEnd') { if (data.type === 'messageEnd') {

View File

@@ -4,7 +4,7 @@ interface LineOutputParserArgs {
key?: string; key?: string;
} }
class LineOutputParser extends BaseOutputParser<string> { class LineOutputParser extends BaseOutputParser<string | undefined> {
private key = 'questions'; private key = 'questions';
constructor(args?: LineOutputParserArgs) { constructor(args?: LineOutputParserArgs) {
@@ -18,7 +18,7 @@ class LineOutputParser extends BaseOutputParser<string> {
lc_namespace = ['langchain', 'output_parsers', 'line_output_parser']; lc_namespace = ['langchain', 'output_parsers', 'line_output_parser'];
async parse(text: string): Promise<string> { async parse(text: string): Promise<string | undefined> {
text = text.trim() || ''; text = text.trim() || '';
const regex = /^(\s*(-|\*|\d+\.\s|\d+\)\s|\u2022)\s*)+/; const regex = /^(\s*(-|\*|\d+\.\s|\d+\)\s|\u2022)\s*)+/;
@@ -26,7 +26,7 @@ class LineOutputParser extends BaseOutputParser<string> {
const endKeyIndex = text.indexOf(`</${this.key}>`); const endKeyIndex = text.indexOf(`</${this.key}>`);
if (startKeyIndex === -1 || endKeyIndex === -1) { if (startKeyIndex === -1 || endKeyIndex === -1) {
return ''; return undefined;
} }
const questionsStartIndex = const questionsStartIndex =

View File

@@ -453,7 +453,6 @@ class MetaSearchAgent implements MetaSearchAgentType {
event.event === 'on_chain_end' && event.event === 'on_chain_end' &&
event.name === 'FinalSourceRetriever' event.name === 'FinalSourceRetriever'
) { ) {
``;
emitter.emit( emitter.emit(
'data', 'data',
JSON.stringify({ type: 'sources', data: event.data.output }), JSON.stringify({ type: 'sources', data: event.data.output }),

View File

@@ -2,17 +2,17 @@ import type { Config } from 'tailwindcss';
import type { DefaultColors } from 'tailwindcss/types/generated/colors'; import type { DefaultColors } from 'tailwindcss/types/generated/colors';
const themeDark = (colors: DefaultColors) => ({ const themeDark = (colors: DefaultColors) => ({
50: '#111116', 50: '#0d1117',
100: '#1f202b', 100: '#161b22',
200: '#2d2f3f', 200: '#21262d',
300: '#3a3c4c', 300: '#30363d',
}); });
const themeLight = (colors: DefaultColors) => ({ const themeLight = (colors: DefaultColors) => ({
50: '#ffffff', 50: '#ffffff',
100: '#f1f5f9', 100: '#f6f8fa',
200: '#c4c7c5', 200: '#d0d7de',
300: '#9ca3af', 300: '#afb8c1',
}); });
const config: Config = { const config: Config = {