initlal commit

This commit is contained in:
DaKheera47 2026-02-15 18:28:01 +00:00
parent d0b4091a60
commit 00531c83c4
25 changed files with 2214 additions and 96 deletions

View File

@ -472,23 +472,6 @@ export async function listJobChatThreads(jobId: string): Promise<{
return fetchApi<{ threads: JobChatThread[] }>(`/jobs/${jobId}/chat/threads`);
}
export async function listJobGhostwriterMessages(
jobId: string,
options?: { limit?: number; offset?: number },
): Promise<{ messages: JobChatMessage[] }> {
const params = new URLSearchParams();
if (typeof options?.limit === "number") {
params.set("limit", String(options.limit));
}
if (typeof options?.offset === "number") {
params.set("offset", String(options.offset));
}
const query = params.toString();
return fetchApi<{ messages: JobChatMessage[] }>(
`/jobs/${jobId}/chat/messages${query ? `?${query}` : ""}`,
);
}
export async function createJobChatThread(
jobId: string,
input?: { title?: string | null },
@ -556,23 +539,6 @@ export async function streamJobChatMessage(
);
}
export async function streamJobGhostwriterMessage(
jobId: string,
input: { content: string; signal?: AbortSignal },
handlers: {
onEvent: (event: JobChatStreamEvent) => void;
},
): Promise<void> {
return streamSseEvents(
`/jobs/${jobId}/chat/messages`,
{ content: input.content, stream: true },
{
onEvent: handlers.onEvent,
signal: input.signal,
},
);
}
export async function cancelJobChatRun(
jobId: string,
threadId: string,
@ -587,19 +553,6 @@ export async function cancelJobChatRun(
);
}
export async function cancelJobGhostwriterRun(
jobId: string,
runId: string,
): Promise<{ cancelled: boolean; alreadyFinished: boolean }> {
return fetchApi<{ cancelled: boolean; alreadyFinished: boolean }>(
`/jobs/${jobId}/chat/runs/${runId}/cancel`,
{
method: "POST",
body: JSON.stringify({}),
},
);
}
export async function regenerateJobChatMessage(
jobId: string,
threadId: string,
@ -633,24 +586,6 @@ export async function streamRegenerateJobChatMessage(
);
}
export async function streamRegenerateJobGhostwriterMessage(
jobId: string,
assistantMessageId: string,
input: { signal?: AbortSignal },
handlers: {
onEvent: (event: JobChatStreamEvent) => void;
},
): Promise<void> {
return streamSseEvents(
`/jobs/${jobId}/chat/messages/${assistantMessageId}/regenerate`,
{ stream: true },
{
onEvent: handlers.onEvent,
signal: input.signal,
},
);
}
export async function processJob(
id: string,
options?: { force?: boolean },

View File

@ -0,0 +1,52 @@
import { Send } from "lucide-react";
import type React from "react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
type ComposerProps = {
disabled?: boolean;
onSend: (content: string) => Promise<void>;
};
export const Composer: React.FC<ComposerProps> = ({ disabled, onSend }) => {
const [value, setValue] = useState("");
const submit = async () => {
const content = value.trim();
if (!content || disabled) return;
setValue("");
await onSend(content);
};
return (
<div className="space-y-2">
<Textarea
placeholder="Ask anything about this job..."
value={value}
onChange={(event) => setValue(event.target.value)}
disabled={disabled}
onKeyDown={(event) => {
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
event.preventDefault();
void submit();
}
}}
className="min-h-[84px]"
/>
<div className="flex items-center justify-between">
<div className="text-[10px] text-muted-foreground">
Cmd/Ctrl+Enter to send
</div>
<Button
size="sm"
onClick={() => void submit()}
disabled={disabled || !value.trim()}
>
<Send className="mr-1 h-3.5 w-3.5" />
Send
</Button>
</div>
</div>
);
};

View File

@ -0,0 +1,361 @@
import type {
Job,
JobChatMessage,
JobChatStreamEvent,
JobChatThread,
} from "@shared/types";
import { MessageSquare } from "lucide-react";
import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import * as api from "../../api";
import { useSettings } from "../../hooks/useSettings";
import { Composer } from "./Composer";
import { MessageList } from "./MessageList";
import { RunControls } from "./RunControls";
import { ThreadList } from "./ThreadList";
type JobChatPanelProps = {
job: Job;
};
export const JobChatPanel: React.FC<JobChatPanelProps> = ({ job }) => {
const { settings } = useSettings();
const enabled = settings?.jobChatEnabled ?? false;
const [threads, setThreads] = useState<JobChatThread[]>([]);
const [activeThreadId, setActiveThreadId] = useState<string | null>(null);
const [messages, setMessages] = useState<JobChatMessage[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isStreaming, setIsStreaming] = useState(false);
const [streamingMessageId, setStreamingMessageId] = useState<string | null>(
null,
);
const [activeRunId, setActiveRunId] = useState<string | null>(null);
const messageListRef = useRef<HTMLDivElement | null>(null);
const streamAbortRef = useRef<AbortController | null>(null);
const activeThreadIdRef = useRef<string | null>(null);
useEffect(() => {
activeThreadIdRef.current = activeThreadId;
}, [activeThreadId]);
useEffect(() => {
const container = messageListRef.current;
if (!container) return;
const distanceToBottom =
container.scrollHeight - container.scrollTop - container.clientHeight;
if (distanceToBottom < 120 || isStreaming) {
container.scrollTop = container.scrollHeight;
}
});
const loadThreadMessages = useCallback(
async (threadId: string) => {
const data = await api.listJobChatMessages(job.id, threadId, {
limit: 300,
});
setMessages(data.messages);
},
[job.id],
);
const createThread = useCallback(async () => {
const created = await api.createJobChatThread(job.id, {
title: `${job.title} @ ${job.employer}`,
});
setThreads((current) => [created.thread, ...current]);
setActiveThreadId(created.thread.id);
setMessages([]);
return created.thread;
}, [job.id, job.title, job.employer]);
const load = useCallback(async () => {
if (!enabled) {
setIsLoading(false);
return;
}
setIsLoading(true);
try {
const data = await api.listJobChatThreads(job.id);
const nextThreads = data.threads;
setThreads(nextThreads);
let threadId = nextThreads[0]?.id ?? null;
if (!threadId) {
const created = await createThread();
threadId = created.id;
}
setActiveThreadId(threadId);
if (threadId) {
await loadThreadMessages(threadId);
}
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to load job chat";
toast.error(message);
} finally {
setIsLoading(false);
}
}, [enabled, job.id, createThread, loadThreadMessages]);
useEffect(() => {
void load();
return () => {
streamAbortRef.current?.abort();
streamAbortRef.current = null;
};
}, [load]);
const onStreamEvent = useCallback(
(event: JobChatStreamEvent) => {
if (event.type === "ready") {
setActiveRunId(event.runId);
setStreamingMessageId(event.messageId);
setMessages((current) => {
if (current.some((message) => message.id === event.messageId))
return current;
return [
...current,
{
id: event.messageId,
threadId: event.threadId,
jobId: job.id,
role: "assistant",
content: "",
status: "partial",
tokensIn: null,
tokensOut: null,
version: 1,
replacesMessageId: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
];
});
return;
}
if (event.type === "delta") {
setMessages((current) =>
current.map((message) =>
message.id === event.messageId
? {
...message,
content: `${message.content}${event.delta}`,
status: "partial",
updatedAt: new Date().toISOString(),
}
: message,
),
);
return;
}
if (event.type === "completed" || event.type === "cancelled") {
setMessages((current) => {
const next = current.filter(
(message) => message.id !== event.message.id,
);
return [...next, event.message].sort((a, b) =>
a.createdAt.localeCompare(b.createdAt),
);
});
setStreamingMessageId(null);
setActiveRunId(null);
setIsStreaming(false);
return;
}
if (event.type === "error") {
toast.error(event.message);
setStreamingMessageId(null);
setActiveRunId(null);
setIsStreaming(false);
}
},
[job.id],
);
const sendMessage = useCallback(
async (content: string) => {
if (!activeThreadIdRef.current || isStreaming) return;
const threadId = activeThreadIdRef.current;
const optimisticUser: JobChatMessage = {
id: `tmp-user-${Date.now()}`,
threadId,
jobId: job.id,
role: "user",
content,
status: "complete",
tokensIn: null,
tokensOut: null,
version: 1,
replacesMessageId: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
setMessages((current) => [...current, optimisticUser]);
setIsStreaming(true);
const controller = new AbortController();
streamAbortRef.current = controller;
try {
await api.streamJobChatMessage(
job.id,
threadId,
{ content, signal: controller.signal },
{ onEvent: onStreamEvent },
);
await loadThreadMessages(threadId);
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
return;
}
const message =
error instanceof Error ? error.message : "Failed to send message";
toast.error(message);
} finally {
streamAbortRef.current = null;
setIsStreaming(false);
}
},
[isStreaming, job.id, loadThreadMessages, onStreamEvent],
);
const stopStreaming = useCallback(async () => {
if (!activeThreadId || !activeRunId) return;
try {
await api.cancelJobChatRun(job.id, activeThreadId, activeRunId);
streamAbortRef.current?.abort();
streamAbortRef.current = null;
setIsStreaming(false);
setActiveRunId(null);
setStreamingMessageId(null);
await loadThreadMessages(activeThreadId);
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to stop run";
toast.error(message);
}
}, [activeThreadId, activeRunId, job.id, loadThreadMessages]);
const canRegenerate = useMemo(() => {
if (isStreaming || messages.length === 0) return false;
const last = messages[messages.length - 1];
return last.role === "assistant";
}, [isStreaming, messages]);
const regenerate = useCallback(async () => {
if (!activeThreadId || isStreaming || messages.length === 0) return;
const last = messages[messages.length - 1];
if (last.role !== "assistant") return;
setIsStreaming(true);
const controller = new AbortController();
streamAbortRef.current = controller;
try {
await api.streamRegenerateJobChatMessage(
job.id,
activeThreadId,
last.id,
{ signal: controller.signal },
{ onEvent: onStreamEvent },
);
await loadThreadMessages(activeThreadId);
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
return;
}
const message =
error instanceof Error
? error.message
: "Failed to regenerate response";
toast.error(message);
} finally {
streamAbortRef.current = null;
setIsStreaming(false);
}
}, [
activeThreadId,
isStreaming,
job.id,
loadThreadMessages,
messages,
onStreamEvent,
]);
if (!enabled) {
return (
<Card className="border-border/50">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<MessageSquare className="h-4 w-4" />
Job Copilot
</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
Enable this in Settings Job Chat Copilot.
</CardContent>
</Card>
);
}
return (
<Card className="border-border/50">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<MessageSquare className="h-4 w-4" />
Job Copilot
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<ThreadList
threads={threads}
activeThreadId={activeThreadId}
onSelectThread={(threadId) => {
setActiveThreadId(threadId);
void loadThreadMessages(threadId);
}}
onCreateThread={() => {
void createThread();
}}
disabled={isLoading || isStreaming}
/>
<div
ref={messageListRef}
className="max-h-[420px] overflow-y-auto rounded-md border border-border/50 p-3"
>
<MessageList
messages={messages}
isStreaming={isStreaming}
streamingMessageId={streamingMessageId}
/>
</div>
<RunControls
isStreaming={isStreaming}
canRegenerate={canRegenerate}
onStop={stopStreaming}
onRegenerate={regenerate}
/>
<Composer
disabled={isLoading || isStreaming || !activeThreadId}
onSend={sendMessage}
/>
</CardContent>
</Card>
);
};

View File

@ -0,0 +1,59 @@
import type { JobChatMessage } from "@shared/types";
import type React from "react";
import { StreamingMessage } from "./StreamingMessage";
type MessageListProps = {
messages: JobChatMessage[];
isStreaming: boolean;
streamingMessageId: string | null;
};
export const MessageList: React.FC<MessageListProps> = ({
messages,
isStreaming,
streamingMessageId,
}) => {
return (
<div className="space-y-3">
{messages.length === 0 ? (
<div className="rounded-md border border-dashed border-border/60 p-3 text-xs text-muted-foreground">
Ask for interview prep, response drafts, or application strategy for
this job.
</div>
) : (
messages.map((message) => {
const isUser = message.role === "user";
const isActiveStreaming =
isStreaming &&
message.role === "assistant" &&
streamingMessageId === message.id;
return (
<div
key={message.id}
className={`rounded-lg border p-3 ${
isUser
? "border-primary/30 bg-primary/5"
: "border-border/60 bg-background"
}`}
>
<div className="mb-1 text-[10px] uppercase tracking-wide text-muted-foreground">
{isUser
? "You"
: `Copilot${message.version > 1 ? ` v${message.version}` : ""}`}
</div>
{isActiveStreaming ? (
<StreamingMessage content={message.content} />
) : (
<div className="whitespace-pre-wrap text-sm leading-relaxed text-foreground">
{message.content ||
(message.role === "assistant" ? "..." : "")}
</div>
)}
</div>
);
})
)}
</div>
);
};

View File

@ -0,0 +1,45 @@
import { Loader2, RefreshCcw, Square } from "lucide-react";
import type React from "react";
import { Button } from "@/components/ui/button";
type RunControlsProps = {
isStreaming: boolean;
canRegenerate: boolean;
onStop: () => Promise<void>;
onRegenerate: () => Promise<void>;
};
export const RunControls: React.FC<RunControlsProps> = ({
isStreaming,
canRegenerate,
onStop,
onRegenerate,
}) => {
return (
<div className="flex items-center justify-end gap-2">
{isStreaming ? (
<Button size="sm" variant="outline" onClick={() => void onStop()}>
<Square className="mr-1 h-3.5 w-3.5" />
Stop
</Button>
) : (
<Button
size="sm"
variant="outline"
onClick={() => void onRegenerate()}
disabled={!canRegenerate}
>
<RefreshCcw className="mr-1 h-3.5 w-3.5" />
Regenerate
</Button>
)}
{isStreaming && (
<div className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Generating
</div>
)}
</div>
);
};

View File

@ -0,0 +1,16 @@
import type React from "react";
type StreamingMessageProps = {
content: string;
};
export const StreamingMessage: React.FC<StreamingMessageProps> = ({
content,
}) => {
return (
<div className="whitespace-pre-wrap text-sm leading-relaxed text-foreground">
{content}
<span className="ml-1 inline-block h-4 w-2 animate-pulse rounded bg-primary/60 align-middle" />
</div>
);
};

View File

@ -0,0 +1,62 @@
import type { JobChatThread } from "@shared/types";
import type React from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type ThreadListProps = {
threads: JobChatThread[];
activeThreadId: string | null;
onSelectThread: (threadId: string) => void;
onCreateThread: () => void;
disabled?: boolean;
};
export const ThreadList: React.FC<ThreadListProps> = ({
threads,
activeThreadId,
onSelectThread,
onCreateThread,
disabled,
}) => {
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="text-xs uppercase tracking-wide text-muted-foreground">
Threads
</div>
<Button
size="sm"
variant="outline"
onClick={onCreateThread}
disabled={disabled}
>
New
</Button>
</div>
<div className="max-h-40 space-y-1 overflow-auto rounded-md border border-border/50 p-1">
{threads.length === 0 ? (
<div className="p-2 text-xs text-muted-foreground">
No threads yet
</div>
) : (
threads.map((thread) => (
<button
key={thread.id}
type="button"
onClick={() => onSelectThread(thread.id)}
className={cn(
"w-full rounded px-2 py-1.5 text-left text-xs transition-colors",
activeThreadId === thread.id
? "bg-primary/10 text-primary"
: "hover:bg-muted",
)}
>
{thread.title || "Untitled thread"}
</button>
))
)}
</div>
</div>
);
};

View File

@ -25,6 +25,7 @@ import * as api from "../api";
import { ConfirmDelete } from "../components/ConfirmDelete";
import { GhostwriterDrawer } from "../components/ghostwriter/GhostwriterDrawer";
import { JobHeader } from "../components/JobHeader";
import { JobChatPanel } from "../components/job-chat/JobChatPanel";
import {
type LogEventFormValues,
LogEventModal,
@ -352,6 +353,8 @@ export const JobPage: React.FC = () => {
</CardContent>
</Card>
)}
{job && <JobChatPanel job={job} />}
</div>
</div>

View File

@ -47,6 +47,7 @@ const DEFAULT_FORM_VALUES: UpdateSettingsInput = {
resumeProjects: null,
rxresumeBaseResumeId: null,
showSponsorInfo: null,
jobChatEnabled: null,
chatStyleTone: "",
chatStyleFormality: "",
chatStyleConstraints: "",
@ -86,6 +87,7 @@ const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = {
resumeProjects: null,
rxresumeBaseResumeId: null,
showSponsorInfo: null,
jobChatEnabled: null,
chatStyleTone: null,
chatStyleFormality: null,
chatStyleConstraints: null,
@ -119,6 +121,7 @@ const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
resumeProjects: data.resumeProjects,
rxresumeBaseResumeId: data.rxresumeBaseResumeId ?? null,
showSponsorInfo: data.overrideShowSponsorInfo,
jobChatEnabled: data.overrideJobChatEnabled,
chatStyleTone: data.overrideChatStyleTone ?? "",
chatStyleFormality: data.overrideChatStyleFormality ?? "",
chatStyleConstraints: data.overrideChatStyleConstraints ?? "",
@ -214,6 +217,10 @@ const getDerivedSettings = (settings: AppSettings | null) => {
default: settings?.defaultShowSponsorInfo ?? true,
},
chat: {
enabled: {
effective: settings?.jobChatEnabled ?? false,
default: settings?.defaultJobChatEnabled ?? false,
},
tone: {
effective: settings?.chatStyleTone ?? "professional",
default: settings?.defaultChatStyleTone ?? "professional",
@ -583,6 +590,7 @@ export const SettingsPage: React.FC = () => {
resumeProjects: resumeProjectsOverride,
rxresumeBaseResumeId: normalizeString(data.rxresumeBaseResumeId),
showSponsorInfo: nullIfSame(data.showSponsorInfo, display.default),
jobChatEnabled: nullIfSame(data.jobChatEnabled, chat.enabled.default),
chatStyleTone: normalizeString(data.chatStyleTone),
chatStyleFormality: normalizeString(data.chatStyleFormality),
chatStyleConstraints: normalizeString(data.chatStyleConstraints),

View File

@ -8,6 +8,7 @@ import {
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
@ -28,17 +29,51 @@ export const ChatSettingsSection: React.FC<ChatSettingsSectionProps> = ({
isLoading,
isSaving,
}) => {
const { tone, formality, constraints, doNotUse } = values;
const { enabled, tone, formality, constraints, doNotUse } = values;
const { control, register } = useFormContext<UpdateSettingsInput>();
const { control, register, watch } = useFormContext<UpdateSettingsInput>();
const isEnabled = watch("jobChatEnabled") ?? enabled.default;
return (
<AccordionItem value="chat" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">Ghostwriter</span>
<span className="text-base font-semibold">Job Chat Copilot</span>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-4">
<div className="flex items-start space-x-3">
<Controller
name="jobChatEnabled"
control={control}
render={({ field }) => (
<Checkbox
id="jobChatEnabled"
checked={field.value ?? enabled.default}
onCheckedChange={(checked) => {
field.onChange(
checked === "indeterminate" ? null : checked === true,
);
}}
disabled={isLoading || isSaving}
/>
)}
/>
<div className="flex flex-col gap-1.5">
<label
htmlFor="jobChatEnabled"
className="text-sm font-medium leading-none cursor-pointer"
>
Enable per-job copilot chat
</label>
<p className="text-xs text-muted-foreground">
Adds a persistent chat thread on each job page with prefilled
job and profile context.
</p>
</div>
</div>
<Separator />
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<label htmlFor="chatStyleTone" className="text-sm font-medium">
@ -49,9 +84,9 @@ export const ChatSettingsSection: React.FC<ChatSettingsSectionProps> = ({
control={control}
render={({ field }) => (
<Select
value={field.value || tone.default}
value={field.value ?? tone.default}
onValueChange={(value) => field.onChange(value)}
disabled={isLoading || isSaving}
disabled={isLoading || isSaving || !isEnabled}
>
<SelectTrigger id="chatStyleTone">
<SelectValue placeholder="Select tone" />
@ -79,9 +114,9 @@ export const ChatSettingsSection: React.FC<ChatSettingsSectionProps> = ({
control={control}
render={({ field }) => (
<Select
value={field.value || formality.default}
value={field.value ?? formality.default}
onValueChange={(value) => field.onChange(value)}
disabled={isLoading || isSaving}
disabled={isLoading || isSaving || !isEnabled}
>
<SelectTrigger id="chatStyleFormality">
<SelectValue placeholder="Select formality" />
@ -101,8 +136,8 @@ export const ChatSettingsSection: React.FC<ChatSettingsSectionProps> = ({
label="Constraints"
inputProps={register("chatStyleConstraints")}
placeholder="Example: keep answers under 120 words and include bullet points"
disabled={isLoading || isSaving}
helper="Optional global writing constraints used by Ghostwriter replies."
disabled={isLoading || isSaving || !isEnabled}
helper="Optional global writing constraints used by job chat replies."
current={constraints.effective || "—"}
/>
@ -110,7 +145,7 @@ export const ChatSettingsSection: React.FC<ChatSettingsSectionProps> = ({
label="Do-not-use terms"
inputProps={register("chatStyleDoNotUse")}
placeholder="Example: synergize, leverage"
disabled={isLoading || isSaving}
disabled={isLoading || isSaving || !isEnabled}
helper="Optional comma-separated words or phrases to avoid."
current={doNotUse.effective || "—"}
/>
@ -118,6 +153,13 @@ export const ChatSettingsSection: React.FC<ChatSettingsSectionProps> = ({
<Separator />
<div className="grid gap-2 text-sm sm:grid-cols-2">
<div>
<div className="text-xs text-muted-foreground">Enabled</div>
<div className="break-words font-mono text-xs">
Effective: {enabled.effective ? "Yes" : "No"} | Default:{" "}
{enabled.default ? "Yes" : "No"}
</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Tone</div>
<div className="break-words font-mono text-xs">

View File

@ -15,6 +15,7 @@ export type ModelValues = EffectiveDefault<string> & {
export type WebhookValues = EffectiveDefault<string>;
export type DisplayValues = EffectiveDefault<boolean>;
export type ChatValues = {
enabled: EffectiveDefault<boolean>;
tone: EffectiveDefault<string>;
formality: EffectiveDefault<string>;
constraints: EffectiveDefault<string>;

View File

@ -6,7 +6,7 @@ import { Router } from "express";
import { backupRouter } from "./routes/backup";
import { databaseRouter } from "./routes/database";
import { demoRouter } from "./routes/demo";
import { ghostwriterRouter } from "./routes/ghostwriter";
import { jobChatRouter } from "./routes/job-chat";
import { jobsRouter } from "./routes/jobs";
import { manualJobsRouter } from "./routes/manual-jobs";
import { onboardingRouter } from "./routes/onboarding";
@ -21,7 +21,7 @@ import { webhookRouter } from "./routes/webhook";
export const apiRouter = Router();
apiRouter.use("/jobs", jobsRouter);
apiRouter.use("/jobs/:id/chat", ghostwriterRouter);
apiRouter.use("/jobs/:id/chat", jobChatRouter);
apiRouter.use("/demo", demoRouter);
apiRouter.use("/settings", settingsRouter);
apiRouter.use("/pipeline", pipelineRouter);

View File

@ -0,0 +1,154 @@
import type { Server } from "node:http";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { startServer, stopServer } from "./test-utils";
vi.mock("../../services/job-chat", () => ({
listThreads: vi.fn(async () => [
{
id: "thread-1",
jobId: "job-1",
title: "Thread",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
lastMessageAt: new Date().toISOString(),
},
]),
createThread: vi.fn(
async (input: { jobId: string; title?: string | null }) => ({
id: "thread-created",
jobId: input.jobId,
title: input.title ?? null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
lastMessageAt: null,
}),
),
listMessages: vi.fn(async () => [
{
id: "message-1",
threadId: "thread-1",
jobId: "job-1",
role: "user",
content: "hello",
status: "complete",
tokensIn: 1,
tokensOut: null,
version: 1,
replacesMessageId: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
]),
sendMessage: vi.fn(async () => ({
userMessage: {
id: "user-1",
threadId: "thread-1",
jobId: "job-1",
role: "user",
content: "hello",
status: "complete",
tokensIn: 1,
tokensOut: null,
version: 1,
replacesMessageId: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
assistantMessage: {
id: "assistant-1",
threadId: "thread-1",
jobId: "job-1",
role: "assistant",
content: "hi",
status: "complete",
tokensIn: 1,
tokensOut: 1,
version: 1,
replacesMessageId: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
runId: "run-1",
})),
cancelRun: vi.fn(async () => ({ cancelled: true, alreadyFinished: false })),
regenerateMessage: vi.fn(async () => ({
runId: "run-2",
assistantMessage: {
id: "assistant-2",
threadId: "thread-1",
jobId: "job-1",
role: "assistant",
content: "updated",
status: "complete",
tokensIn: 1,
tokensOut: 1,
version: 2,
replacesMessageId: "assistant-1",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
})),
}));
describe.sequential("Job Chat API", () => {
let server: Server;
let baseUrl: string;
let closeDb: () => void;
let tempDir: string;
beforeEach(async () => {
({ server, baseUrl, closeDb, tempDir } = await startServer());
});
afterEach(async () => {
await stopServer({ server, closeDb, tempDir });
});
it("lists threads with request id metadata", async () => {
const res = await fetch(`${baseUrl}/api/jobs/job-1/chat/threads`, {
headers: {
"x-request-id": "chat-req-1",
},
});
const body = await res.json();
expect(res.status).toBe(200);
expect(res.headers.get("x-request-id")).toBe("chat-req-1");
expect(body.ok).toBe(true);
expect(body.data.threads.length).toBe(1);
expect(body.meta.requestId).toBe("chat-req-1");
});
it("creates thread and sends a message", async () => {
const threadRes = await fetch(`${baseUrl}/api/jobs/job-1/chat/threads`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ title: "My thread" }),
});
const threadBody = await threadRes.json();
expect(threadRes.status).toBe(201);
expect(threadBody.ok).toBe(true);
expect(threadBody.data.thread.id).toBe("thread-created");
const messageRes = await fetch(
`${baseUrl}/api/jobs/job-1/chat/threads/thread-1/messages`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ content: "hello" }),
},
);
const messageBody = await messageRes.json();
expect(messageRes.status).toBe(200);
expect(messageBody.ok).toBe(true);
expect(messageBody.data.runId).toBe("run-1");
expect(messageBody.data.assistantMessage.role).toBe("assistant");
expect(typeof messageBody.meta.requestId).toBe("string");
});
});

View File

@ -0,0 +1,318 @@
import { asyncRoute, fail, ok } from "@infra/http";
import { runWithRequestContext } from "@infra/request-context";
import { badRequest, toAppError } from "@server/infra/errors";
import { type Request, type Response, Router } from "express";
import { z } from "zod";
import * as jobChatService from "../../services/job-chat";
export const jobChatRouter = Router({ mergeParams: true });
const createThreadSchema = z.object({
title: z.string().trim().max(200).nullable().optional(),
});
const listMessagesQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(500).optional(),
offset: z.coerce.number().int().min(0).max(10000).optional(),
});
const sendMessageSchema = z.object({
content: z.string().trim().min(1).max(20000),
stream: z.boolean().optional(),
});
const regenerateSchema = z.object({
stream: z.boolean().optional(),
});
function getJobId(req: Request): string {
const jobId = req.params.id;
if (!jobId) {
throw badRequest("Missing job id");
}
return jobId;
}
function writeSse(res: Response, event: unknown): void {
res.write(`data: ${JSON.stringify(event)}\n\n`);
}
jobChatRouter.get(
"/threads",
asyncRoute(async (req, res) => {
const jobId = getJobId(req);
await runWithRequestContext({ jobId }, async () => {
const threads = await jobChatService.listThreads(jobId);
ok(res, { threads });
});
}),
);
jobChatRouter.post(
"/threads",
asyncRoute(async (req, res) => {
const jobId = getJobId(req);
const parsed = createThreadSchema.safeParse(req.body);
if (!parsed.success) {
return fail(
res,
badRequest(parsed.error.message, parsed.error.flatten()),
);
}
await runWithRequestContext({ jobId }, async () => {
const thread = await jobChatService.createThread({
jobId,
title: parsed.data.title,
});
ok(res, { thread }, 201);
});
}),
);
jobChatRouter.get(
"/threads/:threadId/messages",
asyncRoute(async (req, res) => {
const jobId = getJobId(req);
const threadId = req.params.threadId;
if (!threadId) {
return fail(res, badRequest("Missing thread id"));
}
const parsed = listMessagesQuerySchema.safeParse(req.query);
if (!parsed.success) {
return fail(
res,
badRequest(parsed.error.message, parsed.error.flatten()),
);
}
await runWithRequestContext({ jobId }, async () => {
const messages = await jobChatService.listMessages({
jobId,
threadId,
limit: parsed.data.limit,
offset: parsed.data.offset,
});
ok(res, { messages });
});
}),
);
jobChatRouter.post(
"/threads/:threadId/messages",
asyncRoute(async (req, res) => {
const jobId = getJobId(req);
const threadId = req.params.threadId;
if (!threadId) {
return fail(res, badRequest("Missing thread id"));
}
const parsed = sendMessageSchema.safeParse(req.body);
if (!parsed.success) {
return fail(
res,
badRequest(parsed.error.message, parsed.error.flatten()),
);
}
await runWithRequestContext({ jobId }, async () => {
if (parsed.data.stream) {
res.status(200);
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache, no-transform");
res.setHeader("Connection", "keep-alive");
res.flushHeaders?.();
try {
await jobChatService.sendMessage({
jobId,
threadId,
content: parsed.data.content,
stream: {
onReady: ({ runId, messageId, requestId }) =>
writeSse(res, {
type: "ready",
runId,
threadId,
messageId,
requestId,
}),
onDelta: ({ runId, messageId, delta }) =>
writeSse(res, {
type: "delta",
runId,
messageId,
delta,
}),
onCompleted: ({ runId, message }) =>
writeSse(res, {
type: "completed",
runId,
message,
}),
onCancelled: ({ runId, message }) =>
writeSse(res, {
type: "cancelled",
runId,
message,
}),
onError: ({ runId, code, message, requestId }) =>
writeSse(res, {
type: "error",
runId,
code,
message,
requestId,
}),
},
});
} catch (error) {
const appError = toAppError(error);
writeSse(res, {
type: "error",
code: appError.code,
message: appError.message,
requestId: res.getHeader("x-request-id") || "unknown",
});
} finally {
res.end();
}
return;
}
const result = await jobChatService.sendMessage({
jobId,
threadId,
content: parsed.data.content,
});
ok(res, {
userMessage: result.userMessage,
assistantMessage: result.assistantMessage,
runId: result.runId,
});
});
}),
);
jobChatRouter.post(
"/threads/:threadId/runs/:runId/cancel",
asyncRoute(async (req, res) => {
const jobId = getJobId(req);
const threadId = req.params.threadId;
const runId = req.params.runId;
if (!threadId || !runId) {
return fail(res, badRequest("Missing thread id or run id"));
}
await runWithRequestContext({ jobId }, async () => {
const result = await jobChatService.cancelRun({
jobId,
threadId,
runId,
});
ok(res, result);
});
}),
);
jobChatRouter.post(
"/threads/:threadId/messages/:assistantMessageId/regenerate",
asyncRoute(async (req, res) => {
const jobId = getJobId(req);
const threadId = req.params.threadId;
const assistantMessageId = req.params.assistantMessageId;
if (!threadId || !assistantMessageId) {
return fail(res, badRequest("Missing thread id or message id"));
}
const parsed = regenerateSchema.safeParse(req.body ?? {});
if (!parsed.success) {
return fail(
res,
badRequest(parsed.error.message, parsed.error.flatten()),
);
}
await runWithRequestContext({ jobId }, async () => {
if (parsed.data.stream) {
res.status(200);
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache, no-transform");
res.setHeader("Connection", "keep-alive");
res.flushHeaders?.();
try {
await jobChatService.regenerateMessage({
jobId,
threadId,
assistantMessageId,
stream: {
onReady: ({ runId, messageId, requestId }) =>
writeSse(res, {
type: "ready",
runId,
threadId,
messageId,
requestId,
}),
onDelta: ({ runId, messageId, delta }) =>
writeSse(res, {
type: "delta",
runId,
messageId,
delta,
}),
onCompleted: ({ runId, message }) =>
writeSse(res, {
type: "completed",
runId,
message,
}),
onCancelled: ({ runId, message }) =>
writeSse(res, {
type: "cancelled",
runId,
message,
}),
onError: ({ runId, code, message, requestId }) =>
writeSse(res, {
type: "error",
runId,
code,
message,
requestId,
}),
},
});
} catch (error) {
const appError = toAppError(error);
writeSse(res, {
type: "error",
code: appError.code,
message: appError.message,
requestId: res.getHeader("x-request-id") || "unknown",
});
} finally {
res.end();
}
return;
}
const result = await jobChatService.regenerateMessage({
jobId,
threadId,
assistantMessageId,
});
ok(res, result);
});
}),
);

View File

@ -451,25 +451,6 @@ const migrations = [
`CREATE INDEX IF NOT EXISTS idx_job_chat_threads_job_updated ON job_chat_threads(job_id, updated_at)`,
`CREATE INDEX IF NOT EXISTS idx_job_chat_messages_thread_created ON job_chat_messages(thread_id, created_at)`,
`CREATE INDEX IF NOT EXISTS idx_job_chat_runs_thread_status ON job_chat_runs(thread_id, status)`,
// Ensure only one running run per thread; backfill any duplicates first.
`WITH ranked AS (
SELECT
id,
ROW_NUMBER() OVER (PARTITION BY thread_id ORDER BY started_at DESC, id DESC) AS rank_in_thread
FROM job_chat_runs
WHERE status = 'running'
)
UPDATE job_chat_runs
SET
status = 'failed',
error_code = COALESCE(error_code, 'CONFLICT'),
error_message = COALESCE(error_message, 'Recovered duplicate running run during migration'),
completed_at = COALESCE(completed_at, CAST(strftime('%s', 'now') AS INTEGER) * 1000),
updated_at = datetime('now')
WHERE id IN (SELECT id FROM ranked WHERE rank_in_thread > 1)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_job_chat_runs_thread_running_unique
ON job_chat_runs(thread_id)
WHERE status = 'running'`,
// Backfill: Create "Applied" events for legacy jobs that have applied_at set but no event entry
`INSERT INTO stage_events (id, application_id, title, from_stage, to_stage, occurred_at, metadata)

View File

@ -0,0 +1,329 @@
import { randomUUID } from "node:crypto";
import type {
JobChatMessage,
JobChatMessageRole,
JobChatMessageStatus,
JobChatRun,
JobChatRunStatus,
JobChatThread,
} from "@shared/types";
import { and, desc, eq } from "drizzle-orm";
import { db, schema } from "../db";
const { jobChatMessages, jobChatRuns, jobChatThreads } = schema;
function mapThread(row: typeof jobChatThreads.$inferSelect): JobChatThread {
return {
id: row.id,
jobId: row.jobId,
title: row.title,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
lastMessageAt: row.lastMessageAt,
};
}
function mapMessage(row: typeof jobChatMessages.$inferSelect): JobChatMessage {
return {
id: row.id,
threadId: row.threadId,
jobId: row.jobId,
role: row.role as JobChatMessageRole,
content: row.content,
status: row.status as JobChatMessageStatus,
tokensIn: row.tokensIn,
tokensOut: row.tokensOut,
version: row.version,
replacesMessageId: row.replacesMessageId,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
function mapRun(row: typeof jobChatRuns.$inferSelect): JobChatRun {
return {
id: row.id,
threadId: row.threadId,
jobId: row.jobId,
status: row.status as JobChatRunStatus,
model: row.model,
provider: row.provider,
errorCode: row.errorCode,
errorMessage: row.errorMessage,
startedAt: row.startedAt,
completedAt: row.completedAt,
requestId: row.requestId,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
export async function listThreadsForJob(
jobId: string,
): Promise<JobChatThread[]> {
const rows = await db
.select()
.from(jobChatThreads)
.where(eq(jobChatThreads.jobId, jobId))
.orderBy(desc(jobChatThreads.updatedAt));
return rows.map(mapThread);
}
export async function getThreadById(
threadId: string,
): Promise<JobChatThread | null> {
const [row] = await db
.select()
.from(jobChatThreads)
.where(eq(jobChatThreads.id, threadId));
return row ? mapThread(row) : null;
}
export async function getThreadForJob(
jobId: string,
threadId: string,
): Promise<JobChatThread | null> {
const [row] = await db
.select()
.from(jobChatThreads)
.where(
and(eq(jobChatThreads.id, threadId), eq(jobChatThreads.jobId, jobId)),
);
return row ? mapThread(row) : null;
}
export async function createThread(input: {
jobId: string;
title?: string | null;
}): Promise<JobChatThread> {
const id = randomUUID();
const now = new Date().toISOString();
await db.insert(jobChatThreads).values({
id,
jobId: input.jobId,
title: input.title ?? null,
createdAt: now,
updatedAt: now,
lastMessageAt: null,
});
const thread = await getThreadById(id);
if (!thread) {
throw new Error(`Failed to load created chat thread ${id}.`);
}
return thread;
}
export async function touchThread(
threadId: string,
lastMessageAt?: string,
): Promise<void> {
const now = new Date().toISOString();
await db
.update(jobChatThreads)
.set({
updatedAt: now,
...(lastMessageAt !== undefined ? { lastMessageAt } : {}),
})
.where(eq(jobChatThreads.id, threadId));
}
export async function listMessagesForThread(
threadId: string,
options?: { limit?: number; offset?: number },
): Promise<JobChatMessage[]> {
const limit = options?.limit ?? 200;
const offset = options?.offset ?? 0;
const rows = await db
.select()
.from(jobChatMessages)
.where(eq(jobChatMessages.threadId, threadId))
.orderBy(jobChatMessages.createdAt)
.limit(limit)
.offset(offset);
return rows.map(mapMessage);
}
export async function getMessageById(
messageId: string,
): Promise<JobChatMessage | null> {
const [row] = await db
.select()
.from(jobChatMessages)
.where(eq(jobChatMessages.id, messageId));
return row ? mapMessage(row) : null;
}
export async function createMessage(input: {
threadId: string;
jobId: string;
role: JobChatMessageRole;
content: string;
status?: JobChatMessageStatus;
tokensIn?: number | null;
tokensOut?: number | null;
version?: number;
replacesMessageId?: string | null;
}): Promise<JobChatMessage> {
const id = randomUUID();
const now = new Date().toISOString();
await db.insert(jobChatMessages).values({
id,
threadId: input.threadId,
jobId: input.jobId,
role: input.role,
content: input.content,
status: input.status ?? "partial",
tokensIn: input.tokensIn ?? null,
tokensOut: input.tokensOut ?? null,
version: input.version ?? 1,
replacesMessageId: input.replacesMessageId ?? null,
createdAt: now,
updatedAt: now,
});
await touchThread(input.threadId, now);
const created = await getMessageById(id);
if (!created) {
throw new Error(`Failed to load created chat message ${id}.`);
}
return created;
}
export async function updateMessage(
messageId: string,
input: {
content?: string;
status?: JobChatMessageStatus;
tokensIn?: number | null;
tokensOut?: number | null;
},
): Promise<JobChatMessage | null> {
const now = new Date().toISOString();
await db
.update(jobChatMessages)
.set({
...(input.content !== undefined ? { content: input.content } : {}),
...(input.status !== undefined ? { status: input.status } : {}),
...(input.tokensIn !== undefined ? { tokensIn: input.tokensIn } : {}),
...(input.tokensOut !== undefined ? { tokensOut: input.tokensOut } : {}),
updatedAt: now,
})
.where(eq(jobChatMessages.id, messageId));
const message = await getMessageById(messageId);
if (message) {
await touchThread(message.threadId, now);
}
return message;
}
export async function getLatestAssistantMessage(
threadId: string,
): Promise<JobChatMessage | null> {
const [row] = await db
.select()
.from(jobChatMessages)
.where(
and(
eq(jobChatMessages.threadId, threadId),
eq(jobChatMessages.role, "assistant"),
),
)
.orderBy(desc(jobChatMessages.createdAt))
.limit(1);
return row ? mapMessage(row) : null;
}
export async function createRun(input: {
threadId: string;
jobId: string;
model: string | null;
provider: string | null;
requestId?: string | null;
}): Promise<JobChatRun> {
const id = randomUUID();
const startedAt = Date.now();
const now = new Date(startedAt).toISOString();
await db.insert(jobChatRuns).values({
id,
threadId: input.threadId,
jobId: input.jobId,
status: "running",
model: input.model,
provider: input.provider,
errorCode: null,
errorMessage: null,
startedAt,
completedAt: null,
requestId: input.requestId ?? null,
createdAt: now,
updatedAt: now,
});
const run = await getRunById(id);
if (!run) {
throw new Error(`Failed to load created chat run ${id}.`);
}
return run;
}
export async function getRunById(runId: string): Promise<JobChatRun | null> {
const [row] = await db
.select()
.from(jobChatRuns)
.where(eq(jobChatRuns.id, runId));
return row ? mapRun(row) : null;
}
export async function getActiveRunForThread(
threadId: string,
): Promise<JobChatRun | null> {
const [row] = await db
.select()
.from(jobChatRuns)
.where(
and(
eq(jobChatRuns.threadId, threadId),
eq(jobChatRuns.status, "running"),
),
)
.orderBy(desc(jobChatRuns.startedAt))
.limit(1);
return row ? mapRun(row) : null;
}
export async function completeRun(
runId: string,
input: {
status: Exclude<JobChatRunStatus, "running">;
errorCode?: string | null;
errorMessage?: string | null;
},
): Promise<JobChatRun | null> {
const nowEpoch = Date.now();
const nowIso = new Date(nowEpoch).toISOString();
await db
.update(jobChatRuns)
.set({
status: input.status,
completedAt: nowEpoch,
errorCode: input.errorCode ?? null,
errorMessage: input.errorMessage ?? null,
updatedAt: nowIso,
})
.where(eq(jobChatRuns.id, runId));
return getRunById(runId);
}

View File

@ -26,6 +26,7 @@ export type SettingKey =
| "jobspyResultsWanted"
| "jobspyCountryIndeed"
| "showSponsorInfo"
| "jobChatEnabled"
| "chatStyleTone"
| "chatStyleFormality"
| "chatStyleConstraints"

View File

@ -0,0 +1,198 @@
import { logger } from "@infra/logger";
import { sanitizeUnknown } from "@infra/sanitize";
import type { Job, ResumeProfile } from "@shared/types";
import { badRequest, notFound } from "../infra/errors";
import * as jobsRepo from "../repositories/jobs";
import * as settingsRepo from "../repositories/settings";
import { getProfile } from "./profile";
import { resolveSettingValue } from "./settings-conversion";
type JobChatStyle = {
tone: string;
formality: string;
constraints: string;
doNotUse: string;
};
export type JobChatPromptContext = {
job: Job;
style: JobChatStyle;
systemPrompt: string;
jobSnapshot: string;
profileSnapshot: string;
};
const MAX_JOB_DESCRIPTION = 4000;
const MAX_PROFILE_SUMMARY = 1200;
const MAX_SKILLS = 18;
const MAX_PROJECTS = 6;
const MAX_EXPERIENCE = 5;
const MAX_ITEM_TEXT = 320;
function truncate(value: string | null | undefined, max: number): string {
if (!value) return "";
const trimmed = value.trim();
if (trimmed.length <= max) return trimmed;
return `${trimmed.slice(0, max)}...`;
}
function compactJoin(parts: Array<string | null | undefined>): string {
return parts.filter(Boolean).join("\n");
}
function buildJobSnapshot(job: Job): string {
const snapshot = {
event: "job.completed",
sentAt: new Date().toISOString(),
job: {
id: job.id,
source: job.source,
title: job.title,
employer: job.employer,
location: job.location,
salary: job.salary,
status: job.status,
jobUrl: job.jobUrl,
applicationLink: job.applicationLink,
suitabilityScore: job.suitabilityScore,
suitabilityReason: truncate(job.suitabilityReason, 600),
tailoredSummary: truncate(job.tailoredSummary, 1200),
tailoredHeadline: truncate(job.tailoredHeadline, 300),
tailoredSkills: truncate(job.tailoredSkills, 1200),
jobDescription: truncate(job.jobDescription, MAX_JOB_DESCRIPTION),
},
};
return JSON.stringify(snapshot, null, 2);
}
function buildProfileSnapshot(profile: ResumeProfile): string {
const summary =
truncate(profile?.sections?.summary?.content, MAX_PROFILE_SUMMARY) ||
truncate(profile?.basics?.summary, MAX_PROFILE_SUMMARY);
const skills = (profile?.sections?.skills?.items ?? [])
.slice(0, MAX_SKILLS)
.map((item) => {
const keywords = (item.keywords ?? []).slice(0, 8).join(", ");
return `${item.name}${keywords ? `: ${keywords}` : ""}`;
});
const projects = (profile?.sections?.projects?.items ?? [])
.filter((item) => item.visible !== false)
.slice(0, MAX_PROJECTS)
.map(
(item) =>
`${item.name} (${item.date || "n/a"}): ${truncate(item.summary, MAX_ITEM_TEXT)}`,
);
const experience = (profile?.sections?.experience?.items ?? [])
.filter((item) => item.visible !== false)
.slice(0, MAX_EXPERIENCE)
.map(
(item) =>
`${item.position} @ ${item.company} (${item.date || "n/a"}): ${truncate(item.summary, MAX_ITEM_TEXT)}`,
);
return compactJoin([
`Name: ${profile?.basics?.name || "Unknown"}`,
`Headline: ${truncate(profile?.basics?.headline || profile?.basics?.label, 200) || ""}`,
summary ? `Summary:\n${summary}` : null,
skills.length > 0 ? `Skills:\n- ${skills.join("\n- ")}` : null,
projects.length > 0 ? `Projects:\n- ${projects.join("\n- ")}` : null,
experience.length > 0 ? `Experience:\n- ${experience.join("\n- ")}` : null,
]);
}
function buildSystemPrompt(style: JobChatStyle): string {
return compactJoin([
"You are a job application copilot for a single job.",
"Use only the provided job and profile context unless the user gives extra details.",
"Do not claim actions were executed. You are read-only and advisory.",
"If details are missing, say what is missing before making assumptions.",
"Avoid exposing private profile details that are unrelated to the user request.",
`Writing style tone: ${style.tone}.`,
`Writing style formality: ${style.formality}.`,
style.constraints ? `Writing constraints: ${style.constraints}` : null,
style.doNotUse ? `Avoid these terms: ${style.doNotUse}` : null,
]);
}
async function resolveStyle(): Promise<JobChatStyle> {
const overrides = await settingsRepo.getAllSettings();
const tone = resolveSettingValue(
"chatStyleTone",
overrides.chatStyleTone,
).value;
const formality = resolveSettingValue(
"chatStyleFormality",
overrides.chatStyleFormality,
).value;
const constraints = resolveSettingValue(
"chatStyleConstraints",
overrides.chatStyleConstraints,
).value;
const doNotUse = resolveSettingValue(
"chatStyleDoNotUse",
overrides.chatStyleDoNotUse,
).value;
return {
tone,
formality,
constraints,
doNotUse,
};
}
export async function isJobChatEnabled(): Promise<boolean> {
const overrides = await settingsRepo.getAllSettings();
return resolveSettingValue("jobChatEnabled", overrides.jobChatEnabled).value;
}
export async function buildJobChatPromptContext(
jobId: string,
): Promise<JobChatPromptContext> {
const job = await jobsRepo.getJobById(jobId);
if (!job) {
throw notFound("Job not found");
}
const style = await resolveStyle();
let profile: ResumeProfile = {};
try {
profile = await getProfile();
} catch (error) {
logger.warn("Failed to load profile for job chat context", {
jobId,
error: sanitizeUnknown(error),
});
}
const systemPrompt = buildSystemPrompt(style);
const jobSnapshot = buildJobSnapshot(job);
const profileSnapshot = buildProfileSnapshot(profile);
if (!jobSnapshot.trim()) {
throw badRequest("Unable to build job context");
}
logger.info("Built job chat context", {
jobId,
includesProfile: Boolean(profileSnapshot),
contextStats: sanitizeUnknown({
systemChars: systemPrompt.length,
jobChars: jobSnapshot.length,
profileChars: profileSnapshot.length,
}),
});
return {
job,
style,
systemPrompt,
jobSnapshot,
profileSnapshot,
};
}

View File

@ -0,0 +1,521 @@
import { logger } from "@infra/logger";
import { getRequestId } from "@infra/request-context";
import {
badRequest,
conflict,
notFound,
requestTimeout,
upstreamError,
} from "../infra/errors";
import * as jobChatRepo from "../repositories/job-chat";
import * as settingsRepo from "../repositories/settings";
import {
buildJobChatPromptContext,
isJobChatEnabled,
} from "./job-chat-context";
import { LlmService } from "./llm/service";
import type { JsonSchemaDefinition } from "./llm/types";
type LlmRuntimeSettings = {
model: string;
provider: string | null;
baseUrl: string | null;
apiKey: string | null;
};
const abortControllers = new Map<string, AbortController>();
const CHAT_RESPONSE_SCHEMA: JsonSchemaDefinition = {
name: "job_chat_response",
schema: {
type: "object",
properties: {
response: {
type: "string",
},
},
required: ["response"],
additionalProperties: false,
},
};
function estimateTokenCount(value: string): number {
if (!value) return 0;
return Math.ceil(value.length / 4);
}
function chunkText(value: string, maxChunk = 60): string[] {
if (!value) return [];
const chunks: string[] = [];
let cursor = 0;
while (cursor < value.length) {
chunks.push(value.slice(cursor, cursor + maxChunk));
cursor += maxChunk;
}
return chunks;
}
async function resolveLlmRuntimeSettings(): Promise<LlmRuntimeSettings> {
const overrides = await settingsRepo.getAllSettings();
const model =
overrides.modelTailoring ||
overrides.model ||
process.env.MODEL ||
"google/gemini-3-flash-preview";
const provider =
overrides.llmProvider || process.env.LLM_PROVIDER || "openrouter";
const baseUrl = overrides.llmBaseUrl || process.env.LLM_BASE_URL || null;
const apiKey = overrides.llmApiKey || process.env.LLM_API_KEY || null;
return {
model,
provider,
baseUrl,
apiKey,
};
}
async function buildConversationMessages(
threadId: string,
): Promise<Array<{ role: "user" | "assistant"; content: string }>> {
const messages = await jobChatRepo.listMessagesForThread(threadId, {
limit: 40,
});
return messages
.filter(
(message): message is typeof message & { role: "user" | "assistant" } =>
message.role === "user" || message.role === "assistant",
)
.filter((message) => message.status !== "failed")
.map((message) => ({
role: message.role,
content: message.content,
}));
}
async function ensureChatEnabled(): Promise<void> {
const enabled = await isJobChatEnabled();
if (!enabled) {
throw badRequest(
"Job chat is disabled. Enable it in Settings > Job Chat Copilot.",
);
}
}
type GenerateReplyOptions = {
jobId: string;
threadId: string;
prompt: string;
replaceMessageId?: string;
version?: number;
stream?: {
onReady: (payload: {
runId: string;
messageId: string;
requestId: string;
}) => void;
onDelta: (payload: {
runId: string;
messageId: string;
delta: string;
}) => void;
onCompleted: (payload: {
runId: string;
message: Awaited<ReturnType<typeof jobChatRepo.getMessageById>>;
}) => void;
onCancelled: (payload: {
runId: string;
message: Awaited<ReturnType<typeof jobChatRepo.getMessageById>>;
}) => void;
onError: (payload: {
runId: string;
code: string;
message: string;
requestId: string;
}) => void;
};
};
export async function createThread(input: {
jobId: string;
title?: string | null;
}) {
await ensureChatEnabled();
return jobChatRepo.createThread(input);
}
export async function listThreads(jobId: string) {
await ensureChatEnabled();
return jobChatRepo.listThreadsForJob(jobId);
}
export async function listMessages(input: {
jobId: string;
threadId: string;
limit?: number;
offset?: number;
}) {
await ensureChatEnabled();
const thread = await jobChatRepo.getThreadForJob(input.jobId, input.threadId);
if (!thread) {
throw notFound("Thread not found for this job");
}
return jobChatRepo.listMessagesForThread(input.threadId, {
limit: input.limit,
offset: input.offset,
});
}
async function runAssistantReply(
options: GenerateReplyOptions,
): Promise<{ runId: string; messageId: string; message: string }> {
await ensureChatEnabled();
const thread = await jobChatRepo.getThreadForJob(
options.jobId,
options.threadId,
);
if (!thread) {
throw notFound("Thread not found for this job");
}
const activeRun = await jobChatRepo.getActiveRunForThread(options.threadId);
if (activeRun) {
throw conflict("A chat generation is already running for this thread");
}
const [context, llmConfig, history] = await Promise.all([
buildJobChatPromptContext(options.jobId),
resolveLlmRuntimeSettings(),
buildConversationMessages(options.threadId),
]);
const requestId = getRequestId() ?? "unknown";
const assistantMessage = await jobChatRepo.createMessage({
threadId: options.threadId,
jobId: options.jobId,
role: "assistant",
content: "",
status: "partial",
version: options.version ?? 1,
replacesMessageId: options.replaceMessageId ?? null,
});
const run = await jobChatRepo.createRun({
threadId: options.threadId,
jobId: options.jobId,
model: llmConfig.model,
provider: llmConfig.provider,
requestId,
});
const controller = new AbortController();
abortControllers.set(run.id, controller);
options.stream?.onReady({
runId: run.id,
messageId: assistantMessage.id,
requestId,
});
let accumulated = "";
try {
const llm = new LlmService({
provider: llmConfig.provider,
baseUrl: llmConfig.baseUrl,
apiKey: llmConfig.apiKey,
});
const llmResult = await llm.callJson<{ response: string }>({
model: llmConfig.model,
messages: [
{
role: "system",
content: context.systemPrompt,
},
{
role: "system",
content: `Job Context (JSON):\n${context.jobSnapshot}`,
},
{
role: "system",
content: `Profile Context:\n${context.profileSnapshot || "No profile context available."}`,
},
...history,
{
role: "user",
content: options.prompt,
},
],
jsonSchema: CHAT_RESPONSE_SCHEMA,
maxRetries: 1,
retryDelayMs: 300,
jobId: options.jobId,
signal: controller.signal,
});
if (!llmResult.success) {
if (controller.signal.aborted) {
throw requestTimeout("Chat generation was cancelled");
}
throw upstreamError("LLM generation failed", {
reason: llmResult.error,
});
}
const finalText = (llmResult.data.response || "").trim();
const chunks = chunkText(finalText);
for (const chunk of chunks) {
if (controller.signal.aborted) {
const cancelled = await jobChatRepo.updateMessage(assistantMessage.id, {
content: accumulated,
status: "cancelled",
tokensIn: estimateTokenCount(options.prompt),
tokensOut: estimateTokenCount(accumulated),
});
await jobChatRepo.completeRun(run.id, {
status: "cancelled",
errorCode: "REQUEST_TIMEOUT",
errorMessage: "Generation cancelled by user",
});
options.stream?.onCancelled({ runId: run.id, message: cancelled });
return {
runId: run.id,
messageId: assistantMessage.id,
message: accumulated,
};
}
accumulated += chunk;
options.stream?.onDelta({
runId: run.id,
messageId: assistantMessage.id,
delta: chunk,
});
}
const completedMessage = await jobChatRepo.updateMessage(
assistantMessage.id,
{
content: accumulated,
status: "complete",
tokensIn: estimateTokenCount(options.prompt),
tokensOut: estimateTokenCount(accumulated),
},
);
await jobChatRepo.completeRun(run.id, {
status: "completed",
});
options.stream?.onCompleted({
runId: run.id,
message: completedMessage,
});
return {
runId: run.id,
messageId: assistantMessage.id,
message: accumulated,
};
} catch (error) {
const appError = error instanceof Error ? error : new Error(String(error));
const isCancelled =
controller.signal.aborted || appError.name === "AbortError";
const status = isCancelled ? "cancelled" : "failed";
const code = isCancelled ? "REQUEST_TIMEOUT" : "UPSTREAM_ERROR";
const message = isCancelled
? "Generation cancelled by user"
: appError.message || "Generation failed";
const failedMessage = await jobChatRepo.updateMessage(assistantMessage.id, {
content: accumulated,
status: isCancelled ? "cancelled" : "failed",
tokensIn: estimateTokenCount(options.prompt),
tokensOut: estimateTokenCount(accumulated),
});
await jobChatRepo.completeRun(run.id, {
status,
errorCode: code,
errorMessage: message,
});
if (isCancelled) {
options.stream?.onCancelled({ runId: run.id, message: failedMessage });
return {
runId: run.id,
messageId: assistantMessage.id,
message: accumulated,
};
}
options.stream?.onError({
runId: run.id,
code,
message,
requestId,
});
throw upstreamError(message, { runId: run.id });
} finally {
abortControllers.delete(run.id);
logger.info("Job chat run finished", {
jobId: options.jobId,
threadId: options.threadId,
runId: run.id,
});
}
}
export async function sendMessage(input: {
jobId: string;
threadId: string;
content: string;
stream?: GenerateReplyOptions["stream"];
}) {
await ensureChatEnabled();
const content = input.content.trim();
if (!content) {
throw badRequest("Message content is required");
}
const thread = await jobChatRepo.getThreadForJob(input.jobId, input.threadId);
if (!thread) {
throw notFound("Thread not found for this job");
}
const userMessage = await jobChatRepo.createMessage({
threadId: input.threadId,
jobId: input.jobId,
role: "user",
content,
status: "complete",
tokensIn: estimateTokenCount(content),
tokensOut: null,
});
const result = await runAssistantReply({
jobId: input.jobId,
threadId: input.threadId,
prompt: content,
stream: input.stream,
});
const assistantMessage = await jobChatRepo.getMessageById(result.messageId);
return {
userMessage,
assistantMessage,
runId: result.runId,
};
}
export async function regenerateMessage(input: {
jobId: string;
threadId: string;
assistantMessageId: string;
stream?: GenerateReplyOptions["stream"];
}) {
await ensureChatEnabled();
const thread = await jobChatRepo.getThreadForJob(input.jobId, input.threadId);
if (!thread) {
throw notFound("Thread not found for this job");
}
const target = await jobChatRepo.getMessageById(input.assistantMessageId);
if (
!target ||
target.threadId !== input.threadId ||
target.jobId !== input.jobId
) {
throw notFound("Assistant message not found for this thread");
}
if (target.role !== "assistant") {
throw badRequest("Only assistant messages can be regenerated");
}
const latestAssistant = await jobChatRepo.getLatestAssistantMessage(
input.threadId,
);
if (!latestAssistant || latestAssistant.id !== target.id) {
throw badRequest("Only the latest assistant message can be regenerated");
}
const messages = await jobChatRepo.listMessagesForThread(input.threadId, {
limit: 200,
});
const targetIndex = messages.findIndex((message) => message.id === target.id);
const priorUser =
targetIndex > 0
? [...messages.slice(0, targetIndex)]
.reverse()
.find((message) => message.role === "user")
: null;
if (!priorUser) {
throw badRequest("Could not find a user message to regenerate from");
}
const result = await runAssistantReply({
jobId: input.jobId,
threadId: input.threadId,
prompt: priorUser.content,
replaceMessageId: target.id,
version: (target.version || 1) + 1,
stream: input.stream,
});
const assistantMessage = await jobChatRepo.getMessageById(result.messageId);
return {
runId: result.runId,
assistantMessage,
};
}
export async function cancelRun(input: {
jobId: string;
threadId: string;
runId: string;
}): Promise<{ cancelled: boolean; alreadyFinished: boolean }> {
await ensureChatEnabled();
const run = await jobChatRepo.getRunById(input.runId);
if (!run || run.threadId !== input.threadId || run.jobId !== input.jobId) {
throw notFound("Run not found for this thread");
}
if (run.status !== "running") {
return {
cancelled: false,
alreadyFinished: true,
};
}
const controller = abortControllers.get(input.runId);
if (controller) {
controller.abort();
}
await jobChatRepo.completeRun(input.runId, {
status: "cancelled",
errorCode: "REQUEST_TIMEOUT",
errorMessage: "Generation cancelled by user",
});
return {
cancelled: true,
alreadyFinished: false,
};
}

View File

@ -13,6 +13,7 @@ type SettingsConversionValueMap = {
jobspyResultsWanted: number;
jobspyCountryIndeed: string;
showSponsorInfo: boolean;
jobChatEnabled: boolean;
chatStyleTone: string;
chatStyleFormality: string;
chatStyleConstraints: string;
@ -141,6 +142,14 @@ export const settingsConversionMetadata: SettingsConversionMetadata = {
serialize: serializeBitBool,
resolve: resolveWithNullishFallback,
},
jobChatEnabled: {
defaultValue: () =>
(process.env.JOB_CHAT_ENABLED || "").toLowerCase() === "true" ||
process.env.JOB_CHAT_ENABLED === "1",
parseOverride: parseBitBoolOrNull,
serialize: serializeBitBool,
resolve: resolveWithNullishFallback,
},
chatStyleTone: {
defaultValue: () => process.env.CHAT_STYLE_TONE || "professional",
parseOverride: (raw) => raw ?? null,

View File

@ -188,6 +188,11 @@ export const settingsUpdateRegistry: Partial<{
actions: [metadataPersistAction("showSponsorInfo", value)],
}),
),
jobChatEnabled: singleAction(({ value }) =>
result({
actions: [metadataPersistAction("jobChatEnabled", value)],
}),
),
chatStyleTone: singleAction(({ value }) =>
result({
actions: [metadataPersistAction("chatStyleTone", value)],

View File

@ -146,6 +146,14 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
const overrideShowSponsorInfo = showSponsorInfoSetting.overrideValue;
const showSponsorInfo = showSponsorInfoSetting.value;
const jobChatEnabledSetting = resolveSettingValue(
"jobChatEnabled",
overrides.jobChatEnabled,
);
const defaultJobChatEnabled = jobChatEnabledSetting.defaultValue;
const overrideJobChatEnabled = jobChatEnabledSetting.overrideValue;
const jobChatEnabled = jobChatEnabledSetting.value;
const chatStyleToneSetting = resolveSettingValue(
"chatStyleTone",
overrides.chatStyleTone,
@ -278,6 +286,9 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
showSponsorInfo,
defaultShowSponsorInfo,
overrideShowSponsorInfo,
jobChatEnabled,
defaultJobChatEnabled,
overrideJobChatEnabled,
chatStyleTone,
defaultChatStyleTone,
overrideChatStyleTone,

View File

@ -54,6 +54,7 @@ export const updateSettingsSchema = z
.optional(),
jobspyCountryIndeed: z.string().trim().max(100).nullable().optional(),
showSponsorInfo: z.boolean().nullable().optional(),
jobChatEnabled: z.boolean().nullable().optional(),
chatStyleTone: z.string().trim().max(100).nullable().optional(),
chatStyleFormality: z.string().trim().max(100).nullable().optional(),
chatStyleConstraints: z.string().trim().max(4000).nullable().optional(),

View File

@ -179,6 +179,9 @@ export const createAppSettings = (
showSponsorInfo: true,
defaultShowSponsorInfo: true,
overrideShowSponsorInfo: null,
jobChatEnabled: false,
defaultJobChatEnabled: false,
overrideJobChatEnabled: null,
chatStyleTone: "professional",
defaultChatStyleTone: "professional",
overrideChatStyleTone: null,

View File

@ -913,6 +913,9 @@ export interface AppSettings {
showSponsorInfo: boolean;
defaultShowSponsorInfo: boolean;
overrideShowSponsorInfo: boolean | null;
jobChatEnabled: boolean;
defaultJobChatEnabled: boolean;
overrideJobChatEnabled: boolean | null;
chatStyleTone: string;
defaultChatStyleTone: string;
overrideChatStyleTone: string | null;