I give you a challenge: can you build SolveIt in SolveIt in SolveIt?
Agents, Approaching AI, LLMs, SolveIt, Programming, HTML
Reimplementing a light version of SolveIt with FastHTML, Basecoat, and within SolveIt.
Author
Salman Naqvi
Published
Monday, 20 January 2025
This app is built on the techniques taught in Lessons 4, 5, and 6 of the SolveIt course led by Jeremy Howard who created the first modern LLM in 2018, and Johnothan Whitaker. SolveIt is also a brilliant problem-solving framework and interactive platform, that puts you in control–not the LLM.
This blog post is a rendered documentation of a very, very basic reimplementation of SolveIt using FastHTML as the web framework. The main goal of this exercise was to exercise my web development and HTMX skillz. Play with the app at this link. Or watch the brief overview below.
SolveIt is an IDE of sorts that’s not only tailored towards coding, but a variety of other tasks from writing to problem solving to planning to learning to exploring and what not. Its premise revolves around the LLM putting the human in control, and thereby boosting human performance and capability. A core part of why SolveIt works so well is that your work is the LLM context.
Many parts of this app can still be improved, refactored, or made more efficient. Play with the app at this link.
On branch main
Your branch is up to date with 'origin/main'.
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: 29_lets_build_an_ai_model.ipynb
Untracked files:
(use "git add <file>..." to include in what will be committed)
../images/31_solveit_in_solveit/31_solveit_in_solveit.ipynb
no changes added to commit (use "git add" and/or "git commit -a")
.delete_where with no arguments passed will drop all rows from the table.
New Dialog
Creating a new dialog first auto-increments dialog_num based on the user’s existing dialogs, then sets session['current_dialog'] to track which dialog is active. The dialog is stored in the dialogs table for persistence across sessions, while also being loaded into user_chats as a live chat object for the current server session.
from lisette.core import*from toolslm.shell import get_shellsp ='Respond in the language the user uses; The user has access to a Python interpreter that you can view; NEVER copy the format of the code interpreter messsages in your response.'def make_chat(hist=[]): return Chat('moonshot/kimi-k2-0711-preview', hist=hist, sp=sp), get_shell()
:::
make_chat()
(<lisette.core.Chat at 0x7abddf926ea0>,
<IPython.terminal.interactiveshell.TerminalInteractiveShell at 0x7abddf9486e0>)
As my Kimi API key is compatable with servers in China, I have to change the Moonshoot endpoint.
I can see the same Python interpreter you’re using.
Whenever you run code in it, the full session—inputs, outputs, errors, variables, imports, even matplotlib plots—is visible to me in real time, so I can comment on or debug it without you having to paste anything.
I’ll be handling session persistance with this user_chats dictionary. If my server restarts, it means user_chats will be erased and thus a new session will be created for all users. Users can input their restore code to retrieve back their data.
from fastcore.allimport*import jsondef ensure_dialog(session): uid, dnum =map(session.get, ('userid', 'current_dialog'))if (uid, dnum) in user_chats: returnelif dnum and (uid, dnum) in dialogs: d = dialogs[(uid, dnum)] user_chats[(uid, dnum)] = make_chat(json.loads(d.messages))else: create_dialog(session)
:::
ensure_dialog acts as a lazy loader for chat sessions. It first checks whether the user’s current dialog is already active in memory—if so, there’s nothing to do. If the dialog exists in the database but hasn’t been loaded yet, it retrieves the stored messages and reconstructs the chat object. Otherwise, when no dialog exists at all, it creates a fresh one for the user.
ensure_dialog(fake_session)user_chats
{(1, 1): (<lisette.core.Chat at 0x7abdd55438f0>,
<IPython.terminal.interactiveshell.TerminalInteractiveShell at 0x7abdd5351730>)}
The cookie only stores the user id. The data for the user is stored inside the current Python runtime. Once the runtime ends, the data is lost. The database permanently stores everything.
{(1, 1): (<lisette.core.Chat at 0x7abdd55438f0>,
<IPython.terminal.interactiveshell.TerminalInteractiveShell at 0x7abdd5351730>),
(1, 2): (<lisette.core.Chat at 0x7abdd52451c0>,
<IPython.terminal.interactiveshell.TerminalInteractiveShell at 0x7abdd5245100>)}
Helper functions for managing chat sessions and rendering responses. get_chat retrieves the active chat and shell for the current user’s dialog. sync_hist persists the conversation history to the database. mk_reply generates the UI response—a pair of bubbles (user input and model output) along with an OOB swap to clear the input field.
user_chats
{(1, 1): (<lisette.core.Chat at 0x7abdd55438f0>,
<IPython.terminal.interactiveshell.TerminalInteractiveShell at 0x7abdd5351730>),
(1, 2): (<lisette.core.Chat at 0x7abdd52451c0>,
<IPython.terminal.interactiveshell.TerminalInteractiveShell at 0x7abdd5245100>)}
def sync_hist(session, hist): dialogs.update(uid=session['userid'], dialog_num=session['current_dialog'], messages=json.dumps([h.model_dump() ifhasattr(h, 'model_dump') else h for h in hist]))
:::
c = Chat('moonshot/kimi-k2-0711-preview', sp=sp)c('你是谁?'); c.hist
I’ve added a type key to allow me to know in what style to render the message (blue for 'code', red for 'prompt', and green for 'note') when I load them.
{(1, 1): (<lisette.core.Chat at 0x7abdd55438f0>,
<IPython.terminal.interactiveshell.TerminalInteractiveShell at 0x7abdd5351730>),
(1, 2): (<lisette.core.Chat at 0x7abdd52451c0>,
<IPython.terminal.interactiveshell.TerminalInteractiveShell at 0x7abdd5245100>)}
Code Route
sh = get_shell(); sh
<IPython.terminal.interactiveshell.TerminalInteractiveShell at 0x7abdd40e5bb0>
ex_code similarly also stores the conversation with a type marker and syncs with the database. A special '[INTERPRETER INPUT/OUTPUT]' marker is also added as indicators for the LLM.
Three editor components for the different message types. Each editor is a form that posts to its corresponding route (ex_code, ask_llm, or add_note) and appends the result to the message area.
The editor routes need the session cookie for translations because each editor is loaded in a separate request. Since ContextVar resets with each new request, we need to re-set the language from the session at the start of each request.
A tabbed navigation bar that lets users switch between the three editor types—code, prompt, and note. Each tab triggers an HTMX request to load the corresponding editor form into the editor container.
@rtdef load_messages(session): c,sh = get_chat(session)for i in c.hist: role = i.get('role') ifisinstance(i, dict) else i.roleif role=='user': yield Div(Bubble(i['content'].replace('[INTERPRETER INPUT]\n', ''), i['type']), cls='space-y-0.5')if role=='assistant': yield Div(Bubble(i['content'].replace('[INTERPRETER OUTPUT]\n', ''), i['type'], 'r'), cls='space-y-0.5')
:::
prev(f'/load_messages')
Dialog
The dialog component ties everything together—a title bar that supports inline editing, a message display area that loads existing conversation history, and the tabbed editor for adding new messages. Clicking a dialog name makes it editable, and saving updates the database immediately.
{(1, 1): (<lisette.core.Chat at 0x7abdd55438f0>,
<IPython.terminal.interactiveshell.TerminalInteractiveShell at 0x7abdd5351730>),
(1, 2): (<lisette.core.Chat at 0x7abdd52451c0>,
<IPython.terminal.interactiveshell.TerminalInteractiveShell at 0x7abdd5245100>)}
{(1, 2): (<lisette.core.Chat at 0x7abdd52451c0>,
<IPython.terminal.interactiveshell.TerminalInteractiveShell at 0x7abdd5245100>)}
Restore Dialog
The restore session feature allows users to recover their data if the server restarts or they switch devices. Users enter their unique restore code (generated at account creation), which looks up their account in the database. If found, it restores their session by setting the user ID, loading their first dialog, and redirecting to the main page. A destructive alert displays if the restore code is invalid.
The dialog list component displays all dialogs belonging to the current user. The display_dialogs route fetches all user dialogs from the database and includes a “new dialog” card that triggers dialog creation. The restore code form is also embedded here, allowing users to recover their session from a different device.
Build Start Time: 2026-01-16 01:30:30.917195+00:00
Step 1/14 : FROM plash:latest
---> 8c65e51fa2d2
Step 2/14 : ARG USER_UID=1000
---> Using cache
---> 781580c8e262
Step 3/14 : ARG USER_GID=1000
---> Using cache
---> dcf3956b8c50
Step 4/14 : RUN groupadd -g $USER_GID plash && useradd -u $USER_UID -g $USER_GID -m plash
---> Using cache
---> fe019dc49177
Step 5/14 : ENV PLASH_PRODUCTION=1
---> Using cache
---> a86e28497fc0
Step 6/14 : COPY .dockerignore setup.sh* ./
---> Using cache
---> 786f3c3d2c09
Step 7/14 : RUN if [ -f ./setup.sh ]; then echo "Found setup.sh, executing..." && chmod +x ./setup.sh && ./setup.sh; fi
---> Using cache
---> 3f80b2a299d3
Step 8/14 : COPY .dockerignore requirements.txt* ./
---> Using cache
---> dea62d9dd70b
Step 9/14 : RUN if [ -f ./requirements.txt ]; then echo "Found requirements.txt, executing..." && uv pip install -r ./requirements.txt --system; fi
---> Using cache
---> 241057bc8973
Step 10/14 : RUN chown -R plash:plash /app
---> Using cache
---> eb5b444c975f
Step 11/14 : USER plash
---> Using cache
---> d2eda950b2e1
Step 12/14 : ENV PATH="/home/plash/.local/bin:$PATH"
---> Using cache
---> 5631843ecbf7
Step 13/14 : EXPOSE 5001
---> Using cache
---> b1e69db470b1
Step 14/14 : ENTRYPOINT ["bash", "-c", "if [ -f ./plash.env ]; then . ./plash.env; fi && python main.py"]
---> Using cache
---> 5ad4e58136f3
Successfully built 5ad4e58136f3
Successfully tagged 9d102cbb-e493-4ce7-9110-09dbf5c01851:latest
Build End Time: 2026-01-16 01:30:31.194696+00:00
print(pc.logs(mode='app'))
INFO: Will watch for changes in these directories: ['/app']
INFO: Uvicorn running on http://0.0.0.0:5001 (Press CTRL+C to quit)
INFO: Started reloader process [1] using WatchFiles
Link: http://localhost:5001
INFO: Started server process [14]
INFO: Waiting for application startup.
INFO: Application startup complete.