Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ yarn-error.log*

# env files (can opt-in for committing if needed)
.env*
.private/
veriworkly_ai_credit_spec.md

# except example file
!.env.example
Expand Down Expand Up @@ -64,4 +66,4 @@ portfolio_static_rendering.md
portfolio_nextjs_architecture.md
portfolio_nextjs_static_production.md
portfolio_production_comparison.md
portfolio_system_specification.md
portfolio_system_specification.md
17 changes: 17 additions & 0 deletions apps/portfolio/components/dashboard/editor/ContentCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Field } from "./Field";
import { AssetUpload } from "./AssetUpload";
import { SectionEditor } from "./SectionEditor";
import { inputClass as input } from "./constants";
import { PortfolioAiAssist } from "./PortfolioAiAssist";

export interface ContentCanvasProps {
selectedSectionId: string;
Expand All @@ -14,6 +15,7 @@ export interface ContentCanvasProps {
export function ContentCanvas({ selectedSectionId, onClose }: ContentCanvasProps) {
const content = usePortfolioStore((state) => state.content);
const updateIdentity = usePortfolioStore((state) => state.updateIdentity);
const documentId = usePortfolioStore((state) => state.draft?.id);
const selectedSection = content.sections.find((section) => section.id === selectedSectionId);

return (
Expand Down Expand Up @@ -52,6 +54,12 @@ export function ContentCanvas({ selectedSectionId, onClose }: ContentCanvasProps
onChange={(e) => updateIdentity({ headline: e.target.value })}
/>
</Field>
<PortfolioAiAssist
context={JSON.stringify({ name: content.identity.name, bio: content.identity.bio })}
documentId={documentId}
onApply={(headline) => updateIdentity({ headline })}
text={content.identity.headline}
/>
<Field label="Short introduction">
<textarea
className={input}
Expand All @@ -60,6 +68,15 @@ export function ContentCanvas({ selectedSectionId, onClose }: ContentCanvasProps
onChange={(e) => updateIdentity({ bio: e.target.value })}
/>
</Field>
<PortfolioAiAssist
context={JSON.stringify({
identity: content.identity,
sections: content.sections.map(({ type, title, items }) => ({ type, title, items })),
})}
documentId={documentId}
onApply={(bio) => updateIdentity({ bio })}
text={content.identity.bio}
/>
<div className="grid gap-3 sm:grid-cols-2">
<Field label="Location">
<input
Expand Down
146 changes: 146 additions & 0 deletions apps/portfolio/components/dashboard/editor/PortfolioAiAssist.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
"use client";

import { useEffect, useState } from "react";

import { Sparkles, X } from "lucide-react";

import {
generateAiContent,
getAiActions,
type AiActionPolicy,
type AiMode,
} from "@/lib/ai-client";

import { actionClass, inputClass } from "./constants";

export function PortfolioAiAssist({
text,
context,
documentId,
onApply,
}: {
text: string;
context: string;
documentId?: string;
onApply: (content: string) => void;
}) {
const [open, setOpen] = useState(false);
const [mode, setMode] = useState<AiMode>("standard");
const [instructions, setInstructions] = useState("");
const [result, setResult] = useState("");
const [message, setMessage] = useState("");
const [loading, setLoading] = useState(false);
const [actions, setActions] = useState<AiActionPolicy | null>(null);

useEffect(() => {
if (!open || actions) return;
void getAiActions().then(setActions).catch((error) => setMessage(error.message));
}, [actions, open]);

if (!open) {
return (
<button
className={`${actionClass} text-accent mt-1 px-0`}
onClick={() => setOpen(true)}
type="button"
>
<Sparkles size={13} /> Improve with AI
</button>
);
}

const cost = actions?.generate_portfolio_copy.costs[mode];

return (
<div className="border-line bg-paper mt-2 space-y-2.5 rounded-xl border p-3">
<div className="flex items-center justify-between">
<p className="text-xs font-extrabold">AI writing assistant</p>
<button aria-label="Close AI assistant" onClick={() => setOpen(false)} type="button">
<X className="text-muted" size={14} />
</button>
</div>
<div className="flex gap-2">
{(["standard", "expert"] as const).map((option) => (
<button
className={`${actionClass} ${mode === option ? "bg-accent text-white" : "border-line bg-panel border"}`}
key={option}
onClick={() => setMode(option)}
type="button"
>
{option === "standard" ? "Standard" : "Expert"}
{actions ? ` · ${actions.generate_portfolio_copy.costs[option]} cr` : ""}
</button>
))}
</div>
<input
className={inputClass}
maxLength={500}
onChange={(event) => setInstructions(event.target.value)}
placeholder="Optional direction or tone"
value={instructions}
/>
{result ? (
<>
<textarea
aria-label="Generated portfolio draft"
className={inputClass}
onChange={(event) => setResult(event.target.value)}
rows={5}
value={result}
/>
<div className="flex gap-2">
<button
className={`${actionClass} bg-accent text-white`}
onClick={() => {
onApply(result);
setOpen(false);
setResult("");
}}
type="button"
>
Replace field
</button>
<button
className={`${actionClass} border-line bg-panel border`}
onClick={() => setResult("")}
type="button"
>
Discard draft
</button>
</div>
</>
) : (
<button
className={`${actionClass} bg-accent text-white`}
disabled={loading || cost == null}
onClick={async () => {
setLoading(true);
setMessage("");
try {
const response = await generateAiContent({
action: "generate_portfolio_copy",
mode,
input: { text, context, instructions },
requestId: crypto.randomUUID(),
documentId,
});
setResult(response.content);
setMessage(`${response.credits.spent} credits used. Balance ${response.credits.balance}.`);
} catch (error) {
setMessage(error instanceof Error ? error.message : "AI generation failed.");
} finally {
setLoading(false);
}
}}
type="button"
>
{loading ? "Generating..." : `Generate draft${cost == null ? "" : ` · ${cost} credits`}`}
</button>
)}
<p className="text-muted text-[11px] leading-4">
{message ||
"Generation sends this field and relevant portfolio context to the configured AI provider. Your current text stays unchanged until you replace the field."}
</p>
</div>
);
}
13 changes: 13 additions & 0 deletions apps/portfolio/components/dashboard/editor/SectionEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ import { Field } from "./Field";
import { ItemAction } from "./ItemAction";
import { AssetUpload } from "./AssetUpload";
import { actionClass as action, inputClass as input, sectionInfo } from "./constants";
import { PortfolioAiAssist } from "./PortfolioAiAssist";

export interface SectionEditorProps {
section: PortfolioSection;
}

export function SectionEditor({ section }: SectionEditorProps) {
const updateSection = usePortfolioStore((state) => state.updateSection);
const documentId = usePortfolioStore((state) => state.draft?.id);
const replaceItems = (items: Array<Record<string, unknown>>) =>
updateSection(section.id, { items });
const updateItem = (index: number, patch: Record<string, unknown>) =>
Expand Down Expand Up @@ -102,6 +104,17 @@ export function SectionEditor({ section }: SectionEditorProps) {
onChange={(e) => updateItem(index, { summary: e.target.value })}
/>
</Field>
<PortfolioAiAssist
context={JSON.stringify({
sectionType: section.type,
sectionTitle: section.title,
itemTitle: item.title,
year: item.year,
})}
documentId={documentId}
onApply={(summary) => updateItem(index, { summary })}
text={String(item.summary ?? "")}
/>
{section.type === "projects" ? (
<AssetUpload
kind="PROJECT_COVER"
Expand Down
59 changes: 59 additions & 0 deletions apps/portfolio/lib/ai-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"use client";

import { authenticatedFetch } from "@/lib/authenticated-fetch";

export type AiAction =
| "rewrite_short_text"
| "rewrite_section"
| "generate_section"
| "generate_portfolio_copy"
| "generate_cover_letter"
| "tailor_resume_to_job"
| "generate_document";
export type AiMode = "standard" | "expert";
export type AiActionPolicy = Record<
AiAction,
{
costs: Record<AiMode, number>;
}
>;
export type AiGenerateResult = {
content: string;
usage: { promptTokens: number; completionTokens: number; totalTokens: number } | null;
credits: { spent: number; balance: number };
};

type ApiResponse<T> = { data?: T; message?: string };
let actionsPromise: Promise<AiActionPolicy> | null = null;

async function readResponse<T>(response: Response) {
const payload = (await response.json().catch(() => ({}))) as ApiResponse<T>;
if (!response.ok || !payload.data) throw new Error(payload.message || "AI request failed.");
return payload.data;
}

export function getAiActions() {
actionsPromise ??= authenticatedFetch("/ai/actions")
.then((response) => readResponse<AiActionPolicy>(response))
.catch((error) => {
actionsPromise = null;
throw error;
});
return actionsPromise;
}

export async function generateAiContent(input: {
action: AiAction;
mode: AiMode;
input: { text?: string; context?: string; instructions?: string };
requestId: string;
documentId?: string;
}) {
return readResponse<AiGenerateResult>(
await authenticatedFetch("/ai/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
}),
);
}
28 changes: 28 additions & 0 deletions apps/server/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,26 @@ RATE_LIMIT_MAX_REQUESTS=100

LOG_LEVEL=info

# =========================================================
# AI Provider
# =========================================================

# Development uses NVIDIA NIM. Production uses OpenRouter.
NVIDIA_API_KEY=
NVIDIA_BASE_URL=https://integrate.api.nvidia.com/v1
OPENROUTER_API_KEY=
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
AI_STANDARD_MODEL=
AI_EXPERT_MODEL=
# Point to an ignored/mounted policy file, or inject the same JSON through AI_PRIVATE_CONFIG_JSON.
# The policy contains model routing, prompts, token limits, and standard/expert credit costs.
AI_PRIVATE_CONFIG_PATH=
AI_PRIVATE_CONFIG_JSON=
AI_TIMEOUT_MS=120000
AI_RATE_LIMIT_WINDOW_MS=60000
AI_RATE_LIMIT_MAX_REQUESTS=20
SITE_URL=https://veriworkly.com


# =========================================================
# 🔑 Authentication
Expand Down Expand Up @@ -158,6 +178,14 @@ DODO_PAYMENTS_ENVIRONMENT=test_mode
DODO_PAYMENTS_SEVEN_DAY_PRODUCT_ID=
DODO_PAYMENTS_MONTHLY_PRODUCT_ID=
DODO_PAYMENTS_ANNUAL_PRODUCT_ID=
# The legacy monthly/annual ids above remain Portfolio Pro fallbacks.
DODO_PAYMENTS_PORTFOLIO_PRO_MONTHLY_PRODUCT_ID=
DODO_PAYMENTS_PORTFOLIO_PRO_ANNUAL_PRODUCT_ID=
DODO_PAYMENTS_AI_CREDITS_MONTHLY_PRODUCT_ID=
DODO_PAYMENTS_AI_CREDITS_ANNUAL_PRODUCT_ID=
DODO_PAYMENTS_BUNDLE_MONTHLY_PRODUCT_ID=
DODO_PAYMENTS_BUNDLE_ANNUAL_PRODUCT_ID=
DODO_PAYMENTS_CREDIT_PACK_100_PRODUCT_ID=
DODO_PAYMENTS_CHECKOUT_RETURN_URL=http://localhost:3001/billing?checkout=complete
DODO_PAYMENTS_CHECKOUT_CANCEL_URL=http://localhost:3001/billing?checkout=cancelled
DODO_PAYMENTS_PORTAL_RETURN_URL=http://localhost:3001/billing
Expand Down
1 change: 1 addition & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"helmet": "^7.2.0",
"node-cron": "^4.2.1",
"nodemailer": "^8.0.10",
"openai": "^6.42.0",
"pg": "^8.21.0",
"redis": "^5.12.1",
"uuid": "^14.0.0",
Expand Down
Loading
Loading