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.
@@ -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,3 @@
1
+ # icon.png
2
+
3
+ (empty)
@@ -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,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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+