collective.aitools 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- collective/aitools/AGENTS.md +289 -0
- collective/aitools/__init__.py +14 -0
- collective/aitools/backfill.py +110 -0
- collective/aitools/browser/__init__.py +0 -0
- collective/aitools/browser/assistant.py +66 -0
- collective/aitools/browser/configure.zcml +57 -0
- collective/aitools/browser/help_me_write.py +70 -0
- collective/aitools/browser/recognize_image.py +57 -0
- collective/aitools/browser/static/aitools-remote.min.js +2 -0
- collective/aitools/browser/static/aitools.min.js +2 -0
- collective/aitools/browser/static/chunks/46.17392fd62e1893d8f4e0.min.js +2 -0
- collective/aitools/browser/static/chunks/46.17392fd62e1893d8f4e0.min.js.map +1 -0
- collective/aitools/browser/static/chunks/692.cd4653742130a6d95d03.min.js +12 -0
- collective/aitools/browser/static/chunks/692.cd4653742130a6d95d03.min.js.map +1 -0
- collective/aitools/browser/static/chunks/709.828f420ec7e88f982470.min.js +2 -0
- collective/aitools/browser/static/chunks/709.828f420ec7e88f982470.min.js.map +1 -0
- collective/aitools/browser/static/chunks/87.2eb8178be9c49f8e5ae5.min.js +2 -0
- collective/aitools/browser/static/chunks/87.2eb8178be9c49f8e5ae5.min.js.map +1 -0
- collective/aitools/browser/suggest_tags.py +53 -0
- collective/aitools/browser/summarize.py +53 -0
- collective/aitools/browser/templates/assistant_viewlet.pt +7 -0
- collective/aitools/browser/templates/help_me_write_viewlet.pt +8 -0
- collective/aitools/browser/templates/recognize_image_viewlet.pt +8 -0
- collective/aitools/browser/templates/suggest_tags_viewlet.pt +8 -0
- collective/aitools/browser/templates/summarize_viewlet.pt +8 -0
- collective/aitools/capabilities/__init__.py +18 -0
- collective/aitools/capabilities/base.py +76 -0
- collective/aitools/capabilities/configure.zcml +24 -0
- collective/aitools/capabilities/propose_draft.py +250 -0
- collective/aitools/capabilities/search_site.py +68 -0
- collective/aitools/capabilities/types.py +151 -0
- collective/aitools/capabilities/where_to_add.py +103 -0
- collective/aitools/configure.zcml +30 -0
- collective/aitools/content/__init__.py +0 -0
- collective/aitools/controlpanels/__init__.py +0 -0
- collective/aitools/controlpanels/ai_tools.py +36 -0
- collective/aitools/controlpanels/configure.zcml +22 -0
- collective/aitools/dependencies.zcml +8 -0
- collective/aitools/indexers/__init__.py +0 -0
- collective/aitools/indexers/configure.zcml +7 -0
- collective/aitools/interfaces.py +312 -0
- collective/aitools/locales/__init__.py +0 -0
- collective/aitools/locales/__main__.py +69 -0
- collective/aitools/locales/collective.aitools.pot +239 -0
- collective/aitools/locales/de/LC_MESSAGES/collective.aitools.mo +0 -0
- collective/aitools/locales/de/LC_MESSAGES/collective.aitools.po +236 -0
- collective/aitools/locales/en/LC_MESSAGES/collective.aitools.mo +0 -0
- collective/aitools/locales/en/LC_MESSAGES/collective.aitools.po +236 -0
- collective/aitools/locales/es/LC_MESSAGES/collective.aitools.mo +0 -0
- collective/aitools/locales/es/LC_MESSAGES/collective.aitools.po +236 -0
- collective/aitools/locales/it/LC_MESSAGES/collective.aitools.mo +0 -0
- collective/aitools/locales/it/LC_MESSAGES/collective.aitools.po +236 -0
- collective/aitools/locales/pt_BR/LC_MESSAGES/collective.aitools.mo +0 -0
- collective/aitools/locales/pt_BR/LC_MESSAGES/collective.aitools.po +236 -0
- collective/aitools/permissions.zcml +5 -0
- collective/aitools/profiles/default/browserlayer.xml +6 -0
- collective/aitools/profiles/default/catalog.xml +13 -0
- collective/aitools/profiles/default/controlpanel.xml +21 -0
- collective/aitools/profiles/default/diff_tool.xml +6 -0
- collective/aitools/profiles/default/metadata.xml +7 -0
- collective/aitools/profiles/default/registry/.gitkeep +0 -0
- collective/aitools/profiles/default/registry/main.xml +10 -0
- collective/aitools/profiles/default/registry/resources.xml +18 -0
- collective/aitools/profiles/default/repositorytool.xml +6 -0
- collective/aitools/profiles/default/rolemap.xml +6 -0
- collective/aitools/profiles/default/types/.gitkeep +0 -0
- collective/aitools/profiles/default/types.xml +10 -0
- collective/aitools/profiles/initial/metadata.xml +4 -0
- collective/aitools/profiles/uninstall/browserlayer.xml +6 -0
- collective/aitools/profiles/uninstall/registry.xml +10 -0
- collective/aitools/profiles.zcml +41 -0
- collective/aitools/serializers/__init__.py +0 -0
- collective/aitools/serializers/configure.zcml +10 -0
- collective/aitools/serializers/summary.py +10 -0
- collective/aitools/services/__init__.py +0 -0
- collective/aitools/services/assistant.py +187 -0
- collective/aitools/services/configure.zcml +59 -0
- collective/aitools/services/help_me_write.py +79 -0
- collective/aitools/services/recognize_image.py +65 -0
- collective/aitools/services/suggest_tags.py +55 -0
- collective/aitools/services/summarize.py +55 -0
- collective/aitools/setuphandlers/__init__.py +17 -0
- collective/aitools/setuphandlers/examplecontent/.gitkeep +0 -0
- collective/aitools/setuphandlers/examplecontent/content/__metadata__.json +40 -0
- collective/aitools/setuphandlers/examplecontent/content/a58ccead718140c1baa98d43595fc3e6/data.json +56 -0
- collective/aitools/setuphandlers/examplecontent/content/a58ccead718140c1baa98d43595fc3e6/image/plone-foundation.png +0 -0
- collective/aitools/setuphandlers/examplecontent/content/a720393b3c0240e5bd27c43fcd2cfd1e/data.json +98 -0
- collective/aitools/setuphandlers/examplecontent/content/plone_site_root/data.json +267 -0
- collective/aitools/setuphandlers/examplecontent/principals.json +4 -0
- collective/aitools/setuphandlers/examplecontent/redirects.json +1 -0
- collective/aitools/setuphandlers/examplecontent/relations.json +1 -0
- collective/aitools/setuphandlers/examplecontent/translations.json +1 -0
- collective/aitools/setuphandlers/initial.py +16 -0
- collective/aitools/testing.py +49 -0
- collective/aitools/tools/__init__.py +13 -0
- collective/aitools/tools/assistant.py +124 -0
- collective/aitools/tools/base.py +75 -0
- collective/aitools/tools/configure.zcml +35 -0
- collective/aitools/tools/help_me_write.py +83 -0
- collective/aitools/tools/recognize_image.py +131 -0
- collective/aitools/tools/suggest_tags.py +102 -0
- collective/aitools/tools/summarize.py +53 -0
- collective/aitools/tools/text.py +108 -0
- collective/aitools/upgrades/__init__.py +0 -0
- collective/aitools/upgrades/configure.zcml +21 -0
- collective/aitools/vocabularies/__init__.py +0 -0
- collective/aitools/vocabularies/configure.zcml +5 -0
- collective_aitools-1.0.0.dist-info/METADATA +155 -0
- collective_aitools-1.0.0.dist-info/RECORD +113 -0
- collective_aitools-1.0.0.dist-info/WHEEL +4 -0
- collective_aitools-1.0.0.dist-info/entry_points.txt +2 -0
- collective_aitools-1.0.0.dist-info/licenses/LICENSE.GPL +339 -0
- collective_aitools-1.0.0.dist-info/licenses/LICENSE.md +15 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
# AGENTS.md — collective.aitools package source
|
|
2
|
+
|
|
3
|
+
Notes for the add-on source itself. Read [`../../../AGENTS.md`](../../../AGENTS.md)
|
|
4
|
+
(backend) and the repo root `AGENTS.md` first. **Update this file whenever you
|
|
5
|
+
add or change code under `src/collective/aitools/`** — that is the maintenance
|
|
6
|
+
rule from the backend guide, applied here.
|
|
7
|
+
|
|
8
|
+
## The AI tools framework
|
|
9
|
+
|
|
10
|
+
The product's features are **AI tools**: each is a named utility providing
|
|
11
|
+
[`IAITool`](interfaces.py), enabled/configured from the "AI Tools" control
|
|
12
|
+
panel, and reaching a model through the `collective.aisettings` `IAIService`
|
|
13
|
+
utility (never an LLM SDK directly). When no model is configured the tool
|
|
14
|
+
passes `model=None` so aisettings resolves one by capability.
|
|
15
|
+
|
|
16
|
+
> Don't confuse our `collective.aitools.interfaces.IAITool` (a high-level
|
|
17
|
+
> editor/visitor feature) with `collective.aisettings`'s `IAITool` (a
|
|
18
|
+
> pydantic-ai function-calling tool offered to an agent mid-run). Different
|
|
19
|
+
> concept, same short name, different package.
|
|
20
|
+
|
|
21
|
+
### Anatomy of a tool
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
tools/
|
|
25
|
+
├── base.py ← AITool base class + get_tool / get_tools / get_tools_settings
|
|
26
|
+
├── recognize_image.py ← RecognizeImageTool (vision → IAIService.analyze_image)
|
|
27
|
+
├── help_me_write.py ← HelpMeWriteTool (completion → IAIService.chat)
|
|
28
|
+
├── summarize.py ← SummarizeTool (completion → IAIService.chat)
|
|
29
|
+
├── suggest_tags.py ← SuggestTagsTool (completion, vocabulary-aware)
|
|
30
|
+
├── assistant.py ← AssistantTool (agentic chat: IAIService.tool_call loop) — see below
|
|
31
|
+
└── configure.zcml ← one <utility provides=IAITool name=...> per tool
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Most tools do a single model call. **`assistant`** is different: it runs a
|
|
35
|
+
tool-calling *loop* and dispatches to **capabilities** (`capabilities/`) — see
|
|
36
|
+
the "The Assistant (agentic chat) + capabilities" section below.
|
|
37
|
+
|
|
38
|
+
`AITool` (in [`tools/base.py`](tools/base.py)) reads the registry via the
|
|
39
|
+
`<name>_enabled` / `<name>_model` convention, exposes `is_enabled()`,
|
|
40
|
+
`get_model()`, `available_for(context)`, and `self.service` (the
|
|
41
|
+
`IAIService` utility). Subclasses set `name` / `title` / `description` and
|
|
42
|
+
implement `run(context, **kwargs)`. The public `run` should early-return via
|
|
43
|
+
`self._skip_if_disabled()`.
|
|
44
|
+
|
|
45
|
+
How tools are *invoked* (event subscriber, action/menu item, async cron) is
|
|
46
|
+
deliberately undecided — tools only expose `run`.
|
|
47
|
+
|
|
48
|
+
### Adding a new tool — checklist
|
|
49
|
+
|
|
50
|
+
1. Create `tools/<name>.py` with a subclass of `AITool` (set `name` to match
|
|
51
|
+
the utility name; implement `run`).
|
|
52
|
+
2. Register it in [`tools/configure.zcml`](tools/configure.zcml) as a
|
|
53
|
+
`<utility ... provides="collective.aitools.interfaces.IAITool"
|
|
54
|
+
name="<name>" />`.
|
|
55
|
+
3. Add `<name>_enabled` (Bool) and, if it uses a model, `<name>_model`
|
|
56
|
+
(TextLine) to [`IAIToolsSettings`](interfaces.py) — plus any extra config
|
|
57
|
+
fields the tool needs (e.g. `recognize_image_prompt`).
|
|
58
|
+
4. The control panel ([`controlpanels/ai_tools.py`](controlpanels/ai_tools.py))
|
|
59
|
+
renders the new fields automatically (it's bound to the whole schema). No
|
|
60
|
+
change needed there.
|
|
61
|
+
5. The registry records are created by `<records interface=...IAIToolsSettings />`
|
|
62
|
+
in [`profiles/default/registry/main.xml`](profiles/default/registry/main.xml) —
|
|
63
|
+
already wired, but **bump the profile version in
|
|
64
|
+
`profiles/default/metadata.xml` and add an upgrade step under `upgrades/`**
|
|
65
|
+
so existing sites get the new records.
|
|
66
|
+
6. Add tests under `backend/tests/tools/` (registration, enable/disable,
|
|
67
|
+
model resolution, and a `run` test with the `fake_service` fixture pattern).
|
|
68
|
+
7. Update this file.
|
|
69
|
+
|
|
70
|
+
## The Assistant (agentic chat) + capabilities
|
|
71
|
+
|
|
72
|
+
The `assistant` tool ([`tools/assistant.py`](tools/assistant.py)) is a chat
|
|
73
|
+
assistant for authenticated users. Unlike the other tools it is **agentic**: it
|
|
74
|
+
hands the model a set of *capabilities* (function/tool-calling schemas), runs
|
|
75
|
+
whichever the model asks for, feeds the results back, and repeats until the
|
|
76
|
+
model produces a final answer (capped at `MAX_STEPS`). It reaches the model
|
|
77
|
+
through `IAIService.tool_call(messages, tools, …)` — the OpenAI-style
|
|
78
|
+
tool-calling surface — never an SDK directly.
|
|
79
|
+
|
|
80
|
+
### Capabilities
|
|
81
|
+
|
|
82
|
+
A **capability** is the assistant's equivalent of a tool, but exposed *to the
|
|
83
|
+
model* as a callable function. Each is a named utility providing
|
|
84
|
+
[`IAIAssistantCapability`](interfaces.py), under `capabilities/`:
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
capabilities/
|
|
88
|
+
├── base.py ← AICapability base + get_capability / get_capabilities / manifest_for
|
|
89
|
+
├── search_site.py ← search_site (permission View; read-only catalog search)
|
|
90
|
+
├── where_to_add.py ← where_to_add (permission cmf.ModifyPortalContent; suggest folders)
|
|
91
|
+
├── propose_draft.py ← propose_draft (permission cmf.ModifyPortalContent; AI-drafts, proposes only)
|
|
92
|
+
├── types.py ← resolve_portal_type + IContentTypeSynonyms extension point
|
|
93
|
+
└── configure.zcml ← one <utility provides=IAIAssistantCapability name=...> per capability
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`AICapability` ([`capabilities/base.py`](capabilities/base.py)) sets `name`,
|
|
97
|
+
`description` (sent to the model), `parameters` (a JSON Schema for the call
|
|
98
|
+
args), and `permission` (a Zope permission). It exposes `allowed_for(context)`
|
|
99
|
+
(a real `checkPermission`), `tool_schema()` (the OpenAI function definition),
|
|
100
|
+
and `run(context, request, **kwargs)` returning a JSON-serializable result.
|
|
101
|
+
|
|
102
|
+
### Security model — read this before adding a capability
|
|
103
|
+
|
|
104
|
+
- **The manifest is a filter; `run` is the enforcement.** `manifest_for(context)`
|
|
105
|
+
only offers the model the capabilities the current user is allowed to use, but
|
|
106
|
+
`AssistantTool._run_capability` **re-checks `allowed_for` before every call**
|
|
107
|
+
and returns an error payload (not a raise) if it fails. A hallucinated or
|
|
108
|
+
malicious tool call can never do something the user can't.
|
|
109
|
+
- **No writes inside the loop.** A capability that would change content must
|
|
110
|
+
only *propose* it. `propose_draft` validates + generates but creates nothing;
|
|
111
|
+
the proposal bubbles up (`result["proposal"]`) to the widget as a confirm
|
|
112
|
+
card. The actual write happens in a separate, explicit, permission-gated
|
|
113
|
+
endpoint (`@aitools-assistant-apply`) that **re-validates server-side** — the
|
|
114
|
+
client's proposal is untrusted, so tampering can't escalate.
|
|
115
|
+
|
|
116
|
+
### Content-type synonyms (extensible)
|
|
117
|
+
|
|
118
|
+
`resolve_portal_type(value)` ([`capabilities/types.py`](capabilities/types.py))
|
|
119
|
+
lets the model/editor use loose type names. Precedence: exact id →
|
|
120
|
+
case-insensitive id → type title → synonyms; it only ever returns a type that
|
|
121
|
+
exists on the site. Synonyms come from the built-in `TYPE_SYNONYMS` **plus**
|
|
122
|
+
every registered [`IContentTypeSynonyms`](interfaces.py) utility, merged by
|
|
123
|
+
`collect_synonyms()` (add-on entries override built-ins on conflict).
|
|
124
|
+
Add-ons that ship content types register their own provider (subclass
|
|
125
|
+
`SynonymProvider`, set `synonyms`, register a `<utility>`) to teach the
|
|
126
|
+
assistant friendly names — no change to this package.
|
|
127
|
+
|
|
128
|
+
### Adding a new capability — checklist
|
|
129
|
+
|
|
130
|
+
1. Create `capabilities/<name>.py` subclassing `AICapability`; set `name`,
|
|
131
|
+
`description`, `parameters` (JSON Schema), `permission`; implement `run`.
|
|
132
|
+
2. Register it in [`capabilities/configure.zcml`](capabilities/configure.zcml)
|
|
133
|
+
as `<utility ... provides="collective.aitools.interfaces.IAIAssistantCapability"
|
|
134
|
+
name="<name>" />`.
|
|
135
|
+
3. Read-only? Safe to run in the loop. Does it write? Return a `proposal`
|
|
136
|
+
instead and add a handler to `AssistantApply` (`services/assistant.py`) that
|
|
137
|
+
re-validates and performs the write.
|
|
138
|
+
4. Add tests; update this file.
|
|
139
|
+
|
|
140
|
+
### REST endpoints
|
|
141
|
+
|
|
142
|
+
- `@aitools-assistant` (POST, permission `zope2.View`) — drives the `assistant`
|
|
143
|
+
tool over the client's `{messages}` history; returns `{reply, steps,
|
|
144
|
+
proposals}`. History is sanitized/clamped server-side; the system prompt and
|
|
145
|
+
tool turns are added by the tool, never taken from the client.
|
|
146
|
+
- `@aitools-assistant-apply` (POST, permission `cmf.ModifyPortalContent`) —
|
|
147
|
+
applies a user-confirmed `{proposal}`. Implements `create_draft` (re-validates
|
|
148
|
+
via `propose_draft.validate_draft`, creates a private draft, sets the body
|
|
149
|
+
only on types that have a `text` RichText field). Add more proposal types
|
|
150
|
+
here.
|
|
151
|
+
|
|
152
|
+
> The chat endpoint is gated at `View` (so the authenticated members the viewlet
|
|
153
|
+
> targets can chat; capabilities self-gate). Anonymous rate-limiting is **not**
|
|
154
|
+
> done yet — see [What's deferred](#whats-deferred).
|
|
155
|
+
|
|
156
|
+
### Classic-UI chat widget
|
|
157
|
+
|
|
158
|
+
A floating chat widget, separate from the edit-form tools:
|
|
159
|
+
|
|
160
|
+
- **Viewlet** ([`browser/assistant.py`](browser/assistant.py)) — renders the
|
|
161
|
+
trigger in `IPortalFooter` when the tool is enabled and the user is
|
|
162
|
+
**authenticated** (`not api.user.is_anonymous()` — deliberately *not* a
|
|
163
|
+
permission check, since the viewlet runs on the root where editor perms may be
|
|
164
|
+
absent). Emits config incl. both endpoints, translated labels, and a
|
|
165
|
+
`plone.protect` CSRF `token` (`createToken()`).
|
|
166
|
+
- **Pattern** ([`resources/js/assistant.pattern.js`](../../../resources/js/assistant.pattern.js))
|
|
167
|
+
— a launcher + toggleable panel with chat bubbles, proposal confirm cards, and
|
|
168
|
+
a "Start new chat" (↻) button. The transcript and open/closed state persist in
|
|
169
|
+
**sessionStorage**, so navigating (a full reload) keeps the conversation and
|
|
170
|
+
re-opens the widget. Assistant replies render through the safe Markdown helper
|
|
171
|
+
[`resources/js/lib/markdown.js`](../../../resources/js/lib/markdown.js)
|
|
172
|
+
(HTML-escaped first, then links/emphasis; link hrefs scheme-checked).
|
|
173
|
+
- **CSRF** — writes (the apply request) require the token; the pattern sends it
|
|
174
|
+
via the `X-CSRF-TOKEN` header (`postJSON`'s `extraHeaders` arg). `postJSON`
|
|
175
|
+
lives in [`lib/ui.js`](../../../resources/js/lib/ui.js).
|
|
176
|
+
|
|
177
|
+
## The AI Tools control panel
|
|
178
|
+
|
|
179
|
+
[`controlpanels/ai_tools.py`](controlpanels/ai_tools.py) mirrors the
|
|
180
|
+
`collective.aisettings` "AI Settings" panel exactly: a `RegistryEditForm`
|
|
181
|
+
(`IAIToolsSettings`) wrapped with `layout.wrap_form` for classic Plone, plus a
|
|
182
|
+
`RegistryConfigletPanel` adapter named `ai-tools` for the Volto REST surface
|
|
183
|
+
(`@controlpanels/ai-tools`). The Volto widget itself is not built yet — that's
|
|
184
|
+
the deferred frontend work.
|
|
185
|
+
|
|
186
|
+
## Classic-UI edit-form integration (editor tools)
|
|
187
|
+
|
|
188
|
+
Editor-facing UI is built as **Patternslib patterns** bundled with webpack (the
|
|
189
|
+
same setup other ESP packages use, e.g. `collective.alerts`). Each tool is a
|
|
190
|
+
REST endpoint + a viewlet that emits a Patternslib *trigger element* + a pattern
|
|
191
|
+
in the bundle. There are four edit-form tools:
|
|
192
|
+
|
|
193
|
+
| Tool | Endpoint | Button fills |
|
|
194
|
+
| --- | --- | --- |
|
|
195
|
+
| Help me write | `@aitools-help-me-write` | TinyMCE body (draft / rewrite) |
|
|
196
|
+
| Get image details | `@aitools-recognize-image` | Title / Description / Tags from an image |
|
|
197
|
+
| Summarize | `@aitools-summarize` | Description from the body text |
|
|
198
|
+
| Suggest tags | `@aitools-suggest-tags` | Tags from the body text (vocabulary-aware) |
|
|
199
|
+
|
|
200
|
+
- **REST endpoints** — `services/*.py`, all `for="*"`, permission
|
|
201
|
+
`cmf.ModifyPortalContent`; each just drives its tool and returns `{result}` /
|
|
202
|
+
`{error}`. Model choice / prompts / the enabled gate stay server-side. The
|
|
203
|
+
text tools take the current form text in the body (`{text}`) since the content
|
|
204
|
+
may be unsaved; recognize-image takes the chosen `{image}` data-URI.
|
|
205
|
+
- **Viewlets** — `browser/*.py` render (in `IPortalFooter`, only on edit/add
|
|
206
|
+
forms and only when the tool is enabled) a hidden `<div class="pat-aitools-…">`
|
|
207
|
+
whose `data-pat-aitools-…` attribute carries the JSON config (endpoint,
|
|
208
|
+
translated labels, …). No script/CSS is injected by the viewlet — the bundle
|
|
209
|
+
is loaded globally by the resource registry.
|
|
210
|
+
- **Patterns** (bundle source under [`resources/`](../../../resources), *not*
|
|
211
|
+
`browser/static`): `help-me-write.pattern.js` hooks the global `window.tinymce`
|
|
212
|
+
registry; `recognize-image.pattern.js` attaches a button per image file field;
|
|
213
|
+
`summarize.pattern.js` / `suggest-tags.pattern.js` are built from the shared
|
|
214
|
+
`lib/content-tool.js` factory (read body text via `lib/fields.getBodyText` →
|
|
215
|
+
POST → fill one empty field). Shared field/widget logic (locating widgets,
|
|
216
|
+
fill-if-empty, the select2 tags API) lives in `lib/fields.js`; the button+status
|
|
217
|
+
UI in `lib/ui.js`. All tools fill target fields **only when empty**. SCSS is
|
|
218
|
+
imported by the patterns.
|
|
219
|
+
|
|
220
|
+
### Shared backend helpers
|
|
221
|
+
|
|
222
|
+
- `tools/text.py` — `get_body_text(context)` (title + description + every
|
|
223
|
+
RichText field, HTML-stripped, capped) and `clean_tags()` (trim / de-dupe /
|
|
224
|
+
clamp), reused by the tools and the CLI scripts.
|
|
225
|
+
- `backfill.py` — `setup_admin` / `resolve_site` / `run_backfill(site,
|
|
226
|
+
process_item, …)`: the catalog-walk + tally + periodic-commit loop behind every
|
|
227
|
+
`scripts/*.py` backfill (recognize-images, summarize-content, suggest-tags).
|
|
228
|
+
|
|
229
|
+
### Building the bundle
|
|
230
|
+
|
|
231
|
+
Source is ES6 in `backend/resources/`; webpack (via `@patternslib/dev`) compiles
|
|
232
|
+
it to `browser/static/aitools-remote.min.js` (module-federation remote entry).
|
|
233
|
+
That output **is committed** and ships in the wheel.
|
|
234
|
+
|
|
235
|
+
```sh
|
|
236
|
+
make install-npm # npm install (first time)
|
|
237
|
+
make build-resources # npm run build → browser/static/aitools-*.min.js
|
|
238
|
+
make watch-resources # rebuild on change
|
|
239
|
+
make check-resources # CI: build + fail if the committed bundle is stale
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Wiring: `resources/index.js` → `resources/aitools-config.js` imports the
|
|
243
|
+
patterns and calls `registry.init()`. The bundle is registered as
|
|
244
|
+
`plone.bundles/aitools` in
|
|
245
|
+
[`profiles/default/registry/resources.xml`](profiles/default/registry/resources.xml)
|
|
246
|
+
pointing at `++plone++collective.aitools/aitools-remote.min.js`; the
|
|
247
|
+
`++plone++collective.aitools` static directory is declared with `<plone:static>`
|
|
248
|
+
in [`browser/configure.zcml`](browser/configure.zcml).
|
|
249
|
+
|
|
250
|
+
When you add another edit-form tool, follow the same endpoint + viewlet-trigger
|
|
251
|
+
+ pattern trio. The TinyMCE selection/replacement helpers and the field-fill
|
|
252
|
+
helpers in the patterns are the reusable parts.
|
|
253
|
+
|
|
254
|
+
Registry records use **no prefix** (keyed
|
|
255
|
+
`collective.aitools.interfaces.IAIToolsSettings.<field>`), matching aisettings.
|
|
256
|
+
|
|
257
|
+
## collective.aisettings coupling
|
|
258
|
+
|
|
259
|
+
- [`dependencies.zcml`](dependencies.zcml) `<include>`s `collective.aisettings`
|
|
260
|
+
so its `IAIService` utility is registered (it also auto-includes in a real
|
|
261
|
+
site via `plone.autoinclude`; the explicit include is what makes it load in
|
|
262
|
+
the test layer).
|
|
263
|
+
- [`profiles/default/metadata.xml`](profiles/default/metadata.xml) depends on
|
|
264
|
+
`profile-collective.aisettings:default` so installing this add-on installs
|
|
265
|
+
aisettings too.
|
|
266
|
+
- Both this package's and aisettings' `setuphandlers/initial.py` import
|
|
267
|
+
`plone.exportimport` (Plone 6.1 core, for example-content import). The dev
|
|
268
|
+
venv must have it; if a fresh `make install` lacks it, install
|
|
269
|
+
`plone.exportimport` (matching aisettings' pin, 2.0.1).
|
|
270
|
+
|
|
271
|
+
## What's deferred
|
|
272
|
+
|
|
273
|
+
- **Embeddings tool** — intentionally not implemented yet (vector
|
|
274
|
+
storage/proximity-search backend undecided; `collective.vectorsearch`
|
|
275
|
+
exists but computes embeddings locally via FastEmbed rather than through
|
|
276
|
+
aisettings). Once available, `search_site` should rank semantically.
|
|
277
|
+
- **Volto counterparts** of these tools, the assistant widget, and the
|
|
278
|
+
control-panel widget.
|
|
279
|
+
- **Assistant hardening** — anonymous access + rate-limiting/abuse controls for
|
|
280
|
+
the chat endpoint (today it's authenticated-only by viewlet, `View` by
|
|
281
|
+
endpoint); streaming replies; more `apply` proposal types beyond
|
|
282
|
+
`create_draft`; showing `steps` in the widget.
|
|
283
|
+
|
|
284
|
+
## Tests
|
|
285
|
+
|
|
286
|
+
`backend/tests/tools/test_tools.py` and
|
|
287
|
+
`backend/tests/controlpanels/test_ai_tools.py`. The `fake_service` fixture
|
|
288
|
+
swaps the global `IAIService` for a recorder so `run` can be asserted without
|
|
289
|
+
a network/model. Run with `make test` from `backend/`.
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Shared catalog-walk helper for the CLI backfill scripts.
|
|
2
|
+
|
|
3
|
+
The ``scripts/*.py`` entry points (run via ``zconsole run``) bind the admin
|
|
4
|
+
user, resolve the Plone site and then hand a per-item callable to
|
|
5
|
+
:func:`run_backfill`, which walks the catalog subtree, tallies outcomes and
|
|
6
|
+
commits periodically. Keeping the walk here (rather than in each script) means
|
|
7
|
+
the scripts stay tiny and the logic is import-testable.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from AccessControl.SecurityManagement import newSecurityManager
|
|
11
|
+
|
|
12
|
+
import transaction
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def setup_admin(app):
|
|
16
|
+
"""Bind the Zope ``admin`` user as the acting user. Returns the user."""
|
|
17
|
+
admin = app.acl_users.getUserById("admin")
|
|
18
|
+
if admin is None:
|
|
19
|
+
raise SystemExit("No 'admin' user found; cannot run.")
|
|
20
|
+
newSecurityManager(None, admin.__of__(app.acl_users))
|
|
21
|
+
return admin
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def resolve_site(app, site_id):
|
|
25
|
+
"""Return the Plone site object, setting it as the active local site."""
|
|
26
|
+
from zope.component.hooks import setSite
|
|
27
|
+
|
|
28
|
+
site = app.get(site_id)
|
|
29
|
+
if site is None:
|
|
30
|
+
raise SystemExit(f"Plone site {site_id!r} not found under the app root.")
|
|
31
|
+
setSite(site)
|
|
32
|
+
return site
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def resolve_path(site, path):
|
|
36
|
+
"""Turn a user-supplied ``--path`` into an absolute catalog path.
|
|
37
|
+
|
|
38
|
+
Accepts an absolute path ("/Plone/news") or one relative to the site
|
|
39
|
+
("news"); an empty path means the whole site.
|
|
40
|
+
"""
|
|
41
|
+
site_path = "/".join(site.getPhysicalPath())
|
|
42
|
+
if not path:
|
|
43
|
+
return site_path
|
|
44
|
+
if not path.startswith("/"):
|
|
45
|
+
path = "/" + path
|
|
46
|
+
if path == site_path or path.startswith(site_path + "/"):
|
|
47
|
+
return path
|
|
48
|
+
return site_path + path
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def run_backfill(
|
|
52
|
+
site,
|
|
53
|
+
process_item,
|
|
54
|
+
*,
|
|
55
|
+
path="",
|
|
56
|
+
dry_run=False,
|
|
57
|
+
commit_every=10,
|
|
58
|
+
limit=0,
|
|
59
|
+
):
|
|
60
|
+
"""Walk the catalog subtree and apply ``process_item`` to each object.
|
|
61
|
+
|
|
62
|
+
``process_item(obj)`` must return one of ``"updated"``, ``"skipped"`` or
|
|
63
|
+
``"failed"`` and is responsible for its own field logic (it should not
|
|
64
|
+
commit). Returns a tally dict with keys ``processed``, ``updated``,
|
|
65
|
+
``skipped`` and ``failed``.
|
|
66
|
+
"""
|
|
67
|
+
catalog = site.portal_catalog
|
|
68
|
+
target_path = resolve_path(site, path)
|
|
69
|
+
print(f"Scanning {target_path!r} ...")
|
|
70
|
+
|
|
71
|
+
tally = {"processed": 0, "updated": 0, "skipped": 0, "failed": 0}
|
|
72
|
+
for brain in catalog(path={"query": target_path}):
|
|
73
|
+
try:
|
|
74
|
+
obj = brain.getObject()
|
|
75
|
+
except Exception as exc:
|
|
76
|
+
print(f" ! {brain.getPath()}: could not load ({exc})")
|
|
77
|
+
tally["failed"] += 1
|
|
78
|
+
continue
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
outcome = process_item(obj)
|
|
82
|
+
except Exception as exc:
|
|
83
|
+
print(f" ! {obj.absolute_url()}: failed ({exc})")
|
|
84
|
+
tally["failed"] += 1
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
if outcome is None:
|
|
88
|
+
continue # not applicable to this item; don't count it
|
|
89
|
+
|
|
90
|
+
tally["processed"] += 1
|
|
91
|
+
tally[outcome] += 1
|
|
92
|
+
due = commit_every and tally["updated"] % commit_every == 0
|
|
93
|
+
if outcome == "updated" and not dry_run and due:
|
|
94
|
+
transaction.commit()
|
|
95
|
+
|
|
96
|
+
if limit and tally["processed"] >= limit:
|
|
97
|
+
print(f"Reached --limit {limit}; stopping.")
|
|
98
|
+
break
|
|
99
|
+
|
|
100
|
+
if dry_run:
|
|
101
|
+
transaction.abort()
|
|
102
|
+
else:
|
|
103
|
+
transaction.commit()
|
|
104
|
+
|
|
105
|
+
print(
|
|
106
|
+
"Done. processed={processed} updated={updated} "
|
|
107
|
+
"skipped={skipped} failed={failed}".format(**tally)
|
|
108
|
+
+ (" (dry-run, nothing written)" if dry_run else "")
|
|
109
|
+
)
|
|
110
|
+
return tally
|
|
File without changes
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Viewlet that mounts the classic-UI chat "Assistant" widget.
|
|
2
|
+
|
|
3
|
+
Renders (site-wide, in ``IPortalFooter``) the Patternslib trigger for the chat
|
|
4
|
+
widget when the ``assistant`` tool is enabled and the visitor is *authenticated*
|
|
5
|
+
(anonymous visitors don't get the widget in this first, editor-focused slice).
|
|
6
|
+
We deliberately gate on "logged in or not" rather than a permission, because
|
|
7
|
+
the viewlet is rendered on the portal root where an editor permission may not
|
|
8
|
+
be granted; the individual capabilities enforce their own permissions.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from collective.aitools import _
|
|
12
|
+
from collective.aitools.tools import get_tool
|
|
13
|
+
from plone import api
|
|
14
|
+
from plone.app.layout.viewlets.common import ViewletBase
|
|
15
|
+
from plone.protect.authenticator import createToken
|
|
16
|
+
from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile
|
|
17
|
+
from zope.i18n import translate
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AssistantViewlet(ViewletBase):
|
|
23
|
+
index = ViewPageTemplateFile("templates/assistant_viewlet.pt")
|
|
24
|
+
|
|
25
|
+
def available(self) -> bool:
|
|
26
|
+
tool = get_tool("assistant")
|
|
27
|
+
if tool is None or not tool.is_enabled():
|
|
28
|
+
return False
|
|
29
|
+
# Show only to authenticated users (not anonymous). No permission check:
|
|
30
|
+
# the viewlet renders on the root, where editor perms may be absent.
|
|
31
|
+
return not api.user.is_anonymous()
|
|
32
|
+
|
|
33
|
+
def render(self):
|
|
34
|
+
if not self.available():
|
|
35
|
+
return ""
|
|
36
|
+
return self.index()
|
|
37
|
+
|
|
38
|
+
def endpoint_url(self) -> str:
|
|
39
|
+
return f"{self.context.absolute_url()}/@aitools-assistant"
|
|
40
|
+
|
|
41
|
+
def apply_url(self) -> str:
|
|
42
|
+
return f"{self.context.absolute_url()}/@aitools-assistant-apply"
|
|
43
|
+
|
|
44
|
+
def config_json(self) -> str:
|
|
45
|
+
t = lambda msg: translate(msg, context=self.request)
|
|
46
|
+
return json.dumps({
|
|
47
|
+
"endpoint": self.endpoint_url(),
|
|
48
|
+
"applyEndpoint": self.apply_url(),
|
|
49
|
+
# CSRF token for the write (apply) request; plone.protect accepts
|
|
50
|
+
# it via the X-CSRF-TOKEN header. Freshly minted per page render.
|
|
51
|
+
"token": createToken(),
|
|
52
|
+
"labels": {
|
|
53
|
+
"title": t(_("Assistant")),
|
|
54
|
+
"open": t(_("Open the assistant")),
|
|
55
|
+
"close": t(_("Close")),
|
|
56
|
+
"new_chat": t(_("Start new chat")),
|
|
57
|
+
"placeholder": t(_("Ask me anything about this site…")),
|
|
58
|
+
"send": t(_("Send")),
|
|
59
|
+
"loading": t(_("Thinking…")),
|
|
60
|
+
"error": t(_("Something went wrong. Please try again.")),
|
|
61
|
+
"create": t(_("Create draft")),
|
|
62
|
+
"creating": t(_("Creating…")),
|
|
63
|
+
"created": t(_("Draft created:")),
|
|
64
|
+
"open_item": t(_("Open")),
|
|
65
|
+
},
|
|
66
|
+
})
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
<configure
|
|
2
|
+
xmlns="http://namespaces.zope.org/zope"
|
|
3
|
+
xmlns:browser="http://namespaces.zope.org/browser"
|
|
4
|
+
xmlns:plone="http://namespaces.plone.org/plone"
|
|
5
|
+
i18n_domain="collective.aitools"
|
|
6
|
+
>
|
|
7
|
+
|
|
8
|
+
<!-- Webpack build output (Patternslib bundle) served under
|
|
9
|
+
++plone++collective.aitools, registered as a Plone bundle in
|
|
10
|
+
profiles/default/registry/resources.xml. -->
|
|
11
|
+
<plone:static
|
|
12
|
+
directory="static"
|
|
13
|
+
name="collective.aitools"
|
|
14
|
+
type="plone"
|
|
15
|
+
/>
|
|
16
|
+
|
|
17
|
+
<browser:viewlet
|
|
18
|
+
name="collective.aitools.help_me_write"
|
|
19
|
+
manager="plone.app.layout.viewlets.interfaces.IPortalFooter"
|
|
20
|
+
class=".help_me_write.HelpMeWriteViewlet"
|
|
21
|
+
permission="zope2.View"
|
|
22
|
+
layer="..interfaces.IBrowserLayer"
|
|
23
|
+
/>
|
|
24
|
+
|
|
25
|
+
<browser:viewlet
|
|
26
|
+
name="collective.aitools.recognize_image"
|
|
27
|
+
manager="plone.app.layout.viewlets.interfaces.IPortalFooter"
|
|
28
|
+
class=".recognize_image.RecognizeImageViewlet"
|
|
29
|
+
permission="zope2.View"
|
|
30
|
+
layer="..interfaces.IBrowserLayer"
|
|
31
|
+
/>
|
|
32
|
+
|
|
33
|
+
<browser:viewlet
|
|
34
|
+
name="collective.aitools.summarize"
|
|
35
|
+
manager="plone.app.layout.viewlets.interfaces.IPortalFooter"
|
|
36
|
+
class=".summarize.SummarizeViewlet"
|
|
37
|
+
permission="zope2.View"
|
|
38
|
+
layer="..interfaces.IBrowserLayer"
|
|
39
|
+
/>
|
|
40
|
+
|
|
41
|
+
<browser:viewlet
|
|
42
|
+
name="collective.aitools.suggest_tags"
|
|
43
|
+
manager="plone.app.layout.viewlets.interfaces.IPortalFooter"
|
|
44
|
+
class=".suggest_tags.SuggestTagsViewlet"
|
|
45
|
+
permission="zope2.View"
|
|
46
|
+
layer="..interfaces.IBrowserLayer"
|
|
47
|
+
/>
|
|
48
|
+
|
|
49
|
+
<browser:viewlet
|
|
50
|
+
name="collective.aitools.assistant"
|
|
51
|
+
manager="plone.app.layout.viewlets.interfaces.IPortalFooter"
|
|
52
|
+
class=".assistant.AssistantViewlet"
|
|
53
|
+
permission="zope2.View"
|
|
54
|
+
layer="..interfaces.IBrowserLayer"
|
|
55
|
+
/>
|
|
56
|
+
|
|
57
|
+
</configure>
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Viewlet that emits the classic-UI "Help me write" Patternslib trigger.
|
|
2
|
+
|
|
3
|
+
Only renders when the ``help_me_write`` tool is enabled and the current view
|
|
4
|
+
is an edit or add form. It emits a hidden element carrying a small JSON config
|
|
5
|
+
(endpoint URL, the operations with their labels and whether they need typed
|
|
6
|
+
input, and the translated UI strings) in its ``data-pat-aitools-help-me-write``
|
|
7
|
+
attribute. The ``aitools`` bundle (built from ``resources/``) picks that up and
|
|
8
|
+
builds the TinyMCE button. The bundle itself is loaded globally through the
|
|
9
|
+
resource registry, so the viewlet no longer injects any script/CSS.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from collective.aitools import _
|
|
13
|
+
from collective.aitools.tools import get_tool
|
|
14
|
+
from collective.aitools.tools.help_me_write import OPERATION_TITLES
|
|
15
|
+
from collective.aitools.tools.help_me_write import OPERATIONS_REQUIRING_INPUT
|
|
16
|
+
from plone.app.layout.viewlets.common import ViewletBase
|
|
17
|
+
from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile
|
|
18
|
+
from zope.i18n import translate
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class HelpMeWriteViewlet(ViewletBase):
|
|
24
|
+
index = ViewPageTemplateFile("templates/help_me_write_viewlet.pt")
|
|
25
|
+
|
|
26
|
+
def _is_form_view(self) -> bool:
|
|
27
|
+
url = (self.request.get("ACTUAL_URL", "") or self.request.getURL()).rstrip("/")
|
|
28
|
+
last = url.rsplit("/", 1)[-1]
|
|
29
|
+
return last in ("edit",) or "++add++" in url
|
|
30
|
+
|
|
31
|
+
def available(self) -> bool:
|
|
32
|
+
tool = get_tool("help_me_write")
|
|
33
|
+
if tool is None or not tool.is_enabled():
|
|
34
|
+
return False
|
|
35
|
+
return self._is_form_view()
|
|
36
|
+
|
|
37
|
+
def render(self):
|
|
38
|
+
if not self.available():
|
|
39
|
+
return ""
|
|
40
|
+
return self.index()
|
|
41
|
+
|
|
42
|
+
# -- template helpers --
|
|
43
|
+
|
|
44
|
+
def endpoint_url(self) -> str:
|
|
45
|
+
return f"{self.context.absolute_url()}/@aitools-help-me-write"
|
|
46
|
+
|
|
47
|
+
def _operations(self):
|
|
48
|
+
return [
|
|
49
|
+
{
|
|
50
|
+
"id": op_id,
|
|
51
|
+
"label": translate(title, context=self.request),
|
|
52
|
+
"needsInput": op_id in OPERATIONS_REQUIRING_INPUT,
|
|
53
|
+
}
|
|
54
|
+
for op_id, title in OPERATION_TITLES.items()
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
def config_json(self) -> str:
|
|
58
|
+
t = lambda msg: translate(msg, context=self.request)
|
|
59
|
+
return json.dumps({
|
|
60
|
+
"endpoint": self.endpoint_url(),
|
|
61
|
+
"operations": self._operations(),
|
|
62
|
+
"labels": {
|
|
63
|
+
"button": t(_("Help me write")),
|
|
64
|
+
"send": t(_("Send")),
|
|
65
|
+
"loading": t(_("Generating...")),
|
|
66
|
+
"error": t(_("Something went wrong. Please try again.")),
|
|
67
|
+
"noText": t(_("Select or enter some text first.")),
|
|
68
|
+
"draftPlaceholder": t(_("What do you want to write about?")),
|
|
69
|
+
},
|
|
70
|
+
})
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Viewlet that emits the classic-UI "Get image details" Patternslib trigger.
|
|
2
|
+
|
|
3
|
+
Only renders when the ``recognize_image`` tool is enabled and the current view
|
|
4
|
+
is an edit or add form. It emits a hidden element carrying a small JSON config
|
|
5
|
+
(endpoint URL and the translated UI strings) in its
|
|
6
|
+
``data-pat-aitools-recognize-image`` attribute. The ``aitools`` bundle (built
|
|
7
|
+
from ``resources/``) picks that up and adds a "Get image details" button next
|
|
8
|
+
to each image file field. The bundle itself is loaded globally through the
|
|
9
|
+
resource registry, so the viewlet no longer injects any script/CSS.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from collective.aitools import _
|
|
13
|
+
from collective.aitools.tools import get_tool
|
|
14
|
+
from plone.app.layout.viewlets.common import ViewletBase
|
|
15
|
+
from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile
|
|
16
|
+
from zope.i18n import translate
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class RecognizeImageViewlet(ViewletBase):
|
|
22
|
+
index = ViewPageTemplateFile("templates/recognize_image_viewlet.pt")
|
|
23
|
+
|
|
24
|
+
def _is_form_view(self) -> bool:
|
|
25
|
+
url = (self.request.get("ACTUAL_URL", "") or self.request.getURL()).rstrip("/")
|
|
26
|
+
last = url.rsplit("/", 1)[-1]
|
|
27
|
+
return last in ("edit",) or "++add++" in url
|
|
28
|
+
|
|
29
|
+
def available(self) -> bool:
|
|
30
|
+
tool = get_tool("recognize_image")
|
|
31
|
+
if tool is None or not tool.is_enabled():
|
|
32
|
+
return False
|
|
33
|
+
return self._is_form_view()
|
|
34
|
+
|
|
35
|
+
def render(self):
|
|
36
|
+
if not self.available():
|
|
37
|
+
return ""
|
|
38
|
+
return self.index()
|
|
39
|
+
|
|
40
|
+
# -- template helpers --
|
|
41
|
+
|
|
42
|
+
def endpoint_url(self) -> str:
|
|
43
|
+
return f"{self.context.absolute_url()}/@aitools-recognize-image"
|
|
44
|
+
|
|
45
|
+
def config_json(self) -> str:
|
|
46
|
+
t = lambda msg: translate(msg, context=self.request)
|
|
47
|
+
return json.dumps({
|
|
48
|
+
"endpoint": self.endpoint_url(),
|
|
49
|
+
"labels": {
|
|
50
|
+
"button": t(_("Get image details")),
|
|
51
|
+
"loading": t(_("Analyzing image...")),
|
|
52
|
+
"error": t(_("Something went wrong. Please try again.")),
|
|
53
|
+
"noImage": t(_("Choose an image first.")),
|
|
54
|
+
"done": t(_("Filled in the empty fields.")),
|
|
55
|
+
"readError": t(_("Could not read the selected image.")),
|
|
56
|
+
},
|
|
57
|
+
})
|