first commit
This commit is contained in:
@@ -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/**
|
||||
@@ -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.
|
||||
@@ -0,0 +1,707 @@
|
||||
<div align="center">
|
||||
|
||||
# CADDesigner: Conceptual CAD Model Generation with a General-Purpose Agent
|
||||
|
||||
Fengxiao Fan\* · Jingzhe Ni\* · Xiaolong Yin · Sirui Wang · Xingyu Lu · Qiang Zou · Ruofeng Tong · Min Tang · Peng Du†
|
||||
|
||||
Zhejiang University, China
|
||||
|
||||
(\* equal contribution, † corresponding author)
|
||||
|
||||
[](https://562590763.github.io/CADDesigner/files/CADDesigner.pdf)
|
||||
[](https://www.sciencedirect.com/science/article/pii/S0010448526000576)
|
||||
[](https://562590763.github.io/CADDesigner/)
|
||||
[](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.
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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
|
||||
@@ -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 |
@@ -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"]
|
||||
@@ -0,0 +1,5 @@
|
||||
from config.config import get_config
|
||||
|
||||
__all__ = [
|
||||
"get_config",
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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 "$@"
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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())
|
||||
@@ -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?
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
+3377
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
@@ -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>,
|
||||
)
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
@@ -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.
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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
Reference in New Issue
Block a user