feat(backend): persist tasks and generated message history
- add EntityTask domain and CRUD API backed by PostgreSQL - relate generated messages directly to tasks and delete on task removal - move preview generation to backend Llama endpoint - migrate frontend task APIs from localStorage to backend endpoints - update tests and CLAUDE rules for backend-owned LLM/persistence
This commit is contained in:
@@ -1,11 +1,14 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import apiClient from '@/api/apiClient'
|
||||
import {
|
||||
activateTask,
|
||||
buildTaskPreviewPrompt,
|
||||
createTask,
|
||||
deleteTaskGeneratedMessage,
|
||||
deleteTask,
|
||||
generateTaskPreview,
|
||||
getAllTasks,
|
||||
getTaskGeneratedMessages,
|
||||
getTask,
|
||||
inactivateTask,
|
||||
getTasksByEntity,
|
||||
@@ -13,6 +16,22 @@ import {
|
||||
type EntityTaskResponse,
|
||||
} from '@/api/tasksApi'
|
||||
|
||||
vi.mock('@/api/apiClient', () => ({
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const mockedApiClient = apiClient as unknown as {
|
||||
get: ReturnType<typeof vi.fn>
|
||||
post: ReturnType<typeof vi.fn>
|
||||
put: ReturnType<typeof vi.fn>
|
||||
delete: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
const taskOne: EntityTaskResponse = {
|
||||
id: 'task-1',
|
||||
entityId: 'entity-1',
|
||||
@@ -57,47 +76,48 @@ const previewTask = {
|
||||
|
||||
describe('tasksApi', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
vi.restoreAllMocks()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should_returnTask_when_getTaskCalledWithExistingId', async () => {
|
||||
localStorage.setItem('condado:entity-tasks', JSON.stringify([taskOne, taskTwo]))
|
||||
mockedApiClient.get.mockResolvedValue({ data: taskOne })
|
||||
|
||||
await expect(getTask('task-1')).resolves.toEqual(taskOne)
|
||||
expect(mockedApiClient.get).toHaveBeenCalledWith('/v1/tasks/task-1')
|
||||
})
|
||||
|
||||
it('should_returnInactiveTask_when_getTaskCalledWithInactiveId', async () => {
|
||||
localStorage.setItem('condado:entity-tasks', JSON.stringify([taskOne, taskTwo]))
|
||||
mockedApiClient.get.mockResolvedValue({ data: taskTwo })
|
||||
|
||||
await expect(getTask('task-2')).resolves.toEqual(taskTwo)
|
||||
expect(mockedApiClient.get).toHaveBeenCalledWith('/v1/tasks/task-2')
|
||||
})
|
||||
|
||||
it('should_returnTasksForEntity_when_getTasksByEntityCalled', async () => {
|
||||
localStorage.setItem('condado:entity-tasks', JSON.stringify([taskOne, taskTwo]))
|
||||
mockedApiClient.get.mockResolvedValue({ data: [taskOne] })
|
||||
|
||||
await expect(getTasksByEntity('entity-1')).resolves.toEqual([taskOne])
|
||||
expect(mockedApiClient.get).toHaveBeenCalledWith('/v1/tasks/entity/entity-1')
|
||||
})
|
||||
|
||||
it('should_hideInactiveTasks_when_getAllTasksCalled', async () => {
|
||||
localStorage.setItem('condado:entity-tasks', JSON.stringify([taskOne, taskTwo]))
|
||||
mockedApiClient.get.mockResolvedValue({ data: [taskOne] })
|
||||
|
||||
await expect(getAllTasks()).resolves.toEqual([taskOne])
|
||||
expect(mockedApiClient.get).toHaveBeenCalledWith('/v1/tasks')
|
||||
})
|
||||
|
||||
it('should_returnInactiveTasksForEntity_when_getTasksByEntityCalled', async () => {
|
||||
localStorage.setItem(
|
||||
'condado:entity-tasks',
|
||||
JSON.stringify([
|
||||
mockedApiClient.get.mockResolvedValue({
|
||||
data: [
|
||||
taskOne,
|
||||
taskTwo,
|
||||
{
|
||||
...taskOne,
|
||||
id: 'task-3',
|
||||
active: false,
|
||||
},
|
||||
])
|
||||
)
|
||||
],
|
||||
})
|
||||
|
||||
await expect(getTasksByEntity('entity-1')).resolves.toEqual([
|
||||
taskOne,
|
||||
@@ -110,7 +130,12 @@ describe('tasksApi', () => {
|
||||
})
|
||||
|
||||
it('should_storeActiveTaskByDefault_when_createTaskCalled', async () => {
|
||||
vi.spyOn(crypto, 'randomUUID').mockReturnValue('00000000-0000-0000-0000-000000000003')
|
||||
mockedApiClient.post.mockResolvedValue({
|
||||
data: {
|
||||
...taskOne,
|
||||
id: '00000000-0000-0000-0000-000000000003',
|
||||
},
|
||||
})
|
||||
|
||||
const createdTask = await createTask({
|
||||
entityId: 'entity-1',
|
||||
@@ -126,16 +151,25 @@ describe('tasksApi', () => {
|
||||
active: true,
|
||||
})
|
||||
)
|
||||
expect(JSON.parse(localStorage.getItem('condado:entity-tasks') ?? '[]')).toEqual([
|
||||
expect.objectContaining({
|
||||
id: '00000000-0000-0000-0000-000000000003',
|
||||
active: true,
|
||||
}),
|
||||
])
|
||||
expect(mockedApiClient.post).toHaveBeenCalledWith('/v1/tasks', {
|
||||
entityId: 'entity-1',
|
||||
name: 'Daily Check-in',
|
||||
prompt: 'Ask about ceremonial coffee',
|
||||
scheduleCron: '0 8 * * 1-5',
|
||||
emailLookback: 'last_day',
|
||||
})
|
||||
})
|
||||
|
||||
it('should_updateStoredTask_when_updateTaskCalled', async () => {
|
||||
localStorage.setItem('condado:entity-tasks', JSON.stringify([taskOne, taskTwo]))
|
||||
mockedApiClient.put.mockResolvedValue({
|
||||
data: {
|
||||
...taskOne,
|
||||
name: 'Daily Check-in',
|
||||
prompt: 'Ask about ceremonial coffee',
|
||||
scheduleCron: '0 8 * * 1-5',
|
||||
emailLookback: 'last_day',
|
||||
},
|
||||
})
|
||||
|
||||
const updatedTask = await updateTask('task-1', {
|
||||
entityId: 'entity-1',
|
||||
@@ -152,20 +186,17 @@ describe('tasksApi', () => {
|
||||
scheduleCron: '0 8 * * 1-5',
|
||||
emailLookback: 'last_day',
|
||||
})
|
||||
expect(JSON.parse(localStorage.getItem('condado:entity-tasks') ?? '[]')).toEqual([
|
||||
{
|
||||
...taskOne,
|
||||
name: 'Daily Check-in',
|
||||
prompt: 'Ask about ceremonial coffee',
|
||||
scheduleCron: '0 8 * * 1-5',
|
||||
emailLookback: 'last_day',
|
||||
},
|
||||
taskTwo,
|
||||
])
|
||||
expect(mockedApiClient.put).toHaveBeenCalledWith('/v1/tasks/task-1', {
|
||||
entityId: 'entity-1',
|
||||
name: 'Daily Check-in',
|
||||
prompt: 'Ask about ceremonial coffee',
|
||||
scheduleCron: '0 8 * * 1-5',
|
||||
emailLookback: 'last_day',
|
||||
})
|
||||
})
|
||||
|
||||
it('should_markTaskInactive_when_inactivateTaskCalled', async () => {
|
||||
localStorage.setItem('condado:entity-tasks', JSON.stringify([taskOne, taskTwo]))
|
||||
mockedApiClient.post.mockResolvedValue({ data: { ...taskOne, active: false } })
|
||||
|
||||
const updatedTask = await inactivateTask('task-1')
|
||||
|
||||
@@ -173,17 +204,11 @@ describe('tasksApi', () => {
|
||||
...taskOne,
|
||||
active: false,
|
||||
})
|
||||
expect(JSON.parse(localStorage.getItem('condado:entity-tasks') ?? '[]')).toEqual([
|
||||
{
|
||||
...taskOne,
|
||||
active: false,
|
||||
},
|
||||
taskTwo,
|
||||
])
|
||||
expect(mockedApiClient.post).toHaveBeenCalledWith('/v1/tasks/task-1/inactivate')
|
||||
})
|
||||
|
||||
it('should_markTaskActive_when_activateTaskCalled', async () => {
|
||||
localStorage.setItem('condado:entity-tasks', JSON.stringify([taskOne, taskTwo]))
|
||||
mockedApiClient.post.mockResolvedValue({ data: { ...taskTwo, active: true } })
|
||||
|
||||
const updatedTask = await activateTask('task-2')
|
||||
|
||||
@@ -191,21 +216,51 @@ describe('tasksApi', () => {
|
||||
...taskTwo,
|
||||
active: true,
|
||||
})
|
||||
expect(JSON.parse(localStorage.getItem('condado:entity-tasks') ?? '[]')).toEqual([
|
||||
taskOne,
|
||||
{
|
||||
...taskTwo,
|
||||
active: true,
|
||||
},
|
||||
])
|
||||
expect(mockedApiClient.post).toHaveBeenCalledWith('/v1/tasks/task-2/activate')
|
||||
})
|
||||
|
||||
it('should_removeTask_when_deleteTaskCalled', async () => {
|
||||
localStorage.setItem('condado:entity-tasks', JSON.stringify([taskOne, taskTwo]))
|
||||
mockedApiClient.delete.mockResolvedValue({ data: {} })
|
||||
|
||||
await deleteTask('task-1')
|
||||
|
||||
expect(JSON.parse(localStorage.getItem('condado:entity-tasks') ?? '[]')).toEqual([taskTwo])
|
||||
expect(mockedApiClient.delete).toHaveBeenCalledWith('/v1/tasks/task-1')
|
||||
})
|
||||
|
||||
it('should_callBackendHistoryEndpoint_when_getTaskGeneratedMessagesCalled', async () => {
|
||||
mockedApiClient.get.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: 'message-1',
|
||||
taskId: 'task-1',
|
||||
label: 'Message #1',
|
||||
content: 'SUBJECT: One\nBODY:\nFirst message',
|
||||
createdAt: '2026-03-27T10:00:00Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await expect(getTaskGeneratedMessages('task-1')).resolves.toEqual([
|
||||
{
|
||||
id: 'message-1',
|
||||
taskId: 'task-1',
|
||||
label: 'Message #1',
|
||||
content: 'SUBJECT: One\nBODY:\nFirst message',
|
||||
createdAt: '2026-03-27T10:00:00Z',
|
||||
},
|
||||
])
|
||||
|
||||
expect(mockedApiClient.get).toHaveBeenCalledWith('/v1/tasks/task-1/generated-messages')
|
||||
})
|
||||
|
||||
it('should_deleteGeneratedMessageFromHistory_when_deleteTaskGeneratedMessageCalled', async () => {
|
||||
mockedApiClient.delete.mockResolvedValue({ data: {} })
|
||||
|
||||
await deleteTaskGeneratedMessage('task-1', 'message-1')
|
||||
|
||||
expect(mockedApiClient.delete).toHaveBeenCalledWith(
|
||||
'/v1/tasks/task-1/generated-messages/message-1'
|
||||
)
|
||||
})
|
||||
|
||||
it('should_buildDeterministicPrompt_when_buildTaskPreviewPromptCalled', () => {
|
||||
@@ -236,40 +291,32 @@ INSTRUCTIONS
|
||||
- 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.' }),
|
||||
it('should_callBackendPreviewEndpoint_when_generateTaskPreviewCalled', async () => {
|
||||
mockedApiClient.post.mockResolvedValue({
|
||||
data: {
|
||||
id: 'message-1',
|
||||
taskId: 'task-1',
|
||||
label: 'Message #1',
|
||||
content: 'SUBJECT: Memo\nBODY:\nPlease secure the crackers.',
|
||||
createdAt: '2026-03-27T10:00:00Z',
|
||||
},
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchSpy)
|
||||
|
||||
await expect(generateTaskPreview({ entity, task: previewTask })).resolves.toBe(
|
||||
await expect(generateTaskPreview('task-1', { 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,
|
||||
}),
|
||||
expect(mockedApiClient.post).toHaveBeenCalledWith('/v1/tasks/task-1/generated-messages/generate', {
|
||||
entity,
|
||||
task: previewTask,
|
||||
})
|
||||
})
|
||||
|
||||
it('should_throwReadableError_when_ollamaRequestFails', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 503,
|
||||
json: async () => ({ error: 'model temporarily unavailable' }),
|
||||
})
|
||||
)
|
||||
it('should_throwErrorFromBackend_when_generateTaskPreviewFails', async () => {
|
||||
mockedApiClient.post.mockRejectedValue(new Error('backend unavailable'))
|
||||
|
||||
await expect(generateTaskPreview({ entity, task: previewTask })).rejects.toThrow(
|
||||
'Unable to generate a test message from the local model. model temporarily unavailable'
|
||||
await expect(generateTaskPreview('task-1', { entity, task: previewTask })).rejects.toThrow(
|
||||
'backend unavailable'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -61,9 +61,18 @@ const mockTask = {
|
||||
createdAt: '2026-03-26T10:00:00Z',
|
||||
}
|
||||
|
||||
let persistedHistory: Array<{
|
||||
id: string
|
||||
taskId: string
|
||||
label: string
|
||||
content: string
|
||||
createdAt: string
|
||||
}> = []
|
||||
|
||||
describe('EditTaskPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
persistedHistory = []
|
||||
vi.mocked(entitiesApi.getEntity).mockResolvedValue(mockEntity)
|
||||
vi.mocked(tasksApi.getTask).mockResolvedValue(mockTask)
|
||||
vi.mocked(tasksApi.buildTaskPreviewPrompt).mockImplementation(
|
||||
@@ -73,6 +82,10 @@ describe('EditTaskPage', () => {
|
||||
vi.mocked(tasksApi.activateTask).mockResolvedValue({ ...mockTask, active: true })
|
||||
vi.mocked(tasksApi.inactivateTask).mockResolvedValue({ ...mockTask, active: false })
|
||||
vi.mocked(tasksApi.deleteTask).mockResolvedValue(undefined)
|
||||
vi.mocked(tasksApi.getTaskGeneratedMessages).mockImplementation(async () => persistedHistory)
|
||||
vi.mocked(tasksApi.deleteTaskGeneratedMessage).mockImplementation(async (_taskId, messageId) => {
|
||||
persistedHistory = persistedHistory.filter((message) => message.id !== messageId)
|
||||
})
|
||||
mockNavigate.mockClear()
|
||||
})
|
||||
|
||||
@@ -93,7 +106,18 @@ describe('EditTaskPage', () => {
|
||||
})
|
||||
|
||||
it('should_generatePreviewAndUpdateTask_when_formSubmitted', async () => {
|
||||
vi.mocked(tasksApi.generateTaskPreview).mockResolvedValue('SUBJECT: Preview\nBODY:\nFormal nonsense')
|
||||
vi.mocked(tasksApi.generateTaskPreview).mockImplementation(async () => {
|
||||
const content = 'SUBJECT: Preview\nBODY:\nFormal nonsense'
|
||||
const nextMessage = {
|
||||
id: `message-${persistedHistory.length + 1}`,
|
||||
taskId: 'task-1',
|
||||
label: `Message #${persistedHistory.length + 1}`,
|
||||
content,
|
||||
createdAt: '2026-03-27T12:00:00Z',
|
||||
}
|
||||
persistedHistory = [nextMessage, ...persistedHistory]
|
||||
return content
|
||||
})
|
||||
vi.mocked(tasksApi.updateTask).mockResolvedValue({
|
||||
...mockTask,
|
||||
name: 'Daily Check-in',
|
||||
@@ -137,7 +161,8 @@ describe('EditTaskPage', () => {
|
||||
emailLookback: 'last_day',
|
||||
})
|
||||
)
|
||||
expect(vi.mocked(tasksApi.generateTaskPreview).mock.calls[0][0]).toEqual(
|
||||
expect(vi.mocked(tasksApi.generateTaskPreview).mock.calls[0][0]).toEqual('task-1')
|
||||
expect(vi.mocked(tasksApi.generateTaskPreview).mock.calls[0][1]).toEqual(
|
||||
expect.objectContaining({
|
||||
entity: mockEntity,
|
||||
task: expect.objectContaining({
|
||||
@@ -201,8 +226,34 @@ describe('EditTaskPage', () => {
|
||||
|
||||
it('should_showAndManageGeneratedMessageHistory_when_multipleMessagesGenerated', async () => {
|
||||
vi.mocked(tasksApi.generateTaskPreview)
|
||||
.mockResolvedValueOnce('SUBJECT: First\nBODY:\nFirst output')
|
||||
.mockResolvedValueOnce('SUBJECT: Second\nBODY:\nSecond output')
|
||||
.mockImplementationOnce(async () => {
|
||||
const content = 'SUBJECT: First\nBODY:\nFirst output'
|
||||
persistedHistory = [
|
||||
{
|
||||
id: 'message-1',
|
||||
taskId: 'task-1',
|
||||
label: 'Message #1',
|
||||
content,
|
||||
createdAt: '2026-03-27T12:00:00Z',
|
||||
},
|
||||
...persistedHistory,
|
||||
]
|
||||
return content
|
||||
})
|
||||
.mockImplementationOnce(async () => {
|
||||
const content = 'SUBJECT: Second\nBODY:\nSecond output'
|
||||
persistedHistory = [
|
||||
{
|
||||
id: 'message-2',
|
||||
taskId: 'task-1',
|
||||
label: 'Message #2',
|
||||
content,
|
||||
createdAt: '2026-03-27T12:10:00Z',
|
||||
},
|
||||
...persistedHistory,
|
||||
]
|
||||
return content
|
||||
})
|
||||
|
||||
renderPage()
|
||||
await screen.findByRole('link', { name: /back to entity a/i })
|
||||
@@ -215,7 +266,7 @@ describe('EditTaskPage', () => {
|
||||
fireEvent.click(generateButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Second output/i)).toBeInTheDocument()
|
||||
expect(tasksApi.generateTaskPreview).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
const history = screen.getByRole('list', { name: /generated message history/i })
|
||||
@@ -224,6 +275,9 @@ describe('EditTaskPage', () => {
|
||||
const firstMessageHistoryItem = screen.getByRole('button', { name: /^message #1$/i })
|
||||
const secondMessageHistoryItem = screen.getByRole('button', { name: /^message #2$/i })
|
||||
|
||||
fireEvent.click(secondMessageHistoryItem)
|
||||
expect(screen.getByText(/Second output/i)).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(firstMessageHistoryItem)
|
||||
expect(screen.getByText(/First output/i)).toBeInTheDocument()
|
||||
|
||||
@@ -234,11 +288,29 @@ describe('EditTaskPage', () => {
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(tasksApi.deleteTaskGeneratedMessage).toHaveBeenCalledWith('task-1', 'message-1')
|
||||
expect(firstMessageHistoryItem).not.toBeInTheDocument()
|
||||
expect(secondMessageHistoryItem).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should_loadPersistedGeneratedMessageHistory_when_pageLoads', async () => {
|
||||
persistedHistory = [
|
||||
{
|
||||
id: 'message-1',
|
||||
taskId: 'task-1',
|
||||
label: 'Message #1',
|
||||
content: 'SUBJECT: Persisted\nBODY:\nFrom storage',
|
||||
createdAt: '2026-03-27T12:00:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
renderPage()
|
||||
|
||||
await screen.findByRole('button', { name: /^message #1$/i })
|
||||
expect(screen.getByText(/From storage/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should_renderActivateButton_when_taskIsInactive', async () => {
|
||||
renderPage({ task: { ...mockTask, active: false } })
|
||||
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import type { VirtualEntityResponse } from './entitiesApi'
|
||||
|
||||
const STORAGE_KEY = 'condado:entity-tasks'
|
||||
const OLLAMA_GENERATE_URL = 'http://localhost:11434/api/generate'
|
||||
const OLLAMA_MODEL = 'gemma3:4b'
|
||||
import apiClient from './apiClient'
|
||||
|
||||
export type EmailLookback = 'last_day' | 'last_week' | 'last_month'
|
||||
|
||||
@@ -32,24 +29,12 @@ export interface TaskPreviewRequest {
|
||||
task: EntityTaskCreateDto
|
||||
}
|
||||
|
||||
function readTasks(): EntityTaskResponse[] {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return []
|
||||
|
||||
try {
|
||||
return (JSON.parse(raw) as Array<Omit<EntityTaskResponse, 'active'> & { active?: boolean }>).map(
|
||||
(task) => ({
|
||||
...task,
|
||||
active: task.active ?? true,
|
||||
})
|
||||
)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function writeTasks(tasks: EntityTaskResponse[]): void {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks))
|
||||
export interface GeneratedMessageHistoryItem {
|
||||
id: string
|
||||
taskId: string
|
||||
label: string
|
||||
content: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export function getEmailLookbackLabel(value: EmailLookback): string {
|
||||
@@ -62,27 +47,6 @@ function getEntityValue(value: string | null | undefined): 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
|
||||
@@ -117,132 +81,94 @@ export function buildTaskPreviewPrompt(
|
||||
}
|
||||
|
||||
/** 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}`)
|
||||
}
|
||||
export async function generateTaskPreview(
|
||||
taskId: string,
|
||||
payload: TaskPreviewRequest
|
||||
): Promise<string> {
|
||||
const response = await apiClient.post<GeneratedMessageHistoryItem>(
|
||||
`/v1/tasks/${taskId}/generated-messages/generate`,
|
||||
payload
|
||||
)
|
||||
return response.data.content
|
||||
}
|
||||
|
||||
/** Returns all scheduled tasks currently configured in local storage. */
|
||||
/** Returns generated message history for one task. */
|
||||
export async function getTaskGeneratedMessages(
|
||||
taskId: string
|
||||
): Promise<GeneratedMessageHistoryItem[]> {
|
||||
const response = await apiClient.get<GeneratedMessageHistoryItem[]>(
|
||||
`/v1/tasks/${taskId}/generated-messages`
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** Deletes one generated message from a task history. */
|
||||
export async function deleteTaskGeneratedMessage(taskId: string, messageId: string): Promise<void> {
|
||||
await apiClient.delete(`/v1/tasks/${taskId}/generated-messages/${messageId}`)
|
||||
}
|
||||
|
||||
/** Returns all active scheduled tasks from backend. */
|
||||
export async function getAllTasks(): Promise<EntityTaskResponse[]> {
|
||||
return readTasks().filter((task) => task.active)
|
||||
const response = await apiClient.get<EntityTaskResponse[]>('/v1/tasks')
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** Returns scheduled tasks for a specific entity. */
|
||||
/** Returns all tasks for a specific entity, including inactive ones. */
|
||||
export async function getTasksByEntity(entityId: string): Promise<EntityTaskResponse[]> {
|
||||
return readTasks().filter((task) => task.entityId === entityId)
|
||||
const response = await apiClient.get<EntityTaskResponse[]>(`/v1/tasks/entity/${entityId}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** Returns one scheduled task by identifier. */
|
||||
export async function getTask(taskId: string): Promise<EntityTaskResponse | null> {
|
||||
return readTasks().find((task) => task.id === taskId) ?? null
|
||||
}
|
||||
|
||||
/** Creates a scheduled task in local storage. */
|
||||
export async function createTask(data: EntityTaskCreateDto): Promise<EntityTaskResponse> {
|
||||
const current = readTasks()
|
||||
const task: EntityTaskResponse = {
|
||||
...data,
|
||||
id: crypto.randomUUID(),
|
||||
active: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
try {
|
||||
const response = await apiClient.get<EntityTaskResponse>(`/v1/tasks/${taskId}`)
|
||||
return response.data
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
current.push(task)
|
||||
writeTasks(current)
|
||||
return task
|
||||
}
|
||||
|
||||
/** Updates one scheduled task in local storage. */
|
||||
/** Creates a scheduled task. */
|
||||
export async function createTask(data: EntityTaskCreateDto): Promise<EntityTaskResponse> {
|
||||
const response = await apiClient.post<EntityTaskResponse>('/v1/tasks', data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** Updates one scheduled task. */
|
||||
export async function updateTask(
|
||||
taskId: string,
|
||||
data: EntityTaskUpdateDto
|
||||
): Promise<EntityTaskResponse | null> {
|
||||
const current = readTasks()
|
||||
const existingTask = current.find((task) => task.id === taskId)
|
||||
|
||||
if (!existingTask) {
|
||||
try {
|
||||
const response = await apiClient.put<EntityTaskResponse>(`/v1/tasks/${taskId}`, data)
|
||||
return response.data
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
const updatedTask: EntityTaskResponse = {
|
||||
...existingTask,
|
||||
...data,
|
||||
}
|
||||
|
||||
writeTasks(current.map((task) => (task.id === taskId ? updatedTask : task)))
|
||||
return updatedTask
|
||||
}
|
||||
|
||||
/** Marks one scheduled task as inactive in local storage. */
|
||||
/** Marks one scheduled task as inactive. */
|
||||
export async function inactivateTask(taskId: string): Promise<EntityTaskResponse | null> {
|
||||
const current = readTasks()
|
||||
const existingTask = current.find((task) => task.id === taskId)
|
||||
|
||||
if (!existingTask) {
|
||||
try {
|
||||
const response = await apiClient.post<EntityTaskResponse>(`/v1/tasks/${taskId}/inactivate`)
|
||||
return response.data
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
const updatedTask: EntityTaskResponse = {
|
||||
...existingTask,
|
||||
active: false,
|
||||
}
|
||||
|
||||
writeTasks(current.map((task) => (task.id === taskId ? updatedTask : task)))
|
||||
return updatedTask
|
||||
}
|
||||
|
||||
/** Marks one scheduled task as active in local storage. */
|
||||
/** Marks one scheduled task as active. */
|
||||
export async function activateTask(taskId: string): Promise<EntityTaskResponse | null> {
|
||||
const current = readTasks()
|
||||
const existingTask = current.find((task) => task.id === taskId)
|
||||
|
||||
if (!existingTask) {
|
||||
try {
|
||||
const response = await apiClient.post<EntityTaskResponse>(`/v1/tasks/${taskId}/activate`)
|
||||
return response.data
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
const updatedTask: EntityTaskResponse = {
|
||||
...existingTask,
|
||||
active: true,
|
||||
}
|
||||
|
||||
writeTasks(current.map((task) => (task.id === taskId ? updatedTask : task)))
|
||||
return updatedTask
|
||||
}
|
||||
|
||||
/** Deletes one scheduled task from local storage. */
|
||||
/** Deletes one scheduled task. */
|
||||
export async function deleteTask(taskId: string): Promise<void> {
|
||||
writeTasks(readTasks().filter((task) => task.id !== taskId))
|
||||
await apiClient.delete(`/v1/tasks/${taskId}`)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { getEntity } from '../api/entitiesApi'
|
||||
import {
|
||||
activateTask,
|
||||
buildTaskPreviewPrompt,
|
||||
deleteTaskGeneratedMessage,
|
||||
deleteTask,
|
||||
generateTaskPreview,
|
||||
getTaskGeneratedMessages,
|
||||
getTask,
|
||||
inactivateTask,
|
||||
updateTask,
|
||||
@@ -35,12 +37,6 @@ interface RegularitySuggestion {
|
||||
cronParts: CronParts
|
||||
}
|
||||
|
||||
interface GeneratedMessageItem {
|
||||
id: string
|
||||
label: string
|
||||
content: string
|
||||
}
|
||||
|
||||
const DEFAULT_CRON_PARTS: CronParts = {
|
||||
minute: '0',
|
||||
hour: '9',
|
||||
@@ -121,10 +117,8 @@ export default function EditTaskPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const [cronParts, setCronParts] = useState<CronParts>(DEFAULT_CRON_PARTS)
|
||||
const [taskForm, setTaskForm] = useState<TaskFormState>(DEFAULT_TASK_FORM)
|
||||
const [generatedMessages, setGeneratedMessages] = useState<GeneratedMessageItem[]>([])
|
||||
const [selectedMessageId, setSelectedMessageId] = useState('')
|
||||
const [previewError, setPreviewError] = useState('')
|
||||
const generatedMessageCounter = useRef(0)
|
||||
|
||||
const { data: entity, isLoading: isLoadingEntity } = useQuery({
|
||||
queryKey: ['entity', entityId],
|
||||
@@ -138,6 +132,26 @@ export default function EditTaskPage() {
|
||||
enabled: Boolean(taskId),
|
||||
})
|
||||
|
||||
const { data: generatedMessages = [] } = useQuery({
|
||||
queryKey: ['task-generated-messages', taskId],
|
||||
queryFn: () => getTaskGeneratedMessages(taskId),
|
||||
enabled: Boolean(taskId),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (generatedMessages.length === 0) {
|
||||
if (selectedMessageId) {
|
||||
setSelectedMessageId('')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const hasCurrentSelection = generatedMessages.some((message) => message.id === selectedMessageId)
|
||||
if (!hasCurrentSelection) {
|
||||
setSelectedMessageId(generatedMessages[0].id)
|
||||
}
|
||||
}, [generatedMessages, selectedMessageId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!task) {
|
||||
return
|
||||
@@ -193,20 +207,13 @@ export default function EditTaskPage() {
|
||||
})
|
||||
|
||||
const previewMutation = useMutation({
|
||||
mutationFn: generateTaskPreview,
|
||||
mutationFn: (payload: Parameters<typeof generateTaskPreview>[1]) =>
|
||||
generateTaskPreview(taskId, payload),
|
||||
onMutate: () => {
|
||||
setPreviewError('')
|
||||
},
|
||||
onSuccess: (value) => {
|
||||
generatedMessageCounter.current += 1
|
||||
const nextMessage: GeneratedMessageItem = {
|
||||
id: `message-${generatedMessageCounter.current}`,
|
||||
label: `Message #${generatedMessageCounter.current}`,
|
||||
content: value,
|
||||
}
|
||||
|
||||
setGeneratedMessages((prev) => [nextMessage, ...prev])
|
||||
setSelectedMessageId(nextMessage.id)
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['task-generated-messages', taskId] })
|
||||
},
|
||||
onError: (error) => {
|
||||
setPreviewError(
|
||||
@@ -246,6 +253,13 @@ export default function EditTaskPage() {
|
||||
[generatedMessages, selectedMessageId]
|
||||
)
|
||||
|
||||
const deleteGeneratedMessageMutation = useMutation({
|
||||
mutationFn: (messageId: string) => deleteTaskGeneratedMessage(taskId, messageId),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['task-generated-messages', taskId] })
|
||||
},
|
||||
})
|
||||
|
||||
const applyCronParts = (nextCronParts: CronParts) => {
|
||||
setCronParts(nextCronParts)
|
||||
setTaskForm((prev) => ({ ...prev, scheduleCron: buildCron(nextCronParts) }))
|
||||
@@ -514,13 +528,7 @@ export default function EditTaskPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setGeneratedMessages((prev) => {
|
||||
const nextMessages = prev.filter((item) => item.id !== message.id)
|
||||
if (selectedMessageId === message.id) {
|
||||
setSelectedMessageId(nextMessages[0]?.id ?? '')
|
||||
}
|
||||
return nextMessages
|
||||
})
|
||||
deleteGeneratedMessageMutation.mutate(message.id)
|
||||
}}
|
||||
className="rounded border border-red-500/40 px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-red-300 hover:bg-red-500/10"
|
||||
aria-label={`Delete ${message.label.toLowerCase()}`}
|
||||
|
||||
Reference in New Issue
Block a user