mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 02:36:01 -10:00
feat(studio): add rich task workspaces
Add linked maps, comparisons, dashboards and workspace-backed photo acquisition and viewing for desktop task surfaces. Certify Mantine viewer interactions and isolate Windows command-line and direct DNS test fixtures from host process/proxy behaviour. Cache impact: shortened write_file schema changes tool/prefix hashes once on upgrade (5572 to 5524 schema tokens); system prompt stays unchanged. Update the boot goldens for that deliberate contract change. Image discovery stays hidden.
This commit is contained in:
1 parent
1409a8ee35
commit
14f3d5c24c
62 files changed
+2819
-95
No files matched your search
@@ -81,3 +81,7 @@ Report model/configuration differences and missing evidence as limitations.
|
||||
The RFC's benefit remains unproven until observed trajectories show fewer input
|
||||
exchanges to the same verified outcome. These results alone do not justify P3;
|
||||
adaptive layout or MCP Apps needs a measured problem in addition to this result.
|
||||
|
||||
The owner-requested map, comparison and dashboard workspaces extend the delivery
|
||||
scope independently of this study. Their scripted acceptance fixtures verify
|
||||
rendering and interaction only; they do not count as observed benefit evidence.
|
||||
@@ -32,13 +32,17 @@
|
||||
"@codemirror/state": "^6.7.6",
|
||||
"@codemirror/view": "^6.43.14",
|
||||
"@lezer/highlight": "^1.2.5",
|
||||
"@mantine/charts": "9.7.2",
|
||||
"@mantine/core": "9.7.2",
|
||||
"@mantine/hooks": "9.7.2",
|
||||
"codemirror": "^6.0.2",
|
||||
"katex": "^0.19.0",
|
||||
"maplibre-gl": "6.14.0",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0",
|
||||
"react-map-gl": "8.1.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"recharts": "3.10.1",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
owner: @esengine
|
||||
backup: @SivanCola
|
||||
status: active
|
||||
reviewed: 2026-10-13
|
||||
---
|
||||
|
||||
# Fictional camera fixture images
|
||||
|
||||
These images illustrate fictional products for development acceptance only.
|
||||
They do not depict products whose specifications or prices were verified.
|
||||
Generated with the built-in image generation tool. No external source image
|
||||
was supplied. The originals are retained in the local generated-images folder.
|
||||
|
||||
Shared prompt: product mockup, no brand/logo/text, entire camera in a centered
|
||||
three-quarter front view, warm light gray seamless background, soft studio
|
||||
lighting and contact shadow, landscape 4:3, fictional rather than a real product.
|
||||
|
||||
- `camera-lite.png`: compact charcoal travel camera with a small silver lens.
|
||||
- `camera-travel.png`: mid-size camera with silver top plate, charcoal textured
|
||||
body and a medium black interchangeable lens.
|
||||
- `camera-pro.png`: substantial matte charcoal camera with a hand grip and large
|
||||
dark interchangeable lens.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.3 MiB |
@@ -0,0 +1,19 @@
|
||||
export const scenarios = {
|
||||
map: { spec_version: 1, body: [{ kind: "map", key: "itinerary", label: "东京 · 三日城市漫游", field: { type: "multichoice", required: true, default: ["asakusa", "ueno", "meiji", "shibuya", "garden"], options: [
|
||||
{ value: "asakusa", label: "浅草寺", group: "第 1 天", description: "从雷门出发,沿仲见世街步行探索浅草。", location: { latitude: 35.7148, longitude: 139.7967 }, facts: [{ label: "建议停留", value: "1.5 小时" }, { label: "区域", value: "浅草" }] },
|
||||
{ value: "ueno", label: "上野公园", group: "第 1 天", description: "公园散步与博物馆,可根据天气调整。", location: { latitude: 35.7146, longitude: 139.7732 }, facts: [{ label: "建议停留", value: "2 小时" }] },
|
||||
{ value: "meiji", label: "明治神宫", group: "第 2 天", description: "从城市街区走进安静的林荫参道。", location: { latitude: 35.6764, longitude: 139.6993 }, facts: [{ label: "建议停留", value: "1 小时" }] },
|
||||
{ value: "shibuya", label: "涩谷十字路口", group: "第 2 天", description: "傍晚感受街区节奏,附近安排晚餐。", location: { latitude: 35.6595, longitude: 139.7005 }, facts: [{ label: "建议停留", value: "1.5 小时" }] },
|
||||
{ value: "garden", label: "新宿御苑", group: "第 3 天", description: "为最后一天保留一段轻松的花园时光。", location: { latitude: 35.6852, longitude: 139.7100 }, facts: [{ label: "建议停留", value: "2 小时" }] },
|
||||
] } }] },
|
||||
comparison: { spec_version: 1, body: [{ kind: "comparison", key: "camera", label: "找到适合旅行的相机", field: { type: "multichoice", required: true, default: ["lite"], options: [
|
||||
{ value: "lite", image: { path: "perf/media/camera-lite.png", alt: "虚构相机示意图" }, label: "Pocket Lite", group: "轻便随身", description: "更轻的行李,随手记录每一次出发。", price: 699, currency: "USD", facts: [{ label: "重量", value: "294 g" }, { label: "传感器", value: "1 英寸" }, { label: "镜头", value: "24–70 mm" }, { label: "防抖", value: "光学防抖" }], preview: "适合城市漫游与日常记录" },
|
||||
{ value: "travel", image: { path: "perf/media/camera-travel.png", alt: "虚构相机示意图" }, label: "Travel One", group: "均衡之选", description: "在画质与便携之间找到更好的平衡。", price: 1099, currency: "USD", facts: [{ label: "重量", value: "436 g" }, { label: "传感器", value: "APS-C" }, { label: "镜头", value: "可更换" }, { label: "防抖", value: "五轴机身防抖" }], preview: "适合希望保留更多创作空间的旅行者" },
|
||||
{ value: "pro", image: { path: "perf/media/camera-pro.png", alt: "虚构相机示意图" }, label: "Horizon Pro", group: "专业创作", description: "为风景、人像与低光创作提供更多细节。", price: 1899, currency: "USD", facts: [{ label: "重量", value: "658 g" }, { label: "传感器", value: "全画幅" }, { label: "镜头", value: "可更换" }, { label: "防抖", value: "五轴机身防抖" }], preview: "适合画质优先的创作行程" },
|
||||
] } }] },
|
||||
dashboard: { spec_version: 1, body: [{ kind: "dashboard", label: "业务概览 · 过去 12 个月", dataset: { categories: ["1 月", "2 月", "3 月", "4 月", "5 月", "6 月", "7 月", "8 月", "9 月", "10 月", "11 月", "12 月"], series: [
|
||||
{ id: "revenue", label: "收入", unit: "USD", values: [12400, 14200, 13100, 18600, 21400, 19800, 25300, 27200, 24800, 31200, 34600, 38200] },
|
||||
{ id: "orders", label: "订单", values: [124, 139, 131, 180, 202, 193, 246, 267, 244, 304, 337, 371] },
|
||||
{ id: "visitors", label: "访客", values: [2400, 2700, 2600, 3300, 3800, 3600, 4600, 5100, 4700, 5900, 6400, 7100] },
|
||||
] } }] },
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN" data-theme="light"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Reasonix · Rich UI acceptance</title></head><body><div id="root"></div><script type="module" src="./rich-ui.jsx"></script></body></html>
|
||||
@@ -0,0 +1,24 @@
|
||||
import React, { useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { SurfaceCard, surfaceTool } from "../src/ui/surface/SurfaceCard";
|
||||
import { scenarios } from "./rich-ui-data";
|
||||
import "../src/styles/tokens.css";
|
||||
import "../src/styles/app.css";
|
||||
import "../src/styles/studio.css";
|
||||
import "../src/styles/themes.css";
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
const selected = params.get("scenario") || "map";
|
||||
document.documentElement.dataset.theme = params.get("theme") || "light";
|
||||
document.body.style.overflow = "auto";
|
||||
|
||||
function Preview() {
|
||||
const spec = scenarios[selected] || scenarios.map;
|
||||
const [saved, setSaved] = useState();
|
||||
const item = { t: "ask", id: "preview", ask: { id: "preview", questions: [{ id: "surface", prompt: "Preview", options: [], surface: spec }] }, ...(saved ? { answered: [[]], surfaceValues: saved } : {}) };
|
||||
return <main style={{ maxWidth: 1160, margin: "24px auto", padding: "0 20px 32px" }}>
|
||||
<header style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 20, marginBottom: 24 }}><div><strong style={{ fontSize: 20 }}>Reasonix</strong><div style={{ color: "var(--muted)", fontSize: 12 }}>交互式任务界面 · 开发验收 / 示例数据与虚构商品示意图</div></div><nav style={{ display: "flex", gap: 16 }}>{[["map", "地图与行程"], ["comparison", "产品对比"], ["dashboard", "数据仪表盘"]].map(([id, label]) => <a key={id} href={`?scenario=${id}&theme=${params.get("theme") || "light"}`} style={{ color: id === selected ? "var(--text)" : "var(--muted)", fontSize: 13 }}>{label}</a>)}</nav></header>
|
||||
{selected === "dashboard" ? surfaceTool({ name: "render_surface", readOnly: true, args: JSON.stringify(spec), output: "Displayed" }, false) : <SurfaceCard port={{ workspaceImageURL: path => `/${path}` }} item={item} onAnswer={async (_item, _ask, answers) => { if (answers[0]) { window.previewAnswer = answers[0].value; setSaved(answers[0].value); } }} />}
|
||||
</main>;
|
||||
}
|
||||
createRoot(document.getElementById("root")).render(<Preview />);
|
||||
Generated
+717
File diff suppressed because it is too large.
Load diff
@@ -236,6 +236,7 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "surface.refresh", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "surface.action", kind: "interaction", target: "entity", proof: "authority-effect" },
|
||||
{ id: "surface.delivery", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "surface.explore", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "surface.edit", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "ask.answer", kind: "interaction", target: "none", proof: "authority-effect" },
|
||||
{ id: "ask.step", kind: "view", target: "none", proof: "interaction" },
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { EN_SURFACES } from "./en_surfaces";
|
||||
// English catalogue, keyed by the Chinese source text. A key that is missing
|
||||
// here renders as Chinese rather than as a blank, so an incomplete translation
|
||||
// degrades instead of breaking — and i18n.test.ts is what stops it from
|
||||
@@ -21,6 +22,7 @@ import { EN_COMMUNITY } from "./en_community";
|
||||
import { EN_SPONSOR } from "./en_sponsor";
|
||||
|
||||
export const EN: Record<string, string> = {
|
||||
...EN_SURFACES,
|
||||
"工作台": "Workbench",
|
||||
"从右侧选择文件,或打开浏览器": "Choose a file on the right, or open a browser",
|
||||
...EN_SETTINGS,
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
export const EN_SURFACES: Record<string, string> = {
|
||||
"查看大图:{alt}": "View image: {alt}",
|
||||
"关闭图片": "Close image",
|
||||
"重试图片": "Retry image",
|
||||
"图片来源": "Image source",
|
||||
"图片暂不可用": "Image unavailable",
|
||||
"收起工作区": "Collapse workspace",
|
||||
"展开工作区": "Expand workspace",
|
||||
"地图与行程": "Map & itinerary",
|
||||
"选择地点并调整访问顺序,地图与行程同步显示": "Choose places and arrange the visit order with the map alongside.",
|
||||
"查看全部地点": "Show all places",
|
||||
"全部地点": "All places",
|
||||
"已选 {n} 个": "{n} selected",
|
||||
"虚线表示访问顺序,不代表道路导航路线": "Dashed lines show visit order, not road directions.",
|
||||
"地点地图": "Places map",
|
||||
"部分地图内容加载失败,地点列表仍可用": "Some map content could not load. The place list is still available.",
|
||||
"移出行程": "Remove stop",
|
||||
"加入行程": "Add stop",
|
||||
"上移 {name}": "Move {name} up",
|
||||
"下移 {name}": "Move {name} down",
|
||||
"对比与选择": "Compare & choose",
|
||||
"筛选候选项,查看属性差异,再提交你的选择": "Filter options, compare the details and submit your choice.",
|
||||
"搜索选项": "Search options",
|
||||
"仅看差异": "Differences only",
|
||||
"仅看已选": "Selected only",
|
||||
"卡片视图": "Card view",
|
||||
"属性对比": "Compare details",
|
||||
"按价格排序": "Sort by price",
|
||||
"全部选项": "All options",
|
||||
"没有符合筛选条件的选项": "No options match these filters.",
|
||||
"属性": "Attribute",
|
||||
"价格": "Price",
|
||||
"选择": "Select",
|
||||
"选择 {name}": "Select {name}",
|
||||
"已选择": "Selected",
|
||||
"数据仪表盘": "Data dashboard",
|
||||
"切换指标与范围,图表、汇总和明细同步更新": "Explore metrics and ranges with linked charts, summaries and details.",
|
||||
"全部数据": "All data",
|
||||
"最近一半": "Recent half",
|
||||
"合计": "Total",
|
||||
"平均值": "Average",
|
||||
"最新值": "Latest value",
|
||||
"数据明细": "Data details"
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
export interface SurfaceLocation {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
export interface SurfaceFact {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
export interface SurfaceDataset {
|
||||
categories: string[];
|
||||
series: SurfaceSeries[];
|
||||
}
|
||||
export interface SurfaceSeries {
|
||||
id: string;
|
||||
label: string;
|
||||
unit?: string;
|
||||
values: number[];
|
||||
}
|
||||
|
||||
export interface SurfaceImage {
|
||||
path: string;
|
||||
alt: string;
|
||||
source?: string;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { SurfaceImage, SurfaceLocation, SurfaceFact, SurfaceDataset } from "./surface-rich";
|
||||
export type { SurfaceImage, SurfaceLocation, SurfaceFact, SurfaceDataset, SurfaceSeries } from "./surface-rich";
|
||||
// Mirrors internal/contract/eventwire. Field names and kind strings must match exactly;
|
||||
// this file is the contract, not a convenience shape.
|
||||
|
||||
@@ -380,7 +382,7 @@ export interface ExtensionNotification {
|
||||
export type ExtensionViewTone = "dim" | "strong" | "ok" | "warn" | "err" | "accent";
|
||||
|
||||
export interface ExtensionViewNode {
|
||||
kind: "text" | "markdown" | "row" | "stack" | "kv" | "meter" | "pip" | "button" | "divider" | "field" | "choice_card" | "editable_text" | "diff_preview";
|
||||
kind: "text" | "markdown" | "row" | "stack" | "kv" | "meter" | "pip" | "button" | "divider" | "field" | "choice_card" | "editable_text" | "diff_preview" | "map" | "comparison" | "dashboard";
|
||||
value?: string;
|
||||
key?: string;
|
||||
label?: string;
|
||||
@@ -390,6 +392,7 @@ export interface ExtensionViewNode {
|
||||
children?: ExtensionViewNode[];
|
||||
field?: SurfaceField;
|
||||
diff?: SurfaceDiff;
|
||||
dataset?: SurfaceDataset;
|
||||
}
|
||||
|
||||
export interface SurfaceDiff {
|
||||
@@ -412,6 +415,12 @@ export interface SurfaceOption {
|
||||
label: string;
|
||||
description?: string;
|
||||
preview?: string;
|
||||
group?: string;
|
||||
location?: SurfaceLocation;
|
||||
price?: number;
|
||||
currency?: string;
|
||||
facts?: SurfaceFact[];
|
||||
image?: SurfaceImage;
|
||||
}
|
||||
|
||||
export interface ExtensionView {
|
||||
|
||||
@@ -745,7 +745,7 @@ const Row = memo(function Row({
|
||||
case "approval":
|
||||
return <ApprovalCard item={it} onApprove={onApprove} onFullAccess={onFullAccess} onPlan={onPlan} />;
|
||||
case "ask":
|
||||
return it.ask.questions.some(q => q.surface) ? <SurfaceCard item={it} onAnswer={onAnswer} /> : it.ask.origin ? <ElicitCard item={it} onAnswer={onAnswer} /> : <AskCard item={it} onAnswer={onAnswer} />;
|
||||
return it.ask.questions.some(q => q.surface) ? <SurfaceCard item={it} onAnswer={onAnswer} port={port} /> : it.ask.origin ? <ElicitCard item={it} onAnswer={onAnswer} /> : <AskCard item={it} onAnswer={onAnswer} />;
|
||||
case "compaction":
|
||||
return <CompactionCard c={it.c} done={it.done} />;
|
||||
|
||||
|
||||
@@ -1,32 +1,40 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { Button, Checkbox, MantineProvider, Radio, Textarea, TextInput } from "@mantine/core";
|
||||
import { certifyRoots, LIBRARY_PRIMITIVES } from "./roots";
|
||||
import { Button, Checkbox, MantineProvider, Modal, UnstyledButton, Radio, Textarea, TextInput } from "@mantine/core";
|
||||
import { certifyRoots, LIBRARY_PRIMITIVES, RUNTIME_EVENT, eventOfProp } from "./roots";
|
||||
|
||||
afterEach(cleanup);
|
||||
it.each(LIBRARY_PRIMITIVES)("certifies $name only through its declared module import", p => {
|
||||
it.each(LIBRARY_PRIMITIVES.flatMap(p => Object.keys(p.callbacks).map(prop => ({ ...p, prop }))))("certifies $name.$prop only through its declared module import", p => {
|
||||
const [name, ...members] = p.name.split('.');
|
||||
const target = ['Control', ...members].join('.');
|
||||
const prop = Object.keys(p.callbacks)[0];
|
||||
const prop = p.prop;
|
||||
const source = (module: string) => `import {${name} as Control} from '${module}'; function App(){return <${target} ${prop}={()=>{}} />}`;
|
||||
const good = certifyRoots(new Map([['fixture.tsx',source(p.module)]]));
|
||||
expect(good.roots).toHaveLength(1);
|
||||
expect(good.roots[0].event).toBe(Object.values(p.callbacks)[0]);
|
||||
expect(good.roots[0].event).toBe(p.callbacks[prop]);
|
||||
const unknown = certifyRoots(new Map([['fixture.tsx',source('unverified-library')]]));
|
||||
expect(unknown.roots).toHaveLength(0);
|
||||
expect(unknown.uncertified).toHaveLength(1);
|
||||
expect(unknown.uncertified).toHaveLength(RUNTIME_EVENT.has(eventOfProp(prop) ?? "") ? 0 : 1);
|
||||
});
|
||||
it.each(LIBRARY_PRIMITIVES)("backs $name callbacks with the installed styled component DOM", p => {
|
||||
it.each(LIBRARY_PRIMITIVES.flatMap(p => Object.keys(p.callbacks).map(prop => ({ ...p, prop }))))("backs $name.$prop callbacks with the installed styled component DOM", p => {
|
||||
const callback = vi.fn();
|
||||
const control = p.name === 'Button' ? <Button onClick={callback}>Fixture</Button>
|
||||
const control = p.name === 'Modal' ? <Modal opened onClose={p.prop === 'onClose' ? callback : () => {}} onKeyDown={p.prop === 'onKeyDown' ? callback : undefined} title="Fixture" closeButtonProps={{ 'aria-label': 'Close fixture' }}>Content</Modal>
|
||||
: p.name === 'UnstyledButton' ? <UnstyledButton onClick={callback}>Fixture</UnstyledButton>
|
||||
: p.name === 'Button' ? <Button onClick={callback}>Fixture</Button>
|
||||
: p.name === 'Checkbox' ? <Checkbox label="Fixture" onChange={callback} />
|
||||
: p.name === 'TextInput' ? <TextInput aria-label="Fixture" onChange={callback} />
|
||||
: p.name === 'Textarea' ? <Textarea aria-label="Fixture" onChange={callback} />
|
||||
: p.name === 'Radio.Card' ? <Radio.Card onClick={callback}>Fixture</Radio.Card>
|
||||
: <Checkbox.Card onClick={callback}>Fixture</Checkbox.Card>;
|
||||
render(<MantineProvider forceColorScheme="light">{control}</MantineProvider>);
|
||||
const role = p.name === 'Button' ? 'button' : p.name === 'Radio.Card' ? 'radio' : p.name.startsWith('Checkbox') ? 'checkbox' : 'textbox';
|
||||
if (p.name === 'Modal') {
|
||||
if (p.prop === 'onClose') fireEvent.click(screen.getByRole('button', { name: 'Close fixture' }));
|
||||
else fireEvent.keyDown(screen.getByRole('dialog'), { key: 'ArrowRight' });
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
return;
|
||||
}
|
||||
const role = p.name === 'Button' || p.name === 'UnstyledButton' ? 'button' : p.name === 'Radio.Card' ? 'radio' : p.name.startsWith('Checkbox') ? 'checkbox' : 'textbox';
|
||||
const input = screen.getByRole(role,{name:'Fixture'});
|
||||
if(role === 'textbox') fireEvent.change(input,{target:{value:'value'}});else fireEvent.click(input);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -49,6 +49,8 @@ export const PRIMITIVES: Primitive[] = [
|
||||
];
|
||||
|
||||
export const LIBRARY_PRIMITIVES: { module: string; name: string; callbacks: Record<string, string> }[] = [
|
||||
{ module: "@mantine/core", name: "Modal", callbacks: { onClose: "click", onKeyDown: "keydown" } },
|
||||
{ module: "@mantine/core", name: "UnstyledButton", callbacks: { onClick: "click" } },
|
||||
{ module: "@mantine/core", name: "Button", callbacks: { onClick: "click" } },
|
||||
{ module: "@mantine/core", name: "TextInput", callbacks: { onChange: "change" } },
|
||||
{ module: "@mantine/core", name: "Textarea", callbacks: { onChange: "change" } },
|
||||
|
||||
@@ -8,17 +8,18 @@ import "../RestoreNotice.css";
|
||||
import type { Tool } from "../../port/wire";
|
||||
import { surfaceOfTool, surfaceFields, surfaceActions, actionSurfaceOfTool } from "./call";
|
||||
import "./SurfaceCard.css";
|
||||
import { SurfaceMediaContext, type SurfaceMediaPort } from "./media-context";
|
||||
|
||||
const NativeSurface = lazySurface(() => import("./a2ui/form"));
|
||||
|
||||
const NativeAction = lazySurface(()=>import("./a2ui/action"));
|
||||
|
||||
export function surfaceTool(tool: Tool, running: boolean, port?: Partial<SurfaceActionPort>) {
|
||||
export function surfaceTool(tool: Tool, running: boolean, port?: Partial<SurfaceActionPort & SurfaceMediaPort>) {
|
||||
const spec = surfaceOfTool(tool);
|
||||
if (spec && surfaceActions(spec).length) {
|
||||
if (running) return null;
|
||||
if (!actionSurfaceOfTool(tool)) return undefined;
|
||||
return <NativeAction key={tool.id} spec={spec} callId={tool.id ?? ""} port={port?.surfaceAction && port.surfaceActionState && port.cancelQueued ? port as SurfaceActionPort : undefined}/>;
|
||||
return <SurfaceMediaContext.Provider value={port}><NativeAction key={tool.id} spec={spec} callId={tool.id ?? ""} port={port?.surfaceAction && port.surfaceActionState && port.cancelQueued ? port as SurfaceActionPort : undefined}/></SurfaceMediaContext.Provider>;
|
||||
}
|
||||
if (spec && surfaceFields(spec).length) return null;
|
||||
if (spec && !running && tool.output !== undefined) return <ExtensionView body={spec.body} />;
|
||||
@@ -26,6 +27,7 @@ export function surfaceTool(tool: Tool, running: boolean, port?: Partial<Surface
|
||||
}
|
||||
|
||||
export interface SurfaceCardProps {
|
||||
port?: SurfaceMediaPort;
|
||||
item: Extract<Item, { t: "ask" }>;
|
||||
onAnswer: (itemId: string, id: string, answers: { questionId: string; selected: string[]; value?: Record<string, unknown> }[]) => Promise<void>;
|
||||
}
|
||||
@@ -33,5 +35,5 @@ export interface SurfaceCardProps {
|
||||
export function SurfaceCard(props: SurfaceCardProps) {
|
||||
const spec = parseSurface(props.item.ask.questions[0]?.surface);
|
||||
if (!spec || props.item.surfaceExpired) return <div className="restore-notice" role="status" data-code="surface.expired">{t("界面已失效,请等待新的请求")}</div>;
|
||||
return <NativeSurface key={props.item.ask.id} {...props} spec={spec} />;
|
||||
return <SurfaceMediaContext.Provider value={props.port}><NativeSurface key={props.item.ask.id} {...props} spec={spec} /></SurfaceMediaContext.Provider>;
|
||||
}
|
||||
@@ -3,16 +3,23 @@ import { createContext, useContext } from "react";
|
||||
import { Catalog, CommonSchemas } from "@a2ui/web_core/v0_9";
|
||||
import { createComponentImplementation, type ReactComponentImplementation } from "@a2ui/react/v0_9";
|
||||
import { z } from "zod";
|
||||
import type { ExtensionViewNode } from "../../../port/wire";
|
||||
import type { ExtensionViewNode, SurfaceDataset } from "../../../port/wire";
|
||||
import { SurfaceField } from "../SurfaceField";
|
||||
import { SurfaceDiffPreview } from "../SurfaceDiffPreview";
|
||||
|
||||
import { lazySurface } from "./lazy";
|
||||
|
||||
const MapPlanner = lazySurface(() => import("../rich/MapPlanner"));
|
||||
const Comparison = lazySurface(() => import("../rich/Comparison"));
|
||||
const Dashboard = lazySurface(() => import("../rich/Dashboard"));
|
||||
|
||||
export const SurfaceContext = createContext<{ disabled: boolean; inputs: boolean; actionDisabled?: boolean; onAction?: (id: string) => void }>({ disabled: true, inputs: false });
|
||||
|
||||
const tone = z.string().optional();
|
||||
const textSchema = z.object({ value: z.string(), tone }).strict();
|
||||
const fieldValue = z.union([CommonSchemas.DynamicString, CommonSchemas.DynamicNumber, CommonSchemas.DynamicBoolean, z.array(z.string())]);
|
||||
const implementations: ReactComponentImplementation[] = [
|
||||
createComponentImplementation({ name: "Dashboard", schema: z.object({ dataset: z.custom<SurfaceDataset>(), label: z.string().optional() }).strict() }, ({ props }) => <Dashboard dataset={props.dataset} label={props.label} />),
|
||||
createComponentImplementation({ name: "Text", schema: textSchema }, ({ props }) => <span className="xv-t" data-tone={props.tone}>{props.value}</span>),
|
||||
createComponentImplementation({ name: "Markdown", schema: textSchema }, ({ props }) => <p className="xv-md">{props.value}</p>),
|
||||
...(["Row", "Stack"] as const).map(name => createComponentImplementation({ name, schema: z.object({ children: CommonSchemas.ChildList }).strict() }, ({ props, buildChild }) =>
|
||||
@@ -30,6 +37,10 @@ const implementations: ReactComponentImplementation[] = [
|
||||
}),
|
||||
createComponentImplementation({ name: "Field", schema: z.object({ node: z.custom<ExtensionViewNode>(), value: fieldValue }).strict() }, ({ props }) => {
|
||||
const { disabled, inputs } = useContext(SurfaceContext);
|
||||
if (inputs && (props.node.kind === "map" || props.node.kind === "comparison")) {
|
||||
const View = props.node.kind === "map" ? MapPlanner : Comparison;
|
||||
return <View node={props.node} value={props.value} disabled={disabled} change={value => props.setValue(value)} />;
|
||||
}
|
||||
return inputs ? <SurfaceField node={props.node} value={props.value} disabled={disabled} change={value => props.setValue(value as string | number | boolean | string[])} /> : null;
|
||||
}),
|
||||
createComponentImplementation({ name: "DiffPreview", schema: z.object({ before: z.string(), after: z.string(), label: z.string().optional() }).strict() }, ({ props }) =>
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ExtensionViewNode } from "../../../port/wire";
|
||||
import { validSurfaceValue } from "../spec";
|
||||
import { studioCatalog } from "./catalog";
|
||||
|
||||
const components = { text: "Text", markdown: "Markdown", row: "Row", stack: "Stack", kv: "KV", meter: "Meter", pip: "Pip", divider: "Divider", button: "ExtensionButton", field: "Field", choice_card: "Field", editable_text: "Field", diff_preview: "DiffPreview" };
|
||||
const components = { text: "Text", markdown: "Markdown", row: "Row", stack: "Stack", kv: "KV", meter: "Meter", pip: "Pip", divider: "Divider", button: "ExtensionButton", field: "Field", choice_card: "Field", editable_text: "Field", diff_preview: "DiffPreview", map: "Field", comparison: "Field", dashboard: "Dashboard" };
|
||||
|
||||
export function createSurfaceRuntime(body: ExtensionViewNode[], restored?: Record<string, unknown>) {
|
||||
const fields: { node: ExtensionViewNode; id: string }[] = [];
|
||||
@@ -20,7 +20,8 @@ export function createSurfaceRuntime(body: ExtensionViewNode[], restored?: Recor
|
||||
const fieldId = `f${fields.length}`;
|
||||
fields.push({ node, id: fieldId });
|
||||
props = { node, value: { path: `/fields/${fieldId}` } };
|
||||
} else if (node.diff) props = { ...node.diff, label: node.label };
|
||||
} else if (node.dataset) props = { dataset: node.dataset, label: node.label };
|
||||
else if (node.diff) props = { ...node.diff, label: node.label };
|
||||
else props = displayProps(node);
|
||||
if (component === "ExtensionButton") props = { label: node.label ?? "", actionId: node.actionId ?? "" };
|
||||
if (component === "Text" || component === "Markdown" || component === "KV") props.value ??= "";
|
||||
|
||||
@@ -33,6 +33,15 @@ export function actionSurfaceOfTool(tool: Tool) {
|
||||
try {return JSON.parse(tool.output ?? "{}").nonblocking===true ? spec : null;} catch {return null;}
|
||||
}
|
||||
|
||||
export function visibleSurfaceOfTool(tool: Tool) {
|
||||
const action = actionSurfaceOfTool(tool);
|
||||
if (action) return action;
|
||||
const spec = surfaceOfTool(tool);
|
||||
if (!spec || tool.output === undefined || surfaceFields(spec).length || surfaceActions(spec).length) return null;
|
||||
const dashboard = (nodes: SurfaceSpec["body"]): boolean => nodes.some(n => n.kind === "dashboard" || !!n.children && dashboard(n.children));
|
||||
return dashboard(spec.body) ? spec : null;
|
||||
}
|
||||
|
||||
function recordedValues(spec: SurfaceSpec, output?: string): Record<string, unknown> | undefined {
|
||||
try {
|
||||
const raw = JSON.parse(output ?? "");
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createContext } from "react";
|
||||
import type { AgentPort } from "../../port/port";
|
||||
|
||||
export type SurfaceMediaPort = Pick<AgentPort, "workspaceImageURL">;
|
||||
export const SurfaceMediaContext = createContext<Partial<SurfaceMediaPort> | undefined>(undefined);
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useContext, useState } from "react";
|
||||
import { Button, Modal, Stack, Text, UnstyledButton } from "@mantine/core";
|
||||
import { SurfaceMediaContext } from "./media-context";
|
||||
import type { SurfaceImage } from "../../port/wire";
|
||||
import { imageSourceURL } from "./rich-spec";
|
||||
import { t } from "../../i18n";
|
||||
|
||||
|
||||
export function OptionImage({ image, fit = "cover" }: { image?: SurfaceImage; fit?: "cover" | "contain" }) {
|
||||
const port = useContext(SurfaceMediaContext);
|
||||
if (!image || !port?.workspaceImageURL) return null;
|
||||
const src = port.workspaceImageURL(image.path);
|
||||
return <PhotoCard key={src} src={src} image={image} fit={fit} />;
|
||||
}
|
||||
|
||||
function SourceLink({ source }: { source?: string }) {
|
||||
const url = imageSourceURL(source);
|
||||
return url ? <a className="rich-option-source" href={url.href} target="_blank" rel="noopener noreferrer">{t("图片来源")} · {url.hostname}</a> : null;
|
||||
}
|
||||
|
||||
function PhotoCard({ src, image, fit }: { src: string; image: SurfaceImage; fit: "cover" | "contain" }) {
|
||||
const [opened, setOpened] = useState(false);
|
||||
return <div>
|
||||
<MediaImage src={src} alt={image.alt} fit={fit} open={() => setOpened(true)} />
|
||||
<SourceLink source={image.source} />
|
||||
<Modal data-action-click="surface.explore" data-action-keydown="surface.explore" opened={opened} onClose={() => setOpened(false)} title={image.alt} size="xl" centered withinPortal={false} closeOnEscape={false} closeButtonProps={{ "aria-label": t("关闭图片") }} onKeyDown={event => { if (event.key === "Escape") { event.stopPropagation(); setOpened(false); } }}>
|
||||
<MediaImage key={opened ? "open" : "closed"} src={src} alt={image.alt} fit="contain" />
|
||||
<SourceLink source={image.source} />
|
||||
</Modal>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function MediaImage({ src, alt, fit, open }: { src: string; alt: string; fit: "cover" | "contain"; open?: () => void }) {
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const image = <img key={attempt} src={src} alt={alt} loading={open ? "lazy" : "eager"} decoding="async" style={{ objectFit: fit }} onError={() => setFailed(true)} />;
|
||||
if (failed) return <div className="rich-option-media"><Stack gap="xs" align="center"><Text size="sm" c="dimmed">{t("图片暂不可用")}</Text><Button variant="subtle" size="xs" data-action="surface.explore" onClick={() => { setAttempt(value => value + 1); setFailed(false); }}>{t("重试图片")}</Button></Stack></div>;
|
||||
return open ? <UnstyledButton className="rich-option-media rich-photo-trigger" aria-label={t("查看大图:{alt}", { alt })} data-action="surface.explore" onClick={open}>{image}</UnstyledButton> : <div className="rich-option-media rich-photo-viewer">{image}</div>;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
const object = (v: unknown): v is Record<string, unknown> => !!v && typeof v === "object" && !Array.isArray(v);
|
||||
const keys = (v: Record<string, unknown>, allowed: string[]) => Object.keys(v).every(k => allowed.includes(k));
|
||||
const text = (v: unknown, cap = 200) => typeof v === "string" && new TextEncoder().encode(v).length <= cap;
|
||||
const finite = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
|
||||
|
||||
export function richOption(o: Record<string, unknown>): boolean {
|
||||
if (o.image !== undefined) {
|
||||
const i = o.image;
|
||||
if (!object(i) || !keys(i, ["path", "alt", "source"]) || !text(i.path, 1024) || !text(i.alt) || !i.alt) return false;
|
||||
if (i.source !== undefined && !imageSourceURL(i.source)) return false;
|
||||
const path = i.path as string;
|
||||
if (/[:\\?#%\x00\r\n\t]/.test(path) || path.split("/").some(s => !s || s === "." || s === "..") || !/\.(png|jpe?g|webp|avif|gif)$/i.test(path)) return false;
|
||||
}
|
||||
if (o.group !== undefined && !text(o.group)) return false;
|
||||
if (o.currency !== undefined && !text(o.currency, 8)) return false;
|
||||
if (o.price !== undefined && (!finite(o.price) || o.price < 0 || !o.currency)) return false;
|
||||
if (o.location !== undefined) {
|
||||
const p = o.location;
|
||||
if (!object(p) || !keys(p, ["latitude", "longitude"]) || !finite(p.latitude) || !finite(p.longitude) || Math.abs(p.latitude) > 85 || Math.abs(p.longitude) > 180) return false;
|
||||
}
|
||||
if (o.facts !== undefined) {
|
||||
if (!Array.isArray(o.facts) || o.facts.length > 12) return false;
|
||||
const seen = new Set<string>();
|
||||
for (const f of o.facts) {
|
||||
if (!object(f) || !keys(f, ["label", "value"]) || !text(f.label) || !f.label || !text(f.value, 4000) || seen.has(f.label as string)) return false;
|
||||
seen.add(f.label as string);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function richDataset(raw: unknown): boolean {
|
||||
if (!object(raw) || !keys(raw, ["categories", "series"]) || !Array.isArray(raw.categories) || !Array.isArray(raw.series)) return false;
|
||||
const categories = raw.categories;
|
||||
if (!categories.length || categories.length > 64 || new Set(categories).size !== categories.length || categories.some(c => !text(c) || !c)) return false;
|
||||
if (!raw.series.length || raw.series.length > 8) return false;
|
||||
const seen = new Set<string>();
|
||||
for (const s of raw.series) {
|
||||
if (!object(s) || !keys(s, ["id", "label", "unit", "values"]) || typeof s.id !== "string" || !/^[a-zA-Z0-9_-]{1,64}$/.test(s.id) || seen.has(s.id) || !text(s.label) || !s.label || (s.unit !== undefined && !text(s.unit)) || !Array.isArray(s.values) || s.values.length !== categories.length || s.values.some(v => !finite(v))) return false;
|
||||
seen.add(s.id);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function imageSourceURL(source: unknown): URL | null {
|
||||
if (!text(source, 4096) || !/^https?:\/\//.test(source as string) || /[\\\r\n\t]/.test(source as string)) return null;
|
||||
try {
|
||||
const url = new URL(source as string);
|
||||
return url.hostname && !url.username && !url.password ? url : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { SurfaceKit } from "./SurfaceKit";
|
||||
import { OptionImage } from "./media";
|
||||
import { SurfaceMediaContext } from "./media-context";
|
||||
import { SurfaceCard, surfaceTool } from "./SurfaceCard";
|
||||
import type { Item } from "../../state/session";
|
||||
import type { ExtensionViewNode } from "../../port/wire";
|
||||
|
||||
vi.mock("./rich/MapCanvas", () => ({ default: ({ active }: { active?: string }) => <div data-testid="map-active">{active}</div> }));
|
||||
vi.mock("@mantine/charts", () => ({ AreaChart: () => <div data-testid="area" />, BarChart: () => <div data-testid="bar" /> }));
|
||||
afterEach(() => { cleanup(); vi.restoreAllMocks(); });
|
||||
const places = [{ value: "park", label: "Park", group: "Day 1", location: { latitude: 35, longitude: 139 } }, { value: "museum", label: "Museum", group: "Day 2", location: { latitude: 36, longitude: 140 } }];
|
||||
const map: ExtensionViewNode = { kind: "map", key: "trip", label: "Tokyo", field: { type: "multichoice", required: true, default: ["park", "museum"], options: places } };
|
||||
const compare: ExtensionViewNode = { kind: "comparison", key: "camera", label: "Cameras", field: { type: "choice", options: [{ value: "a", label: "Alpha", group: "Travel", price: 100, currency: "USD", facts: [{ label: "Weight", value: "300 g" }] }, { value: "b", label: "Beta", group: "Studio", price: 200, currency: "USD", facts: [{ label: "Weight", value: "500 g" }] }] } };
|
||||
function item(body: ExtensionViewNode[]): Extract<Item, { t: "ask" }> { return { t: "ask", id: "row", ask: { id: "ask", questions: [{ id: "surface", prompt: "Plan", options: [], surface: { spec_version: 1, body } }] } }; }
|
||||
|
||||
it("links place focus, preserves reordered ids across a failed send and seals replay", async () => {
|
||||
const answer = vi.fn().mockRejectedValueOnce(new Error("refused")).mockResolvedValueOnce(undefined);
|
||||
const view = render(<SurfaceCard item={item([map])} onAnswer={answer} />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Museum" }));
|
||||
expect(screen.getByTestId("map-active").textContent).toBe("museum");
|
||||
fireEvent.click(screen.getByRole("button", { name: "上移 Museum" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "提交" }));
|
||||
await screen.findByRole("alert");
|
||||
expect(answer.mock.calls[0][2][0].value.trip).toEqual(["museum", "park"]);
|
||||
fireEvent.click(screen.getByRole("button", { name: "提交" }));
|
||||
await waitFor(() => expect(answer).toHaveBeenCalledTimes(2));
|
||||
expect(answer.mock.calls[1][2][0].value.trip).toEqual(["museum", "park"]);
|
||||
view.rerender(<SurfaceCard item={{ ...item([map]), answered: [[]], surfaceValues: { trip: ["museum", "park"] } }} onAnswer={answer} />);
|
||||
await waitFor(() => expect((screen.getByRole("button", { name: "上移 Park" }) as HTMLButtonElement).disabled).toBe(true));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Park" }));
|
||||
expect(screen.getByTestId("map-active").textContent).toBe("park");
|
||||
});
|
||||
|
||||
it("filters comparison options without dropping the selection, exposes facts and submits ids", async () => {
|
||||
const answer = vi.fn(async () => {});
|
||||
render(<SurfaceCard item={item([compare])} onAnswer={answer} />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "选择 Alpha" }));
|
||||
fireEvent.change(screen.getByLabelText("搜索选项"), { target: { value: "Beta" } });
|
||||
expect(screen.queryByRole("button", { name: "选择 Alpha" })).toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: "属性对比" }));
|
||||
expect(screen.getByRole("table").textContent).toContain("500 g");
|
||||
fireEvent.click(screen.getByRole("button", { name: "提交" }));
|
||||
await waitFor(() => expect(answer).toHaveBeenCalled());
|
||||
expect((answer.mock.calls as unknown[][])[0][2]).toEqual([{ questionId: "surface", selected: [], value: { camera: "a" } }]);
|
||||
});
|
||||
|
||||
it("recomputes dashboard summaries and details from the chosen series and range", async () => {
|
||||
const dashboard: ExtensionViewNode = { kind: "dashboard", label: "Activity", dataset: { categories: ["Jan", "Feb", "Mar", "Apr"], series: [{ id: "sales", label: "Sales", values: [1, 2, 3, 4] }, { id: "orders", label: "Orders", values: [10, 20, 30, 40] }] } };
|
||||
render(<SurfaceCard item={item([dashboard, compare])} onAnswer={vi.fn(async () => {})} />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Orders" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "最近一半" }));
|
||||
expect(screen.getByTestId("dashboard-total").textContent).toBe("70");
|
||||
fireEvent.click(screen.getByRole("button", { name: "柱状图" }));
|
||||
expect(screen.getByTestId("bar")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "数据明细" }));
|
||||
expect(screen.getByRole("table").textContent).toContain("Mar");
|
||||
expect(screen.getByRole("table").textContent).not.toContain("Jan");
|
||||
});
|
||||
|
||||
|
||||
it("compares only differing properties and retains selection across workspace expansion", async () => {
|
||||
const options = compare.field!.options!.map(o => ({ ...o, facts: [...o.facts!, { label: "Warranty", value: "2 years" }] }));
|
||||
render(<SurfaceCard item={item([{ ...compare, field: { ...compare.field!, options } }])} onAnswer={vi.fn(async () => {})} />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "选择 Alpha" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "属性对比" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "仅看差异" }));
|
||||
expect(screen.getByRole("table").textContent).toContain("Weight");
|
||||
expect(screen.getByRole("table").textContent).not.toContain("Warranty");
|
||||
fireEvent.click(screen.getByRole("button", { name: "展开工作区" }));
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" });
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "选择 Alpha" }).getAttribute("aria-pressed")).toBe("true");
|
||||
fireEvent.click(screen.getByRole("button", { name: "仅看差异" }));
|
||||
expect(screen.getByRole("table").textContent).toContain("Warranty");
|
||||
});
|
||||
|
||||
|
||||
it("reveals a focused stop within the itinerary without scrolling on replay", async () => {
|
||||
const view = render(<SurfaceCard item={{ ...item([map]), answered: [[]], surfaceValues: { trip: ["park", "museum"] } }} onAnswer={vi.fn(async () => {})} />);
|
||||
const museum = await screen.findByRole("button", { name: "Museum" });
|
||||
const list = view.container.querySelector(".rich-map-list") as HTMLElement;
|
||||
const row = museum.closest(".rich-stop") as HTMLElement;
|
||||
vi.spyOn(list, "getBoundingClientRect").mockReturnValue({ top: 100, bottom: 200 } as DOMRect);
|
||||
vi.spyOn(row, "getBoundingClientRect").mockReturnValue({ top: 300, bottom: 380 } as DOMRect);
|
||||
expect(list.scrollTop).toBe(0);
|
||||
fireEvent.click(museum);
|
||||
expect(list.scrollTop).toBe(180);
|
||||
expect(row.dataset.active).toBe("true");
|
||||
});
|
||||
|
||||
|
||||
it("loads product media through the owning workspace and preserves selection after image failure", async () => {
|
||||
const body = [{ ...compare, field: { ...compare.field!, options: [{ ...compare.field!.options![0], image: { path: "images/camera.png", alt: "Camera front", source: "https://example.com/camera" } }] } }];
|
||||
const port = { workspaceImageURL: vi.fn((path: string) => `/workspace-a/image?path=${encodeURIComponent(path)}`) };
|
||||
const answer = vi.fn(async () => {});
|
||||
const view = render(<SurfaceCard item={item(body)} port={port} onAnswer={answer} />);
|
||||
const photo = await screen.findByRole("img", { name: "Camera front" });
|
||||
expect(photo.getAttribute("src")).toBe("/workspace-a/image?path=images%2Fcamera.png");
|
||||
fireEvent.click(screen.getByRole("button", { name: "选择 Alpha" }));
|
||||
expect(screen.getByRole("link", { name: /example.com/ }).getAttribute("href")).toBe("https://example.com/camera");
|
||||
fireEvent.error(photo);
|
||||
expect(screen.queryByRole("img")).toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: "提交" }));
|
||||
await waitFor(() => expect(answer).toHaveBeenCalled());
|
||||
expect((answer.mock.calls as unknown[][])[0][2]).toEqual([{ questionId: "surface", selected: [], value: { camera: "a" } }]);
|
||||
view.rerender(<SurfaceCard item={item(body)} port={{ workspaceImageURL: () => "/workspace-b/image" }} onAnswer={answer} />);
|
||||
expect((await screen.findByRole("img", { name: "Camera front" })).getAttribute("src")).toBe("/workspace-b/image");
|
||||
});
|
||||
|
||||
|
||||
it("uses the same workspace media port for nonblocking product actions", async () => {
|
||||
const spec = { spec_version: 1, body: [{ ...compare, field: { ...compare.field!, options: [{ ...compare.field!.options![0], image: { path: "images/camera.png", alt: "Action camera" } }] } }, { kind: "button", label: "Continue", actionId: "continue" }] };
|
||||
const port = {
|
||||
workspaceImageURL: (path: string) => `/runtime/action-image?path=${encodeURIComponent(path)}`,
|
||||
surfaceActionState: vi.fn(async () => ({ active: true, action: { sessionId: "s", surfaceId: "surface", callId: "call", requestId: "", actionId: "", values: {} } })),
|
||||
surfaceAction: vi.fn(), cancelQueued: vi.fn()
|
||||
};
|
||||
render(<>{surfaceTool({ id: "call", name: "render_surface", readOnly: true, args: JSON.stringify(spec), output: JSON.stringify({ nonblocking: true }) }, false, port)}</>);
|
||||
expect((await screen.findByRole("img", { name: "Action camera" })).getAttribute("src")).toBe("/runtime/action-image?path=images%2Fcamera.png");
|
||||
expect(port.surfaceAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps photos usable when a plugin supplies a malformed source link", () => {
|
||||
for (const source of ["not a URL", "javascript:alert(1)", "https://user:[email protected]/photo"]) {
|
||||
const view = render(<SurfaceKit><SurfaceMediaContext.Provider value={{ workspaceImageURL: () => "/workspace/image" }}><OptionImage image={{ path: "photo.png", alt: "Photo", source }} /></SurfaceMediaContext.Provider></SurfaceKit>);
|
||||
expect(screen.getByRole("img", { name: "Photo" })).toBeTruthy();
|
||||
expect(screen.queryByRole("link")).toBeNull();
|
||||
view.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("opens photos, retries failures and closes the viewer without changing answers", async () => {
|
||||
const photo = { path: "images/camera.png", alt: "Camera front", source: "https://example.com/camera" };
|
||||
const body = [{ ...compare, field: { ...compare.field!, options: [{ ...compare.field!.options![0], image: photo }] } }];
|
||||
const answer = vi.fn(async () => {});
|
||||
render(<SurfaceCard item={item(body)} port={{ workspaceImageURL: () => "/workspace/photo" }} onAnswer={answer} />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "选择 Alpha" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "展开工作区" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "查看大图:Camera front" }));
|
||||
const viewer = await screen.findByRole("dialog", { name: "Camera front" });
|
||||
const image = within(viewer).getByRole("img", { name: "Camera front" });
|
||||
expect(image.getAttribute("src")).toBe("/workspace/photo");
|
||||
expect(within(viewer).getByRole("link", { name: /example.com/ })).toBeTruthy();
|
||||
fireEvent.error(image);
|
||||
fireEvent.click(within(viewer).getByRole("button", { name: "重试图片" }));
|
||||
expect(within(viewer).getByRole("img", { name: "Camera front" })).toBeTruthy();
|
||||
fireEvent.keyDown(viewer, { key: "Escape" });
|
||||
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Camera front" })).toBeNull());
|
||||
expect(screen.getByRole("dialog", { name: "Cameras" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "选择 Alpha" }).getAttribute("aria-pressed")).toBe("true");
|
||||
expect(answer).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { parseSurface } from "./spec";
|
||||
|
||||
const map = { kind: "map", key: "stops", label: "Trip", field: { type: "multichoice", options: [{ value: "a", label: "Park", location: { latitude: 35, longitude: 139 } }] } };
|
||||
const dashboard = { kind: "dashboard", label: "Revenue", dataset: { categories: ["Jan", "Feb"], series: [{ id: "sales", label: "Sales", values: [10, 20] }] } };
|
||||
it("accepts mapped choices and rectangular dashboard data", () => {
|
||||
expect(parseSurface({ spec_version: 1, body: [map, dashboard] })).not.toBeNull();
|
||||
const comparison = { ...map, kind: "comparison", field: { type: "choice", options: [{ value: "camera", label: "Camera", price: 100, currency: "USD", facts: [{ label: "Weight", value: "300 g" }] }] } };
|
||||
expect(parseSurface({ spec_version: 1, body: [comparison] })).not.toBeNull();
|
||||
});
|
||||
it("refuses malformed coordinates, executable properties and inconsistent series", () => {
|
||||
for (const location of [{ latitude: 91, longitude: 0 }, { latitude: 0, longitude: -181 }, { longitude: 0 }, { latitude: 0, longitude: 0, url: "https://invalid.test" }]) {
|
||||
expect(parseSurface({ spec_version: 1, body: [{ ...map, field: { ...map.field, options: [{ ...map.field.options[0], location }] } }] })).toBeNull();
|
||||
}
|
||||
expect(parseSurface({ spec_version: 1, body: [{ ...dashboard, dataset: { ...dashboard.dataset, series: [{ id: "sales", label: "Sales", values: [1] }] } }] })).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts workspace image references and refuses network or escaping paths", () => {
|
||||
const spec = (path: string) => ({ spec_version: 1, body: [{ ...map, field: { ...map.field, options: [{ ...map.field.options[0], image: { path, alt: "Park" } }] } }] });
|
||||
expect(parseSurface(spec("images/park.png"))).not.toBeNull();
|
||||
for (const path of ["../park.png", "/park.png", "https://example.com/a.png", "C:/a.png", "images/../a.png", "images//a.png", "a.svg", "a.png?x=1", "%2e%2e/a.png"]) expect(parseSurface(spec(path))).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts source links but refuses active schemes and credentials", () => {
|
||||
const spec = (source: string) => ({ spec_version: 1, body: [{ ...map, field: { ...map.field, options: [{ ...map.field.options[0], image: { path: "images/park.png", alt: "Park", source } }] } }] });
|
||||
expect(parseSurface(spec("https://example.com/park"))).not.toBeNull();
|
||||
for (const source of ["javascript:alert(1)", "file:///tmp/a", "//example.com/a", "https://user:[email protected]/a"]) expect(parseSurface(spec(source))).toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useState } from "react";
|
||||
import { Badge, Button, Group as MantineGroup, Paper, Stack, Text, TextInput, Title } from "@mantine/core";
|
||||
import { Workspace, type RichInputProps } from "./Workspace";
|
||||
import { OptionImage } from "../media";
|
||||
import { t } from "../../../i18n";
|
||||
|
||||
export default function Comparison({ node, value, disabled, change }: RichInputProps) {
|
||||
const options = node.field!.options!;
|
||||
const selected = Array.isArray(value) ? value as string[] : typeof value === "string" ? [value] : [];
|
||||
const [search, setSearch] = useState("");
|
||||
const [group, setGroup] = useState("");
|
||||
const [shortlist, setShortlist] = useState(false);
|
||||
const [table, setTable] = useState(false);
|
||||
const [differences, setDifferences] = useState(false);
|
||||
const [ascending, setAscending] = useState(false);
|
||||
const groups = [...new Set(options.map(o => o.group).filter((g): g is string => !!g))];
|
||||
const comparablePrices = new Set(options.filter(o => o.price !== undefined).map(o => o.currency)).size === 1;
|
||||
const visible = options.filter(o => (!group || o.group === group) && (!shortlist || selected.includes(o.value)) && `${o.label} ${o.description ?? ""}`.toLocaleLowerCase().includes(search.toLocaleLowerCase()));
|
||||
if (ascending) visible.sort((a, b) => (a.price ?? Infinity) - (b.price ?? Infinity));
|
||||
const facts = [...new Set(visible.flatMap(o => o.facts?.map(f => f.label) ?? []))];
|
||||
const priceDiffers = new Set(visible.map(o => JSON.stringify([o.currency, o.price]))).size > 1;
|
||||
const differing = (f: string) => new Set(visible.map(o => o.facts?.find(v => v.label === f)?.value)).size > 1;
|
||||
const select = (id: string) => { if (!disabled) change(node.field!.type === "choice" ? id : selected.includes(id) ? selected.filter(v => v !== id) : [...selected, id]); };
|
||||
return <Workspace title={node.label} badge={t("对比与选择")} subtitle={t("筛选候选项,查看属性差异,再提交你的选择")}>
|
||||
<MantineGroup justify="space-between"><TextInput style={{ flex: "1 1 200px" }} aria-label={t("搜索选项")} placeholder={t("搜索选项")} value={search} data-action="surface.explore" onChange={e => setSearch(e.target.value)} /><MantineGroup gap="xs">
|
||||
<Button size="xs" variant={shortlist ? "light" : "subtle"} data-action="surface.explore" onClick={() => setShortlist(v => !v)}>{t("仅看已选")}</Button>
|
||||
<Button size="xs" variant={table ? "light" : "subtle"} data-action="surface.explore" onClick={() => setTable(v => !v)}>{table ? t("卡片视图") : t("属性对比")}</Button>
|
||||
{table && <Button size="xs" variant={differences ? "light" : "subtle"} aria-pressed={differences} data-action="surface.explore" onClick={() => setDifferences(v => !v)}>{t("仅看差异")}</Button>}
|
||||
{comparablePrices && <Button size="xs" variant={ascending ? "light" : "subtle"} data-action="surface.explore" onClick={() => setAscending(v => !v)}>{t("按价格排序")}</Button>}
|
||||
</MantineGroup></MantineGroup>
|
||||
<MantineGroup gap="xs"><Button size="xs" variant={!group ? "light" : "subtle"} data-action="surface.explore" onClick={() => setGroup("")}>{t("全部选项")}</Button>{groups.map(g => <Button key={g} size="xs" variant={group === g ? "light" : "subtle"} data-action="surface.explore" onClick={() => setGroup(g)}>{g}</Button>)}<Text c="dimmed" size="xs">{t("已选 {n} 个", { n: selected.length })}</Text></MantineGroup>
|
||||
{!visible.length && <Text role="status" c="dimmed">{t("没有符合筛选条件的选项")}</Text>}
|
||||
{table && visible.length > 0 ? <div className="rich-table"><table aria-label={node.label}><thead><tr><th>{t("属性")}</th>{visible.map(o => <th key={o.value}>{o.label}</th>)}</tr></thead><tbody>
|
||||
{(!differences || priceDiffers) && <tr data-different={priceDiffers}><th>{t("价格")}</th>{visible.map(o => <td key={o.value}>{o.price === undefined ? "—" : `${o.currency} ${o.price.toLocaleString()}`}</td>)}</tr>}
|
||||
{facts.filter(f => !differences || differing(f)).map(f => <tr key={f} data-different={differing(f)}><th>{f}</th>{visible.map(o => <td key={o.value}>{o.facts?.find(v => v.label === f)?.value ?? "—"}</td>)}</tr>)}
|
||||
<tr><th>{t("选择")}</th>{visible.map(o => <td key={o.value}><Button size="xs" variant={selected.includes(o.value) ? "light" : "filled"} disabled={disabled} aria-pressed={selected.includes(o.value)} aria-label={t("选择 {name}", { name: o.label })} data-action="surface.edit" data-target={node.key} onClick={() => select(o.value)}>{selected.includes(o.value) ? t("已选择") : t("选择")}</Button></td>)}</tr>
|
||||
</tbody></table></div> : <div className="rich-products">{visible.map(o => <Paper className="rich-product" data-selected={selected.includes(o.value)} radius="lg" p="lg" key={o.value}>
|
||||
<Stack h="100%" gap="md"><OptionImage image={o.image} fit="contain" /><MantineGroup justify="space-between">{o.group && <Badge color="gray" variant="light">{o.group}</Badge>}{selected.includes(o.value) && <Badge color="teal">{t("已选择")}</Badge>}</MantineGroup><Title order={4} size="xl">{o.label}</Title>
|
||||
{o.price !== undefined && <Text className="rich-product-price" fw={600}><Text component="span" size="xs" c="dimmed">{o.currency}</Text> {o.price.toLocaleString()}</Text>}
|
||||
{o.description && <Text c="dimmed" size="sm">{o.description}</Text>}
|
||||
<Stack gap="xs" style={{ flex: 1 }}>{o.facts?.map(f => <MantineGroup key={f.label} justify="space-between" align="flex-start" wrap="nowrap"><Text c="dimmed" size="sm">{f.label}</Text><Text size="sm" ta="right">{f.value}</Text></MantineGroup>)}</Stack>
|
||||
{o.preview && <Text size="sm">{o.preview}</Text>}
|
||||
<Button color="teal" variant={selected.includes(o.value) ? "light" : "filled"} disabled={disabled} aria-pressed={selected.includes(o.value)} aria-label={t("选择 {name}", { name: o.label })} data-action="surface.edit" data-target={node.key} onClick={() => select(o.value)}>{selected.includes(o.value) ? t("已选择") : t("选择")}</Button>
|
||||
</Stack>
|
||||
</Paper>)}</div>}
|
||||
</Workspace>;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useState } from "react";
|
||||
import { AreaChart, BarChart } from "@mantine/charts";
|
||||
import { Button, Group as MantineGroup, Paper, Stack, Text } from "@mantine/core";
|
||||
import type { SurfaceDataset } from "../../../port/wire";
|
||||
import { Workspace } from "./Workspace";
|
||||
import { t } from "../../../i18n";
|
||||
import "@mantine/charts/styles.layer.css";
|
||||
|
||||
export default function Dashboard({ dataset, label }: { dataset: SurfaceDataset; label?: string }) {
|
||||
const [seriesId, setSeriesId] = useState(dataset.series[0].id);
|
||||
const [recent, setRecent] = useState(false);
|
||||
const [bar, setBar] = useState(false);
|
||||
const [table, setTable] = useState(false);
|
||||
const series = dataset.series.find(s => s.id === seriesId) ?? dataset.series[0];
|
||||
const start = recent ? Math.floor(dataset.categories.length / 2) : 0;
|
||||
const values = series.values.slice(start);
|
||||
const total = values.reduce((a, b) => a + b, 0);
|
||||
const rows = dataset.categories.slice(start).map((category, i) => ({ category, metric: values[i] }));
|
||||
const format = (v: number) => v.toLocaleString(undefined, { maximumFractionDigits: 2 });
|
||||
const chart = { h: 320, data: rows, dataKey: "category", series: [{ name: "metric", label: series.label, color: "teal.6" }], valueFormatter: format, withLegend: false, withAnimation: false };
|
||||
return <Workspace title={label} badge={t("数据仪表盘")} subtitle={t("切换指标与范围,图表、汇总和明细同步更新")}>
|
||||
<MantineGroup justify="space-between"><MantineGroup gap="xs">{dataset.series.map(s => <Button key={s.id} size="xs" variant={s.id === series.id ? "light" : "subtle"} data-action="surface.explore" onClick={() => setSeriesId(s.id)}>{s.label}</Button>)}</MantineGroup><MantineGroup gap="xs"><Button size="xs" variant={!recent ? "light" : "subtle"} data-action="surface.explore" onClick={() => setRecent(false)}>{t("全部数据")}</Button><Button size="xs" variant={recent ? "light" : "subtle"} data-action="surface.explore" onClick={() => setRecent(true)}>{t("最近一半")}</Button></MantineGroup></MantineGroup>
|
||||
<div className="rich-metrics">{[{ label: t("合计"), value: total, test: "dashboard-total" }, { label: t("平均值"), value: total / values.length }, { label: t("最新值"), value: values[values.length - 1] }].map(k => <Paper key={k.label} className="rich-metric" p="lg" radius="md"><Stack gap={5}><Text size="sm" c="dimmed">{k.label}</Text><Text className="rich-metric-value" fw={600} data-testid={k.test}>{format(k.value)}</Text><Text size="xs" c="dimmed">{series.label}{series.unit ? ` · ${series.unit}` : ""}</Text></Stack></Paper>)}</div>
|
||||
<Paper p="md" radius="md" className="rich-chart"><Stack><MantineGroup justify="space-between"><Text fw={500}>{series.label}{series.unit ? ` · ${series.unit}` : ""}</Text><MantineGroup gap="xs"><Button size="xs" variant={!bar ? "light" : "subtle"} data-action="surface.explore" onClick={() => setBar(false)}>{t("折线图")}</Button><Button size="xs" variant={bar ? "light" : "subtle"} data-action="surface.explore" onClick={() => setBar(true)}>{t("柱状图")}</Button><Button size="xs" variant={table ? "light" : "subtle"} data-action="surface.explore" onClick={() => setTable(v => !v)}>{t("数据明细")}</Button></MantineGroup></MantineGroup>{bar ? <BarChart {...chart} /> : <AreaChart {...chart} curveType="linear" />}</Stack></Paper>
|
||||
{table && <div className="rich-table"><table aria-label={label}><thead><tr><th>{t("分类")}</th><th>{series.label}{series.unit ? ` (${series.unit})` : ""}</th></tr></thead><tbody>{rows.map(r => <tr key={r.category}><th>{r.category}</th><td>{format(r.metric)}</td></tr>)}</tbody></table></div>}
|
||||
</Workspace>;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { SurfaceKit } from "../SurfaceKit";
|
||||
import MapCanvas from "./MapCanvas";
|
||||
|
||||
const api = vi.hoisted(() => ({ fitBounds: vi.fn(), jumpTo: vi.fn() }));
|
||||
vi.mock("maplibre-gl", () => ({ setWorkerUrl: vi.fn() }));
|
||||
vi.mock("react-map-gl/maplibre", async () => {
|
||||
const React = await import("react");
|
||||
return {
|
||||
default: React.forwardRef(({ children, onLoad }: { children: React.ReactNode; onLoad: () => void }, ref) => {
|
||||
React.useImperativeHandle(ref, () => api);
|
||||
React.useEffect(onLoad, []);
|
||||
return <div>{children}</div>;
|
||||
}),
|
||||
Marker: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
Source: () => null, Layer: () => null, NavigationControl: () => null
|
||||
};
|
||||
});
|
||||
afterEach(() => { cleanup(); vi.clearAllMocks(); });
|
||||
|
||||
it("starts with the whole itinerary and restores it after explicit place focus", () => {
|
||||
const places = [{ value: "a", label: "Park", location: { latitude: 35, longitude: 139 } }, { value: "b", label: "Museum", location: { latitude: 36, longitude: 140 } }];
|
||||
const focus = vi.fn();
|
||||
const view = (version: number) => <SurfaceKit><MapCanvas places={places} order={["a", "b"]} active="b" focusVersion={version} focus={focus} /></SurfaceKit>;
|
||||
const mounted = render(view(0));
|
||||
expect(api.fitBounds).toHaveBeenCalledWith([[139, 35], [140, 36]], expect.objectContaining({ duration: 0 }));
|
||||
expect(api.jumpTo).not.toHaveBeenCalled();
|
||||
mounted.rerender(view(1));
|
||||
expect(api.jumpTo).toHaveBeenCalledWith({ center: [140, 36] });
|
||||
api.fitBounds.mockClear();
|
||||
fireEvent.click(screen.getByRole("button", { name: "查看全部地点" }));
|
||||
expect(api.fitBounds).toHaveBeenCalledWith([[139, 35], [140, 36]], expect.objectContaining({ maxZoom: 14 }));
|
||||
expect(focus).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Map, { Layer, Marker, NavigationControl, Source, type MapRef } from "react-map-gl/maplibre";
|
||||
import { Alert, Button } from "@mantine/core";
|
||||
import { setWorkerUrl, type StyleSpecification } from "maplibre-gl";
|
||||
import workerUrl from "maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url";
|
||||
import type { SurfaceOption } from "../../../port/wire";
|
||||
import { t } from "../../../i18n";
|
||||
import "maplibre-gl/dist/maplibre-gl.css";
|
||||
|
||||
setWorkerUrl(workerUrl);
|
||||
|
||||
const style: StyleSpecification = { version: 8, sources: { streets: { type: "raster", tiles: ["https://tile.openstreetmap.org/{z}/{x}/{y}.png"], tileSize: 256, maxzoom: 19, attribution: '© <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener noreferrer">OpenStreetMap</a> contributors' } }, layers: [{ id: "streets", type: "raster", source: "streets" }] };
|
||||
|
||||
export default function MapCanvas({ places, order, active, focusVersion, focus }: { places: SurfaceOption[]; order: string[]; active?: string; focusVersion: number; focus: (id: string) => void }) {
|
||||
const ref = useRef<MapRef>(null);
|
||||
const [error, setError] = useState(false);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const coordinates = places.map(p => [p.location!.longitude, p.location!.latitude] as [number, number]);
|
||||
const selected = order.flatMap(id => { const p = places.find(o => o.value === id); return p ? [[p.location!.longitude, p.location!.latitude]] : []; });
|
||||
const bounds = JSON.stringify(coordinates);
|
||||
const overview = () => {
|
||||
if (!ready || !ref.current || !coordinates.length) return;
|
||||
const xs = coordinates.map(p => p[0]), ys = coordinates.map(p => p[1]);
|
||||
ref.current.fitBounds([[Math.min(...xs), Math.min(...ys)], [Math.max(...xs), Math.max(...ys)]], { padding: 55, maxZoom: 14, duration: 0 });
|
||||
};
|
||||
useEffect(overview, [bounds, ready]);
|
||||
const point = places.find(p => p.value === active)?.location;
|
||||
useEffect(() => { if (point && ready && focusVersion > 0) ref.current?.jumpTo({ center: [point.longitude, point.latitude] }); }, [point?.longitude, point?.latitude, ready, focusVersion]);
|
||||
return <div className="rich-map" aria-label={t("地点地图")}>
|
||||
<Map key={attempt} ref={ref} initialViewState={{ longitude: coordinates[0]?.[0] ?? 0, latitude: coordinates[0]?.[1] ?? 0, zoom: 11 }} mapStyle={style} onLoad={() => { setReady(true); setError(false); }} onError={() => setError(true)} attributionControl={{ compact: false }}>
|
||||
<NavigationControl position="top-right" showCompass={false} />
|
||||
{selected.length > 1 && <Source id="visit-order" type="geojson" data={{ type: "Feature", properties: {}, geometry: { type: "LineString", coordinates: selected } }}><Layer id="visit-order-line" type="line" paint={{ "line-color": "#0d9488", "line-width": 3, "line-dasharray": [2, 2] }} /></Source>}
|
||||
{places.map(p => <Marker key={p.value} longitude={p.location!.longitude} latitude={p.location!.latitude} anchor="center"><Button className="rich-map-pin" radius="xl" color={active === p.value ? "dark" : "teal"} size="compact-sm" aria-label={p.label} data-action="surface.explore" onClick={() => focus(p.value)}>{order.includes(p.value) ? order.indexOf(p.value) + 1 : "+"}</Button></Marker>)}
|
||||
</Map>
|
||||
<Button className="rich-map-overview" size="xs" variant="default" disabled={!ready || !coordinates.length} data-action="surface.explore" onClick={overview}>{t("查看全部地点")}</Button>
|
||||
{error && <Alert className="rich-map-status" color="yellow" role="status">{t("部分地图内容加载失败,地点列表仍可用")} <Button size="xs" variant="subtle" data-action="surface.explore" onClick={() => { setReady(false); setError(false); setAttempt(v => v + 1); }}>{t("重试")}</Button></Alert>}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import { Badge, Button, Group as MantineGroup, Paper, Stack, Text } from "@mantine/core";
|
||||
import { Workspace, type RichInputProps } from "./Workspace";
|
||||
import { lazySurface } from "../a2ui/lazy";
|
||||
import { OptionImage } from "../media";
|
||||
import { t } from "../../../i18n";
|
||||
|
||||
const MapCanvas = lazySurface(() => import("./MapCanvas"));
|
||||
export default function MapPlanner({ node, value, disabled, change }: RichInputProps) {
|
||||
const places = node.field!.options!;
|
||||
const order = Array.isArray(value) ? value as string[] : [];
|
||||
const [active, setActive] = useState(places[0]?.value);
|
||||
const [focusVersion, setFocusVersion] = useState(0);
|
||||
const focus = (id: string) => { setActive(id); setFocusVersion(v => v + 1); };
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const activeRef = useRef<HTMLDivElement>(null);
|
||||
useLayoutEffect(() => {
|
||||
if (!focusVersion || !listRef.current || !activeRef.current) return;
|
||||
const list = listRef.current.getBoundingClientRect(), row = activeRef.current.getBoundingClientRect();
|
||||
const delta = row.top < list.top ? row.top - list.top : row.bottom > list.bottom ? row.bottom - list.bottom : 0;
|
||||
listRef.current.scrollTop += delta;
|
||||
}, [active, focusVersion]);
|
||||
const [group, setGroup] = useState("");
|
||||
const groups = [...new Set(places.map(p => p.group).filter((v): v is string => !!v))];
|
||||
const visible = [...order.flatMap(id => places.filter(p => p.value === id)), ...places.filter(p => !order.includes(p.value))].filter(p => !group || p.group === group);
|
||||
const move = (id: string, delta: number) => { const next = [...order]; const from = next.indexOf(id), to = from + delta; if (from < 0 || to < 0 || to >= next.length || disabled) return; [next[from], next[to]] = [next[to], next[from]]; change(next); };
|
||||
return <Workspace title={node.label} badge={t("地图与行程")} subtitle={t("选择地点并调整访问顺序,地图与行程同步显示")}>
|
||||
<MantineGroup gap="xs"><Button size="xs" variant={!group ? "light" : "subtle"} data-action="surface.explore" onClick={() => setGroup("")}>{t("全部地点")}</Button>{groups.map(g => <Button key={g} size="xs" variant={group === g ? "light" : "subtle"} data-action="surface.explore" onClick={() => setGroup(g)}>{g}</Button>)}<Badge variant="light" color="gray">{t("已选 {n} 个", { n: order.length })}</Badge></MantineGroup>
|
||||
<div className="rich-split">
|
||||
<Stack gap="xs"><MapCanvas places={visible} order={order} active={active} focusVersion={focusVersion} focus={focus} /><Text size="xs" c="dimmed">{t("虚线表示访问顺序,不代表道路导航路线")}</Text></Stack>
|
||||
<Stack ref={listRef} gap="sm" className="rich-map-list">
|
||||
{visible.map(p => { const at = order.indexOf(p.value), included = at >= 0; return <Paper ref={active === p.value ? activeRef : undefined} key={p.value} p="md" radius="md" className="rich-stop" data-active={active === p.value}>
|
||||
<Stack gap="sm"><MantineGroup justify="space-between" wrap="nowrap"><Badge circle color={included ? "teal" : "gray"} variant="light">{included ? at + 1 : "+"}</Badge><Button variant="subtle" size="sm" data-action="surface.explore" onClick={() => focus(p.value)}>{p.label}</Button>{p.group && <Text size="xs" c="dimmed">{p.group}</Text>}</MantineGroup>
|
||||
{active === p.value && <OptionImage image={p.image} />}
|
||||
{p.description && <Text size="sm">{p.description}</Text>}
|
||||
{active === p.value && p.facts?.map(f => <MantineGroup key={f.label} justify="space-between"><Text c="dimmed" size="xs">{f.label}</Text><Text size="xs">{f.value}</Text></MantineGroup>)}
|
||||
<MantineGroup gap="xs"><Button variant={included ? "light" : "filled"} color="teal" size="xs" disabled={disabled} data-action="surface.edit" data-target={node.key} onClick={() => change(included ? order.filter(id => id !== p.value) : [...order, p.value])}>{included ? t("移出行程") : t("加入行程")}</Button>
|
||||
{included && <><Button size="xs" variant="subtle" aria-label={t("上移 {name}", { name: p.label })} disabled={disabled || at === 0} data-action="surface.edit" data-target={node.key} onClick={() => move(p.value, -1)}>↑</Button><Button size="xs" variant="subtle" aria-label={t("下移 {name}", { name: p.label })} disabled={disabled || at === order.length - 1} data-action="surface.edit" data-target={node.key} onClick={() => move(p.value, 1)}>↓</Button></>}
|
||||
</MantineGroup></Stack>
|
||||
</Paper>; })}
|
||||
</Stack>
|
||||
</div>
|
||||
</Workspace>;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { Button, FocusTrap, Group as MantineGroup, Paper, Stack, Text, Title } from "@mantine/core";
|
||||
import { t } from "../../../i18n";
|
||||
import type { ExtensionViewNode } from "../../../port/wire";
|
||||
import "./rich.css";
|
||||
|
||||
export interface RichInputProps {
|
||||
node: ExtensionViewNode;
|
||||
value: unknown;
|
||||
disabled: boolean;
|
||||
change: (value: string | string[]) => void;
|
||||
}
|
||||
|
||||
export function Workspace({ title, subtitle, badge, children }: { title?: string; subtitle: string; badge: string; children: ReactNode }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
return <FocusTrap active={expanded}><section className={`rich-workspace${expanded ? " rich-expanded" : ""}`} role={expanded ? "dialog" : "region"} aria-modal={expanded || undefined} aria-label={title || badge} tabIndex={-1} data-action="surface.explore" onKeyDown={e => { if (e.key === "Escape" && expanded) { e.stopPropagation(); setExpanded(false); } }}>
|
||||
<Paper className="rich-workspace-body" radius="lg"><Stack gap="lg">
|
||||
<MantineGroup className="rich-heading" justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Stack gap={6}><Text className="rich-eyebrow" size="xs" fw={600} c="dimmed">{badge}</Text><Title order={3} className="rich-title">{title}</Title><Text c="dimmed" size="sm">{subtitle}</Text></Stack>
|
||||
<Button variant="subtle" size="xs" data-action="surface.explore" onClick={() => setExpanded(v => !v)}>{expanded ? t("收起工作区") : t("展开工作区")}</Button>
|
||||
</MantineGroup>
|
||||
{children}
|
||||
</Stack></Paper>
|
||||
</section></FocusTrap>;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
.rich-workspace { container-type: inline-size; min-width: 0; }
|
||||
.rich-expanded { position: fixed; inset: 20px; z-index: 1000; overflow: auto; background: var(--surface); }
|
||||
.rich-split { display: grid; gap: 20px; min-width: 0; }
|
||||
.rich-map { height: 400px; border-radius: var(--mantine-radius-md); overflow: hidden; position: relative; background: var(--mantine-color-default-hover); }
|
||||
.rich-map .maplibregl-map { font-family: var(--ui); }
|
||||
.rich-map-status { position: absolute; top: 12px; left: 12px; right: 52px; z-index: 1; }
|
||||
.rich-map-list { max-height: 430px; overflow: auto; padding: 2px; }
|
||||
.rich-stop[data-active="true"] { background: color-mix(in srgb, var(--mantine-color-teal-light) 30%, var(--mantine-color-body)); }
|
||||
.rich-map-pin { border: 2px solid white; box-shadow: 0 2px 8px #0003; }
|
||||
.rich-table { overflow: auto; max-height: 440px; }
|
||||
.rich-table table { width: 100%; min-width: 360px; border-collapse: collapse; font-size: 13px; }
|
||||
.rich-table th, .rich-table td { padding: 12px; text-align: start; border-bottom: 1px solid var(--mantine-color-default-border); overflow-wrap: anywhere; max-width: 320px; }
|
||||
.rich-table th { background: var(--mantine-color-default-hover); font-weight: 500; }
|
||||
.rich-chart { min-width: 0; }
|
||||
@container (min-width: 700px) { .rich-split { grid-template-columns: minmax(0, 1.3fr) minmax(260px, .8fr); } .rich-map { height: 490px; } .rich-map-list { max-height: 420px; } }
|
||||
@media (max-width: 600px) { .rich-expanded { inset: 6px; } .rich-map { height: 300px; } }
|
||||
|
||||
.rich-workspace-body { padding: 8px 0; background: transparent; }
|
||||
.rich-heading { padding-bottom: 8px; }
|
||||
.rich-eyebrow { letter-spacing: .08em; }
|
||||
.rich-title { font-size: clamp(20px, 3cqi, 28px); font-weight: 600; letter-spacing: -.025em; line-height: 1.3; }
|
||||
.rich-expanded { border: 1px solid var(--mantine-color-default-border); border-radius: 16px; box-shadow: 0 20px 80px #0003; padding: 28px; }
|
||||
.rich-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; background: var(--mantine-color-default-hover); border-radius: 12px; }
|
||||
.rich-metric { background: transparent; }
|
||||
.rich-metric-value { font-size: clamp(20px, 4cqi, 36px); letter-spacing: -.04em; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
|
||||
.rich-chart { padding-inline: 0; background: transparent; }
|
||||
.rich-products { display: grid; grid-template-columns: minmax(0, 1fr); gap: 16px; }
|
||||
.rich-product { border: 1px solid var(--mantine-color-default-border); background: var(--mantine-color-body); }
|
||||
.rich-product[data-selected="true"] { border-color: var(--mantine-color-teal-6); background: color-mix(in srgb, var(--mantine-color-teal-light) 30%, var(--mantine-color-body)); }
|
||||
.rich-product-price { font-size: 30px; letter-spacing: -.035em; font-variant-numeric: tabular-nums; }
|
||||
.rich-stop { background: transparent; border-bottom: 1px solid var(--mantine-color-default-border); }
|
||||
@container (min-width: 540px) { .rich-products { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
@container (min-width: 980px) { .rich-products { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
|
||||
@container (max-width: 420px) { .rich-metrics { gap: 0; } .rich-metric { padding: 10px; } }
|
||||
|
||||
.rich-table thead th { position: sticky; top: 0; z-index: 1; background: var(--mantine-color-body); }
|
||||
.rich-table tbody th { position: sticky; left: 0; background: var(--mantine-color-body); }
|
||||
.rich-table tr[data-different="true"] td { background: color-mix(in srgb, var(--mantine-color-teal-light) 16%, transparent); }
|
||||
.rich-product .mantine-Title-root { overflow-wrap: anywhere; }
|
||||
.rich-heading > :first-child { min-width: 0; }
|
||||
.rich-heading button { flex-shrink: 0; }
|
||||
|
||||
.rich-map-overview { position: absolute; left: 12px; bottom: 28px; z-index: 1; box-shadow: 0 2px 8px #0002; }
|
||||
|
||||
.rich-option-media { aspect-ratio: 4 / 3; overflow: hidden; border-radius: 10px; background: var(--mantine-color-default-hover); display: grid; place-items: center; color: var(--mantine-color-dimmed); font-size: 12px; }
|
||||
.rich-option-media img { width: 100%; height: 100%; display: block; min-height: 0; }
|
||||
|
||||
.rich-option-source { display: block; margin-top: 6px; color: var(--mantine-color-dimmed); font-size: 11px; overflow-wrap: anywhere; }
|
||||
|
||||
.rich-photo-trigger { width: 100%; cursor: zoom-in; }
|
||||
.rich-photo-viewer { aspect-ratio: auto; height: min(65vh, 720px); }
|
||||
@@ -1,3 +1,4 @@
|
||||
import { richOption, richDataset } from "./rich-spec";
|
||||
import type { ExtensionViewNode, SurfaceField } from "../../port/wire";
|
||||
|
||||
export interface SurfaceSpec {
|
||||
@@ -36,7 +37,8 @@ function field(raw: unknown): raw is SurfaceField {
|
||||
if (options && options.length > 16) return false;
|
||||
const seen = new Set<string>();
|
||||
for (const o of options ?? []) {
|
||||
if (!object(o) || !keys(o, ["value", "label", "description", "preview"]) || !id(o.value) || seen.has(o.value) || !text(o.label, 200, true) || !text(o.description, 4000) || !text(o.preview, 4000)) return false;
|
||||
if (!object(o) || !keys(o, ["value", "label", "description", "preview", "location", "group", "facts", "price", "currency", "image"]) || !id(o.value) || seen.has(o.value) || !text(o.label, 200, true) || !text(o.description, 4000) || !text(o.preview, 4000)) return false;
|
||||
if (!richOption(o)) return false;
|
||||
seen.add(o.value);
|
||||
}
|
||||
return raw.default === undefined || validSurfaceValue(raw as unknown as SurfaceField, raw.default);
|
||||
@@ -66,10 +68,14 @@ export function parseSurface(raw: unknown): SurfaceSpec | null {
|
||||
case "diff_preview":
|
||||
if (!object(v.diff) || !keys(v.diff, ["before", "after"]) || typeof v.diff.before !== "string" || typeof v.diff.after !== "string" || !text(v.diff.before, 4000) || !text(v.diff.after, 4000)) return false;
|
||||
allowed = ["label", "diff"]; break;
|
||||
case "field": case "choice_card": case "editable_text":
|
||||
case "dashboard":
|
||||
if (!richDataset(v.dataset)) return false; allowed = ["label", "dataset"]; break;
|
||||
case "field": case "choice_card": case "editable_text": case "map": case "comparison":
|
||||
if (!id(v.key) || fields.has(v.key) || fields.size >= 16 || !text(v.label, 200, true) || !field(v.field)) return false;
|
||||
if (v.kind === "choice_card" && v.field.type !== "choice" && v.field.type !== "multichoice") return false;
|
||||
if (v.kind === "editable_text" && v.field.type !== "string") return false;
|
||||
if (v.kind === "map" && (v.field.type !== "multichoice" || !v.field.options?.every(o => o.location))) return false;
|
||||
if (v.kind === "comparison" && v.field.type !== "choice" && v.field.type !== "multichoice") return false;
|
||||
fields.add(v.key); allowed = ["key", "label", "field"]; break;
|
||||
default: return false;
|
||||
}
|
||||
|
||||
@@ -122,3 +122,9 @@ it("keeps published action surfaces outside work disclosures",()=>{
|
||||
const item: Item={t:"tool",id:"surface",running:false,children:[],tool:{id:"call",name:"render_surface",readOnly:true,args:JSON.stringify({spec_version:1,body:[{kind:"button",label:"Continue",actionId:"continue"}]}),output:'{"nonblocking":true}'}};
|
||||
expect(transcriptRows([item])).toEqual([{item}]);
|
||||
});
|
||||
it("keeps a completed dashboard visible and leaves failed publications as tools", () => {
|
||||
const item: Item = { t: "tool", id: "dashboard", running: false, children: [], tool: { name: "render_surface", readOnly: true, args: JSON.stringify({ spec_version: 1, body: [{ kind: "dashboard", dataset: { categories: ["Jan"], series: [{ id: "sales", label: "Sales", values: [10] }] } } ] }), output: "Displayed" } };
|
||||
expect(transcriptRows([item])).toEqual([{ item }]);
|
||||
const failed = { ...item, tool: { ...item.tool, err: "refused" } };
|
||||
expect(transcriptRows([failed])).toEqual([{ activity: [failed] }]);
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { actionSurfaceOfTool } from "./surface/call";
|
||||
import { visibleSurfaceOfTool } from "./surface/call";
|
||||
import type { Item } from "../state/session";
|
||||
import { chartOfTool } from "./chart/spec";
|
||||
|
||||
@@ -51,7 +51,7 @@ export function transcriptRows(all: Item[], foldWork?: { running: boolean }): Sh
|
||||
while (end < items.length && items[end].t !== "user") end++;
|
||||
const turn: Row[] = [];
|
||||
for (const item of items.slice(at, end)) {
|
||||
const work = (item.t === "tool" && !chartOfTool(item.tool) && !actionSurfaceOfTool(item.tool)) || item.t === "reads";
|
||||
const work = (item.t === "tool" && !chartOfTool(item.tool) && !visibleSurfaceOfTool(item.tool)) || item.t === "reads";
|
||||
const last = turn[turn.length - 1];
|
||||
if (work && last && "activity" in last && !("item" in last)) last.activity.push(item);
|
||||
else turn.push(work ? { activity: [item] } : { item });
|
||||
|
||||
@@ -126,3 +126,112 @@ The input boundary review covers the pending-ask map, legacy answer wrappers, HT
|
||||
The security review covers closed component kinds, bounded decoding, duplicate keys, field ids, required values and choice membership. Display strings and string defaults are redacted before native rendering. Approval policy, permission grants, device authentication and session storage ownership are unchanged.
|
||||
|
||||
Real boot effect tests prove hidden discovery, typed refusal/retry, pending replay and persisted resume. Adjacent tests cover ordinary asks, extension actions, recovery, frontend acknowledgment, local drafts and the proxy decision barrier. Full-suite baseline failures are reported separately from these targeted checks.
|
||||
|
||||
|
||||
## Rich desktop workspaces
|
||||
|
||||
The product scope includes all three task workspaces below, following the owner's
|
||||
request to deliver rich task interfaces. This extends the initial form-oriented
|
||||
scope without waiting for the input-count study. It does not establish a measured
|
||||
benefit, implement arbitrary generated pages, or add a TUI renderer.
|
||||
|
||||
- `map`: linked place markers and an ordered itinerary, group filtering, focus,
|
||||
add/remove and reorder. Answers contain option ids in visit order. Groups are
|
||||
authored labels; dashed connections show visit order, not road navigation.
|
||||
- `comparison`: searchable and grouped candidate cards, a property comparison
|
||||
table with differing-property emphasis, optional prices, sorting and a retained
|
||||
shortlist. Answers use the
|
||||
existing choice/multichoice contract. Optional images reference existing workspace-relative raster files.
|
||||
- `dashboard`: bounded category/series data, metric and range selection, summaries,
|
||||
area/bar charts and a detail table. Exploration is local and sends no answer.
|
||||
Successful display-only dashboards remain visible in the conversation.
|
||||
|
||||
Mantine supplies controls and charts; MapLibre with react-map-gl supplies the map.
|
||||
These renderers load lazily. MapLibre's worker is bundled through Vite's worker
|
||||
URL import.
|
||||
|
||||
The host fixes the OpenStreetMap tile URL and attribution; the model
|
||||
cannot supply resource URLs or executable markup. A tile failure leaves the list
|
||||
usable and offers a retry. Network availability still governs the basemap.
|
||||
|
||||
The component container determines card columns. Workspaces use one heading and
|
||||
content hierarchy, borderless map rows and a shared metric strip rather than
|
||||
repeated framed panels. Expansion preserves the component and selection state;
|
||||
settled replay disables answer changes while retaining local exploration.
|
||||
|
||||
Run `pnpm exec vite --host 127.0.0.1` from `desktop/frontend-next`, then open
|
||||
`/perf/rich-ui.html?scenario=map&theme=light`. The `comparison` and `dashboard`
|
||||
scenarios and `theme=dark` use the production renderer with clearly labelled
|
||||
sample data and a local acknowledgment.
|
||||
|
||||
They are development acceptance fixtures,
|
||||
not provider evaluations.
|
||||
|
||||
Validate both conversation-sized and expanded layouts.
|
||||
|
||||
|
||||
### Workspace media
|
||||
|
||||
Map and comparison options accept `image: {path, alt, source?}`. Paths are bounded,
|
||||
workspace-relative raster references; URLs, traversal, absolute paths, SVG and
|
||||
query strings are refused by both contract validators. The hidden schema names
|
||||
this vocabulary without changing the provider prefix.
|
||||
|
||||
The owning transcript passes its image port to both blocking and nonblocking
|
||||
surfaces. Images use the existing workspace image endpoint, including its rooted
|
||||
file access, size cap and content restrictions. No new frontend file or network endpoint
|
||||
is introduced.
|
||||
|
||||
Descriptions and source links are redacted with other display text.
|
||||
|
||||
Product images use a fixed aspect ratio with contain fit; the focused place uses
|
||||
cover fit. Failed images retain the card and its selection. Changing workspace
|
||||
endpoints resets a failed image. Missing images keep the text layout usable.
|
||||
|
||||
|
||||
### Acquiring real photos
|
||||
|
||||
After researching a product or place through the configured search capability,
|
||||
`web_fetch` exposes up to 32 image candidates from static page metadata (`og:image`,
|
||||
`twitter:image`) and image tags (`src` and lazy `data-src`, independently).
|
||||
|
||||
Candidate URLs
|
||||
resolve against the first declared HTTP(S) base URL, or the final page URL.
|
||||
|
||||
This is page extraction, not a separate
|
||||
image-search service; it does not execute scripts or bypass login or anti-hotlinking.
|
||||
|
||||
The hidden `import_web_image` tool accepts the researched page `url`, selected
|
||||
`image_url`, a new relative `path`, and `alt`.
|
||||
|
||||
It fetches the page again and requires
|
||||
the chosen URL to occur among its candidates. Both requests reuse the existing
|
||||
proxy-aware SSRF-guarded transport.
|
||||
|
||||
Page bodies are limited to 1 MiB; image bodies
|
||||
to 8 MiB and decoded dimensions to 40 million pixels. PNG, JPEG, GIF and WebP
|
||||
are decoded and must match the destination extension.
|
||||
|
||||
AVIF can still be displayed
|
||||
from an existing workspace file but is not imported by this decoder.
|
||||
|
||||
Import is a declared write with named-path confinement, session-store protection,
|
||||
rooted file access, exclusive creation and a recovery receipt. Existing files are
|
||||
not overwritten.
|
||||
|
||||
It retains the normal approval and post-write verification gates.
|
||||
|
||||
The result supplies the renderable image object, final source and image URLs,
|
||||
dimensions and SHA-256; the tool trajectory preserves this retrieval evidence.
|
||||
|
||||
Cards display the optional HTTP(S) source-page link without loading remote media
|
||||
in the renderer. Source links are attribution supplied with the spec, not a
|
||||
host-certified identity or licensing badge.
|
||||
|
||||
Page membership alone cannot prove
|
||||
that a photo depicts the requested entity: the agent must inspect the candidates
|
||||
and respect the source's reuse terms.
|
||||
|
||||
Missing, unsupported or failed acquisition
|
||||
leaves text cards usable; generated fixture illustrations must never substitute
|
||||
for photos of real entities.
|
||||
@@ -465,6 +465,7 @@ func addBuiltins(reg *tool.Registry, enabled, writeRoots []string, bashSpec sand
|
||||
writers[i] = builtin.BindFileWriteReceipt(writer, fileWriteReceipt)
|
||||
}
|
||||
confined := append(writers,
|
||||
builtin.ConfineWebImage(writeRoots, sessionGuard, proxySpec, fileWriteReceipt),
|
||||
bashTool,
|
||||
searchTool,
|
||||
builtin.ConfineWebFetch(proxySpec))
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
package boot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"image"
|
||||
"image/png"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reasonix/internal/runtime/agent"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/state/sessionstore"
|
||||
)
|
||||
|
||||
func TestEffectRichSurfaceChoicesReachProviderAndReplay(t *testing.T) {
|
||||
isolateConfigHome(t)
|
||||
dir := robustTempDir(t)
|
||||
t.Chdir(dir)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/camera.png" {
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
_ = png.Encode(w, image.NewRGBA(image.Rect(0, 0, 2, 2)))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = w.Write([]byte(`<base href="/photos/"><img src="data:image/gif;base64,AA" data-src="../camera.png" alt="Camera front">`))
|
||||
}))
|
||||
defer server.Close()
|
||||
spec := `{"spec_version":1,"body":[{"kind":"map","key":"trip","label":"Trip","field":{"type":"multichoice","required":true,"options":[{"value":"park","label":"Park","location":{"latitude":35,"longitude":139}},{"value":"museum","label":"Museum","location":{"latitude":36,"longitude":140}}]}},{"kind":"comparison","key":"camera","label":"Camera","field":{"type":"choice","options":[{"value":"a","label":"Alpha","image":{"path":"images/camera.png","alt":"Camera front"},"price":100,"currency":"USD","facts":[{"label":"Weight","value":"300g"}]}]}},{"kind":"dashboard","label":"Visits","dataset":{"categories":["Mon","Tue"],"series":[{"id":"visits","label":"Visits","values":[4,8]}]}}]}`
|
||||
spec = strings.Replace(spec, `"alt":"Camera front"`, `"alt":"Camera front","source":"`+server.URL+`"`, 1)
|
||||
importArgs, _ := json.Marshal(map[string]string{"url": server.URL, "image_url": server.URL + "/camera.png", "path": "images/camera.png", "alt": "Camera front"})
|
||||
probe := &capabilityProbeProvider{calls: []string{
|
||||
`{"action":"inspect","capability_id":"tool:import_web_image"}`,
|
||||
chartCall(t, "tool:import_web_image", string(importArgs)),
|
||||
chartCall(t, "tool:render_surface", spec)}}
|
||||
prompts := make(chan event.Ask, 1)
|
||||
ctrl := buildChartAssembly(t, "rich-surface-effect", probe, dir, event.FuncSink(func(e event.Event) {
|
||||
if e.Kind == event.AskRequest {
|
||||
prompts <- e.Ask
|
||||
}
|
||||
}))
|
||||
t.Cleanup(ctrl.Close)
|
||||
ctrl.EnableInteractiveApproval()
|
||||
ctrl.SetAutoApproveTools(true)
|
||||
path := filepath.Join(dir, ".reasonix", "sessions", "rich.jsonl")
|
||||
ctrl.SetSessionPath(path)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- ctrl.Run(ctx, "plan the trip and compare cameras") }()
|
||||
const answer = `{"trip":["museum","park"],"camera":"a"}`
|
||||
select {
|
||||
case ask := <-prompts:
|
||||
if _, err := os.Stat(filepath.Join(dir, "images/camera.png")); err != nil {
|
||||
t.Fatalf("image not acquired before publication: %v; results: %v", err, probe.toolResults())
|
||||
}
|
||||
body := ask.Questions[0].Surface.Body
|
||||
if body[1].Field.Options[0].Image.Source != server.URL {
|
||||
t.Fatal("image source missing from frontend")
|
||||
}
|
||||
|
||||
if body[0].Field.Options[0].Location == nil || body[1].Field.Options[0].Facts[0].Value != "300g" || body[1].Field.Options[0].Image == nil || body[1].Field.Options[0].Image.Path != "images/camera.png" || body[2].Dataset.Series[0].Values[1] != 8 {
|
||||
t.Fatal("rich data did not reach frontend")
|
||||
}
|
||||
if err := ctrl.AnswerQuestion(ask.ID, []event.AskAnswer{{QuestionID: "surface", Value: json.RawMessage(answer)}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case err := <-done:
|
||||
t.Fatalf("ended without surface: %v", err)
|
||||
case <-ctx.Done():
|
||||
t.Fatal(ctx.Err())
|
||||
}
|
||||
select {
|
||||
case err := <-done:
|
||||
var unready *agent.FinalReadinessError
|
||||
if !errors.As(err, &unready) || !slices.Contains(unready.Missing, "verification") {
|
||||
t.Fatalf("image write must retain verification debt: %v", err)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal(ctx.Err())
|
||||
}
|
||||
assertRichAnswer(t, probe.toolResults())
|
||||
ctrl.Close()
|
||||
loaded, err := sessionstore.LoadSession(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var results []string
|
||||
for _, m := range loaded.Snapshot() {
|
||||
if m.Role == provider.RoleTool {
|
||||
results = append(results, m.Content)
|
||||
}
|
||||
}
|
||||
assertRichAnswer(t, results)
|
||||
foundImage := false
|
||||
for _, result := range results {
|
||||
if strings.Contains(result, `"sha256"`) && strings.Contains(result, server.URL) {
|
||||
foundImage = true
|
||||
}
|
||||
}
|
||||
if !foundImage {
|
||||
t.Fatal("image retrieval provenance absent from replay")
|
||||
}
|
||||
probe.mu.Lock()
|
||||
defer probe.mu.Unlock()
|
||||
for _, schema := range probe.reqs[0].Tools {
|
||||
if schema.Name == "import_web_image" {
|
||||
t.Fatal("image tool leaked into provider prefix")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func assertRichAnswer(t *testing.T, results []string) {
|
||||
t.Helper()
|
||||
for _, result := range results {
|
||||
if strings.Contains(result, `"trip":["museum","park"]`) && strings.Contains(result, `"camera":"a"`) {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("ordered itinerary and chosen product absent")
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"SystemHash": "bc08dbc97c837bba",
|
||||
"ToolsHash": "3925f9e5204c89a8",
|
||||
"PrefixHash": "3fa0fd47e7b19113",
|
||||
"ToolsHash": "6f94c683e8b644ff",
|
||||
"PrefixHash": "fc040f3fa379ff06",
|
||||
"LogRewriteVersion": 0,
|
||||
"ToolSchemaTokens": 5572
|
||||
"ToolSchemaTokens": 5524
|
||||
}
|
||||
@@ -729,15 +729,13 @@
|
||||
},
|
||||
{
|
||||
"name": "write_file",
|
||||
"description": "Write content to a file at the given path (overwriting existing content). Creates parent directories as needed. Sends the whole file every time: for a change to part of one that already exists, edit_file and multi_edit send only the changed region.",
|
||||
"description": "Write full file content, overwriting and creating parent directories. Prefer edit_file/multi_edit for partial changes.",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"content": {
|
||||
"description": "Full content to write",
|
||||
"type": "string"
|
||||
},
|
||||
"path": {
|
||||
"description": "File path",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -717,16 +717,14 @@
|
||||
},
|
||||
{
|
||||
"Name": "write_file",
|
||||
"Description": "Write content to a file at the given path (overwriting existing content). Creates parent directories as needed. Sends the whole file every time: for a change to part of one that already exists, edit_file and multi_edit send only the changed region.",
|
||||
"Description": "Write full file content, overwriting and creating parent directories. Prefer edit_file/multi_edit for partial changes.",
|
||||
"ReadOnly": false,
|
||||
"Schema": {
|
||||
"properties": {
|
||||
"content": {
|
||||
"description": "Full content to write",
|
||||
"type": "string"
|
||||
},
|
||||
"path": {
|
||||
"description": "File path",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -36,16 +36,17 @@ type ExtensionView struct {
|
||||
|
||||
// ExtensionViewNode is one primitive of a view.
|
||||
type ExtensionViewNode struct {
|
||||
Kind string `json:"kind"`
|
||||
Value string `json:"value,omitempty" externalizable:"true"`
|
||||
Key string `json:"key,omitempty"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Tone string `json:"tone,omitempty"`
|
||||
Progress *float64 `json:"progress,omitempty"`
|
||||
ActionID string `json:"actionId,omitempty"`
|
||||
Children []ExtensionViewNode `json:"children,omitempty"`
|
||||
Field *surfacespec.Field `json:"field,omitempty"`
|
||||
Diff *surfacespec.Diff `json:"diff,omitempty"`
|
||||
Kind string `json:"kind"`
|
||||
Value string `json:"value,omitempty" externalizable:"true"`
|
||||
Key string `json:"key,omitempty"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Tone string `json:"tone,omitempty"`
|
||||
Progress *float64 `json:"progress,omitempty"`
|
||||
ActionID string `json:"actionId,omitempty"`
|
||||
Children []ExtensionViewNode `json:"children,omitempty"`
|
||||
Field *surfacespec.Field `json:"field,omitempty"`
|
||||
Diff *surfacespec.Diff `json:"diff,omitempty"`
|
||||
Dataset *surfacespec.Dataset `json:"dataset,omitempty"`
|
||||
}
|
||||
|
||||
// ExtensionPanel is the JSON form of an event.ExtensionPanelView.
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package surfacespec
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Image struct {
|
||||
Path string `json:"path"`
|
||||
Alt string `json:"alt"`
|
||||
Source string `json:"source,omitempty"`
|
||||
}
|
||||
|
||||
func validImage(i *Image) bool {
|
||||
if i == nil {
|
||||
return true
|
||||
}
|
||||
if i.Source != "" {
|
||||
u, err := url.Parse(i.Source)
|
||||
if err != nil || len(i.Source) > 4096 || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil || strings.ContainsAny(i.Source, "\\\r\n\t") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if i.Path == "" || len(i.Path) > 1024 || i.Alt == "" || len(i.Alt) > MaxLabel || strings.ContainsAny(i.Path, ":\\?#%\x00\r\n\t") {
|
||||
return false
|
||||
}
|
||||
for segment := range strings.SplitSeq(i.Path, "/") {
|
||||
if segment == "" || segment == "." || segment == ".." {
|
||||
return false
|
||||
}
|
||||
}
|
||||
switch strings.ToLower(path.Ext(i.Path)) {
|
||||
case ".png", ".jpg", ".jpeg", ".webp", ".avif", ".gif":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,15 @@ func (s *Spec) Redact(redact func(string) string) {
|
||||
n := &nodes[i]
|
||||
n.Value = redact(n.Value)
|
||||
n.Label = redact(n.Label)
|
||||
if n.Dataset != nil {
|
||||
for j := range n.Dataset.Categories {
|
||||
n.Dataset.Categories[j] = redact(n.Dataset.Categories[j])
|
||||
}
|
||||
for j := range n.Dataset.Series {
|
||||
n.Dataset.Series[j].Label = redact(n.Dataset.Series[j].Label)
|
||||
n.Dataset.Series[j].Unit = redact(n.Dataset.Series[j].Unit)
|
||||
}
|
||||
}
|
||||
if n.Diff != nil {
|
||||
n.Diff.Before = redact(n.Diff.Before)
|
||||
n.Diff.After = redact(n.Diff.After)
|
||||
@@ -23,6 +32,16 @@ func (s *Spec) Redact(redact func(string) string) {
|
||||
o.Label = redact(o.Label)
|
||||
o.Description = redact(o.Description)
|
||||
o.Preview = redact(o.Preview)
|
||||
o.Group = redact(o.Group)
|
||||
o.Currency = redact(o.Currency)
|
||||
if o.Image != nil {
|
||||
o.Image.Alt = redact(o.Image.Alt)
|
||||
o.Image.Source = redact(o.Image.Source)
|
||||
}
|
||||
for k := range o.Facts {
|
||||
o.Facts[k].Label = redact(o.Facts[k].Label)
|
||||
o.Facts[k].Value = redact(o.Facts[k].Value)
|
||||
}
|
||||
}
|
||||
if n.Field.Type == "string" && len(n.Field.Default) != 0 {
|
||||
var value string
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package surfacespec
|
||||
|
||||
type Location struct {
|
||||
Latitude *float64 `json:"latitude"`
|
||||
Longitude *float64 `json:"longitude"`
|
||||
}
|
||||
|
||||
type Fact struct {
|
||||
Label string `json:"label"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type Dataset struct {
|
||||
Categories []string `json:"categories"`
|
||||
Series []Series `json:"series"`
|
||||
}
|
||||
|
||||
type Series struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Unit string `json:"unit,omitempty"`
|
||||
Values []float64 `json:"values"`
|
||||
}
|
||||
|
||||
func validateRichOption(o Option, path string) error {
|
||||
if !validImage(o.Image) {
|
||||
return fail(CodeValueInvalid, path+"/image")
|
||||
}
|
||||
if len(o.Group) > MaxLabel || len(o.Currency) > 8 || len(o.Facts) > 12 {
|
||||
return fail(CodeLimitExceeded, path)
|
||||
}
|
||||
if o.Price != nil && (!finite(*o.Price) || *o.Price < 0 || o.Currency == "") {
|
||||
return fail(CodeValueInvalid, path+"/price")
|
||||
}
|
||||
if o.Location != nil {
|
||||
p := o.Location
|
||||
if p.Latitude == nil || p.Longitude == nil || !finite(*p.Latitude) || !finite(*p.Longitude) || *p.Latitude < -85 || *p.Latitude > 85 || *p.Longitude < -180 || *p.Longitude > 180 {
|
||||
return fail(CodeValueInvalid, path+"/location")
|
||||
}
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, f := range o.Facts {
|
||||
if f.Label == "" || seen[f.Label] || len(f.Label) > MaxLabel || len(f.Value) > MaxText {
|
||||
return fail(CodeSchemaInvalid, path+"/facts")
|
||||
}
|
||||
seen[f.Label] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRichInput(n Node, path string) error {
|
||||
if n.Kind == "map" {
|
||||
if n.Field.Type != "multichoice" {
|
||||
return fail(CodeSchemaInvalid, path+"/field/type")
|
||||
}
|
||||
for _, o := range n.Field.Options {
|
||||
if o.Location == nil {
|
||||
return fail(CodeSchemaInvalid, path+"/field/options")
|
||||
}
|
||||
}
|
||||
}
|
||||
if n.Kind == "comparison" && n.Field.Type != "choice" && n.Field.Type != "multichoice" {
|
||||
return fail(CodeSchemaInvalid, path+"/field/type")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDataset(d *Dataset, path string) error {
|
||||
if d == nil || len(d.Categories) == 0 || len(d.Categories) > 64 || len(d.Series) == 0 || len(d.Series) > 8 {
|
||||
return fail(CodeSchemaInvalid, path)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, c := range d.Categories {
|
||||
if c == "" || len(c) > MaxLabel || seen[c] {
|
||||
return fail(CodeSchemaInvalid, path+"/categories")
|
||||
}
|
||||
seen[c] = true
|
||||
}
|
||||
seen = map[string]bool{}
|
||||
for _, s := range d.Series {
|
||||
if !identifier(s.ID) || seen[s.ID] || s.Label == "" || len(s.Label) > MaxLabel || len(s.Unit) > MaxLabel || len(s.Values) != len(d.Categories) {
|
||||
return fail(CodeSchemaInvalid, path+"/series")
|
||||
}
|
||||
seen[s.ID] = true
|
||||
for _, v := range s.Values {
|
||||
if !finite(v) {
|
||||
return fail(CodeValueInvalid, path+"/series/values")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package surfacespec
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRichTaskSurfaces(t *testing.T) {
|
||||
const raw = `{"spec_version":1,"body":[{"kind":"map","key":"stops","label":"Trip","field":{"type":"multichoice","required":true,"options":[{"value":"a","label":"Museum","group":"Day 1","location":{"latitude":35.7,"longitude":139.7}},{"value":"b","label":"Park","location":{"latitude":35.8,"longitude":139.8}}]}},{"kind":"comparison","key":"product","label":"Compare","field":{"type":"choice","options":[{"value":"p","label":"Camera","price":400,"currency":"USD","facts":[{"label":"Weight","value":"300 g"}]}]}},{"kind":"dashboard","label":"Revenue","dataset":{"categories":["Jan","Feb"],"series":[{"id":"revenue","label":"Revenue","unit":"USD","values":[10,20]}]}}]}`
|
||||
s, err := Parse([]byte(raw))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.ParseAnswers([]byte(`{"stops":["b","a"],"product":"p"}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, bad := range []string{
|
||||
strings.Replace(raw, `35.7`, `91`, 1),
|
||||
strings.Replace(raw, `139.7`, `181`, 1),
|
||||
strings.Replace(raw, `"latitude":35.7,`, ``, 1),
|
||||
strings.Replace(raw, `"longitude":139.7`, `"longitude":139.7,"url":"https://invalid.test"`, 1),
|
||||
strings.Replace(raw, `"values":[10,20]`, `"values":[10]`, 1),
|
||||
strings.Replace(raw, `"price":400`, `"price":-1`, 1),
|
||||
strings.Replace(raw, `"unit":"USD"`, `"unit":"USD","fetch":"https://invalid.test"`, 1),
|
||||
} {
|
||||
if _, err := Parse([]byte(bad)); err == nil {
|
||||
t.Fatalf("accepted invalid rich spec: %s", bad)
|
||||
}
|
||||
}
|
||||
s.Redact(func(v string) string { return strings.ReplaceAll(v, "Revenue", "hidden") })
|
||||
canonical, _ := s.Canonical()
|
||||
if strings.Contains(string(canonical), "Revenue") {
|
||||
t.Fatal("dataset labels escaped redaction")
|
||||
}
|
||||
if _, err := Parse(canonical); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRichImageReferences(t *testing.T) {
|
||||
const raw = `{"spec_version":1,"body":[{"kind":"comparison","key":"product","label":"Compare","field":{"type":"choice","options":[{"value":"a","label":"Camera","image":{"path":"images/camera.png","alt":"Camera front"}}]}}]}`
|
||||
s, err := Parse([]byte(raw))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.Redact(func(v string) string { return strings.ReplaceAll(v, "Camera front", "Product photo") })
|
||||
b, _ := s.Canonical()
|
||||
if !strings.Contains(string(b), "Product photo") {
|
||||
t.Fatal("image description escaped redaction")
|
||||
}
|
||||
for _, invalid := range []string{"../camera.png", "/camera.png", "https://example.com/a.png", "C:/camera.png", "images/../a.png", "images//a.png", "images/a.svg", "images/a.png?x=1", "images/%2e%2e/a.png"} {
|
||||
if _, err := Parse([]byte(strings.Replace(raw, "images/camera.png", invalid, 1))); err == nil {
|
||||
t.Fatalf("accepted %s", invalid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageSource(t *testing.T) {
|
||||
raw := `{"spec_version":1,"body":[{"kind":"comparison","key":"product","label":"Compare","field":{"type":"choice","options":[{"value":"a","label":"Camera","image":{"path":"images/camera.png","alt":"Camera front","source":"https://example.com/product"}}]}}]}`
|
||||
if _, err := Parse([]byte(raw)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, source := range []string{"javascript:alert(1)", "file:///tmp/a", "//example.com/a", "https://user:[email protected]/a"} {
|
||||
if _, err := Parse([]byte(strings.Replace(raw, "https://example.com/product", source, 1))); err == nil {
|
||||
t.Fatalf("accepted %s", source)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ type Node struct {
|
||||
Children []Node `json:"children,omitempty"`
|
||||
Field *Field `json:"field,omitempty"`
|
||||
Diff *Diff `json:"diff,omitempty"`
|
||||
Dataset *Dataset `json:"dataset,omitempty"`
|
||||
}
|
||||
|
||||
type Diff struct {
|
||||
@@ -48,10 +49,16 @@ type Field struct {
|
||||
}
|
||||
|
||||
type Option struct {
|
||||
Value string `json:"value"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Preview string `json:"preview,omitempty"`
|
||||
Value string `json:"value"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Preview string `json:"preview,omitempty"`
|
||||
Group string `json:"group,omitempty"`
|
||||
Location *Location `json:"location,omitempty"`
|
||||
Price *float64 `json:"price,omitempty"`
|
||||
Currency string `json:"currency,omitempty"`
|
||||
Facts []Fact `json:"facts,omitempty"`
|
||||
Image *Image `json:"image,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Spec) Canonical() ([]byte, error) { return json.Marshal(s) }
|
||||
@@ -42,7 +42,7 @@ func validateNode(n Node, path string, depth int, budget *int, fields map[string
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
present := map[string]bool{"value": n.Value != "", "key": n.Key != "", "label": n.Label != "", "tone": n.Tone != "", "progress": n.Progress != nil, "actionId": n.ActionID != "", "children": len(n.Children) != 0, "field": n.Field != nil, "diff": n.Diff != nil}
|
||||
present := map[string]bool{"value": n.Value != "", "key": n.Key != "", "label": n.Label != "", "tone": n.Tone != "", "progress": n.Progress != nil, "actionId": n.ActionID != "", "children": len(n.Children) != 0, "field": n.Field != nil, "diff": n.Diff != nil, "dataset": n.Dataset != nil}
|
||||
for key, exists := range present {
|
||||
if exists && !containsToken(allowed, key) {
|
||||
return fail(CodeSchemaInvalid, path+"/"+key)
|
||||
@@ -98,7 +98,9 @@ func nodeFields(n Node, path string, fields map[string]*Field) (string, error) {
|
||||
return "", fail(CodeLimitExceeded, path+"/diff")
|
||||
}
|
||||
allowed = "label diff"
|
||||
case "field", "choice_card", "editable_text":
|
||||
case "dashboard":
|
||||
return "label dataset", validateDataset(n.Dataset, path+"/dataset")
|
||||
case "field", "choice_card", "editable_text", "map", "comparison":
|
||||
return "key label field", validateInput(n, path, fields)
|
||||
default:
|
||||
return "", fail(CodeComponentUnknown, path+"/kind")
|
||||
@@ -123,6 +125,9 @@ func validateInput(n Node, path string, fields map[string]*Field) error {
|
||||
if n.Kind == "editable_text" && n.Field.Type != "string" {
|
||||
return fail(CodeSchemaInvalid, path+"/field/type")
|
||||
}
|
||||
if err := validateRichInput(n, path); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateField(n.Field, path+"/field")
|
||||
}
|
||||
|
||||
@@ -178,6 +183,9 @@ func validateField(f *Field, path string) error {
|
||||
return fail(CodeSchemaInvalid, p)
|
||||
}
|
||||
seen[o.Value] = true
|
||||
if err := validateRichOption(o, p); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(o.Label) > MaxLabel || len(o.Description) > MaxText || len(o.Preview) > MaxText {
|
||||
return fail(CodeLimitExceeded, p)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ type RenderSurfaceTool struct{}
|
||||
func NewRenderSurfaceTool() *RenderSurfaceTool { return &RenderSurfaceTool{} }
|
||||
func (*RenderSurfaceTool) Name() string { return surfacespec.ToolName }
|
||||
func (*RenderSurfaceTool) Description() string {
|
||||
return "Show a native structured form, choice cards, editable_text drafts or diff_preview text comparisons. Fields accept string, number, bool, choice or multichoice values keyed by stable ids. Drafts use string fields; diff previews carry before/after text and do not modify files. Display-only trees return a summary. Trees with button nodes return immediately; clicks deliver typed surface_action notes through the controller. Forms without buttons wait through ask. Never use surfaces to approve tools or grant permissions. No HTML, CSS, URLs or executable components."
|
||||
return "Build an interactive task workspace: map for geographic places and ordered itineraries, comparison for product choices, dashboard for data exploration, plus forms, choice cards, editable_text and diff_preview. Map uses a multichoice field whose options each have location {latitude,longitude} and optional group (such as a day); submitted ids preserve visit order. Comparison uses choice/multichoice options with facts {label,value}, optional group and price/currency. Map and comparison options can include image {path,alt,source} referencing an existing workspace-relative raster file. For real product/place photos, research a source page with web_fetch, then use import_web_image to save an observed candidate and copy its returned image object including source. Omit images if acquisition fails; never use generated substitutes for real entities. Dashboard uses dataset {categories,series:[{id,label,unit,values}]} with equal-length finite numeric series. Compose these with text, fields and buttons for a complete task. Use researched coordinates and data; never invent a location or metric. Map connections indicate visit order, not road routing. Fields accept string, number, bool, choice or multichoice values keyed by stable ids. Drafts use string fields; diff previews carry before/after text and do not modify files. Display-only trees return a summary. Trees with button nodes return immediately; clicks deliver typed surface_action notes through the controller. Forms without buttons wait through ask. Never use surfaces to approve tools or grant permissions. No HTML, CSS, remote image URLs or executable components."
|
||||
}
|
||||
func (*RenderSurfaceTool) ReadOnly() bool { return true }
|
||||
func (*RenderSurfaceTool) Reach() tool.Reach { return tool.ReachHostControl }
|
||||
|
||||
@@ -3,21 +3,357 @@ package agent
|
||||
import "encoding/json"
|
||||
|
||||
var surfaceSchema = json.RawMessage(`{
|
||||
"type":"object","additionalProperties":false,"required":["spec_version","body"],
|
||||
"properties":{"spec_version":{"type":"integer","enum":[1]},"title":{"type":"string","maxLength":200},"body":{"type":"array","minItems":1,"maxItems":200,"items":{"$ref":"#/$defs/node"}}},
|
||||
"$defs":{
|
||||
"node":{"type":"object","additionalProperties":false,"required":["kind"],"properties":{
|
||||
"kind":{"type":"string","enum":["text","markdown","row","stack","kv","meter","pip","divider","field","choice_card","editable_text","diff_preview","button"]},
|
||||
"value":{"type":"string","maxLength":4000},"key":{"type":"string","maxLength":200},"label":{"type":"string","maxLength":200},
|
||||
"tone":{"type":"string","enum":["dim","strong","ok","warn","err","accent"]},"progress":{"type":"number","minimum":0,"maximum":1},
|
||||
"actionId":{"type":"string","pattern":"^[a-zA-Z0-9_-]{1,64}$"},
|
||||
"children":{"type":"array","items":{"$ref":"#/$defs/node"}},"field":{"$ref":"#/$defs/field"},"diff":{"$ref":"#/$defs/diff"}}},
|
||||
"diff":{"type":"object","additionalProperties":false,"required":["before","after"],"properties":{"before":{"type":"string","maxLength":4000},"after":{"type":"string","maxLength":4000}}},
|
||||
"field":{"type":"object","additionalProperties":false,"required":["type"],"properties":{
|
||||
"type":{"type":"string","enum":["string","number","bool","choice","multichoice"]},"required":{"type":"boolean"},
|
||||
"default":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"array","items":{"type":"string"}}]},
|
||||
"min":{"type":"number"},"max":{"type":"number"},"max_length":{"type":"integer","minimum":0,"maximum":4000},
|
||||
"options":{"type":"array","minItems":1,"maxItems":16,"items":{"$ref":"#/$defs/option"}}}},
|
||||
"option":{"type":"object","additionalProperties":false,"required":["value","label"],"properties":{
|
||||
"value":{"type":"string","pattern":"^[a-zA-Z0-9_-]{1,64}$"},"label":{"type":"string","maxLength":200},"description":{"type":"string","maxLength":4000},"preview":{"type":"string","maxLength":4000}}}
|
||||
}}`)
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"spec_version",
|
||||
"body"
|
||||
],
|
||||
"properties": {
|
||||
"spec_version": {
|
||||
"type": "integer",
|
||||
"enum": [
|
||||
1
|
||||
]
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"maxLength": 200
|
||||
},
|
||||
"body": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 200,
|
||||
"items": {
|
||||
"$ref": "#/$defs/node"
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"node": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"kind"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"text",
|
||||
"markdown",
|
||||
"row",
|
||||
"stack",
|
||||
"kv",
|
||||
"meter",
|
||||
"pip",
|
||||
"divider",
|
||||
"field",
|
||||
"choice_card",
|
||||
"editable_text",
|
||||
"diff_preview",
|
||||
"button",
|
||||
"map",
|
||||
"comparison",
|
||||
"dashboard"
|
||||
]
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"maxLength": 4000
|
||||
},
|
||||
"key": {
|
||||
"type": "string",
|
||||
"maxLength": 200
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"maxLength": 200
|
||||
},
|
||||
"tone": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"dim",
|
||||
"strong",
|
||||
"ok",
|
||||
"warn",
|
||||
"err",
|
||||
"accent"
|
||||
]
|
||||
},
|
||||
"progress": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"actionId": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-zA-Z0-9_-]{1,64}$"
|
||||
},
|
||||
"children": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/node"
|
||||
}
|
||||
},
|
||||
"field": {
|
||||
"$ref": "#/$defs/field"
|
||||
},
|
||||
"diff": {
|
||||
"$ref": "#/$defs/diff"
|
||||
},
|
||||
"dataset": {
|
||||
"$ref": "#/$defs/dataset"
|
||||
}
|
||||
}
|
||||
},
|
||||
"diff": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"before",
|
||||
"after"
|
||||
],
|
||||
"properties": {
|
||||
"before": {
|
||||
"type": "string",
|
||||
"maxLength": 4000
|
||||
},
|
||||
"after": {
|
||||
"type": "string",
|
||||
"maxLength": 4000
|
||||
}
|
||||
}
|
||||
},
|
||||
"field": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"type"
|
||||
],
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"string",
|
||||
"number",
|
||||
"bool",
|
||||
"choice",
|
||||
"multichoice"
|
||||
]
|
||||
},
|
||||
"required": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"default": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"min": {
|
||||
"type": "number"
|
||||
},
|
||||
"max": {
|
||||
"type": "number"
|
||||
},
|
||||
"max_length": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 4000
|
||||
},
|
||||
"options": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 16,
|
||||
"items": {
|
||||
"$ref": "#/$defs/option"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"option": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"value",
|
||||
"label"
|
||||
],
|
||||
"properties": {
|
||||
"value": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-zA-Z0-9_-]{1,64}$"
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"maxLength": 200
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"maxLength": 4000
|
||||
},
|
||||
"preview": {
|
||||
"type": "string",
|
||||
"maxLength": 4000
|
||||
},
|
||||
"group": {
|
||||
"type": "string",
|
||||
"maxLength": 200
|
||||
},
|
||||
"location": {
|
||||
"$ref": "#/$defs/location"
|
||||
},
|
||||
"price": {
|
||||
"type": "number",
|
||||
"minimum": 0
|
||||
},
|
||||
"currency": {
|
||||
"type": "string",
|
||||
"maxLength": 8
|
||||
},
|
||||
"facts": {
|
||||
"type": "array",
|
||||
"minItems": 0,
|
||||
"maxItems": 12,
|
||||
"items": {
|
||||
"$ref": "#/$defs/fact"
|
||||
}
|
||||
},
|
||||
"image": {
|
||||
"$ref": "#/$defs/image"
|
||||
}
|
||||
}
|
||||
},
|
||||
"location": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"latitude",
|
||||
"longitude"
|
||||
],
|
||||
"properties": {
|
||||
"latitude": {
|
||||
"type": "number",
|
||||
"minimum": -85,
|
||||
"maximum": 85
|
||||
},
|
||||
"longitude": {
|
||||
"type": "number",
|
||||
"minimum": -180,
|
||||
"maximum": 180
|
||||
}
|
||||
}
|
||||
},
|
||||
"fact": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"label",
|
||||
"value"
|
||||
],
|
||||
"properties": {
|
||||
"label": {
|
||||
"type": "string",
|
||||
"maxLength": 200
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"maxLength": 4000
|
||||
}
|
||||
}
|
||||
},
|
||||
"dataset": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"categories",
|
||||
"series"
|
||||
],
|
||||
"properties": {
|
||||
"categories": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 64,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"maxLength": 200
|
||||
}
|
||||
},
|
||||
"series": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 8,
|
||||
"items": {
|
||||
"$ref": "#/$defs/series"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"series": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"label",
|
||||
"values"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-zA-Z0-9_-]{1,64}$"
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"maxLength": 200
|
||||
},
|
||||
"unit": {
|
||||
"type": "string",
|
||||
"maxLength": 200
|
||||
},
|
||||
"values": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 64,
|
||||
"items": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"image": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"path",
|
||||
"alt"
|
||||
],
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 1024,
|
||||
"description": "Existing workspace-relative raster image path; no URLs, absolute paths or traversal."
|
||||
},
|
||||
"source": {"type":"string","maxLength":4096,"description":"Original HTTP(S) source page returned by import_web_image; displayed as an attribution link."},
|
||||
"alt": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 200
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/contract/tool"
|
||||
"reasonix/internal/safety/sandbox"
|
||||
@@ -34,16 +35,19 @@ func TestBashRefusesCommandLineOverWindowsLimit(t *testing.T) {
|
||||
t.Skip("powershell not found")
|
||||
}
|
||||
sh := sandbox.Shell{Kind: sandbox.ShellPowerShell, Path: powershell}
|
||||
ctx := context.Background()
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
fits := powerShellCommandAtUnits(t, sh, windowsCommandLineMax)
|
||||
args, _ := json.Marshal(map[string]any{"command": fits})
|
||||
res, err := (bash{shell: sh}).ExecuteDetailed(ctx, args)
|
||||
if err != nil {
|
||||
t.Fatalf("command at the limit failed: %v", err)
|
||||
for _, units := range []int{windowsCommandLineMax - 1, windowsCommandLineMax} {
|
||||
fits := powerShellCommandAtUnits(t, sh, units)
|
||||
if err := checkCommandLine(unconfinedShellArgv(sh, fits)); err != nil {
|
||||
t.Fatalf("preflight refused %d units: %v", units, err)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(res.Output, "中中中") {
|
||||
t.Fatalf("command at the limit output = %.80q", res.Output)
|
||||
args, _ := json.Marshal(map[string]any{"command": "Write-Output '中中中'"})
|
||||
res, err := (bash{shell: sh}).ExecuteDetailed(ctx, args)
|
||||
if err != nil || !strings.Contains(res.Output, "中中中") {
|
||||
t.Fatalf("Unicode command failed: %v; output %.80q", err, res.Output)
|
||||
}
|
||||
|
||||
over := powerShellCommandAtUnits(t, sh, windowsCommandLineMax+1)
|
||||
|
||||
@@ -110,6 +110,10 @@ func ConfineWebFetch(proxySpec netclient.ProxySpec) tool.Tool {
|
||||
return webFetch{proxySpec: proxySpec}
|
||||
}
|
||||
|
||||
func ConfineWebImage(roots []string, guard SessionDataGuard, proxySpec netclient.ProxySpec, receipt func(string, bool, []byte)) tool.Tool {
|
||||
return webImage{roots: realRoots(roots), guard: guard, proxySpec: proxySpec, receipt: receipt}
|
||||
}
|
||||
|
||||
// ConfineWriters returns the file-writing built-ins (write_file, edit_file,
|
||||
// multi_edit, move_file, notebook_edit) bound to roots — the only directories they may
|
||||
// modify. The composition root adds these to the per-run registry to override
|
||||
|
||||
@@ -37,14 +37,14 @@ const (
|
||||
func (webFetch) Name() string { return "web_fetch" }
|
||||
|
||||
func (webFetch) Description() string {
|
||||
return "Fetch a URL over HTTPS/HTTP and return its text content. HTML pages are reduced to readable text (scripts, styles, tags stripped, whitespace collapsed); JSON / plain text / markdown bodies come back verbatim. Use to read documentation pages, API responses, or source files hosted somewhere the local filesystem can't reach."
|
||||
return "Read HTTP(S) text. Reduces HTML to text and image candidates for import_web_image; returns JSON/plain text verbatim."
|
||||
}
|
||||
|
||||
func (webFetch) Schema() json.RawMessage {
|
||||
return json.RawMessage(`{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"url":{"type":"string","description":"Absolute URL beginning with http:// or https://"}
|
||||
"url":{"type":"string"}
|
||||
},
|
||||
"required":["url"]
|
||||
}`)
|
||||
@@ -304,6 +304,10 @@ func (wf webFetch) Execute(ctx context.Context, args json.RawMessage) (string, e
|
||||
out := string(body)
|
||||
if strings.Contains(ct, "text/html") || looksLikeHTML(out) {
|
||||
out = htmlToText(out)
|
||||
if candidates := pageImages(body, resp.Request.URL); len(candidates) > 0 {
|
||||
encoded, _ := json.Marshal(candidates)
|
||||
out += "\n\nImage candidates (untrusted page metadata; import_web_image validates and saves the selected image):\n" + string(encoded)
|
||||
}
|
||||
}
|
||||
out = strings.TrimSpace(out)
|
||||
if out == "" {
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/netclient"
|
||||
)
|
||||
|
||||
func TestBlockedFetchIP(t *testing.T) {
|
||||
@@ -46,7 +48,7 @@ func TestWebFetchAllowsLoopback(t *testing.T) {
|
||||
defer srv.Close()
|
||||
|
||||
args, _ := json.Marshal(map[string]any{"url": srv.URL})
|
||||
out, err := webFetch{}.Execute(context.Background(), args)
|
||||
out, err := (webFetch{proxySpec: netclient.ProxySpec{Mode: netclient.ModeOff}}).Execute(context.Background(), args)
|
||||
if err != nil {
|
||||
t.Fatalf("loopback fetch should succeed, got %v", err)
|
||||
}
|
||||
@@ -59,7 +61,7 @@ func TestWebFetchAllowsLoopback(t *testing.T) {
|
||||
// endpoint is refused at dial time (no packet leaves the host).
|
||||
func TestWebFetchRefusesLinkLocal(t *testing.T) {
|
||||
args, _ := json.Marshal(map[string]any{"url": "http://169.254.169.254/latest/meta-data/"})
|
||||
_, err := webFetch{}.Execute(context.Background(), args)
|
||||
_, err := (webFetch{proxySpec: netclient.ProxySpec{Mode: netclient.ModeOff}}).Execute(context.Background(), args)
|
||||
if err == nil {
|
||||
t.Fatal("fetch to 169.254.169.254 should be refused")
|
||||
}
|
||||
@@ -92,7 +94,7 @@ func TestWebFetchDialsAllowedAddressBesideBlockedOne(t *testing.T) {
|
||||
stubFetchLookup(t, "fdfe:dcba:9876::152", "127.0.0.1")
|
||||
|
||||
args, _ := json.Marshal(map[string]any{"url": "http://api-docs.example:" + port + "/"})
|
||||
out, err := webFetch{}.Execute(context.Background(), args)
|
||||
out, err := (webFetch{proxySpec: netclient.ProxySpec{Mode: netclient.ModeOff}}).Execute(context.Background(), args)
|
||||
if err != nil {
|
||||
t.Fatalf("fetch should use the allowed address, got %v", err)
|
||||
}
|
||||
@@ -104,7 +106,7 @@ func TestWebFetchDialsAllowedAddressBesideBlockedOne(t *testing.T) {
|
||||
func TestWebFetchRefusesHostWithOnlyBlockedAddresses(t *testing.T) {
|
||||
stubFetchLookup(t, "fdfe:dcba:9876::152", "10.0.0.7")
|
||||
args, _ := json.Marshal(map[string]any{"url": "http://internal.example/"})
|
||||
_, err := webFetch{}.Execute(context.Background(), args)
|
||||
_, err := (webFetch{proxySpec: netclient.ProxySpec{Mode: netclient.ModeOff}}).Execute(context.Background(), args)
|
||||
if err == nil || !strings.Contains(err.Error(), "refusing to fetch internal address") {
|
||||
t.Fatalf("want SSRF refusal, got %v", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
package builtin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
_ "golang.org/x/image/webp"
|
||||
nethtml "golang.org/x/net/html"
|
||||
"reasonix/internal/base/netclient"
|
||||
"reasonix/internal/contract/tool"
|
||||
)
|
||||
|
||||
type webImage struct {
|
||||
workDir string
|
||||
roots []string
|
||||
guard SessionDataGuard
|
||||
proxySpec netclient.ProxySpec
|
||||
receipt func(string, bool, []byte)
|
||||
}
|
||||
|
||||
func init() { tool.RegisterBuiltin(webImage{}) }
|
||||
func (webImage) Name() string { return "import_web_image" }
|
||||
func (webImage) ReadOnly() bool { return false }
|
||||
func (webImage) WritesNamedPaths() bool { return true }
|
||||
func (webImage) Description() string {
|
||||
return "Import web_fetch image_url from source url into new path. Validates PNG/JPEG/GIF/WebP. Returns image {path,alt,source} for render_surface. Match extension."
|
||||
}
|
||||
func (webImage) Schema() json.RawMessage {
|
||||
return json.RawMessage(`{"type":"object","additionalProperties":false,"required":["url","image_url","path","alt"],"properties":{"url":{"type":"string"},"image_url":{"type":"string"},"path":{"type":"string"},"alt":{"type":"string"}}}`)
|
||||
}
|
||||
func (webImage) Provenance(args json.RawMessage) tool.Provenance {
|
||||
return (webFetch{}).Provenance(args)
|
||||
}
|
||||
|
||||
type imageCandidate struct {
|
||||
URL string `json:"url"`
|
||||
Alt string `json:"alt,omitempty"`
|
||||
}
|
||||
|
||||
func webImageURL(raw string, base *url.URL) *url.URL {
|
||||
u, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if base != nil {
|
||||
u = base.ResolveReference(u)
|
||||
}
|
||||
if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil {
|
||||
return nil
|
||||
}
|
||||
u.Fragment = ""
|
||||
return u
|
||||
}
|
||||
|
||||
func pageImages(body []byte, base *url.URL) []imageCandidate {
|
||||
var out []imageCandidate
|
||||
base = pageImageBase(body, base)
|
||||
seen := map[string]bool{}
|
||||
z := nethtml.NewTokenizer(bytes.NewReader(body))
|
||||
for len(out) < 32 {
|
||||
tt := z.Next()
|
||||
if tt == nethtml.ErrorToken {
|
||||
break
|
||||
}
|
||||
if tt != nethtml.StartTagToken && tt != nethtml.SelfClosingTagToken {
|
||||
continue
|
||||
}
|
||||
token := z.Token()
|
||||
attrs := map[string]string{}
|
||||
for _, a := range token.Attr {
|
||||
attrs[a.Key] = a.Val
|
||||
}
|
||||
var sources []string
|
||||
alt := attrs["alt"]
|
||||
switch token.Data {
|
||||
case "img":
|
||||
sources = []string{attrs["src"], attrs["data-src"]}
|
||||
case "meta":
|
||||
key := attrs["property"]
|
||||
if key == "" {
|
||||
key = attrs["name"]
|
||||
}
|
||||
if key == "og:image" || key == "og:image:url" || key == "twitter:image" {
|
||||
sources = []string{attrs["content"]}
|
||||
}
|
||||
}
|
||||
|
||||
for _, raw := range sources {
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
u := webImageURL(raw, base)
|
||||
if u == nil || len(u.String()) > 4096 || seen[u.String()] {
|
||||
continue
|
||||
}
|
||||
if len(alt) > 200 {
|
||||
alt = ""
|
||||
}
|
||||
seen[u.String()] = true
|
||||
out = append(out, imageCandidate{URL: u.String(), Alt: alt})
|
||||
if len(out) == 32 {
|
||||
return out
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func pageImageBase(body []byte, page *url.URL) *url.URL {
|
||||
z := nethtml.NewTokenizer(bytes.NewReader(body))
|
||||
for {
|
||||
kind := z.Next()
|
||||
if kind == nethtml.ErrorToken {
|
||||
return page
|
||||
}
|
||||
if kind != nethtml.StartTagToken && kind != nethtml.SelfClosingTagToken {
|
||||
continue
|
||||
}
|
||||
token := z.Token()
|
||||
if token.Data != "base" {
|
||||
continue
|
||||
}
|
||||
for _, attr := range token.Attr {
|
||||
if attr.Key != "href" {
|
||||
continue
|
||||
}
|
||||
if base := webImageURL(attr.Val, page); base != nil {
|
||||
return base
|
||||
}
|
||||
return page
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w webImage) fetch(ctx context.Context, raw string, limit int64) ([]byte, *url.URL, error) {
|
||||
u := webImageURL(raw, nil)
|
||||
if u == nil {
|
||||
return nil, nil, fmt.Errorf("absolute HTTP(S) URL without credentials required")
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "reasonix-web-fetch/1.0")
|
||||
resp, err := ssrfGuardedClient((webFetch{proxySpec: w.proxySpec}).proxyURLFor).Do(req)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, nil, fmt.Errorf("image source returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if int64(len(body)) > limit {
|
||||
return nil, nil, fmt.Errorf("image source exceeds %d bytes", limit)
|
||||
}
|
||||
if webImageURL(resp.Request.URL.String(), nil) == nil {
|
||||
return nil, nil, fmt.Errorf("invalid redirect URL")
|
||||
}
|
||||
return body, resp.Request.URL, nil
|
||||
}
|
||||
|
||||
func (w webImage) Execute(ctx context.Context, args json.RawMessage) (string, error) {
|
||||
var p struct {
|
||||
URL string `json:"url"`
|
||||
ImageURL string `json:"image_url"`
|
||||
Path string `json:"path"`
|
||||
Alt string `json:"alt"`
|
||||
}
|
||||
if err := json.Unmarshal(args, &p); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := validateImageTarget(p.Path, p.Alt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
target := resolveIn(w.workDir, p.Path)
|
||||
if err := confineWrite(ctx, w.roots, w.guard, ManagedConfigPaths{}, nil, target); err != nil {
|
||||
return "", err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, 2*webFetchTimeout)
|
||||
defer cancel()
|
||||
page, source, err := w.fetch(ctx, p.URL, webFetchMaxRead)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
found := false
|
||||
for _, candidate := range pageImages(page, source) {
|
||||
if candidate.URL == p.ImageURL {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return "", fmt.Errorf("image_url is not an observed image on the source page")
|
||||
}
|
||||
data, resolved, err := w.fetch(ctx, p.ImageURL, 8<<20)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cfg, format, err := image.DecodeConfig(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("unsupported or invalid raster image: %w", err)
|
||||
}
|
||||
if cfg.Width < 1 || cfg.Height < 1 || int64(cfg.Width)*int64(cfg.Height) > 40_000_000 {
|
||||
return "", fmt.Errorf("image dimensions exceed limit")
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(p.Path))
|
||||
if ext == ".jpg" {
|
||||
ext = ".jpeg"
|
||||
}
|
||||
if ext != "."+format {
|
||||
return "", fmt.Errorf("path extension must match %s image", format)
|
||||
}
|
||||
if _, _, err = image.Decode(bytes.NewReader(data)); err != nil {
|
||||
return "", fmt.Errorf("invalid raster image: %w", err)
|
||||
}
|
||||
dir := w.workDir
|
||||
if dir == "" {
|
||||
dir = "."
|
||||
}
|
||||
root, err := os.OpenRoot(dir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer root.Close()
|
||||
if err = root.MkdirAll(filepath.Dir(p.Path), 0755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
file, err := root.OpenFile(p.Path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_, writeErr := file.Write(data)
|
||||
closeErr := file.Close()
|
||||
if writeErr != nil || closeErr != nil {
|
||||
_ = root.Remove(p.Path)
|
||||
return "", fmt.Errorf("save image: %w", errors.Join(writeErr, closeErr))
|
||||
}
|
||||
if w.receipt != nil {
|
||||
w.receipt(target, false, nil)
|
||||
}
|
||||
result := map[string]any{"image": map[string]string{"path": p.Path, "alt": p.Alt, "source": source.String()}, "image_url": resolved.String(), "sha256": fmt.Sprintf("%x", sha256.Sum256(data)), "width": cfg.Width, "height": cfg.Height}
|
||||
out, err := json.Marshal(result)
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
func validateImageTarget(path, alt string) error {
|
||||
if alt == "" || len(alt) > 200 || len(path) > 1024 || strings.ContainsAny(path, ":\\?#%\x00\r\n\t") || !filepath.IsLocal(path) {
|
||||
return fmt.Errorf("valid relative image path and alt required")
|
||||
}
|
||||
for segment := range strings.SplitSeq(path, "/") {
|
||||
if segment == "" || segment == "." || segment == ".." {
|
||||
return fmt.Errorf("invalid image path")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package builtin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"image"
|
||||
"image/png"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWebImageAcquisition(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/photo.png" {
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
_ = png.Encode(w, image.NewRGBA(image.Rect(0, 0, 2, 2)))
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/bad.png" {
|
||||
_, _ = w.Write([]byte("not an image"))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = w.Write([]byte(`<html><meta property="og:image" content="/photo.png"><img src="/bad.png" alt="broken"><img src="javascript:alert(1)"></html>`))
|
||||
}))
|
||||
defer server.Close()
|
||||
out := runTool(t, webFetch{}, map[string]any{"url": server.URL})
|
||||
if !strings.Contains(out, server.URL+"/photo.png") {
|
||||
t.Errorf("web_fetch discarded image candidates: %s", out)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
tools := (Workspace{Dir: dir}).Tools("import_web_image")
|
||||
if len(tools) != 1 {
|
||||
t.Fatal("image importer not registered")
|
||||
}
|
||||
importer := tools[0]
|
||||
if importer.ReadOnly() {
|
||||
t.Fatal("image importer must declare writes")
|
||||
}
|
||||
args := map[string]any{"url": server.URL, "image_url": server.URL + "/photo.png", "path": "images/photo.png", "alt": "Real source photo"}
|
||||
out = runTool(t, importer, args)
|
||||
var result struct {
|
||||
Image struct{ Path, Alt, Source string }
|
||||
}
|
||||
if err := json.Unmarshal([]byte(out), &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Image.Path != "images/photo.png" || strings.TrimSuffix(result.Image.Source, "/") != server.URL {
|
||||
t.Fatalf("missing provenance: %s", out)
|
||||
}
|
||||
f, err := os.Open(filepath.Join(dir, "images/photo.png"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
if _, err = png.Decode(f); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, tc := range []struct{ name, key, value string }{
|
||||
{"overwrite", "path", "images/photo.png"},
|
||||
{"outside", "path", "../outside.png"},
|
||||
{"unobserved", "image_url", server.URL + "/unknown.png"},
|
||||
{"invalid image", "image_url", server.URL + "/bad.png"},
|
||||
{"wrong extension", "path", "images/photo.jpg"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
p := map[string]any{"url": server.URL, "image_url": server.URL + "/photo.png", "path": "images/new.png", "alt": "Photo"}
|
||||
p[tc.key] = tc.value
|
||||
if _, err := importer.Execute(context.Background(), argsJSON(t, p)); err == nil {
|
||||
t.Fatal("accepted invalid import")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebImageLive(t *testing.T) {
|
||||
page := os.Getenv("REASONIX_IMAGE_SMOKE_PAGE")
|
||||
if page == "" {
|
||||
t.Skip("set REASONIX_IMAGE_SMOKE_PAGE for an explicit network smoke test")
|
||||
}
|
||||
w := webImage{workDir: t.TempDir()}
|
||||
body, source, err := w.fetch(context.Background(), page, webFetchMaxRead)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
candidates := pageImages(body, source)
|
||||
for _, candidate := range candidates {
|
||||
t.Logf("candidate: %s", candidate.URL)
|
||||
}
|
||||
selected := os.Getenv("REASONIX_IMAGE_SMOKE_URL")
|
||||
if selected == "" {
|
||||
return
|
||||
}
|
||||
out := runTool(t, w, map[string]any{"url": page, "image_url": selected, "path": "photo.jpg", "alt": "Source photo"})
|
||||
t.Log(out)
|
||||
}
|
||||
|
||||
func TestWebImageBoundaries(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
w := webImage{workDir: dir, roots: realRoots([]string{filepath.Join(dir, "allowed")}), guard: NewSessionDataGuard(dir, nil)}
|
||||
for _, path := range []string{"outside.png", "sessions/photo.png"} {
|
||||
if _, err := w.Execute(context.Background(), argsJSON(t, map[string]any{"path": path, "alt": "Photo"})); err == nil {
|
||||
t.Fatalf("accepted forbidden path %s", path)
|
||||
}
|
||||
}
|
||||
outside := t.TempDir()
|
||||
if err := os.Symlink(outside, filepath.Join(dir, "escape")); err == nil {
|
||||
w.roots = realRoots([]string{dir})
|
||||
if _, err := w.Execute(context.Background(), argsJSON(t, map[string]any{"path": "escape/photo.png", "alt": "Photo"})); err == nil {
|
||||
t.Fatal("accepted escaping symlink")
|
||||
}
|
||||
}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/status" {
|
||||
rw.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
_, _ = rw.Write([]byte("12345"))
|
||||
}))
|
||||
defer server.Close()
|
||||
for _, raw := range []string{server.URL + "/status", server.URL, "file:///tmp/photo.png", "https://user:[email protected]/photo"} {
|
||||
if _, _, err := w.fetch(context.Background(), raw, 4); err == nil {
|
||||
t.Fatalf("accepted invalid fetch %s", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageImagesLazySourcesAndBase(t *testing.T) {
|
||||
page, _ := url.Parse("https://shop.example/catalog/item")
|
||||
for _, tc := range []struct {
|
||||
name, html string
|
||||
want []imageCandidate
|
||||
}{
|
||||
{"lazy placeholder", `<img src="data:image/gif;base64,AA" data-src="photo.jpg" alt="Camera">`, []imageCandidate{{URL: "https://shop.example/catalog/photo.jpg", Alt: "Camera"}}},
|
||||
{"retain both", `<img src="small.jpg" data-src="large.jpg" alt="Camera">`, []imageCandidate{{URL: "https://shop.example/catalog/small.jpg", Alt: "Camera"}, {URL: "https://shop.example/catalog/large.jpg", Alt: "Camera"}}},
|
||||
{"base", `<base href="https://cdn.example/photos/"><img src="a.jpg"><base href="https://ignored.example/"><img data-src="b.jpg">`, []imageCandidate{{URL: "https://cdn.example/photos/a.jpg"}, {URL: "https://cdn.example/photos/b.jpg"}}},
|
||||
{"invalid base", `<base href="javascript:alert(1)"><base href="https://ignored.example/"><img src="a.jpg">`, []imageCandidate{{URL: "https://shop.example/catalog/a.jpg"}}},
|
||||
{"deduplicate", `<img src="a.jpg" data-src="a.jpg"><img src="a.jpg#fragment">`, []imageCandidate{{URL: "https://shop.example/catalog/a.jpg"}}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := pageImages([]byte(tc.html), page); !reflect.DeepEqual(got, tc.want) {
|
||||
t.Fatalf("got %#v; want %#v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -81,20 +81,21 @@ func (w Workspace) Tools(enabled ...string) []tool.Tool {
|
||||
}
|
||||
|
||||
overrides := map[string]tool.Tool{
|
||||
"read_file": readFile{workDir: w.Dir, netRoots: netRoots, readRoots: readRoots, paths: w.ReadPaths, forbidRoots: forbidRoots, overlay: w.FileOverlay},
|
||||
"write_file": writeFile{workDir: w.Dir, roots: roots, guard: w.SessionGuard, managed: w.ManagedConfig, sessionTemp: w.SessionTemp, overlay: w.FileOverlay, receipt: w.FileWriteReceipt},
|
||||
"edit_file": editFile{workDir: w.Dir, roots: roots, guard: w.SessionGuard, managed: w.ManagedConfig, sessionTemp: w.SessionTemp, overlay: w.FileOverlay},
|
||||
"multi_edit": multiEdit{workDir: w.Dir, roots: roots, guard: w.SessionGuard, managed: w.ManagedConfig, sessionTemp: w.SessionTemp, overlay: w.FileOverlay},
|
||||
"move_file": moveFile{workDir: w.Dir, roots: roots, guard: w.SessionGuard, managed: w.ManagedConfig, sessionTemp: w.SessionTemp},
|
||||
"notebook_edit": notebookEdit{workDir: w.Dir, roots: roots, guard: w.SessionGuard, managed: w.ManagedConfig, sessionTemp: w.SessionTemp, overlay: w.FileOverlay},
|
||||
"delete_range": deleteRange{workDir: w.Dir, roots: roots, guard: w.SessionGuard, managed: w.ManagedConfig, sessionTemp: w.SessionTemp, overlay: w.FileOverlay},
|
||||
"delete_symbol": deleteSymbol{workDir: w.Dir, roots: roots, guard: w.SessionGuard, managed: w.ManagedConfig, sessionTemp: w.SessionTemp, overlay: w.FileOverlay},
|
||||
"code_index": codeIndex{workDir: w.Dir, netRoots: netRoots, readRoots: readRoots, forbidRoots: forbidRoots},
|
||||
"bash": bash{workDir: w.Dir, sb: w.Bash, timeout: w.BashTimeout, guard: w.SessionGuard, terminal: w.Terminal, sessionTemp: w.SessionTemp, paths: w.ReadPaths},
|
||||
"ls": listDir{workDir: w.Dir, netRoots: netRoots, readRoots: readRoots, paths: w.ReadPaths, forbidRoots: forbidRoots},
|
||||
"glob": globTool{workDir: w.Dir, netRoots: netRoots, readRoots: readRoots, paths: w.ReadPaths, forbidRoots: forbidRoots},
|
||||
"grep": grepTool{workDir: w.Dir, netRoots: netRoots, readRoots: readRoots, paths: w.ReadPaths, rg: w.Search.RgPath, forbidRoots: forbidRoots, sb: w.Bash, sessionTemp: w.SessionTemp},
|
||||
"web_fetch": webFetch{proxySpec: w.ProxySpec},
|
||||
"read_file": readFile{workDir: w.Dir, netRoots: netRoots, readRoots: readRoots, paths: w.ReadPaths, forbidRoots: forbidRoots, overlay: w.FileOverlay},
|
||||
"write_file": writeFile{workDir: w.Dir, roots: roots, guard: w.SessionGuard, managed: w.ManagedConfig, sessionTemp: w.SessionTemp, overlay: w.FileOverlay, receipt: w.FileWriteReceipt},
|
||||
"edit_file": editFile{workDir: w.Dir, roots: roots, guard: w.SessionGuard, managed: w.ManagedConfig, sessionTemp: w.SessionTemp, overlay: w.FileOverlay},
|
||||
"multi_edit": multiEdit{workDir: w.Dir, roots: roots, guard: w.SessionGuard, managed: w.ManagedConfig, sessionTemp: w.SessionTemp, overlay: w.FileOverlay},
|
||||
"move_file": moveFile{workDir: w.Dir, roots: roots, guard: w.SessionGuard, managed: w.ManagedConfig, sessionTemp: w.SessionTemp},
|
||||
"notebook_edit": notebookEdit{workDir: w.Dir, roots: roots, guard: w.SessionGuard, managed: w.ManagedConfig, sessionTemp: w.SessionTemp, overlay: w.FileOverlay},
|
||||
"delete_range": deleteRange{workDir: w.Dir, roots: roots, guard: w.SessionGuard, managed: w.ManagedConfig, sessionTemp: w.SessionTemp, overlay: w.FileOverlay},
|
||||
"delete_symbol": deleteSymbol{workDir: w.Dir, roots: roots, guard: w.SessionGuard, managed: w.ManagedConfig, sessionTemp: w.SessionTemp, overlay: w.FileOverlay},
|
||||
"code_index": codeIndex{workDir: w.Dir, netRoots: netRoots, readRoots: readRoots, forbidRoots: forbidRoots},
|
||||
"bash": bash{workDir: w.Dir, sb: w.Bash, timeout: w.BashTimeout, guard: w.SessionGuard, terminal: w.Terminal, sessionTemp: w.SessionTemp, paths: w.ReadPaths},
|
||||
"ls": listDir{workDir: w.Dir, netRoots: netRoots, readRoots: readRoots, paths: w.ReadPaths, forbidRoots: forbidRoots},
|
||||
"glob": globTool{workDir: w.Dir, netRoots: netRoots, readRoots: readRoots, paths: w.ReadPaths, forbidRoots: forbidRoots},
|
||||
"grep": grepTool{workDir: w.Dir, netRoots: netRoots, readRoots: readRoots, paths: w.ReadPaths, rg: w.Search.RgPath, forbidRoots: forbidRoots, sb: w.Bash, sessionTemp: w.SessionTemp},
|
||||
"web_fetch": webFetch{proxySpec: w.ProxySpec},
|
||||
"import_web_image": webImage{workDir: w.Dir, roots: roots, guard: w.SessionGuard, proxySpec: w.ProxySpec, receipt: w.FileWriteReceipt},
|
||||
}
|
||||
all := tool.Builtins()
|
||||
if len(enabled) == 0 {
|
||||
|
||||
@@ -82,3 +82,7 @@ func (d deleteSymbol) WritePaths(args json.RawMessage) ([]string, error) {
|
||||
func (m moveFile) WritePaths(args json.RawMessage) ([]string, error) {
|
||||
return ResolveWritePaths(m.workDir, m.sessionTemp, m.roots, args, true)
|
||||
}
|
||||
|
||||
func (w webImage) WritePaths(args json.RawMessage) ([]string, error) {
|
||||
return ResolveWritePaths(w.workDir, nil, w.roots, args, false)
|
||||
}
|
||||
@@ -41,11 +41,11 @@ type writeFile struct {
|
||||
func (writeFile) Name() string { return "write_file" }
|
||||
|
||||
func (writeFile) Description() string {
|
||||
return "Write content to a file at the given path (overwriting existing content). Creates parent directories as needed. Sends the whole file every time: for a change to part of one that already exists, edit_file and multi_edit send only the changed region."
|
||||
return "Write full file content, overwriting and creating parent directories. Prefer edit_file/multi_edit for partial changes."
|
||||
}
|
||||
|
||||
func (writeFile) Schema() json.RawMessage {
|
||||
return json.RawMessage(`{"type":"object","properties":{"path":{"type":"string","description":"File path"},"content":{"type":"string","description":"Full content to write"}},"required":["path","content"]}`)
|
||||
return json.RawMessage(`{"type":"object","properties":{"path":{"type":"string"},"content":{"type":"string"}},"required":["path","content"]}`)
|
||||
}
|
||||
|
||||
func (writeFile) ReadOnly() bool { return false }
|
||||
|
||||
@@ -47,6 +47,12 @@ var mirroredWireTypes = []wireMirror{
|
||||
{"internal/contract/surfacespec/spec.go", "Field", tsWireFile, "SurfaceField"},
|
||||
{"internal/contract/surfacespec/spec.go", "Diff", tsWireFile, "SurfaceDiff"},
|
||||
{"internal/contract/surfacespec/spec.go", "Option", tsWireFile, "SurfaceOption"},
|
||||
{"internal/contract/surfacespec/image.go", "Image", "desktop/frontend-next/src/port/surface-rich.ts", "SurfaceImage"},
|
||||
{"internal/contract/surfacespec/rich.go", "Location", "desktop/frontend-next/src/port/surface-rich.ts", "SurfaceLocation"},
|
||||
{"internal/contract/surfacespec/rich.go", "Fact", "desktop/frontend-next/src/port/surface-rich.ts", "SurfaceFact"},
|
||||
{"internal/contract/surfacespec/rich.go", "Dataset", "desktop/frontend-next/src/port/surface-rich.ts", "SurfaceDataset"},
|
||||
{"internal/contract/surfacespec/rich.go", "Series", "desktop/frontend-next/src/port/surface-rich.ts", "SurfaceSeries"},
|
||||
|
||||
{"internal/contract/eventwire/extension.go", "ExtensionViewNode", tsWireFile, "ExtensionViewNode"},
|
||||
{"internal/contract/eventwire/extension.go", "ExtensionSurface", tsWireFile, "ExtensionSurface"},
|
||||
{"internal/contract/event/notice_payload.go", "DormantPermissionRule", tsBoundaryFile, "DormantPermissionRule"},
|
||||
|
||||
Reference in new issue
Block a user