unmagic-components 0.3.0 → 0.4.0
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +98 -1
- data/README.md +506 -1
- data/app/assets/icons/lucide/LICENSE +43 -0
- data/app/assets/icons/lucide/arrow-down.svg +1 -0
- data/app/assets/icons/lucide/arrow-left.svg +1 -0
- data/app/assets/icons/lucide/arrow-up.svg +1 -0
- data/app/assets/icons/lucide/ban.svg +1 -0
- data/app/assets/icons/lucide/brain.svg +1 -0
- data/app/assets/icons/lucide/check.svg +1 -0
- data/app/assets/icons/lucide/chevron-down.svg +1 -0
- data/app/assets/icons/lucide/chevron-left.svg +1 -0
- data/app/assets/icons/lucide/chevron-right.svg +1 -0
- data/app/assets/icons/lucide/circle-check.svg +1 -0
- data/app/assets/icons/lucide/circle-dashed.svg +1 -0
- data/app/assets/icons/lucide/circle-question-mark.svg +1 -0
- data/app/assets/icons/lucide/circle-stop.svg +1 -0
- data/app/assets/icons/lucide/circle-x.svg +1 -0
- data/app/assets/icons/lucide/clock.svg +1 -0
- data/app/assets/icons/lucide/copy.svg +1 -0
- data/app/assets/icons/lucide/ellipsis-vertical.svg +1 -0
- data/app/assets/icons/lucide/file.svg +1 -0
- data/app/assets/icons/lucide/folder.svg +1 -0
- data/app/assets/icons/lucide/grip-vertical.svg +1 -0
- data/app/assets/icons/lucide/info.svg +1 -0
- data/app/assets/icons/lucide/lightbulb.svg +1 -0
- data/app/assets/icons/lucide/list-checks.svg +1 -0
- data/app/assets/icons/lucide/loader-circle.svg +1 -0
- data/app/assets/icons/lucide/messages-square.svg +1 -0
- data/app/assets/icons/lucide/paperclip.svg +1 -0
- data/app/assets/icons/lucide/pencil.svg +1 -0
- data/app/assets/icons/lucide/plus.svg +1 -0
- data/app/assets/icons/lucide/rotate-cw.svg +1 -0
- data/app/assets/icons/lucide/shield-alert.svg +1 -0
- data/app/assets/icons/lucide/timer.svg +1 -0
- data/app/assets/icons/lucide/triangle-alert.svg +1 -0
- data/app/assets/icons/lucide/wrench.svg +1 -0
- data/app/assets/icons/lucide/x.svg +1 -0
- data/app/assets/javascripts/unmagic/components/ai_chat.js +142 -0
- data/app/assets/javascripts/unmagic/components/autoscroll.js +103 -0
- data/app/assets/javascripts/unmagic/components/board.js +65 -0
- data/app/assets/javascripts/unmagic/components/dropzone.js +206 -0
- data/app/assets/javascripts/unmagic/components/elapsed.js +64 -0
- data/app/assets/javascripts/unmagic/components/optimistic.js +71 -0
- data/app/assets/javascripts/unmagic/components/placement.js +30 -0
- data/app/assets/javascripts/unmagic/components/slash_menu.js +186 -0
- data/app/assets/javascripts/unmagic/components/sortable.js +6 -0
- data/app/assets/javascripts/unmagic/components/sortable_item.js +122 -0
- data/app/assets/javascripts/unmagic/components/sortable_list.js +135 -0
- data/app/assets/javascripts/unmagic/components/sortable_param.js +14 -0
- data/app/assets/javascripts/unmagic/components/sortable_session.js +502 -0
- data/app/assets/javascripts/unmagic/components/streaming_markdown.js +269 -0
- data/app/assets/javascripts/unmagic/components/toasts.js +14 -4
- data/app/assets/javascripts/unmagic/components/toolbar.js +76 -0
- data/app/assets/javascripts/unmagic/components/upsert.js +4 -20
- data/app/assets/javascripts/unmagic/components.js +10 -0
- data/app/assets/tailwind/unmagic_components/engine.css +1515 -0
- data/lib/unmagic/components/action_view_helpers.rb +558 -3
- data/lib/unmagic/components/ai_chat/action_bar.rb +70 -0
- data/lib/unmagic/components/ai_chat/attachments.rb +119 -0
- data/lib/unmagic/components/ai_chat/branch_picker.rb +65 -0
- data/lib/unmagic/components/ai_chat/citation.rb +89 -0
- data/lib/unmagic/components/ai_chat/composer.rb +160 -0
- data/lib/unmagic/components/ai_chat/failure.rb +80 -0
- data/lib/unmagic/components/ai_chat/message.rb +142 -0
- data/lib/unmagic/components/ai_chat/payload.rb +93 -0
- data/lib/unmagic/components/ai_chat/permission.rb +143 -0
- data/lib/unmagic/components/ai_chat/plan.rb +91 -0
- data/lib/unmagic/components/ai_chat/proposal.rb +102 -0
- data/lib/unmagic/components/ai_chat/reasoning.rb +75 -0
- data/lib/unmagic/components/ai_chat/request.rb +206 -0
- data/lib/unmagic/components/ai_chat/section.rb +53 -0
- data/lib/unmagic/components/ai_chat/slash_menu.rb +68 -0
- data/lib/unmagic/components/ai_chat/tool_call.rb +181 -0
- data/lib/unmagic/components/ai_chat/transcript.rb +68 -0
- data/lib/unmagic/components/ai_chat/welcome.rb +73 -0
- data/lib/unmagic/components/ai_chat/workspace.rb +74 -0
- data/lib/unmagic/components/ai_chat.rb +31 -0
- data/lib/unmagic/components/avatar.rb +134 -0
- data/lib/unmagic/components/board.rb +182 -0
- data/lib/unmagic/components/browser/app/controllers/unmagic/components/browser/application_controller.rb +80 -0
- data/lib/unmagic/components/browser/app/controllers/unmagic/components/browser/demos_controller.rb +137 -0
- data/lib/unmagic/components/browser/app/controllers/unmagic/components/browser/pages_controller.rb +40 -0
- data/lib/unmagic/components/browser/app/helpers/unmagic/components/browser/application_helper.rb +52 -0
- data/lib/unmagic/components/browser/app/models/unmagic/components/browser/board_store.rb +88 -0
- data/lib/unmagic/components/browser/app/models/unmagic/components/browser/catalog.rb +65 -0
- data/lib/unmagic/components/browser/app/models/unmagic/components/browser/pager.rb +11 -0
- data/lib/unmagic/components/browser/app/models/unmagic/components/browser/profile.rb +25 -0
- data/lib/unmagic/components/browser/app/models/unmagic/components/browser/reply.rb +72 -0
- data/lib/unmagic/components/browser/app/models/unmagic/components/browser/thing.rb +31 -0
- data/lib/unmagic/components/browser/app/views/layouts/unmagic/components/browser/application.html.erb +65 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/application/_code.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/application/_crumb.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/application/_example.html.erb +37 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/application/_nav.html.erb +17 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/demos/edit_profile.html.erb +9 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat/_conversation.html.erb +101 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat/_live.html.erb +53 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat/_thumbnail.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat/_welcome.html.erb +12 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_action_bar/_always.html.erb +6 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_action_bar/_hover.html.erb +12 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_action_bar/_thumbnail.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_attachments/_dropzone.html.erb +17 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_attachments/_sent.html.erb +11 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_attachments/_thumbnail.html.erb +6 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_branch_picker/_positions.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_branch_picker/_thumbnail.html.erb +3 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_citation/_compact.html.erb +10 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_citation/_quote.html.erb +11 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_citation/_thumbnail.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_composer/_parts.html.erb +14 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_composer/_states.html.erb +8 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_composer/_thumbnail.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_failure/_full.html.erb +11 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_failure/_plain.html.erb +3 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_failure/_thumbnail.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_message/_parts.html.erb +23 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_message/_states.html.erb +9 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_message/_thumbnail.html.erb +4 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_message/_turns.html.erb +16 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_payload/_json.html.erb +4 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_payload/_text.html.erb +4 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_payload/_thumbnail.html.erb +3 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_permission/_answered.html.erb +12 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_permission/_thumbnail.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_permission/_waiting.html.erb +12 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_plan/_empty.html.erb +3 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_plan/_states.html.erb +9 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_plan/_thumbnail.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_proposal/_decided.html.erb +11 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_proposal/_inline.html.erb +18 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_proposal/_thumbnail.html.erb +6 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_reasoning/_settled.html.erb +6 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_reasoning/_thinking.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_reasoning/_thumbnail.html.erb +3 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_request/_answered.html.erb +10 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_request/_choices.html.erb +13 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_request/_form.html.erb +9 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_request/_thumbnail.html.erb +8 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_slash_menu/_composer.html.erb +15 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_slash_menu/_thumbnail.html.erb +9 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_tool_call/_made.html.erb +12 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_tool_call/_run.html.erb +25 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_tool_call/_states.html.erb +22 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_tool_call/_thumbnail.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_welcome/_suggestions.html.erb +11 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_welcome/_thumbnail.html.erb +6 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_workspace/_files.html.erb +12 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_workspace/_thumbnail.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/avatar/_group.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/avatar/_sizes.html.erb +8 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/avatar/_thumbnail.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/avatar/_tints.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/badge/_in_context.html.erb +14 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/badge/_thumbnail.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/badge/_tones.html.erb +8 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/board/_handles.html.erb +13 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/board/_live.html.erb +34 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/board/_thumbnail.html.erb +10 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/button/_sizes.html.erb +12 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/button/_thumbnail.html.erb +4 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/button/_variants.html.erb +6 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/callout/_neutral.html.erb +3 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/callout/_thumbnail.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/callout/_tones.html.erb +14 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/card/_actions_and_footer.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/card/_flush_table.html.erb +6 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/card/_link.html.erb +6 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/card/_thumbnail.html.erb +6 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/confirm/_danger.html.erb +3 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/confirm/_plain.html.erb +2 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/confirm/_thumbnail.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/copy_button/_from_element.html.erb +4 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/copy_button/_thumbnail.html.erb +4 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/copy_button/_with_label.html.erb +3 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/detail_list/_inline.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/detail_list/_stacked.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/detail_list/_thumbnail.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/dialog/_dialog_tag.html.erb +8 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/dialog/_modal.html.erb +10 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/dialog/_thumbnail.html.erb +4 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/elapsed/_countdown.html.erb +3 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/elapsed/_running.html.erb +8 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/elapsed/_thumbnail.html.erb +4 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/empty_state/_default.html.erb +1 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/empty_state/_thumbnail.html.erb +3 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/forms/_autogrow.html.erb +4 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/forms/_control_classes.html.erb +54 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/forms/_fields.html.erb +28 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/forms/_states.html.erb +11 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/forms/_thumbnail.html.erb +8 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/forms/_uuid_field.html.erb +9 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/local_time/_absolute.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/local_time/_relative.html.erb +14 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/local_time/_thumbnail.html.erb +14 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/menu/_actions.html.erb +10 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/menu/_labelled.html.erb +6 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/menu/_thumbnail.html.erb +14 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/page_header/_default.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/page_header/_thumbnail.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/page_header/_with_leading.html.erb +14 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/skeleton/_blocking_out.html.erb +25 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/skeleton/_card.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/skeleton/_detail_list.html.erb +15 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/skeleton/_page_header.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/skeleton/_text_and_button.html.erb +17 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/skeleton/_thumbnail.html.erb +10 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/sortable_list/_connected.html.erb +17 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/sortable_list/_grid.html.erb +20 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/sortable_list/_handles.html.erb +22 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/sortable_list/_thumbnail.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/streaming_markdown/_reveal.html.erb +19 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/streaming_markdown/_stopped.html.erb +31 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/streaming_markdown/_thumbnail.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/table/_deferred.html.erb +6 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/table/_empty.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/table/_sortable.html.erb +8 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/table/_table_tag.html.erb +4 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/table/_thumbnail.html.erb +4 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/tabs/_links.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/tabs/_panels.html.erb +18 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/tabs/_thumbnail.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/toast/_above_dialog.html.erb +10 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/toast/_flash.html.erb +13 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/toast/_stream.html.erb +6 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/toast/_thumbnail.html.erb +10 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/tooltip/_placement.html.erb +8 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/tooltip/_term.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/tooltip/_thumbnail.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/pages/component.html.erb +58 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/pages/installation.html.erb +47 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/pages/overview.html.erb +49 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/pages/theming.html.erb +52 -0
- data/lib/unmagic/components/browser/assets/browser.css +2 -0
- data/lib/unmagic/components/browser/catalog/ai_chat.rb +21 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_action_bar.rb +15 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_attachments.rb +15 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_branch_picker.rb +12 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_citation.rb +15 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_composer.rb +16 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_failure.rb +15 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_message.rb +17 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_payload.rb +16 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_permission.rb +15 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_plan.rb +14 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_proposal.rb +15 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_reasoning.rb +14 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_request.rb +17 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_slash_menu.rb +14 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_tool_call.rb +17 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_welcome.rb +13 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_workspace.rb +12 -0
- data/lib/unmagic/components/browser/catalog/avatar.rb +15 -0
- data/lib/unmagic/components/browser/catalog/badge.rb +11 -0
- data/lib/unmagic/components/browser/catalog/board.rb +18 -0
- data/lib/unmagic/components/browser/catalog/button.rb +14 -0
- data/lib/unmagic/components/browser/catalog/callout.rb +13 -0
- data/lib/unmagic/components/browser/catalog/card.rb +16 -0
- data/lib/unmagic/components/browser/catalog/confirm.rb +14 -0
- data/lib/unmagic/components/browser/catalog/copy_button.rb +15 -0
- data/lib/unmagic/components/browser/catalog/detail_list.rb +14 -0
- data/lib/unmagic/components/browser/catalog/dialog.rb +15 -0
- data/lib/unmagic/components/browser/catalog/elapsed.rb +14 -0
- data/lib/unmagic/components/browser/catalog/empty_state.rb +11 -0
- data/lib/unmagic/components/browser/catalog/forms.rb +21 -0
- data/lib/unmagic/components/browser/catalog/local_time.rb +13 -0
- data/lib/unmagic/components/browser/catalog/menu.rb +15 -0
- data/lib/unmagic/components/browser/catalog/page_header.rb +13 -0
- data/lib/unmagic/components/browser/catalog/skeleton.rb +18 -0
- data/lib/unmagic/components/browser/catalog/sortable_list.rb +17 -0
- data/lib/unmagic/components/browser/catalog/streaming_markdown.rb +17 -0
- data/lib/unmagic/components/browser/catalog/table.rb +17 -0
- data/lib/unmagic/components/browser/catalog/tabs.rb +15 -0
- data/lib/unmagic/components/browser/catalog/toast.rb +17 -0
- data/lib/unmagic/components/browser/catalog/tooltip.rb +14 -0
- data/lib/unmagic/components/browser/config/routes.rb +41 -0
- data/lib/unmagic/components/browser/tailwind/browser.css +33 -0
- data/lib/unmagic/components/browser.rb +39 -0
- data/lib/unmagic/components/configuration.rb +31 -1
- data/lib/unmagic/components/duration.rb +35 -0
- data/lib/unmagic/components/elapsed.rb +46 -0
- data/lib/unmagic/components/engine.rb +9 -0
- data/lib/unmagic/components/icons.rb +34 -26
- data/lib/unmagic/components/modal.rb +1 -1
- data/lib/unmagic/components/page_header.rb +8 -0
- data/lib/unmagic/components/sortable.rb +104 -0
- data/lib/unmagic/components/streaming_markdown.rb +34 -0
- data/lib/unmagic/components/turbo_stream_actions.rb +16 -0
- data/lib/unmagic/components/version.rb +1 -1
- data/lib/unmagic/components.rb +30 -1
- metadata +308 -4
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unmagic
|
|
4
|
+
module Components
|
|
5
|
+
module AIChat
|
|
6
|
+
# Files attached to a turn: the tiles a sent turn carries. The drop zone that
|
|
7
|
+
# takes them is Dropzone. See ActionViewHelpers#ai_chat_attachments.
|
|
8
|
+
class Attachments
|
|
9
|
+
ALIGNMENTS = %i[start end].freeze
|
|
10
|
+
|
|
11
|
+
File = Struct.new(:name, :size, :url, :thumbnail)
|
|
12
|
+
|
|
13
|
+
def initialize(view, align: :start, **options)
|
|
14
|
+
AIChat.validate!("ai_chat_attachments", :align, align, ALIGNMENTS)
|
|
15
|
+
|
|
16
|
+
@view = view
|
|
17
|
+
@align = align
|
|
18
|
+
@options = options
|
|
19
|
+
@files = []
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def file(name, size: nil, url: nil, thumbnail: nil)
|
|
23
|
+
@files << File.new(name.to_s, size, url, thumbnail)
|
|
24
|
+
nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def render
|
|
28
|
+
return "".html_safe if @files.empty?
|
|
29
|
+
|
|
30
|
+
view.content_tag(:ul, **@options,
|
|
31
|
+
class: view.class_names("UnmagicAIChatAttachments", "UnmagicAIChatAttachments--#{@align}", @options[:class])) do
|
|
32
|
+
safe_join(@files.map { |file| item(file) })
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
attr_reader :view
|
|
39
|
+
|
|
40
|
+
delegate :tag, :safe_join, to: :view, private: true
|
|
41
|
+
|
|
42
|
+
# A thumbnail is decoration: the name beside it is the name.
|
|
43
|
+
def item(file)
|
|
44
|
+
content = safe_join [
|
|
45
|
+
(file.thumbnail ? tag.img(src: file.thumbnail, alt: "", class: "UnmagicAIChatAttachments__thumb") : Icons.svg(view, :file, class: "UnmagicAIChatAttachments__glyph")),
|
|
46
|
+
tag.span(file.name, class: "UnmagicAIChatAttachments__name"),
|
|
47
|
+
(tag.span(view.number_to_human_size(file.size), class: "UnmagicAIChatAttachments__size") if file.size)
|
|
48
|
+
].compact
|
|
49
|
+
|
|
50
|
+
tag.li(class: "UnmagicAIChatAttachments__file") do
|
|
51
|
+
file.url ? view.link_to(content, file.url, class: "UnmagicAIChatAttachments__link") : content
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# A region that takes dropped and pasted files. See
|
|
57
|
+
# ActionViewHelpers#ai_chat_dropzone.
|
|
58
|
+
class Dropzone
|
|
59
|
+
def initialize(view, input:, url: nil, field: nil, chips: nil, label: nil, **options)
|
|
60
|
+
raise ArgumentError, "ai_chat_dropzone needs an input: (dragging is never the only way in)" if input.blank?
|
|
61
|
+
raise ArgumentError, "an uploading ai_chat_dropzone needs a field: to post the uploads under" if url && field.blank?
|
|
62
|
+
|
|
63
|
+
@view = view
|
|
64
|
+
@input = input
|
|
65
|
+
@url = url
|
|
66
|
+
@field = field
|
|
67
|
+
@chips = chips
|
|
68
|
+
@label = label
|
|
69
|
+
@options = options
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The overlay can't take the pointer, or it would swallow the drop it
|
|
73
|
+
# advertises; and it's only feedback for a gesture no keyboard can make.
|
|
74
|
+
def render(content)
|
|
75
|
+
label = @label || AIChat.t("attachments.drop", default: "Drop to attach")
|
|
76
|
+
|
|
77
|
+
view.content_tag("unmagic-dropzone", **@options,
|
|
78
|
+
input: @input, url: @url, field: @field, chips: @chips,
|
|
79
|
+
data: {
|
|
80
|
+
attached: AIChat.t("attachments.attached", name: "{name}", default: "%{name} attached"),
|
|
81
|
+
failed: AIChat.t("attachments.failed", name: "{name}", default: "Couldn't attach %{name}"),
|
|
82
|
+
remove: AIChat.t("attachments.remove", name: "{name}", default: "Remove %{name}")
|
|
83
|
+
}.merge(@options[:data] || {}),
|
|
84
|
+
class: view.class_names("UnmagicAIChatDropzone", @options[:class])) do
|
|
85
|
+
safe_join [
|
|
86
|
+
tag.div(tag.p(safe_join([ Icons.svg(view, :paperclip), label ])),
|
|
87
|
+
class: "UnmagicAIChatDropzone__overlay", "aria-hidden": "true"),
|
|
88
|
+
content,
|
|
89
|
+
tag.span(class: "UnmagicVisuallyHidden", "aria-live": "polite", "data-dropzone-status": ""),
|
|
90
|
+
chip
|
|
91
|
+
].compact
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
attr_reader :view
|
|
98
|
+
|
|
99
|
+
delegate :tag, :safe_join, to: :view, private: true
|
|
100
|
+
|
|
101
|
+
# What an attached file looks like before it's sent. The element clones it,
|
|
102
|
+
# so the chip is the server's markup rather than HTML built in script.
|
|
103
|
+
def chip
|
|
104
|
+
tag.template("data-dropzone-chip": "") do
|
|
105
|
+
tag.div(class: "UnmagicAIChatAttachments__file") do
|
|
106
|
+
safe_join [
|
|
107
|
+
Icons.svg(view, :file, class: "UnmagicAIChatAttachments__glyph"),
|
|
108
|
+
tag.span(class: "UnmagicAIChatAttachments__name", "data-dropzone-name": ""),
|
|
109
|
+
tag.span(class: "UnmagicAIChatAttachments__size", "data-dropzone-size": ""),
|
|
110
|
+
tag.button(Icons.svg(view, :x), type: "button", class: "UnmagicAIChatAttachments__remove",
|
|
111
|
+
"data-dropzone-remove": "")
|
|
112
|
+
]
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unmagic
|
|
4
|
+
module Components
|
|
5
|
+
module AIChat
|
|
6
|
+
# Walking between alternative versions of a turn. It renders counts and
|
|
7
|
+
# follows links; what a branch is stays the host's business. See
|
|
8
|
+
# ActionViewHelpers#ai_chat_branch_picker.
|
|
9
|
+
class BranchPicker
|
|
10
|
+
def initialize(view, index:, count:, previous: nil, next: nil, method: :get, **options)
|
|
11
|
+
raise ArgumentError, "ai_chat_branch_picker count must be at least 1 (got #{count.inspect})" unless count.to_i >= 1
|
|
12
|
+
unless (1..count).cover?(index)
|
|
13
|
+
raise ArgumentError, "ai_chat_branch_picker index #{index.inspect} is outside 1..#{count}"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
@view = view
|
|
17
|
+
@index = index
|
|
18
|
+
@count = count
|
|
19
|
+
@previous = previous
|
|
20
|
+
@next = binding.local_variable_get(:next)
|
|
21
|
+
@method = method
|
|
22
|
+
@options = options
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# One version isn't a branch, and a 1/1 on every turn is noise on every turn.
|
|
26
|
+
def render
|
|
27
|
+
return "".html_safe if @count == 1
|
|
28
|
+
|
|
29
|
+
label = AIChat.t("branch_picker.label", index: @index, count: @count, default: "Version %{index} of %{count}")
|
|
30
|
+
|
|
31
|
+
view.content_tag(:div, **@options, role: "group", "aria-label": label,
|
|
32
|
+
class: view.class_names("UnmagicAIChatBranchPicker", @options[:class])) do
|
|
33
|
+
safe_join [
|
|
34
|
+
step(@previous, :chevron_left, AIChat.t("branch_picker.previous", default: "Previous version")),
|
|
35
|
+
tag.span("#{@index}/#{@count}", class: "UnmagicAIChatBranchPicker__count", "aria-hidden": "true"),
|
|
36
|
+
step(@next, :chevron_right, AIChat.t("branch_picker.next", default: "Next version"))
|
|
37
|
+
]
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
attr_reader :view
|
|
44
|
+
|
|
45
|
+
delegate :tag, :safe_join, to: :view, private: true
|
|
46
|
+
|
|
47
|
+
# An end with nowhere to go is a span, not a disabled link: there's no such
|
|
48
|
+
# thing, and it would be a lie to the keyboard.
|
|
49
|
+
def step(url, icon, label)
|
|
50
|
+
classes = "UnmagicAIChatBranchPicker__step"
|
|
51
|
+
glyph = Icons.svg(view, icon)
|
|
52
|
+
|
|
53
|
+
if url.nil?
|
|
54
|
+
tag.span(glyph, "aria-disabled": "true", title: label, class: classes)
|
|
55
|
+
elsif @method == :get
|
|
56
|
+
view.link_to(glyph, url, "aria-label": label, title: label, class: classes)
|
|
57
|
+
else
|
|
58
|
+
view.button_to(url, method: @method, "aria-label": label, title: label, class: classes,
|
|
59
|
+
form: { class: "UnmagicAIChatBranchPicker__form" }) { glyph }
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unmagic
|
|
4
|
+
module Components
|
|
5
|
+
module AIChat
|
|
6
|
+
# A quotation of a record the application holds, rendered from the record
|
|
7
|
+
# rather than from the model's paraphrase of it. See
|
|
8
|
+
# ActionViewHelpers#ai_chat_citation.
|
|
9
|
+
class Citation
|
|
10
|
+
def initialize(view, compact: false, **options)
|
|
11
|
+
@view = view
|
|
12
|
+
@compact = compact
|
|
13
|
+
@options = options
|
|
14
|
+
@avatar = nil
|
|
15
|
+
@who = nil
|
|
16
|
+
@when = nil
|
|
17
|
+
@quote = nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# With no block, the gem's own avatar for whoever who: names, hidden from
|
|
21
|
+
# assistive technology since the name is read beside it.
|
|
22
|
+
def avatar(content = nil, src: nil, &block)
|
|
23
|
+
@avatar = block ? view.capture(&block) : (content || { src: src })
|
|
24
|
+
nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def who(text)
|
|
28
|
+
@who = text
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def when(time, url: nil)
|
|
33
|
+
@when = [ time, url ]
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Plain text, escaped, newlines kept. The quoted record can't inject markup
|
|
38
|
+
# through it, and neither can the model that chose to quote it.
|
|
39
|
+
def quote(text)
|
|
40
|
+
@quote = text.to_s
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def render
|
|
45
|
+
return "".html_safe if [ @who, @when, @quote ].all?(&:blank?)
|
|
46
|
+
|
|
47
|
+
view.content_tag(:figure, **@options,
|
|
48
|
+
class: view.class_names("UnmagicAIChatCitation", "not-prose",
|
|
49
|
+
{ "UnmagicAIChatCitation--compact" => @compact }, @options[:class])) do
|
|
50
|
+
safe_join [
|
|
51
|
+
(tag.div(avatar_markup, class: "UnmagicAIChatCitation__avatar") if @avatar && !@compact),
|
|
52
|
+
tag.div(class: "UnmagicAIChatCitation__main") do
|
|
53
|
+
safe_join [ caption, (tag.blockquote(@quote, cite: @when&.last, class: "UnmagicAIChatCitation__quote") if @quote.present?) ].compact
|
|
54
|
+
end
|
|
55
|
+
].compact
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
attr_reader :view
|
|
62
|
+
|
|
63
|
+
delegate :tag, :safe_join, to: :view, private: true
|
|
64
|
+
|
|
65
|
+
def avatar_markup
|
|
66
|
+
return @avatar unless @avatar.is_a?(Hash)
|
|
67
|
+
|
|
68
|
+
Avatar.new(view, @who, src: @avatar[:src], size: :small, "aria-hidden": "true").render
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def caption
|
|
72
|
+
return if @who.blank? && @when.nil?
|
|
73
|
+
|
|
74
|
+
tag.figcaption(class: "UnmagicAIChatCitation__caption") do
|
|
75
|
+
safe_join [ (tag.span(@who, class: "UnmagicAIChatCitation__who") if @who.present?), timestamp ].compact
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def timestamp
|
|
80
|
+
return unless @when
|
|
81
|
+
|
|
82
|
+
time, url = @when
|
|
83
|
+
stamp = view.local_time_tag(time)
|
|
84
|
+
url ? view.link_to(stamp, url, class: "UnmagicAIChatCitation__when") : tag.span(stamp, class: "UnmagicAIChatCitation__when")
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unmagic
|
|
4
|
+
module Components
|
|
5
|
+
module AIChat
|
|
6
|
+
# The box a person types into, and the one button at the end of it. See
|
|
7
|
+
# ActionViewHelpers#ai_chat_composer.
|
|
8
|
+
class Composer
|
|
9
|
+
STATES = %i[idle running stopping waiting].freeze
|
|
10
|
+
STOP_FORM = "stop_turn"
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
# The action region on its own: Send, or Stop while a turn runs. Neither
|
|
14
|
+
# button is nested in the form it submits (both name it with form=), which
|
|
15
|
+
# is what lets a broadcast redraw this region without a form builder and
|
|
16
|
+
# without taking the half-typed draft with it.
|
|
17
|
+
def action(view, form:, state:, label: nil, stop_form: STOP_FORM)
|
|
18
|
+
AIChat.validate!("ai_chat_composer", :state, state, STATES)
|
|
19
|
+
|
|
20
|
+
view.tag.div(id: "#{form}_action", class: "UnmagicAIChatComposer__action", "aria-live": "polite") do
|
|
21
|
+
case state
|
|
22
|
+
when :idle then send_button(view, form, label)
|
|
23
|
+
when :running then stop_button(view, stop_form)
|
|
24
|
+
when :stopping then stopping_button(view)
|
|
25
|
+
when :waiting
|
|
26
|
+
view.safe_join [
|
|
27
|
+
view.tag.span(AIChat.t("composer.waiting", default: "Waiting for your input…"),
|
|
28
|
+
class: "UnmagicAIChatComposer__hint"),
|
|
29
|
+
stop_button(view, stop_form)
|
|
30
|
+
]
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def send_button(view, form, label)
|
|
38
|
+
label ||= AIChat.t("composer.send", default: "Send")
|
|
39
|
+
view.tag.button(type: "submit", form: form, class: Button.classes(:primary)) do
|
|
40
|
+
view.safe_join [ view.tag.span(label), Icons.svg(view, :arrow_up) ]
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def stop_button(view, stop_form)
|
|
45
|
+
view.tag.button(type: "submit", form: stop_form,
|
|
46
|
+
class: view.class_names(Button.classes, "UnmagicAIChatComposer__stop")) do
|
|
47
|
+
view.safe_join [ Icons.svg(view, :circle_stop), AIChat.t("composer.stop", default: "Stop") ]
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Asked, not yet granted: a button still reading Stop looks like one that
|
|
52
|
+
# didn't work, and gets pressed again.
|
|
53
|
+
def stopping_button(view)
|
|
54
|
+
view.tag.button(type: "button", disabled: true, class: Button.classes) do
|
|
55
|
+
view.safe_join [
|
|
56
|
+
Icons.svg(view, :loader_circle, class: "UnmagicAIChatSpinner"),
|
|
57
|
+
AIChat.t("composer.stopping", default: "Stopping…")
|
|
58
|
+
]
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def initialize(view, form:, field:, state: :idle, label: nil, stop_form: STOP_FORM, placeholder: nil,
|
|
64
|
+
rows: 2, **options)
|
|
65
|
+
form_id = form.options[:id]
|
|
66
|
+
raise ArgumentError, "ai_chat_composer needs its form to have an id: (the Send button names it)" if form_id.blank?
|
|
67
|
+
|
|
68
|
+
AIChat.validate!("ai_chat_composer", :state, state, STATES)
|
|
69
|
+
|
|
70
|
+
@view = view
|
|
71
|
+
@form = form
|
|
72
|
+
@form_id = form_id.to_s
|
|
73
|
+
@field = field
|
|
74
|
+
@state = state
|
|
75
|
+
@label = label
|
|
76
|
+
@stop_form = stop_form
|
|
77
|
+
@placeholder = placeholder
|
|
78
|
+
@rows = rows
|
|
79
|
+
@options = options
|
|
80
|
+
@optimistic = nil
|
|
81
|
+
@attach = nil
|
|
82
|
+
@menu = nil
|
|
83
|
+
@actions = nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Draw the question into the transcript the moment it's sent, under an id
|
|
87
|
+
# the form mints and the server's own row will come back with.
|
|
88
|
+
def optimistic(id:, container:)
|
|
89
|
+
@optimistic = { id: id, container: container }
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def attach(content = nil, &block)
|
|
94
|
+
@attach = block ? view.capture(&block) : content
|
|
95
|
+
nil
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def menu(content = nil, &block)
|
|
99
|
+
@menu = block ? view.capture(&block) : content
|
|
100
|
+
nil
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def actions(content = nil, &block)
|
|
104
|
+
@actions = block ? view.capture(&block) : content
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def render
|
|
109
|
+
box = tag.div(class: "UnmagicAIChatComposer__box") do
|
|
110
|
+
safe_join [
|
|
111
|
+
(view.uuid_input_tag(@optimistic[:id]) if @optimistic),
|
|
112
|
+
field,
|
|
113
|
+
(tag.div(@attach, class: "UnmagicAIChatComposer__attach") if @attach.present?),
|
|
114
|
+
(tag.div(@actions, class: "UnmagicAIChatComposer__actions") if @actions.present?),
|
|
115
|
+
self.class.action(view, form: @form_id, state: @state, label: @label, stop_form: @stop_form)
|
|
116
|
+
].compact
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
view.content_tag(:div, **@options, class: view.class_names("UnmagicAIChatComposer", @options[:class])) do
|
|
120
|
+
safe_join [
|
|
121
|
+
@menu.presence,
|
|
122
|
+
tag.div(class: "UnmagicAIChatComposer__chips", "data-ai-chat-chips": ""),
|
|
123
|
+
(@optimistic ? optimistic_wrapper(box) : box)
|
|
124
|
+
].compact
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
attr_reader :view
|
|
131
|
+
|
|
132
|
+
delegate :tag, :safe_join, to: :view, private: true
|
|
133
|
+
|
|
134
|
+
# The textarea is the composer's own chrome rather than a styled control:
|
|
135
|
+
# the box around it is the control and carries the focus ring, so it skips
|
|
136
|
+
# the control_class seam that would give it a border of its own. It is
|
|
137
|
+
# never disabled, whatever the state — only the button changes.
|
|
138
|
+
def field
|
|
139
|
+
label = @placeholder.presence || AIChat.t("composer.label", default: "Message")
|
|
140
|
+
value = @form.object.try(@field) if @form.object.respond_to?(@field)
|
|
141
|
+
|
|
142
|
+
Autogrow.wrap(view, view.text_area_tag(@form.field_name(@field), value,
|
|
143
|
+
id: @form.field_id(@field), rows: @rows, required: true, placeholder: @placeholder,
|
|
144
|
+
"aria-label": label, class: "UnmagicAIChatComposer__field", "data-ai-chat-composer-field": ""))
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def optimistic_wrapper(box)
|
|
148
|
+
text = @form.field_name(@field)
|
|
149
|
+
|
|
150
|
+
view.content_tag("unmagic-optimistic", container: @optimistic[:container], class: "UnmagicOptimistic") do
|
|
151
|
+
safe_join [
|
|
152
|
+
box,
|
|
153
|
+
tag.template(Message.new(view, role: :user, optimistic: { id: @optimistic[:id], text: text }).render(""))
|
|
154
|
+
]
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unmagic
|
|
4
|
+
module Components
|
|
5
|
+
module AIChat
|
|
6
|
+
# A turn that fell over, and what it fell over on. See
|
|
7
|
+
# ActionViewHelpers#ai_chat_failure.
|
|
8
|
+
class Failure
|
|
9
|
+
def initialize(view, live: false, **options)
|
|
10
|
+
@view = view
|
|
11
|
+
@live = live
|
|
12
|
+
@options = options
|
|
13
|
+
@cause = nil
|
|
14
|
+
@details = []
|
|
15
|
+
@retry = nil
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# One line naming the error, above the fold.
|
|
19
|
+
def cause(text)
|
|
20
|
+
@cause = text
|
|
21
|
+
nil
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# A row inside the disclosure, for whoever is working on the assistant.
|
|
25
|
+
def detail(label, payload, language: nil)
|
|
26
|
+
@details << Payload.new(view, payload, label: label, language: language)
|
|
27
|
+
nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def retry(content = nil, &block)
|
|
31
|
+
@retry = block ? view.capture(&block) : content
|
|
32
|
+
nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The sentence is always the caller's: the raw exception is for the logs,
|
|
36
|
+
# never for whoever asked. role="alert" only when streamed in, or every past
|
|
37
|
+
# failure in a reloaded transcript would be announced.
|
|
38
|
+
def render(message)
|
|
39
|
+
details = @details.map(&:render).select(&:present?)
|
|
40
|
+
|
|
41
|
+
view.content_tag(:div, **@options, role: ("alert" if @live),
|
|
42
|
+
class: view.class_names("UnmagicAIChatFailure", @options[:class])) do
|
|
43
|
+
safe_join [
|
|
44
|
+
tag.div(class: "UnmagicAIChatFailure__head") do
|
|
45
|
+
safe_join [
|
|
46
|
+
Icons.svg(view, :circle_x, class: "UnmagicAIChatFailure__icon"),
|
|
47
|
+
tag.div(class: "UnmagicAIChatFailure__text") do
|
|
48
|
+
safe_join [
|
|
49
|
+
tag.p(message, class: "UnmagicAIChatFailure__message"),
|
|
50
|
+
(tag.p(@cause, class: "UnmagicAIChatFailure__cause") if @cause.present?)
|
|
51
|
+
].compact
|
|
52
|
+
end
|
|
53
|
+
]
|
|
54
|
+
end,
|
|
55
|
+
(disclosure(details) if details.any?),
|
|
56
|
+
(tag.div(@retry, class: "UnmagicAIChatFailure__retry") if @retry.present?)
|
|
57
|
+
].compact
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
attr_reader :view
|
|
64
|
+
|
|
65
|
+
delegate :tag, :safe_join, to: :view, private: true
|
|
66
|
+
|
|
67
|
+
def disclosure(details)
|
|
68
|
+
tag.details(class: "UnmagicAIChatFailure__details") do
|
|
69
|
+
safe_join [
|
|
70
|
+
tag.summary(class: "UnmagicAIChatFailure__summary") do
|
|
71
|
+
safe_join [ AIChat.t("failure.details", default: "Details"), Icons.svg(view, :chevron_right, class: "UnmagicAIChatChevron") ]
|
|
72
|
+
end,
|
|
73
|
+
tag.div(safe_join(details), class: "UnmagicAIChatFailure__body")
|
|
74
|
+
]
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unmagic
|
|
4
|
+
module Components
|
|
5
|
+
module AIChat
|
|
6
|
+
# One turn: a user's bubble of plain text, or an assistant's unbubbled
|
|
7
|
+
# prose. See ActionViewHelpers#ai_chat_message.
|
|
8
|
+
class Message
|
|
9
|
+
ROLES = %i[user assistant].freeze
|
|
10
|
+
|
|
11
|
+
def initialize(view, role:, id: nil, streaming: false, final: false, optimistic: nil, **options)
|
|
12
|
+
AIChat.validate!("ai_chat_message", :role, role, ROLES)
|
|
13
|
+
if optimistic && role != :user
|
|
14
|
+
raise ArgumentError, "only a user turn can be optimistic (the composer draws what was typed)"
|
|
15
|
+
end
|
|
16
|
+
if optimistic && !(optimistic.key?(:id) && optimistic.key?(:text))
|
|
17
|
+
raise ArgumentError, "optimistic: needs id: and text:, naming the form fields to fill them from"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
@view = view
|
|
21
|
+
@role = role
|
|
22
|
+
@id = id
|
|
23
|
+
@streaming = streaming
|
|
24
|
+
@final = final
|
|
25
|
+
@optimistic = optimistic
|
|
26
|
+
@options = options
|
|
27
|
+
@reasoning = nil
|
|
28
|
+
@actions = nil
|
|
29
|
+
@branches = nil
|
|
30
|
+
@attachments = nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# A collapsed "thought process" above the body. Takes ai_chat_reasoning's
|
|
34
|
+
# options; the body is the block.
|
|
35
|
+
def reasoning(content = nil, **options, &block)
|
|
36
|
+
@reasoning = Reasoning.new(view, id: ("#{@id}_reasoning" if @id), **options)
|
|
37
|
+
.render(block ? view.capture(&block) : content)
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def actions(content = nil, &block)
|
|
42
|
+
@actions = block ? view.capture(&block) : content
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def branches(content = nil, &block)
|
|
47
|
+
@branches = block ? view.capture(&block) : content
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Above a user's bubble, which is the order it happened in.
|
|
52
|
+
def attachments(content = nil, &block)
|
|
53
|
+
@attachments = block ? view.capture(&block) : content
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def render(body)
|
|
58
|
+
@role == :user ? user(body) : assistant(body)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
attr_reader :view
|
|
64
|
+
|
|
65
|
+
delegate :tag, :safe_join, to: :view, private: true
|
|
66
|
+
|
|
67
|
+
def user(body)
|
|
68
|
+
data = @optimistic ? { optimistic_id: @optimistic[:id], optimistic: "" } : {}
|
|
69
|
+
|
|
70
|
+
root(data: data) do
|
|
71
|
+
safe_join [
|
|
72
|
+
role_label,
|
|
73
|
+
(tag.div(@attachments, class: "UnmagicAIChatMessage__attachments") if @attachments.present?),
|
|
74
|
+
tag.div(@optimistic ? "" : trimmed(body), class: "UnmagicAIChatMessage__bubble",
|
|
75
|
+
data: (@optimistic ? { optimistic_text: @optimistic[:text] } : {})),
|
|
76
|
+
footer
|
|
77
|
+
].compact
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# A settled turn with nothing to say is hidden rather than dropped, so a
|
|
82
|
+
# broadcast can still key on its id. A turn still streaming is never empty:
|
|
83
|
+
# it shows the thinking spinner until its first token replaces it.
|
|
84
|
+
def assistant(body)
|
|
85
|
+
blank = body.blank? && @reasoning.blank? && !@streaming
|
|
86
|
+
|
|
87
|
+
root(hidden: blank) do
|
|
88
|
+
safe_join [ role_label, @reasoning.presence, content(body), footer ].compact
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# The body is always the streaming element when the turn has an id, even once
|
|
93
|
+
# it has settled: the settled render upserts over the streaming one, and the
|
|
94
|
+
# element carries the reveal across that swap.
|
|
95
|
+
def content(body)
|
|
96
|
+
body = thinking if body.blank? && @streaming && !@final
|
|
97
|
+
return tag.div(body, class: "UnmagicAIChatMessage__body UnmagicProse") unless @id
|
|
98
|
+
|
|
99
|
+
StreamingMarkdown.new(view, id: "#{@id}_content", final: @final, streaming: @streaming,
|
|
100
|
+
class: "UnmagicAIChatMessage__body UnmagicProse").render(body)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# The bubble keeps the line breaks that were typed, so a block's own leading
|
|
104
|
+
# newline and indentation would show as a blank first line. Trimming the ends
|
|
105
|
+
# can't make markup, so already-escaped content stays safe.
|
|
106
|
+
def trimmed(body)
|
|
107
|
+
return body.to_s.strip unless body.respond_to?(:html_safe?) && body.html_safe?
|
|
108
|
+
|
|
109
|
+
body.to_str.strip.html_safe # rubocop:disable Rails/OutputSafety -- escaped content, only whitespace removed
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def thinking
|
|
113
|
+
tag.span(class: "UnmagicAIChatMessage__thinking") do
|
|
114
|
+
safe_join [
|
|
115
|
+
Icons.svg(view, :loader_circle, class: "UnmagicAIChatSpinner"),
|
|
116
|
+
tag.span(AIChat.t("message.thinking", default: "Thinking"), class: "UnmagicVisuallyHidden")
|
|
117
|
+
]
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def footer
|
|
122
|
+
return if @actions.blank? && @branches.blank?
|
|
123
|
+
|
|
124
|
+
tag.div(safe_join([ @branches.presence, @actions.presence ].compact), class: "UnmagicAIChatMessage__footer")
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def role_label
|
|
128
|
+
default = @role == :user ? "You said" : "Assistant said"
|
|
129
|
+
tag.span(AIChat.t("message.#{@role}", default: default), class: "UnmagicVisuallyHidden")
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def root(hidden: false, data: {}, &block)
|
|
133
|
+
view.content_tag(:div, **@options,
|
|
134
|
+
id: (@id unless @optimistic),
|
|
135
|
+
hidden: hidden,
|
|
136
|
+
data: data.merge(@options[:data] || {}),
|
|
137
|
+
class: view.class_names("UnmagicAIChatMessage", "UnmagicAIChatMessage--#{@role}", @options[:class]), &block)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|