@artooi/agents-web-components 0.0.0-stage → 0.1.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.
- package/CHANGELOG.md +68 -0
- package/LICENSE +21 -0
- package/README.md +4397 -2
- package/dist/agents-web-components.bundle.js +5708 -0
- package/dist/agents-web-components.bundle.js.map +7 -0
- package/dist/bundle.d.ts +3 -0
- package/dist/bundle.d.ts.map +1 -0
- package/dist/chat.d.ts +3 -0
- package/dist/chat.d.ts.map +1 -0
- package/dist/chat.js +16373 -0
- package/dist/chat.js.map +7 -0
- package/dist/chunk-ARNLFGCC.js +7715 -0
- package/dist/chunk-ARNLFGCC.js.map +7 -0
- package/dist/constants.d.ts +575 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/core/activity_registration.d.ts +19 -0
- package/dist/core/activity_registration.d.ts.map +1 -0
- package/dist/core/activity_registry.d.ts +46 -0
- package/dist/core/activity_registry.d.ts.map +1 -0
- package/dist/core/activity_renderer.d.ts +20 -0
- package/dist/core/activity_renderer.d.ts.map +1 -0
- package/dist/core/ag_ui_chat.d.ts +836 -0
- package/dist/core/ag_ui_chat.d.ts.map +1 -0
- package/dist/core/agui_client.d.ts +370 -0
- package/dist/core/agui_client.d.ts.map +1 -0
- package/dist/core/attachment.d.ts +42 -0
- package/dist/core/attachment.d.ts.map +1 -0
- package/dist/core/chat_registry.d.ts +23 -0
- package/dist/core/chat_registry.d.ts.map +1 -0
- package/dist/core/client_seed.d.ts +27 -0
- package/dist/core/client_seed.d.ts.map +1 -0
- package/dist/core/conversation_store.d.ts +195 -0
- package/dist/core/conversation_store.d.ts.map +1 -0
- package/dist/core/create_http_agent.d.ts +57 -0
- package/dist/core/create_http_agent.d.ts.map +1 -0
- package/dist/core/define_chat.d.ts +13 -0
- package/dist/core/define_chat.d.ts.map +1 -0
- package/dist/core/events/attachments_detail.d.ts +9 -0
- package/dist/core/events/attachments_detail.d.ts.map +1 -0
- package/dist/core/events/bar_height_detail.d.ts +6 -0
- package/dist/core/events/bar_height_detail.d.ts.map +1 -0
- package/dist/core/events/custom_agent_detail.d.ts +8 -0
- package/dist/core/events/custom_agent_detail.d.ts.map +1 -0
- package/dist/core/events/dock_width_detail.d.ts +8 -0
- package/dist/core/events/dock_width_detail.d.ts.map +1 -0
- package/dist/core/events/feedback_detail.d.ts +28 -0
- package/dist/core/events/feedback_detail.d.ts.map +1 -0
- package/dist/core/events/invalidate_detail.d.ts +16 -0
- package/dist/core/events/invalidate_detail.d.ts.map +1 -0
- package/dist/core/events/maximize_detail.d.ts +5 -0
- package/dist/core/events/maximize_detail.d.ts.map +1 -0
- package/dist/core/events/run_finished_detail.d.ts +36 -0
- package/dist/core/events/run_finished_detail.d.ts.map +1 -0
- package/dist/core/events/state_detail.d.ts +5 -0
- package/dist/core/events/state_detail.d.ts.map +1 -0
- package/dist/core/events/submit_detail.d.ts +8 -0
- package/dist/core/events/submit_detail.d.ts.map +1 -0
- package/dist/core/events/thread_detail.d.ts +27 -0
- package/dist/core/events/thread_detail.d.ts.map +1 -0
- package/dist/core/events/thread_title_detail.d.ts +8 -0
- package/dist/core/events/thread_title_detail.d.ts.map +1 -0
- package/dist/core/events/toggle_detail.d.ts +5 -0
- package/dist/core/events/toggle_detail.d.ts.map +1 -0
- package/dist/core/events/tool_run.d.ts +11 -0
- package/dist/core/events/tool_run.d.ts.map +1 -0
- package/dist/core/events/unread_detail.d.ts +11 -0
- package/dist/core/events/unread_detail.d.ts.map +1 -0
- package/dist/core/message_role.d.ts +4 -0
- package/dist/core/message_role.d.ts.map +1 -0
- package/dist/core/read_flag.d.ts +11 -0
- package/dist/core/read_flag.d.ts.map +1 -0
- package/dist/core/read_json_attribute.d.ts +15 -0
- package/dist/core/read_json_attribute.d.ts.map +1 -0
- package/dist/core/read_max_tool_rounds.d.ts +9 -0
- package/dist/core/read_max_tool_rounds.d.ts.map +1 -0
- package/dist/core/remote_conversation_store.d.ts +71 -0
- package/dist/core/remote_conversation_store.d.ts.map +1 -0
- package/dist/core/run_handlers.d.ts +85 -0
- package/dist/core/run_handlers.d.ts.map +1 -0
- package/dist/core/run_index.d.ts +58 -0
- package/dist/core/run_index.d.ts.map +1 -0
- package/dist/core/storage_scope.d.ts +118 -0
- package/dist/core/storage_scope.d.ts.map +1 -0
- package/dist/core/tool_outcome.d.ts +27 -0
- package/dist/core/tool_outcome.d.ts.map +1 -0
- package/dist/core/transcribe_audio.d.ts +32 -0
- package/dist/core/transcribe_audio.d.ts.map +1 -0
- package/dist/core/upload_attachment.d.ts +44 -0
- package/dist/core/upload_attachment.d.ts.map +1 -0
- package/dist/core/utils.d.ts +117 -0
- package/dist/core/utils.d.ts.map +1 -0
- package/dist/dom/animations.d.ts +122 -0
- package/dist/dom/animations.d.ts.map +1 -0
- package/dist/dom/dom_driver.d.ts +28 -0
- package/dist/dom/dom_driver.d.ts.map +1 -0
- package/dist/dom/highlight_overlay.d.ts +39 -0
- package/dist/dom/highlight_overlay.d.ts.map +1 -0
- package/dist/dom/native_setter.d.ts +5 -0
- package/dist/dom/native_setter.d.ts.map +1 -0
- package/dist/dom/ring_color.d.ts +16 -0
- package/dist/dom/ring_color.d.ts.map +1 -0
- package/dist/dom/utils.d.ts +3 -0
- package/dist/dom/utils.d.ts.map +1 -0
- package/dist/index.d.ts +62 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +223 -0
- package/dist/index.js.map +7 -0
- package/dist/skills/fill_template.d.ts +16 -0
- package/dist/skills/fill_template.d.ts.map +1 -0
- package/dist/skills/parse_skills.d.ts +8 -0
- package/dist/skills/parse_skills.d.ts.map +1 -0
- package/dist/skills/skill.d.ts +33 -0
- package/dist/skills/skill.d.ts.map +1 -0
- package/dist/skills/skill_catalog.d.ts +69 -0
- package/dist/skills/skill_catalog.d.ts.map +1 -0
- package/dist/skills/skill_name_from.d.ts +12 -0
- package/dist/skills/skill_name_from.d.ts.map +1 -0
- package/dist/tools/chat_surface_tools.d.ts +77 -0
- package/dist/tools/chat_surface_tools.d.ts.map +1 -0
- package/dist/tools/client_tool_registry.d.ts +58 -0
- package/dist/tools/client_tool_registry.d.ts.map +1 -0
- package/dist/tools/is_destructive.d.ts +14 -0
- package/dist/tools/is_destructive.d.ts.map +1 -0
- package/dist/tools/is_navigates.d.ts +9 -0
- package/dist/tools/is_navigates.d.ts.map +1 -0
- package/dist/tools/page_action_tools.d.ts +30 -0
- package/dist/tools/page_action_tools.d.ts.map +1 -0
- package/dist/tools/page_map.d.ts +16 -0
- package/dist/tools/page_map.d.ts.map +1 -0
- package/dist/tools/page_state.d.ts +27 -0
- package/dist/tools/page_state.d.ts.map +1 -0
- package/dist/tools/parse_tool_catalog.d.ts +21 -0
- package/dist/tools/parse_tool_catalog.d.ts.map +1 -0
- package/dist/tools/route_map.d.ts +37 -0
- package/dist/tools/route_map.d.ts.map +1 -0
- package/dist/tools/tool_catalog.d.ts +86 -0
- package/dist/tools/tool_catalog.d.ts.map +1 -0
- package/dist/tools/tool_dispatch.d.ts +82 -0
- package/dist/tools/tool_dispatch.d.ts.map +1 -0
- package/dist/ui/charts/chart_block.d.ts +57 -0
- package/dist/ui/charts/chart_block.d.ts.map +1 -0
- package/dist/ui/charts/chart_spec_from.d.ts +12 -0
- package/dist/ui/charts/chart_spec_from.d.ts.map +1 -0
- package/dist/ui/charts/chart_styles.d.ts +2 -0
- package/dist/ui/charts/chart_styles.d.ts.map +1 -0
- package/dist/ui/charts/chart_tool.d.ts +15 -0
- package/dist/ui/charts/chart_tool.d.ts.map +1 -0
- package/dist/ui/composer/attachment_styles.d.ts +2 -0
- package/dist/ui/composer/attachment_styles.d.ts.map +1 -0
- package/dist/ui/composer/attachment_tray.d.ts +53 -0
- package/dist/ui/composer/attachment_tray.d.ts.map +1 -0
- package/dist/ui/composer/auto_grow.d.ts +10 -0
- package/dist/ui/composer/auto_grow.d.ts.map +1 -0
- package/dist/ui/composer/build_composer.d.ts +32 -0
- package/dist/ui/composer/build_composer.d.ts.map +1 -0
- package/dist/ui/composer/composer_attachments.d.ts +57 -0
- package/dist/ui/composer/composer_attachments.d.ts.map +1 -0
- package/dist/ui/composer/composer_styles.d.ts +2 -0
- package/dist/ui/composer/composer_styles.d.ts.map +1 -0
- package/dist/ui/composer/composer_voice.d.ts +48 -0
- package/dist/ui/composer/composer_voice.d.ts.map +1 -0
- package/dist/ui/composer/skill_styles.d.ts +2 -0
- package/dist/ui/composer/skill_styles.d.ts.map +1 -0
- package/dist/ui/composer/skills_menu.d.ts +34 -0
- package/dist/ui/composer/skills_menu.d.ts.map +1 -0
- package/dist/ui/composer/voice_input.d.ts +46 -0
- package/dist/ui/composer/voice_input.d.ts.map +1 -0
- package/dist/ui/excerpts/attach_copy_buttons.d.ts +13 -0
- package/dist/ui/excerpts/attach_copy_buttons.d.ts.map +1 -0
- package/dist/ui/excerpts/copy_payload.d.ts +30 -0
- package/dist/ui/excerpts/copy_payload.d.ts.map +1 -0
- package/dist/ui/excerpts/page_quote_offer.d.ts +33 -0
- package/dist/ui/excerpts/page_quote_offer.d.ts.map +1 -0
- package/dist/ui/excerpts/place_offer.d.ts +9 -0
- package/dist/ui/excerpts/place_offer.d.ts.map +1 -0
- package/dist/ui/excerpts/quote_selection.d.ts +66 -0
- package/dist/ui/excerpts/quote_selection.d.ts.map +1 -0
- package/dist/ui/excerpts/transcript_quote_offer.d.ts +57 -0
- package/dist/ui/excerpts/transcript_quote_offer.d.ts.map +1 -0
- package/dist/ui/fill_ui_string.d.ts +32 -0
- package/dist/ui/fill_ui_string.d.ts.map +1 -0
- package/dist/ui/history/checkpoint_menu.d.ts +41 -0
- package/dist/ui/history/checkpoint_menu.d.ts.map +1 -0
- package/dist/ui/history/conversation_history.d.ts +317 -0
- package/dist/ui/history/conversation_history.d.ts.map +1 -0
- package/dist/ui/history/history_styles.d.ts +2 -0
- package/dist/ui/history/history_styles.d.ts.map +1 -0
- package/dist/ui/history/relative_time.d.ts +23 -0
- package/dist/ui/history/relative_time.d.ts.map +1 -0
- package/dist/ui/history/thread_drawer.d.ts +64 -0
- package/dist/ui/history/thread_drawer.d.ts.map +1 -0
- package/dist/ui/history/utils.d.ts +2 -0
- package/dist/ui/history/utils.d.ts.map +1 -0
- package/dist/ui/interrupts/approval_card.d.ts +64 -0
- package/dist/ui/interrupts/approval_card.d.ts.map +1 -0
- package/dist/ui/interrupts/approval_styles.d.ts +2 -0
- package/dist/ui/interrupts/approval_styles.d.ts.map +1 -0
- package/dist/ui/interrupts/confirmation_card.d.ts +51 -0
- package/dist/ui/interrupts/confirmation_card.d.ts.map +1 -0
- package/dist/ui/interrupts/confirmation_styles.d.ts +2 -0
- package/dist/ui/interrupts/confirmation_styles.d.ts.map +1 -0
- package/dist/ui/interrupts/pending_decision.d.ts +22 -0
- package/dist/ui/interrupts/pending_decision.d.ts.map +1 -0
- package/dist/ui/interrupts/question_card.d.ts +49 -0
- package/dist/ui/interrupts/question_card.d.ts.map +1 -0
- package/dist/ui/interrupts/question_styles.d.ts +2 -0
- package/dist/ui/interrupts/question_styles.d.ts.map +1 -0
- package/dist/ui/place/place.d.ts +17 -0
- package/dist/ui/place/place.d.ts.map +1 -0
- package/dist/ui/place/place_chip.d.ts +26 -0
- package/dist/ui/place/place_chip.d.ts.map +1 -0
- package/dist/ui/place/place_from_route.d.ts +9 -0
- package/dist/ui/place/place_from_route.d.ts.map +1 -0
- package/dist/ui/place/place_styles.d.ts +2 -0
- package/dist/ui/place/place_styles.d.ts.map +1 -0
- package/dist/ui/placement/clamp_launcher.d.ts +20 -0
- package/dist/ui/placement/clamp_launcher.d.ts.map +1 -0
- package/dist/ui/placement/clamp_panel.d.ts +13 -0
- package/dist/ui/placement/clamp_panel.d.ts.map +1 -0
- package/dist/ui/placement/collapse_styles.d.ts +2 -0
- package/dist/ui/placement/collapse_styles.d.ts.map +1 -0
- package/dist/ui/placement/dock_styles.d.ts +2 -0
- package/dist/ui/placement/dock_styles.d.ts.map +1 -0
- package/dist/ui/placement/embedded_styles.d.ts +2 -0
- package/dist/ui/placement/embedded_styles.d.ts.map +1 -0
- package/dist/ui/placement/launcher_drag.d.ts +49 -0
- package/dist/ui/placement/launcher_drag.d.ts.map +1 -0
- package/dist/ui/placement/launcher_placement.d.ts +77 -0
- package/dist/ui/placement/launcher_placement.d.ts.map +1 -0
- package/dist/ui/placement/launcher_styles.d.ts +2 -0
- package/dist/ui/placement/launcher_styles.d.ts.map +1 -0
- package/dist/ui/placement/page_styles.d.ts +2 -0
- package/dist/ui/placement/page_styles.d.ts.map +1 -0
- package/dist/ui/placement/panel_drag.d.ts +40 -0
- package/dist/ui/placement/panel_drag.d.ts.map +1 -0
- package/dist/ui/placement/panel_placement.d.ts +173 -0
- package/dist/ui/placement/panel_placement.d.ts.map +1 -0
- package/dist/ui/placement/place_widget.d.ts +31 -0
- package/dist/ui/placement/place_widget.d.ts.map +1 -0
- package/dist/ui/placement/preset_styles.d.ts +2 -0
- package/dist/ui/placement/preset_styles.d.ts.map +1 -0
- package/dist/ui/placement/resize_handle.d.ts +78 -0
- package/dist/ui/placement/resize_handle.d.ts.map +1 -0
- package/dist/ui/placement/resize_styles.d.ts +2 -0
- package/dist/ui/placement/resize_styles.d.ts.map +1 -0
- package/dist/ui/placement/sidebar_styles.d.ts +2 -0
- package/dist/ui/placement/sidebar_styles.d.ts.map +1 -0
- package/dist/ui/placement/small_viewport_styles.d.ts +2 -0
- package/dist/ui/placement/small_viewport_styles.d.ts.map +1 -0
- package/dist/ui/placement/surface_for.d.ts +93 -0
- package/dist/ui/placement/surface_for.d.ts.map +1 -0
- package/dist/ui/progress/prettify_tool_name.d.ts +11 -0
- package/dist/ui/progress/prettify_tool_name.d.ts.map +1 -0
- package/dist/ui/progress/run_announcer.d.ts +41 -0
- package/dist/ui/progress/run_announcer.d.ts.map +1 -0
- package/dist/ui/progress/run_notice.d.ts +21 -0
- package/dist/ui/progress/run_notice.d.ts.map +1 -0
- package/dist/ui/progress/run_notice_styles.d.ts +2 -0
- package/dist/ui/progress/run_notice_styles.d.ts.map +1 -0
- package/dist/ui/progress/subagent_panel.d.ts +101 -0
- package/dist/ui/progress/subagent_panel.d.ts.map +1 -0
- package/dist/ui/progress/subagent_progress.d.ts +57 -0
- package/dist/ui/progress/subagent_progress.d.ts.map +1 -0
- package/dist/ui/progress/subagent_update.d.ts +28 -0
- package/dist/ui/progress/subagent_update.d.ts.map +1 -0
- package/dist/ui/progress/thoughts_block.d.ts +57 -0
- package/dist/ui/progress/thoughts_block.d.ts.map +1 -0
- package/dist/ui/progress/thoughts_styles.d.ts +2 -0
- package/dist/ui/progress/thoughts_styles.d.ts.map +1 -0
- package/dist/ui/progress/tool_call_card.d.ts +167 -0
- package/dist/ui/progress/tool_call_card.d.ts.map +1 -0
- package/dist/ui/progress/tool_group_styles.d.ts +2 -0
- package/dist/ui/progress/tool_group_styles.d.ts.map +1 -0
- package/dist/ui/progress/tool_step_group.d.ts +50 -0
- package/dist/ui/progress/tool_step_group.d.ts.map +1 -0
- package/dist/ui/progress/tool_step_styles.d.ts +2 -0
- package/dist/ui/progress/tool_step_styles.d.ts.map +1 -0
- package/dist/ui/shell/adopt_styles.d.ts +28 -0
- package/dist/ui/shell/adopt_styles.d.ts.map +1 -0
- package/dist/ui/shell/build_header.d.ts +45 -0
- package/dist/ui/shell/build_header.d.ts.map +1 -0
- package/dist/ui/shell/build_launcher.d.ts +33 -0
- package/dist/ui/shell/build_launcher.d.ts.map +1 -0
- package/dist/ui/shell/collapse_glyph.d.ts +14 -0
- package/dist/ui/shell/collapse_glyph.d.ts.map +1 -0
- package/dist/ui/shell/frame_styles.d.ts +2 -0
- package/dist/ui/shell/frame_styles.d.ts.map +1 -0
- package/dist/ui/shell/glyph_slot.d.ts +7 -0
- package/dist/ui/shell/glyph_slot.d.ts.map +1 -0
- package/dist/ui/shell/header_button.d.ts +11 -0
- package/dist/ui/shell/header_button.d.ts.map +1 -0
- package/dist/ui/shell/icon_element.d.ts +12 -0
- package/dist/ui/shell/icon_element.d.ts.map +1 -0
- package/dist/ui/shell/is_unread_badge_enabled.d.ts +10 -0
- package/dist/ui/shell/is_unread_badge_enabled.d.ts.map +1 -0
- package/dist/ui/shell/read_launcher_icon_url.d.ts +8 -0
- package/dist/ui/shell/read_launcher_icon_url.d.ts.map +1 -0
- package/dist/ui/shell/theme_styles.d.ts +2 -0
- package/dist/ui/shell/theme_styles.d.ts.map +1 -0
- package/dist/ui/shell/token_styles.d.ts +2 -0
- package/dist/ui/shell/token_styles.d.ts.map +1 -0
- package/dist/ui/styles.d.ts +2 -0
- package/dist/ui/styles.d.ts.map +1 -0
- package/dist/ui/themes/classic_styles.d.ts +2 -0
- package/dist/ui/themes/classic_styles.d.ts.map +1 -0
- package/dist/ui/themes/classic_tokens.d.ts +2 -0
- package/dist/ui/themes/classic_tokens.d.ts.map +1 -0
- package/dist/ui/themes/code_tokens.d.ts +2 -0
- package/dist/ui/themes/code_tokens.d.ts.map +1 -0
- package/dist/ui/themes/ledger_styles.d.ts +2 -0
- package/dist/ui/themes/ledger_styles.d.ts.map +1 -0
- package/dist/ui/themes/ledger_tokens.d.ts +2 -0
- package/dist/ui/themes/ledger_tokens.d.ts.map +1 -0
- package/dist/ui/themes/modern_styles.d.ts +2 -0
- package/dist/ui/themes/modern_styles.d.ts.map +1 -0
- package/dist/ui/themes/quiet_styles.d.ts +2 -0
- package/dist/ui/themes/quiet_styles.d.ts.map +1 -0
- package/dist/ui/themes/scheme_styles.d.ts +2 -0
- package/dist/ui/themes/scheme_styles.d.ts.map +1 -0
- package/dist/ui/themes/soft_styles.d.ts +2 -0
- package/dist/ui/themes/soft_styles.d.ts.map +1 -0
- package/dist/ui/themes/soft_tokens.d.ts +2 -0
- package/dist/ui/themes/soft_tokens.d.ts.map +1 -0
- package/dist/ui/themes/theme_hosts.d.ts +15 -0
- package/dist/ui/themes/theme_hosts.d.ts.map +1 -0
- package/dist/ui/transcript/answer_actions.d.ts +50 -0
- package/dist/ui/transcript/answer_actions.d.ts.map +1 -0
- package/dist/ui/transcript/answer_stream.d.ts +56 -0
- package/dist/ui/transcript/answer_stream.d.ts.map +1 -0
- package/dist/ui/transcript/attachment_chip_styles.d.ts +2 -0
- package/dist/ui/transcript/attachment_chip_styles.d.ts.map +1 -0
- package/dist/ui/transcript/attachment_chips.d.ts +21 -0
- package/dist/ui/transcript/attachment_chips.d.ts.map +1 -0
- package/dist/ui/transcript/build_transcript.d.ts +21 -0
- package/dist/ui/transcript/build_transcript.d.ts.map +1 -0
- package/dist/ui/transcript/greeting_styles.d.ts +2 -0
- package/dist/ui/transcript/greeting_styles.d.ts.map +1 -0
- package/dist/ui/transcript/groups_tool_steps.d.ts +13 -0
- package/dist/ui/transcript/groups_tool_steps.d.ts.map +1 -0
- package/dist/ui/transcript/message_action_styles.d.ts +2 -0
- package/dist/ui/transcript/message_action_styles.d.ts.map +1 -0
- package/dist/ui/transcript/message_actions.d.ts +74 -0
- package/dist/ui/transcript/message_actions.d.ts.map +1 -0
- package/dist/ui/transcript/message_styles.d.ts +2 -0
- package/dist/ui/transcript/message_styles.d.ts.map +1 -0
- package/dist/ui/transcript/render_markdown.d.ts +40 -0
- package/dist/ui/transcript/render_markdown.d.ts.map +1 -0
- package/dist/ui/transcript/render_or_warn.d.ts +16 -0
- package/dist/ui/transcript/render_or_warn.d.ts.map +1 -0
- package/dist/ui/transcript/reveal_words.d.ts +8 -0
- package/dist/ui/transcript/reveal_words.d.ts.map +1 -0
- package/dist/ui/transcript/starter_chips.d.ts +21 -0
- package/dist/ui/transcript/starter_chips.d.ts.map +1 -0
- package/dist/ui/transcript/stick_to_bottom.d.ts +55 -0
- package/dist/ui/transcript/stick_to_bottom.d.ts.map +1 -0
- package/dist/ui/transcript/suggestion_chips.d.ts +29 -0
- package/dist/ui/transcript/suggestion_chips.d.ts.map +1 -0
- package/dist/ui/transcript/transcript.d.ts +196 -0
- package/dist/ui/transcript/transcript.d.ts.map +1 -0
- package/dist/ui/transcript/transcript_styles.d.ts +2 -0
- package/dist/ui/transcript/transcript_styles.d.ts.map +1 -0
- package/dist/ui/ui_strings.d.ts +352 -0
- package/dist/ui/ui_strings.d.ts.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/package.json +86 -3
- package/src/bundle.ts +2 -0
- package/src/chat.ts +2 -0
- package/src/constants.ts +650 -0
- package/src/core/activity_registration.ts +19 -0
- package/src/core/activity_registry.ts +133 -0
- package/src/core/activity_renderer.ts +19 -0
- package/src/core/ag_ui_chat.ts +3966 -0
- package/src/core/agui_client.ts +1028 -0
- package/src/core/attachment.ts +62 -0
- package/src/core/chat_registry.ts +53 -0
- package/src/core/client_seed.ts +27 -0
- package/src/core/conversation_store.ts +478 -0
- package/src/core/create_http_agent.ts +97 -0
- package/src/core/define_chat.ts +25 -0
- package/src/core/events/attachments_detail.ts +9 -0
- package/src/core/events/bar_height_detail.ts +5 -0
- package/src/core/events/custom_agent_detail.ts +7 -0
- package/src/core/events/dock_width_detail.ts +7 -0
- package/src/core/events/feedback_detail.ts +27 -0
- package/src/core/events/invalidate_detail.ts +15 -0
- package/src/core/events/maximize_detail.ts +4 -0
- package/src/core/events/run_finished_detail.ts +36 -0
- package/src/core/events/state_detail.ts +4 -0
- package/src/core/events/submit_detail.ts +8 -0
- package/src/core/events/thread_detail.ts +26 -0
- package/src/core/events/thread_title_detail.ts +7 -0
- package/src/core/events/toggle_detail.ts +4 -0
- package/src/core/events/tool_run.ts +10 -0
- package/src/core/events/unread_detail.ts +10 -0
- package/src/core/message_role.ts +4 -0
- package/src/core/read_flag.ts +13 -0
- package/src/core/read_json_attribute.ts +30 -0
- package/src/core/read_max_tool_rounds.ts +13 -0
- package/src/core/remote_conversation_store.ts +279 -0
- package/src/core/run_handlers.ts +524 -0
- package/src/core/run_index.ts +114 -0
- package/src/core/storage_scope.ts +291 -0
- package/src/core/tool_outcome.ts +39 -0
- package/src/core/transcribe_audio.ts +76 -0
- package/src/core/upload_attachment.ts +130 -0
- package/src/core/utils.ts +230 -0
- package/src/dom/animations.ts +341 -0
- package/src/dom/dom_driver.ts +91 -0
- package/src/dom/highlight_overlay.ts +204 -0
- package/src/dom/native_setter.ts +40 -0
- package/src/dom/ring_color.ts +206 -0
- package/src/dom/utils.ts +5 -0
- package/src/index.ts +220 -0
- package/src/skills/fill_template.ts +31 -0
- package/src/skills/parse_skills.ts +31 -0
- package/src/skills/skill.ts +32 -0
- package/src/skills/skill_catalog.ts +172 -0
- package/src/skills/skill_name_from.ts +19 -0
- package/src/tools/chat_surface_tools.ts +183 -0
- package/src/tools/client_tool_registry.ts +80 -0
- package/src/tools/is_destructive.ts +17 -0
- package/src/tools/is_navigates.ts +12 -0
- package/src/tools/page_action_tools.ts +123 -0
- package/src/tools/page_map.ts +26 -0
- package/src/tools/page_state.ts +57 -0
- package/src/tools/parse_tool_catalog.ts +41 -0
- package/src/tools/route_map.ts +132 -0
- package/src/tools/tool_catalog.ts +334 -0
- package/src/tools/tool_dispatch.ts +453 -0
- package/src/ui/charts/chart_block.ts +519 -0
- package/src/ui/charts/chart_spec_from.ts +125 -0
- package/src/ui/charts/chart_styles.ts +63 -0
- package/src/ui/charts/chart_tool.ts +72 -0
- package/src/ui/composer/attachment_styles.ts +71 -0
- package/src/ui/composer/attachment_tray.ts +295 -0
- package/src/ui/composer/auto_grow.ts +12 -0
- package/src/ui/composer/build_composer.ts +114 -0
- package/src/ui/composer/composer_attachments.ts +324 -0
- package/src/ui/composer/composer_styles.ts +177 -0
- package/src/ui/composer/composer_voice.ts +93 -0
- package/src/ui/composer/skill_styles.ts +120 -0
- package/src/ui/composer/skills_menu.ts +188 -0
- package/src/ui/composer/voice_input.ts +282 -0
- package/src/ui/excerpts/attach_copy_buttons.ts +73 -0
- package/src/ui/excerpts/copy_payload.ts +156 -0
- package/src/ui/excerpts/page_quote_offer.ts +203 -0
- package/src/ui/excerpts/place_offer.ts +24 -0
- package/src/ui/excerpts/quote_selection.ts +345 -0
- package/src/ui/excerpts/transcript_quote_offer.ts +175 -0
- package/src/ui/fill_ui_string.ts +41 -0
- package/src/ui/history/checkpoint_menu.ts +300 -0
- package/src/ui/history/conversation_history.ts +1169 -0
- package/src/ui/history/history_styles.ts +492 -0
- package/src/ui/history/relative_time.ts +50 -0
- package/src/ui/history/thread_drawer.ts +471 -0
- package/src/ui/history/utils.ts +11 -0
- package/src/ui/interrupts/approval_card.ts +202 -0
- package/src/ui/interrupts/approval_styles.ts +52 -0
- package/src/ui/interrupts/confirmation_card.ts +138 -0
- package/src/ui/interrupts/confirmation_styles.ts +114 -0
- package/src/ui/interrupts/pending_decision.ts +33 -0
- package/src/ui/interrupts/question_card.ts +213 -0
- package/src/ui/interrupts/question_styles.ts +73 -0
- package/src/ui/place/place.ts +16 -0
- package/src/ui/place/place_chip.ts +146 -0
- package/src/ui/place/place_from_route.ts +27 -0
- package/src/ui/place/place_styles.ts +108 -0
- package/src/ui/placement/clamp_launcher.ts +38 -0
- package/src/ui/placement/clamp_panel.ts +30 -0
- package/src/ui/placement/collapse_styles.ts +472 -0
- package/src/ui/placement/dock_styles.ts +116 -0
- package/src/ui/placement/embedded_styles.ts +14 -0
- package/src/ui/placement/launcher_drag.ts +206 -0
- package/src/ui/placement/launcher_placement.ts +133 -0
- package/src/ui/placement/launcher_styles.ts +189 -0
- package/src/ui/placement/page_styles.ts +47 -0
- package/src/ui/placement/panel_drag.ts +138 -0
- package/src/ui/placement/panel_placement.ts +1975 -0
- package/src/ui/placement/place_widget.ts +64 -0
- package/src/ui/placement/preset_styles.ts +49 -0
- package/src/ui/placement/resize_handle.ts +229 -0
- package/src/ui/placement/resize_styles.ts +201 -0
- package/src/ui/placement/sidebar_styles.ts +113 -0
- package/src/ui/placement/small_viewport_styles.ts +70 -0
- package/src/ui/placement/surface_for.ts +183 -0
- package/src/ui/progress/prettify_tool_name.ts +16 -0
- package/src/ui/progress/run_announcer.ts +67 -0
- package/src/ui/progress/run_notice.ts +61 -0
- package/src/ui/progress/run_notice_styles.ts +52 -0
- package/src/ui/progress/subagent_panel.ts +227 -0
- package/src/ui/progress/subagent_progress.ts +200 -0
- package/src/ui/progress/subagent_update.ts +89 -0
- package/src/ui/progress/thoughts_block.ts +165 -0
- package/src/ui/progress/thoughts_styles.ts +74 -0
- package/src/ui/progress/tool_call_card.ts +429 -0
- package/src/ui/progress/tool_group_styles.ts +313 -0
- package/src/ui/progress/tool_step_group.ts +317 -0
- package/src/ui/progress/tool_step_styles.ts +486 -0
- package/src/ui/shell/adopt_styles.ts +34 -0
- package/src/ui/shell/build_header.ts +121 -0
- package/src/ui/shell/build_launcher.ts +79 -0
- package/src/ui/shell/collapse_glyph.ts +42 -0
- package/src/ui/shell/frame_styles.ts +147 -0
- package/src/ui/shell/glyph_slot.ts +12 -0
- package/src/ui/shell/header_button.ts +22 -0
- package/src/ui/shell/icon_element.ts +33 -0
- package/src/ui/shell/is_unread_badge_enabled.ts +11 -0
- package/src/ui/shell/read_launcher_icon_url.ts +9 -0
- package/src/ui/shell/theme_styles.ts +11 -0
- package/src/ui/shell/token_styles.ts +480 -0
- package/src/ui/styles.ts +110 -0
- package/src/ui/themes/classic_styles.ts +32 -0
- package/src/ui/themes/classic_tokens.ts +51 -0
- package/src/ui/themes/code_tokens.ts +21 -0
- package/src/ui/themes/ledger_styles.ts +169 -0
- package/src/ui/themes/ledger_tokens.ts +33 -0
- package/src/ui/themes/modern_styles.ts +80 -0
- package/src/ui/themes/quiet_styles.ts +118 -0
- package/src/ui/themes/scheme_styles.ts +26 -0
- package/src/ui/themes/soft_styles.ts +149 -0
- package/src/ui/themes/soft_tokens.ts +29 -0
- package/src/ui/themes/theme_hosts.ts +14 -0
- package/src/ui/transcript/answer_actions.ts +165 -0
- package/src/ui/transcript/answer_stream.ts +123 -0
- package/src/ui/transcript/attachment_chip_styles.ts +116 -0
- package/src/ui/transcript/attachment_chips.ts +82 -0
- package/src/ui/transcript/build_transcript.ts +67 -0
- package/src/ui/transcript/greeting_styles.ts +171 -0
- package/src/ui/transcript/groups_tool_steps.ts +16 -0
- package/src/ui/transcript/message_action_styles.ts +155 -0
- package/src/ui/transcript/message_actions.ts +234 -0
- package/src/ui/transcript/message_styles.ts +262 -0
- package/src/ui/transcript/render_markdown.ts +181 -0
- package/src/ui/transcript/render_or_warn.ts +22 -0
- package/src/ui/transcript/reveal_words.ts +41 -0
- package/src/ui/transcript/starter_chips.ts +42 -0
- package/src/ui/transcript/stick_to_bottom.ts +126 -0
- package/src/ui/transcript/suggestion_chips.ts +73 -0
- package/src/ui/transcript/transcript.ts +555 -0
- package/src/ui/transcript/transcript_styles.ts +153 -0
- package/src/ui/ui_strings.ts +550 -0
- package/src/version.ts +1 -0
|
@@ -0,0 +1,3966 @@
|
|
|
1
|
+
import type { Context, Message, Tool } from "@ag-ui/core";
|
|
2
|
+
import {
|
|
3
|
+
ATTACHMENT_EVENT,
|
|
4
|
+
BAR_HEIGHT_EVENT,
|
|
5
|
+
CHART_ACTIVITY_TYPE,
|
|
6
|
+
COMPACTION_ACTIVITY_TYPE,
|
|
7
|
+
CUSTOM_AGENT_EVENT,
|
|
8
|
+
DOCK_WIDTH_EVENT,
|
|
9
|
+
ICON_MAXIMIZE,
|
|
10
|
+
ICON_MOON,
|
|
11
|
+
ICON_RESTORE,
|
|
12
|
+
ICON_SUN,
|
|
13
|
+
MAXIMIZE_EVENT,
|
|
14
|
+
MESSAGE_ROLE,
|
|
15
|
+
PAGE_MAP_CONTEXT,
|
|
16
|
+
STATE_EVENT,
|
|
17
|
+
STORAGE_ROOT,
|
|
18
|
+
SUBMIT_EVENT,
|
|
19
|
+
SUGGESTIONS_ACTIVITY_TYPE,
|
|
20
|
+
THREAD_EVENT,
|
|
21
|
+
THREAD_TITLE_EVENT,
|
|
22
|
+
TOGGLE_EVENT,
|
|
23
|
+
TOOL_DISPLAY,
|
|
24
|
+
TOOL_GROUP_DISPLAY,
|
|
25
|
+
type ToolGroupDisplayMode,
|
|
26
|
+
UNREAD_EVENT,
|
|
27
|
+
} from "../constants.js";
|
|
28
|
+
import type { Skill } from "../skills/skill.js";
|
|
29
|
+
import { SkillCatalog } from "../skills/skill_catalog.js";
|
|
30
|
+
// biome-ignore lint/style/useImportType: the emitted declaration file copies this form
|
|
31
|
+
import { type ChatCorner, type ChatSurfaceReport } from "../tools/chat_surface_tools.js";
|
|
32
|
+
// biome-ignore lint/style/useImportType: the emitted declaration file copies this form
|
|
33
|
+
import { type ClientTool } from "../tools/client_tool_registry.js";
|
|
34
|
+
// biome-ignore lint/style/useImportType: the emitted declaration file copies this form
|
|
35
|
+
import { type ResolvePageTarget } from "../tools/page_action_tools.js";
|
|
36
|
+
import { createPageMapContext, type PageMap } from "../tools/page_map.js";
|
|
37
|
+
// biome-ignore lint/style/useImportType: the emitted declaration file copies this form
|
|
38
|
+
import { type PageState } from "../tools/page_state.js";
|
|
39
|
+
// biome-ignore lint/style/useImportType: the emitted declaration file copies this form
|
|
40
|
+
import { type RouteMap } from "../tools/route_map.js";
|
|
41
|
+
import { ToolCatalog } from "../tools/tool_catalog.js";
|
|
42
|
+
import { ToolDispatch } from "../tools/tool_dispatch.js";
|
|
43
|
+
import { renderChart } from "../ui/charts/chart_block.js";
|
|
44
|
+
import { chartSpecFrom } from "../ui/charts/chart_spec_from.js";
|
|
45
|
+
import { CHART_TOOL_NAME, createChartTool } from "../ui/charts/chart_tool.js";
|
|
46
|
+
import { autoGrow } from "../ui/composer/auto_grow.js";
|
|
47
|
+
import { buildComposer } from "../ui/composer/build_composer.js";
|
|
48
|
+
import { ComposerAttachments } from "../ui/composer/composer_attachments.js";
|
|
49
|
+
import { ComposerVoice } from "../ui/composer/composer_voice.js";
|
|
50
|
+
import { SkillsMenu } from "../ui/composer/skills_menu.js";
|
|
51
|
+
import { TranscriptQuoteOffer } from "../ui/excerpts/transcript_quote_offer.js";
|
|
52
|
+
import { fillUiString } from "../ui/fill_ui_string.js";
|
|
53
|
+
import { CheckpointMenu } from "../ui/history/checkpoint_menu.js";
|
|
54
|
+
import { ConversationHistory } from "../ui/history/conversation_history.js";
|
|
55
|
+
import type { RelativeTimeFormatter } from "../ui/history/relative_time.js";
|
|
56
|
+
import { ThreadDrawer } from "../ui/history/thread_drawer.js";
|
|
57
|
+
// biome-ignore lint/style/useImportType: the emitted declaration file copies this form
|
|
58
|
+
import { type ApprovalRenderer } from "../ui/interrupts/approval_card.js";
|
|
59
|
+
import { PendingDecision } from "../ui/interrupts/pending_decision.js";
|
|
60
|
+
// biome-ignore lint/style/useImportType: the emitted declaration file copies this form
|
|
61
|
+
import { type QuestionRenderer } from "../ui/interrupts/question_card.js";
|
|
62
|
+
// biome-ignore lint/style/useImportType: the emitted declaration file copies this form
|
|
63
|
+
import { type Place } from "../ui/place/place.js";
|
|
64
|
+
import { PlaceChip } from "../ui/place/place_chip.js";
|
|
65
|
+
import { placeFromRoute } from "../ui/place/place_from_route.js";
|
|
66
|
+
import { PanelPlacement } from "../ui/placement/panel_placement.js";
|
|
67
|
+
import { type Surface, surfaceFor } from "../ui/placement/surface_for.js";
|
|
68
|
+
import { RunAnnouncer } from "../ui/progress/run_announcer.js";
|
|
69
|
+
import { renderRunNotice } from "../ui/progress/run_notice.js";
|
|
70
|
+
import { SubAgentProgress } from "../ui/progress/subagent_progress.js";
|
|
71
|
+
// biome-ignore lint/style/useImportType: the emitted declaration file copies this form
|
|
72
|
+
import { type ToolDisplayMode, type ToolPayloadFormatter } from "../ui/progress/tool_call_card.js";
|
|
73
|
+
import { adoptStyles } from "../ui/shell/adopt_styles.js";
|
|
74
|
+
import { buildHeader } from "../ui/shell/build_header.js";
|
|
75
|
+
import { buildLauncher } from "../ui/shell/build_launcher.js";
|
|
76
|
+
import { collapseGlyph } from "../ui/shell/collapse_glyph.js";
|
|
77
|
+
import { iconElement } from "../ui/shell/icon_element.js";
|
|
78
|
+
import { isUnreadBadgeEnabled } from "../ui/shell/is_unread_badge_enabled.js";
|
|
79
|
+
import { readLauncherIconUrl } from "../ui/shell/read_launcher_icon_url.js";
|
|
80
|
+
import { AnswerActions } from "../ui/transcript/answer_actions.js";
|
|
81
|
+
import { AnswerStream } from "../ui/transcript/answer_stream.js";
|
|
82
|
+
import { renderAttachmentChips } from "../ui/transcript/attachment_chips.js";
|
|
83
|
+
import { buildTranscript } from "../ui/transcript/build_transcript.js";
|
|
84
|
+
import { renderStarterChips } from "../ui/transcript/starter_chips.js";
|
|
85
|
+
import { renderSuggestionChips } from "../ui/transcript/suggestion_chips.js";
|
|
86
|
+
import { Transcript } from "../ui/transcript/transcript.js";
|
|
87
|
+
import { DEFAULT_UI_STRINGS, mergeUiStrings, type UiStrings } from "../ui/ui_strings.js";
|
|
88
|
+
import type { ActivityRegistration } from "./activity_registration.js";
|
|
89
|
+
import { ActivityRegistry } from "./activity_registry.js";
|
|
90
|
+
import type { ActivityRenderer } from "./activity_renderer.js";
|
|
91
|
+
import { AgUiClient } from "./agui_client.js";
|
|
92
|
+
// biome-ignore lint/style/useImportType: the emitted declaration file copies this form
|
|
93
|
+
import { type AttachmentRef } from "./attachment.js";
|
|
94
|
+
import { ChatRegistration } from "./chat_registry.js";
|
|
95
|
+
import type { ClientSeed } from "./client_seed.js";
|
|
96
|
+
import {
|
|
97
|
+
type ClientConversationStore,
|
|
98
|
+
type NavigationCheckpoint,
|
|
99
|
+
SessionStorageStore,
|
|
100
|
+
type ThreadMeta,
|
|
101
|
+
threadTitleFrom,
|
|
102
|
+
} from "./conversation_store.js";
|
|
103
|
+
import { type AgentFactory, createHttpAgent } from "./create_http_agent.js";
|
|
104
|
+
import type { BarHeightDetail } from "./events/bar_height_detail.js";
|
|
105
|
+
import type { DockWidthDetail } from "./events/dock_width_detail.js";
|
|
106
|
+
import type { MaximizeDetail } from "./events/maximize_detail.js";
|
|
107
|
+
import type { StateDetail } from "./events/state_detail.js";
|
|
108
|
+
import type { SubmitDetail } from "./events/submit_detail.js";
|
|
109
|
+
import type { ThreadDetail } from "./events/thread_detail.js";
|
|
110
|
+
import type { ThreadTitleDetail } from "./events/thread_title_detail.js";
|
|
111
|
+
import type { ToggleDetail } from "./events/toggle_detail.js";
|
|
112
|
+
import type { UnreadDetail } from "./events/unread_detail.js";
|
|
113
|
+
import type { MessageRole } from "./message_role.js";
|
|
114
|
+
import { readFlag } from "./read_flag.js";
|
|
115
|
+
import { readJsonAttribute } from "./read_json_attribute.js";
|
|
116
|
+
import { readMaxToolRounds } from "./read_max_tool_rounds.js";
|
|
117
|
+
import { RemoteConversationStore } from "./remote_conversation_store.js";
|
|
118
|
+
import { RunHandlers } from "./run_handlers.js";
|
|
119
|
+
import { StorageScope } from "./storage_scope.js";
|
|
120
|
+
// biome-ignore lint/style/useImportType: the emitted declaration file copies this form
|
|
121
|
+
import { type TranscribeHandler } from "./transcribe_audio.js";
|
|
122
|
+
// biome-ignore lint/style/useImportType: the emitted declaration file copies this form
|
|
123
|
+
import { type UploadHandler } from "./upload_attachment.js";
|
|
124
|
+
import { warnOnCrossOriginCredentials, withCredentials } from "./utils.js";
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Attributes read once while connecting, to decide what chrome exists at all.
|
|
128
|
+
*
|
|
129
|
+
* Changing one afterwards is silently ignored: the tray, the mic, the skills
|
|
130
|
+
* menu and the header icon are built during connect and no later read revisits
|
|
131
|
+
* the decision. Observed only so `attributeChangedCallback` can warn.
|
|
132
|
+
*
|
|
133
|
+
* Excludes the attributes that are re-read per use, where a late change works
|
|
134
|
+
* and a warning would be wrong: `data-runs-url`, `data-page-actions`,
|
|
135
|
+
* `data-text-animation`, `data-tool-display`, `endpoint`, and the CSS-reactive
|
|
136
|
+
* `theme` / `collapsed` / `data-tool-group-display`.
|
|
137
|
+
*/
|
|
138
|
+
const CONNECT_TIME_ATTRIBUTES = [
|
|
139
|
+
"data-attachments-url",
|
|
140
|
+
"data-attachment-accept",
|
|
141
|
+
"data-attachment-max-bytes",
|
|
142
|
+
"data-transcribe-url",
|
|
143
|
+
"data-threads-url",
|
|
144
|
+
"data-threads-cache",
|
|
145
|
+
"data-tools-url",
|
|
146
|
+
"data-skills-url",
|
|
147
|
+
"data-skills",
|
|
148
|
+
"data-prompt-chips",
|
|
149
|
+
"data-slash-commands",
|
|
150
|
+
"data-theme-toggle",
|
|
151
|
+
"data-strings",
|
|
152
|
+
"data-icon-url",
|
|
153
|
+
] as const;
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The cookie policies `fetch` accepts. Anything else is a configuration
|
|
157
|
+
* mistake, and one that would otherwise surface as an unexplained 401 from a
|
|
158
|
+
* request the browser silently sent anonymously.
|
|
159
|
+
*/
|
|
160
|
+
const CREDENTIALS_MODES: readonly string[] = ["omit", "same-origin", "include"];
|
|
161
|
+
|
|
162
|
+
const SERVER_PLACEHOLDER_TITLE = "New conversation";
|
|
163
|
+
|
|
164
|
+
/** What a server derives as a thread's title: the first user text, 60 code points at most. */
|
|
165
|
+
function serverTitleFrom(messages: readonly Message[]): string {
|
|
166
|
+
for (const message of messages) {
|
|
167
|
+
const text =
|
|
168
|
+
typeof message.content === "string" ? message.content.split(/\s+/).join(" ").trim() : "";
|
|
169
|
+
if (message.role === "user" && text !== "") {
|
|
170
|
+
const points = Array.from(text);
|
|
171
|
+
return points.length <= 60 ? text : `${points.slice(0, 59).join("").trimEnd()}\u2026`;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return "";
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Whether `value` is one of the three modes `fetch` understands. */
|
|
178
|
+
function isCredentialsMode(value: string): value is RequestCredentials {
|
|
179
|
+
return CREDENTIALS_MODES.includes(value);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The `title-text` value that makes the header follow the thread on screen. */
|
|
183
|
+
const TITLE_TEXT_AUTO = "auto";
|
|
184
|
+
|
|
185
|
+
/** The width at and below which every placement but two is the screen. */
|
|
186
|
+
const PHONE_QUERY = "(max-width: 600px)";
|
|
187
|
+
|
|
188
|
+
/** The anchored conversation list's widest, and its distance from its button and the window. */
|
|
189
|
+
const THREADS_POPOVER_WIDTH = 320;
|
|
190
|
+
const THREADS_POPOVER_GAP = 8;
|
|
191
|
+
|
|
192
|
+
/** Per-tab persistence key for the collapsed state (survives MPA reloads). */
|
|
193
|
+
const COLLAPSED_KEY = `${STORAGE_ROOT}:collapsed`;
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Persistence key for the built-in theme toggle. It holds the user's scheme
|
|
197
|
+
* word only: the theme name belongs to the host's markup, and one saved value
|
|
198
|
+
* is restored on every page that shares the key.
|
|
199
|
+
*/
|
|
200
|
+
const THEME_KEY = `${STORAGE_ROOT}:theme`;
|
|
201
|
+
|
|
202
|
+
/** The scheme words of the `theme` attribute; every other word names a theme. */
|
|
203
|
+
const SCHEME_WORDS: readonly string[] = ["light", "dark", "auto"];
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The last scheme word in a saved value, or `undefined` when it names none. A
|
|
207
|
+
* value saved before only the scheme was kept (`"soft dark"`) still gives up
|
|
208
|
+
* just its scheme.
|
|
209
|
+
*/
|
|
210
|
+
function savedScheme(saved: string): string | undefined {
|
|
211
|
+
return saved
|
|
212
|
+
.split(/\s+/)
|
|
213
|
+
.filter((word) => SCHEME_WORDS.includes(word))
|
|
214
|
+
.at(-1);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** `theme`'s words with its scheme word, if any, replaced by `scheme`. */
|
|
218
|
+
function withScheme(theme: string, scheme: string): string {
|
|
219
|
+
const names = theme.split(/\s+/).filter((word) => word !== "" && !SCHEME_WORDS.includes(word));
|
|
220
|
+
return [...names, scheme].join(" ");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The element a selector names, in the given root first and then the
|
|
225
|
+
* document; null for no match, and for a selector that does not parse, which
|
|
226
|
+
* is the same mistake from the host's side.
|
|
227
|
+
*/
|
|
228
|
+
function findAnchor(root: Node, selector: string): Element | null {
|
|
229
|
+
try {
|
|
230
|
+
const scope = root instanceof ShadowRoot ? root : document;
|
|
231
|
+
return scope.querySelector(selector) ?? document.querySelector(selector);
|
|
232
|
+
} catch {
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* `<ag-ui-chat>` — a framework-free chat sidebar Web Component over AG-UI.
|
|
239
|
+
*
|
|
240
|
+
* Owns the Shadow DOM shell (header, scrolling message list, input row),
|
|
241
|
+
* builds an {@link AgUiClient} on first send via the overridable
|
|
242
|
+
* {@link agentFactory}, and renders streaming assistant text plus tool-call
|
|
243
|
+
* activity. Emits a {@link SUBMIT_EVENT} for host visibility.
|
|
244
|
+
*
|
|
245
|
+
* The per-run frontend tool catalog and context come from {@link getTools} and
|
|
246
|
+
* {@link getContext}.
|
|
247
|
+
*/
|
|
248
|
+
export class AgUiChat extends HTMLElement {
|
|
249
|
+
/** Agent factory; override to inject a custom or fake agent (tests). */
|
|
250
|
+
agentFactory: AgentFactory = createHttpAgent;
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Static extra HTTP headers, sent with every request this element makes: the
|
|
254
|
+
* agent run, the thread index and its messages, the tool and skill catalogs,
|
|
255
|
+
* the run index, uploads and transcription.
|
|
256
|
+
*
|
|
257
|
+
* For values fixed for the element's lifetime. A rotating credential belongs
|
|
258
|
+
* in {@link getHeaders} instead — only a re-assignment updates this, so a
|
|
259
|
+
* token captured here is pinned until the host assigns again.
|
|
260
|
+
*/
|
|
261
|
+
headers: Record<string, string> = {};
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Live header source, called afresh immediately before every request — the
|
|
265
|
+
* way to supply rotating credentials, with nothing to re-assign or keep in
|
|
266
|
+
* sync.
|
|
267
|
+
*
|
|
268
|
+
* Composes with {@link headers} rather than replacing it: merged per key with
|
|
269
|
+
* `getHeaders()` winning, so a static `X-Client` and a rotating
|
|
270
|
+
* `Authorization` are configured independently and neither drops the other.
|
|
271
|
+
*/
|
|
272
|
+
getHeaders: (() => Record<string, string>) | null = null;
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Origins, besides the page's own, that this element may send {@link headers}
|
|
276
|
+
* and {@link getHeaders} credentials to without saying so on the console.
|
|
277
|
+
*
|
|
278
|
+
* Seven attributes name a URL, and every one of them carries these headers.
|
|
279
|
+
* They are plain HTML, so a page that builds one from a query parameter or
|
|
280
|
+
* from tenant-authored configuration has handed an attacker the destination,
|
|
281
|
+
* and the token leaves on the element's first request. Naming the origins you
|
|
282
|
+
* expect turns that from silent into either confirmed or reported.
|
|
283
|
+
*
|
|
284
|
+
* A notice rather than a refusal: a cross-origin agent is a documented
|
|
285
|
+
* deployment, so refusing would break working installations to defend against
|
|
286
|
+
* a page that is already interpolating untrusted data into its own markup.
|
|
287
|
+
* Leaving this empty costs nothing but one console line per foreign origin.
|
|
288
|
+
*/
|
|
289
|
+
trustedOrigins: readonly string[] = [];
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Permit `<img>` in rendered assistant markdown. **Off by default**: a
|
|
293
|
+
* model-controlled image URL is fetched with no user interaction, which
|
|
294
|
+
* makes it a zero-click exfiltration channel for prompt-injected page
|
|
295
|
+
* data. Enable only when the content source is trusted.
|
|
296
|
+
*/
|
|
297
|
+
allowImages = false;
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Replace the relative timestamps in the thread drawer and the checkpoint
|
|
301
|
+
* panel -- `"5m ago"`, `"2d ago"` -- with the host's own formatting.
|
|
302
|
+
*
|
|
303
|
+
* The built-in is locale-neutral on purpose: there is no `Intl` anywhere in
|
|
304
|
+
* this component, so it never disagrees with the page it is embedded in by
|
|
305
|
+
* guessing a locale. That is a good default and a bad requirement, which is
|
|
306
|
+
* what this is for.
|
|
307
|
+
*
|
|
308
|
+
* ```js
|
|
309
|
+
* const rtf = new Intl.RelativeTimeFormat("de", { numeric: "auto" });
|
|
310
|
+
* chat.formatRelativeTime = (ts) =>
|
|
311
|
+
* rtf.format(Math.round((ts - Date.now()) / 60000), "minute");
|
|
312
|
+
* ```
|
|
313
|
+
*/
|
|
314
|
+
formatRelativeTime: RelativeTimeFormatter | null = null;
|
|
315
|
+
|
|
316
|
+
/** When true, destructive tools execute without a confirmation modal. */
|
|
317
|
+
autoConfirm = false;
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* When true, the built-in `ask_user` frontend tool is offered to the agent:
|
|
321
|
+
* calling it renders an inline question card and returns the user's answer.
|
|
322
|
+
* Off by default, like the other built-in tool groups, so the advertised
|
|
323
|
+
* catalog does not change until a host asks for it.
|
|
324
|
+
*/
|
|
325
|
+
askUser = false;
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Optional full replacement for the `ask_user` question UI, resolving with
|
|
329
|
+
* the answer; the same seam as {@link approvalRenderer}, styled via `strings`
|
|
330
|
+
* and the `question*` `::part()`s when left unset. Requires {@link askUser}.
|
|
331
|
+
* A renderer that throws or rejects hands the question to the built-in card,
|
|
332
|
+
* as a failing {@link approvalRenderer} does, unless Stop already fired.
|
|
333
|
+
*/
|
|
334
|
+
askUserRenderer: QuestionRenderer | null = null;
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Optional full replacement for the server-side-tool approval UI: an approval
|
|
338
|
+
* interrupt invokes this instead of the built-in {@link requestApproval}
|
|
339
|
+
* card, resolving `true` to approve or `false` to deny. Style the built-in
|
|
340
|
+
* card via `strings` and the `approval*` `::part()`s instead. The gate itself
|
|
341
|
+
* is enabled server-side; this only changes how the decision is collected.
|
|
342
|
+
*/
|
|
343
|
+
approvalRenderer: ApprovalRenderer | null = null;
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Let the user edit a gated call's arguments before approving it.
|
|
347
|
+
*
|
|
348
|
+
* Off by default and **an assertion about your server**, not a negotiation:
|
|
349
|
+
* AG-UI carries `editedArgs` in the resume payload and gates it on the
|
|
350
|
+
* agent's own `approveWithEdits` capability, which this component never sees
|
|
351
|
+
* -- capabilities are not on the wire it reads. So the host says whether its
|
|
352
|
+
* agent honours them. Turned on against a server that does not, the user
|
|
353
|
+
* would edit arguments it silently discards, which is worse than not
|
|
354
|
+
* offering.
|
|
355
|
+
*
|
|
356
|
+
* Only affects calls whose arguments are known here: an interrupt names a
|
|
357
|
+
* `toolCallId`, and the tool card for that call is where the arguments still
|
|
358
|
+
* are. An interrupt naming no card gets the plain approve/deny.
|
|
359
|
+
*/
|
|
360
|
+
approveWithEdits = false;
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Optional per-call confirmation predicate. When set it is authoritative,
|
|
364
|
+
* deciding from the tool name and args whether this particular call needs
|
|
365
|
+
* confirmation — so one tool can be instant for some args and confirmed for
|
|
366
|
+
* others, which a static `x-destructive` flag cannot express. When unset the
|
|
367
|
+
* `x-destructive` flag decides. `autoConfirm` short-circuits both.
|
|
368
|
+
*/
|
|
369
|
+
confirmPredicate:
|
|
370
|
+
| ((toolName: string, args: Record<string, unknown>) => boolean | Promise<boolean>)
|
|
371
|
+
| null = null;
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Per-run frontend tool catalog provider. Defaults to the built-in
|
|
375
|
+
* `route.*` tools (when a {@link routeMap} is set) plus the tools registered
|
|
376
|
+
* via {@link registerTool} / {@link registerPageState}; override to supply a
|
|
377
|
+
* fully custom catalog.
|
|
378
|
+
*/
|
|
379
|
+
getTools: () => Tool[] = () => this.#tools.defaultTools();
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Per-run context provider. Defaults to the compact page map, when a
|
|
383
|
+
* {@link getPageMap} provider is set and {@link autoInjectPageMap} is on.
|
|
384
|
+
*
|
|
385
|
+
* Attachments are deliberately not restated here: the server derives its own
|
|
386
|
+
* manifest from the refs riding the messages.
|
|
387
|
+
*/
|
|
388
|
+
getContext: () => Context[] = () => [
|
|
389
|
+
...createPageMapContext(this.getPageMap, this.autoInjectPageMap),
|
|
390
|
+
];
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Navigable routes the agent can jump to via the built-in `route.*` tools.
|
|
394
|
+
* While {@link getPlace} is unset, the route matching the current path also
|
|
395
|
+
* stands in as the place: the chip names it and the place entry carries it.
|
|
396
|
+
* No summary of the map itself is sent.
|
|
397
|
+
*/
|
|
398
|
+
routeMap: RouteMap = [];
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Optional client-side router. When set (an SPA), `navigate_to_route` routes
|
|
402
|
+
* in-page and the run loop continues; when unset (an MPA like the admin), it
|
|
403
|
+
* falls back to `window.location` and the resumable-loop machinery applies.
|
|
404
|
+
*/
|
|
405
|
+
navigate: ((path: string) => void) | null = null;
|
|
406
|
+
|
|
407
|
+
/** Provider for the per-run page map; see {@link getContext}. */
|
|
408
|
+
getPageMap: (() => PageMap) | null = null;
|
|
409
|
+
|
|
410
|
+
/** Whether to auto-inject the page map into context each run. */
|
|
411
|
+
autoInjectPageMap = true;
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Where in the app the user is. The chip in the composer's tool row draws it,
|
|
415
|
+
* and every tool round sends it as one `place` context entry from the same
|
|
416
|
+
* read, after whatever {@link getContext} returns, so a host that replaces
|
|
417
|
+
* that keeps it. Read before every round, on connect, on expand, when the
|
|
418
|
+
* composer gains focus, on `popstate` and `hashchange`, when the conversation
|
|
419
|
+
* changes, and on {@link refreshPlace}.
|
|
420
|
+
*
|
|
421
|
+
* When unset, the {@link routeMap} route matching `location.pathname` stands
|
|
422
|
+
* in: its title (else its id) as the label, its group as the detail, the
|
|
423
|
+
* path as the href. With neither, no chip is drawn and nothing is sent; a
|
|
424
|
+
* host with a route map that wants no chip sets `() => null`.
|
|
425
|
+
*
|
|
426
|
+
* The chip's eye toggle withholds the place, and with it the page: a round
|
|
427
|
+
* whose place is withheld sends no `page_map` entry, whatever
|
|
428
|
+
* {@link getContext} returns, and offers no `read_page`. It holds for the
|
|
429
|
+
* same place, lapses when a different place is read, and is not persisted.
|
|
430
|
+
*/
|
|
431
|
+
getPlace: (() => Place | null) | null = null;
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Persistence for the conversation + navigation checkpoint. Defaults to
|
|
435
|
+
* per-tab `sessionStorage` so the chat survives full page reloads; inject a
|
|
436
|
+
* server-backed store for cross-tab/device durability.
|
|
437
|
+
*/
|
|
438
|
+
conversationStore: ClientConversationStore = new SessionStorageStore();
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* How attached files are uploaded. `null` (default) uses the built-in
|
|
442
|
+
* multipart `POST` to `data-attachments-url`; a custom {@link UploadHandler}
|
|
443
|
+
* swaps the transport without changing the tray, the chips, or the AG-UI
|
|
444
|
+
* wire. When set, the 📎 affordance appears even with no
|
|
445
|
+
* `data-attachments-url`, and the handler owns its own endpoint and headers.
|
|
446
|
+
*/
|
|
447
|
+
uploadHandler: UploadHandler | null = null;
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* How recorded voice clips are transcribed. `null` (default) POSTs the clip
|
|
451
|
+
* to `data-transcribe-url`; a custom {@link TranscribeHandler} swaps the
|
|
452
|
+
* transport without touching the mic button. When set, the 🎤 affordance
|
|
453
|
+
* appears even with no `data-transcribe-url`.
|
|
454
|
+
*/
|
|
455
|
+
transcribeHandler: TranscribeHandler | null = null;
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Builds the tool result a navigating tool resumes with after the page
|
|
459
|
+
* reloads. Defaults to the landed URL; a host (e.g. the admin package) can
|
|
460
|
+
* override to include a page snapshot or post-reload validation errors.
|
|
461
|
+
*/
|
|
462
|
+
navigationResult: (checkpoint: NavigationCheckpoint) => unknown = () => ({
|
|
463
|
+
navigated: true,
|
|
464
|
+
url: window.location.href,
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Named values used to fill a skill prompt's `{placeholder}`s before send
|
|
469
|
+
* (e.g. `{ model: "Order", selected_ids: "1,2" }`). A host (the admin) sets
|
|
470
|
+
* this from the current page; a missing placeholder blocks the send.
|
|
471
|
+
*/
|
|
472
|
+
skillContext: () => Record<string, unknown> = () => ({});
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Friendly display labels for tool-call cards, keyed by tool name. The
|
|
476
|
+
* fallback when a tool has no `x-summary` in its own schema, which chiefly
|
|
477
|
+
* means server-side tools: AG-UI streams only the tool-call name, so their
|
|
478
|
+
* schema never reaches the browser. Client tools should prefer `x-summary`.
|
|
479
|
+
*/
|
|
480
|
+
toolSummaries: Record<string, string> = {};
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Optional presentation hook for the two payload regions of a tool-call card
|
|
484
|
+
* -- the arguments and the result. Unset (the default) leaves both
|
|
485
|
+
* pretty-printed as JSON.
|
|
486
|
+
*
|
|
487
|
+
* The seam exists because a wide result has no good rendering as JSON: a
|
|
488
|
+
* thirty-field row is a wall of text where the host wanted a table, or a
|
|
489
|
+
* sentence. `ClientTool.render` cannot answer it -- it is handed the
|
|
490
|
+
* *arguments* only, and a server-side tool has no `ClientTool` at all, so the
|
|
491
|
+
* result region was the one part of the transcript a host could not reach.
|
|
492
|
+
*
|
|
493
|
+
* **Presentation, not translation.** The card and the model already read
|
|
494
|
+
* separate copies of a tool result: the model's is maintained by
|
|
495
|
+
* `@ag-ui/client` from the same event and persisted with the history, and the
|
|
496
|
+
* card has always shown that string reformatted. So a formatter changes what
|
|
497
|
+
* the person reads and nothing the agent reads -- which makes restyling safe
|
|
498
|
+
* and *rewording* a way to make the card disagree with the prose beside it.
|
|
499
|
+
* Rename a value on the server, where it reaches both.
|
|
500
|
+
*
|
|
501
|
+
* Read at render time rather than captured, so a host that sets it from a
|
|
502
|
+
* framework effect after the first card still formats the results that settle
|
|
503
|
+
* afterwards. See {@link ToolPayloadFormatter}.
|
|
504
|
+
*/
|
|
505
|
+
formatToolPayload: ToolPayloadFormatter | null = null;
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Localizable UI strings — a partial override merged over the English
|
|
509
|
+
* {@link DEFAULT_UI_STRINGS}. Resolved once on connect (so set it before the
|
|
510
|
+
* element is appended); the `data-strings` JSON attribute is the markup
|
|
511
|
+
* equivalent, and this property wins key-by-key over it.
|
|
512
|
+
*/
|
|
513
|
+
strings: Partial<UiStrings> = {};
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Resolve a `scroll_to` / `drag_and_drop` target string to a host-page
|
|
517
|
+
* element (or `null`). Defaults to a CSS-selector lookup; override to map
|
|
518
|
+
* page-map element ids. The page-action tools are opt-in via the
|
|
519
|
+
* `data-page-actions` attribute (`"scroll"` / `"drag"` / `"chat"`).
|
|
520
|
+
*/
|
|
521
|
+
resolvePageTarget: ResolvePageTarget = (target) => document.querySelector<HTMLElement>(target);
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Foreign origins already reported, so the notice is once per origin per
|
|
525
|
+
* element rather than once per request. Per-element rather than module-level,
|
|
526
|
+
* because two elements on one page are two separate configurations.
|
|
527
|
+
*/
|
|
528
|
+
#warnedOrigins = new Set<string>();
|
|
529
|
+
/** The resolved string table (defaults ← `data-strings` ← `strings`). */
|
|
530
|
+
#strings: UiStrings = DEFAULT_UI_STRINGS;
|
|
531
|
+
|
|
532
|
+
/** A delegated sub-agent's progress, hung off the card that delegated. */
|
|
533
|
+
readonly #subagents = new SubAgentProgress({
|
|
534
|
+
card: (callId) => this.#transcript.card(callId),
|
|
535
|
+
strings: () => this.#strings,
|
|
536
|
+
follow: () => this.#transcript.follow(),
|
|
537
|
+
});
|
|
538
|
+
/**
|
|
539
|
+
* The AG-UI activities this element can draw, and the blocks it drew. A field
|
|
540
|
+
* rather than built in the constructor, because the constructor registers the
|
|
541
|
+
* built-in renderers through it.
|
|
542
|
+
*/
|
|
543
|
+
readonly #activities = new ActivityRegistry({
|
|
544
|
+
ensureGroup: () => this.#transcript.ensureGroup(),
|
|
545
|
+
afterTranscriptGrew: () => this.#transcript.afterGrew(),
|
|
546
|
+
appendNotice: (icon, text, kind) => this.#transcript.appendNotice(icon, text, kind),
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
/** The action row under each finished answer, and the one row holding Retry. */
|
|
550
|
+
readonly #actions = new AnswerActions({
|
|
551
|
+
element: this,
|
|
552
|
+
strings: () => this.#strings,
|
|
553
|
+
retry: () => {
|
|
554
|
+
void this.retryLastTurn();
|
|
555
|
+
},
|
|
556
|
+
// Late-bound: the history is built in the constructor, after this field.
|
|
557
|
+
threadId: () => this.#history.threadId,
|
|
558
|
+
follow: () => this.#transcript.follow(),
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
readonly #root: ShadowRoot;
|
|
562
|
+
/** The built-in stylesheet once rendered, kept so `extraStyles` can re-adopt beside it. */
|
|
563
|
+
#builtInSheet: CSSStyleSheet | null = null;
|
|
564
|
+
/** The host's own sheets, adopted after the built-in one. */
|
|
565
|
+
#extraStyles: readonly CSSStyleSheet[] = [];
|
|
566
|
+
/** The screen-reader-only status region the run and its tool calls report into. */
|
|
567
|
+
readonly #announcer = new RunAnnouncer();
|
|
568
|
+
/** Return-to-foot affordance, shown only once something has been missed. */
|
|
569
|
+
readonly #jumpButton = document.createElement("button");
|
|
570
|
+
/**
|
|
571
|
+
* Quoting a selection into the composer: the offer beside a transcript
|
|
572
|
+
* selection, the same offer over the host page, and the quotation itself.
|
|
573
|
+
* Built in the constructor, once the composer it writes into exists.
|
|
574
|
+
*/
|
|
575
|
+
readonly #excerpts: TranscriptQuoteOffer;
|
|
576
|
+
/**
|
|
577
|
+
* Positioning context for {@link AgUiChat.#jumpButton}.
|
|
578
|
+
*
|
|
579
|
+
* The button cannot live in the scrolling list -- it would scroll away with
|
|
580
|
+
* the content it is offering to scroll to -- and it cannot be positioned
|
|
581
|
+
* against the panel either: the panel's foot is below the composer, the skill
|
|
582
|
+
* chips and the footer, so `bottom` measured from there lands the button on
|
|
583
|
+
* top of the composer rather than over the transcript. This wrapper is the
|
|
584
|
+
* only box whose foot *is* the transcript's foot.
|
|
585
|
+
*/
|
|
586
|
+
readonly #messagesWrap = document.createElement("div");
|
|
587
|
+
readonly #chat: HTMLDivElement;
|
|
588
|
+
readonly #messages: HTMLDivElement;
|
|
589
|
+
readonly #input: HTMLTextAreaElement;
|
|
590
|
+
readonly #send: HTMLButtonElement;
|
|
591
|
+
readonly #title: HTMLSpanElement;
|
|
592
|
+
readonly #skillsMenu: SkillsMenu;
|
|
593
|
+
readonly #drawer: ThreadDrawer;
|
|
594
|
+
/** Checkpoint panel; rows load only when `data-runs-url` is set. */
|
|
595
|
+
readonly #checkpoints: CheckpointMenu;
|
|
596
|
+
/**
|
|
597
|
+
* Which conversation is on screen and how one gets there: the active thread,
|
|
598
|
+
* the restore from the store, the conversation list's verbs and the
|
|
599
|
+
* checkpoint continuation. Built in the constructor, after the drawer, the
|
|
600
|
+
* checkpoint panel and the run it continues exist.
|
|
601
|
+
*/
|
|
602
|
+
readonly #history: ConversationHistory;
|
|
603
|
+
/**
|
|
604
|
+
* The one-line hint above the composer, cleared by the next keystroke.
|
|
605
|
+
*
|
|
606
|
+
* Two things write it -- a skill whose template is short of a field, and a
|
|
607
|
+
* run continuation picked with nothing typed -- and both say the same kind of
|
|
608
|
+
* thing: what the composer still needs before this can go. Its `part` stays
|
|
609
|
+
* `skill-hint`, which the skills feature named and the README documents;
|
|
610
|
+
* renaming a part is breaking, and a second hint element in the same slot
|
|
611
|
+
* would be worse than one whose name is a release older than its job.
|
|
612
|
+
*/
|
|
613
|
+
readonly #composerHint: HTMLDivElement;
|
|
614
|
+
/** File-picker button + hidden input + tray slot; the tray mounts on connect. */
|
|
615
|
+
readonly #attachButton: HTMLButtonElement;
|
|
616
|
+
readonly #fileInput: HTMLInputElement;
|
|
617
|
+
readonly #attachSlot: HTMLDivElement;
|
|
618
|
+
/** Optional built-in header theme toggle; shown only with `data-theme-toggle`. */
|
|
619
|
+
readonly #themeToggle: HTMLButtonElement;
|
|
620
|
+
/** What the collapsed widget shrinks to: the floating launcher, or the sidebar rail. */
|
|
621
|
+
readonly #launcher: HTMLButtonElement;
|
|
622
|
+
/** The launcher's unread badge; hidden at zero, while expanded, and when the host opts out. */
|
|
623
|
+
readonly #badge: HTMLSpanElement;
|
|
624
|
+
/** The rail's vertical caption. Rendered only by the sidebar's edge rail and the bottom bar. */
|
|
625
|
+
readonly #railLabel: HTMLSpanElement = document.createElement("span");
|
|
626
|
+
/**
|
|
627
|
+
* The bottom bar under collapse-to="bar": the launcher drawn as a strip, and
|
|
628
|
+
* the history button beside it. Outside the bar it draws no box of its own.
|
|
629
|
+
*/
|
|
630
|
+
readonly #dockBar: HTMLDivElement = document.createElement("div");
|
|
631
|
+
/** The bottom bar's history button, which opens the drawer as a popover. */
|
|
632
|
+
readonly #barHistory: HTMLButtonElement = document.createElement("button");
|
|
633
|
+
/**
|
|
634
|
+
* The conversation list's home while collapsed under collapse-to="none": outside
|
|
635
|
+
* the hidden panel, placed beside the host's button. No box of its own.
|
|
636
|
+
*/
|
|
637
|
+
readonly #threadsPopover: HTMLDivElement = document.createElement("div");
|
|
638
|
+
/** The button the popover was opened from, whose presses are not outside it. */
|
|
639
|
+
#threadsAnchor: Element | null = null;
|
|
640
|
+
/** The bar height last reported, so the event fires on a change only. */
|
|
641
|
+
#barHeight = 0;
|
|
642
|
+
/** The docked width and edge last reported, so the event fires on a change only. */
|
|
643
|
+
#dockWidth = 0;
|
|
644
|
+
#dockSide: DockWidthDetail["side"] = "right";
|
|
645
|
+
/** The element the panel rises from under collapse-to="none", set as a property. */
|
|
646
|
+
#anchor: Element | null = null;
|
|
647
|
+
/** Whether a missing anchor has been reported; once per anchor given. */
|
|
648
|
+
#anchorWarned = false;
|
|
649
|
+
// Unseen answers per thread: finished while collapsed or off screen.
|
|
650
|
+
#unreadByThread = new Map<string, number>();
|
|
651
|
+
/** Empty-state region at the top of the message list; hidden once anything renders. */
|
|
652
|
+
readonly #emptyWrap: HTMLDivElement;
|
|
653
|
+
/**
|
|
654
|
+
* The transcript: bubbles, the open answer group, the pending dots, the
|
|
655
|
+
* reasoning region, the tool cards and the scroller. Built in the
|
|
656
|
+
* constructor, once the list and its empty-state region exist.
|
|
657
|
+
*/
|
|
658
|
+
readonly #transcript: Transcript;
|
|
659
|
+
/**
|
|
660
|
+
* Running the tool calls a round produced and answering the server's
|
|
661
|
+
* interrupts. Built in the constructor, after the transcript it draws in.
|
|
662
|
+
*/
|
|
663
|
+
readonly #dispatch: ToolDispatch;
|
|
664
|
+
/**
|
|
665
|
+
* The AG-UI event handlers every client this element builds is given, and
|
|
666
|
+
* what one interaction accumulates for the host until it settles. Built in
|
|
667
|
+
* the constructor, after the transcript it draws in.
|
|
668
|
+
*/
|
|
669
|
+
readonly #runHandlers: RunHandlers;
|
|
670
|
+
/**
|
|
671
|
+
* The greeting's own text, the fallback content of the `greeting` slot.
|
|
672
|
+
* Rendered under every placement and shown by the stylesheet only where the
|
|
673
|
+
* greeting layout is on, so a placement switch needs nothing from script.
|
|
674
|
+
*/
|
|
675
|
+
readonly #greetingText: HTMLSpanElement = document.createElement("span");
|
|
676
|
+
/**
|
|
677
|
+
* Files handed to the composer: the upload tray and the picker, drop and
|
|
678
|
+
* paste routes into it. Built in the constructor; the tray itself only once
|
|
679
|
+
* uploads are wired on connect.
|
|
680
|
+
*/
|
|
681
|
+
readonly #attachments: ComposerAttachments;
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* Where the panel and launcher sit and how big the panel is: the drags, the
|
|
685
|
+
* resize grips, and the persisted record of them. Built in the constructor,
|
|
686
|
+
* once the launcher and the shadow root it reaches into exist.
|
|
687
|
+
*/
|
|
688
|
+
readonly #placement: PanelPlacement;
|
|
689
|
+
/** The maximized state as applied, which the reflected attribute may run ahead of. */
|
|
690
|
+
#maximized = false;
|
|
691
|
+
/** The maximized state listeners were last told, which a mount settles once. */
|
|
692
|
+
#maximizedHeard = false;
|
|
693
|
+
/** The header's maximize and collapse controls, once rendered. */
|
|
694
|
+
#maximizeControl: { readonly button: HTMLButtonElement; readonly before: HTMLElement } | null =
|
|
695
|
+
null;
|
|
696
|
+
/** The header's collapse control, once rendered, repainted for its placement. */
|
|
697
|
+
#collapseControl: HTMLButtonElement | null = null;
|
|
698
|
+
/** What the user has sent this session, newest first, for arrow-key recall. */
|
|
699
|
+
readonly #sentDrafts: string[] = [];
|
|
700
|
+
/** Typed while a run was in flight, oldest first; sent when it settles. */
|
|
701
|
+
readonly #queued: string[] = [];
|
|
702
|
+
/** The row those show up in, above the composer. */
|
|
703
|
+
readonly #queuedRow: HTMLDivElement = document.createElement("div");
|
|
704
|
+
/** How far back the composer has been walked; null while the user is typing. */
|
|
705
|
+
#recallIndex: number | null = null;
|
|
706
|
+
/**
|
|
707
|
+
* What the walk last put in the composer, read back from it. A walk is live
|
|
708
|
+
* only while the composer still holds exactly this, so a write from anywhere
|
|
709
|
+
* else ends it. Stale while no walk is in progress, which is harmless: the
|
|
710
|
+
* comparison can only ever end a walk, never start one.
|
|
711
|
+
*/
|
|
712
|
+
#recalled = "";
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Re-clamp the dragged launcher when the window changes size. Bound once as
|
|
716
|
+
* a field so `removeEventListener` on disconnect gets the same reference.
|
|
717
|
+
*/
|
|
718
|
+
readonly #onViewportResize = (): void => {
|
|
719
|
+
if (this.#readyToPlace()) {
|
|
720
|
+
this.#placement.restoreLauncherPosition();
|
|
721
|
+
}
|
|
722
|
+
};
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* The same for a change only the panel's own layout shows: a host's size, a
|
|
726
|
+
* viewport inset token, a line typed in the composer. Not a moment to hand a
|
|
727
|
+
* position back, so a panel under data-launcher-drag="false" stays put.
|
|
728
|
+
*/
|
|
729
|
+
readonly #onLayoutChange = (): void => {
|
|
730
|
+
if (this.#readyToPlace()) {
|
|
731
|
+
this.#placement.settleLauncherPosition();
|
|
732
|
+
}
|
|
733
|
+
};
|
|
734
|
+
|
|
735
|
+
/** Bring the viewport's readings up to date, and say whether placing may follow. */
|
|
736
|
+
#readyToPlace(): boolean {
|
|
737
|
+
this.#placement.publishVisualViewport();
|
|
738
|
+
this.#syncDock();
|
|
739
|
+
// Not while a gesture owns the position: restoring re-applies the stored,
|
|
740
|
+
// pre-drag position, and on a phone the visual viewport changes all through
|
|
741
|
+
// a drag, so the two would fight. Nor while this element has no box: placing
|
|
742
|
+
// would measure a rect of nothing. Showing it reports again.
|
|
743
|
+
return !this.#placement.dragging() && this.#hasBox();
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* Whether this element generates a box at all. Hidden on itself or on any ancestor
|
|
748
|
+
* it does not; a host collapsed to its launcher does, whatever its size, and that
|
|
749
|
+
* launcher still has to be placed, which is why this is not a test of the size.
|
|
750
|
+
*/
|
|
751
|
+
#hasBox(): boolean {
|
|
752
|
+
return this.getClientRects().length > 0;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/** Restore for a moment that is not a viewport's, which showing this reports anyway. */
|
|
756
|
+
#restoreIfBoxed(): void {
|
|
757
|
+
if (this.#hasBox()) {
|
|
758
|
+
this.#placement.restoreLauncherPosition();
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/** Mic button mount point (input row); the control mounts on connect when enabled. */
|
|
763
|
+
readonly #voiceSlot: HTMLSpanElement;
|
|
764
|
+
/** The composer's mic, mounted on connect when transcription is available. */
|
|
765
|
+
readonly #voice: ComposerVoice;
|
|
766
|
+
/** Whether the element is currently in the DOM; gates the connect-time warning. */
|
|
767
|
+
#connected = false;
|
|
768
|
+
/** A {@link newChat} asked for before the element connected, to start at connect. */
|
|
769
|
+
#newChatOnConnect = false;
|
|
770
|
+
/**
|
|
771
|
+
* Aborted when the element leaves the document, and replaced when it comes
|
|
772
|
+
* back. Every listener connecting adds to a node that outlives the connection
|
|
773
|
+
* goes under this signal, since connecting runs again on every insertion.
|
|
774
|
+
*/
|
|
775
|
+
#connection = new AbortController();
|
|
776
|
+
/**
|
|
777
|
+
* Whether the element has connected before, which makes this connection a
|
|
778
|
+
* re-insertion: there is a transcript on screen from last time, and the
|
|
779
|
+
* history replay would draw the conversation a second time beneath it.
|
|
780
|
+
*/
|
|
781
|
+
#connectedBefore = false;
|
|
782
|
+
/**
|
|
783
|
+
* The `user-key` whose state the element holds -- the transcript, the recall
|
|
784
|
+
* history, the Always allow waivers and the shared state -- or `null` before
|
|
785
|
+
* the first connect, when it holds nobody's.
|
|
786
|
+
*
|
|
787
|
+
* Not the same thing as the attribute. The attribute names the principal the
|
|
788
|
+
* host says is here now, and it can change while the element is out of the
|
|
789
|
+
* document, where nothing acts on it; this is who the state still belongs
|
|
790
|
+
* to. Connecting compares the two, which is how a key changed while detached
|
|
791
|
+
* still gets the handover a live change gets.
|
|
792
|
+
*/
|
|
793
|
+
#principalKey: string | null = null;
|
|
794
|
+
/**
|
|
795
|
+
* The current principal's tenure: replaced on every handover, and captured by
|
|
796
|
+
* each client as it is built. A client outlives the handover that stops it (a
|
|
797
|
+
* stopped run's last save, a tool result, an event already read off the
|
|
798
|
+
* wire), and one whose tenure has passed writes nothing, into either
|
|
799
|
+
* principal's storage or state. An identity rather than the key: a first key
|
|
800
|
+
* is an adoption and keeps saving, and a key that leaves and comes back is a
|
|
801
|
+
* second tenure.
|
|
802
|
+
*/
|
|
803
|
+
#tenure: object = {};
|
|
804
|
+
/**
|
|
805
|
+
* The conversation on screen: replaced whenever {@link #resetConversation}
|
|
806
|
+
* clears the transcript for another one, and captured by each frontend tool
|
|
807
|
+
* call as it is dispatched. A call outlives the reset that stops its run, and
|
|
808
|
+
* one resuming after it skips drawing into the new conversation. Not
|
|
809
|
+
* {@link #tenure}: the call still settles its card, clears its checkpoint and
|
|
810
|
+
* returns its result.
|
|
811
|
+
*/
|
|
812
|
+
#conversation: object = {};
|
|
813
|
+
/**
|
|
814
|
+
* The remote store `data-threads-url` wrapped around the conversation store,
|
|
815
|
+
* and the store inside it, so connecting again can wrap that store rather
|
|
816
|
+
* than the wrapper.
|
|
817
|
+
*/
|
|
818
|
+
#threadStore: { remote: RemoteConversationStore; inner: ClientConversationStore } | null = null;
|
|
819
|
+
|
|
820
|
+
#client: AgUiClient | null = null;
|
|
821
|
+
// Seed for the next client. Once one exists it owns the live value (the
|
|
822
|
+
// agent applies STATE_SNAPSHOT / STATE_DELTA into it), so this is only the
|
|
823
|
+
// starting point — `sharedState` reads through to the client when present,
|
|
824
|
+
// and to a running checkpoint continuation before that. Every client mirrors
|
|
825
|
+
// its changes here, which is what the client built after a continuation is
|
|
826
|
+
// seeded from.
|
|
827
|
+
#sharedState: Record<string, unknown> = {};
|
|
828
|
+
// Whether an interaction is in flight (first onRunStart → onSettled). Drives
|
|
829
|
+
// the Send⇄Stop button: `agent.isRunning` is false between frontend-tool
|
|
830
|
+
// rounds, but the user must still be able to stop there.
|
|
831
|
+
#running = false;
|
|
832
|
+
/**
|
|
833
|
+
* The send {@link sendMessage} or the retry {@link retryLastTurn} has taken
|
|
834
|
+
* whose run has not started yet; null otherwise. `running` turns on only a
|
|
835
|
+
* microtask after the run is asked for, and a submit listener or a second
|
|
836
|
+
* call in that gap would start a second run on the same client. Every restore
|
|
837
|
+
* takes it too, through `holdRun`, until the store answers.
|
|
838
|
+
*
|
|
839
|
+
* Let go of once the run starts, since `running` holds from then and a hold
|
|
840
|
+
* past it would refuse a host's follow-up from the run-finished event; or by
|
|
841
|
+
* the send's, retry's or restore's end when its run never starts. An object
|
|
842
|
+
* rather than a flag, so each ending releases only what it took, and how
|
|
843
|
+
* {@link sendMessage} tells the hold it waits on apart: see {@link #restoreHold}.
|
|
844
|
+
*/
|
|
845
|
+
#sending: object | null = null;
|
|
846
|
+
/**
|
|
847
|
+
* The hold the latest restore with no run to resume took, and what settles
|
|
848
|
+
* once that restore has ended or been stopped; `null` when the latest
|
|
849
|
+
* restore will resume a run. Only meaningful while `#sending` is that hold:
|
|
850
|
+
* then a host's {@link sendMessage} waits for it rather than refusing.
|
|
851
|
+
*/
|
|
852
|
+
#restoreHold: { readonly hold: object; readonly restored: Promise<void> } | null = null;
|
|
853
|
+
/**
|
|
854
|
+
* The thread the latest {@link THREAD_EVENT} reported, which the next one
|
|
855
|
+
* names as the thread it replaced; `null` before the first.
|
|
856
|
+
*/
|
|
857
|
+
#shownThread: string | null = null;
|
|
858
|
+
/** The thread on screen's messages as last saved or restored, which name it. */
|
|
859
|
+
#titleMessages: readonly Message[] = [];
|
|
860
|
+
/** Titles renamed here or listed that its messages do not derive, by thread. */
|
|
861
|
+
readonly #givenTitles = new Map<string, string>();
|
|
862
|
+
/** The thread and title {@link threadTitle} last reported. */
|
|
863
|
+
#titled = { threadId: "", title: "" };
|
|
864
|
+
/** The decision a run is suspended on, which a Stop abandons. */
|
|
865
|
+
readonly #decision = new PendingDecision();
|
|
866
|
+
/** The place chip, kept across connections like the composer it sits in. */
|
|
867
|
+
readonly #place = new PlaceChip();
|
|
868
|
+
/**
|
|
869
|
+
* The frontend tools the agent is offered, what the current round advertised,
|
|
870
|
+
* and the server's labels for its own tools. A field, because the tool
|
|
871
|
+
* registry it holds is reachable from the moment the element exists.
|
|
872
|
+
*/
|
|
873
|
+
readonly #tools = new ToolCatalog({
|
|
874
|
+
element: this,
|
|
875
|
+
routeMap: () => this.routeMap,
|
|
876
|
+
navigate: () => this.navigate,
|
|
877
|
+
getPageMap: () => this.getPageMap,
|
|
878
|
+
pageWithheld: () => this.#place.withheld,
|
|
879
|
+
resolvePageTarget: (target) => this.resolvePageTarget(target),
|
|
880
|
+
getTools: () => this.getTools(),
|
|
881
|
+
askUser: () => this.askUser,
|
|
882
|
+
askUserRenderer: () => this.askUserRenderer,
|
|
883
|
+
decision: this.#decision,
|
|
884
|
+
setAsking: (callId, asking) => this.#transcript.setAsking(callId, asking),
|
|
885
|
+
ensureGroup: () => this.#transcript.ensureGroup(),
|
|
886
|
+
strings: () => this.#strings,
|
|
887
|
+
hidePending: () => this.#transcript.hidePending(),
|
|
888
|
+
updateEmptyState: () => this.#transcript.updateEmptyState(),
|
|
889
|
+
follow: () => this.#transcript.follow(),
|
|
890
|
+
fetchInit: (url) => this.#fetchInit(url),
|
|
891
|
+
});
|
|
892
|
+
/** The assistant answer currently streaming into the transcript. */
|
|
893
|
+
readonly #stream = new AnswerStream({
|
|
894
|
+
openBubble: () => this.appendMessage(MESSAGE_ROLE.ASSISTANT, ""),
|
|
895
|
+
allowImages: () => this.allowImages,
|
|
896
|
+
follow: () => this.#transcript.follow(),
|
|
897
|
+
});
|
|
898
|
+
// This chat's place among the connected ones, which a free highlight ring reads.
|
|
899
|
+
readonly #chatRegistration = new ChatRegistration(this);
|
|
900
|
+
/**
|
|
901
|
+
* Which storage keys are this element's: the namespace it claims, the keys
|
|
902
|
+
* its layout preferences live under, and the built-in store scoped to them.
|
|
903
|
+
*/
|
|
904
|
+
readonly #storage = new StorageScope({
|
|
905
|
+
id: () => this.id,
|
|
906
|
+
endpoint: () => this.endpoint,
|
|
907
|
+
});
|
|
908
|
+
/**
|
|
909
|
+
* The skill catalog: its three sources merged into the menu, and what a pick
|
|
910
|
+
* does to the composer. Built in the constructor, after the menu it fills.
|
|
911
|
+
*/
|
|
912
|
+
readonly #skills: SkillCatalog;
|
|
913
|
+
|
|
914
|
+
constructor() {
|
|
915
|
+
super();
|
|
916
|
+
this.#root = this.attachShadow({ mode: "open" });
|
|
917
|
+
this.#chat = document.createElement("div");
|
|
918
|
+
this.#messages = document.createElement("div");
|
|
919
|
+
this.#input = document.createElement("textarea");
|
|
920
|
+
this.#send = document.createElement("button");
|
|
921
|
+
this.#title = document.createElement("span");
|
|
922
|
+
this.#composerHint = document.createElement("div");
|
|
923
|
+
this.#attachButton = document.createElement("button");
|
|
924
|
+
this.#fileInput = document.createElement("input");
|
|
925
|
+
this.#attachSlot = document.createElement("div");
|
|
926
|
+
this.#voiceSlot = document.createElement("span");
|
|
927
|
+
this.#themeToggle = document.createElement("button");
|
|
928
|
+
this.#launcher = document.createElement("button");
|
|
929
|
+
this.#badge = document.createElement("span");
|
|
930
|
+
this.#emptyWrap = document.createElement("div");
|
|
931
|
+
this.#transcript = new Transcript({
|
|
932
|
+
element: this,
|
|
933
|
+
messages: this.#messages,
|
|
934
|
+
emptyWrap: this.#emptyWrap,
|
|
935
|
+
strings: () => this.#strings,
|
|
936
|
+
allowImages: () => this.allowImages,
|
|
937
|
+
resolveTool: (name) => this.#tools.resolve(name),
|
|
938
|
+
toolSummaries: () => this.toolSummaries,
|
|
939
|
+
serverSummary: (name) => this.#tools.summary(name),
|
|
940
|
+
toolDisplay: () => this.toolDisplay,
|
|
941
|
+
toolGroupDisplay: () => this.toolGroupDisplay,
|
|
942
|
+
// A thunk over the live property, not the property itself: the card keeps
|
|
943
|
+
// this for the life of the call, and the result region is filled when the
|
|
944
|
+
// tool settles -- which can be long after a host set the hook.
|
|
945
|
+
formatToolPayload: (payload) => this.formatToolPayload?.(payload) ?? null,
|
|
946
|
+
});
|
|
947
|
+
this.#dispatch = new ToolDispatch({
|
|
948
|
+
element: this,
|
|
949
|
+
transcript: this.#transcript,
|
|
950
|
+
tools: this.#tools,
|
|
951
|
+
decision: this.#decision,
|
|
952
|
+
strings: () => this.#strings,
|
|
953
|
+
announce: (message) => this.#announcer.announce(message),
|
|
954
|
+
autoConfirm: () => this.autoConfirm,
|
|
955
|
+
confirmPredicate: () => this.confirmPredicate,
|
|
956
|
+
navigate: () => this.navigate,
|
|
957
|
+
approveWithEdits: () => this.approveWithEdits,
|
|
958
|
+
approvalRenderer: () => this.approvalRenderer,
|
|
959
|
+
getContext: () => this.#roundContext(),
|
|
960
|
+
conversationStore: () => this.conversationStore,
|
|
961
|
+
threadId: () => this.#history.threadId,
|
|
962
|
+
// Read live and compared, the way each client compares the one it was
|
|
963
|
+
// built with: see #tenure.
|
|
964
|
+
tenure: () => this.#tenure,
|
|
965
|
+
// The same, for the conversation on screen: see #conversation.
|
|
966
|
+
conversation: () => this.#conversation,
|
|
967
|
+
});
|
|
968
|
+
this.#runHandlers = new RunHandlers({
|
|
969
|
+
element: this,
|
|
970
|
+
transcript: this.#transcript,
|
|
971
|
+
stream: this.#stream,
|
|
972
|
+
actions: this.#actions,
|
|
973
|
+
activities: this.#activities,
|
|
974
|
+
subagents: this.#subagents,
|
|
975
|
+
announcer: this.#announcer,
|
|
976
|
+
strings: () => this.#strings,
|
|
977
|
+
running: () => this.#running,
|
|
978
|
+
setRunning: (running) => this.#setRunning(running),
|
|
979
|
+
appendMessage: (role, content) => this.appendMessage(role, content),
|
|
980
|
+
noteUnread: (threadId) => this.#noteUnread(threadId),
|
|
981
|
+
});
|
|
982
|
+
this.#placement = new PanelPlacement({
|
|
983
|
+
element: this,
|
|
984
|
+
launcher: this.#launcher,
|
|
985
|
+
root: this.#root,
|
|
986
|
+
connected: () => this.#connected,
|
|
987
|
+
collapsed: () => this.collapsed,
|
|
988
|
+
collapsible: () => this.#surface().collapseTo !== null,
|
|
989
|
+
strings: () => this.#strings,
|
|
990
|
+
readPreference: (base) => this.#storage.readPreference(base),
|
|
991
|
+
writePreference: (base, value) => this.#storage.writePreference(base, value),
|
|
992
|
+
clearPreference: (base) => this.#storage.clearPreference(base),
|
|
993
|
+
announceSurfaceChange: (text, undo) => this.#announceSurfaceChange(text, undo),
|
|
994
|
+
});
|
|
995
|
+
this.#excerpts = new TranscriptQuoteOffer({
|
|
996
|
+
element: this,
|
|
997
|
+
root: this.#root,
|
|
998
|
+
messages: this.#messages,
|
|
999
|
+
messagesWrap: this.#messagesWrap,
|
|
1000
|
+
input: this.#input,
|
|
1001
|
+
strings: () => this.#strings,
|
|
1002
|
+
autoGrow: () => autoGrow(this.#input),
|
|
1003
|
+
quote: (text) => this.quote(text),
|
|
1004
|
+
});
|
|
1005
|
+
this.#attachments = new ComposerAttachments({
|
|
1006
|
+
element: this,
|
|
1007
|
+
chat: this.#chat,
|
|
1008
|
+
slot: this.#attachSlot,
|
|
1009
|
+
fileInput: this.#fileInput,
|
|
1010
|
+
button: this.#attachButton,
|
|
1011
|
+
strings: () => this.#strings,
|
|
1012
|
+
uploadHandler: () => this.uploadHandler,
|
|
1013
|
+
headersFor: (url) => this.#headersFor(url),
|
|
1014
|
+
credentialsOption: () => this.#credentialsOption(),
|
|
1015
|
+
});
|
|
1016
|
+
this.#voice = new ComposerVoice({
|
|
1017
|
+
element: this,
|
|
1018
|
+
slot: this.#voiceSlot,
|
|
1019
|
+
input: this.#input,
|
|
1020
|
+
strings: () => this.#strings,
|
|
1021
|
+
transcribeHandler: () => this.transcribeHandler,
|
|
1022
|
+
headersFor: (url) => this.#headersFor(url),
|
|
1023
|
+
credentialsOption: () => this.#credentialsOption(),
|
|
1024
|
+
onInput: () => this.#onInput(),
|
|
1025
|
+
});
|
|
1026
|
+
// The compaction notice is a registration, not a branch -- and going through
|
|
1027
|
+
// the seam earns it two things it did not have: a reload puts it back (it is
|
|
1028
|
+
// content, and content replays), and a server redrawing under the same id
|
|
1029
|
+
// replaces it rather than adding a second notice for one event.
|
|
1030
|
+
this.registerActivityRenderer({
|
|
1031
|
+
type: COMPACTION_ACTIVITY_TYPE,
|
|
1032
|
+
render: (content) => {
|
|
1033
|
+
const removed = compactionRemoved(content);
|
|
1034
|
+
return removed === null
|
|
1035
|
+
? null
|
|
1036
|
+
: renderRunNotice(
|
|
1037
|
+
"\u{1F5DC}",
|
|
1038
|
+
fillUiString(this.#strings.historyCompacted, { count: removed }),
|
|
1039
|
+
"compaction",
|
|
1040
|
+
);
|
|
1041
|
+
},
|
|
1042
|
+
});
|
|
1043
|
+
// Follow-up chips, registered through the same seam for the same reasons:
|
|
1044
|
+
// a reload puts them back, and a server pushing a new set under a new id
|
|
1045
|
+
// supersedes the old one rather than leaving two offers on screen.
|
|
1046
|
+
this.registerActivityRenderer({
|
|
1047
|
+
type: SUGGESTIONS_ACTIVITY_TYPE,
|
|
1048
|
+
render: (content) =>
|
|
1049
|
+
renderSuggestionChips(content, this.#strings, (prompt) => {
|
|
1050
|
+
void this.sendMessage(prompt);
|
|
1051
|
+
}),
|
|
1052
|
+
});
|
|
1053
|
+
this.#skillsMenu = new SkillsMenu((skill) => this.#skills.apply(skill));
|
|
1054
|
+
this.#skills = new SkillCatalog({
|
|
1055
|
+
element: this,
|
|
1056
|
+
menu: this.#skillsMenu,
|
|
1057
|
+
input: this.#input,
|
|
1058
|
+
hint: this.#composerHint,
|
|
1059
|
+
strings: () => this.#strings,
|
|
1060
|
+
context: () => this.skillContext(),
|
|
1061
|
+
flag: (name) => readFlag(this, name),
|
|
1062
|
+
readJsonAttribute: (name) => readJsonAttribute(this, name),
|
|
1063
|
+
fetchInit: (url) => this.#fetchInit(url),
|
|
1064
|
+
send: (content) => {
|
|
1065
|
+
void this.sendMessage(content);
|
|
1066
|
+
},
|
|
1067
|
+
submit: () => {
|
|
1068
|
+
void this.#submit();
|
|
1069
|
+
},
|
|
1070
|
+
autoGrow: () => autoGrow(this.#input),
|
|
1071
|
+
});
|
|
1072
|
+
this.#drawer = new ThreadDrawer({
|
|
1073
|
+
onSelect: (threadId) => {
|
|
1074
|
+
// switchThread rejects when the store cannot load the thread, and keeps
|
|
1075
|
+
// the old one on screen; a pick has nobody to tell, so it ends here.
|
|
1076
|
+
this.#history.switchThread(threadId).catch(() => undefined);
|
|
1077
|
+
},
|
|
1078
|
+
onNew: () => {
|
|
1079
|
+
this.newChat();
|
|
1080
|
+
void this.#history.refreshDrawer();
|
|
1081
|
+
},
|
|
1082
|
+
onRename: (threadId, title) => {
|
|
1083
|
+
this.#givenTitles.set(threadId, title);
|
|
1084
|
+
this.#retitle();
|
|
1085
|
+
this.#history.renameThread(threadId, title);
|
|
1086
|
+
},
|
|
1087
|
+
onDelete: (threadId) => {
|
|
1088
|
+
this.#history.deleteThread(threadId);
|
|
1089
|
+
},
|
|
1090
|
+
});
|
|
1091
|
+
this.#checkpoints = new CheckpointMenu((runId, verb) => {
|
|
1092
|
+
// Reported rather than dropped: the send fails when the host's store
|
|
1093
|
+
// refuses the first save, and `continueRun` has already let go of the
|
|
1094
|
+
// continuation by then, so the panel takes another pick.
|
|
1095
|
+
void this.#history.continueRun(runId, verb).catch((error: unknown) => {
|
|
1096
|
+
console.warn("<ag-ui-chat>: continuing a run failed", error);
|
|
1097
|
+
});
|
|
1098
|
+
});
|
|
1099
|
+
this.#history = new ConversationHistory({
|
|
1100
|
+
element: this,
|
|
1101
|
+
drawer: this.#drawer,
|
|
1102
|
+
checkpoints: this.#checkpoints,
|
|
1103
|
+
transcript: this.#transcript,
|
|
1104
|
+
actions: this.#actions,
|
|
1105
|
+
activities: this.#activities,
|
|
1106
|
+
tools: this.#tools,
|
|
1107
|
+
input: this.#input,
|
|
1108
|
+
hint: this.#composerHint,
|
|
1109
|
+
strings: () => this.#strings,
|
|
1110
|
+
conversationStore: () => this.conversationStore,
|
|
1111
|
+
formatRelativeTime: () => this.formatRelativeTime,
|
|
1112
|
+
navigationResult: () => this.navigationResult,
|
|
1113
|
+
headersFor: (url) => this.#headersFor(url),
|
|
1114
|
+
requestCredentials: () => this.#requestCredentials(),
|
|
1115
|
+
appendMessage: (role, content) => this.appendMessage(role, content),
|
|
1116
|
+
// A continuation sends only what the composer holds. The tray is not
|
|
1117
|
+
// read, so nothing rides along to be drawn or announced.
|
|
1118
|
+
announceTurn: (content) => this.#announceTurn(content, []),
|
|
1119
|
+
recordTurn: (content) => this.#recordTurn(content),
|
|
1120
|
+
autoGrow: () => autoGrow(this.#input),
|
|
1121
|
+
continuationEnded: () => this.#flushQueued(),
|
|
1122
|
+
client: () => this.#client,
|
|
1123
|
+
ensureClient: () => this.#ensureClient(),
|
|
1124
|
+
buildClient: (seed) => this.#buildClient(seed),
|
|
1125
|
+
releaseClient: () => {
|
|
1126
|
+
this.#client = null;
|
|
1127
|
+
},
|
|
1128
|
+
// A held send or retry is as much in flight to a pick as a run is: it has
|
|
1129
|
+
// asked this element's client for a run that has not started, and a
|
|
1130
|
+
// continuation started beside it was a second run in one conversation.
|
|
1131
|
+
// So is a restore, which holds through the same field from before the
|
|
1132
|
+
// store answers.
|
|
1133
|
+
running: () => this.#running || this.#sending !== null,
|
|
1134
|
+
// The hold a send takes, taken for every restore: Send parks the turn, a
|
|
1135
|
+
// Retry and a pick refuse, and `sendMessage` refuses behind a restore that
|
|
1136
|
+
// resumes a run and waits on `restored` for one that does not. The same
|
|
1137
|
+
// field rather than a flag, so the resumed run starting (`#setRunning`) or
|
|
1138
|
+
// a Stop (`#cancelRun`) lets go of it; a flag the restore cleared would
|
|
1139
|
+
// refuse the parked turn once the resumed run settles. Whether the restore
|
|
1140
|
+
// was stopped is asked of its own generation, since a waiting send takes
|
|
1141
|
+
// this hold over.
|
|
1142
|
+
holdRun: (restored) => {
|
|
1143
|
+
const hold = {};
|
|
1144
|
+
this.#sending = hold;
|
|
1145
|
+
this.#restoreHold = restored === null ? null : { hold, restored };
|
|
1146
|
+
return {
|
|
1147
|
+
release: () => this.#releaseHold(hold),
|
|
1148
|
+
};
|
|
1149
|
+
},
|
|
1150
|
+
cancelRun: () => this.#cancelRun(),
|
|
1151
|
+
resetState: () => this.#resetState(),
|
|
1152
|
+
setRunning: (running) => this.#setRunning(running),
|
|
1153
|
+
threadChanged: (threadId, reason) => this.#dispatchThread(threadId, reason),
|
|
1154
|
+
threadsListed: (threads) => {
|
|
1155
|
+
this.#takeListedTitle(threads);
|
|
1156
|
+
this.#takeListedUnread(threads);
|
|
1157
|
+
},
|
|
1158
|
+
threadDeleted: (threadId) => this.#forgetUnread([threadId]),
|
|
1159
|
+
});
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
/** Attributes the element reacts to after it has been connected. */
|
|
1163
|
+
static get observedAttributes(): string[] {
|
|
1164
|
+
return [
|
|
1165
|
+
"title-text",
|
|
1166
|
+
"placement",
|
|
1167
|
+
"collapse-to",
|
|
1168
|
+
"anchor",
|
|
1169
|
+
"anchor-side",
|
|
1170
|
+
"maximized",
|
|
1171
|
+
"data-side",
|
|
1172
|
+
"data-small-viewport",
|
|
1173
|
+
"data-threads-search",
|
|
1174
|
+
"credentials",
|
|
1175
|
+
"user-key",
|
|
1176
|
+
"user-name",
|
|
1177
|
+
"data-tool-group-display",
|
|
1178
|
+
"data-collapse-icon",
|
|
1179
|
+
"theme",
|
|
1180
|
+
...CONNECT_TIME_ATTRIBUTES,
|
|
1181
|
+
];
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
attributeChangedCallback(name: string, previous: string | null, value: string | null): void {
|
|
1185
|
+
if (name === "credentials") {
|
|
1186
|
+
// Reported the moment the attribute is written — before connect, and
|
|
1187
|
+
// whether it came from markup or the property setter. An unrecognised
|
|
1188
|
+
// mode is otherwise inert, and the request it was meant to authorise
|
|
1189
|
+
// goes out anonymously with nothing to show for it.
|
|
1190
|
+
if (value !== null && !isCredentialsMode(value)) {
|
|
1191
|
+
console.error(
|
|
1192
|
+
`<ag-ui-chat>: credentials="${value}" is not a fetch credentials mode ` +
|
|
1193
|
+
`(${CREDENTIALS_MODES.join(" / ")}) — it is being ignored, so requests use ` +
|
|
1194
|
+
"the browser default and cross-origin cookies will not be sent.",
|
|
1195
|
+
);
|
|
1196
|
+
}
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
if (name === "data-tool-group-display") {
|
|
1200
|
+
this.#transcript.refreshStepGroups();
|
|
1201
|
+
return;
|
|
1202
|
+
}
|
|
1203
|
+
if (name === "data-threads-search") {
|
|
1204
|
+
const mode = this.getAttribute("data-threads-search");
|
|
1205
|
+
this.#drawer.setSearchMode(mode === "always" || mode === "never" ? mode : "auto");
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
if (name === "anchor") {
|
|
1209
|
+
this.#anchorWarned = false;
|
|
1210
|
+
this.#syncDock();
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
if (name === "maximized") {
|
|
1214
|
+
this.#applyMaximized(value !== null);
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1217
|
+
if (name === "data-collapse-icon") {
|
|
1218
|
+
this.#paintCollapseControl();
|
|
1219
|
+
return;
|
|
1220
|
+
}
|
|
1221
|
+
// Whoever sets the scheme (a host, a picker, the toggle itself), the
|
|
1222
|
+
// toggle's glyph has to offer the way out of the one now drawn.
|
|
1223
|
+
if (name === "theme") {
|
|
1224
|
+
if (this.#themeToggle.isConnected) {
|
|
1225
|
+
this.#syncThemeGlyph();
|
|
1226
|
+
}
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
1229
|
+
if (name === "anchor-side" || name === "data-side" || name === "data-small-viewport") {
|
|
1230
|
+
this.#paintCollapseControl();
|
|
1231
|
+
this.#syncDock();
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
// collapse-to changes the surface the way placement does -- a docked panel
|
|
1235
|
+
// has no launcher to drag, and keeps its own dragged spot apart from the
|
|
1236
|
+
// corner's, so the same handover applies -- and is live the same way.
|
|
1237
|
+
if (name === "placement" || name === "collapse-to") {
|
|
1238
|
+
// Before the size handover, which a maximized panel refuses: a placement
|
|
1239
|
+
// without the control takes its own stored size, not none.
|
|
1240
|
+
this.#syncMaximizeControl();
|
|
1241
|
+
// A placement owns the axes it fixes, so hand those back before anything
|
|
1242
|
+
// else: a size dragged under the previous placement would otherwise sit
|
|
1243
|
+
// inline and outrank the new one.
|
|
1244
|
+
this.#placement.releaseOwnedAxes();
|
|
1245
|
+
// A dragged size belongs to its placement; collapse-to keeps it.
|
|
1246
|
+
if (name === "placement") {
|
|
1247
|
+
this.#placement.switchPlacement(previous);
|
|
1248
|
+
}
|
|
1249
|
+
// Position is owned the same way a size is: a placement that places
|
|
1250
|
+
// itself takes back a launcher the user had dragged somewhere else.
|
|
1251
|
+
this.#placement.releaseLauncherPosition();
|
|
1252
|
+
// Switching into a placement with no collapsed state has to release it,
|
|
1253
|
+
// not just stop offering it: the control is gone from the header the
|
|
1254
|
+
// moment the attribute changes, so a panel collapsed under the previous
|
|
1255
|
+
// placement would have no way back.
|
|
1256
|
+
if (this.#surface().collapseTo === null && this.collapsed) {
|
|
1257
|
+
this.setCollapsed(false);
|
|
1258
|
+
}
|
|
1259
|
+
this.#stampCollapseTarget();
|
|
1260
|
+
this.#paintCollapseControl();
|
|
1261
|
+
if (name === "collapse-to") {
|
|
1262
|
+
// The strip's label and the popover's home both depend on the target,
|
|
1263
|
+
// and neither is re-derived by the dock sync.
|
|
1264
|
+
this.#syncLauncher();
|
|
1265
|
+
this.#homeDrawer(this.#surface().collapseTo === "bar" ? this.#dockBar : null);
|
|
1266
|
+
}
|
|
1267
|
+
this.#syncDock();
|
|
1268
|
+
// Placement also moves the panel, so the edges its layout holds still
|
|
1269
|
+
// change with it. Deferred a frame so the new rules have applied.
|
|
1270
|
+
requestAnimationFrame(() => this.#placement.syncResizeAnchor());
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
1273
|
+
if (name === "title-text") {
|
|
1274
|
+
this.#paintTitle();
|
|
1275
|
+
return;
|
|
1276
|
+
}
|
|
1277
|
+
if (name === "user-name") {
|
|
1278
|
+
// Display only, and live: an auth handshake that resolves after mount
|
|
1279
|
+
// names the user on screen the moment it does. Before connect the string
|
|
1280
|
+
// table is still the defaults, and rendering fills it again from the
|
|
1281
|
+
// resolved one.
|
|
1282
|
+
this.#syncGreeting();
|
|
1283
|
+
return;
|
|
1284
|
+
}
|
|
1285
|
+
if (name === "user-key") {
|
|
1286
|
+
// Acted on here only while connected; connecting hands over a change made
|
|
1287
|
+
// while detached (see #principalKey). Absent and empty name one principal.
|
|
1288
|
+
if (this.#connected && (previous ?? "") !== (value ?? "")) {
|
|
1289
|
+
this.#changePrincipal(previous ?? "", value ?? "");
|
|
1290
|
+
}
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
1293
|
+
// Everything else is read once, while connecting, to build chrome. A later
|
|
1294
|
+
// change does nothing, which reads as a broken component, so say so.
|
|
1295
|
+
if (previous === value || !this.#connected) {
|
|
1296
|
+
return;
|
|
1297
|
+
}
|
|
1298
|
+
console.warn(
|
|
1299
|
+
`<ag-ui-chat>: "${name}" was changed after the element connected, and is ` +
|
|
1300
|
+
"read only while connecting — this assignment has no effect. Set it " +
|
|
1301
|
+
"before the element enters the DOM (in the markup, or on the element " +
|
|
1302
|
+
"before appending it); frameworks that patch attributes after mount " +
|
|
1303
|
+
"should bind it at creation. To apply a new value now, remove and " +
|
|
1304
|
+
"re-insert the element.",
|
|
1305
|
+
);
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
/**
|
|
1309
|
+
* Declare a frontend tool the agent may call.
|
|
1310
|
+
*
|
|
1311
|
+
* **A handler's thrown message leaves the browser.** When a handler rejects,
|
|
1312
|
+
* its `Error.message` is posted back as that call's tool result — into the
|
|
1313
|
+
* conversation, on to the AG-UI endpoint, persisted server-side, and
|
|
1314
|
+
* forwarded to the model provider on every later round. That is deliberate,
|
|
1315
|
+
* since it is what lets the agent recover from a failure it caused; but it
|
|
1316
|
+
* means an internal hostname, a signed URL or a stack-derived path in a
|
|
1317
|
+
* rethrown error is disclosed to parties the host never chose. Throw the
|
|
1318
|
+
* message you would be content for the model to read, and log the detail.
|
|
1319
|
+
*/
|
|
1320
|
+
registerTool(tool: ClientTool): void {
|
|
1321
|
+
this.#tools.register(tool);
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
/**
|
|
1325
|
+
* AG-UI **shared state** for this conversation — the protocol's own state
|
|
1326
|
+
* channel, sent as `RunAgentInput.state` on every run and replaced in place
|
|
1327
|
+
* when the server streams `STATE_SNAPSHOT` / `STATE_DELTA`. Assigning seeds
|
|
1328
|
+
* the next run; reading returns whatever the agent last applied.
|
|
1329
|
+
*
|
|
1330
|
+
* Listen for {@link STATE_EVENT} to react to server-driven changes. Distinct
|
|
1331
|
+
* from {@link registerPageState}, which exposes host state as ordinary tools.
|
|
1332
|
+
*/
|
|
1333
|
+
get sharedState(): Readonly<Record<string, unknown>> {
|
|
1334
|
+
return this.#liveClient()?.state ?? this.#sharedState;
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
set sharedState(state: Readonly<Record<string, unknown>>) {
|
|
1338
|
+
this.#sharedState = { ...state };
|
|
1339
|
+
// A client already exists for this conversation — push it through so the
|
|
1340
|
+
// next run sends it, rather than silently waiting for a new conversation.
|
|
1341
|
+
// A continuation in flight is that client while it runs: its next round
|
|
1342
|
+
// sends what it holds, and the getter reads it back from there.
|
|
1343
|
+
this.#liveClient()?.setState(this.#sharedState);
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
/**
|
|
1347
|
+
* The client holding the conversation's live shared state: a checkpoint
|
|
1348
|
+
* continuation while one runs, otherwise the conversation's own client, and
|
|
1349
|
+
* `null` before either exists. The continuation first, because the state it
|
|
1350
|
+
* streams is applied to its own agent. The fallback agrees only because the
|
|
1351
|
+
* conversation's client is released at the continuation's first save, so no
|
|
1352
|
+
* test can tell them apart; reading the continuation stays right however long
|
|
1353
|
+
* that client is kept. The setter's is held by "reads the shared state it
|
|
1354
|
+
* changed while it runs, not the conversation's" (checkpoints tests).
|
|
1355
|
+
*/
|
|
1356
|
+
#liveClient(): AgUiClient | null {
|
|
1357
|
+
return this.#history.continuation ?? this.#client;
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
/** Bind a piece of host page state to `read_<name>` / `set_<name>` tools. */
|
|
1361
|
+
registerPageState(binding: PageState): void {
|
|
1362
|
+
this.#tools.registerPageState(binding);
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
/** The AG-UI endpoint URL, read from the `endpoint` attribute. */
|
|
1366
|
+
get endpoint(): string {
|
|
1367
|
+
return this.getAttribute("endpoint") ?? "";
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
// Reflecting setter so frameworks (e.g. React 19) that assign matching
|
|
1371
|
+
// props as element *properties* don't hit a read-only property. Read at
|
|
1372
|
+
// use-time, so a runtime change applies to the next run.
|
|
1373
|
+
set endpoint(value: string) {
|
|
1374
|
+
this.setAttribute("endpoint", value);
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
/**
|
|
1378
|
+
* Who the stored conversation belongs to, from the `user-key` attribute.
|
|
1379
|
+
*
|
|
1380
|
+
* Set it to whatever identifies the signed-in principal — a user id, an
|
|
1381
|
+
* account id, a hash of one. The value joins the storage namespace, so two
|
|
1382
|
+
* principals in the same tab cannot read each other's transcript, and
|
|
1383
|
+
* **changing it purges what the previous one left behind**.
|
|
1384
|
+
*
|
|
1385
|
+
* That purge is the reason this is a live attribute rather than a
|
|
1386
|
+
* connect-time one. `sessionStorage` survives same-tab navigation, so it
|
|
1387
|
+
* survives a logout; and a single-page app signs out through its own router
|
|
1388
|
+
* without remounting anything, so there is no other moment at which the
|
|
1389
|
+
* element could find out. The host naming the new principal — or dropping the
|
|
1390
|
+
* attribute — is the signal.
|
|
1391
|
+
*
|
|
1392
|
+
* Absent means exactly today's behaviour, which is why nothing breaks by
|
|
1393
|
+
* leaving it off: the conversation is scoped to the element and to nobody in
|
|
1394
|
+
* particular, and on a shared workstation it carries into whoever signs in
|
|
1395
|
+
* next in the same tab.
|
|
1396
|
+
*
|
|
1397
|
+
* The first value to arrive is treated as a host naming the user who was
|
|
1398
|
+
* already there, not as a handover: the conversation in progress moves into
|
|
1399
|
+
* the principal's namespace rather than being destroyed, so an element
|
|
1400
|
+
* configured by an async auth handshake keeps what is on screen.
|
|
1401
|
+
*/
|
|
1402
|
+
get userKey(): string {
|
|
1403
|
+
return this.getAttribute("user-key") ?? "";
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
set userKey(value: string) {
|
|
1407
|
+
this.setAttribute("user-key", value);
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
/**
|
|
1411
|
+
* The signed-in user's display name, from the `user-name` attribute, for the
|
|
1412
|
+
* greeting an empty conversation shows under `placement="page"`.
|
|
1413
|
+
*
|
|
1414
|
+
* Presentation only. Unlike {@link userKey} it scopes nothing and is never
|
|
1415
|
+
* sent to the server. Absent or blank means the nameless greeting. Live: a
|
|
1416
|
+
* name that arrives after the element connected replaces the greeting on
|
|
1417
|
+
* screen.
|
|
1418
|
+
*/
|
|
1419
|
+
get userName(): string {
|
|
1420
|
+
return this.getAttribute("user-name") ?? "";
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
set userName(value: string) {
|
|
1424
|
+
this.setAttribute("user-name", value);
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
/** Fill the greeting from the resolved string table and the current name. */
|
|
1428
|
+
#syncGreeting(): void {
|
|
1429
|
+
const name = this.userName.trim();
|
|
1430
|
+
this.#greetingText.textContent =
|
|
1431
|
+
name === "" ? this.#strings.greetingNoName : fillUiString(this.#strings.greeting, { name });
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
/**
|
|
1435
|
+
* Cookie policy for **every** request this element makes, as `fetch`'s own
|
|
1436
|
+
* `credentials` mode (`"omit"` / `"same-origin"` / `"include"`). Mirrored to
|
|
1437
|
+
* the `credentials` attribute, so markup embeds can set it without script.
|
|
1438
|
+
*
|
|
1439
|
+
* `null` (the default) leaves the browser's `same-origin` default in place,
|
|
1440
|
+
* which sends no cookies at all to an endpoint on a different origin — and
|
|
1441
|
+
* the request goes out anonymously rather than failing, so the symptom is a
|
|
1442
|
+
* 401 from a server that looks correctly configured. A cookie-authenticated
|
|
1443
|
+
* cross-origin deployment wants `"include"`, plus
|
|
1444
|
+
* `Access-Control-Allow-Credentials: true` and a concrete, non-wildcard
|
|
1445
|
+
* `Access-Control-Allow-Origin` on the server.
|
|
1446
|
+
*
|
|
1447
|
+
* Read per request, so a late assignment applies to everything after it.
|
|
1448
|
+
* `"omit"` cannot be honoured by the built-in upload transport, an
|
|
1449
|
+
* `XMLHttpRequest` with only a two-state cookie switch; every other endpoint
|
|
1450
|
+
* honours all three modes.
|
|
1451
|
+
*/
|
|
1452
|
+
get credentials(): RequestCredentials | null {
|
|
1453
|
+
const attr = this.getAttribute("credentials");
|
|
1454
|
+
return attr !== null && isCredentialsMode(attr) ? attr : null;
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
set credentials(value: RequestCredentials | null) {
|
|
1458
|
+
if (value === null) {
|
|
1459
|
+
this.removeAttribute("credentials");
|
|
1460
|
+
return;
|
|
1461
|
+
}
|
|
1462
|
+
// Thrown, not warned: an unrecognised mode is inert at request time, and
|
|
1463
|
+
// the whole failure this option exists to fix is a request that goes out
|
|
1464
|
+
// wrong without saying so. Fail where the mistake was made instead.
|
|
1465
|
+
if (!isCredentialsMode(value)) {
|
|
1466
|
+
throw new TypeError(
|
|
1467
|
+
`<ag-ui-chat>: credentials must be one of ${CREDENTIALS_MODES.map((mode) => `"${mode}"`).join(", ")} ` +
|
|
1468
|
+
`(got ${JSON.stringify(value)}).`,
|
|
1469
|
+
);
|
|
1470
|
+
}
|
|
1471
|
+
this.setAttribute("credentials", value);
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
/**
|
|
1475
|
+
* The headers for the request about to go out: the static {@link headers}
|
|
1476
|
+
* with {@link getHeaders}'s live values overlaid, per key.
|
|
1477
|
+
*
|
|
1478
|
+
* Every request site goes through here, so "how this element authenticates"
|
|
1479
|
+
* is one answer rather than one per endpoint.
|
|
1480
|
+
*/
|
|
1481
|
+
#requestHeaders(): Record<string, string> {
|
|
1482
|
+
return { ...this.headers, ...this.getHeaders?.() };
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
/**
|
|
1486
|
+
* The request headers, having first reported the destination if it is
|
|
1487
|
+
* foreign: the one path that has both the headers and the URL.
|
|
1488
|
+
*/
|
|
1489
|
+
#headersFor(url: string): Record<string, string> {
|
|
1490
|
+
const headers = this.#requestHeaders();
|
|
1491
|
+
warnOnCrossOriginCredentials(
|
|
1492
|
+
url,
|
|
1493
|
+
Object.keys(headers),
|
|
1494
|
+
this.trustedOrigins,
|
|
1495
|
+
this.#warnedOrigins,
|
|
1496
|
+
);
|
|
1497
|
+
return headers;
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
/** The configured cookie policy as `fetch` spells it; `undefined` when unset. */
|
|
1501
|
+
#requestCredentials(): RequestCredentials | undefined {
|
|
1502
|
+
return this.credentials ?? undefined;
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
/**
|
|
1506
|
+
* The `credentials` entry for an {@link AgentFactory} call, or nothing at all.
|
|
1507
|
+
*
|
|
1508
|
+
* Spread rather than assigned: `exactOptionalPropertyTypes` rejects an
|
|
1509
|
+
* explicit `credentials: undefined`, and a factory should see the field
|
|
1510
|
+
* absent — not present-and-empty — when no policy is configured. The agent
|
|
1511
|
+
* reads it when it is built (first send, thread switch, continuation), by
|
|
1512
|
+
* which time any host configuration has landed.
|
|
1513
|
+
*/
|
|
1514
|
+
#credentialsOption(): { credentials?: RequestCredentials } {
|
|
1515
|
+
const credentials = this.#requestCredentials();
|
|
1516
|
+
return credentials === undefined ? {} : { credentials };
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
/** The `fetch` init for the element's own plain GETs (catalogs). */
|
|
1520
|
+
#fetchInit(url: string): RequestInit | undefined {
|
|
1521
|
+
return withCredentials({ headers: this.#headersFor(url) }, this.#requestCredentials());
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
/**
|
|
1525
|
+
* How much detail tool-call cards show, from the `data-tool-display`
|
|
1526
|
+
* attribute (`grouped` / `minimal` / `inline` / `compact` / `full`).
|
|
1527
|
+
* Defaults to `grouped`, for an absent attribute and for a value the
|
|
1528
|
+
* element does not know; `full` is the per-call card default before
|
|
1529
|
+
* `grouped` existed.
|
|
1530
|
+
*
|
|
1531
|
+
* The four card modes are applied by the shadow CSS from the attribute
|
|
1532
|
+
* itself, so changing between them restyles every card already in the
|
|
1533
|
+
* transcript. Whether calls fold into a step group is decided as each card
|
|
1534
|
+
* is built: switching to `grouped` folds only the calls drawn afterwards,
|
|
1535
|
+
* and switching away flattens the existing groups, again by CSS.
|
|
1536
|
+
*/
|
|
1537
|
+
get toolDisplay(): ToolDisplayMode {
|
|
1538
|
+
const attr = this.getAttribute("data-tool-display");
|
|
1539
|
+
if (
|
|
1540
|
+
attr === TOOL_DISPLAY.INLINE ||
|
|
1541
|
+
attr === TOOL_DISPLAY.MINIMAL ||
|
|
1542
|
+
attr === TOOL_DISPLAY.COMPACT ||
|
|
1543
|
+
attr === TOOL_DISPLAY.FULL
|
|
1544
|
+
) {
|
|
1545
|
+
return attr;
|
|
1546
|
+
}
|
|
1547
|
+
return TOOL_DISPLAY.GROUPED;
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
set toolDisplay(value: ToolDisplayMode) {
|
|
1551
|
+
this.setAttribute("data-tool-display", value);
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
/**
|
|
1555
|
+
* How an opened step group draws its calls, from the
|
|
1556
|
+
* `data-tool-group-display` attribute (`full` / `compact` / `minimal` /
|
|
1557
|
+
* `inline`). Defaults to `full`, for an absent attribute and for a value the
|
|
1558
|
+
* element does not know, which is what a group drew before the attribute.
|
|
1559
|
+
*
|
|
1560
|
+
* Read only while `toolDisplay` is `grouped`: under a card mode the groups
|
|
1561
|
+
* are flattened into that mode's cards. Applied by the shadow CSS from the
|
|
1562
|
+
* attribute itself, so changing it restyles groups already on screen.
|
|
1563
|
+
*/
|
|
1564
|
+
get toolGroupDisplay(): ToolGroupDisplayMode {
|
|
1565
|
+
const attr = this.getAttribute("data-tool-group-display");
|
|
1566
|
+
if (
|
|
1567
|
+
attr === TOOL_GROUP_DISPLAY.INLINE ||
|
|
1568
|
+
attr === TOOL_GROUP_DISPLAY.MINIMAL ||
|
|
1569
|
+
attr === TOOL_GROUP_DISPLAY.COMPACT
|
|
1570
|
+
) {
|
|
1571
|
+
return attr;
|
|
1572
|
+
}
|
|
1573
|
+
return TOOL_GROUP_DISPLAY.FULL;
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
set toolGroupDisplay(value: ToolGroupDisplayMode) {
|
|
1577
|
+
this.setAttribute("data-tool-group-display", value);
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
/**
|
|
1581
|
+
* The host's own constructed stylesheets, adopted after the built-in one.
|
|
1582
|
+
*
|
|
1583
|
+
* The escape hatch for structure that neither the tokens nor the parts
|
|
1584
|
+
* reach. Per element, like the built-in sheet: two elements on one page can
|
|
1585
|
+
* carry different sheets. Constructed sheets are not subject to a
|
|
1586
|
+
* `style-src` policy, so this works under the same strict CSP the
|
|
1587
|
+
* built-in sheet does. Setting it re-adopts at once; the array is copied, so
|
|
1588
|
+
* mutating the one passed in changes nothing until it is set again.
|
|
1589
|
+
*
|
|
1590
|
+
* Unstable: reaches shadow class names, which may change in any minor.
|
|
1591
|
+
*/
|
|
1592
|
+
get extraStyles(): readonly CSSStyleSheet[] {
|
|
1593
|
+
return this.#extraStyles;
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
set extraStyles(sheets: readonly CSSStyleSheet[]) {
|
|
1597
|
+
this.#extraStyles = [...sheets];
|
|
1598
|
+
if (this.#builtInSheet !== null) {
|
|
1599
|
+
this.#root.adoptedStyleSheets = [this.#builtInSheet, ...this.#extraStyles];
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
connectedCallback(): void {
|
|
1604
|
+
// A fresh signal per connection: disconnecting aborted the last one, and
|
|
1605
|
+
// with it every listener the last connection added.
|
|
1606
|
+
this.#connection = new AbortController();
|
|
1607
|
+
// Coming back is a reload, not a merge, for the reason `reload()` is: the
|
|
1608
|
+
// run was cancelled on the way out, and the transcript is rebuilt below
|
|
1609
|
+
// from persisted history -- which could only be appended to what is still
|
|
1610
|
+
// showing. The composer's recall history is kept, because a move is not a
|
|
1611
|
+
// farewell, and the Stop on the way out dropped the queued messages because
|
|
1612
|
+
// recall already held them.
|
|
1613
|
+
if (this.#connectedBefore) {
|
|
1614
|
+
this.#resetConversation();
|
|
1615
|
+
}
|
|
1616
|
+
this.#connectedBefore = true;
|
|
1617
|
+
// Resolve the per-instance storage namespace before any key read/write, so
|
|
1618
|
+
// this instance doesn't share collapsed/theme/thread state with another on
|
|
1619
|
+
// the same origin.
|
|
1620
|
+
this.#storage.claim();
|
|
1621
|
+
this.#chatRegistration.join();
|
|
1622
|
+
// Restore a dragged size before the panel paints, so it does not snap from
|
|
1623
|
+
// the placement default to the user's width on the first frame.
|
|
1624
|
+
this.#placement.restoreSize();
|
|
1625
|
+
// Position the grip at the corner this layout grows toward. Deferred to a
|
|
1626
|
+
// frame so the host's own stylesheet has applied; re-measured on every drag
|
|
1627
|
+
// anyway, so a wrong first guess costs a grip in the wrong corner and never
|
|
1628
|
+
// a wrong resize.
|
|
1629
|
+
requestAnimationFrame(() => {
|
|
1630
|
+
// Before the anchor, which the placement stamps itself once a dragged
|
|
1631
|
+
// position exists.
|
|
1632
|
+
this.#restoreIfBoxed();
|
|
1633
|
+
this.#placement.syncResizeAnchor();
|
|
1634
|
+
});
|
|
1635
|
+
// Resolve the string table before rendering any chrome (defaults are the
|
|
1636
|
+
// floor; `data-strings` then the `strings` property layer over them).
|
|
1637
|
+
this.#strings = mergeUiStrings({ ...this.#readStringOverrides(), ...this.strings });
|
|
1638
|
+
// Restore the scheme the built-in toggle persisted last visit onto the
|
|
1639
|
+
// markup's theme name (opt-in only, so it never overrides a host that
|
|
1640
|
+
// drives `theme` itself).
|
|
1641
|
+
if (this.getAttribute("data-theme-toggle") !== null) {
|
|
1642
|
+
const saved = this.#storage.readPreference(THEME_KEY);
|
|
1643
|
+
const scheme = saved === null ? undefined : savedScheme(saved);
|
|
1644
|
+
if (scheme !== undefined) {
|
|
1645
|
+
this.setAttribute("theme", withScheme(this.getAttribute("theme") ?? "", scheme));
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
this.#render();
|
|
1649
|
+
this.#drawer.setStrings(this.#strings);
|
|
1650
|
+
this.#checkpoints.setStrings(this.#strings);
|
|
1651
|
+
// Gated on the placement, not just on the stored value: the key is
|
|
1652
|
+
// namespaced per instance but not per placement, so a tab that collapsed a
|
|
1653
|
+
// floating panel and later loaded the same instance as a page would restore
|
|
1654
|
+
// a state that placement has no way out of.
|
|
1655
|
+
if (this.#surface().collapseTo !== null && this.#startsCollapsed()) {
|
|
1656
|
+
this.setAttribute("collapsed", "");
|
|
1657
|
+
}
|
|
1658
|
+
this.#syncLauncher();
|
|
1659
|
+
this.#stampCollapseTarget();
|
|
1660
|
+
// The bar's height follows its content (the font, a long title), so it is
|
|
1661
|
+
// measured rather than known; the observer reports a change of size, and
|
|
1662
|
+
// the strip going from drawn to not drawn is one.
|
|
1663
|
+
const dockObserver = new ResizeObserver(() => this.#syncDock());
|
|
1664
|
+
dockObserver.observe(this.#dockBar);
|
|
1665
|
+
this.#connection.signal.addEventListener("abort", () => dockObserver.disconnect());
|
|
1666
|
+
// A host's own size arrives with no event. The transcript is watched rather
|
|
1667
|
+
// than this element: a page publishing a token from its own observer changes
|
|
1668
|
+
// this box after that observer ran, which the browser reports as a loop. The
|
|
1669
|
+
// transcript already has an observer, so this adds no report of its own.
|
|
1670
|
+
const layoutObserver = new ResizeObserver(this.#onLayoutChange);
|
|
1671
|
+
layoutObserver.observe(this.#messages);
|
|
1672
|
+
this.#connection.signal.addEventListener("abort", () => layoutObserver.disconnect());
|
|
1673
|
+
// The viewport insets arrive the same way, and a page's observer writes them after
|
|
1674
|
+
// the window's resize event, so that restore reads the bar as it was. The probe
|
|
1675
|
+
// carries them, and an observer on it would be a report on the window for the same
|
|
1676
|
+
// reason, so it takes them as a transition instead: heard the next frame, whichever
|
|
1677
|
+
// element wrote them and however late.
|
|
1678
|
+
this.#placement.probe.addEventListener("transitionrun", this.#onLayoutChange, {
|
|
1679
|
+
signal: this.#connection.signal,
|
|
1680
|
+
});
|
|
1681
|
+
// A resize drag is reported when it ends, and the last frame may change nothing
|
|
1682
|
+
// the observer sees.
|
|
1683
|
+
const endDrag = (): void => void requestAnimationFrame(() => this.#syncDock());
|
|
1684
|
+
window.addEventListener("pointerup", endDrag, { signal: this.#connection.signal });
|
|
1685
|
+
window.addEventListener("keyup", endDrag, { signal: this.#connection.signal });
|
|
1686
|
+
this.#syncDock();
|
|
1687
|
+
// A connect that restored or defaulted to collapsed leaves a state asked for in markup.
|
|
1688
|
+
if (this.collapsed) {
|
|
1689
|
+
this.setMaximized(false);
|
|
1690
|
+
}
|
|
1691
|
+
// Under collapse-to="none" the host draws the collapsed state itself, so
|
|
1692
|
+
// it has to learn the state the element mounted in -- restored from
|
|
1693
|
+
// storage or the placement's default -- without waiting for a change.
|
|
1694
|
+
// Dispatched directly rather than through setCollapsed, which would write
|
|
1695
|
+
// storage and report the unread count for a state nobody changed.
|
|
1696
|
+
if (this.#surface().collapseTo === "none") {
|
|
1697
|
+
this.#emit<ToggleDetail>(TOGGLE_EVENT, { collapsed: this.collapsed });
|
|
1698
|
+
}
|
|
1699
|
+
this.#skills.init();
|
|
1700
|
+
// A `user-key` changed while detached gets the handover a live change gets,
|
|
1701
|
+
// here, the first moment it can: nothing goes out for a detached node, a key
|
|
1702
|
+
// that left and came back hands nothing over, and the namespace was just
|
|
1703
|
+
// claimed again, so another element's is never purged. A first connect skips
|
|
1704
|
+
// it, holding nobody's state. Before the store is scoped, so an adoption
|
|
1705
|
+
// moves the conversation before the store mints a thread there.
|
|
1706
|
+
const held = this.#principalKey;
|
|
1707
|
+
const changed = held !== null && held !== this.userKey;
|
|
1708
|
+
if (changed) {
|
|
1709
|
+
this.#changePrincipal(held, this.userKey);
|
|
1710
|
+
}
|
|
1711
|
+
this.#principalKey = this.userKey;
|
|
1712
|
+
// Namespace the built-in default store too (a host-injected store is used
|
|
1713
|
+
// verbatim). Must precede #wireThreadStore, which wraps the current store.
|
|
1714
|
+
this.conversationStore = this.#storage.scopeStore(this.#unwrappedStore(), this.userKey);
|
|
1715
|
+
window.addEventListener("resize", this.#onViewportResize);
|
|
1716
|
+
// The visual viewport changes without the window resizing -- a keyboard
|
|
1717
|
+
// opening, a pinch-zoom, the URL bar collapsing -- and `scroll` is what
|
|
1718
|
+
// fires when it is panned rather than resized.
|
|
1719
|
+
window.visualViewport?.addEventListener("resize", this.#onViewportResize);
|
|
1720
|
+
window.visualViewport?.addEventListener("scroll", this.#onViewportResize);
|
|
1721
|
+
this.#placement.publishVisualViewport();
|
|
1722
|
+
this.#wireThreadStore();
|
|
1723
|
+
this.#attachments.wire(this.#connection.signal);
|
|
1724
|
+
this.#voice.wire();
|
|
1725
|
+
this.#history.adoptActiveThread();
|
|
1726
|
+
// The catalog requests go out a microtask later, so a host configuring
|
|
1727
|
+
// through a framework ref still has a chance to be heard — see #startup.
|
|
1728
|
+
queueMicrotask(() => this.#startup());
|
|
1729
|
+
// A key changed while the element was away handed it over all the same,
|
|
1730
|
+
// and the restore is the arriving principal's: reported as the live change
|
|
1731
|
+
// is. An adoption is not a handover, and restores the same conversation.
|
|
1732
|
+
void this.#history.rehydrate(changed && held !== "" ? "rescope" : "restore");
|
|
1733
|
+
if (changed) {
|
|
1734
|
+
// The last of what a live change does: a drawer left open across the
|
|
1735
|
+
// move still lists the threads of the principal who left, titles and
|
|
1736
|
+
// all, until something reloads it. A plain move leaves it alone, since
|
|
1737
|
+
// the list it shows is still this principal's.
|
|
1738
|
+
void this.#history.refreshDrawer();
|
|
1739
|
+
}
|
|
1740
|
+
// Last: everything above reads (and some of it sets) attributes, and none
|
|
1741
|
+
// of that should trip the connect-time warning.
|
|
1742
|
+
this.#connected = true;
|
|
1743
|
+
this.#reportMaximized();
|
|
1744
|
+
if (this.#newChatOnConnect) {
|
|
1745
|
+
this.#newChatOnConnect = false;
|
|
1746
|
+
// After the restore above has started: starting the chat stops it, so
|
|
1747
|
+
// the one event is the new chat's and the conversation left stays kept.
|
|
1748
|
+
this.newChat();
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
/**
|
|
1753
|
+
* The catalog requests the element issues on startup: the tool labels
|
|
1754
|
+
* (`data-tools-url`) and the backend skills (`data-skills-url`).
|
|
1755
|
+
*
|
|
1756
|
+
* Deliberately one microtask behind `connectedCallback`. A framework ref is
|
|
1757
|
+
* attached after the node is inserted but within the same commit, so a
|
|
1758
|
+
* request issued from `connectedCallback` itself goes out before `headers`,
|
|
1759
|
+
* {@link getHeaders} or {@link credentials} exist and 401s in a way that
|
|
1760
|
+
* reads as a server fault. A microtask lands after that commit, still before
|
|
1761
|
+
* paint.
|
|
1762
|
+
*
|
|
1763
|
+
* It is not a fix for configuration arriving later than the commit (a passive
|
|
1764
|
+
* effect, an awaited token fetch): configure before insertion, or call
|
|
1765
|
+
* {@link reload}, since a longer timer would hide that race rather than close
|
|
1766
|
+
* it.
|
|
1767
|
+
*
|
|
1768
|
+
* The history replay stays in `connectedCallback` on purpose. It renders into
|
|
1769
|
+
* the transcript, so deferring it would let a `sendMessage()` in the same
|
|
1770
|
+
* task land first and be duplicated by the replay.
|
|
1771
|
+
*/
|
|
1772
|
+
#startup(): void {
|
|
1773
|
+
// An element can be inserted and removed inside one task (a discarded
|
|
1774
|
+
// render, a double-mount); nothing should go out for a node that has
|
|
1775
|
+
// already left the document.
|
|
1776
|
+
if (!this.#connected) {
|
|
1777
|
+
return;
|
|
1778
|
+
}
|
|
1779
|
+
void this.#tools.fetchCatalog();
|
|
1780
|
+
void this.#skills.fetch();
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
/**
|
|
1784
|
+
* Re-run everything the element loads on startup — the tool-label catalog,
|
|
1785
|
+
* the backend skill catalog and the thread's history — with the transport
|
|
1786
|
+
* configuration as it stands now.
|
|
1787
|
+
*
|
|
1788
|
+
* For a host that can only configure the element after the fact (a token
|
|
1789
|
+
* fetched in a passive effect, an async auth handshake), this re-issues the
|
|
1790
|
+
* startup requests authenticated, without removing and re-inserting the node.
|
|
1791
|
+
*
|
|
1792
|
+
* A reload, not a merge: the in-flight run is cancelled and the transcript is
|
|
1793
|
+
* rebuilt from persisted history, so anything streamed since is dropped. Call
|
|
1794
|
+
* it once, when configuration lands, not between turns.
|
|
1795
|
+
*/
|
|
1796
|
+
async reload(): Promise<void> {
|
|
1797
|
+
this.#cancelRun();
|
|
1798
|
+
this.#resetState();
|
|
1799
|
+
this.#setRunning(false);
|
|
1800
|
+
await Promise.all([
|
|
1801
|
+
this.#tools.fetchCatalog(),
|
|
1802
|
+
this.#skills.fetch(),
|
|
1803
|
+
this.#history.rehydrate("restore"),
|
|
1804
|
+
]);
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
/**
|
|
1808
|
+
* Tear down live resources when the element leaves the DOM: cancel the
|
|
1809
|
+
* in-flight run so its stream closes, abort in-flight uploads so they do not
|
|
1810
|
+
* orphan server-side files, and release the mic so the browser's recording
|
|
1811
|
+
* indicator clears. Without this a removed element leaks all three.
|
|
1812
|
+
*/
|
|
1813
|
+
disconnectedCallback(): void {
|
|
1814
|
+
this.#connected = false;
|
|
1815
|
+
this.#connection.abort();
|
|
1816
|
+
window.removeEventListener("resize", this.#onViewportResize);
|
|
1817
|
+
window.visualViewport?.removeEventListener("resize", this.#onViewportResize);
|
|
1818
|
+
window.visualViewport?.removeEventListener("scroll", this.#onViewportResize);
|
|
1819
|
+
this.#storage.release();
|
|
1820
|
+
this.#chatRegistration.leave();
|
|
1821
|
+
this.#cancelRun();
|
|
1822
|
+
this.#excerpts.detachPageOffer();
|
|
1823
|
+
this.#attachments.tray?.dispose();
|
|
1824
|
+
this.#voice.dispose();
|
|
1825
|
+
this.#transcript.disposeScroller();
|
|
1826
|
+
this.#announcer.dispose();
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
/** Parse the inline `data-strings` JSON overrides (empty when absent/malformed). */
|
|
1830
|
+
#readStringOverrides(): Partial<UiStrings> {
|
|
1831
|
+
const parsed = readJsonAttribute(this, "data-strings");
|
|
1832
|
+
// A JSON number or string parses fine and overrides nothing. Not warned
|
|
1833
|
+
// about separately: it is the same "this attribute did not take effect"
|
|
1834
|
+
// as a parse failure, and the warning above already covers the spelling
|
|
1835
|
+
// that produces it by accident.
|
|
1836
|
+
return typeof parsed === "object" && parsed !== null ? (parsed as Partial<UiStrings>) : {};
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
/**
|
|
1840
|
+
* Put `text` into the composer as a markdown quotation, and focus it.
|
|
1841
|
+
*
|
|
1842
|
+
* Deliberately **not** a send. Quoting is how a question narrows to one part
|
|
1843
|
+
* of an answer, so the quotation is the preamble and the question is what
|
|
1844
|
+
* comes next -- the caret is left after it, on its own line.
|
|
1845
|
+
*
|
|
1846
|
+
* This is also the seam for the half of this feature the component cannot
|
|
1847
|
+
* build: selection in the **host page**. A widget mounted beside a table can
|
|
1848
|
+
* be asked about a row, and nothing in a chat's own transcript can offer
|
|
1849
|
+
* that. A host reads its own selection, however it likes, and calls this.
|
|
1850
|
+
*
|
|
1851
|
+
* No-ops on text that is empty or only whitespace.
|
|
1852
|
+
*/
|
|
1853
|
+
quote(text: string): void {
|
|
1854
|
+
this.#excerpts.insert(text);
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
/**
|
|
1858
|
+
* Offer to quote what the user selects in the **host page**, not just in the
|
|
1859
|
+
* transcript. Returns a function that stops offering.
|
|
1860
|
+
*
|
|
1861
|
+
* The same select-then-offer gesture, over a table, a diff, a report -- the
|
|
1862
|
+
* surface the user actually works in, which is the half of quoting no hosted
|
|
1863
|
+
* chat can reach. Opt-in, because it listens on the host's document and that
|
|
1864
|
+
* is theirs to grant.
|
|
1865
|
+
*
|
|
1866
|
+
* Deliberately **not** a four-line recipe, which is how this shipped first
|
|
1867
|
+
* and was wrong: a page listener that quotes every settled selection appends
|
|
1868
|
+
* to the composer on every drag made to read, to copy or to fix a typo -- and
|
|
1869
|
+
* it cannot tell a selection in the page's prose from one inside the user's
|
|
1870
|
+
* own half-typed `<input>`, because Chrome reports a field's internal
|
|
1871
|
+
* selection as an ordinary range over the field's *wrapper*. See
|
|
1872
|
+
* {@link attachQuoteOffer} for the guards.
|
|
1873
|
+
*
|
|
1874
|
+
* Detached automatically when the element leaves the document; a host that
|
|
1875
|
+
* re-mounts it calls this again.
|
|
1876
|
+
*/
|
|
1877
|
+
offerQuoteInPage(within: HTMLElement = document.body): () => void {
|
|
1878
|
+
return this.#excerpts.offerInPage(within);
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
/**
|
|
1882
|
+
* When `data-threads-url` is set, route thread enumeration / load / rename /
|
|
1883
|
+
* delete through that server endpoint (wrapping the current store as the
|
|
1884
|
+
* client-only fallback), so the history drawer shows durable, cross-device
|
|
1885
|
+
* threads. Without it, the client store's per-tab threads are used.
|
|
1886
|
+
*
|
|
1887
|
+
* `data-threads-cache="false"` drops the local copy of the message bodies —
|
|
1888
|
+
* for the deployment that pointed history at a server precisely so that
|
|
1889
|
+
* transcripts do not sit in the browser. The client-only concerns (the active
|
|
1890
|
+
* thread id, the navigation checkpoint) keep their local store either way.
|
|
1891
|
+
*/
|
|
1892
|
+
#wireThreadStore(): void {
|
|
1893
|
+
this.#threadStore = null;
|
|
1894
|
+
const url = this.getAttribute("data-threads-url");
|
|
1895
|
+
if (url !== null) {
|
|
1896
|
+
const inner = this.conversationStore;
|
|
1897
|
+
const remote = new RemoteConversationStore(
|
|
1898
|
+
url,
|
|
1899
|
+
() => this.#headersFor(url),
|
|
1900
|
+
inner,
|
|
1901
|
+
() => this.#requestCredentials(),
|
|
1902
|
+
this.getAttribute("data-threads-cache") !== "false",
|
|
1903
|
+
);
|
|
1904
|
+
this.#threadStore = { remote, inner };
|
|
1905
|
+
this.conversationStore = remote;
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
/**
|
|
1910
|
+
* The conversation store without the remote this element wrapped it in, so a
|
|
1911
|
+
* re-insertion does not stack a second remote, and a `data-threads-url`
|
|
1912
|
+
* removed while detached takes it away. A store the host assigned since
|
|
1913
|
+
* comes back as it is.
|
|
1914
|
+
*/
|
|
1915
|
+
#unwrappedStore(): ClientConversationStore {
|
|
1916
|
+
const wrapped = this.#threadStore;
|
|
1917
|
+
return wrapped !== null && this.conversationStore === wrapped.remote
|
|
1918
|
+
? wrapped.inner
|
|
1919
|
+
: this.conversationStore;
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
/**
|
|
1923
|
+
* Replace the host-supplied (client) skill catalog. Merged after the embedded
|
|
1924
|
+
* and fetched skills (so a client skill overrides a same-named server one).
|
|
1925
|
+
*/
|
|
1926
|
+
setSkills(skills: readonly Skill[]): void {
|
|
1927
|
+
this.#skills.setClientSkills(skills);
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
/** Whether the widget is collapsed (reflected as the `collapsed` attribute). */
|
|
1931
|
+
get collapsed(): boolean {
|
|
1932
|
+
return this.hasAttribute("collapsed");
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
// Property setter (framework interop) — delegates to setCollapsed so a
|
|
1936
|
+
// `collapsed` prop assignment persists + emits the toggle event.
|
|
1937
|
+
set collapsed(value: boolean) {
|
|
1938
|
+
this.setCollapsed(value);
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
/**
|
|
1942
|
+
* Read {@link getPlace} (or the route map standing in) again and redraw the
|
|
1943
|
+
* chip. A single-page app calls this after its router moves, since a
|
|
1944
|
+
* `pushState` fires no event; history traversal and hash changes are caught
|
|
1945
|
+
* without it. Does nothing before the element connects.
|
|
1946
|
+
*/
|
|
1947
|
+
refreshPlace(): void {
|
|
1948
|
+
if (!this.isConnected) {
|
|
1949
|
+
return;
|
|
1950
|
+
}
|
|
1951
|
+
this.#place.update(
|
|
1952
|
+
this.getPlace === null
|
|
1953
|
+
? placeFromRoute(this.routeMap, window.location.pathname)
|
|
1954
|
+
: this.getPlace(),
|
|
1955
|
+
);
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
/**
|
|
1959
|
+
* A round's context: the host's, then the place. A withheld place withholds
|
|
1960
|
+
* the page map too, so it is filtered out of whatever the host returned.
|
|
1961
|
+
*/
|
|
1962
|
+
#roundContext(): Context[] {
|
|
1963
|
+
const context = this.getContext();
|
|
1964
|
+
const kept = this.#place.withheld
|
|
1965
|
+
? context.filter((entry) => entry.description !== PAGE_MAP_CONTEXT)
|
|
1966
|
+
: context;
|
|
1967
|
+
return [...kept, ...this.#place.context()];
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
/**
|
|
1971
|
+
* Set the collapsed state: reflect the `collapsed` attribute, persist it
|
|
1972
|
+
* per-tab, and emit a {@link TOGGLE_EVENT} so a host can mirror the state in
|
|
1973
|
+
* its own chrome.
|
|
1974
|
+
*/
|
|
1975
|
+
setCollapsed(collapsed: boolean, options: { readonly announce?: boolean } = {}): void {
|
|
1976
|
+
if (collapsed && this.#surface().collapseTo === null) {
|
|
1977
|
+
return;
|
|
1978
|
+
}
|
|
1979
|
+
// Collapsing leaves the maximized state, so the panel reopens at its own size.
|
|
1980
|
+
if (collapsed) {
|
|
1981
|
+
this.setMaximized(false);
|
|
1982
|
+
}
|
|
1983
|
+
if (options.announce === true && collapsed && !this.collapsed) {
|
|
1984
|
+
// Only when the agent did it, and before the state changes, so the notice
|
|
1985
|
+
// lands where the user can still see it. No undo: reading it means
|
|
1986
|
+
// expanding, which is what the undo would do. Expanding announces nothing,
|
|
1987
|
+
// since the panel arriving is the announcement.
|
|
1988
|
+
this.#announceSurfaceChange(this.#strings.chatMinimised, null);
|
|
1989
|
+
}
|
|
1990
|
+
if (!collapsed) {
|
|
1991
|
+
this.refreshPlace();
|
|
1992
|
+
// Re-decide which way to open before opening: the viewport may have
|
|
1993
|
+
// changed since the launcher was dropped, and this corner is what the
|
|
1994
|
+
// panel grows from.
|
|
1995
|
+
this.#restoreIfBoxed();
|
|
1996
|
+
}
|
|
1997
|
+
if (collapsed) {
|
|
1998
|
+
this.setAttribute("collapsed", "");
|
|
1999
|
+
} else {
|
|
2000
|
+
// The history popover belongs to the bar only while the panel is away.
|
|
2001
|
+
// Opening the panel closes it and takes the drawer back, so the panel
|
|
2002
|
+
// lands on the conversation rather than on a list over it.
|
|
2003
|
+
this.#homeDrawer();
|
|
2004
|
+
this.removeAttribute("collapsed");
|
|
2005
|
+
}
|
|
2006
|
+
this.#syncDock();
|
|
2007
|
+
this.#storage.writeSession(COLLAPSED_KEY, collapsed ? "1" : "0");
|
|
2008
|
+
// Expanding reads the thread on screen; other threads keep their marks.
|
|
2009
|
+
// Reported on every toggle, as before, with the threads it cleared.
|
|
2010
|
+
this.#unreadChanged(this.#markSeen());
|
|
2011
|
+
this.dispatchEvent(
|
|
2012
|
+
new CustomEvent<ToggleDetail>(TOGGLE_EVENT, {
|
|
2013
|
+
detail: { collapsed },
|
|
2014
|
+
bubbles: true,
|
|
2015
|
+
composed: true,
|
|
2016
|
+
}),
|
|
2017
|
+
);
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
/**
|
|
2021
|
+
* Answers the user has not seen, totalled across threads: each finished while
|
|
2022
|
+
* the panel was collapsed, and is read once its thread is on screen with the
|
|
2023
|
+
* panel expanded. The launcher's badge renders this; {@link UNREAD_EVENT}
|
|
2024
|
+
* announces every change and the threads it touched, so a host chrome can
|
|
2025
|
+
* render its own instead.
|
|
2026
|
+
*/
|
|
2027
|
+
get unread(): number {
|
|
2028
|
+
let total = 0;
|
|
2029
|
+
for (const count of this.#unreadByThread.values()) {
|
|
2030
|
+
total += count;
|
|
2031
|
+
}
|
|
2032
|
+
return total;
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
/**
|
|
2036
|
+
* The thread on screen; `null` before the element first connects.
|
|
2037
|
+
*
|
|
2038
|
+
* Only reads. `conversationStore.threadId()` mints and stores a thread when
|
|
2039
|
+
* the store points at none, so a host asking which conversation is showing
|
|
2040
|
+
* could start one; this never does. {@link THREAD_EVENT} says when it changes.
|
|
2041
|
+
*/
|
|
2042
|
+
get threadId(): string | null {
|
|
2043
|
+
const threadId = this.#history.threadId;
|
|
2044
|
+
return threadId === "" ? null : threadId;
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
/**
|
|
2048
|
+
* The title of the thread on screen; `""` while nothing has been sent in it,
|
|
2049
|
+
* and before the element connects.
|
|
2050
|
+
*
|
|
2051
|
+
* A title renamed here, or one the conversation list shows that the thread's
|
|
2052
|
+
* messages do not derive (a server's own), wins. Otherwise it is the first
|
|
2053
|
+
* user message, and a bare skill `/token` reads as the catalog's title for
|
|
2054
|
+
* that skill, else as the token in words. `title-text="auto"` draws it in the
|
|
2055
|
+
* header; {@link THREAD_TITLE_EVENT} says when it changes.
|
|
2056
|
+
*/
|
|
2057
|
+
get threadTitle(): string {
|
|
2058
|
+
return this.#titled.title;
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
/**
|
|
2062
|
+
* Whether an interaction is in flight: the composer's own state, the one that
|
|
2063
|
+
* shows Stop. It spans every tool round, a retry and a checkpoint
|
|
2064
|
+
* continuation as well as a send, which {@link SUBMIT_EVENT} alone does not.
|
|
2065
|
+
* Reflected as `data-running` on the element, for a host's CSS.
|
|
2066
|
+
*/
|
|
2067
|
+
get running(): boolean {
|
|
2068
|
+
return this.#running;
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
/**
|
|
2072
|
+
* The height of the bottom bar in CSS px, and 0 when none is drawn: under
|
|
2073
|
+
* any collapse-to but "bar", and on a phone while the panel is open, where
|
|
2074
|
+
* the panel is the screen. The bar is fixed over the page, so a host pads
|
|
2075
|
+
* its own page by this much; {@link BAR_HEIGHT_EVENT} says when it changes.
|
|
2076
|
+
*/
|
|
2077
|
+
get barHeight(): number {
|
|
2078
|
+
return this.#barHeight;
|
|
2079
|
+
}
|
|
2080
|
+
|
|
2081
|
+
/**
|
|
2082
|
+
* The width in CSS px a docked panel covers at rest, and 0 when it covers
|
|
2083
|
+
* none of the page: the rail while collapsed to it, the panel while open, 0
|
|
2084
|
+
* under every other placement, under collapse-to="none" or a launcher while
|
|
2085
|
+
* collapsed, on a phone while open (the panel is the screen) and under
|
|
2086
|
+
* --ag-ui-position: static, where the host's layout already makes room. The
|
|
2087
|
+
* panel is fixed over the page, so a host pads its own by this much;
|
|
2088
|
+
* {@link DOCK_WIDTH_EVENT} says when it changes, never per animation frame.
|
|
2089
|
+
*/
|
|
2090
|
+
get dockWidth(): number {
|
|
2091
|
+
return this.#dockWidth;
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
/**
|
|
2095
|
+
* The element the panel rises from under collapse-to="none", right-aligned
|
|
2096
|
+
* to it: typically the host's own button in its own bar. Takes precedence
|
|
2097
|
+
* over the anchor attribute, which names one by selector. One that resolves
|
|
2098
|
+
* to nothing leaves the panel in its placement's own corner, with a console
|
|
2099
|
+
* warning.
|
|
2100
|
+
*/
|
|
2101
|
+
get anchor(): Element | null {
|
|
2102
|
+
return this.#anchor;
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
set anchor(value: Element | null) {
|
|
2106
|
+
this.#anchor = value;
|
|
2107
|
+
this.#anchorWarned = false;
|
|
2108
|
+
this.#syncDock();
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
/** The surface the live placement and collapse-to attributes describe. */
|
|
2112
|
+
#surface(): Surface {
|
|
2113
|
+
return surfaceFor(this.getAttribute("placement"), this.getAttribute("collapse-to"));
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
/**
|
|
2117
|
+
* Stamp what the panel collapses to as data-collapsed-to, which is what the
|
|
2118
|
+
* stylesheet draws the bar and the hidden state from. Resolved here rather
|
|
2119
|
+
* than in CSS, because "bar" over a sidebar is its rail and "none" over a
|
|
2120
|
+
* page is no collapse at all, and the stylesheet would have to restate that
|
|
2121
|
+
* table.
|
|
2122
|
+
*/
|
|
2123
|
+
#stampCollapseTarget(): void {
|
|
2124
|
+
const target = this.#surface().collapseTo;
|
|
2125
|
+
if (target === null) {
|
|
2126
|
+
this.removeAttribute("data-collapsed-to");
|
|
2127
|
+
} else {
|
|
2128
|
+
this.setAttribute("data-collapsed-to", target);
|
|
2129
|
+
}
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
/** What the panel covers of the page at rest, from state and the panel's own box. */
|
|
2133
|
+
#restingDockWidth(): number {
|
|
2134
|
+
const placement = this.getAttribute("placement");
|
|
2135
|
+
if (
|
|
2136
|
+
(placement !== "sidebar" && placement !== "side") ||
|
|
2137
|
+
!this.#hasBox() ||
|
|
2138
|
+
getComputedStyle(this).position === "static"
|
|
2139
|
+
) {
|
|
2140
|
+
return 0;
|
|
2141
|
+
}
|
|
2142
|
+
if (this.collapsed) {
|
|
2143
|
+
return this.#surface().collapseTo === "rail" ? this.#launcher.offsetWidth : 0;
|
|
2144
|
+
}
|
|
2145
|
+
const phone =
|
|
2146
|
+
this.getAttribute("data-small-viewport") !== "off" && window.matchMedia(PHONE_QUERY).matches;
|
|
2147
|
+
return phone ? 0 : this.#chat.offsetWidth;
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
/** Tell the host when the docked width, or the edge it docks against, changed. */
|
|
2151
|
+
#syncDockWidth(): void {
|
|
2152
|
+
// A drag-resize reports once, when it ends.
|
|
2153
|
+
if (this.#placement.dragging()) {
|
|
2154
|
+
return;
|
|
2155
|
+
}
|
|
2156
|
+
const width = this.#restingDockWidth();
|
|
2157
|
+
const side = this.getAttribute("data-side") === "left" ? "left" : "right";
|
|
2158
|
+
if (width === this.#dockWidth && (width === 0 || side === this.#dockSide)) {
|
|
2159
|
+
return;
|
|
2160
|
+
}
|
|
2161
|
+
this.#dockWidth = width;
|
|
2162
|
+
this.#dockSide = side;
|
|
2163
|
+
this.#emit<DockWidthDetail>(DOCK_WIDTH_EVENT, { width, side });
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2166
|
+
/** Re-measure the bar or the anchor, and tell the host when the bar's height changed. */
|
|
2167
|
+
#syncDock(): void {
|
|
2168
|
+
// isConnected rather than the connected flag, which connectedCallback
|
|
2169
|
+
// sets only at its end, after the first measurement here.
|
|
2170
|
+
if (!this.isConnected) {
|
|
2171
|
+
return;
|
|
2172
|
+
}
|
|
2173
|
+
// With no box there is no bar drawn, which the host still hears of, and nothing to
|
|
2174
|
+
// measure: syncing would place a dragged panel from a rect of nothing. Showing this
|
|
2175
|
+
// element resizes the bar or the transcript, and their observers sync it then.
|
|
2176
|
+
const height = this.#hasBox()
|
|
2177
|
+
? this.#placement.syncDock(this.#dockBar, this.#resolveAnchor())
|
|
2178
|
+
: 0;
|
|
2179
|
+
this.#syncDockWidth();
|
|
2180
|
+
if (height === this.#barHeight) {
|
|
2181
|
+
return;
|
|
2182
|
+
}
|
|
2183
|
+
this.#barHeight = height;
|
|
2184
|
+
this.#emit<BarHeightDetail>(BAR_HEIGHT_EVENT, { height });
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2187
|
+
/**
|
|
2188
|
+
* The anchor in force under collapse-to="none": the property, else the
|
|
2189
|
+
* attribute's selector, looked up in the root this element lives in and
|
|
2190
|
+
* then the document, so a chat inside a framework's shadow root can name a
|
|
2191
|
+
* button beside it. Null under any other collapse-to, with nothing given,
|
|
2192
|
+
* or when what was given is not in the document -- reported once per anchor.
|
|
2193
|
+
*/
|
|
2194
|
+
#resolveAnchor(): Element | null {
|
|
2195
|
+
if (this.#surface().collapseTo !== "none") {
|
|
2196
|
+
return null;
|
|
2197
|
+
}
|
|
2198
|
+
const selector = this.getAttribute("anchor") ?? "";
|
|
2199
|
+
if (this.#anchor === null && selector === "") {
|
|
2200
|
+
return null;
|
|
2201
|
+
}
|
|
2202
|
+
const found = this.#anchor ?? findAnchor(this.getRootNode(), selector);
|
|
2203
|
+
if (found?.isConnected === true) {
|
|
2204
|
+
return found;
|
|
2205
|
+
}
|
|
2206
|
+
if (!this.#anchorWarned) {
|
|
2207
|
+
this.#anchorWarned = true;
|
|
2208
|
+
console.warn(
|
|
2209
|
+
`<ag-ui-chat>: the anchor ${this.#anchor === null ? `"${selector}"` : "element"} ` +
|
|
2210
|
+
"is not in the document, so the panel opens from its placement's own corner.",
|
|
2211
|
+
);
|
|
2212
|
+
}
|
|
2213
|
+
return null;
|
|
2214
|
+
}
|
|
2215
|
+
|
|
2216
|
+
/**
|
|
2217
|
+
* Whether to mount collapsed.
|
|
2218
|
+
*
|
|
2219
|
+
* A stored choice wins in either direction. With nothing stored, the corner
|
|
2220
|
+
* placements, the two with a launcher, start collapsed unless
|
|
2221
|
+
* `data-start-open`; the placements that place themselves start open, since a
|
|
2222
|
+
* host that docks or embeds the widget has given it room.
|
|
2223
|
+
*/
|
|
2224
|
+
#startsCollapsed(): boolean {
|
|
2225
|
+
const stored = this.#storage.readScopedItem(COLLAPSED_KEY);
|
|
2226
|
+
if (stored !== null) {
|
|
2227
|
+
return stored === "1";
|
|
2228
|
+
}
|
|
2229
|
+
return this.#surface().startsCollapsed && !this.hasAttribute("data-start-open");
|
|
2230
|
+
}
|
|
2231
|
+
|
|
2232
|
+
/**
|
|
2233
|
+
* Describe the panel to whoever is asking -- in practice the agent, through
|
|
2234
|
+
* the opt-in chat-surface tools.
|
|
2235
|
+
*
|
|
2236
|
+
* `movable` folds two separate reasons into the one answer a caller needs:
|
|
2237
|
+
* a placement that places itself owns its position, and a panel that fills
|
|
2238
|
+
* the screen has nowhere to go. Reporting them apart would make every caller
|
|
2239
|
+
* re-derive the same conjunction. A corner panel docked under
|
|
2240
|
+
* `collapse-to="bar"` or `"none"` is movable on a wide viewport, like the
|
|
2241
|
+
* corner panel it is.
|
|
2242
|
+
*/
|
|
2243
|
+
describeSurface(): ChatSurfaceReport {
|
|
2244
|
+
return this.#placement.describeSurface();
|
|
2245
|
+
}
|
|
2246
|
+
|
|
2247
|
+
/**
|
|
2248
|
+
* Send the panel to a corner, and report whether it went.
|
|
2249
|
+
*
|
|
2250
|
+
* A third claimant on the axes a placement and a user drag already share, and
|
|
2251
|
+
* it takes them the same way the drag does rather than inventing a second
|
|
2252
|
+
* mechanism: the same commit path, so the launcher travels with the panel,
|
|
2253
|
+
* the corner it opens from is re-picked, and switching placement hands
|
|
2254
|
+
* everything back. What it must not do is claim a move it did not make --
|
|
2255
|
+
* hence the boolean, and hence {@link describeSurface} existing so a caller
|
|
2256
|
+
* can ask first. A docked panel moves too, within the room above its bar or
|
|
2257
|
+
* anchor, and remembers the spot as a header drag would.
|
|
2258
|
+
*/
|
|
2259
|
+
moveTo(corner: ChatCorner, options: { readonly announce?: boolean } = {}): boolean {
|
|
2260
|
+
return this.#placement.moveTo(corner, options);
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2263
|
+
/**
|
|
2264
|
+
* Drop the size the user dragged in the current placement, and its stored
|
|
2265
|
+
* copy, so the placement's default and the host's `--ag-ui-width` /
|
|
2266
|
+
* `--ag-ui-height` apply again. Other placements keep theirs.
|
|
2267
|
+
* Under `collapse-to="bar"` or `"none"` it also returns a panel the user
|
|
2268
|
+
* dragged off its dock to the dock, and forgets the stored spot; a corner
|
|
2269
|
+
* panel keeps a dragged position.
|
|
2270
|
+
*/
|
|
2271
|
+
resetSize(): void {
|
|
2272
|
+
this.#placement.resetSize();
|
|
2273
|
+
this.#syncDock();
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
/**
|
|
2277
|
+
* Whether the panel is maximized (reflected as the `maximized` attribute).
|
|
2278
|
+
* Never stored: a reload opens at the placement's own size.
|
|
2279
|
+
*/
|
|
2280
|
+
get maximized(): boolean {
|
|
2281
|
+
return this.#maximized;
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
set maximized(value: boolean) {
|
|
2285
|
+
this.setMaximized(value);
|
|
2286
|
+
}
|
|
2287
|
+
|
|
2288
|
+
/**
|
|
2289
|
+
* Grow the open panel to `--ag-ui-maximized-width` / `--ag-ui-maximized-height`,
|
|
2290
|
+
* or restore it to the size it had, and emit a {@link MAXIMIZE_EVENT}. Refused
|
|
2291
|
+
* where the header offers no control: `page`, `full`, `embedded`, an unknown
|
|
2292
|
+
* placement, and a viewport of 600px or less.
|
|
2293
|
+
*/
|
|
2294
|
+
setMaximized(maximized: boolean): void {
|
|
2295
|
+
if (maximized) {
|
|
2296
|
+
this.setAttribute("maximized", "");
|
|
2297
|
+
} else {
|
|
2298
|
+
this.removeAttribute("maximized");
|
|
2299
|
+
}
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
/** Flip the maximized state. Bound to the built-in header control. */
|
|
2303
|
+
toggleMaximized(): void {
|
|
2304
|
+
this.setMaximized(!this.#maximized);
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2307
|
+
/** Whether the header offers the maximize control here, now. */
|
|
2308
|
+
#maximizable(): boolean {
|
|
2309
|
+
return this.#surface().maximizes !== null && !window.matchMedia(PHONE_QUERY).matches;
|
|
2310
|
+
}
|
|
2311
|
+
|
|
2312
|
+
/** The one path into and out of the state: the attribute's. */
|
|
2313
|
+
#applyMaximized(asked: boolean): void {
|
|
2314
|
+
const maximized = asked && this.#maximizable() && !this.collapsed;
|
|
2315
|
+
if (asked && !maximized) {
|
|
2316
|
+
this.removeAttribute("maximized");
|
|
2317
|
+
return;
|
|
2318
|
+
}
|
|
2319
|
+
if (maximized === this.#maximized) {
|
|
2320
|
+
return;
|
|
2321
|
+
}
|
|
2322
|
+
this.#maximized = maximized;
|
|
2323
|
+
if (this.#connected) {
|
|
2324
|
+
if (maximized) {
|
|
2325
|
+
this.#placement.enterMaximized();
|
|
2326
|
+
} else {
|
|
2327
|
+
this.#placement.leaveMaximized();
|
|
2328
|
+
}
|
|
2329
|
+
this.#syncDock();
|
|
2330
|
+
}
|
|
2331
|
+
this.#paintMaximizeControl();
|
|
2332
|
+
this.#reportMaximized();
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
/** Tell listeners the state once it differs from what they last heard; a mount defers it. */
|
|
2336
|
+
#reportMaximized(): void {
|
|
2337
|
+
if (!this.#connected || this.#maximized === this.#maximizedHeard) {
|
|
2338
|
+
return;
|
|
2339
|
+
}
|
|
2340
|
+
this.#maximizedHeard = this.#maximized;
|
|
2341
|
+
this.#emit<MaximizeDetail>(MAXIMIZE_EVENT, { maximized: this.#maximized });
|
|
2342
|
+
}
|
|
2343
|
+
|
|
2344
|
+
/** Offer the control where a placement has one and leave the state where it has not. */
|
|
2345
|
+
#syncMaximizeControl(): void {
|
|
2346
|
+
const offered = this.#maximizable();
|
|
2347
|
+
if (!offered) {
|
|
2348
|
+
this.setMaximized(false);
|
|
2349
|
+
}
|
|
2350
|
+
const control = this.#maximizeControl;
|
|
2351
|
+
if (control === null) {
|
|
2352
|
+
return;
|
|
2353
|
+
}
|
|
2354
|
+
if (!offered) {
|
|
2355
|
+
control.button.remove();
|
|
2356
|
+
} else if (!control.button.isConnected) {
|
|
2357
|
+
control.before.before(control.button);
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
/** Name the control for what pressing it does now. */
|
|
2362
|
+
#paintMaximizeControl(): void {
|
|
2363
|
+
const button = this.#maximizeControl?.button;
|
|
2364
|
+
if (button === undefined) {
|
|
2365
|
+
return;
|
|
2366
|
+
}
|
|
2367
|
+
const label = this.#maximized ? this.#strings.restore : this.#strings.maximize;
|
|
2368
|
+
button.title = label;
|
|
2369
|
+
button.setAttribute("aria-label", label);
|
|
2370
|
+
fillSlotFallback(button, this.#maximized ? ICON_RESTORE : ICON_MAXIMIZE);
|
|
2371
|
+
}
|
|
2372
|
+
|
|
2373
|
+
/** Draw the collapse mark data-collapse-icon chooses, or the placement's default. */
|
|
2374
|
+
#paintCollapseControl(): void {
|
|
2375
|
+
const button = this.#collapseControl;
|
|
2376
|
+
if (button === null) {
|
|
2377
|
+
return;
|
|
2378
|
+
}
|
|
2379
|
+
const glyph = collapseGlyph(
|
|
2380
|
+
this.getAttribute("data-collapse-icon"),
|
|
2381
|
+
this.getAttribute("placement"),
|
|
2382
|
+
this.getAttribute("data-side"),
|
|
2383
|
+
);
|
|
2384
|
+
fillSlotFallback(button, glyph);
|
|
2385
|
+
}
|
|
2386
|
+
|
|
2387
|
+
/** Flip the collapsed state. Bound to the built-in header toggle. */
|
|
2388
|
+
toggleCollapsed(): void {
|
|
2389
|
+
this.setCollapsed(!this.collapsed);
|
|
2390
|
+
}
|
|
2391
|
+
|
|
2392
|
+
/**
|
|
2393
|
+
* Flip the scheme word of the `theme` attribute between `light` and `dark`
|
|
2394
|
+
* and persist the choice per tab. Bound to the optional built-in header theme
|
|
2395
|
+
* toggle (`data-theme-toggle`). The theme name and any host words are kept:
|
|
2396
|
+
* `soft` becomes `soft dark`, and `soft dark` becomes `soft light`. With no
|
|
2397
|
+
* scheme word (`auto`, or a host rule setting `color-scheme` on the element),
|
|
2398
|
+
* it flips away from the scheme the panel is drawn in.
|
|
2399
|
+
*/
|
|
2400
|
+
toggleTheme(): void {
|
|
2401
|
+
// Only the scheme word flips, so a named theme survives the toggle:
|
|
2402
|
+
// "soft" becomes "soft dark", and "soft dark" becomes "soft light". And
|
|
2403
|
+
// only the scheme word is saved: the name is the host's, and a name saved
|
|
2404
|
+
// on one page would replace another page's theme on restore.
|
|
2405
|
+
const theme = this.getAttribute("theme") ?? "";
|
|
2406
|
+
const scheme = this.#drawnScheme() === "dark" ? "light" : "dark";
|
|
2407
|
+
this.setAttribute("theme", withScheme(theme, scheme));
|
|
2408
|
+
this.#storage.writePreference(THEME_KEY, scheme);
|
|
2409
|
+
}
|
|
2410
|
+
|
|
2411
|
+
/**
|
|
2412
|
+
* Move the element's client state from one principal to another.
|
|
2413
|
+
*
|
|
2414
|
+
* `sessionStorage` outlives a logout and nothing remounts, so the host naming
|
|
2415
|
+
* the new principal is the only signal the element gets.
|
|
2416
|
+
*
|
|
2417
|
+
* A live change runs all of it. connectedCallback runs it for a key changed
|
|
2418
|
+
* while detached, before the store is scoped and before `#connected` is set
|
|
2419
|
+
* (which `live` reads), and there only the half about what storage and the
|
|
2420
|
+
* element still hold: the arriving half is what connecting goes on to do.
|
|
2421
|
+
*/
|
|
2422
|
+
#changePrincipal(previousKey: string, nextKey: string): void {
|
|
2423
|
+
this.#principalKey = nextKey;
|
|
2424
|
+
const live = this.#connected;
|
|
2425
|
+
const previous = this.#storage.conversationNamespace(previousKey);
|
|
2426
|
+
const next = this.#storage.conversationNamespace(nextKey);
|
|
2427
|
+
if (previousKey === "") {
|
|
2428
|
+
// Absent to present is not a handover. It is the documented late
|
|
2429
|
+
// configuration shape — the element mounts, an auth handshake resolves,
|
|
2430
|
+
// and only then is the user known — so the conversation already on screen
|
|
2431
|
+
// belongs to this principal and moves with them. Moving rather than
|
|
2432
|
+
// copying also matters: a copy left behind under the unscoped namespace
|
|
2433
|
+
// is a transcript the next key-less mount would happily adopt.
|
|
2434
|
+
SessionStorageStore.adopt(previous, next);
|
|
2435
|
+
if (live) {
|
|
2436
|
+
this.#rescopeStore(next);
|
|
2437
|
+
}
|
|
2438
|
+
return;
|
|
2439
|
+
}
|
|
2440
|
+
// First, ahead of the purge: from here on, nothing a client built for the
|
|
2441
|
+
// previous principal does is written anywhere. See #tenure.
|
|
2442
|
+
this.#tenure = {};
|
|
2443
|
+
SessionStorageStore.purge(previous);
|
|
2444
|
+
if (live) {
|
|
2445
|
+
this.#rescopeStore(next);
|
|
2446
|
+
}
|
|
2447
|
+
// An Always allow is the previous principal's decision too, and it lives in
|
|
2448
|
+
// memory rather than in the store just purged, so it has to be forgotten
|
|
2449
|
+
// here or it outlives them. The adoption above keeps it, for the same
|
|
2450
|
+
// reason it keeps the conversation: the same person is still there.
|
|
2451
|
+
this.#dispatch.forgetWaivers();
|
|
2452
|
+
// The transcript on screen, the run in flight and the replayed history all
|
|
2453
|
+
// belong to the principal who just left. Purging storage without clearing
|
|
2454
|
+
// these would leave the previous user's conversation visible to the new one.
|
|
2455
|
+
this.#cancelRun();
|
|
2456
|
+
this.#resetState();
|
|
2457
|
+
// Shared state too, and only here (New chat keeps it): it seeds the next
|
|
2458
|
+
// client, so it would go out as the new principal's first run state.
|
|
2459
|
+
this.#sharedState = {};
|
|
2460
|
+
// And the unsent turn, also only here: the previous principal's words.
|
|
2461
|
+
// Through the input handler, so the hint and slash palette come down too.
|
|
2462
|
+
this.#input.value = "";
|
|
2463
|
+
this.#onInput();
|
|
2464
|
+
this.#setRunning(false);
|
|
2465
|
+
// The previous principal's marks go with their threads, unread: nobody saw them.
|
|
2466
|
+
this.#forgetUnread([...this.#unreadByThread.keys()], true);
|
|
2467
|
+
if (!live) {
|
|
2468
|
+
return;
|
|
2469
|
+
}
|
|
2470
|
+
// A recording in progress is theirs too: taking the mic down ends it
|
|
2471
|
+
// untranscribed, and a fresh one leaves the next principal a working
|
|
2472
|
+
// control. A detached change has been through both already.
|
|
2473
|
+
this.#voice.dispose();
|
|
2474
|
+
this.#voice.wire();
|
|
2475
|
+
this.#history.adoptActiveThread();
|
|
2476
|
+
void this.#history.rehydrate("rescope");
|
|
2477
|
+
void this.#history.refreshDrawer();
|
|
2478
|
+
}
|
|
2479
|
+
|
|
2480
|
+
/**
|
|
2481
|
+
* Rebuild the `sessionStorage` store under `namespace`, re-wrapping it for
|
|
2482
|
+
* `data-threads-url` exactly as connecting did.
|
|
2483
|
+
*
|
|
2484
|
+
* A store the host assigned scopes itself and is left alone, except a plain
|
|
2485
|
+
* `SessionStorageStore` assigned before connecting, which connecting took for
|
|
2486
|
+
* the element's own; so the store in use is what the scope is asked about.
|
|
2487
|
+
*/
|
|
2488
|
+
#rescopeStore(namespace: string): void {
|
|
2489
|
+
const store = this.#storage.rescopeStore(this.#unwrappedStore(), namespace);
|
|
2490
|
+
if (store === null) {
|
|
2491
|
+
return;
|
|
2492
|
+
}
|
|
2493
|
+
this.conversationStore = store;
|
|
2494
|
+
this.#wireThreadStore();
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2497
|
+
/**
|
|
2498
|
+
* The scheme the panel is drawn in, which the toggle flips and its glyph
|
|
2499
|
+
* offers the way out of. A scheme word decides it, since the scheme rules
|
|
2500
|
+
* hold against a host's. Without one the panel follows whatever sets its
|
|
2501
|
+
* color-scheme: a host rule on the element, auto's "light dark", or the
|
|
2502
|
+
* light default. Both schemes allowed means the system picks. Read before
|
|
2503
|
+
* the shadow styles apply too (the glyph is drawn first), when the computed
|
|
2504
|
+
* value carries only a host rule, so auto is answered from its word.
|
|
2505
|
+
*/
|
|
2506
|
+
#drawnScheme(): "light" | "dark" {
|
|
2507
|
+
const words = (this.getAttribute("theme") ?? "").split(/\s+/);
|
|
2508
|
+
if (words.includes("dark")) {
|
|
2509
|
+
return "dark";
|
|
2510
|
+
}
|
|
2511
|
+
if (words.includes("light")) {
|
|
2512
|
+
return "light";
|
|
2513
|
+
}
|
|
2514
|
+
const declared = getComputedStyle(this).colorScheme.split(/\s+/);
|
|
2515
|
+
const dark = declared.includes("dark");
|
|
2516
|
+
if (dark !== declared.includes("light")) {
|
|
2517
|
+
return dark ? "dark" : "light";
|
|
2518
|
+
}
|
|
2519
|
+
if (dark || words.includes("auto")) {
|
|
2520
|
+
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
|
2521
|
+
}
|
|
2522
|
+
return "light";
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2525
|
+
/** Reflect the current theme on the toggle: show the destination's glyph. */
|
|
2526
|
+
#syncThemeGlyph(): void {
|
|
2527
|
+
const dark = this.#drawnScheme() === "dark";
|
|
2528
|
+
// Replaced wholesale rather than toggling a class: the two marks are
|
|
2529
|
+
// different paths, not one path in two states, and the slot has to keep
|
|
2530
|
+
// working so a host can still supply its own.
|
|
2531
|
+
this.#themeToggle.replaceChildren(
|
|
2532
|
+
iconElement("theme", "theme-icon", dark ? ICON_SUN : ICON_MOON, null),
|
|
2533
|
+
);
|
|
2534
|
+
}
|
|
2535
|
+
|
|
2536
|
+
/**
|
|
2537
|
+
* Open the thread-history drawer: the imperative route to the control that
|
|
2538
|
+
* renders as `::part(history-button)`.
|
|
2539
|
+
*
|
|
2540
|
+
* A host that hides `::part(header)` for its own title bar hides the history,
|
|
2541
|
+
* new-chat and collapse buttons with it. Each has a method so that chrome can
|
|
2542
|
+
* be rebuilt: this one, {@link openCheckpoints}, {@link newChat},
|
|
2543
|
+
* {@link toggleCollapsed} and {@link toggleTheme}.
|
|
2544
|
+
*/
|
|
2545
|
+
openThreads(options: { readonly anchor?: Element } = {}): void {
|
|
2546
|
+
// Collapsed into the bar, the list opens as a popover over the bar rather
|
|
2547
|
+
// than expanding the panel: the same drawer, moved beside the bar, since
|
|
2548
|
+
// nothing inside the hidden panel can be drawn. Under the host's own bar it
|
|
2549
|
+
// opens beside the host's button, and a pick leaves the panel to the host.
|
|
2550
|
+
const target = this.#surface().collapseTo;
|
|
2551
|
+
const anchor = options.anchor ?? this.#resolveAnchor();
|
|
2552
|
+
// An embedded chat collapsed under none is not drawn at all, popover included.
|
|
2553
|
+
const drawn = this.getAttribute("placement") !== "embedded";
|
|
2554
|
+
if (this.collapsed && target === "bar") {
|
|
2555
|
+
this.#dockBar.append(this.#drawer.element);
|
|
2556
|
+
} else if (this.collapsed && target === "none" && anchor !== null && drawn) {
|
|
2557
|
+
this.#threadsAnchor = anchor;
|
|
2558
|
+
this.#threadsPopover.append(this.#drawer.element);
|
|
2559
|
+
this.#placeThreadsPopover();
|
|
2560
|
+
} else {
|
|
2561
|
+
if (this.collapsed && target === "none") {
|
|
2562
|
+
console.warn(
|
|
2563
|
+
drawn
|
|
2564
|
+
? '<ag-ui-chat>: openThreads() under collapse-to="none" needs an anchor to open ' +
|
|
2565
|
+
"beside; pass openThreads({ anchor }) or set the anchor attribute."
|
|
2566
|
+
: '<ag-ui-chat>: openThreads() under placement="embedded" and collapse-to="none" ' +
|
|
2567
|
+
"has no popover: the collapsed chat is not drawn. Expand it first.",
|
|
2568
|
+
);
|
|
2569
|
+
}
|
|
2570
|
+
this.#homeDrawer();
|
|
2571
|
+
}
|
|
2572
|
+
this.#history.openThreads();
|
|
2573
|
+
}
|
|
2574
|
+
|
|
2575
|
+
/**
|
|
2576
|
+
* Stand the popover on its button: below one in the top half of the viewport,
|
|
2577
|
+
* above one in the bottom half, right-aligned to it and held inside the window.
|
|
2578
|
+
*/
|
|
2579
|
+
#placeThreadsPopover(): void {
|
|
2580
|
+
const anchor = this.#threadsAnchor;
|
|
2581
|
+
if (anchor === null || this.#drawer.element.parentNode !== this.#threadsPopover) {
|
|
2582
|
+
return;
|
|
2583
|
+
}
|
|
2584
|
+
const rect = anchor.getBoundingClientRect();
|
|
2585
|
+
const width = document.documentElement.clientWidth;
|
|
2586
|
+
const height = document.documentElement.clientHeight;
|
|
2587
|
+
const below = rect.top + rect.height / 2 < height / 2;
|
|
2588
|
+
const list = Math.min(THREADS_POPOVER_WIDTH, width - 2 * THREADS_POPOVER_GAP);
|
|
2589
|
+
const right = Math.min(
|
|
2590
|
+
Math.max(THREADS_POPOVER_GAP, width - rect.right),
|
|
2591
|
+
width - THREADS_POPOVER_GAP - list,
|
|
2592
|
+
);
|
|
2593
|
+
const room = (below ? height - rect.bottom : rect.top) - 2 * THREADS_POPOVER_GAP;
|
|
2594
|
+
this.style.setProperty(
|
|
2595
|
+
"--_threads-top",
|
|
2596
|
+
below ? `${rect.bottom + THREADS_POPOVER_GAP}px` : "auto",
|
|
2597
|
+
);
|
|
2598
|
+
this.style.setProperty(
|
|
2599
|
+
"--_threads-bottom",
|
|
2600
|
+
below ? "auto" : `${height - rect.top + THREADS_POPOVER_GAP}px`,
|
|
2601
|
+
);
|
|
2602
|
+
this.style.setProperty("--_threads-right", `${Math.max(THREADS_POPOVER_GAP, right)}px`);
|
|
2603
|
+
this.style.setProperty("--_threads-room", `${Math.max(0, room)}px`);
|
|
2604
|
+
}
|
|
2605
|
+
|
|
2606
|
+
/**
|
|
2607
|
+
* Put the drawer back in the panel, where every surface but the bar's
|
|
2608
|
+
* popover draws it. A popover still open is closed first: the list was
|
|
2609
|
+
* opened over the bar, and carried into the panel it would cover the
|
|
2610
|
+
* conversation the user asked to see.
|
|
2611
|
+
*/
|
|
2612
|
+
#homeDrawer(keep: Node | null = null): void {
|
|
2613
|
+
const away = this.#drawer.element.parentNode;
|
|
2614
|
+
if (away !== keep && (away === this.#dockBar || away === this.#threadsPopover)) {
|
|
2615
|
+
this.#drawer.close();
|
|
2616
|
+
this.#chat.insertBefore(this.#drawer.element, this.#checkpoints.element);
|
|
2617
|
+
this.#threadsAnchor = null;
|
|
2618
|
+
for (const name of ["top", "bottom", "right", "room"]) {
|
|
2619
|
+
this.style.removeProperty(`--_threads-${name}`);
|
|
2620
|
+
}
|
|
2621
|
+
}
|
|
2622
|
+
}
|
|
2623
|
+
|
|
2624
|
+
/**
|
|
2625
|
+
* Open the checkpoints panel (the `::part(checkpoints-button)` route).
|
|
2626
|
+
*
|
|
2627
|
+
* It lists the runs the `data-runs-url` server reports as continuable;
|
|
2628
|
+
* without that attribute the built-in button is never rendered and this opens
|
|
2629
|
+
* an empty panel.
|
|
2630
|
+
*/
|
|
2631
|
+
openCheckpoints(): void {
|
|
2632
|
+
this.#history.openCheckpoints();
|
|
2633
|
+
}
|
|
2634
|
+
|
|
2635
|
+
/** Close the conversation list, if it is open. */
|
|
2636
|
+
closeThreads(): void {
|
|
2637
|
+
this.#drawer.close();
|
|
2638
|
+
}
|
|
2639
|
+
|
|
2640
|
+
/** Close the checkpoints panel, if it is open. */
|
|
2641
|
+
closeCheckpoints(): void {
|
|
2642
|
+
this.#checkpoints.close();
|
|
2643
|
+
}
|
|
2644
|
+
|
|
2645
|
+
/**
|
|
2646
|
+
* Put a stored thread on screen, as picking it in the conversation list does,
|
|
2647
|
+
* for a host that lists conversations in its own chrome.
|
|
2648
|
+
*
|
|
2649
|
+
* Resolves once the thread has been replayed, when {@link THREAD_EVENT} has
|
|
2650
|
+
* fired; a second call for the thread already loading settles with that load.
|
|
2651
|
+
* If the store rejects the load, the element stays on the thread it was on,
|
|
2652
|
+
* fires nothing, and the promise rejects with the store's error.
|
|
2653
|
+
* **A run in flight is cancelled**, as the list and {@link newChat} do;
|
|
2654
|
+
* a host that would rather wait reads {@link running} first. The thread
|
|
2655
|
+
* already on screen is left alone, and so is everything before the element
|
|
2656
|
+
* connects, since until then it has no store to read the thread from.
|
|
2657
|
+
*/
|
|
2658
|
+
switchThread(threadId: string): Promise<void> {
|
|
2659
|
+
if (!this.#connected) {
|
|
2660
|
+
return Promise.resolve();
|
|
2661
|
+
}
|
|
2662
|
+
return this.#history.switchThread(threadId);
|
|
2663
|
+
}
|
|
2664
|
+
|
|
2665
|
+
/**
|
|
2666
|
+
* Delete a stored thread, as the conversation list's delete does. Deleting the
|
|
2667
|
+
* thread on screen cancels its run and puts a fresh thread in its place, and
|
|
2668
|
+
* {@link THREAD_EVENT} reports it; deleting another leaves the screen alone.
|
|
2669
|
+
* Does nothing before the element connects.
|
|
2670
|
+
*/
|
|
2671
|
+
deleteThread(threadId: string): void {
|
|
2672
|
+
if (!this.#connected) {
|
|
2673
|
+
return;
|
|
2674
|
+
}
|
|
2675
|
+
this.#history.deleteThread(threadId);
|
|
2676
|
+
}
|
|
2677
|
+
|
|
2678
|
+
/**
|
|
2679
|
+
* Open the checkpoints panel, or close it if it is already open — what the
|
|
2680
|
+
* built-in checkpoints button does, because a control that opens a panel is read as the
|
|
2681
|
+
* control that also dismisses it. {@link openCheckpoints} stays open-only for a
|
|
2682
|
+
* host that means exactly that.
|
|
2683
|
+
*/
|
|
2684
|
+
toggleCheckpoints(): void {
|
|
2685
|
+
if (this.#checkpoints.open_) {
|
|
2686
|
+
this.#checkpoints.close();
|
|
2687
|
+
return;
|
|
2688
|
+
}
|
|
2689
|
+
this.openCheckpoints();
|
|
2690
|
+
}
|
|
2691
|
+
|
|
2692
|
+
/**
|
|
2693
|
+
* Start a fresh conversation: drop the in-memory run state, clear the
|
|
2694
|
+
* transcript, and mint a new thread id.
|
|
2695
|
+
*
|
|
2696
|
+
* The conversation being left is kept, and stays in the history drawer to
|
|
2697
|
+
* return to. Deleting one is the drawer row's own action; a button that
|
|
2698
|
+
* starts something new must not be the button that destroys what was there.
|
|
2699
|
+
*
|
|
2700
|
+
* Focus moves to the composer, unless the widget is collapsed and the
|
|
2701
|
+
* composer is not on screen: starting a conversation is a request to type
|
|
2702
|
+
* one. The page is not scrolled to bring the composer into view.
|
|
2703
|
+
*/
|
|
2704
|
+
newChat(): void {
|
|
2705
|
+
// Before the element connects it has no store scoped to its namespace and
|
|
2706
|
+
// no thread to leave, and minting one would write to the unscoped store and
|
|
2707
|
+
// fire an event for a thread it does not yet show. Connecting starts it.
|
|
2708
|
+
if (!this.#connected) {
|
|
2709
|
+
this.#newChatOnConnect = true;
|
|
2710
|
+
return;
|
|
2711
|
+
}
|
|
2712
|
+
// Stop any in-flight run first — discarding the client mid-run would
|
|
2713
|
+
// leave the old agent streaming into a cleared transcript.
|
|
2714
|
+
this.#cancelRun();
|
|
2715
|
+
this.#history.reapUnsent();
|
|
2716
|
+
this.#resetState();
|
|
2717
|
+
this.#history.startThread();
|
|
2718
|
+
this.#setRunning(false);
|
|
2719
|
+
this.#dispatchThread(this.#history.threadId, "new");
|
|
2720
|
+
// Whichever control started it, focus returns to the emptied field, without
|
|
2721
|
+
// scrolling: a host resetting a chat further down the page did not ask for
|
|
2722
|
+
// the page to move or a phone keyboard to open.
|
|
2723
|
+
if (!this.collapsed) {
|
|
2724
|
+
this.#input.focus({ preventScroll: true });
|
|
2725
|
+
}
|
|
2726
|
+
}
|
|
2727
|
+
|
|
2728
|
+
/** Drop the in-memory run + transcript, leaving the thread id untouched. */
|
|
2729
|
+
#resetState(): void {
|
|
2730
|
+
this.#resetConversation();
|
|
2731
|
+
// The composer's own history goes with the conversation it was typed
|
|
2732
|
+
// into. The path that makes this more than tidiness is the `user-key`
|
|
2733
|
+
// rescope, which purges storage and wipes the transcript precisely so the
|
|
2734
|
+
// previous principal's words are not visible to the next one -- and would
|
|
2735
|
+
// otherwise leave every one of them a single ArrowUp away.
|
|
2736
|
+
this.#sentDrafts.length = 0;
|
|
2737
|
+
this.#recallIndex = null;
|
|
2738
|
+
}
|
|
2739
|
+
|
|
2740
|
+
/**
|
|
2741
|
+
* Drop the in-memory run and transcript, and nothing the user typed.
|
|
2742
|
+
*
|
|
2743
|
+
* Split from {@link #resetState} for re-insertion, which rebuilds the
|
|
2744
|
+
* conversation from history but is not a new one, so the composer's recall
|
|
2745
|
+
* history stays.
|
|
2746
|
+
*/
|
|
2747
|
+
#resetConversation(): void {
|
|
2748
|
+
this.#client = null;
|
|
2749
|
+
// Every path here has just cancelled the run, but a cancelled run ends
|
|
2750
|
+
// later: once its request closes, or once a host tool's handler returns.
|
|
2751
|
+
// Whatever it says then is about the conversation being cleared, so it must
|
|
2752
|
+
// not draw into this one, put a new run's Stop back to Send, or report the
|
|
2753
|
+
// new conversation's tools as its own.
|
|
2754
|
+
this.#runHandlers.detach();
|
|
2755
|
+
// A frontend tool call of that run can resume later still, for the same
|
|
2756
|
+
// reason, and must not draw into this one either: see #conversation.
|
|
2757
|
+
this.#conversation = {};
|
|
2758
|
+
this.#clearTranscript();
|
|
2759
|
+
this.#history.forgetRestored();
|
|
2760
|
+
}
|
|
2761
|
+
|
|
2762
|
+
/**
|
|
2763
|
+
* Wipe the rendered transcript and everything that indexes into it.
|
|
2764
|
+
*
|
|
2765
|
+
* Split from {@link #resetState} because a retry re-renders the transcript
|
|
2766
|
+
* while keeping the *client*: dropping the client there would take the
|
|
2767
|
+
* agent's message list with it, which is the thing being truncated.
|
|
2768
|
+
*/
|
|
2769
|
+
#clearTranscript(): void {
|
|
2770
|
+
// Before the transcript goes: a render still queued would otherwise fire
|
|
2771
|
+
// against the wiped list and open a fresh bubble holding the discarded
|
|
2772
|
+
// conversation's last tokens.
|
|
2773
|
+
this.#stream.end();
|
|
2774
|
+
this.#transcript.releaseTurn();
|
|
2775
|
+
// The panels go with the cards they hung off.
|
|
2776
|
+
this.#subagents.clear();
|
|
2777
|
+
this.#transcript.forgetCards();
|
|
2778
|
+
this.#activities.clearBlocks();
|
|
2779
|
+
this.#actions.forget();
|
|
2780
|
+
this.#attachments.tray?.clear();
|
|
2781
|
+
// Returning to an empty conversation snaps back to the centre: only the send
|
|
2782
|
+
// that left it travels. And whatever restore was holding the layout back is
|
|
2783
|
+
// no longer this transcript's, so a new chat started mid-restore greets the
|
|
2784
|
+
// user rather than waiting on a load it has abandoned.
|
|
2785
|
+
this.removeAttribute("data-composer-settling");
|
|
2786
|
+
this.removeAttribute("data-restoring");
|
|
2787
|
+
// Keep the empty-state region; everything else clears.
|
|
2788
|
+
this.#transcript.empty();
|
|
2789
|
+
}
|
|
2790
|
+
|
|
2791
|
+
/**
|
|
2792
|
+
* Ask the same question again and replace the answer.
|
|
2793
|
+
*
|
|
2794
|
+
* History is truncated to the most recent user message inclusive and the run
|
|
2795
|
+
* repeats, so the agent answers what it was asked rather than being told its
|
|
2796
|
+
* last answer was wrong. Returns `false` when there is nothing to retry, or
|
|
2797
|
+
* while anything that holds a send is in flight: a run, a send or another
|
|
2798
|
+
* retry whose run has not started yet, a checkpoint continuation, or a
|
|
2799
|
+
* restore of the stored conversation that is still loading. It does not wait
|
|
2800
|
+
* for a restore as a send does: nothing is on screen to retry yet, and after
|
|
2801
|
+
* the load it would re-ask the previous visit's last question. Also `false`
|
|
2802
|
+
* when host code it
|
|
2803
|
+
* runs before its run starts -- the store's save, or an activity or tool
|
|
2804
|
+
* renderer the replay draws through -- has stopped it by starting a new chat
|
|
2805
|
+
* or changing `user-key`. A tool or context provider that stops it does so
|
|
2806
|
+
* inside the client's resume, past the last point this can see: nothing is
|
|
2807
|
+
* sent, and this resolves `true`, because both a run starting and a Stop let
|
|
2808
|
+
* go of the hold it checks. Rejects with the store's error when the store
|
|
2809
|
+
* refuses the save, and then the conversation is as it was.
|
|
2810
|
+
*
|
|
2811
|
+
* Public because a host with its own message UI wants the same button, and
|
|
2812
|
+
* because the failed-run notice reaches it from outside the action row.
|
|
2813
|
+
*
|
|
2814
|
+
* **A retried turn re-runs its tools**, which for a page-driving agent is not
|
|
2815
|
+
* neutral: the previous attempt already clicked what it clicked, and this
|
|
2816
|
+
* does not undo it. Confirmation still applies, so a destructive tool asks
|
|
2817
|
+
* again -- unless the user waived it for this session.
|
|
2818
|
+
*/
|
|
2819
|
+
async retryLastTurn(): Promise<boolean> {
|
|
2820
|
+
// Refused for whatever refuses a send, since it starts a run on the same
|
|
2821
|
+
// client: a held send and a picked checkpoint's continuation included.
|
|
2822
|
+
if (this.#busy()) {
|
|
2823
|
+
return false;
|
|
2824
|
+
}
|
|
2825
|
+
// Held from here as a send is, since the run starts a microtask after it is
|
|
2826
|
+
// asked for. Released once it starts, by `#setRunning`, and below otherwise.
|
|
2827
|
+
const hold = {};
|
|
2828
|
+
this.#sending = hold;
|
|
2829
|
+
try {
|
|
2830
|
+
const client = this.#ensureClient();
|
|
2831
|
+
const kept = client.truncateToLastUser();
|
|
2832
|
+
if (kept === null) {
|
|
2833
|
+
return false;
|
|
2834
|
+
}
|
|
2835
|
+
// The truncation saved through the host's store, whose code may have
|
|
2836
|
+
// started a new chat or changed `user-key`, and so stopped the Retry.
|
|
2837
|
+
if (this.#sending !== hold) {
|
|
2838
|
+
return false;
|
|
2839
|
+
}
|
|
2840
|
+
// Re-render between the truncation and the run: the kept turns replay as
|
|
2841
|
+
// restored history (static, no entrance animation), and only the new
|
|
2842
|
+
// answer arrives live. Streaming into the old transcript would put the
|
|
2843
|
+
// new answer underneath the one it replaces.
|
|
2844
|
+
this.#clearTranscript();
|
|
2845
|
+
for (const message of kept) {
|
|
2846
|
+
this.#history.replay(message);
|
|
2847
|
+
// A replay draws through host renderers, which can stop it the same way.
|
|
2848
|
+
if (this.#sending !== hold) {
|
|
2849
|
+
return false;
|
|
2850
|
+
}
|
|
2851
|
+
}
|
|
2852
|
+
await client.resume();
|
|
2853
|
+
return true;
|
|
2854
|
+
} finally {
|
|
2855
|
+
// Only its own hold, as a send's end releases only its own. A turn the
|
|
2856
|
+
// composer queued behind it is sent from here when its run never started,
|
|
2857
|
+
// because no run settles to send it.
|
|
2858
|
+
this.#releaseHold(hold);
|
|
2859
|
+
}
|
|
2860
|
+
}
|
|
2861
|
+
|
|
2862
|
+
/**
|
|
2863
|
+
* Append a message bubble and return it.
|
|
2864
|
+
*
|
|
2865
|
+
* Assistant content renders as sanitised markdown/HTML; user content stays
|
|
2866
|
+
* literal text, which also avoids rendering user-authored markup.
|
|
2867
|
+
*
|
|
2868
|
+
* Assistant bubbles land in the current answer group, opening one if needed;
|
|
2869
|
+
* a user bubble closes the prior group and sits directly in the list, the
|
|
2870
|
+
* well wrapping only the assistant turn.
|
|
2871
|
+
*/
|
|
2872
|
+
appendMessage(role: MessageRole, content: string): HTMLDivElement {
|
|
2873
|
+
return this.#transcript.append(role, content);
|
|
2874
|
+
}
|
|
2875
|
+
|
|
2876
|
+
/**
|
|
2877
|
+
* Build the chrome, from the attributes as they stand.
|
|
2878
|
+
*
|
|
2879
|
+
* Runs on every connect, not once, because removing and re-inserting the
|
|
2880
|
+
* element is the documented way to apply a connect-time attribute written
|
|
2881
|
+
* late. So it has to build the same element the second time as the first:
|
|
2882
|
+
* the elements made here are new each time, the long-lived ones it fills
|
|
2883
|
+
* are filled with `replaceChildren` rather than appended to, and every
|
|
2884
|
+
* listener on a long-lived one goes under the connection's signal.
|
|
2885
|
+
*/
|
|
2886
|
+
#render(): void {
|
|
2887
|
+
const { signal } = this.#connection;
|
|
2888
|
+
this.#chat.className = "chat";
|
|
2889
|
+
this.#chat.setAttribute("part", "panel");
|
|
2890
|
+
|
|
2891
|
+
const titleText = this.#titleText();
|
|
2892
|
+
const themed = this.getAttribute("data-theme-toggle") !== null;
|
|
2893
|
+
const { header, checkpoints, maximize, collapse } = buildHeader({
|
|
2894
|
+
host: this,
|
|
2895
|
+
title: this.#title,
|
|
2896
|
+
titleText,
|
|
2897
|
+
iconUrl: this.getAttribute("data-icon-url"),
|
|
2898
|
+
strings: this.#strings,
|
|
2899
|
+
// Asks the history's run index rather than re-testing the attribute, so
|
|
2900
|
+
// "configured" means one thing everywhere (an empty value is unset, not a
|
|
2901
|
+
// relative URL to the current page).
|
|
2902
|
+
withCheckpoints: this.#history.runs() !== null,
|
|
2903
|
+
themeToggle: themed ? this.#themeToggle : null,
|
|
2904
|
+
signal,
|
|
2905
|
+
onHistory: () => this.openThreads(),
|
|
2906
|
+
onCheckpoints: () => this.toggleCheckpoints(),
|
|
2907
|
+
onNewChat: () => this.newChat(),
|
|
2908
|
+
onCollapse: () => this.toggleCollapsed(),
|
|
2909
|
+
onMaximize: () => this.toggleMaximized(),
|
|
2910
|
+
onToggleTheme: () => this.toggleTheme(),
|
|
2911
|
+
});
|
|
2912
|
+
if (themed) {
|
|
2913
|
+
this.#syncThemeGlyph();
|
|
2914
|
+
}
|
|
2915
|
+
|
|
2916
|
+
// A panel is a window and a header is its title bar.
|
|
2917
|
+
this.#placement.enablePanelDrag(header);
|
|
2918
|
+
this.#maximizeControl = { button: maximize, before: collapse };
|
|
2919
|
+
this.#collapseControl = collapse;
|
|
2920
|
+
this.#paintCollapseControl();
|
|
2921
|
+
this.#syncMaximizeControl();
|
|
2922
|
+
this.#paintMaximizeControl();
|
|
2923
|
+
window
|
|
2924
|
+
.matchMedia(PHONE_QUERY)
|
|
2925
|
+
.addEventListener("change", () => this.#syncMaximizeControl(), { signal });
|
|
2926
|
+
|
|
2927
|
+
buildTranscript({
|
|
2928
|
+
messages: this.#messages,
|
|
2929
|
+
jumpButton: this.#jumpButton,
|
|
2930
|
+
emptyWrap: this.#emptyWrap,
|
|
2931
|
+
greetingText: this.#greetingText,
|
|
2932
|
+
starters: renderStarterChips(this, this.#strings, (prompt) => {
|
|
2933
|
+
void this.sendMessage(prompt);
|
|
2934
|
+
}),
|
|
2935
|
+
strings: this.#strings,
|
|
2936
|
+
});
|
|
2937
|
+
this.#syncGreeting();
|
|
2938
|
+
|
|
2939
|
+
// The quote offer, and the transcript's settled selections it listens for.
|
|
2940
|
+
this.#excerpts.mount(signal);
|
|
2941
|
+
|
|
2942
|
+
// Built here rather than at field initialisation: the viewport has to exist
|
|
2943
|
+
// and the observer has to have something to observe.
|
|
2944
|
+
this.#transcript.mountScroller(this.#jumpButton, signal);
|
|
2945
|
+
|
|
2946
|
+
this.#announcer.mount();
|
|
2947
|
+
|
|
2948
|
+
this.#messages.append(this.#emptyWrap);
|
|
2949
|
+
// Stamped from the first frame, so a page that mounts empty is laid out as
|
|
2950
|
+
// empty rather than switching to it on the first change.
|
|
2951
|
+
this.#transcript.updateEmptyState();
|
|
2952
|
+
|
|
2953
|
+
const inputRow = buildComposer({
|
|
2954
|
+
input: this.#input,
|
|
2955
|
+
send: this.#send,
|
|
2956
|
+
attachButton: this.#attachButton,
|
|
2957
|
+
fileInput: this.#fileInput,
|
|
2958
|
+
voiceSlot: this.#voiceSlot,
|
|
2959
|
+
place: this.#place.element,
|
|
2960
|
+
hint: this.#composerHint,
|
|
2961
|
+
queuedRow: this.#queuedRow,
|
|
2962
|
+
attachSlot: this.#attachSlot,
|
|
2963
|
+
strings: this.#strings,
|
|
2964
|
+
signal,
|
|
2965
|
+
onKeydown: (event) => this.#onKeydown(event),
|
|
2966
|
+
onInput: () => this.#onInput(),
|
|
2967
|
+
onSend: () => {
|
|
2968
|
+
// One button, two states: Send while idle, Stop while a run is in
|
|
2969
|
+
// flight (no layout change).
|
|
2970
|
+
if (this.#running) {
|
|
2971
|
+
this.#cancelRun();
|
|
2972
|
+
return;
|
|
2973
|
+
}
|
|
2974
|
+
void this.#submit();
|
|
2975
|
+
},
|
|
2976
|
+
onFilesPicked: () => this.#attachments.onFilesPicked(),
|
|
2977
|
+
});
|
|
2978
|
+
|
|
2979
|
+
this.#place.mount(signal, this.#strings);
|
|
2980
|
+
this.#input.addEventListener("focus", () => this.refreshPlace(), { signal });
|
|
2981
|
+
window.addEventListener("popstate", () => this.refreshPlace(), { signal });
|
|
2982
|
+
window.addEventListener("hashchange", () => this.refreshPlace(), { signal });
|
|
2983
|
+
this.refreshPlace();
|
|
2984
|
+
|
|
2985
|
+
// Host content against the input, after everything of the turn being
|
|
2986
|
+
// written. A bare slot, whose own box is none, so an empty one takes no
|
|
2987
|
+
// space and the greeting layout carries a filled one with the composer.
|
|
2988
|
+
const aboveComposer = document.createElement("slot");
|
|
2989
|
+
aboveComposer.name = "above-composer";
|
|
2990
|
+
|
|
2991
|
+
const footer = document.createElement("slot");
|
|
2992
|
+
footer.name = "footer";
|
|
2993
|
+
|
|
2994
|
+
this.#messagesWrap.className = "messages-wrap";
|
|
2995
|
+
// Sibling of the list inside a shared box, not a child of it: the
|
|
2996
|
+
// affordance offering to scroll must not scroll away with the content.
|
|
2997
|
+
this.#messagesWrap.replaceChildren(this.#messages, this.#jumpButton, this.#excerpts.button);
|
|
2998
|
+
|
|
2999
|
+
// Composer surfaces sit just above the input: the skills palette (opens on
|
|
3000
|
+
// `/`), the chips, the hint saying what the composer still needs, the
|
|
3001
|
+
// queued messages and the pending-attachments tray, then the host's own
|
|
3002
|
+
// content directly over the input.
|
|
3003
|
+
this.#chat.replaceChildren(
|
|
3004
|
+
header,
|
|
3005
|
+
this.#messagesWrap,
|
|
3006
|
+
this.#skillsMenu.palette,
|
|
3007
|
+
this.#skillsMenu.chips,
|
|
3008
|
+
this.#composerHint,
|
|
3009
|
+
this.#queuedRow,
|
|
3010
|
+
this.#attachSlot,
|
|
3011
|
+
aboveComposer,
|
|
3012
|
+
inputRow,
|
|
3013
|
+
footer,
|
|
3014
|
+
this.#drawer.element,
|
|
3015
|
+
this.#checkpoints.element,
|
|
3016
|
+
);
|
|
3017
|
+
|
|
3018
|
+
// Clicking away dismisses the checkpoints popover. Escape already did, and the
|
|
3019
|
+
// drawer has a backdrop that swallows the click — this popover has neither, so
|
|
3020
|
+
// it could only be closed by answering it.
|
|
3021
|
+
//
|
|
3022
|
+
// `pointerdown`, and the header button excluded: pointerdown runs *before* the
|
|
3023
|
+
// button's own click, so closing here and toggling there would land back open.
|
|
3024
|
+
// Composed path rather than `target`, because the event is retargeted at the
|
|
3025
|
+
// shadow boundary and every one of these nodes is inside it.
|
|
3026
|
+
this.#chat.addEventListener(
|
|
3027
|
+
"pointerdown",
|
|
3028
|
+
(event) => {
|
|
3029
|
+
if (!this.#checkpoints.open_) {
|
|
3030
|
+
return;
|
|
3031
|
+
}
|
|
3032
|
+
const path = event.composedPath();
|
|
3033
|
+
if (path.includes(this.#checkpoints.element) || path.includes(checkpoints)) {
|
|
3034
|
+
return;
|
|
3035
|
+
}
|
|
3036
|
+
this.#checkpoints.close();
|
|
3037
|
+
},
|
|
3038
|
+
{ signal },
|
|
3039
|
+
);
|
|
3040
|
+
|
|
3041
|
+
buildLauncher({
|
|
3042
|
+
launcher: this.#launcher,
|
|
3043
|
+
badge: this.#badge,
|
|
3044
|
+
railLabel: this.#railLabel,
|
|
3045
|
+
bar: this.#dockBar,
|
|
3046
|
+
barHistory: this.#barHistory,
|
|
3047
|
+
barPlace: this.#place.barElement,
|
|
3048
|
+
titleText,
|
|
3049
|
+
iconUrl: readLauncherIconUrl(this),
|
|
3050
|
+
strings: this.#strings,
|
|
3051
|
+
signal,
|
|
3052
|
+
// The bar stays on screen with the panel open, as the way to minimise
|
|
3053
|
+
// it, so its strip toggles; everywhere else the launcher is only drawn
|
|
3054
|
+
// while collapsed and only ever opens.
|
|
3055
|
+
onOpen: () => this.setCollapsed(this.#surface().collapseTo === "bar" && !this.collapsed),
|
|
3056
|
+
// The bar's history button toggles: the popover has no backdrop, and a
|
|
3057
|
+
// press on the bar is not a press outside it, so this is how the button
|
|
3058
|
+
// that opened the list closes it again. openThreads() stays open-only.
|
|
3059
|
+
onHistory: () => {
|
|
3060
|
+
if (this.#drawer.isOpen()) {
|
|
3061
|
+
this.#drawer.close();
|
|
3062
|
+
} else {
|
|
3063
|
+
this.openThreads();
|
|
3064
|
+
}
|
|
3065
|
+
},
|
|
3066
|
+
});
|
|
3067
|
+
// Neither popover has a backdrop to catch a click away, so a press outside
|
|
3068
|
+
// closes it, as Escape already does. The bar, or the list and the host's
|
|
3069
|
+
// button, are not outside: that button toggles the list itself.
|
|
3070
|
+
document.addEventListener(
|
|
3071
|
+
"pointerdown",
|
|
3072
|
+
(event) => {
|
|
3073
|
+
const away = this.#drawer.element.parentNode;
|
|
3074
|
+
if (!this.#drawer.isOpen() || (away !== this.#dockBar && away !== this.#threadsPopover)) {
|
|
3075
|
+
return;
|
|
3076
|
+
}
|
|
3077
|
+
const path = event.composedPath();
|
|
3078
|
+
const own: readonly (Node | null)[] =
|
|
3079
|
+
away === this.#dockBar ? [away] : [away, this.#threadsAnchor];
|
|
3080
|
+
if (!path.some((node) => own.includes(node as Node))) {
|
|
3081
|
+
this.#drawer.close();
|
|
3082
|
+
}
|
|
3083
|
+
},
|
|
3084
|
+
{ signal },
|
|
3085
|
+
);
|
|
3086
|
+
window.addEventListener("resize", () => this.#placeThreadsPopover(), { signal });
|
|
3087
|
+
this.#placement.enableLauncherDrag(signal);
|
|
3088
|
+
|
|
3089
|
+
this.#placement.mountResizeGrips(this.#chat);
|
|
3090
|
+
this.#builtInSheet = adoptStyles(this.#root, this.#extraStyles);
|
|
3091
|
+
const probe = this.#placement.probe;
|
|
3092
|
+
probe.className = "viewport-probe";
|
|
3093
|
+
probe.setAttribute("aria-hidden", "true");
|
|
3094
|
+
// A transition, so a token changing is an event. On the corners of a box with no
|
|
3095
|
+
// size, which draw nothing: a margin would push it out by the token again, and that
|
|
3096
|
+
// is scroll on a page or a card holding the chat. Not on the padding, which the
|
|
3097
|
+
// viewport is read from and has to stay instant.
|
|
3098
|
+
probe.style.borderRadius =
|
|
3099
|
+
"var(--_viewport-inset-top) var(--_viewport-inset-right) var(--_viewport-inset-bottom) var(--_viewport-inset-left)";
|
|
3100
|
+
probe.style.transition = "border-radius 1ms";
|
|
3101
|
+
this.#threadsPopover.className = "threads-popover";
|
|
3102
|
+
this.#root.replaceChildren(
|
|
3103
|
+
probe,
|
|
3104
|
+
this.#announcer.region,
|
|
3105
|
+
this.#chat,
|
|
3106
|
+
this.#dockBar,
|
|
3107
|
+
this.#threadsPopover,
|
|
3108
|
+
);
|
|
3109
|
+
}
|
|
3110
|
+
|
|
3111
|
+
/**
|
|
3112
|
+
* Reflect the collapsed state and the unread count on the launcher.
|
|
3113
|
+
*
|
|
3114
|
+
* The count is also the launcher's accessible name: a badge that only exists
|
|
3115
|
+
* as a coloured dot says nothing to a screen reader, and "Expand" alone would
|
|
3116
|
+
* be a lie once answers are waiting behind it.
|
|
3117
|
+
*/
|
|
3118
|
+
#syncLauncher(): void {
|
|
3119
|
+
this.#launcher.setAttribute("aria-expanded", String(!this.collapsed));
|
|
3120
|
+
const unread = this.unread;
|
|
3121
|
+
// Past 9 the exact number stops being information and starts being a
|
|
3122
|
+
// layout problem — the badge is a circle, not a field.
|
|
3123
|
+
this.#badge.textContent = unread > 9 ? "9+" : String(unread);
|
|
3124
|
+
// Only on the collapsed launcher: an open panel can now hold another
|
|
3125
|
+
// thread's unseen answers, and the launcher behind it is not where to say so.
|
|
3126
|
+
this.#badge.hidden = unread === 0 || !this.collapsed || !isUnreadBadgeEnabled(this);
|
|
3127
|
+
const label =
|
|
3128
|
+
!this.collapsed && this.#surface().collapseTo === "bar"
|
|
3129
|
+
? this.#strings.collapse
|
|
3130
|
+
: this.#badge.hidden
|
|
3131
|
+
? this.#strings.expand
|
|
3132
|
+
: fillUiString(this.#strings.expandUnread, { count: unread });
|
|
3133
|
+
this.#launcher.setAttribute("aria-label", label);
|
|
3134
|
+
this.#launcher.title = label;
|
|
3135
|
+
}
|
|
3136
|
+
|
|
3137
|
+
/** Dispatch a bubbling, composed event for the host to hear. */
|
|
3138
|
+
#emit<T>(type: string, detail: T): void {
|
|
3139
|
+
this.dispatchEvent(new CustomEvent<T>(type, { detail, bubbles: true, composed: true }));
|
|
3140
|
+
}
|
|
3141
|
+
|
|
3142
|
+
/**
|
|
3143
|
+
* Repaint the badge and tell the host the total, naming `threadIds`, the
|
|
3144
|
+
* threads whose marks changed.
|
|
3145
|
+
*
|
|
3146
|
+
* The count is kept whether or not the badge renders it, so `unread` stays
|
|
3147
|
+
* truthful for a host chrome and switching the badge on mid-session doesn't
|
|
3148
|
+
* start from a number that was never counted.
|
|
3149
|
+
*/
|
|
3150
|
+
#unreadChanged(threadIds: readonly string[]): void {
|
|
3151
|
+
this.#syncLauncher();
|
|
3152
|
+
this.#emit<UnreadDetail>(UNREAD_EVENT, { unread: this.unread, threadIds });
|
|
3153
|
+
}
|
|
3154
|
+
|
|
3155
|
+
/**
|
|
3156
|
+
* Note an answer that finished on `threadId`: read as it lands when that
|
|
3157
|
+
* thread is on screen in an expanded panel, otherwise one more unseen.
|
|
3158
|
+
*/
|
|
3159
|
+
#noteUnread(threadId: string): void {
|
|
3160
|
+
if (this.#inView(threadId)) {
|
|
3161
|
+
this.conversationStore.markRead?.(threadId);
|
|
3162
|
+
return;
|
|
3163
|
+
}
|
|
3164
|
+
this.#unreadByThread.set(threadId, (this.#unreadByThread.get(threadId) ?? 0) + 1);
|
|
3165
|
+
this.#unreadChanged([threadId]);
|
|
3166
|
+
}
|
|
3167
|
+
|
|
3168
|
+
/**
|
|
3169
|
+
* Whether `threadId` is in front of the user: drawn on screen, with no switch
|
|
3170
|
+
* away from it in flight, in an expanded panel. On screen while collapsed is
|
|
3171
|
+
* not read.
|
|
3172
|
+
*/
|
|
3173
|
+
#inView(threadId: string): boolean {
|
|
3174
|
+
return !this.collapsed && threadId === this.#shownThread && threadId === this.#history.threadId;
|
|
3175
|
+
}
|
|
3176
|
+
|
|
3177
|
+
/** Clear the marks of the thread in view, telling the store; the threads cleared. */
|
|
3178
|
+
#markSeen(): string[] {
|
|
3179
|
+
const threadId = this.#shownThread;
|
|
3180
|
+
if (threadId === null || !this.#inView(threadId) || !this.#unreadByThread.delete(threadId)) {
|
|
3181
|
+
return [];
|
|
3182
|
+
}
|
|
3183
|
+
this.conversationStore.markRead?.(threadId);
|
|
3184
|
+
return [threadId];
|
|
3185
|
+
}
|
|
3186
|
+
|
|
3187
|
+
/**
|
|
3188
|
+
* Drop the marks of `threadIds` without reading them (a deleted thread, a
|
|
3189
|
+
* principal who left). Reported when any had marks, or always when `report`.
|
|
3190
|
+
*/
|
|
3191
|
+
#forgetUnread(threadIds: readonly string[], report = false): void {
|
|
3192
|
+
const dropped = threadIds.filter((threadId) => this.#unreadByThread.delete(threadId));
|
|
3193
|
+
if (dropped.length > 0 || report) {
|
|
3194
|
+
this.#unreadChanged(dropped);
|
|
3195
|
+
}
|
|
3196
|
+
}
|
|
3197
|
+
|
|
3198
|
+
/**
|
|
3199
|
+
* Take the threads a store lists as unread that this page has not counted:
|
|
3200
|
+
* one mark each, or read at once when the thread is in view.
|
|
3201
|
+
*/
|
|
3202
|
+
#takeListedUnread(threads: readonly ThreadMeta[]): void {
|
|
3203
|
+
const marked: string[] = [];
|
|
3204
|
+
for (const { threadId, unread } of threads) {
|
|
3205
|
+
if (unread !== true || this.#unreadByThread.has(threadId)) {
|
|
3206
|
+
continue;
|
|
3207
|
+
}
|
|
3208
|
+
if (this.#inView(threadId)) {
|
|
3209
|
+
this.conversationStore.markRead?.(threadId);
|
|
3210
|
+
} else {
|
|
3211
|
+
this.#unreadByThread.set(threadId, 1);
|
|
3212
|
+
marked.push(threadId);
|
|
3213
|
+
}
|
|
3214
|
+
}
|
|
3215
|
+
if (marked.length > 0) {
|
|
3216
|
+
this.#unreadChanged(marked);
|
|
3217
|
+
}
|
|
3218
|
+
}
|
|
3219
|
+
|
|
3220
|
+
/**
|
|
3221
|
+
* Forward input changes to the skills palette and clear any stale hint.
|
|
3222
|
+
*
|
|
3223
|
+
* Typing is the answer to every hint that surface carries -- a skill short of
|
|
3224
|
+
* a field, a continuation short of its next turn -- so the keystroke that
|
|
3225
|
+
* starts answering it is the right moment to take it down.
|
|
3226
|
+
*/
|
|
3227
|
+
#onInput(): void {
|
|
3228
|
+
this.#skillsMenu.onInput(this.#input.value);
|
|
3229
|
+
this.#composerHint.hidden = true;
|
|
3230
|
+
autoGrow(this.#input);
|
|
3231
|
+
// Typing puts the composer back in the user's hands: the next ArrowUp
|
|
3232
|
+
// starts from the newest turn again rather than continuing a walk through
|
|
3233
|
+
// history the user has since edited. Needed beside the walk's own check
|
|
3234
|
+
// below, which compares text: an edit typed and then deleted leaves the
|
|
3235
|
+
// turn there unchanged, and the user has still taken the box back.
|
|
3236
|
+
this.#recallIndex = null;
|
|
3237
|
+
}
|
|
3238
|
+
|
|
3239
|
+
#onKeydown(event: KeyboardEvent): void {
|
|
3240
|
+
// The skills palette consumes arrows/enter/escape while open.
|
|
3241
|
+
if (this.#skillsMenu.onKeydown(event)) {
|
|
3242
|
+
event.preventDefault();
|
|
3243
|
+
return;
|
|
3244
|
+
}
|
|
3245
|
+
// Escape-to-cancel — only reachable when the palette is closed (it
|
|
3246
|
+
// consumed the key above otherwise), so the two Escapes don't clash.
|
|
3247
|
+
if (event.key === "Escape" && this.#running) {
|
|
3248
|
+
event.preventDefault();
|
|
3249
|
+
this.#cancelRun();
|
|
3250
|
+
return;
|
|
3251
|
+
}
|
|
3252
|
+
if (event.key === "Enter" && !event.shiftKey) {
|
|
3253
|
+
event.preventDefault();
|
|
3254
|
+
void this.#submit();
|
|
3255
|
+
return;
|
|
3256
|
+
}
|
|
3257
|
+
this.#recallHistory(event);
|
|
3258
|
+
}
|
|
3259
|
+
|
|
3260
|
+
/**
|
|
3261
|
+
* Walk back through what the user has already sent, on the arrow keys.
|
|
3262
|
+
*
|
|
3263
|
+
* Only from an empty composer, and only with the palette closed -- which the
|
|
3264
|
+
* caller has already established, since the palette consumes arrows while it
|
|
3265
|
+
* is open. Both conditions matter: arrows inside text are how you move the
|
|
3266
|
+
* caret, and taking them would break editing to add a shortcut.
|
|
3267
|
+
*
|
|
3268
|
+
* The drafts are the user's own turns in this conversation, newest first,
|
|
3269
|
+
* which is what every shell and every coding agent means by this. Arrowing
|
|
3270
|
+
* forward past the newest empties the composer again rather than sticking on
|
|
3271
|
+
* it, so the way out is the same key that got you in.
|
|
3272
|
+
*
|
|
3273
|
+
* A walk ends with any write into the composer that is not its own, so that
|
|
3274
|
+
* whatever was written is never replaced by the next step.
|
|
3275
|
+
*/
|
|
3276
|
+
#recallHistory(event: KeyboardEvent): void {
|
|
3277
|
+
const back = event.key === "ArrowUp";
|
|
3278
|
+
if ((!back && event.key !== "ArrowDown") || this.#skillsMenu.isOpen()) {
|
|
3279
|
+
return;
|
|
3280
|
+
}
|
|
3281
|
+
const drafts = this.#sentDrafts;
|
|
3282
|
+
if (drafts.length === 0) {
|
|
3283
|
+
return;
|
|
3284
|
+
}
|
|
3285
|
+
// The walk checks the composer rather than trusting every writer to end it:
|
|
3286
|
+
// a quotation, a prefill, dictation and the host page all write it too.
|
|
3287
|
+
if (this.#input.value !== this.#recalled) {
|
|
3288
|
+
this.#recallIndex = null;
|
|
3289
|
+
}
|
|
3290
|
+
// An empty composer is the only safe entry: anything typed is the user's,
|
|
3291
|
+
// and replacing it with a past turn would lose it without asking.
|
|
3292
|
+
if (this.#recallIndex === null && (!back || this.#input.value !== "")) {
|
|
3293
|
+
return;
|
|
3294
|
+
}
|
|
3295
|
+
const next = this.#recallIndex === null ? 0 : this.#recallIndex + (back ? 1 : -1);
|
|
3296
|
+
if (next >= drafts.length) {
|
|
3297
|
+
return;
|
|
3298
|
+
}
|
|
3299
|
+
event.preventDefault();
|
|
3300
|
+
this.#recallIndex = next < 0 ? null : next;
|
|
3301
|
+
// Asserted: `next` is bounded on both sides above, so a default is unreachable.
|
|
3302
|
+
this.#input.value = next < 0 ? "" : (drafts[next] as string);
|
|
3303
|
+
// Read back rather than taken from the draft, so a value the textarea
|
|
3304
|
+
// normalises on the way in -- its line endings -- still reads as the
|
|
3305
|
+
// walk's own on the next step instead of ending it.
|
|
3306
|
+
this.#recalled = this.#input.value;
|
|
3307
|
+
this.#input.setSelectionRange(this.#input.value.length, this.#input.value.length);
|
|
3308
|
+
autoGrow(this.#input);
|
|
3309
|
+
}
|
|
3310
|
+
|
|
3311
|
+
/**
|
|
3312
|
+
* Stop the in-flight run: decline any confirmation card awaiting a decision
|
|
3313
|
+
* (the loop is suspended on it), then cancel the client run — the abort
|
|
3314
|
+
* closes the streaming request, which is AG-UI's cancel (the server
|
|
3315
|
+
* observes the disconnect).
|
|
3316
|
+
*/
|
|
3317
|
+
#cancelRun(): void {
|
|
3318
|
+
// Stopping discards what was waiting, and loses nothing: a queued turn
|
|
3319
|
+
// entered the recall history as it left the composer. Also reached from
|
|
3320
|
+
// disconnectedCallback, where a DOM move is not a farewell either.
|
|
3321
|
+
this.#queued.length = 0;
|
|
3322
|
+
this.#renderQueued();
|
|
3323
|
+
this.#decision.abort();
|
|
3324
|
+
this.#client?.cancel();
|
|
3325
|
+
// A send whose run has not started is ended here too, because nothing else
|
|
3326
|
+
// can reach it: the cancel above lands on a client that has not been asked
|
|
3327
|
+
// for the run yet, and a client forgets a Stop when it is next asked for one.
|
|
3328
|
+
// This is how a submit listener that starts a new chat, switches thread or
|
|
3329
|
+
// moves the element stops the send it is hearing about.
|
|
3330
|
+
this.#sending = null;
|
|
3331
|
+
// A checkpoint continuation is as much the run in flight -- the composer
|
|
3332
|
+
// offers Stop for it -- but it runs on a client the element does not hold.
|
|
3333
|
+
this.#history.stopContinuation();
|
|
3334
|
+
}
|
|
3335
|
+
|
|
3336
|
+
/**
|
|
3337
|
+
* Say the conversation on screen changed: {@link THREAD_EVENT}.
|
|
3338
|
+
*
|
|
3339
|
+
* The thread it replaced is the one last reported, rather than the one the
|
|
3340
|
+
* element last pointed at: a switch superseded before it replayed never drew
|
|
3341
|
+
* its thread.
|
|
3342
|
+
*/
|
|
3343
|
+
#dispatchThread(threadId: string, reason: ThreadDetail["reason"]): void {
|
|
3344
|
+
const detail: ThreadDetail = { threadId, previousThreadId: this.#shownThread, reason };
|
|
3345
|
+
this.#shownThread = threadId;
|
|
3346
|
+
const seen = this.#markSeen();
|
|
3347
|
+
if (seen.length > 0) {
|
|
3348
|
+
this.#unreadChanged(seen);
|
|
3349
|
+
}
|
|
3350
|
+
// The chip always names the live page, whichever conversation is on screen.
|
|
3351
|
+
this.refreshPlace();
|
|
3352
|
+
this.#titleMessages = this.#history.restored;
|
|
3353
|
+
// Settled before the thread event so its listeners read the new title,
|
|
3354
|
+
// and announced after it, as the change that follows from the switch.
|
|
3355
|
+
const retitled = this.#settleTitle();
|
|
3356
|
+
this.#emit<ThreadDetail>(THREAD_EVENT, detail);
|
|
3357
|
+
if (retitled) {
|
|
3358
|
+
this.#announceTitle();
|
|
3359
|
+
}
|
|
3360
|
+
// A picked thread may carry a title its messages do not derive (renamed
|
|
3361
|
+
// elsewhere, or named by a server). On restore only from the element's own
|
|
3362
|
+
// sessionStorage store, where the read is local: a remote one is a GET per page load.
|
|
3363
|
+
if (
|
|
3364
|
+
reason === "switch" ||
|
|
3365
|
+
(reason === "restore" && this.conversationStore instanceof SessionStorageStore)
|
|
3366
|
+
) {
|
|
3367
|
+
void this.#history.refreshDrawer();
|
|
3368
|
+
}
|
|
3369
|
+
}
|
|
3370
|
+
|
|
3371
|
+
/** The header and rail text: the fixed `title-text`, or the thread's under `"auto"`. */
|
|
3372
|
+
#titleText(): string {
|
|
3373
|
+
// `#strings` is the resolved table once connected, the English defaults before then.
|
|
3374
|
+
const fixed = this.getAttribute("title-text");
|
|
3375
|
+
if (fixed !== TITLE_TEXT_AUTO) {
|
|
3376
|
+
return fixed ?? this.#strings.title;
|
|
3377
|
+
}
|
|
3378
|
+
return this.#titled.title === "" ? this.#strings.title : this.#titled.title;
|
|
3379
|
+
}
|
|
3380
|
+
|
|
3381
|
+
#paintTitle(): void {
|
|
3382
|
+
this.#title.textContent = this.#titleText();
|
|
3383
|
+
this.#railLabel.textContent = this.#title.textContent;
|
|
3384
|
+
}
|
|
3385
|
+
|
|
3386
|
+
/**
|
|
3387
|
+
* Recompute {@link threadTitle} and repaint; `true` when it changed. A new
|
|
3388
|
+
* thread counts as a change unless both titles are empty, so a switch
|
|
3389
|
+
* between two same-named threads is still reported.
|
|
3390
|
+
*/
|
|
3391
|
+
#settleTitle(): boolean {
|
|
3392
|
+
const threadId = this.#history.threadId;
|
|
3393
|
+
const title =
|
|
3394
|
+
this.#givenTitles.get(threadId) ??
|
|
3395
|
+
threadTitleFrom(this.#titleMessages, (name) => this.#skills.title(name));
|
|
3396
|
+
const previous = this.#titled;
|
|
3397
|
+
if (title === previous.title && (threadId === previous.threadId || title === "")) {
|
|
3398
|
+
return false;
|
|
3399
|
+
}
|
|
3400
|
+
this.#titled = { threadId, title };
|
|
3401
|
+
this.#paintTitle();
|
|
3402
|
+
return true;
|
|
3403
|
+
}
|
|
3404
|
+
|
|
3405
|
+
#announceTitle(): void {
|
|
3406
|
+
this.#emit<ThreadTitleDetail>(THREAD_TITLE_EVENT, { ...this.#titled });
|
|
3407
|
+
}
|
|
3408
|
+
|
|
3409
|
+
/** {@link #settleTitle}, announcing the change if there was one. */
|
|
3410
|
+
#retitle(): void {
|
|
3411
|
+
if (this.#settleTitle()) {
|
|
3412
|
+
this.#announceTitle();
|
|
3413
|
+
}
|
|
3414
|
+
}
|
|
3415
|
+
|
|
3416
|
+
/**
|
|
3417
|
+
* Keep the list's title for the thread on screen when it is a rename or a title
|
|
3418
|
+
* the server chose. A server's own derivation of the first message (cut at 60
|
|
3419
|
+
* code points) or its placeholder is not one: the element's own title stays.
|
|
3420
|
+
*/
|
|
3421
|
+
#takeListedTitle(threads: readonly ThreadMeta[]): void {
|
|
3422
|
+
const threadId = this.#history.threadId;
|
|
3423
|
+
const row = threads.find((thread) => thread.threadId === threadId);
|
|
3424
|
+
if (row === undefined || this.#titleMessages.length === 0) {
|
|
3425
|
+
return;
|
|
3426
|
+
}
|
|
3427
|
+
const derived = [
|
|
3428
|
+
threadTitleFrom(this.#titleMessages),
|
|
3429
|
+
serverTitleFrom(this.#titleMessages),
|
|
3430
|
+
SERVER_PLACEHOLDER_TITLE,
|
|
3431
|
+
];
|
|
3432
|
+
if (derived.includes(row.title)) {
|
|
3433
|
+
return;
|
|
3434
|
+
}
|
|
3435
|
+
this.#givenTitles.set(threadId, row.title);
|
|
3436
|
+
this.#retitle();
|
|
3437
|
+
}
|
|
3438
|
+
|
|
3439
|
+
/**
|
|
3440
|
+
* Swap the composer button between Send (idle) and Stop (running).
|
|
3441
|
+
*
|
|
3442
|
+
* The glyph is swapped by CSS from `data-state` — both are mounted — so this
|
|
3443
|
+
* only has to move the accessible name, which is the button's whole label now
|
|
3444
|
+
* that it carries no text.
|
|
3445
|
+
*/
|
|
3446
|
+
#setRunning(running: boolean): void {
|
|
3447
|
+
const settled = this.#running && !running;
|
|
3448
|
+
this.#running = running;
|
|
3449
|
+
// The send that asked for this run is covered from here until it settles,
|
|
3450
|
+
// so its hold ends now, and a host's follow-up from run-finished is heard.
|
|
3451
|
+
if (running) {
|
|
3452
|
+
this.#sending = null;
|
|
3453
|
+
}
|
|
3454
|
+
const label = running ? this.#strings.stop : this.#strings.send;
|
|
3455
|
+
this.#send.title = label;
|
|
3456
|
+
this.#send.setAttribute("aria-label", label);
|
|
3457
|
+
this.#send.dataset["state"] = running ? "running" : "idle";
|
|
3458
|
+
// For a host's CSS, as `data-empty` is: the same state, on the element.
|
|
3459
|
+
this.toggleAttribute("data-running", running);
|
|
3460
|
+
if (settled) {
|
|
3461
|
+
this.#flushQueued();
|
|
3462
|
+
}
|
|
3463
|
+
}
|
|
3464
|
+
|
|
3465
|
+
/** Let go of `hold` if it is still the one held, and send what queued behind it. */
|
|
3466
|
+
#releaseHold(hold: object): void {
|
|
3467
|
+
if (this.#sending === hold) {
|
|
3468
|
+
this.#sending = null;
|
|
3469
|
+
this.#flushQueued();
|
|
3470
|
+
}
|
|
3471
|
+
}
|
|
3472
|
+
|
|
3473
|
+
/** Whether a run, a send or retry held short of its run, or a continuation is in flight. */
|
|
3474
|
+
#busy(): boolean {
|
|
3475
|
+
return this.#running || this.#sending !== null || this.#history.continuation !== null;
|
|
3476
|
+
}
|
|
3477
|
+
|
|
3478
|
+
/**
|
|
3479
|
+
* Send the next message that was typed while the run was going.
|
|
3480
|
+
*
|
|
3481
|
+
* One at a time, through the same path as anything else: each queued turn
|
|
3482
|
+
* starts a run of its own, and the next is sent when *that* one settles. Any
|
|
3483
|
+
* other shape would be a second sender racing the guard above.
|
|
3484
|
+
*/
|
|
3485
|
+
#flushQueued(): void {
|
|
3486
|
+
// Guarded here rather than at each caller, because this is what takes the
|
|
3487
|
+
// turn off the queue: a caller that ran while something was still in
|
|
3488
|
+
// flight would shift a turn off and lose it to the no-op in `sendMessage`.
|
|
3489
|
+
// A continuation settles its run before the element lets go of it, so the
|
|
3490
|
+
// settle below arrives while `continuation` is still set, and the release
|
|
3491
|
+
// that follows is what drains.
|
|
3492
|
+
if (this.#running || this.#history.continuation !== null) {
|
|
3493
|
+
return;
|
|
3494
|
+
}
|
|
3495
|
+
const next = this.#queued.shift();
|
|
3496
|
+
this.#renderQueued();
|
|
3497
|
+
if (next !== undefined) {
|
|
3498
|
+
void this.sendMessage(next);
|
|
3499
|
+
}
|
|
3500
|
+
}
|
|
3501
|
+
|
|
3502
|
+
/**
|
|
3503
|
+
* Draw what is waiting, as chips that can be taken back.
|
|
3504
|
+
*
|
|
3505
|
+
* Visible and removable, because a message the user typed and cannot see is
|
|
3506
|
+
* a message they will type again -- and one they changed their mind about
|
|
3507
|
+
* has to be retractable before it is sent on their behalf.
|
|
3508
|
+
*/
|
|
3509
|
+
#renderQueued(): void {
|
|
3510
|
+
this.#queuedRow.replaceChildren();
|
|
3511
|
+
this.#queuedRow.hidden = this.#queued.length === 0;
|
|
3512
|
+
for (const [index, text] of this.#queued.entries()) {
|
|
3513
|
+
const chip = document.createElement("button");
|
|
3514
|
+
chip.type = "button";
|
|
3515
|
+
chip.className = "queued-chip";
|
|
3516
|
+
chip.setAttribute("part", "queued-chip");
|
|
3517
|
+
chip.textContent = text;
|
|
3518
|
+
chip.title = fillUiString(this.#strings.removeQueued, { text });
|
|
3519
|
+
chip.setAttribute("aria-label", chip.title);
|
|
3520
|
+
chip.addEventListener("click", () => {
|
|
3521
|
+
this.#queued.splice(index, 1);
|
|
3522
|
+
this.#renderQueued();
|
|
3523
|
+
});
|
|
3524
|
+
this.#queuedRow.appendChild(chip);
|
|
3525
|
+
}
|
|
3526
|
+
}
|
|
3527
|
+
|
|
3528
|
+
/**
|
|
3529
|
+
* Put a turn that has left the composer at the front of the recall history,
|
|
3530
|
+
* and start the next walk from it.
|
|
3531
|
+
*
|
|
3532
|
+
* Every turn the user typed and pressed a key to send comes through here:
|
|
3533
|
+
* sent at once, queued behind a run, or sent to continue a checkpoint. A
|
|
3534
|
+
* host's own `sendMessage` does not, because its text was never in the
|
|
3535
|
+
* composer, and the arrow keys walk back through what the user wrote there.
|
|
3536
|
+
*/
|
|
3537
|
+
#recordTurn(content: string): void {
|
|
3538
|
+
// A repeat of the last one is not a second entry: the point is to reach
|
|
3539
|
+
// what was said, not how often. Nothing is recorded for an attachment sent
|
|
3540
|
+
// with no text, which has nothing for the composer to hold.
|
|
3541
|
+
if (content !== "" && this.#sentDrafts[0] !== content) {
|
|
3542
|
+
this.#sentDrafts.unshift(content);
|
|
3543
|
+
}
|
|
3544
|
+
// A press sends without an input event, which is what ends a walk
|
|
3545
|
+
// otherwise. Left where it was, the next ArrowUp stepped past the turn just
|
|
3546
|
+
// taken, or past whichever one now sits where it had walked to.
|
|
3547
|
+
this.#recallIndex = null;
|
|
3548
|
+
}
|
|
3549
|
+
|
|
3550
|
+
async #submit(): Promise<void> {
|
|
3551
|
+
const content = this.#input.value.trim();
|
|
3552
|
+
const attachments = this.#attachments.tray?.readyRefs() ?? [];
|
|
3553
|
+
// Allow an attachments-only message (no typed text), but nothing empty.
|
|
3554
|
+
if (content === "" && attachments.length === 0) {
|
|
3555
|
+
return;
|
|
3556
|
+
}
|
|
3557
|
+
// The single choke point for Enter and Send. A second run cannot start
|
|
3558
|
+
// while a run, a held send or a picked checkpoint is in flight: it would
|
|
3559
|
+
// orphan the first, which is unabortable, and corrupt its pending tool
|
|
3560
|
+
// cards. So the turn queues. Text only: an attachment is the tray's state,
|
|
3561
|
+
// with no second copy to park. Recorded for recall first, since it leaves
|
|
3562
|
+
// the box either way and the queue sends it through `sendMessage`, which
|
|
3563
|
+
// records nothing.
|
|
3564
|
+
this.#recordTurn(content);
|
|
3565
|
+
if (this.#busy()) {
|
|
3566
|
+
if (content !== "") {
|
|
3567
|
+
this.#queued.push(content);
|
|
3568
|
+
this.#renderQueued();
|
|
3569
|
+
this.#input.value = "";
|
|
3570
|
+
autoGrow(this.#input);
|
|
3571
|
+
}
|
|
3572
|
+
return;
|
|
3573
|
+
}
|
|
3574
|
+
this.#input.value = "";
|
|
3575
|
+
autoGrow(this.#input);
|
|
3576
|
+
// A file still uploading does not ride along (`readyRefs()` returns only
|
|
3577
|
+
// settled ones), so say so while `hasPending()` still describes this send.
|
|
3578
|
+
if (this.#attachments.tray?.hasPending() === true) {
|
|
3579
|
+
this.#transcript.appendNotice(
|
|
3580
|
+
"\u{1F4CE}",
|
|
3581
|
+
fillUiString(this.#strings.attachmentsStillUploading, {
|
|
3582
|
+
n: this.#attachments.tray.pendingCount(),
|
|
3583
|
+
}),
|
|
3584
|
+
"attachment-pending",
|
|
3585
|
+
);
|
|
3586
|
+
}
|
|
3587
|
+
// The refs ride the message from here; drop the settled chips, keep any
|
|
3588
|
+
// still uploading for a follow-up message.
|
|
3589
|
+
this.#attachments.tray?.clearReady();
|
|
3590
|
+
await this.sendMessage(content, attachments);
|
|
3591
|
+
}
|
|
3592
|
+
|
|
3593
|
+
/**
|
|
3594
|
+
* Send a message as if the user had typed it — renders the user bubble,
|
|
3595
|
+
* dispatches {@link SUBMIT_EVENT}, and starts the run.
|
|
3596
|
+
*
|
|
3597
|
+
* The programmatic half of the composer, for a host driving its own input.
|
|
3598
|
+
* Everything the built-in Send does happens here; Send reads the composer,
|
|
3599
|
+
* clears it, and calls this.
|
|
3600
|
+
*
|
|
3601
|
+
* `attachments` are durable {@link AttachmentRef}s — what {@link attachFile}
|
|
3602
|
+
* resolves to and what {@link ATTACHMENT_EVENT} reports.
|
|
3603
|
+
*
|
|
3604
|
+
* Resolves `true` once the turn has been handed to the client for its run,
|
|
3605
|
+
* which is when the interaction settles, frontend-tool rounds included; a
|
|
3606
|
+
* run a tool or context provider then stops still counts, as it does for
|
|
3607
|
+
* {@link retryLastTurn}. Resolves `false` when nothing was sent.
|
|
3608
|
+
*
|
|
3609
|
+
* Nothing is sent for an empty message, nor while a run or a picked
|
|
3610
|
+
* checkpoint's continuation is in flight, since a second concurrent run
|
|
3611
|
+
* would orphan the first. A send is in flight from the moment it is taken
|
|
3612
|
+
* rather than from its run's start, so nothing is sent from a
|
|
3613
|
+
* {@link SUBMIT_EVENT} listener or for a second call in the same task
|
|
3614
|
+
* either. Nor with no `endpoint`, which says so in the transcript. Nor
|
|
3615
|
+
* behind a reload's restore that will resume a run a navigating tool
|
|
3616
|
+
* interrupted, since that restore ends by starting the run.
|
|
3617
|
+
*
|
|
3618
|
+
* **While the element restores a stored conversation with no run to resume
|
|
3619
|
+
* -- on connect, {@link reload}, a thread picked from the drawer, a change
|
|
3620
|
+
* from one `user-key` to another -- it waits, then sends.** The turn has to follow the
|
|
3621
|
+
* conversation it is part of: sent at once, it went out without it, the
|
|
3622
|
+
* conversation was then drawn under it, and its save replaced the stored
|
|
3623
|
+
* one. So the bubble, the {@link SUBMIT_EVENT} and the request all come after
|
|
3624
|
+
* the replay, by the length of the store's load. A New chat, a thread
|
|
3625
|
+
* switch, a change from one `user-key` to another, a reload or the element
|
|
3626
|
+
* leaving the page while it waits drops it, resolving `false`: the
|
|
3627
|
+
* conversation it was addressed to is no longer on screen. Leaving the page
|
|
3628
|
+
* resolves it only once the load answers or the element is back, since
|
|
3629
|
+
* that is when the restore learns it was stopped. A first `user-key` on an
|
|
3630
|
+
* anonymous conversation adopts it rather than replacing it, and stops
|
|
3631
|
+
* nothing. A second call while one waits is
|
|
3632
|
+
* refused, as a second call in the same task is.
|
|
3633
|
+
*
|
|
3634
|
+
* Unlike the built-in Send it does not queue: it returns, and the caller
|
|
3635
|
+
* keeps what it tried to send. Nor does it consult the tray -- what you pass
|
|
3636
|
+
* is what is sent.
|
|
3637
|
+
*/
|
|
3638
|
+
async sendMessage(content: string, attachments: readonly AttachmentRef[] = []): Promise<boolean> {
|
|
3639
|
+
if (content === "" && attachments.length === 0) {
|
|
3640
|
+
return false;
|
|
3641
|
+
}
|
|
3642
|
+
const restore = this.#restoreHold;
|
|
3643
|
+
if (restore !== null && this.#sending === restore.hold) {
|
|
3644
|
+
// Taken over from the restore, so a second host call is refused, the
|
|
3645
|
+
// built-in Send parks behind this one rather than going first, and the
|
|
3646
|
+
// restore's own release when it ends lets go of nothing -- this send's
|
|
3647
|
+
// run settling, or its end, is what sends the parked turn.
|
|
3648
|
+
const waiting = {};
|
|
3649
|
+
this.#sending = waiting;
|
|
3650
|
+
await restore.restored;
|
|
3651
|
+
// Anything that stopped the restore let go of this too, by the Stop
|
|
3652
|
+
// that New chat, a thread switch, a principal handover, a reload and the
|
|
3653
|
+
// element leaving all reach. Otherwise let go of here and the guards
|
|
3654
|
+
// asked again from the top, as for a call made now. Held by "drops the
|
|
3655
|
+
// waiting send and the queued turn when New chat is pressed".
|
|
3656
|
+
if (this.#sending !== waiting) {
|
|
3657
|
+
return false;
|
|
3658
|
+
}
|
|
3659
|
+
this.#sending = null;
|
|
3660
|
+
return this.sendMessage(content, attachments);
|
|
3661
|
+
}
|
|
3662
|
+
if (this.#busy()) {
|
|
3663
|
+
return false;
|
|
3664
|
+
}
|
|
3665
|
+
const send = {};
|
|
3666
|
+
this.#sending = send;
|
|
3667
|
+
try {
|
|
3668
|
+
// Only a send travels. Every other way out of the empty state -- a
|
|
3669
|
+
// restored transcript, a thread picked from the drawer, a resumed
|
|
3670
|
+
// checkpoint -- is a change of context rather than a continuation of what
|
|
3671
|
+
// the user was doing, and snaps. Armed before the bubble lands so both
|
|
3672
|
+
// writes reach the same style recalculation, which is what makes the
|
|
3673
|
+
// change a transition rather than a jump.
|
|
3674
|
+
if (this.#transcript.isEmpty()) {
|
|
3675
|
+
this.setAttribute("data-composer-settling", "");
|
|
3676
|
+
}
|
|
3677
|
+
// Taken before the announcement, because its listeners run here: a host's
|
|
3678
|
+
// own send from one is refused as overlapping this one, as it would be
|
|
3679
|
+
// from any later moment of it.
|
|
3680
|
+
this.#announceTurn(content, attachments);
|
|
3681
|
+
// A listener can also have stopped it -- New chat, a thread switch, a
|
|
3682
|
+
// change of principal, moving the element. All of them reach the Stop,
|
|
3683
|
+
// which let go of this send, and the turn would otherwise go out into the
|
|
3684
|
+
// conversation that replaced it, or from an element nothing can stop.
|
|
3685
|
+
if (this.#sending !== send) {
|
|
3686
|
+
return false;
|
|
3687
|
+
}
|
|
3688
|
+
// Read where the request is made, which says why nothing was sent when
|
|
3689
|
+
// there is no endpoint to send to.
|
|
3690
|
+
const connected = this.endpoint !== "";
|
|
3691
|
+
await this.#client_send(content, attachments);
|
|
3692
|
+
return connected;
|
|
3693
|
+
} finally {
|
|
3694
|
+
// Still held here only if its run never started: no endpoint, or a
|
|
3695
|
+
// client or store that threw before asking for one. No run settles
|
|
3696
|
+
// then, so this is what sends a turn queued behind it. Not released if
|
|
3697
|
+
// it is no longer this send's: the run starting let go of it, or a Stop
|
|
3698
|
+
// did, and the field may hold the next send by now.
|
|
3699
|
+
this.#releaseHold(send);
|
|
3700
|
+
}
|
|
3701
|
+
}
|
|
3702
|
+
|
|
3703
|
+
/**
|
|
3704
|
+
* Put a turn the user sent on screen, and tell the host it was sent.
|
|
3705
|
+
*
|
|
3706
|
+
* The half of a send that does not depend on where it goes, so both senders
|
|
3707
|
+
* take it from here: {@link sendMessage}, and a checkpoint continuation, which
|
|
3708
|
+
* posts to the resume or fork endpoint on a client of its own.
|
|
3709
|
+
*
|
|
3710
|
+
* Arming the composer's travel stays with {@link sendMessage}: continuing a
|
|
3711
|
+
* run is a change of context, and docks at once.
|
|
3712
|
+
*/
|
|
3713
|
+
#announceTurn(content: string, attachments: readonly AttachmentRef[]): void {
|
|
3714
|
+
const bubble = this.appendMessage(MESSAGE_ROLE.USER, content);
|
|
3715
|
+
if (attachments.length > 0) {
|
|
3716
|
+
bubble.appendChild(renderAttachmentChips(attachments));
|
|
3717
|
+
}
|
|
3718
|
+
this.#emit<SubmitDetail>(SUBMIT_EVENT, { content, attachments });
|
|
3719
|
+
}
|
|
3720
|
+
|
|
3721
|
+
/**
|
|
3722
|
+
* Queue a file for upload into the attachment tray, exactly as the file
|
|
3723
|
+
* picker and drag-and-drop do — validation, progress chip, and all.
|
|
3724
|
+
*
|
|
3725
|
+
* Returns `false` when uploads are not configured (no `data-attachments-url`
|
|
3726
|
+
* and no {@link uploadHandler}) — the only signal a host gets, since the tray
|
|
3727
|
+
* does not exist to report anything then.
|
|
3728
|
+
*
|
|
3729
|
+
* Uploading is asynchronous: watch {@link ATTACHMENT_EVENT} for the resulting
|
|
3730
|
+
* {@link AttachmentRef} and pass it to {@link sendMessage} once `pending`
|
|
3731
|
+
* reaches zero.
|
|
3732
|
+
*/
|
|
3733
|
+
attachFile(file: File): boolean {
|
|
3734
|
+
return this.#attachments.attach(file);
|
|
3735
|
+
}
|
|
3736
|
+
|
|
3737
|
+
/**
|
|
3738
|
+
* Hand the message to the client, or say why it is going nowhere.
|
|
3739
|
+
*
|
|
3740
|
+
* With no `endpoint`, the bubble and {@link SUBMIT_EVENT} have already
|
|
3741
|
+
* happened, so the send looks made. The console tells the developer which
|
|
3742
|
+
* attribute is missing and the transcript tells the reader, on every
|
|
3743
|
+
* attempt: a once-only report is silence for each one after.
|
|
3744
|
+
*/
|
|
3745
|
+
async #client_send(content: string, attachments: readonly AttachmentRef[]): Promise<void> {
|
|
3746
|
+
if (this.endpoint === "") {
|
|
3747
|
+
console.error(
|
|
3748
|
+
"<ag-ui-chat>: no endpoint is set, so this message was not sent and no " +
|
|
3749
|
+
"request was made. Point the element at your AG-UI mount with the " +
|
|
3750
|
+
'endpoint attribute (endpoint="/agent/"), or assign chat.endpoint ' +
|
|
3751
|
+
"before sending.",
|
|
3752
|
+
);
|
|
3753
|
+
this.#transcript.appendNotice("⚠", this.#strings.notConnected, "not-connected");
|
|
3754
|
+
return;
|
|
3755
|
+
}
|
|
3756
|
+
await this.#ensureClient().send(content, attachments);
|
|
3757
|
+
}
|
|
3758
|
+
|
|
3759
|
+
#ensureClient(): AgUiClient {
|
|
3760
|
+
if (this.#client === null) {
|
|
3761
|
+
this.#client = this.#buildClient({
|
|
3762
|
+
endpoint: this.endpoint,
|
|
3763
|
+
initialMessages: this.#history.restored,
|
|
3764
|
+
// Its history is the whole conversation, so nothing goes ahead of it.
|
|
3765
|
+
follows: [],
|
|
3766
|
+
});
|
|
3767
|
+
}
|
|
3768
|
+
return this.#client;
|
|
3769
|
+
}
|
|
3770
|
+
|
|
3771
|
+
/**
|
|
3772
|
+
* Build a client for the conversation on screen: the one construction shared
|
|
3773
|
+
* by the conversation's own client and every checkpoint continuation.
|
|
3774
|
+
*
|
|
3775
|
+
* One construction, so the two cannot drift apart; what differs is the seed.
|
|
3776
|
+
*/
|
|
3777
|
+
#buildClient(seed: ClientSeed): AgUiClient {
|
|
3778
|
+
// Fixed when the client is built: a stopped run saves once its request
|
|
3779
|
+
// closes, and read live that save would land under New chat's thread.
|
|
3780
|
+
const threadId = this.#history.threadId;
|
|
3781
|
+
// Fixed for the same reason, and checked by every write below: see #tenure.
|
|
3782
|
+
// The thread id alone cannot do it, because the store is still read live --
|
|
3783
|
+
// it has to be, since an adoption rescopes it under a run that keeps going.
|
|
3784
|
+
const tenure = this.#tenure;
|
|
3785
|
+
const agent = this.agentFactory({
|
|
3786
|
+
endpoint: seed.endpoint,
|
|
3787
|
+
headers: this.#requestHeaders(),
|
|
3788
|
+
// Live getter: the client is built once and cached, but a rotated
|
|
3789
|
+
// token must still reach every request — the factory's fetch wrapper
|
|
3790
|
+
// re-reads this on each call.
|
|
3791
|
+
getHeaders: () => this.#requestHeaders(),
|
|
3792
|
+
trustedOrigins: this.trustedOrigins,
|
|
3793
|
+
...this.#credentialsOption(),
|
|
3794
|
+
threadId,
|
|
3795
|
+
initialMessages: seed.initialMessages,
|
|
3796
|
+
initialState: this.#sharedState,
|
|
3797
|
+
});
|
|
3798
|
+
return new AgUiClient({
|
|
3799
|
+
agent,
|
|
3800
|
+
handlers: this.#runHandlers.forClient(threadId),
|
|
3801
|
+
// The place is read here because the client asks for the tools first,
|
|
3802
|
+
// so the round's tools and context answer from the same read.
|
|
3803
|
+
getTools: () => {
|
|
3804
|
+
this.refreshPlace();
|
|
3805
|
+
return this.#tools.advertise();
|
|
3806
|
+
},
|
|
3807
|
+
getContext: () => this.#dispatch.buildContext(),
|
|
3808
|
+
executeTool: (call) => this.#dispatch.execute(call),
|
|
3809
|
+
resolveInterrupts: (interrupts) => this.#dispatch.resolveInterrupts(interrupts),
|
|
3810
|
+
// Every client saves the whole conversation, since a store keeps one list
|
|
3811
|
+
// per thread: a continuation writes the one it continues ahead of its own.
|
|
3812
|
+
// Every save reaches the store through here, so this is the one tenure
|
|
3813
|
+
// gate for them, `onSaved` (the next client's seed) included.
|
|
3814
|
+
onPersist: (messages) => {
|
|
3815
|
+
if (tenure !== this.#tenure) {
|
|
3816
|
+
return;
|
|
3817
|
+
}
|
|
3818
|
+
const conversation = [...seed.follows, ...messages];
|
|
3819
|
+
this.conversationStore.saveMessages(threadId, conversation);
|
|
3820
|
+
if (threadId === this.#history.threadId) {
|
|
3821
|
+
this.#titleMessages = conversation;
|
|
3822
|
+
this.#retitle();
|
|
3823
|
+
}
|
|
3824
|
+
seed.onSaved?.(conversation);
|
|
3825
|
+
},
|
|
3826
|
+
// Shared state is the next principal's first `RunAgentInput.state`, and
|
|
3827
|
+
// the handover has just emptied it. A snapshot already read off the wire
|
|
3828
|
+
// is applied after the Stop, and would put it back.
|
|
3829
|
+
onStateChanged: (state) => {
|
|
3830
|
+
if (tenure === this.#tenure) {
|
|
3831
|
+
this.#onSharedStateChanged(state);
|
|
3832
|
+
}
|
|
3833
|
+
},
|
|
3834
|
+
connectionLostMessage: this.#strings.connectionLost,
|
|
3835
|
+
unfinishedMessage: this.#strings.callNotFinished,
|
|
3836
|
+
declinedMessage: this.#strings.declinedAction,
|
|
3837
|
+
maxToolRounds: readMaxToolRounds(this),
|
|
3838
|
+
});
|
|
3839
|
+
}
|
|
3840
|
+
|
|
3841
|
+
/** Mirror the agent's applied state and tell the host it moved. */
|
|
3842
|
+
#onSharedStateChanged(state: Readonly<Record<string, unknown>>): void {
|
|
3843
|
+
this.#sharedState = { ...state };
|
|
3844
|
+
this.#emit<StateDetail>(STATE_EVENT, { state: this.#sharedState });
|
|
3845
|
+
}
|
|
3846
|
+
|
|
3847
|
+
/**
|
|
3848
|
+
* Say that the agent rearranged the user's window, and offer the way back.
|
|
3849
|
+
*
|
|
3850
|
+
* Only on the agent's path. A host calling {@link moveTo} is arranging its
|
|
3851
|
+
* own page and does not need telling what it just did; an agent doing it
|
|
3852
|
+
* mid-conversation is the case where a panel appears to move on its own.
|
|
3853
|
+
*/
|
|
3854
|
+
#announceSurfaceChange(text: string, undo: (() => void) | null): void {
|
|
3855
|
+
this.#transcript.appendNotice(
|
|
3856
|
+
"⤢",
|
|
3857
|
+
text,
|
|
3858
|
+
"surface",
|
|
3859
|
+
undo === null ? undefined : { label: this.#strings.undo, onActivate: undo },
|
|
3860
|
+
);
|
|
3861
|
+
}
|
|
3862
|
+
|
|
3863
|
+
/**
|
|
3864
|
+
* Turn on chart rendering, by whichever route this consumer wants.
|
|
3865
|
+
*
|
|
3866
|
+
* Both routes converge on one renderer deliberately. Built apart they become
|
|
3867
|
+
* two chart implementations with two sets of bugs, and the choice between them
|
|
3868
|
+
* is about *where the data lives* rather than how a bar should look:
|
|
3869
|
+
*
|
|
3870
|
+
* - `"tool"` registers the built-in `render_chart`. The agent decides a chart
|
|
3871
|
+
* helps and calls it, so the numbers are in its context and it can discuss
|
|
3872
|
+
* them afterwards. Costs one model round, and works over any transport.
|
|
3873
|
+
* - `"activity"` draws a server-pushed `ACTIVITY_SNAPSHOT` of type `chart`.
|
|
3874
|
+
* No round trip, and the data never enters the model's context at all —
|
|
3875
|
+
* which is what makes it the one for a large or sensitive dataset. Only this
|
|
3876
|
+
* route can update a chart in place as the server computes.
|
|
3877
|
+
*
|
|
3878
|
+
* Off unless asked for, both of them: a component that renders whatever
|
|
3879
|
+
* arrives is not something to switch on for everybody.
|
|
3880
|
+
*/
|
|
3881
|
+
enableCharts(routes: readonly ("tool" | "activity")[] = ["tool", "activity"]): void {
|
|
3882
|
+
const first = !this.#activities.has(CHART_ACTIVITY_TYPE) && !this.#tools.has(CHART_TOOL_NAME);
|
|
3883
|
+
if (routes.includes("activity")) {
|
|
3884
|
+
// The chart is a registration like any host's, not a privileged branch.
|
|
3885
|
+
// If the built-in cannot be expressed through the seam, the seam is not
|
|
3886
|
+
// one -- so this is the test as much as the feature.
|
|
3887
|
+
this.registerActivityRenderer({
|
|
3888
|
+
type: CHART_ACTIVITY_TYPE,
|
|
3889
|
+
render: (content) => {
|
|
3890
|
+
const spec = chartSpecFrom(content);
|
|
3891
|
+
return spec === null ? null : renderChart(spec);
|
|
3892
|
+
},
|
|
3893
|
+
removedNotice: this.#strings.chartUndrawable,
|
|
3894
|
+
});
|
|
3895
|
+
}
|
|
3896
|
+
if (routes.includes("tool")) {
|
|
3897
|
+
this.registerTool(createChartTool());
|
|
3898
|
+
}
|
|
3899
|
+
// Called after connecting, the history already replayed with charts off,
|
|
3900
|
+
// so redraw rather than make the obvious call order wrong.
|
|
3901
|
+
if (first && this.isConnected) {
|
|
3902
|
+
this.reload();
|
|
3903
|
+
}
|
|
3904
|
+
}
|
|
3905
|
+
|
|
3906
|
+
/**
|
|
3907
|
+
* Teach this element to draw one kind of AG-UI activity.
|
|
3908
|
+
*
|
|
3909
|
+
* `activity_type` is one of exactly two fields the protocol leaves an open
|
|
3910
|
+
* string, and it is the **content** one: an activity is materialised into a
|
|
3911
|
+
* message, persisted with the thread, and replayed on every restore. Its
|
|
3912
|
+
* sibling `CUSTOM` carries an imperative and is dispatched to the page
|
|
3913
|
+
* instead ({@link CUSTOM_AGENT_EVENT}).
|
|
3914
|
+
*
|
|
3915
|
+
* That asymmetry decides which carrier a server should use. Content has a
|
|
3916
|
+
* place in the conversation and should come back; an imperative has no place
|
|
3917
|
+
* and no meaning once acted on.
|
|
3918
|
+
*
|
|
3919
|
+
* ```js
|
|
3920
|
+
* chat.registerActivityRenderer({
|
|
3921
|
+
* type: "build_status",
|
|
3922
|
+
* render: (content) => {
|
|
3923
|
+
* const el = document.createElement("div");
|
|
3924
|
+
* el.textContent = `Build ${content.status}`;
|
|
3925
|
+
* return el;
|
|
3926
|
+
* },
|
|
3927
|
+
* });
|
|
3928
|
+
* ```
|
|
3929
|
+
*
|
|
3930
|
+
* Registering a type twice replaces the earlier renderer, so a host can
|
|
3931
|
+
* override a built-in -- `chart` and `compaction` are registrations like any
|
|
3932
|
+
* other, not privileged branches.
|
|
3933
|
+
*
|
|
3934
|
+
* ⚠ `render` runs again on every thread load. See {@link ActivityRenderer}
|
|
3935
|
+
* for what that requires of it.
|
|
3936
|
+
*/
|
|
3937
|
+
registerActivityRenderer(registration: ActivityRegistration): void {
|
|
3938
|
+
this.#activities.register(registration);
|
|
3939
|
+
}
|
|
3940
|
+
|
|
3941
|
+
/**
|
|
3942
|
+
* Activity types that arrived with nobody registered to draw them.
|
|
3943
|
+
*
|
|
3944
|
+
* Deliberately the only trace an unhandled activity leaves. Ignoring an
|
|
3945
|
+
* unknown name is the protocol's own answer and the whole point of an open
|
|
3946
|
+
* field, so warning would fire on every forward-compatible server -- but
|
|
3947
|
+
* "nothing happened and nothing was said" is impossible to debug, so the set
|
|
3948
|
+
* is readable. Accumulates for the element's lifetime, across threads.
|
|
3949
|
+
*/
|
|
3950
|
+
get unhandledActivityTypes(): readonly string[] {
|
|
3951
|
+
return this.#activities.unhandledTypes();
|
|
3952
|
+
}
|
|
3953
|
+
}
|
|
3954
|
+
|
|
3955
|
+
/** Draw `html` as the fallback of the button's slots, so a host's own projected icon still wins. */
|
|
3956
|
+
function fillSlotFallback(button: HTMLElement, html: string): void {
|
|
3957
|
+
for (const slot of button.querySelectorAll("slot")) {
|
|
3958
|
+
slot.innerHTML = html;
|
|
3959
|
+
}
|
|
3960
|
+
}
|
|
3961
|
+
|
|
3962
|
+
/** The `removed` count from a compaction activity payload, or `null` if absent. */
|
|
3963
|
+
function compactionRemoved(content: unknown): number | null {
|
|
3964
|
+
const removed = (content as { removed?: unknown } | null | undefined)?.removed;
|
|
3965
|
+
return typeof removed === "number" ? removed : null;
|
|
3966
|
+
}
|