This commit is contained in:
lee-at-zoo-corp
2025-06-20 16:39:18 -04:00
parent eaa52a9b73
commit c3620b29aa
7 changed files with 244 additions and 47 deletions

View File

@ -11,11 +11,13 @@ export type HomeItem = Project | Prompt
export type HomeItems = Project[] | Prompt[]
export const areHomeItemsProjects = (items: HomeItems): items is Project[] => {
if (items.length === 0) return true
const item = items[0]
return item !== undefined && 'path' in item
}
export const areHomeItemsPrompts = (items: HomeItems): items is Prompt[] => {
if (items.length === 0) return true
const item = items[0]
return item !== undefined && 'prompt' in item
}

View File

@ -1,30 +1,40 @@
import ms from 'ms'
import type { Prompt } from '@src/lib/prompt'
interface PromptCardProps extends Prompt {
onAction?: (id: Prompt['id']) => void
export interface PromptCardProps extends Prompt {
disabled?: boolean
onAction?: (id: Prompt['id'], prompt: Prompt['prompt']) => void
onDelete?: (id: Prompt['id']) => void
onFeedback?: (id: string, feedback: Prompt['feedback']) => void
}
export const PromptFeedback = (props: {
id: Prompt['id']
selected: Prompt['feedback']
selected?: Prompt['feedback']
disabled?: boolean
onFeedback: (id: Prompt['id'], feedback: Prompt['feedback']) => void
}) => {
const cssUp = 'border-green-300'
const cssDown = 'border-red-300'
const cssUp =
props.selected === undefined || props.selected === 'thumbs_up'
? 'border-green-300'
: 'border-green-100 text-chalkboard-60'
const cssDown =
props.selected === undefined || props.selected === 'thumbs_down'
? 'border-red-300'
: 'border-red-100 text-chalkboard-60'
return (
<div className="flex flex-row gap-2">
<button
onClick={() => props.onFeedback(props.id, 'thumbs_up')}
disabled={props.disabled}
className={cssUp}
>
Good
</button>
<button
onClick={() => props.onFeedback(props.id, 'thumbs_down')}
disabled={props.disabled}
className={cssDown}
>
Bad
@ -35,13 +45,18 @@ export const PromptFeedback = (props: {
export const PromptCardActionButton = (props: {
status: Prompt['status']
disabled?: boolean
onClick: () => void
}) => {
return (
<button
className="rounded-full bg-gray-100"
onClick={props.onClick}
disabled={props.status === 'queued' || props.status === 'in_progress'}
disabled={
props.disabled ||
props.status === 'queued' ||
props.status === 'in_progress'
}
>
{props.status === 'completed' ? 'Create' : 'Pending'}
</button>
@ -49,15 +64,19 @@ export const PromptCardActionButton = (props: {
}
export const PromptCard = (props: PromptCardProps) => {
const cssCard = 'flex flex-col border rounded-md p-2 gap-2 justify-between'
return (
<div className="flex flex-col border rounded-md p-2 gap-2 justify-between">
<div className={`${cssCard} ${props.disabled ? 'text-chalkboard-60' : ''}`}>
<div className="flex flex-row justify-between gap-2">
<div>{props.prompt}</div>
<div className="w-fit flex flex-col items-end">
<button onClick={() => props.onDelete?.(props.id)}>Delete</button>
{/* TODO: */}
{/* <button disabled={props.disabled} onClick={() => props.onDelete?.(props.id)}>Delete</button> */}
<PromptFeedback
id={props.id}
selected={props.feedback}
disabled={props.disabled}
onFeedback={(...args) => props.onFeedback?.(...args)}
/>
</div>
@ -65,7 +84,8 @@ export const PromptCard = (props: PromptCardProps) => {
<div className="flex flex-row justify-between">
<PromptCardActionButton
status={props.status}
onClick={() => props.onAction?.(props.id)}
disabled={props.disabled}
onClick={() => props.onAction?.(props.id, props.prompt)}
/>
<div>
{ms(new Date(props.created_at).getTime(), { long: true })} ago

View File

@ -26,6 +26,10 @@ import {
engineStreamContextCreate,
engineStreamMachine,
} from '@src/machines/engineStreamMachine'
import {
mlEphantDefaultContext,
mlEphantManagerMachine,
} from '@src/machines/mlEphantManagerMachine'
import { ACTOR_IDS } from '@src/machines/machineConstants'
import { settingsMachine } from '@src/machines/settingsMachine'
import { systemIOMachineDesktop } from '@src/machines/systemIO/systemIOMachineDesktop'
@ -117,13 +121,21 @@ if (typeof window !== 'undefined') {
},
})
}
const { AUTH, SETTINGS, SYSTEM_IO, ENGINE_STREAM, COMMAND_BAR, BILLING } =
ACTOR_IDS
const {
AUTH,
SETTINGS,
SYSTEM_IO,
ENGINE_STREAM,
MLEPHANT_MANAGER,
COMMAND_BAR,
BILLING,
} = ACTOR_IDS
const appMachineActors = {
[AUTH]: authMachine,
[SETTINGS]: settingsMachine,
[SYSTEM_IO]: isDesktop() ? systemIOMachineDesktop : systemIOMachineWeb,
[ENGINE_STREAM]: engineStreamMachine,
[MLEPHANT_MANAGER]: mlEphantManagerMachine,
[COMMAND_BAR]: commandBarMachine,
[BILLING]: billingMachine,
} as const
@ -157,6 +169,10 @@ const appMachine = setup({
systemId: ENGINE_STREAM,
input: engineStreamContextCreate(),
}),
spawnChild(appMachineActors[MLEPHANT_MANAGER], {
systemId: MLEPHANT_MANAGER,
input: mlEphantDefaultContext(),
}),
spawnChild(appMachineActors[SYSTEM_IO], {
systemId: SYSTEM_IO,
}),
@ -226,6 +242,10 @@ export const engineStreamActor = appActor.system.get(
ENGINE_STREAM
) as ActorRefFrom<(typeof appMachineActors)[typeof ENGINE_STREAM]>
export const mlEphantManagerActor = appActor.system.get(
MLEPHANT_MANAGER
) as ActorRefFrom<(typeof appMachineActors)[typeof MLEPHANT_MANAGER]>
export const commandBarActor = appActor.system.get(COMMAND_BAR) as ActorRefFrom<
(typeof appMachineActors)[typeof COMMAND_BAR]
>

View File

@ -68,8 +68,10 @@ export function getPromptSortFunction(sortBy: string) {
const sortByModified = (a: Prompt, b: Prompt) => {
if (a.created_at && b.created_at) {
const aDate = new Date(a.created_at)
const bDate = new Date(b.created_at)
// INTENTIONALLY REVERSED
// Will not show properly otherwise.
const aDate = new Date(b.created_at)
const bDate = new Date(a.created_at)
return !sortBy || sortBy.includes('desc')
? bDate.getTime() - aDate.getTime()
: aDate.getTime() - bDate.getTime()

View File

@ -3,6 +3,7 @@ export const ACTOR_IDS = {
SETTINGS: 'settings',
SYSTEM_IO: 'systemIO',
ENGINE_STREAM: 'engine_stream',
MLEPHANT_MANAGER: 'mlephant_manager',
COMMAND_BAR: 'command_bar',
BILLING: 'billing',
} as const

View File

@ -1,7 +1,14 @@
import { setup, fromPromise } from 'xstate'
import { assign, setup, fromPromise } from 'xstate'
import type { ActorRefFrom } from 'xstate'
import type { Prompt } from '@src/lib/prompt'
import { generateFakeSubmittedPrompt } from '@src/lib/prompt'
export enum MlEphantManagerStates {
Setup = 'setup',
Idle = 'idle',
Pending = 'pending',
}
export enum MlEphantManagerTransitionStates {
GetPromptsThatCreatedProjects = 'get-prompts-that-created-projects',
GetPromptsBelongingToProject = 'get-prompts-belonging-to-project',
@ -23,12 +30,14 @@ export type MlEphantManagerEvents =
}
| {
type: MlEphantManagerTransitionStates.PromptCreateModel
// May or may not belong to a project.
projectId?: string
// For now we fake this, using project_name.
projectId: string
prompt: string
}
| {
type: MlEphantManagerTransitionStates.PromptEditModel
projectId: string
prompt: string
}
| {
type: MlEphantManagerTransitionStates.PromptRate
@ -43,10 +52,8 @@ export type MlEphantManagerEvents =
promptId: string
}
export enum MlEphantManagerStates {
Idle = 'idle',
Pending = 'pending',
}
// Used to specify a specific event in input properties
type XSEvent<T> = Extract<MlEphantManagerEvents, { type: T }>
export interface MlEphantManagerContext {
promptsThatCreatedProjects: Map<Prompt['id'], Prompt>
@ -54,7 +61,7 @@ export interface MlEphantManagerContext {
promptsBelongingToProject?: Map<Prompt['id'], Prompt>
}
export const mlEphantDefaultContext = Object.freeze({
export const mlEphantDefaultContext = () => ({
promptsThatCreatedProjects: new Map(),
promptsBelongingToProject: undefined,
hasPendingPrompts: false,
@ -67,37 +74,146 @@ export const mlEphantManagerMachine = setup({
},
actors: {
[MlEphantManagerTransitionStates.GetPromptsThatCreatedProjects]:
fromPromise(async function (args) {
console.log(arguments)
return {
promptsThatCreatedProjects: new Array(13)
.fill(undefined)
.map(generateFakeSubmittedPrompt),
fromPromise(async function (args: {
input: {
context: MlEphantManagerContext
}
}): Promise<Partial<MlEphantManagerContext>> {
return new Promise((resolve) => {
setTimeout(() => {
const results = new Array(13)
.fill(undefined)
.map(generateFakeSubmittedPrompt)
const promptsThatCreatedProjects = new Map(
args.input.context.promptsThatCreatedProjects
)
results.forEach((result) => {
promptsThatCreatedProjects.set(result.id, result)
})
resolve({
promptsThatCreatedProjects,
})
}, 2000)
})
}),
[MlEphantManagerTransitionStates.PromptCreateModel]: fromPromise(
async function (args: {
input: {
event: XSEvent<MlEphantManagerTransitionStates.PromptCreateModel>
context: MlEphantManagerContext
}
}): Promise<Partial<MlEphantManagerContext>> {
return new Promise((resolve) => {
setTimeout(() => {
const promptsThatCreatedProjects = new Map(
args.input.context.promptsThatCreatedProjects
)
const result = generateFakeSubmittedPrompt()
promptsThatCreatedProjects.set(result.id, result)
resolve({
promptsThatCreatedProjects,
})
}, 5000)
})
}
),
[MlEphantManagerTransitionStates.PromptPollStatus]: fromPromise(
async function (args: {
input: {
context: MlEphantManagerContext
}
}): Promise<Partial<MlEphantManagerContext>> {
return new Promise((resolve) => {
setTimeout(() => {
const promptsThatCreatedProjects = new Map(
args.input.context.promptsThatCreatedProjects
)
// Do the same for prompts of a project
promptsThatCreatedProjects.values().forEach((prompt) => {
if (prompt.status !== 'pending') return
prompt.status = 'completed'
})
resolve({
promptsThatCreatedProjects,
})
}, 3000)
})
}
),
},
}).createMachine({
initial: MlEphantManagerStates.Idle,
context: mlEphantDefaultContext,
initial: MlEphantManagerStates.Setup,
context: mlEphantDefaultContext(),
states: {
[MlEphantManagerStates.Setup]: {
invoke: {
input: (args: { context: MlEphantManagerContext }) => args,
src: MlEphantManagerTransitionStates.GetPromptsThatCreatedProjects,
onDone: {
target: MlEphantManagerStates.Idle,
actions: assign(({ event }) => event.output),
},
onError: { target: MlEphantManagerStates.Idle },
},
},
[MlEphantManagerStates.Idle]: {
on: {
[MlEphantManagerTransitionStates.GetPromptsThatCreatedProjects]: {
target: MlEphantManagerTransitionStates.GetPromptsThatCreatedProjects,
target:
MlEphantManagerStates.Pending +
'.' +
MlEphantManagerTransitionStates.GetPromptsThatCreatedProjects,
},
},
},
[MlEphantManagerStates.Pending]: {
initial: MlEphantManagerStates.Idle,
states: {
// Pop back out to Idle.
[MlEphantManagerStates.Idle]: {
type: 'final',
target: MlEphantManagerStates.Idle,
},
[MlEphantManagerTransitionStates.GetPromptsThatCreatedProjects]: {
invoke: {
input: (args: any) => args,
input: (args) => ({
context: args.context,
}),
src: MlEphantManagerTransitionStates.GetPromptsThatCreatedProjects,
onDone: { target: MlEphantManagerStates.Idle },
onError: { target: MlEphantManagerStates.Idle },
},
},
[MlEphantManagerTransitionStates.PromptCreateModel]: {
invoke: {
input: (args) => ({
event:
args.event as XSEvent<MlEphantManagerTransitionStates.PromptCreateModel>,
context: args.context,
}),
src: MlEphantManagerTransitionStates.PromptCreateModel,
onDone: { target: MlEphantManagerStates.PromptPollStatus },
onError: { target: MlEphantManagerStates.PromptPollStatus },
},
},
[MlEphantManagerTransitionStates.PromptPollStatus]: {
invoke: {
input: (args) => ({
event:
args.event as XSEvent<MlEphantManagerTransitionStates.PromptPollStatus>,
context: args.context,
}),
src: MlEphantManagerTransitionStates.PromptPollStatus,
onDone: { target: MlEphantManagerStates.Idle },
onError: { target: MlEphantManagerStates.Idle },
},
},
},
},
},
})
export type MlEphantManagerActor = ActorRefFrom<typeof mlEphantManagerMachine>

View File

@ -1,4 +1,5 @@
import type { FormEvent, HTMLProps } from 'react'
import { useSelector } from '@xstate/react'
import { useEffect, useState } from 'react'
import { toast } from 'react-hot-toast'
import { useHotkeys } from 'react-hotkeys-hook'
@ -29,7 +30,6 @@ import { PATHS } from '@src/lib/paths'
import { markOnce } from '@src/lib/performance'
import type { Project } from '@src/lib/project'
import type { Prompt } from '@src/lib/prompt'
import { generateFakeSubmittedPrompt } from '@src/lib/prompt'
import {
getNextSearchParams,
getProjectSortFunction,
@ -44,6 +44,7 @@ import {
kclManager,
authActor,
billingActor,
mlEphantManagerActor,
systemIOActor,
useSettings,
} from '@src/lib/singletons'
@ -73,6 +74,7 @@ import {
defaultLocalStatusBarItems,
defaultGlobalStatusBarItems,
} from '@src/components/StatusBar/defaultStatusBarItems'
import { MlEphantManagerStates } from '@src/machines/mlEphantManagerMachine'
type ReadWriteProjectState = {
value: boolean
@ -217,15 +219,10 @@ const Home = () => {
}
)
const projects = useFolders()
const prompts: Prompt[] = [
generateFakeSubmittedPrompt(),
generateFakeSubmittedPrompt(),
generateFakeSubmittedPrompt(),
generateFakeSubmittedPrompt(),
generateFakeSubmittedPrompt(),
generateFakeSubmittedPrompt(),
generateFakeSubmittedPrompt(),
]
const prompts = useSelector(mlEphantManagerActor, (actor) => {
return actor.context.promptsThatCreatedProjects
})
const [tabSelected, setTabSelected] = useState<HomeTabKeys>(
HomeTabKeys.Projects
)
@ -236,17 +233,23 @@ const Home = () => {
const onChangeTab = (key: HomeTabKeys) => {
setTabSelected(key)
switch (key) {
}
useEffect(() => {
switch (tabSelected) {
case HomeTabKeys.Projects:
setItems(projects)
break
case HomeTabKeys.Prompts:
setItems(prompts)
// Lessons hard learned: VERY important to do this here, and not within
// the useSelector. React will think it's a new value every time, and
// cause this useEffect to fire indefinitely.
setItems(Array.from(prompts.values()))
break
default:
const _ex: never = key
const _ex: never = tabSelected
}
}
}, [tabSelected, prompts, projects])
useEffect(() => {
searchAgainst(items)('')
@ -439,6 +442,7 @@ const Home = () => {
searchResults={searchResults}
sortBy={sortBy}
query={query}
settings={settings}
/>
</div>
<StatusBar
@ -483,6 +487,7 @@ function HomeTab(props: HomeTabProps) {
<div className="flex flex-row">
{tabs.map((el) => (
<div
key={el.key}
className={el.key === selected ? cssActive : cssInactive}
onClick={onClickTab(el.key)}
role="tab"
@ -617,11 +622,14 @@ interface HomeItemsAreaProps {
searchResults: HomeItems
sortBy: string
query: string
settings: ReturnType<typeof useSettings>
}
function HomeItemsArea(props: HomeItemsAreaProps) {
let grid = null
console.log('home items area', props.tabSelected, props.searchResults)
switch (props.tabSelected) {
case HomeTabKeys.Projects:
grid = areHomeItemsProjects(props.searchResults) ? (
@ -640,6 +648,7 @@ function HomeItemsArea(props: HomeItemsAreaProps) {
searchResults={props.searchResults}
query={props.query}
sortBy={props.sortBy}
settings={props.settings}
/>
) : (
<NoResults />
@ -660,13 +669,26 @@ interface ResultGridPromptsProps {
searchResults: Prompt[]
query: string
sortBy: string
settings: ReturnType<typeof useSettings>
}
function ResultGridPrompts(props: ResultGridPromptsProps) {
// Maybe consider lifting this higher but I see no reason at the moment
const onAction = (...args: any) => {
console.log(args)
const onAction = (_id: Prompt['id'], prompt: Prompt['prompt']) => {
commandBarActor.send({
type: 'Find and select command',
data: {
groupId: 'application',
name: 'Text-to-CAD',
argDefaultValues: {
method: 'newProject',
prompt,
newProjectName: props.settings.projects.defaultProjectName.current,
},
},
})
}
// no-op for now
const onDelete = (...args: any) => {
console.log(args)
}
@ -674,6 +696,16 @@ function ResultGridPrompts(props: ResultGridPromptsProps) {
console.log(args)
}
const mlEphantManagerSnapshot = mlEphantManagerActor.getSnapshot()
if (mlEphantManagerSnapshot.matches(MlEphantManagerStates.Setup)) {
return (
<div className="col-start-2 -col-end-1 w-full flex flex-col justify-center items-center">
<Loading isDummy={true}>Loading your prompts...</Loading>
</div>
)
}
return (
<div className="grid w-full sm:grid-cols-2 lg:grid-cols-2 xl:grid-cols-2 gap-4">
{props.searchResults
@ -682,6 +714,10 @@ function ResultGridPrompts(props: ResultGridPromptsProps) {
<PromptCard
key={prompt.id}
{...prompt}
disabled={
mlEphantManagerSnapshot.matches(MlEphantManagerStates.Pending) ||
prompt.status !== 'completed'
}
onAction={onAction}
onDelete={onDelete}
onFeedback={onFeedback}