Ghostwriter Introduced (#166)

* initlal commit

* Ghostwriter always enabled

* rename code

* ghostwriter panel

* separate component

* ui improvements

* single thread

* copy improvement

* dont pop up keyboard shortcuts

* markdown renderer

* ghostwriter button placement

* better UX

* ghostwriter copy

* meta shortcut

* better settings menu

* formatting

* doocumentation

* add tests

* race condition

* race condition 2

* pass title

* more comments

* comments

* formtting
This commit is contained in:
Shaheer Sarfaraz 2026-02-15 22:03:37 +00:00 committed by GitHub
parent e6563e74c3
commit d0b4091a60
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
39 changed files with 4306 additions and 27 deletions

View File

@ -18,6 +18,12 @@ Welcome to the JobOps documentation. This folder contains comprehensive guides f
- PDF generation and regeneration
- Post-application tracking overview
- **[Ghostwriter](./ghostwriter.md)** - Context-aware per-job chat assistant
- One persistent conversation per job
- Streaming responses, stop, and regenerate
- Markdown rendering and drawer UX behavior
- Writing style settings impact
- **[Post-Application Tracking](./post-application-tracking.md)** - Email-to-job matching
- How the Smart Router AI works
- Gmail integration setup
@ -58,6 +64,7 @@ JobOps uses specialized extractors to gather jobs from different sources:
documentation/
├── self-hosting.md # Deployment guide
├── orchestrator.md # Core workflow documentation
├── ghostwriter.md # Ghostwriter feature documentation
├── post-application-tracking.md # Email tracking feature
└── extractors/ # Job source extractors
├── README.md

View File

@ -0,0 +1,60 @@
# Ghostwriter
Ghostwriter is the per-job AI chat assistant in JobOps. It is optional to use and is designed for drafting application content with job-specific context already loaded.
## What Ghostwriter is for
Ghostwriter is not a generic chat box. For each job, it uses:
- The current job description and job metadata
- A reduced snapshot of your resume/profile
- Your global Ghostwriter writing style settings
This makes it useful for:
- Drafting role-specific answers
- Cover letter and outreach drafts
- Interview prep talking points tied to the current JD
- Rephrasing content to match your preferred style
## Where it appears
- Available from job details in `discovered` and `ready` flows
- Opens as a right-side drawer
- One persistent conversation per job
## Writing style settings impact
Ghostwriter settings are global and affect new generations:
- `Tone`: adds a tone instruction in the Ghostwriter system prompt
- `Formality`: adds a formality instruction
- `Constraints`: appended as explicit writing constraints
- `Do-not-use terms`: appended as language to avoid
Defaults:
- Tone: `professional`
- Formality: `medium`
- Constraints: empty
- Do-not-use terms: empty
## Context + safety model
Ghostwriter context is assembled server-side with size limits and sanitization:
- Job snapshot is truncated to fit prompt budget
- Profile snapshot includes only relevant slices (summary, skills, projects, experience)
- System prompt enforces read-only assistant behavior
- Logging stores metadata only (not raw full prompt/response dumps)
## API surface (current)
Primary per-job endpoints:
- `GET /api/jobs/:id/chat/messages`
- `POST /api/jobs/:id/chat/messages` (supports streaming)
- `POST /api/jobs/:id/chat/runs/:runId/cancel`
- `POST /api/jobs/:id/chat/messages/:assistantMessageId/regenerate` (supports streaming)
Compatibility endpoints for thread resources remain present, but UI behavior is one conversation per job.

View File

@ -30,10 +30,23 @@ There are two main ways a job becomes Ready:
Once a job is `ready`, the Ready panel is the "shipping lane":
- View/download the PDF.
- Open Ghostwriter for context-aware drafting tied to the current job.
- Open the job listing.
- Mark Applied (moves to `applied`).
- Optional: edit tailoring, edit the JD, or regenerate the PDF.
## Ghostwriter (per-job context chat)
Ghostwriter is always on and is available in both `discovered` and `ready` job views.
- It uses the current job context, profile context, and your global writing style settings.
- Conversation state is persistent per job.
- Responses stream in real time and can be cancelled.
- Regenerate is available for the last assistant response.
- Messages render as Markdown.
For full details and API surface, see [Ghostwriter](./ghostwriter.md).
## Generating PDFs (first time)
The PDF is generated from:

View File

@ -15,6 +15,9 @@ import type {
BulkPostApplicationActionResponse,
DemoInfoResponse,
Job,
JobChatMessage,
JobChatStreamEvent,
JobChatThread,
JobListItem,
JobOutcome,
JobSource,
@ -390,6 +393,264 @@ export async function updateJob(
});
}
async function streamSseEvents(
endpoint: string,
input: Record<string, unknown>,
handlers: {
onEvent: (event: JobChatStreamEvent) => void;
signal?: AbortSignal;
},
): Promise<void> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (cachedBasicAuthCredentials) {
headers.Authorization = encodeBasicAuth(cachedBasicAuthCredentials);
}
const response = await fetch(`${API_BASE}${endpoint}`, {
method: "POST",
headers,
body: JSON.stringify(input),
signal: handlers.signal,
});
if (!response.ok) {
let errorMessage = `Stream request failed with status ${response.status}`;
try {
const payload = await response.json();
const parsed = normalizeApiResponse(payload);
if ("ok" in parsed && !parsed.ok) {
errorMessage = parsed.error.message || errorMessage;
}
} catch {
// ignore parse errors; keep status-based message
}
throw new ApiClientError(errorMessage, {
status: response.status,
});
}
if (!response.body) {
throw new ApiClientError("Streaming not supported by this browser");
}
const decoder = new TextDecoder();
const reader = response.body.getReader();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let separatorIndex = buffer.indexOf("\n\n");
while (separatorIndex !== -1) {
const frame = buffer.slice(0, separatorIndex);
buffer = buffer.slice(separatorIndex + 2);
const dataLines = frame
.split("\n")
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice(5).trim())
.filter(Boolean);
for (const line of dataLines) {
try {
handlers.onEvent(JSON.parse(line) as JobChatStreamEvent);
} catch {
// Ignore malformed events to keep stream resilient
}
}
separatorIndex = buffer.indexOf("\n\n");
}
}
}
export async function listJobChatThreads(jobId: string): Promise<{
threads: JobChatThread[];
}> {
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 },
): Promise<{ thread: JobChatThread }> {
return fetchApi<{ thread: JobChatThread }>(`/jobs/${jobId}/chat/threads`, {
method: "POST",
body: JSON.stringify({
title: input?.title ?? null,
}),
});
}
export async function listJobChatMessages(
jobId: string,
threadId: 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/threads/${threadId}/messages${query ? `?${query}` : ""}`,
);
}
export async function sendJobChatMessage(
jobId: string,
threadId: string,
input: { content: string },
): Promise<{
userMessage: JobChatMessage;
assistantMessage: JobChatMessage | null;
runId: string;
}> {
return fetchApi<{
userMessage: JobChatMessage;
assistantMessage: JobChatMessage | null;
runId: string;
}>(`/jobs/${jobId}/chat/threads/${threadId}/messages`, {
method: "POST",
body: JSON.stringify(input),
});
}
export async function streamJobChatMessage(
jobId: string,
threadId: string,
input: { content: string; signal?: AbortSignal },
handlers: {
onEvent: (event: JobChatStreamEvent) => void;
},
): Promise<void> {
return streamSseEvents(
`/jobs/${jobId}/chat/threads/${threadId}/messages`,
{ content: input.content, stream: true },
{
onEvent: handlers.onEvent,
signal: input.signal,
},
);
}
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,
runId: string,
): Promise<{ cancelled: boolean; alreadyFinished: boolean }> {
return fetchApi<{ cancelled: boolean; alreadyFinished: boolean }>(
`/jobs/${jobId}/chat/threads/${threadId}/runs/${runId}/cancel`,
{
method: "POST",
body: JSON.stringify({}),
},
);
}
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,
assistantMessageId: string,
): Promise<{ runId: string; assistantMessage: JobChatMessage | null }> {
return fetchApi<{ runId: string; assistantMessage: JobChatMessage | null }>(
`/jobs/${jobId}/chat/threads/${threadId}/messages/${assistantMessageId}/regenerate`,
{
method: "POST",
body: JSON.stringify({}),
},
);
}
export async function streamRegenerateJobChatMessage(
jobId: string,
threadId: string,
assistantMessageId: string,
input: { signal?: AbortSignal },
handlers: {
onEvent: (event: JobChatStreamEvent) => void;
},
): Promise<void> {
return streamSseEvents(
`/jobs/${jobId}/chat/threads/${threadId}/messages/${assistantMessageId}/regenerate`,
{ stream: true },
{
onEvent: handlers.onEvent,
signal: input.signal,
},
);
}
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

@ -50,6 +50,7 @@ import { useProfile } from "../hooks/useProfile";
import { useRescoreJob } from "../hooks/useRescoreJob";
import { FitAssessment, JobHeader, TailoredSummary } from ".";
import { TailorMode } from "./discovered-panel/TailorMode";
import { GhostwriterDrawer } from "./ghostwriter/GhostwriterDrawer";
import { JobDetailsEditDrawer } from "./JobDetailsEditDrawer";
import { KbdHint } from "./KbdHint";
@ -297,18 +298,10 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
*/}
<div className="pb-4 border-b border-border/40">
<div className="grid gap-2 sm:grid-cols-2">
{/* Show PDF - to verify quickly without download */}
<Button
asChild
variant="outline"
className="h-9 w-full gap-1 px-2 text-xs"
>
<a href={pdfHref} target="_blank" rel="noopener noreferrer">
<FileText className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">View PDF</span>
<KbdHint shortcut="p" className="ml-auto" />
</a>
</Button>
<GhostwriterDrawer
job={job}
triggerClassName="h-9 w-full justify-center gap-1 px-2 text-xs"
/>
{/* Download PDF - primary artifact action */}
<Button
@ -448,6 +441,15 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
<DropdownMenuSeparator />
{/* Utility actions */}
<DropdownMenuItem
onSelect={() =>
window.open(pdfHref, "_blank", "noopener,noreferrer")
}
>
<FileText className="mr-2 h-4 w-4" />
View PDF
</DropdownMenuItem>
<DropdownMenuItem onSelect={handleCopyInfo}>
<Copy className="mr-2 h-4 w-4" />
Copy job info

View File

@ -0,0 +1,90 @@
import { getMetaShortcutLabel, isMetaKeyPressed } from "@client/lib/meta-key";
import { RefreshCcw, Send, Square } 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;
isStreaming: boolean;
canRegenerate: boolean;
onRegenerate: () => Promise<void>;
onStop: () => Promise<void>;
onSend: (content: string) => Promise<void>;
};
export const Composer: React.FC<ComposerProps> = ({
disabled,
isStreaming,
canRegenerate,
onRegenerate,
onStop,
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 (isMetaKeyPressed(event) && 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">
{getMetaShortcutLabel("Enter")} to send
</div>
<div className="flex items-center gap-1">
{isStreaming ? (
<Button
size="icon"
variant="outline"
onClick={() => void onStop()}
aria-label="Stop generating"
title="Stop generating"
>
<Square className="h-3.5 w-3.5" />
</Button>
) : (
<Button
size="icon"
variant="outline"
onClick={() => void onRegenerate()}
disabled={disabled || !canRegenerate}
aria-label="Regenerate response"
title="Regenerate response"
>
<RefreshCcw className="h-3.5 w-3.5" />
</Button>
)}
<Button
size="icon"
onClick={() => void submit()}
disabled={disabled || !value.trim()}
aria-label="Send message"
title="Send message"
>
<Send className="h-3.5 w-3.5" />
</Button>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,63 @@
import type { Job } from "@shared/types";
import { PanelRightOpen } from "lucide-react";
import type React from "react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { cn } from "@/lib/utils";
import { GhostwriterPanel } from "./GhostwriterPanel";
type GhostwriterDrawerProps = {
job: Job | null;
triggerClassName?: string;
};
export const GhostwriterDrawer: React.FC<GhostwriterDrawerProps> = ({
job,
triggerClassName,
}) => {
const [open, setOpen] = useState(false);
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button
size="sm"
variant="outline"
className={cn("h-8 gap-1.5 text-xs", triggerClassName)}
disabled={!job}
>
<PanelRightOpen className="h-3.5 w-3.5" />
Ghostwriter
</Button>
</SheetTrigger>
<SheetContent
side="right"
className="flex w-full flex-col p-0 sm:max-w-none lg:w-[50vw] xl:w-[40vw] 2xl:w-[30vw]"
>
<div className="border-b border-border/50 p-4">
<SheetHeader>
<SheetTitle>Ghostwriter</SheetTitle>
<SheetDescription>
{job && `${job.title} at ${job.employer}.`}
</SheetDescription>
</SheetHeader>
</div>
{job && (
<div className="flex min-h-0 flex-1 p-4 pt-0">
<GhostwriterPanel job={job} />
</div>
)}
</SheetContent>
</Sheet>
);
};

View File

@ -0,0 +1,283 @@
import type { Job, JobChatMessage, JobChatStreamEvent } from "@shared/types";
import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import * as api from "../../api";
import { Composer } from "./Composer";
import { MessageList } from "./MessageList";
type GhostwriterPanelProps = {
job: Job;
};
export const GhostwriterPanel: React.FC<GhostwriterPanelProps> = ({ job }) => {
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 messageCount = messages.length;
useEffect(() => {
if (messageCount === 0) return;
const container = messageListRef.current;
if (!container) return;
const distanceToBottom =
container.scrollHeight - container.scrollTop - container.clientHeight;
if (distanceToBottom < 120 || isStreaming) {
container.scrollTop = container.scrollHeight;
}
}, [messageCount, isStreaming]);
const loadMessages = useCallback(async () => {
const data = await api.listJobGhostwriterMessages(job.id, {
limit: 300,
});
setMessages(data.messages);
}, [job.id]);
const load = useCallback(async () => {
setIsLoading(true);
try {
await loadMessages();
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to load Ghostwriter";
toast.error(message);
} finally {
setIsLoading(false);
}
}, [loadMessages]);
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 (isStreaming) return;
const optimisticUser: JobChatMessage = {
id: `tmp-user-${Date.now()}`,
threadId: messages[messages.length - 1]?.threadId || "pending-thread",
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.streamJobGhostwriterMessage(
job.id,
{ content, signal: controller.signal },
{ onEvent: onStreamEvent },
);
await loadMessages();
} 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, loadMessages, messages, onStreamEvent],
);
const stopStreaming = useCallback(async () => {
streamAbortRef.current?.abort();
streamAbortRef.current = null;
setIsStreaming(false);
setStreamingMessageId(null);
const runId = activeRunId;
setActiveRunId(null);
if (!runId) {
return;
}
try {
await api.cancelJobGhostwriterRun(job.id, runId);
await loadMessages();
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to stop run";
toast.error(message);
}
}, [activeRunId, job.id, loadMessages]);
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 (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.streamRegenerateJobGhostwriterMessage(
job.id,
last.id,
{ signal: controller.signal },
{ onEvent: onStreamEvent },
);
await loadMessages();
} 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);
}
}, [isStreaming, job.id, loadMessages, messages, onStreamEvent]);
return (
<div className="flex h-full min-h-0 flex-1 flex-col">
<div
ref={messageListRef}
className="min-h-0 flex-1 overflow-y-auto border-b border-border/50 pb-3 pr-1"
>
{messages.length === 0 && !isLoading ? (
<div className="flex h-full min-h-[260px] justify-center px-3 flex-col text-left">
<h4 className="font-medium">
{job.title} at {job.employer}
</h4>
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
Ghostwriter already has this job description, your resume and your
writing style preferences. Ask for tailored response drafts, or
concise role-fit talking points.
</p>
</div>
) : (
<MessageList
messages={messages}
isStreaming={isStreaming}
streamingMessageId={streamingMessageId}
/>
)}
</div>
<div className="mt-4">
<Composer
disabled={isLoading || isStreaming}
isStreaming={isStreaming}
canRegenerate={canRegenerate}
onRegenerate={regenerate}
onStop={stopStreaming}
onSend={sendMessage}
/>
</div>
</div>
);
};

View File

@ -0,0 +1,60 @@
import type { JobChatMessage } from "@shared/types";
import type React from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
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 &&
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"
: `Ghostwriter${message.version > 1 ? ` v${message.version}` : ""}`}
</div>
{isActiveStreaming ? (
<StreamingMessage content={message.content} />
) : message.role === "assistant" ? (
<div className="text-sm leading-relaxed text-foreground [&_a]:text-primary [&_a]:underline [&_blockquote]:border-l [&_blockquote]:border-border [&_blockquote]:pl-3 [&_code]:rounded [&_code]:bg-muted/40 [&_code]:px-1 [&_h1]:mt-4 [&_h1]:text-base [&_h1]:font-semibold [&_h2]:mt-4 [&_h2]:text-sm [&_h2]:font-semibold [&_h3]:mt-3 [&_h3]:text-sm [&_h3]:font-semibold [&_li]:my-1 [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_p]:my-2 [&_pre]:my-3 [&_pre]:overflow-x-auto [&_pre]:rounded [&_pre]:bg-muted/40 [&_pre]:p-3 [&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{message.content || "..."}
</ReactMarkdown>
</div>
) : (
<div className="whitespace-pre-wrap text-sm leading-relaxed text-foreground">
{message.content}
</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,140 @@
import type { Job, JobChatThread } from "@shared/types";
import type React from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type ThreadListProps = {
job: Job;
threads: JobChatThread[];
previews: Record<string, string>;
activeThreadId: string | null;
onSelectThread: (threadId: string) => void;
onCreateThread: () => void;
disabled?: boolean;
};
function formatRelativeTime(value: string | null): string {
if (!value) return "Updated just now";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "Updated recently";
const diffMs = date.getTime() - Date.now();
const absMs = Math.abs(diffMs);
const minute = 60 * 1000;
const hour = 60 * minute;
const day = 24 * hour;
const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
if (absMs < hour) {
const magnitudeMinutes = Math.max(1, Math.round(absMs / minute));
const minutes = Math.sign(diffMs) * magnitudeMinutes;
return `Updated ${rtf.format(minutes, "minute")}`;
}
if (absMs < day) {
const hours = Math.round(diffMs / hour);
return `Updated ${rtf.format(hours, "hour")}`;
}
const days = Math.round(diffMs / day);
return `Updated ${rtf.format(days, "day")}`;
}
function normalizeThreadTitle(input: string | null, fallback: string): string {
const value = input?.trim();
return value && value.length > 0 ? value : fallback;
}
export const ThreadList: React.FC<ThreadListProps> = ({
job,
threads,
previews,
activeThreadId,
onSelectThread,
onCreateThread,
disabled,
}) => {
const titleCounts = new Map<string, number>();
threads.forEach((thread) => {
const normalized = normalizeThreadTitle(
thread.title,
`${job.title} @ ${job.employer}`,
);
titleCounts.set(normalized, (titleCounts.get(normalized) ?? 0) + 1);
});
const seenTitles = new Map<string, number>();
return (
<aside className="min-h-0 space-y-3 pr-0 md:pr-4">
<div className="flex items-center justify-between">
<div className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
Threads
</div>
<Button
size="sm"
variant="outline"
onClick={onCreateThread}
disabled={disabled}
className="h-8 px-2.5 text-xs"
>
New
</Button>
</div>
<div className="max-h-[13rem] space-y-1 overflow-auto pr-1">
{threads.length === 0 ? (
<div className="p-2 text-xs text-muted-foreground">
No threads yet
</div>
) : (
threads.map((thread) => {
const rawTitle = normalizeThreadTitle(
thread.title,
`${job.title} @ ${job.employer}`,
);
const seenCount = (seenTitles.get(rawTitle) ?? 0) + 1;
seenTitles.set(rawTitle, seenCount);
const hasDuplicates = (titleCounts.get(rawTitle) ?? 0) > 1;
const title = hasDuplicates
? `${rawTitle} (${seenCount})`
: rawTitle;
const preview = previews[thread.id]?.trim() || "No messages yet";
const isActive = activeThreadId === thread.id;
return (
<button
key={thread.id}
type="button"
onClick={() => onSelectThread(thread.id)}
disabled={disabled}
className={cn(
"relative w-full rounded-md border px-3 py-2.5 text-left transition-colors",
isActive
? "border-foreground/25 bg-accent text-accent-foreground"
: "border-transparent hover:border-border/50 hover:bg-muted/40",
)}
>
{isActive && (
<span className="absolute bottom-2 left-0 top-2 w-0.5 rounded-r bg-foreground" />
)}
<div
className={cn(
"truncate pr-2 text-xs",
isActive ? "font-semibold" : "font-medium text-foreground",
)}
>
{title}
</div>
<div className="mt-1 text-[10px] text-muted-foreground">
{formatRelativeTime(thread.lastMessageAt ?? thread.updatedAt)}
</div>
<div className="mt-1 truncate text-[11px] text-muted-foreground/90">
{preview}
</div>
</button>
);
})
)}
</div>
</aside>
);
};

View File

@ -8,7 +8,7 @@ type KeyBindingMap = Record<string, (event: KeyboardEvent) => void>;
* don't fire while the user is typing in a form control.
*/
const INPUT_TAG_NAMES = new Set(["INPUT", "TEXTAREA", "SELECT"]);
const MODIFIER_PATTERN = /(?:^|\+)(\$mod|Shift|Control|Meta|Alt)(?:\+|$)/;
const NON_SHIFT_MODIFIER_PATTERN = /(?:^|\+)(\$mod|Control|Meta|Alt)(?:\+|$)/;
function isEditableTarget(event: KeyboardEvent): boolean {
const target = event.target;
@ -27,8 +27,10 @@ function isEditableTarget(event: KeyboardEvent): boolean {
* - Uses a stable ref for handler updates without rebinding.
* - Rebuilds bindings when the key set changes.
*
* Modifier shortcuts (e.g. "$mod+K") bypass the input guard because the user
* explicitly held a modifier -- those are intentional even inside inputs.
* Non-shift modifier shortcuts (e.g. "$mod+K") bypass the input guard because
* the user explicitly held a non-text modifier. Shift-only shortcuts (e.g.
* "Shift+?") stay blocked in inputs so typing punctuation doesn't trigger app
* hotkeys.
*/
export function useHotkeys(
bindings: KeyBindingMap,
@ -49,13 +51,13 @@ export function useHotkeys(
const guarded: KeyBindingMap = {};
const bindingKeys = bindingSignature ? bindingSignature.split("|") : [];
for (const key of bindingKeys) {
const hasModifier = key
const hasNonShiftModifier = key
.split(" ")
.some((sequence) => MODIFIER_PATTERN.test(sequence));
.some((sequence) => NON_SHIFT_MODIFIER_PATTERN.test(sequence));
guarded[key] = (event: KeyboardEvent) => {
// Skip single-key shortcuts when the user is typing in an input.
if (!hasModifier && isEditableTarget(event)) return;
if (!hasNonShiftModifier && isEditableTarget(event)) return;
bindingsRef.current[key]?.(event);
};
}

View File

@ -23,6 +23,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { formatTimestamp } from "@/lib/utils";
import * as api from "../api";
import { ConfirmDelete } from "../components/ConfirmDelete";
import { GhostwriterDrawer } from "../components/ghostwriter/GhostwriterDrawer";
import { JobHeader } from "../components/JobHeader";
import {
type LogEventFormValues,
@ -273,10 +274,13 @@ export const JobPage: React.FC = () => {
<div className="space-y-4">
<Card className="border-border/50">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<CalendarClock className="h-4 w-4" />
Application details
</CardTitle>
<div className="flex items-center justify-between gap-3">
<CardTitle className="flex items-center gap-2 text-base">
<CalendarClock className="h-4 w-4" />
Application details
</CardTitle>
<GhostwriterDrawer job={job} />
</div>
</CardHeader>
<CardContent className="space-y-4">
<div>

View File

@ -1030,7 +1030,7 @@ describe("OrchestratorPage", () => {
pressKeyOn(input, "?", { shiftKey: true });
await waitFor(() => {
expect(screen.getByTestId("help-dialog")).toHaveTextContent("open");
expect(screen.getByTestId("help-dialog")).toHaveTextContent("closed");
});
});
});

View File

@ -1,6 +1,7 @@
import * as api from "@client/api";
import { PageHeader } from "@client/components/layout";
import { BackupSettingsSection } from "@client/pages/settings/components/BackupSettingsSection";
import { ChatSettingsSection } from "@client/pages/settings/components/ChatSettingsSection";
import { DangerZoneSection } from "@client/pages/settings/components/DangerZoneSection";
import { DisplaySettingsSection } from "@client/pages/settings/components/DisplaySettingsSection";
import { EnvironmentSettingsSection } from "@client/pages/settings/components/EnvironmentSettingsSection";
@ -46,6 +47,10 @@ const DEFAULT_FORM_VALUES: UpdateSettingsInput = {
resumeProjects: null,
rxresumeBaseResumeId: null,
showSponsorInfo: null,
chatStyleTone: "",
chatStyleFormality: "",
chatStyleConstraints: "",
chatStyleDoNotUse: "",
rxresumeEmail: "",
rxresumePassword: "",
basicAuthUser: "",
@ -81,6 +86,10 @@ const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = {
resumeProjects: null,
rxresumeBaseResumeId: null,
showSponsorInfo: null,
chatStyleTone: null,
chatStyleFormality: null,
chatStyleConstraints: null,
chatStyleDoNotUse: null,
rxresumeEmail: null,
rxresumePassword: null,
basicAuthUser: null,
@ -110,6 +119,10 @@ const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
resumeProjects: data.resumeProjects,
rxresumeBaseResumeId: data.rxresumeBaseResumeId ?? null,
showSponsorInfo: data.overrideShowSponsorInfo,
chatStyleTone: data.overrideChatStyleTone ?? "",
chatStyleFormality: data.overrideChatStyleFormality ?? "",
chatStyleConstraints: data.overrideChatStyleConstraints ?? "",
chatStyleDoNotUse: data.overrideChatStyleDoNotUse ?? "",
rxresumeEmail: data.rxresumeEmail ?? "",
rxresumePassword: "",
basicAuthUser: data.basicAuthUser ?? "",
@ -200,6 +213,24 @@ const getDerivedSettings = (settings: AppSettings | null) => {
effective: settings?.showSponsorInfo ?? true,
default: settings?.defaultShowSponsorInfo ?? true,
},
chat: {
tone: {
effective: settings?.chatStyleTone ?? "professional",
default: settings?.defaultChatStyleTone ?? "professional",
},
formality: {
effective: settings?.chatStyleFormality ?? "medium",
default: settings?.defaultChatStyleFormality ?? "medium",
},
constraints: {
effective: settings?.chatStyleConstraints ?? "",
default: settings?.defaultChatStyleConstraints ?? "",
},
doNotUse: {
effective: settings?.chatStyleDoNotUse ?? "",
default: settings?.defaultChatStyleDoNotUse ?? "",
},
},
envSettings: {
readable: {
rxresumeEmail: settings?.rxresumeEmail ?? "",
@ -386,6 +417,7 @@ export const SettingsPage: React.FC = () => {
pipelineWebhook,
jobCompleteWebhook,
display,
chat,
envSettings,
defaultResumeProjects,
profileProjects,
@ -551,6 +583,10 @@ export const SettingsPage: React.FC = () => {
resumeProjects: resumeProjectsOverride,
rxresumeBaseResumeId: normalizeString(data.rxresumeBaseResumeId),
showSponsorInfo: nullIfSame(data.showSponsorInfo, display.default),
chatStyleTone: normalizeString(data.chatStyleTone),
chatStyleFormality: normalizeString(data.chatStyleFormality),
chatStyleConstraints: normalizeString(data.chatStyleConstraints),
chatStyleDoNotUse: normalizeString(data.chatStyleDoNotUse),
backupEnabled: nullIfSame(
data.backupEnabled,
backup.backupEnabled.default,
@ -730,6 +766,11 @@ export const SettingsPage: React.FC = () => {
isLoading={isLoading}
isSaving={isSaving}
/>
<ChatSettingsSection
values={chat}
isLoading={isLoading}
isSaving={isSaving}
/>
<ScoringSettingsSection
values={scoring}
isLoading={isLoading}

View File

@ -580,7 +580,7 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
className="text-[10px] text-muted-foreground/60 hover:text-muted-foreground transition-colors"
onClick={() => setDetailTab("description")}
>
View full description 
View full description
</button>
</div>
</div>

View File

@ -0,0 +1,138 @@
import { SettingsInput } from "@client/pages/settings/components/SettingsInput";
import type { ChatValues } from "@client/pages/settings/types";
import type { UpdateSettingsInput } from "@shared/settings-schema.js";
import type React from "react";
import { Controller, useFormContext } from "react-hook-form";
import {
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
type ChatSettingsSectionProps = {
values: ChatValues;
isLoading: boolean;
isSaving: boolean;
};
export const ChatSettingsSection: React.FC<ChatSettingsSectionProps> = ({
values,
isLoading,
isSaving,
}) => {
const { tone, formality, constraints, doNotUse } = values;
const { control, register } = useFormContext<UpdateSettingsInput>();
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>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<label htmlFor="chatStyleTone" className="text-sm font-medium">
Tone
</label>
<Controller
name="chatStyleTone"
control={control}
render={({ field }) => (
<Select
value={field.value || tone.default}
onValueChange={(value) => field.onChange(value)}
disabled={isLoading || isSaving}
>
<SelectTrigger id="chatStyleTone">
<SelectValue placeholder="Select tone" />
</SelectTrigger>
<SelectContent>
<SelectItem value="professional">Professional</SelectItem>
<SelectItem value="concise">Concise</SelectItem>
<SelectItem value="direct">Direct</SelectItem>
<SelectItem value="friendly">Friendly</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<label
htmlFor="chatStyleFormality"
className="text-sm font-medium"
>
Formality
</label>
<Controller
name="chatStyleFormality"
control={control}
render={({ field }) => (
<Select
value={field.value || formality.default}
onValueChange={(value) => field.onChange(value)}
disabled={isLoading || isSaving}
>
<SelectTrigger id="chatStyleFormality">
<SelectValue placeholder="Select formality" />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">Low</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="high">High</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
</div>
<SettingsInput
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."
current={constraints.effective || "—"}
/>
<SettingsInput
label="Do-not-use terms"
inputProps={register("chatStyleDoNotUse")}
placeholder="Example: synergize, leverage"
disabled={isLoading || isSaving}
helper="Optional comma-separated words or phrases to avoid."
current={doNotUse.effective || "—"}
/>
<Separator />
<div className="grid gap-2 text-sm sm:grid-cols-2">
<div>
<div className="text-xs text-muted-foreground">Tone</div>
<div className="break-words font-mono text-xs">
Effective: {tone.effective} | Default: {tone.default}
</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Formality</div>
<div className="break-words font-mono text-xs">
Effective: {formality.effective} | Default: {formality.default}
</div>
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>
);
};

View File

@ -14,6 +14,12 @@ export type ModelValues = EffectiveDefault<string> & {
export type WebhookValues = EffectiveDefault<string>;
export type DisplayValues = EffectiveDefault<boolean>;
export type ChatValues = {
tone: EffectiveDefault<string>;
formality: EffectiveDefault<string>;
constraints: EffectiveDefault<string>;
doNotUse: EffectiveDefault<string>;
};
export type EnvSettingsValues = {
readable: {

View File

@ -5,7 +5,7 @@ import * as React from "react";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
"inline-flex cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {

View File

@ -6,6 +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 { jobsRouter } from "./routes/jobs";
import { manualJobsRouter } from "./routes/manual-jobs";
import { onboardingRouter } from "./routes/onboarding";
@ -20,6 +21,7 @@ import { webhookRouter } from "./routes/webhook";
export const apiRouter = Router();
apiRouter.use("/jobs", jobsRouter);
apiRouter.use("/jobs/:id/chat", ghostwriterRouter);
apiRouter.use("/demo", demoRouter);
apiRouter.use("/settings", settingsRouter);
apiRouter.use("/pipeline", pipelineRouter);

View File

@ -0,0 +1,185 @@
import type { Server } from "node:http";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { startServer, stopServer } from "./test-utils";
vi.mock("../../services/ghostwriter", () => ({
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(),
},
]),
listMessagesForJob: 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",
})),
sendMessageForJob: 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("Ghostwriter 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 messages with request id metadata", async () => {
const res = await fetch(`${baseUrl}/api/jobs/job-1/chat/messages`, {
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.messages.length).toBe(1);
expect(body.meta.requestId).toBe("chat-req-1");
});
it("sends a message in the per-job conversation", async () => {
const messageRes = await fetch(`${baseUrl}/api/jobs/job-1/chat/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,638 @@
import { asyncRoute, fail, ok } from "@infra/http";
import { logger } from "@infra/logger";
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 ghostwriterService from "../../services/ghostwriter";
export const ghostwriterRouter = 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 {
if (res.writableEnded || res.destroyed) return;
res.write(`data: ${JSON.stringify(event)}\n\n`);
}
function setupSseStream(
req: Request,
res: Response,
onDisconnectRun?: (runId: string) => Promise<void>,
): {
setRunId: (runId: string) => void;
isClosed: () => boolean;
cleanup: () => void;
} {
res.status(200);
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache, no-transform");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no");
res.flushHeaders?.();
let closed = false;
let activeRunId: string | null = null;
let disconnectHandled = false;
const handleDisconnect = () => {
if (disconnectHandled) return;
disconnectHandled = true;
if (!activeRunId || !onDisconnectRun) return;
void onDisconnectRun(activeRunId).catch((error) => {
logger.warn("Ghostwriter stream disconnect cancellation failed", {
runId: activeRunId,
error,
});
});
};
const heartbeat = setInterval(() => {
if (res.writableEnded || res.destroyed) return;
res.write(": heartbeat\n\n");
}, 30000);
const onClose = () => {
closed = true;
clearInterval(heartbeat);
handleDisconnect();
};
req.on("close", onClose);
return {
setRunId: (runId: string) => {
activeRunId = runId;
if (closed) {
handleDisconnect();
}
},
isClosed: () => closed,
cleanup: () => {
clearInterval(heartbeat);
req.off("close", onClose);
},
};
}
ghostwriterRouter.get(
"/messages",
asyncRoute(async (req, res) => {
const jobId = getJobId(req);
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 ghostwriterService.listMessagesForJob({
jobId,
limit: parsed.data.limit,
offset: parsed.data.offset,
});
ok(res, { messages });
});
}),
);
ghostwriterRouter.post(
"/messages",
asyncRoute(async (req, res) => {
const jobId = getJobId(req);
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) {
const sse = setupSseStream(req, res, async (runId: string) => {
await ghostwriterService.cancelRunForJob({
jobId,
runId,
});
});
try {
await ghostwriterService.sendMessageForJob({
jobId,
content: parsed.data.content,
stream: {
onReady: ({ runId, threadId, messageId, requestId }) => {
sse.setRunId(runId);
if (sse.isClosed()) return;
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 {
sse.cleanup();
if (!res.writableEnded) {
res.end();
}
}
return;
}
const result = await ghostwriterService.sendMessageForJob({
jobId,
content: parsed.data.content,
});
ok(res, {
userMessage: result.userMessage,
assistantMessage: result.assistantMessage,
runId: result.runId,
});
});
}),
);
ghostwriterRouter.post(
"/runs/:runId/cancel",
asyncRoute(async (req, res) => {
const jobId = getJobId(req);
const runId = req.params.runId;
if (!runId) {
return fail(res, badRequest("Missing run id"));
}
await runWithRequestContext({ jobId }, async () => {
const result = await ghostwriterService.cancelRunForJob({
jobId,
runId,
});
ok(res, result);
});
}),
);
ghostwriterRouter.post(
"/messages/:assistantMessageId/regenerate",
asyncRoute(async (req, res) => {
const jobId = getJobId(req);
const assistantMessageId = req.params.assistantMessageId;
if (!assistantMessageId) {
return fail(res, badRequest("Missing 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) {
const sse = setupSseStream(req, res, async (runId: string) => {
await ghostwriterService.cancelRunForJob({
jobId,
runId,
});
});
try {
await ghostwriterService.regenerateMessageForJob({
jobId,
assistantMessageId,
stream: {
onReady: ({ runId, threadId, messageId, requestId }) => {
sse.setRunId(runId);
if (sse.isClosed()) return;
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 {
sse.cleanup();
if (!res.writableEnded) {
res.end();
}
}
return;
}
const result = await ghostwriterService.regenerateMessageForJob({
jobId,
assistantMessageId,
});
ok(res, result);
});
}),
);
ghostwriterRouter.get(
"/threads",
asyncRoute(async (req, res) => {
const jobId = getJobId(req);
await runWithRequestContext({ jobId }, async () => {
const threads = await ghostwriterService.listThreads(jobId);
ok(res, { threads });
});
}),
);
ghostwriterRouter.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 ghostwriterService.createThread({
jobId,
title: parsed.data.title,
});
ok(res, { thread }, 201);
});
}),
);
ghostwriterRouter.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 ghostwriterService.listMessages({
jobId,
threadId,
limit: parsed.data.limit,
offset: parsed.data.offset,
});
ok(res, { messages });
});
}),
);
ghostwriterRouter.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) {
const sse = setupSseStream(req, res, async (runId: string) => {
await ghostwriterService.cancelRun({
jobId,
threadId,
runId,
});
});
try {
await ghostwriterService.sendMessage({
jobId,
threadId,
content: parsed.data.content,
stream: {
onReady: ({ runId, messageId, requestId }) => {
sse.setRunId(runId);
if (sse.isClosed()) return;
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 {
sse.cleanup();
if (!res.writableEnded) {
res.end();
}
}
return;
}
const result = await ghostwriterService.sendMessage({
jobId,
threadId,
content: parsed.data.content,
});
ok(res, {
userMessage: result.userMessage,
assistantMessage: result.assistantMessage,
runId: result.runId,
});
});
}),
);
ghostwriterRouter.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 ghostwriterService.cancelRun({
jobId,
threadId,
runId,
});
ok(res, result);
});
}),
);
ghostwriterRouter.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) {
const sse = setupSseStream(req, res, async (runId: string) => {
await ghostwriterService.cancelRun({
jobId,
threadId,
runId,
});
});
try {
await ghostwriterService.regenerateMessage({
jobId,
threadId,
assistantMessageId,
stream: {
onReady: ({ runId, messageId, requestId }) => {
sse.setRunId(runId);
if (sse.isClosed()) return;
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 {
sse.cleanup();
if (!res.writableEnded) {
res.end();
}
}
return;
}
const result = await ghostwriterService.regenerateMessage({
jobId,
threadId,
assistantMessageId,
});
ok(res, result);
});
}),
);

View File

@ -92,6 +92,51 @@ const migrations = [
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)`,
`CREATE TABLE IF NOT EXISTS job_chat_threads (
id TEXT PRIMARY KEY,
job_id TEXT NOT NULL,
title TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
last_message_at TEXT,
FOREIGN KEY (job_id) REFERENCES jobs(id) ON DELETE CASCADE
)`,
`CREATE TABLE IF NOT EXISTS job_chat_messages (
id TEXT PRIMARY KEY,
thread_id TEXT NOT NULL,
job_id TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('system', 'user', 'assistant', 'tool')),
content TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'partial' CHECK(status IN ('complete', 'partial', 'cancelled', 'failed')),
tokens_in INTEGER,
tokens_out INTEGER,
version INTEGER NOT NULL DEFAULT 1,
replaces_message_id TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (thread_id) REFERENCES job_chat_threads(id) ON DELETE CASCADE,
FOREIGN KEY (job_id) REFERENCES jobs(id) ON DELETE CASCADE
)`,
`CREATE TABLE IF NOT EXISTS job_chat_runs (
id TEXT PRIMARY KEY,
thread_id TEXT NOT NULL,
job_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'running' CHECK(status IN ('running', 'completed', 'cancelled', 'failed')),
model TEXT,
provider TEXT,
error_code TEXT,
error_message TEXT,
started_at INTEGER NOT NULL,
completed_at INTEGER,
request_id TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (thread_id) REFERENCES job_chat_threads(id) ON DELETE CASCADE,
FOREIGN KEY (job_id) REFERENCES jobs(id) ON DELETE CASCADE
)`,
`CREATE TABLE IF NOT EXISTS stage_events (
id TEXT PRIMARY KEY,
application_id TEXT NOT NULL,
@ -403,6 +448,28 @@ const migrations = [
`CREATE INDEX IF NOT EXISTS idx_interviews_application_id ON interviews(application_id)`,
`CREATE INDEX IF NOT EXISTS idx_post_app_sync_runs_provider_account_started_at ON post_application_sync_runs(provider, account_key, started_at)`,
`CREATE INDEX IF NOT EXISTS idx_post_app_messages_provider_account_processing_status ON post_application_messages(provider, account_key, processing_status)`,
`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

@ -8,6 +8,9 @@ import {
APPLICATION_TASK_TYPES,
INTERVIEW_OUTCOMES,
INTERVIEW_TYPES,
JOB_CHAT_MESSAGE_ROLES,
JOB_CHAT_MESSAGE_STATUSES,
JOB_CHAT_RUN_STATUSES,
POST_APPLICATION_INTEGRATION_STATUSES,
POST_APPLICATION_MESSAGE_TYPES,
POST_APPLICATION_PROCESSING_STATUSES,
@ -170,6 +173,87 @@ export const pipelineRuns = sqliteTable("pipeline_runs", {
errorMessage: text("error_message"),
});
export const jobChatThreads = sqliteTable(
"job_chat_threads",
{
id: text("id").primaryKey(),
jobId: text("job_id")
.notNull()
.references(() => jobs.id, { onDelete: "cascade" }),
title: text("title"),
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
lastMessageAt: text("last_message_at"),
},
(table) => ({
jobUpdatedIndex: index("idx_job_chat_threads_job_updated").on(
table.jobId,
table.updatedAt,
),
}),
);
export const jobChatMessages = sqliteTable(
"job_chat_messages",
{
id: text("id").primaryKey(),
threadId: text("thread_id")
.notNull()
.references(() => jobChatThreads.id, { onDelete: "cascade" }),
jobId: text("job_id")
.notNull()
.references(() => jobs.id, { onDelete: "cascade" }),
role: text("role", { enum: JOB_CHAT_MESSAGE_ROLES }).notNull(),
content: text("content").notNull().default(""),
status: text("status", { enum: JOB_CHAT_MESSAGE_STATUSES })
.notNull()
.default("partial"),
tokensIn: integer("tokens_in"),
tokensOut: integer("tokens_out"),
version: integer("version").notNull().default(1),
replacesMessageId: text("replaces_message_id"),
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
},
(table) => ({
threadCreatedIndex: index("idx_job_chat_messages_thread_created").on(
table.threadId,
table.createdAt,
),
}),
);
export const jobChatRuns = sqliteTable(
"job_chat_runs",
{
id: text("id").primaryKey(),
threadId: text("thread_id")
.notNull()
.references(() => jobChatThreads.id, { onDelete: "cascade" }),
jobId: text("job_id")
.notNull()
.references(() => jobs.id, { onDelete: "cascade" }),
status: text("status", { enum: JOB_CHAT_RUN_STATUSES })
.notNull()
.default("running"),
model: text("model"),
provider: text("provider"),
errorCode: text("error_code"),
errorMessage: text("error_message"),
startedAt: integer("started_at", { mode: "number" }).notNull(),
completedAt: integer("completed_at", { mode: "number" }),
requestId: text("request_id"),
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
},
(table) => ({
threadStatusIndex: index("idx_job_chat_runs_thread_status").on(
table.threadId,
table.status,
),
}),
);
export const settings = sqliteTable("settings", {
key: text("key").primaryKey(),
value: text("value").notNull(),
@ -310,6 +394,12 @@ export type InterviewRow = typeof interviews.$inferSelect;
export type NewInterviewRow = typeof interviews.$inferInsert;
export type PipelineRunRow = typeof pipelineRuns.$inferSelect;
export type NewPipelineRunRow = typeof pipelineRuns.$inferInsert;
export type JobChatThreadRow = typeof jobChatThreads.$inferSelect;
export type NewJobChatThreadRow = typeof jobChatThreads.$inferInsert;
export type JobChatMessageRow = typeof jobChatMessages.$inferSelect;
export type NewJobChatMessageRow = typeof jobChatMessages.$inferInsert;
export type JobChatRunRow = typeof jobChatRuns.$inferSelect;
export type NewJobChatRunRow = typeof jobChatRuns.$inferInsert;
export type SettingsRow = typeof settings.$inferSelect;
export type NewSettingsRow = typeof settings.$inferInsert;
export type PostApplicationIntegrationRow =

View File

@ -0,0 +1,368 @@
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 getOrCreateThreadForJob(input: {
jobId: string;
title?: string | null;
}): Promise<JobChatThread> {
const existing = await listThreadsForJob(input.jobId);
if (existing.length > 0) {
return existing[0];
}
return createThread({
jobId: input.jobId,
title: input.title ?? null,
});
}
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);
}
export async function completeRunIfRunning(
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(and(eq(jobChatRuns.id, runId), eq(jobChatRuns.status, "running")));
return getRunById(runId);
}

View File

@ -26,6 +26,10 @@ export type SettingKey =
| "jobspyResultsWanted"
| "jobspyCountryIndeed"
| "showSponsorInfo"
| "chatStyleTone"
| "chatStyleFormality"
| "chatStyleConstraints"
| "chatStyleDoNotUse"
| "rxresumeEmail"
| "rxresumePassword"
| "basicAuthUser"

View File

@ -0,0 +1,126 @@
import { createJob } from "@shared/testing/factories";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AppError } from "../infra/errors";
import { buildJobChatPromptContext } from "./ghostwriter-context";
vi.mock("../repositories/jobs", () => ({
getJobById: vi.fn(),
}));
vi.mock("../repositories/settings", () => ({
getAllSettings: vi.fn(),
}));
vi.mock("./profile", () => ({
getProfile: vi.fn(),
}));
vi.mock("./settings-conversion", () => ({
resolveSettingValue: vi.fn(),
}));
import { getJobById } from "../repositories/jobs";
import { getAllSettings } from "../repositories/settings";
import { getProfile } from "./profile";
import { resolveSettingValue } from "./settings-conversion";
describe("buildJobChatPromptContext", () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getAllSettings).mockResolvedValue({});
vi.mocked(resolveSettingValue).mockImplementation((key, override) => {
const fallback: Record<string, string> = {
chatStyleTone: "professional",
chatStyleFormality: "medium",
chatStyleConstraints: "",
chatStyleDoNotUse: "",
};
return { value: override ?? fallback[key as string] ?? "" } as any;
});
});
it("builds context with style directives and snapshots", async () => {
const job = createJob({
id: "job-ctx-1",
title: "Software Engineer",
employer: "JP Morgan",
jobDescription: "A".repeat(5000),
});
vi.mocked(getJobById).mockResolvedValue(job);
vi.mocked(getAllSettings).mockResolvedValue({
chatStyleTone: "direct",
chatStyleFormality: "high",
chatStyleConstraints: "Keep responses under 120 words",
chatStyleDoNotUse: "synergy, leverage",
});
vi.mocked(getProfile).mockResolvedValue({
basics: {
name: "Test User",
headline: "Full-stack engineer",
summary: "I build production systems",
},
sections: {
skills: {
name: "Skills",
visible: true,
id: "skills-1",
items: [
{
id: "skill-1",
visible: true,
name: "TypeScript",
description: "",
level: 4,
keywords: ["Node.js", "React"],
},
],
},
},
});
const context = await buildJobChatPromptContext(job.id);
expect(context.style).toEqual({
tone: "direct",
formality: "high",
constraints: "Keep responses under 120 words",
doNotUse: "synergy, leverage",
});
expect(context.systemPrompt).toContain("Writing style tone: direct.");
expect(context.systemPrompt).toContain("Writing style formality: high.");
expect(context.systemPrompt).toContain(
"Writing constraints: Keep responses under 120 words",
);
expect(context.systemPrompt).toContain(
"Avoid these terms: synergy, leverage",
);
expect(context.jobSnapshot).toContain('"id": "job-ctx-1"');
expect(context.jobSnapshot.length).toBeLessThan(6000);
expect(context.profileSnapshot).toContain("Name: Test User");
expect(context.profileSnapshot).toContain("Skills:");
});
it("falls back to empty profile snapshot when profile loading fails", async () => {
const job = createJob({ id: "job-ctx-2" });
vi.mocked(getJobById).mockResolvedValue(job);
vi.mocked(getProfile).mockRejectedValue(new Error("profile unavailable"));
const context = await buildJobChatPromptContext(job.id);
expect(context.job.id).toBe("job-ctx-2");
expect(context.profileSnapshot).toContain("Name: Unknown");
expect(context.systemPrompt).toContain("Writing style tone: professional.");
});
it("throws not found for unknown job", async () => {
vi.mocked(getJobById).mockResolvedValue(null);
await expect(
buildJobChatPromptContext("missing-job"),
).rejects.toMatchObject({
code: "NOT_FOUND",
status: 404,
} satisfies Partial<AppError>);
});
});

View File

@ -0,0 +1,193 @@
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 Ghostwriter, a job-application writing assistant 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 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,528 @@
import type { JobChatMessage } from "@shared/types";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
getRequestId: vi.fn(),
buildJobChatPromptContext: vi.fn(),
llmCallJson: vi.fn(),
repo: {
getOrCreateThreadForJob: vi.fn(),
getThreadForJob: vi.fn(),
listMessagesForThread: vi.fn(),
getActiveRunForThread: vi.fn(),
createMessage: vi.fn(),
createRun: vi.fn(),
updateMessage: vi.fn(),
completeRun: vi.fn(),
completeRunIfRunning: vi.fn(),
getMessageById: vi.fn(),
getLatestAssistantMessage: vi.fn(),
getRunById: vi.fn(),
},
settings: {
getAllSettings: vi.fn(),
},
}));
vi.mock("@infra/logger", () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
vi.mock("@infra/request-context", () => ({
getRequestId: mocks.getRequestId,
}));
vi.mock("./ghostwriter-context", () => ({
buildJobChatPromptContext: mocks.buildJobChatPromptContext,
}));
vi.mock("../repositories/settings", () => ({
getAllSettings: mocks.settings.getAllSettings,
}));
vi.mock("../repositories/ghostwriter", () => ({
getOrCreateThreadForJob: mocks.repo.getOrCreateThreadForJob,
getThreadForJob: mocks.repo.getThreadForJob,
listMessagesForThread: mocks.repo.listMessagesForThread,
getActiveRunForThread: mocks.repo.getActiveRunForThread,
createMessage: mocks.repo.createMessage,
createRun: mocks.repo.createRun,
updateMessage: mocks.repo.updateMessage,
completeRun: mocks.repo.completeRun,
completeRunIfRunning: mocks.repo.completeRunIfRunning,
getMessageById: mocks.repo.getMessageById,
getLatestAssistantMessage: mocks.repo.getLatestAssistantMessage,
getRunById: mocks.repo.getRunById,
}));
vi.mock("./llm/service", () => ({
LlmService: class {
callJson = mocks.llmCallJson;
},
}));
import {
cancelRun,
cancelRunForJob,
createThread,
regenerateMessage,
sendMessage,
sendMessageForJob,
} from "./ghostwriter";
const thread = {
id: "thread-1",
jobId: "job-1",
title: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
lastMessageAt: null,
};
const baseUserMessage: JobChatMessage = {
id: "user-1",
threadId: "thread-1",
jobId: "job-1",
role: "user",
content: "Tell me about this role",
status: "complete",
tokensIn: 6,
tokensOut: null,
version: 1,
replacesMessageId: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const baseAssistantMessage: JobChatMessage = {
id: "assistant-1",
threadId: "thread-1",
jobId: "job-1",
role: "assistant",
content: "Draft response",
status: "complete",
tokensIn: 6,
tokensOut: 4,
version: 1,
replacesMessageId: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
describe("ghostwriter service", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.getRequestId.mockReturnValue("req-123");
mocks.settings.getAllSettings.mockResolvedValue({});
mocks.buildJobChatPromptContext.mockResolvedValue({
job: { id: "job-1" },
style: {
tone: "professional",
formality: "medium",
constraints: "",
doNotUse: "",
},
systemPrompt: "system prompt",
jobSnapshot: '{"job":"snapshot"}',
profileSnapshot: "profile snapshot",
});
mocks.repo.getOrCreateThreadForJob.mockResolvedValue(thread);
mocks.repo.getThreadForJob.mockResolvedValue(thread);
mocks.repo.getActiveRunForThread.mockResolvedValue(null);
mocks.repo.createRun.mockResolvedValue({
id: "run-1",
threadId: "thread-1",
jobId: "job-1",
status: "running",
model: "model-a",
provider: "openrouter",
errorCode: null,
errorMessage: null,
startedAt: Date.now(),
completedAt: null,
requestId: "req-123",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
mocks.repo.completeRun.mockResolvedValue(null);
mocks.repo.completeRunIfRunning.mockResolvedValue({
id: "run-1",
threadId: "thread-1",
jobId: "job-1",
status: "completed",
model: "model-a",
provider: "openrouter",
errorCode: null,
errorMessage: null,
startedAt: Date.now(),
completedAt: Date.now(),
requestId: "req-123",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
mocks.repo.updateMessage.mockResolvedValue(baseAssistantMessage);
mocks.repo.getMessageById.mockResolvedValue(baseAssistantMessage);
mocks.repo.listMessagesForThread.mockResolvedValue([
baseUserMessage,
baseAssistantMessage,
{
...baseAssistantMessage,
id: "tool-1",
role: "tool",
},
{
...baseAssistantMessage,
id: "failed-1",
role: "assistant",
status: "failed",
},
]);
mocks.llmCallJson.mockResolvedValue({
success: true,
data: { response: "Thanks for your question." },
});
});
afterEach(() => {
vi.useRealTimers();
});
it("sends message, runs LLM, and returns user + assistant messages", async () => {
const assistantPartial: JobChatMessage = {
...baseAssistantMessage,
id: "assistant-partial",
content: "",
status: "partial",
};
const assistantComplete: JobChatMessage = {
...baseAssistantMessage,
id: "assistant-partial",
content: "Thanks for your question.",
status: "complete",
tokensOut: 7,
};
mocks.repo.createMessage
.mockResolvedValueOnce(baseUserMessage)
.mockResolvedValueOnce(assistantPartial);
mocks.repo.updateMessage.mockResolvedValue(assistantComplete);
mocks.repo.getMessageById.mockResolvedValue(assistantComplete);
const result = await sendMessageForJob({
jobId: "job-1",
content: " Tell me about this role ",
});
expect(result.runId).toBe("run-1");
expect(result.userMessage.role).toBe("user");
expect(result.assistantMessage?.role).toBe("assistant");
expect(mocks.repo.createRun).toHaveBeenCalledWith(
expect.objectContaining({
requestId: "req-123",
}),
);
const llmArg = mocks.llmCallJson.mock.calls[0][0];
expect(llmArg.messages.at(-1)).toMatchObject({
role: "user",
content: "Tell me about this role",
});
expect(
llmArg.messages.filter(
(message: { role: string }) =>
message.role !== "system" && message.role !== "user",
),
).toEqual([{ role: "assistant", content: "Draft response" }]);
});
it("passes title when creating the first thread", async () => {
await createThread({
jobId: "job-1",
title: "Initial thread title",
});
expect(mocks.repo.getOrCreateThreadForJob).toHaveBeenCalledWith({
jobId: "job-1",
title: "Initial thread title",
});
});
it("rejects empty message content", async () => {
await expect(
sendMessage({
jobId: "job-1",
threadId: "thread-1",
content: " ",
}),
).rejects.toMatchObject({
code: "INVALID_REQUEST",
status: 400,
});
});
it("cancels a running generation during streaming", async () => {
vi.useFakeTimers();
const assistantPartial: JobChatMessage = {
...baseAssistantMessage,
id: "assistant-stream",
content: "",
status: "partial",
};
const assistantCancelled: JobChatMessage = {
...assistantPartial,
status: "cancelled",
content: "",
};
let cancelPromise: Promise<{
cancelled: boolean;
alreadyFinished: boolean;
}> | null = null;
mocks.repo.createMessage
.mockResolvedValueOnce(baseUserMessage)
.mockResolvedValueOnce(assistantPartial);
mocks.repo.updateMessage.mockResolvedValue(assistantCancelled);
mocks.repo.getMessageById.mockResolvedValue(assistantCancelled);
mocks.repo.getRunById.mockResolvedValue({
id: "run-1",
threadId: "thread-1",
jobId: "job-1",
status: "running",
model: "model-a",
provider: "openrouter",
errorCode: null,
errorMessage: null,
startedAt: Date.now(),
completedAt: null,
requestId: "req-123",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
mocks.repo.completeRunIfRunning.mockResolvedValue({
id: "run-1",
threadId: "thread-1",
jobId: "job-1",
status: "cancelled",
model: "model-a",
provider: "openrouter",
errorCode: "REQUEST_TIMEOUT",
errorMessage: "Generation cancelled by user",
startedAt: Date.now(),
completedAt: Date.now(),
requestId: "req-123",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
mocks.llmCallJson.mockImplementation(async () => {
await vi.advanceTimersByTimeAsync(1);
return {
success: true,
data: { response: "x".repeat(200) },
};
});
const onReady = vi.fn(({ runId }: { runId: string }) => {
cancelPromise = cancelRunForJob({ jobId: "job-1", runId });
});
const onCancelled = vi.fn();
const onCompleted = vi.fn();
const resultPromise = sendMessageForJob({
jobId: "job-1",
content: "Cancel this",
stream: {
onReady,
onDelta: vi.fn(),
onCompleted,
onCancelled,
onError: vi.fn(),
},
});
await vi.runAllTimersAsync();
const result = await resultPromise;
await cancelPromise;
expect(onReady).toHaveBeenCalled();
expect(onCancelled).toHaveBeenCalled();
expect(onCompleted).not.toHaveBeenCalled();
expect(result.assistantMessage?.status).toBe("cancelled");
});
it("keeps cancellation when completion races at finish", async () => {
const assistantPartial: JobChatMessage = {
...baseAssistantMessage,
id: "assistant-race",
content: "",
status: "partial",
};
const assistantComplete: JobChatMessage = {
...assistantPartial,
content: "Thanks for your question.",
status: "complete",
tokensOut: 7,
};
const assistantCancelled: JobChatMessage = {
...assistantPartial,
content: "Thanks for your question.",
status: "cancelled",
tokensOut: 7,
};
mocks.repo.createMessage
.mockResolvedValueOnce(baseUserMessage)
.mockResolvedValueOnce(assistantPartial);
mocks.repo.updateMessage
.mockResolvedValueOnce(assistantComplete)
.mockResolvedValueOnce(assistantCancelled);
mocks.repo.getMessageById.mockResolvedValue(assistantCancelled);
mocks.repo.completeRunIfRunning.mockResolvedValueOnce({
id: "run-1",
threadId: "thread-1",
jobId: "job-1",
status: "cancelled",
model: "model-a",
provider: "openrouter",
errorCode: "REQUEST_TIMEOUT",
errorMessage: "Generation cancelled by user",
startedAt: Date.now(),
completedAt: Date.now(),
requestId: "req-123",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
const onCompleted = vi.fn();
const onCancelled = vi.fn();
const result = await sendMessageForJob({
jobId: "job-1",
content: "Tell me about this role",
stream: {
onReady: vi.fn(),
onDelta: vi.fn(),
onCompleted,
onCancelled,
onError: vi.fn(),
},
});
expect(onCompleted).not.toHaveBeenCalled();
expect(onCancelled).toHaveBeenCalledTimes(1);
expect(result.assistantMessage?.status).toBe("cancelled");
});
it("enforces regenerate only on latest assistant message", async () => {
mocks.repo.getMessageById.mockResolvedValue(baseAssistantMessage);
mocks.repo.getLatestAssistantMessage.mockResolvedValue({
...baseAssistantMessage,
id: "assistant-latest",
});
await expect(
regenerateMessage({
jobId: "job-1",
threadId: "thread-1",
assistantMessageId: "assistant-1",
}),
).rejects.toMatchObject({
code: "INVALID_REQUEST",
status: 400,
});
});
it("returns alreadyFinished when cancelling non-running run", async () => {
mocks.repo.getRunById.mockResolvedValue({
id: "run-finished",
threadId: "thread-1",
jobId: "job-1",
status: "completed",
model: "model-a",
provider: "openrouter",
errorCode: null,
errorMessage: null,
startedAt: Date.now(),
completedAt: Date.now(),
requestId: "req-123",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
const result = await cancelRun({
jobId: "job-1",
threadId: "thread-1",
runId: "run-finished",
});
expect(result).toEqual({ cancelled: false, alreadyFinished: true });
expect(mocks.repo.completeRun).not.toHaveBeenCalled();
expect(mocks.repo.completeRunIfRunning).not.toHaveBeenCalled();
});
it("returns alreadyFinished when run completes before cancel write", async () => {
mocks.repo.getRunById.mockResolvedValue({
id: "run-race",
threadId: "thread-1",
jobId: "job-1",
status: "running",
model: "model-a",
provider: "openrouter",
errorCode: null,
errorMessage: null,
startedAt: Date.now(),
completedAt: null,
requestId: "req-123",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
mocks.repo.completeRunIfRunning.mockResolvedValue({
id: "run-race",
threadId: "thread-1",
jobId: "job-1",
status: "completed",
model: "model-a",
provider: "openrouter",
errorCode: null,
errorMessage: null,
startedAt: Date.now(),
completedAt: Date.now(),
requestId: "req-123",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
const result = await cancelRun({
jobId: "job-1",
threadId: "thread-1",
runId: "run-race",
});
expect(result).toEqual({ cancelled: false, alreadyFinished: true });
});
it("maps createRun unique constraint races to conflict", async () => {
mocks.repo.createMessage.mockResolvedValue(baseUserMessage);
mocks.repo.createRun.mockRejectedValue(
new Error(
"UNIQUE constraint failed: job_chat_runs.thread_id (idx_job_chat_runs_thread_running_unique)",
),
);
await expect(
sendMessageForJob({
jobId: "job-1",
content: "hello",
}),
).rejects.toMatchObject({
code: "CONFLICT",
status: 409,
});
});
});

View File

@ -0,0 +1,617 @@
import { logger } from "@infra/logger";
import { getRequestId } from "@infra/request-context";
import type { JobChatMessage, JobChatRun } from "@shared/types";
import {
badRequest,
conflict,
notFound,
requestTimeout,
upstreamError,
} from "../infra/errors";
import * as jobChatRepo from "../repositories/ghostwriter";
import * as settingsRepo from "../repositories/settings";
import { buildJobChatPromptContext } from "./ghostwriter-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;
}
function isRunningRunUniqueConstraintError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return (
message.includes("idx_job_chat_runs_thread_running_unique") ||
message.includes("UNIQUE constraint failed: job_chat_runs.thread_id")
);
}
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,
}));
}
type GenerateReplyOptions = {
jobId: string;
threadId: string;
prompt: string;
replaceMessageId?: string;
version?: number;
stream?: {
onReady: (payload: {
runId: string;
threadId: 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;
};
};
async function ensureJobThread(jobId: string, title?: string | null) {
return jobChatRepo.getOrCreateThreadForJob({
jobId,
title: title ?? null,
});
}
export async function createThread(input: {
jobId: string;
title?: string | null;
}) {
return ensureJobThread(input.jobId, input.title);
}
export async function listThreads(jobId: string) {
const thread = await ensureJobThread(jobId);
return [thread];
}
export async function listMessages(input: {
jobId: string;
threadId: string;
limit?: number;
offset?: number;
}) {
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,
});
}
export async function listMessagesForJob(input: {
jobId: string;
limit?: number;
offset?: number;
}) {
const thread = await ensureJobThread(input.jobId);
return jobChatRepo.listMessagesForThread(thread.id, {
limit: input.limit,
offset: input.offset,
});
}
async function runAssistantReply(
options: GenerateReplyOptions,
): Promise<{ runId: string; messageId: string; message: string }> {
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";
let run: JobChatRun;
try {
run = await jobChatRepo.createRun({
threadId: options.threadId,
jobId: options.jobId,
model: llmConfig.model,
provider: llmConfig.provider,
requestId,
});
} catch (error) {
if (isRunningRunUniqueConstraintError(error)) {
throw conflict("A chat generation is already running for this thread");
}
throw error;
}
let assistantMessage: JobChatMessage;
try {
assistantMessage = await jobChatRepo.createMessage({
threadId: options.threadId,
jobId: options.jobId,
role: "assistant",
content: "",
status: "partial",
version: options.version ?? 1,
replacesMessageId: options.replaceMessageId ?? null,
});
} catch (error) {
await jobChatRepo.completeRun(run.id, {
status: "failed",
errorCode: "INTERNAL_ERROR",
errorMessage: "Failed to create assistant message",
});
throw error;
}
const controller = new AbortController();
abortControllers.set(run.id, controller);
options.stream?.onReady({
runId: run.id,
threadId: options.threadId,
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),
},
);
const runAfterComplete = await jobChatRepo.completeRunIfRunning(run.id, {
status: "completed",
});
if (!runAfterComplete || runAfterComplete.status !== "completed") {
if (runAfterComplete?.status === "cancelled") {
const cancelledMessage = await jobChatRepo.updateMessage(
assistantMessage.id,
{
content: accumulated,
status: "cancelled",
tokensIn: estimateTokenCount(options.prompt),
tokensOut: estimateTokenCount(accumulated),
},
);
options.stream?.onCancelled({
runId: run.id,
message: cancelledMessage,
});
}
return {
runId: run.id,
messageId: assistantMessage.id,
message: accumulated,
};
}
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"];
}) {
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 sendMessageForJob(input: {
jobId: string;
content: string;
stream?: GenerateReplyOptions["stream"];
}) {
const thread = await ensureJobThread(input.jobId);
return sendMessage({
jobId: input.jobId,
threadId: thread.id,
content: input.content,
stream: input.stream,
});
}
export async function regenerateMessage(input: {
jobId: string;
threadId: string;
assistantMessageId: string;
stream?: GenerateReplyOptions["stream"];
}) {
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 regenerateMessageForJob(input: {
jobId: string;
assistantMessageId: string;
stream?: GenerateReplyOptions["stream"];
}) {
const thread = await ensureJobThread(input.jobId);
return regenerateMessage({
jobId: input.jobId,
threadId: thread.id,
assistantMessageId: input.assistantMessageId,
stream: input.stream,
});
}
export async function cancelRun(input: {
jobId: string;
threadId: string;
runId: string;
}): Promise<{ cancelled: boolean; alreadyFinished: boolean }> {
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();
}
const runAfterCancel = await jobChatRepo.completeRunIfRunning(input.runId, {
status: "cancelled",
errorCode: "REQUEST_TIMEOUT",
errorMessage: "Generation cancelled by user",
});
if (!runAfterCancel || runAfterCancel.status !== "cancelled") {
return {
cancelled: false,
alreadyFinished: true,
};
}
return {
cancelled: true,
alreadyFinished: false,
};
}
export async function cancelRunForJob(input: {
jobId: string;
runId: string;
}): Promise<{ cancelled: boolean; alreadyFinished: boolean }> {
const thread = await ensureJobThread(input.jobId);
return cancelRun({
jobId: input.jobId,
threadId: thread.id,
runId: input.runId,
});
}

View File

@ -79,6 +79,7 @@ export class LlmService {
jsonSchema,
maxRetries = 0,
retryDelayMs = 500,
signal,
} = options;
const jobId = options.jobId;
@ -94,6 +95,7 @@ export class LlmService {
maxRetries,
retryDelayMs,
jobId,
signal,
});
if (result.success) {
@ -173,9 +175,17 @@ export class LlmService {
maxRetries: number;
retryDelayMs: number;
jobId?: string;
signal?: AbortSignal;
}): Promise<LlmResponse<T>> {
const { mode, model, messages, jsonSchema, maxRetries, retryDelayMs } =
args;
const {
mode,
model,
messages,
jsonSchema,
maxRetries,
retryDelayMs,
signal,
} = args;
const jobId = args.jobId;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
@ -202,6 +212,7 @@ export class LlmService {
method: "POST",
headers,
body: JSON.stringify(body),
signal,
});
if (!response.ok) {

View File

@ -30,6 +30,8 @@ export interface LlmRequestOptions<_T> {
retryDelayMs?: number;
/** Job ID for logging purposes */
jobId?: string;
/** Optional abort signal for cancellation */
signal?: AbortSignal;
}
export interface LlmResult<T> {

View File

@ -13,6 +13,10 @@ type SettingsConversionValueMap = {
jobspyResultsWanted: number;
jobspyCountryIndeed: string;
showSponsorInfo: boolean;
chatStyleTone: string;
chatStyleFormality: string;
chatStyleConstraints: string;
chatStyleDoNotUse: string;
backupEnabled: boolean;
backupHour: number;
backupMaxCount: number;
@ -137,6 +141,30 @@ export const settingsConversionMetadata: SettingsConversionMetadata = {
serialize: serializeBitBool,
resolve: resolveWithNullishFallback,
},
chatStyleTone: {
defaultValue: () => process.env.CHAT_STYLE_TONE || "professional",
parseOverride: (raw) => raw ?? null,
serialize: (value) => value ?? null,
resolve: resolveWithEmptyStringFallback,
},
chatStyleFormality: {
defaultValue: () => process.env.CHAT_STYLE_FORMALITY || "medium",
parseOverride: (raw) => raw ?? null,
serialize: (value) => value ?? null,
resolve: resolveWithEmptyStringFallback,
},
chatStyleConstraints: {
defaultValue: () => process.env.CHAT_STYLE_CONSTRAINTS || "",
parseOverride: (raw) => raw ?? null,
serialize: (value) => value ?? null,
resolve: resolveWithEmptyStringFallback,
},
chatStyleDoNotUse: {
defaultValue: () => process.env.CHAT_STYLE_DO_NOT_USE || "",
parseOverride: (raw) => raw ?? null,
serialize: (value) => value ?? null,
resolve: resolveWithEmptyStringFallback,
},
backupEnabled: {
defaultValue: () => false,
parseOverride: parseBitBoolOrNull,

View File

@ -188,6 +188,26 @@ export const settingsUpdateRegistry: Partial<{
actions: [metadataPersistAction("showSponsorInfo", value)],
}),
),
chatStyleTone: singleAction(({ value }) =>
result({
actions: [metadataPersistAction("chatStyleTone", value)],
}),
),
chatStyleFormality: singleAction(({ value }) =>
result({
actions: [metadataPersistAction("chatStyleFormality", value)],
}),
),
chatStyleConstraints: singleAction(({ value }) =>
result({
actions: [metadataPersistAction("chatStyleConstraints", value)],
}),
),
chatStyleDoNotUse: singleAction(({ value }) =>
result({
actions: [metadataPersistAction("chatStyleDoNotUse", value)],
}),
),
llmApiKey: singleAction(({ value }) => {
const normalized = toNormalizedStringOrNull(value);
return result({

View File

@ -146,6 +146,39 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
const overrideShowSponsorInfo = showSponsorInfoSetting.overrideValue;
const showSponsorInfo = showSponsorInfoSetting.value;
const chatStyleToneSetting = resolveSettingValue(
"chatStyleTone",
overrides.chatStyleTone,
);
const defaultChatStyleTone = chatStyleToneSetting.defaultValue;
const overrideChatStyleTone = chatStyleToneSetting.overrideValue;
const chatStyleTone = chatStyleToneSetting.value;
const chatStyleFormalitySetting = resolveSettingValue(
"chatStyleFormality",
overrides.chatStyleFormality,
);
const defaultChatStyleFormality = chatStyleFormalitySetting.defaultValue;
const overrideChatStyleFormality = chatStyleFormalitySetting.overrideValue;
const chatStyleFormality = chatStyleFormalitySetting.value;
const chatStyleConstraintsSetting = resolveSettingValue(
"chatStyleConstraints",
overrides.chatStyleConstraints,
);
const defaultChatStyleConstraints = chatStyleConstraintsSetting.defaultValue;
const overrideChatStyleConstraints =
chatStyleConstraintsSetting.overrideValue;
const chatStyleConstraints = chatStyleConstraintsSetting.value;
const chatStyleDoNotUseSetting = resolveSettingValue(
"chatStyleDoNotUse",
overrides.chatStyleDoNotUse,
);
const defaultChatStyleDoNotUse = chatStyleDoNotUseSetting.defaultValue;
const overrideChatStyleDoNotUse = chatStyleDoNotUseSetting.overrideValue;
const chatStyleDoNotUse = chatStyleDoNotUseSetting.value;
const backupEnabledSetting = resolveSettingValue(
"backupEnabled",
overrides.backupEnabled,
@ -245,6 +278,18 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
showSponsorInfo,
defaultShowSponsorInfo,
overrideShowSponsorInfo,
chatStyleTone,
defaultChatStyleTone,
overrideChatStyleTone,
chatStyleFormality,
defaultChatStyleFormality,
overrideChatStyleFormality,
chatStyleConstraints,
defaultChatStyleConstraints,
overrideChatStyleConstraints,
chatStyleDoNotUse,
defaultChatStyleDoNotUse,
overrideChatStyleDoNotUse,
backupEnabled,
defaultBackupEnabled,
overrideBackupEnabled,

View File

@ -54,6 +54,10 @@ export const updateSettingsSchema = z
.optional(),
jobspyCountryIndeed: z.string().trim().max(100).nullable().optional(),
showSponsorInfo: 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(),
chatStyleDoNotUse: z.string().trim().max(1000).nullable().optional(),
rxresumeEmail: z.string().trim().max(200).nullable().optional(),
rxresumePassword: z.string().trim().max(2000).nullable().optional(),
basicAuthUser: z.string().trim().max(200).nullable().optional(),

View File

@ -179,6 +179,18 @@ export const createAppSettings = (
showSponsorInfo: true,
defaultShowSponsorInfo: true,
overrideShowSponsorInfo: null,
chatStyleTone: "professional",
defaultChatStyleTone: "professional",
overrideChatStyleTone: null,
chatStyleFormality: "medium",
defaultChatStyleFormality: "medium",
overrideChatStyleFormality: null,
chatStyleConstraints: "",
defaultChatStyleConstraints: "",
overrideChatStyleConstraints: null,
chatStyleDoNotUse: "",
defaultChatStyleDoNotUse: "",
overrideChatStyleDoNotUse: null,
llmApiKeyHint: null,
rxresumeEmail: null,
rxresumePasswordHint: null,

View File

@ -620,6 +620,102 @@ export interface BulkJobActionResponse {
results: BulkJobActionResult[];
}
export const JOB_CHAT_MESSAGE_ROLES = [
"system",
"user",
"assistant",
"tool",
] as const;
export type JobChatMessageRole = (typeof JOB_CHAT_MESSAGE_ROLES)[number];
export const JOB_CHAT_MESSAGE_STATUSES = [
"complete",
"partial",
"cancelled",
"failed",
] as const;
export type JobChatMessageStatus = (typeof JOB_CHAT_MESSAGE_STATUSES)[number];
export const JOB_CHAT_RUN_STATUSES = [
"running",
"completed",
"cancelled",
"failed",
] as const;
export type JobChatRunStatus = (typeof JOB_CHAT_RUN_STATUSES)[number];
export interface JobChatThread {
id: string;
jobId: string;
title: string | null;
createdAt: string;
updatedAt: string;
lastMessageAt: string | null;
}
export interface JobChatMessage {
id: string;
threadId: string;
jobId: string;
role: JobChatMessageRole;
content: string;
status: JobChatMessageStatus;
tokensIn: number | null;
tokensOut: number | null;
version: number;
replacesMessageId: string | null;
createdAt: string;
updatedAt: string;
}
export interface JobChatRun {
id: string;
threadId: string;
jobId: string;
status: JobChatRunStatus;
model: string | null;
provider: string | null;
errorCode: string | null;
errorMessage: string | null;
startedAt: number;
completedAt: number | null;
requestId: string | null;
createdAt: string;
updatedAt: string;
}
export type JobChatStreamEvent =
| {
type: "ready";
runId: string;
threadId: string;
messageId: string;
requestId: string;
}
| {
type: "delta";
runId: string;
messageId: string;
delta: string;
}
| {
type: "completed";
runId: string;
message: JobChatMessage;
}
| {
type: "cancelled";
runId: string;
message: JobChatMessage;
}
| {
type: "error";
runId: string;
code: string;
message: string;
requestId: string;
};
// Visa Sponsors types
export interface VisaSponsor {
organisationName: string;
@ -817,6 +913,18 @@ export interface AppSettings {
showSponsorInfo: boolean;
defaultShowSponsorInfo: boolean;
overrideShowSponsorInfo: boolean | null;
chatStyleTone: string;
defaultChatStyleTone: string;
overrideChatStyleTone: string | null;
chatStyleFormality: string;
defaultChatStyleFormality: string;
overrideChatStyleFormality: string | null;
chatStyleConstraints: string;
defaultChatStyleConstraints: string;
overrideChatStyleConstraints: string | null;
chatStyleDoNotUse: string;
defaultChatStyleDoNotUse: string;
overrideChatStyleDoNotUse: string | null;
llmApiKeyHint: string | null;
rxresumeEmail: string | null;
rxresumePasswordHint: string | null;