opscale/fields
Composer 安装命令:
composer require opscale/fields
包简介
Family of custom fields for Laravel Nova. Includes AI: a conversational assistant that proposes values for the fields of a resource form.
README 文档
README
Family of custom fields for Laravel Nova. The first member is AI: a conversational assistant that lives inside a resource form, chats with the user, and proposes values for the other fields of the form. The user accepts or discards each proposal — the AI never writes anything by itself.
use Opscale\Fields\AI; public function fields(NovaRequest $request): array { return [ Text::make('Name'), Textarea::make('Description'), BelongsTo::make('Category', 'category'), AI::make('Assistant') ->fields(['name', 'description', 'category']) ->agent(ProductCopilot::class) ->context(fn (NovaRequest $r) => ['tenant_id' => $r->user()->tenant_id]) ->onlyOnForms(), ]; }
->fields([...])— which form attributes the assistant may propose values for. TheproposeFieldstool schema handed to the agent is derived from this single list, so it can never drift from the form.->agent(...)— a laravel/ai agent class extendingOpscale\Fields\AI\Agents\FieldAgent.->context(...)— extra server-side context (tenant, user...). Client input is never trusted for authorization.->maxDuration(120)— seconds after which the stream self-terminates (see footguns).
The field is headless: no database column, a no-op on save, forms only. If the panel's JavaScript ever fails, the form keeps working — the assistant is strictly additive and can be ignored entirely.
Requirements
| Dependency | Version | Why pinned |
|---|---|---|
| PHP | ^8.3 | required by laravel/ai |
| Laravel | ^12 | ^13 | required by laravel/ai |
| laravel/nova | ~5.9.0 (pinned) | accepted values are pushed into native fields through Nova's internal event bus ({formUniqueId}-{attribute}-value / -change). That naming is undocumented API; re-verify it in vendor/laravel/nova/public/app.js (getFieldAttributeValueEventName, listenToValueChanges) and the laravel-nova npm mixins (src/mixins/FormField.js) before raising the constraint. |
| laravel/ai | ^0.9 | streams the Vercel UI Message Stream v1 (x-vercel-ai-ui-message-stream: v1) |
| @ai-sdk/vue (bundled) | ~4.0.22 + ai ~7.0.22 | consumes exactly that protocol version — bump both sides of the protocol together |
Installation
composer require opscale/fields
Publish laravel/ai's config and conversation tables (the field persists chat history with RemembersConversations):
php artisan vendor:publish --provider="Laravel\Ai\AiServiceProvider"
php artisan migrate
Configure your provider key on the server — it never reaches the browser:
OPENAI_API_KEY=sk-... # or ANTHROPIC_API_KEY, etc. (config/ai.php)
Writing an agent
Extend FieldAgent: describe your domain in persona() and expose lookup tools for relationship fields. Everything else (proposal schema, current form values, server context, the proposeFields tool, conversation memory) is injected by the field's endpoint.
use Opscale\Fields\AI\Agents\FieldAgent; final class ProductCopilot extends FieldAgent { protected function persona(): string { return 'You are a product catalog copilot... derive name, description, ' .'price, SKU, stock and category from a short merchant description.'; } protected function lookupTools(): iterable { yield new SearchCategories; // returns real category IDs from the DB } }
Relationship fields are proposed as ID + display label: the ID is what fills the field, the label is what the user sees before accepting. The base instructions forbid inventing IDs — the model must pick them from a lookup tool.
The compression ratio
If the assistant asked one question per field, this would just be a slower form. The base prompt pushes the opposite: few high-level questions, many derived fields. Real exchange with the ProductCopilot demo (2 user answers → 6 fields proposed):
User: I want to list the wireless mouse we just received. Assistant: What price point and roughly how many units did you receive? User: Around $49.99, we got 200 units. Assistant: (proposes via
proposeFields)
- Name → "Wireless Pro Mouse"
- Description → "Precision wireless mouse with ergonomic design..."
- Price → 49.99
- SKU → "WPM-WL-001"
- Stock → 200
- Category → Peripherals (ID 1, from
searchCategories)
The user accepts all six, edits any of them by hand, or types "make the description more formal" to get a refined re-proposal — all inside the panel.
How it works
Vue panel (useChat, @ai-sdk/vue)
└── POST nova-vendor/nova-ai-field/{resource}/ai/chat ← Nova middleware
└── ChatController re-resolves the AI field on the resource
└── FieldAgent (laravel/ai) + RemembersConversations
├── lookup tools (real DB queries)
└── proposeFields tool → inert handle(), no server effect
└── ->stream()->usingVercelDataProtocol() (SSE, UI Message Stream v1)
Panel reads proposeFields tool invocations from the typed message parts
└── proposal list: field → value → [accept] [discard]
└── accept: Nova.$emit(`{formUniqueId}-{attribute}-value`, value)
The model never returns text that needs parsing: proposals arrive as a typed tool-input-available part. proposeFields.handle() exists because laravel/ai's Tool contract requires it, but it is deliberately inert — it only acknowledges, executing nothing.
Footguns
-
Proxy buffering. The endpoint already sends
X-Accel-Buffering: no, but nginx setups withproxy_buffering onwill still swallow the stream and deliver everything at once when it finishes. It looks like a code bug; it is not. Configure:location /nova-vendor/nova-ai-field/ { proxy_buffering off; }
-
PHP-FPM workers. Every open SSE stream holds a worker. Fine for a Nova panel (internal users, low concurrency), but keep a bound on it: the field passes
maxDuration(default 120s) as the stream timeout so a stuck stream frees its worker. Tune per field with->maxDuration(seconds)and sizepm.max_childrenaccordingly. -
Nova is pinned (
~5.9.0) because accepting a proposal uses Nova's internal event bus (see Requirements). When bumping Nova, re-check the event names before widening the constraint. -
Relationship fields. A
BelongsTois filled with an ID, never a label, so proposals carry both (value= ID,display= label). Known v1 limitation: Nova 5.9's BelongsTo form component does not subscribe to the-valueevent bus, so accepting a relation proposal may not visually fill the combobox — the user sees the proposed record's name in the panel and can select it manually. Text, textarea, number, markdown and similar native fields do subscribe and fill instantly.
Out of scope (v1)
- Inline ✨ buttons on native Nova fields.
- "Pending state" styling over native fields.
- Any override of Nova's Vue components (fragile across upgrades — everything lives inside this field's panel).
Development
composer serve # build workbench + Nova dev server (admin@laravel.com / password) npm run quality # duster + phpstan + pest npm run watch # rebuild JS on change
The workbench ships a Product resource wired to the ProductCopilot demo agent (workbench/app/Agents).
Package layout
src/
├── FieldServiceProvider.php shared provider: one route group + ONE asset bundle
├── AI.php the field class (Opscale\Fields\AI)
└── AI/ the field's support code (Opscale\Fields\AI\*)
├── Agents/FieldAgent.php
├── Http/Controllers/ChatController.php
└── Tools/ProposeFields.php
resources/js/
├── field.js single entrypoint registering every field's components
└── ai/FormField.vue
routes/api.php grouped under nova-vendor/nova-ai-field
src/AI.php (class) and src/AI/ (support namespace) coexist on purpose — valid PSR-4, do not flatten. The package is multi-field by design: new fields add components to the same bundle, routes to the same group, and registrations to the same provider.
统计信息
- 总下载量: 0
- 月度下载量: 0
- 日度下载量: 0
- 收藏数: 0
- 点击次数: 2
- 依赖项目数: 0
- 推荐数: 0
其他信息
- 授权协议: MIT
- 更新时间: 2026-07-11