zjcode 0.0.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.
- deepagents_code/__init__.py +42 -0
- deepagents_code/__main__.py +6 -0
- deepagents_code/_ask_user_types.py +90 -0
- deepagents_code/_cli_context.py +96 -0
- deepagents_code/_constants.py +41 -0
- deepagents_code/_debug.py +148 -0
- deepagents_code/_debug_buffer.py +204 -0
- deepagents_code/_env_vars.py +411 -0
- deepagents_code/_fake_models.py +66 -0
- deepagents_code/_git.py +521 -0
- deepagents_code/_paths.py +69 -0
- deepagents_code/_server_config.py +576 -0
- deepagents_code/_session_stats.py +235 -0
- deepagents_code/_startup_error.py +45 -0
- deepagents_code/_testing_models.py +305 -0
- deepagents_code/_textual_patches.py +420 -0
- deepagents_code/_tool_stream.py +694 -0
- deepagents_code/_version.py +46 -0
- deepagents_code/agent.py +1976 -0
- deepagents_code/app.py +19239 -0
- deepagents_code/app.tcss +438 -0
- deepagents_code/approval_mode.py +131 -0
- deepagents_code/ask_user.py +306 -0
- deepagents_code/auth_display.py +185 -0
- deepagents_code/auth_store.py +545 -0
- deepagents_code/built_in_skills/__init__.py +5 -0
- deepagents_code/built_in_skills/remember/SKILL.md +118 -0
- deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
- deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
- deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
- deepagents_code/client/__init__.py +1 -0
- deepagents_code/client/commands/__init__.py +1 -0
- deepagents_code/client/commands/auth.py +541 -0
- deepagents_code/client/commands/config.py +714 -0
- deepagents_code/client/commands/mcp.py +250 -0
- deepagents_code/client/commands/tools.py +416 -0
- deepagents_code/client/launch/__init__.py +1 -0
- deepagents_code/client/launch/server.py +978 -0
- deepagents_code/client/launch/server_manager.py +540 -0
- deepagents_code/client/non_interactive.py +1758 -0
- deepagents_code/client/remote_client.py +794 -0
- deepagents_code/clipboard.py +217 -0
- deepagents_code/command_registry.py +450 -0
- deepagents_code/config.py +4829 -0
- deepagents_code/config_manifest.py +1451 -0
- deepagents_code/configurable_model.py +577 -0
- deepagents_code/default_agent_prompt.md +12 -0
- deepagents_code/doctor.py +559 -0
- deepagents_code/editor.py +142 -0
- deepagents_code/event_bus.py +411 -0
- deepagents_code/extras_info.py +661 -0
- deepagents_code/file_ops.py +576 -0
- deepagents_code/formatting.py +135 -0
- deepagents_code/goal_rubric.py +497 -0
- deepagents_code/goal_tools.py +459 -0
- deepagents_code/hooks.py +348 -0
- deepagents_code/input.py +1041 -0
- deepagents_code/integrations/__init__.py +1 -0
- deepagents_code/integrations/openai_codex.py +551 -0
- deepagents_code/integrations/sandbox_config.py +198 -0
- deepagents_code/integrations/sandbox_factory.py +1124 -0
- deepagents_code/integrations/sandbox_provider.py +137 -0
- deepagents_code/integrations/sandbox_registry.py +350 -0
- deepagents_code/iterm_cursor_guide.py +176 -0
- deepagents_code/local_context.py +926 -0
- deepagents_code/main.py +3985 -0
- deepagents_code/managed_tools.py +642 -0
- deepagents_code/mcp_auth.py +1950 -0
- deepagents_code/mcp_config.py +176 -0
- deepagents_code/mcp_disabled.py +212 -0
- deepagents_code/mcp_login_service.py +281 -0
- deepagents_code/mcp_oauth_ui.py +199 -0
- deepagents_code/mcp_providers/__init__.py +23 -0
- deepagents_code/mcp_providers/_registry.py +39 -0
- deepagents_code/mcp_providers/base.py +133 -0
- deepagents_code/mcp_providers/github.py +102 -0
- deepagents_code/mcp_providers/slack.py +175 -0
- deepagents_code/mcp_tools.py +2427 -0
- deepagents_code/mcp_trust.py +207 -0
- deepagents_code/media_utils.py +826 -0
- deepagents_code/memory_guard.py +474 -0
- deepagents_code/model_config.py +4156 -0
- deepagents_code/notifications.py +247 -0
- deepagents_code/offload.py +402 -0
- deepagents_code/onboarding.py +223 -0
- deepagents_code/output.py +69 -0
- deepagents_code/paste_collapse.py +103 -0
- deepagents_code/project_utils.py +231 -0
- deepagents_code/py.typed +0 -0
- deepagents_code/reasoning_effort.py +641 -0
- deepagents_code/reliable_rubric.py +97 -0
- deepagents_code/resume_state.py +237 -0
- deepagents_code/server_graph.py +310 -0
- deepagents_code/session_end_summary.py +329 -0
- deepagents_code/sessions.py +1716 -0
- deepagents_code/skills/__init__.py +18 -0
- deepagents_code/skills/commands.py +1252 -0
- deepagents_code/skills/invocation.py +112 -0
- deepagents_code/skills/load.py +196 -0
- deepagents_code/skills/trust.py +547 -0
- deepagents_code/state_migration.py +136 -0
- deepagents_code/subagents.py +278 -0
- deepagents_code/system_prompt.md +204 -0
- deepagents_code/terminal_capabilities.py +115 -0
- deepagents_code/terminal_escape.py +287 -0
- deepagents_code/theme.py +891 -0
- deepagents_code/todo_list_prompt.md +12 -0
- deepagents_code/tool_catalog.py +509 -0
- deepagents_code/tool_display.py +367 -0
- deepagents_code/tools.py +516 -0
- deepagents_code/tui/__init__.py +1 -0
- deepagents_code/tui/textual_adapter.py +2553 -0
- deepagents_code/tui/widgets/__init__.py +9 -0
- deepagents_code/tui/widgets/_js_eval_display.py +139 -0
- deepagents_code/tui/widgets/_links.py +261 -0
- deepagents_code/tui/widgets/agent_selector.py +420 -0
- deepagents_code/tui/widgets/approval.py +602 -0
- deepagents_code/tui/widgets/ask_user.py +515 -0
- deepagents_code/tui/widgets/auth.py +1997 -0
- deepagents_code/tui/widgets/autocomplete.py +890 -0
- deepagents_code/tui/widgets/chat_input.py +3181 -0
- deepagents_code/tui/widgets/codex_auth.py +452 -0
- deepagents_code/tui/widgets/cwd_switch.py +242 -0
- deepagents_code/tui/widgets/debug_console.py +868 -0
- deepagents_code/tui/widgets/diff.py +252 -0
- deepagents_code/tui/widgets/effort_selector.py +189 -0
- deepagents_code/tui/widgets/goal_review.py +390 -0
- deepagents_code/tui/widgets/goal_status.py +50 -0
- deepagents_code/tui/widgets/history.py +194 -0
- deepagents_code/tui/widgets/install_confirm.py +248 -0
- deepagents_code/tui/widgets/launch_init.py +482 -0
- deepagents_code/tui/widgets/loading.py +227 -0
- deepagents_code/tui/widgets/mcp_login.py +539 -0
- deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
- deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
- deepagents_code/tui/widgets/message_store.py +999 -0
- deepagents_code/tui/widgets/messages.py +3846 -0
- deepagents_code/tui/widgets/model_selector.py +2343 -0
- deepagents_code/tui/widgets/notification_center.py +456 -0
- deepagents_code/tui/widgets/notification_detail.py +257 -0
- deepagents_code/tui/widgets/notification_settings.py +170 -0
- deepagents_code/tui/widgets/restart_prompt.py +158 -0
- deepagents_code/tui/widgets/skill_trust.py +131 -0
- deepagents_code/tui/widgets/startup_tip.py +91 -0
- deepagents_code/tui/widgets/status.py +781 -0
- deepagents_code/tui/widgets/subagent_panel.py +969 -0
- deepagents_code/tui/widgets/theme_selector.py +401 -0
- deepagents_code/tui/widgets/thread_selector.py +2564 -0
- deepagents_code/tui/widgets/tool_renderers.py +190 -0
- deepagents_code/tui/widgets/tool_widgets.py +304 -0
- deepagents_code/tui/widgets/update_available.py +311 -0
- deepagents_code/tui/widgets/update_confirm.py +184 -0
- deepagents_code/tui/widgets/update_progress.py +327 -0
- deepagents_code/tui/widgets/welcome.py +508 -0
- deepagents_code/turn_end_summary.py +522 -0
- deepagents_code/ui.py +858 -0
- deepagents_code/unicode_security.py +563 -0
- deepagents_code/update_check.py +3124 -0
- zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
- zjcode-0.0.1.dist-info/METADATA +220 -0
- zjcode-0.0.1.dist-info/RECORD +163 -0
- zjcode-0.0.1.dist-info/WHEEL +4 -0
- zjcode-0.0.1.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,826 @@
|
|
|
1
|
+
"""Utilities for handling image and video media from clipboard and files."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import io
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import pathlib
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
|
|
11
|
+
# S404: subprocess needed for clipboard access via pngpaste/osascript
|
|
12
|
+
import subprocess # noqa: S404
|
|
13
|
+
import sys
|
|
14
|
+
import tempfile
|
|
15
|
+
from collections import Counter
|
|
16
|
+
from collections.abc import Iterable
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import TYPE_CHECKING, Any
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from langchain_core.messages.content import VideoContentBlock
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
IMAGE_EXTENSIONS: frozenset[str] = frozenset(
|
|
26
|
+
{
|
|
27
|
+
".png",
|
|
28
|
+
".jpg",
|
|
29
|
+
".jpeg",
|
|
30
|
+
".gif",
|
|
31
|
+
".bmp",
|
|
32
|
+
".tiff",
|
|
33
|
+
".tif",
|
|
34
|
+
".webp",
|
|
35
|
+
".ico",
|
|
36
|
+
}
|
|
37
|
+
)
|
|
38
|
+
"""Common image file extensions supported by PIL."""
|
|
39
|
+
|
|
40
|
+
VIDEO_EXTENSIONS: frozenset[str] = frozenset(
|
|
41
|
+
{
|
|
42
|
+
".mp4",
|
|
43
|
+
".mov",
|
|
44
|
+
".avi",
|
|
45
|
+
".webm",
|
|
46
|
+
".m4v",
|
|
47
|
+
".wmv",
|
|
48
|
+
}
|
|
49
|
+
)
|
|
50
|
+
"""Video file extensions with validated magic-byte support."""
|
|
51
|
+
|
|
52
|
+
MAX_MEDIA_BYTES: int = 20 * 1024 * 1024
|
|
53
|
+
"""Maximum media file size (20 MB). Keeps base64 payload under ~27 MB."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def strip_media_placeholders(
|
|
57
|
+
text: str,
|
|
58
|
+
placeholders: Iterable[str],
|
|
59
|
+
*,
|
|
60
|
+
placeholder_spans: Iterable[tuple[int, int]] | None = None,
|
|
61
|
+
) -> str:
|
|
62
|
+
"""Remove display-only media placeholders from user text.
|
|
63
|
+
|
|
64
|
+
Placeholders like `[image 1]` are inserted into the terminal input purely for
|
|
65
|
+
display; the actual media travels as structured content blocks. They must not
|
|
66
|
+
leak into the canonical model-facing message or LangSmith trace as if the user
|
|
67
|
+
typed them.
|
|
68
|
+
|
|
69
|
+
When available, tracked placeholder spans identify the exact display tokens to
|
|
70
|
+
strip so user-authored literal duplicates with the same token are preserved.
|
|
71
|
+
The token fallback removes one matching occurrence per tracked media item for
|
|
72
|
+
callers that only have placeholder text.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
text: Raw user text that may contain media placeholders.
|
|
76
|
+
placeholders: Exact placeholder tokens for the media actually attached to
|
|
77
|
+
this message (e.g. ``["[image 1]", "[video 1]"]``).
|
|
78
|
+
placeholder_spans: Exact `(start, end)` spans for tracked display tokens
|
|
79
|
+
in `text`, when known.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
Text with the given media placeholders removed and surrounding whitespace
|
|
83
|
+
tidied. Newlines are preserved so multi-line prompts keep their structure.
|
|
84
|
+
Returns an empty string when only whitespace remains after removal, so
|
|
85
|
+
callers can treat a placeholder-only message as having no text block.
|
|
86
|
+
"""
|
|
87
|
+
tokens = [p for p in placeholders if p]
|
|
88
|
+
if not tokens:
|
|
89
|
+
return text
|
|
90
|
+
|
|
91
|
+
valid_spans = _valid_placeholder_spans(text, tokens, placeholder_spans)
|
|
92
|
+
spans = [(start, end) for start, end, _token in valid_spans]
|
|
93
|
+
counts = Counter(tokens)
|
|
94
|
+
for _start, _end, token in valid_spans:
|
|
95
|
+
counts[token] -= 1
|
|
96
|
+
for token, count in counts.items():
|
|
97
|
+
if count <= 0:
|
|
98
|
+
continue
|
|
99
|
+
pattern = re.compile(r"[ \t]*" + re.escape(token))
|
|
100
|
+
matches = list(pattern.finditer(text))
|
|
101
|
+
if len(matches) > count:
|
|
102
|
+
# No (or too few) tracked spans, yet the token appears more times
|
|
103
|
+
# than we intend to strip: we can't tell the display token from a
|
|
104
|
+
# user-typed literal and fall back to removing the leading
|
|
105
|
+
# occurrence(s). That guess can strip a literal and leave the real
|
|
106
|
+
# display token behind, so leave a breadcrumb (never the text).
|
|
107
|
+
logger.debug(
|
|
108
|
+
"Ambiguous media placeholder strip: removing %d of %d "
|
|
109
|
+
"occurrences of %r with no tracked span to disambiguate",
|
|
110
|
+
count,
|
|
111
|
+
len(matches),
|
|
112
|
+
token,
|
|
113
|
+
)
|
|
114
|
+
for index, match in enumerate(matches):
|
|
115
|
+
if index >= count:
|
|
116
|
+
break
|
|
117
|
+
spans.append(match.span())
|
|
118
|
+
|
|
119
|
+
# Only strip spaces/tabs (not newlines) so code indentation on lines after a
|
|
120
|
+
# removed placeholder is preserved. A full .strip() would collapse
|
|
121
|
+
# "[image 1]\n def foo():" to "def foo():", losing the leading indent.
|
|
122
|
+
cleaned = text
|
|
123
|
+
for start, end in sorted(spans, reverse=True):
|
|
124
|
+
cleaned = cleaned[:start] + cleaned[end:]
|
|
125
|
+
cleaned = cleaned.strip(" \t")
|
|
126
|
+
return cleaned if cleaned.strip() else ""
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _valid_placeholder_spans(
|
|
130
|
+
text: str,
|
|
131
|
+
tokens: list[str],
|
|
132
|
+
spans: Iterable[tuple[int, int]] | None,
|
|
133
|
+
) -> list[tuple[int, int, str]]:
|
|
134
|
+
"""Return valid display placeholder spans expanded over adjacent padding.
|
|
135
|
+
|
|
136
|
+
A span is kept only when it is in range and its slice equals one of the
|
|
137
|
+
bound tokens, so a stale span (e.g. left by an offset shift) is dropped
|
|
138
|
+
rather than used to delete arbitrary text; the caller then falls back to
|
|
139
|
+
token matching for that item.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
text: Text the spans index into.
|
|
143
|
+
tokens: Bound placeholder tokens for the attached media.
|
|
144
|
+
spans: Candidate `(start, end)` display-token spans, or `None`.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
`(start, end, token)` triples with `start` expanded left over any
|
|
148
|
+
adjacent spaces/tabs, for spans that validate against `text`.
|
|
149
|
+
"""
|
|
150
|
+
if spans is None:
|
|
151
|
+
return []
|
|
152
|
+
|
|
153
|
+
token_set = set(tokens)
|
|
154
|
+
valid: list[tuple[int, int, str]] = []
|
|
155
|
+
for start, end in spans:
|
|
156
|
+
if not (0 <= start < end <= len(text)):
|
|
157
|
+
continue
|
|
158
|
+
token = text[start:end]
|
|
159
|
+
if token not in token_set:
|
|
160
|
+
continue
|
|
161
|
+
expanded_start = start
|
|
162
|
+
while expanded_start > 0 and text[expanded_start - 1] in " \t":
|
|
163
|
+
expanded_start -= 1
|
|
164
|
+
valid.append((expanded_start, end, token))
|
|
165
|
+
return valid
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _get_executable(name: str) -> str | None:
|
|
169
|
+
"""Get full path to an executable using shutil.which().
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
name: Name of the executable to find
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
Full path to executable, or None if not found.
|
|
176
|
+
"""
|
|
177
|
+
return shutil.which(name)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@dataclass
|
|
181
|
+
class ImageData:
|
|
182
|
+
"""Represents a pasted image with its base64 encoding."""
|
|
183
|
+
|
|
184
|
+
base64_data: str
|
|
185
|
+
format: str # "png", "jpeg", etc.
|
|
186
|
+
placeholder: str # Display text like "[image 1]"
|
|
187
|
+
placeholder_span: tuple[int, int] | None = None
|
|
188
|
+
|
|
189
|
+
def to_message_content(self) -> dict:
|
|
190
|
+
"""Convert to LangChain message content format.
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
Dict with type and image_url for multimodal messages.
|
|
194
|
+
"""
|
|
195
|
+
return {
|
|
196
|
+
"type": "image_url",
|
|
197
|
+
"image_url": {"url": f"data:image/{self.format};base64,{self.base64_data}"},
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@dataclass
|
|
202
|
+
class VideoData:
|
|
203
|
+
"""Represents a pasted video with its base64 encoding."""
|
|
204
|
+
|
|
205
|
+
base64_data: str
|
|
206
|
+
format: str # "mp4", "quicktime", etc.
|
|
207
|
+
placeholder: str # Display text like "[video 1]"
|
|
208
|
+
placeholder_span: tuple[int, int] | None = None
|
|
209
|
+
|
|
210
|
+
def to_message_content(self) -> "VideoContentBlock":
|
|
211
|
+
"""Convert to LangChain `VideoContentBlock` format.
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
`VideoContentBlock` with base64 data and mime_type.
|
|
215
|
+
"""
|
|
216
|
+
from langchain_core.messages.content import create_video_block
|
|
217
|
+
|
|
218
|
+
return create_video_block(
|
|
219
|
+
base64=self.base64_data,
|
|
220
|
+
mime_type=f"video/{self.format}",
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _get_windows_clipboard_image() -> ImageData | None:
|
|
225
|
+
"""Get clipboard image on Windows using PowerShell.
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
ImageData if an image is found, None otherwise.
|
|
229
|
+
"""
|
|
230
|
+
from PIL import Image, UnidentifiedImageError
|
|
231
|
+
|
|
232
|
+
powershell_path = _get_executable("powershell")
|
|
233
|
+
if not powershell_path:
|
|
234
|
+
return None
|
|
235
|
+
|
|
236
|
+
# Create temp file for image
|
|
237
|
+
fd, temp_path = tempfile.mkstemp(suffix=".png")
|
|
238
|
+
os.close(fd)
|
|
239
|
+
|
|
240
|
+
try:
|
|
241
|
+
# PowerShell script to get clipboard image and save to file
|
|
242
|
+
script = f"""
|
|
243
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
244
|
+
if ([System.Windows.Forms.Clipboard]::ContainsImage()) {{
|
|
245
|
+
$image = [System.Windows.Forms.Clipboard]::GetImage()
|
|
246
|
+
$image.Save('{temp_path}', [System.Drawing.Imaging.ImageFormat]::Png)
|
|
247
|
+
Write-Output "success"
|
|
248
|
+
}}
|
|
249
|
+
"""
|
|
250
|
+
|
|
251
|
+
try:
|
|
252
|
+
result = subprocess.run( # noqa: S603
|
|
253
|
+
[powershell_path, "-Command", script],
|
|
254
|
+
capture_output=True,
|
|
255
|
+
check=False,
|
|
256
|
+
timeout=5,
|
|
257
|
+
text=True,
|
|
258
|
+
)
|
|
259
|
+
except FileNotFoundError:
|
|
260
|
+
logger.debug("powershell not found for clipboard image paste")
|
|
261
|
+
return None
|
|
262
|
+
except subprocess.TimeoutExpired:
|
|
263
|
+
logger.debug("powershell clipboard read timed out")
|
|
264
|
+
return None
|
|
265
|
+
|
|
266
|
+
if result.returncode != 0 or "success" not in result.stdout:
|
|
267
|
+
return None
|
|
268
|
+
|
|
269
|
+
# Read the image file
|
|
270
|
+
temp_file = pathlib.Path(temp_path)
|
|
271
|
+
if temp_file.exists() and temp_file.stat().st_size > 0:
|
|
272
|
+
image_bytes = temp_file.read_bytes()
|
|
273
|
+
# Reject oversized images before encoding to avoid provider
|
|
274
|
+
# BadRequestError and subsequent thread checkpoint poisoning.
|
|
275
|
+
if len(image_bytes) > MAX_MEDIA_BYTES:
|
|
276
|
+
logger.warning(
|
|
277
|
+
"Clipboard image from Windows is too large "
|
|
278
|
+
"(%d MB, max %d MB)",
|
|
279
|
+
len(image_bytes) // (1024 * 1024),
|
|
280
|
+
MAX_MEDIA_BYTES // (1024 * 1024),
|
|
281
|
+
)
|
|
282
|
+
return None
|
|
283
|
+
try:
|
|
284
|
+
Image.open(io.BytesIO(image_bytes))
|
|
285
|
+
base64_data = base64.b64encode(image_bytes).decode("utf-8")
|
|
286
|
+
return ImageData(
|
|
287
|
+
base64_data=base64_data,
|
|
288
|
+
format="png",
|
|
289
|
+
placeholder="[image]",
|
|
290
|
+
)
|
|
291
|
+
except (UnidentifiedImageError, OSError) as e:
|
|
292
|
+
logger.debug(
|
|
293
|
+
"Invalid image data from Windows clipboard: %s",
|
|
294
|
+
e,
|
|
295
|
+
exc_info=True,
|
|
296
|
+
)
|
|
297
|
+
return None
|
|
298
|
+
finally:
|
|
299
|
+
# Clean up temp file
|
|
300
|
+
if pathlib.Path(temp_path).exists():
|
|
301
|
+
pathlib.Path(temp_path).unlink()
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _get_linux_clipboard_image() -> ImageData | None:
|
|
305
|
+
"""Get clipboard image on Linux using xclip (X11) or wl-paste (Wayland).
|
|
306
|
+
|
|
307
|
+
Returns:
|
|
308
|
+
ImageData if an image is found, None otherwise.
|
|
309
|
+
"""
|
|
310
|
+
from PIL import Image, UnidentifiedImageError
|
|
311
|
+
|
|
312
|
+
# Try wl-paste first (Wayland)
|
|
313
|
+
wl_paste_path = _get_executable("wl-paste")
|
|
314
|
+
if wl_paste_path:
|
|
315
|
+
try:
|
|
316
|
+
# Check if clipboard has image
|
|
317
|
+
check_result = subprocess.run( # noqa: S603
|
|
318
|
+
[wl_paste_path, "-l"],
|
|
319
|
+
capture_output=True,
|
|
320
|
+
check=False,
|
|
321
|
+
timeout=2,
|
|
322
|
+
text=True,
|
|
323
|
+
)
|
|
324
|
+
if check_result.returncode == 0 and "image/" in check_result.stdout:
|
|
325
|
+
# Get image data
|
|
326
|
+
result = subprocess.run( # noqa: S603
|
|
327
|
+
[wl_paste_path, "-t", "image/png"],
|
|
328
|
+
capture_output=True,
|
|
329
|
+
check=False,
|
|
330
|
+
timeout=3,
|
|
331
|
+
)
|
|
332
|
+
if result.returncode == 0 and result.stdout:
|
|
333
|
+
# Reject oversized images before encoding to avoid provider
|
|
334
|
+
# BadRequestError and subsequent thread checkpoint poisoning.
|
|
335
|
+
if len(result.stdout) > MAX_MEDIA_BYTES:
|
|
336
|
+
logger.warning(
|
|
337
|
+
"Clipboard image from wl-paste is too large "
|
|
338
|
+
"(%d MB, max %d MB)",
|
|
339
|
+
len(result.stdout) // (1024 * 1024),
|
|
340
|
+
MAX_MEDIA_BYTES // (1024 * 1024),
|
|
341
|
+
)
|
|
342
|
+
return None
|
|
343
|
+
try:
|
|
344
|
+
Image.open(io.BytesIO(result.stdout))
|
|
345
|
+
base64_data = base64.b64encode(result.stdout).decode("utf-8")
|
|
346
|
+
return ImageData(
|
|
347
|
+
base64_data=base64_data,
|
|
348
|
+
format="png",
|
|
349
|
+
placeholder="[image]",
|
|
350
|
+
)
|
|
351
|
+
except (UnidentifiedImageError, OSError) as e:
|
|
352
|
+
logger.debug(
|
|
353
|
+
"Invalid image data from wl-paste: %s",
|
|
354
|
+
e,
|
|
355
|
+
exc_info=True,
|
|
356
|
+
)
|
|
357
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
358
|
+
pass
|
|
359
|
+
|
|
360
|
+
# Fall back to xclip (X11)
|
|
361
|
+
xclip_path = _get_executable("xclip")
|
|
362
|
+
if xclip_path:
|
|
363
|
+
try:
|
|
364
|
+
# Check if clipboard has image
|
|
365
|
+
check_result = subprocess.run( # noqa: S603
|
|
366
|
+
[xclip_path, "-selection", "clipboard", "-t", "TARGETS", "-o"],
|
|
367
|
+
capture_output=True,
|
|
368
|
+
check=False,
|
|
369
|
+
timeout=2,
|
|
370
|
+
text=True,
|
|
371
|
+
)
|
|
372
|
+
if check_result.returncode == 0 and "image/png" in check_result.stdout:
|
|
373
|
+
# Get image data
|
|
374
|
+
result = subprocess.run( # noqa: S603
|
|
375
|
+
[xclip_path, "-selection", "clipboard", "-t", "image/png", "-o"],
|
|
376
|
+
capture_output=True,
|
|
377
|
+
check=False,
|
|
378
|
+
timeout=3,
|
|
379
|
+
)
|
|
380
|
+
if result.returncode == 0 and result.stdout:
|
|
381
|
+
# Reject oversized images before encoding to avoid provider
|
|
382
|
+
# BadRequestError and subsequent thread checkpoint poisoning.
|
|
383
|
+
if len(result.stdout) > MAX_MEDIA_BYTES:
|
|
384
|
+
logger.warning(
|
|
385
|
+
"Clipboard image from xclip is too large "
|
|
386
|
+
"(%d MB, max %d MB)",
|
|
387
|
+
len(result.stdout) // (1024 * 1024),
|
|
388
|
+
MAX_MEDIA_BYTES // (1024 * 1024),
|
|
389
|
+
)
|
|
390
|
+
return None
|
|
391
|
+
try:
|
|
392
|
+
Image.open(io.BytesIO(result.stdout))
|
|
393
|
+
base64_data = base64.b64encode(result.stdout).decode("utf-8")
|
|
394
|
+
return ImageData(
|
|
395
|
+
base64_data=base64_data,
|
|
396
|
+
format="png",
|
|
397
|
+
placeholder="[image]",
|
|
398
|
+
)
|
|
399
|
+
except (UnidentifiedImageError, OSError) as e:
|
|
400
|
+
logger.debug(
|
|
401
|
+
"Invalid image data from xclip: %s",
|
|
402
|
+
e,
|
|
403
|
+
exc_info=True,
|
|
404
|
+
)
|
|
405
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
406
|
+
pass
|
|
407
|
+
|
|
408
|
+
return None
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def get_clipboard_image() -> ImageData | None:
|
|
412
|
+
"""Attempt to read an image from the system clipboard.
|
|
413
|
+
|
|
414
|
+
Supports:
|
|
415
|
+
- macOS via `pngpaste` or `osascript`
|
|
416
|
+
- Windows via PowerShell
|
|
417
|
+
- Linux via `wl-paste` (Wayland) or `xclip` (X11)
|
|
418
|
+
|
|
419
|
+
Returns:
|
|
420
|
+
ImageData if an image is found, None otherwise.
|
|
421
|
+
"""
|
|
422
|
+
if sys.platform == "darwin":
|
|
423
|
+
return _get_macos_clipboard_image()
|
|
424
|
+
if sys.platform == "win32":
|
|
425
|
+
return _get_windows_clipboard_image()
|
|
426
|
+
if sys.platform.startswith("linux"):
|
|
427
|
+
return _get_linux_clipboard_image()
|
|
428
|
+
logger.warning(
|
|
429
|
+
"Clipboard image paste is not supported on %s. "
|
|
430
|
+
"You can still attach images by dragging and dropping file paths.",
|
|
431
|
+
sys.platform,
|
|
432
|
+
)
|
|
433
|
+
return None
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def get_image_from_path(path: pathlib.Path) -> ImageData | None:
|
|
437
|
+
"""Read and encode an image file from disk.
|
|
438
|
+
|
|
439
|
+
Args:
|
|
440
|
+
path: Path to the image file.
|
|
441
|
+
|
|
442
|
+
Returns:
|
|
443
|
+
`ImageData` when the file is a valid image, otherwise `None`.
|
|
444
|
+
"""
|
|
445
|
+
from PIL import Image, UnidentifiedImageError
|
|
446
|
+
|
|
447
|
+
try:
|
|
448
|
+
file_size = path.stat().st_size
|
|
449
|
+
if file_size == 0:
|
|
450
|
+
logger.debug("Image file is empty: %s", path)
|
|
451
|
+
return None
|
|
452
|
+
if file_size > MAX_MEDIA_BYTES:
|
|
453
|
+
logger.warning(
|
|
454
|
+
"Image file %s is too large (%d MB, max %d MB)",
|
|
455
|
+
path,
|
|
456
|
+
file_size // (1024 * 1024),
|
|
457
|
+
MAX_MEDIA_BYTES // (1024 * 1024),
|
|
458
|
+
)
|
|
459
|
+
return None
|
|
460
|
+
|
|
461
|
+
image_bytes = path.read_bytes()
|
|
462
|
+
if not image_bytes:
|
|
463
|
+
return None
|
|
464
|
+
|
|
465
|
+
with Image.open(io.BytesIO(image_bytes)) as image:
|
|
466
|
+
image_format = (image.format or "").lower()
|
|
467
|
+
|
|
468
|
+
if image_format == "jpg":
|
|
469
|
+
image_format = "jpeg"
|
|
470
|
+
if not image_format:
|
|
471
|
+
suffix = path.suffix.lower().removeprefix(".")
|
|
472
|
+
image_format = "jpeg" if suffix == "jpg" else suffix
|
|
473
|
+
if not image_format:
|
|
474
|
+
image_format = "png"
|
|
475
|
+
|
|
476
|
+
return ImageData(
|
|
477
|
+
base64_data=encode_to_base64(image_bytes),
|
|
478
|
+
format=image_format,
|
|
479
|
+
placeholder="[image]",
|
|
480
|
+
)
|
|
481
|
+
except (UnidentifiedImageError, OSError) as e:
|
|
482
|
+
logger.debug("Failed to load image from %s: %s", path, e, exc_info=True)
|
|
483
|
+
return None
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _detect_video_format(data: bytes) -> str | None:
|
|
487
|
+
"""Detect video MIME subtype from magic bytes.
|
|
488
|
+
|
|
489
|
+
Args:
|
|
490
|
+
data: Raw file bytes (at least 12 bytes for reliable detection).
|
|
491
|
+
|
|
492
|
+
Returns:
|
|
493
|
+
MIME subtype (e.g. "mp4", "webm") or `None` if unrecognized.
|
|
494
|
+
"""
|
|
495
|
+
min_avi_len = 12
|
|
496
|
+
if data[4:8] == b"ftyp":
|
|
497
|
+
# ftyp box: major brand at bytes 8-12 distinguishes MOV vs MP4
|
|
498
|
+
brand = data[8:12]
|
|
499
|
+
if brand == b"qt ":
|
|
500
|
+
return "quicktime"
|
|
501
|
+
return "mp4"
|
|
502
|
+
if data[:4] == b"RIFF" and len(data) >= min_avi_len and data[8:12] == b"AVI ":
|
|
503
|
+
return "avi"
|
|
504
|
+
if data[:4] == b"\x30\x26\xb2\x75": # ASF/WMV
|
|
505
|
+
return "x-ms-wmv"
|
|
506
|
+
if data[:4] == b"\x1a\x45\xdf\xa3": # WebM/Matroska (EBML header)
|
|
507
|
+
return "webm"
|
|
508
|
+
return None
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def get_video_from_path(path: pathlib.Path) -> VideoData | None:
|
|
512
|
+
"""Read and encode a video file from disk.
|
|
513
|
+
|
|
514
|
+
Args:
|
|
515
|
+
path: Path to the video file.
|
|
516
|
+
|
|
517
|
+
Returns:
|
|
518
|
+
`VideoData` when the file is a valid video, otherwise `None`.
|
|
519
|
+
"""
|
|
520
|
+
suffix = path.suffix.lower()
|
|
521
|
+
if suffix not in VIDEO_EXTENSIONS:
|
|
522
|
+
return None
|
|
523
|
+
|
|
524
|
+
try:
|
|
525
|
+
file_size = path.stat().st_size
|
|
526
|
+
if file_size == 0:
|
|
527
|
+
logger.debug("Video file is empty: %s", path)
|
|
528
|
+
return None
|
|
529
|
+
if file_size > MAX_MEDIA_BYTES:
|
|
530
|
+
logger.warning(
|
|
531
|
+
"Video file %s is too large (%d MB, max %d MB)",
|
|
532
|
+
path,
|
|
533
|
+
file_size // (1024 * 1024),
|
|
534
|
+
MAX_MEDIA_BYTES // (1024 * 1024),
|
|
535
|
+
)
|
|
536
|
+
return None
|
|
537
|
+
|
|
538
|
+
video_bytes = path.read_bytes()
|
|
539
|
+
|
|
540
|
+
# Validate it's a real video file by checking magic bytes
|
|
541
|
+
# MP4 starts with ftyp, MOV also uses ftyp, AVI starts with RIFF
|
|
542
|
+
min_video_len = 8
|
|
543
|
+
if len(video_bytes) < min_video_len:
|
|
544
|
+
logger.debug("Video file too small (%d bytes): %s", len(video_bytes), path)
|
|
545
|
+
return None
|
|
546
|
+
|
|
547
|
+
# Detect format from magic bytes (not extension) so renamed files
|
|
548
|
+
# get the correct MIME type.
|
|
549
|
+
detected_format = _detect_video_format(video_bytes)
|
|
550
|
+
if detected_format is None:
|
|
551
|
+
logger.warning(
|
|
552
|
+
"Video file %s has unrecognized signature for extension '%s'; "
|
|
553
|
+
"skipping. If this is a valid video, the format may not be "
|
|
554
|
+
"supported yet.",
|
|
555
|
+
path,
|
|
556
|
+
suffix,
|
|
557
|
+
)
|
|
558
|
+
return None
|
|
559
|
+
|
|
560
|
+
return VideoData(
|
|
561
|
+
base64_data=encode_to_base64(video_bytes),
|
|
562
|
+
format=detected_format,
|
|
563
|
+
placeholder="[video]",
|
|
564
|
+
)
|
|
565
|
+
except OSError as e:
|
|
566
|
+
logger.warning("Failed to load video from %s: %s", path, e, exc_info=True)
|
|
567
|
+
return None
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def get_media_from_path(path: pathlib.Path) -> ImageData | VideoData | None:
|
|
571
|
+
"""Try to load a file as an image first, then as a video.
|
|
572
|
+
|
|
573
|
+
Args:
|
|
574
|
+
path: Path to the media file.
|
|
575
|
+
|
|
576
|
+
Returns:
|
|
577
|
+
`ImageData` or `VideoData` if the file is valid media, otherwise `None`.
|
|
578
|
+
"""
|
|
579
|
+
result: ImageData | VideoData | None = get_image_from_path(path)
|
|
580
|
+
if result is not None:
|
|
581
|
+
return result
|
|
582
|
+
return get_video_from_path(path)
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _get_macos_clipboard_image() -> ImageData | None:
|
|
586
|
+
"""Get clipboard image on macOS using pngpaste or osascript.
|
|
587
|
+
|
|
588
|
+
First tries pngpaste (faster if installed), then falls back to osascript.
|
|
589
|
+
|
|
590
|
+
Returns:
|
|
591
|
+
ImageData if an image is found, None otherwise.
|
|
592
|
+
"""
|
|
593
|
+
from PIL import Image, UnidentifiedImageError
|
|
594
|
+
|
|
595
|
+
# Try pngpaste first (fast if installed)
|
|
596
|
+
pngpaste_path = _get_executable("pngpaste")
|
|
597
|
+
if pngpaste_path:
|
|
598
|
+
try:
|
|
599
|
+
# S603: pngpaste_path is validated via shutil.which(), args are hardcoded
|
|
600
|
+
result = subprocess.run( # noqa: S603
|
|
601
|
+
[pngpaste_path, "-"],
|
|
602
|
+
capture_output=True,
|
|
603
|
+
check=False,
|
|
604
|
+
timeout=2,
|
|
605
|
+
)
|
|
606
|
+
if result.returncode == 0 and result.stdout:
|
|
607
|
+
# Reject oversized images before encoding to avoid provider
|
|
608
|
+
# BadRequestError and subsequent thread checkpoint poisoning.
|
|
609
|
+
if len(result.stdout) > MAX_MEDIA_BYTES:
|
|
610
|
+
logger.warning(
|
|
611
|
+
"Clipboard image from pngpaste is too large "
|
|
612
|
+
"(%d MB, max %d MB)",
|
|
613
|
+
len(result.stdout) // (1024 * 1024),
|
|
614
|
+
MAX_MEDIA_BYTES // (1024 * 1024),
|
|
615
|
+
)
|
|
616
|
+
return None
|
|
617
|
+
# Successfully got PNG data - validate it's a real image
|
|
618
|
+
try:
|
|
619
|
+
Image.open(io.BytesIO(result.stdout))
|
|
620
|
+
base64_data = base64.b64encode(result.stdout).decode("utf-8")
|
|
621
|
+
return ImageData(
|
|
622
|
+
base64_data=base64_data,
|
|
623
|
+
format="png", # 'pngpaste -' always outputs PNG
|
|
624
|
+
placeholder="[image]",
|
|
625
|
+
)
|
|
626
|
+
except (
|
|
627
|
+
# UnidentifiedImageError: corrupted or non-image data
|
|
628
|
+
UnidentifiedImageError,
|
|
629
|
+
OSError, # OSError: I/O errors during image processing
|
|
630
|
+
) as e:
|
|
631
|
+
logger.debug(
|
|
632
|
+
"Invalid image data from pngpaste: %s", e, exc_info=True
|
|
633
|
+
)
|
|
634
|
+
except FileNotFoundError:
|
|
635
|
+
# pngpaste not installed - expected on systems without it
|
|
636
|
+
logger.debug("pngpaste not found, falling back to osascript")
|
|
637
|
+
except subprocess.TimeoutExpired:
|
|
638
|
+
logger.debug("pngpaste timed out after 2 seconds")
|
|
639
|
+
|
|
640
|
+
# Fallback to osascript with temp file (built-in but slower)
|
|
641
|
+
return _get_clipboard_via_osascript()
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _get_clipboard_via_osascript() -> ImageData | None:
|
|
645
|
+
"""Get clipboard image via osascript using a temp file.
|
|
646
|
+
|
|
647
|
+
osascript outputs data in a special format that can't be captured as raw binary,
|
|
648
|
+
so we write to a temp file instead.
|
|
649
|
+
|
|
650
|
+
Returns:
|
|
651
|
+
ImageData if an image is found, None otherwise.
|
|
652
|
+
"""
|
|
653
|
+
from PIL import Image, UnidentifiedImageError
|
|
654
|
+
|
|
655
|
+
# Get osascript path - it's a macOS builtin so should always exist
|
|
656
|
+
osascript_path = _get_executable("osascript")
|
|
657
|
+
if not osascript_path:
|
|
658
|
+
return None
|
|
659
|
+
|
|
660
|
+
# Create a temp file for the image
|
|
661
|
+
fd, temp_path = tempfile.mkstemp(suffix=".png")
|
|
662
|
+
os.close(fd)
|
|
663
|
+
|
|
664
|
+
try:
|
|
665
|
+
# First check if clipboard has PNG data
|
|
666
|
+
# S603: osascript_path is validated via shutil.which(), args are hardcoded
|
|
667
|
+
check_result = subprocess.run( # noqa: S603
|
|
668
|
+
[osascript_path, "-e", "clipboard info"],
|
|
669
|
+
capture_output=True,
|
|
670
|
+
check=False,
|
|
671
|
+
timeout=2,
|
|
672
|
+
text=True,
|
|
673
|
+
)
|
|
674
|
+
|
|
675
|
+
if check_result.returncode != 0:
|
|
676
|
+
return None
|
|
677
|
+
|
|
678
|
+
# Check for PNG or TIFF in clipboard info
|
|
679
|
+
clipboard_info = check_result.stdout.lower()
|
|
680
|
+
if "pngf" not in clipboard_info and "tiff" not in clipboard_info:
|
|
681
|
+
return None
|
|
682
|
+
|
|
683
|
+
# Try to get PNG first, fall back to TIFF
|
|
684
|
+
if "pngf" in clipboard_info:
|
|
685
|
+
get_script = f"""
|
|
686
|
+
set pngData to the clipboard as «class PNGf»
|
|
687
|
+
set theFile to open for access POSIX file "{temp_path}" with write permission
|
|
688
|
+
write pngData to theFile
|
|
689
|
+
close access theFile
|
|
690
|
+
return "success"
|
|
691
|
+
""" # noqa: E501
|
|
692
|
+
else:
|
|
693
|
+
get_script = f"""
|
|
694
|
+
set tiffData to the clipboard as TIFF picture
|
|
695
|
+
set theFile to open for access POSIX file "{temp_path}" with write permission
|
|
696
|
+
write tiffData to theFile
|
|
697
|
+
close access theFile
|
|
698
|
+
return "success"
|
|
699
|
+
""" # noqa: E501
|
|
700
|
+
|
|
701
|
+
# S603: osascript_path validated via shutil.which(), script is internal
|
|
702
|
+
result = subprocess.run( # noqa: S603
|
|
703
|
+
[osascript_path, "-e", get_script],
|
|
704
|
+
capture_output=True,
|
|
705
|
+
check=False,
|
|
706
|
+
timeout=3,
|
|
707
|
+
text=True,
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
if result.returncode != 0 or "success" not in result.stdout:
|
|
711
|
+
return None
|
|
712
|
+
|
|
713
|
+
# Check if file was created and has content
|
|
714
|
+
if (
|
|
715
|
+
not pathlib.Path(temp_path).exists()
|
|
716
|
+
or pathlib.Path(temp_path).stat().st_size == 0
|
|
717
|
+
):
|
|
718
|
+
return None
|
|
719
|
+
|
|
720
|
+
# Read and validate the image
|
|
721
|
+
image_data = pathlib.Path(temp_path).read_bytes()
|
|
722
|
+
|
|
723
|
+
# Reject oversized images before encoding to avoid provider
|
|
724
|
+
# BadRequestError and subsequent thread checkpoint poisoning.
|
|
725
|
+
if len(image_data) > MAX_MEDIA_BYTES:
|
|
726
|
+
logger.warning(
|
|
727
|
+
"Clipboard image from osascript is too large "
|
|
728
|
+
"(%d MB, max %d MB)",
|
|
729
|
+
len(image_data) // (1024 * 1024),
|
|
730
|
+
MAX_MEDIA_BYTES // (1024 * 1024),
|
|
731
|
+
)
|
|
732
|
+
return None
|
|
733
|
+
|
|
734
|
+
try:
|
|
735
|
+
image = Image.open(io.BytesIO(image_data))
|
|
736
|
+
# Convert to PNG if it's not already (e.g., if we got TIFF)
|
|
737
|
+
buffer = io.BytesIO()
|
|
738
|
+
image.save(buffer, format="PNG")
|
|
739
|
+
buffer.seek(0)
|
|
740
|
+
base64_data = base64.b64encode(buffer.getvalue()).decode("utf-8")
|
|
741
|
+
|
|
742
|
+
return ImageData(
|
|
743
|
+
base64_data=base64_data,
|
|
744
|
+
format="png",
|
|
745
|
+
placeholder="[image]",
|
|
746
|
+
)
|
|
747
|
+
except (
|
|
748
|
+
# UnidentifiedImageError: corrupted or non-image data
|
|
749
|
+
UnidentifiedImageError,
|
|
750
|
+
OSError, # OSError: I/O errors during image processing
|
|
751
|
+
) as e:
|
|
752
|
+
logger.debug(
|
|
753
|
+
"Failed to process clipboard image via osascript: %s", e, exc_info=True
|
|
754
|
+
)
|
|
755
|
+
return None
|
|
756
|
+
|
|
757
|
+
except subprocess.TimeoutExpired:
|
|
758
|
+
logger.debug("osascript timed out while accessing clipboard")
|
|
759
|
+
return None
|
|
760
|
+
except OSError as e:
|
|
761
|
+
logger.debug("OSError accessing clipboard via osascript: %s", e)
|
|
762
|
+
return None
|
|
763
|
+
finally:
|
|
764
|
+
# Clean up temp file
|
|
765
|
+
try:
|
|
766
|
+
pathlib.Path(temp_path).unlink()
|
|
767
|
+
except OSError as e:
|
|
768
|
+
logger.debug("Failed to clean up temp file %s: %s", temp_path, e)
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def encode_to_base64(data: bytes) -> str:
|
|
772
|
+
"""Encode raw bytes to a base64 string.
|
|
773
|
+
|
|
774
|
+
Args:
|
|
775
|
+
data: Raw bytes to encode.
|
|
776
|
+
|
|
777
|
+
Returns:
|
|
778
|
+
Base64-encoded string.
|
|
779
|
+
"""
|
|
780
|
+
return base64.b64encode(data).decode("utf-8")
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def create_multimodal_content(
|
|
784
|
+
text: str, images: list[ImageData], videos: list[VideoData] | None = None
|
|
785
|
+
) -> list[Any]:
|
|
786
|
+
"""Create multimodal message content with text, images, and videos.
|
|
787
|
+
|
|
788
|
+
Args:
|
|
789
|
+
text: Text content of the message
|
|
790
|
+
images: List of ImageData objects
|
|
791
|
+
videos: Optional list of VideoData objects
|
|
792
|
+
|
|
793
|
+
Returns:
|
|
794
|
+
List of content blocks in LangChain message format.
|
|
795
|
+
"""
|
|
796
|
+
content_blocks = []
|
|
797
|
+
|
|
798
|
+
# Add text block. Strip only the display-only placeholders bound to the media
|
|
799
|
+
# actually attached here (e.g. "[image 1]") so the canonical/model-facing text
|
|
800
|
+
# never contains fake user-authored placeholder text. When a span is known,
|
|
801
|
+
# text that merely resembles the schema is preserved exactly; without a span
|
|
802
|
+
# `strip_media_placeholders` falls back to removing one occurrence per item,
|
|
803
|
+
# which can catch a look-alike literal. The media itself is carried by the
|
|
804
|
+
# structured blocks below.
|
|
805
|
+
#
|
|
806
|
+
# `placeholders` and `spans` are passed as parallel-but-unzipped lists on
|
|
807
|
+
# purpose: `strip_media_placeholders` recovers each span's token from the
|
|
808
|
+
# text slice, and each tracked media item has a unique token, so it never
|
|
809
|
+
# needs index alignment between the two.
|
|
810
|
+
media = [*images, *(videos or [])]
|
|
811
|
+
placeholders = [item.placeholder for item in media]
|
|
812
|
+
spans = [
|
|
813
|
+
item.placeholder_span for item in media if item.placeholder_span is not None
|
|
814
|
+
]
|
|
815
|
+
clean_text = strip_media_placeholders(text, placeholders, placeholder_spans=spans)
|
|
816
|
+
if clean_text:
|
|
817
|
+
content_blocks.append({"type": "text", "text": clean_text})
|
|
818
|
+
|
|
819
|
+
# Add image blocks
|
|
820
|
+
content_blocks.extend(image.to_message_content() for image in images)
|
|
821
|
+
|
|
822
|
+
# Add video blocks
|
|
823
|
+
if videos:
|
|
824
|
+
content_blocks.extend(video.to_message_content() for video in videos)
|
|
825
|
+
|
|
826
|
+
return content_blocks
|