agent-webui 0.1.8__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent_webui-0.1.8/PKG-INFO +57 -0
- agent_webui-0.1.8/README.md +28 -0
- agent_webui-0.1.8/agent/agent_webui/__init__.py +0 -0
- agent_webui-0.1.8/agent/agent_webui/agent.py +57 -0
- agent_webui-0.1.8/agent/agent_webui/api_extensions.py +289 -0
- agent_webui-0.1.8/agent/agent_webui/data.py +103 -0
- agent_webui-0.1.8/agent/agent_webui/db.py +42 -0
- agent_webui-0.1.8/agent/agent_webui/icon.png +3 -0
- agent_webui-0.1.8/agent/agent_webui/server.py +133 -0
- agent_webui-0.1.8/agent/agent_webui.egg-info/PKG-INFO +57 -0
- agent_webui-0.1.8/agent/agent_webui.egg-info/SOURCES.txt +14 -0
- agent_webui-0.1.8/agent/agent_webui.egg-info/dependency_links.txt +1 -0
- agent_webui-0.1.8/agent/agent_webui.egg-info/requires.txt +15 -0
- agent_webui-0.1.8/agent/agent_webui.egg-info/top_level.txt +1 -0
- agent_webui-0.1.8/pyproject.toml +53 -0
- agent_webui-0.1.8/setup.cfg +4 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agent-webui
|
|
3
|
+
Version: 0.1.8
|
|
4
|
+
Summary: Agent Web Interface for Pydantic AI Agents
|
|
5
|
+
Author-email: Audel Rouhi <knucklessg1@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Classifier: Development Status :: 4 - Beta
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
Requires-Dist: bs4>=0.0.2
|
|
15
|
+
Requires-Dist: fastapi>=0.117.1
|
|
16
|
+
Requires-Dist: lancedb>=0.25.0
|
|
17
|
+
Requires-Dist: langchain-text-splitters>=0.3.11
|
|
18
|
+
Requires-Dist: logfire[fastapi,starlette]>=4.9.0
|
|
19
|
+
Requires-Dist: markdown2>=2.5.4
|
|
20
|
+
Requires-Dist: pip>=25.2
|
|
21
|
+
Requires-Dist: pydantic-ai-slim[anthropic,cli,google,openai]>=1.14.0
|
|
22
|
+
Requires-Dist: pyright>=1.1.405
|
|
23
|
+
Requires-Dist: python-frontmatter>=1.1.0
|
|
24
|
+
Requires-Dist: sentence-transformers>=5.1.1
|
|
25
|
+
Requires-Dist: sse-starlette>=3.0.2
|
|
26
|
+
Requires-Dist: starlette>=0.48.0
|
|
27
|
+
Requires-Dist: uvicorn>=0.37.0
|
|
28
|
+
Requires-Dist: watchfiles>=1.1.0
|
|
29
|
+
|
|
30
|
+
# Agent WebUI
|
|
31
|
+
|
|
32
|
+
_Version 0.1.8_
|
|
33
|
+
|
|
34
|
+
A React-based chat interface for [Pydantic AI](https://ai.pydantic.dev/). This package powers the documentation assistant at [ai.pydantic.dev/web/](https://ai.pydantic.dev/web/).
|
|
35
|
+
|
|
36
|
+
Built with [Vercel AI SDK](https://sdk.vercel.ai/) and designed to work with Pydantic AI's streaming chat API.
|
|
37
|
+
|
|
38
|
+
## Features
|
|
39
|
+
|
|
40
|
+
- Streaming message responses with reasoning display
|
|
41
|
+
- Tool call visualization with collapsible input/output
|
|
42
|
+
- Conversation persistence via localStorage
|
|
43
|
+
- Dynamic model and tool selection
|
|
44
|
+
- Dark/light theme support
|
|
45
|
+
- Mobile-responsive sidebar
|
|
46
|
+
|
|
47
|
+
## Development
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
pnpm install
|
|
51
|
+
pnpm run dev:server # start the Python backend (requires agent/ setup)
|
|
52
|
+
pnpm run dev # start the Vite dev server
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## License
|
|
56
|
+
|
|
57
|
+
MIT
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Agent WebUI
|
|
2
|
+
|
|
3
|
+
_Version 0.1.8_
|
|
4
|
+
|
|
5
|
+
A React-based chat interface for [Pydantic AI](https://ai.pydantic.dev/). This package powers the documentation assistant at [ai.pydantic.dev/web/](https://ai.pydantic.dev/web/).
|
|
6
|
+
|
|
7
|
+
Built with [Vercel AI SDK](https://sdk.vercel.ai/) and designed to work with Pydantic AI's streaming chat API.
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- Streaming message responses with reasoning display
|
|
12
|
+
- Tool call visualization with collapsible input/output
|
|
13
|
+
- Conversation persistence via localStorage
|
|
14
|
+
- Dynamic model and tool selection
|
|
15
|
+
- Dark/light theme support
|
|
16
|
+
- Mobile-responsive sidebar
|
|
17
|
+
|
|
18
|
+
## Development
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
pnpm install
|
|
22
|
+
pnpm run dev:server # start the Python backend (requires agent/ setup)
|
|
23
|
+
pnpm run dev # start the Vite dev server
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## License
|
|
27
|
+
|
|
28
|
+
MIT
|
|
File without changes
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from typing import Any, cast
|
|
2
|
+
|
|
3
|
+
import pydantic_ai
|
|
4
|
+
|
|
5
|
+
from agent_web.data import Repo, get_docs_dir, get_markdown, get_table_of_contents
|
|
6
|
+
from agent_web.db import open_populated_table
|
|
7
|
+
|
|
8
|
+
agent = pydantic_ai.Agent(
|
|
9
|
+
instructions="Help the user answer questions about two products ('repos'): Pydantic AI (pydantic-ai), an open source agent framework library, and Pydantic Logfire (logfire), an observability platform. Start by using the `search_docs` tool to search the relevant documentation and answer the question based on the search results. It uses a hybrid of semantic and keyword search, so writing either keywords or sentences may work. It's not searching google. Each search result starts with a path to a .md file. The file `foo/bar.md` corresponds to the URL `https://ai.pydantic.dev/foo/bar/` for Pydantic AI, `https://logfire.pydantic.dev/docs/foo/bar/` for Logfire. Include the URLs in your answer. The search results may not return complete files, or may not return the files you need. If they don't have what you need, you can use the `get_docs_file` tool. You probably only need to search once or twice, definitely not more than 3 times. The user doesn't see the search results, you need to actually return a summary of the info. To see the files that exist for the `get_docs_file` tool, along with a preview of the sections within, use the `get_table_of_contents` tool.",
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
agent.tool_plain(get_table_of_contents)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@agent.tool_plain
|
|
16
|
+
def get_docs_file(repo: Repo, filename: str):
|
|
17
|
+
"""Get the full text of a documentation file by its filename, e.g. `foo/bar.md`."""
|
|
18
|
+
if not filename.endswith('.md'):
|
|
19
|
+
filename += '.md'
|
|
20
|
+
path = get_docs_dir(repo) / filename
|
|
21
|
+
if not path.exists():
|
|
22
|
+
return f'File {filename} does not exist'
|
|
23
|
+
return get_markdown(path)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@agent.tool_plain
|
|
27
|
+
def search_docs(repo: Repo, query: str):
|
|
28
|
+
results = cast(
|
|
29
|
+
list[dict[str, Any]],
|
|
30
|
+
open_populated_table(repo)
|
|
31
|
+
.search( # type: ignore
|
|
32
|
+
query,
|
|
33
|
+
query_type='hybrid',
|
|
34
|
+
vector_column_name='vector',
|
|
35
|
+
fts_columns='text',
|
|
36
|
+
)
|
|
37
|
+
.limit(10)
|
|
38
|
+
.to_list(),
|
|
39
|
+
)
|
|
40
|
+
results = [
|
|
41
|
+
r
|
|
42
|
+
for r in results
|
|
43
|
+
if not any(
|
|
44
|
+
r != r2
|
|
45
|
+
and r['path'] == r2['path']
|
|
46
|
+
and r['headers'][: len(r2['headers'])] == r2['headers']
|
|
47
|
+
for r2 in results
|
|
48
|
+
)
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
return '\n\n---------\n\n'.join(r['text'] for r in results)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
if __name__ == '__main__':
|
|
55
|
+
# print(agent.run_sync('how do i see errors').output)
|
|
56
|
+
# search_docs("logfire", "errors debugging view errors logs")
|
|
57
|
+
agent.to_cli_sync()
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
5
|
+
import shutil
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Dict, List
|
|
8
|
+
|
|
9
|
+
from fastapi import APIRouter, Request, UploadFile, File, HTTPException
|
|
10
|
+
from fastapi.responses import FileResponse
|
|
11
|
+
|
|
12
|
+
from agent_utilities.agent_utilities import get_agent_workspace
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
router = APIRouter(prefix='/api/enhanced')
|
|
17
|
+
AGENT_WORKSPACE = get_agent_workspace()
|
|
18
|
+
DEFAULT_AGENT_DIR = Path(__file__).parent / 'agent'
|
|
19
|
+
|
|
20
|
+
# These will be provided by agent-utilities when the app is initialized
|
|
21
|
+
workspace_helpers: Dict[str, Any] = {}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_helper(name: str, fallback: Any = None):
|
|
25
|
+
helper = workspace_helpers.get(name)
|
|
26
|
+
if not helper:
|
|
27
|
+
logger.warning(
|
|
28
|
+
f"Helper '{name}' not found in workspace_helpers. Available: {list(workspace_helpers.keys())}"
|
|
29
|
+
)
|
|
30
|
+
return fallback
|
|
31
|
+
return helper
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def set_workspace_helpers(helpers: Dict[str, Any]):
|
|
35
|
+
global workspace_helpers
|
|
36
|
+
logger.info(f'Setting workspace helpers. Keys: {list(helpers.keys())}')
|
|
37
|
+
workspace_helpers = helpers
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@router.get('/info')
|
|
41
|
+
async def get_info():
|
|
42
|
+
name = workspace_helpers.get('agent_name', 'Agent')
|
|
43
|
+
description = workspace_helpers.get('agent_description', 'AI Agent')
|
|
44
|
+
emoji = workspace_helpers.get('agent_emoji', '🤖')
|
|
45
|
+
|
|
46
|
+
user_emoji = '👤'
|
|
47
|
+
try:
|
|
48
|
+
content = workspace_helpers['load_workspace_file']('USER.md')
|
|
49
|
+
match = re.search(r'\* \*\*Emoji:\*\* (.*)', content)
|
|
50
|
+
if match:
|
|
51
|
+
user_emoji = match.group(1).strip()
|
|
52
|
+
except Exception:
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
'name': name,
|
|
57
|
+
'description': description,
|
|
58
|
+
'emoji': emoji,
|
|
59
|
+
'user_emoji': user_emoji,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@router.get('/files')
|
|
64
|
+
async def list_files():
|
|
65
|
+
load_files = get_helper('list_workspace_files')
|
|
66
|
+
if not load_files:
|
|
67
|
+
raise HTTPException(status_code=501, detail='File helpers not initialized')
|
|
68
|
+
return load_files()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@router.get('/files/{filename}')
|
|
72
|
+
async def get_file(filename: str):
|
|
73
|
+
# Check workspace first
|
|
74
|
+
load_file = get_helper('load_workspace_file')
|
|
75
|
+
content = load_file(filename) if load_file else ''
|
|
76
|
+
|
|
77
|
+
# Fallback to default template if content is empty and it's a core file
|
|
78
|
+
if not content and DEFAULT_AGENT_DIR.joinpath(filename).exists():
|
|
79
|
+
content = DEFAULT_AGENT_DIR.joinpath(filename).read_text(encoding='utf-8')
|
|
80
|
+
|
|
81
|
+
return {'content': content}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@router.get('/config-files')
|
|
85
|
+
async def list_config_files():
|
|
86
|
+
# Merge workspace files with default templates
|
|
87
|
+
list_files = get_helper('list_workspace_files')
|
|
88
|
+
workspace_files = set(list_files() if list_files else [])
|
|
89
|
+
default_files = set(f.name for f in DEFAULT_AGENT_DIR.glob('*.md') if f.is_file())
|
|
90
|
+
|
|
91
|
+
# We care about .md files and specifically mcp_config.json for configuration
|
|
92
|
+
all_files = sorted(list(workspace_files.union(default_files)))
|
|
93
|
+
config_files = [
|
|
94
|
+
f
|
|
95
|
+
for f in all_files
|
|
96
|
+
if (f.endswith('.md') and not f.endswith('_LOG.md')) or f == 'mcp_config.json'
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
return config_files
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@router.put('/files/{filename}')
|
|
103
|
+
async def update_file(filename: str, data: Dict[str, str]):
|
|
104
|
+
if not filename.endswith('.md') and not filename.endswith('.json'):
|
|
105
|
+
raise HTTPException(status_code=400, detail='Only .md and .json files allowed')
|
|
106
|
+
write_helper = get_helper(
|
|
107
|
+
'write_md_file'
|
|
108
|
+
) # Using write_md_file for general writing for now
|
|
109
|
+
if not write_helper:
|
|
110
|
+
raise HTTPException(status_code=501, detail='Write helper not initialized')
|
|
111
|
+
write_helper(filename, data.get('content', ''))
|
|
112
|
+
return {'status': 'success'}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@router.get('/skills')
|
|
116
|
+
async def list_skills():
|
|
117
|
+
skills = workspace_helpers['list_skills']()
|
|
118
|
+
return sorted(skills, key=lambda x: x.get('name', '').lower())
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@router.post('/skills/{skill_id}/toggle')
|
|
122
|
+
async def toggle_skill(skill_id: str):
|
|
123
|
+
return workspace_helpers['toggle_skill'](skill_id)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@router.post('/reload')
|
|
127
|
+
async def reload_agent(request: Request):
|
|
128
|
+
try:
|
|
129
|
+
workspace_helpers['initialize_workspace']()
|
|
130
|
+
# Find the reloadable app wrapper in the state
|
|
131
|
+
reloadable = getattr(request.app.state, 'reload_app', None)
|
|
132
|
+
if not reloadable:
|
|
133
|
+
raise HTTPException(
|
|
134
|
+
status_code=501, detail='Reloadable wrapper not found in app state'
|
|
135
|
+
)
|
|
136
|
+
reloadable.reload()
|
|
137
|
+
return {'status': 'success', 'message': 'Agent reloaded successfully'}
|
|
138
|
+
except Exception as e:
|
|
139
|
+
logger.error(f'Reload failed: {e}')
|
|
140
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def parse_cron_table(content: str) -> List[Dict[str, Any]]:
|
|
144
|
+
tasks = []
|
|
145
|
+
lines = content.split('\n')
|
|
146
|
+
for line in lines:
|
|
147
|
+
if '|' in line and 'ID' not in line and '---' not in line:
|
|
148
|
+
parts = [p.strip() for p in line.split('|') if p.strip()]
|
|
149
|
+
if len(parts) >= 3:
|
|
150
|
+
tasks.append(
|
|
151
|
+
{
|
|
152
|
+
'id': parts[0],
|
|
153
|
+
'name': parts[1],
|
|
154
|
+
'schedule': parts[2], # Interval in minutes or cron string
|
|
155
|
+
}
|
|
156
|
+
)
|
|
157
|
+
return tasks
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def parse_cron_logs(content: str) -> List[Dict[str, Any]]:
|
|
161
|
+
logs = []
|
|
162
|
+
# Each entry starts with "### ["
|
|
163
|
+
parts = re.split(r'(?=^### \[)', content, flags=re.MULTILINE)
|
|
164
|
+
|
|
165
|
+
for part in parts:
|
|
166
|
+
if not part.strip() or not part.startswith('### ['):
|
|
167
|
+
continue
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
# Format: ### [2026-03-07 05:32:11] Log Cleanup (`log-cleanup`)
|
|
171
|
+
header_match = re.search(r'^### \[(.*?)\] (.*?) \(`(.*?)`\)', part)
|
|
172
|
+
if header_match:
|
|
173
|
+
ts = header_match.group(1)
|
|
174
|
+
name = header_match.group(2)
|
|
175
|
+
tid = header_match.group(3)
|
|
176
|
+
|
|
177
|
+
# Output is after the header line, up to the separator "---"
|
|
178
|
+
body = part.split('\n\n', 1)[1] if '\n\n' in part else ''
|
|
179
|
+
output = body.split('\n---')[0].strip()
|
|
180
|
+
|
|
181
|
+
logs.append(
|
|
182
|
+
{
|
|
183
|
+
'timestamp': ts,
|
|
184
|
+
'task_id': tid,
|
|
185
|
+
'task_name': name,
|
|
186
|
+
'status': 'success', # Default for now
|
|
187
|
+
'output': output,
|
|
188
|
+
}
|
|
189
|
+
)
|
|
190
|
+
except Exception as e:
|
|
191
|
+
logger.debug(f'Error parsing log entry: {e}')
|
|
192
|
+
|
|
193
|
+
return logs[::-1] # Newest first
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@router.get('/cron/calendar')
|
|
197
|
+
async def get_cron_calendar():
|
|
198
|
+
get_cal = get_helper('get_cron_calendar')
|
|
199
|
+
tasks = get_cal() if get_cal else []
|
|
200
|
+
if not tasks:
|
|
201
|
+
# Try fallback to DEFAULT_AGENT_DIR/CRON.md
|
|
202
|
+
cron_path = DEFAULT_AGENT_DIR / 'CRON.md'
|
|
203
|
+
if cron_path.exists():
|
|
204
|
+
tasks = parse_cron_table(cron_path.read_text(encoding='utf-8'))
|
|
205
|
+
return tasks
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@router.get('/cron/logs')
|
|
209
|
+
async def get_cron_logs():
|
|
210
|
+
get_logs = get_helper('get_cron_logs')
|
|
211
|
+
logs = get_logs() if get_logs else []
|
|
212
|
+
|
|
213
|
+
if not logs:
|
|
214
|
+
# Try fallback to DEFAULT_AGENT_DIR/CRON_LOG.md
|
|
215
|
+
log_path = DEFAULT_AGENT_DIR / 'CRON_LOG.md'
|
|
216
|
+
if log_path.exists():
|
|
217
|
+
logs = parse_cron_logs(log_path.read_text(encoding='utf-8'))
|
|
218
|
+
return logs
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@router.post('/upload')
|
|
222
|
+
async def upload_file(file: UploadFile = File(...)):
|
|
223
|
+
get_workspace = get_helper('get_workspace_path')
|
|
224
|
+
if not get_workspace:
|
|
225
|
+
raise HTTPException(status_code=501, detail='Workspace helper not initialized')
|
|
226
|
+
workspace_dir = get_workspace('')
|
|
227
|
+
file_path = workspace_dir / file.filename
|
|
228
|
+
with open(file_path, 'wb') as buffer:
|
|
229
|
+
shutil.copyfileobj(file.file, buffer)
|
|
230
|
+
return {'filename': file.filename}
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@router.get('/agent-icon')
|
|
234
|
+
async def get_agent_icon():
|
|
235
|
+
# Try to get custom icon from workspace first
|
|
236
|
+
get_path = workspace_helpers.get('get_workspace_path')
|
|
237
|
+
if get_path:
|
|
238
|
+
workspace_icon = get_path('icon.png')
|
|
239
|
+
if workspace_icon.exists():
|
|
240
|
+
return FileResponse(path=workspace_icon)
|
|
241
|
+
|
|
242
|
+
# Fallback to package icon
|
|
243
|
+
get_icon_p = workspace_helpers.get('get_agent_icon_path')
|
|
244
|
+
icon_path = get_icon_p() if get_icon_p else None
|
|
245
|
+
if not icon_path or not Path(icon_path).exists():
|
|
246
|
+
raise HTTPException(status_code=404, detail='Icon not found')
|
|
247
|
+
return FileResponse(path=icon_path)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@router.get('/download/{filename}')
|
|
251
|
+
async def download_file(filename: str):
|
|
252
|
+
get_workspace = get_helper('get_workspace_path')
|
|
253
|
+
if not get_workspace:
|
|
254
|
+
raise HTTPException(status_code=501, detail='Workspace helper not initialized')
|
|
255
|
+
workspace_dir = get_workspace('')
|
|
256
|
+
file_path = workspace_dir / filename
|
|
257
|
+
if not file_path.exists():
|
|
258
|
+
raise HTTPException(status_code=404, detail='File not found')
|
|
259
|
+
return FileResponse(path=file_path, filename=filename)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@router.get('/chats')
|
|
263
|
+
async def list_chats():
|
|
264
|
+
h = get_helper('list_chats')
|
|
265
|
+
return h() if h else []
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
@router.get('/chats/{chat_id}')
|
|
269
|
+
async def get_chat(chat_id: str):
|
|
270
|
+
h = get_helper('get_chat')
|
|
271
|
+
return h(chat_id) if h else None
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@router.post('/chats')
|
|
275
|
+
async def save_chat(data: Dict[str, Any]):
|
|
276
|
+
h = get_helper('save_chat')
|
|
277
|
+
return h(data) if h else {'status': 'error'}
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@router.put('/chats/{chat_id}/title')
|
|
281
|
+
async def update_chat_title(chat_id: str, data: Dict[str, Any]):
|
|
282
|
+
h = get_helper('update_chat_title')
|
|
283
|
+
return h(chat_id, data) if h else {'status': 'error'}
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@router.delete('/chats/{chat_id}')
|
|
287
|
+
async def delete_chat(chat_id: str):
|
|
288
|
+
h = get_helper('delete_chat')
|
|
289
|
+
return h(chat_id) if h else {'status': 'error'}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from collections import Counter
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, Literal
|
|
5
|
+
|
|
6
|
+
import frontmatter
|
|
7
|
+
import markdown2
|
|
8
|
+
from bs4 import BeautifulSoup
|
|
9
|
+
from langchain_text_splitters import MarkdownHeaderTextSplitter
|
|
10
|
+
|
|
11
|
+
Repo = Literal['pydantic-ai', 'logfire']
|
|
12
|
+
repos: tuple[Repo, ...] = 'pydantic-ai', 'logfire'
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_docs_dir(repo: Repo) -> Path:
|
|
16
|
+
result = Path(__file__).parent.parent.parent.parent / repo / 'docs'
|
|
17
|
+
if not result.exists():
|
|
18
|
+
raise ValueError(f'This repo should live next to the {repo} repo')
|
|
19
|
+
return result
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
IGNORED_FILES = 'release-notes.md', 'help.md', '/api/', '/legal/'
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_docs_files(repo: Repo) -> list[Path]:
|
|
26
|
+
return [
|
|
27
|
+
file
|
|
28
|
+
for file in get_docs_dir(repo).rglob('*.md')
|
|
29
|
+
if not any(ignored in str(file) for ignored in IGNORED_FILES)
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_markdown(path: Path) -> str:
|
|
34
|
+
markdown_string = path.read_text()
|
|
35
|
+
markdown_string = frontmatter.loads(markdown_string).content
|
|
36
|
+
return markdown_string
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_table_of_contents(repo: Repo):
|
|
40
|
+
"""Get a list of all docs files and a preview of the sections within."""
|
|
41
|
+
result = ''
|
|
42
|
+
for file in get_docs_files(repo):
|
|
43
|
+
markdown_string = get_markdown(file)
|
|
44
|
+
markdown_string = re.sub(
|
|
45
|
+
r'^```\w+ [^\n]+$', '```', markdown_string, flags=re.MULTILINE
|
|
46
|
+
)
|
|
47
|
+
html_output = markdown2.markdown(markdown_string, extras=['fenced-code-blocks']) # type: ignore
|
|
48
|
+
soup = BeautifulSoup(html_output, 'html.parser')
|
|
49
|
+
headers = soup.find_all(['h1', 'h2', 'h3', 'h4'])
|
|
50
|
+
result += f'{file.relative_to(get_docs_dir(repo))}\n'
|
|
51
|
+
result += '\n'.join(
|
|
52
|
+
'#' * int(header.name[1]) + ' ' + header.get_text() # type: ignore
|
|
53
|
+
for header in headers
|
|
54
|
+
)
|
|
55
|
+
result += '\n\n'
|
|
56
|
+
return result
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
headers_to_split_on = [('#' * n, f'H{n}') for n in range(1, 7)]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def get_docs_rows(repo: Repo) -> list[dict[str, Any]]:
|
|
63
|
+
data: list[dict[str, Any]] = []
|
|
64
|
+
for file in get_docs_files(repo):
|
|
65
|
+
markdown_document = get_markdown(file)
|
|
66
|
+
rel_path = str(file.relative_to(get_docs_dir(repo)))
|
|
67
|
+
|
|
68
|
+
unique: set[tuple[tuple[str, ...], str]] = set()
|
|
69
|
+
for num_headers in range(len(headers_to_split_on)):
|
|
70
|
+
splitter = MarkdownHeaderTextSplitter(headers_to_split_on[:num_headers])
|
|
71
|
+
splits = splitter.split_text(markdown_document)
|
|
72
|
+
for split in splits:
|
|
73
|
+
metadata: dict[str, Any] = split.metadata # type: ignore
|
|
74
|
+
headers = [
|
|
75
|
+
f'{prefix} {metadata[header_type]}'
|
|
76
|
+
for prefix, header_type in headers_to_split_on
|
|
77
|
+
if header_type in metadata
|
|
78
|
+
]
|
|
79
|
+
content = '\n\n'.join([rel_path, *headers, split.page_content])
|
|
80
|
+
if len(content.encode()) > 16384:
|
|
81
|
+
continue
|
|
82
|
+
unique.add((tuple(headers), content))
|
|
83
|
+
|
|
84
|
+
counts = Counter[tuple[str, ...]]()
|
|
85
|
+
for headers, content in sorted(unique):
|
|
86
|
+
counts[headers] += 1
|
|
87
|
+
count = str(counts[headers])
|
|
88
|
+
data.append(
|
|
89
|
+
dict(
|
|
90
|
+
path=rel_path,
|
|
91
|
+
headers=headers,
|
|
92
|
+
text=content,
|
|
93
|
+
count=count,
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
return data
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
if __name__ == '__main__':
|
|
101
|
+
print(get_table_of_contents('logfire'))
|
|
102
|
+
rows = get_docs_rows('logfire')
|
|
103
|
+
print(f'Generated {len(rows)} rows')
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import lancedb
|
|
2
|
+
from lancedb.embeddings import get_registry
|
|
3
|
+
from lancedb.pydantic import LanceModel, Vector # type: ignore
|
|
4
|
+
|
|
5
|
+
from chatbot.data import get_docs_rows, Repo
|
|
6
|
+
|
|
7
|
+
db = lancedb.connect('/tmp/lancedb-pydantic-ai-chat')
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def create_table(repo: Repo):
|
|
11
|
+
embeddings = get_registry().get('sentence-transformers').create() # type: ignore
|
|
12
|
+
|
|
13
|
+
class Documents(LanceModel):
|
|
14
|
+
path: str
|
|
15
|
+
headers: list[str]
|
|
16
|
+
count: int
|
|
17
|
+
text: str = embeddings.SourceField() # type: ignore
|
|
18
|
+
vector: Vector(embeddings.ndims()) = embeddings.VectorField() # type: ignore
|
|
19
|
+
|
|
20
|
+
table = db.create_table(repo, schema=Documents, mode='overwrite') # type: ignore
|
|
21
|
+
table.create_fts_index('text')
|
|
22
|
+
return table
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def open_table(repo: Repo):
|
|
26
|
+
try:
|
|
27
|
+
return db.open_table(repo)
|
|
28
|
+
except ValueError:
|
|
29
|
+
return create_table(repo)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def populate_table(repo: Repo):
|
|
33
|
+
table = open_table(repo)
|
|
34
|
+
rows = get_docs_rows(repo)
|
|
35
|
+
table.add(data=rows) # type: ignore
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def open_populated_table(repo: Repo):
|
|
39
|
+
table = open_table(repo)
|
|
40
|
+
if table.count_rows() == 0:
|
|
41
|
+
populate_table(repo)
|
|
42
|
+
return table
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
from __future__ import annotations as _annotations
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, Dict
|
|
5
|
+
|
|
6
|
+
import logfire
|
|
7
|
+
from fastapi import FastAPI
|
|
8
|
+
from fastapi.staticfiles import StaticFiles
|
|
9
|
+
from pydantic_ai import Agent
|
|
10
|
+
|
|
11
|
+
from .api_extensions import router as enhanced_router, set_workspace_helpers
|
|
12
|
+
|
|
13
|
+
# Configure logfire
|
|
14
|
+
logfire.configure(send_to_logfire='if-token-present')
|
|
15
|
+
logfire.instrument_pydantic_ai()
|
|
16
|
+
|
|
17
|
+
__version__ = "0.1.8"
|
|
18
|
+
|
|
19
|
+
def create_agent_web_app(
|
|
20
|
+
agent: Agent,
|
|
21
|
+
workspace_helpers: Dict[str, Any],
|
|
22
|
+
models: Dict[str, str] | None = None,
|
|
23
|
+
builtin_tools: list[Any] | None = None,
|
|
24
|
+
html_source: str | Path | None = None,
|
|
25
|
+
) -> FastAPI:
|
|
26
|
+
"""
|
|
27
|
+
Creates the agent-web FastAPI application, integrating Pydantic-AI's
|
|
28
|
+
built-in web UI with enhanced features for workspace management.
|
|
29
|
+
"""
|
|
30
|
+
# Set helpers for the enhanced API
|
|
31
|
+
set_workspace_helpers(workspace_helpers)
|
|
32
|
+
|
|
33
|
+
# Filter models based on available API keys
|
|
34
|
+
default_models = {}
|
|
35
|
+
if os.getenv('ANTHROPIC_API_KEY'):
|
|
36
|
+
default_models['Claude Sonnet 3.5'] = 'anthropic:claude-3-5-sonnet-latest'
|
|
37
|
+
if os.getenv('OPENAI_API_KEY'):
|
|
38
|
+
default_models['GPT 4o'] = 'openai:gpt-4o'
|
|
39
|
+
if os.getenv('GOOGLE_API_KEY'):
|
|
40
|
+
default_models['Gemini 2.0 Pro'] = 'google-gla:gemini-2.0-pro'
|
|
41
|
+
|
|
42
|
+
# Check for Ollama / Local models
|
|
43
|
+
# We'll include Qwen 3 Coder if Ollama is configured
|
|
44
|
+
if os.getenv('OLLAMA_BASE_URL') or os.getenv('OLLAMA_HOST'):
|
|
45
|
+
default_models['Qwen 3 Coder'] = 'ollama:qwen3-coder'
|
|
46
|
+
|
|
47
|
+
if not default_models:
|
|
48
|
+
default_models['Test Model (Markdown Only)'] = 'test'
|
|
49
|
+
|
|
50
|
+
# Create the main FastAPI app
|
|
51
|
+
app = FastAPI(title='Agent Web Dashboard')
|
|
52
|
+
|
|
53
|
+
# Include the enhanced API routes
|
|
54
|
+
app.include_router(enhanced_router)
|
|
55
|
+
|
|
56
|
+
# Use Pydantic-AI's to_web to get their Starlette app
|
|
57
|
+
pydantic_app = agent.to_web(
|
|
58
|
+
models=models or default_models,
|
|
59
|
+
builtin_tools=builtin_tools,
|
|
60
|
+
html_source=html_source,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# Merge pydantic-ai routes into our main app
|
|
64
|
+
for route in pydantic_app.routes:
|
|
65
|
+
if hasattr(route, 'path'):
|
|
66
|
+
# Core API routes
|
|
67
|
+
if (
|
|
68
|
+
route.path.startswith('/chat')
|
|
69
|
+
or route.path.startswith('/configure')
|
|
70
|
+
or route.path.startswith('/api')
|
|
71
|
+
):
|
|
72
|
+
app.routes.append(route)
|
|
73
|
+
# If html_source is provided, we also want Pydantic AI's UI routes (/ and /{id})
|
|
74
|
+
elif html_source and (route.path == '/' or route.path == '/{id}'):
|
|
75
|
+
app.routes.append(route)
|
|
76
|
+
|
|
77
|
+
dist_path = Path(__file__).parent / 'dist'
|
|
78
|
+
|
|
79
|
+
# Custom StaticFiles to handle SPA routing (fallback to index.html)
|
|
80
|
+
class SPAStaticFiles(StaticFiles):
|
|
81
|
+
async def get_response(self, path: str, scope):
|
|
82
|
+
try:
|
|
83
|
+
return await super().get_response(path, scope)
|
|
84
|
+
except _errors.HTTPException as ex:
|
|
85
|
+
if (
|
|
86
|
+
ex.status_code == 404
|
|
87
|
+
and not any(
|
|
88
|
+
path.startswith(p) for p in ['api', 'chat', 'configure']
|
|
89
|
+
)
|
|
90
|
+
and '.' not in path
|
|
91
|
+
):
|
|
92
|
+
return await super().get_response('index.html', scope)
|
|
93
|
+
raise ex
|
|
94
|
+
|
|
95
|
+
from starlette import exceptions as _errors
|
|
96
|
+
|
|
97
|
+
# Mount our built React app (only if no custom html_source is provided)
|
|
98
|
+
if not html_source:
|
|
99
|
+
if dist_path.exists():
|
|
100
|
+
app.mount(
|
|
101
|
+
'/',
|
|
102
|
+
SPAStaticFiles(directory=str(dist_path), html=True),
|
|
103
|
+
name='dashboard',
|
|
104
|
+
)
|
|
105
|
+
else:
|
|
106
|
+
print(
|
|
107
|
+
f'Warning: Static assets not found at {dist_path}. Dashboard UI will not be served.'
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
logfire.instrument_starlette(app)
|
|
111
|
+
return app
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# For standalone testing
|
|
115
|
+
if __name__ == '__main__':
|
|
116
|
+
from .agent import agent as test_agent
|
|
117
|
+
|
|
118
|
+
dummy_helpers = {
|
|
119
|
+
'agent_name': 'Test Agent',
|
|
120
|
+
'agent_description': 'A standalone test agent',
|
|
121
|
+
'agent_emoji': '🧪',
|
|
122
|
+
'get_workspace_path': lambda x: Path('/tmp') / x,
|
|
123
|
+
'load_workspace_file': lambda x: f'Content of {x}',
|
|
124
|
+
'write_md_file': lambda x, y: print(f'Writing {y} to {x}'),
|
|
125
|
+
'list_workspace_files': lambda: ['IDENTITY.md', 'USER.md'],
|
|
126
|
+
'list_skills': lambda: [],
|
|
127
|
+
'get_cron_calendar': lambda: [],
|
|
128
|
+
'get_agent_icon_path': lambda: None,
|
|
129
|
+
}
|
|
130
|
+
app = create_agent_web_app(test_agent, dummy_helpers)
|
|
131
|
+
import uvicorn
|
|
132
|
+
|
|
133
|
+
uvicorn.run(app, host='0.0.0.0', port=8000)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agent-webui
|
|
3
|
+
Version: 0.1.8
|
|
4
|
+
Summary: Agent Web Interface for Pydantic AI Agents
|
|
5
|
+
Author-email: Audel Rouhi <knucklessg1@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Classifier: Development Status :: 4 - Beta
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
Requires-Dist: bs4>=0.0.2
|
|
15
|
+
Requires-Dist: fastapi>=0.117.1
|
|
16
|
+
Requires-Dist: lancedb>=0.25.0
|
|
17
|
+
Requires-Dist: langchain-text-splitters>=0.3.11
|
|
18
|
+
Requires-Dist: logfire[fastapi,starlette]>=4.9.0
|
|
19
|
+
Requires-Dist: markdown2>=2.5.4
|
|
20
|
+
Requires-Dist: pip>=25.2
|
|
21
|
+
Requires-Dist: pydantic-ai-slim[anthropic,cli,google,openai]>=1.14.0
|
|
22
|
+
Requires-Dist: pyright>=1.1.405
|
|
23
|
+
Requires-Dist: python-frontmatter>=1.1.0
|
|
24
|
+
Requires-Dist: sentence-transformers>=5.1.1
|
|
25
|
+
Requires-Dist: sse-starlette>=3.0.2
|
|
26
|
+
Requires-Dist: starlette>=0.48.0
|
|
27
|
+
Requires-Dist: uvicorn>=0.37.0
|
|
28
|
+
Requires-Dist: watchfiles>=1.1.0
|
|
29
|
+
|
|
30
|
+
# Agent WebUI
|
|
31
|
+
|
|
32
|
+
_Version 0.1.8_
|
|
33
|
+
|
|
34
|
+
A React-based chat interface for [Pydantic AI](https://ai.pydantic.dev/). This package powers the documentation assistant at [ai.pydantic.dev/web/](https://ai.pydantic.dev/web/).
|
|
35
|
+
|
|
36
|
+
Built with [Vercel AI SDK](https://sdk.vercel.ai/) and designed to work with Pydantic AI's streaming chat API.
|
|
37
|
+
|
|
38
|
+
## Features
|
|
39
|
+
|
|
40
|
+
- Streaming message responses with reasoning display
|
|
41
|
+
- Tool call visualization with collapsible input/output
|
|
42
|
+
- Conversation persistence via localStorage
|
|
43
|
+
- Dynamic model and tool selection
|
|
44
|
+
- Dark/light theme support
|
|
45
|
+
- Mobile-responsive sidebar
|
|
46
|
+
|
|
47
|
+
## Development
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
pnpm install
|
|
51
|
+
pnpm run dev:server # start the Python backend (requires agent/ setup)
|
|
52
|
+
pnpm run dev # start the Vite dev server
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## License
|
|
56
|
+
|
|
57
|
+
MIT
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
agent/agent_webui/__init__.py
|
|
4
|
+
agent/agent_webui/agent.py
|
|
5
|
+
agent/agent_webui/api_extensions.py
|
|
6
|
+
agent/agent_webui/data.py
|
|
7
|
+
agent/agent_webui/db.py
|
|
8
|
+
agent/agent_webui/icon.png
|
|
9
|
+
agent/agent_webui/server.py
|
|
10
|
+
agent/agent_webui.egg-info/PKG-INFO
|
|
11
|
+
agent/agent_webui.egg-info/SOURCES.txt
|
|
12
|
+
agent/agent_webui.egg-info/dependency_links.txt
|
|
13
|
+
agent/agent_webui.egg-info/requires.txt
|
|
14
|
+
agent/agent_webui.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
bs4>=0.0.2
|
|
2
|
+
fastapi>=0.117.1
|
|
3
|
+
lancedb>=0.25.0
|
|
4
|
+
langchain-text-splitters>=0.3.11
|
|
5
|
+
logfire[fastapi,starlette]>=4.9.0
|
|
6
|
+
markdown2>=2.5.4
|
|
7
|
+
pip>=25.2
|
|
8
|
+
pydantic-ai-slim[anthropic,cli,google,openai]>=1.14.0
|
|
9
|
+
pyright>=1.1.405
|
|
10
|
+
python-frontmatter>=1.1.0
|
|
11
|
+
sentence-transformers>=5.1.1
|
|
12
|
+
sse-starlette>=3.0.2
|
|
13
|
+
starlette>=0.48.0
|
|
14
|
+
uvicorn>=0.37.0
|
|
15
|
+
watchfiles>=1.1.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
agent_webui
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=80.9.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "agent-webui"
|
|
7
|
+
version = "0.1.8"
|
|
8
|
+
description = "Agent Web Interface for Pydantic AI Agents"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 4 - Beta",
|
|
12
|
+
"License :: OSI Approved :: MIT License",
|
|
13
|
+
"Environment :: Console",
|
|
14
|
+
"Operating System :: POSIX :: Linux",
|
|
15
|
+
"Programming Language :: Python :: 3"]
|
|
16
|
+
requires-python = ">=3.10"
|
|
17
|
+
dependencies = [
|
|
18
|
+
"bs4>=0.0.2",
|
|
19
|
+
"fastapi>=0.117.1",
|
|
20
|
+
"lancedb>=0.25.0",
|
|
21
|
+
"langchain-text-splitters>=0.3.11",
|
|
22
|
+
"logfire[fastapi,starlette]>=4.9.0",
|
|
23
|
+
"markdown2>=2.5.4",
|
|
24
|
+
"pip>=25.2",
|
|
25
|
+
"pydantic-ai-slim[anthropic,cli,google,openai]>=1.14.0",
|
|
26
|
+
"pyright>=1.1.405",
|
|
27
|
+
"python-frontmatter>=1.1.0",
|
|
28
|
+
"sentence-transformers>=5.1.1",
|
|
29
|
+
"sse-starlette>=3.0.2",
|
|
30
|
+
"starlette>=0.48.0",
|
|
31
|
+
"uvicorn>=0.37.0",
|
|
32
|
+
"watchfiles>=1.1.0",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[[project.authors]]
|
|
36
|
+
name = "Audel Rouhi"
|
|
37
|
+
email = "knucklessg1@gmail.com"
|
|
38
|
+
|
|
39
|
+
[project.license]
|
|
40
|
+
text = "MIT"
|
|
41
|
+
|
|
42
|
+
[tool.ruff.format]
|
|
43
|
+
docstring-code-format = false
|
|
44
|
+
quote-style = "single"
|
|
45
|
+
|
|
46
|
+
[tool.setuptools]
|
|
47
|
+
include-package-data = true
|
|
48
|
+
|
|
49
|
+
[tool.setuptools.package-data]
|
|
50
|
+
agent_webui = ["dist/**", "icon.png"]
|
|
51
|
+
|
|
52
|
+
[tool.setuptools.packages.find]
|
|
53
|
+
where = ["agent"]
|