Compare commits
5 commits
main
...
studio/ope
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c43a0fbfb | ||
|
|
1b116d2eca | ||
|
|
9807cdf039 | ||
|
|
0894a1fad8 | ||
|
|
42159249ea |
7 changed files with 406 additions and 87 deletions
|
|
@ -243,6 +243,89 @@ def _split_pending_citation_tail(text: str) -> tuple[str, str]:
|
|||
return text[:last_open], text[last_open:]
|
||||
|
||||
|
||||
def _extract_web_search_action(item: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Normalise web_search_call.action across variants. gpt-5.x agentic
|
||||
search emits action.type in {search, open_page, find_in_page}; older
|
||||
variants may put query at item.query or use action.queries[0].
|
||||
Returns only non-empty keys among query/url/pattern/action_type.
|
||||
"""
|
||||
if not isinstance(item, dict):
|
||||
return {}
|
||||
action = item.get("action") if isinstance(item.get("action"), dict) else {}
|
||||
atype = action.get("type") if isinstance(action.get("type"), str) else ""
|
||||
query = action.get("query") if isinstance(action.get("query"), str) else ""
|
||||
if not query:
|
||||
# Fallbacks: action.queries[0], item.query, item.queries[0].
|
||||
for src in (action.get("queries"), item.get("queries")):
|
||||
if isinstance(src, list) and src and isinstance(src[0], str) and src[0]:
|
||||
query = src[0]
|
||||
break
|
||||
if not query and isinstance(item.get("query"), str):
|
||||
query = item["query"]
|
||||
url = action.get("url") if isinstance(action.get("url"), str) else ""
|
||||
pattern = action.get("pattern") if isinstance(action.get("pattern"), str) else ""
|
||||
out: dict[str, Any] = {}
|
||||
if query:
|
||||
out["query"] = query
|
||||
if url:
|
||||
out["url"] = url
|
||||
if pattern:
|
||||
out["pattern"] = pattern
|
||||
if atype:
|
||||
out["action_type"] = atype
|
||||
return out
|
||||
|
||||
|
||||
def _format_web_search_per_call_sources(results: Any, sources: Any) -> str:
|
||||
"""Format per-call sources as the Title/URL/Snippet block the
|
||||
frontend's parseSearchResults expects. Prefers `results` (snippet
|
||||
bearing, reasoning models) then `action.sources` (urls only).
|
||||
Returns "" when neither is populated.
|
||||
"""
|
||||
blocks: list[str] = []
|
||||
if isinstance(results, list):
|
||||
for r in results:
|
||||
if not isinstance(r, dict):
|
||||
continue
|
||||
url = r.get("url") if isinstance(r.get("url"), str) else ""
|
||||
if not url:
|
||||
continue
|
||||
title = r.get("title") if isinstance(r.get("title"), str) else url
|
||||
snippet = r.get("snippet") or r.get("text") or ""
|
||||
entry = f"Title: {title}\nURL: {url}"
|
||||
if isinstance(snippet, str) and snippet:
|
||||
entry += f"\nSnippet: {snippet}"
|
||||
blocks.append(entry)
|
||||
if not blocks and isinstance(sources, list):
|
||||
for s in sources:
|
||||
url = ""
|
||||
if isinstance(s, str):
|
||||
url = s
|
||||
elif isinstance(s, dict):
|
||||
url = s.get("url") if isinstance(s.get("url"), str) else ""
|
||||
if not url:
|
||||
continue
|
||||
blocks.append(f"Title: {url}\nURL: {url}")
|
||||
return "\n---\n".join(blocks)
|
||||
|
||||
|
||||
def _web_search_card_text(args: dict[str, Any]) -> str:
|
||||
"""Single-line summary for the per-card tool_end result."""
|
||||
if not args:
|
||||
return ""
|
||||
q, u, p = args.get("query") or "", args.get("url") or "", args.get("pattern") or ""
|
||||
atype = args.get("action_type") or ""
|
||||
if atype == "open_page" and u:
|
||||
return f"Read: {u}"
|
||||
if atype == "find_in_page" and u:
|
||||
return f"Find {p!r} in {u}" if p else f"Find in {u}"
|
||||
if q:
|
||||
return f"Searching: {q}"
|
||||
if u:
|
||||
return f"Read: {u}"
|
||||
return ""
|
||||
|
||||
|
||||
class _AnthropicThinkingSpec(NamedTuple):
|
||||
prefixes: tuple[str, ...]
|
||||
kind: Literal["adaptive", "manual"]
|
||||
|
|
@ -2185,7 +2268,7 @@ class ExternalProviderClient:
|
|||
parts.append(f"--- stderr ---\n{stderr}")
|
||||
if isinstance(return_code, int) and return_code != 0:
|
||||
parts.append(f"return_code: {return_code}")
|
||||
return "\n".join(parts) if parts else "(no output)"
|
||||
return "\n".join(parts) if parts else ""
|
||||
if inner_type == "text_editor_code_execution_result":
|
||||
# view: file content; create: is_file_update flag;
|
||||
# str_replace: diff `lines` list. The matching
|
||||
|
|
@ -3233,6 +3316,14 @@ class ExternalProviderClient:
|
|||
tools_array.append(_openai_image_generation_tool())
|
||||
if tools_array:
|
||||
body["tools"] = tools_array
|
||||
# Opt into the per-call source list (action.sources) and the
|
||||
# per-call snippet list (results, reasoning models only) so
|
||||
# each web_search card can surface what was actually consulted.
|
||||
if "web_search" in enabled_tools:
|
||||
body["include"] = [
|
||||
"web_search_call.action.sources",
|
||||
"web_search_call.results",
|
||||
]
|
||||
|
||||
url = f"{self.base_url}/responses"
|
||||
completion_id = f"chatcmpl-openai-{model.replace('/', '-')}"
|
||||
|
|
@ -3267,6 +3358,13 @@ class ExternalProviderClient:
|
|||
attempt_body["tools"] = tools_array_attempt
|
||||
else:
|
||||
attempt_body.pop("tools", None)
|
||||
if "web_search" in enabled_tools:
|
||||
attempt_body["include"] = [
|
||||
"web_search_call.action.sources",
|
||||
"web_search_call.results",
|
||||
]
|
||||
else:
|
||||
attempt_body.pop("include", None)
|
||||
return attempt_body
|
||||
|
||||
def _is_openai_container_expired_error(error_text: str) -> bool:
|
||||
|
|
@ -3468,43 +3566,52 @@ class ExternalProviderClient:
|
|||
return f"data: {_json.dumps(chunk)}"
|
||||
|
||||
def _format_shell_output(output: Any) -> str:
|
||||
"""Render an OpenAI `shell_call_output.output` list
|
||||
as the preformatted text payload the frontend's
|
||||
CodeExecutionToolUI displays inside a <pre>. Each
|
||||
entry has stdout/stderr/outcome — concatenate them
|
||||
with a separator block per entry and append
|
||||
`return_code` / `(timeout)` annotations only when
|
||||
they convey information beyond "succeeded".
|
||||
"""Render OpenAI `shell_call_output.output` for the
|
||||
CodeExecutionToolUI <pre>. Each entry has
|
||||
stdout/stderr/outcome (canonical) or `text`/`content`
|
||||
(older shapes). When the entry contains no
|
||||
recognised text fields at all, dump the raw JSON so
|
||||
the user can see what OpenAI actually returned.
|
||||
"""
|
||||
if not isinstance(output, list):
|
||||
return ""
|
||||
parts: list[str] = []
|
||||
for entry in output:
|
||||
if not isinstance(entry, dict):
|
||||
if isinstance(entry, str) and entry:
|
||||
parts.append(entry)
|
||||
continue
|
||||
stdout = entry.get("stdout") or ""
|
||||
stdout = (
|
||||
entry.get("stdout")
|
||||
or entry.get("text")
|
||||
or entry.get("content")
|
||||
or ""
|
||||
)
|
||||
stderr = entry.get("stderr") or ""
|
||||
outcome = entry.get("outcome") or {}
|
||||
chunk_parts: list[str] = []
|
||||
if stdout:
|
||||
if isinstance(stdout, str) and stdout:
|
||||
chunk_parts.append(stdout)
|
||||
if stderr:
|
||||
if isinstance(stderr, str) and stderr:
|
||||
chunk_parts.append(f"--- stderr ---\n{stderr}")
|
||||
if isinstance(outcome, dict):
|
||||
outcome_type = outcome.get("type")
|
||||
if outcome_type == "exit":
|
||||
exit_code = outcome.get("exit_code")
|
||||
if isinstance(exit_code, int) and exit_code != 0:
|
||||
chunk_parts.append(f"return_code: {exit_code}")
|
||||
if isinstance(exit_code, int):
|
||||
chunk_parts.append(f"exit_code: {exit_code}")
|
||||
elif outcome_type == "timeout":
|
||||
chunk_parts.append("(timeout)")
|
||||
if not chunk_parts:
|
||||
# Unknown shape: surface the raw dict so the
|
||||
# user can see what OpenAI actually returned.
|
||||
try:
|
||||
chunk_parts.append(_json.dumps(entry, indent = 2))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if chunk_parts:
|
||||
parts.append("\n".join(chunk_parts))
|
||||
return (
|
||||
"\n--- next command ---\n".join(parts)
|
||||
if parts
|
||||
else "(no output)"
|
||||
)
|
||||
return "\n--- next command ---\n".join(parts)
|
||||
|
||||
def _record_url_citation(payload: dict[str, Any]) -> None:
|
||||
"""Append a url_citation onto the shared all_url_citations
|
||||
|
|
@ -3844,45 +3951,56 @@ class ExternalProviderClient:
|
|||
yield _chunk_with_text(summary_text)
|
||||
reasoning_emitted = True
|
||||
elif item.get("type") == "web_search_call":
|
||||
# done is the canonical place to read the
|
||||
# query, so emit both tool_start and tool_end
|
||||
# here. Frontend then renders a card per call
|
||||
# with the proper "Searching: <query>" label.
|
||||
# Citations are aggregated separately and the
|
||||
# *last* call's result is overwritten at
|
||||
# response.completed with the citation list
|
||||
# (so the source-pill extraction at message
|
||||
# tail surfaces them once).
|
||||
# Dispatch on action.type so open_page /
|
||||
# find_in_page render url+pattern instead
|
||||
# of an empty query card. Last call's
|
||||
# result is overwritten with citations at
|
||||
# response.completed.
|
||||
item_id = item.get("id", "") or (
|
||||
f"ws_{len(web_search_calls)}"
|
||||
)
|
||||
action = item.get("action")
|
||||
query = (
|
||||
action.get("query", "")
|
||||
if isinstance(action, dict)
|
||||
else ""
|
||||
)
|
||||
web_search_calls[item_id] = {"query": query}
|
||||
args = _extract_web_search_action(item)
|
||||
# Backfill query from any prior event for this id.
|
||||
if not args.get("query"):
|
||||
prior = web_search_calls.get(item_id) or {}
|
||||
prior_q = (
|
||||
prior.get("query")
|
||||
if isinstance(prior, dict)
|
||||
else ""
|
||||
)
|
||||
if isinstance(prior_q, str) and prior_q:
|
||||
args["query"] = prior_q
|
||||
web_search_calls[item_id] = dict(args)
|
||||
yield _emit_tool_event(
|
||||
{
|
||||
"type": "tool_start",
|
||||
"tool_name": "web_search",
|
||||
"tool_call_id": item_id,
|
||||
"arguments": (
|
||||
{"query": query} if query else {}
|
||||
),
|
||||
"arguments": args,
|
||||
}
|
||||
)
|
||||
# Per-card text; last call gets overwritten
|
||||
# with citations at response.completed.
|
||||
per_call_result = (
|
||||
f"Searching: {query}" if query else ""
|
||||
# Per-call sources: prefer the snippet-bearing
|
||||
# `results` list (reasoning models only), then
|
||||
# fall back to `action.sources` URLs. Formatted
|
||||
# as the Title/URL/Snippet block the frontend
|
||||
# parser already understands.
|
||||
action_obj = (
|
||||
item.get("action")
|
||||
if isinstance(item.get("action"), dict)
|
||||
else {}
|
||||
) or {}
|
||||
per_call_sources = (
|
||||
_format_web_search_per_call_sources(
|
||||
item.get("results"),
|
||||
action_obj.get("sources"),
|
||||
)
|
||||
)
|
||||
yield _emit_tool_event(
|
||||
{
|
||||
"type": "tool_end",
|
||||
"tool_call_id": item_id,
|
||||
"result": per_call_result,
|
||||
"result": per_call_sources
|
||||
or _web_search_card_text(args),
|
||||
}
|
||||
)
|
||||
elif item.get("type") == "shell_call":
|
||||
|
|
|
|||
|
|
@ -200,6 +200,139 @@ def test_web_search_empty_query_falls_back_to_empty_result(monkeypatch):
|
|||
assert ends[0]["result"] == ""
|
||||
|
||||
|
||||
def test_web_search_open_page_action_renders_url(monkeypatch):
|
||||
"""gpt-5.x agentic search emits `open_page` with a url (no query)."""
|
||||
sse_events = [
|
||||
{
|
||||
"type": "response.output_item.done",
|
||||
"item": {
|
||||
"type": "web_search_call",
|
||||
"id": "ws_open",
|
||||
"action": {
|
||||
"type": "open_page",
|
||||
"url": "https://en.wikipedia.org/wiki/Tiger",
|
||||
},
|
||||
},
|
||||
},
|
||||
{"type": "response.completed", "response": {}},
|
||||
]
|
||||
lines = _drive_stream(sse_events, ["web_search"], monkeypatch)
|
||||
events = _tool_events(lines)
|
||||
starts = [e for e in events if e["type"] == "tool_start"]
|
||||
ends = [e for e in events if e["type"] == "tool_end"]
|
||||
assert starts[0]["arguments"]["url"] == "https://en.wikipedia.org/wiki/Tiger"
|
||||
assert starts[0]["arguments"]["action_type"] == "open_page"
|
||||
assert "Read: https://en.wikipedia.org/wiki/Tiger" in ends[0]["result"]
|
||||
|
||||
|
||||
def test_web_search_find_in_page_action_renders_url_and_pattern(monkeypatch):
|
||||
"""`find_in_page` actions surface both url and pattern to the card."""
|
||||
sse_events = [
|
||||
{
|
||||
"type": "response.output_item.done",
|
||||
"item": {
|
||||
"type": "web_search_call",
|
||||
"id": "ws_find",
|
||||
"action": {
|
||||
"type": "find_in_page",
|
||||
"url": "https://en.wikipedia.org/wiki/Tiger",
|
||||
"pattern": "population",
|
||||
},
|
||||
},
|
||||
},
|
||||
{"type": "response.completed", "response": {}},
|
||||
]
|
||||
lines = _drive_stream(sse_events, ["web_search"], monkeypatch)
|
||||
events = _tool_events(lines)
|
||||
starts = [e for e in events if e["type"] == "tool_start"]
|
||||
ends = [e for e in events if e["type"] == "tool_end"]
|
||||
assert starts[0]["arguments"]["url"] == "https://en.wikipedia.org/wiki/Tiger"
|
||||
assert starts[0]["arguments"]["pattern"] == "population"
|
||||
assert starts[0]["arguments"]["action_type"] == "find_in_page"
|
||||
assert "population" in ends[0]["result"]
|
||||
assert "en.wikipedia.org" in ends[0]["result"]
|
||||
|
||||
|
||||
def test_web_search_action_queries_plural_falls_back(monkeypatch):
|
||||
"""`action.queries[0]` is used when `action.query` is absent (older shape)."""
|
||||
sse_events = [
|
||||
{
|
||||
"type": "response.output_item.done",
|
||||
"item": {
|
||||
"type": "web_search_call",
|
||||
"id": "ws_plural",
|
||||
"action": {"queries": ["renewable energy 2026"]},
|
||||
},
|
||||
},
|
||||
{"type": "response.completed", "response": {}},
|
||||
]
|
||||
lines = _drive_stream(sse_events, ["web_search"], monkeypatch)
|
||||
events = _tool_events(lines)
|
||||
starts = [e for e in events if e["type"] == "tool_start"]
|
||||
ends = [e for e in events if e["type"] == "tool_end"]
|
||||
assert starts[0]["arguments"]["query"] == "renewable energy 2026"
|
||||
assert ends[0]["result"] == "Searching: renewable energy 2026"
|
||||
|
||||
|
||||
def test_web_search_per_call_results_formatted_as_source_blocks(monkeypatch):
|
||||
"""`results` array (reasoning models) is formatted into Title/URL/Snippet blocks."""
|
||||
sse_events = [
|
||||
{
|
||||
"type": "response.output_item.done",
|
||||
"item": {
|
||||
"type": "web_search_call",
|
||||
"id": "ws_r",
|
||||
"action": {"type": "search", "query": "tiger ranking"},
|
||||
"results": [
|
||||
{
|
||||
"url": "https://a.example/1",
|
||||
"title": "Tigers",
|
||||
"snippet": "Big cats",
|
||||
},
|
||||
{"url": "https://b.example/2", "title": "Lion stats"},
|
||||
],
|
||||
},
|
||||
},
|
||||
{"type": "response.completed", "response": {}},
|
||||
]
|
||||
lines = _drive_stream(sse_events, ["web_search"], monkeypatch)
|
||||
events = _tool_events(lines)
|
||||
ends = [e for e in events if e["type"] == "tool_end"]
|
||||
body = ends[0]["result"]
|
||||
assert "Title: Tigers" in body
|
||||
assert "URL: https://a.example/1" in body
|
||||
assert "Snippet: Big cats" in body
|
||||
assert "Title: Lion stats" in body
|
||||
|
||||
|
||||
def test_web_search_action_sources_url_only_falls_back(monkeypatch):
|
||||
"""`action.sources` URLs are surfaced when `results` is absent."""
|
||||
sse_events = [
|
||||
{
|
||||
"type": "response.output_item.done",
|
||||
"item": {
|
||||
"type": "web_search_call",
|
||||
"id": "ws_s",
|
||||
"action": {
|
||||
"type": "search",
|
||||
"query": "X",
|
||||
"sources": [
|
||||
{"type": "url", "url": "https://x.example/1"},
|
||||
"https://x.example/2",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{"type": "response.completed", "response": {}},
|
||||
]
|
||||
lines = _drive_stream(sse_events, ["web_search"], monkeypatch)
|
||||
events = _tool_events(lines)
|
||||
ends = [e for e in events if e["type"] == "tool_end"]
|
||||
body = ends[0]["result"]
|
||||
assert "https://x.example/1" in body
|
||||
assert "https://x.example/2" in body
|
||||
|
||||
|
||||
# ── shell_call output fallbacks ────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -411,9 +411,22 @@ function StreamdownBlock(props: BlockProps) {
|
|||
}
|
||||
const AUDIO_PLAYER_RE = /<audio-player\s+src="([^"]+)"\s*\/>/;
|
||||
|
||||
// Defensive scrub: strip any OpenAI cite markers that escape the
|
||||
// backend rewriter (e.g. SSE dropped before end-of-stream flush).
|
||||
const OPENAI_CITE_MARKER_RE = /cite[^]*/g;
|
||||
const OPENAI_PUA_ORPHAN_RE = /[]/g;
|
||||
function scrubOpenAICitationMarkers(text: string): string {
|
||||
if (!text) return text;
|
||||
if (!text.includes("") && !text.includes("")) return text;
|
||||
return text.replace(OPENAI_CITE_MARKER_RE, "").replace(OPENAI_PUA_ORPHAN_RE, "");
|
||||
}
|
||||
|
||||
const MarkdownTextImpl = () => {
|
||||
const { text, status } = useMessagePartText();
|
||||
const processedText = useMemo(() => preprocessLaTeX(text), [text]);
|
||||
const processedText = useMemo(
|
||||
() => preprocessLaTeX(scrubOpenAICitationMarkers(text)),
|
||||
[text],
|
||||
);
|
||||
|
||||
const audioMatch = text.match(AUDIO_PLAYER_RE);
|
||||
if (audioMatch) {
|
||||
|
|
|
|||
|
|
@ -230,14 +230,23 @@ const GeneratedImageViewportOverlay: FC<{ hideComposer?: boolean }> = ({
|
|||
)}
|
||||
aria-label="Generated image preview"
|
||||
>
|
||||
<div className="pointer-events-auto relative flex min-h-0 w-full max-w-[1100px] flex-1 flex-col items-center justify-center gap-3 rounded-3xl bg-muted/10 p-3 ring-1 ring-border/20">
|
||||
<div className="absolute inset-x-3 top-3 z-10 flex justify-end">
|
||||
<div className="flex shrink-0 items-center gap-1 rounded-full bg-background/70 p-1 ring-1 ring-border/20 backdrop-blur-sm">
|
||||
<div className="pointer-events-auto relative flex min-h-0 w-full max-w-[1100px] flex-1 flex-col items-center justify-center gap-3">
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center">
|
||||
<div className="relative inline-block">
|
||||
<img
|
||||
src={overlay.image}
|
||||
alt={overlay.title}
|
||||
// Keep aspect ratio + downscale only: explicit caps so a
|
||||
// huge viewport doesn't scale a generated image past its
|
||||
// intrinsic resolution and turn soft.
|
||||
className="block h-auto max-h-[min(70vh,620px)] w-auto max-w-[520px] rounded-2xl object-contain"
|
||||
/>
|
||||
<div className="absolute right-2 top-2 z-10 flex shrink-0 items-center gap-2 rounded-full bg-background/80 p-1.5 ring-1 ring-border/30 backdrop-blur-sm">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="size-7 rounded-full"
|
||||
size="icon"
|
||||
className="size-10 rounded-full bg-primary/10 text-primary ring-1 ring-primary/30 hover:bg-primary/20 hover:text-primary"
|
||||
onClick={() =>
|
||||
downloadImagePart({
|
||||
image: overlay.image,
|
||||
|
|
@ -246,26 +255,20 @@ const GeneratedImageViewportOverlay: FC<{ hideComposer?: boolean }> = ({
|
|||
}
|
||||
aria-label="Download generated image"
|
||||
>
|
||||
<DownloadIcon className="size-3.5" />
|
||||
<DownloadIcon className="size-5" strokeWidth={2} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="size-7 rounded-full"
|
||||
size="icon"
|
||||
className="size-10 rounded-full bg-primary/10 text-primary ring-1 ring-primary/30 hover:bg-primary/20 hover:text-primary"
|
||||
onClick={closeOverlay}
|
||||
aria-label="Close generated image preview"
|
||||
>
|
||||
<XIcon className="size-3.5" />
|
||||
<XIcon className="size-5" strokeWidth={2} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center pt-1">
|
||||
<img
|
||||
src={overlay.image}
|
||||
alt={overlay.title}
|
||||
className="max-h-full max-w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="w-full max-w-[min(100%,46rem)] shrink-0 text-center"
|
||||
|
|
|
|||
|
|
@ -175,6 +175,12 @@ const CodeExecutionToolUIImpl: ToolCallMessagePartComponent = ({
|
|||
[resultText],
|
||||
);
|
||||
|
||||
// Surface the full untruncated command in the expanded body so a
|
||||
// user clicking the row can read the heredoc that was clipped in
|
||||
// the trigger label.
|
||||
const showFullCommand = !isRunning && kind === "bash" && !!command && command !== commandLabel;
|
||||
const emptyOutput = !isRunning && !resultText;
|
||||
|
||||
return (
|
||||
<ToolFallbackRoot open={open} onOpenChange={setOpen}>
|
||||
<ToolFallbackTrigger
|
||||
|
|
@ -188,16 +194,36 @@ const CodeExecutionToolUIImpl: ToolCallMessagePartComponent = ({
|
|||
<LoaderIcon className="size-3.5 animate-spin" />
|
||||
<span>{runningLabel}</span>
|
||||
</div>
|
||||
) : resultText ? (
|
||||
<div>
|
||||
<div className="flex justify-end">
|
||||
<CopyBtn text={resultText} />
|
||||
</div>
|
||||
<pre className="mt-1 max-h-64 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-2 text-xs">
|
||||
{displayedResult}
|
||||
</pre>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{showFullCommand ? (
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">Command</span>
|
||||
<CopyBtn text={command} />
|
||||
</div>
|
||||
<pre className="mt-1 max-h-64 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-2 text-xs">
|
||||
{command}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
{resultText ? (
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">Output</span>
|
||||
<CopyBtn text={resultText} />
|
||||
</div>
|
||||
<pre className="mt-1 max-h-64 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-2 text-xs">
|
||||
{displayedResult}
|
||||
</pre>
|
||||
</div>
|
||||
) : emptyOutput ? (
|
||||
<p className="text-xs italic text-muted-foreground">
|
||||
Command completed with no output.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
</ToolFallbackContent>
|
||||
</ToolFallbackRoot>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,13 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
|
||||
import { DownloadIcon, ImageIcon, PencilIcon } from "lucide-react";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
DownloadIcon,
|
||||
ImageIcon,
|
||||
PencilIcon,
|
||||
} from "lucide-react";
|
||||
import type { CSSProperties, MouseEvent } from "react";
|
||||
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useGeneratedImageOverlay } from "./generated-image-overlay-context";
|
||||
|
|
@ -348,7 +354,7 @@ const ImageGenerationToolUIImpl: ToolCallMessagePartComponent = ({
|
|||
{promptCanExpand ? (
|
||||
<button
|
||||
type="button"
|
||||
className="mt-2 inline-flex text-xs font-medium text-foreground/80 underline-offset-4 hover:text-foreground hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||
className="mt-2 inline-flex items-center gap-1 text-xs font-medium text-foreground/80 underline-offset-4 hover:text-foreground hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||
onClick={() =>
|
||||
setExpandedCaptionPrompt((value) =>
|
||||
value === captionPrompt ? null : captionPrompt,
|
||||
|
|
@ -356,6 +362,11 @@ const ImageGenerationToolUIImpl: ToolCallMessagePartComponent = ({
|
|||
}
|
||||
aria-expanded={promptExpanded}
|
||||
>
|
||||
{promptExpanded ? (
|
||||
<ChevronDownIcon className="size-3.5" />
|
||||
) : (
|
||||
<ChevronRightIcon className="size-3.5" />
|
||||
)}
|
||||
{promptExpanded ? "Show less" : "Show more"}
|
||||
</button>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -70,7 +70,11 @@ const WebSearchToolUIImpl: ToolCallMessagePartComponent = ({
|
|||
}) => {
|
||||
const query = (args as { query?: string })?.query ?? "";
|
||||
const url = ((args as { url?: string })?.url ?? "").trim();
|
||||
const isUrlFetch = !!url;
|
||||
const pattern = (args as { pattern?: string })?.pattern ?? "";
|
||||
const actionType = (args as { action_type?: string })?.action_type ?? "";
|
||||
// gpt-5.x agentic action.type: search | open_page | find_in_page.
|
||||
const isFindInPage = actionType === "find_in_page" || (!!url && !!pattern);
|
||||
const isUrlFetch = !!url && !isFindInPage;
|
||||
const displayDomain = (() => {
|
||||
if (!url) return "";
|
||||
try {
|
||||
|
|
@ -105,11 +109,15 @@ const WebSearchToolUIImpl: ToolCallMessagePartComponent = ({
|
|||
<ToolFallbackRoot open={open} onOpenChange={setOpen}>
|
||||
<ToolFallbackTrigger
|
||||
toolName={
|
||||
isUrlFetch
|
||||
? displayDomain ? `Read ${displayDomain}` : "Read page"
|
||||
: query
|
||||
? `Searched "${query}"`
|
||||
: "Web Search"
|
||||
isFindInPage
|
||||
? pattern
|
||||
? `Found "${pattern}" in ${displayDomain || "page"}`
|
||||
: `Find in ${displayDomain || "page"}`
|
||||
: isUrlFetch
|
||||
? displayDomain ? `Read ${displayDomain}` : "Read page"
|
||||
: query
|
||||
? `Searched "${query}"`
|
||||
: "Web Search"
|
||||
}
|
||||
status={status}
|
||||
icon={GlobeIcon}
|
||||
|
|
@ -119,9 +127,15 @@ const WebSearchToolUIImpl: ToolCallMessagePartComponent = ({
|
|||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<LoaderIcon className="size-3.5 animate-spin" />
|
||||
<span>
|
||||
{isUrlFetch
|
||||
? <>Reading {displayDomain || "page"}…</>
|
||||
: <>Searching for “{query}”…</>
|
||||
{isFindInPage
|
||||
? pattern
|
||||
? <>Finding “{pattern}” in {displayDomain || "page"}…</>
|
||||
: <>Searching {displayDomain || "page"}…</>
|
||||
: isUrlFetch
|
||||
? <>Reading {displayDomain || "page"}…</>
|
||||
: query
|
||||
? <>Searching for “{query}”…</>
|
||||
: <>Searching…</>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -140,14 +154,15 @@ const WebSearchToolUIImpl: ToolCallMessagePartComponent = ({
|
|||
</Source>
|
||||
))}
|
||||
</div>
|
||||
) : result ? (
|
||||
<div>
|
||||
<pre className="max-h-40 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-2 text-xs">
|
||||
{typeof result === "string"
|
||||
? result
|
||||
: JSON.stringify(result, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
) : url ? (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="break-all text-xs text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
{url}
|
||||
</a>
|
||||
) : null}
|
||||
</ToolFallbackContent>
|
||||
</ToolFallbackRoot>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue