feat: meter AI usage
This commit is contained in:
@@ -31,10 +31,17 @@ beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockedApi.get.mockImplementation((url: string) => {
|
||||
if (url.includes("/modules")) return Promise.resolve({ data: { modules: [], provider: "gemini" } } as any);
|
||||
if (url === "/ai/usage") return Promise.resolve({ data: { currentMonth: { calls: 3, inputCharacters: 100, outputCharacters: 50, estimatedTokens: 38 }, allTime: { calls: 8, inputCharacters: 300, outputCharacters: 120, estimatedTokens: 105 } } } as any);
|
||||
return Promise.resolve({ data: [] } as any); // history
|
||||
});
|
||||
});
|
||||
|
||||
test("shows transparent monthly usage", async () => {
|
||||
renderPanel();
|
||||
expect(await screen.findByText(/approximately/)).toHaveTextContent("3 runs");
|
||||
expect(screen.getByText(/approximately/)).toHaveTextContent("38 tokens");
|
||||
});
|
||||
|
||||
test("renders modules and generates a suggestion into history", async () => {
|
||||
mockedApi.post.mockResolvedValueOnce({
|
||||
data: { id: 1, module: "job-analysis", mode: null, title: "Job analysis", provider: "gemini", result: { text: "**Company**\nAcme" }, createdAtUtc: new Date().toISOString() },
|
||||
|
||||
@@ -10,6 +10,11 @@ export type AiInteraction = {
|
||||
createdAtUtc: string;
|
||||
};
|
||||
|
||||
export type AiUsage = {
|
||||
currentMonth: { calls: number; inputCharacters: number; outputCharacters: number; estimatedTokens: number };
|
||||
allTime: { calls: number; inputCharacters: number; outputCharacters: number; estimatedTokens: number };
|
||||
};
|
||||
|
||||
export const AI_MODULES: { key: string; label: string; blurb: string }[] = [
|
||||
{ key: "job-analysis", label: "Job Analysis", blurb: "Break down the advert: skills, requirements, salary, work model, interview topics." },
|
||||
{ key: "career-match", label: "Career Match", blurb: "Your profile vs the advert: strengths, gaps, match %, suggested improvements." },
|
||||
@@ -21,6 +26,7 @@ export const AI_MODULES: { key: string; label: string; blurb: string }[] = [
|
||||
export const COVER_LETTER_MODES = ["professional", "friendly", "short", "detailed", "modern", "traditional"];
|
||||
|
||||
export const aiWorkspaceApi = {
|
||||
usage: () => api.get<AiUsage>("/ai/usage").then((r) => r.data),
|
||||
modules: (jobId: number) => api.get<{ modules: string[]; provider: string }>(`/jobapplications/${jobId}/ai/modules`).then((r) => r.data),
|
||||
generate: (jobId: number, body: { module: string; mode?: string; extraContext?: string }) =>
|
||||
api.post<AiInteraction>(`/jobapplications/${jobId}/ai/generate`, body).then((r) => r.data),
|
||||
|
||||
@@ -14,7 +14,7 @@ import ReplayIcon from "@mui/icons-material/Replay";
|
||||
import { getApiErrorMessage } from "../api";
|
||||
import { useToast } from "../toast";
|
||||
import Markdown from "./Markdown";
|
||||
import { AI_MODULES, AiInteraction, COVER_LETTER_MODES, aiWorkspaceApi } from "../aiWorkspace";
|
||||
import { AI_MODULES, AiInteraction, AiUsage, COVER_LETTER_MODES, aiWorkspaceApi } from "../aiWorkspace";
|
||||
|
||||
// Phase 5 — the central AI Workspace for one job application. Every result is a suggestion the user
|
||||
// reviews and copies; nothing is applied automatically.
|
||||
@@ -24,6 +24,7 @@ export default function AiWorkspacePanel({ jobId }: { jobId: number }) {
|
||||
const [mode, setMode] = useState("professional");
|
||||
const [extra, setExtra] = useState("");
|
||||
const [provider, setProvider] = useState("");
|
||||
const [usage, setUsage] = useState<AiUsage | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [current, setCurrent] = useState<AiInteraction | null>(null);
|
||||
const [history, setHistory] = useState<AiInteraction[]>([]);
|
||||
@@ -44,6 +45,7 @@ export default function AiWorkspacePanel({ jobId }: { jobId: number }) {
|
||||
|
||||
useEffect(() => {
|
||||
aiWorkspaceApi.modules(jobId).then((r) => setProvider(r.provider)).catch(() => undefined);
|
||||
aiWorkspaceApi.usage().then(setUsage).catch(() => undefined);
|
||||
loadHistory();
|
||||
}, [jobId, loadHistory]);
|
||||
|
||||
@@ -54,6 +56,7 @@ export default function AiWorkspacePanel({ jobId }: { jobId: number }) {
|
||||
const res = await aiWorkspaceApi.generate(jobId, { module, mode: module === "cover-letter" ? mode : undefined, extraContext: extra || undefined });
|
||||
setCurrent(res);
|
||||
setHistory((h) => [res, ...h]);
|
||||
aiWorkspaceApi.usage().then(setUsage).catch(() => undefined);
|
||||
} catch (err) {
|
||||
toast(getApiErrorMessage(err, "AI generation failed."), "error");
|
||||
} finally {
|
||||
@@ -85,6 +88,7 @@ export default function AiWorkspacePanel({ jobId }: { jobId: number }) {
|
||||
<Alert severity="info" sx={{ py: 0.5 }}>
|
||||
AI suggestions never change your profile, CVs, or this application. Review, then copy what you want to keep.
|
||||
{provider && <> Provider: <strong>{provider}</strong>.</>}
|
||||
{usage && <> This month: <strong>{usage.currentMonth.calls}</strong> runs · approximately <strong>{usage.currentMonth.estimatedTokens.toLocaleString()}</strong> tokens.</>}
|
||||
</Alert>
|
||||
|
||||
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 1 }}>
|
||||
|
||||
Reference in New Issue
Block a user