diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..3a814283 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,5 @@ +{ + "recommendations": [ + "openai.chatgpt" + ] +} \ No newline at end of file diff --git a/__tests__/components/RewritePreviewPanel.test.tsx b/__tests__/components/RewritePreviewPanel.test.tsx index 26cc704a..2be2ea98 100644 --- a/__tests__/components/RewritePreviewPanel.test.tsx +++ b/__tests__/components/RewritePreviewPanel.test.tsx @@ -4,10 +4,23 @@ import React from "react"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import "@testing-library/jest-dom"; + +jest.mock("react-markdown", () => { + return function ReactMarkdown({ children }: { children: string }) { + let processed = children.replace(/\*\*(.*?)\*\*/g, "$1"); + processed = processed.replace(/\*(.*?)\*/g, "$1"); + return
; + }; +}); + +jest.mock("remark-gfm", () => () => {}); +jest.mock("remark-math", () => () => {}); +jest.mock("rehype-katex", () => () => {}); + import { RewritePreviewPanel } from "~/app/employer/documents/components/generator/RewritePreviewPanel"; describe("RewritePreviewPanel", () => { - it("renders before/after diff and Accept/Reject/Try again buttons", () => { + it("renders before/after diff and Push to Rewrite/Reject/Regenerate buttons", () => { const onAccept = jest.fn(); const onReject = jest.fn(); const onTryAgain = jest.fn(); @@ -22,12 +35,12 @@ describe("RewritePreviewPanel", () => { /> ); - expect(screen.getByText("Accept")).toBeInTheDocument(); + expect(screen.getByText("Push to Rewrite")).toBeInTheDocument(); expect(screen.getByText("Reject")).toBeInTheDocument(); - expect(screen.getByText("Try Again")).toBeInTheDocument(); + expect(screen.getByText("Regenerate")).toBeInTheDocument(); }); - it("calls onAccept when Accept is clicked", async () => { + it("calls onAccept when Push to Rewrite is clicked", async () => { const onAccept = jest.fn(); const onReject = jest.fn(); const onTryAgain = jest.fn(); @@ -42,7 +55,7 @@ describe("RewritePreviewPanel", () => { /> ); - await userEvent.click(screen.getByText("Accept")); + await userEvent.click(screen.getByText("Push to Rewrite")); expect(onAccept).toHaveBeenCalledTimes(1); }); @@ -65,7 +78,7 @@ describe("RewritePreviewPanel", () => { expect(onReject).toHaveBeenCalledTimes(1); }); - it("calls onTryAgain when Try Again is clicked", async () => { + it("calls onTryAgain when Regenerate is clicked", async () => { const onAccept = jest.fn(); const onReject = jest.fn(); const onTryAgain = jest.fn(); @@ -80,7 +93,28 @@ describe("RewritePreviewPanel", () => { /> ); - await userEvent.click(screen.getByText("Try Again")); + await userEvent.click(screen.getByText("Regenerate")); expect(onTryAgain).toHaveBeenCalledTimes(1); }); + + it("renders markdown formatting in clean view", async () => { + const onAccept = jest.fn(); + const onReject = jest.fn(); + const onTryAgain = jest.fn(); + + render( + + ); + + await userEvent.click(screen.getByRole("tab", { name: /clean view/i })); + + expect(screen.getByText("Bold", { selector: "strong" })).toBeInTheDocument(); + expect(screen.getByText("italic", { selector: "em" })).toBeInTheDocument(); + }); }); diff --git a/src/app/api/agents/documentQ&A/services/index.ts b/src/app/api/agents/documentQ&A/services/index.ts index b3f0b2a4..81404a63 100644 --- a/src/app/api/agents/documentQ&A/services/index.ts +++ b/src/app/api/agents/documentQ&A/services/index.ts @@ -16,7 +16,8 @@ export { buildReferences, extractRecommendedPages, filterPagesByAICitation } fro export { performTavilySearch } from "./tavilySearch"; export { executeWebSearchAgent } from "./webSearchAgent"; export { SYSTEM_PROMPTS, getSystemPrompt, getWebSearchInstruction } from "./prompts"; -export { getChatModel, getEmbeddings, getChatModelForProvider, getProviderDefaultModel, describeOllamaError, describeProviderError } from "./models"; +export { getChatModel, getEmbeddings } from "./models"; +export { getChatModelForProvider, getProviderDefaultModel, describeOllamaError, describeProviderError } from "~/lib/ai/chat-model-factory"; export { ProviderModelMap, ProviderDefaultModels } from "./types"; // RLM Search (hierarchical, cost-aware retrieval for large documents) diff --git a/src/app/api/agents/documentQ&A/services/models.ts b/src/app/api/agents/documentQ&A/services/models.ts index ffe50b37..3d853660 100644 --- a/src/app/api/agents/documentQ&A/services/models.ts +++ b/src/app/api/agents/documentQ&A/services/models.ts @@ -1,108 +1,9 @@ -import { ChatOpenAI, OpenAIEmbeddings } from "@langchain/openai"; -import { ChatAnthropic } from "@langchain/anthropic"; -import { ChatGoogleGenerativeAI } from "@langchain/google-genai"; -import type { BaseChatModel } from "@langchain/core/language_models/chat_models"; -import type { AIModelType } from "./types"; -export { getChatModelForProvider, getProviderDefaultModel, describeOllamaError, describeProviderError } from "~/lib/ai/chat-model-factory"; - -// Re-export type for convenience -export type { AIModelType }; - /** - * Get a chat model instance based on the model type - * - * Supports all model types defined in types.ts: - * - OpenAI: gpt-5.2, gpt-5.1, gpt-5-nano, gpt-5-mini - * - Anthropic: claude-sonnet-4, claude-opus-4.5 - * - Google: gemini-2.5-flash, gemini-3-flash, gemini-3-pro + * Document Q&A chat model factory. + * Re-exports from shared lib so one place controls model config. */ -export function getChatModel(modelType: AIModelType): BaseChatModel { - switch (modelType) { - // OpenAI Models - case "gpt-5.2": - return new ChatOpenAI({ - openAIApiKey: process.env.OPENAI_API_KEY, - modelName: "gpt-5.2", - temperature: 0.7, - timeout: 600000, - }); - - case "gpt-5.1": - return new ChatOpenAI({ - openAIApiKey: process.env.OPENAI_API_KEY, - modelName: "gpt-5.1", - temperature: 0.7, - timeout: 600000, - }); - - case "gpt-5-nano": - return new ChatOpenAI({ - openAIApiKey: process.env.OPENAI_API_KEY, - modelName: "gpt-5-nano-2025-08-07", - timeout: 300000, - }); - - case "gpt-5-mini": - return new ChatOpenAI({ - openAIApiKey: process.env.OPENAI_API_KEY, - modelName: "gpt-5-mini-2025-08-07", - timeout: 600000, - }); - - // Anthropic Models - case "claude-sonnet-4": - return new ChatAnthropic({ - anthropicApiKey: process.env.ANTHROPIC_API_KEY, - modelName: "claude-sonnet-4-20250514", - temperature: 0.7, - }); - - case "claude-opus-4.5": - return new ChatAnthropic({ - anthropicApiKey: process.env.ANTHROPIC_API_KEY, - modelName: "claude-opus-4.5", - temperature: 0.7, - }); - - // Google Gemini Models - case "gemini-2.5-flash": - return new ChatGoogleGenerativeAI({ - apiKey: process.env.GOOGLE_AI_API_KEY, - model: "gemini-2.5-flash", - temperature: 0.7, - }); - - case "gemini-3-flash": - return new ChatGoogleGenerativeAI({ - apiKey: process.env.GOOGLE_AI_API_KEY, - model: "gemini-3-flash-preview", - temperature: 0.7, - }); - - case "gemini-3-pro": - return new ChatGoogleGenerativeAI({ - apiKey: process.env.GOOGLE_AI_API_KEY, - model: "gemini-3-pro-preview", - temperature: 0.7, - }); - - // Default fallback - default: - return new ChatOpenAI({ - openAIApiKey: process.env.OPENAI_API_KEY, - modelName: "gpt-5-mini", - temperature: 0.7, - timeout: 600000, - }); - } -} - -/** - * Get embeddings instance - */ -export function getEmbeddings(): OpenAIEmbeddings { - return new OpenAIEmbeddings({ - model: "text-embedding-ada-002", - openAIApiKey: process.env.OPENAI_API_KEY, - }); -} +export { + getChatModel, + getEmbeddings, + type AIModelType, +} from "~/lib/models"; diff --git a/src/app/api/document-generator/generate/route.ts b/src/app/api/document-generator/generate/route.ts index 16e91f8f..15091f2b 100644 --- a/src/app/api/document-generator/generate/route.ts +++ b/src/app/api/document-generator/generate/route.ts @@ -91,7 +91,7 @@ Guidelines: - Apply the requested tone if specified - Keep similar length to the original unless otherwise specified - Output ONLY the rewritten text: no quotation marks, no "Here is the rewrite:", no wrapper text -- Use HTML tags for formatting: for bold, for italic, for underline. Do NOT use Markdown (** or *).`, +- Use Markdown for formatting: **bold**, *italic*, __underline__. Do NOT use raw HTML tags.`, summarize: `You are an expert editor specializing in summarization. Your task is to create a concise summary of the given text. @@ -140,7 +140,15 @@ export async function POST(request: Request) { ); } - const body = await request.json() as unknown; + let body: unknown; + try { + body = (await request.json()) as unknown; + } catch { + return NextResponse.json( + { success: false, message: "Invalid JSON body", error: "Request body must be valid JSON" }, + { status: 400 }, + ); + } const validation = GenerateSchema.safeParse(body); if (!validation.success) { @@ -153,8 +161,8 @@ export async function POST(request: Request) { const { action, content, prompt, context, options } = validation.data; const startTime = Date.now(); - // Get the AI model - const modelId = (options?.model ?? "gpt-5-mini") as AIModelType; + // Get the AI model (gpt-4o is widely available; gpt-5-mini may require newer API access) + const modelId = (options?.model ?? "gpt-4o") as AIModelType; const chat = getChatModel(modelId); // Build the system prompt @@ -221,6 +229,17 @@ export async function POST(request: Request) { const firstDraft = normalizeModelContent(firstPass.content); // Refining through second pass + const refinementInstructions = [ + "Improve sentence flow and rhythm", + "Remove any redundancy or filler phrases", + "Ensure it reads naturally, not like it was AI-generated", + "Preserve all factual information, names, numbers, and technical terms", + prompt + ? "IMPORTANT: The user requested specific additions or changes. Make sure these are fully incorporated and prioritized: " + prompt + : "Do not change the meaning or add new information beyond what was requested", + "Output ONLY the refined text: no quotation marks, no wrapper phrases", + ].join("\n- "); + const secondPass = await chat.call([ new SystemMessage(systemPrompt), new HumanMessage(`Here is a rewritten version of the original text: @@ -228,12 +247,7 @@ export async function POST(request: Request) { "${firstDraft}" Now refine it further: -- Improve sentence flow and rhythm -- Remove any redundancy or filler phrases -- Ensure it reads naturally, not like it was AI-generated -- Preserve all factual information, names, numbers, and technical terms -- Do not change the meaning or add new information -- Output ONLY the refined text: no quotation marks, no wrapper phrases`), +- ${refinementInstructions}`), ]); // Use the refined second pass for rewrite (skip the generic call below) @@ -287,13 +301,17 @@ Now refine it further: }); } catch (error) { + const errMessage = error instanceof Error ? error.message : String(error); + const stack = error instanceof Error ? error.stack : undefined; + console.error("[document-generator/generate] error:", error); return NextResponse.json( - { - success: false, + { + success: false, message: "Failed to generate content", - error: error instanceof Error ? error.message : "Unknown error" + error: errMessage, + ...(process.env.NODE_ENV === "development" && stack ? { stack } : {}), }, - { status: 500 } + { status: 500, headers: { "Content-Type": "application/json" } }, ); } } diff --git a/src/app/api/marketing-pipeline/route.ts b/src/app/api/marketing-pipeline/route.ts index 4f1e2dab..6e9e0642 100644 --- a/src/app/api/marketing-pipeline/route.ts +++ b/src/app/api/marketing-pipeline/route.ts @@ -4,6 +4,7 @@ import { eq } from "drizzle-orm"; import { db } from "~/server/db"; import { users } from "~/server/db/schema"; import { MarketingPipelineInputSchema, runMarketingPipeline } from "~/lib/tools/marketing-pipeline"; +import type { PipelineSSEEvent } from "~/lib/tools/marketing-pipeline"; export const runtime = "nodejs"; export const maxDuration = 60; @@ -18,7 +19,15 @@ export async function POST(request: Request) { ); } - const body = (await request.json()) as unknown; + let body: unknown; + try { + body = (await request.json()) as unknown; + } catch { + return NextResponse.json( + { success: false, message: "Invalid JSON body", error: "Request body must be valid JSON" }, + { status: 400 }, + ); + } const validation = MarketingPipelineInputSchema.safeParse(body); if (!validation.success) { return NextResponse.json( @@ -55,29 +64,64 @@ export async function POST(request: Request) { const url = new URL(request.url); const debug = url.searchParams.get("debug") === "true"; - const result = await runMarketingPipeline({ - companyId, - input: validation.data, - debug, + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + async start(controller) { + function send(event: PipelineSSEEvent) { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`)); + } + + try { + const result = await runMarketingPipeline({ + companyId, + input: validation.data, + debug, + onProgress: (progressEvent) => send(progressEvent), + }); + + send({ type: "result", success: true, data: result }); + } catch (error) { + const errMessage = error instanceof Error ? error.message : String(error); + console.error("[marketing-pipeline] POST error:", error); + + const hint = + !process.env.OPENAI_API_KEY && errMessage.toLowerCase().includes("openai") + ? " (Ensure OPENAI_API_KEY is set in .env)" + : errMessage.toLowerCase().includes("company") + ? " (Ensure your user has a valid company profile)" + : ""; + + send({ + type: "error", + success: false, + message: "Failed to run marketing pipeline", + error: errMessage + hint, + }); + } finally { + controller.close(); + } + }, }); - return NextResponse.json( - { - success: true, - data: result, + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", }, - { status: 200 }, - ); + }); } catch (error) { + const errMessage = error instanceof Error ? error.message : String(error); console.error("[marketing-pipeline] POST error:", error); + return NextResponse.json( { success: false, message: "Failed to run marketing pipeline", - error: error instanceof Error ? error.message : "Unknown error", + error: errMessage, }, - { status: 500 }, + { status: 500, headers: { "Content-Type": "application/json" } }, ); } } - diff --git a/src/app/employer/_components/ProfileDropdown.tsx b/src/app/employer/_components/ProfileDropdown.tsx index ecb30424..dff6bdd0 100644 --- a/src/app/employer/_components/ProfileDropdown.tsx +++ b/src/app/employer/_components/ProfileDropdown.tsx @@ -8,6 +8,11 @@ import { const ProfileDropdown: React.FC = () => { const dropdownRef = useRef(null); + const [isMounted, setIsMounted] = React.useState(false); + + useEffect(() => { + setIsMounted(true); + }, []); useEffect(() => { function handleClickOutside(event: MouseEvent) { @@ -26,7 +31,7 @@ const ProfileDropdown: React.FC = () => { return (
- + {isMounted ? : ); }; diff --git a/src/app/employer/documents/components/DocumentGeneratorEditor.tsx b/src/app/employer/documents/components/DocumentGeneratorEditor.tsx index f24b846a..9fa3c499 100644 --- a/src/app/employer/documents/components/DocumentGeneratorEditor.tsx +++ b/src/app/employer/documents/components/DocumentGeneratorEditor.tsx @@ -401,7 +401,14 @@ export function DocumentGeneratorEditor({ }), }); - const data = (await response.json()) as { success: boolean; generatedContent?: string }; + const rawText = await response.text(); + let data: { success: boolean; generatedContent?: string; message?: string; error?: string }; + try { + data = JSON.parse(rawText); + } catch { + setAiError(rawText?.slice(0, 120) || "Server returned an invalid response. Please try again."); + return; + } if (data.success && data.generatedContent) { const generatedContent = data.generatedContent; @@ -514,7 +521,14 @@ export function DocumentGeneratorEditor({ options: { tone: "professional", length: "medium" }, }), }); - const data = (await response.json()) as { success: boolean; generatedContent?: string }; + const rawText = await response.text(); + let data: { success: boolean; generatedContent?: string }; + try { + data = JSON.parse(rawText); + } catch { + setAiError(rawText?.slice(0, 120) || "Server returned an invalid response. Please try again."); + return; + } if (data.success && data.generatedContent) { setRewritePreview((p) => (p ? { ...p, proposedText: stripRewriteQuotes(data.generatedContent!) } : null)); } diff --git a/src/app/employer/documents/components/DocumentViewerShell.tsx b/src/app/employer/documents/components/DocumentViewerShell.tsx index 7b4cc61c..b5691c5c 100644 --- a/src/app/employer/documents/components/DocumentViewerShell.tsx +++ b/src/app/employer/documents/components/DocumentViewerShell.tsx @@ -295,6 +295,20 @@ export function DocumentViewerShell({ userRole }: DocumentViewerShellProps) { } }, [isRoleLoading, documents]); + useEffect(() => { + if (isRoleLoading || userRole !== "employer") return; + + const params = new URLSearchParams(window.location.search); + const requestedView = params.get("view"); + if (requestedView === "rewrite") { + setViewMode("rewrite"); + params.delete("view"); + const next = params.toString(); + const newUrl = next ? `${window.location.pathname}?${next}` : window.location.pathname; + window.history.replaceState({}, "", newUrl); + } + }, [isRoleLoading, userRole]); + useEffect(() => { if (!userId || isRoleLoading) return; void fetchDocuments(); diff --git a/src/app/employer/documents/components/MarketingPipelinePanel.tsx b/src/app/employer/documents/components/MarketingPipelinePanel.tsx index 66562fdf..6670a14f 100644 --- a/src/app/employer/documents/components/MarketingPipelinePanel.tsx +++ b/src/app/employer/documents/components/MarketingPipelinePanel.tsx @@ -1,292 +1,13 @@ "use client"; -import { useMemo, useState } from "react"; -import Image from "next/image"; -import { Loader2, MessageSquareText, Megaphone, Sparkles } from "lucide-react"; +import { MarketingPipelineWorkspace } from "~/app/employer/documents/components/marketing-pipeline/MarketingPipelineWorkspace"; import styles from "~/styles/Employer/MarketingPipeline.module.css"; -type Platform = "x" | "linkedin" | "reddit" | "bluesky"; - -interface PipelineResponse { - success: boolean; - message?: string; - data?: { - platform: Platform; - message: string; - "image/video": "image" | "video"; - research: Array<{ - title: string; - url: string; - snippet: string; - source: Platform; - }>; - }; -} - -const REDDIT_SNOO_URL = "/images/reddit-snoo.png"; - -const PLATFORM_OPTIONS: Array<{ - id: Platform; - label: string; - subtitle: string; - logoText: string; - logoImg?: string; -}> = [ - { id: "reddit", label: "Reddit", subtitle: "Community-first threads", logoText: "reddit", logoImg: REDDIT_SNOO_URL }, - { id: "x", label: "Twitter / X", subtitle: "Fast-moving trends", logoText: "𝕏" }, - { id: "linkedin", label: "LinkedIn", subtitle: "B2B + thought leadership", logoText: "in" }, - { id: "bluesky", label: "Bluesky", subtitle: "Decentralized trends", logoText: "🦋" }, -]; - -function usePlatformLogoClassNames() { - return useMemo( - () => ({ - reddit: styles.platformLogoReddit, - x: styles.platformLogoX, - linkedin: styles.platformLogoLinkedin, - bluesky: styles.platformLogoBluesky, - }), - [], - ); -} - export function MarketingPipelinePanel() { - const [platform, setPlatform] = useState(null); - const [prompt, setPrompt] = useState(""); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [result, setResult] = useState(null); - - const logoClassNames = usePlatformLogoClassNames(); - const selectedPlatform = PLATFORM_OPTIONS.find((option) => option.id === platform) ?? null; - - const runPipeline = async () => { - setError(null); - setResult(null); - - if (!platform) { - setError("Choose a platform to continue."); - return; - } - - const normalizedPrompt = prompt.trim(); - if (!normalizedPrompt) { - setError("Add a short description of what you want to promote."); - return; - } - - setLoading(true); - try { - const response = await fetch("/api/marketing-pipeline", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ platform, prompt: normalizedPrompt }), - }); - - const payload = (await response.json()) as PipelineResponse; - if (!response.ok || !payload.success || !payload.data) { - setError(payload.message ?? "We couldn't generate a campaign right now. Please try again."); - return; - } - - setResult(payload.data); - } catch (requestError) { - console.error("[marketing-pipeline] request error:", requestError); - setError("Something went wrong talking to the marketing engine. Try again."); - } finally { - setLoading(false); - } - }; - return (
-
-
- -
-
-

Marketing Pipeline

-

- Create campaign-ready posts for Reddit, X, LinkedIn & Bluesky from your company - knowledge base -

-
-
- - {!platform ? ( -
-
-

Choose platform

-
- {PLATFORM_OPTIONS.map((option) => ( - - ))} -
- {error &&

{error}

} -
-
- ) : ( -
-
-
-
-
- - {selectedPlatform?.logoImg ? ( - - ) : ( - selectedPlatform?.logoText - )} - - {selectedPlatform?.label} -
- -
- -
-
-

Describe what you want to promote

- 1-3 sentences is perfect. -
-