Skip to content

Commit 161cfbb

Browse files
authored
Merge pull request #27 from tylerslaton/threads
Threads
2 parents b63d539 + 3551c8b commit 161cfbb

File tree

16 files changed

+507
-78
lines changed

16 files changed

+507
-78
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,6 @@ next-env.d.ts
3939

4040
# app output
4141
gptscripts
42+
43+
# ignore root level threads directory
44+
/threads

actions/scripts/fetch.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export const fetchScripts = async (): Promise<Record<string, string>> => {
4545
const files = await fs.readdir(SCRIPTS_PATH());
4646
const gptFiles = files.filter(file => file.endsWith('.gpt'));
4747

48-
if (gptFiles.length === 0) throw new Error('no files found in scripts directory');
48+
if (gptFiles.length === 0) return {} as Record<string, string>;
4949

5050
const scripts: Record<string, string> = {};
5151
for (const file of gptFiles) {

actions/threads.tsx

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
"use server"
2+
3+
import { THREADS_DIR } from "@/config/env";
4+
import fs from "fs/promises";
5+
import path from 'path';
6+
7+
const STATE_FILE = "state.json";
8+
const META_FILE = "meta.json";
9+
10+
export type Thread = {
11+
state: string;
12+
meta: ThreadMeta;
13+
}
14+
15+
export type ThreadMeta = {
16+
name: string;
17+
description: string;
18+
created: Date;
19+
updated: Date;
20+
id: string;
21+
script: string;
22+
}
23+
24+
export async function init() {
25+
const threadsDir = THREADS_DIR();
26+
try {
27+
await fs.access(threadsDir);
28+
} catch (error) {
29+
await fs.mkdir(threadsDir, { recursive: true });
30+
}
31+
}
32+
33+
export async function getThreads() {
34+
const threads: Thread[] = [];
35+
const threadsDir = THREADS_DIR();
36+
37+
let threadDirs: void | string[] = [];
38+
try {
39+
threadDirs = await fs.readdir(threadsDir);
40+
} catch (e) {
41+
return [];
42+
}
43+
44+
if (!threadDirs) return [];
45+
46+
for(let threadDir of threadDirs) {
47+
const threadPath = path.join(threadsDir, threadDir);
48+
const files = await fs.readdir(threadPath);
49+
50+
const thread: Thread = {} as Thread;
51+
if (files.includes(STATE_FILE)) {
52+
const state = await fs.readFile(path.join(threadPath, STATE_FILE), "utf-8");
53+
thread.state = state;
54+
}
55+
if (files.includes(META_FILE)) {
56+
const meta = await fs.readFile(path.join(threadPath, META_FILE), "utf-8");
57+
thread.meta = JSON.parse(meta) as ThreadMeta;
58+
} else {
59+
continue;
60+
}
61+
threads.push(thread);
62+
}
63+
64+
return threads.sort((a, b) => a.meta?.updated > b.meta?.updated ? -1 : 1);
65+
}
66+
67+
export async function getThread(id: string) {
68+
const threads = await getThreads();
69+
return threads.find(thread => thread.meta.id === id);
70+
}
71+
72+
async function newThreadName(): Promise<string> {
73+
const threads = await getThreads();
74+
return `New Thread${threads.length ? ' ' + (threads.length+1): '' }`;
75+
}
76+
77+
export async function createThread(script: string): Promise<Thread> {
78+
const threadsDir = THREADS_DIR();
79+
script = script.replace('.gpt', '');
80+
81+
// will probably want something else for this
82+
const id = Math.random().toString(36).substring(7);
83+
const threadPath = path.join(threadsDir, id);
84+
await fs.mkdir(threadPath, { recursive: true });
85+
86+
const threadMeta = {
87+
name: await newThreadName(),
88+
description: '',
89+
created: new Date(),
90+
updated: new Date(),
91+
id,
92+
script
93+
}
94+
const threadState = '';
95+
96+
await fs.writeFile(path.join(threadPath, META_FILE), JSON.stringify(threadMeta));
97+
await fs.writeFile(path.join(threadPath, STATE_FILE), '');
98+
99+
return {
100+
state: threadState,
101+
meta: threadMeta,
102+
}
103+
}
104+
105+
export async function deleteThread(id: string) {
106+
const threadsDir = THREADS_DIR();
107+
const threadPath = path.join(threadsDir,id);
108+
await fs.rm(threadPath, { recursive: true });
109+
}
110+
111+
export async function renameThread(id: string, name: string) {
112+
const threadsDir = THREADS_DIR();
113+
const threadPath = path.join(threadsDir,id);
114+
const meta = await fs.readFile(path.join(threadPath, META_FILE), "utf-8");
115+
const threadMeta = JSON.parse(meta) as ThreadMeta;
116+
threadMeta.name = name;
117+
await fs.writeFile(path.join(threadPath, META_FILE), JSON.stringify(threadMeta));
118+
}
119+
120+
export async function updateThread(id: string, thread: Thread) {
121+
const threadsDir = THREADS_DIR();
122+
const threadPath = path.join(threadsDir,id);
123+
124+
if (thread.state) await fs.writeFile(path.join(threadPath, STATE_FILE), thread.state);
125+
if (thread.meta) {
126+
const existingMeta = await fs.readFile(path.join(threadPath, META_FILE), "utf-8");
127+
const mergedMeta = { ...JSON.parse(existingMeta), ...thread.meta };
128+
await fs.writeFile(path.join(threadPath, META_FILE), JSON.stringify(mergedMeta));
129+
}
130+
}

app/run/layout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ export default function RunLayout({
55
}) {
66
return (
77
<section className="absolute left-0 top-16">
8-
<div className="border-t-1 border-gray-300 dark:border-gray-700" style={{ width: `100vw`, height: `calc(100vh - 66px)`}}>
8+
<div className="border-t-1 dark:border-zinc-800" style={{ width: `100vw`, height: `calc(100vh - 66px)`}}>
99
{children}
1010
</div>
1111
</section>

app/run/page.tsx

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,38 @@
11
"use client"
22

33
import { useSearchParams } from 'next/navigation';
4+
import { useState } from 'react';
45
import Script from "@/components/script";
6+
import Threads from "@/components/threads";
7+
import { Thread } from '@/actions/threads';
58

69
const Run = () => {
7-
const file = useSearchParams().get('file') || '';
10+
const [file, setFile] = useState<string>(useSearchParams().get('file') || '');
11+
const [thread, setThread] = useState<string>(useSearchParams().get('thread') || '');
12+
const [threads, setThreads] = useState<Thread[]>([]);
13+
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null);
814

915
return (
10-
<div className="h-full w-full px-10 2xl:w-1/2 2xl:mx-auto 2xl:px-0 flex flex-col pb-10">
11-
<Script className="pb-10" file={file} />
12-
</div>
16+
<div className="w-full h-full flex pb-10">
17+
<Threads
18+
setThread={setThread}
19+
setScript={setFile}
20+
setThreads={setThreads}
21+
threads={threads}
22+
selectedThreadId={selectedThreadId}
23+
setSelectedThreadId={setSelectedThreadId}
24+
/>
25+
<div className="mx-auto w-1/2">
26+
<Script
27+
enableThreads
28+
className="pb-10"
29+
file={file}
30+
thread={thread}
31+
setThreads={setThreads}
32+
setSelectedThreadId={setSelectedThreadId}
33+
/>
34+
</div>
35+
</div>
1336
);
1437
};
1538

components/edit/new.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ const NewForm: React.FC<NewFormProps> = ({className, setFile}) => {
2828
label="Filename"
2929
type="text"
3030
variant="bordered"
31-
placeholder='my-assistant.gpt (optional)'
31+
placeholder='my-assistant.gpt'
3232
value={filename}
3333
errorMessage={error}
3434
isInvalid={!!error}

components/edit/scriptNav.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import React, { useState, useEffect } from "react";
2+
import { fetchScripts } from "@/actions/scripts/fetch";
23
import {Dropdown, DropdownTrigger, DropdownMenu, DropdownItem, DropdownSection, Button} from "@nextui-org/react";
34
import { IoMenu } from "react-icons/io5";
45
import { FaRegFileCode } from "react-icons/fa";
@@ -12,9 +13,8 @@ const ScriptNav: React.FC<ScriptNavProps> = ({className }) => {
1213
const [files, setFiles] = useState<Record<string, string>>({});
1314

1415
useEffect(() => {
15-
fetch('/api/file')
16-
.then(response => response.json())
17-
.then(data => setFiles(data))
16+
fetchScripts()
17+
.then(scripts => setFiles(scripts))
1818
.catch(error => console.error(error));
1919
}, []);
2020

@@ -32,6 +32,7 @@ const ScriptNav: React.FC<ScriptNavProps> = ({className }) => {
3232
<DropdownMenu
3333
aria-label="edit"
3434
onAction={(key) => { window.location.href = `/edit?file=${key}`;}}
35+
disabledKeys={['no-files']}
3536
>
3637
<DropdownSection title="Actions" showDivider>
3738
<DropdownItem startContent={<VscNewFile />} key="new">

components/edit/sidebar/file.tsx

Whitespace-only changes.

components/script.tsx

Lines changed: 33 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,29 @@
22

33
import React, { useState, useEffect, useRef, useCallback } from "react";
44
import type { Tool } from "@gptscript-ai/gptscript";
5-
import Messages, { type Message, MessageType } from "@/components/script/messages";
5+
import Messages, { MessageType } from "@/components/script/messages";
66
import ChatBar from "@/components/script/chatBar";
77
import ToolForm from "@/components/script/form";
88
import Loading from "@/components/loading";
99
import useChatSocket from '@/components/script/useChatSocket';
1010
import { Button } from "@nextui-org/react";
1111
import { fetchScript, path } from "@/actions/scripts/fetch";
1212
import { getWorkspaceDir } from "@/actions/workspace";
13+
import { createThread, getThreads, Thread } from "@/actions/threads";
1314
import debounce from "lodash/debounce";
15+
import { set } from "lodash";
1416

1517
interface ScriptProps {
1618
file: string;
19+
thread?: string;
1720
className?: string
1821
messagesHeight?: string
22+
enableThreads?: boolean
23+
setThreads?: React.Dispatch<React.SetStateAction<Thread[]>>
24+
setSelectedThreadId?: React.Dispatch<React.SetStateAction<string | null>>
1925
}
2026

21-
const Script: React.FC<ScriptProps> = ({ file, className, messagesHeight = 'h-full' }) => {
27+
const Script: React.FC<ScriptProps> = ({ file, thread, setThreads, className, messagesHeight = 'h-full', enableThreads, setSelectedThreadId }) => {
2228
const [tool, setTool] = useState<Tool>({} as Tool);
2329
const [showForm, setShowForm] = useState(true);
2430
const [formValues, setFormValues] = useState<Record<string, string>>({});
@@ -36,6 +42,10 @@ const Script: React.FC<ScriptProps> = ({ file, className, messagesHeight = 'h-fu
3642
setIsEmpty(!tool.instructions);
3743
}, [tool]);
3844

45+
useEffect(() => {
46+
if(thread) restartScript();
47+
}, [thread]);
48+
3949
useEffect(() => {
4050
if (hasRun || !socket || !connected) return;
4151
if ( !tool.arguments?.properties || Object.keys(tool.arguments.properties).length === 0 ) {
@@ -44,10 +54,10 @@ const Script: React.FC<ScriptProps> = ({ file, className, messagesHeight = 'h-fu
4454
const workspace = await getWorkspaceDir()
4555
return { path, workspace}
4656
})
47-
.then(({path, workspace}) => { socket.emit("run", path, tool.name, formValues, workspace) });
57+
.then(({path, workspace}) => { socket.emit("run", path, tool.name, formValues, workspace, thread)});
4858
setHasRun(true);
4959
}
50-
}, [tool, connected, file, formValues]);
60+
}, [tool, connected, file, formValues, thread]);
5161

5262
useEffect(() => {
5363
if (inputRef.current) {
@@ -61,10 +71,8 @@ const Script: React.FC<ScriptProps> = ({ file, className, messagesHeight = 'h-fu
6171

6272
useEffect(() => {
6373
const smallBody = document.getElementById("small-message");
64-
if (smallBody) {
65-
smallBody.scrollTop = smallBody.scrollHeight;
66-
}
67-
}, [messages]);
74+
if (smallBody) smallBody.scrollTop = smallBody.scrollHeight;
75+
}, [messages, connected, running]);
6876

6977
const handleFormSubmit = () => {
7078
setShowForm(false);
@@ -74,7 +82,7 @@ const Script: React.FC<ScriptProps> = ({ file, className, messagesHeight = 'h-fu
7482
const workspace = await getWorkspaceDir()
7583
return { path, workspace}
7684
})
77-
.then(({path, workspace}) => { socket?.emit("run", path, tool.name, formValues, workspace) });
85+
.then(({path, workspace}) => { socket?.emit("run", path, tool.name, formValues, workspace, thread) });
7886
setHasRun(true);
7987
};
8088

@@ -85,10 +93,19 @@ const Script: React.FC<ScriptProps> = ({ file, className, messagesHeight = 'h-fu
8593
}));
8694
};
8795

88-
const handleMessageSent = (message: string) => {
96+
const handleMessageSent = async (message: string) => {
8997
if (!socket || !connected) return;
98+
99+
let threadId = "";
100+
if (hasNoUserMessages() && enableThreads && !thread && setThreads && setSelectedThreadId) {
101+
const newThread = await createThread(file)
102+
threadId = newThread?.meta?.id;
103+
setThreads( await getThreads());
104+
setSelectedThreadId(threadId);
105+
}
106+
90107
setMessages((prevMessages) => [...prevMessages, { type: MessageType.User, message }]);
91-
socket.emit("userMessage", message);
108+
socket.emit("userMessage", message, threadId);
92109
};
93110

94111
const restartScript = useCallback(
@@ -103,6 +120,8 @@ const Script: React.FC<ScriptProps> = ({ file, className, messagesHeight = 'h-fu
103120
[file, restart]
104121
);
105122

123+
const hasNoUserMessages = useCallback(() => messages.filter((m) => m.type === MessageType.User).length === 0, [messages]);
124+
106125
return (
107126
<div className={`h-full w-full ${className}`}>
108127
{(connected && running)|| (showForm && hasParams) ? (<>
@@ -112,9 +131,9 @@ const Script: React.FC<ScriptProps> = ({ file, className, messagesHeight = 'h-fu
112131
>
113132
{showForm && hasParams ? (
114133
<ToolForm
115-
tool={tool}
116-
formValues={formValues}
117-
handleInputChange={handleInputChange}
134+
tool={tool}
135+
formValues={formValues}
136+
handleInputChange={handleInputChange}
118137
/>
119138
) : (
120139
<Messages restart={restartScript} messages={messages} />

components/script/messages.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@ import Calls from "./messages/calls"
99
import { GoIssueReopened } from "react-icons/go";
1010

1111
export enum MessageType {
12-
User,
13-
Bot,
1412
Alert,
13+
Agent,
14+
User,
1515
}
1616

1717
export type Message = {
@@ -39,7 +39,7 @@ const Message = ({ message, noAvatar, restart }: { message: Message ,noAvatar?:
3939
</p>
4040
</div>
4141
);
42-
case MessageType.Bot:
42+
case MessageType.Agent:
4343
return (
4444
<div className="flex flex-col items-start mb-10">
4545
<div className="flex gap-2 w-full">
@@ -62,7 +62,7 @@ const Message = ({ message, noAvatar, restart }: { message: Message ,noAvatar?:
6262
</Tooltip>
6363
)}
6464
<div
65-
className={`w-[93%] rounded-2xl text-black dark:text-white pt-1 px-4 border dark:border-none dark:bg-zinc-900 ${
65+
className={`w-full rounded-2xl text-black dark:text-white pt-1 px-4 border dark:border-none dark:bg-zinc-900 ${
6666
message.error ? "border-danger dark:border-danger" : ""
6767
}`}
6868
>
@@ -78,7 +78,7 @@ const Message = ({ message, noAvatar, restart }: { message: Message ,noAvatar?:
7878
{message.component}
7979
{message.error && (
8080
<>
81-
<p className="text-danger text-base pl-4 pb-6">{message.error}</p>
81+
<p className="text-danger text-base pl-4 pb-6">{`${JSON.stringify(message.error)}`}</p>
8282
<Tooltip
8383
content="If you are no longer able to chat, click here to restart the script."
8484
closeDelay={0.5}

0 commit comments

Comments
 (0)