Support ephemeral Sessions

This commit is contained in:
Matte Noble
2025-06-27 15:05:09 -07:00
parent bedd59f868
commit 94c24f47fa
12 changed files with 115 additions and 31 deletions

View File

@@ -250,6 +250,10 @@ CREATE TABLE IF NOT EXISTS tasks (
version: 15,
description: "add_tasks_support",
sql: r#"
ALTER TABLE sessions ADD COLUMN ephemeral BOOLEAN DEFAULT "false";
INSERT INTO apps ("name", "description", "interface") VALUES ("Task", "Scheduled task", "Task");
ALTER TABLE tasks ADD COLUMN engine_id INTEGER REFERENCES engines(id);
ALTER TABLE tasks ADD COLUMN model TEXT NOT NULL;
@@ -257,8 +261,8 @@ CREATE TABLE IF NOT EXISTS task_runs (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
task_id INTEGER NOT NULL,
session_id INTEGER NOT NULL,
success BOOLEAN NOT NULL,
timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
state TEXT NOT NULL DEFAULT "Pending",
created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(task_id) REFERENCES tasks(id),
FOREIGN KEY(session_id) REFERENCES sessions(id)
);

View File

@@ -40,7 +40,7 @@
}
</script>
<Flex class={twMerge('w-full flex-col items-start', cls?.toString())}>
<Flex class={twMerge('w-full flex-col items-start overflow-y-scroll', cls?.toString())}>
{#if title}
<p class={twMerge('text-medium text-sm', titleClass?.toString())}>
{title}

View File

@@ -1,15 +1,13 @@
<script lang="ts">
import { twMerge } from 'tailwind-merge';
const { class: cls = '' } = $props();
const { class: cls = 'w-[24px] h-[24px]' } = $props();
</script>
<div class={twMerge('spinner', cls?.toString())}></div>
<style>
.spinner {
width: 24px;
height: 24px;
border-radius: 50%;
position: relative;
animation: rotate 1s linear infinite;

View File

@@ -22,7 +22,7 @@
const isEdit = $derived(task.id !== undefined);
let mcpServers: McpServer[] = $state([]);
let model: IModel = $state(Model.find(String(task.model)) || Model.default());
let model: IModel = $state((task.model && Model.find(task.model)) || Model.default());
async function addMcpServer(mcpServer: McpServer) {
if (isEdit) {

View File

@@ -28,6 +28,7 @@ export enum NodeType {
export enum Interface {
Voice = 'Voice',
Chat = 'Chat',
Task = 'Task',
Dashboard = 'Dashboard',
Daemon = 'Daemon',
}

View File

@@ -42,6 +42,7 @@ interface Row {
app_id: number;
summary: string;
config: string;
ephemeral: string;
created: string;
modified: string;
}
@@ -51,6 +52,7 @@ export default class Session extends Base<Row>('sessions') {
appId?: number = $state();
summary: string = $state(DEFAULT_SUMMARY);
config: Partial<Config> = $state({});
ephemeral: boolean = $state(false);
created?: moment.Moment = $state();
modified?: moment.Moment = $state();
@@ -161,11 +163,13 @@ export default class Session extends Base<Row>('sessions') {
content: Setting.CustomSystemPrompt?.trim() || SYSTEM_PROMPT,
});
await Message.create({
sessionId: this.id,
role: 'assistant',
content: WELCOME_PROMPT,
});
if (!this.ephemeral) {
await Message.create({
sessionId: this.id,
role: 'assistant',
content: WELCOME_PROMPT,
});
}
}
protected static async fromSql(row: Row): Promise<Session> {
@@ -174,6 +178,7 @@ export default class Session extends Base<Row>('sessions') {
appId: row.app_id,
summary: row.summary,
config: JSON.parse(row.config),
ephemeral: row.ephemeral === 'true',
created: moment.utc(row.created),
modified: moment.utc(row.modified),
});
@@ -184,6 +189,7 @@ export default class Session extends Base<Row>('sessions') {
app_id: Number(this.appId),
summary: this.summary,
config: JSON.stringify(this.config),
ephemeral: this.ephemeral ? 'true' : 'false',
};
}
}

View File

@@ -3,20 +3,26 @@ import moment from 'moment';
import { Session, Task } from '$lib/models';
import Base, { type ToSqlRow } from '$lib/models/base.svelte';
export enum State {
Pending = 'Pending',
Success = 'Success',
Failure = 'Failure',
}
interface Row {
id: number;
task_id: number;
session_id: number;
success: boolean;
timestamp: string;
state: State;
created: string;
}
export default class TaskRun extends Base<Row>('task_runs') {
id?: number = $state();
taskId?: number = $state();
sessionId?: number = $state();
success: boolean = $state(false);
timestamp?: moment.Moment = $state();
state: State = $state(State.Pending);
created?: moment.Moment = $state();
get task() {
return Task.find(Number(this.taskId));
@@ -31,8 +37,8 @@ export default class TaskRun extends Base<Row>('task_runs') {
id: row.id,
taskId: row.task_id,
sessionId: row.session_id,
success: row.success,
timestamp: moment.utc(row.timestamp),
state: row.state,
created: moment.utc(row.created),
});
}
@@ -40,8 +46,7 @@ export default class TaskRun extends Base<Row>('task_runs') {
return {
task_id: Number(this.taskId),
session_id: Number(this.sessionId),
success: this.success,
timestamp: this.timestamp?.toString() as string,
state: this.state,
};
}
}

View File

@@ -16,8 +16,8 @@ interface Row {
export default class Task extends Base<Row>('tasks') {
id?: number = $state();
name: string = $state('New Task');
engineId?: number = $state();
model?: string = $state();
engineId: number = $state(Model.default().engineId);
model: string = $state(Model.default().id);
prompt: string = $state('');
period: string = $state('0 12 * * *');
next_run: Date = $state(new Date('2099-12-31T11:59:59.999Z'));

View File

@@ -1,3 +1,54 @@
import type { Task } from './models';
import { dispatch } from './dispatch';
import { Engine, Session, Task, TaskRun } from './models';
import { State } from './models/task-run.svelte';
export function execute(task: Task) {}
const TASK_APP_ID = 2;
export async function execute(task: Task): Promise<TaskRun | undefined> {
if (!task.engineId || !task.model) {
return;
}
const engine = Engine.find(task.engineId);
const model = engine.models.find(m => m.id == task.model);
if (!model) {
return;
}
const session = await Session.create({
appId: TASK_APP_ID,
config: {
engineId: task.engineId,
model: task.model,
enabledMcpServers: task.mcpServers.map(m => m.name),
},
ephemeral: true,
});
if (!session) {
return;
}
task.mcpServers.forEach(async server => {
await session.addMcpServer(server);
await server.start(session);
});
const run = await TaskRun.create({
taskId: task.id,
sessionId: session.id,
});
dispatch(session, model, task.prompt)
.then(_ => {
run.state = State.Success;
run.save();
})
.catch(_ => {
run.state = State.Failure;
run.save();
});
return run;
}

View File

@@ -27,7 +27,7 @@
})
);
const sessions: Session[] = $derived(Session.all());
const sessions: Session[] = $derived(Session.where({ ephemeral: false }));
const mcpServers: McpServer[] = $derived(McpServer.all());
const engines: Engine[] = $derived(Engine.all());
const hasModels = $derived(engines.flatMap(e => e.models).length > 0);

View File

@@ -10,6 +10,7 @@
import Menu from '$components/Menu.svelte';
import Titlebar from '$components/Titlebar.svelte';
import Task from '$lib/models/task.svelte';
import { execute } from '$lib/tasks';
const { children } = $props();
const tasks: Task[] = $derived(Task.all());
@@ -42,7 +43,11 @@
}
async function run(task: Task) {
// noop for now
const run = await execute(task);
if (run) {
await goto(`/tasks/${task.id}/runs/${run.id}`);
}
}
</script>

View File

@@ -5,12 +5,23 @@
import Link from '$components/Link.svelte';
import List from '$components/List.svelte';
import Message from '$components/Message.svelte';
import Spinner from '$components/Spinner.svelte';
import Svg from '$components/Svg.svelte';
import { TaskRun } from '$lib/models';
import Task from '$lib/models/task.svelte';
import { State } from '$lib/models/task-run.svelte';
const task: Task = $derived(Task.find(Number(page.params.task_id)));
const run: TaskRun = $derived(TaskRun.find(Number(page.params.run_id)));
// svelte-ignore non_reactive_update
let content: HTMLDivElement;
function scrollToBottom(_: HTMLDivElement) {
if (content) {
content.scroll({ top: 9e15 });
}
}
</script>
{#snippet RunView(run: TaskRun)}
@@ -19,28 +30,31 @@
class="text-medium flex flex-row items-center px-7 py-2"
activeClass="text-purple border-l border-l-purple"
>
{#if run.success}
{#if run.state == State.Pending}
<Spinner class="h-4 w-4 before:border-[2px] before:border-white/30" />
{:else if run.state == State.Success}
<Svg name="Check" class="text-green h-4 w-4" />
{:else}
<Svg name="Warning" class="text-red h-4 w-4" />
{/if}
<p class="ml-4">{run.timestamp?.format('LLLL')}</p>
<p class="ml-4">{run.created?.format('LLLL')}</p>
</Link>
{/snippet}
{#key page.params.task_id}
<Flex class="h-full w-full flex-col items-start">
<Flex class="h-3/5 w-full flex-col items-start overflow-y-scroll p-8">
<Flex bind:ref={content} class="h-3/5 w-full flex-col items-start overflow-y-scroll p-8">
{#if run}
{#each run.session.messages as message (message.id)}
<div use:scrollToBottom class="hidden"></div>
<Message {message} />
{/each}
{/if}
</Flex>
<Flex class="border-t-light h-2/5 w-full flex-col items-start border-t py-4">
<h3 class="mb-4 ml-8 uppercase">History</h3>
<Flex class="border-t-light h-2/5 w-full flex-col items-start border-t">
<h3 class="bg-medium w-full py-2 pl-8 font-medium uppercase">History</h3>
<List items={task?.runs} itemView={RunView} class="border-t-light border-t" />
</Flex>
</Flex>