first commit

This commit is contained in:
2026-07-22 13:48:46 +08:00
commit c87751c3dc
2820 changed files with 726976 additions and 0 deletions
+46
View File
@@ -0,0 +1,46 @@
# OS, editor, and local dotfiles
.DS_Store
.*
!**/.gitignore
# Python caches and build artifacts
**/__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info/
# Virtual environments
.venv/
# Local secrets and provider configuration
**/provider.json
# Runtime data, logs, and generated reports
*.log
*.diff
*.patch
log*/
pytest-cache-files-*/
.pytest_cache/
sandbox/
sandbox*/
**/data/
**/agent_logs/*
agent_logs/*
**/trace_indices/
# Non-public experiment and image assets
experiment/
experiments/
img/
# Generated documentation directories
**/docs/
# Workspace: keep skill references, ignore generated outputs
workspace/*
!workspace/.gitignore
!workspace/skills/
!workspace/skills/**
+339
View File
@@ -0,0 +1,339 @@
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Lesser General Public License instead.) You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program; if not, write to the Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`Gnomovision' (which makes passes at compilers) written by James Hacker.
<signature of Ty Coon>, 1 April 1989
Ty Coon, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License.
+707
View File
@@ -0,0 +1,707 @@
<div align="center">
# CADDesigner: Conceptual CAD Model Generation with a General-Purpose Agent
Fengxiao Fan\* &middot; Jingzhe Ni\* &middot; Xiaolong Yin &middot; Sirui Wang &middot; Xingyu Lu &middot; Qiang Zou &middot; Ruofeng Tong &middot; Min Tang &middot; Peng Du&dagger;
Zhejiang University, China
(\* equal contribution, &dagger; corresponding author)
[![Paper PDF](https://img.shields.io/badge/Paper-PDF-b31b1b.svg)](https://562590763.github.io/CADDesigner/files/CADDesigner.pdf)
[![Elsevier](https://img.shields.io/badge/Elsevier-Published-f36c21.svg)](https://www.sciencedirect.com/science/article/pii/S0010448526000576)
[![Project Page](https://img.shields.io/badge/Project%20Page-Website-blue.svg)](https://562590763.github.io/CADDesigner/)
[![Code](https://img.shields.io/badge/Code-GitHub-black.svg)](https://github.com/562590763/CADDesigner-Code)
<img src="assets/framework.png" alt="CADDesigner framework" width="92%">
</div>
## News
- **2026.05**: CADDesigner is published in *Computer-Aided Design*.
- **Code release**: This repository contains the public implementation and setup instructions.
## Abstract
Computer-Aided Design (CAD) plays a pivotal role in industrial manufacturing but typically requires a high level of expertise from designers. To lower the entry barrier and improve design efficiency, CADDesigner presents an LLM-powered general-purpose agent for conceptual CAD design. The agent accepts textual descriptions and sketches as input, interacts with users to refine and clarify design requirements, and generates executable CAD modeling code.
CADDesigner is built around the **Explicit Context Imperative Paradigm (ECIP)**, which makes modeling context, intermediate state, and operation intent explicit during code generation. During generation, the agent uses execution feedback and rendered visual feedback to repair the CAD program iteratively. Generated design cases can be stored in a structured knowledge base, providing a path for continual improvement of CAD code generation.
## Highlights
- **Natural-language CAD modeling**: describe a CAD model in text and let the agent generate executable modeling code.
- **Sketch-aware conceptual design**: use sketch or image references together with text prompts.
- **Requirement refinement**: expand vague user requests into structured modeling specifications before code generation.
- **ECIP-based CAD code generation**: represent modeling context and operation state explicitly for more reliable code synthesis.
- **Execution and automatic repair**: run generated code, inspect errors, and repair common CAD modeling failures.
- **Visual feedback loop**: compare rendered model views with the design requirement and revise the model when needed.
- **Interactive development**: support command-line use, API service mode, and a React Web UI.
- **File and artifact management**: save generated scripts, STEP/STL files, rendered images, and conversation context.
## Method Overview
CADDesigner follows a ReAct-style agent workflow for conceptual CAD generation:
1. **Receive Requirement**: the user provides a text prompt and optionally a sketch or reference image.
2. **Requirement Expansion**: a specialist subagent expands the request into dimensions, constraints, assumptions, APIs, and an ordered modeling process.
3. **Code Generation**: CADDesigner produces executable CAD modeling code with explicit context and operation intent.
4. **Execution and Export**: the generated script is executed and exports STEP/STL artifacts.
5. **Error Handling**: tracebacks and missing artifacts trigger automatic repair.
6. **Visual Feedback**: rendered views are checked against the user intent.
7. **Task Completion**: final scripts and exported model artifacts are returned to the user.
## 🚀 Features
- **Natural-language CAD modeling**: describe a 3D model in plain language and let the agent generate accurate CAD code.
- **Multi-framework support**: supports CADQuery, SimpleCADAPI, PythonOCC-related workflows, and extensible CAD tooling.
- **Interactive development**: generate, execute, inspect, and debug CAD code in an iterative loop.
- **Intelligent query expansion**: automatically expands ambiguous requirements into detailed modeling specifications.
- **File management**: built-in file tools help save, inspect, and organize generated models.
- **Error handling and automatic repair**: detects common CAD modeling failures and attempts to repair them.
- **Web interface**: provides a FastAPI backend and React frontend for multi-session interaction.
### Core Components
1. **BaseAgent**: the main AI agent that coordinates the full workflow.
2. **Tools**: specialized tools for CAD code generation, file operations, command execution, rendering, and feedback.
3. **Config**: manages LLM provider configuration, API keys, and model routing.
4. **CLI Interface**: rich terminal-based interaction for local modeling.
5. **Web Interface**: FastAPI service and React UI for browser-based use.
6. **Skill References**: CAD API and workflow references used by specialist subagents under `workspace/skills/`.
## 📋 Prerequisites
### System Requirements
- Python 3.12, as specified in `pyproject.toml`
- Docker, if you want to use the bundled Redis service or full Docker deployment
- Node.js and pnpm, if you want to run the React frontend in development mode
### Required Python Packages
This project uses [uv](https://github.com/astral-sh/uv) for fast and reliable dependency management.
Install all dependencies with:
```bash
uv sync
```
In the development environment, `pyproject.toml` and `uv.lock` are the source of truth for dependencies. The committed `requirements.txt` is exported from `uv.lock` for Docker and deployment environments.
## ⚙️ Configuration
### 1. Environment Variables
The project reads environment variables from a `.env` file in the repository root. Create one from the provided template:
```bash
cp docker/env.example .env
```
Configurable variables include:
- **Redis configuration**
- `REDIS_DB`: Redis database index
- `REDIS_PASSWORD`: Redis password
- `REDIS_HOST`: Redis host
- `REDIS_PORT`: Redis port
- **Storage configuration**
- `CONTEXT_DIR`: context storage directory
- `CONTEXT_AUTO_SUMMARIZE_TRIGGER`: automatic summarization threshold, defaulting to `1000000`, which effectively disables automatic summarization
- `SKETCH_DIR`: SketchPad storage directory
- **Observability configuration, optional**
- `LANGFUSE_SECRET_KEY`: Langfuse secret key
- `LANGFUSE_PUBLIC_KEY`: Langfuse public key
- `LANGFUSE_BASE_URL`: Langfuse service URL
### 2. LLM Provider Configuration
Generate the provider configuration file from the template and then edit it:
```bash
cp config/provider_template.json config/provider.json
```
Edit `config/provider.json` with your own provider settings and API keys:
```json
{
"volc_engine": [
{
"model_name": "deepseek-v3-250324",
"api_keys": ["your_api_key_here"],
"base_url": "https://ark.cn-beijing.volces.com/api/v3/",
"max_retries": 3,
"retry_delay": 1
}
],
"openrouter": [
{
"model_name": "anthropic/claude-sonnet-4.6",
"api_keys": ["your_api_key_here"],
"base_url": "https://openrouter.ai/api/v1"
},
{
"model_name": "google/gemini-3.1-pro-preview",
"api_keys": ["your_api_key_here"],
"base_url": "https://openrouter.ai/api/v1"
},
{
"model_name": "google/gemini-3-flash-preview",
"api_keys": ["your_api_key_here"],
"base_url": "https://openrouter.ai/api/v1"
}
]
}
```
Do not commit real API keys.
### 3. LLM Interface Configuration
The agent uses different LLM interfaces for different tasks:
- **BASIC_INTERFACE**: general conversation and coordination
- **CODE_INTERFACE**: CAD code generation and repair, usually requiring a stronger model
- **QUICK_INTERFACE**: query expansion and lightweight tasks
These routes are configured in `config/config.py` and `config/provider.json`.
## 🚀 Quick Start
### Installation
1. Clone the repository:
```bash
git clone https://github.com/562590763/CADDesigner-Code.git
cd CADDesigner
```
2. Install dependencies:
```bash
uv sync
```
3. Install the repository Git hook, optional for development:
```bash
./scripts/install_git_hooks.sh
```
The hook automatically runs `uv export` before commits, refreshing and staging `requirements.txt`.
4. Configure your LLM provider as described above.
5. Start Redis, required by the backend.
The backend uses Redis in CLI, API, and Web modes. By default, the configuration reads `REDIS_HOST=localhost` and `REDIS_PORT=9736` from `.env`. If you do not already have a local Redis instance, use the bundled Docker Compose file:
```bash
# Start local development Redis
docker compose -f docker/docker-compose.redis.yml up -d
# Check Redis status
docker compose -f docker/docker-compose.redis.yml ps
# Stop and remove the Redis container
docker compose -f docker/docker-compose.redis.yml down
```
You can also verify Redis manually:
```bash
docker compose -f docker/docker-compose.redis.yml ps
redis-cli -p 9736 ping
```
### Run the Agent
#### Method 1: Command-Line Interface
Start interactive CADDesigner:
```bash
LOG_LEVEL=WARNING uv run python main.py
# Press Ctrl+D after finishing multi-line input
```
#### Method 2: Web Interface, Recommended
The current Web stack uses a FastAPI backend and a React frontend.
**Option A: one-command startup, recommended**
```bash
# Start both the API server and the React Web UI
uv run python start_caddesigner_full.py
```
**Option B: start services separately**
```bash
# Terminal 1: start the API server
uv run python start_caddesigner_api.py
# Terminal 2: start the Web UI
uv run python start_caddesigner_ui.py
```
Access the Web interface:
- **Local React Web UI**: <http://127.0.0.1:7860>
- **Local API docs**: <http://127.0.0.1:8000/docs>
- **Local health check**: <http://127.0.0.1:8000/health>
If you start with `--api-host 0.0.0.0` or `--ui-host 0.0.0.0` and want to access from another machine, replace `127.0.0.1` with the server IP or domain:
- **Remote React Web UI**: `http://<server-ip>:7860`
- **Remote API docs**: `http://<server-ip>:8000/docs`
#### Method 3: Start the Frontend Directly Under `frontend/`
The repository also provides a TypeScript + React frontend under `frontend/`, suitable for frontend development and richer multi-session agent chat.
```bash
# Terminal 1: start API
uv run python start_caddesigner_api.py
# Terminal 2: start React UI
cd frontend
pnpm install
pnpm dev
```
Access addresses:
- **React UI**: <http://localhost:4173>
- **Backend API**: <http://localhost:8000>
#### Advanced Configuration
**Custom ports and hosts**
```bash
# Custom configuration
uv run python start_caddesigner_full.py \
--api-port 8001 \
--ui-port 7861 \
--api-host 0.0.0.0 \
--ui-host 0.0.0.0
# Development mode with reload
uv run python start_caddesigner_full.py --reload --debug
# By default, generated runtime artifacts are written under ./workspace.
# Production-style deployment
uv run python start_caddesigner_full.py \
--workers 4 \
--working-dir /var/lib/caddesigner
```
**Remote deployment**
```bash
# Server A: API service
uv run python start_caddesigner_api.py --host 0.0.0.0 --port 8000
# Server B: Web UI pointing to Server A
uv run python start_caddesigner_ui.py \
--host 0.0.0.0 \
--port 7860 \
--api-url http://server-a:8000
```
For detailed startup options, see [STARTUP_GUIDE.md](STARTUP_GUIDE.md).
#### Method 4: Docker Deployment, Recommended for Production
Docker can quickly deploy the full CADDesigner system, including Redis, the FastAPI API service, and the React Web UI.
Notes:
- `docker/docker-compose.yml` starts **Redis + API + React Web UI** together.
- `docker/docker-compose.redis.yml` starts only Redis, useful for local non-Docker backend development.
- The React Web UI is exposed on port `7860` in Docker.
**Preparation**
1. Configure model providers:
```bash
cp docker/provider_template.json docker/provider.json
```
Edit `docker/provider.json` with your model provider and API key settings.
2. Configure Docker Compose environment variables:
```bash
cp docker/env.example docker/.env
```
Edit `docker/.env` if needed. Common values include:
```bash
# Storage directories
CONTEXT_DIR=workspace/data/contexts
CONTEXT_AUTO_SUMMARIZE_TRIGGER=1000000
SKETCH_DIR=workspace/data/sketches
# Logging
LOG_DIR=workspace/agent_logs
LOG_LEVEL=WARNING
```
**Start services**
```bash
docker compose -f docker/docker-compose.yml up -d --build
```
The image build uses `requirements.txt`, which is exported from `uv.lock`. When dependency changes are committed, make sure the Git hook has refreshed `requirements.txt`.
The Compose file starts three services:
- `simplecad_redis`: Redis
- `simplecad_api`: FastAPI API service
- `simplecad_webui`: React Web UI, with Vite proxying to the API
**Access services**
- **Local React Web UI**: <http://127.0.0.1:7860>
- **Local API service**: <http://127.0.0.1:8000>
- **Local API docs**: <http://127.0.0.1:8000/docs>
If accessing from another machine, replace `127.0.0.1` with the host IP or domain:
- **Remote React Web UI**: `http://<your-host-or-ip>:7860`
- **Remote API service**: `http://<your-host-or-ip>:8000`
- **Remote API docs**: `http://<your-host-or-ip>:8000/docs`
**Manage services**
```bash
# Show service status
docker compose -f docker/docker-compose.yml ps
# Follow logs
docker compose -f docker/docker-compose.yml logs -f
# Stop services
docker compose -f docker/docker-compose.yml down
# Restart services
docker compose -f docker/docker-compose.yml restart
# Remove all volumes, use with care
docker compose -f docker/docker-compose.yml down -v
```
**Production deployment suggestions**
1. Change default ports if needed:
```yaml
# Edit docker/docker-compose.yml
ports:
- "your_port:7860" # React UI port
- "your_port:8000" # API port
```
2. Configure a reverse proxy, for example with Nginx:
```nginx
server {
listen 80;
server_name your-domain.com;
location / {
proxy_pass http://localhost:7860;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /v1/ {
proxy_pass http://localhost:8000/v1/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /health {
proxy_pass http://localhost:8000/health;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
```
### Basic Usage Examples
1. **Simple object creation**
```text
Create a cube with side length 50 mm.
```
2. **Complex mechanical part**
```text
Create a DN100 PN16 welding flange according to the ASME B16.5 standard.
```
3. **Parametric model**
```text
Design a gear with 18 teeth, module 2.0, and pressure angle 20 degrees.
```
4. **Sketch-conditioned design**
```text
Use the attached sketch as reference and generate the corresponding CAD model.
```
### Workflow
1. **Query input**: describe the CAD model in natural language and optionally provide a sketch.
2. **Requirement expansion**: the agent asks clarification questions and expands the requirement.
3. **Code generation**: CADDesigner generates CAD modeling code.
4. **Execution and export**: the code is executed and exports STEP/STL artifacts.
5. **Error handling**: the agent automatically repairs issues when possible.
6. **Visual feedback**: rendered model views are checked against the design intent.
7. **Task completion**: final code and generated model artifacts are returned.
## 🛠️ Development Guide
### Dependency Workflow
- Development and lock file: use `pyproject.toml` + `uv.lock`.
- Local installation: run `uv sync`.
- Docker export: the pre-commit hook installed by `./scripts/install_git_hooks.sh` automatically generates and stages `requirements.txt` before commits.
- Manual export:
```bash
./scripts/export_requirements.sh
```
### Project Structure
```text
agent/ Core agent implementation
config/ LLM provider and routing configuration
context/ Conversation, context, and SketchPad management
docker/ Dockerfiles and Compose templates
frontend/ React Web UI
observability/ Langfuse tracing integration
scripts/ Development helper scripts
tools/ CAD generation, command, file, rendering, and repair tools
web_interface/ FastAPI server and routers
workspace/skills/ CAD skill and API references used by subagents
```
#### `agent/BaseAgent.py`
The core agent is responsible for:
- managing conversation history and memory
- coordinating tool use
- handling streaming responses
- implementing memory-management strategies
Key methods:
- `run()`: main execution method
- `memory_manage()`: summarizes and manages conversation history
- `chat_impl()`: core chat logic and detailed agent instructions
#### `tools/`
The tools package contains specialized tools:
- **`make_user_query_more_detailed`**: expands user requirements into detailed modeling specifications.
- **`cad_code_generator`**: a single-call specialist subagent that creates, modifies, and debugs `model.py` with built-in file tools and command execution.
- **`execute_command`**: runs system commands and modeling scripts.
- **`sketch_pad_operations`**: stores, retrieves, searches, and manages SketchPad data.
- **`get_visual_feedback`**: renders and evaluates generated models with visual feedback.
- **SimpleLLMFunc built-in file tools**: `read_file`, `grep`, `sed`, and `echo_into`.
#### `config/config.py`
This module manages:
- LLM provider configuration
- API key loading
- model-selection strategy
- routing between basic, code, and quick interfaces
### Adding New Tools
Add a new CAD tool:
1. Create the new function in the appropriate split module, such as `tools/code_tools.py`, `tools/command_tools.py`, `tools/requirements_tools.py`, or a new module under `tools/`:
```python
@tool(
name="your_tool_name",
description="What your tool does"
)
def your_tool_function(param1: str, param2: int) -> str:
"""
Your tool implementation.
"""
# Tool logic goes here.
return result
```
2. Export and register the tool in the toolkit. For example, expose it from `tools/__init__.py` and add it to the agent toolkit where the tool list is assembled:
```python
toolkit = [
make_user_query_more_detailed,
cad_code_generator,
execute_command,
sketch_pad_operations,
get_visual_feedback,
# SimpleLLMFunc built-in file tools are created from create_builtin_file_tools(...)
your_tool_function, # Add your new tool here.
]
```
### Extending LLM Support
To add a new LLM provider:
1. Add the provider configuration to `config/provider.json`.
2. Update `config/config.py` if additional routing logic is needed.
3. Test the provider on requirement refinement, code generation, and repair tasks.
### Customizing Agent Behavior
Agent behavior is defined by the orchestration logic and prompts in `agent/` and `tools/`. You can customize:
- response format
- tool-use strategy
- error-handling strategy
- visual-feedback policy
- memory-management strategy
## 🧪 Testing
### Manual Testing
```bash
uv run python main.py
```
Example prompt:
```text
Create a simple cylinder with radius 10 mm and height 20 mm.
```
### Automated Testing
Run tests with:
```bash
uv run pytest
```
Recommended test coverage includes:
- code generation quality
- error handling
- file operations
- tool integration
- Web event streaming
## 📁 Output Management
Generated models are usually saved under task-specific directories. We recommend using `workspace/` as the unified output root:
```text
workspace/
|-- DN100_PN16_welding_flange/
| |-- model.py
| |-- model.step
| |-- model.stl
| `-- model_multi_view_render.png
`-- gear_18_teeth/
|-- model.py
|-- model.step
|-- model.stl
`-- model_multi_view_render.png
```
Generated artifacts and local workspaces should not be committed to the public repository.
## 🤝 Contributing
1. Fork the repository.
2. Create a feature branch: `git checkout -b feature-name`.
3. Make changes and test them thoroughly.
4. Submit a pull request with a clear description.
### Code Style
- Follow PEP 8 for Python code.
- Use type hints where possible.
- Add docstrings for public functions.
- Test new functionality before committing.
## 📝 License
This repository is released under the GPL-2.0 license. See [LICENSE](LICENSE) for details.
## 🆘 Support
If you have questions or need support:
1. Check the troubleshooting and startup documentation.
2. Search existing issues.
3. Create a new issue with detailed error information.
## 🔮 Roadmap
- [ ] Browser-based Web interface improvements
- [ ] Integration with more CAD formats, such as IGES and STL
- [ ] Advanced parametric modeling
- [ ] Integration with simulation tools
- [ ] Multilingual support
- [ ] CAD model optimization suggestions
- [ ] Manufacturing database integration
## Citation
If you find this work useful, please cite:
DOI: [10.1016/j.cad.2026.104087](https://doi.org/10.1016/j.cad.2026.104087)
```bibtex
@article{fan2026caddesigner,
author = {Fengxiao Fan and Jingzhe Ni and Xiaolong Yin and Sirui Wang and Xingyu Lu and Qiang Zou and Ruofeng Tong and Min Tang and Peng Du},
title = {{CADDesigner}: Conceptual CAD Model Generation with a General-Purpose Agent},
journal = {Computer-Aided Design},
volume = {198},
pages = {104087},
year = {2026},
doi = {10.1016/j.cad.2026.104087}
}
```
## Acknowledgements
This work was supported by the Leading Goose R&D Program of Zhejiang under Grant No. 2024C01103.
CADDesigner builds on open-source CAD and agent tooling, including CADQuery, PythonOCC, FastAPI, React, uv, and the broader Python CAD ecosystem. We thank the maintainers and contributors of these projects.
---
**Note**: This project is designed for research, education, and professional CAD modeling assistance. Always verify that generated models satisfy your specific design, manufacturing, and safety requirements.
+74
View File
@@ -0,0 +1,74 @@
# CADDesigner Startup Guide
The repository currently keeps one Web runtime stack: FastAPI backend plus React frontend.
The old `web_ui/` Gradio implementation has been removed.
## Recommended Startup
### Start Backend And Frontend Together
```bash
python start_caddesigner_full.py
```
Default addresses:
- React UI: `http://localhost:7860`
- API server: `http://localhost:8000`
- API docs: `http://localhost:8000/docs`
### Start Backend Only
```bash
python start_caddesigner_api.py
```
### Start Frontend Only
```bash
python start_caddesigner_ui.py
```
This is useful when the API is already running locally or remotely. Use `--api-url` to switch the target API address.
## Frontend Development Mode
If you are developing `frontend/` itself, start the backend separately and then run Vite directly:
```bash
python start_caddesigner_api.py --reload
cd frontend
pnpm install
pnpm dev
```
When Vite is run directly, the default address is `http://localhost:4173`.
## Common Arguments
`start_caddesigner_full.py`:
- `--api-host` / `--api-port`: backend listen address
- `--ui-host` / `--ui-port`: frontend listen address
- `--working-dir`: API process working directory. Defaults to `./workspace`, so generated scripts, STEP/STL files, and render artifacts are kept out of the repository root.
- `--reload`: auto-reload backend in development mode
- `--debug`: print additional debugging information
`start_caddesigner_api.py`:
- `--host` / `--port`: backend listen address
- `--working-dir`: API process working directory. Defaults to `./workspace`, so generated scripts, STEP/STL files, and render artifacts are kept out of the repository root.
- `--reload`: auto-reload backend in development mode
- `--workers`: number of worker processes when reload is disabled
`start_caddesigner_ui.py`:
- `--host` / `--port`: frontend listen address
- `--api-url`: backend address used by the Vite proxy
- `--debug`: print additional debugging information
## Notes
- `start_caddesigner_full.py` waits for the backend health check to pass before starting the React UI.
- `start_caddesigner_ui.py` automatically installs frontend dependencies when `frontend/node_modules/` is missing.
- The backend process still controls the business working directory and artifact paths. By default, runtime outputs are written under `workspace/` while the tracked skill references remain under `workspace/skills/`.
@@ -0,0 +1,324 @@
"""
Agent registration mechanism.
Used to manage multiple Agent instances and supports selecting different Agents by model name.
"""
from typing import Dict, Optional, Type, List, Any
from .BaseAgent import BaseAgent
from config.config import get_config
import threading
class AgentRegistry:
"""Agent registry, managing multiple Agent instances."""
def __init__(self):
self._agents: Dict[str, BaseAgent] = {}
self._agent_classes: Dict[str, Type[BaseAgent]] = {}
self._lock = threading.Lock()
def register_agent_class(self, model_name: str, agent_class: Type[BaseAgent]):
"""
Register an Agent class.
Args:
model_name: Model name, used for the model parameter in the API
agent_class: Agent class, inheriting from BaseAgent
"""
with self._lock:
self._agent_classes[model_name] = agent_class
def _create_agent_instance(
self,
model_name: str,
name: Optional[str] = None,
description: Optional[str] = None,
context_file: Optional[str] = None,
**kwargs
) -> BaseAgent:
"""
Internal method: create an Agent instance.
Args:
model_name: Model name
name: Agent name
description: Agent description
context_file: Context file path
**kwargs: Other parameters
Returns:
Agent instance
"""
if model_name not in self._agent_classes:
raise ValueError(f"Unknown model: {model_name}")
agent_class = self._agent_classes[model_name]
# Get configuration.
config = get_config()
# Use default values or passed-in parameters.
agent_name = name or f"{model_name}-agent"
agent_description = description or f"Agent instance for {model_name}"
# Create the Agent instance.
agent = agent_class(
name=agent_name,
description=agent_description,
llm_interface=config.BASIC_INTERFACE,
context_file=context_file,
model_name=model_name, # Pass model_name to the Agent.
**kwargs
)
return agent
def get_or_create_agent(
self,
model_name: str,
**kwargs
) -> BaseAgent:
"""
Get or create an Agent instance (singleton pattern).
Ensure each model_name corresponds to only one Agent instance.
Args:
model_name: Model name
**kwargs: Creation parameters
Returns:
Agent instance
"""
with self._lock:
if model_name not in self._agents:
self._agents[model_name] = self._create_agent_instance(model_name, **kwargs)
return self._agents[model_name]
def create_agent(
self,
model_name: str,
force_new: bool = False,
**kwargs
) -> BaseAgent:
"""
Create an Agent instance.
Args:
model_name: Model name
force_new: Whether to force creation of a new instance, replacing the existing instance
**kwargs: Other parameters
Returns:
Agent instance
"""
with self._lock:
if force_new or model_name not in self._agents:
self._agents[model_name] = self._create_agent_instance(model_name, **kwargs)
return self._agents[model_name]
def get_agent(self, model_name: str) -> Optional[BaseAgent]:
"""
Get an already-created Agent instance.
Args:
model_name: Model name
Returns:
Agent instance or None
"""
return self._agents.get(model_name)
def list_models(self) -> List[str]:
"""
List all registered model names.
Returns:
List of model names
"""
return list(self._agent_classes.keys())
def list_agents(self) -> List[str]:
"""
List all created Agent instances.
Returns:
List of model names for Agent instances
"""
return list(self._agents.keys())
def clear_agents(self):
"""Clear all Agent instances."""
with self._lock:
self._agents.clear()
def remove_agent(self, model_name: str) -> bool:
"""
Remove an Agent instance.
Args:
model_name: Model name
Returns:
Whether removal succeeded
"""
with self._lock:
if model_name in self._agents:
del self._agents[model_name]
return True
return False
def get_agent_info(self, model_name: str) -> Optional[Dict[str, Any]]:
"""
Get Agent information.
Args:
model_name: Model name
Returns:
Agent information dictionary or None
"""
agent = self.get_agent(model_name)
if agent:
return {
"model_name": model_name,
"name": agent.name,
"description": agent.description,
"agent_class": agent.__class__.__name__,
"toolkit_size": len(agent.toolkit),
"session_info": agent.get_session_info(),
"is_singleton": True # Mark this as a singleton instance.
}
return None
def get_all_agents_info(self) -> Dict[str, Dict[str, Any]]:
"""
Get information for all Agents.
Returns:
Dictionary of all Agent information
"""
result = {}
with self._lock:
for model_name in self._agents:
info = self.get_agent_info(model_name)
if info:
result[model_name] = info
return result
def is_agent_active(self, model_name: str) -> bool:
"""
Check whether the Agent with the specified model_name has been created.
Args:
model_name: Model name
Returns:
Whether it has been created
"""
return model_name in self._agents
def get_agent_stats(self) -> Dict[str, Any]:
"""
Get registry statistics.
Returns:
Statistics dictionary
"""
with self._lock:
return {
"registered_models": len(self._agent_classes),
"active_agents": len(self._agents),
"registered_model_list": list(self._agent_classes.keys()),
"active_agent_list": list(self._agents.keys())
}
# Global Agent registry instance.
_global_registry = AgentRegistry()
def get_agent_registry() -> AgentRegistry:
"""Get the global Agent registry."""
return _global_registry
def register_agent(model_name: str, agent_class: Type[BaseAgent]):
"""
Convenience function for registering an Agent class.
Args:
model_name: Model name
agent_class: Agent class
"""
_global_registry.register_agent_class(model_name, agent_class)
def get_agent(model_name: str, **kwargs) -> BaseAgent:
"""
Convenience function for getting or creating an Agent instance (singleton pattern).
Args:
model_name: Model name
**kwargs: Creation parameters, used only during first creation
Returns:
Agent instance
"""
return _global_registry.get_or_create_agent(model_name, **kwargs)
def get_existing_agent(model_name: str) -> Optional[BaseAgent]:
"""
Convenience function for getting an existing Agent instance.
Args:
model_name: Model name
Returns:
Agent instance or None
"""
return _global_registry.get_agent(model_name)
def create_new_agent(model_name: str, **kwargs) -> BaseAgent:
"""
Convenience function for forcing creation of a new Agent instance.
Args:
model_name: Model name
**kwargs: Creation parameters
Returns:
Agent instance
"""
return _global_registry.create_agent(model_name, force_new=True, **kwargs)
def list_available_models() -> List[str]:
"""
Convenience function for listing all available models.
Returns:
List of model names
"""
return _global_registry.list_models()
def get_registry_stats() -> Dict[str, Any]:
"""
Convenience function for getting registry statistics.
Returns:
Statistics dictionary
"""
return _global_registry.get_agent_stats()
def clear_all_agents():
"""
Convenience function for clearing all Agent instances.
"""
_global_registry.clear_agents()
# Also clear BaseAgent's class-level instance cache.
from .BaseAgent import BaseAgent
BaseAgent.clear_instances()
+473
View File
@@ -0,0 +1,473 @@
"""
BaseAgent is the base class for all agents. It defines the basic agent interface and common functionality.
All concrete agent implementations should inherit from this class and implement the abstract methods.
BaseAgent provides the following features:
- Singleton pattern
- Conversation history management
- SketchPad management
- Toolkit management
"""
from bootstrap_env import load_project_env
load_project_env()
from typing import (
Dict,
List,
Optional,
Generator,
Sequence,
Tuple,
AsyncGenerator,
Any,
)
from abc import ABC, abstractmethod
from SimpleLLMFunc import llm_chat, OpenAICompatible # type: ignore
import threading
from context.conversation_manager import get_current_context, get_current_sketch_pad
from context.schemas import Message
from react_stream import extract_output_text, is_response_yield
import json
import os
import uuid
class BaseAgent(ABC):
"""
Agent base class, defining the basic agent interface and common functionality.
All concrete agent implementations should inherit from this class and implement the abstract methods.
"""
# Class-level instance cache, ensuring a singleton for each Agent subclass.
_class_instances: Dict[str, "BaseAgent"] = {}
_class_lock = threading.Lock()
@classmethod
def get_instance(
cls,
model_name: str,
name: Optional[str] = None,
description: Optional[str] = None,
llm_interface: Optional[OpenAICompatible] = None,
**kwargs,
) -> "BaseAgent":
"""
Class method for obtaining an Agent instance (singleton pattern).
Args:
model_name: Model name
name: Agent name
description: Agent description
llm_interface: LLM interface
**kwargs: Other parameters
Returns:
Agent instance
"""
with cls._class_lock:
# Use the class name and model_name as the unique identifier.
instance_key = f"{cls.__name__}:{model_name}"
if instance_key not in cls._class_instances:
if not llm_interface:
# If llm_interface is not provided, try to obtain it from the configuration.
from config.config import get_config
config = get_config()
llm_interface = config.BASIC_INTERFACE
instance_name = name or f"{model_name}-agent"
instance_description = description or f"Agent instance for {model_name}"
cls._class_instances[instance_key] = cls(
name=instance_name,
description=instance_description,
llm_interface=llm_interface,
model_name=model_name,
**kwargs,
)
return cls._class_instances[instance_key]
@classmethod
def clear_instances(cls):
"""Clear all instance caches."""
with cls._class_lock:
cls._class_instances.clear()
@classmethod
def get_all_instances(cls) -> Dict[str, "BaseAgent"]:
"""Get all instances."""
return cls._class_instances.copy()
def __init__(
self,
name: str,
description: str,
llm_interface: Optional[OpenAICompatible] = None,
model_name: Optional[str] = None, # Add the model_name parameter.
**kwargs, # Extra parameters that subclasses can handle.
):
self.name = name
self.description = description
self.model_name = model_name # Store model_name.
self.llm_interface = llm_interface
if not self.llm_interface:
raise ValueError("llm_interface must be provided")
# Subclasses need to define their own toolkit.
self.toolkit = self.get_toolkit()
# Initialize the chat function.
self.chat = llm_chat(
llm_interface=self.llm_interface,
toolkit=self.toolkit, # type: ignore
stream=True,
return_mode="raw",
enable_event=True,
max_tool_calls=2000,
timeout=600,
temperature=1.0,
)(self.chat_impl)
@abstractmethod
def get_toolkit(self) -> Sequence[Any]:
"""
Get the agent-specific toolkit (abstract method).
Subclasses must implement this method to define their own toolkit.
Returns:
List of tool functions
"""
pass
@abstractmethod
def chat_impl(
self,
history: List[Dict[str, Any]],
query: Any,
sketch_pad_summary: str,
) -> Generator[Tuple[str, List[Dict[str, Any]]], None, None]:
"""
Agent conversation implementation logic (abstract method).
Subclasses must implement this method to define the concrete conversation behavior.
Args:
history: Conversation history
query: User query
sketch_pad_summary: SketchPad summary
Returns:
Generator yielding (response_chunk, updated_history)
"""
pass
@abstractmethod
def run(
self, query: Any, raw_user_content: Any = None
) -> AsyncGenerator[Any, None]:
"""
Run the agent to process the user query (abstract method).
Args:
query: User query
raw_user_content: Raw user message content (optional), used for persisting multimodal messages
Returns:
AsyncGenerator yielding response chunks
"""
pass
# Common helper methods.
def get_sketch_pad_summary(self) -> str:
"""Get SketchPad summary information, including all keys and truncated values."""
try:
sketch_pad = get_current_sketch_pad()
if sketch_pad is None:
return "SketchPad unavailable: no active conversation context"
# Get detailed information for all items (including values).
all_items = sketch_pad.list_items(include_value=True)
if not all_items:
return "SketchPad is empty: no stored content"
summary_lines = [f"Current SketchPad state ({len(all_items)} items total):"]
for item in all_items[:20]: # Limit display to the first 20 items.
key = item.key
tags = ", ".join(item.tags) if item.tags else "no tags"
timestamp = item.timestamp
content_type = item.content_type
# Use the value included in the list item for preview.
value_obj = item.value
value_str = str(value_obj) if value_obj is not None else ""
if len(value_str) > 100:
value_preview = value_str[:100] + "..."
else:
value_preview = value_str
value_preview = value_preview.replace("\n", "\\n")
summary_lines.append(
f" - {key}: [{content_type}] {value_preview} "
f"(tags: {tags}, time: {timestamp[:19]})"
)
if len(all_items) > 20:
summary_lines.append(f" ... {len(all_items) - 20} more items not shown")
return "\n".join(summary_lines)
except Exception as e:
return f"Error while retrieving SketchPad summary: {str(e)}"
# Convenience methods for context management.
def get_conversation_history(self, limit: Optional[int] = None):
"""Get the conversation history for the current session."""
context = get_current_context()
if context is None:
raise RuntimeError("No active conversation context")
return context.retrieve_messages(limit)
def get_full_saved_history(self, limit: Optional[int] = None):
"""Get the fully saved conversation history."""
context = get_current_context()
if context is None:
raise RuntimeError("No active conversation context")
return context.retrieve_full_messages(limit)
def search_conversation(self, query: str, limit: int = 5):
"""Search the conversation history for the current session."""
context = get_current_context()
if context is None:
raise RuntimeError("No active conversation context")
# Use a simple search implementation.
return context.search_messages(query, limit)
def search_full_history(self, query: str, limit: int = 5):
"""Search the fully saved conversation history."""
context = get_current_context()
if context is None:
raise RuntimeError("No active conversation context")
return context.search_messages(query, limit)
def clear_conversation(self) -> None:
"""Clear the conversation history for the current session."""
context = get_current_context()
if context is None:
raise RuntimeError("No active conversation context")
context.clear_messages(keep_summary=True)
def get_conversation_summary(self) -> str:
"""Get the conversation summary for the current session."""
context = get_current_context()
if context is None:
raise RuntimeError("No active conversation context")
return context.get_summary() or ""
def get_full_saved_summary(self) -> str:
"""Get the fully saved conversation summary."""
context = get_current_context()
if context is None:
raise RuntimeError("No active conversation context")
return context.get_summary() or ""
def export_conversation(self, file_path: str) -> None:
"""Export the conversation records for the current session."""
context = get_current_context()
if context is None:
raise RuntimeError("No active conversation context")
data = context.serialize()
dir_path = os.path.dirname(file_path)
if dir_path:
os.makedirs(dir_path, exist_ok=True)
with open(file_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def import_conversation(self, file_path: str, merge: bool = False) -> None:
"""Import conversation records."""
context = get_current_context()
if context is None:
raise RuntimeError("No active conversation context")
with open(file_path, "r", encoding="utf-8") as f:
data = json.load(f)
if not merge:
# Clear existing messages while preserving the summary.
context.clear_messages(keep_summary=True)
context.deserialize(data)
# Convenience methods for SketchPad management.
async def store_in_sketch_pad(
self,
value,
key: Optional[str] = None,
tags: Optional[List[str]] = None,
ttl: Optional[int] = None,
) -> str:
"""Store data in SketchPad."""
sketch_pad = get_current_sketch_pad()
if sketch_pad is None:
raise RuntimeError("No active conversation context")
# Generate a key name if one is not provided.
item_key = key or f"item_{uuid.uuid4().hex[:8]}"
# Convert tags to a set.
tags_set = set(tags) if tags else None
await sketch_pad.set_item(
key=item_key,
value=value,
ttl=ttl,
summary=None,
tags=tags_set,
)
return item_key
def get_from_sketch_pad(self, key: str) -> Any:
"""Get data from SketchPad."""
sketch_pad = get_current_sketch_pad()
if sketch_pad is None:
raise RuntimeError("No active conversation context")
return sketch_pad.get_value(key)
def search_sketch_pad(self, query: str, limit: int = 5):
"""Search SketchPad content."""
sketch_pad = get_current_sketch_pad()
if sketch_pad is None:
raise RuntimeError("No active conversation context")
return sketch_pad.search_by_content(query, limit)
def get_sketch_pad_stats(self):
"""Get SketchPad statistics."""
sketch_pad = get_current_sketch_pad()
if sketch_pad is None:
raise RuntimeError("No active conversation context")
return sketch_pad.get_statistics()
def clear_sketch_pad(self):
"""Clear SketchPad."""
sketch_pad = get_current_sketch_pad()
if sketch_pad is None:
raise RuntimeError("No active conversation context")
sketch_pad.clear()
def get_session_info(self):
"""Get session information, including conversation history and SketchPad statistics."""
try:
conversation_count = len(self.get_conversation_history())
sketch_pad_stats = self.get_sketch_pad_stats()
conversation_summary = self.get_conversation_summary()
except RuntimeError:
# If there is no active conversation context, return basic information.
conversation_count = 0
sketch_pad_stats = {}
conversation_summary = None
return {
"agent_name": self.name,
"model_name": self.model_name,
"agent_class": self.__class__.__name__,
"conversation_count": conversation_count,
"sketch_pad_stats": sketch_pad_stats,
"conversation_summary": conversation_summary,
}
# ===== Common: streaming output and chronological persistence =====
def _msg_to_dict(self, msg: Any) -> Dict[str, Any]:
"""Convert backend-returned messages uniformly into dictionaries, supporting both object and dictionary forms."""
if isinstance(msg, dict):
return msg
return {
"role": getattr(msg, "role", None),
"content": getattr(msg, "content", None),
"tool_calls": getattr(msg, "tool_calls", None),
"tool_call_id": getattr(msg, "tool_call_id", None),
}
async def _stream_and_persist(
self, response_packages: AsyncGenerator[Any, None]
) -> AsyncGenerator[Any, None]:
"""
Unified streaming processing and history persistence logic:
- Continuously accumulate assistant text; when encountering tooluse/tool results, persist the accumulated text first, then write the tool message.
- Ensure tool calls appear in history after the moment that triggered them, preserving the correct order.
"""
context = get_current_context()
if context is None:
raise RuntimeError("No active conversation context")
assistant_buffer: str = ""
baseline_len: Optional[int] = None
async for output in response_packages:
yield output
if not is_response_yield(output):
continue
current_messages = output.messages
if baseline_len is None:
try:
baseline_len = (
len(current_messages)
if isinstance(current_messages, list)
else 0
)
except Exception:
baseline_len = 0
delta_text = extract_output_text(output, "agent_stream")
if delta_text:
assistant_buffer += delta_text
try:
if isinstance(current_messages, list):
curr_len = len(current_messages)
if baseline_len is not None and curr_len > baseline_len:
new_msgs = current_messages[baseline_len:curr_len]
for nm in (self._msg_to_dict(x) for x in new_msgs):
role = nm.get("role")
content = nm.get("content")
tool_calls = nm.get("tool_calls")
tool_call_id = nm.get("tool_call_id")
if (role == "assistant" and tool_calls) or role == "tool":
if assistant_buffer.strip():
await context.store_message(
Message(
role="assistant", content=assistant_buffer
)
)
assistant_buffer = ""
if role == "assistant" and tool_calls:
await context.store_message(
Message(
role="assistant",
content=None,
tool_calls=tool_calls,
)
)
elif role == "tool":
await context.store_message(
Message(
role="tool",
content=content,
tool_call_id=tool_call_id,
)
)
baseline_len = curr_len
except Exception:
pass
# At the end of the stream, write the remaining assistant text.
if assistant_buffer.strip():
await context.store_message(
Message(role="assistant", content=assistant_buffer)
)
+345
View File
@@ -0,0 +1,345 @@
from typing import Dict, List, Generator, Tuple, AsyncGenerator, override, Any
from .BaseAgent import BaseAgent
from context.conversation_manager import get_current_context
from SimpleLLMFunc.type import ImgPath, ImgUrl, Text
from tools import (
execute_command,
sketch_pad_operations,
make_user_query_more_detailed,
cad_code_generator,
get_visual_feedback,
)
from context.schemas import Message
class CADAgent(BaseAgent):
@override
def get_toolkit(self) -> List[Any]:
return [
make_user_query_more_detailed,
cad_code_generator,
execute_command,
sketch_pad_operations,
get_visual_feedback,
]
@override
def chat_impl(
self,
history: List[Dict[str, Any]],
query: str | list[Text | ImgUrl | ImgPath | str],
sketch_pad_summary: str,
) -> Generator[Tuple[str, List[Dict[str, Any]]], None, None]: # type: ignore[override]
"""
# 🎯 Identity Description
You are a professional intelligent assistant for CAD modeling, proficient in CADQuery/Python script-based modeling, geometric design, and engineering drawing. You communicate with the user in Chinese and are responsible for automating and closing the feedback loop across the full process from conceptual design to model export.
Your working directory contains or can access the SimpleCAD skill root reported in workspace facts; this folder is important because it holds documentation and operation guidance. When tools need API or workflow reference, they consult this preferred skill root.
---
# 🧭 Process Control Architecture (based on flowchart)
You follow the state machine architecture below to drive task execution. All state decisions and transitions are made autonomously by you.
## [State Nodes]
1. **Receive Requirement**
- User inputs the modeling target
- Use `make_user_query_more_detailed` tool to refine the input into a modeling specification, then store it as `req_xxxx`
- Treat `make_user_query_more_detailed` as a specialist subagent: it can read SketchPad, inspect local skill docs directly, inspect APIs, and return a grounded requirement package
- Do not assume the specialist should stop after a single failed tool call; it should continue searching/reading until it reaches an explicit terminal condition
- In the `query` you pass to this tool, explicitly require the final detailed requirement to include exactly these headings:
1. `## API Reference`
2. `## Refined User Requirements`
3. `## Parameter Table`
4. `## Modeling Process`
5. `## Notes`
- Also explicitly require that the refined requirement be directly usable by `cad_code_generator`, including:
- clear geometry goals,
- dimensions and constraints,
- assumptions and defaults,
- reusable image paths and SketchPad ids,
- verified API recommendations,
- an ordered modeling sequence with recommended APIs and geometric intent.
- Make it explicit that returning anything less than this structured requirement package counts as failure and the specialist should continue refining.
- If the user uploaded reference images from Web UI, their workspace file paths may appear in the user message. When the image matters, pass the relevant path into `make_user_query_more_detailed(query_image_path=...)`.
- Present the refined specification to the user and ask whether they are satisfied. If satisfied, proceed to **Confirm Requirement**;
if not, return to **Receive Requirement**, integrate the new and old inputs, and refine again.
2. **Create Working Environment**
- Decide the target folder and target script path: `./PartName_Specification/model.py`
- If the folder does not exist yet, you may use `execute_command` to create it (`mkdir -p ...`)
- Then proceed to **Generate Code**
3. **Generate Code**
- Use `cad_code_generator` tool as a **single-call specialist subagent**
- **CRITICAL**: You MUST pass `requirement_sketch_key` (the req_xxxx key from make_user_query_more_detailed). The specialist retrieves the detailed spec from SketchPad using this key.
- Pass `task`, `target_file_path`, and `requirement_sketch_key`
- `task` MUST explicitly include three parts: (1) mission, (2) context, (3) termination condition
- Mission must clearly state: "Generate a SimpleCADAPI Python script to `target_file_path`"
- Context must clearly state: user intent + `requirement_sketch_key` (which SketchPad key to read) + current failure/feedback (if any)
- Termination condition must clearly state: "Run the script and export both STL and STEP/STP successfully; otherwise continue debugging"
- Put all context into `task`: full user intent, create/modify type, modification target, traceback/visual feedback, success criteria
- In `task`, explicitly instruct the specialist to validate and keep debugging until the script successfully exports the model files (normally STL and STEP/STP)
- If API references are needed, tell the specialist to consult the preferred skill root's `references` directory briefly, then continue coding/debugging
- In `task`, focus on objective/context/termination only; do not provide long workflow instructions
- Do not assume the specialist should stop after one failed tool call or one failed patch; the intended behavior is to continue retrying until the explicit terminal condition is reached
- Make it explicit that returning analysis without actually writing `target_file_path` counts as failure
- The tool should be used when you need code creation, code modification, or self-contained code repair for the CAD script
- Use this compact task template when calling `cad_code_generator`:
`Mission: Generate a SimpleCADAPI script to <target_file_path>. Context: read SketchPad key <requirement_sketch_key>; user intent is <intent>; failure context is <errors/feedback>. Done only when the script has been executed and both <name>.stl and <name>.step/.stp are exported successfully.`
4. **Execute Modeling Script**
- If `cad_code_generator` has just returned, first use `execute_command` to `ls` the target folder for `.stl` and `.step`/`.stp` outputs
- If the expected exported files already exist, proceed directly to **Visual Feedback Check**
- Otherwise use `execute_command` to run the script and export `.step`, `.stl` files
- If successful, proceed to **Visual Feedback Check** and inform the user with artifact tags so downstream systems can recover the generated files reliably:
```
<|code_file|>Path to model.py</|code_file|>
<|output_file|>Path to .stl file</|output_file|>
<|output_file|>Path to .step or .stp file</|output_file|>
```
At minimum, always include the final `model.py` path with `<|code_file|>` and the final `.stl` path with `<|output_file|>`.
Example:
`<|code_file|>./Screwdriver_Slotted/model.py</|code_file|>`
`<|output_file|>./Screwdriver_Slotted/Screwdriver.stl</|output_file|>`
`<|output_file|>./Screwdriver_Slotted/Screwdriver.step</|output_file|>`
- If execution fails or the model is not exported, enter Debug Phase (Traceback)
5. **Visual Feedback Check**
- For visual verification, prefer passing the exported `.step`/`.stp` file into `get_visual_feedback`; use `.stl` only as a fallback when no CAD-native file is available
- Use `render_multi_view_model` / `get_visual_feedback` to generate and inspect the 6-view image; output parameter must be the folder from **Create Working Environment**
- If the result is unreasonable, automatically enter debug process; otherwise, go to **Task Completion**
- Limit visual-feedback-based code repair to 3 rounds. After 3 failed visual feedback rounds, stop automatic edits, report current artifacts and remaining issues, and ask the user whether to continue.
6. **Task Completion**
- Complete the modeling process and output a [Task Completed] message.
- Repeat the final `<|code_file|>` and `<|output_file|>` tags in the completion message so artifact extraction remains stable.
---
## [Debug Subprocess: Traceback]
- Use execution error context to analyze the cause of failure
- Pay attention to the printed structured information about solids during execution; use it to identify which edge/tag may require follow-up operations (e.g., fillet or chamfer)
- Then use `cad_code_generator` with `requirement_sketch_key` (req_xxxx), `task`, and `target_file_path`.
- **MUST pass requirement_sketch_key** so the specialist retrieves the detailed spec. **Always provide the full modeling goal every time you invoke it**
- Prefer to send traceback / visual feedback / current intent back into `cad_code_generator` so the specialist subagent can repair the file itself
- Debug responsibility is primarily on `cad_code_generator`; only handle debugging outside it when strictly necessary for workflow orchestration
- When calling `cad_code_generator`, the `task` must clearly say whether it is a **modify-existing-code** task or a **create-new-file** task
- In every debug call, `task` must still keep the same three-part structure: mission, context (`requirement_sketch_key` and latest failure), and termination condition (script executed and both STL + STEP/STP exported)
- Pass enough context in `task` every time: full user intent, current target file path, what changed, what failed, and what success should look like
- Also make `task` explicitly require validation and debugging until STL and STEP/STP export succeeds; a clean script run without exported models is **not** enough
- If the specialist needs API docs, point it to the preferred skill root's `references` directory
- Assume the specialist runs under the workspace root, so all paths in `task` should be workspace-relative or absolute and should not reference repo-only helper paths
- When you regain control after `cad_code_generator`, prefer `ls` export checks before rerunning the script; rerun only when outputs are missing or a fresh traceback is needed
- Return to Step 4 **Execute Modeling Script** to retry after applying necessary fixes (feedback, traceback, etc.), until model export and render are successful; however, visual-feedback-based repair must stop after 3 rounds and wait for user confirmation.
---
# 🧰 Tool Usage Guide (Summary)
| Tool Name | Purpose |
|-----------|---------|
| `make_user_query_more_detailed` | Specialist subagent for refining vague requirements into a grounded modeling spec; may inspect docs/APIs/SketchPad before saving `req_xxxx` |
| `cad_code_generator` | Generate or modify SimpleCADAPI code based on requirement and write directly to target file |
| `execute_command` | Execute Python scripts or shell commands |
| `sketch_pad_operations` | Perform `store/retrieve/search/delete/list/search_tags/search/clear/stats` on SketchPad |
| `get_visual_feedback` | Given the requirement, code, and model path, return targeted visual feedback and modification suggestions. Prefer STEP/STP as `model_path` when available |
Notes on `cad_code_generator`:
- Treat it as a specialist coding subagent, not as a dumb text generator.
- **MUST pass requirement_sketch_key** (req_xxxx from make_user_query_more_detailed). The specialist uses it to retrieve the detailed spec.
- Pass `task`, `target_file_path`, and `requirement_sketch_key`.
- Your responsibility is to pack the right context into `task` and always provide the requirement key.
- Its responsibility is to create/repair `model.py` until validation passes and the model export succeeds, or it can clearly explain what extra context is still missing.
---
# 📂 File Organization Requirements
- Folder structure: `./PartType_Parameters/`
- Script filename: `model.py`
- Execute via: `cd ./PartType_Parameters && uv run python model.py` to ensure outputs reside in the same folder
- Export check via: `cd ./PartType_Parameters && ls *.stl && (ls *.step || ls *.stp)`
- Output file names: `PartName.step`, `PartName.stl`
- Render image name: `PartName_multi_view_render.png`
- All files must be in the same folder
- When you know the generated script path, include `<|code_file|>...</|code_file|>` in an assistant message using a workspace-relative path when possible
- When you know generated model artifact paths, include `<|output_file|>...</|output_file|>` for each key artifact you want surfaced; this must include the final `.stl`, and should include the final `.step`/`.stp` when available
---
# 🧠 Intelligent Behavior Constraints
- Only store data in SketchPad when it is truly needed for cross-step context; do not save generated code into SketchPad by default
- After a failure, return to the corresponding state and attempt to fix it
- Do not proceed past user confirmation in any phase
- If the task is too complex, proactively suggest requirement decomposition
---
# 💡 Important Notes
- Always call `sketch_pad_operations: clear` before starting each task to reset the environment
- Do not generate code or write files without user confirmation
- Visual verification stage is mandatory; must use `get_visual_feedback` and rigorously follow suggestions
- Visual-feedback-based automatic repair is capped at 3 rounds per task; execution/export failures may be retried more, but repeated visual mismatches require user confirmation after the cap.
- When both STL and STEP/STP exist, visual feedback should use the STEP/STP file because it renders CAD geometry more faithfully
- Once user confirms satisfaction, summarize the modeling process and declare "This modeling task is completed, code archived".
- Clearly indicate the **current state node**, and state clearly what will be done next at the end of each state
- Tool calls must be clearly announced before use with appropriate emoji
Example Response:
### Entering [Receive Requirement] State
- We will use the `make_user_query_more_detailed` tool to convert vague input into a structured modeling spec.
I now know the detailed modeling spec is saved in SketchPad under key: xxxxxx
### Entering [Confirm Requirement] State
- Next, I will use the `sketch_pad_operations` tool to retrieve and display the spec for your confirmation.
#### Modeling Requirement:
xxxxxxxx
Does this description match your expectations? Would you like any changes? If confirmed, we will proceed to [Create Working Environment]; otherwise, we will iterate again until you are satisfied.
- Always use a line break for state entry descriptions and mark the current state with `###` headings.
- Use proper emoji for tool invocation.
- When using file operation tools to debug, ensure correct indentation. Read context before writing.
- If reusing `cad_code_generator`, always provide full user intent + API reference + current code.
- Always converse with user in English.
"""
return # type: ignore[return-value]
async def run(
self, query: Any, raw_user_content: Any = None
) -> AsyncGenerator[Any, None]: # type: ignore[override]
"""Run the agent with the given query.
Args:
query (str): The query to process.
Returns:
Generator[str, None, None]: The response chunks from the agent.
"""
if not query:
raise ValueError("Query must not be empty")
# Get the SketchPad keys and truncated value contents.
sketch_pad_summary = self.get_sketch_pad_summary()
import re
raw_query = raw_user_content if raw_user_content is not None else query
def _normalize_query_for_chat(content: Any) -> Any:
if isinstance(content, str):
query_image_match = re.search(r"<i>(.*?)</i>", content)
if query_image_match:
query_image = query_image_match.group(1)
text_query = re.sub(r"<i>.*?</i>", "", content).strip()
try:
return (
[Text(text_query), ImgPath(query_image)]
if text_query
else [ImgPath(query_image)]
)
except Exception:
if text_query:
return (
text_query
+ f" (Attached image: the image file path is {query_image}. You can pass this path to `make_user_query_more_detailed` or later to the `get_visual_feedback` tool to obtain more information.)"
)
return re.sub(r"<i>.*?</i>", "", content).strip()
if isinstance(content, list):
normalized_parts: list[Text | ImgUrl | ImgPath] = []
for item in content:
if isinstance(item, Text):
normalized_parts.append(item)
continue
if isinstance(item, ImgUrl):
normalized_parts.append(item)
continue
if isinstance(item, ImgPath):
normalized_parts.append(item)
continue
if isinstance(item, str):
if item.strip():
normalized_parts.append(Text(item))
continue
item_type = getattr(item, "type", None) or (
item.get("type") if isinstance(item, dict) else None
)
if item_type == "text":
text_value = getattr(item, "text", None) or (
item.get("text") if isinstance(item, dict) else None
)
if isinstance(text_value, str) and text_value.strip():
normalized_parts.append(Text(text_value))
elif item_type == "image_url":
image_payload = getattr(item, "image_url", None) or (
item.get("image_url") if isinstance(item, dict) else None
)
if isinstance(image_payload, dict) and isinstance(
image_payload.get("url"), str
):
detail = image_payload.get("detail")
if not isinstance(detail, str):
detail = "auto"
normalized_parts.append(
ImgUrl(image_payload["url"], detail=detail)
)
if len(normalized_parts) == 1 and isinstance(normalized_parts[0], Text):
return str(normalized_parts[0])
return normalized_parts
return str(content)
chat_query = _normalize_query_for_chat(query)
# Get the current conversation context.
current_context = get_current_context()
if current_context is None:
raise RuntimeError("No active conversation context")
def _normalize_message_content_for_history(content: Any) -> Any:
if isinstance(content, str) or content is None:
return content or ""
if isinstance(content, list):
normalized_items: List[Dict[str, Any]] = []
for item in content:
if isinstance(item, dict):
normalized_items.append(item)
elif hasattr(item, "model_dump"):
normalized_items.append(item.model_dump())
return normalized_items
return str(content)
history_messages = current_context.retrieve_messages()
history: List[Dict[str, Any]] = []
for m in history_messages:
if m.role in ("user", "assistant"):
history.append(
{
"role": m.role,
"content": _normalize_message_content_for_history(m.content),
}
)
# Before starting the conversation, store the current user message in the context.
await current_context.store_message(Message(role="user", content=raw_query))
# Call the LLM in raw streaming mode.
response_packages = self.chat(history, chat_query, sketch_pad_summary)
# Reuse the base class streaming processing and history persistence logic.
async for raw in self._stream_and_persist(response_packages):
yield raw
+25
View File
@@ -0,0 +1,25 @@
from .BaseAgent import BaseAgent
from .AgentRegister import (
AgentRegistry,
get_agent_registry,
register_agent,
get_agent,
list_available_models
)
from .CADAgent import CADAgent
__all__ = [
'BaseAgent',
'AgentRegistry',
'get_agent_registry',
'register_agent',
'get_agent',
'list_available_models',
]
# Register CADAgent.
register_agent("cadagent", CADAgent)
Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

+52
View File
@@ -0,0 +1,52 @@
from __future__ import annotations
import os
from pathlib import Path
from dotenv import load_dotenv
PROJECT_ROOT = Path(__file__).resolve().parent
PROJECT_ENV_FILE = PROJECT_ROOT / ".env"
_RUNTIME_PATH_DEFAULTS = {
"CONTEXT_DIR": Path("data/contexts"),
"SKETCH_DIR": Path("data/sketches"),
"LOG_DIR": Path("agent_logs"),
}
def _workspace_runtime_path(path_value: str | Path) -> str:
"""Resolve runtime output paths into the project workspace."""
path = Path(path_value).expanduser()
if path.is_absolute():
return str(path)
parts = path.parts
if parts and parts[0] == "workspace":
return str((PROJECT_ROOT / path).resolve())
return str((PROJECT_ROOT / "workspace" / path).resolve())
def _normalize_runtime_path_env() -> None:
"""Keep runtime data, sketch, and log directories out of the repository root."""
for env_name, default_relative_path in _RUNTIME_PATH_DEFAULTS.items():
raw_value = os.getenv(env_name)
os.environ[env_name] = _workspace_runtime_path(
raw_value if raw_value else default_relative_path
)
def load_project_env() -> None:
"""Load the project .env before importing libraries that snapshot env at import time."""
load_dotenv(PROJECT_ENV_FILE, override=False)
_normalize_runtime_path_env()
load_project_env()
__all__ = ["load_project_env", "PROJECT_ROOT", "PROJECT_ENV_FILE"]
+5
View File
@@ -0,0 +1,5 @@
from config.config import get_config
__all__ = [
"get_config",
]
+53
View File
@@ -0,0 +1,53 @@
from bootstrap_env import load_project_env
load_project_env()
from SimpleLLMFunc import OpenAICompatible
from functools import lru_cache
import os
from typing import Optional
from dotenv import load_dotenv
# Load the .env file under the working directory.
load_dotenv()
current_file_dir = (
__file__.rsplit("/", 1)[0] if "/" in __file__ else __file__.rsplit("\\", 1)[0]
)
class Config:
JSON_FILE = current_file_dir + "/provider.json"
INTERFACE_COLLECTION = OpenAICompatible.load_from_json_file(JSON_FILE)
BASIC_INTERFACE = INTERFACE_COLLECTION["openrouter"]["anthropic/claude-sonnet-4.6"]
CODE_INTERFACE = INTERFACE_COLLECTION["openrouter"]["google/gemini-3.1-pro-preview"]
REASONING_INTERFACE = INTERFACE_COLLECTION["openrouter"]["anthropic/claude-sonnet-4.6"]
QUICK_INTERFACE = INTERFACE_COLLECTION["openrouter"]["google/gemini-3-flash-preview"]
MULTIMODALITY_INTERFACE = INTERFACE_COLLECTION["openrouter"]["google/gemini-3-flash-preview"]
CONTEXT_SUMMARY_INTERFACE = INTERFACE_COLLECTION["openrouter"]["google/gemini-3-flash-preview"]
# ==================== Context & Sketch configuration parameters ====================
# redis url
REDIS_DB: int = int(os.getenv("REDIS_DB", 0))
REDIS_PASSWORD: Optional[str] = os.getenv("REDIS_PASSWORD", None)
REDIS_HOST: str = os.getenv("REDIS_HOST", "localhost")
REDIS_PORT: int = int(os.getenv("REDIS_PORT", 9736))
# Context storage directory configuration.
CONTEXT_DIR: str = os.getenv("CONTEXT_DIR", "workspace/data/contexts")
CONTEXT_MAX_HISTORY_LENGTH: int = int(os.getenv("CONTEXT_MAX_HISTORY_LENGTH", 10))
CONTEXT_AUTO_SUMMARIZE_TRIGGER: int = int(
os.getenv("CONTEXT_AUTO_SUMMARIZE_TRIGGER", 1000000)
)
# SketchPad storage directory configuration.
SKETCH_DIR: str = os.getenv("SKETCH_DIR", "workspace/data/sketches")
@lru_cache()
def get_config() -> Config:
"""Get the configuration instance."""
return Config()
@@ -0,0 +1,52 @@
{
"volc_engine": [
{
"model_name": "deepseek-v3-250324",
"api_keys": [
"your_keys"
],
"base_url": "https://ark.cn-beijing.volces.com/api/v3/",
"max_retries": 3,
"retry_delay": 1
},
{
"model_name": "doubao-1-5-pro-32k-250115",
"api_keys": [
"your_keys"
],
"base_url": "https://ark.cn-beijing.volces.com/api/v3/",
"max_retries": 3,
"retry_delay": 1
}
],
"openrouter": [
{
"model_name": "z-ai/glm-5",
"api_keys": [
"your_keys"
],
"base_url": "https://openrouter.ai/api/v1"
},
{
"model_name": "google/gemini-3.1-pro-preview",
"api_keys": [
"your_keys"
],
"base_url": "https://openrouter.ai/api/v1"
},
{
"model_name": "anthropic/claude-sonnet-4.6",
"api_keys": [
"your_keys"
],
"base_url": "https://openrouter.ai/api/v1"
},
{
"model_name": "google/gemini-3-flash-preview",
"api_keys": [
"your_keys"
],
"base_url": "https://openrouter.ai/api/v1"
}
]
}
+634
View File
@@ -0,0 +1,634 @@
from typing import Dict, List, Optional, Any, Union, override
from SimpleLLMFunc import async_llm_function, OpenAICompatible
import json
import os
import redis
import threading
from datetime import datetime
from abc import ABC, abstractmethod
from context.schemas import Message, ChatMessages
class ContextBackend(ABC):
"""
ContextBackend is the backend interface for context storage, defining the interfaces used by backend implementations.
Main responsibilities:
1. Define core interfaces for storage, querying, serialization, and persistence
2. Provide a unified abstraction layer that supports different storage implementations
3. Manage core data such as conversation history, summaries, and metadata
"""
@abstractmethod
def __init__(
self,
context_id: str,
llm_interface: Optional[OpenAICompatible] = None,
max_history_length: int = 5,
auto_summarize_trigger: int = 1000000,
file_path: Optional[str] = None,
):
"""
Initialize the context backend.
Args:
context_id: Unique context identifier
llm_interface: LLM interface used for history summarization
max_history_length: Maximum history record length
auto_summarize_trigger: Automatic summary trigger threshold
file_path: File persistence path (optional)
"""
pass
# ===== Core storage interface =====
@abstractmethod
async def store_message(self, message: Message) -> None:
"""Store one message."""
pass
@abstractmethod
def retrieve_messages(self, limit: Optional[int] = None) -> List[Message]:
"""Retrieve message history."""
pass
@abstractmethod
def retrieve_full_messages(self, limit: Optional[int] = None) -> List[Message]:
"""Retrieve the complete persisted message history."""
pass
@abstractmethod
def update_summary(self, summary: str) -> None:
"""Update the conversation summary."""
pass
@abstractmethod
def get_summary(self) -> Optional[str]:
"""Get the conversation summary."""
pass
@abstractmethod
def update_metadata(self, metadata: Dict[str, Any]) -> None:
"""Update metadata."""
pass
@abstractmethod
def get_metadata(self) -> Dict[str, Any]:
"""Get metadata."""
pass
# ===== Query interface =====
@abstractmethod
def search_messages(self, query: str, limit: int = 5) -> List[Message]:
"""Search messages."""
pass
@abstractmethod
def get_message_count(self) -> int:
"""Get the message count."""
pass
@abstractmethod
def get_total_message_count(self) -> int:
"""Get the message count in the complete persisted history."""
pass
@abstractmethod
def clear_messages(self, keep_summary: bool = True) -> None:
"""Clear message history."""
pass
# ===== Serialization interface =====
@abstractmethod
def serialize(self) -> Dict[str, Any]:
"""Serialize to a dictionary."""
pass
@abstractmethod
def deserialize(self, data: Dict[str, Any]) -> None:
"""Deserialize from a dictionary."""
pass
# ===== Persistence interface =====
@abstractmethod
async def persist(self) -> bool:
"""Persist to storage."""
pass
@abstractmethod
async def restore(self) -> bool:
"""Restore from storage."""
pass
# ===== Advanced feature interface =====
@abstractmethod
async def auto_summarize(self) -> str:
"""Automatically summarize history records."""
pass
@abstractmethod
def get_context_for_llm(self) -> str:
"""Get a context string suitable for the LLM."""
pass
class RedisFileContextBackend(ContextBackend):
"""
## RedisFileContextBackend combines immediate Redis storage with file-system persistence for context backend implementation.
Features:
1. Redis provides high-performance immediate access
2. The file system provides reliable persistence
3. Supports automatic synchronization and recovery
4. Uses Redis AOF + RDB mechanisms
"""
@override
def __init__(
self,
context_id: str,
llm_interface: Optional[OpenAICompatible] = None,
max_history_length: int = 5,
auto_summarize_trigger: int = 1000000,
redis_host: str = "localhost",
redis_port: int = 6379,
redis_db: int = 0,
file_path: Optional[str] = None,
):
"""
Initialize the Redis file backend.
Args:
context_id: Unique context identifier
llm_interface: LLM interface used for history summarization
max_history_length: Maximum history record length
auto_summarize_trigger: Automatic summary trigger threshold
redis_host: Redis host address
redis_port: Redis port
redis_db: Redis database number
file_path: File persistence path
"""
self.context_id = context_id
self.llm_interface = llm_interface
self.max_history_length = max_history_length
self.auto_summarize_trigger = auto_summarize_trigger
self.file_path = file_path or f"contexts/ctx_{context_id}.json"
# Redis connection.
self.redis_client = redis.Redis(
host=redis_host, port=redis_port, db=redis_db, decode_responses=True
)
# Thread lock.
self._lock = threading.RLock()
# Initialize the history summarization function.
self._summarize_func = None
if self.llm_interface:
self._summarize_func = async_llm_function(
llm_interface=self.llm_interface,
toolkit=[],
timeout=600,
)(self._summarize_history_impl)
# Initialize metadata.
self._init_metadata()
# Try to restore data from storage.
self._restore_from_storage()
def _init_metadata(self) -> None:
"""Initialize metadata."""
self._metadata = {
"context_id": self.context_id,
"session_id": self.context_id,
"start_time": datetime.now().isoformat(),
"last_activity": datetime.now().isoformat(),
"total_messages": 0,
"max_history_length": self.max_history_length,
"auto_summarize_trigger": self.auto_summarize_trigger,
}
def _normalize_metadata(self) -> None:
"""Normalize session metadata to ensure conversation and tracing session alignment."""
self._metadata["context_id"] = self.context_id
self._metadata["session_id"] = self.context_id
self._metadata.setdefault("start_time", datetime.now().isoformat())
self._metadata.setdefault("last_activity", datetime.now().isoformat())
self._metadata.setdefault("total_messages", 0)
self._metadata.setdefault("max_history_length", self.max_history_length)
self._metadata.setdefault("auto_summarize_trigger", self.auto_summarize_trigger)
def _generate_session_id(self) -> str:
"""Generate a session ID."""
return f"session_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
def _get_redis_key(self, key: str) -> str:
"""Get the Redis key name."""
return f"context:{self.context_id}:{key}"
def _serialize_message(self, message: Message) -> str:
return message.model_dump_json()
def _append_to_working_messages(self, message: Message) -> None:
messages_key = self._get_redis_key("messages")
self.redis_client.lpush(messages_key, self._serialize_message(message))
def _append_to_full_messages(self, message: Message) -> None:
messages_key = self._get_redis_key("full_messages")
self.redis_client.rpush(messages_key, self._serialize_message(message))
def _replace_working_messages(self, messages: List[Message]) -> None:
messages_key = self._get_redis_key("messages")
self.redis_client.delete(messages_key)
for message in messages:
self._append_to_working_messages(message)
def _store_metadata_snapshot(self) -> None:
metadata_key = self._get_redis_key("metadata")
self.redis_client.set(metadata_key, json.dumps(self._metadata))
@override
async def store_message(self, message: Message) -> None:
"""
Store one message.
If the number of messages exceeds max_history_length, automatically trigger the summarization strategy and update the conversation records according to that strategy.
Args:
message: Message to store
Returns:
None
"""
with self._lock:
# Ensure the message has a timestamp.
if message.timestamp is None:
message.timestamp = datetime.now().isoformat()
# Store into working memory and full history.
self._append_to_working_messages(message)
self._append_to_full_messages(message)
# Automatic memory management.
await self._auto_memory_manage()
# Limit history length.
messages_key = self._get_redis_key("messages")
self.redis_client.ltrim(messages_key, 0, self.max_history_length - 1)
# Update metadata.
current_total = self._metadata.get("total_messages", 0)
if isinstance(current_total, (int, float)):
self._metadata["total_messages"] = int(current_total) + 1
else:
self._metadata["total_messages"] = 1
self._metadata["last_activity"] = datetime.now().isoformat()
self._store_metadata_snapshot()
# Automatic persistence.
await self.persist()
@override
def retrieve_messages(self, limit: Optional[int] = None) -> List[Message]:
"""Retrieve message history."""
with self._lock:
messages_key = self._get_redis_key("messages")
message_data_list = self.redis_client.lrange(messages_key, 0, -1)
messages = []
for message_data in message_data_list:
try:
message_dict = json.loads(message_data)
message = Message(**message_dict)
messages.append(message)
except Exception as e:
print(f"Warning: Failed to deserialize message: {e}")
# Sort by time, newest first.
messages.reverse()
if limit is not None:
messages = messages[-limit:]
return messages
@override
def retrieve_full_messages(self, limit: Optional[int] = None) -> List[Message]:
"""Retrieve the complete persisted message history."""
with self._lock:
messages_key = self._get_redis_key("full_messages")
message_data_list = self.redis_client.lrange(messages_key, 0, -1)
messages = []
for message_data in message_data_list:
try:
message_dict = json.loads(message_data)
message = Message(**message_dict)
messages.append(message)
except Exception as e:
print(f"Warning: Failed to deserialize full history message: {e}")
if not messages:
messages = self.retrieve_messages()
if limit is not None:
messages = messages[-limit:]
return messages
@override
def update_summary(self, summary: str) -> None:
"""Update the conversation summary."""
with self._lock:
summary_key = self._get_redis_key("summary")
self.redis_client.set(summary_key, summary)
@override
def get_summary(self) -> Optional[str]:
"""Get the conversation summary."""
with self._lock:
summary_key = self._get_redis_key("summary")
return self.redis_client.get(summary_key)
@override
def update_metadata(self, metadata: Dict[str, Any]) -> None:
"""Update metadata."""
with self._lock:
self._metadata.update(metadata)
self._store_metadata_snapshot()
@override
def get_metadata(self) -> Dict[str, Any]:
"""Get metadata."""
with self._lock:
return self._metadata.copy()
@override
def search_messages(self, query: str, limit: int = 5) -> List[Message]:
"""
Search messages.
Args:
query: Search keyword
limit: Search result count limit
Returns:
List[Message]: Search result list
"""
messages = self.retrieve_messages()
results = []
query_lower = query.lower()
for message in reversed(messages):
content = message.content
if isinstance(content, str) and query_lower in content.lower():
results.append(message)
if len(results) >= limit:
break
return list(reversed(results))
@override
def get_message_count(self) -> int:
"""Get the message count."""
with self._lock:
messages_key = self._get_redis_key("messages")
return self.redis_client.llen(messages_key)
@override
def get_total_message_count(self) -> int:
"""Get the message count in the complete persisted history."""
with self._lock:
messages_key = self._get_redis_key("full_messages")
total = self.redis_client.llen(messages_key)
if total == 0:
return self.get_message_count()
return total
@override
def clear_messages(self, keep_summary: bool = True) -> None:
"""Clear message history."""
with self._lock:
messages_key = self._get_redis_key("messages")
full_messages_key = self._get_redis_key("full_messages")
self.redis_client.delete(messages_key)
self.redis_client.delete(full_messages_key)
if not keep_summary:
summary_key = self._get_redis_key("summary")
self.redis_client.delete(summary_key)
self._metadata["total_messages"] = 0
self._metadata["last_activity"] = datetime.now().isoformat()
self._store_metadata_snapshot()
@override
def serialize(self) -> Dict[str, Any]:
"""Serialize to a dictionary."""
with self._lock:
return {
"context_id": self.context_id,
"metadata": self._metadata,
"messages": [msg.model_dump() for msg in self.retrieve_full_messages()],
"working_messages": [
msg.model_dump() for msg in self.retrieve_messages()
],
"summary": self.get_summary(),
"serialization_timestamp": datetime.now().isoformat(),
}
@override
def deserialize(self, data: Dict[str, Any]) -> None:
"""Deserialize from a dictionary."""
with self._lock:
# Restore metadata.
if "metadata" in data:
self._metadata.update(data["metadata"])
self._normalize_metadata()
# Restore messages.
full_history_payload = data.get("messages", [])
full_messages: List[Message] = []
for message_data in full_history_payload:
try:
full_messages.append(Message(**message_data))
except Exception as e:
print(f"Warning: Failed to deserialize full history message: {e}")
full_messages_key = self._get_redis_key("full_messages")
self.redis_client.delete(full_messages_key)
for message in full_messages:
self._append_to_full_messages(message)
working_payload = data.get("working_messages")
working_messages: List[Message] = []
if isinstance(working_payload, list):
for message_data in working_payload:
try:
working_messages.append(Message(**message_data))
except Exception as e:
print(f"Warning: Failed to deserialize working message: {e}")
elif full_messages:
working_messages = full_messages[-self.max_history_length :]
self._replace_working_messages(working_messages)
# Restore summary.
if "summary" in data and data["summary"]:
self.update_summary(data["summary"])
@override
async def persist(self) -> bool:
"""Persist to file."""
try:
# Ensure the directory exists.
dir_path = os.path.dirname(self.file_path)
if dir_path:
os.makedirs(dir_path, exist_ok=True)
# Serialize data.
data = self.serialize()
# Write to file.
with open(self.file_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
return True
except Exception as e:
print(f"Warning: Failed to persist context: {e}")
return False
@override
async def restore(self) -> bool:
"""Restore from file."""
if not os.path.exists(self.file_path):
return False
try:
with open(self.file_path, "r", encoding="utf-8") as f:
data = json.load(f)
self.deserialize(data)
return True
except Exception as e:
print(f"Warning: Failed to restore context: {e}")
return False
def _restore_from_storage(self) -> None:
"""Restore data from storage."""
# Try to restore from Redis.
metadata_key = self._get_redis_key("metadata")
stored_metadata = self.redis_client.get(metadata_key)
if stored_metadata:
try:
self._metadata.update(json.loads(stored_metadata))
self._normalize_metadata()
except Exception as e:
print(f"Warning: Failed to restore metadata from Redis: {e}")
# Try to restore from file.
if os.path.exists(self.file_path):
import asyncio
asyncio.create_task(self.restore())
async def _auto_memory_manage(self) -> None:
"""Automatic memory management."""
if (
self.get_message_count() > self.auto_summarize_trigger
and self.llm_interface
):
# Create summary.
summary = await self.auto_summarize()
# Save summary.
current_summary = self.get_summary()
if current_summary:
self.update_summary(f"{current_summary}\n\n{summary}")
else:
self.update_summary(summary)
# Keep the most recent message.
messages = self.retrieve_messages()
if messages:
self._replace_working_messages([messages[-1]])
@override
async def auto_summarize(self) -> str:
"""Automatically summarize history records."""
if self._summarize_func:
messages = self.retrieve_messages()
return await self._summarize_func(messages)
else:
count = self.get_message_count()
return f"The conversation contains {count} messages."
@override
def get_context_for_llm(self) -> str:
"""Get a context string suitable for the LLM."""
context_parts = []
# Add summary.
summary = self.get_summary()
if summary:
context_parts.append(f"Conversation summary:\n{summary}\n")
# Add recent history records.
messages = self.retrieve_messages()
if messages:
context_parts.append("Recent conversation history:")
for message in messages:
role = message.role
content = message.content
if isinstance(content, str):
context_parts.append(f"{role}: {content}")
return "\n".join(context_parts)
@staticmethod
async def _summarize_history_impl(messages: List[Message]) -> str: # type: ignore
"""
Please extract and summarize key information from the following conversation history. Requirements:
1. Distill the user's core intent and clearly describe it under the [User Intent] field.
2. Extract all key parameters, variable names, keys, file names, and similar information that appeared, and list them under the [Key Information] field. Use one item per line and indicate the type, such as file, key, parameter, and so on.
3. Preserve important operations, decisions, or changes involved in the conversation, and concisely summarize them under the [Conversation Highlights] field.
4. Output all fields strictly in the following format:
[User Intent]
... (briefly describe the user's main requirements and goals)
[Key Information]
- Type: Name
- Type: Name
...
[Conversation Highlights]
- Highlight 1
- Highlight 2
[Files Operated On]
- File 1
- File 2
- File 3
[Next-Step Plan]
- Plan 1
- Plan 2
- Plan 3
[Summary]
- Summary 1
- Summary 2
...
Ensure the summary is accurate and clearly structured, making it easy for later retrieval and context recovery.
Args:
messages: Message list
Returns:
str: Summarized conversation history
"""
@@ -0,0 +1,478 @@
import os
import json
import uuid
import threading
from typing import Dict, Optional, List, Type, Any, Literal, cast
from datetime import datetime
import redis
from SimpleLLMFunc import OpenAICompatible
from context.schemas import Message
from context.context import ContextBackend, RedisFileContextBackend
from config.config import get_config
from SimpleLLMFunc.logger import push_warning, app_log
class ContextManager:
"""
General-purpose context manager that supports different backend implementations.
Main responsibilities:
1. Manage creation and lifecycle of ContextBackend instances
2. Provide advanced convenience interfaces
3. Handle batch operations and cleanup tasks
4. Support pluggable backend implementations
"""
_instance = None
_lock: threading.Lock = threading.Lock()
def __new__(cls, backend_class: Type[ContextBackend] = RedisFileContextBackend):
"""Singleton pattern implementation."""
if cls._instance is None:
with cls._lock:
if cls._instance is None:
cls._instance = super(ContextManager, cls).__new__(cls)
cls._instance.backend_class = backend_class
return cls._instance
def __init__(self, backend_class: Type[ContextBackend]):
"""
Initialize the context manager.
Args:
backend_class: Backend implementation class, defaulting to RedisFileBackend
"""
# Prevent duplicate initialization.
if hasattr(self, "_initialized"):
return
self.backend_class = backend_class
self.config = get_config()
self.context_dir = self.config.CONTEXT_DIR
self._active_contexts: Dict[str, ContextBackend] = {}
# Ensure the directory exists.
os.makedirs(self.context_dir, exist_ok=True)
self._initialized = True
def _redis_client(self) -> redis.Redis:
return redis.Redis(
host=self.config.REDIS_HOST,
port=int(self.config.REDIS_PORT),
db=int(self.config.REDIS_DB),
decode_responses=True,
)
def _list_context_ids_from_redis(self) -> set[str]:
context_ids: set[str] = set()
try:
client = self._redis_client()
raw_keys = cast(Any, client.keys("context:*:*"))
for key in cast(List[str], raw_keys):
parts = key.split(":", 2)
if len(parts) >= 3 and parts[0] == "context" and parts[1]:
context_ids.add(parts[1])
except Exception as e:
print(f"Warning: Failed to list context ids from Redis: {e}")
return context_ids
def _delete_context_redis_keys(self, context_id: str) -> bool:
try:
client = self._redis_client()
raw_keys = cast(Any, client.keys(f"context:{context_id}:*"))
keys = cast(List[str], raw_keys)
if not keys:
return False
deleted = cast(Any, client.delete(*keys))
return int(deleted) > 0
except Exception as e:
print(f"Warning: Failed to delete Redis context keys for {context_id}: {e}")
return False
def create_context(
self,
context_id: Optional[str] = None,
llm_interface: Optional[
OpenAICompatible
] = get_config().CONTEXT_SUMMARY_INTERFACE,
max_history_length: int = get_config().CONTEXT_MAX_HISTORY_LENGTH,
auto_summarize_trigger: int = get_config().CONTEXT_AUTO_SUMMARIZE_TRIGGER,
**backend_kwargs,
) -> ContextBackend:
"""
Create a new context object.
Args:
context_id: Context ID; generated automatically if None
llm_interface: LLM interface
max_history_length: Maximum history length
auto_summarize_trigger: Automatic summary trigger threshold
**backend_kwargs: Extra parameters passed to the backend
Returns:
ContextBackend: Created context object
"""
with self._lock:
if context_id is None:
context_id = str(uuid.uuid4())
# Check whether it already exists.
if context_id in self._active_contexts:
app_log(
f"Context {context_id} already exists, and is in active contexts. Returning the existing context."
)
return self._active_contexts[context_id]
# Generate the file path if the backend needs one.
if "file_path" not in backend_kwargs:
context_file = os.path.join(self.context_dir, f"ctx_{context_id}.json")
backend_kwargs["file_path"] = context_file
push_warning(f"Context file path: {context_file}")
# Create the context object.
context = self.backend_class(
context_id=context_id,
llm_interface=llm_interface,
max_history_length=max_history_length,
auto_summarize_trigger=auto_summarize_trigger,
**backend_kwargs,
)
# Add it to the active context list.
self._active_contexts[context_id] = context
return context
def get_context(self, context_id: str) -> Optional[ContextBackend]:
"""
Get the context object with the specified ID.
Args:
context_id: Context ID
Returns:
ContextBackend: Context object, or None if it does not exist
"""
with self._lock:
# First check active contexts.
if context_id in self._active_contexts:
return self._active_contexts[context_id]
# Try to load from file if the backend supports it.
context_file = os.path.join(self.context_dir, f"ctx_{context_id}.json")
if os.path.exists(context_file):
try:
context = self.backend_class(
context_id=context_id,
llm_interface=self.config.CONTEXT_SUMMARY_INTERFACE, # Can be configured later.
max_history_length=self.config.CONTEXT_MAX_HISTORY_LENGTH,
auto_summarize_trigger=self.config.CONTEXT_AUTO_SUMMARIZE_TRIGGER,
file_path=context_file,
)
self._active_contexts[context_id] = context
return context
except Exception as e:
print(f"Warning: Failed to load context {context_id}: {e}")
return None
def delete_context(self, context_id: str) -> bool:
"""
Delete the context object with the specified ID.
Args:
context_id: Context ID
Returns:
bool: Whether deletion succeeded
"""
with self._lock:
success = False
# Remove it from active contexts.
if context_id in self._active_contexts:
del self._active_contexts[context_id]
success = True
# Delete context keys from Redis.
if self._delete_context_redis_keys(context_id):
success = True
# Delete the file if it exists.
context_file = os.path.join(self.context_dir, f"ctx_{context_id}.json")
if os.path.exists(context_file):
try:
os.remove(context_file)
success = True
except Exception as e:
print(f"Warning: Failed to delete context file {context_file}: {e}")
return success
def list_context_ids(self) -> List[str]:
"""List all known context IDs, including Redis and the file system."""
context_ids = set(self._active_contexts.keys())
context_ids.update(self._list_context_ids_from_redis())
try:
for filename in os.listdir(self.context_dir):
if filename.startswith("ctx_") and filename.endswith(".json"):
context_ids.add(filename[4:-5])
except Exception as e:
print(f"Warning: Failed to scan context dir for ids: {e}")
return sorted(context_ids)
def list_contexts(self) -> List[Dict[str, Any]]:
"""
List all available contexts.
Returns:
List[Dict]: Context information list
"""
contexts = []
# Scan context files in the file system.
try:
for filename in os.listdir(self.context_dir):
if filename.startswith("ctx_") and filename.endswith(".json"):
context_id = filename[4:-5] # Remove the "ctx_" prefix and ".json" suffix.
context_info = {
"context_id": context_id,
"file_path": os.path.join(self.context_dir, filename),
"is_active": context_id in self._active_contexts,
}
# Try to read basic information.
try:
file_path = context_info["file_path"]
if isinstance(file_path, str):
with open(file_path, "r", encoding="utf-8") as f:
data = json.load(f)
metadata = data.get("metadata", {})
context_info.update(
{
"start_time": metadata.get("start_time"),
"last_activity": metadata.get("last_activity"),
"total_messages": metadata.get(
"total_messages", 0
),
}
)
except Exception:
pass # Ignore read errors.
contexts.append(context_info)
except Exception as e:
print(f"Warning: Failed to list contexts: {e}")
return contexts
async def save_context(self, context_id: str) -> bool:
"""
Manually save the specified context to file.
Args:
context_id: Context ID
Returns:
bool: Whether saving succeeded
"""
with self._lock:
context = self._active_contexts.get(context_id)
if context is None:
return False
try:
return await context.persist()
except Exception as e:
print(f"Warning: Failed to save context {context_id}: {e}")
return False
async def save_all_contexts(self) -> int:
"""
Save all active contexts to files.
Returns:
int: Number of contexts successfully saved
"""
saved_count = 0
with self._lock:
context_ids = list(self._active_contexts.keys())
for context_id in context_ids:
if await self.save_context(context_id):
saved_count += 1
return saved_count
async def cleanup_inactive_contexts(self, max_inactive_time: int = 3600) -> int:
"""
Clean up contexts that have been inactive for a long time.
Args:
max_inactive_time: Maximum inactive time in seconds
Returns:
int: Number of cleaned contexts
"""
cleaned_count = 0
current_time = datetime.now()
contexts_to_persist: list[tuple[str, ContextBackend]] = []
with self._lock:
contexts_to_remove = []
for context_id, context in self._active_contexts.items():
try:
metadata = context.get_metadata()
last_activity_str = metadata.get("last_activity")
if last_activity_str:
last_activity = datetime.fromisoformat(last_activity_str)
inactive_time = (current_time - last_activity).total_seconds()
if inactive_time > max_inactive_time:
# Persist the context before removing it.
contexts_to_persist.append((context_id, context))
contexts_to_remove.append(context_id)
cleaned_count += 1
except Exception as e:
print(
f"Warning: Error checking activity for context {context_id}: {e}"
)
for context_id, context in contexts_to_persist:
try:
await context.persist()
except Exception as e:
print(f"Warning: Failed to persist inactive context {context_id}: {e}")
with self._lock:
for context_id in contexts_to_remove:
del self._active_contexts[context_id]
return cleaned_count
# ===== Convenience interfaces =====
async def add_message(
self,
context_id: str,
message: Message,
) -> bool:
"""
Convenience method for adding a message.
Args:
context_id: Context ID
role: Message role
content: Message content
**message_kwargs: Other message parameters
Returns:
bool: Whether adding succeeded
"""
backend = self.get_context(context_id)
if not backend:
return False
try:
await backend.store_message(message)
return True
except Exception as e:
print(f"Warning: Failed to add message: {e}")
return False
def get_history(self, context_id: str, limit: Optional[int] = None) -> List:
"""
Get conversation history.
Args:
context_id: Context ID
limit: Limit on the number of returned messages
Returns:
List: Message history
"""
backend = self.get_context(context_id)
if not backend:
return []
return backend.retrieve_messages(limit)
async def summarize_context(self, context_id: str) -> Optional[str]:
"""
Summarize the context.
Args:
context_id: Context ID
Returns:
Optional[str]: Summary content
"""
backend = self.get_context(context_id)
if not backend:
return None
return await backend.auto_summarize()
# Global instance.
_global_context_manager: Optional[ContextManager] = None
def get_context_manager() -> ContextManager:
"""Get the global ContextManager instance."""
global _global_context_manager
if _global_context_manager is None:
# Get Redis configuration from config.
config = get_config()
# redis config
redis_host = config.REDIS_HOST
redis_port = int(config.REDIS_PORT)
redis_db = int(config.REDIS_DB)
# Create a custom backend class with preconfigured Redis parameters.
class ConfiguredRedisFileBackend(RedisFileContextBackend):
def __init__(
self,
context_id: str,
llm_interface: Optional[
OpenAICompatible
] = config.CONTEXT_SUMMARY_INTERFACE,
max_history_length: int = config.CONTEXT_MAX_HISTORY_LENGTH,
auto_summarize_trigger: int = config.CONTEXT_AUTO_SUMMARIZE_TRIGGER,
redis_host: str = redis_host,
redis_port: int = redis_port,
redis_db: int = redis_db,
file_path: str = "",
):
super().__init__(
context_id=context_id,
llm_interface=llm_interface,
max_history_length=max_history_length,
auto_summarize_trigger=auto_summarize_trigger,
redis_host=redis_host,
redis_port=redis_port,
redis_db=redis_db,
file_path=file_path,
)
self.file_path = file_path
self.context_id = context_id
self.llm_interface = llm_interface
self.max_history_length = max_history_length
self.auto_summarize_trigger = auto_summarize_trigger
self.redis_host = redis_host
self.redis_port = redis_port
self.redis_db = redis_db
# Create ContextManager using the configured backend class.
_global_context_manager = ContextManager(
backend_class=ConfiguredRedisFileBackend
)
return _global_context_manager
@@ -0,0 +1,456 @@
from __future__ import annotations
import uuid
import threading
import os
from typing import Dict, Optional, List, Any
from datetime import datetime
from dataclasses import dataclass
from SimpleLLMFunc import OpenAICompatible
from SimpleLLMFunc.logger import push_warning, push_error, app_log
from context.context_manager import get_context_manager, ContextManager
from context.sketch_manager import get_sketch_manager, SketchManager
from context.sketch_pad import SketchPadBackend
from context.context import ContextBackend
from config.config import get_config
# Global current conversation context variable.
_current_conversation: Optional[Conversation] = None
_conversation_context_lock = threading.RLock()
def get_current_conversation() -> Optional[Conversation]:
"""Get the Conversation in the current context."""
global _current_conversation
with _conversation_context_lock:
return _current_conversation
def get_current_context() -> Optional[ContextBackend]:
"""Get the Context in the current context."""
conversation = get_current_conversation()
return conversation.context if conversation else None
def get_current_sketch_pad() -> Optional[SketchPadBackend]:
"""Get the SketchPad in the current context."""
conversation = get_current_conversation()
return conversation.sketch_pad if conversation else None
@dataclass
class Conversation:
"""
Conversation data class representing a complete conversation session.
Contains a unique UUID, associated Context, and SketchPad.
Supports use as a context manager.
"""
uuid: str
context: ContextBackend
sketch_pad: SketchPadBackend
created_at: datetime
last_accessed: datetime
def update_access_time(self):
"""Update the last access time."""
self.last_accessed = datetime.now()
def __enter__(self):
"""Enter the context manager."""
global _current_conversation
with _conversation_context_lock:
if _current_conversation is not None:
raise RuntimeError("Cannot nest conversation contexts")
_current_conversation = self
self.update_access_time()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""Exit the context manager."""
global _current_conversation
with _conversation_context_lock:
_current_conversation = None
return False
class ConversationManager:
"""
Conversation manager responsible for creating, managing, and coordinating Conversation lifecycles.
Each Conversation contains one Context and one SketchPad, and they share the same UUID.
ConversationManager is a global singleton that uses ContextManager and SketchManager
to manage the underlying Context and SketchPad objects.
"""
_instance = None
_lock = threading.Lock()
def __new__(cls):
"""Singleton pattern implementation."""
if cls._instance is None:
with cls._lock:
if cls._instance is None:
cls._instance = super(ConversationManager, cls).__new__(cls)
return cls._instance
def __init__(self):
"""Initialize ConversationManager."""
# Prevent duplicate initialization.
if hasattr(self, "_initialized"):
return
self.config = get_config()
self.context_manager: ContextManager = get_context_manager()
self.sketch_manager: SketchManager = get_sketch_manager()
self._active_conversations: Dict[str, Conversation] = {}
self._lock = threading.RLock()
# Create the conversations directory.
self.conversations_dir = os.path.join(
os.path.dirname(self.config.CONTEXT_DIR), "conversations"
)
os.makedirs(self.conversations_dir, exist_ok=True)
self._initialized = True
def create_conversation(
self,
conversation_id: Optional[str] = None,
llm_interface: Optional[OpenAICompatible] = None,
max_history_length: int = 5,
) -> Conversation:
"""
Create a new Conversation.
Args:
conversation_id: Conversation UUID; generated automatically if None
llm_interface: LLM interface used for Context
max_history_length: Maximum Context history length
Returns:
Conversation: Created Conversation object
"""
with self._lock:
if conversation_id is None:
conversation_id = str(uuid.uuid4())
# Check whether it already exists.
if conversation_id in self._active_conversations:
conversation = self._active_conversations[conversation_id]
conversation.update_access_time()
return conversation
# Create Context with the ctx prefix.
context = self.context_manager.create_context(
context_id=conversation_id,
llm_interface=llm_interface,
max_history_length=max_history_length,
)
# Create SketchPad with the skt prefix.
sketch_pad = self.sketch_manager.create_sketch_pad(
sketch_id=conversation_id
)
# Create the Conversation object.
now = datetime.now()
conversation = Conversation(
uuid=conversation_id,
context=context,
sketch_pad=sketch_pad,
created_at=now,
last_accessed=now,
)
# Add it to the active Conversation list.
self._active_conversations[conversation_id] = conversation
# Immediately persist context and sketch_pad to the file system.
try:
import asyncio
# Create an event loop to run the asynchronous task.
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(context.persist())
# Synchronously call sketch_pad.persist().
sketch_pad.persist()
finally:
loop.close()
app_log(f"Conversation {conversation_id} was successfully persisted to the file system")
except Exception as e:
push_warning(f"Failed to persist conversation {conversation_id}: {e}")
# Create a persistence marker file.
self._create_conversation_marker(conversation_id)
return conversation
def get_conversation(self, conversation_id: str) -> Optional[Conversation]:
"""
Get the Conversation with the specified ID.
Args:
conversation_id: Conversation UUID
Returns:
Conversation: Conversation object, or None if it does not exist
"""
with self._lock:
# First check active Conversations.
if conversation_id in self._active_conversations:
conversation = self._active_conversations[conversation_id]
conversation.update_access_time()
return conversation
# Try to rebuild from the file system.
context = self.context_manager.get_context(conversation_id)
sketch_pad = self.sketch_manager.get_sketch_pad(conversation_id)
if context is not None and sketch_pad is not None:
# Rebuild the Conversation object.
now = datetime.now()
conversation = Conversation(
uuid=conversation_id,
context=context,
sketch_pad=sketch_pad,
created_at=now, # Use the current time as the rebuild time.
last_accessed=now,
)
self._active_conversations[conversation_id] = conversation
return conversation
return None
def delete_conversation(self, conversation_id: str) -> bool:
"""
Delete the Conversation with the specified ID.
Args:
conversation_id: Conversation UUID
Returns:
bool: Whether deletion succeeded
"""
with self._lock:
success = False
# Remove it from active Conversations.
if conversation_id in self._active_conversations:
del self._active_conversations[conversation_id]
success = True
# Delete the underlying Context and SketchPad.
context_deleted = self.context_manager.delete_context(conversation_id)
sketch_deleted = self.sketch_manager.delete_sketch_pad(conversation_id)
# Delete the marker file.
marker_file = os.path.join(
self.conversations_dir, f"conv_{conversation_id}.marker"
)
if os.path.exists(marker_file):
try:
os.remove(marker_file)
except Exception as e:
push_warning(f"Failed to delete marker file {marker_file}: {e}")
return success or context_deleted or sketch_deleted
def _discover_conversation_ids(self) -> List[str]:
"""Discover all known conversation ids across memory, files, and Redis-backed stores."""
conversation_ids = set(self._active_conversations.keys())
try:
for filename in os.listdir(self.conversations_dir):
if filename.startswith("conv_") and filename.endswith(".marker"):
conversation_ids.add(filename[5:-7])
except Exception as e:
push_warning(f"Failed to scan conversation markers: {e}")
try:
conversation_ids.update(self.context_manager.list_context_ids())
except Exception as e:
push_warning(f"Failed to collect context ids: {e}")
try:
conversation_ids.update(self.sketch_manager.list_sketch_ids())
except Exception as e:
push_warning(f"Failed to collect sketch ids: {e}")
return sorted(conversation_ids)
def delete_all_conversations(self) -> List[str]:
"""Delete all known conversations from memory, files, and Redis-backed stores."""
deleted_ids: List[str] = []
for conversation_id in self._discover_conversation_ids():
if self.delete_conversation(conversation_id):
deleted_ids.append(conversation_id)
return deleted_ids
def list_conversations(self) -> List[Dict[str, Any]]:
"""
List all available Conversations.
Returns:
List[Dict]: Conversation information list
"""
conversations = []
try:
for conversation_id in self._discover_conversation_ids():
marker_file = os.path.join(
self.conversations_dir, f"conv_{conversation_id}.marker"
)
conversation_info = {
"conversation_id": conversation_id,
"marker_file": marker_file if os.path.exists(marker_file) else None,
"is_active": conversation_id in self._active_conversations,
}
context = self.context_manager.get_context(conversation_id)
sketch_pad = self.sketch_manager.get_sketch_pad(conversation_id)
if context:
metadata = context.get_metadata()
conversation_info.update(
{
"context_start_time": metadata.get("start_time"),
"context_last_activity": metadata.get("last_activity"),
"context_total_messages": context.get_total_message_count(),
"context_has_summary": bool(context.get_summary()),
}
)
if sketch_pad:
stats = sketch_pad.get_statistics()
conversation_info.update(
{
"sketch_total_items": stats.total_items,
"sketch_max_items": stats.max_items,
"sketch_memory_usage": stats.memory_usage_percent,
}
)
conversations.append(conversation_info)
except Exception as e:
push_warning(f"Failed to list conversations: {e}")
return conversations
async def save_conversation(self, conversation_id: str) -> bool:
"""
Manually save the specified Conversation to files.
Args:
conversation_id: Conversation UUID
Returns:
bool: Whether saving succeeded
"""
with self._lock:
if conversation_id in self._active_conversations:
try:
conversation = self._active_conversations[conversation_id]
# Save Context.
context_saved = await conversation.context.persist()
# Save SketchPad.
conversation.sketch_pad.persist()
sketch_saved = True
return bool(context_saved and sketch_saved)
except Exception as e:
push_warning(f"Failed to save conversation {conversation_id}: {e}")
return False
async def save_all_conversations(self) -> int:
"""
Save all active Conversations to files.
Returns:
int: Number of Conversations successfully saved
"""
saved_count = 0
with self._lock:
for conversation_id in list(self._active_conversations.keys()):
if await self.save_conversation(conversation_id):
saved_count += 1
return saved_count
async def cleanup_inactive_conversations(
self, max_inactive_time: int = 3600
) -> int:
"""
Clean up Conversations that have been inactive for a long time.
Args:
max_inactive_time: Maximum inactive time in seconds
Returns:
int: Number of cleaned Conversations
"""
cleaned_count = 0
current_time = datetime.now()
with self._lock:
conversations_to_remove = []
for conversation_id, conversation in self._active_conversations.items():
try:
inactive_time = (
current_time - conversation.last_accessed
).total_seconds()
if inactive_time > max_inactive_time:
# Save the Conversation before removing it.
await self.save_conversation(conversation_id)
conversations_to_remove.append(conversation_id)
cleaned_count += 1
except Exception as e:
push_warning(
f"Error checking activity for conversation {conversation_id}: {e}"
)
# Remove inactive Conversations.
for conversation_id in conversations_to_remove:
del self._active_conversations[conversation_id]
return cleaned_count
def _create_conversation_marker(self, conversation_id: str) -> None:
"""
Create a Conversation marker file.
Args:
conversation_id: Conversation UUID
"""
try:
marker_file = os.path.join(
self.conversations_dir, f"conv_{conversation_id}.marker"
)
with open(marker_file, "w") as f:
f.write(
f"Conversation {conversation_id} created at {datetime.now().isoformat()}"
)
except Exception as e:
push_warning(
f"Failed to create marker file for conversation {conversation_id}: {e}"
)
# Global instance.
_global_conversation_manager: Optional[ConversationManager] = None
def get_conversation_manager() -> ConversationManager:
"""Get the global ConversationManager instance."""
global _global_conversation_manager
if _global_conversation_manager is None:
_global_conversation_manager = ConversationManager()
return _global_conversation_manager
+275
View File
@@ -0,0 +1,275 @@
from typing import Literal, List, Optional, Union, Any, Dict, Set
from pydantic import BaseModel, Field, model_validator, field_validator, RootModel
from datetime import datetime
import hashlib
def _content_item_type(value: Any) -> Optional[str]:
if isinstance(value, dict):
item_type = value.get("type")
return item_type if isinstance(item_type, str) else None
item_type = getattr(value, "type", None)
return item_type if isinstance(item_type, str) else None
def _content_item_text(value: Any) -> Optional[str]:
if isinstance(value, dict):
text = value.get("text")
return text if isinstance(text, str) else None
text = getattr(value, "text", None)
return text if isinstance(text, str) else None
def _content_item_image_payload(value: Any) -> Any:
if isinstance(value, dict):
return value.get("image_url")
return getattr(value, "image_url", None)
def _image_payload_string_field(payload: Any, field_name: str) -> Optional[str]:
if isinstance(payload, dict):
value = payload.get(field_name)
return value if isinstance(value, str) else None
value = getattr(payload, field_name, None)
return value if isinstance(value, str) else None
def _normalize_multimodal_content_item(value: Any) -> Dict[str, Any]:
if hasattr(value, "model_dump"):
value = value.model_dump()
item_type = _content_item_type(value)
if item_type == "text":
text = _content_item_text(value)
if text is None:
raise ValueError("Text content item is missing a valid 'text' field")
return {"type": "text", "text": text}
if item_type == "image_url":
image_payload = _content_item_image_payload(value)
url = _image_payload_string_field(image_payload, "url")
if url is None:
raise ValueError(
"Image content item is missing a valid 'image_url.url' field"
)
normalized_payload: Dict[str, Any] = {"url": url}
detail = _image_payload_string_field(image_payload, "detail")
if detail in {"auto", "low", "high"}:
normalized_payload["detail"] = detail
local_path = _image_payload_string_field(image_payload, "local_path")
if local_path:
normalized_payload["local_path"] = local_path
return {"type": "image_url", "image_url": normalized_payload}
raise ValueError(f"Unsupported multimodal content item: {type(value).__name__}")
def _normalize_message_content(value: Any) -> Any:
if value is None or isinstance(value, str):
return value
if isinstance(value, list):
return [_normalize_multimodal_content_item(item) for item in value]
return value
class TextContent(BaseModel):
type: Literal["text"] = Field(..., description="Content block type: plain text")
text: str = Field(..., description="Text content of the message")
class ImageURL(BaseModel):
url: str = Field(..., description="Public access URL for the image")
detail: Optional[Literal["auto", "low", "high"]] = Field(
None, description="Optional image detail level"
)
local_path: Optional[str] = Field(None, description="Local path of the image in the workspace")
class ImageContent(BaseModel):
type: Literal["image_url"] = Field(..., description="Content block type: image URL")
image_url: ImageURL = Field(..., description="Detailed information for the image content")
MessageContent = Union[str, None, List[Union[TextContent, ImageContent]]]
class FunctionCall(BaseModel):
name: str = Field(..., description="Name of the function to call")
arguments: str = Field(..., description="JSON-formatted string of arguments to pass")
class ToolCall(BaseModel):
id: str = Field(..., description="Unique ID of this tool call")
type: Literal["function"] = Field(..., description="Type of called tool (function)")
function: FunctionCall = Field(..., description="Function call specification")
class Message(BaseModel):
role: Literal["system", "user", "assistant", "tool"] = Field(
..., description="Role of the message sender"
)
content: MessageContent = Field(
...,
description=(
"Message content. It can be a string, null (when calling tools), or a list of structured multimodal blocks."
),
)
name: Optional[str] = Field(
default=None,
description="Optional sender name, required when the role is 'user' or 'tool'",
max_length=64,
pattern=r"^[a-zA-Z0-9_]*$",
)
tool_calls: Optional[List[ToolCall]] = Field(
default=None, description="List of tool calls the assistant wants to invoke"
)
tool_call_id: Optional[str] = Field(
default=None, description="Tool call ID that this tool message responds to"
)
timestamp: Optional[str] = Field(default=None, description="Message timestamp (ISO format)")
@field_validator("content", mode="before")
@classmethod
def normalize_content(cls, value: Any):
return _normalize_message_content(value)
@model_validator(mode="after")
def validate_tool_message_consistency(cls, values):
role = values.role
content = values.content
tool_calls = values.tool_calls
tool_call_id = values.tool_call_id
if role == "assistant" and tool_calls and content is not None:
raise ValueError(
"When role is 'assistant' and tool_calls exist, content must be None."
)
if role == "tool" and not tool_call_id:
raise ValueError("When role is 'tool', tool_call_id must be provided.")
return values
class ChatMessages(RootModel[List[Message]]):
"""Chat message list ordered chronologically."""
class SketchPadItem(BaseModel):
"""Data structure for a SketchPad storage item."""
value: Any = Field(..., description="Stored value")
timestamp: datetime = Field(default_factory=datetime.now, description="Creation time")
summary: Optional[str] = Field(default=None, description="Content summary")
expires_at: Optional[datetime] = Field(default=None, description="Expiration time")
access_count: int = Field(default=0, description="Access count")
last_accessed: Optional[datetime] = Field(default=None, description="Last access time")
tags: Set[str] = Field(default_factory=set, description="Tag set")
content_type: str = Field(default="text", description="Content type")
content_hash: Optional[str] = Field(default=None, description="Content hash value")
@field_validator("last_accessed", mode="before")
@classmethod
def set_last_accessed(cls, v):
"""If last_accessed is None, set it to the current time."""
if v is None:
return datetime.now()
return v
@field_validator("content_hash", mode="before")
@classmethod
def set_content_hash(cls, v, info):
"""If content_hash is None, compute the hash value."""
if v is None:
value = info.data.get("value")
if value is not None:
content_str = str(value)
return hashlib.md5(content_str.encode()).hexdigest()[:8]
return v
def is_expired(self) -> bool:
"""Check whether the item has expired."""
return self.expires_at is not None and datetime.now() > self.expires_at
def update_access(self):
"""Update access information for LRU caching."""
self.access_count += 1
self.last_accessed = datetime.now()
def to_dict(self) -> Dict[str, Any]:
"""Convert to a dictionary for serialization."""
return {
"value": self.value,
"timestamp": self.timestamp.isoformat(),
"summary": self.summary,
"expires_at": self.expires_at.isoformat() if self.expires_at else None,
"access_count": self.access_count,
"last_accessed": (
self.last_accessed.isoformat() if self.last_accessed else None
),
"tags": list(self.tags),
"content_type": self.content_type,
"content_hash": self.content_hash,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "SketchPadItem":
"""Create an instance from a dictionary."""
# Process time fields.
if isinstance(data.get("timestamp"), str):
data["timestamp"] = datetime.fromisoformat(data["timestamp"])
if data.get("expires_at") and isinstance(data["expires_at"], str):
data["expires_at"] = datetime.fromisoformat(data["expires_at"])
if data.get("last_accessed") and isinstance(data["last_accessed"], str):
data["last_accessed"] = datetime.fromisoformat(data["last_accessed"])
# Process tags.
if data.get("tags") and isinstance(data["tags"], list):
data["tags"] = set(data["tags"])
return cls(**data)
class SketchPadStatistics(BaseModel):
"""SketchPad statistics."""
total_items: int = Field(..., description="Total number of items")
max_items: int = Field(..., description="Maximum number of items")
items_with_summary: int = Field(..., description="Number of items with summaries")
total_accesses: int = Field(..., description="Total number of accesses")
popular_tags: Dict[str, int] = Field(..., description="Popular tag statistics")
content_types: Dict[str, int] = Field(..., description="Content type statistics")
avg_access_per_item: float = Field(..., description="Average accesses per item")
memory_usage_percent: float = Field(..., description="Memory usage percentage")
class SketchPadSearchResult(BaseModel):
"""SketchPad search result."""
key: str = Field(..., description="Item key")
value: Any = Field(..., description="Item value")
summary: Optional[str] = Field(default=None, description="Item summary")
timestamp: str = Field(..., description="Creation time (ISO format)")
tags: List[str] = Field(default_factory=list, description="Tag list")
content_type: str = Field(..., description="Content type")
access_count: int = Field(..., description="Access count")
class SketchPadListItem(BaseModel):
"""SketchPad list item."""
key: str = Field(..., description="Item key")
summary: Optional[str] = Field(default=None, description="Item summary")
timestamp: str = Field(..., description="Creation time (ISO format)")
tags: List[str] = Field(default_factory=list, description="Tag list")
content_type: str = Field(..., description="Content type")
access_count: int = Field(..., description="Access count")
content_hash: Optional[str] = Field(default=None, description="Content hash value")
value: Optional[Any] = Field(default=None, description="Item value, only present when content is included")
@@ -0,0 +1,540 @@
import os
import json
import uuid
import threading
from typing import Dict, Optional, List, Type, Any, cast
from datetime import datetime
import redis
from SimpleLLMFunc import OpenAICompatible
from context.sketch_pad import SketchPadBackend, RedisFileSketchPadBackend
from config.config import get_config
class SketchManager:
"""
General-purpose SketchPad manager that supports different backend implementations.
Main responsibilities:
1. Manage creation and lifecycle of SketchPadBackend instances
2. Provide advanced convenience interfaces
3. Handle batch operations and cleanup tasks
4. Support pluggable backend implementations
"""
_instance = None
_lock: threading.Lock = threading.Lock()
def __new__(cls, backend_class: Type[SketchPadBackend] = RedisFileSketchPadBackend):
"""Singleton pattern implementation."""
if cls._instance is None:
with cls._lock:
if cls._instance is None:
cls._instance = super(SketchManager, cls).__new__(cls)
cls._instance.backend_class = backend_class
return cls._instance
def __init__(self, backend_class: Type[SketchPadBackend]):
"""
Initialize the SketchPad manager.
Args:
backend_class: Backend implementation class, defaulting to RedisFileSketchPadBackend
"""
# Prevent duplicate initialization.
if hasattr(self, "_initialized"):
return
self.backend_class = backend_class
self.config = get_config()
self.sketch_dir = self.config.SKETCH_DIR
self._active_sketches: Dict[str, SketchPadBackend] = {}
# Ensure the directory exists.
os.makedirs(self.sketch_dir, exist_ok=True)
self._initialized = True
def _redis_client(self) -> redis.Redis:
return redis.Redis(
host=self.config.REDIS_HOST,
port=int(self.config.REDIS_PORT),
db=int(self.config.REDIS_DB),
decode_responses=True,
)
def _list_sketch_ids_from_redis(self) -> set[str]:
sketch_ids: set[str] = set()
try:
client = self._redis_client()
raw_keys = cast(Any, client.keys("sketch_pad:*:*"))
for key in cast(List[str], raw_keys):
parts = key.split(":", 2)
if len(parts) >= 3 and parts[0] == "sketch_pad" and parts[1]:
sketch_ids.add(parts[1])
except Exception as e:
print(f"Warning: Failed to list sketch ids from Redis: {e}")
return sketch_ids
def _delete_sketch_redis_keys(self, sketch_id: str) -> bool:
try:
client = self._redis_client()
raw_keys = cast(Any, client.keys(f"sketch_pad:{sketch_id}:*"))
keys = cast(List[str], raw_keys)
if not keys:
return False
deleted = cast(Any, client.delete(*keys))
return int(deleted) > 0
except Exception as e:
print(f"Warning: Failed to delete Redis sketch keys for {sketch_id}: {e}")
return False
def create_sketch_pad(
self,
sketch_id: Optional[str] = None,
**backend_kwargs,
) -> SketchPadBackend:
"""
Create a new SketchPad object.
Args:
sketch_id: SketchPad ID; generated automatically if None
**backend_kwargs: Extra parameters passed to the backend
Returns:
SketchPadBackend: Created SketchPad object
"""
with self._lock:
if sketch_id is None:
sketch_id = str(uuid.uuid4())
# Check whether it already exists.
if sketch_id in self._active_sketches:
return self._active_sketches[sketch_id]
# Generate the file path if the backend needs one.
if "file_path" not in backend_kwargs:
sketch_file = os.path.join(self.sketch_dir, f"skt_{sketch_id}.json")
backend_kwargs["file_path"] = sketch_file
# Create the SketchPad object.
sketch_pad = self.backend_class(
sketch_pad_id=sketch_id,
**backend_kwargs,
)
# Add it to the active SketchPad list.
self._active_sketches[sketch_id] = sketch_pad
return sketch_pad
def get_sketch_pad(self, sketch_id: str) -> Optional[SketchPadBackend]:
"""
Get the SketchPad object with the specified ID.
Args:
sketch_id: SketchPad ID
Returns:
SketchPadBackend: SketchPad object, or None if it does not exist
"""
with self._lock:
# First check active SketchPads.
if sketch_id in self._active_sketches:
return self._active_sketches[sketch_id]
# Try to load from file if the backend supports it.
sketch_file = os.path.join(self.sketch_dir, f"skt_{sketch_id}.json")
if os.path.exists(sketch_file):
try:
sketch_pad = self.backend_class(
sketch_pad_id=sketch_id,
file_path=sketch_file,
)
self._active_sketches[sketch_id] = sketch_pad
return sketch_pad
except Exception as e:
print(f"Warning: Failed to load sketch {sketch_id}: {e}")
return None
def delete_sketch_pad(self, sketch_id: str) -> bool:
"""
Delete the SketchPad object with the specified ID.
Args:
sketch_id: SketchPad ID
Returns:
bool: Whether deletion succeeded
"""
with self._lock:
success = False
# Remove it from active SketchPads.
if sketch_id in self._active_sketches:
del self._active_sketches[sketch_id]
success = True
# Delete sketch keys from Redis.
if self._delete_sketch_redis_keys(sketch_id):
success = True
# Delete the file if it exists.
sketch_file = os.path.join(self.sketch_dir, f"skt_{sketch_id}.json")
if os.path.exists(sketch_file):
try:
os.remove(sketch_file)
success = True
except Exception as e:
print(f"Warning: Failed to delete sketch file {sketch_file}: {e}")
return success
def list_sketch_ids(self) -> List[str]:
"""List all known SketchPad IDs, including Redis and the file system."""
sketch_ids = set(self._active_sketches.keys())
sketch_ids.update(self._list_sketch_ids_from_redis())
try:
for filename in os.listdir(self.sketch_dir):
if filename.startswith("skt_") and filename.endswith(".json"):
sketch_ids.add(filename[4:-5])
except Exception as e:
print(f"Warning: Failed to scan sketch dir for ids: {e}")
return sorted(sketch_ids)
def list_sketch_pads(self) -> List[Dict[str, Any]]:
"""
List all available SketchPads.
Returns:
List[Dict]: SketchPad information list
"""
sketches = []
# Scan SketchPad files in the file system.
try:
for filename in os.listdir(self.sketch_dir):
if filename.startswith("skt_") and filename.endswith(".json"):
sketch_id = filename[4:-5] # Remove the "skt_" prefix and ".json" suffix.
sketch_info = {
"sketch_id": sketch_id,
"file_path": os.path.join(self.sketch_dir, filename),
"is_active": sketch_id in self._active_sketches,
}
# Try to read basic information.
try:
file_path = sketch_info["file_path"]
if isinstance(file_path, str):
with open(file_path, "r", encoding="utf-8") as f:
data = json.load(f)
sketch_info.update(
{
"total_items": len(data.get("items", {})),
"last_saved": data.get(
"serialization_timestamp"
),
"sketch_pad_id": data.get("sketch_pad_id"),
}
)
except Exception:
pass # Ignore read errors.
sketches.append(sketch_info)
except Exception as e:
print(f"Warning: Failed to list sketches: {e}")
return sketches
def save_sketch_pad(self, sketch_id: str) -> bool:
"""
Manually save the specified SketchPad to file.
Args:
sketch_id: SketchPad ID
Returns:
bool: Whether saving succeeded
"""
with self._lock:
if sketch_id in self._active_sketches:
try:
sketch_pad = self._active_sketches[sketch_id]
sketch_pad.persist()
return True
except Exception as e:
print(f"Warning: Failed to save sketch {sketch_id}: {e}")
return False
async def save_all_sketch_pads(self) -> int:
"""
Save all active SketchPads to files.
Returns:
int: Number of SketchPads successfully saved
"""
saved_count = 0
with self._lock:
for sketch_id in list(self._active_sketches.keys()):
if self.save_sketch_pad(sketch_id):
saved_count += 1
return saved_count
async def cleanup_inactive_sketches(self, max_inactive_count: int = 10) -> int:
"""
Clean up inactive SketchPads based on usage frequency.
Args:
max_inactive_count: Maximum number of SketchPads to keep active
Returns:
int: Number of cleaned SketchPads
"""
cleaned_count = 0
with self._lock:
if len(self._active_sketches) <= max_inactive_count:
return 0
# Sort by access statistics and keep the most-used items.
sketches_by_usage = []
for sketch_id, sketch_pad in self._active_sketches.items():
try:
stats = sketch_pad.get_statistics()
total_accesses = stats.total_accesses
sketches_by_usage.append((sketch_id, sketch_pad, total_accesses))
except Exception:
sketches_by_usage.append((sketch_id, sketch_pad, 0))
# Sort by access count.
sketches_by_usage.sort(key=lambda x: x[2], reverse=True)
# Save and remove low-usage SketchPads.
sketches_to_remove = sketches_by_usage[max_inactive_count:]
for sketch_id, sketch_pad, _ in sketches_to_remove:
try:
# Save to file.
sketch_pad.persist()
# Remove from the active list.
del self._active_sketches[sketch_id]
cleaned_count += 1
except Exception as e:
print(f"Warning: Error cleaning sketch {sketch_id}: {e}")
return cleaned_count
# ===== Convenience interfaces =====
async def set_item(
self,
sketch_id: str,
key: str,
value: Any,
ttl: Optional[int] = None,
summary: Optional[str] = None,
tags: Optional[set] = None,
) -> Optional[str]:
"""
Convenience method for setting an item.
Args:
sketch_id: SketchPad ID
key: Key name
value: Value
ttl: Expiration time in seconds
summary: Summary
tags: Tags
Returns:
Optional[str]: Set key name, or None on failure
"""
sketch_pad = self.get_sketch_pad(sketch_id)
if not sketch_pad:
return None
try:
return await sketch_pad.set_item(key, value, ttl, summary, tags)
except Exception as e:
print(f"Warning: Failed to set item: {e}")
return None
def get_item(self, sketch_id: str, key: str) -> Optional[Any]:
"""
Get an item.
Args:
sketch_id: SketchPad ID
key: Key name
Returns:
Optional[Any]: Item value
"""
sketch_pad = self.get_sketch_pad(sketch_id)
if not sketch_pad:
return None
return sketch_pad.get_item(key)
def get_value(self, sketch_id: str, key: str) -> Optional[Any]:
"""
Get a value.
Args:
sketch_id: SketchPad ID
key: Key name
Returns:
Optional[Any]: Value
"""
sketch_pad = self.get_sketch_pad(sketch_id)
if not sketch_pad:
return None
return sketch_pad.get_value(key)
def search_by_tags(
self, sketch_id: str, tags: set, match_all: bool = False
) -> List[tuple]:
"""
Search by tags.
Args:
sketch_id: SketchPad ID
tags: Tag set
match_all: Whether to match all tags
Returns:
List[tuple]: Search results
"""
sketch_pad = self.get_sketch_pad(sketch_id)
if not sketch_pad:
return []
return sketch_pad.search_by_tags(tags, match_all)
def search_by_content(
self, sketch_id: str, query: str, limit: int = 5
) -> List[tuple]:
"""
Search by content.
Args:
sketch_id: SketchPad ID
query: Search query
limit: Result count limit
Returns:
List[tuple]: Search results
"""
sketch_pad = self.get_sketch_pad(sketch_id)
if not sketch_pad:
return []
return sketch_pad.search_by_content(query, limit)
def delete_item(self, sketch_id: str, key: str) -> bool:
"""
Delete an item.
Args:
sketch_id: SketchPad ID
key: Key name
Returns:
bool: Whether deletion succeeded
"""
sketch_pad = self.get_sketch_pad(sketch_id)
if not sketch_pad:
return False
return sketch_pad.delete(key)
def get_statistics(self, sketch_id: str) -> Optional[Any]:
"""
Get statistics.
Args:
sketch_id: SketchPad ID
Returns:
Optional[Any]: Statistics
"""
sketch_pad = self.get_sketch_pad(sketch_id)
if not sketch_pad:
return None
try:
return sketch_pad.get_statistics()
except Exception as e:
print(f"Warning: Failed to get statistics: {e}")
return None
def list_items(self, sketch_id: str, include_value: bool = False) -> List[Any]:
"""
List all items.
Args:
sketch_id: SketchPad ID
include_value: Whether to include values
Returns:
List[Any]: Item list
"""
sketch_pad = self.get_sketch_pad(sketch_id)
if not sketch_pad:
return []
try:
return sketch_pad.list_items(include_value)
except Exception as e:
print(f"Warning: Failed to list items: {e}")
return []
# Global instance.
_global_sketch_manager: Optional[SketchManager] = None
def get_sketch_manager() -> SketchManager:
"""Get the global SketchManager instance."""
global _global_sketch_manager
if _global_sketch_manager is None:
# Get Redis configuration from config.
config = get_config()
# redis config
redis_host = config.REDIS_HOST
redis_port = int(config.REDIS_PORT)
redis_db = int(config.REDIS_DB)
# Create a custom backend class with preconfigured Redis parameters.
class ConfiguredRedisFileSketchPadBackend(RedisFileSketchPadBackend):
def __init__(
self,
sketch_pad_id: str,
redis_host: str = redis_host,
redis_port: int = redis_port,
redis_db: int = redis_db,
file_path: Optional[str] = None,
):
super().__init__(
sketch_pad_id=sketch_pad_id,
redis_host=redis_host,
redis_port=redis_port,
redis_db=redis_db,
file_path=file_path,
)
# Create SketchManager using the configured backend class.
_global_sketch_manager = SketchManager(
backend_class=ConfiguredRedisFileSketchPadBackend
)
return _global_sketch_manager
+586
View File
@@ -0,0 +1,586 @@
from abc import ABC, abstractmethod
from typing import Any, Dict, List, Optional, Union, Set, Tuple, override, cast
from datetime import datetime, timedelta
import threading
import json
import os
import hashlib
from context.schemas import (
SketchPadItem,
SketchPadStatistics,
SketchPadListItem,
)
from redis import Redis
class SketchPadBackend(ABC):
"""
SketchPad base interface.
Defines the operations that any SketchPad backend implementation must support.
Every sketch item has the following attributes:
value: Any = Field(..., description="Stored value")
timestamp: datetime = Field(default_factory=datetime.now, description="Creation time")
summary: Optional[str] = Field(default=None, description="Content summary")
expires_at: Optional[datetime] = Field(default=None, description="Expiration time")
access_count: int = Field(default=0, description="Access count")
last_accessed: Optional[datetime] = Field(default=None, description="Last access time")
tags: Set[str] = Field(default_factory=set, description="Tag set")
content_type: str = Field(default="text", description="Content type")
content_hash: Optional[str] = Field(default=None, description="Content hash value")
"""
@abstractmethod
def __init__(
self,
sketch_pad_id: str,
file_path: Optional[str] = None,
):
"""
Initialize the SketchPad backend.
Args:
sketch_pad_id: Unique identifier for the SketchPad backend
file_path: File path used for persisting data
"""
pass
@abstractmethod
async def set_item(
self,
key: str,
value: Any,
ttl: Optional[int] = None,
summary: Optional[str] = None,
tags: Optional[Set[str]] = None,
) -> str:
"""Set a key-value pair."""
pass
@abstractmethod
def get_item(self, key: str) -> Optional[SketchPadItem]:
"""Get complete item information."""
pass
@abstractmethod
def get_value(self, key: str) -> Any:
"""Get only the value."""
pass
@abstractmethod
def search_by_tags(
self, tags: Set[str], match_all: bool = False
) -> List[Tuple[str, SketchPadItem]]:
"""Search by tags."""
pass
@abstractmethod
def search_by_content(
self, query: str, limit: int = 5
) -> List[Tuple[str, SketchPadItem]]:
"""Simple content-based search."""
pass
@abstractmethod
def delete(self, key: str) -> bool:
"""Delete a key-value pair."""
pass
@abstractmethod
def exists(self, key: str) -> bool:
"""Check whether a key exists."""
pass
@abstractmethod
def keys(self, pattern: Optional[str] = None) -> List[str]:
"""Get all key names."""
pass
@abstractmethod
def clear(self) -> None:
"""Clear all data."""
pass
@abstractmethod
def serialize(self) -> Dict[str, Any]:
"""Serialize to a dictionary for saving to file."""
pass
@abstractmethod
def deserialize(self, data: Dict[str, Any]) -> None:
"""Deserialize from a dictionary for loading from file."""
pass
@abstractmethod
def persist(self) -> None:
"""Persist data."""
pass
@abstractmethod
def restore(self) -> None:
"""Restore from persisted data."""
pass
@abstractmethod
def get_statistics(self) -> SketchPadStatistics:
"""Get statistics."""
pass
@abstractmethod
def list_items(self, include_value: bool = False) -> List[SketchPadListItem]:
"""List all items."""
pass
class RedisFileSketchPadBackend(SketchPadBackend):
"""
RedisFileSketchPadBackend combines immediate Redis storage with file-system persistence for SketchPad backend implementation.
Features:
1. Redis provides high-performance immediate access
2. The file system provides reliable persistence
3. Supports automatic synchronization and recovery
4. Uses Redis AOF + RDB mechanisms
"""
@override
def __init__(
self,
sketch_pad_id: str,
redis_host: str = "localhost",
redis_port: int = 6379,
redis_db: int = 0,
file_path: Optional[str] = None,
):
"""
Initialize RedisFileSketchPadBackend.
Args:
sketch_pad_id: Unique SketchPad identifier
redis_host: Redis host
redis_port: Redis port
redis_db: Redis database
file_path: File path used for persisting data
"""
self.sketch_pad_id = sketch_pad_id
self.file_path = file_path or f"sketch_pads/sketch_{sketch_pad_id}.json"
self.redis_host = redis_host
self.redis_port = redis_port
self.redis_db = redis_db
self.redis: Redis = Redis(host=self.redis_host, port=self.redis_port, db=self.redis_db)
self._lock = threading.RLock()
self._restore_from_storage()
# ---- Redis typed helpers (to avoid Awaitable union types in stubs) ----
def _redis_get(self, key: str) -> Optional[bytes]:
raw = cast(Any, self.redis.get(key))
return cast(Optional[bytes], raw)
def _redis_keys(self, pattern: str) -> List[bytes]:
raw = cast(Any, self.redis.keys(pattern))
return cast(List[bytes], raw)
def _redis_smembers(self, key: str) -> Set[bytes]:
raw = cast(Any, self.redis.smembers(key))
return cast(Set[bytes], raw)
def _redis_delete(self, *keys: Union[str, bytes]) -> int:
raw = cast(Any, self.redis.delete(*keys))
return cast(int, raw)
def _redis_exists(self, key: str) -> int:
raw = cast(Any, self.redis.exists(key))
return cast(int, raw)
def _restore_from_storage(self) -> None:
"""Restore from storage."""
with self._lock:
if self.file_path is None:
return
if os.path.exists(self.file_path):
try:
with open(self.file_path, "r", encoding="utf-8") as f:
data = json.load(f)
self.deserialize(data)
except Exception as e:
print(f"Warning: Failed to restore from file: {e}")
def _get_redis_key(self, key: str) -> str:
"""Get the Redis key name."""
return f"sketch_pad:{self.sketch_pad_id}:{key}"
def _get_content_hash(self, value: Any) -> str:
"""Compute the hash value of the content."""
content_str = json.dumps(value, sort_keys=True, ensure_ascii=False)
return hashlib.md5(content_str.encode('utf-8')).hexdigest()
@override
async def set_item(
self,
key: str,
value: Any,
ttl: Optional[int] = None,
summary: Optional[str] = None,
tags: Optional[Set[str]] = None,
) -> str:
"""
Set a key-value pair.
Args:
key: Key
value: Value
ttl: Expiration time in seconds
summary: Summary
tags: Tags
"""
with self._lock:
# Create SketchPadItem.
item = SketchPadItem(
value=value,
timestamp=datetime.now(),
summary=summary,
tags=tags or set(),
expires_at=datetime.now() + timedelta(seconds=ttl) if ttl else None,
content_hash=self._get_content_hash(value),
)
# Store in Redis.
item_json = item.model_dump_json()
redis_key = self._get_redis_key(key)
self.redis.set(redis_key, item_json)
# Set expiration time.
if ttl:
self.redis.expire(redis_key, ttl)
# Update tag index.
if tags:
for tag in tags:
tag_key = self._get_redis_key(f"tag:{tag}")
self.redis.sadd(tag_key, key)
return key
@override
def get_item(self, key: str) -> Optional[SketchPadItem]:
"""Get complete item information."""
with self._lock:
item_json_opt = self._redis_get(self._get_redis_key(key))
if item_json_opt is None:
return None
try:
item_bytes = cast(bytes, item_json_opt)
item = SketchPadItem.model_validate_json(item_bytes)
# Update access information.
item.access_count += 1
item.last_accessed = datetime.now()
# Update access information in Redis.
item_json_str: str = item.model_dump_json()
self.redis.set(self._get_redis_key(key), item_json_str)
return item
except Exception as e:
print(f"Warning: Failed to deserialize item: {e}")
return None
@override
def get_value(self, key: str) -> Any:
"""Get only the value."""
item = self.get_item(key)
return item.value if item else None
@override
def search_by_tags(
self, tags: Set[str], match_all: bool = False
) -> List[Tuple[str, SketchPadItem]]:
"""Search by tags."""
with self._lock:
results: List[Tuple[str, SketchPadItem]] = []
if match_all:
# Must match all tags.
if not tags:
return results
# Get all keys for the first tag.
first_tag = list(tags)[0]
tag_key = self._get_redis_key(f"tag:{first_tag}")
candidate_keys = self._redis_smembers(tag_key)
# Check whether each candidate key contains all tags.
for cand_key in candidate_keys:
cand_key_str: str = cand_key.decode('utf-8')
item = self.get_item(cand_key_str)
if item and tags.issubset(item.tags):
results.append((cand_key_str, item))
else:
# Match any tag.
for tag in tags:
tag_key = self._get_redis_key(f"tag:{tag}")
keys = self._redis_smembers(tag_key)
for member_key in keys:
member_key_str: str = member_key.decode('utf-8')
item = self.get_item(member_key_str)
if item and (member_key_str, item) not in results:
results.append((member_key_str, item))
return results
@override
def search_by_content(
self, query: str, limit: int = 5
) -> List[Tuple[str, SketchPadItem]]:
"""Simple content-based search."""
with self._lock:
results: List[Tuple[str, SketchPadItem]] = []
query_lower = query.lower()
# Get all keys, but filter out tag index keys.
pattern = self._get_redis_key("*")
all_keys = self._redis_keys(pattern)
for key in all_keys:
redis_key_string: str = key.decode('utf-8')
# Filter out tag index keys.
if ":tag:" in redis_key_string:
continue
# Extract the original key name.
original_key = redis_key_string.split(":", 2)[-1]
item = self.get_item(original_key)
if item:
# Search value, summary, and tags.
searchable_text = ""
if isinstance(item.value, str):
searchable_text += item.value + " "
if item.summary:
searchable_text += item.summary + " "
if item.tags:
searchable_text += " ".join(item.tags) + " "
if query_lower in searchable_text.lower():
results.append((original_key, item))
if len(results) >= limit:
break
return results
@override
def delete(self, key: str) -> bool:
"""Delete a key-value pair."""
with self._lock:
# Get the item to delete tag indexes.
item = self.get_item(key)
if item and item.tags:
for tag in item.tags:
tag_key = self._get_redis_key(f"tag:{tag}")
self.redis.srem(tag_key, key)
# Delete the primary key.
redis_key = self._get_redis_key(key)
result = self._redis_delete(redis_key)
return result > 0
@override
def exists(self, key: str) -> bool:
"""Check whether a key exists."""
with self._lock:
exists_count = self._redis_exists(self._get_redis_key(key))
return exists_count > 0
@override
def keys(self, pattern: Optional[str] = None) -> List[str]:
"""Get all key names."""
with self._lock:
redis_pattern = self._get_redis_key(pattern or "*")
keys = self._redis_keys(redis_pattern)
# Extract the original key name.
result: List[str] = []
for key in keys:
redis_key_str_list: str = key.decode('utf-8')
original_key = redis_key_str_list.split(":", 2)[-1]
result.append(original_key)
return result
@override
def clear(self) -> None:
"""Clear all data."""
with self._lock:
# Get all keys.
pattern = self._get_redis_key("*")
keys = self._redis_keys(pattern)
# Delete all keys.
if keys:
self._redis_delete(*keys)
@override
def serialize(self) -> Dict[str, Any]:
"""Serialize to a dictionary for saving to file."""
with self._lock:
data: Dict[str, Any] = {
"sketch_pad_id": self.sketch_pad_id,
"items": {},
"serialization_timestamp": datetime.now().isoformat(),
}
# Serialize all items, but filter out tag index keys.
pattern = self._get_redis_key("*")
all_keys = self._redis_keys(pattern)
for key in all_keys:
redis_key_str_ser: str = key.decode('utf-8')
# Filter out tag index keys.
if ":tag:" in redis_key_str_ser:
continue
# Extract the original key name.
original_key = redis_key_str_ser.split(":", 2)[-1]
item = self.get_item(original_key)
if item:
data["items"][original_key] = item.model_dump()
return data
@override
def deserialize(self, data: Dict[str, Any]) -> None:
"""Deserialize from a dictionary for loading from file."""
with self._lock:
if "items" in data:
for key, item_data in data["items"].items():
try:
item = SketchPadItem(**item_data)
item_json = item.model_dump_json()
self.redis.set(self._get_redis_key(key), item_json)
# Restore tag indexes.
if item.tags:
for tag in item.tags:
tag_key = self._get_redis_key(f"tag:{tag}")
self.redis.sadd(tag_key, key)
except Exception as e:
print(f"Warning: Failed to deserialize item {key}: {e}")
@override
def persist(self) -> None:
"""Persist data."""
try:
# Ensure the directory exists.
dir_path = os.path.dirname(self.file_path)
if dir_path:
os.makedirs(dir_path, exist_ok=True)
# Serialize data.
data = self.serialize()
# Write to file.
with open(self.file_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2, default=str)
except Exception as e:
print(f"Warning: Failed to persist sketch pad: {e}")
@override
def restore(self) -> None:
"""Restore from persisted data."""
if not os.path.exists(self.file_path):
return
try:
with open(self.file_path, "r", encoding="utf-8") as f:
data = json.load(f)
self.deserialize(data)
except Exception as e:
print(f"Warning: Failed to restore sketch pad: {e}")
def get_statistics(self) -> SketchPadStatistics:
"""Get statistics."""
with self._lock:
# Get only actual data keys, excluding tag index keys.
pattern = self._get_redis_key("*")
all_keys = self._redis_keys(pattern)
data_keys: List[str] = []
for key in all_keys:
redis_key_str_stats: str = key.decode('utf-8')
# Filter out tag index keys.
if not redis_key_str_stats.endswith(":tag:") and ":tag:" not in redis_key_str_stats:
original_key = redis_key_str_stats.split(":", 2)[-1]
data_keys.append(original_key)
total_items = len(data_keys)
total_accesses = 0
items_with_summary = 0
popular_tags: Dict[str, int] = {}
content_types: Dict[str, int] = {}
for data_key in data_keys:
item = self.get_item(data_key)
if item:
total_accesses += item.access_count
if item.summary:
items_with_summary += 1
# Count tags.
for tag in item.tags:
popular_tags[tag] = popular_tags.get(tag, 0) + 1
# Count content types.
content_types[item.content_type] = content_types.get(item.content_type, 0) + 1
avg_access_per_item = total_accesses / total_items if total_items > 0 else 0
memory_usage_percent = (total_items / 1000) * 100 # Assume a maximum of 1000 items.
return SketchPadStatistics(
total_items=total_items,
max_items=1000,
items_with_summary=items_with_summary,
total_accesses=total_accesses,
popular_tags=popular_tags,
content_types=content_types,
avg_access_per_item=avg_access_per_item,
memory_usage_percent=memory_usage_percent,
)
def list_items(self, include_value: bool = False) -> List[SketchPadListItem]:
"""List all items."""
with self._lock:
items: List[SketchPadListItem] = []
# Get all keys, but filter out tag index keys.
pattern = self._get_redis_key("*")
all_keys = self._redis_keys(pattern)
for key in all_keys:
redis_key_str_list_items: str = key.decode('utf-8')
# Filter out tag index keys.
if ":tag:" in redis_key_str_list_items:
continue
# Extract the original key name.
original_key = redis_key_str_list_items.split(":", 2)[-1]
item = self.get_item(original_key)
if item:
list_item = SketchPadListItem(
key=original_key,
summary=item.summary,
timestamp=item.timestamp.isoformat(),
tags=list(item.tags),
content_type=item.content_type,
access_count=item.access_count,
content_hash=item.content_hash,
value=item.value if include_value else None,
)
items.append(list_item)
return items
+78
View File
@@ -0,0 +1,78 @@
# Use Python 3.12 as the base image (satisfies requires-python >=3.12).
# Force the amd64 architecture to obtain an available `cadquery-ocp` wheel; Linux aarch64 has no official wheel.
FROM --platform=linux/amd64 python:3.12-slim
# Set the working directory.
WORKDIR /app
# Set environment variables.
ENV PYTHONPATH=/app
ENV PYTHONUNBUFFERED=1
ENV DEBIAN_FRONTEND=noninteractive
RUN set -e; . /etc/os-release; \
codename="${VERSION_CODENAME}"; \
printf 'deb https://mirrors.ustc.edu.cn/debian/ %s main contrib non-free non-free-firmware\n' "$codename" > /etc/apt/sources.list; \
printf 'deb https://mirrors.ustc.edu.cn/debian/ %s-updates main contrib non-free non-free-firmware\n' "$codename" >> /etc/apt/sources.list; \
printf 'deb https://mirrors.ustc.edu.cn/debian-security %s-security main contrib non-free non-free-firmware\n' "$codename" >> /etc/apt/sources.list; \
printf 'deb https://mirrors.ustc.edu.cn/debian/ %s-backports main contrib non-free non-free-firmware\n' "$codename" >> /etc/apt/sources.list
# Install system dependencies, including OpenGL runtime libraries for libGL.so.1.
RUN apt-get update && apt-get install -y \
build-essential \
curl \
git \
# OpenGL/GLX/EGL runtime libraries commonly needed by VTK/Qt/pyvista.
libgl1 \
libglib2.0-0 \
libsm6 \
libxext6 \
libxrender1 \
libglu1-mesa \
libglx-mesa0 \
libegl1 \
# Headless rendering with Xvfb.
xvfb \
xauth \
libx11-6 \
libxkbcommon0 \
fonts-dejavu-core \
&& rm -rf /var/lib/apt/lists/*
ENV TMPDIR=/app/tmp
RUN mkdir -p /app/tmp && chmod 1777 /app/tmp
# Copy only exported requirements to maximize layer caching.
COPY requirements.txt ./
# requirements.txt is automatically exported from uv.lock in pre-commit.
RUN export PIP_INDEX_URL="https://mirrors.ustc.edu.cn/pypi/web/simple" \
&& pip install --no-cache-dir -r requirements.txt
ENV DISPLAY=:99
ENV PYVISTA_OFF_SCREEN=true
ENV PYVISTA_USE_PANEL=false
ENV QT_QPA_PLATFORM=offscreen
ENV MPLBACKEND=Agg
ENV LIBGL_ALWAYS_SOFTWARE=1
# Copy project files.
COPY . .
# Create required directories.
RUN mkdir -p /app/data /app/logs /app/workspace
# Set permissions.
RUN chmod +x /app/start_caddesigner_api.py
# Copy and configure the entrypoint script to provide Xvfb headless rendering for the API service.
COPY docker/entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh
# Expose the API port.
EXPOSE 8000
# The entrypoint script starts Xvfb and then executes the final command.
ENTRYPOINT ["/app/entrypoint.sh"]
# Start the API by default.
@@ -0,0 +1,17 @@
FROM node:22-bookworm-slim
WORKDIR /app/frontend
ENV CI=true
COPY frontend/package.json frontend/pnpm-lock.yaml ./
RUN corepack enable \
&& corepack prepare pnpm@10.29.3 --activate \
&& pnpm install --frozen-lockfile
COPY frontend/ ./
EXPOSE 7860
CMD ["sh", "-lc", "pnpm dev --host 0.0.0.0 --port 7860 --strictPort"]
@@ -0,0 +1,21 @@
services:
# Start Redis only for local development integration.
simplecad_redis_dev:
image: redis:7-alpine
container_name: simplecad_redis_dev
restart: unless-stopped
ports:
- "9736:9736"
volumes:
- simplecad_redis_data:/data/simplecad_redis
- ./redis.conf:/usr/local/etc/redis/redis.conf:ro
command: redis-server /usr/local/etc/redis/redis.conf
healthcheck:
test: ["CMD", "redis-cli", "-p", "9736", "ping"]
interval: 10s
timeout: 5s
retries: 5
volumes:
simplecad_redis_data:
driver: local
@@ -0,0 +1,88 @@
services:
# Redis service.
simplecad_redis:
image: redis:7-alpine
container_name: simplecad_redis
restart: unless-stopped
ports:
- "9736:9736"
volumes:
- simplecad_redis_data:/data/simplecad_redis
- ./redis.conf:/usr/local/etc/redis/redis.conf:ro
command: redis-server /usr/local/etc/redis/redis.conf
networks:
- simplecad_network
healthcheck:
test: ["CMD", "redis-cli", "-p", "9736", "ping"]
interval: 10s
timeout: 5s
retries: 5
# API service.
simplecad_api:
build:
context: ..
dockerfile: docker/Dockerfile
platform: linux/amd64
container_name: simplecad_api
restart: unless-stopped
ports:
- "8000:8000"
env_file:
- ${CADDESIGNER_DOCKER_ENV_FILE:-.env}
environment:
- REDIS_HOST=simplecad_redis
- REDIS_PORT=9736
- REDIS_DB=0
- LOG_LEVEL=WARNING
volumes:
- app_data:/app/data
- app_logs:/app/logs
- app_workspace:/app/workspace
- ./provider.json:/app/config/provider.json:ro
depends_on:
simplecad_redis:
condition: service_healthy
command: ["python", "start_caddesigner_api.py", "--host", "0.0.0.0", "--port", "8000", "--working-dir", "/app/workspace"]
networks:
- simplecad_network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 15s
timeout: 5s
retries: 5
start_period: 40s
# Linux compatibility: allow the container to resolve the host machine domain.
extra_hosts:
- "host.docker.internal:host-gateway"
# React Web UI service.
simplecad_webui:
build:
context: ..
dockerfile: docker/Dockerfile.frontend
container_name: simplecad_webui
restart: unless-stopped
ports:
- "7860:7860"
environment:
- CADDESIGNER_API_PROXY_TARGET=http://simplecad_api:8000
depends_on:
simplecad_api:
condition: service_healthy
networks:
- simplecad_network
volumes:
simplecad_redis_data:
driver: local
app_data:
driver: local
app_logs:
driver: local
app_workspace:
driver: local
networks:
simplecad_network:
driver: bridge
@@ -0,0 +1,19 @@
#!/usr/bin/env sh
set -e
export DISPLAY=:99
rm -f /tmp/.X99-lock
if ! pgrep -x Xvfb >/dev/null 2>&1; then
echo "[entrypoint] Starting Xvfb on $DISPLAY"
Xvfb $DISPLAY -screen 0 1920x1080x24 -nolisten tcp &
fi
# Wait for Xvfb to become ready.
sleep 0.5
# Execute the passed command.
exec "$@"
+19
View File
@@ -0,0 +1,19 @@
# Redis configuration
REDIS_DB=0
REDIS_PASSWORD=
REDIS_HOST=localhost
REDIS_PORT=9736
# Storage directories
CONTEXT_DIR=workspace/data/contexts
CONTEXT_AUTO_SUMMARIZE_TRIGGER=1000000
SKETCH_DIR=workspace/data/sketches
# Logging
LOG_DIR=workspace/agent_logs
LOG_LEVEL=WARNING
# Langfuse configuration, optional
LANGFUSE_SECRET_KEY=your_langfuse_secret_key
LANGFUSE_PUBLIC_KEY=your_langfuse_public_key
LANGFUSE_BASE_URL=https://cloud.langfuse.com
@@ -0,0 +1,52 @@
{
"volc_engine": [
{
"model_name": "deepseek-v3-250324",
"api_keys": [
"your_keys"
],
"base_url": "https://ark.cn-beijing.volces.com/api/v3/",
"max_retries": 3,
"retry_delay": 1
},
{
"model_name": "doubao-1-5-pro-32k-250115",
"api_keys": [
"your_keys"
],
"base_url": "https://ark.cn-beijing.volces.com/api/v3/",
"max_retries": 3,
"retry_delay": 1
}
],
"openrouter": [
{
"model_name": "z-ai/glm-5",
"api_keys": [
"your_keys"
],
"base_url": "https://openrouter.ai/api/v1"
},
{
"model_name": "google/gemini-3.1-pro-preview",
"api_keys": [
"your_keys"
],
"base_url": "https://openrouter.ai/api/v1"
},
{
"model_name": "anthropic/claude-sonnet-4.6",
"api_keys": [
"your_keys"
],
"base_url": "https://openrouter.ai/api/v1"
},
{
"model_name": "google/gemini-3-flash-preview",
"api_keys": [
"your_keys"
],
"base_url": "https://openrouter.ai/api/v1"
}
]
}
+52
View File
@@ -0,0 +1,52 @@
# Redis configuration file.
# Basic settings.
bind 0.0.0.0
port 9736
timeout 0
tcp-keepalive 300
# Memory management.
maxmemory 256mb
maxmemory-policy allkeys-lru
# Persistence settings.
save 900 1
save 300 10
save 60 10000
stop-writes-on-bgsave-error yes
rdbcompression yes
rdbchecksum yes
dbfilename dump_backend_redis.rdb
dir /data/simplecad_redis
# AOF settings.
appendonly yes
appendfilename "appendonly.aof"
appendfsync everysec
no-appendfsync-on-rewrite no
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb
# Logging settings.
loglevel notice
logfile ""
# Security settings.
# requirepass your_redis_password_here # Uncomment and set a password.
# Performance optimization.
tcp-backlog 511
databases 16
hash-max-ziplist-entries 512
hash-max-ziplist-value 64
list-max-ziplist-size -2
list-compress-depth 0
set-max-intset-entries 512
zset-max-ziplist-entries 128
zset-max-ziplist-value 64
activerehashing yes
client-output-buffer-limit normal 0 0 0
client-output-buffer-limit slave 256mb 64mb 60
client-output-buffer-limit pubsub 32mb 8mb 60
hz 10
aof-rewrite-incremental-fsync yes
@@ -0,0 +1,218 @@
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import asyncio
import tempfile
from context.sketch_pad import RedisFileSketchPadBackend
async def sketch_pad_example():
"""SketchPad usage example."""
print("=== SketchPad Usage Example ===")
# Create a temporary file.
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
temp_file = f.name
try:
# Initialize SketchPad.
sketch_pad = RedisFileSketchPadBackend(
sketch_pad_id="example_pad",
file_path=temp_file
)
print("1. Storing user preference settings...")
await sketch_pad.set_item(
key="user_preferences",
value={
"theme": "dark",
"language": "en-US",
"notifications": True,
"auto_save": True
},
summary="User interface preference settings",
tags={"preferences", "ui", "settings"}
)
print("2. Storing recently accessed files...")
await sketch_pad.set_item(
key="recent_files",
value=[
{"name": "main.py", "path": "/project/main.py", "last_accessed": "2024-01-15"},
{"name": "config.json", "path": "/project/config.json", "last_accessed": "2024-01-14"},
{"name": "README.md", "path": "/project/README.md", "last_accessed": "2024-01-13"}
],
summary="List of recently accessed files",
tags={"files", "recent", "history"}
)
print("3. Storing temporary calculation results...")
await sketch_pad.set_item(
key="temp_calculation",
value={
"expression": "2 + 3 * 4",
"result": 14,
"timestamp": "2024-01-15T10:30:00"
},
summary="Temporary math calculation result",
tags={"calculation", "temp", "math"},
ttl=300 # Expires after 5 minutes.
)
print("4. Storing code snippets...")
await sketch_pad.set_item(
key="code_snippet",
value={
"language": "python",
"code": "def hello_world():\n print('Hello, World!')",
"description": "Simple Hello World function"
},
summary="Python code snippet",
tags={"code", "python", "snippet"}
)
print("\n5. Querying and retrieving...")
# Get user preferences.
preferences = sketch_pad.get_item("user_preferences")
print(f" User preferences: {preferences.value if preferences else 'Not found'}")
# Search for items containing "file".
file_items = sketch_pad.search_by_content("file", limit=5)
print(f" Items containing 'file': {len(file_items)}")
# Search for items with a specific tag.
recent_items = sketch_pad.search_by_tags({"recent"})
print(f" Recent item count: {len(recent_items)}")
# Search for items with multiple tags.
ui_items = sketch_pad.search_by_tags({"ui", "settings"}, match_all=True)
print(f" Items containing both ui and settings tags: {len(ui_items)}")
print("\n6. Statistics...")
stats = sketch_pad.get_statistics()
print(f" Total item count: {stats.total_items}")
print(f" Total accesses: {stats.total_accesses}")
print(f" Popular tags: {stats.popular_tags}")
print("\n7. Listing all items...")
items = sketch_pad.list_items(include_value=False)
for item in items:
print(f" - {item.key}: {item.summary} (tags: {item.tags})")
print("\n8. Persisting data...")
sketch_pad.persist()
print(" Data saved to file")
print("\n=== Example Complete ===")
finally:
if os.path.exists(temp_file):
os.unlink(temp_file)
async def llm_integration_example():
"""Example showing SketchPad integration with an LLM."""
print("\n=== SketchPad and LLM Integration Example ===")
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
temp_file = f.name
try:
sketch_pad = RedisFileSketchPadBackend(
sketch_pad_id="llm_memory",
file_path=temp_file
)
print("1. Storing LLM conversation context...")
await sketch_pad.set_item(
key="conversation_context",
value={
"user_intent": "Create a Python function to calculate the Fibonacci sequence",
"current_step": "Implementing the function logic",
"requirements": ["recursive implementation", "performance optimization", "error handling"],
"code_progress": "def fibonacci(n):\n if n <= 1:\n return n\n return fibonacci(n-1) + fibonacci(n-2)"
},
summary="LLM conversation context - Fibonacci function development",
tags={"llm", "conversation", "python", "fibonacci", "development"}
)
print("2. Storing user feedback...")
await sketch_pad.set_item(
key="user_feedback",
value={
"feedback_type": "positive",
"message": "The function works well, but it could use some comments",
"suggestions": ["add a docstring", "add type hints", "add examples"],
"timestamp": "2024-01-15T11:00:00"
},
summary="User feedback on the Fibonacci function",
tags={"feedback", "user", "improvement"}
)
print("3. Storing code version...")
await sketch_pad.set_item(
key="code_version_1",
value={
"version": "1.0",
"code": "def fibonacci(n):\n \"\"\"Calculate the nth term of the Fibonacci sequence.\"\"\"\n if n <= 1:\n return n\n return fibonacci(n-1) + fibonacci(n-2)",
"features": ["basic recursive implementation", "simple error handling"],
"performance": "O(2^n) time complexity"
},
summary="Fibonacci function version 1.0",
tags={"code", "version", "fibonacci", "recursive"}
)
print("\n4. LLM query example...")
# Simulate an LLM querying related context.
context_items = sketch_pad.search_by_tags({"llm", "conversation"})
print(f" Found {len(context_items)} conversation context items")
# Simulate an LLM querying user feedback.
feedback_items = sketch_pad.search_by_content("feedback", limit=3)
print(f" Found {len(feedback_items)} feedback-related items")
# Simulate an LLM querying code versions.
code_items = sketch_pad.search_by_tags({"code", "version"})
print(f" Found {len(code_items)} code version items")
print("\n5. Building LLM context...")
context_parts = []
# Get conversation context.
conv_context = sketch_pad.get_item("conversation_context")
if conv_context:
context_parts.append(f"Conversation context: {conv_context.value}")
# Get user feedback.
feedback = sketch_pad.get_item("user_feedback")
if feedback:
context_parts.append(f"User feedback: {feedback.value}")
# Get the latest code version.
code_version = sketch_pad.get_item("code_version_1")
if code_version:
context_parts.append(f"Current code: {code_version.value}")
print(" Constructed LLM context:")
for i, part in enumerate(context_parts, 1):
print(f" {i}. {part[:100]}...")
print("\n=== LLM Integration Example Complete ===")
finally:
if os.path.exists(temp_file):
os.unlink(temp_file)
async def main():
"""Main function."""
await sketch_pad_example()
await llm_integration_example()
print("\nAll examples complete!")
if __name__ == "__main__":
asyncio.run(main())
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+46
View File
@@ -0,0 +1,46 @@
# CADDesigner React UI
This directory contains the standalone TypeScript + React frontend for the CADDesigner project.
## What It Includes
- A collapsible session sidebar with conversation stats and history
- A left-side chat timeline that interleaves assistant text and tool calls in event order
- A right-side workbench with a resizable 3D preview pane and code viewer
- Native streaming from the backend `POST /v1/chat/events` SSE endpoint
- A Vite dev proxy for `/health` and `/v1/*`
## Run It
For the standard local workflow from the repo root, use the startup scripts:
```bash
uv run python start_caddesigner_full.py
```
If you are working on the frontend itself, start the FastAPI backend first on
`http://127.0.0.1:8000`, then run:
```bash
pnpm install
pnpm dev
```
The direct Vite dev server runs on `http://127.0.0.1:4173` by default.
The wrapper script `start_caddesigner_ui.py` starts the same frontend on
`http://127.0.0.1:7860` and auto-installs dependencies when `node_modules/`
is missing.
## Build It
```bash
pnpm build
```
## Notes
- `frontend/.gitignore` ignores local frontend build artifacts like `node_modules/` and `dist/`.
- The startup scripts prefer `pnpm`; they fall back to `npm` only if `pnpm` is unavailable.
- The current 3D viewport focuses on STL previews, which matches the agent's main output flow.
- Model preview assets are loaded through the backend artifact endpoints added under `/v1/conversations/{conversation_id}/artifacts/*`.
@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
@@ -0,0 +1,41 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"packageManager": "pnpm@10.29.3",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0",
"lucide-react": "^0.577.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.7.3",
"react-syntax-highlighter": "^16.1.1",
"remark-gfm": "^4.0.1",
"three": "^0.183.2"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/node": "^24.12.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/three": "^0.183.1",
"@vitejs/plugin-react": "^6.0.0",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.56.1",
"vite": "^8.0.0"
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

File diff suppressed because it is too large Load Diff
+642
View File
@@ -0,0 +1,642 @@
import { Suspense, lazy, useCallback, useEffect, useState } from 'react'
import { Group, Panel, Separator } from 'react-resizable-panels'
import './App.css'
import {
appendAssistantTextBlock,
applyStreamPacketToTurn,
buildChatTurns,
createTurn,
extractArtifactTagPaths,
} from './lib/chat'
import {
createConversation,
deleteConversation,
getConversationArtifactUrl,
getConversationHistory,
getHealth,
getLatestArtifacts,
listConversations,
listModels,
probeConversationArtifact,
readConversationArtifactText,
streamChatEvents,
} from './lib/api'
import { ConversationView } from './components/ConversationView'
import { Sidebar } from './components/Sidebar'
import type {
ArtifactFileInfo,
ChatTurn,
ComposerImageAttachment,
ConversationSummary,
LatestArtifactsResponse,
ModelInfo,
RawChatMessage,
RawMessageContentPart,
} from './types'
const WorkbenchPane = lazy(async () => {
const module = await import('./components/WorkbenchPane')
return { default: module.WorkbenchPane }
})
function sortConversations(items: ConversationSummary[]): ConversationSummary[] {
return [...items].sort((left, right) => {
const rightTime = new Date(
right.context_last_activity ?? right.context_start_time ?? 0,
).getTime()
const leftTime = new Date(
left.context_last_activity ?? left.context_start_time ?? 0,
).getTime()
return rightTime - leftTime
})
}
function buildErrorTurn(turns: ChatTurn[], message: string): ChatTurn[] {
if (!turns.length) {
return [appendAssistantTextBlock(createTurn(''), `Error: ${message}`)]
}
const nextTurns = [...turns]
nextTurns[nextTurns.length - 1] = appendAssistantTextBlock(
nextTurns[nextTurns.length - 1],
`Error: ${message}`,
)
return nextTurns
}
function makeAttachmentId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
}
async function fileToDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
if (typeof reader.result === 'string') {
resolve(reader.result)
return
}
reject(new Error('Failed to encode image attachment'))
}
reader.onerror = () => reject(reader.error ?? new Error('Failed to read image file'))
reader.readAsDataURL(file)
})
}
function dedupeArtifactFiles(items: ArtifactFileInfo[]): ArtifactFileInfo[] {
const seen = new Set<string>()
return items.filter((item) => {
if (!item.path || seen.has(item.path)) {
return false
}
seen.add(item.path)
return true
})
}
function candidateArtifactPaths(path: string): string[] {
const trimmedPath = path.trim()
if (!trimmedPath) {
return []
}
const normalizedPath = trimmedPath.replace(/\\/g, '/').replace(/^\.\//, '')
const nextPaths = [trimmedPath]
if (normalizedPath && normalizedPath !== trimmedPath) {
nextPaths.push(normalizedPath)
}
if (normalizedPath && !normalizedPath.startsWith('workspace/')) {
nextPaths.push(`workspace/${normalizedPath}`)
}
return Array.from(new Set(nextPaths))
}
async function resolveCodeArtifact(
conversationId: string,
taggedPath: string,
): Promise<ArtifactFileInfo | null> {
for (const candidatePath of candidateArtifactPaths(taggedPath)) {
const content = await readConversationArtifactText(conversationId, candidatePath)
if (content == null) {
continue
}
return {
path: candidatePath,
content,
url: getConversationArtifactUrl(conversationId, candidatePath),
content_type: 'text/plain; charset=utf-8',
}
}
return null
}
async function resolveModelArtifact(
conversationId: string,
taggedPath: string,
): Promise<ArtifactFileInfo | null> {
for (const candidatePath of candidateArtifactPaths(taggedPath)) {
const exists = await probeConversationArtifact(conversationId, candidatePath)
if (!exists) {
continue
}
return {
path: candidatePath,
url: getConversationArtifactUrl(conversationId, candidatePath),
}
}
return null
}
async function buildArtifactsFromHistory(
conversationId: string,
messages: RawChatMessage[],
): Promise<LatestArtifactsResponse> {
const taggedArtifacts = extractArtifactTagPaths(messages)
const [codeFiles, modelFiles] = await Promise.all([
Promise.all(taggedArtifacts.codePaths.map((path) => resolveCodeArtifact(conversationId, path))),
Promise.all(taggedArtifacts.modelPaths.map((path) => resolveModelArtifact(conversationId, path))),
])
const resolvedCodeFiles = dedupeArtifactFiles(codeFiles.filter((item): item is ArtifactFileInfo => item !== null))
const resolvedModelFiles = dedupeArtifactFiles(
modelFiles.filter((item): item is ArtifactFileInfo => item !== null),
)
return {
conversation_id: conversationId,
code_file: resolvedCodeFiles[0] ?? null,
code_files: resolvedCodeFiles,
model_file: resolvedModelFiles[0] ?? null,
model_files: resolvedModelFiles,
output_files: resolvedModelFiles.map((item) => item.path),
}
}
function mergeArtifacts(
backendArtifacts: LatestArtifactsResponse | null,
historyArtifacts: LatestArtifactsResponse | null,
): LatestArtifactsResponse | null {
if (!backendArtifacts && !historyArtifacts) {
return null
}
const baseArtifacts = backendArtifacts ?? historyArtifacts
if (!baseArtifacts) {
return null
}
const codeFiles = dedupeArtifactFiles([
...(backendArtifacts?.code_files ?? []),
...(backendArtifacts?.code_file ? [backendArtifacts.code_file] : []),
...(historyArtifacts?.code_files ?? []),
...(historyArtifacts?.code_file ? [historyArtifacts.code_file] : []),
])
const modelFiles = dedupeArtifactFiles([
...(backendArtifacts?.model_files ?? []),
...(backendArtifacts?.model_file ? [backendArtifacts.model_file] : []),
...(historyArtifacts?.model_files ?? []),
...(historyArtifacts?.model_file ? [historyArtifacts.model_file] : []),
])
const outputFiles = Array.from(
new Set([...(backendArtifacts?.output_files ?? []), ...(historyArtifacts?.output_files ?? [])]),
)
return {
conversation_id: baseArtifacts.conversation_id,
code_file: backendArtifacts?.code_file ?? historyArtifacts?.code_file ?? codeFiles[0] ?? null,
code_files: codeFiles,
model_file: backendArtifacts?.model_file ?? historyArtifacts?.model_file ?? modelFiles[0] ?? null,
model_files: modelFiles,
output_files: outputFiles,
}
}
function App() {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
const [conversations, setConversations] = useState<ConversationSummary[]>([])
const [activeConversationId, setActiveConversationId] = useState('')
const [turns, setTurns] = useState<ChatTurn[]>([])
const [artifacts, setArtifacts] = useState<LatestArtifactsResponse | null>(null)
const [models, setModels] = useState<ModelInfo[]>([])
const [selectedModel, setSelectedModel] = useState('cadagent')
const [input, setInput] = useState('')
const [attachments, setAttachments] = useState<ComposerImageAttachment[]>([])
const [bootstrapping, setBootstrapping] = useState(true)
const [artifactLoading, setArtifactLoading] = useState(false)
const [streaming, setStreaming] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const clearConversationState = useCallback(() => {
setActiveConversationId('')
setTurns([])
setArtifacts(null)
}, [])
const handleAttachImages = useCallback(async (files: File[]) => {
if (!files.length) {
return
}
try {
const nextAttachments = await Promise.all(
Array.from(files).map(async (file) => ({
id: makeAttachmentId(),
name: file.name,
dataUrl: await fileToDataUrl(file),
})),
)
setAttachments((current) => [...current, ...nextAttachments])
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : 'Failed to load image attachment')
}
}, [])
const handleRemoveImage = useCallback((attachmentId: string) => {
setAttachments((current) => current.filter((attachment) => attachment.id !== attachmentId))
}, [])
const refreshConversationList = useCallback(async (): Promise<ConversationSummary[]> => {
const nextConversations = sortConversations(await listConversations())
setConversations(nextConversations)
return nextConversations
}, [])
const loadConversationState = useCallback(async (conversationId: string) => {
setArtifactLoading(true)
const [historyResult, artifactsResult] = await Promise.allSettled([
getConversationHistory(conversationId),
getLatestArtifacts(conversationId),
])
const historyArtifacts =
historyResult.status === 'fulfilled'
? await buildArtifactsFromHistory(conversationId, historyResult.value)
: null
if (historyResult.status === 'fulfilled') {
setTurns(buildChatTurns(historyResult.value))
} else {
setTurns([])
}
if (artifactsResult.status === 'fulfilled') {
console.info('[artifacts] loaded latest artifacts', {
conversationId,
historyMessageCount:
historyResult.status === 'fulfilled' ? historyResult.value.length : undefined,
codeFile: artifactsResult.value.code_file?.path ?? null,
codeFiles: (artifactsResult.value.code_files ?? []).map((item) => item.path),
modelFile: artifactsResult.value.model_file?.path ?? null,
modelFiles: (artifactsResult.value.model_files ?? []).map((item) => item.path),
outputFiles: artifactsResult.value.output_files ?? [],
})
} else {
console.warn('[artifacts] failed to load latest artifacts', {
conversationId,
historyStatus: historyResult.status,
reason:
artifactsResult.reason instanceof Error
? artifactsResult.reason.message
: artifactsResult.reason,
})
}
if (historyArtifacts) {
console.info('[artifacts] history tag fallback', {
conversationId,
codeFile: historyArtifacts.code_file?.path ?? null,
codeFiles: historyArtifacts.code_files.map((item) => item.path),
modelFile: historyArtifacts.model_file?.path ?? null,
modelFiles: historyArtifacts.model_files.map((item) => item.path),
outputFiles: historyArtifacts.output_files,
})
}
const mergedArtifacts = mergeArtifacts(
artifactsResult.status === 'fulfilled' ? artifactsResult.value : null,
historyArtifacts,
)
console.info('[artifacts] merged artifact state', {
conversationId,
codeFile: mergedArtifacts?.code_file?.path ?? null,
codeFiles: mergedArtifacts?.code_files.map((item) => item.path) ?? [],
modelFile: mergedArtifacts?.model_file?.path ?? null,
modelFiles: mergedArtifacts?.model_files.map((item) => item.path) ?? [],
outputFiles: mergedArtifacts?.output_files ?? [],
})
setArtifacts(mergedArtifacts)
if (historyResult.status === 'rejected') {
console.warn('[history] failed to load conversation history', {
conversationId,
reason:
historyResult.reason instanceof Error ? historyResult.reason.message : historyResult.reason,
})
}
setArtifactLoading(false)
}, [])
const ensureConversation = useCallback(async (): Promise<string> => {
if (activeConversationId) {
return activeConversationId
}
const created = await createConversation()
setActiveConversationId(created.conversation_id)
return created.conversation_id
}, [activeConversationId])
const bootstrap = useCallback(async () => {
setBootstrapping(true)
setErrorMessage(null)
try {
await getHealth()
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : 'Backend unavailable')
}
try {
const [availableModels, knownConversations] = await Promise.all([
listModels(),
refreshConversationList(),
])
setModels(availableModels)
if (availableModels.length) {
setSelectedModel((currentModel) =>
availableModels.some((model) => model.id === currentModel)
? currentModel
: availableModels[0].id,
)
}
const nextConversationId =
knownConversations[0]?.conversation_id ?? (await createConversation()).conversation_id
setActiveConversationId(nextConversationId)
await loadConversationState(nextConversationId)
await refreshConversationList()
} catch (error) {
setErrorMessage(
error instanceof Error ? error.message : 'Failed to initialize the React UI',
)
} finally {
setBootstrapping(false)
}
}, [loadConversationState, refreshConversationList])
useEffect(() => {
void bootstrap()
}, [bootstrap])
const handleCreateConversation = useCallback(async () => {
setErrorMessage(null)
try {
const created = await createConversation()
setActiveConversationId(created.conversation_id)
setTurns([])
setArtifacts(null)
await refreshConversationList()
await loadConversationState(created.conversation_id)
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : 'Failed to create session')
}
}, [loadConversationState, refreshConversationList])
const handleSelectConversation = useCallback(
async (conversationId: string) => {
setActiveConversationId(conversationId)
setErrorMessage(null)
await loadConversationState(conversationId)
},
[loadConversationState],
)
const handleDeleteConversation = useCallback(
async (conversationId: string) => {
if (streaming || bootstrapping) {
return
}
const shortId = conversationId.slice(0, 8)
if (!window.confirm(`Delete conversation ${shortId}? This cannot be undone.`)) {
return
}
setErrorMessage(null)
try {
await deleteConversation(conversationId)
const refreshed = await refreshConversationList()
if (conversationId !== activeConversationId) {
return
}
const nextConversation = refreshed.find(
(conversation) => conversation.conversation_id !== conversationId,
)
if (!nextConversation) {
clearConversationState()
return
}
setActiveConversationId(nextConversation.conversation_id)
await loadConversationState(nextConversation.conversation_id)
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : 'Failed to delete conversation')
}
},
[
activeConversationId,
bootstrapping,
clearConversationState,
loadConversationState,
refreshConversationList,
streaming,
],
)
useEffect(() => {
const intervalId = window.setInterval(() => {
void refreshConversationList()
}, 10000)
return () => {
window.clearInterval(intervalId)
}
}, [refreshConversationList])
const handleSendMessage = useCallback(async () => {
const nextMessage = input.trim()
if ((!nextMessage && !attachments.length) || streaming) {
return
}
const pendingAttachments = attachments
setStreaming(true)
setErrorMessage(null)
setInput('')
setAttachments([])
const messageContent: string | RawMessageContentPart[] = pendingAttachments.length
? [
...(nextMessage ? [{ type: 'text', text: nextMessage } as const] : []),
...pendingAttachments.map(
(attachment) =>
({
type: 'image_url',
image_url: { url: attachment.dataUrl },
}) as const,
),
]
: nextMessage
try {
const conversationId = await ensureConversation()
setActiveConversationId(conversationId)
const optimisticTurn = createTurn(
nextMessage,
pendingAttachments.map((attachment) => attachment.dataUrl),
)
setTurns((currentTurns) => [...currentTurns, optimisticTurn])
const result = await streamChatEvents({
conversationId,
model: selectedModel,
messageContent,
onPacket: (packet) => {
if (packet.event === 'done') {
return
}
if (packet.event === 'error') {
const packetMessage =
typeof packet.data === 'object' &&
packet.data !== null &&
'message' in packet.data &&
typeof packet.data.message === 'string'
? packet.data.message
: 'The stream reported an unknown error.'
setErrorMessage(packetMessage)
setTurns((currentTurns) => buildErrorTurn(currentTurns, packetMessage))
return
}
setTurns((currentTurns) => {
if (!currentTurns.length) {
return currentTurns
}
const nextTurns = [...currentTurns]
nextTurns[nextTurns.length - 1] = applyStreamPacketToTurn(
nextTurns[nextTurns.length - 1],
packet,
)
return nextTurns
})
},
})
const finalConversationId = result.conversationId || conversationId
setActiveConversationId(finalConversationId)
await Promise.all([refreshConversationList(), loadConversationState(finalConversationId)])
} catch (error) {
const message = error instanceof Error ? error.message : 'Streaming request failed'
setErrorMessage(message)
setAttachments(pendingAttachments)
setTurns((currentTurns) => buildErrorTurn(currentTurns, message))
} finally {
setStreaming(false)
}
}, [
attachments,
ensureConversation,
input,
loadConversationState,
refreshConversationList,
selectedModel,
streaming,
])
return (
<div className="app-shell">
<Sidebar
collapsed={sidebarCollapsed}
conversations={conversations}
activeConversationId={activeConversationId}
busy={streaming || bootstrapping}
onToggle={() => setSidebarCollapsed((value) => !value)}
onCreateConversation={() => void handleCreateConversation()}
onSelectConversation={(conversationId) => void handleSelectConversation(conversationId)}
onDeleteConversation={(conversationId) => void handleDeleteConversation(conversationId)}
/>
<main className="workspace-shell">
<div className="workspace-panels">
<Group orientation="horizontal" className="main-panel-group">
<Panel defaultSize="54%" minSize="34%">
<ConversationView
turns={turns}
activeConversationId={activeConversationId}
isStreaming={streaming}
input={input}
attachments={attachments}
models={models}
selectedModel={selectedModel}
errorMessage={errorMessage}
onInputChange={setInput}
onAttachImages={(files) => void handleAttachImages(files)}
onRemoveImage={handleRemoveImage}
onModelChange={setSelectedModel}
onSubmit={() => void handleSendMessage()}
/>
</Panel>
<Separator className="resize-handle resize-handle-vertical" />
<Panel defaultSize="46%" minSize="30%">
<Suspense
fallback={
<section className="panel-shell workbench-loading-shell">
<div className="empty-state viewport-empty-state">
<h3>Loading workbench</h3>
<p>Preparing the preview and code panels.</p>
</div>
</section>
}
>
<WorkbenchPane artifacts={artifacts} loading={artifactLoading || bootstrapping} />
</Suspense>
</Panel>
</Group>
</div>
</main>
</div>
)
}
export default App
Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

@@ -0,0 +1,72 @@
import { type ReactNode, useId, useLayoutEffect, useRef, useState } from 'react'
interface AnimatedDisclosureProps {
className: string
summaryClassName: string
bodyWrapClassName: string
bodyClassName: string
open: boolean
onToggle: (nextOpen: boolean) => void
summary: ReactNode
children: ReactNode
}
export function AnimatedDisclosure({
className,
summaryClassName,
bodyWrapClassName,
bodyClassName,
open,
onToggle,
summary,
children,
}: AnimatedDisclosureProps) {
const bodyId = useId()
const bodyRef = useRef<HTMLDivElement | null>(null)
const [contentHeight, setContentHeight] = useState(0)
useLayoutEffect(() => {
const node = bodyRef.current
if (!node) {
return
}
const measure = () => {
setContentHeight(node.scrollHeight)
}
measure()
if (typeof ResizeObserver === 'undefined') {
return
}
const observer = new ResizeObserver(() => {
measure()
})
observer.observe(node)
return () => {
observer.disconnect()
}
}, [children])
return (
<section className={`${className} ${open ? 'is-open' : ''}`.trim()}>
<button
aria-controls={bodyId}
aria-expanded={open}
className={summaryClassName}
onClick={() => onToggle(!open)}
type="button"
>
{summary}
</button>
<div className={bodyWrapClassName} id={bodyId} style={{ height: open ? `${contentHeight}px` : '0px' }}>
<div className={bodyClassName} ref={bodyRef}>
{children}
</div>
</div>
</section>
)
}
@@ -0,0 +1,437 @@
import { type ClipboardEvent, useEffect, useMemo, useRef, useState } from 'react'
import { Brain, ImagePlus, Send, X } from 'lucide-react'
import { sanitizeAssistantText } from '../lib/chat'
import type { ChatTurn, ComposerImageAttachment, ModelInfo } from '../types'
import { AnimatedDisclosure } from './AnimatedDisclosure'
import { StreamingMarkdown } from './StreamingMarkdown'
import { ToolCallCard } from './ToolCallCard'
interface ConversationViewProps {
turns: ChatTurn[]
activeConversationId: string
isStreaming: boolean
input: string
attachments: ComposerImageAttachment[]
models: ModelInfo[]
selectedModel: string
errorMessage: string | null
onInputChange: (value: string) => void
onAttachImages: (files: File[]) => void
onRemoveImage: (attachmentId: string) => void
onModelChange: (value: string) => void
onSubmit: () => void
}
interface ImagePreviewState {
src: string
label: string
}
const AUTO_SCROLL_THRESHOLD = 120
const AUTO_SCROLL_UP_EPSILON = 6
interface ReasoningCardProps {
title: string
text: string
autoOpen: boolean
}
function ReasoningCard({ title, text, autoOpen }: ReasoningCardProps) {
const [isOpen, setIsOpen] = useState(autoOpen)
useEffect(() => {
if (autoOpen) {
setIsOpen(true)
}
}, [autoOpen])
return (
<AnimatedDisclosure
bodyClassName="reasoning-card-body"
bodyWrapClassName="reasoning-card-body-wrap"
className="reasoning-card"
onToggle={setIsOpen}
open={isOpen}
summary={
<div className="reasoning-card-title">
<Brain size={14} />
<strong>{title}</strong>
</div>
}
summaryClassName="reasoning-card-summary"
>
<StreamingMarkdown pulseOnMount={autoOpen} text={text} />
</AnimatedDisclosure>
)
}
function collectImageFiles(files: Iterable<File>): File[] {
return Array.from(files).filter((file) => file.type.startsWith('image/'))
}
function collectClipboardImages(event: ClipboardEvent<HTMLTextAreaElement>): File[] {
return Array.from(event.clipboardData.items)
.filter((item) => item.kind === 'file' && item.type.startsWith('image/'))
.map((item) => item.getAsFile())
.filter((file): file is File => file !== null)
}
function isNearTimelineBottom(node: HTMLDivElement): boolean {
return node.scrollHeight - node.scrollTop - node.clientHeight <= AUTO_SCROLL_THRESHOLD
}
export function ConversationView({
turns,
activeConversationId,
isStreaming,
input,
attachments,
models,
selectedModel,
errorMessage,
onInputChange,
onAttachImages,
onRemoveImage,
onModelChange,
onSubmit,
}: ConversationViewProps) {
const timelineRef = useRef<HTMLDivElement | null>(null)
const shouldAutoScrollRef = useRef(true)
const pendingConversationScrollRef = useRef(false)
const lastScrollTopRef = useRef(0)
const [isDraggingFiles, setIsDraggingFiles] = useState(false)
const [previewImage, setPreviewImage] = useState<ImagePreviewState | null>(null)
useEffect(() => {
const node = timelineRef.current
if (!node) {
return
}
pendingConversationScrollRef.current = true
shouldAutoScrollRef.current = true
node.scrollTop = node.scrollHeight
lastScrollTopRef.current = node.scrollTop
}, [activeConversationId])
useEffect(() => {
const node = timelineRef.current
if (!node) {
return
}
const shouldSnapToBottom = pendingConversationScrollRef.current || (isStreaming && shouldAutoScrollRef.current)
if (!shouldSnapToBottom) {
return
}
node.scrollTop = node.scrollHeight
lastScrollTopRef.current = node.scrollTop
if (pendingConversationScrollRef.current && turns.length > 0) {
pendingConversationScrollRef.current = false
}
}, [turns, isStreaming])
useEffect(() => {
const node = timelineRef.current
if (!node) {
return
}
const handleScroll = () => {
const currentScrollTop = node.scrollTop
const nearBottom = isNearTimelineBottom(node)
const scrolledUp = currentScrollTop < lastScrollTopRef.current - AUTO_SCROLL_UP_EPSILON
if (nearBottom) {
shouldAutoScrollRef.current = true
} else if (scrolledUp) {
shouldAutoScrollRef.current = false
}
lastScrollTopRef.current = currentScrollTop
}
handleScroll()
node.addEventListener('scroll', handleScroll)
return () => {
node.removeEventListener('scroll', handleScroll)
}
}, [])
const streamingTurnId = useMemo(
() => (isStreaming ? turns.at(-1)?.id : undefined),
[isStreaming, turns],
)
useEffect(() => {
if (!previewImage) {
return
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setPreviewImage(null)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => {
window.removeEventListener('keydown', handleKeyDown)
}
}, [previewImage])
return (
<section className="panel-shell chat-panel-shell">
<header className="panel-header">
<div className="panel-title-group">
<h2>Chat</h2>
<p className="panel-caption">
{activeConversationId
? `Conversation ${activeConversationId.slice(0, 8)}`
: 'New conversation'}
</p>
</div>
<label className="model-select">
<span>Model</span>
<select value={selectedModel} onChange={(event) => onModelChange(event.target.value)}>
{models.map((model) => (
<option key={model.id} value={model.id}>
{model.id}
</option>
))}
</select>
</label>
</header>
<div className="chat-timeline" ref={timelineRef}>
{turns.length === 0 ? (
<div className="empty-state chat-empty-state">
<h3>Start a conversation</h3>
<p>Describe the part you want, or paste/drag reference images, and the agent will stream text and tool calls in order.</p>
</div>
) : null}
{turns.map((turn) => {
const isActiveStreamingTurn = turn.id === streamingTurnId
return (
<article className="turn" key={turn.id}>
<div className="bubble bubble-user">
<span className="bubble-role">You</span>
{turn.userImages.length ? (
<div className="user-image-grid">
{turn.userImages.map((imageUrl, index) => (
<img
key={`${turn.id}-${index}`}
className="user-image-preview"
src={imageUrl}
alt={`User attachment ${index + 1}`}
onClick={() =>
setPreviewImage({
src: imageUrl,
label: `Conversation image ${index + 1}`,
})
}
/>
))}
</div>
) : null}
{turn.userText ? <p>{turn.userText}</p> : null}
</div>
<div className="assistant-sequence">
{turn.segments.map((segment) => {
if (segment.kind === 'tool') {
return <ToolCallCard key={`${segment.id}-${segment.status}`} activity={segment} />
}
if (segment.kind === 'reasoning') {
const visibleReasoning = segment.rawText.trim()
if (!visibleReasoning) {
return null
}
return (
<ReasoningCard
autoOpen={isActiveStreamingTurn}
key={segment.id}
text={visibleReasoning}
title={segment.title}
/>
)
}
const visibleText = sanitizeAssistantText(segment.rawText)
if (!visibleText) {
return null
}
return (
<div className="bubble bubble-assistant" key={segment.id}>
<span className="bubble-role">Agent</span>
<StreamingMarkdown text={visibleText} pulseOnMount={isActiveStreamingTurn} />
</div>
)
})}
{isActiveStreamingTurn && !turn.segments.length ? (
<div aria-live="polite" className="bubble bubble-assistant bubble-thinking" role="status">
<span className="bubble-role">Agent</span>
<div className="thinking-indicator">
<div className="thinking-indicator-line">
<span className="thinking-indicator-text">Thinking</span>
<span aria-hidden="true" className="thinking-indicator-dots">
<span className="thinking-indicator-dot" />
<span className="thinking-indicator-dot" />
<span className="thinking-indicator-dot" />
</span>
</div>
<p>Planning the next step, selecting tools, and preparing the first response.</p>
</div>
</div>
) : null}
</div>
</article>
)
})}
</div>
<footer
className={`chat-composer-shell ${isDraggingFiles ? 'is-dragging-files' : ''}`}
onDragEnter={(event) => {
event.preventDefault()
setIsDraggingFiles(true)
}}
onDragOver={(event) => {
event.preventDefault()
setIsDraggingFiles(true)
}}
onDragLeave={(event) => {
event.preventDefault()
const nextTarget = event.relatedTarget
if (!(nextTarget instanceof Node) || !event.currentTarget.contains(nextTarget)) {
setIsDraggingFiles(false)
}
}}
onDrop={(event) => {
event.preventDefault()
setIsDraggingFiles(false)
const files = collectImageFiles(event.dataTransfer.files)
if (files.length) {
onAttachImages(files)
}
}}
>
{errorMessage ? <div className="error-banner">{errorMessage}</div> : null}
{attachments.length ? (
<div className="composer-image-list">
{attachments.map((attachment) => (
<div className="composer-image-item" key={attachment.id}>
<img
src={attachment.dataUrl}
alt={attachment.name}
onClick={() =>
setPreviewImage({
src: attachment.dataUrl,
label: attachment.name,
})
}
/>
<div className="composer-image-meta">
<span>{attachment.name}</span>
<button
type="button"
className="composer-image-remove"
onClick={() => onRemoveImage(attachment.id)}
disabled={isStreaming}
aria-label={`Remove ${attachment.name}`}
>
<X size={14} />
</button>
</div>
</div>
))}
</div>
) : null}
<div className="chat-composer-row">
<label className="composer-attach-button" aria-label="Attach image">
<input
type="file"
accept="image/*"
multiple
onChange={(event) => {
onAttachImages(collectImageFiles(event.target.files ?? []))
event.currentTarget.value = ''
}}
disabled={isStreaming}
/>
<ImagePlus size={16} />
</label>
<textarea
className="chat-composer"
value={input}
onChange={(event) => onInputChange(event.target.value)}
onPaste={(event) => {
const imageFiles = collectClipboardImages(event)
if (!imageFiles.length) {
return
}
const plainText = event.clipboardData.getData('text/plain')
if (!plainText) {
event.preventDefault()
}
onAttachImages(imageFiles)
}}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
onSubmit()
}
}}
placeholder="Describe dimensions, constraints, or changes. You can also paste or drag images here..."
rows={3}
disabled={isStreaming}
/>
<button
type="button"
className="send-button"
onClick={onSubmit}
disabled={isStreaming || (!input.trim() && !attachments.length)}
>
<Send size={16} />
</button>
</div>
</footer>
{previewImage ? (
<div
className="image-lightbox"
role="dialog"
aria-modal="true"
aria-label={previewImage.label}
onClick={() => setPreviewImage(null)}
>
<div className="image-lightbox-content" onClick={(event) => event.stopPropagation()}>
<button
type="button"
className="image-lightbox-close"
onClick={() => setPreviewImage(null)}
aria-label="Close image preview"
>
<X size={18} />
</button>
<img src={previewImage.src} alt={previewImage.label} className="image-lightbox-image" />
<p className="image-lightbox-caption">{previewImage.label}</p>
</div>
</div>
) : null}
</section>
)
}
@@ -0,0 +1,159 @@
import { useEffect, useMemo, useState } from 'react'
import type { BufferGeometry } from 'three'
import { Canvas } from '@react-three/fiber'
import { Bounds, OrbitControls } from '@react-three/drei'
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js'
interface ModelViewportProps {
modelUrl?: string
modelPath?: string
localFileInputId?: string
onModelReady?: (modelPath: string) => void
}
function ModelMesh({ geometry }: { geometry: BufferGeometry }) {
return (
<mesh geometry={geometry} castShadow receiveShadow>
<meshStandardMaterial color="#73cab9" metalness={0.16} roughness={0.44} />
</mesh>
)
}
function renderEmptyViewportState(
title: string,
body: string,
localFileInputId?: string,
) {
if (!localFileInputId) {
return (
<div className="empty-state viewport-empty-state">
<h3>{title}</h3>
<p>{body}</p>
</div>
)
}
return (
<label
htmlFor={localFileInputId}
className="empty-state viewport-empty-state is-clickable"
>
<h3>{title}</h3>
<p>{body}</p>
</label>
)
}
export function ModelViewport({
modelUrl,
modelPath,
localFileInputId,
onModelReady,
}: ModelViewportProps) {
const [geometry, setGeometry] = useState<BufferGeometry | null>(null)
const [loading, setLoading] = useState(
Boolean(modelUrl && modelPath && modelPath.toLowerCase().endsWith('.stl')),
)
const [error, setError] = useState<string | null>(null)
const isSupported = useMemo(
() => Boolean(modelPath && modelPath.toLowerCase().endsWith('.stl')),
[modelPath],
)
useEffect(() => {
let cancelled = false
let nextGeometry: BufferGeometry | null = null
if (!modelUrl || !modelPath) {
return undefined
}
if (!isSupported) {
return undefined
}
const loader = new STLLoader()
setLoading(true)
setError(null)
loader.load(
modelUrl,
(loadedGeometry) => {
if (cancelled) {
loadedGeometry.dispose()
return
}
loadedGeometry.computeVertexNormals()
loadedGeometry.center()
nextGeometry = loadedGeometry
setGeometry(loadedGeometry)
setLoading(false)
onModelReady?.(modelPath)
},
undefined,
(loadError) => {
if (cancelled) {
return
}
setError(loadError instanceof Error ? loadError.message : 'Failed to load STL')
setLoading(false)
},
)
return () => {
cancelled = true
nextGeometry?.dispose()
}
}, [isSupported, modelPath, modelUrl, onModelReady])
if (!modelUrl || !modelPath) {
return renderEmptyViewportState(
'3D preview will appear here',
'Run a modeling turn and the newest generated STL will load into this viewport. Click to open a local STL now.',
localFileInputId,
)
}
if (!isSupported) {
return renderEmptyViewportState(
'Preview unavailable',
'Only STL preview is enabled right now. Click to choose a local STL file.',
localFileInputId,
)
}
if (loading) {
return (
<div className="empty-state viewport-empty-state">
<h3>Loading model</h3>
<p>Fetching `{modelPath.split('/').pop()}` for interactive preview.</p>
</div>
)
}
if (error || !geometry) {
return renderEmptyViewportState(
'Preview unavailable',
error ?? 'The geometry could not be prepared. Click to choose another local STL file.',
localFileInputId,
)
}
return (
<div className="model-canvas-shell">
<Canvas camera={{ position: [170, 120, 170], fov: 28 }}>
<color attach="background" args={['#0d141b']} />
<ambientLight intensity={0.82} />
<directionalLight position={[8, 10, 6]} intensity={1.3} />
<directionalLight position={[-8, -8, -6]} intensity={0.32} />
<gridHelper args={[240, 16, '#294553', '#17242d']} position={[0, -36, 0]} />
<Bounds fit clip observe margin={1.25}>
<ModelMesh geometry={geometry} />
</Bounds>
<OrbitControls makeDefault enableDamping />
</Canvas>
</div>
)
}
@@ -0,0 +1,159 @@
import { ChevronLeft, ChevronRight, Plus, Trash2 } from 'lucide-react'
import type { ConversationSummary } from '../types'
interface SidebarProps {
collapsed: boolean
conversations: ConversationSummary[]
activeConversationId: string
busy: boolean
onToggle: () => void
onCreateConversation: () => void
onSelectConversation: (conversationId: string) => void
onDeleteConversation: (conversationId: string) => void
}
function formatDate(value?: string): string {
if (!value) {
return 'No activity'
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return value
}
return date.toLocaleString([], {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
export function Sidebar({
collapsed,
conversations,
activeConversationId,
busy,
onToggle,
onCreateConversation,
onSelectConversation,
onDeleteConversation,
}: SidebarProps) {
return (
<aside className={`sidebar ${collapsed ? 'is-collapsed' : ''}`}>
{collapsed ? (
<>
<div className="sidebar-header-actions">
<button
type="button"
className="toolbar-button"
onClick={onCreateConversation}
disabled={busy}
aria-label="Create conversation"
>
<Plus size={16} />
</button>
<button
type="button"
className="toolbar-button"
onClick={onToggle}
aria-label="Expand sidebar"
>
<ChevronRight size={16} />
</button>
</div>
<div className="rail-list">
{conversations.map((conversation) => {
const isActive = conversation.conversation_id === activeConversationId
return (
<button
type="button"
key={conversation.conversation_id}
className={`rail-item ${isActive ? 'is-active' : ''}`}
onClick={() => onSelectConversation(conversation.conversation_id)}
disabled={busy}
title={conversation.conversation_id}
>
{conversation.conversation_id.slice(0, 2)}
</button>
)
})}
</div>
</>
) : (
<>
<header className="sidebar-header">
<div className="sidebar-title-block">
<h1 className="sidebar-title">Conversations</h1>
<p className="sidebar-caption">{conversations.length} shared sessions</p>
</div>
<div className="sidebar-header-actions">
<button
type="button"
className="toolbar-button"
onClick={onCreateConversation}
disabled={busy}
aria-label="Create conversation"
>
<Plus size={16} />
</button>
<button
type="button"
className="toolbar-button"
onClick={onToggle}
aria-label="Collapse sidebar"
>
<ChevronLeft size={16} />
</button>
</div>
</header>
<div className="conversation-list">
{conversations.map((conversation) => {
const isActive = conversation.conversation_id === activeConversationId
return (
<div
key={conversation.conversation_id}
className={`conversation-row ${isActive ? 'is-active' : ''}`}
>
<button
type="button"
className={`conversation-item ${isActive ? 'is-active' : ''}`}
onClick={() => onSelectConversation(conversation.conversation_id)}
disabled={busy}
>
<div className="conversation-item-primary">
<strong>{conversation.conversation_id.slice(0, 8)}</strong>
<span>{formatDate(conversation.context_last_activity)}</span>
</div>
<div className="conversation-item-secondary">
{conversation.context_total_messages ?? 0} messages
</div>
</button>
<button
type="button"
className="conversation-delete-button"
onClick={() => onDeleteConversation(conversation.conversation_id)}
disabled={busy}
aria-label={`Delete conversation ${conversation.conversation_id}`}
title="Delete conversation"
>
<Trash2 size={14} />
</button>
</div>
)
})}
{!conversations.length ? (
<div className="empty-card">No conversations yet.</div>
) : null}
</div>
</>
)}
</aside>
)
}
@@ -0,0 +1,141 @@
import { useEffect, useMemo, useRef } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
interface StreamingMarkdownProps {
text: string
className?: string
pulseOnMount?: boolean
}
interface PositionPoint {
offset?: number
}
interface HastNode {
type: string
value?: string
tagName?: string
properties?: Record<string, unknown>
children?: HastNode[]
position?: {
start?: PositionPoint
end?: PositionPoint
}
}
function createDeltaSpanNode(text: string): HastNode {
return {
type: 'element',
tagName: 'span',
properties: { className: ['stream-delta'] },
children: [{ type: 'text', value: text }],
}
}
function splitTextNodeByOffset(node: HastNode, boundary: number): HastNode[] {
const value = typeof node.value === 'string' ? node.value : ''
const start = node.position?.start?.offset
const end = node.position?.end?.offset
if (!value || typeof start !== 'number' || typeof end !== 'number') {
return [node]
}
if (boundary <= start) {
return [createDeltaSpanNode(value)]
}
if (boundary >= end) {
return [node]
}
const splitIndex = Math.max(0, Math.min(value.length, boundary - start))
const stableText = value.slice(0, splitIndex)
const deltaText = value.slice(splitIndex)
const nextNodes: HastNode[] = []
if (stableText) {
nextNodes.push({ ...node, value: stableText })
}
if (deltaText) {
nextNodes.push(createDeltaSpanNode(deltaText))
}
return nextNodes.length ? nextNodes : [node]
}
function applyStreamingDelta(node: HastNode, boundary: number): void {
const children = Array.isArray(node.children) ? node.children : null
if (!children?.length) {
return
}
const nextChildren: HastNode[] = []
for (const child of children) {
if (child.type === 'text') {
nextChildren.push(...splitTextNodeByOffset(child, boundary))
continue
}
applyStreamingDelta(child, boundary)
nextChildren.push(child)
}
node.children = nextChildren
}
function createStreamingDeltaPlugin(boundary: number | null) {
return () => (tree: HastNode) => {
if (boundary === null) {
return
}
applyStreamingDelta(tree, boundary)
}
}
export function StreamingMarkdown({
text,
className = 'markdown-body',
pulseOnMount = false,
}: StreamingMarkdownProps) {
const previousTextRef = useRef('')
const hasCommittedRef = useRef(false)
const deltaBoundary = useMemo(() => {
const previousText = previousTextRef.current
if (!hasCommittedRef.current) {
if (pulseOnMount && text) {
return 0
}
return null
}
if (!text || text === previousText || !text.startsWith(previousText)) {
return null
}
return previousText.length
}, [pulseOnMount, text])
const rehypePlugins = useMemo(
() => [createStreamingDeltaPlugin(deltaBoundary)],
[deltaBoundary],
)
useEffect(() => {
previousTextRef.current = text
hasCommittedRef.current = true
}, [text])
return (
<div className={className}>
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={rehypePlugins}>
{text}
</ReactMarkdown>
</div>
)
}
@@ -0,0 +1,247 @@
import { useEffect, useRef, useState } from 'react'
import { AlertTriangle, Brain, CheckCircle2, ChevronDown, LoaderCircle, Wrench } from 'lucide-react'
import { sanitizeAssistantText } from '../lib/chat'
import type { ToolNestedEvent, ToolSegment } from '../types'
import { AnimatedDisclosure } from './AnimatedDisclosure'
import { StreamingMarkdown } from './StreamingMarkdown'
interface ToolCallCardProps {
activity: ToolSegment
}
function statusLabel(status: ToolSegment['status']): string {
if (status === 'running') {
return 'Running'
}
if (status === 'completed') {
return 'Completed'
}
if (status === 'error') {
return 'Failed'
}
return 'Queued'
}
function StatusIcon({ status }: { status: ToolSegment['status'] }) {
if (status === 'running' || status === 'pending') {
return <LoaderCircle size={14} className="spin" />
}
if (status === 'completed') {
return <CheckCircle2 size={14} />
}
return <AlertTriangle size={14} />
}
function nestedStatusLabel(status: ToolNestedEvent['status']): string {
if (status === 'running') {
return 'Running'
}
if (status === 'completed') {
return 'Done'
}
if (status === 'error') {
return 'Failed'
}
return 'Info'
}
function nestedEventVariant(event: ToolNestedEvent): string {
const label = event.detailLabel.trim().toLowerCase()
if (label === 'reasoning') {
return 'reasoning'
}
if (label === 'response') {
return 'response'
}
if (label === 'error') {
return 'error'
}
if (label === 'result') {
return 'result'
}
if (label === 'progress') {
return 'progress'
}
return 'detail'
}
function isNarrativeNestedEvent(event: ToolNestedEvent): boolean {
const variant = nestedEventVariant(event)
return variant === 'response' || variant === 'reasoning'
}
function NestedToolCard({ event }: { event: ToolNestedEvent }) {
const variant = nestedEventVariant(event)
const isRunning = event.status === 'running'
const [isOpen, setIsOpen] = useState(isRunning)
const previousStatusRef = useRef(event.status)
useEffect(() => {
if (isRunning) {
setIsOpen(true)
} else if (previousStatusRef.current === 'running' && event.status !== 'running') {
setIsOpen(false)
}
previousStatusRef.current = event.status
}, [event.status, isRunning])
return (
<AnimatedDisclosure
bodyClassName="tool-nested-body"
bodyWrapClassName="tool-nested-body-wrap"
className={`tool-nested-item is-${variant}`}
onToggle={setIsOpen}
open={isOpen}
summary={
<div className="tool-nested-item-topline">
<strong>{event.title}</strong>
<div className="tool-nested-item-actions">
<span className={`tool-nested-status is-${event.status}`}>{nestedStatusLabel(event.status)}</span>
<ChevronDown className="tool-nested-chevron" size={14} />
</div>
</div>
}
summaryClassName="tool-nested-summary"
>
{event.argumentsPreview ? (
<div className="tool-nested-block is-arguments">
<span className="tool-card-label">Arguments</span>
<pre>{event.argumentsPreview}</pre>
</div>
) : null}
{event.detailPreview ? (
<div className={`tool-nested-block is-${variant}`}>
<span className="tool-card-label">{event.detailLabel}</span>
<pre>{event.detailPreview}</pre>
</div>
) : null}
</AnimatedDisclosure>
)
}
function InlineReasoningCard({ title, text, autoOpen }: { title: string; text: string; autoOpen: boolean }) {
const [isOpen, setIsOpen] = useState(autoOpen)
useEffect(() => {
if (autoOpen) {
setIsOpen(true)
}
}, [autoOpen])
return (
<AnimatedDisclosure
bodyClassName="reasoning-card-body"
bodyWrapClassName="reasoning-card-body-wrap"
className="reasoning-card tool-inline-reasoning"
onToggle={setIsOpen}
open={isOpen}
summary={
<div className="reasoning-card-title">
<Brain size={14} />
<strong>{title}</strong>
</div>
}
summaryClassName="reasoning-card-summary"
>
<StreamingMarkdown pulseOnMount={autoOpen} text={text} />
</AnimatedDisclosure>
)
}
export function ToolCallCard({ activity }: ToolCallCardProps) {
const isRunning = activity.status === 'pending' || activity.status === 'running'
const [isOpen, setIsOpen] = useState(isRunning)
useEffect(() => {
if (isRunning) {
setIsOpen(true)
}
}, [isRunning])
return (
<AnimatedDisclosure
bodyClassName="tool-card-body"
bodyWrapClassName="tool-card-body-wrap"
className="tool-card"
onToggle={setIsOpen}
open={isOpen}
summary={
<div className="tool-card-title-row">
<div className={`tool-status-pill is-${activity.status}`}>
<StatusIcon status={activity.status} />
<span>{statusLabel(activity.status)}</span>
</div>
<div className="tool-card-title">
<Wrench size={14} />
<strong>{activity.toolName}</strong>
</div>
<ChevronDown size={16} className="tool-card-chevron" />
</div>
}
summaryClassName="tool-card-summary"
>
{activity.argumentsPreview ? (
<div className="tool-card-section">
<span className="tool-card-label">Arguments</span>
<pre>{activity.argumentsPreview}</pre>
</div>
) : null}
{activity.nestedEvents.length ? (
<div className="tool-card-section">
<span className="tool-card-label">Subagent Trace</span>
<div className="tool-card-middle-sequence">
{activity.nestedEvents.map((event) => {
const variant = nestedEventVariant(event)
if (isNarrativeNestedEvent(event)) {
const rawText =
variant === 'response'
? sanitizeAssistantText(event.detailPreview)
: event.detailPreview.trim()
if (!rawText) {
return null
}
if (variant === 'reasoning') {
return (
<InlineReasoningCard
autoOpen={isRunning}
key={event.id}
text={rawText}
title={event.title}
/>
)
}
return (
<div className="bubble bubble-assistant bubble-tool-inline" key={event.id}>
<span className="bubble-role">{event.title}</span>
<StreamingMarkdown pulseOnMount={isRunning} text={rawText} />
</div>
)
}
return <NestedToolCard event={event} key={event.id} />
})}
</div>
</div>
) : null}
{activity.detailPreview ? (
<div className="tool-card-section">
<span className="tool-card-label">{activity.status === 'error' ? 'Error' : 'Result'}</span>
<pre>{activity.detailPreview}</pre>
</div>
) : null}
</AnimatedDisclosure>
)
}
@@ -0,0 +1,443 @@
import { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Group, Panel, Separator } from 'react-resizable-panels'
import { PrismLight as SyntaxHighlighter } from 'react-syntax-highlighter'
import python from 'react-syntax-highlighter/dist/esm/languages/prism/python'
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'
import { readConversationArtifactText } from '../lib/api'
import type { ArtifactFileInfo, LatestArtifactsResponse } from '../types'
SyntaxHighlighter.registerLanguage('python', python)
const ModelViewport = lazy(async () => {
const module = await import('./ModelViewport')
return { default: module.ModelViewport }
})
interface WorkbenchPaneProps {
artifacts: LatestArtifactsResponse | null
loading: boolean
}
function dedupeArtifacts(items: ArtifactFileInfo[]): ArtifactFileInfo[] {
const seen = new Set<string>()
return items.filter((item) => {
if (!item.path || seen.has(item.path)) {
return false
}
seen.add(item.path)
return true
})
}
function formatArtifactLabel(path: string): string {
const segments = path.replace(/\\/g, '/').split('/').filter(Boolean)
if (!segments.length) {
return path
}
return segments.slice(-3).join('/')
}
function deriveSiblingModelScriptPath(modelPath: string): string | null {
const normalizedPath = modelPath.replace(/\\/g, '/').trim()
if (!normalizedPath || normalizedPath.startsWith('Local /')) {
return null
}
if (!normalizedPath.toLowerCase().endsWith('.stl')) {
return null
}
const lastSlashIndex = normalizedPath.lastIndexOf('/')
if (lastSlashIndex < 0) {
return 'model.py'
}
return `${normalizedPath.slice(0, lastSlashIndex)}/model.py`
}
export function WorkbenchPane({ artifacts, loading }: WorkbenchPaneProps) {
const modelFileInputId = 'local-model-file-input'
const codeFileInputId = 'local-code-file-input'
const baseCodeOptions = useMemo(() => {
if (artifacts?.code_files?.length) {
return dedupeArtifacts(artifacts.code_files)
}
return artifacts?.code_file ? [artifacts.code_file] : []
}, [artifacts])
const modelOptions = useMemo(() => {
if (artifacts?.model_files?.length) {
return dedupeArtifacts(artifacts.model_files)
}
return artifacts?.model_file ? [artifacts.model_file] : []
}, [artifacts])
const [selectedCodePath, setSelectedCodePath] = useState('')
const [selectedModelPath, setSelectedModelPath] = useState('')
const [localCodeFile, setLocalCodeFile] = useState<{ path: string; content: string } | null>(
null,
)
const [localModelFile, setLocalModelFile] = useState<{ path: string; url: string } | null>(
null,
)
const [autoCodeFile, setAutoCodeFile] = useState<ArtifactFileInfo | null>(null)
const autoResolveRequestRef = useRef(0)
const codeOptions = useMemo(() => {
if (!autoCodeFile) {
return baseCodeOptions
}
return dedupeArtifacts([autoCodeFile, ...baseCodeOptions])
}, [autoCodeFile, baseCodeOptions])
const latestCodePath = autoCodeFile?.path ?? artifacts?.code_file?.path ?? codeOptions[0]?.path ?? ''
const latestModelPath = artifacts?.model_file?.path ?? modelOptions[0]?.path ?? ''
const effectiveSelectedCodePath = codeOptions.some((item) => item.path === selectedCodePath)
? selectedCodePath
: latestCodePath
const effectiveSelectedModelPath = modelOptions.some((item) => item.path === selectedModelPath)
? selectedModelPath
: latestModelPath
const selectedCodeFile = useMemo(
() => codeOptions.find((item) => item.path === effectiveSelectedCodePath) ?? codeOptions[0] ?? null,
[codeOptions, effectiveSelectedCodePath],
)
const selectedModelFile = useMemo(
() => modelOptions.find((item) => item.path === effectiveSelectedModelPath) ?? modelOptions[0] ?? null,
[effectiveSelectedModelPath, modelOptions],
)
const codePath = localCodeFile?.path ?? selectedCodeFile?.path
const codeValue = localCodeFile?.content ?? selectedCodeFile?.content ?? ''
const codeReadFailed = !localCodeFile && Boolean(codePath && selectedCodeFile?.content == null)
const modelPath = localModelFile?.path ?? selectedModelFile?.path
const modelUrl = localModelFile?.url ?? selectedModelFile?.url
const showModelArtifactPicker = !localModelFile && modelOptions.length > 1
const showCodeArtifactPicker = !localCodeFile && codeOptions.length > 1
useEffect(() => {
return () => {
if (localModelFile?.url) {
URL.revokeObjectURL(localModelFile.url)
}
}
}, [localModelFile])
useEffect(() => {
autoResolveRequestRef.current += 1
setAutoCodeFile(null)
}, [artifacts?.conversation_id])
const handleLocalModelSelection = (file: File | null) => {
if (!file) {
return
}
const nextUrl = URL.createObjectURL(file)
setLocalModelFile((currentFile) => {
if (currentFile?.url) {
URL.revokeObjectURL(currentFile.url)
}
return {
path: `Local / ${file.name}`,
url: nextUrl,
}
})
setAutoCodeFile(null)
setSelectedModelPath('')
}
const handleLocalCodeSelection = async (file: File | null) => {
if (!file) {
return
}
const nextContent = await file.text()
setLocalCodeFile({
path: `Local / ${file.name}`,
content: nextContent,
})
setSelectedCodePath('')
}
const handleModelReady = useCallback(
async (readyModelPath: string) => {
if (localModelFile || !artifacts?.conversation_id) {
setAutoCodeFile(null)
return
}
const preferredCodePath = deriveSiblingModelScriptPath(readyModelPath)
if (!preferredCodePath) {
setAutoCodeFile(null)
return
}
const existingCodeFile = baseCodeOptions.find((item) => item.path === preferredCodePath)
if (existingCodeFile?.content != null) {
setAutoCodeFile(existingCodeFile)
setLocalCodeFile(null)
setSelectedCodePath('')
return
}
const requestId = autoResolveRequestRef.current + 1
autoResolveRequestRef.current = requestId
const content = await readConversationArtifactText(artifacts.conversation_id, preferredCodePath)
if (autoResolveRequestRef.current !== requestId) {
return
}
if (content == null) {
setAutoCodeFile(null)
return
}
setAutoCodeFile({
path: preferredCodePath,
content,
content_type: 'text/plain; charset=utf-8',
})
setLocalCodeFile(null)
setSelectedCodePath('')
},
[artifacts?.conversation_id, baseCodeOptions, localModelFile],
)
const renderEmptyStateButton = (
className: string,
heading: string,
body: string,
fileInputId: string,
) => (
<label htmlFor={fileInputId} className={`empty-state ${className} is-clickable`}>
<h3>{heading}</h3>
<p>{body}</p>
</label>
)
useEffect(() => {
console.info('[workbench] artifact selection state', {
loading,
latestCodePath: artifacts?.code_file?.path ?? null,
latestModelPath: artifacts?.model_file?.path ?? null,
codeOptions: codeOptions.map((item) => item.path),
modelOptions: modelOptions.map((item) => item.path),
outputFiles: artifacts?.output_files ?? [],
selectedCodePath: codePath ?? null,
selectedModelPath: modelPath ?? null,
codeReadFailed,
hasModelUrl: Boolean(modelUrl),
autoCodePath: autoCodeFile?.path ?? null,
})
}, [
autoCodeFile,
artifacts,
codeOptions,
codePath,
codeReadFailed,
loading,
modelOptions,
modelPath,
modelUrl,
])
return (
<Group orientation="vertical" className="workbench-group">
<input
id={modelFileInputId}
type="file"
accept=".stl"
className="hidden-file-input"
onChange={(event) => {
handleLocalModelSelection(event.target.files?.[0] ?? null)
event.currentTarget.value = ''
}}
/>
<input
id={codeFileInputId}
type="file"
accept=".py,.txt,.md,.json,.toml,.yaml,.yml,.ts,.tsx,.js,.jsx"
className="hidden-file-input"
onChange={(event) => {
void handleLocalCodeSelection(event.target.files?.[0] ?? null)
event.currentTarget.value = ''
}}
/>
<Panel defaultSize="56%" minSize="28%">
<section className="panel-shell workbench-panel">
<header className="panel-header">
<div className="panel-title-group">
<h2>Preview</h2>
<p className="panel-caption" title={modelPath ?? undefined}>
{modelPath ? formatArtifactLabel(modelPath) : 'No model yet'}
</p>
</div>
<div className="panel-header-actions">
{showModelArtifactPicker ? (
<label className="model-select artifact-picker">
<span>Model</span>
<select
value={effectiveSelectedModelPath}
onChange={(event) => {
setLocalModelFile((currentFile) => {
if (currentFile?.url) {
URL.revokeObjectURL(currentFile.url)
}
return null
})
setAutoCodeFile(null)
setSelectedModelPath(event.target.value)
}}
>
{modelOptions.map((item) => (
<option key={item.path} value={item.path}>
{formatArtifactLabel(item.path)}
</option>
))}
</select>
</label>
) : null}
{modelUrl ? (
<a className="download-link" href={modelUrl} target="_blank" rel="noreferrer">
Download
</a>
) : null}
</div>
</header>
<div className="workbench-content viewport-panel-content">
{loading ? (
renderEmptyStateButton(
'viewport-empty-state',
'Loading preview',
'Fetching the latest model files. Click to open a local STL instead.',
modelFileInputId,
)
) : (
<Suspense
fallback={
renderEmptyStateButton(
'viewport-empty-state',
'Preparing viewport',
'Loading the 3D renderer. Click to open a local STL instead.',
modelFileInputId,
)
}
>
<ModelViewport
key={modelUrl ?? modelPath ?? 'empty-model'}
modelPath={modelPath}
modelUrl={modelUrl}
localFileInputId={modelFileInputId}
onModelReady={handleModelReady}
/>
</Suspense>
)}
</div>
</section>
</Panel>
<Separator className="resize-handle resize-handle-horizontal" />
<Panel defaultSize="44%" minSize="24%">
<section className="panel-shell workbench-panel">
<header className="panel-header">
<div className="panel-title-group">
<h2>Code</h2>
<p className="panel-caption" title={codePath ?? undefined}>
{codePath ? formatArtifactLabel(codePath) : 'No code yet'}
</p>
</div>
<div className="panel-header-actions">
{showCodeArtifactPicker ? (
<label className="model-select artifact-picker">
<span>Code</span>
<select
value={effectiveSelectedCodePath}
onChange={(event) => {
setLocalCodeFile(null)
setSelectedCodePath(event.target.value)
}}
>
{codeOptions.map((item) => (
<option key={item.path} value={item.path}>
{formatArtifactLabel(item.path)}
</option>
))}
</select>
</label>
) : null}
</div>
</header>
<div className="workbench-content code-panel-content">
{codePath && !codeReadFailed ? (
<div className="code-scroll-shell">
<SyntaxHighlighter
language="python"
style={oneDark}
showLineNumbers
wrapLongLines
className="code-scroll-view code-syntax-view"
customStyle={{
margin: 0,
minHeight: '100%',
padding: '0.85rem 0',
background: 'transparent',
fontSize: '0.82rem',
}}
codeTagProps={{
style: {
fontFamily: 'IBM Plex Mono, SFMono-Regular, Consolas, monospace',
},
}}
lineNumberStyle={{
color: '#6f7883',
minWidth: '2.4rem',
paddingRight: '0.9rem',
textAlign: 'right',
userSelect: 'none',
}}
>
{codeValue}
</SyntaxHighlighter>
</div>
) : codeReadFailed ? (
renderEmptyStateButton(
'code-empty-state',
'Code preview unavailable',
'The selected file exists, but its text content could not be read. Click to open a local file instead.',
codeFileInputId,
)
) : (
renderEmptyStateButton(
'code-empty-state',
'No code yet',
'The latest generated CAD script will show up here. Click to open a local code file now.',
codeFileInputId,
)
)}
</div>
</section>
</Panel>
</Group>
)
}
@@ -0,0 +1,62 @@
:root {
font-family: 'IBM Plex Sans', 'Avenir Next', 'Segoe UI', sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: dark;
color: #e6e8eb;
background: #111317;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
--bg: #111317;
--sidebar: #15181d;
--surface: #171b20;
--surface-soft: #1a1f25;
--surface-active: #1f252d;
--ink: #e6e8eb;
--muted: #959da8;
--line: #272d35;
--line-strong: #3a424d;
--accent-line: #5d7388;
--focus: rgba(147, 168, 189, 0.6);
}
* {
box-sizing: border-box;
}
html,
body,
#root {
height: 100%;
min-height: 100%;
}
body {
margin: 0;
height: 100vh;
min-height: 100vh;
overflow: hidden;
background: var(--bg);
color: var(--ink);
}
#root {
height: 100svh;
min-height: 100svh;
overflow: hidden;
}
button,
input,
textarea,
select {
font: inherit;
}
a {
color: inherit;
text-decoration: none;
}
@@ -0,0 +1,266 @@
import type {
RawMessageContentPart,
ConversationDetail,
ConversationSummary,
LatestArtifactsResponse,
ModelInfo,
RawChatMessage,
StreamPacket,
} from '../types'
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL?.replace(/\/$/, '') ?? ''
function buildUrl(path: string): string {
if (!API_BASE_URL) {
return path
}
return `${API_BASE_URL}${path}`
}
async function parseError(response: Response): Promise<string> {
try {
const payload = (await response.json()) as {
error?: { message?: string }
message?: string
}
return payload.error?.message ?? payload.message ?? response.statusText
} catch {
return response.statusText
}
}
async function fetchJson<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(buildUrl(path), {
...init,
headers: {
Accept: 'application/json',
...(init?.headers ?? {}),
},
})
if (!response.ok) {
throw new Error(await parseError(response))
}
return (await response.json()) as T
}
export async function getHealth(): Promise<{ status: string }> {
return fetchJson('/health')
}
export async function listModels(): Promise<ModelInfo[]> {
const response = await fetchJson<{ data: ModelInfo[] }>('/v1/models')
return response.data ?? []
}
export async function listConversations(): Promise<ConversationSummary[]> {
const response = await fetchJson<{ conversations: ConversationSummary[] }>('/v1/conversations')
return response.conversations ?? []
}
export async function createConversation(): Promise<{ conversation_id: string }> {
return fetchJson('/v1/conversations', {
method: 'POST',
})
}
export async function deleteConversation(
conversationId: string,
): Promise<{ deleted: boolean; conversation_id: string }> {
return fetchJson(`/v1/conversations/${conversationId}`, {
method: 'DELETE',
})
}
export async function getConversationDetail(
conversationId: string,
): Promise<ConversationDetail> {
return fetchJson(`/v1/conversations/${conversationId}`)
}
export async function getConversationHistory(
conversationId: string,
): Promise<RawChatMessage[]> {
const response = await fetchJson<{ messages: RawChatMessage[] }>(
`/v1/conversations/${conversationId}/history`,
)
return response.messages ?? []
}
export async function getLatestArtifacts(
conversationId: string,
): Promise<LatestArtifactsResponse> {
return fetchJson(`/v1/conversations/${conversationId}/artifacts/latest`)
}
export function getConversationArtifactUrl(conversationId: string, path: string): string {
return buildUrl(`/v1/conversations/${conversationId}/artifacts/raw?path=${encodeURIComponent(path)}`)
}
export async function probeConversationArtifact(
conversationId: string,
path: string,
): Promise<boolean> {
try {
const url = getConversationArtifactUrl(conversationId, path)
const headResponse = await fetch(url, { method: 'HEAD' })
if (headResponse.ok) {
return true
}
if (headResponse.status !== 405) {
return false
}
const getResponse = await fetch(url, {
headers: {
Accept: '*/*',
},
})
return getResponse.ok
} catch {
return false
}
}
export async function readConversationArtifactText(
conversationId: string,
path: string,
): Promise<string | null> {
try {
const response = await fetch(getConversationArtifactUrl(conversationId, path), {
headers: {
Accept: 'text/plain, text/x-python, */*',
},
})
if (!response.ok) {
return null
}
return await response.text()
} catch {
return null
}
}
function flushPacket(
currentEvent: string,
dataLines: string[],
): StreamPacket | null {
if (!dataLines.length) {
return null
}
const payloadText = dataLines.join('\n')
let payload: unknown = { raw: payloadText }
try {
payload = JSON.parse(payloadText)
} catch {
payload = { raw: payloadText }
}
return {
event: currentEvent || 'message',
data: payload,
}
}
async function consumeSseStream(
stream: ReadableStream<Uint8Array>,
onPacket: (packet: StreamPacket) => void,
): Promise<void> {
const reader = stream.getReader()
const decoder = new TextDecoder()
let buffer = ''
let currentEvent = 'message'
let dataLines: string[] = []
const processLines = (chunk: string): void => {
const lines = chunk.split(/\r?\n/)
buffer = lines.pop() ?? ''
for (const line of lines) {
if (line === '') {
const packet = flushPacket(currentEvent, dataLines)
currentEvent = 'message'
dataLines = []
if (packet) {
onPacket(packet)
}
continue
}
if (line.startsWith(':')) {
continue
}
if (line.startsWith('event:')) {
currentEvent = line.slice(6).trim() || 'message'
continue
}
if (line.startsWith('data:')) {
dataLines.push(line.slice(5).trimStart())
}
}
}
while (true) {
const { value, done } = await reader.read()
if (done) {
if (buffer) {
processLines(`${buffer}\n`)
}
const packet = flushPacket(currentEvent, dataLines)
if (packet) {
onPacket(packet)
}
break
}
buffer += decoder.decode(value, { stream: true })
processLines(buffer)
}
}
export async function streamChatEvents(options: {
conversationId?: string
model: string
messageContent: string | RawMessageContentPart[]
onPacket: (packet: StreamPacket) => void
}): Promise<{ conversationId: string }> {
const response = await fetch(buildUrl('/v1/chat/events'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'text/event-stream',
...(options.conversationId
? { 'X-Conversation-ID': options.conversationId }
: {}),
},
body: JSON.stringify({
model: options.model,
messages: [{ role: 'user', content: options.messageContent }],
stream: true,
}),
})
if (!response.ok) {
throw new Error(await parseError(response))
}
if (!response.body) {
throw new Error('Streaming response body is missing')
}
await consumeSseStream(response.body, options.onPacket)
return {
conversationId:
response.headers.get('X-Conversation-ID') ?? options.conversationId ?? '',
}
}
@@ -0,0 +1,632 @@
import type {
AssistantTextSegment,
ChatTurn,
ReasoningSegment,
RawContentImagePart,
RawContentTextPart,
RawChatMessage,
StreamEventEnvelope,
StreamPacket,
ToolNestedEvent,
ToolSegment,
TurnSegment,
} from '../types'
const CODE_FILE_PATTERN = /<\|code_file\|>[^<]*?<\/\|code_file(?:\|)?>/g
const OUTPUT_FILE_PATTERN = /<\|output_file\|>[^<]*?<\/\|output_file(?:\|)?>/g
const CODE_FILE_CAPTURE_PATTERN = /<\|code_file\|>([^<]*?)<\/\|code_file(?:\|)?>/g
const OUTPUT_FILE_CAPTURE_PATTERN = /<\|output_file\|>([^<]*?)<\/\|output_file(?:\|)?>/g
function makeId(prefix: string): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return `${prefix}-${crypto.randomUUID()}`
}
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
}
function rememberRecentString(values: string[], nextValue: string): void {
const trimmedValue = nextValue.trim()
if (!trimmedValue) {
return
}
const existingIndex = values.indexOf(trimmedValue)
if (existingIndex >= 0) {
values.splice(existingIndex, 1)
}
values.push(trimmedValue)
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
function isTextPart(value: unknown): value is RawContentTextPart {
return isRecord(value) && value.type === 'text' && typeof value.text === 'string'
}
function isImagePart(value: unknown): value is RawContentImagePart {
return (
isRecord(value) &&
value.type === 'image_url' &&
isRecord(value.image_url) &&
typeof value.image_url.url === 'string'
)
}
function cloneSegment(segment: TurnSegment): TurnSegment {
if (segment.kind === 'text' || segment.kind === 'reasoning') {
return { ...segment }
}
return {
...segment,
nestedEvents: segment.nestedEvents.map((event) => ({ ...event })),
}
}
function cloneTurn(turn: ChatTurn): ChatTurn {
return {
...turn,
segments: turn.segments.map(cloneSegment),
}
}
function createTextSegment(rawText = ''): AssistantTextSegment {
return {
kind: 'text',
id: makeId('text'),
rawText,
}
}
function createReasoningSegment(title: string, rawText = ''): ReasoningSegment {
return {
kind: 'reasoning',
id: makeId('reasoning'),
title,
rawText,
}
}
function createToolSegment(toolCallId: string, toolName: string): ToolSegment {
return {
kind: 'tool',
id: makeId('tool'),
toolCallId,
toolName: toolName || 'tool',
status: 'pending',
argumentsPreview: '',
detailPreview: '',
nestedEvents: [],
}
}
function upsertNestedToolEvent(
tool: ToolSegment,
nestedToolCallId: string,
title: string,
): ToolNestedEvent {
const existing = tool.nestedEvents.find(
(item) => item.nestedToolCallId === nestedToolCallId && nestedToolCallId,
)
if (existing) {
if (title) {
existing.title = title
}
return existing
}
const event: ToolNestedEvent = {
id: makeId('tool-nested'),
nestedToolCallId,
title,
status: 'info',
argumentsPreview: '',
detailLabel: 'Detail',
detailPreview: '',
}
tool.nestedEvents.push(event)
return event
}
function appendNarrativeNestedEvent(
tool: ToolSegment,
title: string,
detailLabel: 'Response' | 'Reasoning',
deltaText: string,
): ToolNestedEvent {
const lastEvent = tool.nestedEvents.at(-1)
if (
lastEvent &&
lastEvent.title === title &&
lastEvent.detailLabel === detailLabel &&
lastEvent.status === 'info'
) {
lastEvent.detailPreview += deltaText
return lastEvent
}
const event: ToolNestedEvent = {
id: makeId('tool-narrative'),
nestedToolCallId: makeId('tool-narrative-call'),
title,
status: 'info',
argumentsPreview: '',
detailLabel,
detailPreview: deltaText,
}
tool.nestedEvents.push(event)
return event
}
function upsertToolSegment(
nextTurn: ChatTurn,
toolCallId: string,
toolName: string,
): ToolSegment {
const existing = nextTurn.segments.find(
(segment): segment is ToolSegment =>
segment.kind === 'tool' && segment.toolCallId === toolCallId,
)
if (existing) {
if (toolName && !existing.toolName) {
existing.toolName = toolName
}
return existing
}
const nextSegment = createToolSegment(toolCallId, toolName)
nextTurn.segments.push(nextSegment)
return nextSegment
}
function appendText(
nextTurn: ChatTurn,
rawText: string,
mergeWithPrevious: boolean,
): ChatTurn {
if (!rawText) {
return nextTurn
}
nextTurn.responseText += rawText
const lastSegment = nextTurn.segments.at(-1)
if (mergeWithPrevious && lastSegment?.kind === 'text') {
lastSegment.rawText += rawText
return nextTurn
}
nextTurn.segments.push(createTextSegment(rawText))
return nextTurn
}
function appendReasoning(nextTurn: ChatTurn, rawText: string, title = 'Reasoning'): ChatTurn {
if (!rawText) {
return nextTurn
}
const lastSegment = nextTurn.segments.at(-1)
if (lastSegment?.kind === 'reasoning' && lastSegment.title === title) {
lastSegment.rawText += rawText
return nextTurn
}
nextTurn.segments.push(createReasoningSegment(title, rawText))
return nextTurn
}
export function sanitizeAssistantText(text: string): string {
return text
.replace(CODE_FILE_PATTERN, '')
.replace(OUTPUT_FILE_PATTERN, '')
.replace(/\n\s*\n\s*\n/g, '\n\n')
.trim()
}
export function extractArtifactTagPaths(messages: RawChatMessage[]): {
codePaths: string[]
modelPaths: string[]
} {
const codePaths: string[] = []
const modelPaths: string[] = []
for (const message of messages) {
if (message.role !== 'assistant') {
continue
}
const text = contentToText(message.content)
const codeMatches = text.matchAll(CODE_FILE_CAPTURE_PATTERN)
const modelMatches = text.matchAll(OUTPUT_FILE_CAPTURE_PATTERN)
for (const match of codeMatches) {
if (match[1]) {
rememberRecentString(codePaths, match[1])
}
}
for (const match of modelMatches) {
if (match[1]) {
rememberRecentString(modelPaths, match[1])
}
}
}
return {
codePaths: [...codePaths].reverse(),
modelPaths: [...modelPaths].reverse(),
}
}
export function contentToText(content: unknown): string {
if (content == null) {
return ''
}
if (typeof content === 'string') {
return content
}
if (Array.isArray(content)) {
return content
.map((item) => {
if (isRecord(item)) {
if (item.type === 'text') {
return typeof item.text === 'string' ? item.text : ''
}
if (item.type === 'image_url') {
return '[Image]'
}
}
return String(item)
})
.filter(Boolean)
.join('\n')
.trim()
}
return String(content)
}
export function parseUserMessageContent(content: unknown): {
text: string
imageUrls: string[]
} {
if (typeof content === 'string') {
return { text: content, imageUrls: [] }
}
if (!Array.isArray(content)) {
return { text: contentToText(content), imageUrls: [] }
}
const textParts: string[] = []
const imageUrls: string[] = []
for (const item of content) {
if (isTextPart(item)) {
textParts.push(item.text)
continue
}
if (isImagePart(item)) {
imageUrls.push(item.image_url.url)
}
}
return {
text: textParts.join('\n').trim(),
imageUrls,
}
}
export function compactPreview(value: unknown): string {
if (value == null) {
return ''
}
if (typeof value === 'string') {
const trimmed = value.trim()
if (!trimmed) {
return ''
}
try {
const parsed = JSON.parse(trimmed) as unknown
if (typeof parsed === 'object' && parsed !== null) {
return JSON.stringify(parsed, null, 2)
}
} catch {
return value
}
return value
}
if (typeof value === 'object') {
try {
return JSON.stringify(value, null, 2)
} catch {
return String(value)
}
}
return String(value)
}
export function createTurn(userText: string, userImages: string[] = []): ChatTurn {
return {
id: makeId('turn'),
userText,
userImages,
responseText: '',
segments: [],
}
}
export function appendAssistantTextBlock(turn: ChatTurn, rawText: string): ChatTurn {
const nextTurn = cloneTurn(turn)
return appendText(nextTurn, rawText, false)
}
function eventEnvelopeFromPacket(packet: StreamPacket): StreamEventEnvelope | null {
if (!isRecord(packet.data)) {
return null
}
const nestedEvent = packet.data.event
if (!isRecord(nestedEvent)) {
return null
}
return nestedEvent as StreamEventEnvelope
}
function nestedEventTitle(data: Record<string, unknown>, fallbackName: string): string {
const sourceToolName = typeof data.source_tool_name === 'string' ? data.source_tool_name : ''
const nestedToolName = typeof data.nested_tool_name === 'string' ? data.nested_tool_name : fallbackName
const subagentLabel = typeof data.subagent_label === 'string' ? data.subagent_label : ''
if (sourceToolName && sourceToolName !== nestedToolName) {
return `${sourceToolName} > ${nestedToolName}`
}
if (nestedToolName) {
return nestedToolName
}
return subagentLabel || 'step'
}
function applySubagentNestedEvent(
tool: ToolSegment,
eventName: string,
data: unknown,
): ToolSegment {
if (!isRecord(data)) {
return tool
}
const nestedToolCallId = String(data.nested_tool_call_id ?? '')
const title = nestedEventTitle(data, 'step')
if (eventName === 'subagent_tool_start') {
const nestedEvent = upsertNestedToolEvent(tool, nestedToolCallId, title)
nestedEvent.status = 'running'
nestedEvent.argumentsPreview = compactPreview(data.arguments)
nestedEvent.detailLabel = 'Progress'
nestedEvent.detailPreview = ''
return tool
}
if (eventName === 'subagent_tool_end') {
const nestedEvent = upsertNestedToolEvent(tool, nestedToolCallId, title)
nestedEvent.status = 'completed'
if (!nestedEvent.argumentsPreview) {
nestedEvent.argumentsPreview = compactPreview(data.arguments)
}
nestedEvent.detailLabel = 'Result'
nestedEvent.detailPreview = compactPreview(data.result)
return tool
}
if (eventName === 'subagent_tool_error') {
const nestedEvent = upsertNestedToolEvent(tool, nestedToolCallId, title)
nestedEvent.status = 'error'
if (!nestedEvent.argumentsPreview) {
nestedEvent.argumentsPreview = compactPreview(data.arguments)
}
nestedEvent.detailLabel = 'Error'
nestedEvent.detailPreview = compactPreview(data.error_message)
return tool
}
if (eventName === 'subagent_custom') {
const title = String(data.custom_event_name ?? 'custom event')
const nestedEvent = upsertNestedToolEvent(tool, nestedToolCallId, title)
nestedEvent.status = 'info'
nestedEvent.detailLabel = 'Detail'
nestedEvent.detailPreview = compactPreview(data.data)
return tool
}
if (eventName === 'subagent_response') {
const subagentLabel = String(data.subagent_label ?? 'subagent')
const deltaText =
typeof data.delta_text === 'string' ? data.delta_text : compactPreview(data.delta_text)
appendNarrativeNestedEvent(
tool,
`${subagentLabel} response`,
'Response',
deltaText,
)
return tool
}
if (eventName === 'subagent_reasoning') {
const subagentLabel = String(data.subagent_label ?? 'subagent')
const deltaReasoning =
typeof data.delta_reasoning === 'string'
? data.delta_reasoning
: compactPreview(data.delta_reasoning)
appendNarrativeNestedEvent(
tool,
`${subagentLabel} reasoning`,
'Reasoning',
deltaReasoning,
)
return tool
}
if (eventName === 'subagent_status') {
const title = String(data.phase ?? 'status')
const subagentLabel = String(data.subagent_label ?? 'subagent')
const nestedEvent = upsertNestedToolEvent(
tool,
`status-${subagentLabel}-${title}`,
`${subagentLabel} ${title}`,
)
nestedEvent.status = title === 'finished' ? 'completed' : 'info'
nestedEvent.detailLabel = 'Detail'
nestedEvent.detailPreview = compactPreview(data.message ?? data.target_file_path)
return tool
}
return tool
}
export function applyStreamPacketToTurn(turn: ChatTurn, packet: StreamPacket): ChatTurn {
const nextTurn = cloneTurn(turn)
if (packet.event === 'response') {
if (isRecord(packet.data)) {
if (typeof packet.data.delta_reasoning === 'string') {
appendReasoning(nextTurn, packet.data.delta_reasoning)
}
if (typeof packet.data.delta_text === 'string') {
return appendText(nextTurn, packet.data.delta_text, true)
}
}
return nextTurn
}
const event = eventEnvelopeFromPacket(packet)
if (!event) {
return nextTurn
}
const toolCallId = String(event.tool_call_id ?? '')
const toolName = String(event.tool_name ?? 'tool')
if (packet.event === 'tool_call_start') {
const tool = upsertToolSegment(nextTurn, toolCallId, toolName)
tool.status = 'running'
tool.argumentsPreview = compactPreview(event.arguments)
return nextTurn
}
if (packet.event === 'tool_call_end') {
const tool = upsertToolSegment(nextTurn, toolCallId, toolName)
tool.status = 'completed'
if (!tool.argumentsPreview) {
tool.argumentsPreview = compactPreview(event.arguments)
}
tool.detailPreview = compactPreview(event.result)
return nextTurn
}
if (packet.event === 'tool_call_error') {
const tool = upsertToolSegment(nextTurn, toolCallId, toolName)
tool.status = 'error'
if (!tool.argumentsPreview) {
tool.argumentsPreview = compactPreview(event.arguments)
}
tool.detailPreview = compactPreview(
event.error_message ?? event.error ?? 'Tool call failed',
)
return nextTurn
}
const subagentEventName =
packet.event === 'custom_event' ? String(event.event_name ?? '') : packet.event
if (subagentEventName.startsWith('subagent_')) {
if (!toolCallId) {
return nextTurn
}
const tool = upsertToolSegment(nextTurn, toolCallId, toolName)
applySubagentNestedEvent(tool, subagentEventName, event.data)
return nextTurn
}
return nextTurn
}
export function buildChatTurns(messages: RawChatMessage[]): ChatTurn[] {
const turns: ChatTurn[] = []
let currentTurn: ChatTurn | null = null
const flushTurn = (): void => {
if (currentTurn) {
turns.push(currentTurn)
currentTurn = null
}
}
for (const message of messages) {
if (message.role === 'user') {
flushTurn()
const parsedUserContent = parseUserMessageContent(message.content)
currentTurn = createTurn(parsedUserContent.text, parsedUserContent.imageUrls)
continue
}
if (!currentTurn) {
currentTurn = createTurn('')
}
if (message.role === 'assistant') {
const assistantText = contentToText(message.content)
if (assistantText) {
currentTurn = appendAssistantTextBlock(currentTurn, assistantText)
}
const toolCalls = message.tool_calls ?? []
for (const toolCall of toolCalls) {
const functionInfo = toolCall.function ?? {}
const tool = upsertToolSegment(
currentTurn,
String(toolCall.id ?? ''),
String(functionInfo.name ?? 'tool'),
)
tool.status = 'running'
tool.argumentsPreview = compactPreview(functionInfo.arguments)
}
continue
}
if (message.role === 'tool') {
const tool = upsertToolSegment(
currentTurn,
String(message.tool_call_id ?? ''),
'tool',
)
tool.status = 'completed'
tool.detailPreview = compactPreview(contentToText(message.content))
}
}
flushTurn()
return turns
}
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+150
View File
@@ -0,0 +1,150 @@
export type MessageRole = 'system' | 'user' | 'assistant' | 'tool'
export interface RawToolCall {
id?: string
type?: string
function?: {
name?: string
arguments?: unknown
}
}
export interface RawContentTextPart {
type: 'text'
text: string
}
export interface RawContentImagePart {
type: 'image_url'
image_url: {
url: string
detail?: string
}
}
export type RawMessageContentPart = RawContentTextPart | RawContentImagePart
export interface RawChatMessage {
role: MessageRole
content?: unknown
name?: string | null
tool_calls?: RawToolCall[] | null
tool_call_id?: string | null
}
export interface ModelInfo {
id: string
object: string
created: number
owned_by: string
}
export interface ConversationSummary {
conversation_id: string
marker_file?: string
is_active?: boolean
context_start_time?: string
context_last_activity?: string
context_total_messages?: number
context_has_summary?: boolean
sketch_total_items?: number
sketch_max_items?: number
sketch_memory_usage?: number
}
export interface ConversationDetail {
conversation_id: string
created_at: string
last_accessed: string
message_count: number
sketch_stats?: {
total_items?: number
total_accesses?: number
memory_usage_percent?: number
}
}
export interface ArtifactFileInfo {
path: string
content?: string | null
content_type?: string
url?: string
}
export interface LatestArtifactsResponse {
conversation_id: string
code_file: ArtifactFileInfo | null
code_files: ArtifactFileInfo[]
model_file: ArtifactFileInfo | null
model_files: ArtifactFileInfo[]
output_files: string[]
}
export type ToolStatus = 'pending' | 'running' | 'completed' | 'error'
export type ToolNestedEventStatus = 'running' | 'completed' | 'error' | 'info'
export interface AssistantTextSegment {
kind: 'text'
id: string
rawText: string
}
export interface ReasoningSegment {
kind: 'reasoning'
id: string
title: string
rawText: string
}
export interface ToolSegment {
kind: 'tool'
id: string
toolCallId: string
toolName: string
status: ToolStatus
argumentsPreview: string
detailPreview: string
nestedEvents: ToolNestedEvent[]
}
export interface ToolNestedEvent {
id: string
nestedToolCallId: string
title: string
status: ToolNestedEventStatus
argumentsPreview: string
detailLabel: string
detailPreview: string
}
export type TurnSegment = AssistantTextSegment | ReasoningSegment | ToolSegment
export interface ChatTurn {
id: string
userText: string
userImages: string[]
responseText: string
segments: TurnSegment[]
}
export interface ComposerImageAttachment {
id: string
name: string
dataUrl: string
}
export interface StreamPacket {
event: string
data: unknown
}
export interface StreamEventEnvelope {
tool_call_id?: string
tool_name?: string
arguments?: unknown
result?: unknown
error?: string
error_message?: string
event_name?: string
data?: unknown
}
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2023",
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
@@ -0,0 +1,19 @@
import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
const proxyTarget =
env.CADDESIGNER_API_PROXY_TARGET || env.VITE_API_BASE_URL || 'http://127.0.0.1:8000'
return {
plugins: [react()],
server: {
port: 4173,
proxy: {
'/health': proxyTarget,
'/v1': proxyTarget,
},
},
}
})
+253
View File
@@ -0,0 +1,253 @@
"""
CAD Assistant
Usage:
python main.py
"""
import asyncio
import sys
from time import sleep
from bootstrap_env import load_project_env
load_project_env()
from rich.console import Console
from rich.panel import Panel
from agent import get_agent, BaseAgent
from context.conversation_manager import get_conversation_manager
from observability import propagate_conversation_session
from react_stream import extract_output_text
console = Console()
def setup_agent() -> BaseAgent | None:
"""
Set up the agent.
"""
try:
# Use the global Agent singleton.
agent = get_agent(
model_name="cadagent",
)
console.print("CAD Assistant initialized successfully!")
return agent
except Exception as e:
print(f"Failed to initialize agent: {e}")
return None
def get_input() -> str:
"""
Get user input.
"""
lines = []
console.print("\n===========================")
console.print(">>> ", end="")
try:
while True:
line = input()
lines.append(line)
except EOFError:
pass
except KeyboardInterrupt:
return ""
return "\n".join(lines).strip()
def handle_special_commands(agent: BaseAgent, query: str) -> bool:
"""Handle special commands; return True if handled, otherwise False to continue normal processing."""
query_lower = query.lower().strip()
if query_lower == "/help":
console.print(
Panel.fit(
"[bold cyan]Special Commands:[/bold cyan]\n"
"[yellow]/help[/yellow] - Show this help\n"
"[yellow]/pad[/yellow] - Show SketchPad contents\n"
"[yellow]/pad_search <query>[/yellow] - Search SketchPad\n"
"[yellow]/pad_get <key>[/yellow] - Get content from SketchPad\n"
"[yellow]quit[/yellow] - Exit"
)
)
return True
if query_lower == "/pad":
try:
# Use BaseAgent._get_sketch_pad_summary to retrieve the summary.
summary = agent.get_sketch_pad_summary()
if summary.strip():
console.print(
Panel.fit(
summary,
title="[ SketchPad Contents ]",
border_style="cyan",
)
)
else:
console.print("[yellow]SketchPad is empty.[/yellow]")
except Exception as e:
console.print(f"[red]Unable to access SketchPad: {e}[/red]")
return True
if query_lower.startswith("/pad_search "):
search_query = query[12:].strip()
if search_query:
try:
results = agent.search_sketch_pad(search_query, 5)
if results:
formatted_lines = []
for item in results:
if isinstance(item, tuple) and len(item) >= 2:
key, sketch_item = item[0], item[1]
snippet = getattr(sketch_item, "summary", None) or str(
getattr(sketch_item, "value", "")
)
else:
key = item.get("key", "Unknown")
snippet = item.get("snippet", "No summary")
formatted_lines.append(f"- {key}: {str(snippet)[:50]}...")
content = "\n".join(formatted_lines)
console.print(
Panel.fit(
content,
title=f"[ SketchPad Search: '{search_query}' ]",
border_style="magenta",
)
)
else:
console.print(
f"[yellow]No SketchPad items found for '{search_query}'[/yellow]"
)
except Exception as e:
console.print(f"[red]Search failed: {e}[/red]")
else:
console.print("[red]Please provide a search query[/red]")
return True
if query_lower.startswith("/pad_get "):
key = query[9:].strip()
if key:
try:
value = agent.get_from_sketch_pad(key)
if value is not None:
# Truncate long content.
display_value = str(value)
if len(display_value) > 500:
display_value = display_value[:500] + "..."
console.print(
Panel.fit(
display_value,
title=f"[ SketchPad Item: {key} ]",
border_style="green",
)
)
else:
console.print(
f"[yellow]Key '{key}' not found in SketchPad[/yellow]"
)
except Exception as e:
console.print(f"[red]Get failed: {e}[/red]")
else:
console.print("[red]Usage: /pad_get <key>[/red]")
return True
return False
async def main() -> None:
"""
Main function.
"""
agent = setup_agent()
if not agent:
return
# Create a new conversation context.
conversation_manager = get_conversation_manager()
conversation = conversation_manager.create_conversation()
console.print(
Panel.fit(
f"[bold green]Ready![/bold green] Started new conversation session: [yellow]{conversation.uuid[:8]}...[/yellow]\n"
"[dim]Previous conversations are saved but not loaded automatically.[/dim]\n"
"[yellow]Create a new line and press [bold]Ctrl+D[/bold] (or [bold]Ctrl+Z[/bold] on Windows) to submit your query.[/yellow]\n"
"[cyan]Input 'quit' to exit the program.[/cyan]\n"
"[dim]Type '/help' for special commands, '/full_history' to view saved history.[/dim]",
title="[ CAD Assistant ]",
border_style="blue",
)
)
# Run within the conversation context.
with conversation:
while True:
try:
query = get_input()
if not query:
continue
if query.lower() == "quit":
break
# Handle special commands.
if handle_special_commands(agent, query):
continue
try:
console.print("===========================")
console.print("[Agent] >>> ", end="")
with propagate_conversation_session(
conversation_id=conversation.uuid,
metadata={
"model": getattr(agent, "model_name", None),
"agent_name": getattr(agent, "name", None),
"transport": "cli",
},
tags=["cadagent", "cli"],
):
async for output in agent.run(query):
try:
delta = extract_output_text(output, "cli")
except Exception:
delta = ""
if not delta:
continue
for char in delta:
if char == "\r":
char = "\n"
if char.strip() == "" and char not in ("\n", " "):
continue
console.print(char, end="")
sleep(0.01)
console.print("\n===========================")
except Exception as e:
console.print(f"\nError: {e}")
except KeyboardInterrupt:
console.print("\nType 'quit' to exit.")
continue
except Exception as e:
console.print(f"Error: {e}")
continue
# Save the conversation.
await conversation_manager.save_conversation(conversation.uuid)
console.print(f"[dim]Conversation {conversation.uuid[:8]}... saved.[/dim]")
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
print("\nGoodbye!")
except Exception as e:
print(f"Fatal error: {e}")
sys.exit(1)
@@ -0,0 +1,3 @@
from .langfuse_tracing import propagate_conversation_session
__all__ = ["propagate_conversation_session"]
@@ -0,0 +1,73 @@
from __future__ import annotations
from contextlib import contextmanager
import json
import os
from typing import Any, Iterator, Mapping, Optional
from dotenv import load_dotenv
load_dotenv()
try:
from langfuse import propagate_attributes
except Exception: # pragma: no cover - optional dependency fallback
propagate_attributes = None # type: ignore[assignment]
def _langfuse_is_configured() -> bool:
return bool(os.getenv("LANGFUSE_PUBLIC_KEY") and os.getenv("LANGFUSE_SECRET_KEY"))
def _stringify_metadata(
metadata: Optional[Mapping[str, Any]],
) -> Optional[dict[str, str]]:
if not metadata:
return None
result: dict[str, str] = {}
for key, value in metadata.items():
if value is None:
continue
if isinstance(value, str):
result[key] = value
continue
try:
result[key] = json.dumps(value, ensure_ascii=False)
except Exception:
result[key] = str(value)
return result or None
@contextmanager
def propagate_conversation_session(
*,
conversation_id: str,
metadata: Optional[Mapping[str, Any]] = None,
tags: Optional[list[str]] = None,
) -> Iterator[None]:
"""Attach `session_id=conversation_id` to existing spans in the current execution scope.
SimpleLLMFunc already creates the turn/message spans for us. We only need to make sure
all spans emitted while handling the same conversation inherit the same Langfuse session.
"""
if not _langfuse_is_configured() or propagate_attributes is None:
yield
return
propagated_metadata = _stringify_metadata(
{
"conversation_id": conversation_id,
**(dict(metadata) if metadata else {}),
}
)
with propagate_attributes(
session_id=conversation_id,
metadata=propagated_metadata,
tags=tags,
):
yield
+36
View File
@@ -0,0 +1,36 @@
[project]
name = "caddesigner"
version = "0.1.0"
description = "Conceptual CAD model generation with a general-purpose agent"
readme = "README.md"
requires-python = ">=3.12,<3.13"
dependencies = [
"cadquery>=2.5.2",
"simplellmfunc==0.7.2",
"numpy",
"uvicorn>=0.34.3",
"fastapi>=0.115.14",
"simplecadapi @ https://files.pythonhosted.org/packages/04/34/5a6a16cd7a533324b73997cbf4094708616eff4eb7aa5496280a79be3444/simplecadapi-2.0.8-py3-none-any.whl",
"rich>=14.0.0",
"pyvista>=0.45.3",
"vtk==9.4.2",
"redis>=6.2.0",
"markdown>=3.8.2",
"requests>=2.32.4",
"types-requests>=2.32.4.20250611",
"python-dotenv>=1.1.1",
]
[project.optional-dependencies]
editor = [
"cq-editor>=0.5.0; sys_platform != 'win32'",
]
[dependency-groups]
dev = [
"pytest>=9.0.2",
"pytest-asyncio>=1.3.0",
]
[tool.pytest.ini_options]
asyncio_mode = "auto"
+362
View File
@@ -0,0 +1,362 @@
"""Utilities for working with SimpleLLMFunc React event streams."""
from __future__ import annotations
from dataclasses import is_dataclass
from datetime import datetime
from enum import Enum
import json
from typing import Any, Dict
from SimpleLLMFunc.base.post_process import extract_content_from_stream_response
from SimpleLLMFunc.hooks.events import CustomEvent
from SimpleLLMFunc.hooks.stream import (
EventYield,
ReactOutput,
ResponseYield,
is_event_yield,
is_response_yield,
)
from SimpleLLMFunc.utils.tui.formatters import extract_reasoning_delta
def normalize_for_json(value: Any) -> Any:
"""Recursively normalize complex event payloads into JSON-safe values."""
if value is None or isinstance(value, (str, int, float, bool)):
return value
if isinstance(value, datetime):
return value.isoformat()
if isinstance(value, Enum):
return value.value
if isinstance(value, BaseException):
return {
"type": value.__class__.__name__,
"message": str(value),
}
if isinstance(value, dict):
return {str(key): normalize_for_json(item) for key, item in value.items()}
if isinstance(value, (list, tuple, set)):
return [normalize_for_json(item) for item in value]
if is_dataclass(value):
dataclass_fields = getattr(value, "__dataclass_fields__", {})
return normalize_for_json(
{name: getattr(value, name) for name in dataclass_fields}
)
if hasattr(value, "model_dump"):
try:
return normalize_for_json(value.model_dump())
except Exception:
pass
if hasattr(value, "dict"):
try:
return normalize_for_json(value.dict())
except Exception:
pass
if hasattr(value, "as_dict"):
try:
return normalize_for_json(value.as_dict())
except Exception:
pass
if hasattr(value, "__dict__") and not isinstance(value, type):
try:
return normalize_for_json(vars(value))
except Exception:
pass
return str(value)
def extract_response_text(response: Any, consumer: str = "agent_stream") -> str:
"""Extract text from a ResponseYield payload or raw provider chunk."""
if isinstance(response, str):
return response
if hasattr(response, "model_dump"):
try:
dumped = response.model_dump()
text = extract_response_text(dumped, consumer)
if text:
return text
except Exception:
pass
try:
return extract_content_from_stream_response(response, consumer) or ""
except Exception:
pass
try:
choices = None
if isinstance(response, dict):
choices = response.get("choices")
elif hasattr(response, "choices"):
choices = response.choices
if isinstance(choices, list) and choices:
first = choices[0]
if isinstance(first, dict):
delta = first.get("delta")
if isinstance(delta, dict):
content = delta.get("content")
return content if isinstance(content, str) else ""
message = first.get("message")
if isinstance(message, dict):
content = message.get("content")
return content if isinstance(content, str) else ""
else:
delta = getattr(first, "delta", None)
content = getattr(delta, "content", None) if delta is not None else None
if isinstance(content, str):
return content
message = getattr(first, "message", None)
content = (
getattr(message, "content", None) if message is not None else None
)
if isinstance(content, str):
return content
return ""
except Exception:
return ""
def extract_output_text(output: ReactOutput, consumer: str = "agent_stream") -> str:
"""Extract plain text from a ReactOutput if available."""
if is_response_yield(output):
return extract_response_text(output.response, consumer)
return ""
def extract_response_reasoning(response: Any) -> str:
"""Extract provider reasoning text from a response chunk or full response."""
if isinstance(response, str):
return ""
if hasattr(response, "model_dump"):
try:
dumped = response.model_dump()
reasoning = extract_response_reasoning(dumped)
if reasoning:
return reasoning
except Exception:
pass
try:
if isinstance(response, dict):
choices = response.get("choices")
if isinstance(choices, list) and choices:
first = choices[0]
if isinstance(first, dict):
delta = first.get("delta")
if isinstance(delta, dict):
for key in ("reasoning", "reasoning_content", "reasoning_text"):
value = delta.get(key)
if isinstance(value, str) and value:
return value
details = delta.get("reasoning_details")
if isinstance(details, list):
collected: list[str] = []
for detail in details:
if not isinstance(detail, dict):
continue
detail_type = str(detail.get("type", ""))
if detail_type.endswith("encrypted"):
continue
text = detail.get("text") or detail.get("data")
if isinstance(text, str) and text:
collected.append(text)
if collected:
return "".join(collected)
reasoning = extract_reasoning_delta(response)
if reasoning:
return reasoning
except Exception:
pass
try:
choices = (
response.get("choices") if isinstance(response, dict) else response.choices
)
if not isinstance(choices, list) or not choices:
return ""
first = choices[0]
message = (
first.get("message")
if isinstance(first, dict)
else getattr(first, "message", None)
)
if message is None:
return ""
for attr in ("reasoning", "reasoning_content", "reasoning_text"):
value = (
message.get(attr)
if isinstance(message, dict)
else getattr(message, attr, None)
)
if isinstance(value, str) and value:
return value
details = (
message.get("reasoning_details")
if isinstance(message, dict)
else getattr(message, "reasoning_details", None)
)
if isinstance(details, list):
collected: list[str] = []
for detail in details:
if isinstance(detail, dict):
detail_type = str(detail.get("type", ""))
if detail_type.endswith("encrypted"):
continue
text = detail.get("text") or detail.get("data")
else:
detail_type = str(getattr(detail, "type", ""))
if detail_type.endswith("encrypted"):
continue
text = getattr(detail, "text", None) or getattr(
detail, "data", None
)
if isinstance(text, str) and text:
collected.append(text)
return "".join(collected)
except Exception:
return ""
return ""
def serialize_react_output(
output: ReactOutput,
delta_consumer: str = "agent_stream",
) -> Dict[str, Any]:
"""Serialize ResponseYield/EventYield into a stable JSON payload."""
if is_response_yield(output):
return {
"type": "response",
"delta_text": extract_response_text(output.response, delta_consumer),
"delta_reasoning": extract_response_reasoning(output.response),
"response": normalize_for_json(output.response),
"messages": normalize_for_json(output.messages),
}
assert is_event_yield(output)
event_type = getattr(output.event.event_type, "value", output.event.event_type)
if isinstance(output.event, CustomEvent) and output.event.event_name:
event_type = output.event.event_name
return {
"type": "event",
"event_type": str(event_type),
"event": normalize_for_json(output.event),
"origin": normalize_for_json(output.origin),
}
def event_name_for_output(output: ReactOutput) -> str:
if is_response_yield(output):
return "response"
assert is_event_yield(output)
if isinstance(output.event, CustomEvent) and output.event.event_name:
return output.event.event_name
event_type = getattr(output.event.event_type, "value", output.event.event_type)
return str(event_type)
def format_sse(event: str, data: Dict[str, Any]) -> str:
payload = json.dumps(data, ensure_ascii=False)
return f"event: {event}\ndata: {payload}\n\n"
def project_response_to_oai_chunk(
output: ResponseYield,
request_id: str,
model: str,
created_time: int,
sent_role: bool,
) -> Dict[str, Any] | None:
"""Project a response yield into an OpenAI-compatible streaming chunk."""
raw_response = normalize_for_json(output.response)
if isinstance(raw_response, dict):
if raw_response.get("object") == "chat.completion.chunk":
return raw_response
if raw_response.get("object") == "chat.completion":
choices = raw_response.get("choices")
if isinstance(choices, list) and choices:
first = choices[0] if isinstance(choices[0], dict) else {}
message = first.get("message") if isinstance(first, dict) else {}
if isinstance(message, dict):
delta: Dict[str, Any] = {
"role": message.get("role", "assistant"),
"content": message.get("content"),
}
tool_calls = message.get("tool_calls")
if tool_calls is not None:
delta["tool_calls"] = tool_calls
return {
"id": request_id,
"object": "chat.completion.chunk",
"created": created_time,
"model": model,
"choices": [
{
"index": 0,
"delta": delta,
"finish_reason": first.get("finish_reason"),
}
],
}
delta_text = extract_response_text(output.response, "agent_stream")
tool_calls = None
try:
if isinstance(raw_response, dict):
choices = raw_response.get("choices")
if isinstance(choices, list) and choices:
first_item = choices[0]
delta_value: Any = {}
if isinstance(first_item, dict):
delta_value = first_item.get("delta")
if isinstance(delta_value, dict):
tool_calls = delta_value.get("tool_calls")
except Exception:
tool_calls = None
if not delta_text and not tool_calls:
return None
delta: Dict[str, Any] = {}
if not sent_role:
delta["role"] = "assistant"
if delta_text:
delta["content"] = delta_text
if tool_calls is not None:
delta["tool_calls"] = tool_calls
return {
"id": request_id,
"object": "chat.completion.chunk",
"created": created_time,
"model": model,
"choices": [
{
"index": 0,
"delta": delta,
"finish_reason": None,
}
],
}
__all__ = [
"EventYield",
"ResponseYield",
"event_name_for_output",
"extract_output_text",
"extract_response_reasoning",
"extract_response_text",
"format_sse",
"is_event_yield",
"is_response_yield",
"normalize_for_json",
"project_response_to_oai_chunk",
"serialize_react_output",
]
+282
View File
@@ -0,0 +1,282 @@
# This file was autogenerated by uv via the following command:
# uv export --frozen --no-dev --no-editable --no-hashes --format requirements.txt --output-file requirements.txt
annotated-types==0.7.0
# via pydantic
anyio==4.10.0
# via
# httpx
# openai
# starlette
asttokens==3.0.0
# via stack-data
backoff==2.2.1
# via langfuse
cadquery==2.5.2
# via
# caddesigner
# simplecadapi
cadquery-ocp==7.7.2
# via cadquery
casadi==3.7.1
# via cadquery
certifi==2025.8.3
# via
# httpcore
# httpx
# requests
charset-normalizer==3.4.2
# via requests
click==8.2.1
# via uvicorn
colorama==0.4.6 ; sys_platform == 'win32'
# via
# click
# ipython
# tqdm
contourpy==1.3.3
# via matplotlib
cycler==0.12.1
# via matplotlib
decorator==5.2.1
# via ipython
distro==1.9.0
# via openai
executing==2.2.0
# via stack-data
ezdxf==1.4.2
# via cadquery
fastapi==0.116.1
# via caddesigner
fonttools==4.59.0
# via
# ezdxf
# matplotlib
googleapis-common-protos==1.73.0
# via opentelemetry-exporter-otlp-proto-http
h11==0.16.0
# via
# httpcore
# uvicorn
httpcore==1.0.9
# via httpx
httpx==0.28.1
# via
# langfuse
# openai
# simplellmfunc
idna==3.10
# via
# anyio
# httpx
# requests
importlib-metadata==8.7.0
# via opentelemetry-api
ipython==9.11.0
# via simplellmfunc
ipython-pygments-lexers==1.1.1
# via ipython
jedi==0.19.2
# via ipython
jiter==0.10.0
# via openai
kiwisolver==1.4.8
# via matplotlib
langfuse==4.0.0
# via simplellmfunc
linkify-it-py==2.1.0
# via markdown-it-py
markdown==3.8.2
# via caddesigner
markdown-it-py==3.0.0
# via
# mdit-py-plugins
# rich
# textual
matplotlib==3.10.5
# via
# pyvista
# vtk
matplotlib-inline==0.1.7
# via ipython
mdit-py-plugins==0.5.0
# via markdown-it-py
mdurl==0.1.2
# via markdown-it-py
multimethod==1.12
# via cadquery
nlopt==2.9.1
# via cadquery
numpy==2.3.2
# via
# caddesigner
# casadi
# contourpy
# ezdxf
# matplotlib
# nlopt
# pyvista
# simplecadapi
openai==2.29.0
# via
# langfuse
# simplellmfunc
opentelemetry-api==1.40.0
# via
# langfuse
# opentelemetry-exporter-otlp-proto-http
# opentelemetry-sdk
# opentelemetry-semantic-conventions
opentelemetry-exporter-otlp-proto-common==1.40.0
# via opentelemetry-exporter-otlp-proto-http
opentelemetry-exporter-otlp-proto-http==1.40.0
# via langfuse
opentelemetry-proto==1.40.0
# via
# opentelemetry-exporter-otlp-proto-common
# opentelemetry-exporter-otlp-proto-http
opentelemetry-sdk==1.40.0
# via
# langfuse
# opentelemetry-exporter-otlp-proto-http
opentelemetry-semantic-conventions==0.61b0
# via opentelemetry-sdk
packaging==25.0
# via
# langfuse
# matplotlib
# pooch
parso==0.8.4
# via jedi
path==17.1.1
# via cadquery
pexpect==4.9.0 ; sys_platform != 'emscripten' and sys_platform != 'win32'
# via ipython
pillow==11.3.0
# via
# matplotlib
# pyvista
platformdirs==4.3.8
# via
# pooch
# textual
pooch==1.8.2
# via pyvista
prompt-toolkit==3.0.51
# via ipython
protobuf==6.33.5
# via
# googleapis-common-protos
# opentelemetry-proto
ptyprocess==0.7.0 ; sys_platform != 'emscripten' and sys_platform != 'win32'
# via pexpect
pure-eval==0.2.3
# via stack-data
pydantic==2.11.7
# via
# fastapi
# langfuse
# openai
# pydantic-settings
# simplellmfunc
pydantic-core==2.33.2
# via pydantic
pydantic-settings==2.10.1
# via simplellmfunc
pygments==2.19.2
# via
# ipython
# ipython-pygments-lexers
# rich
pyparsing==3.2.3
# via
# ezdxf
# matplotlib
python-dateutil==2.9.0.post0
# via matplotlib
python-dotenv==1.1.1
# via
# caddesigner
# pydantic-settings
pyvista==0.46.0
# via caddesigner
redis==6.4.0
# via caddesigner
requests==2.32.4
# via
# caddesigner
# opentelemetry-exporter-otlp-proto-http
# pooch
rich==14.1.0
# via
# caddesigner
# simplecadapi
# simplellmfunc
# textual
scooby==0.10.1
# via pyvista
simplecadapi @ https://files.pythonhosted.org/packages/04/34/5a6a16cd7a533324b73997cbf4094708616eff4eb7aa5496280a79be3444/simplecadapi-2.0.8-py3-none-any.whl
# via caddesigner
simplellmfunc==0.7.2
# via caddesigner
six==1.17.0
# via python-dateutil
sniffio==1.3.1
# via
# anyio
# openai
socksio==1.0.0
# via httpx
stack-data==0.6.3
# via ipython
starlette==0.47.2
# via fastapi
textual==0.89.1
# via simplellmfunc
tqdm==4.67.1
# via openai
traitlets==5.14.3
# via
# ipython
# matplotlib-inline
types-requests==2.32.4.20250611
# via caddesigner
typing-extensions==4.14.1
# via
# anyio
# ezdxf
# fastapi
# openai
# opentelemetry-api
# opentelemetry-exporter-otlp-proto-http
# opentelemetry-sdk
# opentelemetry-semantic-conventions
# pydantic
# pydantic-core
# pyvista
# starlette
# textual
# typing-inspection
typing-inspection==0.4.1
# via
# pydantic
# pydantic-settings
typish==1.9.3
# via cadquery
uc-micro-py==2.0.0
# via linkify-it-py
urllib3==2.5.0
# via
# requests
# types-requests
uvicorn==0.35.0
# via caddesigner
vtk==9.4.2
# via
# caddesigner
# pyvista
wcwidth==0.2.13
# via prompt-toolkit
wrapt==1.17.3
# via langfuse
zipp==3.23.0
# via importlib-metadata
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env sh
set -eu
ROOT_DIR=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
cd "$ROOT_DIR"
if ! command -v uv >/dev/null 2>&1; then
echo "[pre-commit] uv is required to export requirements.txt" >&2
exit 1
fi
echo "[pre-commit] Exporting requirements.txt from uv.lock"
uv export \
--frozen \
--no-dev \
--no-editable \
--no-hashes \
--format requirements.txt \
--output-file requirements.txt \
>/dev/null
if git rev-parse --git-dir >/dev/null 2>&1; then
git add requirements.txt
fi
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env sh
set -eu
ROOT_DIR=$(git rev-parse --show-toplevel)
HOOK_PATH="$ROOT_DIR/.git/hooks/pre-commit"
cat > "$HOOK_PATH" <<'EOF'
#!/usr/bin/env sh
set -eu
ROOT_DIR=$(git rev-parse --show-toplevel)
exec "$ROOT_DIR/scripts/export_requirements.sh"
EOF
chmod +x "$HOOK_PATH"
echo "Installed git pre-commit hook at $HOOK_PATH"
+267
View File
@@ -0,0 +1,267 @@
#!/usr/bin/env python3
"""
CADDesigner API server startup script.
"""
import sys
import os
import argparse
import signal
import shutil
from pathlib import Path
from bootstrap_env import load_project_env
load_project_env()
# Add the project root directory to the Python path.
project_root = Path(__file__).parent
sys.path.insert(0, str(project_root))
default_working_dir = project_root / "workspace"
def resolve_display_host(host: str) -> str:
"""Convert a bind address to a display address better suited for browser access."""
if host in {"0.0.0.0", "::"}:
return "localhost"
return host
def validate_directory(path_str: str) -> Path:
"""Validate and return a valid directory path."""
path = Path(path_str).resolve()
if not path.exists():
try:
path.mkdir(parents=True, exist_ok=True)
print(f"Created working directory: {path}")
except Exception as e:
raise argparse.ArgumentTypeError(f"Unable to create directory {path}: {e}")
elif not path.is_dir():
raise argparse.ArgumentTypeError(f"Path {path} is not a directory")
return path
def prepare_working_directory(working_dir: Path) -> None:
"""Prepare runtime workspace assets inside the active working directory."""
source_skills = project_root / "workspace" / "skills"
target_skills = working_dir / "skills"
if source_skills.exists() and source_skills.resolve() != target_skills.resolve():
shutil.copytree(source_skills, target_skills, dirs_exist_ok=True)
def check_config():
from SimpleLLMFunc.logger import app_log
"""Check the configuration file."""
config_file = project_root / "config" / "provider.json"
template_file = project_root / "config" / "provider_template.json"
if not config_file.exists():
print("Configuration file does not exist")
if template_file.exists():
try:
import shutil
shutil.copy2(template_file, config_file)
app_log("Created configuration file from template")
print(f"Please edit the configuration file: {config_file}")
print("You can modify the configuration later; continuing service startup now...")
except Exception as e:
print(f"Failed to copy configuration template: {e}")
return False
else:
print("Configuration template file does not exist; please check the project structure")
return False
else:
app_log("Configuration file check passed")
return True
def signal_handler(signum, frame):
"""Signal handler for graceful shutdown."""
print(f"\nReceived signal {signum}; shutting down the API server...")
sys.exit(0)
def main():
"""Main function."""
parser = argparse.ArgumentParser(
description="CADDesigner API server launcher",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Usage examples:
# Start with the default configuration
%(prog)s
# Customize host and port
%(prog)s --host 0.0.0.0 --port 8001
# Enable development mode (auto reload)
%(prog)s --reload
# Adjust the number of worker processes
%(prog)s --workers 4
# Start in a specific working directory
%(prog)s --working-dir /path/to/workspace
# Production environment configuration example
%(prog)s --host 0.0.0.0 --port 8000 \\
--workers 4 --log-level info \\
--working-dir /var/lib/caddesigner
API endpoints:
GET / # Server information
GET /health # Health check
GET /v1/models # List available models
POST /v1/chat/completions # Chat completion (OpenAI compatible)
GET /v1/conversations # Conversation management
GET /docs # Swagger API documentation
GET /redoc # ReDoc API documentation
""",
)
# Basic server parameters.
parser.add_argument(
"--host", default="0.0.0.0", help="API server host address (default: 0.0.0.0)"
)
parser.add_argument(
"--port", type=int, default=8000, help="API server port (default: 8000)"
)
parser.add_argument(
"--working-dir",
type=validate_directory,
default=default_working_dir,
help="Working directory path (default: ./workspace)",
)
# Development and debugging parameters.
parser.add_argument(
"--reload", action="store_true", help="Enable development mode (auto reload after file changes)"
)
parser.add_argument(
"--log-level",
choices=["debug", "info", "warning", "error", "critical"],
default="info",
help="Log level (default: info)",
)
parser.add_argument(
"--access-log",
action="store_true",
default=True,
help="Enable access log (default: enabled)",
)
# Performance parameters.
parser.add_argument(
"--workers",
type=int,
default=1,
help="Number of worker processes (default: 1; forced to 1 in reload mode)",
)
parser.add_argument(
"--loop",
choices=["auto", "asyncio", "uvloop"],
default="auto",
help="Event loop type (default: auto)",
)
# Security and limit parameters.
parser.add_argument("--limit-concurrency", type=int, help="Maximum concurrent connection limit")
parser.add_argument("--limit-max-requests", type=int, help="Maximum requests handled per process")
# Debugging parameters.
parser.add_argument("--debug", action="store_true", help="Enable debug mode")
args = parser.parse_args()
# Set signal handlers.
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
# Prepare and switch to the working directory.
original_dir = os.getcwd()
prepare_working_directory(args.working_dir)
os.chdir(args.working_dir)
try:
import uvicorn
from web_interface.server import app
except ImportError as e:
print(f"Import error: {e}")
print(
"Please ensure all dependencies are installed: uv sync (development environment) or pip install -r requirements.txt"
)
sys.exit(1)
try:
display_host = resolve_display_host(args.host)
print("Starting CADDesigner API server...")
print("=" * 60)
print(f"Server address: http://{display_host}:{args.port}")
print(f"API documentation: http://{display_host}:{args.port}/docs")
print(f"ReDoc documentation: http://{display_host}:{args.port}/redoc")
print(f"Health check: http://{display_host}:{args.port}/health")
print(f"Working directory: {args.working_dir}")
if args.reload:
print("Development mode: enabled (auto reload)")
if args.workers > 1 and not args.reload:
print(f"Worker processes: {args.workers}")
if args.debug:
print("Debug mode: enabled")
print("Press Ctrl+C to stop the service")
print("=" * 60)
# Check configuration.
if not check_config():
print("Configuration check failed; please check the configuration file")
sys.exit(1)
# Prepare uvicorn configuration.
uvicorn_config = {
"app": "web_interface.server:app",
"host": args.host,
"port": args.port,
"reload": args.reload,
"log_level": args.log_level,
"access_log": args.access_log,
"workers": 1
if args.reload
else args.workers, # Only one worker can be used in reload mode.
"loop": args.loop,
}
# Add optional parameters.
if args.limit_concurrency:
uvicorn_config["limit_concurrency"] = args.limit_concurrency
if args.limit_max_requests:
uvicorn_config["limit_max_requests"] = args.limit_max_requests
# Start the server.
uvicorn.run(**uvicorn_config)
except KeyboardInterrupt:
print("\nAPI server stopped")
except Exception as e:
print(f"Server startup failed: {e}")
if args.debug:
import traceback
traceback.print_exc()
sys.exit(1)
finally:
# Restore the original working directory.
os.chdir(original_dir)
if __name__ == "__main__":
main()
@@ -0,0 +1,328 @@
#!/usr/bin/env python3
"""
CADDesigner full service startup script.
Starts the API and React Web UI by default.
"""
import argparse
from pathlib import Path
import signal
import urllib.error
import urllib.request
from bootstrap_env import load_project_env
load_project_env()
from SimpleLLMFunc.logger import app_log, push_error
import subprocess
import sys
import time
project_root = Path(__file__).parent
sys.path.insert(0, str(project_root))
default_working_dir = project_root / "workspace"
def validate_directory(path_str: str) -> Path:
"""Validate and return a valid directory path."""
path = Path(path_str).resolve()
if not path.exists():
try:
path.mkdir(parents=True, exist_ok=True)
app_log(f"Created working directory: {path}")
except Exception as exc:
raise argparse.ArgumentTypeError(f"Unable to create directory {path}: {exc}") from exc
elif not path.is_dir():
raise argparse.ArgumentTypeError(f"Path {path} is not a directory")
return path
def resolve_display_host(host: str) -> str:
"""Host address used for log display."""
if host in {"0.0.0.0", "::"}:
return "localhost"
return host
def resolve_local_connect_host(host: str) -> str:
"""Host address used for inter-process connections on the same machine."""
if host in {"0.0.0.0", "::"}:
return "127.0.0.1"
return host
def build_http_url(host: str, port: int) -> str:
return f"http://{host}:{port}"
class ServiceManager:
"""Service manager."""
def __init__(self):
self.processes: list[tuple[str, subprocess.Popen]] = []
self.running = False
signal.signal(signal.SIGINT, self._signal_handler)
signal.signal(signal.SIGTERM, self._signal_handler)
def _signal_handler(self, signum, frame):
"""Signal handler."""
app_log(f"\nReceived signal {signum}; shutting down all services...")
self.stop_all()
sys.exit(0)
def start_api_server(self, args):
"""Start the API server."""
app_log("Starting API server...")
cmd = [
sys.executable,
str(project_root / "start_caddesigner_api.py"),
"--host",
args.api_host,
"--port",
str(args.api_port),
"--working-dir",
str(args.working_dir),
"--log-level",
args.log_level,
]
if args.reload:
cmd.append("--reload")
if args.workers > 1 and not args.reload:
cmd.extend(["--workers", str(args.workers)])
if args.debug:
cmd.append("--debug")
process = subprocess.Popen(cmd)
self.processes.append(("API server", process))
return process
def start_ui_server(self, args):
"""Start React Web UI."""
ui_label = "React Web UI"
app_log(f"Starting {ui_label}...")
internal_api_url = build_http_url(
resolve_local_connect_host(args.api_host),
args.api_port,
)
cmd = [
sys.executable,
str(project_root / "start_caddesigner_ui.py"),
"--host",
args.ui_host,
"--port",
str(args.ui_port),
"--api-url",
internal_api_url,
]
if args.debug:
cmd.append("--debug")
process = subprocess.Popen(cmd)
self.processes.append((ui_label, process))
return process
def wait_for_api_ready(self, args, api_process, timeout_seconds: float = 30.0):
"""Wait for the API service to pass the health check."""
health_url = (
build_http_url(resolve_local_connect_host(args.api_host), args.api_port)
+ "/health"
)
deadline = time.time() + timeout_seconds
while time.time() < deadline:
if api_process.poll() is not None:
push_error("API server process exited early")
return False
try:
with urllib.request.urlopen(health_url, timeout=1.0) as response:
if response.status == 200:
app_log(f"API health check passed: {health_url}")
return True
except (urllib.error.URLError, TimeoutError, OSError):
time.sleep(0.5)
continue
time.sleep(0.5)
push_error(f"API health check timed out: {health_url}")
return False
def start_both_services(self, args):
"""Start all services."""
self.running = True
api_display_url = build_http_url(
resolve_display_host(args.api_host), args.api_port
)
ui_display_url = build_http_url(
resolve_display_host(args.ui_host), args.ui_port
)
ui_label = "React UI"
app_log("Starting CADDesigner full service suite...")
app_log("=" * 60)
api_process = self.start_api_server(args)
app_log(f"API server process started (PID: {api_process.pid})")
app_log("Waiting for API health check to pass...")
if not self.wait_for_api_ready(args, api_process):
return False
ui_process = self.start_ui_server(args)
app_log(f"{ui_label} process started (PID: {ui_process.pid})")
app_log("=" * 60)
app_log(f"API server: {api_display_url}")
app_log(f"{ui_label}: {ui_display_url}")
app_log(f"API documentation: {api_display_url}/docs")
app_log(f"Working directory: {args.working_dir}")
app_log("Web UI implementation: React + Vite")
if args.reload:
app_log("Development mode: enabled (API auto reload; React built-in HMR)")
app_log("Press Ctrl+C to stop all services")
app_log("=" * 60)
try:
while self.running and any(
process.poll() is None for _, process in self.processes
):
time.sleep(1)
except KeyboardInterrupt:
app_log("\nStopping all services...")
finally:
self.stop_all()
return True
def stop_all(self):
"""Stop all processes."""
self.running = False
for name, process in self.processes:
if process.poll() is None:
try:
app_log(f"Stopping {name}...")
process.terminate()
process.wait(timeout=5)
app_log(f"{name} stopped")
except subprocess.TimeoutExpired:
app_log(f"Force terminating {name}...")
process.kill()
except Exception as exc:
push_error(f"Error while stopping {name}: {exc}")
self.processes.clear()
app_log("All services stopped")
def main():
"""Main function."""
parser = argparse.ArgumentParser(
description="CADDesigner full service launcher",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Usage examples:
# Start API + React UI with the default configuration
%(prog)s
# Start with custom ports
%(prog)s --api-port 8001 --ui-port 7861
# Enable development mode
%(prog)s --reload
# Start in a specific working directory
%(prog)s --working-dir /path/to/workspace
Service addresses:
API server: http://localhost:8000
React UI: http://localhost:7860
API docs: http://localhost:8000/docs
""",
)
api_group = parser.add_argument_group("API server configuration")
api_group.add_argument(
"--api-host",
default="0.0.0.0",
help="API server host address (default: 0.0.0.0)",
)
api_group.add_argument(
"--api-port",
type=int,
default=8000,
help="API server port (default: 8000)",
)
api_group.add_argument(
"--workers",
type=int,
default=1,
help="Number of API server worker processes (default: 1)",
)
api_group.add_argument(
"--log-level",
choices=["debug", "info", "warning", "error"],
default="info",
help="API server log level (default: info)",
)
ui_group = parser.add_argument_group("React Web UI configuration")
ui_group.add_argument(
"--ui-host",
default="0.0.0.0",
help="Web UI host address (default: 0.0.0.0)",
)
ui_group.add_argument(
"--ui-port",
type=int,
default=7860,
help="Web UI port (default: 7860)",
)
common_group = parser.add_argument_group("Common configuration")
common_group.add_argument(
"--working-dir",
type=validate_directory,
default=default_working_dir,
help="Working directory path (default: ./workspace)",
)
common_group.add_argument(
"--reload",
action="store_true",
help="Enable development mode (API server auto reload)",
)
common_group.add_argument(
"--debug",
action="store_true",
help="Enable debug mode",
)
args = parser.parse_args()
manager = ServiceManager()
try:
success = manager.start_both_services(args)
if not success:
sys.exit(1)
except KeyboardInterrupt:
app_log("\nUser requested service shutdown")
except Exception as exc:
push_error(f"Service startup failed: {exc}")
if args.debug:
import traceback
push_error(traceback.format_exc())
sys.exit(1)
if __name__ == "__main__":
main()
@@ -0,0 +1,175 @@
#!/usr/bin/env python3
"""
CADDesigner React Web UI startup script.
"""
import argparse
import os
from pathlib import Path
import shutil
import subprocess
import sys
project_root = Path(__file__).parent
frontend_root = project_root / "frontend"
def resolve_browser_host(host: str) -> str:
"""Convert a bind address to a display address better suited for browser access."""
if host in {"0.0.0.0", "::"}:
return "localhost"
return host
def resolve_api_url(api_url: str | None) -> str:
"""Resolve the API address that the React UI should connect to."""
if api_url:
return api_url.rstrip("/")
env_api_url = os.getenv("CADDESIGNER_API_URL")
if env_api_url:
return env_api_url.rstrip("/")
return "http://127.0.0.1:8000"
def resolve_package_manager() -> tuple[str, str]:
"""Prefer pnpm, then fall back to npm."""
pnpm_cmd = shutil.which("pnpm")
if pnpm_cmd:
return pnpm_cmd, "pnpm"
npm_cmd = shutil.which("npm")
if npm_cmd:
return npm_cmd, "npm"
raise RuntimeError("Neither pnpm nor npm was found. Please install a Node.js package manager first.")
def ensure_frontend_ready(debug: bool = False) -> tuple[str, str]:
"""Ensure React frontend dependencies are ready and return package manager information."""
if not frontend_root.is_dir():
raise RuntimeError(f"React frontend directory does not exist: {frontend_root}")
package_manager_cmd, package_manager_name = resolve_package_manager()
if not (frontend_root / "node_modules").exists():
print(
f"React frontend dependencies are not installed yet; running {package_manager_name} install ..."
)
install_result = subprocess.run(
[package_manager_cmd, "install"],
cwd=frontend_root,
)
if install_result.returncode != 0:
raise RuntimeError(
f"{package_manager_name} install failed; unable to start React UI"
)
if debug:
print("React frontend dependencies installed successfully")
return package_manager_cmd, package_manager_name
def launch_react_app(args) -> None:
"""Start the standalone React Web UI (Vite)."""
package_manager_cmd, package_manager_name = ensure_frontend_ready(debug=args.debug)
api_url = resolve_api_url(args.api_url)
display_host = resolve_browser_host(args.host)
env = os.environ.copy()
env["VITE_API_BASE_URL"] = api_url
env["CADDESIGNER_API_PROXY_TARGET"] = api_url
print("Starting CADDesigner React Web UI...")
print("=" * 60)
print(f"React UI address: http://{display_host}:{args.port}")
print(f"API proxy target: {api_url}")
print(f"Frontend package manager: {package_manager_name}")
print(f"Frontend directory: {frontend_root}")
if args.debug:
print("Debug mode: enabled")
print("Press Ctrl+C to stop the service")
print("=" * 60)
extra_args_separator = ["--"] if package_manager_name == "npm" else []
cmd = [
package_manager_cmd,
"run",
"dev",
*extra_args_separator,
"--host",
args.host,
"--port",
str(args.port),
"--strictPort",
]
os.chdir(frontend_root)
os.execvpe(package_manager_cmd, cmd, env)
def main():
"""Main function."""
parser = argparse.ArgumentParser(
description="CADDesigner React Web UI launcher",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Usage examples:
# Start React UI with the default configuration
%(prog)s
# Customize the listening address
%(prog)s --host 0.0.0.0 --port 7861
# Connect to a remote API server; React forwards requests through the proxy
%(prog)s --api-url http://192.168.1.100:8000
Environment variable:
CADDESIGNER_API_URL API server address (default: http://localhost:8000)
""",
)
parser.add_argument(
"--host",
default="0.0.0.0",
help="Web UI server host address (default: 0.0.0.0)",
)
parser.add_argument(
"--port",
type=int,
default=7860,
help="Web UI server port (default: 7860)",
)
parser.add_argument(
"--api-url",
default=None,
help="CADDesigner API server address (default: http://localhost:8000)",
)
parser.add_argument(
"--debug",
action="store_true",
help="Enable debug mode",
)
args = parser.parse_args()
try:
launch_react_app(args)
except KeyboardInterrupt:
print("\nUser canceled; exiting...")
except Exception as exc:
print(f"Startup failed: {exc}")
if args.debug:
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == "__main__":
main()
+52
View File
@@ -0,0 +1,52 @@
# Context System Tests
This directory contains tests for the Context system. The tests verify Redis-backed history storage, file-system persistence, message management, context management, serialization, concurrency behavior, and error handling.
## Files
- `test_context_system.py`: complete pytest suite.
- `test_context_basic.py`: basic validation script that does not require pytest.
- `test_context_simple.py`: simplified quick-check script.
- `run_context_tests.py`: helper script for dependency checks, Redis checks, test execution, and reporting.
## Run
Start Redis first:
```bash
redis-server
```
Run the quick validation:
```bash
python test/test_context_simple.py
```
Run the full pytest suite:
```bash
pytest test/test_context_system.py -v
```
Or use the helper runner:
```bash
python test/run_context_tests.py
```
## Coverage
- Redis connection and message storage
- File persistence and restore
- Message retrieval, search, and count limits
- Context creation, lookup, batch save, and cleanup
- Serialization and metadata handling
- Concurrent access safety
- Error handling and automatic summaries
## Notes
- Tests use Redis DB 13-15 to avoid affecting production data.
- File-system tests use temporary directories and clean up after themselves.
- See [TEST_RESULTS.md](./TEST_RESULTS.md) for the historical result summary.
@@ -0,0 +1,40 @@
# Context System Test Results
## Overview
The Context system tests verify that conversation history can be stored in Redis and persisted to the file system. The historical test run recorded here passed all core checks.
## Environment
- Operating system: macOS 24.5.0
- Python: 3.12.10
- Redis: latest available version at test time
- Test database: Redis DB 12
- Test directory: temporary directory with automatic cleanup
## Passed Checks
| Test Item | Status | Notes |
|---|---|---|
| Redis connection | Passed | Redis service was reachable |
| Basic message storage | Passed | Messages were stored and retrieved successfully |
| File persistence | Passed | Data was persisted to the file system |
| Redis data validation | Passed | Redis data format and contents were valid |
| Metadata management | Passed | Metadata updates and retrieval worked correctly |
| Message search | Passed | Keyword search returned expected results |
## Summary
The tests confirmed that the Context system can:
- Store conversation history in Redis.
- Persist data to the file system.
- Provide message management, metadata management, and message search.
- Serialize data in a consistent JSON structure.
## Related Files
- `test_context_simple.py`: simplified quick validation.
- `test_context_basic.py`: basic functional tests.
- `test_context_system.py`: complete pytest suite.
- `run_context_tests.py`: test runner helper.
+1
View File
@@ -0,0 +1 @@
# Test package for CADDesigner.
@@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""
Context system demo script.
Shows practical Context system usage scenarios:
1. Create a conversation context
2. Add messages
3. Persist to file
4. Restore from file
5. Search historical messages
"""
import os
import sys
import asyncio
import tempfile
import shutil
from datetime import datetime
# Add the project root directory to the Python path.
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, project_root)
from context.context_manager import ContextManager
from context.context import RedisFileContextBackend
from context.schemas import Message
async def demo_context_system():
"""Demonstrate the complete Context system functionality."""
print("=" * 60)
print("Context System Feature Demo")
print("=" * 60)
# Create a temporary directory.
demo_dir = tempfile.mkdtemp(prefix="demo_context_")
try:
# 1. Create a context manager.
print("\n1. Creating context manager...")
manager = ContextManager(backend_class=RedisFileContextBackend)
# 2. Create a conversation context.
print("\n2. Creating conversation context...")
context_id = "demo_conversation_001"
context = manager.create_context(
context_id=context_id,
max_history_length=10
)
print(f"✓ Created context: {context_id}")
# 3. Simulate a conversation.
print("\n3. Simulating conversation...")
conversation = [
("user", "Hello, I want to learn about Python programming"),
("assistant", "Hello! Python is a very popular programming language. What features are you interested in?"),
("user", "What are Python's advantages?"),
("assistant", "Python's main advantages include:\n1. Concise and readable syntax\n2. Rich libraries and frameworks\n3. Cross-platform support\n4. Beginner friendliness"),
("user", "I want to learn machine learning. Is Python suitable?"),
("assistant", "Absolutely! Python is very popular in machine learning and has many excellent libraries such as TensorFlow, PyTorch, and scikit-learn."),
("user", "Thanks for the introduction"),
("assistant", "You're welcome! If you have any Python or machine learning questions, feel free to ask.")
]
# Add conversation messages.
for role, content in conversation:
success = await manager.add_message(
context_id=context_id,
role=role,
content=content
)
if success:
print(f"✓ Added {role} message: {content[:30]}...")
else:
print(f"✗ Failed to add {role} message")
# 4. View conversation history.
print("\n4. Viewing conversation history...")
history = manager.get_history(context_id)
print(f"✓ Conversation history contains {len(history)} messages")
for i, message in enumerate(history[-3:], 1): # Show the last 3 messages.
print(f" {i}. {message.role}: {message.content[:50]}...")
# 5. Search historical messages.
print("\n5. Searching historical messages...")
search_results = context.search_messages("Python", limit=5)
print(f"✓ Found {len(search_results)} messages containing 'Python'")
for i, message in enumerate(search_results, 1):
print(f" {i}. {message.role}: {message.content[:50]}...")
# 6. Persist to file.
print("\n6. Persisting to file...")
success = await context.persist()
if success:
print("✓ Successfully persisted to the file system")
# Check file contents.
file_path = context.file_path
if os.path.exists(file_path):
file_size = os.path.getsize(file_path)
print(f"✓ File size: {file_size} bytes")
else:
print("✗ Persistence failed")
# 7. Verify Redis storage.
print("\n7. Verifying Redis storage...")
message_count = context.get_message_count()
metadata = context.get_metadata()
print(f"✓ Redis stores {message_count} messages")
print(f"✓ Metadata contains {len(metadata)} fields")
# 8. Simulate system restart (restore from file).
print("\n8. Simulating system restart...")
# Create a new context manager to simulate a restart.
new_manager = ContextManager(backend_class=RedisFileContextBackend)
new_context = new_manager.get_context(context_id)
if new_context:
restored_history = new_context.retrieve_messages()
print(f"✓ Successfully restored conversation history with {len(restored_history)} messages")
# Verify restored data.
if len(restored_history) == len(history):
print("✓ Data integrity verification passed")
else:
print("✗ Data integrity verification failed")
else:
print("✗ Failed to restore conversation history")
# 9. Display system statistics.
print("\n9. System statistics...")
contexts = manager.list_contexts()
print(f"✓ Current system has {len(contexts)} contexts")
for ctx_info in contexts:
print(f" - {ctx_info['context_id']}: {ctx_info.get('total_messages', 0)} messages")
print("\n" + "=" * 60)
print("Demo complete!")
print("=" * 60)
print("\n🎉 Context system feature verification succeeded:")
print(" ✓ Message storage and retrieval work correctly")
print(" ✓ File system persistence works correctly")
print(" ✓ Message search works correctly")
print(" ✓ Data recovery after system restart works correctly")
print(" ✓ Redis storage works correctly")
print(f"\n📁 Demo file location: {demo_dir}")
print("💡 Inspect the generated JSON file to understand the data format")
except Exception as e:
print(f"❌ Error during demo: {e}")
import traceback
traceback.print_exc()
finally:
# Clean up demo files.
if os.path.exists(demo_dir):
shutil.rmtree(demo_dir)
if __name__ == "__main__":
asyncio.run(demo_context_system())
@@ -0,0 +1,238 @@
#!/usr/bin/env python3
"""
Context system test runner script.
Features:
1. Check whether the Redis service is available
2. Run all Context system tests
3. Generate a test report
"""
import os
import sys
import subprocess
import time
import redis
from typing import Optional, Tuple
# Add the project root directory to the Python path.
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, project_root)
def check_redis_connection(host: str = "localhost", port: int = 6379, timeout: int = 5) -> Tuple[bool, str]:
"""
Check whether the Redis connection is available.
Args:
host: Redis host address
port: Redis port
timeout: Connection timeout in seconds
Returns:
(whether the connection succeeded, error message)
"""
try:
r = redis.Redis(host=host, port=port, socket_connect_timeout=timeout, decode_responses=True)
r.ping()
r.close()
return True, "Redis connection is healthy"
except redis.ConnectionError as e:
return False, f"Redis connection failed: {e}"
except Exception as e:
return False, f"Redis check exception: {e}"
def start_redis_server() -> bool:
"""
Try to start the Redis server.
Returns:
Whether startup succeeded.
"""
try:
# Check whether Redis is already running.
if check_redis_connection()[0]:
print("✓ Redis service is already running")
return True
# Try to start Redis.
print("Trying to start the Redis service...")
# Try to start Redis on macOS.
if sys.platform == "darwin":
try:
# Start Redis with brew.
subprocess.run(["brew", "services", "start", "redis"],
check=True, capture_output=True, timeout=10)
time.sleep(2) # Wait for the service to start.
if check_redis_connection()[0]:
print("✓ Redis service started successfully")
return True
except subprocess.CalledProcessError:
pass
try:
# Start Redis directly.
subprocess.run(["redis-server", "--daemonize", "yes"],
check=True, capture_output=True, timeout=10)
time.sleep(2)
if check_redis_connection()[0]:
print("✓ Redis service started successfully")
return True
except subprocess.CalledProcessError:
pass
# Try to start Redis on Linux.
elif sys.platform.startswith("linux"):
try:
subprocess.run(["sudo", "systemctl", "start", "redis"],
check=True, capture_output=True, timeout=10)
time.sleep(2)
if check_redis_connection()[0]:
print("✓ Redis service started successfully")
return True
except subprocess.CalledProcessError:
pass
print("⚠️ Unable to start Redis automatically; please start it manually")
return False
except Exception as e:
print(f"⚠️ Error while starting Redis service: {e}")
return False
def install_test_dependencies() -> bool:
"""
Install test dependencies.
Returns:
Whether installation succeeded.
"""
try:
print("Checking test dependencies...")
# Check pytest.
try:
import pytest
print("✓ pytest is installed")
except ImportError:
print("Installing pytest...")
subprocess.run([sys.executable, "-m", "pip", "install", "pytest", "pytest-asyncio"],
check=True)
print("✓ pytest installation complete")
# Check redis.
try:
import redis
print("✓ redis is installed")
except ImportError:
print("Installing redis...")
subprocess.run([sys.executable, "-m", "pip", "install", "redis"],
check=True)
print("✓ redis installation complete")
return True
except Exception as e:
print(f"⚠️ Error while installing test dependencies: {e}")
return False
def run_tests() -> bool:
"""
Run Context system tests.
Returns:
Whether all tests passed.
"""
try:
print("\n" + "="*60)
print("Starting Context system tests")
print("="*60)
# Get the test file path.
test_file = os.path.join(project_root, "test", "test_context_system.py")
if not os.path.exists(test_file):
print(f"❌ Test file does not exist: {test_file}")
return False
# Run tests.
cmd = [
sys.executable, "-m", "pytest",
test_file,
"-v", # Verbose output.
"-s", # Show print output.
"--tb=short", # Short traceback.
"--color=yes" # Colored output.
]
print(f"Executing command: {' '.join(cmd)}")
print("-" * 60)
result = subprocess.run(cmd, cwd=project_root)
print("-" * 60)
if result.returncode == 0:
app_log("✅ All tests passed!")
return True
else:
print("❌ Some tests failed")
return False
except Exception as e:
print(f"❌ Error while running tests: {e}")
return False
def main():
"""Main function."""
print("Context System Test Runner")
print("=" * 40)
# 1. Install dependencies.
if not install_test_dependencies():
print("❌ Dependency installation failed; exiting")
return 1
# 2. Check Redis connection.
print("\nChecking Redis service...")
redis_ok, redis_msg = check_redis_connection()
if not redis_ok:
print(f"⚠️ {redis_msg}")
print("Trying to start Redis service...")
if not start_redis_server():
print("❌ Unable to start Redis service")
print("Please start Redis manually and rerun the tests")
print("Example startup commands:")
print(" macOS: brew services start redis")
print(" Linux: sudo systemctl start redis")
print(" Windows: redis-server")
return 1
else:
print(f"{redis_msg}")
# 3. Run tests.
success = run_tests()
if success:
print("\n🎉 Context system tests complete!")
print("The test results verify:")
print(" ✓ Redis storage works correctly")
print(" ✓ File system persistence works correctly")
print(" ✓ Message management works correctly")
print(" ✓ Context manager works correctly")
print(" ✓ Data serialization works correctly")
print(" ✓ Concurrent access is safe")
return 0
else:
print("\n❌ Context system tests failed")
return 1
if __name__ == "__main__":
exit(main())
@@ -0,0 +1,55 @@
import os
import sys
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
from agent.CADAgent import CADAgent
from tools import create_builtin_file_tools
from tools.code_tools import create_codegen_subagent_tools
def _tool_name(tool):
if hasattr(tool, "_tool"):
return tool._tool.name
return getattr(tool, "name", getattr(tool, "__name__", None))
def test_create_builtin_file_tools_exposes_simplellmfunc_file_tool_names(tmp_path):
tools = create_builtin_file_tools(tmp_path)
assert [tool.name for tool in tools] == [
"read_file",
"grep",
"sed",
"echo_into",
]
def test_codegen_subagent_tools_include_command_and_builtin_file_tools(tmp_path):
tools = create_codegen_subagent_tools(tmp_path)
assert [_tool_name(tool) for tool in tools] == [
"execute_command",
"sketch_pad_operations",
"read_file",
"grep",
"sed",
"echo_into",
]
def test_main_cad_agent_toolkit_exposes_codegen_specialist_not_low_level_file_tools():
agent = object.__new__(CADAgent)
toolkit = CADAgent.get_toolkit(agent)
names = [_tool_name(tool) for tool in toolkit]
assert "cad_code_generator" in names
assert "execute_command" in names
assert "read_file" not in names
assert "grep" not in names
assert "sed" not in names
assert "echo_into" not in names
@@ -0,0 +1,19 @@
from agent.CADAgent import CADAgent
def test_cadagent_prompt_includes_artifact_tags() -> None:
prompt = CADAgent.chat_impl.__doc__ or ""
assert "<|code_file|>" in prompt
assert "<|output_file|>" in prompt
assert "Repeat the final `<|code_file|>` and `<|output_file|>` tags" in prompt
def test_cadagent_prompt_prefers_step_for_visual_feedback() -> None:
prompt = CADAgent.chat_impl.__doc__ or ""
assert (
"prefer passing the exported `.step`/`.stp` file into `get_visual_feedback`"
in prompt
)
assert "visual feedback should use the STEP/STP file" in prompt
@@ -0,0 +1,200 @@
import os
import sys
from datetime import datetime, timezone
from typing import Any, cast
import pytest
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
WORKSPACE_ROOT = os.path.join(PROJECT_ROOT, "workspace")
from SimpleLLMFunc.hooks.events import (
ReActEventType,
ToolCallEndEvent,
ToolCallErrorEvent,
ToolCallStartEvent,
)
from SimpleLLMFunc.hooks.stream import EventYield, ResponseYield
import tools.code_tools as code_tools_module
from tools.code_tools import cad_code_generator
class _FakeEmitter:
def __init__(self):
self.events: list[tuple[str, dict]] = []
async def emit(self, event_name: str, data):
self.events.append((event_name, data))
@pytest.mark.asyncio
async def test_cad_code_generator_bridges_nested_specialist_events(monkeypatch):
monkeypatch.chdir(WORKSPACE_ROOT)
async def fake_specialist(**kwargs):
assert "Current working directory:" in kwargs["message"]
assert "Skill root: use the preferred skill root below." in kwargs["message"]
assert "validation_command: uv run python part/model.py" in kwargs["message"]
assert "references/docs/api/README.md" in kwargs["message"]
yield EventYield(
event=ToolCallStartEvent(
event_type=ReActEventType.TOOL_CALL_START,
timestamp=datetime.now(timezone.utc),
trace_id="trace-1",
func_name="cad_code_generator_specialist",
iteration=0,
tool_name="read_file",
tool_call_id="nested-1",
arguments={"file_path": "part/model.py"},
tool_call=cast(
Any,
{
"id": "nested-1",
"type": "function",
"function": {"name": "read_file", "arguments": "{}"},
},
),
)
)
yield EventYield(
event=ToolCallEndEvent(
event_type=ReActEventType.TOOL_CALL_END,
timestamp=datetime.now(timezone.utc),
trace_id="trace-1",
func_name="cad_code_generator_specialist",
iteration=0,
tool_name="read_file",
tool_call_id="nested-1",
arguments={"file_path": "part/model.py"},
result="print('old')",
execution_time=0.05,
success=True,
)
)
yield EventYield(
event=ToolCallErrorEvent(
event_type=ReActEventType.TOOL_CALL_ERROR,
timestamp=datetime.now(timezone.utc),
trace_id="trace-1",
func_name="cad_code_generator_specialist",
iteration=0,
tool_name="execute_command",
tool_call_id="nested-2",
arguments={"command": "python model.py"},
error=RuntimeError("boom"),
error_message="boom",
error_type="RuntimeError",
execution_time=0.12,
)
)
yield ResponseYield(response="STATUS: SUCCESS\nSUMMARY: ok", messages=[])
monkeypatch.setattr(
code_tools_module,
"cad_code_generator_specialist",
fake_specialist,
)
monkeypatch.setattr(
code_tools_module, "_read_latest_code", lambda path: "print('ok')\n"
)
emitter = _FakeEmitter()
result = await cad_code_generator(
task="Create a cube as a new file. This is a create-new-file task.",
target_file_path="part/model.py",
event_emitter=emitter,
)
event_names = [event_name for event_name, _ in emitter.events]
assert event_names == [
"subagent_status",
"subagent_tool_start",
"subagent_tool_end",
"subagent_tool_error",
"subagent_response",
"subagent_status",
]
first_status_payload = emitter.events[0][1]
assert first_status_payload["subagent_label"] == "CAD Code Specialist"
assert first_status_payload["validation_command"].startswith(
"uv run python part/model.py"
)
assert "ls part/*.stl" in first_status_payload["validation_command"]
assert (
"(ls part/*.step || ls part/*.stp)"
in first_status_payload["validation_command"]
)
assert "STATUS: SUCCESS" in result
assert "Latest code" in result
response_payload = emitter.events[4][1]
assert response_payload["delta_text"] == "STATUS: SUCCESS\nSUMMARY: ok"
@pytest.mark.asyncio
async def test_cad_code_generator_retries_when_first_attempt_does_not_produce_code(
monkeypatch,
):
monkeypatch.chdir(WORKSPACE_ROOT)
calls = {"count": 0}
async def fake_specialist(**kwargs):
calls["count"] += 1
if calls["count"] == 1:
assert kwargs["history"] == []
assert "Current working directory:" in kwargs["message"]
assert (
"validation_command: uv run python part/model.py" in kwargs["message"]
)
assert "references/docs/api/README.md" in kwargs["message"]
yield ResponseYield(
response=(
"STATUS: SUCCESS\n"
"SUMMARY: planned but no file write\n"
"```python\n"
"print('draft only')\n"
"```"
),
messages=[],
)
return
assert "must write the required Python script directly" in kwargs["message"]
assert "part/model.py" in kwargs["message"]
assert kwargs["history"][0]["role"] == "user"
assert "target_file: part/model.py" in kwargs["history"][0]["content"]
assert kwargs["history"][1]["role"] == "assistant"
assert "planned but no file write" in kwargs["history"][1]["content"]
yield ResponseYield(
response="STATUS: SUCCESS\nCODE_WRITTEN: YES\nSUMMARY: wrote code",
messages=[],
)
monkeypatch.setattr(
code_tools_module,
"cad_code_generator_specialist",
fake_specialist,
)
code_reads = {"count": 0}
def fake_read_latest_code(path):
code_reads["count"] += 1
if code_reads["count"] == 1:
return None
return "print('ok')\n"
monkeypatch.setattr(code_tools_module, "_read_latest_code", fake_read_latest_code)
result = await cad_code_generator(
task="Create a cube as a new file. This is a create-new-file task.",
target_file_path="part/model.py",
event_emitter=None,
)
assert calls["count"] == 2
assert "CODE_WRITTEN: YES" in result
assert "Latest code" in result
@@ -0,0 +1,74 @@
import os
import subprocess
import sys
from types import SimpleNamespace
import pytest
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
import tools.command_tools as command_tools_module
from tools.command_tools import EXECUTE_COMMAND_TIMEOUT_SECONDS, execute_command
@pytest.mark.asyncio
async def test_execute_command_uses_600_second_timeout(monkeypatch):
captured = {}
async def fake_to_thread(func, *args, **kwargs):
captured["func"] = func
captured["args"] = args
captured["kwargs"] = kwargs
return SimpleNamespace(returncode=0, stdout="ok\n", stderr="")
monkeypatch.setattr(command_tools_module.asyncio, "to_thread", fake_to_thread)
result = await execute_command("uv run python demo.py")
assert result == "ok"
assert captured["args"][0] == "uv run python demo.py"
assert captured["kwargs"]["timeout"] == EXECUTE_COMMAND_TIMEOUT_SECONDS == 600
@pytest.mark.asyncio
async def test_execute_command_returns_english_failure_message(monkeypatch):
async def fake_to_thread(func, *args, **kwargs):
return SimpleNamespace(returncode=1, stdout="trace line\n", stderr="boom\n")
monkeypatch.setattr(command_tools_module.asyncio, "to_thread", fake_to_thread)
result = await execute_command("uv run python broken.py")
assert "Command failed with exit code 1." in result
assert "STDOUT:\ntrace line" in result
assert "STDERR:\nboom" in result
assert "Timeout may be caused by the program waiting for input" not in result
@pytest.mark.asyncio
async def test_execute_command_returns_english_timeout_message(monkeypatch):
async def fake_to_thread(func, *args, **kwargs):
raise subprocess.TimeoutExpired(
cmd="uv run python slow.py",
timeout=EXECUTE_COMMAND_TIMEOUT_SECONDS,
output="still running\n",
stderr="waiting\n",
)
monkeypatch.setattr(command_tools_module.asyncio, "to_thread", fake_to_thread)
result = await execute_command("uv run python slow.py")
assert (
f"Command timed out after {EXECUTE_COMMAND_TIMEOUT_SECONDS} seconds." in result
)
assert (
"The process may be stuck, waiting for input, or simply taking too long."
in result
)
assert "Partial STDOUT:\nstill running" in result
assert "Partial STDERR:\nwaiting" in result
@@ -0,0 +1,450 @@
#!/usr/bin/env python3
"""
Context system basic feature validation script.
Validates core features directly without relying on pytest:
1. Redis storage
2. File persistence
3. Message management
4. Context manager
"""
import os
import sys
import json
import tempfile
import shutil
import asyncio
import redis
from typing import Dict, List, Optional, Any
from datetime import datetime, timedelta
# Add the project root directory to the Python path.
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, project_root)
from context.context_manager import ContextManager
from context.context import RedisFileContextBackend
from context.schemas import Message
from config.config import get_config
class ContextSystemTester:
"""Context system tester."""
def __init__(self):
"""Initialize the tester."""
self.test_context_dir = tempfile.mkdtemp(prefix="test_context_")
self.test_redis_db = 13 # Use a dedicated test database.
# Point the configuration to the test directory.
config = get_config()
config.CONTEXT_DIR = self.test_context_dir
# Create a Redis connection.
self.redis_client = redis.Redis(db=self.test_redis_db, decode_responses=True)
self.redis_client.flushdb()
self.test_results = []
def cleanup(self):
"""Clean up the test environment."""
self.redis_client.flushdb()
self.redis_client.close()
if os.path.exists(self.test_context_dir):
shutil.rmtree(self.test_context_dir)
def log_test(self, test_name: str, success: bool, message: str = ""):
"""Record a test result."""
status = "✓ PASS" if success else "✗ FAIL"
print(f"{status} {test_name}: {message}")
self.test_results.append({
"test": test_name,
"success": success,
"message": message
})
def test_redis_connection(self) -> bool:
"""Test the Redis connection."""
try:
assert self.redis_client.ping()
self.log_test("Redis connection", True, "Redis service is healthy")
return True
except Exception as e:
self.log_test("Redis connection", False, f"Redis connection failed: {e}")
return False
async def test_message_storage(self) -> bool:
"""Test message storage functionality."""
try:
context_id = "test_message_storage"
backend = RedisFileContextBackend(
context_id=context_id,
redis_host="localhost",
redis_port=6379,
redis_db=self.test_redis_db,
file_path=os.path.join(self.test_context_dir, f"ctx_{context_id}.json")
)
# Create test messages.
messages = [
Message(role="system", content="You are a helpful assistant"),
Message(role="user", content="Hello"),
Message(role="assistant", content="Hello! How can I help you?")
]
# Store messages.
for message in messages:
await backend.store_message(message)
# Verify message count.
assert backend.get_message_count() == len(messages)
# Retrieve messages.
retrieved_messages = backend.retrieve_messages()
assert len(retrieved_messages) == len(messages)
# Verify message content.
for i, (original, retrieved) in enumerate(zip(messages, retrieved_messages)):
assert original.role == retrieved.role
assert original.content == retrieved.content
self.log_test("Message storage", True, f"Successfully stored and retrieved {len(messages)} messages")
return True
except Exception as e:
self.log_test("Message storage", False, f"Message storage test failed: {e}")
return False
async def test_file_persistence(self) -> bool:
"""Test file persistence functionality."""
try:
context_id = "test_file_persistence"
file_path = os.path.join(self.test_context_dir, f"ctx_{context_id}.json")
backend = RedisFileContextBackend(
context_id=context_id,
redis_host="localhost",
redis_port=6379,
redis_db=self.test_redis_db,
file_path=file_path
)
# Add messages.
messages = [
Message(role="user", content="Persistence test message 1"),
Message(role="assistant", content="Persistence test reply 1"),
Message(role="user", content="Persistence test message 2")
]
for message in messages:
await backend.store_message(message)
# Persist to file.
success = await backend.persist()
assert success
assert os.path.exists(file_path)
# Verify file contents.
with open(file_path, 'r', encoding='utf-8') as f:
data = json.load(f)
assert data['context_id'] == context_id
assert len(data['messages']) == len(messages)
self.log_test("File persistence", True, "Successfully persisted to the file system")
return True
except Exception as e:
self.log_test("File persistence", False, f"File persistence test failed: {e}")
return False
async def test_file_restoration(self) -> bool:
"""Test file restoration functionality."""
try:
context_id = "test_file_restoration"
file_path = os.path.join(self.test_context_dir, f"ctx_{context_id}.json")
# Create the first backend and add data.
backend1 = RedisFileContextBackend(
context_id=context_id,
redis_host="localhost",
redis_port=6379,
redis_db=self.test_redis_db,
file_path=file_path
)
messages = [
Message(role="user", content="Restoration test message 1"),
Message(role="assistant", content="Restoration test reply 1"),
Message(role="user", content="Restoration test message 2")
]
for message in messages:
await backend1.store_message(message)
# Persist data.
await backend1.persist()
# Clear Redis data to simulate a restart.
self.redis_client.flushdb()
# Create a new backend instance to simulate restart.
backend2 = RedisFileContextBackend(
context_id=context_id,
redis_host="localhost",
redis_port=6379,
redis_db=self.test_redis_db,
file_path=file_path
)
# Restore from file.
success = await backend2.restore()
assert success
# Verify restored data.
restored_messages = backend2.retrieve_messages()
assert len(restored_messages) == len(messages)
for i, (original, restored) in enumerate(zip(messages, restored_messages)):
assert original.role == restored.role
assert original.content == restored.content
self.log_test("File restoration", True, "Successfully restored data from file")
return True
except Exception as e:
self.log_test("File restoration", False, f"File restoration test failed: {e}")
return False
async def test_context_manager(self) -> bool:
"""Test the context manager."""
try:
manager = ContextManager(backend_class=RedisFileContextBackend)
# Create a context.
context_id = "test_manager_context"
context = manager.create_context(context_id=context_id)
assert context is not None
assert context.context_id == context_id
# Add a message through the convenience interface.
success = await manager.add_message(
context_id=context_id,
role="user",
content="Message added through the manager"
)
assert success
# Get history.
history = manager.get_history(context_id)
assert len(history) == 1
assert history[0].role == "user"
assert history[0].content == "Message added through the manager"
# Test retrieving the context.
retrieved_context = manager.get_context(context_id)
assert retrieved_context is not None
assert retrieved_context.context_id == context_id
self.log_test("Context manager", True, "Context manager works correctly")
return True
except Exception as e:
self.log_test("Context manager", False, f"Context manager test failed: {e}")
return False
async def test_redis_persistence_verification(self) -> bool:
"""Verify data persistence in Redis."""
try:
context_id = "test_redis_verification"
backend = RedisFileContextBackend(
context_id=context_id,
redis_host="localhost",
redis_port=6379,
redis_db=self.test_redis_db,
file_path=os.path.join(self.test_context_dir, f"ctx_{context_id}.json")
)
# Add messages.
messages = [
Message(role="user", content="Redis verification message 1"),
Message(role="assistant", content="Redis verification reply 1"),
Message(role="user", content="Redis verification message 2")
]
for message in messages:
await backend.store_message(message)
# Verify data in Redis.
messages_key = f"context:{context_id}:messages"
metadata_key = f"context:{context_id}:metadata"
# Check message data.
redis_messages = self.redis_client.lrange(messages_key, 0, -1)
assert len(redis_messages) == 3
# Check metadata.
redis_metadata = self.redis_client.get(metadata_key)
assert redis_metadata is not None
# Verify message content.
for i, message_json in enumerate(redis_messages):
message_data = json.loads(message_json)
assert message_data['role'] == messages[i].role
assert message_data['content'] == messages[i].content
self.log_test("Redis persistence verification", True, "Redis data storage works correctly")
return True
except Exception as e:
self.log_test("Redis persistence verification", False, f"Redis persistence verification failed: {e}")
return False
async def test_concurrent_access(self) -> bool:
"""Test concurrent access."""
try:
import threading
import time
context_id = "test_concurrent"
backend = RedisFileContextBackend(
context_id=context_id,
redis_host="localhost",
redis_port=6379,
redis_db=self.test_redis_db,
file_path=os.path.join(self.test_context_dir, f"ctx_{context_id}.json")
)
# Add messages concurrently.
def add_messages(thread_id: int, count: int):
for i in range(count):
message = Message(role="user", content=f"Thread {thread_id} message {i}")
# Use a new event loop.
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(backend.store_message(message))
finally:
loop.close()
time.sleep(0.01)
# Create multiple threads.
threads = []
for i in range(3):
thread = threading.Thread(target=add_messages, args=(i, 5))
threads.append(thread)
thread.start()
# Wait for all threads to finish.
for thread in threads:
thread.join()
# Verify that all messages were added.
messages = backend.retrieve_messages()
assert len(messages) == 15 # 3 threads * 5 messages.
self.log_test("Concurrent access", True, "Concurrent access is safe")
return True
except Exception as e:
self.log_test("Concurrent access", False, f"Concurrent access test failed: {e}")
return False
async def run_all_tests(self) -> bool:
"""Run all tests."""
print("=" * 60)
print("Starting Context system tests")
print("=" * 60)
tests = [
("Redis connection", self.test_redis_connection),
("Message storage", self.test_message_storage),
("File persistence", self.test_file_persistence),
("File restoration", self.test_file_restoration),
("Context manager", self.test_context_manager),
("Redis persistence verification", self.test_redis_persistence_verification),
("Concurrent access", self.test_concurrent_access),
]
all_passed = True
for test_name, test_func in tests:
print(f"\nRunning test: {test_name}")
print("-" * 40)
try:
if asyncio.iscoroutinefunction(test_func):
result = await test_func()
else:
result = test_func()
if not result:
all_passed = False
except Exception as e:
self.log_test(test_name, False, f"Test exception: {e}")
all_passed = False
# Output test summary.
print("\n" + "=" * 60)
print("Test Result Summary")
print("=" * 60)
passed_count = sum(1 for result in self.test_results if result["success"])
total_count = len(self.test_results)
for result in self.test_results:
status = "" if result["success"] else ""
print(f"{status} {result['test']}: {result['message']}")
print(f"\nTotal: {passed_count}/{total_count} tests passed")
if all_passed:
print("\n🎉 All tests passed!")
print("Context system feature verification succeeded:")
print(" ✓ Redis storage works correctly")
print(" ✓ File system persistence works correctly")
print(" ✓ Message management works correctly")
print(" ✓ Context manager works correctly")
print(" ✓ Concurrent access is safe")
else:
print("\n❌ Some tests failed")
return all_passed
async def main():
"""Main function."""
print("Context System Basic Feature Validation")
print("=" * 40)
# Check Redis connection.
try:
r = redis.Redis(host="localhost", port=6379, decode_responses=True)
r.ping()
r.close()
print("✓ Redis service is available")
except Exception as e:
print(f"❌ Redis service is unavailable: {e}")
print("Please ensure the Redis service is running")
print("Startup commands:")
print(" macOS: brew services start redis")
print(" Linux: sudo systemctl start redis")
print(" Windows: redis-server")
return 1
# Run tests.
tester = ContextSystemTester()
try:
success = await tester.run_all_tests()
return 0 if success else 1
finally:
tester.cleanup()
if __name__ == "__main__":
exit(asyncio.run(main()))
@@ -0,0 +1,368 @@
#!/usr/bin/env python3
"""
Context system simplified test script.
Focuses on validating core features:
1. Redis storage
2. File persistence
3. Message management
"""
import os
import sys
import json
import tempfile
import shutil
import asyncio
import redis
import traceback
from typing import Dict, List, Optional, Any
# Add the project root directory to the Python path.
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, project_root)
from context.context import RedisFileContextBackend
from context.schemas import Message
class SimpleContextTester:
"""Simplified Context system tester."""
def __init__(self):
"""Initialize the tester."""
self.test_context_dir = tempfile.mkdtemp(prefix="test_context_")
self.test_redis_db = 12 # Use a dedicated test database.
# Create a Redis connection.
self.redis_client = redis.Redis(db=self.test_redis_db, decode_responses=True)
self.redis_client.flushdb()
self.test_results = []
def cleanup(self):
"""Clean up the test environment."""
self.redis_client.flushdb()
self.redis_client.close()
if os.path.exists(self.test_context_dir):
shutil.rmtree(self.test_context_dir)
def log_test(self, test_name: str, success: bool, message: str = ""):
"""Record a test result."""
status = "✓ PASS" if success else "✗ FAIL"
print(f"{status} {test_name}: {message}")
self.test_results.append({
"test": test_name,
"success": success,
"message": message
})
def test_redis_connection(self) -> bool:
"""Test the Redis connection."""
try:
assert self.redis_client.ping()
self.log_test("Redis connection", True, "Redis service is healthy")
return True
except Exception as e:
self.log_test("Redis connection", False, f"Redis connection failed: {e}")
return False
async def test_basic_message_storage(self) -> bool:
"""Test basic message storage functionality."""
try:
context_id = "test_basic_storage"
backend = RedisFileContextBackend(
context_id=context_id,
redis_host="localhost",
redis_port=6379,
redis_db=self.test_redis_db,
file_path=os.path.join(self.test_context_dir, f"ctx_{context_id}.json")
)
# Create a test message.
message = Message(role="user", content="Test message")
# Store the message.
await backend.store_message(message)
# Verify message count.
count = backend.get_message_count()
assert count == 1, f"Expected 1 message, got {count}"
# Retrieve messages.
messages = backend.retrieve_messages()
assert len(messages) == 1, f"Expected 1 message, got {len(messages)}"
# Verify message content.
retrieved_message = messages[0]
assert retrieved_message.role == "user"
assert retrieved_message.content == "Test message"
self.log_test("Basic message storage", True, "Successfully stored and retrieved the message")
return True
except Exception as e:
error_msg = f"Basic message storage test failed: {e}\n{traceback.format_exc()}"
self.log_test("Basic message storage", False, error_msg)
return False
async def test_file_persistence_simple(self) -> bool:
"""Test simple file persistence functionality."""
try:
context_id = "test_file_simple"
file_path = os.path.join(self.test_context_dir, f"ctx_{context_id}.json")
backend = RedisFileContextBackend(
context_id=context_id,
redis_host="localhost",
redis_port=6379,
redis_db=self.test_redis_db,
file_path=file_path
)
# Add one message.
message = Message(role="user", content="Persistence test message")
await backend.store_message(message)
# Persist to file.
success = await backend.persist()
assert success, "Persistence failed"
assert os.path.exists(file_path), f"File does not exist: {file_path}"
# Verify file contents.
with open(file_path, 'r', encoding='utf-8') as f:
data = json.load(f)
assert data['context_id'] == context_id
assert len(data['messages']) == 1
assert data['messages'][0]['content'] == "Persistence test message"
self.log_test("File persistence", True, "Successfully persisted to the file system")
return True
except Exception as e:
error_msg = f"File persistence test failed: {e}\n{traceback.format_exc()}"
self.log_test("File persistence", False, error_msg)
return False
async def test_redis_data_verification(self) -> bool:
"""Verify data in Redis."""
try:
context_id = "test_redis_verify"
backend = RedisFileContextBackend(
context_id=context_id,
redis_host="localhost",
redis_port=6379,
redis_db=self.test_redis_db,
file_path=os.path.join(self.test_context_dir, f"ctx_{context_id}.json")
)
# Add a message.
message = Message(role="user", content="Redis verification message")
await backend.store_message(message)
# Ensure metadata is updated in Redis.
backend.update_metadata({"test_verification": "true"})
# Verify data in Redis.
messages_key = f"context:{context_id}:messages"
metadata_key = f"context:{context_id}:metadata"
# Check message data.
redis_messages = self.redis_client.lrange(messages_key, 0, -1)
assert len(redis_messages) == 1, f"Redis should contain 1 message, got {len(redis_messages)}"
# Check metadata.
redis_metadata = self.redis_client.get(metadata_key)
assert redis_metadata is not None, "Redis should contain metadata"
# Verify message content.
message_data = json.loads(redis_messages[0])
assert message_data['role'] == "user"
assert message_data['content'] == "Redis verification message"
# Verify metadata contents.
metadata_data = json.loads(redis_metadata)
assert metadata_data['context_id'] == context_id
assert metadata_data['test_verification'] == "true"
self.log_test("Redis data verification", True, "Redis data storage works correctly")
return True
except Exception as e:
error_msg = f"Redis data verification failed: {e}\n{traceback.format_exc()}"
self.log_test("Redis data verification", False, error_msg)
return False
async def test_metadata_management(self) -> bool:
"""Test metadata management."""
try:
context_id = "test_metadata"
backend = RedisFileContextBackend(
context_id=context_id,
redis_host="localhost",
redis_port=6379,
redis_db=self.test_redis_db,
file_path=os.path.join(self.test_context_dir, f"ctx_{context_id}.json")
)
# Get initial metadata.
initial_metadata = backend.get_metadata()
assert initial_metadata['context_id'] == context_id
assert 'start_time' in initial_metadata
assert 'last_activity' in initial_metadata
# Update metadata.
new_metadata = {
'custom_field': 'custom_value',
'test_count': 42
}
backend.update_metadata(new_metadata)
# Verify the update.
updated_metadata = backend.get_metadata()
assert updated_metadata['custom_field'] == 'custom_value'
assert updated_metadata['test_count'] == 42
self.log_test("Metadata management", True, "Metadata management works correctly")
return True
except Exception as e:
error_msg = f"Metadata management test failed: {e}\n{traceback.format_exc()}"
self.log_test("Metadata management", False, error_msg)
return False
async def test_message_search(self) -> bool:
"""Test message search functionality."""
try:
context_id = "test_search"
backend = RedisFileContextBackend(
context_id=context_id,
redis_host="localhost",
redis_port=6379,
redis_db=self.test_redis_db,
file_path=os.path.join(self.test_context_dir, f"ctx_{context_id}.json")
)
# Add messages containing specific keywords.
messages = [
Message(role="user", content="I want to learn Python programming"),
Message(role="assistant", content="Python is a great programming language"),
Message(role="user", content="Please tell me about machine learning")
]
for message in messages:
await backend.store_message(message)
# Search for messages containing "Python".
python_results = backend.search_messages("Python", limit=10)
assert len(python_results) == 2, f"Expected 2 messages containing Python, got {len(python_results)}"
# Search for messages containing "machine learning".
ml_results = backend.search_messages("machine learning", limit=10)
assert len(ml_results) == 1, f"Expected 1 message containing machine learning, got {len(ml_results)}"
self.log_test("Message search", True, "Message search works correctly")
return True
except Exception as e:
error_msg = f"Message search test failed: {e}\n{traceback.format_exc()}"
self.log_test("Message search", False, error_msg)
return False
async def run_all_tests(self) -> bool:
"""Run all tests."""
print("=" * 60)
print("Starting simplified Context system tests")
print("=" * 60)
tests = [
("Redis connection", self.test_redis_connection),
("Basic message storage", self.test_basic_message_storage),
("File persistence", self.test_file_persistence_simple),
("Redis data verification", self.test_redis_data_verification),
("Metadata management", self.test_metadata_management),
("Message search", self.test_message_search),
]
all_passed = True
for test_name, test_func in tests:
print(f"\nRunning test: {test_name}")
print("-" * 40)
try:
if asyncio.iscoroutinefunction(test_func):
result = await test_func()
else:
result = test_func()
if not result:
all_passed = False
except Exception as e:
error_msg = f"Test exception: {e}\n{traceback.format_exc()}"
self.log_test(test_name, False, error_msg)
all_passed = False
# Output test summary.
print("\n" + "=" * 60)
print("Test Result Summary")
print("=" * 60)
passed_count = sum(1 for result in self.test_results if result["success"])
total_count = len(self.test_results)
for result in self.test_results:
status = "" if result["success"] else ""
print(f"{status} {result['test']}: {result['message']}")
print(f"\nTotal: {passed_count}/{total_count} tests passed")
if all_passed:
print("\n🎉 All tests passed!")
print("Context system core feature verification succeeded:")
print(" ✓ Redis storage works correctly")
print(" ✓ File system persistence works correctly")
print(" ✓ Message management works correctly")
print(" ✓ Metadata management works correctly")
print(" ✓ Message search works correctly")
else:
print("\n❌ Some tests failed")
return all_passed
async def main():
"""Main function."""
print("Context System Simplified Feature Validation")
print("=" * 40)
# Check Redis connection.
try:
r = redis.Redis(host="localhost", port=6379, decode_responses=True)
r.ping()
r.close()
print("✓ Redis service is available")
except Exception as e:
print(f"❌ Redis service is unavailable: {e}")
print("Please ensure the Redis service is running")
print("Startup commands:")
print(" macOS: brew services start redis")
print(" Linux: sudo systemctl start redis")
print(" Windows: redis-server")
return 1
# Run tests.
tester = SimpleContextTester()
try:
success = await tester.run_all_tests()
return 0 if success else 1
finally:
tester.cleanup()
if __name__ == "__main__":
exit(asyncio.run(main()))
@@ -0,0 +1,764 @@
"""
Unit tests for the Context system.
Test coverage:
1. Redis storage
2. File system persistence
3. Message management
4. Context manager
5. Data serialization and deserialization
6. Automatic summary functionality
7. Metadata management
"""
import os
import json
import tempfile
import shutil
import asyncio
import pytest
import redis
from typing import Dict, List, Optional, Any
from unittest.mock import Mock, AsyncMock, patch
from datetime import datetime, timedelta
# Import the modules under test.
import sys
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from context.context_manager import ContextManager
from context.context import RedisFileContextBackend, ContextBackend
from context.schemas import Message, SketchPadItem
from config.config import get_config
def _make_configured_backend(redis_host: str, redis_port: int, redis_db: int):
class ConfiguredRedisFileContextBackend(RedisFileContextBackend):
def __init__(
self,
context_id: str,
llm_interface=None,
max_history_length: int = 5,
auto_summarize_trigger: int = 1000000,
file_path: Optional[str] = None,
redis_host: str = redis_host,
redis_port: int = redis_port,
redis_db: int = redis_db,
):
super().__init__(
context_id=context_id,
llm_interface=llm_interface,
max_history_length=max_history_length,
auto_summarize_trigger=auto_summarize_trigger,
redis_host=redis_host,
redis_port=redis_port,
redis_db=redis_db,
file_path=file_path,
)
return ConfiguredRedisFileContextBackend
class TestContextSystem:
"""Context system integration tests."""
@pytest.fixture(autouse=True)
def setup_and_teardown(self):
"""Set up and clean up around each test."""
# Set up the test environment.
self.test_context_dir = tempfile.mkdtemp(prefix="test_context_")
self.test_redis_db = 15 # Use a dedicated test database.
# Create test configuration.
self.original_config = None
if hasattr(get_config(), "CONTEXT_DIR"):
self.original_config = get_config().CONTEXT_DIR
# Point the configuration to the test directory.
config = get_config()
config.CONTEXT_DIR = self.test_context_dir
self.redis_host = config.REDIS_HOST
self.redis_port = int(config.REDIS_PORT)
self.backend_class = _make_configured_backend(
self.redis_host,
self.redis_port,
self.test_redis_db,
)
ContextManager._instance = None
# Create a Redis connection.
self.redis_client = redis.Redis(
host=self.redis_host,
port=self.redis_port,
db=self.test_redis_db,
decode_responses=True,
)
# Clear test data.
self.redis_client.flushdb()
yield
# Clean up the test environment.
self.redis_client.flushdb()
self.redis_client.close()
if os.path.exists(self.test_context_dir):
shutil.rmtree(self.test_context_dir)
ContextManager._instance = None
# Restore the original configuration.
if self.original_config:
config.CONTEXT_DIR = self.original_config
def test_redis_connection(self):
"""Test the Redis connection."""
assert self.redis_client.ping()
print("✓ Redis connection is healthy")
def test_context_backend_creation(self):
"""Test context backend creation."""
context_id = "test_context_001"
backend = RedisFileContextBackend(
context_id=context_id,
redis_host=self.redis_host,
redis_port=self.redis_port,
redis_db=self.test_redis_db,
file_path=os.path.join(self.test_context_dir, f"ctx_{context_id}.json"),
)
assert backend.context_id == context_id
assert backend.redis_client is not None
assert backend.file_path is not None
print("✓ Context backend created successfully")
@pytest.mark.asyncio
async def test_message_storage_and_retrieval(self):
"""Test message storage and retrieval."""
context_id = "test_context_002"
backend = RedisFileContextBackend(
context_id=context_id,
redis_host=self.redis_host,
redis_port=self.redis_port,
redis_db=self.test_redis_db,
file_path=os.path.join(self.test_context_dir, f"ctx_{context_id}.json"),
)
# Create test messages.
messages = [
Message(role="system", content="You are a helpful assistant"),
Message(role="user", content="Hello"),
Message(role="assistant", content="Hello! How can I help you?"),
Message(role="user", content="Please introduce Python"),
Message(role="assistant", content="Python is a high-level programming language..."),
]
# Store messages.
for message in messages:
await backend.store_message(message)
# Verify message count.
assert backend.get_message_count() == len(messages)
print(f"✓ Successfully stored {len(messages)} messages")
# Retrieve messages.
retrieved_messages = backend.retrieve_messages()
assert len(retrieved_messages) == len(messages)
# Verify message content.
for i, (original, retrieved) in enumerate(zip(messages, retrieved_messages)):
assert original.role == retrieved.role
assert original.content == retrieved.content
assert retrieved.timestamp is not None
print("✓ Message retrieval works correctly")
@pytest.mark.asyncio
async def test_file_persistence(self):
"""Test file persistence functionality."""
context_id = "test_context_003"
file_path = os.path.join(self.test_context_dir, f"ctx_{context_id}.json")
backend = RedisFileContextBackend(
context_id=context_id,
redis_host=self.redis_host,
redis_port=self.redis_port,
redis_db=self.test_redis_db,
file_path=file_path,
)
# Add some messages.
messages = [
Message(role="user", content="Test message 1"),
Message(role="assistant", content="Test reply 1"),
Message(role="user", content="Test message 2"),
Message(role="assistant", content="Test reply 2"),
]
for message in messages:
await backend.store_message(message)
# Persist to file.
success = await backend.persist()
assert success
assert os.path.exists(file_path)
# Verify file contents.
with open(file_path, "r", encoding="utf-8") as f:
data = json.load(f)
assert data["context_id"] == context_id
assert "messages" in data
assert "metadata" in data
assert len(data["messages"]) == len(messages)
print("✓ File persistence works correctly")
@pytest.mark.asyncio
async def test_file_restoration(self):
"""Test file restoration functionality."""
context_id = "test_context_004"
file_path = os.path.join(self.test_context_dir, f"ctx_{context_id}.json")
# Create the first backend and add data.
backend1 = RedisFileContextBackend(
context_id=context_id,
redis_host=self.redis_host,
redis_port=self.redis_port,
redis_db=self.test_redis_db,
file_path=file_path,
)
messages = [
Message(role="user", content="Persistence test message 1"),
Message(role="assistant", content="Persistence test reply 1"),
Message(role="user", content="Persistence test message 2"),
]
for message in messages:
await backend1.store_message(message)
# Persist data.
await backend1.persist()
# Create a new backend instance to simulate restart.
backend2 = RedisFileContextBackend(
context_id=context_id,
redis_host=self.redis_host,
redis_port=self.redis_port,
redis_db=self.test_redis_db,
file_path=file_path,
)
# Restore from file.
success = await backend2.restore()
assert success
# Verify restored data.
restored_messages = backend2.retrieve_messages()
assert len(restored_messages) == len(messages)
for i, (original, restored) in enumerate(zip(messages, restored_messages)):
assert original.role == restored.role
assert original.content == restored.content
print("✓ File restoration works correctly")
@pytest.mark.asyncio
async def test_context_manager_integration(self):
"""Test context manager integration functionality."""
# Create a context manager.
manager = ContextManager(backend_class=self.backend_class)
# Create a context.
context_id = "test_context_005"
context = manager.create_context(context_id=context_id, max_history_length=10)
assert context is not None
assert context.context_id == context_id
# Add a message through the convenience interface.
success = await manager.add_message(
context_id=context_id,
message=Message(role="user", content="Message added through the manager"),
)
assert success
# Get history.
history = manager.get_history(context_id)
assert len(history) == 1
assert history[0].role == "user"
assert history[0].content == "Message added through the manager"
# Test retrieving the context.
retrieved_context = manager.get_context(context_id)
assert retrieved_context is not None
assert retrieved_context.context_id == context_id
print("✓ Context manager integration works correctly")
def test_metadata_management(self):
"""Test metadata management."""
context_id = "test_context_006"
backend = RedisFileContextBackend(
context_id=context_id,
redis_host=self.redis_host,
redis_port=self.redis_port,
redis_db=self.test_redis_db,
file_path=os.path.join(self.test_context_dir, f"ctx_{context_id}.json"),
)
# Get initial metadata.
initial_metadata = backend.get_metadata()
assert initial_metadata["context_id"] == context_id
assert "start_time" in initial_metadata
assert "last_activity" in initial_metadata
# Update metadata.
new_metadata = {"custom_field": "custom_value", "test_count": 42}
backend.update_metadata(new_metadata)
# Verify the update.
updated_metadata = backend.get_metadata()
assert updated_metadata["custom_field"] == "custom_value"
assert updated_metadata["test_count"] == 42
print("✓ Metadata management works correctly")
@pytest.mark.asyncio
async def test_message_search(self):
"""Test message search functionality."""
context_id = "test_context_007"
backend = RedisFileContextBackend(
context_id=context_id,
redis_host=self.redis_host,
redis_port=self.redis_port,
redis_db=self.test_redis_db,
file_path=os.path.join(self.test_context_dir, f"ctx_{context_id}.json"),
)
# Add messages containing specific keywords.
messages = [
Message(role="user", content="I want to learn Python programming"),
Message(role="assistant", content="Python is a great programming language"),
Message(role="user", content="Please tell me about machine learning"),
Message(role="assistant", content="Machine learning is a branch of artificial intelligence"),
Message(role="user", content="Python is commonly used in machine learning"),
]
for message in messages:
await backend.store_message(message)
# Search for messages containing "Python".
python_results = backend.search_messages("Python", limit=10)
assert len(python_results) == 3
# Search for messages containing "machine learning".
ml_results = backend.search_messages("machine learning", limit=10)
assert len(ml_results) == 3
# Search for a nonexistent keyword.
empty_results = backend.search_messages("nonexistent keyword", limit=10)
assert len(empty_results) == 0
print("✓ Message search works correctly")
@pytest.mark.asyncio
async def test_message_limit_management(self):
"""Test message count limit management."""
context_id = "test_context_008"
max_history_length = 3
backend = RedisFileContextBackend(
context_id=context_id,
redis_host=self.redis_host,
redis_port=self.redis_port,
redis_db=self.test_redis_db,
file_path=os.path.join(self.test_context_dir, f"ctx_{context_id}.json"),
max_history_length=max_history_length,
)
# Add more messages than the limit allows.
for i in range(5):
message = Message(role="user", content=f"Message {i}")
await backend.store_message(message)
# Verify that only the latest messages are retained.
messages = backend.retrieve_messages()
assert len(messages) == max_history_length
# Verify that the retained messages are the latest ones.
expected_contents = ["Message 2", "Message 3", "Message 4"]
for i, message in enumerate(messages):
assert message.content == expected_contents[i]
print("✓ Message count limit management works correctly")
@pytest.mark.asyncio
async def test_context_serialization(self):
"""Test context serialization functionality."""
context_id = "test_context_009"
backend = RedisFileContextBackend(
context_id=context_id,
redis_host=self.redis_host,
redis_port=self.redis_port,
redis_db=self.test_redis_db,
file_path=os.path.join(self.test_context_dir, f"ctx_{context_id}.json"),
)
# Add messages and metadata.
messages = [
Message(role="user", content="Serialization test message"),
Message(role="assistant", content="Serialization test reply"),
]
for message in messages:
await backend.store_message(message)
backend.update_summary("This is a test conversation")
backend.update_metadata({"test_key": "test_value"})
# Serialize.
serialized_data = backend.serialize()
# Verify serialized data.
assert serialized_data["context_id"] == context_id
assert len(serialized_data["messages"]) == 2
assert serialized_data["summary"] == "This is a test conversation"
assert "serialization_timestamp" in serialized_data
# Create a new backend and deserialize.
new_backend = RedisFileContextBackend(
context_id="new_context",
redis_host=self.redis_host,
redis_port=self.redis_port,
redis_db=self.test_redis_db,
file_path=os.path.join(self.test_context_dir, "ctx_new_context.json"),
)
new_backend.deserialize(serialized_data)
# Verify deserialization result.
restored_messages = new_backend.retrieve_messages()
assert len(restored_messages) == 2
assert new_backend.get_summary() == "This is a test conversation"
print("✓ Context serialization works correctly")
@pytest.mark.asyncio
async def test_context_manager_list_and_delete(self):
"""Test context manager list and delete functionality."""
manager = ContextManager(backend_class=self.backend_class)
# Create multiple contexts.
context_ids = ["test_ctx_001", "test_ctx_002", "test_ctx_003"]
for context_id in context_ids:
context = manager.create_context(context_id=context_id)
await manager.add_message(
context_id,
Message(role="user", content=f"Test message {context_id}"),
)
# List all contexts.
contexts = manager.list_contexts()
assert len(contexts) >= len(context_ids)
# Verify that the created contexts are all listed.
found_contexts = [ctx["context_id"] for ctx in contexts]
for context_id in context_ids:
assert context_id in found_contexts
# Delete one context.
delete_success = manager.delete_context(context_ids[0])
assert delete_success
# Verify it cannot be retrieved after deletion.
deleted_context = manager.get_context(context_ids[0])
assert deleted_context is None
print("✓ Context manager list and delete functionality works correctly")
@pytest.mark.asyncio
async def test_redis_persistence_verification(self):
"""Verify data persistence in Redis."""
context_id = "test_redis_persistence"
# Create a backend and add data.
backend = RedisFileContextBackend(
context_id=context_id,
redis_host=self.redis_host,
redis_port=self.redis_port,
redis_db=self.test_redis_db,
file_path=os.path.join(self.test_context_dir, f"ctx_{context_id}.json"),
)
# Add messages.
messages = [
Message(role="user", content="Redis persistence test message 1"),
Message(role="assistant", content="Redis persistence test reply 1"),
Message(role="user", content="Redis persistence test message 2"),
]
for message in messages:
await backend.store_message(message)
# Verify data in Redis.
messages_key = f"context:{context_id}:messages"
metadata_key = f"context:{context_id}:metadata"
summary_key = f"context:{context_id}:summary"
# Check message data.
redis_messages = self.redis_client.lrange(messages_key, 0, -1)
assert len(redis_messages) == 3
# Check metadata.
redis_metadata = self.redis_client.get(metadata_key)
assert redis_metadata is not None
# Verify message content.
for i, message_json in enumerate(redis_messages):
message_data = json.loads(message_json)
expected_message = messages[-(i + 1)]
assert message_data["role"] == expected_message.role
assert message_data["content"] == expected_message.content
print("✓ Redis data persistence verification passed")
@pytest.mark.asyncio
async def test_file_system_persistence_verification(self):
"""Verify data persistence in the file system."""
context_id = "test_file_persistence"
file_path = os.path.join(self.test_context_dir, f"ctx_{context_id}.json")
backend = RedisFileContextBackend(
context_id=context_id,
redis_host=self.redis_host,
redis_port=self.redis_port,
redis_db=self.test_redis_db,
file_path=file_path,
)
# Add messages and metadata.
messages = [
Message(role="user", content="File persistence test message 1"),
Message(role="assistant", content="File persistence test reply 1"),
Message(role="user", content="File persistence test message 2"),
]
for message in messages:
await backend.store_message(message)
backend.update_summary("File persistence test summary")
backend.update_metadata({"file_test": "file_value"})
# Persist to file.
await backend.persist()
# Verify file exists.
assert os.path.exists(file_path)
# Read and verify file contents.
with open(file_path, "r", encoding="utf-8") as f:
file_data = json.load(f)
# Verify file structure.
assert file_data["context_id"] == context_id
assert len(file_data["messages"]) == 3
assert file_data["summary"] == "File persistence test summary"
assert file_data["metadata"]["file_test"] == "file_value"
# Verify message content.
for i, message_data in enumerate(file_data["messages"]):
assert message_data["role"] == messages[i].role
assert message_data["content"] == messages[i].content
print("✓ File system data persistence verification passed")
@pytest.mark.asyncio
async def test_concurrent_access(self):
"""Test concurrent access."""
import threading
import time
context_id = "test_concurrent"
backend = RedisFileContextBackend(
context_id=context_id,
redis_host=self.redis_host,
redis_port=self.redis_port,
redis_db=self.test_redis_db,
file_path=os.path.join(self.test_context_dir, f"ctx_{context_id}.json"),
max_history_length=20,
)
# Add messages concurrently.
def add_messages(thread_id: int, count: int):
for i in range(count):
message = Message(role="user", content=f"Thread {thread_id} message {i}")
asyncio.run(backend.store_message(message))
time.sleep(0.01) # Small delay.
# Create multiple threads.
threads = []
for i in range(3):
thread = threading.Thread(target=add_messages, args=(i, 5))
threads.append(thread)
thread.start()
# Wait for all threads to finish.
for thread in threads:
thread.join()
# Verify that all messages were added.
messages = backend.retrieve_messages()
assert len(messages) == 15 # 3 threads * 5 messages.
print("✓ Concurrent access test passed")
def test_error_handling(self):
"""Test error handling."""
# Test an invalid Redis connection.
try:
invalid_backend = RedisFileContextBackend(
context_id="test_error",
redis_host="invalid_host",
redis_port=9999,
redis_db=0,
file_path=os.path.join(self.test_context_dir, "ctx_test_error.json"),
)
# If the connection fails, it should raise an exception.
invalid_backend.redis_client.ping()
except Exception as e:
print(f"✓ Expected Redis connection error: {type(e).__name__}")
# Test an invalid file path.
backend = RedisFileContextBackend(
context_id="test_error_file",
redis_host=self.redis_host,
redis_port=self.redis_port,
redis_db=self.test_redis_db,
file_path="/invalid/path/ctx_test.json",
)
# Try to persist to an invalid path.
async def test_invalid_persist():
return await backend.persist()
result = asyncio.run(test_invalid_persist())
assert not result # Should fail.
print("✓ Error handling test passed")
class TestContextManagerAdvanced:
"""Advanced ContextManager feature tests."""
@pytest.fixture(autouse=True)
def setup(self):
"""Set up the test environment."""
self.test_context_dir = tempfile.mkdtemp(prefix="test_manager_")
self.test_redis_db = 14
# Modify configuration.
config = get_config()
config.CONTEXT_DIR = self.test_context_dir
self.redis_host = config.REDIS_HOST
self.redis_port = int(config.REDIS_PORT)
self.backend_class = _make_configured_backend(
self.redis_host,
self.redis_port,
self.test_redis_db,
)
ContextManager._instance = None
# Create a Redis connection.
self.redis_client = redis.Redis(
host=self.redis_host,
port=self.redis_port,
db=self.test_redis_db,
decode_responses=True,
)
self.redis_client.flushdb()
yield
# Clean up.
self.redis_client.flushdb()
self.redis_client.close()
shutil.rmtree(self.test_context_dir)
ContextManager._instance = None
@pytest.mark.asyncio
async def test_context_manager_singleton(self):
"""Test the ContextManager singleton pattern."""
manager1 = ContextManager(backend_class=self.backend_class)
manager2 = ContextManager(backend_class=self.backend_class)
assert manager1 is manager2
print("✓ ContextManager singleton pattern works correctly")
@pytest.mark.asyncio
async def test_context_manager_bulk_operations(self):
"""Test ContextManager batch operations."""
manager = ContextManager(backend_class=self.backend_class)
# Create multiple contexts.
context_ids = ["bulk_test_001", "bulk_test_002", "bulk_test_003"]
contexts = []
for context_id in context_ids:
context = manager.create_context(context_id=context_id)
contexts.append(context)
# Add a message.
await manager.add_message(
context_id,
Message(role="user", content=f"Batch test message {context_id}"),
)
# Save all contexts.
saved_count = await manager.save_all_contexts()
assert saved_count == len(context_ids)
# Verify files exist.
for context_id in context_ids:
file_path = os.path.join(self.test_context_dir, f"ctx_{context_id}.json")
assert os.path.exists(file_path)
print("✓ ContextManager batch operations work correctly")
@pytest.mark.asyncio
async def test_context_manager_cleanup(self):
"""Test ContextManager cleanup functionality."""
manager = ContextManager(backend_class=self.backend_class)
# Create a context and add a message.
context_id = "cleanup_test"
context = manager.create_context(context_id=context_id)
await manager.add_message(
context_id,
Message(role="user", content="Cleanup test message"),
)
# Simulate long-term inactivity by modifying last_activity in metadata.
context.update_metadata(
{"last_activity": (datetime.now() - timedelta(hours=2)).isoformat()}
)
# Run cleanup with a short inactivity threshold.
cleaned_count = await manager.cleanup_inactive_contexts(max_inactive_time=1)
assert cleaned_count == 1
# Verify the context was removed from the active cache but can still be restored from persistent storage on demand.
assert context_id not in manager._active_contexts
retrieved_context = manager.get_context(context_id)
assert retrieved_context is not None
print("✓ ContextManager cleanup functionality works correctly")
if __name__ == "__main__":
# Run tests.
pytest.main([__file__, "-v", "-s"])
@@ -0,0 +1,144 @@
import fnmatch
import os
import sys
import threading
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
class _FakeRedis:
_store: dict[str, object] = {}
def __init__(self, *args, **kwargs):
pass
@classmethod
def reset(cls) -> None:
cls._store = {}
def keys(self, pattern: str):
return [key for key in self._store.keys() if fnmatch.fnmatch(key, pattern)]
def delete(self, *keys: str):
deleted = 0
for key in keys:
if key in self._store:
deleted += 1
del self._store[key]
return deleted
def test_delete_context_removes_file_and_redis_keys(tmp_path, monkeypatch):
import context.context_manager as context_manager_module
_FakeRedis.reset()
monkeypatch.setattr(context_manager_module.redis, "Redis", _FakeRedis)
context_id = "conv-1"
context_file = tmp_path / f"ctx_{context_id}.json"
context_file.write_text("{}", encoding="utf-8")
_FakeRedis._store = {
f"context:{context_id}:messages": ["message"],
f"context:{context_id}:metadata": "{}",
}
manager = object.__new__(context_manager_module.ContextManager)
manager._lock = threading.RLock()
manager._active_contexts = {}
manager.context_dir = str(tmp_path)
manager.config = type(
"Cfg",
(),
{"REDIS_HOST": "localhost", "REDIS_PORT": 9736, "REDIS_DB": 0},
)()
assert manager.delete_context(context_id) is True
assert context_file.exists() is False
assert not any(
key.startswith(f"context:{context_id}:") for key in _FakeRedis._store
)
def test_delete_sketch_pad_removes_file_and_redis_keys(tmp_path, monkeypatch):
import context.sketch_manager as sketch_manager_module
_FakeRedis.reset()
monkeypatch.setattr(sketch_manager_module.redis, "Redis", _FakeRedis)
sketch_id = "conv-1"
sketch_file = tmp_path / f"skt_{sketch_id}.json"
sketch_file.write_text("{}", encoding="utf-8")
_FakeRedis._store = {
f"sketch_pad:{sketch_id}:code": "print('x')",
f"sketch_pad:{sketch_id}:tag:model": ["code"],
}
manager = object.__new__(sketch_manager_module.SketchManager)
manager._lock = threading.RLock()
manager._active_sketches = {}
manager.sketch_dir = str(tmp_path)
manager.config = type(
"Cfg",
(),
{"REDIS_HOST": "localhost", "REDIS_PORT": 9736, "REDIS_DB": 0},
)()
assert manager.delete_sketch_pad(sketch_id) is True
assert sketch_file.exists() is False
assert not any(
key.startswith(f"sketch_pad:{sketch_id}:") for key in _FakeRedis._store
)
def test_delete_all_conversations_discovers_ids_across_sources(tmp_path):
from context.conversation_manager import ConversationManager
marker_id = "marker-only"
active_id = "active-only"
context_id = "context-only"
sketch_id = "sketch-only"
conversations_dir = tmp_path / "conversations"
conversations_dir.mkdir()
(conversations_dir / f"conv_{marker_id}.marker").write_text("", encoding="utf-8")
deleted_contexts: list[str] = []
deleted_sketches: list[str] = []
class _FakeContextManager:
def list_context_ids(self):
return [context_id]
def delete_context(self, conversation_id: str):
deleted_contexts.append(conversation_id)
return conversation_id in {marker_id, active_id, context_id, sketch_id}
class _FakeSketchManager:
def list_sketch_ids(self):
return [sketch_id]
def delete_sketch_pad(self, conversation_id: str):
deleted_sketches.append(conversation_id)
return conversation_id in {marker_id, active_id, context_id, sketch_id}
manager = object.__new__(ConversationManager)
manager._lock = threading.RLock()
manager._active_conversations = {active_id: object()}
manager.context_manager = _FakeContextManager()
manager.sketch_manager = _FakeSketchManager()
manager.conversations_dir = str(conversations_dir)
deleted_ids = manager.delete_all_conversations()
assert sorted(deleted_ids) == sorted([marker_id, active_id, context_id, sketch_id])
assert sorted(deleted_contexts) == sorted(
[marker_id, active_id, context_id, sketch_id]
)
assert sorted(deleted_sketches) == sorted(
[marker_id, active_id, context_id, sketch_id]
)
@@ -0,0 +1,251 @@
# pyright: reportAssignmentType=false, reportArgumentType=false, reportIndexIssue=false
import os
import sys
from typing import Any, cast
import pytest
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
from context.context import RedisFileContextBackend
from context.schemas import Message
from web_interface.models import (
ChatMessageContentImage,
ChatMessageContentImageUrl,
ChatMessageContentText,
)
from web_interface.routers.conversation_router import get_conversation_history
class _FakeRedis:
_store: dict[str, object] = {}
def __init__(self, *args, **kwargs):
pass
@classmethod
def reset(cls) -> None:
cls._store = {}
def lpush(self, key: str, value: str) -> None:
values = cast(list[str], self._store.setdefault(key, []))
values.insert(0, value)
def rpush(self, key: str, value: str) -> None:
values = cast(list[str], self._store.setdefault(key, []))
values.append(value)
def lrange(self, key: str, start: int, end: int):
values = list(cast(list[str], self._store.get(key, [])))
if end == -1:
end = len(values) - 1
return values[start : end + 1]
def llen(self, key: str) -> int:
values = self._store.get(key, [])
assert isinstance(values, list)
return len(values)
def delete(self, key: str) -> None:
self._store.pop(key, None)
def set(self, key: str, value: str) -> None:
self._store[key] = value
def get(self, key: str):
return self._store.get(key)
def ltrim(self, key: str, start: int, end: int) -> None:
values = list(cast(list[str], self._store.get(key, [])))
if end == -1:
trimmed = values[start:]
else:
trimmed = values[start : end + 1]
self._store[key] = trimmed
@pytest.mark.asyncio
async def test_full_history_persists_even_when_working_memory_summarizes(
tmp_path, monkeypatch
):
import context.context as context_module
_FakeRedis.reset()
monkeypatch.setattr(context_module.redis, "Redis", _FakeRedis)
context_file = tmp_path / "ctx_conv-1.json"
backend = RedisFileContextBackend(
context_id="conv-1",
llm_interface=None,
max_history_length=2,
auto_summarize_trigger=2,
file_path=str(context_file),
)
cast(Any, backend).llm_interface = object()
async def fake_summarize(messages):
return "summary"
backend._summarize_func = fake_summarize
await backend.store_message(Message(role="user", content="first"))
await backend.store_message(Message(role="assistant", content="second"))
await backend.store_message(Message(role="user", content="third"))
assert [message.content for message in backend.retrieve_messages()] == ["third"]
assert [message.content for message in backend.retrieve_full_messages()] == [
"first",
"second",
"third",
]
await backend.persist()
_FakeRedis.reset()
restored = RedisFileContextBackend(
context_id="conv-1",
llm_interface=None,
max_history_length=2,
auto_summarize_trigger=2,
file_path=str(context_file),
)
await restored.restore()
assert [message.content for message in restored.retrieve_messages()] == ["third"]
assert [message.content for message in restored.retrieve_full_messages()] == [
"first",
"second",
"third",
]
@pytest.mark.asyncio
async def test_conversation_history_endpoint_returns_full_persisted_history():
archived_messages = [
Message(role="user", content="first"),
Message(role="assistant", content="second"),
Message(role="user", content="third"),
]
working_messages = [archived_messages[-1]]
class _FakeContext:
def retrieve_messages(self):
return working_messages
def retrieve_full_messages(self):
return archived_messages
class _FakeConversation:
uuid = "conversation-1"
context = _FakeContext()
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
class _FakeConversationManager:
def get_conversation(self, conversation_id):
return _FakeConversation() if conversation_id == "conversation-1" else None
class _FakeState:
conversation_manager = _FakeConversationManager()
response = cast(
Any,
await get_conversation_history(
conversation_id="conversation-1",
limit=None,
state=cast(Any, _FakeState()),
),
)
assert response["total_messages"] == 3
assert [message["content"] for message in response["messages"]] == [
"first",
"second",
"third",
]
@pytest.mark.asyncio
async def test_large_auto_summarize_trigger_effectively_disables_summary(
tmp_path, monkeypatch
):
import context.context as context_module
_FakeRedis.reset()
monkeypatch.setattr(context_module.redis, "Redis", _FakeRedis)
context_file = tmp_path / "ctx_conv-2.json"
backend = RedisFileContextBackend(
context_id="conv-2",
llm_interface=None,
max_history_length=2,
auto_summarize_trigger=999999,
file_path=str(context_file),
)
cast(Any, backend).llm_interface = object()
summarize_call_count = 0
async def fake_summarize(messages):
nonlocal summarize_call_count
summarize_call_count += 1
return "summary"
backend._summarize_func = fake_summarize
await backend.store_message(Message(role="user", content="first"))
await backend.store_message(Message(role="assistant", content="second"))
await backend.store_message(Message(role="user", content="third"))
assert summarize_call_count == 0
assert backend.get_summary() is None
assert [message.content for message in backend.retrieve_messages()] == [
"second",
"third",
]
def test_context_message_accepts_web_multimodal_models():
message = Message(
role="user",
content=[
ChatMessageContentText.model_validate(
{"type": "text", "text": "Treat this as an assembly"}
),
ChatMessageContentImage.model_validate(
{
"type": "image_url",
"image_url": ChatMessageContentImageUrl.model_validate(
{
"url": "data:image/png;base64,abcd",
"local_path": "/tmp/query_image_001.png",
}
),
}
),
],
)
assert isinstance(message.content, list)
payload = message.model_dump()
assert payload["content"] == [
{"type": "text", "text": "Treat this as an assembly"},
{
"type": "image_url",
"image_url": {
"url": "data:image/png;base64,abcd",
"detail": None,
"local_path": "/tmp/query_image_001.png",
},
},
]
@@ -0,0 +1,43 @@
import os
import sys
from contextlib import contextmanager
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
from observability.langfuse_tracing import propagate_conversation_session
import observability.langfuse_tracing as tracing_module
def test_propagate_conversation_session_uses_conversation_id_as_session(monkeypatch):
captured: dict[str, object] = {}
@contextmanager
def fake_propagate_attributes(**kwargs):
captured.update(kwargs)
yield
monkeypatch.setattr(tracing_module, "_langfuse_is_configured", lambda: True)
monkeypatch.setattr(
tracing_module,
"propagate_attributes",
fake_propagate_attributes,
)
with propagate_conversation_session(
conversation_id="conversation-123",
metadata={"model": "cadagent", "turn": 2},
tags=["cadagent", "event_stream"],
):
pass
assert captured["session_id"] == "conversation-123"
assert captured["tags"] == ["cadagent", "event_stream"]
assert captured["metadata"] == {
"conversation_id": "conversation-123",
"model": "cadagent",
"turn": "2",
}
@@ -0,0 +1,339 @@
import os
import sys
from types import SimpleNamespace
import numpy as np
import pytest
from PIL import Image
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
import tools.model_view_tools as model_view_tools_module
import tools.reference_image as reference_image_module
from tools.model_view_tools import get_visual_feedback
@pytest.mark.asyncio
async def test_get_visual_feedback_uses_latest_uploaded_image_for_both_llm_steps(
monkeypatch, tmp_path
):
image_path = tmp_path / "query_image_001.png"
image_path.write_bytes(b"fake-png-bytes")
render_path = tmp_path / "render.png"
render_path.write_bytes(b"fake-render")
class FakeContext:
def retrieve_full_messages(self):
return [
SimpleNamespace(
role="user",
content=[
{"type": "text", "text": "Inspect this part"},
{
"type": "image_url",
"image_url": {
"url": "data:image/png;base64,abcd",
"local_path": str(image_path),
},
},
],
)
]
monkeypatch.setattr(
reference_image_module,
"get_current_context",
lambda: FakeContext(),
)
monkeypatch.setattr(
model_view_tools_module, "_require_simplecad_renderer", lambda: None
)
monkeypatch.setattr(
model_view_tools_module, "print_tool_output", lambda *args, **kwargs: None
)
class FakeSketchPad:
async def set_item(self, key, value, ttl=None, summary=None, tags=None):
return key
monkeypatch.setattr(
model_view_tools_module,
"get_current_sketch_pad",
lambda: FakeSketchPad(),
)
captured = {}
async def fake_question_generator(user_query, code, query_image_path):
captured["question_image_path"] = (
str(query_image_path.path) if query_image_path else None
)
return "Checklist"
async def fake_visual_feedback_generator(
questions, multi_view_results, query_image_path
):
captured["visual_image_path"] = (
str(query_image_path.path) if query_image_path else None
)
captured["multi_view_results"] = str(multi_view_results.path)
return "Looks correct\nPASS"
monkeypatch.setattr(
model_view_tools_module,
"question_generator",
fake_question_generator,
)
monkeypatch.setattr(
model_view_tools_module,
"visual_feedback_generator",
fake_visual_feedback_generator,
)
from SimpleLLMFunc.type import ImgPath
monkeypatch.setattr(
model_view_tools_module,
"render_multi_view_model",
lambda model_path, output_path: ImgPath(render_path, detail="high"),
)
result = await get_visual_feedback(
user_query="Inspect this part",
code="result = None",
model_path="./part/model.stl",
)
expected_image_path = str(image_path.resolve())
assert captured["question_image_path"] == expected_image_path
assert captured["visual_image_path"] == expected_image_path
assert captured["multi_view_results"] == str(render_path.resolve())
assert "Model path: ./part/model.stl" in result
def test_camera_relative_light_rig_tracks_camera_direction() -> None:
for view_dir in (
np.array([1.0, 0.0, 0.0]),
np.array([-1.0, 0.0, 0.0]),
np.array([0.0, 0.0, 1.0]),
np.array([1.0, 1.0, -1.0]),
):
normalized_view = view_dir / np.linalg.norm(view_dir)
light_dirs, light_weights, ambient = (
model_view_tools_module._camera_relative_light_rig(normalized_view)
)
assert ambient > 0.0
assert len(light_dirs) == len(light_weights) >= 3
assert np.dot(light_dirs[0], normalized_view) > 0.85
assert all(
abs(np.linalg.norm(light_dir) - 1.0) < 1e-6 for light_dir in light_dirs
)
def test_camera_relative_shading_keeps_front_faces_bright_across_views() -> None:
base_color = np.array([0.72, 0.76, 0.81], dtype=float)
front_face_brightness: list[float] = []
for view_dir in (
np.array([1.0, 0.0, 0.0]),
np.array([-1.0, 0.0, 0.0]),
np.array([0.0, 1.0, 0.0]),
np.array([1.0, 1.0, -1.0]),
):
normalized_view = view_dir / np.linalg.norm(view_dir)
normals = np.stack([normalized_view, -normalized_view])
shaded = model_view_tools_module._shade_normals_camera_relative(
normals,
base_color,
normalized_view,
)
front_face_brightness.append(float(np.mean(shaded[0, :3])))
assert float(np.mean(shaded[0, :3])) > float(np.mean(shaded[1, :3]))
assert min(front_face_brightness) > 0.55
assert max(front_face_brightness) - min(front_face_brightness) < 0.12
def test_surface_shading_normals_keep_planar_triangles_consistent() -> None:
class FakeVector:
def __init__(self, x: float, y: float, z: float) -> None:
self.x = x
self.y = y
self.z = z
class FakeCadFace:
def geomType(self) -> str:
return "PLANE"
def normalAt(self, location=None):
return FakeVector(0.0, 0.0, 1.0)
class FakeFace:
cq_face = FakeCadFace()
tri_pts = np.array(
[
[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0]],
[[0.0, 0.0, 0.0], [1.0, 1.0, 0.0], [0.0, 1.0, 0.0]],
],
dtype=float,
)
normals = model_view_tools_module._compute_surface_shading_normals(
FakeFace(), tri_pts
)
assert normals.shape == (2, 3)
assert np.allclose(normals[0], [0.0, 0.0, 1.0])
assert np.allclose(normals[1], [0.0, 0.0, 1.0])
def test_direct_cad_renderer_is_used_for_brep_like_formats() -> None:
assert model_view_tools_module._should_use_direct_cad_renderer("part.step") is True
assert model_view_tools_module._should_use_direct_cad_renderer("part.stp") is True
assert model_view_tools_module._should_use_direct_cad_renderer("part.brep") is True
assert model_view_tools_module._should_use_direct_cad_renderer("part.bin") is True
assert model_view_tools_module._should_use_direct_cad_renderer("part.stl") is False
def test_prefer_cad_native_model_path_uses_step_over_stl(tmp_path) -> None:
stl_path = tmp_path / "model.stl"
step_path = tmp_path / "model.step"
stl_path.write_text("solid", encoding="utf-8")
step_path.write_text("step", encoding="utf-8")
selected = model_view_tools_module._prefer_cad_native_model_path(str(stl_path))
assert selected == str(step_path.resolve())
def test_prefer_cad_native_model_path_keeps_stl_without_step(tmp_path) -> None:
stl_path = tmp_path / "model.stl"
stl_path.write_text("solid", encoding="utf-8")
selected = model_view_tools_module._prefer_cad_native_model_path(str(stl_path))
assert selected == str(stl_path.resolve())
def test_load_renderable_shapes_flattens_step_compounds(monkeypatch) -> None:
class FakeCadShape:
def __init__(self, shape_type: str, solids=None) -> None:
self._shape_type = shape_type
self._solids = list(solids or [])
def ShapeType(self) -> str:
return self._shape_type
def Solids(self):
return list(self._solids)
class FakeWrappedSolid:
def __init__(self, obj) -> None:
self.obj = obj
class FakeWorkplane:
def __init__(self, values) -> None:
self._values = values
def vals(self):
return list(self._values)
solid_a = FakeCadShape("Solid")
solid_b = FakeCadShape("Solid")
compound = FakeCadShape("Compound", solids=[solid_a, solid_b])
fake_cq = SimpleNamespace(
importers=SimpleNamespace(
importShape=lambda import_type, model_path: FakeWorkplane([compound])
)
)
monkeypatch.setattr(model_view_tools_module, "cq", fake_cq)
monkeypatch.setattr(model_view_tools_module, "ScadSolid", FakeWrappedSolid)
monkeypatch.setattr(
model_view_tools_module, "_require_simplecad_renderer", lambda: None
)
result = model_view_tools_module._load_renderable_shapes("part.step")
assert [wrapped.obj for wrapped in result] == [solid_a, solid_b]
def test_feature_edge_mask_detects_normal_and_depth_discontinuities() -> None:
mask = np.ones((8, 8), dtype=bool)
depth = np.zeros((8, 8), dtype=float)
normals = np.zeros((8, 8, 3), dtype=float)
normals[:, :4] = np.array([0.0, 0.0, 1.0])
normals[:, 4:] = np.array([1.0, 0.0, 0.0])
edge_mask = model_view_tools_module._compute_feature_edge_mask(
mask,
depth,
normals,
depth_jump_threshold=10.0,
normal_cos_threshold=0.95,
)
assert edge_mask[:, 3:5].any()
depth[:, 4:] = 4.0
normals[:, :] = np.array([0.0, 0.0, 1.0])
edge_mask = model_view_tools_module._compute_feature_edge_mask(
mask,
depth,
normals,
depth_jump_threshold=1.0,
normal_cos_threshold=0.95,
)
assert edge_mask[:, 3:5].any()
def test_direct_rasterizer_supersamples_and_preserves_target_size() -> None:
triangles = [
np.array([[0.0, 0.0, 1.0], [1.0, 0.0, 1.0], [1.0, 1.0, 1.0]], dtype=float),
np.array([[0.0, 0.0, 1.0], [1.0, 1.0, 1.0], [0.0, 1.0, 1.0]], dtype=float),
]
normals = [
np.array([0.0, 0.0, 1.0], dtype=float),
np.array([0.0, 0.0, 1.0], dtype=float),
]
image = model_view_tools_module._rasterize_projected_triangles(
triangles,
normals,
image_size=(48, 48),
zoom=4.0,
background_rgb=np.array([255, 255, 255], dtype=np.uint8),
fill_rgb=np.array([180, 190, 200], dtype=np.uint8),
outline_rgb=np.array([0, 0, 0], dtype=np.uint8),
)
pixels = np.asarray(image)
unique_colors = np.unique(pixels.reshape(-1, 3), axis=0)
assert image.size == (48, 48)
assert len(unique_colors) > 3
def test_axis_triad_overlay_draws_small_corner_marker() -> None:
image = Image.new("RGB", (320, 320), "white")
annotated = model_view_tools_module._add_axis_triad_overlay(
image,
np.array([1.0, 1.0, 1.0]) / np.sqrt(3.0),
)
original = np.asarray(image)
updated = np.asarray(annotated)
diff = np.abs(updated.astype(int) - original.astype(int)).sum(axis=2)
changed_pixels = np.argwhere(diff > 0)
assert changed_pixels.size > 0
assert int(changed_pixels[:, 0].max()) > 220
assert int(changed_pixels[:, 1].max()) < 120
@@ -0,0 +1,155 @@
from __future__ import annotations
from pathlib import Path
from experiments.scripts.output_test_bench import (
CaseSpec,
build_progress_snapshot,
build_benchmark_prompt,
discover_cases,
load_existing_results,
merge_artifacts,
scan_output_directory,
)
def test_discover_cases_extracts_case_metadata(tmp_path: Path) -> None:
dataset_root = tmp_path / "output_test"
case_dir = dataset_root / "simple" / "0000" / "00000797"
case_dir.mkdir(parents=True)
(case_dir / "description.txt").write_text("Create a part", encoding="utf-8")
(case_dir / "00000797.png").write_bytes(b"png")
(case_dir / "00000797_cip.py").write_text("# cip", encoding="utf-8")
(case_dir / "00000797_cq.py").write_text("# cq", encoding="utf-8")
(case_dir / "00000797.step").write_text("step", encoding="utf-8")
cases = discover_cases(dataset_root)
assert len(cases) == 1
case = cases[0]
assert case.case_id == "simple/0000/00000797"
assert case.case_slug == "simple__0000__00000797"
assert Path(case.description_path).name == "description.txt"
assert Path(case.image_path or "").name == "00000797.png"
assert Path(case.reference_cip_path or "").name == "00000797_cip.py"
assert Path(case.reference_cq_path or "").name == "00000797_cq.py"
assert Path(case.reference_step_path or "").name == "00000797.step"
def test_discover_cases_works_when_dataset_root_is_already_simple_dir(
tmp_path: Path,
) -> None:
dataset_root = tmp_path / "simple"
case_dir = dataset_root / "0000" / "00000797"
case_dir.mkdir(parents=True)
(case_dir / "description.txt").write_text("Create a part", encoding="utf-8")
(case_dir / "00000797.png").write_bytes(b"png")
cases = discover_cases(dataset_root)
assert len(cases) == 1
case = cases[0]
assert case.case_id == "0000/00000797"
assert case.bucket_id == "0000"
assert case.sample_id == "00000797"
def test_build_benchmark_prompt_requires_full_automation(tmp_path: Path) -> None:
case = CaseSpec(
case_id="simple/0000/00000797",
case_slug="simple__0000__00000797",
bucket_id="0000",
sample_id="00000797",
case_dir=str(tmp_path),
description_path=str(tmp_path / "description.txt"),
image_path=str(tmp_path / "00000797.png"),
reference_cip_path=None,
reference_cq_path=None,
reference_step_path=None,
)
prompt = build_benchmark_prompt(
case=case,
description_text="Build a flange from the reference image.",
requested_output_dir=Path(
"/repo/workspace/experiments/output_test/r1/simple/0000/00000797"
),
execution_root=Path("/repo/workspace"),
)
assert "full user confirmation and authorization" in prompt
assert "Do not stop to ask for confirmation" in prompt
assert (
"target_output_dir: ./experiments/output_test/r1/simple/0000/00000797" in prompt
)
assert "<|code_file|>path/to/model.py</|code_file|>" in prompt
assert "Build a flange from the reference image." in prompt
def test_merge_artifacts_falls_back_to_directory_scan(tmp_path: Path) -> None:
output_dir = tmp_path / "workspace" / "case"
output_dir.mkdir(parents=True)
(output_dir / "model.py").write_text("print('ok')", encoding="utf-8")
(output_dir / "part.stl").write_text("solid", encoding="utf-8")
(output_dir / "part.step").write_text("step", encoding="utf-8")
scanned = scan_output_directory(output_dir)
merged = merge_artifacts(
tagged_artifacts={"code_path": None, "output_paths": []},
scanned_outputs=scanned,
requested_output_dir=output_dir,
)
assert merged["artifact_source"] == "dir_scan"
assert merged["code_path"] == output_dir / "model.py"
assert merged["stl_path"] == output_dir / "part.stl"
assert merged["step_path"] == output_dir / "part.step"
def test_load_existing_results_keeps_latest_per_case(tmp_path: Path) -> None:
case_results = tmp_path / "case_results.jsonl"
case_results.write_text(
"\n".join(
[
'{"case_id":"simple/0000/0001","status":"failed"}',
'{"case_id":"simple/0000/0002","status":"success"}',
'{"case_id":"simple/0000/0001","status":"success"}',
]
)
+ "\n",
encoding="utf-8",
)
loaded = load_existing_results(case_results)
assert sorted(loaded) == ["simple/0000/0001", "simple/0000/0002"]
assert loaded["simple/0000/0001"]["status"] == "success"
def test_build_progress_snapshot_counts_skipped_running_and_pending(
tmp_path: Path,
) -> None:
results = [
{"case_id": "simple/0000/0001", "status": "success"},
{"case_id": "simple/0000/0002", "status": "failed"},
]
snapshot = build_progress_snapshot(
run_id="r1",
dataset_root=tmp_path,
results=results,
total_cases=5,
skipped_existing=2,
running_case_ids=["simple/0000/0003"],
pending_case_ids=["simple/0000/0004", "simple/0000/0005"],
status="running",
)
assert snapshot["run_id"] == "r1"
assert snapshot["total_cases"] == 5
assert snapshot["completed_cases"] == 2
assert snapshot["skipped_existing"] == 2
assert snapshot["running_cases"] == 1
assert snapshot["pending_cases"] == 2
assert snapshot["success"] == 1
assert snapshot["failed"] == 1
@@ -0,0 +1,220 @@
import os
import sys
import importlib
from datetime import datetime
from typing import Any, cast
import pytest
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
from SimpleLLMFunc.hooks.events import (
CustomEvent,
ReactEndEvent,
ReactIterationStartEvent,
ReActEventType,
)
from SimpleLLMFunc.hooks.stream import EventOrigin, EventYield, ResponseYield
from agent.BaseAgent import BaseAgent
from react_stream import format_sse, serialize_react_output
base_agent_module = importlib.import_module("agent.BaseAgent")
class _FakeDelta:
def __init__(self, content, reasoning=None):
self.content = content
self.reasoning = reasoning
class _FakeChoice:
def __init__(self, content, reasoning=None):
self.delta = _FakeDelta(content, reasoning)
class _FakeChunk:
def __init__(self, content, reasoning=None):
self.choices = [_FakeChoice(content, reasoning)]
class _DummyAgent(BaseAgent):
def __init__(self):
pass
def get_toolkit(self):
return []
def chat_impl(self, history, query, sketch_pad_summary):
if False:
yield history, query, sketch_pad_summary
async def run(self, query, raw_user_content=None):
if False:
yield query, raw_user_content
class _FakeContext:
def __init__(self):
self.messages = []
async def store_message(self, message):
self.messages.append(message)
def _build_origin() -> EventOrigin:
return EventOrigin(
session_id="session-1",
agent_call_id="agent-call-1",
event_seq=1,
)
def test_serialize_react_output_normalizes_response_and_event_payloads():
response_output = ResponseYield(
response=cast(Any, _FakeChunk("hello world", reasoning="thinking")),
messages=[{"role": "assistant", "content": "hello world"}],
)
response_payload = serialize_react_output(response_output, delta_consumer="web")
assert response_payload["type"] == "response"
assert response_payload["delta_text"] == "hello world"
assert response_payload["delta_reasoning"] == "thinking"
assert response_payload["messages"][0]["content"] == "hello world"
event = ReactEndEvent(
event_type=ReActEventType.REACT_END,
timestamp=datetime(2026, 3, 18, 12, 0, 0),
trace_id="trace-1",
func_name="chat_impl",
iteration=1,
final_response="done",
final_messages=[{"role": "assistant", "content": "done"}],
total_iterations=1,
total_execution_time=0.5,
total_tool_calls=0,
total_llm_calls=1,
)
event_output = EventYield(event=event, origin=_build_origin())
event_payload = serialize_react_output(event_output)
assert event_payload["type"] == "event"
assert event_payload["event_type"] == "react_end"
assert event_payload["event"]["timestamp"] == "2026-03-18T12:00:00"
assert event_payload["origin"]["session_id"] == "session-1"
sse_packet = format_sse("response", response_payload)
assert sse_packet.startswith("event: response\n")
assert '"delta_text": "hello world"' in sse_packet
def test_custom_event_uses_event_name_for_stream_routing():
event = CustomEvent(
event_type=ReActEventType.CUSTOM_EVENT,
timestamp=datetime(2026, 3, 18, 12, 0, 0),
trace_id="trace-1",
func_name="chat_impl",
iteration=1,
event_name="subagent_status",
data={"phase": "started"},
)
event_output = EventYield(event=event, origin=_build_origin())
payload = serialize_react_output(event_output)
assert payload["event_type"] == "subagent_status"
@pytest.mark.asyncio
async def test_stream_and_persist_ignores_events_and_preserves_message_order(
monkeypatch,
):
fake_context = _FakeContext()
monkeypatch.setattr(base_agent_module, "get_current_context", lambda: fake_context)
tool_call = [
{
"id": "call_1",
"type": "function",
"function": {"name": "lookup", "arguments": "{}"},
}
]
async def output_stream():
yield ResponseYield(
response=cast(Any, _FakeChunk("Hello")),
messages=[{"role": "user", "content": "hi"}],
)
yield EventYield(
event=ReactIterationStartEvent(
event_type=ReActEventType.REACT_ITERATION_START,
timestamp=datetime(2026, 3, 18, 12, 0, 0),
trace_id="trace-1",
func_name="chat_impl",
iteration=1,
current_messages=[{"role": "user", "content": "hi"}],
),
origin=_build_origin(),
)
yield ResponseYield(
response=cast(Any, _FakeChunk("")),
messages=cast(
Any,
[
{"role": "user", "content": "hi"},
{"role": "assistant", "content": None, "tool_calls": tool_call},
],
),
)
yield ResponseYield(
response=cast(Any, _FakeChunk("")),
messages=cast(
Any,
[
{"role": "user", "content": "hi"},
{"role": "assistant", "content": None, "tool_calls": tool_call},
{
"role": "tool",
"content": "lookup result",
"tool_call_id": "call_1",
},
],
),
)
yield ResponseYield(
response=cast(Any, _FakeChunk(" world")),
messages=cast(
Any,
[
{"role": "user", "content": "hi"},
{"role": "assistant", "content": None, "tool_calls": tool_call},
{
"role": "tool",
"content": "lookup result",
"tool_call_id": "call_1",
},
{"role": "assistant", "content": "Hello world"},
],
),
)
agent = _DummyAgent()
yielded = []
async for output in agent._stream_and_persist(output_stream()):
yielded.append(output)
assert len(yielded) == 5
assert [message.role for message in fake_context.messages] == [
"assistant",
"assistant",
"tool",
"assistant",
]
assert fake_context.messages[0].content == "Hello"
assert fake_context.messages[1].tool_calls[0].id == "call_1"
assert fake_context.messages[2].tool_call_id == "call_1"
assert fake_context.messages[3].content == " world"
@@ -0,0 +1,78 @@
from __future__ import annotations
from pathlib import Path
from experiments.scripts.rebuttal_monitor import (
RebuttalTarget,
build_target_status,
safe_json_dump,
)
def test_build_target_status_reads_manifest_progress_and_log(tmp_path: Path) -> None:
worktree = tmp_path / "rebuttle-ecip"
state_dir = tmp_path / "state"
run_root = worktree / "workspace" / "experiments" / "runs" / "run1"
run_root.mkdir(parents=True)
log_path = tmp_path / "ecip.log"
log_path.write_text("line one\nline two\n", encoding="utf-8")
safe_json_dump(
{
"run_id": "run1",
"status": "running",
"updated_at": "2026-03-22T12:00:00+00:00",
"total_cases": 8,
"completed_cases": 3,
"success": 2,
"failed": 1,
"running_case_ids": ["simple/0000/0004"],
"pending_case_ids": ["simple/0000/0005"],
"skipped_existing": 2,
},
run_root / "progress.json",
)
safe_json_dump(
{
"run_id": "run1",
"total_cases": 8,
"success": 2,
"failed": 1,
},
run_root / "summary.json",
)
safe_json_dump(
{
"target": "ecip",
"branch": "rebuttle/ecip",
"worktree_path": str(worktree),
"run_id": "run1",
"pid": 999999,
"execution_root": str(worktree / "workspace"),
"run_root": str(run_root),
"output_root": str(
worktree / "workspace" / "experiments" / "output_test" / "run1"
),
"log_path": str(log_path),
"started_at": "2026-03-22T11:59:00+00:00",
"updated_at": "2026-03-22T12:00:00+00:00",
},
state_dir / "ecip.json",
)
status = build_target_status(
RebuttalTarget(
name="ecip",
branch="rebuttle/ecip",
worktree_path=str(worktree),
),
state_dir=state_dir,
log_line_count=5,
)
assert status["target"] == "ecip"
assert status["run_id"] == "run1"
assert status["summary"]["success"] == 2
assert status["progress"]["running_case_ids"] == ["simple/0000/0004"]
assert status["log_tail"] == ["line one", "line two"]
assert status["status"] in {"running", "stopped", "finished"}
@@ -0,0 +1,270 @@
#!/usr/bin/env python3
"""
Test the new SketchManager implementation.
"""
import asyncio
import json
import os
import tempfile
import shutil
from typing import Optional
from context.sketch_manager import get_sketch_manager, SketchManager
from context.sketch_pad import SketchPadBackend, RedisFileSketchPadBackend
from context.schemas import SketchPadListItem, SketchPadStatistics
class MockSketchPadBackend(SketchPadBackend):
"""Mock SketchPad backend for tests."""
def __init__(self, sketch_pad_id: str, file_path: Optional[str] = None):
self.sketch_pad_id = sketch_pad_id
self.file_path = file_path
self._storage = {}
self._lock = asyncio.Lock()
async def set_item(
self,
key: str,
value: any,
ttl: Optional[int] = None,
summary: Optional[str] = None,
tags: Optional[set] = None,
) -> str:
from context.schemas import SketchPadItem
from datetime import datetime
item = SketchPadItem(
value=value,
timestamp=datetime.now(),
summary=summary,
tags=tags or set(),
)
self._storage[key] = item
return key
def get_item(self, key: str):
return self._storage.get(key)
def get_value(self, key: str):
item = self.get_item(key)
return item.value if item else None
def search_by_tags(self, tags: set, match_all: bool = False):
results = []
for key, item in self._storage.items():
if match_all:
if tags.issubset(item.tags):
results.append((key, item))
else:
if tags.intersection(item.tags):
results.append((key, item))
return results
def search_by_content(self, query: str, limit: int = 5):
results = []
query_lower = query.lower()
for key, item in self._storage.items():
content = str(item.value) + (item.summary or "")
if query_lower in content.lower():
results.append((key, item))
if len(results) >= limit:
break
return results
def delete(self, key: str) -> bool:
if key in self._storage:
del self._storage[key]
return True
return False
def exists(self, key: str) -> bool:
return key in self._storage
def keys(self, pattern: Optional[str] = None) -> list[str]:
if pattern:
return [k for k in self._storage.keys() if pattern in k]
return list(self._storage.keys())
def clear(self) -> None:
self._storage.clear()
def serialize(self) -> dict[str, any]:
return {
"sketch_pad_id": self.sketch_pad_id,
"items": {k: v.model_dump() for k, v in self._storage.items()},
"serialization_timestamp": "2024-01-01T00:00:00",
}
def deserialize(self, data: dict[str, any]) -> None:
from context.schemas import SketchPadItem
if "items" in data:
for key, item_data in data["items"].items():
try:
item = SketchPadItem(**item_data)
self._storage[key] = item
except Exception as e:
print(f"Warning: Failed to deserialize item {key}: {e}")
def persist(self) -> None:
if self.file_path:
data = self.serialize()
os.makedirs(os.path.dirname(self.file_path), exist_ok=True)
with open(self.file_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def restore(self) -> None:
if self.file_path and os.path.exists(self.file_path):
with open(self.file_path, "r", encoding="utf-8") as f:
data = json.load(f)
self.deserialize(data)
def get_statistics(self) -> SketchPadStatistics:
return SketchPadStatistics(
total_items=len(self._storage),
max_items=len(self._storage),
items_with_summary=sum(
1 for item in self._storage.values() if item.summary
),
total_accesses=sum(item.access_count for item in self._storage.values()),
popular_tags={},
content_types={},
avg_access_per_item=0.0,
memory_usage_percent=0.0,
)
def list_items(self, include_value: bool = False) -> list[SketchPadListItem]:
return [
SketchPadListItem(
key=key,
summary=item.summary,
timestamp=item.timestamp.isoformat(),
tags=sorted(item.tags),
content_type=item.content_type,
access_count=item.access_count,
content_hash=item.content_hash,
value=item.value if include_value else None,
)
for key, item in self._storage.items()
]
async def test_sketch_manager():
"""Test basic SketchManager functionality."""
SketchManager._instance = None
# Create a temporary directory.
temp_dir = tempfile.mkdtemp()
try:
# Create a SketchManager that uses the mock backend.
manager = SketchManager(backend_class=MockSketchPadBackend)
manager.sketch_dir = temp_dir
print("=== Test Basic SketchManager Functionality ===")
# Test creating a SketchPad.
sketch_pad = manager.create_sketch_pad(sketch_id="test_sketch")
print(f"✓ Created SketchPad: {sketch_pad.sketch_pad_id}")
# Test setting an item.
key = await manager.set_item(
"test_sketch", "key1", "value1", tags={"test", "demo"}
)
print(f"✓ Set item: {key}")
# Test retrieving an item.
item = manager.get_item("test_sketch", "key1")
print(f"✓ Retrieved item: {item.value if item else None}")
# Test retrieving a value.
value = manager.get_value("test_sketch", "key1")
print(f"✓ Retrieved value: {value}")
# Test tag-based search.
results = manager.search_by_tags("test_sketch", {"test"})
print(f"✓ Tag search: found {len(results)} items")
# Test content-based search.
results = manager.search_by_content("test_sketch", "value")
print(f"✓ Content search: found {len(results)} items")
# Test deleting an item.
success = manager.delete_item("test_sketch", "key1")
print(f"✓ Deleted item: {success}")
# Test retrieving a SketchPad.
retrieved_pad = manager.get_sketch_pad("test_sketch")
print(f"✓ Retrieved SketchPad: {retrieved_pad is not None}")
# Test listing SketchPads.
sketches = manager.list_sketch_pads()
print(f"✓ Listed SketchPads: found {len(sketches)}")
# Test saving a SketchPad.
success = manager.save_sketch_pad("test_sketch")
print(f"✓ Saved SketchPad: {success}")
# Test deleting a SketchPad.
success = manager.delete_sketch_pad("test_sketch")
print(f"✓ Deleted SketchPad: {success}")
print("\n=== All Tests Passed ===")
finally:
# Clean up the temporary directory.
shutil.rmtree(temp_dir)
async def test_global_manager():
"""Test the global SketchManager."""
SketchManager._instance = None
print("\n=== Test Global SketchManager ===")
# Get the global manager.
manager = get_sketch_manager()
print(f"✓ Retrieved global manager: {type(manager)}")
# Test creating a SketchPad using the Redis backend.
try:
sketch_pad = manager.create_sketch_pad(sketch_id="global_test")
print(f"✓ Created global SketchPad: {sketch_pad.sketch_pad_id}")
# Test setting an item.
key = await manager.set_item(
"global_test", "global_key", "global_value", tags={"global"}
)
print(f"✓ Set global item: {key}")
# Test retrieving a value.
value = manager.get_value("global_test", "global_key")
print(f"✓ Retrieved global value: {value}")
# Clean up.
manager.delete_sketch_pad("global_test")
print("✓ Cleaned up global test data")
except Exception as e:
print(f"⚠ Global manager test failed (Redis may be required): {e}")
print("=== Global Manager Test Complete ===")
async def main():
"""Main test function."""
print("Starting tests for the new SketchManager implementation...")
await test_sketch_manager()
await test_global_manager()
print("\nAll tests complete!")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,207 @@
import asyncio
import json
import tempfile
import os
from typing import Dict, Any
from context.sketch_pad import RedisFileSketchPadBackend
from context.schemas import SketchPadItem
from config.config import get_config
def _create_sketch_pad(sketch_pad_id: str, file_path: str) -> RedisFileSketchPadBackend:
config = get_config()
return RedisFileSketchPadBackend(
sketch_pad_id=sketch_pad_id,
redis_host=config.REDIS_HOST,
redis_port=int(config.REDIS_PORT),
redis_db=int(config.REDIS_DB),
file_path=file_path,
)
async def test_sketch_pad_basic_operations():
"""Test basic SketchPad operations."""
print("=== Test Basic SketchPad Operations ===")
# Create a temporary file.
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
temp_file = f.name
try:
# Initialize SketchPad.
sketch_pad = _create_sketch_pad("test_pad", temp_file)
# Test setting items.
print("1. Testing item creation...")
await sketch_pad.set_item(
key="user_preference",
value={"theme": "dark", "language": "zh-CN"},
summary="User preference settings",
tags={"preference", "settings"},
)
await sketch_pad.set_item(
key="recent_files",
value=["file1.txt", "file2.py", "file3.json"],
summary="Recently accessed files",
tags={"files", "recent"},
)
await sketch_pad.set_item(
key="temp_data",
value="This is temporary data",
summary="Temporarily stored data",
tags={"temp", "data"},
ttl=60, # Expires after 60 seconds.
)
# Test retrieving items.
print("2. Testing item retrieval...")
item = sketch_pad.get_item("user_preference")
print(f" user_preference: {item.value if item else 'Not found'}")
value = sketch_pad.get_value("recent_files")
print(f" recent_files: {value}")
# Test existence checks.
print("3. Testing existence checks...")
print(f" user_preference exists: {sketch_pad.exists('user_preference')}")
print(f" non_existent exists: {sketch_pad.exists('non_existent')}")
# Test retrieving all keys.
print("4. Testing key retrieval...")
keys = sketch_pad.keys()
print(f" All keys: {keys}")
# Test tag search.
print("5. Testing tag search...")
preference_items = sketch_pad.search_by_tags({"preference"})
print(f" Items with the preference tag: {len(preference_items)}")
recent_items = sketch_pad.search_by_tags({"recent", "files"}, match_all=True)
print(f" Items with both recent and files tags: {len(recent_items)}")
# Test content search.
print("6. Testing content search...")
search_results = sketch_pad.search_by_content("file", limit=3)
print(f" Content containing 'file': {len(search_results)}")
# Test statistics.
print("7. Testing statistics...")
stats = sketch_pad.get_statistics()
print(f" Total item count: {stats.total_items}")
print(f" Total accesses: {stats.total_accesses}")
print(f" Popular tags: {stats.popular_tags}")
# Test listing items.
print("8. Testing item listing...")
items = sketch_pad.list_items(include_value=False)
print(f" Item list: {len(items)} items")
for item in items:
print(f" - {item.key}: {item.summary}")
# Test persistence.
print("9. Testing persistence...")
sketch_pad.persist()
print(" Persistence complete")
# Test deletion.
print("10. Testing deletion...")
deleted = sketch_pad.delete("temp_data")
print(f" Deleted temp_data: {deleted}")
print(f" temp_data exists: {sketch_pad.exists('temp_data')}")
print("=== Test Complete ===")
finally:
# Clean up.
if os.path.exists(temp_file):
os.unlink(temp_file)
async def test_sketch_pad_advanced_features():
"""Test advanced SketchPad features."""
print("\n=== Test Advanced SketchPad Features ===")
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
temp_file = f.name
try:
sketch_pad = _create_sketch_pad("advanced_test", temp_file)
# Test complex data structures.
print("1. Testing complex data structures...")
complex_data = {
"nested": {"list": [1, 2, 3], "dict": {"a": 1, "b": 2}, "string": "test"},
"array": [{"id": 1, "name": "item1"}, {"id": 2, "name": "item2"}],
}
await sketch_pad.set_item(
key="complex_data",
value=complex_data,
summary="Complex nested data structure",
tags={"complex", "nested", "data"},
)
# Test access counting.
print("2. Testing access counting...")
for i in range(5):
item = sketch_pad.get_item("complex_data")
print(f" Access {i + 1}, access count: {item.access_count if item else 0}")
# Test expiration time.
print("3. Testing expiration time...")
await sketch_pad.set_item(
key="expiring_item",
value="This item will expire",
summary="Test expiration behavior",
tags={"expire", "test"},
ttl=2, # Expires after 2 seconds.
)
print(" Waiting 3 seconds for the item to expire...")
await asyncio.sleep(3)
expired_item = sketch_pad.get_item("expiring_item")
print(f" Expired item: {'expired' if expired_item is None else 'not expired'}")
# Test serialization and deserialization.
print("4. Testing serialization and deserialization...")
serialized = sketch_pad.serialize()
print(f" Serialized data size: {len(json.dumps(serialized, default=str))} characters")
# Create a new sketch pad and deserialize the data.
new_sketch_pad = _create_sketch_pad("restored_test", temp_file + ".restored")
new_sketch_pad.deserialize(serialized)
restored_item = new_sketch_pad.get_item("complex_data")
print(
f" Data after deserialization: {restored_item.value if restored_item else 'Not found'}"
)
print("=== Advanced Feature Test Complete ===")
finally:
if os.path.exists(temp_file):
os.unlink(temp_file)
if os.path.exists(temp_file + ".restored"):
os.unlink(temp_file + ".restored")
async def main():
"""Main test function."""
print("Starting SketchPad tests...")
try:
await test_sketch_pad_basic_operations()
await test_sketch_pad_advanced_features()
print("\nAll tests complete!")
except Exception as e:
print(f"Error during tests: {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,260 @@
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import asyncio
import tempfile
import time
from context.sketch_pad import RedisFileSketchPadBackend
from config.config import get_config
def _create_sketch_pad(sketch_pad_id: str, file_path: str) -> RedisFileSketchPadBackend:
config = get_config()
return RedisFileSketchPadBackend(
sketch_pad_id=sketch_pad_id,
redis_host=config.REDIS_HOST,
redis_port=int(config.REDIS_PORT),
redis_db=int(config.REDIS_DB),
file_path=file_path,
)
async def test_comprehensive_operations():
"""Comprehensive SketchPad feature test."""
print("=== Comprehensive SketchPad Feature Test ===")
# Create a temporary file.
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
temp_file = f.name
try:
# Initialize SketchPad.
sketch_pad = _create_sketch_pad("comprehensive_test", temp_file)
print("1. Testing basic CRUD operations...")
# Create items.
await sketch_pad.set_item(
key="user_profile",
value={"name": "Zhang San", "age": 30, "city": "Beijing"},
summary="User profile information",
tags={"profile", "user", "personal"},
)
await sketch_pad.set_item(
key="project_config",
value={"debug": True, "log_level": "INFO", "max_workers": 4},
summary="Project configuration information",
tags={"config", "project", "settings"},
)
await sketch_pad.set_item(
key="temp_note",
value="This is a temporary note for testing",
summary="Temporary note",
tags={"note", "temp"},
ttl=10, # Expires after 10 seconds.
)
# Read items.
profile_item = sketch_pad.get_item("user_profile")
print(f" User information: {profile_item.value if profile_item else 'Not found'}")
config_value = sketch_pad.get_value("project_config")
print(f" Project configuration: {config_value}")
# Check existence.
print(f" user_profile exists: {sketch_pad.exists('user_profile')}")
print(f" non_existent exists: {sketch_pad.exists('non_existent')}")
print("2. Testing tag search...")
# Single-tag search.
profile_items = sketch_pad.search_by_tags({"profile"})
print(f" Items with the profile tag: {len(profile_items)}")
# Multi-tag search (match any).
config_items = sketch_pad.search_by_tags({"config", "settings"})
print(f" Items with the config or settings tag: {len(config_items)}")
# Multi-tag search (match all).
all_match_items = sketch_pad.search_by_tags(
{"config", "project"}, match_all=True
)
print(f" Items with both config and project tags: {len(all_match_items)}")
print("3. Testing content search...")
# Search for items containing specific content.
search_results = sketch_pad.search_by_content("configuration", limit=5)
print(f" Items containing 'configuration': {len(search_results)}")
search_results = sketch_pad.search_by_content("Beijing", limit=5)
print(f" Items containing 'Beijing': {len(search_results)}")
print("4. Testing access statistics...")
# Access the same item multiple times.
for i in range(3):
item = sketch_pad.get_item("user_profile")
print(
f" Access {i + 1} to user_profile, access count: {item.access_count if item else 0}"
)
print("5. Testing expiration behavior...")
# Check whether the temporary item has expired.
print(" Waiting 5 seconds to check expiration behavior...")
await asyncio.sleep(5)
temp_item = sketch_pad.get_item("temp_note")
print(f" Temporary item status: {'expired' if temp_item is None else 'not expired'}")
print("6. Testing statistics...")
stats = sketch_pad.get_statistics()
print(f" Total item count: {stats.total_items}")
print(f" Total accesses: {stats.total_accesses}")
print(f" Items with summaries: {stats.items_with_summary}")
print(f" Popular tags: {stats.popular_tags}")
print(f" Content type statistics: {stats.content_types}")
print(f" Average access count: {stats.avg_access_per_item:.2f}")
print("7. Testing listing functionality...")
items = sketch_pad.list_items(include_value=False)
print(f" Item list (without values): {len(items)} items")
for item in items:
print(f" - {item.key}: {item.summary} (access count: {item.access_count})")
items_with_values = sketch_pad.list_items(include_value=True)
print(f" Item list (with values): {len(items_with_values)} items")
print("8. Testing persistence and restoration...")
# Persist data.
sketch_pad.persist()
print(" Data persisted to file")
# Create a new sketch pad and restore data.
new_sketch_pad = _create_sketch_pad("restored_test", temp_file + ".restored")
# Copy data.
data = sketch_pad.serialize()
new_sketch_pad.deserialize(data)
# Verify restored data.
restored_profile = new_sketch_pad.get_item("user_profile")
print(
f" Restored user information: {restored_profile.value if restored_profile else 'Not found'}"
)
print("9. Testing deletion functionality...")
# Delete one item.
deleted = sketch_pad.delete("project_config")
print(f" Deleted project_config: {deleted}")
print(f" project_config exists: {sketch_pad.exists('project_config')}")
# Verify that the tag index was also deleted.
config_search = sketch_pad.search_by_tags({"config"})
print(f" Items with the config tag after deletion: {len(config_search)}")
print("10. Testing clear functionality...")
# Clear all data.
sketch_pad.clear()
print(" Data cleared")
# Verify the clear result.
remaining_keys = sketch_pad.keys()
print(f" Remaining key count: {len(remaining_keys)}")
print("=== Comprehensive Feature Test Complete ===")
finally:
if os.path.exists(temp_file):
os.unlink(temp_file)
if os.path.exists(temp_file + ".restored"):
os.unlink(temp_file + ".restored")
async def test_performance():
"""Test performance."""
print("\n=== SketchPad Performance Test ===")
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
temp_file = f.name
try:
sketch_pad = _create_sketch_pad("performance_test", temp_file)
print("1. Testing batch write performance...")
start_time = time.time()
for i in range(100):
await sketch_pad.set_item(
key=f"item_{i}",
value=f"This is the data for item {i}",
summary=f"Summary for item {i}",
tags={f"tag_{i % 10}", f"category_{i % 5}"},
)
write_time = time.time() - start_time
print(f" Time to write 100 items: {write_time:.3f} seconds")
print(f" Average write speed: {100 / write_time:.1f} items/second")
print("2. Testing batch read performance...")
start_time = time.time()
for i in range(100):
item = sketch_pad.get_item(f"item_{i}")
read_time = time.time() - start_time
print(f" Time to read 100 items: {read_time:.3f} seconds")
print(f" Average read speed: {100 / read_time:.1f} items/second")
print("3. Testing search performance...")
start_time = time.time()
search_results = sketch_pad.search_by_tags({"tag_1"})
search_time = time.time() - start_time
print(f" Tag search time: {search_time:.3f} seconds")
print(f" Search result count: {len(search_results)}")
print("4. Testing statistics performance...")
start_time = time.time()
stats = sketch_pad.get_statistics()
stats_time = time.time() - start_time
print(f" Statistics calculation time: {stats_time:.3f} seconds")
print(f" Statistics result: {stats.total_items} items")
print("=== Performance Test Complete ===")
finally:
if os.path.exists(temp_file):
os.unlink(temp_file)
async def main():
"""Main test function."""
print("Starting comprehensive SketchPad tests...")
try:
await test_comprehensive_operations()
await test_performance()
print("\nAll tests complete! SketchPad is functioning normally.")
except Exception as e:
print(f"Error during tests: {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,81 @@
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import asyncio
import tempfile
import os
from context.sketch_pad import RedisFileSketchPadBackend
from config.config import get_config
def _create_sketch_pad(sketch_pad_id: str, file_path: str) -> RedisFileSketchPadBackend:
config = get_config()
return RedisFileSketchPadBackend(
sketch_pad_id=sketch_pad_id,
redis_host=config.REDIS_HOST,
redis_port=int(config.REDIS_PORT),
redis_db=int(config.REDIS_DB),
file_path=file_path,
)
async def test_basic_operations():
"""Test basic operations."""
print("=== SketchPad Basic Operation Test ===")
# Create a temporary file.
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
temp_file = f.name
try:
# Initialize SketchPad.
sketch_pad = _create_sketch_pad("test_pad", temp_file)
# Set items.
print("1. Setting items...")
await sketch_pad.set_item(
key="user_config",
value={"theme": "dark", "lang": "en"},
summary="User configuration",
tags={"config", "user"},
)
await sketch_pad.set_item(
key="temp_data", value="temporary data", summary="temporary storage", tags={"temp"}, ttl=5
)
# Get items.
print("2. Getting items...")
item = sketch_pad.get_item("user_config")
print(f" user_config: {item.value if item else 'Not found'}")
# Check existence.
print("3. Checking existence...")
print(f" user_config exists: {sketch_pad.exists('user_config')}")
# Get all keys.
print("4. Getting all keys...")
keys = sketch_pad.keys()
print(f" All keys: {keys}")
# Search by tag.
print("5. Searching by tag...")
config_items = sketch_pad.search_by_tags({"config"})
print(f" Configuration item count: {len(config_items)}")
# Statistics.
print("6. Statistics...")
stats = sketch_pad.get_statistics()
print(f" Total item count: {stats.total_items}")
print("=== Test Complete ===")
finally:
if os.path.exists(temp_file):
os.unlink(temp_file)
if __name__ == "__main__":
asyncio.run(test_basic_operations())
@@ -0,0 +1,177 @@
import os
import sys
from types import SimpleNamespace
import pytest
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
WORKSPACE_ROOT = os.path.join(PROJECT_ROOT, "workspace")
import tools.requirements_tools as requirements_tools_module
import tools.reference_image as reference_image_module
from tools.requirements_tools import (
create_requirement_refinement_subagent_tools,
make_user_query_more_detailed,
)
def _tool_name(tool):
if hasattr(tool, "_tool"):
return tool._tool.name
return getattr(tool, "name", getattr(tool, "__name__", None))
def test_requirement_refinement_subagent_tools_use_workspace_tools_only():
tools = create_requirement_refinement_subagent_tools()
assert [_tool_name(tool) for tool in tools] == [
"execute_command",
"sketch_pad_operations",
"read_file",
"grep",
"sed",
"echo_into",
]
@pytest.mark.asyncio
async def test_make_user_query_more_detailed_runs_specialist_subagent(
monkeypatch, tmp_path
):
monkeypatch.chdir(WORKSPACE_ROOT)
calls = {"specialist": 0}
stored = {}
image_path = tmp_path / "query_image_001.png"
image_path.write_bytes(b"fake-png-bytes")
async def fake_run_subagent_with_events(**kwargs):
calls["specialist"] += 1
request = kwargs["specialist_kwargs"]["message"]
text = request[0]["text"] if isinstance(request, list) else request
assert kwargs["specialist_kwargs"]["history"] == []
assert "SKILL.md" in text
assert "Current working directory:" in text
assert "Skill root: use the preferred skill root below." in text
assert "Preferred skill root:" in text
assert "references/docs/api/README.md" in text
assert "## API Reference" in text
assert "## Refined User Requirements" in text
assert "## Parameter Table" in text
assert "## Modeling Process" in text
assert "## Notes" in text
return (
"## API Reference\n- skill doc evidence\n\n"
"## Refined User Requirements\n- refined requirements\n\n"
"## Parameter Table\n- none\n\n"
"## Modeling Process\n- step one\n\n"
"## Notes\n- note"
)
monkeypatch.setattr(
requirements_tools_module,
"run_subagent_with_events",
fake_run_subagent_with_events,
)
class FakeSketchPad:
async def set_item(self, key, value, ttl=None, summary=None, tags=None):
stored["key"] = key
stored["value"] = value
stored["tags"] = tags
return key
monkeypatch.setattr(
requirements_tools_module,
"get_current_sketch_pad",
lambda: FakeSketchPad(),
)
result = await make_user_query_more_detailed(
query="Create a box",
query_image_path=str(image_path),
)
assert calls["specialist"] == 1
assert "SketchPad Key" in result
assert stored["key"].startswith("req_")
assert "Refined User Requirements" in stored["value"]
@pytest.mark.asyncio
async def test_make_user_query_more_detailed_uses_latest_uploaded_image_when_omitted(
monkeypatch, tmp_path
):
monkeypatch.chdir(WORKSPACE_ROOT)
calls = {"specialist": 0}
stored = {}
image_path = tmp_path / "query_image_001.png"
image_path.write_bytes(b"fake-png-bytes")
async def fake_run_subagent_with_events(**kwargs):
calls["specialist"] += 1
request = kwargs["specialist_kwargs"]["message"]
assert isinstance(request, list)
assert request[0]["type"] == "text"
assert request[1]["type"] == "image_url"
assert request[1]["image_url"]["url"].startswith("data:image/png;base64,")
assert kwargs["status_payload"]["query_image_path"] == str(image_path.resolve())
return (
"## API Reference\n- skill doc evidence\n\n"
"## Refined User Requirements\n- refined requirements\n\n"
"## Parameter Table\n- none\n\n"
"## Modeling Process\n- step one\n\n"
"## Notes\n- note"
)
monkeypatch.setattr(
requirements_tools_module,
"run_subagent_with_events",
fake_run_subagent_with_events,
)
class FakeContext:
def retrieve_full_messages(self):
return [
SimpleNamespace(
role="user",
content=[
{"type": "text", "text": "Create a box"},
{
"type": "image_url",
"image_url": {
"url": "data:image/png;base64,abcd",
"local_path": str(image_path),
},
},
],
)
]
monkeypatch.setattr(
reference_image_module,
"get_current_context",
lambda: FakeContext(),
)
class FakeSketchPad:
async def set_item(self, key, value, ttl=None, summary=None, tags=None):
stored["key"] = key
stored["value"] = value
stored["tags"] = tags
return key
monkeypatch.setattr(
requirements_tools_module,
"get_current_sketch_pad",
lambda: FakeSketchPad(),
)
result = await make_user_query_more_detailed(query="Create a box")
assert calls["specialist"] == 1
assert "SketchPad Key" in result
assert stored["key"].startswith("req_")
@@ -0,0 +1,47 @@
import os
import sys
from datetime import datetime, timezone
import pytest
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
from SimpleLLMFunc.hooks.events import ReActEventType, ReactEndEvent
from SimpleLLMFunc.hooks.stream import EventYield, ResponseYield
from tools.subagent_utils import run_subagent_with_events
@pytest.mark.asyncio
async def test_run_subagent_with_events_ignores_empty_chunk_repr_and_uses_react_end_text():
async def fake_specialist(**kwargs):
yield ResponseYield(response="", messages=[])
yield EventYield(
event=ReactEndEvent(
event_type=ReActEventType.REACT_END,
timestamp=datetime.now(timezone.utc),
trace_id="trace-1",
func_name="specialist",
iteration=1,
final_response="## Refined User Requirements\n- final answer",
final_messages=[],
total_iterations=1,
total_execution_time=0.1,
total_tool_calls=0,
total_llm_calls=1,
total_token_usage=None,
)
)
result = await run_subagent_with_events(
specialist_callable=fake_specialist,
specialist_kwargs={"message": "test", "history": []},
subagent_label="Requirement Refinement Specialist",
event_emitter=None,
)
assert result == "## Refined User Requirements\n- final answer"
@@ -0,0 +1,206 @@
import os
import sys
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
from web_interface.artifacts import extract_latest_artifacts
def test_extract_latest_artifacts_prefers_tagged_files(tmp_path):
workspace_dir = tmp_path / "workspace" / "demo_part"
workspace_dir.mkdir(parents=True)
code_path = workspace_dir / "model.py"
model_path = workspace_dir / "part.stl"
code_path.write_text("print('demo')\n", encoding="utf-8")
model_path.write_text("solid demo\nendsolid demo\n", encoding="utf-8")
messages = [
{
"role": "assistant",
"content": (
"Saved files\n"
"<|code_file|>workspace/demo_part/model.py</|code_file|>\n"
"<|output_file|>workspace/demo_part/part.stl</|output_file|>"
),
}
]
artifacts = extract_latest_artifacts(messages, project_root=tmp_path)
assert artifacts["code_path"] == code_path.resolve(strict=False)
assert artifacts["code_paths"] == [code_path.resolve(strict=False)]
assert artifacts["model_path"] == model_path.resolve(strict=False)
assert artifacts["model_paths"] == [model_path.resolve(strict=False)]
assert artifacts["output_paths"] == [model_path.resolve(strict=False)]
def test_extract_latest_artifacts_accepts_legacy_closing_tags(tmp_path):
workspace_dir = tmp_path / "workspace" / "demo_part"
workspace_dir.mkdir(parents=True)
code_path = workspace_dir / "model.py"
model_path = workspace_dir / "part.stl"
code_path.write_text("print('demo')\n", encoding="utf-8")
model_path.write_text("solid demo\nendsolid demo\n", encoding="utf-8")
messages = [
{
"role": "assistant",
"content": (
"Saved files\n"
"<|code_file|>./demo_part/model.py</|code_file>\n"
"<|output_file|>./demo_part/part.stl</|output_file>"
),
}
]
artifacts = extract_latest_artifacts(messages, project_root=tmp_path / "workspace")
assert artifacts["code_path"] == code_path.resolve(strict=False)
assert artifacts["code_paths"] == [code_path.resolve(strict=False)]
assert artifacts["model_path"] == model_path.resolve(strict=False)
assert artifacts["model_paths"] == [model_path.resolve(strict=False)]
assert artifacts["output_paths"] == [model_path.resolve(strict=False)]
def test_extract_latest_artifacts_falls_back_to_latest_stl_near_model_py(tmp_path):
workspace_dir = tmp_path / "workspace" / "fallback_case"
workspace_dir.mkdir(parents=True)
code_path = workspace_dir / "model.py"
older_stl = workspace_dir / "older.stl"
newer_stl = workspace_dir / "newer.stl"
code_path.write_text("print('demo')\n", encoding="utf-8")
older_stl.write_text("solid older\nendsolid older\n", encoding="utf-8")
newer_stl.write_text("solid newer\nendsolid newer\n", encoding="utf-8")
os.utime(older_stl, (1, 1))
os.utime(newer_stl, (2, 2))
messages = [
{
"role": "assistant",
"content": "<|code_file|>workspace/fallback_case/model.py</|code_file|>",
}
]
artifacts = extract_latest_artifacts(messages, project_root=tmp_path)
assert artifacts["code_path"] == code_path.resolve(strict=False)
assert artifacts["code_paths"] == [code_path.resolve(strict=False)]
assert artifacts["model_path"] == newer_stl.resolve(strict=False)
assert artifacts["model_paths"] == [newer_stl.resolve(strict=False)]
assert artifacts["output_paths"] == []
def test_extract_latest_artifacts_resolves_output_file_relative_to_workspace_root(
tmp_path,
):
workspace_dir = tmp_path / "workspace" / "demo_part"
workspace_dir.mkdir(parents=True)
code_path = workspace_dir / "model.py"
model_path = workspace_dir / "part.stl"
code_path.write_text("print('demo')\n", encoding="utf-8")
model_path.write_text("solid demo\nendsolid demo\n", encoding="utf-8")
messages = [
{
"role": "assistant",
"content": (
"Saved files\n"
"<|code_file|>workspace/demo_part/model.py</|code_file|>\n"
"<|output_file|>./demo_part/part.stl</|output_file|>"
),
}
]
artifacts = extract_latest_artifacts(messages, project_root=tmp_path)
assert artifacts["code_path"] == code_path.resolve(strict=False)
assert artifacts["code_paths"] == [code_path.resolve(strict=False)]
assert artifacts["model_path"] == model_path.resolve(strict=False)
assert artifacts["model_paths"] == [model_path.resolve(strict=False)]
assert artifacts["output_paths"] == [model_path.resolve(strict=False)]
def test_extract_latest_artifacts_resolves_output_file_relative_to_code_dir(tmp_path):
workspace_dir = tmp_path / "workspace" / "demo_part"
workspace_dir.mkdir(parents=True)
code_path = workspace_dir / "model.py"
model_path = workspace_dir / "part.stl"
code_path.write_text("print('demo')\n", encoding="utf-8")
model_path.write_text("solid demo\nendsolid demo\n", encoding="utf-8")
messages = [
{
"role": "assistant",
"content": (
"Saved files\n"
"<|code_file|>workspace/demo_part/model.py</|code_file|>\n"
"<|output_file|>./part.stl</|output_file|>"
),
}
]
artifacts = extract_latest_artifacts(messages, project_root=tmp_path)
assert artifacts["code_path"] == code_path.resolve(strict=False)
assert artifacts["code_paths"] == [code_path.resolve(strict=False)]
assert artifacts["model_path"] == model_path.resolve(strict=False)
assert artifacts["model_paths"] == [model_path.resolve(strict=False)]
assert artifacts["output_paths"] == [model_path.resolve(strict=False)]
def test_extract_latest_artifacts_returns_manual_selection_candidates_in_recency_order(
tmp_path,
):
alpha_dir = tmp_path / "workspace" / "alpha"
beta_dir = tmp_path / "workspace" / "beta"
alpha_dir.mkdir(parents=True)
beta_dir.mkdir(parents=True)
alpha_code = alpha_dir / "model.py"
beta_code = beta_dir / "model.py"
alpha_model = alpha_dir / "alpha.stl"
beta_model = beta_dir / "beta.stl"
alpha_code.write_text("print('alpha')\n", encoding="utf-8")
beta_code.write_text("print('beta')\n", encoding="utf-8")
alpha_model.write_text("solid alpha\nendsolid alpha\n", encoding="utf-8")
beta_model.write_text("solid beta\nendsolid beta\n", encoding="utf-8")
messages = [
{
"role": "assistant",
"content": (
"<|code_file|>workspace/alpha/model.py</|code_file|>\n"
"<|output_file|>workspace/alpha/alpha.stl</|output_file|>"
),
},
{
"role": "assistant",
"content": (
"<|code_file|>workspace/beta/model.py</|code_file|>\n"
"<|output_file|>workspace/beta/beta.stl</|output_file|>"
),
},
]
artifacts = extract_latest_artifacts(messages, project_root=tmp_path)
assert artifacts["code_path"] == beta_code.resolve(strict=False)
assert artifacts["code_paths"] == [
beta_code.resolve(strict=False),
alpha_code.resolve(strict=False),
]
assert artifacts["model_path"] == beta_model.resolve(strict=False)
assert artifacts["model_paths"] == [
beta_model.resolve(strict=False),
alpha_model.resolve(strict=False),
]
assert artifacts["output_paths"] == [
beta_model.resolve(strict=False),
alpha_model.resolve(strict=False),
]
@@ -0,0 +1,434 @@
# pyright: reportCallIssue=false, reportArgumentType=false
import os
import sys
from datetime import datetime
import importlib
import json
from typing import Any, cast
import pytest
from contextlib import contextmanager
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
from SimpleLLMFunc.hooks.events import ReactEndEvent, ReactStartEvent, ReActEventType
from SimpleLLMFunc.hooks.stream import EventOrigin, EventYield, ResponseYield
from web_interface.models import ChatCompletionRequest, ChatMessage
chat_router_module = importlib.import_module("web_interface.routers.chat_router")
utils_module = importlib.import_module("web_interface.utils")
from web_interface.routers.chat_router import stream_chat_completion, stream_chat_events
from web_interface.utils import process_agent_response, validate_chat_request
class _FakeConversation:
def __init__(self):
self.uuid = "conversation-1"
self.context = type("Ctx", (), {"persist": self._persist})()
self.sketch_pad = type("Sketch", (), {"persist": lambda self: None})()
self.persisted = False
async def _persist(self):
self.persisted = True
return True
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
class _FakeAgent:
def __init__(self, outputs):
self.outputs = outputs
self.name = "fake-agent"
self.queries = []
self.raw_contents = []
async def run(self, query, raw_user_content=None):
self.queries.append(query)
self.raw_contents.append(raw_user_content)
for output in self.outputs:
yield output
def _origin(seq: int = 1) -> EventOrigin:
return EventOrigin(
session_id="session-1",
agent_call_id="agent-call-1",
event_seq=seq,
)
def _request() -> ChatCompletionRequest:
return _request_with_messages([_user_message("make a cube")], stream=True)
def _user_message(content: Any) -> ChatMessage:
return ChatMessage(
role="user",
content=content,
name=None,
tool_calls=None,
tool_call_id=None,
)
def _request_with_messages(
messages: list[ChatMessage],
*,
stream: bool = True,
) -> ChatCompletionRequest:
return ChatCompletionRequest(
model="cadagent",
messages=messages,
temperature=1.0,
top_p=1.0,
n=1,
stream=stream,
stop=None,
max_tokens=None,
presence_penalty=0.0,
frequency_penalty=0.0,
logit_bias=None,
user=None,
tools=None,
tool_choice=None,
)
def _parse_sse_lines(lines):
current_event = "message"
data_lines = []
def _flush_packet():
nonlocal current_event, data_lines
if not data_lines:
return None
payload_text = "\n".join(data_lines)
try:
payload = json.loads(payload_text)
except json.JSONDecodeError:
payload = {"raw": payload_text}
packet = {"event": current_event, "data": payload}
current_event = "message"
data_lines = []
return packet
for raw_line in lines:
line = (
raw_line.decode("utf-8") if isinstance(raw_line, bytes) else str(raw_line)
)
if line == "":
packet = _flush_packet()
if packet is not None:
yield packet
continue
if line.startswith(":"):
continue
if line.startswith("event:"):
current_event = line[6:].strip() or "message"
continue
if line.startswith("data:"):
data_lines.append(line[5:].strip())
packet = _flush_packet()
if packet is not None:
yield packet
def test_validate_chat_request_accepts_image_only_user_message():
request = _request_with_messages(
[
_user_message(
[
{
"type": "image_url",
"image_url": {"url": "data:image/png;base64,abcd"},
}
]
)
],
stream=False,
)
query, request_id, raw_user_content = validate_chat_request(request)
assert isinstance(query, list)
assert request_id.startswith("chatcmpl-")
assert isinstance(raw_user_content, list)
@pytest.mark.asyncio
async def test_stream_chat_events_passes_multimodal_query_to_agent():
request = _request_with_messages(
[
_user_message(
[
{"type": "text", "text": "analyze this"},
{
"type": "image_url",
"image_url": {"url": "data:image/png;base64,abcd"},
},
]
)
],
stream=True,
)
conversation = _FakeConversation()
agent = _FakeAgent([ResponseYield(response="ok", messages=[])])
_ = [
packet
async for packet in stream_chat_events(
request, cast(Any, conversation), cast(Any, agent)
)
]
assert len(agent.queries) == 1
assert isinstance(agent.queries[0], list)
assert isinstance(agent.raw_contents[0], list)
@pytest.mark.asyncio
async def test_stream_chat_completion_projects_only_response_packets():
request = _request()
conversation = _FakeConversation()
outputs = [
EventYield(
event=ReactStartEvent(
event_type=ReActEventType.REACT_START,
timestamp=datetime(2026, 3, 18, 12, 0, 0),
trace_id="trace-1",
func_name="chat_impl",
iteration=0,
user_task_prompt="make a cube",
initial_messages=[],
available_tools=[],
),
origin=_origin(1),
),
ResponseYield(
response=cast(
Any,
{
"id": "chunk-1",
"object": "chat.completion.chunk",
"created": 1,
"model": "cadagent",
"choices": [
{
"index": 0,
"delta": {
"role": "assistant",
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {
"name": "cad_code_generator",
"arguments": "{}",
},
}
],
},
"finish_reason": None,
}
],
},
),
messages=[],
),
]
agent = _FakeAgent(outputs)
packets = [
packet
async for packet in stream_chat_completion(
request,
"chatcmpl-test",
cast(Any, conversation),
cast(Any, agent),
)
]
assert any('"tool_calls"' in packet for packet in packets)
assert not any("react_start" in packet for packet in packets)
assert packets[-1] == "data: [DONE]\n\n"
@pytest.mark.asyncio
async def test_stream_chat_events_emits_named_sse_events_and_done():
request = _request()
conversation = _FakeConversation()
outputs = [
EventYield(
event=ReactStartEvent(
event_type=ReActEventType.REACT_START,
timestamp=datetime(2026, 3, 18, 12, 0, 0),
trace_id="trace-1",
func_name="chat_impl",
iteration=0,
user_task_prompt="make a cube",
initial_messages=[],
available_tools=[],
),
origin=_origin(1),
),
ResponseYield(
response="hello", messages=[{"role": "assistant", "content": "hello"}]
),
EventYield(
event=ReactEndEvent(
event_type=ReActEventType.REACT_END,
timestamp=datetime(2026, 3, 18, 12, 0, 1),
trace_id="trace-1",
func_name="chat_impl",
iteration=1,
final_response="hello",
final_messages=[{"role": "assistant", "content": "hello"}],
total_iterations=1,
total_execution_time=0.5,
total_tool_calls=0,
total_llm_calls=1,
),
origin=_origin(2),
),
]
agent = _FakeAgent(outputs)
packets = [
packet
async for packet in stream_chat_events(
request,
cast(Any, conversation),
cast(Any, agent),
)
]
assert packets[0].startswith("event: react_start\n")
assert any(packet.startswith("event: response\n") for packet in packets)
assert any('"delta_text": "hello"' in packet for packet in packets)
assert packets[-1].startswith("event: done\n")
@pytest.mark.asyncio
async def test_process_agent_response_aggregates_text_from_response_yields_only():
conversation = _FakeConversation()
outputs = [
EventYield(
event=ReactStartEvent(
event_type=ReActEventType.REACT_START,
timestamp=datetime(2026, 3, 18, 12, 0, 0),
trace_id="trace-1",
func_name="chat_impl",
iteration=0,
user_task_prompt="make a cube",
initial_messages=[],
available_tools=[],
),
origin=_origin(1),
),
ResponseYield(response="hello", messages=[]),
ResponseYield(response=" world", messages=[]),
]
agent = _FakeAgent(outputs)
full_response, prompt_tokens, completion_tokens = await process_agent_response(
"make a cube", cast(Any, conversation), cast(Any, agent)
)
assert full_response == "hello world"
assert prompt_tokens is None
assert completion_tokens is None
assert conversation.persisted is True
def test_api_client_parse_sse_lines_understands_event_and_data_frames():
lines = [
b"event: response",
b'data: {"delta_text": "hello"}',
b"",
b"event: tool_call_start",
b'data: {"event_type": "tool_call_start"}',
b"",
b"event: done",
b'data: {"ok": true}',
b"",
]
packets = list(_parse_sse_lines(lines))
assert packets == [
{"event": "response", "data": {"delta_text": "hello"}},
{"event": "tool_call_start", "data": {"event_type": "tool_call_start"}},
{"event": "done", "data": {"ok": True}},
]
@pytest.mark.asyncio
async def test_stream_chat_events_propagates_conversation_session(monkeypatch):
request = _request()
conversation = _FakeConversation()
outputs = [ResponseYield(response="hello", messages=[])]
agent = _FakeAgent(outputs)
captured: dict[str, object] = {}
@contextmanager
def fake_propagate_conversation_session(**kwargs):
captured.update(kwargs)
yield
monkeypatch.setattr(
chat_router_module,
"propagate_conversation_session",
fake_propagate_conversation_session,
)
packets = [
packet
async for packet in stream_chat_events(
request,
cast(Any, conversation),
cast(Any, agent),
)
]
assert any(packet.startswith("event: response\n") for packet in packets)
assert captured["conversation_id"] == "conversation-1"
assert captured["tags"] == ["cadagent", "event_stream"]
@pytest.mark.asyncio
async def test_process_agent_response_propagates_conversation_session(monkeypatch):
conversation = _FakeConversation()
agent = _FakeAgent([ResponseYield(response="hello", messages=[])])
captured: dict[str, object] = {}
@contextmanager
def fake_propagate_conversation_session(**kwargs):
captured.update(kwargs)
yield
monkeypatch.setattr(
utils_module,
"propagate_conversation_session",
fake_propagate_conversation_session,
)
full_response, _, _ = await process_agent_response(
"make a cube", cast(Any, conversation), cast(Any, agent)
)
assert full_response == "hello"
assert captured["conversation_id"] == "conversation-1"
assert captured["tags"] == ["cadagent", "non_stream"]
+41
View File
@@ -0,0 +1,41 @@
"""Main entry point for the tools module."""
# Import all split tool modules.
from .common import print_tool_output
from .command_tools import (
execute_command,
)
# Requirement refinement tools.
from .requirements_tools import (
make_user_query_more_detailed,
)
# Code generation and execution tools.
from .code_tools import (
cad_code_generator,
)
# SketchPad operation tools.
from .sketch_tools import (
sketch_pad_operations,
)
# Model multi-view rendering tools.
from .model_view_tools import get_visual_feedback
from .builtin_file_toolkit import create_builtin_file_tools
# Export all tool functions to maintain backward compatibility.
__all__ = [
"make_user_query_more_detailed",
"cad_code_generator",
"execute_command",
"sketch_pad_operations",
"get_visual_feedback",
"print_tool_output",
"create_builtin_file_tools",
]
@@ -0,0 +1,17 @@
from __future__ import annotations
from pathlib import Path
from typing import List, Optional
from SimpleLLMFunc.builtin import FileToolset
from SimpleLLMFunc.tool import Tool
def create_builtin_file_tools(workspace: Optional[str | Path] = None) -> List[Tool]:
"""Create SimpleLLMFunc builtin file tools scoped to the active workspace."""
root = Path(workspace).expanduser().resolve() if workspace else Path.cwd().resolve()
return FileToolset(root).toolset
__all__ = ["create_builtin_file_tools"]
+274
View File
@@ -0,0 +1,274 @@
"""CAD code generation tool implemented as a specialist subagent."""
from __future__ import annotations
from pathlib import Path
import re
import shlex
from typing import Any, Optional
from SimpleLLMFunc import llm_chat, tool
from SimpleLLMFunc.type import HistoryList
from .builtin_file_toolkit import create_builtin_file_tools
from .command_tools import execute_command
from .common import (
SUBAGENT_MAX_TOOL_CALLS,
build_simplecad_workspace_fact_block,
get_config,
print_tool_output,
)
from .sketch_tools import sketch_pad_operations
from .subagent_utils import run_subagent_with_events
def create_codegen_subagent_tools(
workspace: Optional[str | Path] = None,
) -> list[Any]:
"""Create the low-level tools owned by the CAD codegen specialist."""
return [
execute_command,
sketch_pad_operations,
*create_builtin_file_tools(workspace),
]
def _build_codegen_request(
*,
task: str,
target_file_path: str,
validation_command: Optional[str],
requirement_sketch_key: Optional[str] = None,
) -> str:
parts = [f"target_file: {target_file_path}"]
if validation_command and validation_command.strip():
parts.append(f"validation_command: {validation_command.strip()}")
if requirement_sketch_key and requirement_sketch_key.strip():
parts.append(f"requirement_key: {requirement_sketch_key.strip()}")
parts.append("")
parts.append(build_simplecad_workspace_fact_block())
parts.append("")
parts.append(task.strip())
return "\n".join(parts)
def _extract_python_code_block(text: str) -> Optional[str]:
match = re.search(r"```python\s*(.*?)```", text, flags=re.DOTALL)
if match:
return match.group(1).strip()
return None
def _build_missing_code_retry_request(
*,
target_file_path: str,
) -> str:
return "\n".join(
[
"<RETRY_AFTER_NO_CODE>",
f"You ended without writing any script to {target_file_path}.",
f"You must write the required Python script directly to {target_file_path} before you finish.",
"Do not stop after planning, describing the approach, or pasting a code block in chat.",
"Use the file-writing tool now, save the script to disk, then validate/debug until export succeeds.",
"</RETRY_AFTER_NO_CODE>",
]
)
def _build_missing_code_retry_history(
*,
original_request: str,
prior_report: str,
) -> HistoryList:
history: HistoryList = [{"role": "user", "content": original_request}]
if prior_report.strip():
history.append({"role": "assistant", "content": prior_report.strip()})
return history
def _default_validation_command(
target_file_path: str, validation_command: Optional[str]
) -> Optional[str]:
if validation_command and validation_command.strip():
return validation_command.strip()
if target_file_path.endswith(".py"):
script_path = shlex.quote(target_file_path)
output_dir = shlex.quote(str(Path(target_file_path).parent or Path(".")))
stl_glob = f"{output_dir}/*.stl"
step_glob = f"{output_dir}/*.step"
stp_glob = f"{output_dir}/*.stp"
return (
f"uv run python {script_path} && "
f"ls {stl_glob} && "
f"(ls {step_glob} || ls {stp_glob})"
)
return None
def _read_latest_code(target_file_path: str) -> Optional[str]:
candidate = Path(target_file_path)
if not candidate.is_absolute():
candidate = Path.cwd() / candidate
if not candidate.exists() or not candidate.is_file():
return None
try:
return candidate.read_text(encoding="utf-8")
except Exception:
return None
@tool(
name="cad_code_generator",
description=(
"Delegate CAD script creation or repair to a specialist subagent that owns "
"builtin file tools and can iteratively debug the target file."
),
best_practices=[
"MUST pass requirement_sketch_key (the req_xxxx from make_user_query_more_detailed) so the specialist retrieves the detailed spec.",
"Use one complete natural-language task block instead of splitting context across many parameters.",
"In the task, always say whether this is create-new-file or modify-existing-code.",
"In the task, include the full user intent, failure context, relevant SketchPad ids, and success criteria.",
"In the task, explicitly tell the specialist to validate and keep debugging until STL and STEP/STP export succeeds.",
"Always provide the exact target_file_path for model.py or the file to repair.",
],
)
async def cad_code_generator(
task: str,
target_file_path: str,
requirement_sketch_key: Optional[str] = None,
event_emitter: Any = None,
) -> str:
"""Run the CAD coding specialist as a single-call subagent.
Args:
task: A complete natural-language mission for the coding specialist.
This should explicitly include:
- the full user intent,
- whether the job is create-new-file or modify-existing-code,
- the concrete modification target or creation goal,
- any traceback / visual feedback / failure context,
- any relevant SketchPad ids that the specialist should inspect,
- any reference-code SketchPad ids if they matter,
- the expected success criteria,
- and an explicit instruction to validate and keep debugging until model export succeeds.
Prefer one complete instruction block instead of splitting context across
many parameters.
target_file_path: The exact path of the script file that the specialist owns.
In the normal workflow this should be the final `model.py` path.
requirement_sketch_key: REQUIRED. The SketchPad key (e.g. req_xxxx) from make_user_query_more_detailed.
The specialist will retrieve and follow this detailed requirement.
event_emitter: Optional tool event emitter used to forward nested specialist
progress events back to the outer agent event stream.
Returns:
A concise report from the specialist plus the latest code snapshot.
"""
actual_validation_command = _default_validation_command(
target_file_path,
None,
)
request_payload = _build_codegen_request(
task=task.strip(),
target_file_path=target_file_path,
validation_command=actual_validation_command,
requirement_sketch_key=requirement_sketch_key,
)
print_tool_output(
"🧠 CAD Code Specialist",
"\n".join(
[
f"Target file: {target_file_path}",
f"Validation command: {actual_validation_command or '(not provided)'}",
f"Task summary: {task.strip()[:160]}",
]
),
)
report = await run_subagent_with_events(
specialist_callable=cad_code_generator_specialist,
specialist_kwargs={
"message": request_payload,
"history": [],
},
subagent_label="CAD Code Specialist",
event_emitter=event_emitter,
status_payload={
"target_file_path": target_file_path,
"validation_command": actual_validation_command,
},
)
written_code = _read_latest_code(target_file_path)
latest_code = written_code or _extract_python_code_block(report)
if written_code is None:
print_tool_output(
"⚠️ CAD Code Specialist",
"First attempt did not write the target file. Appending a stricter follow-up instruction.",
)
retry_report = await run_subagent_with_events(
specialist_callable=cad_code_generator_specialist,
specialist_kwargs={
"message": _build_missing_code_retry_request(
target_file_path=target_file_path,
),
"history": _build_missing_code_retry_history(
original_request=request_payload,
prior_report=report,
),
},
subagent_label="CAD Code Specialist",
event_emitter=event_emitter,
status_payload={
"target_file_path": target_file_path,
"validation_command": actual_validation_command,
"retry_reason": "no_code_written",
},
)
report = retry_report.strip() or report
written_code = _read_latest_code(target_file_path)
latest_code = written_code or _extract_python_code_block(report)
if latest_code is None:
return report.strip()
return (
f"{report.strip()}\n\n"
f"📁 Target file: {target_file_path}\n"
f"📄 Latest code:\n```python\n{latest_code.strip()}\n```"
)
@llm_chat(
llm_interface=get_config().REASONING_INTERFACE,
toolkit=create_codegen_subagent_tools(),
max_tool_calls=SUBAGENT_MAX_TOOL_CALLS,
stream=True,
enable_event=True,
timeout=900,
temperature=0.8,
)
async def cad_code_generator_specialist(
message: str,
history: HistoryList | None = None,
) -> None: # type: ignore[misc]
"""You are a CAD coding agent. Write Python code directly to the target file with echo_into.
Always create/write the target file first. Use the workspace facts included in the user message. Read the chosen skill root's `SKILL.md`, then `references/docs/api/README.md`, then the exact API Markdown pages you use. Use the provided `validation_command` exactly; when you run Python in this repo/workspace, prefer `uv run python ...`. Run validation directly with `execute_command`; that tool already allows up to 600 seconds for a command, so use it as the standard execution path. After a successful script run, verify exported files with `ls` instead of rerunning the same script just to check whether STL/STEP outputs exist. Do not print whole solids, assemblies, or full model objects for inspection; use QL queries and print only the small queried facts you need for grounding/debugging. Keep debugging until the script is executed successfully and exports both STL and STEP/STP (for example, ./model.stl and ./model.step).
"""
pass
__all__ = [
"cad_code_generator",
"cad_code_generator_specialist",
"create_codegen_subagent_tools",
]
@@ -0,0 +1,93 @@
import asyncio
from SimpleLLMFunc import tool
from .common import print_tool_output
EXECUTE_COMMAND_TIMEOUT_SECONDS = 600
def _build_command_failure_message(result) -> str:
parts = [f"Command failed with exit code {result.returncode}."]
stdout = result.stdout.strip()
stderr = result.stderr.strip()
if stdout:
parts.append(f"STDOUT:\n{stdout}")
if stderr:
parts.append(f"STDERR:\n{stderr}")
return "\n\n".join(parts)
def _build_command_timeout_message(exc) -> str:
parts = [
f"Command timed out after {EXECUTE_COMMAND_TIMEOUT_SECONDS} seconds.",
"The process may be stuck, waiting for input, or simply taking too long.",
]
stdout = (exc.stdout or "").strip()
stderr = (exc.stderr or "").strip()
if stdout:
parts.append(f"Partial STDOUT:\n{stdout}")
if stderr:
parts.append(f"Partial STDERR:\n{stderr}")
return "\n\n".join(parts)
@tool(
name="execute_command",
description="Execute a system command in shell and return the output.",
)
async def execute_command(command: str) -> str:
"""Execute a system command in shell and return the output.
Args:
command: The system command to execute, recommended commands are uv run python <script path>
Returns:
The command output (stdout on success, stderr on failure)
"""
import subprocess
import time
try:
print_tool_output("⚡ Running Command", f"Executing: {command}")
start_time = time.time()
result = await asyncio.to_thread(
subprocess.run,
command,
shell=True,
capture_output=True,
text=True,
timeout=EXECUTE_COMMAND_TIMEOUT_SECONDS,
)
execution_time = time.time() - start_time
if result.returncode == 0:
print_tool_output(
"✅ Command Completed",
f"Return code: {result.returncode}, Time: {execution_time:.2f}s, Output: {len(result.stdout)} chars",
)
return result.stdout.strip()
else:
print_tool_output(
"❌ Command Failed",
f"Command failed.\nError: {result.stderr.strip()}",
)
return _build_command_failure_message(result)
except subprocess.TimeoutExpired as exc:
print_tool_output(
"⏱️ Command Timed Out", f"Timeout while executing command: {str(exc)}"
)
return _build_command_timeout_message(exc)
except Exception as e:
print_tool_output("💥 Command Error", f"Command execution failed: {str(e)}")
return f"Command execution failed: {str(e)}"

Some files were not shown because too many files have changed in this diff Show More