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:
parent
e6563e74c3
commit
d0b4091a60
@ -18,6 +18,12 @@ Welcome to the JobOps documentation. This folder contains comprehensive guides f
|
|||||||
- PDF generation and regeneration
|
- PDF generation and regeneration
|
||||||
- Post-application tracking overview
|
- 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
|
- **[Post-Application Tracking](./post-application-tracking.md)** - Email-to-job matching
|
||||||
- How the Smart Router AI works
|
- How the Smart Router AI works
|
||||||
- Gmail integration setup
|
- Gmail integration setup
|
||||||
@ -58,6 +64,7 @@ JobOps uses specialized extractors to gather jobs from different sources:
|
|||||||
documentation/
|
documentation/
|
||||||
├── self-hosting.md # Deployment guide
|
├── self-hosting.md # Deployment guide
|
||||||
├── orchestrator.md # Core workflow documentation
|
├── orchestrator.md # Core workflow documentation
|
||||||
|
├── ghostwriter.md # Ghostwriter feature documentation
|
||||||
├── post-application-tracking.md # Email tracking feature
|
├── post-application-tracking.md # Email tracking feature
|
||||||
└── extractors/ # Job source extractors
|
└── extractors/ # Job source extractors
|
||||||
├── README.md
|
├── README.md
|
||||||
|
|||||||
60
documentation/ghostwriter.md
Normal file
60
documentation/ghostwriter.md
Normal 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.
|
||||||
@ -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":
|
Once a job is `ready`, the Ready panel is the "shipping lane":
|
||||||
|
|
||||||
- View/download the PDF.
|
- View/download the PDF.
|
||||||
|
- Open Ghostwriter for context-aware drafting tied to the current job.
|
||||||
- Open the job listing.
|
- Open the job listing.
|
||||||
- Mark Applied (moves to `applied`).
|
- Mark Applied (moves to `applied`).
|
||||||
- Optional: edit tailoring, edit the JD, or regenerate the PDF.
|
- 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)
|
## Generating PDFs (first time)
|
||||||
|
|
||||||
The PDF is generated from:
|
The PDF is generated from:
|
||||||
|
|||||||
@ -15,6 +15,9 @@ import type {
|
|||||||
BulkPostApplicationActionResponse,
|
BulkPostApplicationActionResponse,
|
||||||
DemoInfoResponse,
|
DemoInfoResponse,
|
||||||
Job,
|
Job,
|
||||||
|
JobChatMessage,
|
||||||
|
JobChatStreamEvent,
|
||||||
|
JobChatThread,
|
||||||
JobListItem,
|
JobListItem,
|
||||||
JobOutcome,
|
JobOutcome,
|
||||||
JobSource,
|
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(
|
export async function processJob(
|
||||||
id: string,
|
id: string,
|
||||||
options?: { force?: boolean },
|
options?: { force?: boolean },
|
||||||
|
|||||||
@ -50,6 +50,7 @@ import { useProfile } from "../hooks/useProfile";
|
|||||||
import { useRescoreJob } from "../hooks/useRescoreJob";
|
import { useRescoreJob } from "../hooks/useRescoreJob";
|
||||||
import { FitAssessment, JobHeader, TailoredSummary } from ".";
|
import { FitAssessment, JobHeader, TailoredSummary } from ".";
|
||||||
import { TailorMode } from "./discovered-panel/TailorMode";
|
import { TailorMode } from "./discovered-panel/TailorMode";
|
||||||
|
import { GhostwriterDrawer } from "./ghostwriter/GhostwriterDrawer";
|
||||||
import { JobDetailsEditDrawer } from "./JobDetailsEditDrawer";
|
import { JobDetailsEditDrawer } from "./JobDetailsEditDrawer";
|
||||||
import { KbdHint } from "./KbdHint";
|
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="pb-4 border-b border-border/40">
|
||||||
<div className="grid gap-2 sm:grid-cols-2">
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
{/* Show PDF - to verify quickly without download */}
|
<GhostwriterDrawer
|
||||||
<Button
|
job={job}
|
||||||
asChild
|
triggerClassName="h-9 w-full justify-center gap-1 px-2 text-xs"
|
||||||
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>
|
|
||||||
|
|
||||||
{/* Download PDF - primary artifact action */}
|
{/* Download PDF - primary artifact action */}
|
||||||
<Button
|
<Button
|
||||||
@ -448,6 +441,15 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
|||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
{/* Utility actions */}
|
{/* Utility actions */}
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() =>
|
||||||
|
window.open(pdfHref, "_blank", "noopener,noreferrer")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FileText className="mr-2 h-4 w-4" />
|
||||||
|
View PDF
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem onSelect={handleCopyInfo}>
|
<DropdownMenuItem onSelect={handleCopyInfo}>
|
||||||
<Copy className="mr-2 h-4 w-4" />
|
<Copy className="mr-2 h-4 w-4" />
|
||||||
Copy job info
|
Copy job info
|
||||||
|
|||||||
90
orchestrator/src/client/components/ghostwriter/Composer.tsx
Normal file
90
orchestrator/src/client/components/ghostwriter/Composer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
140
orchestrator/src/client/components/ghostwriter/ThreadList.tsx
Normal file
140
orchestrator/src/client/components/ghostwriter/ThreadList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -8,7 +8,7 @@ type KeyBindingMap = Record<string, (event: KeyboardEvent) => void>;
|
|||||||
* don't fire while the user is typing in a form control.
|
* don't fire while the user is typing in a form control.
|
||||||
*/
|
*/
|
||||||
const INPUT_TAG_NAMES = new Set(["INPUT", "TEXTAREA", "SELECT"]);
|
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 {
|
function isEditableTarget(event: KeyboardEvent): boolean {
|
||||||
const target = event.target;
|
const target = event.target;
|
||||||
@ -27,8 +27,10 @@ function isEditableTarget(event: KeyboardEvent): boolean {
|
|||||||
* - Uses a stable ref for handler updates without rebinding.
|
* - Uses a stable ref for handler updates without rebinding.
|
||||||
* - Rebuilds bindings when the key set changes.
|
* - Rebuilds bindings when the key set changes.
|
||||||
*
|
*
|
||||||
* Modifier shortcuts (e.g. "$mod+K") bypass the input guard because the user
|
* Non-shift modifier shortcuts (e.g. "$mod+K") bypass the input guard because
|
||||||
* explicitly held a modifier -- those are intentional even inside inputs.
|
* 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(
|
export function useHotkeys(
|
||||||
bindings: KeyBindingMap,
|
bindings: KeyBindingMap,
|
||||||
@ -49,13 +51,13 @@ export function useHotkeys(
|
|||||||
const guarded: KeyBindingMap = {};
|
const guarded: KeyBindingMap = {};
|
||||||
const bindingKeys = bindingSignature ? bindingSignature.split("|") : [];
|
const bindingKeys = bindingSignature ? bindingSignature.split("|") : [];
|
||||||
for (const key of bindingKeys) {
|
for (const key of bindingKeys) {
|
||||||
const hasModifier = key
|
const hasNonShiftModifier = key
|
||||||
.split(" ")
|
.split(" ")
|
||||||
.some((sequence) => MODIFIER_PATTERN.test(sequence));
|
.some((sequence) => NON_SHIFT_MODIFIER_PATTERN.test(sequence));
|
||||||
|
|
||||||
guarded[key] = (event: KeyboardEvent) => {
|
guarded[key] = (event: KeyboardEvent) => {
|
||||||
// Skip single-key shortcuts when the user is typing in an input.
|
// 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);
|
bindingsRef.current[key]?.(event);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,6 +23,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|||||||
import { formatTimestamp } from "@/lib/utils";
|
import { formatTimestamp } from "@/lib/utils";
|
||||||
import * as api from "../api";
|
import * as api from "../api";
|
||||||
import { ConfirmDelete } from "../components/ConfirmDelete";
|
import { ConfirmDelete } from "../components/ConfirmDelete";
|
||||||
|
import { GhostwriterDrawer } from "../components/ghostwriter/GhostwriterDrawer";
|
||||||
import { JobHeader } from "../components/JobHeader";
|
import { JobHeader } from "../components/JobHeader";
|
||||||
import {
|
import {
|
||||||
type LogEventFormValues,
|
type LogEventFormValues,
|
||||||
@ -273,10 +274,13 @@ export const JobPage: React.FC = () => {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Card className="border-border/50">
|
<Card className="border-border/50">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<CalendarClock className="h-4 w-4" />
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
Application details
|
<CalendarClock className="h-4 w-4" />
|
||||||
</CardTitle>
|
Application details
|
||||||
|
</CardTitle>
|
||||||
|
<GhostwriterDrawer job={job} />
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -1030,7 +1030,7 @@ describe("OrchestratorPage", () => {
|
|||||||
|
|
||||||
pressKeyOn(input, "?", { shiftKey: true });
|
pressKeyOn(input, "?", { shiftKey: true });
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId("help-dialog")).toHaveTextContent("open");
|
expect(screen.getByTestId("help-dialog")).toHaveTextContent("closed");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import * as api from "@client/api";
|
import * as api from "@client/api";
|
||||||
import { PageHeader } from "@client/components/layout";
|
import { PageHeader } from "@client/components/layout";
|
||||||
import { BackupSettingsSection } from "@client/pages/settings/components/BackupSettingsSection";
|
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 { DangerZoneSection } from "@client/pages/settings/components/DangerZoneSection";
|
||||||
import { DisplaySettingsSection } from "@client/pages/settings/components/DisplaySettingsSection";
|
import { DisplaySettingsSection } from "@client/pages/settings/components/DisplaySettingsSection";
|
||||||
import { EnvironmentSettingsSection } from "@client/pages/settings/components/EnvironmentSettingsSection";
|
import { EnvironmentSettingsSection } from "@client/pages/settings/components/EnvironmentSettingsSection";
|
||||||
@ -46,6 +47,10 @@ const DEFAULT_FORM_VALUES: UpdateSettingsInput = {
|
|||||||
resumeProjects: null,
|
resumeProjects: null,
|
||||||
rxresumeBaseResumeId: null,
|
rxresumeBaseResumeId: null,
|
||||||
showSponsorInfo: null,
|
showSponsorInfo: null,
|
||||||
|
chatStyleTone: "",
|
||||||
|
chatStyleFormality: "",
|
||||||
|
chatStyleConstraints: "",
|
||||||
|
chatStyleDoNotUse: "",
|
||||||
rxresumeEmail: "",
|
rxresumeEmail: "",
|
||||||
rxresumePassword: "",
|
rxresumePassword: "",
|
||||||
basicAuthUser: "",
|
basicAuthUser: "",
|
||||||
@ -81,6 +86,10 @@ const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = {
|
|||||||
resumeProjects: null,
|
resumeProjects: null,
|
||||||
rxresumeBaseResumeId: null,
|
rxresumeBaseResumeId: null,
|
||||||
showSponsorInfo: null,
|
showSponsorInfo: null,
|
||||||
|
chatStyleTone: null,
|
||||||
|
chatStyleFormality: null,
|
||||||
|
chatStyleConstraints: null,
|
||||||
|
chatStyleDoNotUse: null,
|
||||||
rxresumeEmail: null,
|
rxresumeEmail: null,
|
||||||
rxresumePassword: null,
|
rxresumePassword: null,
|
||||||
basicAuthUser: null,
|
basicAuthUser: null,
|
||||||
@ -110,6 +119,10 @@ const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
|
|||||||
resumeProjects: data.resumeProjects,
|
resumeProjects: data.resumeProjects,
|
||||||
rxresumeBaseResumeId: data.rxresumeBaseResumeId ?? null,
|
rxresumeBaseResumeId: data.rxresumeBaseResumeId ?? null,
|
||||||
showSponsorInfo: data.overrideShowSponsorInfo,
|
showSponsorInfo: data.overrideShowSponsorInfo,
|
||||||
|
chatStyleTone: data.overrideChatStyleTone ?? "",
|
||||||
|
chatStyleFormality: data.overrideChatStyleFormality ?? "",
|
||||||
|
chatStyleConstraints: data.overrideChatStyleConstraints ?? "",
|
||||||
|
chatStyleDoNotUse: data.overrideChatStyleDoNotUse ?? "",
|
||||||
rxresumeEmail: data.rxresumeEmail ?? "",
|
rxresumeEmail: data.rxresumeEmail ?? "",
|
||||||
rxresumePassword: "",
|
rxresumePassword: "",
|
||||||
basicAuthUser: data.basicAuthUser ?? "",
|
basicAuthUser: data.basicAuthUser ?? "",
|
||||||
@ -200,6 +213,24 @@ const getDerivedSettings = (settings: AppSettings | null) => {
|
|||||||
effective: settings?.showSponsorInfo ?? true,
|
effective: settings?.showSponsorInfo ?? true,
|
||||||
default: settings?.defaultShowSponsorInfo ?? 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: {
|
envSettings: {
|
||||||
readable: {
|
readable: {
|
||||||
rxresumeEmail: settings?.rxresumeEmail ?? "",
|
rxresumeEmail: settings?.rxresumeEmail ?? "",
|
||||||
@ -386,6 +417,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
pipelineWebhook,
|
pipelineWebhook,
|
||||||
jobCompleteWebhook,
|
jobCompleteWebhook,
|
||||||
display,
|
display,
|
||||||
|
chat,
|
||||||
envSettings,
|
envSettings,
|
||||||
defaultResumeProjects,
|
defaultResumeProjects,
|
||||||
profileProjects,
|
profileProjects,
|
||||||
@ -551,6 +583,10 @@ export const SettingsPage: React.FC = () => {
|
|||||||
resumeProjects: resumeProjectsOverride,
|
resumeProjects: resumeProjectsOverride,
|
||||||
rxresumeBaseResumeId: normalizeString(data.rxresumeBaseResumeId),
|
rxresumeBaseResumeId: normalizeString(data.rxresumeBaseResumeId),
|
||||||
showSponsorInfo: nullIfSame(data.showSponsorInfo, display.default),
|
showSponsorInfo: nullIfSame(data.showSponsorInfo, display.default),
|
||||||
|
chatStyleTone: normalizeString(data.chatStyleTone),
|
||||||
|
chatStyleFormality: normalizeString(data.chatStyleFormality),
|
||||||
|
chatStyleConstraints: normalizeString(data.chatStyleConstraints),
|
||||||
|
chatStyleDoNotUse: normalizeString(data.chatStyleDoNotUse),
|
||||||
backupEnabled: nullIfSame(
|
backupEnabled: nullIfSame(
|
||||||
data.backupEnabled,
|
data.backupEnabled,
|
||||||
backup.backupEnabled.default,
|
backup.backupEnabled.default,
|
||||||
@ -730,6 +766,11 @@ export const SettingsPage: React.FC = () => {
|
|||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
isSaving={isSaving}
|
isSaving={isSaving}
|
||||||
/>
|
/>
|
||||||
|
<ChatSettingsSection
|
||||||
|
values={chat}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isSaving={isSaving}
|
||||||
|
/>
|
||||||
<ScoringSettingsSection
|
<ScoringSettingsSection
|
||||||
values={scoring}
|
values={scoring}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
|||||||
@ -580,7 +580,7 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
|||||||
className="text-[10px] text-muted-foreground/60 hover:text-muted-foreground transition-colors"
|
className="text-[10px] text-muted-foreground/60 hover:text-muted-foreground transition-colors"
|
||||||
onClick={() => setDetailTab("description")}
|
onClick={() => setDetailTab("description")}
|
||||||
>
|
>
|
||||||
View full description
|
View full description
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -14,6 +14,12 @@ export type ModelValues = EffectiveDefault<string> & {
|
|||||||
|
|
||||||
export type WebhookValues = EffectiveDefault<string>;
|
export type WebhookValues = EffectiveDefault<string>;
|
||||||
export type DisplayValues = EffectiveDefault<boolean>;
|
export type DisplayValues = EffectiveDefault<boolean>;
|
||||||
|
export type ChatValues = {
|
||||||
|
tone: EffectiveDefault<string>;
|
||||||
|
formality: EffectiveDefault<string>;
|
||||||
|
constraints: EffectiveDefault<string>;
|
||||||
|
doNotUse: EffectiveDefault<string>;
|
||||||
|
};
|
||||||
|
|
||||||
export type EnvSettingsValues = {
|
export type EnvSettingsValues = {
|
||||||
readable: {
|
readable: {
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import * as React from "react";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const buttonVariants = cva(
|
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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { Router } from "express";
|
|||||||
import { backupRouter } from "./routes/backup";
|
import { backupRouter } from "./routes/backup";
|
||||||
import { databaseRouter } from "./routes/database";
|
import { databaseRouter } from "./routes/database";
|
||||||
import { demoRouter } from "./routes/demo";
|
import { demoRouter } from "./routes/demo";
|
||||||
|
import { ghostwriterRouter } from "./routes/ghostwriter";
|
||||||
import { jobsRouter } from "./routes/jobs";
|
import { jobsRouter } from "./routes/jobs";
|
||||||
import { manualJobsRouter } from "./routes/manual-jobs";
|
import { manualJobsRouter } from "./routes/manual-jobs";
|
||||||
import { onboardingRouter } from "./routes/onboarding";
|
import { onboardingRouter } from "./routes/onboarding";
|
||||||
@ -20,6 +21,7 @@ import { webhookRouter } from "./routes/webhook";
|
|||||||
export const apiRouter = Router();
|
export const apiRouter = Router();
|
||||||
|
|
||||||
apiRouter.use("/jobs", jobsRouter);
|
apiRouter.use("/jobs", jobsRouter);
|
||||||
|
apiRouter.use("/jobs/:id/chat", ghostwriterRouter);
|
||||||
apiRouter.use("/demo", demoRouter);
|
apiRouter.use("/demo", demoRouter);
|
||||||
apiRouter.use("/settings", settingsRouter);
|
apiRouter.use("/settings", settingsRouter);
|
||||||
apiRouter.use("/pipeline", pipelineRouter);
|
apiRouter.use("/pipeline", pipelineRouter);
|
||||||
|
|||||||
185
orchestrator/src/server/api/routes/ghostwriter.test.ts
Normal file
185
orchestrator/src/server/api/routes/ghostwriter.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
638
orchestrator/src/server/api/routes/ghostwriter.ts
Normal file
638
orchestrator/src/server/api/routes/ghostwriter.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
@ -92,6 +92,51 @@ const migrations = [
|
|||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
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 (
|
`CREATE TABLE IF NOT EXISTS stage_events (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
application_id TEXT NOT NULL,
|
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_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_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_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
|
// 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)
|
`INSERT INTO stage_events (id, application_id, title, from_stage, to_stage, occurred_at, metadata)
|
||||||
|
|||||||
@ -8,6 +8,9 @@ import {
|
|||||||
APPLICATION_TASK_TYPES,
|
APPLICATION_TASK_TYPES,
|
||||||
INTERVIEW_OUTCOMES,
|
INTERVIEW_OUTCOMES,
|
||||||
INTERVIEW_TYPES,
|
INTERVIEW_TYPES,
|
||||||
|
JOB_CHAT_MESSAGE_ROLES,
|
||||||
|
JOB_CHAT_MESSAGE_STATUSES,
|
||||||
|
JOB_CHAT_RUN_STATUSES,
|
||||||
POST_APPLICATION_INTEGRATION_STATUSES,
|
POST_APPLICATION_INTEGRATION_STATUSES,
|
||||||
POST_APPLICATION_MESSAGE_TYPES,
|
POST_APPLICATION_MESSAGE_TYPES,
|
||||||
POST_APPLICATION_PROCESSING_STATUSES,
|
POST_APPLICATION_PROCESSING_STATUSES,
|
||||||
@ -170,6 +173,87 @@ export const pipelineRuns = sqliteTable("pipeline_runs", {
|
|||||||
errorMessage: text("error_message"),
|
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", {
|
export const settings = sqliteTable("settings", {
|
||||||
key: text("key").primaryKey(),
|
key: text("key").primaryKey(),
|
||||||
value: text("value").notNull(),
|
value: text("value").notNull(),
|
||||||
@ -310,6 +394,12 @@ export type InterviewRow = typeof interviews.$inferSelect;
|
|||||||
export type NewInterviewRow = typeof interviews.$inferInsert;
|
export type NewInterviewRow = typeof interviews.$inferInsert;
|
||||||
export type PipelineRunRow = typeof pipelineRuns.$inferSelect;
|
export type PipelineRunRow = typeof pipelineRuns.$inferSelect;
|
||||||
export type NewPipelineRunRow = typeof pipelineRuns.$inferInsert;
|
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 SettingsRow = typeof settings.$inferSelect;
|
||||||
export type NewSettingsRow = typeof settings.$inferInsert;
|
export type NewSettingsRow = typeof settings.$inferInsert;
|
||||||
export type PostApplicationIntegrationRow =
|
export type PostApplicationIntegrationRow =
|
||||||
|
|||||||
368
orchestrator/src/server/repositories/ghostwriter.ts
Normal file
368
orchestrator/src/server/repositories/ghostwriter.ts
Normal 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);
|
||||||
|
}
|
||||||
@ -26,6 +26,10 @@ export type SettingKey =
|
|||||||
| "jobspyResultsWanted"
|
| "jobspyResultsWanted"
|
||||||
| "jobspyCountryIndeed"
|
| "jobspyCountryIndeed"
|
||||||
| "showSponsorInfo"
|
| "showSponsorInfo"
|
||||||
|
| "chatStyleTone"
|
||||||
|
| "chatStyleFormality"
|
||||||
|
| "chatStyleConstraints"
|
||||||
|
| "chatStyleDoNotUse"
|
||||||
| "rxresumeEmail"
|
| "rxresumeEmail"
|
||||||
| "rxresumePassword"
|
| "rxresumePassword"
|
||||||
| "basicAuthUser"
|
| "basicAuthUser"
|
||||||
|
|||||||
126
orchestrator/src/server/services/ghostwriter-context.test.ts
Normal file
126
orchestrator/src/server/services/ghostwriter-context.test.ts
Normal 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>);
|
||||||
|
});
|
||||||
|
});
|
||||||
193
orchestrator/src/server/services/ghostwriter-context.ts
Normal file
193
orchestrator/src/server/services/ghostwriter-context.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
528
orchestrator/src/server/services/ghostwriter.test.ts
Normal file
528
orchestrator/src/server/services/ghostwriter.test.ts
Normal 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
617
orchestrator/src/server/services/ghostwriter.ts
Normal file
617
orchestrator/src/server/services/ghostwriter.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -79,6 +79,7 @@ export class LlmService {
|
|||||||
jsonSchema,
|
jsonSchema,
|
||||||
maxRetries = 0,
|
maxRetries = 0,
|
||||||
retryDelayMs = 500,
|
retryDelayMs = 500,
|
||||||
|
signal,
|
||||||
} = options;
|
} = options;
|
||||||
const jobId = options.jobId;
|
const jobId = options.jobId;
|
||||||
|
|
||||||
@ -94,6 +95,7 @@ export class LlmService {
|
|||||||
maxRetries,
|
maxRetries,
|
||||||
retryDelayMs,
|
retryDelayMs,
|
||||||
jobId,
|
jobId,
|
||||||
|
signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
@ -173,9 +175,17 @@ export class LlmService {
|
|||||||
maxRetries: number;
|
maxRetries: number;
|
||||||
retryDelayMs: number;
|
retryDelayMs: number;
|
||||||
jobId?: string;
|
jobId?: string;
|
||||||
|
signal?: AbortSignal;
|
||||||
}): Promise<LlmResponse<T>> {
|
}): Promise<LlmResponse<T>> {
|
||||||
const { mode, model, messages, jsonSchema, maxRetries, retryDelayMs } =
|
const {
|
||||||
args;
|
mode,
|
||||||
|
model,
|
||||||
|
messages,
|
||||||
|
jsonSchema,
|
||||||
|
maxRetries,
|
||||||
|
retryDelayMs,
|
||||||
|
signal,
|
||||||
|
} = args;
|
||||||
const jobId = args.jobId;
|
const jobId = args.jobId;
|
||||||
|
|
||||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||||
@ -202,6 +212,7 @@ export class LlmService {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
headers,
|
headers,
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
|
signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@ -30,6 +30,8 @@ export interface LlmRequestOptions<_T> {
|
|||||||
retryDelayMs?: number;
|
retryDelayMs?: number;
|
||||||
/** Job ID for logging purposes */
|
/** Job ID for logging purposes */
|
||||||
jobId?: string;
|
jobId?: string;
|
||||||
|
/** Optional abort signal for cancellation */
|
||||||
|
signal?: AbortSignal;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LlmResult<T> {
|
export interface LlmResult<T> {
|
||||||
|
|||||||
@ -13,6 +13,10 @@ type SettingsConversionValueMap = {
|
|||||||
jobspyResultsWanted: number;
|
jobspyResultsWanted: number;
|
||||||
jobspyCountryIndeed: string;
|
jobspyCountryIndeed: string;
|
||||||
showSponsorInfo: boolean;
|
showSponsorInfo: boolean;
|
||||||
|
chatStyleTone: string;
|
||||||
|
chatStyleFormality: string;
|
||||||
|
chatStyleConstraints: string;
|
||||||
|
chatStyleDoNotUse: string;
|
||||||
backupEnabled: boolean;
|
backupEnabled: boolean;
|
||||||
backupHour: number;
|
backupHour: number;
|
||||||
backupMaxCount: number;
|
backupMaxCount: number;
|
||||||
@ -137,6 +141,30 @@ export const settingsConversionMetadata: SettingsConversionMetadata = {
|
|||||||
serialize: serializeBitBool,
|
serialize: serializeBitBool,
|
||||||
resolve: resolveWithNullishFallback,
|
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: {
|
backupEnabled: {
|
||||||
defaultValue: () => false,
|
defaultValue: () => false,
|
||||||
parseOverride: parseBitBoolOrNull,
|
parseOverride: parseBitBoolOrNull,
|
||||||
|
|||||||
@ -188,6 +188,26 @@ export const settingsUpdateRegistry: Partial<{
|
|||||||
actions: [metadataPersistAction("showSponsorInfo", value)],
|
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 }) => {
|
llmApiKey: singleAction(({ value }) => {
|
||||||
const normalized = toNormalizedStringOrNull(value);
|
const normalized = toNormalizedStringOrNull(value);
|
||||||
return result({
|
return result({
|
||||||
|
|||||||
@ -146,6 +146,39 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
|
|||||||
const overrideShowSponsorInfo = showSponsorInfoSetting.overrideValue;
|
const overrideShowSponsorInfo = showSponsorInfoSetting.overrideValue;
|
||||||
const showSponsorInfo = showSponsorInfoSetting.value;
|
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(
|
const backupEnabledSetting = resolveSettingValue(
|
||||||
"backupEnabled",
|
"backupEnabled",
|
||||||
overrides.backupEnabled,
|
overrides.backupEnabled,
|
||||||
@ -245,6 +278,18 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
|
|||||||
showSponsorInfo,
|
showSponsorInfo,
|
||||||
defaultShowSponsorInfo,
|
defaultShowSponsorInfo,
|
||||||
overrideShowSponsorInfo,
|
overrideShowSponsorInfo,
|
||||||
|
chatStyleTone,
|
||||||
|
defaultChatStyleTone,
|
||||||
|
overrideChatStyleTone,
|
||||||
|
chatStyleFormality,
|
||||||
|
defaultChatStyleFormality,
|
||||||
|
overrideChatStyleFormality,
|
||||||
|
chatStyleConstraints,
|
||||||
|
defaultChatStyleConstraints,
|
||||||
|
overrideChatStyleConstraints,
|
||||||
|
chatStyleDoNotUse,
|
||||||
|
defaultChatStyleDoNotUse,
|
||||||
|
overrideChatStyleDoNotUse,
|
||||||
backupEnabled,
|
backupEnabled,
|
||||||
defaultBackupEnabled,
|
defaultBackupEnabled,
|
||||||
overrideBackupEnabled,
|
overrideBackupEnabled,
|
||||||
|
|||||||
@ -54,6 +54,10 @@ export const updateSettingsSchema = z
|
|||||||
.optional(),
|
.optional(),
|
||||||
jobspyCountryIndeed: z.string().trim().max(100).nullable().optional(),
|
jobspyCountryIndeed: z.string().trim().max(100).nullable().optional(),
|
||||||
showSponsorInfo: z.boolean().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(),
|
rxresumeEmail: z.string().trim().max(200).nullable().optional(),
|
||||||
rxresumePassword: z.string().trim().max(2000).nullable().optional(),
|
rxresumePassword: z.string().trim().max(2000).nullable().optional(),
|
||||||
basicAuthUser: z.string().trim().max(200).nullable().optional(),
|
basicAuthUser: z.string().trim().max(200).nullable().optional(),
|
||||||
|
|||||||
@ -179,6 +179,18 @@ export const createAppSettings = (
|
|||||||
showSponsorInfo: true,
|
showSponsorInfo: true,
|
||||||
defaultShowSponsorInfo: true,
|
defaultShowSponsorInfo: true,
|
||||||
overrideShowSponsorInfo: null,
|
overrideShowSponsorInfo: null,
|
||||||
|
chatStyleTone: "professional",
|
||||||
|
defaultChatStyleTone: "professional",
|
||||||
|
overrideChatStyleTone: null,
|
||||||
|
chatStyleFormality: "medium",
|
||||||
|
defaultChatStyleFormality: "medium",
|
||||||
|
overrideChatStyleFormality: null,
|
||||||
|
chatStyleConstraints: "",
|
||||||
|
defaultChatStyleConstraints: "",
|
||||||
|
overrideChatStyleConstraints: null,
|
||||||
|
chatStyleDoNotUse: "",
|
||||||
|
defaultChatStyleDoNotUse: "",
|
||||||
|
overrideChatStyleDoNotUse: null,
|
||||||
llmApiKeyHint: null,
|
llmApiKeyHint: null,
|
||||||
rxresumeEmail: null,
|
rxresumeEmail: null,
|
||||||
rxresumePasswordHint: null,
|
rxresumePasswordHint: null,
|
||||||
|
|||||||
@ -620,6 +620,102 @@ export interface BulkJobActionResponse {
|
|||||||
results: BulkJobActionResult[];
|
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
|
// Visa Sponsors types
|
||||||
export interface VisaSponsor {
|
export interface VisaSponsor {
|
||||||
organisationName: string;
|
organisationName: string;
|
||||||
@ -817,6 +913,18 @@ export interface AppSettings {
|
|||||||
showSponsorInfo: boolean;
|
showSponsorInfo: boolean;
|
||||||
defaultShowSponsorInfo: boolean;
|
defaultShowSponsorInfo: boolean;
|
||||||
overrideShowSponsorInfo: boolean | null;
|
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;
|
llmApiKeyHint: string | null;
|
||||||
rxresumeEmail: string | null;
|
rxresumeEmail: string | null;
|
||||||
rxresumePasswordHint: string | null;
|
rxresumePasswordHint: string | null;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user