feat(frontend): generate task previews with local ollama
Replace the local preview stub with a real Ollama-backed test message flow using the configured local model. Show the exact final prompt live on create and edit task pages, render generated output below it, and cover the integration with frontend tests.
This commit is contained in:
@@ -1,8 +1,10 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import {
|
import {
|
||||||
activateTask,
|
activateTask,
|
||||||
|
buildTaskPreviewPrompt,
|
||||||
createTask,
|
createTask,
|
||||||
deleteTask,
|
deleteTask,
|
||||||
|
generateTaskPreview,
|
||||||
getAllTasks,
|
getAllTasks,
|
||||||
getTask,
|
getTask,
|
||||||
inactivateTask,
|
inactivateTask,
|
||||||
@@ -33,6 +35,26 @@ const taskTwo: EntityTaskResponse = {
|
|||||||
createdAt: '2026-03-26T11:00:00Z',
|
createdAt: '2026-03-26T11:00:00Z',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const entity = {
|
||||||
|
id: 'entity-1',
|
||||||
|
name: 'Milton Fiscal',
|
||||||
|
email: 'milton@condado.test',
|
||||||
|
jobTitle: 'Director of Ceremonial Logistics',
|
||||||
|
personality: 'Rigidly formal but secretly obsessed with office snacks.',
|
||||||
|
scheduleCron: '0 9 * * 1-5',
|
||||||
|
contextWindowDays: 7,
|
||||||
|
active: true,
|
||||||
|
createdAt: '2026-03-20T09:00:00Z',
|
||||||
|
}
|
||||||
|
|
||||||
|
const previewTask = {
|
||||||
|
entityId: 'entity-1',
|
||||||
|
name: 'Snack Escalation Briefing',
|
||||||
|
prompt: 'Draft an absurdly official update about disappearing crackers.',
|
||||||
|
scheduleCron: '15 10 * * 2',
|
||||||
|
emailLookback: 'last_week' as const,
|
||||||
|
}
|
||||||
|
|
||||||
describe('tasksApi', () => {
|
describe('tasksApi', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear()
|
localStorage.clear()
|
||||||
@@ -54,14 +76,7 @@ describe('tasksApi', () => {
|
|||||||
it('should_returnTasksForEntity_when_getTasksByEntityCalled', async () => {
|
it('should_returnTasksForEntity_when_getTasksByEntityCalled', async () => {
|
||||||
localStorage.setItem('condado:entity-tasks', JSON.stringify([taskOne, taskTwo]))
|
localStorage.setItem('condado:entity-tasks', JSON.stringify([taskOne, taskTwo]))
|
||||||
|
|
||||||
await expect(getTasksByEntity('entity-1')).resolves.toEqual([
|
await expect(getTasksByEntity('entity-1')).resolves.toEqual([taskOne])
|
||||||
taskOne,
|
|
||||||
{
|
|
||||||
...taskOne,
|
|
||||||
id: 'task-3',
|
|
||||||
active: false,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should_hideInactiveTasks_when_getAllTasksCalled', async () => {
|
it('should_hideInactiveTasks_when_getAllTasksCalled', async () => {
|
||||||
@@ -70,7 +85,7 @@ describe('tasksApi', () => {
|
|||||||
await expect(getAllTasks()).resolves.toEqual([taskOne])
|
await expect(getAllTasks()).resolves.toEqual([taskOne])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should_hideInactiveTasksForEntity_when_getTasksByEntityCalled', async () => {
|
it('should_returnInactiveTasksForEntity_when_getTasksByEntityCalled', async () => {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
'condado:entity-tasks',
|
'condado:entity-tasks',
|
||||||
JSON.stringify([
|
JSON.stringify([
|
||||||
@@ -84,7 +99,14 @@ describe('tasksApi', () => {
|
|||||||
])
|
])
|
||||||
)
|
)
|
||||||
|
|
||||||
await expect(getTasksByEntity('entity-1')).resolves.toEqual([taskOne])
|
await expect(getTasksByEntity('entity-1')).resolves.toEqual([
|
||||||
|
taskOne,
|
||||||
|
{
|
||||||
|
...taskOne,
|
||||||
|
id: 'task-3',
|
||||||
|
active: false,
|
||||||
|
},
|
||||||
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should_storeActiveTaskByDefault_when_createTaskCalled', async () => {
|
it('should_storeActiveTaskByDefault_when_createTaskCalled', async () => {
|
||||||
@@ -185,4 +207,68 @@ describe('tasksApi', () => {
|
|||||||
|
|
||||||
expect(JSON.parse(localStorage.getItem('condado:entity-tasks') ?? '[]')).toEqual([taskTwo])
|
expect(JSON.parse(localStorage.getItem('condado:entity-tasks') ?? '[]')).toEqual([taskTwo])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should_buildDeterministicPrompt_when_buildTaskPreviewPromptCalled', () => {
|
||||||
|
expect(buildTaskPreviewPrompt(entity, previewTask)).toEqual(`You are preparing a test email message for a scheduled task in Condado Abaixo da Media SA.
|
||||||
|
|
||||||
|
ENTITY DETAILS
|
||||||
|
- Entity ID: entity-1
|
||||||
|
- Name: Milton Fiscal
|
||||||
|
- Email: milton@condado.test
|
||||||
|
- Job Title: Director of Ceremonial Logistics
|
||||||
|
- Personality: Rigidly formal but secretly obsessed with office snacks.
|
||||||
|
- Entity Schedule Cron: 0 9 * * 1-5
|
||||||
|
- Context Window Days: 7
|
||||||
|
- Active: true
|
||||||
|
|
||||||
|
TASK DETAILS
|
||||||
|
- Task Name: Snack Escalation Briefing
|
||||||
|
- Task Prompt: Draft an absurdly official update about disappearing crackers.
|
||||||
|
- Task Schedule Cron: 15 10 * * 2
|
||||||
|
- Email Lookback: Last week
|
||||||
|
|
||||||
|
INSTRUCTIONS
|
||||||
|
- Write exactly one email message.
|
||||||
|
- Use an extremely formal corporate tone.
|
||||||
|
- Keep the content casual, mundane, and slightly nonsensical.
|
||||||
|
- Reflect the entity personality and task prompt faithfully.
|
||||||
|
- Output plain text only with no markdown fences.`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should_callOllamaGenerateEndpoint_when_generateTaskPreviewCalled', async () => {
|
||||||
|
const fetchSpy = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ response: 'SUBJECT: Memo\nBODY:\nPlease secure the crackers.' }),
|
||||||
|
})
|
||||||
|
vi.stubGlobal('fetch', fetchSpy)
|
||||||
|
|
||||||
|
await expect(generateTaskPreview({ entity, task: previewTask })).resolves.toBe(
|
||||||
|
'SUBJECT: Memo\nBODY:\nPlease secure the crackers.'
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(fetchSpy).toHaveBeenCalledWith('http://localhost:11434/api/generate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: 'gemma3:4b',
|
||||||
|
prompt: buildTaskPreviewPrompt(entity, previewTask),
|
||||||
|
stream: false,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should_throwReadableError_when_ollamaRequestFails', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 503,
|
||||||
|
json: async () => ({ error: 'model temporarily unavailable' }),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
await expect(generateTaskPreview({ entity, task: previewTask })).rejects.toThrow(
|
||||||
|
'Unable to generate a test message from the local model. model temporarily unavailable'
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@@ -40,6 +40,10 @@ const mockEntity = {
|
|||||||
describe('CreateTaskPage', () => {
|
describe('CreateTaskPage', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(tasksApi.getEmailLookbackLabel).mockReturnValue('Last week')
|
vi.mocked(tasksApi.getEmailLookbackLabel).mockReturnValue('Last week')
|
||||||
|
vi.mocked(tasksApi.buildTaskPreviewPrompt).mockImplementation(
|
||||||
|
(entity, task) =>
|
||||||
|
`PROMPT FOR ${entity.name}: ${task.name} | ${task.prompt} | ${task.scheduleCron} | ${task.emailLookback}`
|
||||||
|
)
|
||||||
vi.mocked(entitiesApi.getEntity).mockResolvedValue(mockEntity)
|
vi.mocked(entitiesApi.getEntity).mockResolvedValue(mockEntity)
|
||||||
mockNavigate.mockClear()
|
mockNavigate.mockClear()
|
||||||
})
|
})
|
||||||
@@ -98,11 +102,17 @@ describe('CreateTaskPage', () => {
|
|||||||
fireEvent.click(screen.getByRole('button', { name: /Weekdays/i }))
|
fireEvent.click(screen.getByRole('button', { name: /Weekdays/i }))
|
||||||
fireEvent.change(screen.getByLabelText(/^Hour$/i), { target: { value: '8' } })
|
fireEvent.change(screen.getByLabelText(/^Hour$/i), { target: { value: '8' } })
|
||||||
|
|
||||||
|
expect(screen.getByText(/Final Prompt/i)).toBeInTheDocument()
|
||||||
|
expect(
|
||||||
|
screen.getByText('PROMPT FOR Entity A: Morning Blast | Talk about coffee | 0 8 * * 1-5 | last_week')
|
||||||
|
).toBeInTheDocument()
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /generate test message/i }))
|
fireEvent.click(screen.getByRole('button', { name: /generate test message/i }))
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(tasksApi.generateTaskPreview).toHaveBeenCalled()
|
expect(tasksApi.generateTaskPreview).toHaveBeenCalled()
|
||||||
expect(vi.mocked(tasksApi.generateTaskPreview).mock.calls[0][0]).toEqual(
|
expect(vi.mocked(tasksApi.buildTaskPreviewPrompt)).toHaveBeenCalledWith(
|
||||||
|
mockEntity,
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
entityId: 'entity-1',
|
entityId: 'entity-1',
|
||||||
name: 'Morning Blast',
|
name: 'Morning Blast',
|
||||||
@@ -111,6 +121,18 @@ describe('CreateTaskPage', () => {
|
|||||||
emailLookback: 'last_week',
|
emailLookback: 'last_week',
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
expect(vi.mocked(tasksApi.generateTaskPreview).mock.calls[0][0]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
entity: mockEntity,
|
||||||
|
task: expect.objectContaining({
|
||||||
|
entityId: 'entity-1',
|
||||||
|
name: 'Morning Blast',
|
||||||
|
prompt: 'Talk about coffee',
|
||||||
|
scheduleCron: '0 8 * * 1-5',
|
||||||
|
emailLookback: 'last_week',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
)
|
||||||
expect(screen.getByText(/Formal nonsense/i)).toBeInTheDocument()
|
expect(screen.getByText(/Formal nonsense/i)).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -131,6 +153,26 @@ describe('CreateTaskPage', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should_showReadablePreviewError_when_generationFails', async () => {
|
||||||
|
vi.mocked(tasksApi.generateTaskPreview).mockRejectedValue(
|
||||||
|
new Error('Unable to generate a test message from the local model. Connection refused')
|
||||||
|
)
|
||||||
|
|
||||||
|
render(<CreateTaskPage />, { wrapper })
|
||||||
|
await screen.findByRole('link', { name: /back to entity a/i })
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText(/task name/i), { target: { value: 'Morning Blast' } })
|
||||||
|
fireEvent.change(screen.getByLabelText(/task prompt/i), { target: { value: 'Talk about coffee' } })
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /generate test message/i }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Unable to generate a test message from the local model. Connection refused/i)
|
||||||
|
).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('should_applyDailySuggestion_when_suggestionClicked', async () => {
|
it('should_applyDailySuggestion_when_suggestionClicked', async () => {
|
||||||
render(<CreateTaskPage />, { wrapper })
|
render(<CreateTaskPage />, { wrapper })
|
||||||
await screen.findByRole('link', { name: /back to entity a/i })
|
await screen.findByRole('link', { name: /back to entity a/i })
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ describe('EditTaskPage', () => {
|
|||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
vi.mocked(entitiesApi.getEntity).mockResolvedValue(mockEntity)
|
vi.mocked(entitiesApi.getEntity).mockResolvedValue(mockEntity)
|
||||||
vi.mocked(tasksApi.getTask).mockResolvedValue(mockTask)
|
vi.mocked(tasksApi.getTask).mockResolvedValue(mockTask)
|
||||||
|
vi.mocked(tasksApi.buildTaskPreviewPrompt).mockImplementation(
|
||||||
|
(entity, task) =>
|
||||||
|
`PROMPT FOR ${entity.name}: ${task.name} | ${task.prompt} | ${task.scheduleCron} | ${task.emailLookback}`
|
||||||
|
)
|
||||||
vi.mocked(tasksApi.activateTask).mockResolvedValue({ ...mockTask, active: true })
|
vi.mocked(tasksApi.activateTask).mockResolvedValue({ ...mockTask, active: true })
|
||||||
vi.mocked(tasksApi.inactivateTask).mockResolvedValue({ ...mockTask, active: false })
|
vi.mocked(tasksApi.inactivateTask).mockResolvedValue({ ...mockTask, active: false })
|
||||||
vi.mocked(tasksApi.deleteTask).mockResolvedValue(undefined)
|
vi.mocked(tasksApi.deleteTask).mockResolvedValue(undefined)
|
||||||
@@ -112,11 +116,19 @@ describe('EditTaskPage', () => {
|
|||||||
fireEvent.click(screen.getByRole('button', { name: /Weekdays/i }))
|
fireEvent.click(screen.getByRole('button', { name: /Weekdays/i }))
|
||||||
fireEvent.change(screen.getByLabelText(/^Hour$/i), { target: { value: '8' } })
|
fireEvent.change(screen.getByLabelText(/^Hour$/i), { target: { value: '8' } })
|
||||||
|
|
||||||
|
expect(screen.getByText(/Final Prompt/i)).toBeInTheDocument()
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
'PROMPT FOR Entity A: Daily Check-in | Ask about ceremonial coffee | 0 8 * * 1-5 | last_day'
|
||||||
|
)
|
||||||
|
).toBeInTheDocument()
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /generate test message/i }))
|
fireEvent.click(screen.getByRole('button', { name: /generate test message/i }))
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(tasksApi.generateTaskPreview).toHaveBeenCalled()
|
expect(tasksApi.generateTaskPreview).toHaveBeenCalled()
|
||||||
expect(vi.mocked(tasksApi.generateTaskPreview).mock.calls[0][0]).toEqual(
|
expect(vi.mocked(tasksApi.buildTaskPreviewPrompt)).toHaveBeenCalledWith(
|
||||||
|
mockEntity,
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
entityId: 'entity-1',
|
entityId: 'entity-1',
|
||||||
name: 'Daily Check-in',
|
name: 'Daily Check-in',
|
||||||
@@ -125,6 +137,18 @@ describe('EditTaskPage', () => {
|
|||||||
emailLookback: 'last_day',
|
emailLookback: 'last_day',
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
expect(vi.mocked(tasksApi.generateTaskPreview).mock.calls[0][0]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
entity: mockEntity,
|
||||||
|
task: expect.objectContaining({
|
||||||
|
entityId: 'entity-1',
|
||||||
|
name: 'Daily Check-in',
|
||||||
|
prompt: 'Ask about ceremonial coffee',
|
||||||
|
scheduleCron: '0 8 * * 1-5',
|
||||||
|
emailLookback: 'last_day',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
)
|
||||||
expect(screen.getByText(/Formal nonsense/i)).toBeInTheDocument()
|
expect(screen.getByText(/Formal nonsense/i)).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -145,6 +169,23 @@ describe('EditTaskPage', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should_showReadablePreviewError_when_generationFails', async () => {
|
||||||
|
vi.mocked(tasksApi.generateTaskPreview).mockRejectedValue(
|
||||||
|
new Error('Unable to generate a test message from the local model. Connection refused')
|
||||||
|
)
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
await screen.findByRole('link', { name: /back to entity a/i })
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /generate test message/i }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Unable to generate a test message from the local model. Connection refused/i)
|
||||||
|
).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('should_renderActivateButton_when_taskIsInactive', async () => {
|
it('should_renderActivateButton_when_taskIsInactive', async () => {
|
||||||
renderPage({ task: { ...mockTask, active: false } })
|
renderPage({ task: { ...mockTask, active: false } })
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
|
import type { VirtualEntityResponse } from './entitiesApi'
|
||||||
|
|
||||||
const STORAGE_KEY = 'condado:entity-tasks'
|
const STORAGE_KEY = 'condado:entity-tasks'
|
||||||
|
const OLLAMA_GENERATE_URL = 'http://localhost:11434/api/generate'
|
||||||
|
const OLLAMA_MODEL = 'gemma3:4b'
|
||||||
|
|
||||||
export type EmailLookback = 'last_day' | 'last_week' | 'last_month'
|
export type EmailLookback = 'last_day' | 'last_week' | 'last_month'
|
||||||
|
|
||||||
@@ -23,6 +27,11 @@ export interface EntityTaskCreateDto {
|
|||||||
|
|
||||||
export type EntityTaskUpdateDto = EntityTaskCreateDto
|
export type EntityTaskUpdateDto = EntityTaskCreateDto
|
||||||
|
|
||||||
|
export interface TaskPreviewRequest {
|
||||||
|
entity: VirtualEntityResponse
|
||||||
|
task: EntityTaskCreateDto
|
||||||
|
}
|
||||||
|
|
||||||
function readTasks(): EntityTaskResponse[] {
|
function readTasks(): EntityTaskResponse[] {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY)
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
if (!raw) return []
|
if (!raw) return []
|
||||||
@@ -49,22 +58,102 @@ export function getEmailLookbackLabel(value: EmailLookback): string {
|
|||||||
return 'Last week'
|
return 'Last week'
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Simulates a task preview generated from the configured prompt. */
|
function getEntityValue(value: string | null | undefined): string {
|
||||||
export async function generateTaskPreview(data: EntityTaskCreateDto): Promise<string> {
|
return value && value.trim().length > 0 ? value : 'Not provided'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readOllamaError(response: Response): Promise<string> {
|
||||||
|
try {
|
||||||
|
const data = (await response.json()) as { error?: string }
|
||||||
|
if (data.error?.trim()) {
|
||||||
|
return data.error
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return `Request failed with status ${response.status}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Request failed with status ${response.status}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReadablePreviewError(error: unknown): string {
|
||||||
|
if (error instanceof Error && error.message.trim().length > 0) {
|
||||||
|
return error.message
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Unknown error'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTaskPreviewPrompt(
|
||||||
|
entity: VirtualEntityResponse,
|
||||||
|
task: EntityTaskCreateDto
|
||||||
|
): string {
|
||||||
return [
|
return [
|
||||||
`SUBJECT: Internal Alignment Update - ${data.name}`,
|
'You are preparing a test email message for a scheduled task in Condado Abaixo da Media SA.',
|
||||||
'BODY:',
|
|
||||||
`Dear Team,`,
|
|
||||||
'',
|
'',
|
||||||
`In strict accordance with our communication standards, this message was generated from the prompt: "${data.prompt}".`,
|
'ENTITY DETAILS',
|
||||||
`Context window considered: ${getEmailLookbackLabel(data.emailLookback)}.`,
|
`- Entity ID: ${entity.id}`,
|
||||||
'Operational interpretation: please proceed casually, but with ceremonial seriousness.',
|
`- Name: ${entity.name}`,
|
||||||
|
`- Email: ${entity.email}`,
|
||||||
|
`- Job Title: ${entity.jobTitle}`,
|
||||||
|
`- Personality: ${getEntityValue(entity.personality)}`,
|
||||||
|
`- Entity Schedule Cron: ${getEntityValue(entity.scheduleCron)}`,
|
||||||
|
`- Context Window Days: ${entity.contextWindowDays}`,
|
||||||
|
`- Active: ${String(entity.active)}`,
|
||||||
'',
|
'',
|
||||||
'Regards,',
|
'TASK DETAILS',
|
||||||
'Automated Task Preview',
|
`- Task Name: ${task.name}`,
|
||||||
|
`- Task Prompt: ${task.prompt}`,
|
||||||
|
`- Task Schedule Cron: ${task.scheduleCron}`,
|
||||||
|
`- Email Lookback: ${getEmailLookbackLabel(task.emailLookback)}`,
|
||||||
|
'',
|
||||||
|
'INSTRUCTIONS',
|
||||||
|
'- Write exactly one email message.',
|
||||||
|
'- Use an extremely formal corporate tone.',
|
||||||
|
'- Keep the content casual, mundane, and slightly nonsensical.',
|
||||||
|
'- Reflect the entity personality and task prompt faithfully.',
|
||||||
|
'- Output plain text only with no markdown fences.',
|
||||||
].join('\n')
|
].join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Generates a task preview via the local Ollama model. */
|
||||||
|
export async function generateTaskPreview({ entity, task }: TaskPreviewRequest): Promise<string> {
|
||||||
|
const prompt = buildTaskPreviewPrompt(entity, task)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(OLLAMA_GENERATE_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: OLLAMA_MODEL,
|
||||||
|
prompt,
|
||||||
|
stream: false,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = await readOllamaError(response)
|
||||||
|
throw new Error(`Unable to generate a test message from the local model. ${message}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as { response?: string }
|
||||||
|
const generatedMessage = data.response?.trim()
|
||||||
|
|
||||||
|
if (!generatedMessage) {
|
||||||
|
throw new Error('Unable to generate a test message from the local model. The model returned an empty response.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return generatedMessage
|
||||||
|
} catch (error) {
|
||||||
|
const message = getReadablePreviewError(error)
|
||||||
|
|
||||||
|
if (message.startsWith('Unable to generate a test message from the local model.')) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unable to generate a test message from the local model. ${message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Returns all scheduled tasks currently configured in local storage. */
|
/** Returns all scheduled tasks currently configured in local storage. */
|
||||||
export async function getAllTasks(): Promise<EntityTaskResponse[]> {
|
export async function getAllTasks(): Promise<EntityTaskResponse[]> {
|
||||||
return readTasks().filter((task) => task.active)
|
return readTasks().filter((task) => task.active)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|||||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||||
import { getEntity } from '../api/entitiesApi'
|
import { getEntity } from '../api/entitiesApi'
|
||||||
import {
|
import {
|
||||||
|
buildTaskPreviewPrompt,
|
||||||
createTask,
|
createTask,
|
||||||
generateTaskPreview,
|
generateTaskPreview,
|
||||||
type EmailLookback,
|
type EmailLookback,
|
||||||
@@ -83,6 +84,7 @@ export default function CreateTaskPage() {
|
|||||||
const [cronParts, setCronParts] = useState<CronParts>(DEFAULT_CRON_PARTS)
|
const [cronParts, setCronParts] = useState<CronParts>(DEFAULT_CRON_PARTS)
|
||||||
const [taskForm, setTaskForm] = useState<TaskFormState>(DEFAULT_TASK_FORM)
|
const [taskForm, setTaskForm] = useState<TaskFormState>(DEFAULT_TASK_FORM)
|
||||||
const [preview, setPreview] = useState('')
|
const [preview, setPreview] = useState('')
|
||||||
|
const [previewError, setPreviewError] = useState('')
|
||||||
|
|
||||||
const { data: entity, isLoading } = useQuery({
|
const { data: entity, isLoading } = useQuery({
|
||||||
queryKey: ['entity', entityId],
|
queryKey: ['entity', entityId],
|
||||||
@@ -100,7 +102,18 @@ export default function CreateTaskPage() {
|
|||||||
|
|
||||||
const previewMutation = useMutation({
|
const previewMutation = useMutation({
|
||||||
mutationFn: generateTaskPreview,
|
mutationFn: generateTaskPreview,
|
||||||
|
onMutate: () => {
|
||||||
|
setPreview('')
|
||||||
|
setPreviewError('')
|
||||||
|
},
|
||||||
onSuccess: (value) => setPreview(value),
|
onSuccess: (value) => setPreview(value),
|
||||||
|
onError: (error) => {
|
||||||
|
setPreviewError(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Unable to generate a test message from the local model.'
|
||||||
|
)
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const canSubmit = useMemo(() => {
|
const canSubmit = useMemo(() => {
|
||||||
@@ -108,6 +121,25 @@ export default function CreateTaskPage() {
|
|||||||
return Boolean(taskForm.name.trim() && taskForm.prompt.trim() && hasFilledCronParts)
|
return Boolean(taskForm.name.trim() && taskForm.prompt.trim() && hasFilledCronParts)
|
||||||
}, [cronParts, taskForm.name, taskForm.prompt])
|
}, [cronParts, taskForm.name, taskForm.prompt])
|
||||||
|
|
||||||
|
const currentTask = useMemo(
|
||||||
|
() => ({
|
||||||
|
entityId,
|
||||||
|
name: taskForm.name,
|
||||||
|
prompt: taskForm.prompt,
|
||||||
|
scheduleCron: taskForm.scheduleCron,
|
||||||
|
emailLookback: taskForm.emailLookback,
|
||||||
|
}),
|
||||||
|
[entityId, taskForm.emailLookback, taskForm.name, taskForm.prompt, taskForm.scheduleCron]
|
||||||
|
)
|
||||||
|
|
||||||
|
const finalPrompt = useMemo(() => {
|
||||||
|
if (!entity) {
|
||||||
|
return 'Entity details unavailable.'
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildTaskPreviewPrompt(entity, currentTask)
|
||||||
|
}, [currentTask, entity])
|
||||||
|
|
||||||
const applyCronParts = (nextCronParts: CronParts) => {
|
const applyCronParts = (nextCronParts: CronParts) => {
|
||||||
setCronParts(nextCronParts)
|
setCronParts(nextCronParts)
|
||||||
setTaskForm((prev) => ({ ...prev, scheduleCron: buildCron(nextCronParts) }))
|
setTaskForm((prev) => ({ ...prev, scheduleCron: buildCron(nextCronParts) }))
|
||||||
@@ -313,13 +345,8 @@ export default function CreateTaskPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!canSubmit) return
|
if (!canSubmit) return
|
||||||
previewMutation.mutate({
|
if (!entity) return
|
||||||
entityId,
|
previewMutation.mutate({ entity, task: currentTask })
|
||||||
name: taskForm.name,
|
|
||||||
prompt: taskForm.prompt,
|
|
||||||
scheduleCron: taskForm.scheduleCron,
|
|
||||||
emailLookback: taskForm.emailLookback,
|
|
||||||
})
|
|
||||||
}}
|
}}
|
||||||
disabled={!canSubmit || previewMutation.isPending}
|
disabled={!canSubmit || previewMutation.isPending}
|
||||||
className="rounded-md border border-cyan-500 px-3 py-2 text-sm font-medium text-cyan-300 hover:bg-cyan-500/10 disabled:opacity-50"
|
className="rounded-md border border-cyan-500 px-3 py-2 text-sm font-medium text-cyan-300 hover:bg-cyan-500/10 disabled:opacity-50"
|
||||||
@@ -327,10 +354,30 @@ export default function CreateTaskPage() {
|
|||||||
{previewMutation.isPending ? 'Generating…' : 'Generate Test Message'}
|
{previewMutation.isPending ? 'Generating…' : 'Generate Test Message'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-slate-400">
|
||||||
|
Final Prompt
|
||||||
|
</p>
|
||||||
|
<pre className="mt-2 whitespace-pre-wrap rounded-md bg-slate-950 p-4 text-xs text-slate-200">
|
||||||
|
{finalPrompt}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{previewError && (
|
||||||
|
<p role="alert" className="mt-4 text-sm text-red-300">
|
||||||
|
{previewError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{preview && (
|
{preview && (
|
||||||
<pre className="mt-4 whitespace-pre-wrap rounded-md bg-slate-950 p-4 text-xs text-slate-200">
|
<div className="mt-4">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-slate-400">
|
||||||
|
Generated Message
|
||||||
|
</p>
|
||||||
|
<pre className="mt-2 whitespace-pre-wrap rounded-md bg-slate-950 p-4 text-xs text-slate-200">
|
||||||
{preview}
|
{preview}
|
||||||
</pre>
|
</pre>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Link, useNavigate, useParams } from 'react-router-dom'
|
|||||||
import { getEntity } from '../api/entitiesApi'
|
import { getEntity } from '../api/entitiesApi'
|
||||||
import {
|
import {
|
||||||
activateTask,
|
activateTask,
|
||||||
|
buildTaskPreviewPrompt,
|
||||||
deleteTask,
|
deleteTask,
|
||||||
generateTaskPreview,
|
generateTaskPreview,
|
||||||
getTask,
|
getTask,
|
||||||
@@ -115,6 +116,7 @@ export default function EditTaskPage() {
|
|||||||
const [cronParts, setCronParts] = useState<CronParts>(DEFAULT_CRON_PARTS)
|
const [cronParts, setCronParts] = useState<CronParts>(DEFAULT_CRON_PARTS)
|
||||||
const [taskForm, setTaskForm] = useState<TaskFormState>(DEFAULT_TASK_FORM)
|
const [taskForm, setTaskForm] = useState<TaskFormState>(DEFAULT_TASK_FORM)
|
||||||
const [preview, setPreview] = useState('')
|
const [preview, setPreview] = useState('')
|
||||||
|
const [previewError, setPreviewError] = useState('')
|
||||||
|
|
||||||
const { data: entity, isLoading: isLoadingEntity } = useQuery({
|
const { data: entity, isLoading: isLoadingEntity } = useQuery({
|
||||||
queryKey: ['entity', entityId],
|
queryKey: ['entity', entityId],
|
||||||
@@ -184,7 +186,18 @@ export default function EditTaskPage() {
|
|||||||
|
|
||||||
const previewMutation = useMutation({
|
const previewMutation = useMutation({
|
||||||
mutationFn: generateTaskPreview,
|
mutationFn: generateTaskPreview,
|
||||||
|
onMutate: () => {
|
||||||
|
setPreview('')
|
||||||
|
setPreviewError('')
|
||||||
|
},
|
||||||
onSuccess: (value) => setPreview(value),
|
onSuccess: (value) => setPreview(value),
|
||||||
|
onError: (error) => {
|
||||||
|
setPreviewError(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Unable to generate a test message from the local model.'
|
||||||
|
)
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const canSubmit = useMemo(() => {
|
const canSubmit = useMemo(() => {
|
||||||
@@ -192,6 +205,25 @@ export default function EditTaskPage() {
|
|||||||
return Boolean(taskForm.name.trim() && taskForm.prompt.trim() && hasFilledCronParts)
|
return Boolean(taskForm.name.trim() && taskForm.prompt.trim() && hasFilledCronParts)
|
||||||
}, [cronParts, taskForm.name, taskForm.prompt])
|
}, [cronParts, taskForm.name, taskForm.prompt])
|
||||||
|
|
||||||
|
const currentTask = useMemo(
|
||||||
|
() => ({
|
||||||
|
entityId,
|
||||||
|
name: taskForm.name,
|
||||||
|
prompt: taskForm.prompt,
|
||||||
|
scheduleCron: taskForm.scheduleCron,
|
||||||
|
emailLookback: taskForm.emailLookback,
|
||||||
|
}),
|
||||||
|
[entityId, taskForm.emailLookback, taskForm.name, taskForm.prompt, taskForm.scheduleCron]
|
||||||
|
)
|
||||||
|
|
||||||
|
const finalPrompt = useMemo(() => {
|
||||||
|
if (!entity) {
|
||||||
|
return 'Entity details unavailable.'
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildTaskPreviewPrompt(entity, currentTask)
|
||||||
|
}, [currentTask, entity])
|
||||||
|
|
||||||
const applyCronParts = (nextCronParts: CronParts) => {
|
const applyCronParts = (nextCronParts: CronParts) => {
|
||||||
setCronParts(nextCronParts)
|
setCronParts(nextCronParts)
|
||||||
setTaskForm((prev) => ({ ...prev, scheduleCron: buildCron(nextCronParts) }))
|
setTaskForm((prev) => ({ ...prev, scheduleCron: buildCron(nextCronParts) }))
|
||||||
@@ -402,13 +434,8 @@ export default function EditTaskPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!canSubmit) return
|
if (!canSubmit) return
|
||||||
previewMutation.mutate({
|
if (!entity) return
|
||||||
entityId,
|
previewMutation.mutate({ entity, task: currentTask })
|
||||||
name: taskForm.name,
|
|
||||||
prompt: taskForm.prompt,
|
|
||||||
scheduleCron: taskForm.scheduleCron,
|
|
||||||
emailLookback: taskForm.emailLookback,
|
|
||||||
})
|
|
||||||
}}
|
}}
|
||||||
disabled={!canSubmit || previewMutation.isPending}
|
disabled={!canSubmit || previewMutation.isPending}
|
||||||
className="rounded-md border border-cyan-500 px-3 py-2 text-sm font-medium text-cyan-300 hover:bg-cyan-500/10 disabled:opacity-50"
|
className="rounded-md border border-cyan-500 px-3 py-2 text-sm font-medium text-cyan-300 hover:bg-cyan-500/10 disabled:opacity-50"
|
||||||
@@ -416,10 +443,30 @@ export default function EditTaskPage() {
|
|||||||
{previewMutation.isPending ? 'Generating…' : 'Generate Test Message'}
|
{previewMutation.isPending ? 'Generating…' : 'Generate Test Message'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-slate-400">
|
||||||
|
Final Prompt
|
||||||
|
</p>
|
||||||
|
<pre className="mt-2 whitespace-pre-wrap rounded-md bg-slate-950 p-4 text-xs text-slate-200">
|
||||||
|
{finalPrompt}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{previewError && (
|
||||||
|
<p role="alert" className="mt-4 text-sm text-red-300">
|
||||||
|
{previewError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{preview && (
|
{preview && (
|
||||||
<pre className="mt-4 whitespace-pre-wrap rounded-md bg-slate-950 p-4 text-xs text-slate-200">
|
<div className="mt-4">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-slate-400">
|
||||||
|
Generated Message
|
||||||
|
</p>
|
||||||
|
<pre className="mt-2 whitespace-pre-wrap rounded-md bg-slate-950 p-4 text-xs text-slate-200">
|
||||||
{preview}
|
{preview}
|
||||||
</pre>
|
</pre>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user