codex-agent-framework 0.1.1__py3-none-any.whl
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.
- codex_agent/__init__.py +38 -0
- codex_agent/__main__.py +18 -0
- codex_agent/agent.py +1179 -0
- codex_agent/ai.py +494 -0
- codex_agent/builtin_commands.py +136 -0
- codex_agent/builtin_providers.py +42 -0
- codex_agent/builtin_tools.py +418 -0
- codex_agent/chat.py +139 -0
- codex_agent/command.py +19 -0
- codex_agent/event.py +136 -0
- codex_agent/get_text/__init__.py +20 -0
- codex_agent/get_text/default_gitignore +110 -0
- codex_agent/get_text/get_text.py +1240 -0
- codex_agent/get_text/simpler_get_text.py +184 -0
- codex_agent/get_webdriver.py +44 -0
- codex_agent/image.py +304 -0
- codex_agent/latex.py +165 -0
- codex_agent/memory.py +318 -0
- codex_agent/message.py +423 -0
- codex_agent/prompts/image_generation_system_prompt.txt +13 -0
- codex_agent/prompts/system_prompt.txt +88 -0
- codex_agent/provider.py +19 -0
- codex_agent/stream_utils.py +645 -0
- codex_agent/tool.py +235 -0
- codex_agent/utils.py +374 -0
- codex_agent/voice.py +353 -0
- codex_agent/worker.py +190 -0
- codex_agent_framework-0.1.1.dist-info/METADATA +361 -0
- codex_agent_framework-0.1.1.dist-info/RECORD +33 -0
- codex_agent_framework-0.1.1.dist-info/WHEEL +5 -0
- codex_agent_framework-0.1.1.dist-info/entry_points.txt +2 -0
- codex_agent_framework-0.1.1.dist-info/licenses/LICENSE +21 -0
- codex_agent_framework-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Lightweight text extraction using only standard library.
|
|
2
|
+
|
|
3
|
+
This is a fallback for when the full get_text.py dependencies are not available.
|
|
4
|
+
Supports basic file reading and URL fetching without external dependencies
|
|
5
|
+
(except urllib which is stdlib).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
from typing import Optional
|
|
11
|
+
from urllib.request import urlopen
|
|
12
|
+
from urllib.error import URLError, HTTPError
|
|
13
|
+
from html.parser import HTMLParser
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_text(source: str) -> str:
|
|
17
|
+
"""Extract text from a file or URL using only stdlib.
|
|
18
|
+
|
|
19
|
+
This is a simplified version that handles:
|
|
20
|
+
- Plain text files
|
|
21
|
+
- URLs (basic HTTP/HTTPS fetching)
|
|
22
|
+
- Directory listings
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
source: File path or URL to extract text from
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
Extracted text content, or error message
|
|
29
|
+
"""
|
|
30
|
+
# Check if it's a URL
|
|
31
|
+
if source.startswith(('http://', 'https://')):
|
|
32
|
+
return _handle_url(source)
|
|
33
|
+
|
|
34
|
+
# Check if it's a directory
|
|
35
|
+
if os.path.isdir(source):
|
|
36
|
+
return _handle_directory(source)
|
|
37
|
+
|
|
38
|
+
# Handle as file
|
|
39
|
+
return _handle_file(source)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _handle_file(path: str) -> str:
|
|
43
|
+
"""Read a text file from disk.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
path: Path to file
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
File contents or error message
|
|
50
|
+
"""
|
|
51
|
+
try:
|
|
52
|
+
with open(path, 'r', encoding='utf-8') as f:
|
|
53
|
+
return f.read()
|
|
54
|
+
except FileNotFoundError:
|
|
55
|
+
return f"File not found: {path}"
|
|
56
|
+
except PermissionError:
|
|
57
|
+
return f"Permission denied: {path}"
|
|
58
|
+
except UnicodeDecodeError:
|
|
59
|
+
# Try binary mode for non-text files
|
|
60
|
+
return f"Binary file (cannot read as text): {path}"
|
|
61
|
+
except Exception as e:
|
|
62
|
+
return f"Error reading file: {e}"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _handle_directory(path: str) -> str:
|
|
66
|
+
"""List directory contents.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
path: Path to directory
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
Directory listing as text
|
|
73
|
+
"""
|
|
74
|
+
try:
|
|
75
|
+
entries = os.listdir(path)
|
|
76
|
+
if not entries:
|
|
77
|
+
return f"{path}/\n(empty directory)"
|
|
78
|
+
|
|
79
|
+
lines = [f"{path}/"]
|
|
80
|
+
for entry in sorted(entries):
|
|
81
|
+
full_path = os.path.join(path, entry)
|
|
82
|
+
if os.path.isdir(full_path):
|
|
83
|
+
lines.append(f" {entry}/")
|
|
84
|
+
else:
|
|
85
|
+
lines.append(f" {entry}")
|
|
86
|
+
|
|
87
|
+
return '\n'.join(lines)
|
|
88
|
+
except PermissionError:
|
|
89
|
+
return f"Permission denied: {path}"
|
|
90
|
+
except Exception as e:
|
|
91
|
+
return f"Error listing directory: {e}"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class _HTMLTextExtractor(HTMLParser):
|
|
95
|
+
"""Simple HTML parser to extract text content using stdlib only."""
|
|
96
|
+
|
|
97
|
+
def __init__(self):
|
|
98
|
+
super().__init__()
|
|
99
|
+
self.text_parts = []
|
|
100
|
+
self.skip_tags = {'script', 'style', 'head', 'meta', 'link'}
|
|
101
|
+
self.current_tag = None
|
|
102
|
+
|
|
103
|
+
def handle_starttag(self, tag, attrs):
|
|
104
|
+
self.current_tag = tag
|
|
105
|
+
|
|
106
|
+
def handle_endtag(self, tag):
|
|
107
|
+
self.current_tag = None
|
|
108
|
+
|
|
109
|
+
def handle_data(self, data):
|
|
110
|
+
if self.current_tag not in self.skip_tags:
|
|
111
|
+
text = data.strip()
|
|
112
|
+
if text:
|
|
113
|
+
self.text_parts.append(text)
|
|
114
|
+
|
|
115
|
+
def get_text(self):
|
|
116
|
+
return '\n'.join(self.text_parts)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _strip_html(html_content: str) -> str:
|
|
120
|
+
"""Strip HTML tags and extract text content.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
html_content: HTML content as string
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
Plain text content
|
|
127
|
+
"""
|
|
128
|
+
try:
|
|
129
|
+
parser = _HTMLTextExtractor()
|
|
130
|
+
parser.feed(html_content)
|
|
131
|
+
text = parser.get_text()
|
|
132
|
+
|
|
133
|
+
# Clean up excessive whitespace
|
|
134
|
+
text = re.sub(r'\n{3,}', '\n\n', text)
|
|
135
|
+
|
|
136
|
+
return text if text else html_content # Fallback to raw if parsing fails
|
|
137
|
+
except Exception:
|
|
138
|
+
# If parsing fails, return raw content
|
|
139
|
+
return html_content
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _handle_url(url: str) -> str:
|
|
143
|
+
"""Fetch content from a URL and extract text.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
url: URL to fetch
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
URL content as text or error message
|
|
150
|
+
"""
|
|
151
|
+
try:
|
|
152
|
+
with urlopen(url, timeout=30) as response:
|
|
153
|
+
# Read content
|
|
154
|
+
content = response.read()
|
|
155
|
+
|
|
156
|
+
# Try to decode as text
|
|
157
|
+
try:
|
|
158
|
+
html = content.decode('utf-8')
|
|
159
|
+
except UnicodeDecodeError:
|
|
160
|
+
# Try other common encodings
|
|
161
|
+
for encoding in ['latin-1', 'iso-8859-1', 'windows-1252']:
|
|
162
|
+
try:
|
|
163
|
+
html = content.decode(encoding)
|
|
164
|
+
break
|
|
165
|
+
except UnicodeDecodeError:
|
|
166
|
+
continue
|
|
167
|
+
else:
|
|
168
|
+
return f"Binary content (cannot decode as text): {url}"
|
|
169
|
+
|
|
170
|
+
# Strip HTML tags if it looks like HTML
|
|
171
|
+
if '<html' in html.lower() or '<body' in html.lower():
|
|
172
|
+
return _strip_html(html)
|
|
173
|
+
else:
|
|
174
|
+
# Plain text content
|
|
175
|
+
return html
|
|
176
|
+
|
|
177
|
+
except HTTPError as e:
|
|
178
|
+
return f"HTTP Error {e.code}: {url}"
|
|
179
|
+
except URLError as e:
|
|
180
|
+
return f"URL Error: {e.reason}"
|
|
181
|
+
except TimeoutError:
|
|
182
|
+
return f"Request timed out: {url}"
|
|
183
|
+
except Exception as e:
|
|
184
|
+
return f"Error fetching URL: {e}"
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from selenium import webdriver
|
|
2
|
+
from selenium.webdriver.firefox.service import Service as FirefoxService
|
|
3
|
+
from selenium.webdriver.firefox.options import Options as FirefoxOptions
|
|
4
|
+
from get_gecko_driver import GetGeckoDriver
|
|
5
|
+
from shutil import which
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
def get_webdriver():
|
|
9
|
+
"""Get a headless Firefox WebDriver instance.
|
|
10
|
+
|
|
11
|
+
Automatically installs geckodriver if not found in PATH.
|
|
12
|
+
Configures Firefox in headless mode with standard options.
|
|
13
|
+
|
|
14
|
+
Returns:
|
|
15
|
+
selenium.webdriver.Firefox: Configured headless Firefox WebDriver.
|
|
16
|
+
|
|
17
|
+
Note:
|
|
18
|
+
Requires Firefox browser to be installed on the system.
|
|
19
|
+
"""
|
|
20
|
+
from .utils import root_join
|
|
21
|
+
tmp_path=root_join("tmp")
|
|
22
|
+
if not os.path.isdir(tmp_path):
|
|
23
|
+
os.makedirs(tmp_path)
|
|
24
|
+
os.environ['TMPDIR']=tmp_path
|
|
25
|
+
path=which('geckodriver')
|
|
26
|
+
if not path:
|
|
27
|
+
# Install the latest version of GeckoDriver
|
|
28
|
+
get_driver = GetGeckoDriver()
|
|
29
|
+
path=get_driver.install()
|
|
30
|
+
print(f"geckodriver successfully installed to {path}")
|
|
31
|
+
else:
|
|
32
|
+
path=os.path.dirname(path)
|
|
33
|
+
# Set the driver
|
|
34
|
+
service=FirefoxService(log_path=os.path.devnull,executable_path=os.path.join(path,'geckodriver'))
|
|
35
|
+
options = FirefoxOptions()
|
|
36
|
+
# Enable headless mode
|
|
37
|
+
options.add_argument('--headless')
|
|
38
|
+
# Disable GPU (not necessary for headless)
|
|
39
|
+
options.add_argument('--disable-gpu')
|
|
40
|
+
# Set the window size (some websites require a minimum window size)
|
|
41
|
+
options.add_argument('--window-size=1920,1080')
|
|
42
|
+
# Instantiate a headless Firefox WebDriver
|
|
43
|
+
driver = webdriver.Firefox(options=options, service=service)
|
|
44
|
+
return driver
|
codex_agent/image.py
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
from .message import Message, register_message_type
|
|
2
|
+
from .utils import guess_extension_from_bytes, runtime_join, short_id, timestamp
|
|
3
|
+
import base64
|
|
4
|
+
import binascii
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from io import BytesIO
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
|
|
10
|
+
from PIL import Image as PILImage
|
|
11
|
+
import requests
|
|
12
|
+
from urllib.parse import urlparse
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
IMAGE_CACHE_ATTR = 'b64_string'
|
|
16
|
+
IMAGE_DIR = 'images'
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def ext_to_mime(ext):
|
|
20
|
+
ext = ext.lower().lstrip('.')
|
|
21
|
+
if ext in ('jpg', 'jpeg'):
|
|
22
|
+
return 'image/jpeg'
|
|
23
|
+
if ext == 'png':
|
|
24
|
+
return 'image/png'
|
|
25
|
+
if ext == 'webp':
|
|
26
|
+
return 'image/webp'
|
|
27
|
+
if ext == 'gif':
|
|
28
|
+
return 'image/gif'
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _data_url(data, mime_type):
|
|
33
|
+
b64 = base64.b64encode(data).decode('utf-8')
|
|
34
|
+
return f"data:{mime_type};base64,{b64}"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def data_url_from_file(path):
|
|
38
|
+
ext = os.path.splitext(path)[1]
|
|
39
|
+
mime_type = ext_to_mime(ext) or 'image/jpeg'
|
|
40
|
+
with open(path, 'rb') as f:
|
|
41
|
+
return _data_url(f.read(), mime_type)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def image_request_headers(url):
|
|
45
|
+
"""Return browser-like headers for remote image fetches.
|
|
46
|
+
|
|
47
|
+
Some image hosts/CDNs reject the default python-requests user agent even
|
|
48
|
+
for public images. A small, stable header set makes observe() much more
|
|
49
|
+
reliable without changing semantics.
|
|
50
|
+
"""
|
|
51
|
+
parsed = urlparse(url)
|
|
52
|
+
referer = f"{parsed.scheme}://{parsed.netloc}/" if parsed.scheme and parsed.netloc else None
|
|
53
|
+
headers = {
|
|
54
|
+
"User-Agent": (
|
|
55
|
+
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
|
56
|
+
"(KHTML, like Gecko) Chrome/124.0 Safari/537.36 codex-agent/observe"
|
|
57
|
+
),
|
|
58
|
+
"Accept": "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
|
|
59
|
+
}
|
|
60
|
+
if referer:
|
|
61
|
+
headers["Referer"] = referer
|
|
62
|
+
return headers
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def image_mime_from_source(source, content_type=None):
|
|
66
|
+
content_type = (content_type or '').split(';')[0].strip()
|
|
67
|
+
if content_type.startswith('image/'):
|
|
68
|
+
return content_type
|
|
69
|
+
if isinstance(source, str):
|
|
70
|
+
ext = os.path.splitext(source.split('?', 1)[0])[1]
|
|
71
|
+
return ext_to_mime(ext) or 'image/jpeg'
|
|
72
|
+
return 'image/png'
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def fetch_image_bytes(url, timeout=15):
|
|
76
|
+
response = requests.get(url, timeout=timeout, headers=image_request_headers(url))
|
|
77
|
+
response.raise_for_status()
|
|
78
|
+
|
|
79
|
+
data = response.content
|
|
80
|
+
if not data:
|
|
81
|
+
raise ValueError(f"URL resolved to an empty image response: {url!r}")
|
|
82
|
+
|
|
83
|
+
content_type = response.headers.get('content-type')
|
|
84
|
+
content_mime = (content_type or '').split(';')[0].strip()
|
|
85
|
+
guessed_mime = ext_to_mime(guess_extension_from_bytes(data) or '')
|
|
86
|
+
|
|
87
|
+
if content_mime and not content_mime.startswith('image/'):
|
|
88
|
+
if not guessed_mime:
|
|
89
|
+
raise ValueError(f"URL did not resolve to an image: {url!r} ({content_mime})")
|
|
90
|
+
return data, guessed_mime
|
|
91
|
+
|
|
92
|
+
return data, guessed_mime or image_mime_from_source(url, content_type)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def data_url_from_url(url):
|
|
96
|
+
data, mime_type = fetch_image_bytes(url)
|
|
97
|
+
return _data_url(data, mime_type)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def persist_image_url(url):
|
|
101
|
+
data, mime_type = fetch_image_bytes(url)
|
|
102
|
+
ext = os.path.splitext(url.split('?', 1)[0])[1] or None
|
|
103
|
+
mime_ext = {
|
|
104
|
+
'image/jpeg': '.jpg',
|
|
105
|
+
'image/png': '.png',
|
|
106
|
+
'image/webp': '.webp',
|
|
107
|
+
'image/gif': '.gif',
|
|
108
|
+
}.get(mime_type)
|
|
109
|
+
if mime_ext and ext_to_mime(ext or '') != mime_type:
|
|
110
|
+
ext = mime_ext
|
|
111
|
+
return persist_image_bytes(data, name=f"remote{ext or ''}")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def get_data_url(source):
|
|
115
|
+
if isinstance(source, str) and os.path.isfile(source):
|
|
116
|
+
return data_url_from_file(source)
|
|
117
|
+
if isinstance(source, str) and source.startswith(('http://', 'https://')):
|
|
118
|
+
return data_url_from_url(source)
|
|
119
|
+
raise ValueError(f"Unsupported image source: {source!r}")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def persist_image_bytes(data, name=None):
|
|
123
|
+
_, ext = os.path.splitext(name) if isinstance(name, str) else ('', '')
|
|
124
|
+
ext = ext or guess_extension_from_bytes(data) or '.png'
|
|
125
|
+
|
|
126
|
+
folder = runtime_join(IMAGE_DIR)
|
|
127
|
+
os.makedirs(folder, exist_ok=True)
|
|
128
|
+
|
|
129
|
+
path = runtime_join(IMAGE_DIR, f"{timestamp()}_{short_id()}{ext}")
|
|
130
|
+
with open(path, 'wb') as f:
|
|
131
|
+
f.write(data)
|
|
132
|
+
return path
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def persist_bytesio(source):
|
|
136
|
+
return persist_image_bytes(source.getvalue(), name=getattr(source, 'name', None))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def image_name_from_source(source):
|
|
140
|
+
if isinstance(source, BytesIO) and getattr(source, 'name', None):
|
|
141
|
+
return os.path.basename(source.name)
|
|
142
|
+
if isinstance(source, str) and os.path.isfile(source):
|
|
143
|
+
return os.path.basename(source)
|
|
144
|
+
if source:
|
|
145
|
+
return str(source)
|
|
146
|
+
return 'Unnamed image'
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def normalize_image_content(content):
|
|
150
|
+
if isinstance(content, BytesIO):
|
|
151
|
+
return persist_bytesio(content)
|
|
152
|
+
if isinstance(content, bytes):
|
|
153
|
+
return persist_image_bytes(content)
|
|
154
|
+
if content is not None and not isinstance(content, str):
|
|
155
|
+
raise TypeError(f"Image content must be a path, URL, BytesIO, or bytes. Got {type(content).__name__}.")
|
|
156
|
+
return content
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@register_message_type
|
|
160
|
+
class ImageMessage(Message):
|
|
161
|
+
"""Image message with a lightweight serialized payload.
|
|
162
|
+
|
|
163
|
+
The session stores only reloadable ``content``. The base64 data URL is
|
|
164
|
+
cached as runtime metadata and generated only when formatting API input.
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
detail = "auto"
|
|
168
|
+
description = None
|
|
169
|
+
image_name = None
|
|
170
|
+
embedding = None
|
|
171
|
+
name = 'image'
|
|
172
|
+
role = "user"
|
|
173
|
+
|
|
174
|
+
def __init__(self, *args, **kwargs):
|
|
175
|
+
args, kwargs, data_url, content_name = self._prepare_init_payload(args, kwargs)
|
|
176
|
+
super().__init__(*args, **kwargs)
|
|
177
|
+
|
|
178
|
+
if not self.get('image_name'):
|
|
179
|
+
self.image_name = content_name or image_name_from_source(self.get('content'))
|
|
180
|
+
|
|
181
|
+
if data_url:
|
|
182
|
+
self.set_attr(IMAGE_CACHE_ATTR, data_url)
|
|
183
|
+
|
|
184
|
+
@classmethod
|
|
185
|
+
def _prepare_init_payload(cls, args, kwargs):
|
|
186
|
+
kwargs = dict(kwargs)
|
|
187
|
+
data_url = kwargs.pop(IMAGE_CACHE_ATTR, None)
|
|
188
|
+
content = kwargs.get('content')
|
|
189
|
+
|
|
190
|
+
if args and isinstance(args[0], Mapping):
|
|
191
|
+
data = dict(args[0])
|
|
192
|
+
data_url = data.pop(IMAGE_CACHE_ATTR, data_url)
|
|
193
|
+
if 'content' in data:
|
|
194
|
+
if 'content' not in kwargs:
|
|
195
|
+
content = data['content']
|
|
196
|
+
data['content'] = normalize_image_content(data['content'])
|
|
197
|
+
args = (data, *args[1:])
|
|
198
|
+
|
|
199
|
+
if 'content' in kwargs:
|
|
200
|
+
content = kwargs['content']
|
|
201
|
+
kwargs['content'] = normalize_image_content(content)
|
|
202
|
+
|
|
203
|
+
content_name = image_name_from_source(content) if content is not None else None
|
|
204
|
+
return args, kwargs, data_url, content_name
|
|
205
|
+
|
|
206
|
+
def get_base64(self):
|
|
207
|
+
data_url = self.get_attr(IMAGE_CACHE_ATTR, None)
|
|
208
|
+
if data_url:
|
|
209
|
+
return data_url
|
|
210
|
+
|
|
211
|
+
try:
|
|
212
|
+
data_url = get_data_url(self.get('content'))
|
|
213
|
+
except Exception:
|
|
214
|
+
return None
|
|
215
|
+
|
|
216
|
+
self.set_attr(IMAGE_CACHE_ATTR, data_url)
|
|
217
|
+
return data_url
|
|
218
|
+
|
|
219
|
+
def is_valid(self):
|
|
220
|
+
data_url = self.get_base64()
|
|
221
|
+
if not isinstance(data_url, str) or not data_url.startswith('data:image/'):
|
|
222
|
+
return False
|
|
223
|
+
match = re.match(r"^data:image/[^;]+;base64,(.*)$", data_url)
|
|
224
|
+
if not match:
|
|
225
|
+
return False
|
|
226
|
+
try:
|
|
227
|
+
image_bytes = base64.b64decode(match.group(1), validate=True)
|
|
228
|
+
except (binascii.Error, ValueError):
|
|
229
|
+
return False
|
|
230
|
+
try:
|
|
231
|
+
with PILImage.open(BytesIO(image_bytes)) as img:
|
|
232
|
+
img.verify()
|
|
233
|
+
except Exception:
|
|
234
|
+
return False
|
|
235
|
+
return True
|
|
236
|
+
|
|
237
|
+
def content_to_response_parts(self, output=False):
|
|
238
|
+
data_url = self.get_base64()
|
|
239
|
+
if not data_url:
|
|
240
|
+
return [{
|
|
241
|
+
'type': 'input_text',
|
|
242
|
+
'text': f"Unable to access the image ({self.image_name!r})",
|
|
243
|
+
}]
|
|
244
|
+
|
|
245
|
+
text = (
|
|
246
|
+
"The following image has been made available in context for visual analysis:\n"
|
|
247
|
+
+ repr(self.image_name)
|
|
248
|
+
)
|
|
249
|
+
if self.get('description'):
|
|
250
|
+
text += f"\nAlong with the following description:\n{self.description}\n"
|
|
251
|
+
|
|
252
|
+
return [
|
|
253
|
+
{
|
|
254
|
+
'type': 'input_image',
|
|
255
|
+
'image_url': data_url,
|
|
256
|
+
'detail': self.detail,
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
'type': 'input_text',
|
|
260
|
+
'text': text,
|
|
261
|
+
},
|
|
262
|
+
]
|
|
263
|
+
|
|
264
|
+
def to_response_format(self):
|
|
265
|
+
return {
|
|
266
|
+
"type": "message",
|
|
267
|
+
"role": self.role,
|
|
268
|
+
"content": self.content_to_response_parts(),
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
def as_string_attrs(self):
|
|
272
|
+
attrs = self.extract('name', 'type', 'timestamp')
|
|
273
|
+
attrs["image_name"] = self.get("image_name")
|
|
274
|
+
attrs["detail"] = self.get("detail")
|
|
275
|
+
return self.format_as_string_attrs(attrs)
|
|
276
|
+
|
|
277
|
+
def as_string_body(self):
|
|
278
|
+
parts = []
|
|
279
|
+
if self.get("content"):
|
|
280
|
+
parts.append(f"<source>{self.content}</source>")
|
|
281
|
+
if self.get("description"):
|
|
282
|
+
parts.append(f"<description>{self.description}</description>")
|
|
283
|
+
return "\n".join(parts)
|
|
284
|
+
|
|
285
|
+
def as_bytesio(self):
|
|
286
|
+
data_url = self.get_base64()
|
|
287
|
+
if not data_url:
|
|
288
|
+
return None
|
|
289
|
+
|
|
290
|
+
match = re.match(r"^data:image/[^;]+;base64,(.*)$", data_url)
|
|
291
|
+
if not match:
|
|
292
|
+
return None
|
|
293
|
+
return BytesIO(base64.b64decode(match.group(1)))
|
|
294
|
+
|
|
295
|
+
def show(self):
|
|
296
|
+
img_bytes = self.as_bytesio()
|
|
297
|
+
if not img_bytes:
|
|
298
|
+
print("[ImageMessage.show] Image non disponible en mémoire.")
|
|
299
|
+
return
|
|
300
|
+
|
|
301
|
+
try:
|
|
302
|
+
PILImage.open(img_bytes).show()
|
|
303
|
+
except Exception as e:
|
|
304
|
+
print(f"[ImageMessage.show] Erreur lors de l'affichage : {e}")
|
codex_agent/latex.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from .stream_utils import Streamer, TokenStreamer, fenced
|
|
3
|
+
|
|
4
|
+
def escape_formula_specials(formula: str) -> str:
|
|
5
|
+
r"""
|
|
6
|
+
Dans une formule KaTeX, on veut transformer uniquement les séquences
|
|
7
|
+
d'escape type JSON/Python :
|
|
8
|
+
|
|
9
|
+
\n, \t, \r, \b, \f, \", \'
|
|
10
|
+
|
|
11
|
+
en :
|
|
12
|
+
|
|
13
|
+
\\n, \\t, \\r, \\b, \\f, \\", \'
|
|
14
|
+
|
|
15
|
+
SANS toucher :
|
|
16
|
+
- aux séquences déjà échappées (\\n, \\t, ...)
|
|
17
|
+
- aux commandes LaTeX (\frac, \alpha, \begin, ...)
|
|
18
|
+
- aux brackets ()[]{} et autres structures KaTeX.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
# 1) Contrôles \n \t \r \b \f
|
|
22
|
+
# On ne touche que les séquences \x non suivies d'une autre lettre
|
|
23
|
+
# pour éviter \frac, \begin, etc.
|
|
24
|
+
control_chars = "ntrbf"
|
|
25
|
+
pattern_controls = re.compile(
|
|
26
|
+
rf'(?<!\\)\\([{control_chars}])(?=$|[^A-Za-z])'
|
|
27
|
+
)
|
|
28
|
+
formula = pattern_controls.sub(r'\\\1', formula)
|
|
29
|
+
|
|
30
|
+
# 2) Quotes : \" et \'
|
|
31
|
+
pattern_quotes = re.compile(r'(?<!\\)\\(["\'])')
|
|
32
|
+
formula = pattern_quotes.sub(r'\\\1', formula)
|
|
33
|
+
|
|
34
|
+
return formula
|
|
35
|
+
|
|
36
|
+
def _normalize_latex_delimiters(text: str) -> str:
|
|
37
|
+
r"""
|
|
38
|
+
Convertit :
|
|
39
|
+
\( ... \) -> $...$
|
|
40
|
+
\[ ... \] -> $$...$$
|
|
41
|
+
|
|
42
|
+
- Sur une seule ligne (pas de \n à l'intérieur)
|
|
43
|
+
- On supporte les backslashes normaux type LLM / JSON.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
# Display math : \[ ... \] -> $$ ... $$
|
|
47
|
+
text = re.sub(
|
|
48
|
+
r'(?<!\\)\\\[(?P<disp>[^\]\n]+?)\\\]', # \[ ... \]
|
|
49
|
+
lambda m: f"$${m.group('disp')}$$",
|
|
50
|
+
text,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Inline math : \( ... \) -> $ ... $
|
|
54
|
+
text = re.sub(
|
|
55
|
+
r'(?<!\\)\\\((?P<inl>[^\)\n]+?)\\\)', # \( ... \)
|
|
56
|
+
lambda m: f"${m.group('inl')}$",
|
|
57
|
+
text,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
return text
|
|
61
|
+
|
|
62
|
+
def _escape_katex_formulas(text: str) -> str:
|
|
63
|
+
r"""
|
|
64
|
+
Traite uniquement les formules KaTeX dans `text` (hors blocs de code).
|
|
65
|
+
|
|
66
|
+
Gère :
|
|
67
|
+
- block triple ligne : $$ \n ... \n $$
|
|
68
|
+
- display inline : $$ ... $$
|
|
69
|
+
- inline : $ ... $
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
# D'abord normaliser \(..\) et \[..\] en $..$ / $$..$$
|
|
73
|
+
text = _normalize_latex_delimiters(text)
|
|
74
|
+
|
|
75
|
+
pattern = re.compile(
|
|
76
|
+
r"""
|
|
77
|
+
# 1) Bloc KaTeX sur plusieurs lignes :
|
|
78
|
+
(?P<block>^\s*\$\$\s*\n(?P<block_content>.*?)(?:\n)?\s*\$\$\s*$)
|
|
79
|
+
|
|
|
80
|
+
# 2) Inline display : $$...$$ sur une seule ligne
|
|
81
|
+
(?P<inline_display>(?<!\$)\$\$(?!\$)(?P<inline_display_content>[^\n$]+?)\$\$(?!\$))
|
|
82
|
+
|
|
|
83
|
+
# 3) Inline normal : $...$ sur une seule ligne
|
|
84
|
+
(?P<inline>(?<!\$)\$(?!\$)(?P<inline_content>[^$\n]+?)\$(?!\$))
|
|
85
|
+
""",
|
|
86
|
+
re.VERBOSE | re.DOTALL | re.MULTILINE,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def repl(match: re.Match) -> str:
|
|
90
|
+
# Bloc multi-ligne
|
|
91
|
+
if match.group("block_content") is not None:
|
|
92
|
+
content = match.group("block_content")
|
|
93
|
+
return "$$\n" + escape_formula_specials(content) + "\n$$"
|
|
94
|
+
|
|
95
|
+
# Inline display $$...$$
|
|
96
|
+
if match.group("inline_display_content") is not None:
|
|
97
|
+
content = match.group("inline_display_content")
|
|
98
|
+
return f"$${escape_formula_specials(content)}$$"
|
|
99
|
+
|
|
100
|
+
# Inline $...$
|
|
101
|
+
content = match.group("inline_content")
|
|
102
|
+
return f"${escape_formula_specials(content)}$"
|
|
103
|
+
|
|
104
|
+
return pattern.sub(repl, text)
|
|
105
|
+
|
|
106
|
+
def format_latex(md: str) -> str:
|
|
107
|
+
r"""
|
|
108
|
+
Traite les formules KaTeX dans un markdown *raw-like* :
|
|
109
|
+
|
|
110
|
+
- ignore complètement les blocs de code :
|
|
111
|
+
```...``` ou ~~~...~~~
|
|
112
|
+
- convertit \(..\) en $..$, \[..] en $$..$$
|
|
113
|
+
- gère :
|
|
114
|
+
$$ \n ... \n $$ (block)
|
|
115
|
+
$$ ... $$ (inline display)
|
|
116
|
+
$ ... $ (inline)
|
|
117
|
+
- échappe les séquences d'escape foireuses dans les formules.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
code_pattern = re.compile(
|
|
121
|
+
r"""
|
|
122
|
+
(
|
|
123
|
+
```.*?``` # bloc ```...```
|
|
124
|
+
| ~~~.*?~~~ # bloc ~~~...~~~
|
|
125
|
+
)
|
|
126
|
+
""",
|
|
127
|
+
re.VERBOSE | re.DOTALL,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
parts = code_pattern.split(md)
|
|
131
|
+
out_parts = []
|
|
132
|
+
|
|
133
|
+
for part in parts:
|
|
134
|
+
if not part:
|
|
135
|
+
continue
|
|
136
|
+
if part.startswith("```") or part.startswith("~~~"):
|
|
137
|
+
# bloc de code → on ne touche à rien
|
|
138
|
+
out_parts.append(part)
|
|
139
|
+
else:
|
|
140
|
+
# texte normal → traitement KaTeX
|
|
141
|
+
out_parts.append(_escape_katex_formulas(part))
|
|
142
|
+
|
|
143
|
+
return "".join(out_parts)
|
|
144
|
+
|
|
145
|
+
class LaTeXProcessor(Streamer):
|
|
146
|
+
"""Stream processor for formatting LaTeX/KaTeX formulas in markdown text.
|
|
147
|
+
|
|
148
|
+
Escapes special characters in LaTeX formulas while preserving code blocks.
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
def __init__(self):
|
|
152
|
+
super().__init__()
|
|
153
|
+
self.token_streamer = TokenStreamer([
|
|
154
|
+
# ignore processing in markdown code blocs
|
|
155
|
+
(fenced('```','```',flags=re.DOTALL|re.MULTILINE), lambda m: f'{m.group()}'),
|
|
156
|
+
# Mute LaTeX formulas
|
|
157
|
+
(fenced(r'\$\$',r'\$\$',flags=re.DOTALL|re.MULTILINE), lambda m: f'{format_latex(m.group())}'),
|
|
158
|
+
(fenced(r'\\\[',r'\\\]',flags=re.DOTALL|re.MULTILINE), lambda m: f'{format_latex(m.group())}'),
|
|
159
|
+
(fenced(r'\\\(',r'\\\)',flags=re.DOTALL|re.MULTILINE), lambda m: f'{format_latex(m.group())}'),
|
|
160
|
+
(r'\$[^$\n]+?\$', lambda m: f'{format_latex(m.group())}'),
|
|
161
|
+
], threaded=False)
|
|
162
|
+
|
|
163
|
+
def stream_processor(self, stream):
|
|
164
|
+
"""Process stream and format LaTeX formulas."""
|
|
165
|
+
return self.token_streamer.process(stream)
|