collavre 0.22.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +1 -0
- data/app/assets/images/collavre/landing/interface-en-dark.png +0 -0
- data/app/assets/images/collavre/landing/interface-en.png +0 -0
- data/app/assets/images/collavre/landing/interface-ko-dark.png +0 -0
- data/app/assets/images/collavre/landing/interface-ko.png +0 -0
- data/app/assets/stylesheets/collavre/actiontext.css +251 -90
- data/app/assets/stylesheets/collavre/code_highlight.css +7 -201
- data/app/assets/stylesheets/collavre/comments_popup.css +158 -61
- data/app/assets/stylesheets/collavre/creatives.css +11 -2
- data/app/assets/stylesheets/collavre/landing.css +92 -0
- data/app/assets/stylesheets/collavre/modal_dialog.css +32 -0
- data/app/assets/stylesheets/collavre/popup.css +4 -2
- data/app/assets/stylesheets/collavre/tables.css +91 -0
- data/app/channels/collavre/inbox_badge_channel.rb +30 -0
- data/app/controllers/collavre/admin/settings_controller.rb +52 -67
- data/app/controllers/collavre/api/v1/agents_controller.rb +17 -188
- data/app/controllers/collavre/api/v1/base_controller.rb +9 -27
- data/app/controllers/collavre/api/v1/mobile/agent_events_controller.rb +224 -0
- data/app/controllers/collavre/api/v1/mobile/base_controller.rb +95 -0
- data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +31 -0
- data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +25 -0
- data/app/controllers/collavre/attachments_controller.rb +13 -3
- data/app/controllers/collavre/concerns/slide_viewable.rb +8 -32
- data/app/controllers/collavre/creatives_controller.rb +47 -103
- data/app/controllers/collavre/inbox_items_controller.rb +10 -4
- data/app/controllers/collavre/landing_controller.rb +5 -2
- data/app/controllers/collavre/tasks_controller.rb +13 -4
- data/app/controllers/collavre/topics_controller.rb +54 -1
- data/app/controllers/collavre/typo_corrections_controller.rb +39 -0
- data/app/controllers/concerns/collavre/api/authenticatable.rb +40 -0
- data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +16 -1
- data/app/controllers/concerns/collavre/users_controller/registration.rb +41 -1
- data/app/errors/collavre/api_error.rb +20 -0
- data/app/helpers/collavre/application_helper.rb +1 -0
- data/app/helpers/collavre/creatives_helper.rb +30 -8
- data/app/javascript/collavre.js +2 -0
- data/app/javascript/components/ImageResizer.jsx +9 -3
- data/app/javascript/components/InlineLexicalEditor.jsx +157 -72
- data/app/javascript/components/__tests__/creative_tree_row_parent_id.test.js +87 -0
- data/app/javascript/components/creative_tree_row.js +28 -5
- data/app/javascript/components/plugins/list_tab_indent_plugin.jsx +16 -0
- data/app/javascript/components/plugins/markdown_shortcuts_plugin.jsx +15 -4
- data/app/javascript/components/plugins/table_hover_actions_plugin.jsx +405 -0
- data/app/javascript/controllers/__tests__/inbox_badge_controller.test.js +73 -0
- data/app/javascript/controllers/__tests__/search_popup_controller.test.js +104 -0
- data/app/javascript/controllers/__tests__/topic_list_controller.test.js +82 -0
- data/app/javascript/controllers/comment_controller.js +5 -4
- data/app/javascript/controllers/comment_version_controller.js +2 -1
- data/app/javascript/controllers/comments/__tests__/form_controller_double_submit.test.js +159 -0
- data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +3 -2
- data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +94 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_topic_list.test.js +61 -0
- data/app/javascript/controllers/comments/form_controller.js +21 -5
- data/app/javascript/controllers/comments/list_controller.js +18 -17
- data/app/javascript/controllers/comments/presence_controller.js +2 -1
- data/app/javascript/controllers/comments/topics_controller.js +61 -8
- data/app/javascript/controllers/common_popup_controller.js +2 -2
- data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +150 -0
- data/app/javascript/controllers/creatives/import_controller.js +2 -1
- data/app/javascript/controllers/creatives/select_mode_controller.js +2 -1
- data/app/javascript/controllers/creatives/tree_controller.js +142 -1
- data/app/javascript/controllers/image_lightbox_controller.js +2 -1
- data/app/javascript/controllers/inbox_badge_controller.js +33 -0
- data/app/javascript/controllers/index.js +7 -1
- data/app/javascript/controllers/search_popup_controller.js +17 -1
- data/app/javascript/controllers/share_modal_controller.js +4 -3
- data/app/javascript/controllers/topic_list_controller.js +78 -0
- data/app/javascript/controllers/topic_search_controller.js +2 -1
- data/app/javascript/creatives/drag_drop/event_handlers.js +14 -5
- data/app/javascript/creatives/topic_move_members_popup.js +156 -0
- data/app/javascript/creatives/tree_renderer.js +11 -0
- data/app/javascript/lib/__tests__/common_popup_bounds.test.js +58 -0
- data/app/javascript/lib/__tests__/turbo_confirm.test.js +81 -0
- data/app/javascript/lib/__tests__/typo_correction.test.js +192 -0
- data/app/javascript/lib/api/__tests__/api_error.test.js +96 -0
- data/app/javascript/lib/api/__tests__/queue_manager.test.js +88 -1
- data/app/javascript/lib/api/api_error.js +108 -0
- data/app/javascript/lib/api/queue_manager.js +38 -4
- data/app/javascript/lib/common_popup.js +42 -10
- data/app/javascript/lib/editor/__tests__/code_edit_view_token_parity.test.js +121 -0
- data/app/javascript/lib/editor/__tests__/code_language_roundtrip.test.js +152 -0
- data/app/javascript/lib/editor/__tests__/code_languages.test.js +93 -0
- data/app/javascript/lib/editor/code_languages.js +173 -0
- data/app/javascript/lib/editor/code_token_theme.js +41 -0
- data/app/javascript/lib/lexical/__tests__/code_block_toggle.test.js +326 -0
- data/app/javascript/lib/lexical/__tests__/code_fence_shortcut.test.js +179 -0
- data/app/javascript/lib/lexical/__tests__/image_focus.test.js +139 -0
- data/app/javascript/lib/lexical/__tests__/list_tab_indent.test.js +633 -0
- data/app/javascript/lib/lexical/__tests__/markdown_serialize.test.js +627 -0
- data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +20 -1
- data/app/javascript/lib/lexical/__tests__/selection_boundary.test.js +88 -0
- data/app/javascript/lib/lexical/__tests__/table_transformer.test.js +163 -0
- data/app/javascript/lib/lexical/__tests__/trailing_paragraph.test.js +104 -0
- data/app/javascript/lib/lexical/code_block_toggle.js +130 -0
- data/app/javascript/lib/lexical/code_fence_shortcut.js +60 -0
- data/app/javascript/lib/lexical/list_tab_indent.js +210 -0
- data/app/javascript/lib/lexical/markdown_serialize.js +320 -0
- data/app/javascript/lib/lexical/selection_boundary.js +58 -0
- data/app/javascript/lib/lexical/table_transformer.js +182 -0
- data/app/javascript/lib/lexical/trailing_paragraph.js +29 -0
- data/app/javascript/lib/turbo_confirm.js +46 -0
- data/app/javascript/lib/typo_correction.js +146 -0
- data/app/javascript/lib/utils/__tests__/confirm_dialog.test.js +88 -0
- data/app/javascript/lib/utils/__tests__/dialog.test.js +92 -0
- data/app/javascript/lib/utils/__tests__/markdown.test.js +153 -0
- data/app/javascript/lib/utils/__tests__/sanitize_description.test.js +68 -0
- data/app/javascript/lib/utils/__tests__/table_download.test.js +93 -0
- data/app/javascript/lib/utils/confirm_dialog.js +10 -0
- data/app/javascript/lib/utils/dialog.js +300 -0
- data/app/javascript/lib/utils/markdown.js +154 -67
- data/app/javascript/lib/utils/sanitize_description.js +31 -0
- data/app/javascript/lib/utils/table_download.js +15 -0
- data/app/javascript/modules/__tests__/creative_inline_payload.test.js +231 -0
- data/app/javascript/modules/__tests__/creative_save_queue.test.js +228 -0
- data/app/javascript/modules/__tests__/creative_tree_dom.test.js +425 -0
- data/app/javascript/modules/__tests__/typo_corrector.test.js +365 -0
- data/app/javascript/modules/creative_inline_payload.js +86 -0
- data/app/javascript/modules/creative_row_editor.js +231 -471
- data/app/javascript/modules/creative_row_editor_helpers.js +76 -0
- data/app/javascript/modules/creative_save_queue.js +95 -0
- data/app/javascript/modules/creative_tree_dom.js +260 -0
- data/app/javascript/modules/export_to_markdown.js +2 -1
- data/app/javascript/modules/lexical_inline_editor.jsx +2 -1
- data/app/javascript/modules/slide_view.js +11 -2
- data/app/javascript/modules/typo_corrector.js +534 -0
- data/app/jobs/collavre/ai_agent_job.rb +7 -4
- data/app/jobs/collavre/compress_job.rb +6 -2
- data/app/jobs/collavre/permission_cache_job.rb +20 -2
- data/app/jobs/collavre/trigger_loop_check_job.rb +20 -3
- data/app/jobs/collavre/update_mcp_tools_job.rb +17 -0
- data/app/models/collavre/channel.rb +16 -0
- data/app/models/collavre/comment/approvable.rb +16 -0
- data/app/models/collavre/comment/broadcastable.rb +46 -7
- data/app/models/collavre/comment/notifiable.rb +15 -5
- data/app/models/collavre/comment.rb +87 -32
- data/app/models/collavre/comment_version.rb +1 -1
- data/app/models/collavre/creative/describable.rb +104 -19
- data/app/models/collavre/creative/linkable.rb +9 -0
- data/app/models/collavre/creative/permissible.rb +97 -49
- data/app/models/collavre/creative.rb +73 -5
- data/app/models/collavre/creative_share.rb +78 -26
- data/app/models/collavre/inbox_item.rb +2 -2
- data/app/models/collavre/task.rb +27 -0
- data/app/models/collavre/user.rb +90 -10
- data/app/models/concerns/collavre/indexed_json_columns.rb +58 -0
- data/app/services/collavre/agent_type_classifier.rb +23 -0
- data/app/services/collavre/ai_agent/a2a_dispatcher.rb +4 -0
- data/app/services/collavre/ai_agent/message_builder.rb +1 -1
- data/app/services/collavre/ai_agent/session_provisioner.rb +126 -0
- data/app/services/collavre/ai_agent/task_claim_service.rb +96 -0
- data/app/services/collavre/ai_client.rb +81 -12
- data/app/services/collavre/auto_theme_generator.rb +15 -11
- data/app/services/collavre/creatives/children_index.rb +86 -0
- data/app/services/collavre/creatives/comment_badge_index.rb +73 -0
- data/app/services/collavre/creatives/creative_tree_serializer.rb +122 -0
- data/app/services/collavre/creatives/effective_creative_resolution.rb +37 -0
- data/app/services/collavre/creatives/index_query.rb +85 -16
- data/app/services/collavre/creatives/permission_checker.rb +1 -1
- data/app/services/collavre/creatives/permission_filter.rb +144 -17
- data/app/services/collavre/creatives/tree_builder.rb +107 -78
- data/app/services/collavre/gemini_parent_recommender.rb +1 -1
- data/app/services/collavre/http_client.rb +119 -0
- data/app/services/collavre/inbox_reply_service.rb +5 -0
- data/app/services/collavre/link_preview_fetcher.rb +129 -38
- data/app/services/collavre/markdown_converter.rb +13 -3
- data/app/services/collavre/markdown_importer.rb +30 -6
- data/app/services/collavre/mobile/event_summarizer.rb +40 -0
- data/app/services/collavre/orchestration/agent_context_builder.rb +1 -15
- data/app/services/collavre/orchestration/agent_orchestrator.rb +34 -8
- data/app/services/collavre/orchestration/arbiter.rb +16 -0
- data/app/services/collavre/orchestration/matcher.rb +79 -4
- data/app/services/collavre/orchestration/policy_resolver.rb +4 -3
- data/app/services/collavre/orchestration/stuck_detector.rb +141 -34
- data/app/services/collavre/system_events/context_builder.rb +1 -14
- data/app/services/collavre/tools/creative_batch_service.rb +3 -2
- data/app/services/collavre/tools/creative_create_service.rb +8 -8
- data/app/services/collavre/tools/creative_update_service.rb +23 -8
- data/app/services/collavre/tools/preview_attach_service.rb +5 -7
- data/app/services/collavre/typo_corrector.rb +188 -0
- data/app/views/collavre/comments/_comment.html.erb +9 -4
- data/app/views/collavre/comments/_comments_popup.html.erb +25 -4
- data/app/views/collavre/creatives/_inline_edit_form.html.erb +10 -0
- data/app/views/collavre/creatives/_topic_move_members_modal.html.erb +42 -0
- data/app/views/collavre/creatives/index.html.erb +14 -1
- data/app/views/collavre/creatives/slide_view.html.erb +1 -1
- data/app/views/collavre/landing/show.html.erb +52 -10
- data/app/views/collavre/users/show.html.erb +3 -0
- data/app/views/collavre/users/typo_correction.html.erb +50 -0
- data/app/views/layouts/collavre/slide.html.erb +1 -0
- data/config/locales/comments.en.yml +16 -0
- data/config/locales/comments.ko.yml +16 -0
- data/config/locales/creatives.en.yml +6 -0
- data/config/locales/creatives.ko.yml +5 -0
- data/config/locales/integrations.en.yml +1 -1
- data/config/locales/integrations.ko.yml +1 -1
- data/config/locales/landing.en.yml +22 -10
- data/config/locales/landing.ko.yml +22 -10
- data/config/locales/mobile.en.yml +16 -0
- data/config/locales/mobile.ko.yml +16 -0
- data/config/locales/orchestration.en.yml +1 -0
- data/config/locales/orchestration.ko.yml +1 -0
- data/config/locales/users.en.yml +15 -0
- data/config/locales/users.ko.yml +15 -0
- data/config/routes.rb +13 -0
- data/db/migrate/20260120045354_encrypt_oauth_tokens.rb +13 -4
- data/db/migrate/20260612000000_add_topic_concurrency_defer_to_comments.rb +38 -0
- data/db/migrate/20260617090000_add_typo_correction_settings_to_users.rb +18 -0
- data/db/migrate/20260702000001_enforce_system_settings_key_not_null.rb +9 -0
- data/db/migrate/20260702000002_add_creative_index_and_fk_to_tags.rb +11 -0
- data/db/migrate/20260702000003_add_creative_type_index_to_labels.rb +10 -0
- data/db/migrate/20260702000004_add_creative_created_at_index_to_comments.rb +10 -0
- data/db/migrate/20260702000005_promote_channel_config_index_columns.rb +76 -0
- data/db/migrate/20260713000001_add_comments_count_to_creatives.rb +20 -0
- data/db/migrate/20260713000002_add_comment_versions_count_to_comments.rb +20 -0
- data/db/migrate/20260715000000_change_creatives_description_to_plain_text.rb +16 -0
- data/db/seeds.rb +51 -0
- data/lib/collavre/version.rb +1 -1
- data/lib/generators/collavre/install/install_generator.rb +1 -0
- metadata +96 -2
- data/app/services/collavre/tools/description_normalizable.rb +0 -16
|
@@ -5,12 +5,11 @@ module Tools
|
|
|
5
5
|
class CreativeCreateService
|
|
6
6
|
extend T::Sig
|
|
7
7
|
extend ToolMeta
|
|
8
|
-
include DescriptionNormalizable
|
|
9
8
|
|
|
10
9
|
tool_name "creative_create_service"
|
|
11
|
-
tool_description "Create a new Creative (task/content block) in the hierarchical structure. Creatives function like tasks in a tree structure, with automatic progress calculation.\n\nUse this to:\n- Create new tasks under a parent Creative\n- Add sub-items to organize work\n- Build hierarchical project structures\n\nNote: The description field
|
|
10
|
+
tool_description "Create a new Creative (task/content block) in the hierarchical structure. Creatives function like tasks in a tree structure, with automatic progress calculation.\n\nUse this to:\n- Create new tasks under a parent Creative\n- Add sub-items to organize work\n- Build hierarchical project structures\n\nNote: The description field is written as Markdown."
|
|
12
11
|
|
|
13
|
-
tool_param :description, description: "The content/title of the Creative
|
|
12
|
+
tool_param :description, description: "The content/title of the Creative, written in Markdown (GitHub-Flavored: headings, bold/italic, lists, links, tables, code blocks, task lists). A single newline is a line break. Plain text is stored as-is. Example: '# Title\\n\\n- item one\\n- item two'.", required: true
|
|
14
13
|
tool_param :parent_id, description: "ID of the parent Creative. Required to create under a specific parent. If omitted, creates a root Creative.", required: false
|
|
15
14
|
tool_param :progress, description: "Initial progress value (0.0 to 1.0). Default is 0.", required: false
|
|
16
15
|
tool_param :after_id, description: "ID of a sibling Creative to insert after. Used for ordering.", required: false
|
|
@@ -32,15 +31,16 @@ module Tools
|
|
|
32
31
|
end
|
|
33
32
|
end
|
|
34
33
|
|
|
35
|
-
#
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
#
|
|
34
|
+
# Build the creative. The description is authored as Markdown: store it as
|
|
35
|
+
# the canonical markdown_source and let Describable#convert_markdown_to_html
|
|
36
|
+
# render it to the HTML description (markdown_editor defaults to the
|
|
37
|
+
# advanced "source" surface for tool/MCP writes).
|
|
39
38
|
creative = Creative.new(
|
|
40
|
-
description: normalized_description,
|
|
41
39
|
parent: parent,
|
|
42
40
|
progress: progress || 0
|
|
43
41
|
)
|
|
42
|
+
creative.content_type_input = "markdown"
|
|
43
|
+
creative.markdown_source = description
|
|
44
44
|
|
|
45
45
|
# Set user based on parent or current user
|
|
46
46
|
creative.user = parent ? parent.user : Current.user
|
|
@@ -5,13 +5,12 @@ module Tools
|
|
|
5
5
|
class CreativeUpdateService
|
|
6
6
|
extend T::Sig
|
|
7
7
|
extend ToolMeta
|
|
8
|
-
include DescriptionNormalizable
|
|
9
8
|
|
|
10
9
|
tool_name "creative_update_service"
|
|
11
10
|
tool_description "Update an existing Creative's content, progress, or parent. Use this to:\n- Modify the description/title of a Creative\n- Mark a leaf Creative as complete (progress = 1.0)\n- Move a Creative to a different parent\n\nProgress constraints:\n- Only 1.0 (100%) is allowed — partial progress updates are not supported\n- Only leaf Creatives (with no children) can have their progress updated\n- Parent Creative progress is automatically calculated from children\n\nUse creative_retrieval_service to find the correct Creative before updating."
|
|
12
11
|
|
|
13
12
|
tool_param :id, description: "The ID of the Creative to update.", required: true
|
|
14
|
-
tool_param :description, description: "New content/title for the Creative.
|
|
13
|
+
tool_param :description, description: "New content/title for the Creative, written in Markdown (GitHub-Flavored: headings, bold/italic, lists, links, tables, code blocks, task lists). A single newline is a line break. Replaces the whole body. If omitted, the description remains unchanged.", required: false
|
|
15
14
|
tool_param :progress, description: "Set to 1.0 to mark a leaf Creative as complete. Only 1.0 is allowed; partial progress and updates on parent Creatives are rejected.", required: false
|
|
16
15
|
tool_param :parent_id, description: "New parent Creative ID to move this Creative under. If omitted, nil, or 0, the parent remains unchanged.", required: false
|
|
17
16
|
|
|
@@ -34,9 +33,22 @@ module Tools
|
|
|
34
33
|
updates = {}
|
|
35
34
|
parent_updates = {}
|
|
36
35
|
|
|
37
|
-
# Handle description update
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
# Handle description update. The description is authored as Markdown: set it
|
|
37
|
+
# as the canonical markdown_source on the base/origin and let
|
|
38
|
+
# Describable#convert_markdown_to_html render the HTML description on save.
|
|
39
|
+
#
|
|
40
|
+
# Force the "source" editing surface (like create does) rather than letting
|
|
41
|
+
# convert_markdown_to_html preserve a prior "rich" preference: this update
|
|
42
|
+
# replaces the whole body with fresh tool/MCP-authored GFM, so the old
|
|
43
|
+
# rich-authored content the preference belonged to is gone. Tool writes can
|
|
44
|
+
# contain tables/raw HTML the Lexical nodes don't fully model; reopening in
|
|
45
|
+
# the source textarea preserves that structure, whereas a rich-editor
|
|
46
|
+
# round-trip could silently rewrite it.
|
|
47
|
+
description_provided = description.present?
|
|
48
|
+
if description_provided
|
|
49
|
+
base.content_type_input = "markdown"
|
|
50
|
+
base.markdown_source = description
|
|
51
|
+
base.markdown_editor = "source"
|
|
40
52
|
end
|
|
41
53
|
|
|
42
54
|
# Handle progress update
|
|
@@ -89,9 +101,12 @@ module Tools
|
|
|
89
101
|
success &&= creative.update(parent_updates)
|
|
90
102
|
end
|
|
91
103
|
|
|
92
|
-
# Update content on the base/origin
|
|
93
|
-
if
|
|
94
|
-
|
|
104
|
+
# Update content on the base/origin. markdown_source is set on `base`
|
|
105
|
+
# directly (above), so save it whenever a description was provided even if
|
|
106
|
+
# `updates` (progress) is empty.
|
|
107
|
+
if updates.present? || description_provided
|
|
108
|
+
base.assign_attributes(updates) if updates.present?
|
|
109
|
+
success &&= base.save
|
|
95
110
|
|
|
96
111
|
# Note: progress updates are only allowed on leaf Creatives (validated above).
|
|
97
112
|
# Parent progress is automatically calculated from children.
|
|
@@ -113,15 +113,13 @@ module Tools
|
|
|
113
113
|
raise ArgumentError, "preview_url is not a valid URI: #{preview_url.inspect}"
|
|
114
114
|
end
|
|
115
115
|
|
|
116
|
-
#
|
|
117
|
-
#
|
|
118
|
-
#
|
|
119
|
-
#
|
|
116
|
+
# worktree_id is a real column denormalized from config (see
|
|
117
|
+
# Collavre::Channel#sync_indexed_config_columns), so the lookup is an
|
|
118
|
+
# indexed query — it also backs the (topic_id, worktree_id) unique index
|
|
119
|
+
# that makes attach idempotent.
|
|
120
120
|
sig { params(topic: Collavre::Topic, worktree_id: String).returns(T.nilable(Collavre::PreviewChannel)) }
|
|
121
121
|
def lookup_channel(topic, worktree_id)
|
|
122
|
-
Collavre::PreviewChannel.
|
|
123
|
-
c.worktree_id.to_s == worktree_id.to_s
|
|
124
|
-
end
|
|
122
|
+
Collavre::PreviewChannel.find_by(topic_id: topic.id, worktree_id: worktree_id)
|
|
125
123
|
end
|
|
126
124
|
end
|
|
127
125
|
end
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
module Collavre
|
|
2
|
+
# Server-side typo correction. Sends the text the user is typing to an LLM and
|
|
3
|
+
# returns a validated *structured edit list* — never a rewritten string. Each
|
|
4
|
+
# edit is {original, suggestion, reason, confidence}. Validation rejects anything
|
|
5
|
+
# that is not a small, in-place spelling fix (style rewrites, hallucinated spans,
|
|
6
|
+
# edits inside code/URLs/mentions), so the UI can trust every returned edit.
|
|
7
|
+
class TypoCorrector
|
|
8
|
+
AGENT_EMAIL = "typo-corrector@collavre.local"
|
|
9
|
+
|
|
10
|
+
# Reject "corrections" that are really rewrites: an edit is kept only when the
|
|
11
|
+
# original is short and the suggestion is within this edit distance of it.
|
|
12
|
+
MAX_ORIGINAL_LENGTH = 40
|
|
13
|
+
MIN_EDIT_DISTANCE_CAP = 2
|
|
14
|
+
EDIT_DISTANCE_RATIO = 0.4
|
|
15
|
+
|
|
16
|
+
FALLBACK_SYSTEM_PROMPT = <<~PROMPT.freeze
|
|
17
|
+
You are a typo correction engine. Fix only spelling mistakes and obvious typos,
|
|
18
|
+
never style, grammar or meaning, in any language. `original` must be an exact
|
|
19
|
+
substring of the input. Return ONLY JSON:
|
|
20
|
+
{"edits":[{"original":"x","suggestion":"y","reason":"spelling","confidence":0.0}]}
|
|
21
|
+
PROMPT
|
|
22
|
+
|
|
23
|
+
def initialize(client: nil)
|
|
24
|
+
@client = client
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Returns an Array of Hashes with string keys: "original", "suggestion",
|
|
28
|
+
# "reason", "confidence". Empty array on blank input or LLM/parse failure.
|
|
29
|
+
def correct(text)
|
|
30
|
+
text = text.to_s
|
|
31
|
+
return [] if text.strip.blank?
|
|
32
|
+
|
|
33
|
+
response = client.chat([
|
|
34
|
+
{ role: :user, parts: [ { text: "Text:\n#{text}" } ] }
|
|
35
|
+
])
|
|
36
|
+
|
|
37
|
+
edits = parse_response(response)
|
|
38
|
+
validate(edits, text)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def client
|
|
44
|
+
@client ||= build_client
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def build_client
|
|
48
|
+
agent = Collavre.user_class.find_by(email: AGENT_EMAIL)
|
|
49
|
+
AiClient.new(
|
|
50
|
+
vendor: agent&.llm_vendor.presence || ENV.fetch("COLLAVRE_DEFAULT_LLM_VENDOR", "gemini"),
|
|
51
|
+
model: agent&.llm_model.presence || ENV.fetch("COLLAVRE_DEFAULT_LLM_MODEL", "gemini-3.1-flash-lite"),
|
|
52
|
+
system_prompt: agent&.system_prompt.presence || FALLBACK_SYSTEM_PROMPT,
|
|
53
|
+
llm_api_key: agent&.llm_api_key,
|
|
54
|
+
gateway_url: agent&.gateway_url,
|
|
55
|
+
# Vendor adapters (e.g. OpenClaw) resolve the gateway/key from the context
|
|
56
|
+
# user, not the llm_api_key/gateway_url kwargs. Pass the agent so an
|
|
57
|
+
# OpenClaw-backed typo agent isn't built with user: nil (which makes the
|
|
58
|
+
# adapter bail with "Gateway URL not configured" and silently return no edits).
|
|
59
|
+
context: { user: agent },
|
|
60
|
+
# Runs on debounced typing of *unsubmitted* text — never persist the draft
|
|
61
|
+
# to ActivityLog (RubyLlmInteractionLogger writes `messages` there).
|
|
62
|
+
log_interactions: false
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def parse_response(content)
|
|
67
|
+
return [] if content.blank?
|
|
68
|
+
|
|
69
|
+
cleaned = content.gsub(/^```json\s*/, "").gsub(/\s*```$/, "").strip
|
|
70
|
+
parsed = JSON.parse(cleaned)
|
|
71
|
+
edits = parsed.is_a?(Hash) ? parsed["edits"] : parsed
|
|
72
|
+
edits.is_a?(Array) ? edits : []
|
|
73
|
+
rescue JSON::ParserError => e
|
|
74
|
+
# Never log the raw response: it can echo the user's unsubmitted draft,
|
|
75
|
+
# which would leak private text to application logs despite log_interactions: false.
|
|
76
|
+
Rails.logger.warn("[TypoCorrector] JSON parse error: #{e.message} (#{content.to_s.length} chars)")
|
|
77
|
+
[]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def validate(edits, text)
|
|
81
|
+
skip_ranges = skip_ranges(text)
|
|
82
|
+
seen = {}
|
|
83
|
+
|
|
84
|
+
edits.filter_map do |edit|
|
|
85
|
+
next unless edit.is_a?(Hash)
|
|
86
|
+
|
|
87
|
+
original = edit["original"].to_s
|
|
88
|
+
suggestion = edit["suggestion"].to_s
|
|
89
|
+
next if original.empty? || suggestion.empty? || original == suggestion
|
|
90
|
+
next if original.length > MAX_ORIGINAL_LENGTH
|
|
91
|
+
|
|
92
|
+
# `original` must appear verbatim in the text, outside any skip region
|
|
93
|
+
# (code/URL/mention/markup). We return the offset of that first *valid*
|
|
94
|
+
# occurrence so the client anchors there — otherwise the client's own
|
|
95
|
+
# left-to-right search would bind to an earlier occurrence sitting inside
|
|
96
|
+
# a protected span and edit it (a markdown-canonical/code-safety hole).
|
|
97
|
+
offset = first_offset_outside_skip_ranges(text, original, skip_ranges)
|
|
98
|
+
next if offset.nil?
|
|
99
|
+
|
|
100
|
+
# Reject big rewrites — keep only minimal in-place fixes.
|
|
101
|
+
cap = [ MIN_EDIT_DISTANCE_CAP, (original.length * EDIT_DISTANCE_RATIO).ceil ].max
|
|
102
|
+
next if levenshtein(original, suggestion) > cap
|
|
103
|
+
|
|
104
|
+
key = [ original, suggestion ]
|
|
105
|
+
next if seen[key]
|
|
106
|
+
seen[key] = true
|
|
107
|
+
|
|
108
|
+
{
|
|
109
|
+
"original" => original,
|
|
110
|
+
"suggestion" => suggestion,
|
|
111
|
+
"reason" => edit["reason"].presence || "spelling",
|
|
112
|
+
"confidence" => clamp_confidence(edit["confidence"]),
|
|
113
|
+
# Emit the offset in UTF-16 code units, the coordinate space JS string
|
|
114
|
+
# indexing uses. A Ruby character index would be off by one per astral
|
|
115
|
+
# character (emoji, etc.) before the typo, so the client's substring
|
|
116
|
+
# check at `offset` would fail and fall back to indexOf — which can bind
|
|
117
|
+
# to a protected occurrence the server already excluded.
|
|
118
|
+
"offset" => utf16_offset(text, offset)
|
|
119
|
+
}
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def clamp_confidence(value)
|
|
124
|
+
return 0.5 if value.nil?
|
|
125
|
+
|
|
126
|
+
Float(value).clamp(0.0, 1.0)
|
|
127
|
+
rescue ArgumentError, TypeError
|
|
128
|
+
0.5
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Character ranges that must never be edited: fenced code, inline code, URLs,
|
|
132
|
+
# @mentions, HTML tags, and markdown-canonical <span style> color fragments.
|
|
133
|
+
def skip_ranges(text)
|
|
134
|
+
ranges = []
|
|
135
|
+
patterns = [
|
|
136
|
+
/```.*?```/m, # fenced code blocks
|
|
137
|
+
/`[^`]*`/, # inline code
|
|
138
|
+
%r{https?://\S+}, # URLs
|
|
139
|
+
/@[^\s@]+/, # @mentions
|
|
140
|
+
# `[^<>]` (not just `[^>]`) keeps this linear: a stray run of "<<<<" can't
|
|
141
|
+
# make scan re-walk the tail from every "<" (CodeQL polynomial-ReDoS).
|
|
142
|
+
/<[^<>]+>/ # HTML / span-style markup
|
|
143
|
+
]
|
|
144
|
+
patterns.each do |pattern|
|
|
145
|
+
text.scan(pattern) { ranges << Regexp.last_match.offset(0) }
|
|
146
|
+
end
|
|
147
|
+
ranges
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# First character offset where `needle` occurs entirely outside every skip
|
|
151
|
+
# range, or nil if every occurrence overlaps a protected span.
|
|
152
|
+
def first_offset_outside_skip_ranges(text, needle, skip_ranges)
|
|
153
|
+
pos = 0
|
|
154
|
+
while (idx = text.index(needle, pos))
|
|
155
|
+
finish = idx + needle.length
|
|
156
|
+
inside = skip_ranges.any? { |(s, e)| idx < e && finish > s }
|
|
157
|
+
return idx unless inside
|
|
158
|
+
|
|
159
|
+
pos = idx + 1
|
|
160
|
+
end
|
|
161
|
+
nil
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Convert a Ruby character index into a UTF-16 code-unit offset (JS string
|
|
165
|
+
# coordinates). Characters outside the BMP count as two code units in JS.
|
|
166
|
+
def utf16_offset(text, char_index)
|
|
167
|
+
text[0, char_index].to_s.encode("UTF-16LE").bytesize / 2
|
|
168
|
+
rescue Encoding::UndefinedConversionError, Encoding::InvalidByteSequenceError
|
|
169
|
+
char_index
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Iterative Levenshtein distance.
|
|
173
|
+
def levenshtein(a, b)
|
|
174
|
+
a = a.chars
|
|
175
|
+
b = b.chars
|
|
176
|
+
prev = (0..b.length).to_a
|
|
177
|
+
a.each_with_index do |ac, i|
|
|
178
|
+
curr = [ i + 1 ]
|
|
179
|
+
b.each_with_index do |bc, j|
|
|
180
|
+
cost = ac == bc ? 0 : 1
|
|
181
|
+
curr << [ curr[j] + 1, prev[j + 1] + 1, prev[j] + cost ].min
|
|
182
|
+
end
|
|
183
|
+
prev = curr
|
|
184
|
+
end
|
|
185
|
+
prev.last
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<% comment_topic = comment.topic %>
|
|
2
2
|
<% current_topic_id = local_assigns[:current_topic_id] %>
|
|
3
|
-
<% has_pending_action = comment.
|
|
3
|
+
<% has_pending_action = comment.approval_action? && comment.action_executed_at.blank? %>
|
|
4
4
|
<% approver_id = comment.approver_id %>
|
|
5
5
|
<div class="comment-item" id="<%= dom_id(comment) %>" data-controller="comment" data-user-id="<%= comment.user&.id %>" data-comment-id="<%= comment.id %>" data-topic-id="<%= comment_topic&.id %>" data-creative-id="<%= comment.creative_id %>" data-creative-owner-id="<%= comment.creative&.user_id %>" data-has-pending-action="<%= has_pending_action %>" data-approver-id="<%= approver_id %>" data-ai-user="<%= comment.user&.ai_user? %>" data-streaming="<%= local_assigns.fetch(:streaming, false) %>">
|
|
6
6
|
<div class="comment-select">
|
|
@@ -49,6 +49,11 @@
|
|
|
49
49
|
<button class="comment-stop-btn" type="button" data-action="click->comment#cancelTask" data-task-id="<%= comment.task_id %>" title="<%= t('collavre.comments.stop_agent') %>">
|
|
50
50
|
<span class="agent-stop-icon">■</span> <%= t('collavre.comments.stop_agent') %>
|
|
51
51
|
</button>
|
|
52
|
+
<% elsif comment.waiting_notice? && (blocking_task = comment.topic_blocking_task) %>
|
|
53
|
+
<%# Stop the in-progress blocker so the deferred waiter can proceed (stuck recovery). %>
|
|
54
|
+
<button class="comment-stop-btn" type="button" data-action="click->comment#cancelTask" data-task-id="<%= blocking_task.id %>" title="<%= t('collavre.comments.stop_blocking_agent') %>">
|
|
55
|
+
<span class="agent-stop-icon">■</span> <%= t('collavre.comments.stop_blocking_agent') %>
|
|
56
|
+
</button>
|
|
52
57
|
<% end %>
|
|
53
58
|
<button class="add-reaction-btn" type="button" data-action="click->comment#triggerReactionPicker" title="<%= t('collavre.comments.add_reaction') %>">
|
|
54
59
|
<span class="grayscale-emoji">☺</span>
|
|
@@ -85,8 +90,8 @@
|
|
|
85
90
|
</div>
|
|
86
91
|
<% end %>
|
|
87
92
|
<div class="comment-content" data-comment-target="content"><%= comment.content %></div>
|
|
88
|
-
<% if comment.
|
|
89
|
-
<% version_count = comment.
|
|
93
|
+
<% if comment.comment_versions_count > 0 %>
|
|
94
|
+
<% version_count = comment.comment_versions_count %>
|
|
90
95
|
<% selected_id = comment.selected_version_id %>
|
|
91
96
|
<% version_ids = comment.comment_versions.order(:version_number).pluck(:id) %>
|
|
92
97
|
<% selected_index = selected_id ? (version_ids.index(selected_id)&.+(1) || version_count) : version_count %>
|
|
@@ -168,7 +173,7 @@
|
|
|
168
173
|
<a href="#" class="comment-topic-switch" data-topic-id="<%= comment_topic.id %>">#<%= comment_topic.name %></a>
|
|
169
174
|
</div>
|
|
170
175
|
<% end %>
|
|
171
|
-
<% if comment.
|
|
176
|
+
<% if comment.approval_action? %>
|
|
172
177
|
<div class="comment-action-block" data-comment-id="<%= comment.id %>">
|
|
173
178
|
<details class="comment-action-details">
|
|
174
179
|
<summary class="comment-action-summary"><%= t("collavre.comments.action_summary") %></summary>
|
|
@@ -61,7 +61,20 @@
|
|
|
61
61
|
data-remove-from-history-label="<%= t('collavre.comments.remove_from_history') %>"
|
|
62
62
|
data-inbox-reply-button="<%= t('collavre.comments.inbox_reply_button') %>"
|
|
63
63
|
data-table-download-csv-text="<%= t('collavre.comments.table_download.csv') %>"
|
|
64
|
-
data-table-download-excel-text="<%= t('collavre.comments.table_download.excel') %>"
|
|
64
|
+
data-table-download-excel-text="<%= t('collavre.comments.table_download.excel') %>"
|
|
65
|
+
<% if Current.user %>
|
|
66
|
+
data-typo-enabled="<%= Current.user.typo_correction_enabled %>"
|
|
67
|
+
data-typo-threshold="<%= Current.user.typo_correction_threshold %>"
|
|
68
|
+
data-typo-on-voice="<%= Current.user.typo_correction_on_voice %>"
|
|
69
|
+
data-typo-on-soft-keyboard="<%= Current.user.typo_correction_on_soft_keyboard %>"
|
|
70
|
+
data-typo-on-physical-keyboard="<%= Current.user.typo_correction_on_physical_keyboard %>"
|
|
71
|
+
data-typo-in-chat="<%= Current.user.typo_correction_in_chat %>"
|
|
72
|
+
data-typo-in-editor="<%= Current.user.typo_correction_in_editor %>"
|
|
73
|
+
data-typo-endpoint="<%= collavre.typo_corrections_path %>"
|
|
74
|
+
data-typo-keep-label="<%= t('collavre.comments.typo_keep_label') %>"
|
|
75
|
+
data-typo-custom-label="<%= t('collavre.comments.typo_custom_label') %>"
|
|
76
|
+
data-typo-input-label="<%= t('collavre.comments.typo_input_label') %>"
|
|
77
|
+
<% end %>>
|
|
65
78
|
<div class="resize-handle resize-handle-left" data-comments--popup-target="leftHandle"></div>
|
|
66
79
|
<div class="resize-handle resize-handle-right" data-comments--popup-target="rightHandle"></div>
|
|
67
80
|
<div class="comments-popup-header" data-comments--popup-target="header">
|
|
@@ -112,9 +125,17 @@
|
|
|
112
125
|
data-action="dragover->comments--contexts#handleExternalDragOver drop->comments--contexts#handleExternalDrop dragleave->comments--contexts#handleExternalDragLeave"></div>
|
|
113
126
|
<div data-share-modal-target="container"></div>
|
|
114
127
|
<div id="comment-participants" data-comments--presence-target="participants" data-comments--mention-menu-target="participants"></div>
|
|
115
|
-
<div
|
|
116
|
-
|
|
117
|
-
|
|
128
|
+
<div class="comment-topics-bar">
|
|
129
|
+
<div id="comment-topics" data-comments--topics-target="list" class="comment-topics-list"
|
|
130
|
+
data-confirm-delete-text="<%= t('collavre.topics.delete_confirm') %>"
|
|
131
|
+
data-new-topic-placeholder="<%= t('collavre.topics.new_placeholder') %>"></div>
|
|
132
|
+
<button type="button" class="topic-list-btn"
|
|
133
|
+
data-action="click->comments--topics#openTopicListPopup"
|
|
134
|
+
title="<%= t('collavre.comments.topic_list') %>"
|
|
135
|
+
aria-label="<%= t('collavre.comments.topic_list') %>">
|
|
136
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
|
|
137
|
+
</button>
|
|
138
|
+
</div>
|
|
118
139
|
<%= render Collavre::UserMentionMenuComponent.new(menu_id: 'mention-menu') %>
|
|
119
140
|
<%= render Collavre::CommandMenuComponent.new(menu_id: 'command-menu') %>
|
|
120
141
|
<div id="comments-list" data-comments--popup-target="list" data-comments--list-target="list"><%= t('app.loading') %></div>
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
<input type="hidden" name="authenticity_token" value="<%= form_authenticity_token %>" />
|
|
5
5
|
<input type="hidden" id="inline-creative-description" name="creative[description]" />
|
|
6
6
|
<input type="hidden" id="inline-content-type" name="creative[content_type_input]" value="html" />
|
|
7
|
+
<input type="hidden" id="inline-markdown-editor" name="creative[markdown_editor]" />
|
|
7
8
|
<input type="hidden" id="inline-markdown-source" name="creative[markdown_source]" />
|
|
8
9
|
<input type="hidden" id="inline-parent-id" name="creative[parent_id]" />
|
|
9
10
|
<input type="hidden" id="inline-before-id" name="before_id" />
|
|
@@ -49,6 +50,15 @@
|
|
|
49
50
|
style="margin-left:0.5em;font-family:monospace;font-size:0.9em;">
|
|
50
51
|
<%= t('collavre.creatives.index.toggle_markdown') %>
|
|
51
52
|
</button>
|
|
53
|
+
<span id="inline-save-status"
|
|
54
|
+
class="inline-save-status"
|
|
55
|
+
role="status"
|
|
56
|
+
aria-live="polite"
|
|
57
|
+
data-label-pending="<%= t('collavre.creatives.index.save_status_pending') %>"
|
|
58
|
+
data-label-saving="<%= t('collavre.creatives.index.save_status_saving') %>"
|
|
59
|
+
data-label-saved="<%= t('collavre.creatives.index.save_status_saved') %>"
|
|
60
|
+
data-label-error="<%= t('collavre.creatives.index.save_status_error') %>"
|
|
61
|
+
style="margin-left:auto;font-size:0.85em;color:var(--text-secondary);"></span>
|
|
52
62
|
</div>
|
|
53
63
|
<div style="margin-top:0.5em;">
|
|
54
64
|
<button type="button" id="inline-move-up" class="creative-action-btn" title="<%= t('collavre.creatives.index.inline_move_up_tooltip') %>">
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
Popup shown after a topic is moved to another creative, offering to re-add
|
|
3
|
+
members who had access on the source creative but not on the target.
|
|
4
|
+
Rendered hidden; populated and shown by topic_move_members_popup.js using the
|
|
5
|
+
`missing_members` payload returned from TopicsController#move.
|
|
6
|
+
%>
|
|
7
|
+
<div id="topic-move-members-modal"
|
|
8
|
+
style="display: none; position: fixed; inset: 0; z-index: 10001; align-items: center; justify-content: center; background: rgba(0, 0, 0, 0.3);"
|
|
9
|
+
data-shares-url-template="<%= collavre.creative_creative_shares_path('__CREATIVE_ID__') %>"
|
|
10
|
+
data-adding-text="<%= t('collavre.topics.move.add_members.adding') %>"
|
|
11
|
+
data-added-one="<%= t('collavre.topics.move.add_members.added.one') %>"
|
|
12
|
+
data-added-other="<%= t('collavre.topics.move.add_members.added.other', count: '__COUNT__') %>"
|
|
13
|
+
data-partial-text="<%= t('collavre.topics.move.add_members.partial', added: '__ADDED__', failed: '__FAILED__') %>"
|
|
14
|
+
data-failed-text="<%= t('collavre.topics.move.add_members.failed') %>"
|
|
15
|
+
data-description-template="<%= t('collavre.topics.move.add_members.description', creative: '__CREATIVE_NAME__') %>">
|
|
16
|
+
<div class="popup-box" style="max-width: 460px; width: 90%;">
|
|
17
|
+
<button type="button" id="topic-move-members-close" class="popup-close" aria-label="Close" style="position: absolute; top: 12px; right: 12px;">×</button>
|
|
18
|
+
<h2><%= t('collavre.topics.move.add_members.title') %></h2>
|
|
19
|
+
<p id="topic-move-members-description" style="opacity: 0.75; margin: 0.5em 0 1em;"></p>
|
|
20
|
+
<div id="topic-move-members-message" aria-live="polite"></div>
|
|
21
|
+
<ul id="topic-move-members-list" class="share-grid"></ul>
|
|
22
|
+
<div style="display: flex; gap: 0.5em; justify-content: flex-end; margin-top: 1em;">
|
|
23
|
+
<button type="button" id="topic-move-members-skip" class="btn btn-secondary"><%= t('collavre.topics.move.add_members.skip') %></button>
|
|
24
|
+
<button type="button" id="topic-move-members-add" class="btn btn-primary"><%= t('collavre.topics.move.add_members.add') %></button>
|
|
25
|
+
</div>
|
|
26
|
+
</div>
|
|
27
|
+
|
|
28
|
+
<template id="topic-move-member-row">
|
|
29
|
+
<li class="topic-move-member-row" style="display: flex; align-items: center; gap: 0.5em;">
|
|
30
|
+
<input type="checkbox" class="tmm-checkbox" checked>
|
|
31
|
+
<span class="avatar share-avatar tmm-avatar" style="display: inline-block; width: 20px; height: 20px; border-radius: 50%; background: var(--surface-3, #ddd); text-align: center; line-height: 20px; font-size: 10px; overflow: hidden;"></span>
|
|
32
|
+
<span class="tmm-name"></span>
|
|
33
|
+
<span class="tmm-email" style="opacity: 0.5; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"></span>
|
|
34
|
+
<select class="org-chart-permission-select share-modal-permission-select tmm-permission">
|
|
35
|
+
<% %w[admin write feedback read].each do |perm| %>
|
|
36
|
+
<option value="<%= perm %>"><%= t("collavre.creatives.index.permission_#{perm}") %></option>
|
|
37
|
+
<% end %>
|
|
38
|
+
</select>
|
|
39
|
+
<span class="tmm-status" style="width: 16px; text-align: center;"></span>
|
|
40
|
+
</li>
|
|
41
|
+
</template>
|
|
42
|
+
</div>
|
|
@@ -104,6 +104,17 @@
|
|
|
104
104
|
<div data-share-modal-target="container"></div>
|
|
105
105
|
<% end %>
|
|
106
106
|
|
|
107
|
+
<%# The "re-add dropped members" modal must be present wherever a topic can be
|
|
108
|
+
dropped onto another creative — including the top-level index, where
|
|
109
|
+
@parent_creative/@creative are nil (moving a topic between root-level
|
|
110
|
+
creatives). Gating it on the write check above hid it exactly there, so
|
|
111
|
+
getModal() returned null and the popup silently never appeared. It stays
|
|
112
|
+
hidden until populated, and share creation is permission-checked
|
|
113
|
+
server-side, so rendering it for any authenticated user is safe. %>
|
|
114
|
+
<% if authenticated? %>
|
|
115
|
+
<%= render 'topic_move_members_modal' %>
|
|
116
|
+
<% end %>
|
|
117
|
+
|
|
107
118
|
<%= render 'import_upload_zone' %>
|
|
108
119
|
|
|
109
120
|
<% if can_manage_integrations %>
|
|
@@ -159,6 +170,7 @@
|
|
|
159
170
|
title_effective_origin = @parent_creative.effective_origin(Set.new)
|
|
160
171
|
title_content_type = title_effective_origin.data&.dig("content_type")
|
|
161
172
|
title_markdown_source = title_can_write ? title_effective_origin.data&.dig("markdown_source") : nil
|
|
173
|
+
title_markdown_editor = title_effective_origin.data&.dig("editor")
|
|
162
174
|
%>
|
|
163
175
|
<%= content_tag(
|
|
164
176
|
"creative-tree-row",
|
|
@@ -172,7 +184,8 @@
|
|
|
172
184
|
"data-progress-value": @parent_creative.progress,
|
|
173
185
|
"data-origin-id": @parent_creative.origin_id,
|
|
174
186
|
"data-content-type": title_content_type,
|
|
175
|
-
"data-markdown-source": title_markdown_source
|
|
187
|
+
"data-markdown-source": title_markdown_source,
|
|
188
|
+
"data-markdown-editor": title_markdown_editor
|
|
176
189
|
) %>
|
|
177
190
|
<% else %>
|
|
178
191
|
<%= content_tag(
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
<% initial_index = params[:slide].to_i.clamp(0, @slide_ids.length - 1) %>
|
|
9
9
|
<% initial_prompt = @creative.prompt_for(Current.user) %>
|
|
10
10
|
<div id="slide-view" data-slide-ids="<%= @slide_ids.join(',') %>" data-initial-index="<%= initial_index %>" data-root-id="<%= @creative.id %>">
|
|
11
|
-
<div id="slide-content"><%= content_tag(tag_name,
|
|
11
|
+
<div id="slide-content"><%= content_tag(tag_name, embed_youtube_iframe(@creative.effective_description), class: 'creative-content') %></div>
|
|
12
12
|
</div>
|
|
13
13
|
<div id="slide-controls">
|
|
14
14
|
<div id="slide-counter"><%= initial_index + 1 %> / <%= @slide_ids.length %></div>
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
<section id="problem" class="landing-section landing-problem">
|
|
20
20
|
<div class="landing-container">
|
|
21
21
|
<h2 class="landing-section-title"><%= t('collavre.landing.problem.title') %></h2>
|
|
22
|
+
<p class="landing-section-sub"><%= t('collavre.landing.problem.subtitle') %></p>
|
|
22
23
|
<div class="landing-problem-grid">
|
|
23
24
|
<div class="landing-problem-card">
|
|
24
25
|
<span class="landing-problem-icon">📝</span>
|
|
@@ -52,40 +53,77 @@
|
|
|
52
53
|
<p><%= t('collavre.landing.features.tree.desc') %></p>
|
|
53
54
|
</div>
|
|
54
55
|
|
|
55
|
-
<div class="landing-feature-card">
|
|
56
|
-
<div class="landing-feature-icon">🤖</div>
|
|
57
|
-
<h3><%= t('collavre.landing.features.ai.title') %></h3>
|
|
58
|
-
<p><%= t('collavre.landing.features.ai.desc') %></p>
|
|
59
|
-
</div>
|
|
60
|
-
|
|
61
56
|
<div class="landing-feature-card">
|
|
62
57
|
<div class="landing-feature-icon">💡</div>
|
|
63
58
|
<h3><%= t('collavre.landing.features.context.title') %></h3>
|
|
64
59
|
<p><%= t('collavre.landing.features.context.desc') %></p>
|
|
65
60
|
</div>
|
|
66
61
|
|
|
62
|
+
<div class="landing-feature-card">
|
|
63
|
+
<div class="landing-feature-icon">🤖</div>
|
|
64
|
+
<h3><%= t('collavre.landing.features.ai.title') %></h3>
|
|
65
|
+
<p><%= t('collavre.landing.features.ai.desc') %></p>
|
|
66
|
+
</div>
|
|
67
|
+
|
|
67
68
|
<div class="landing-feature-card">
|
|
68
69
|
<div class="landing-feature-icon">💬</div>
|
|
69
70
|
<h3><%= t('collavre.landing.features.chat.title') %></h3>
|
|
70
71
|
<p><%= t('collavre.landing.features.chat.desc') %></p>
|
|
71
72
|
</div>
|
|
72
73
|
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
</section>
|
|
73
77
|
|
|
78
|
+
<%# ═════════════════════════════════════ THE INTERFACE ══════════════════════════════════════ %>
|
|
79
|
+
<%# A still before the film: the reader decides in two seconds whether this is a surface they
|
|
80
|
+
would write in, and a video they have not pressed play on cannot answer that. %>
|
|
81
|
+
<section id="interface" class="landing-section landing-interface">
|
|
82
|
+
<div class="landing-container">
|
|
83
|
+
<h2 class="landing-section-title"><%= t('collavre.landing.interface.title') %></h2>
|
|
84
|
+
<p class="landing-section-sub"><%= t('collavre.landing.interface.subtitle') %></p>
|
|
85
|
+
|
|
86
|
+
<div class="landing-shot-frame landing-shot-light">
|
|
87
|
+
<%= image_tag "collavre/landing/interface-#{I18n.locale == :ko ? 'ko' : 'en'}.png",
|
|
88
|
+
class: "landing-shot-img",
|
|
89
|
+
alt: t('collavre.landing.interface.alt'), loading: "lazy" %>
|
|
74
90
|
</div>
|
|
91
|
+
<div class="landing-shot-frame landing-shot-dark">
|
|
92
|
+
<%= image_tag "collavre/landing/interface-#{I18n.locale == :ko ? 'ko' : 'en'}-dark.png",
|
|
93
|
+
class: "landing-shot-img",
|
|
94
|
+
alt: t('collavre.landing.interface.alt'), loading: "lazy" %>
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
<ul class="landing-interface-points">
|
|
98
|
+
<li><%= t('collavre.landing.interface.markdown') %></li>
|
|
99
|
+
<li><%= t('collavre.landing.interface.rich') %></li>
|
|
100
|
+
<li><%= t('collavre.landing.interface.drag') %></li>
|
|
101
|
+
<li><%= t('collavre.landing.interface.presence') %></li>
|
|
102
|
+
</ul>
|
|
75
103
|
</div>
|
|
76
104
|
</section>
|
|
77
105
|
|
|
78
106
|
<%# ══════════════════════════════════════════ DEMO ══════════════════════════════════════════ %>
|
|
107
|
+
<%# Demo video/poster URLs are full absolute URLs stored per-locale in
|
|
108
|
+
config/locales/landing.*.yml. They point at the hosted assets directly, so
|
|
109
|
+
they resolve from any environment (preview included) with no DB lookup and
|
|
110
|
+
no env-bound blob ids. Render the section only when a video URL is set. %>
|
|
111
|
+
<% demo_video = t('collavre.landing.demo.video', default: nil) %>
|
|
112
|
+
<% demo_poster = t('collavre.landing.demo.poster', default: nil) %>
|
|
113
|
+
<% demo_video_dark = t('collavre.landing.demo.video_dark', default: nil) %>
|
|
114
|
+
<% demo_poster_dark = t('collavre.landing.demo.poster_dark', default: nil) %>
|
|
115
|
+
<% if demo_video.present? || demo_video_dark.present? %>
|
|
79
116
|
<section id="demo" class="landing-section landing-demo">
|
|
80
117
|
<div class="landing-container">
|
|
81
118
|
<h2 class="landing-section-title"><%= t('collavre.landing.demo.title') %></h2>
|
|
82
119
|
<%# Light mode video %>
|
|
120
|
+
<% if demo_video.present? %>
|
|
83
121
|
<div class="landing-demo-frame landing-demo-light" data-controller="landing-video">
|
|
84
122
|
<video class="landing-demo-video"
|
|
85
123
|
data-landing-video-target="video"
|
|
86
124
|
autoplay muted loop playsinline
|
|
87
|
-
poster="
|
|
88
|
-
<source src="
|
|
125
|
+
poster="<%= demo_poster %>">
|
|
126
|
+
<source src="<%= demo_video %>" type="video/mp4">
|
|
89
127
|
</video>
|
|
90
128
|
<div class="landing-demo-progress" data-landing-video-target="progressBar">
|
|
91
129
|
<div class="landing-demo-progress-fill" data-landing-video-target="progressFill"></div>
|
|
@@ -94,13 +132,15 @@
|
|
|
94
132
|
<span class="landing-demo-toggle-icon" data-landing-video-target="icon">❚❚</span>
|
|
95
133
|
</button>
|
|
96
134
|
</div>
|
|
135
|
+
<% end %>
|
|
97
136
|
<%# Dark mode video %>
|
|
137
|
+
<% if demo_video_dark.present? %>
|
|
98
138
|
<div class="landing-demo-frame landing-demo-dark" data-controller="landing-video">
|
|
99
139
|
<video class="landing-demo-video"
|
|
100
140
|
data-landing-video-target="video"
|
|
101
141
|
autoplay muted loop playsinline
|
|
102
|
-
poster="
|
|
103
|
-
<source src="
|
|
142
|
+
poster="<%= demo_poster_dark %>">
|
|
143
|
+
<source src="<%= demo_video_dark %>" type="video/mp4">
|
|
104
144
|
</video>
|
|
105
145
|
<div class="landing-demo-progress" data-landing-video-target="progressBar">
|
|
106
146
|
<div class="landing-demo-progress-fill" data-landing-video-target="progressFill"></div>
|
|
@@ -109,8 +149,10 @@
|
|
|
109
149
|
<span class="landing-demo-toggle-icon" data-landing-video-target="icon">❚❚</span>
|
|
110
150
|
</button>
|
|
111
151
|
</div>
|
|
152
|
+
<% end %>
|
|
112
153
|
</div>
|
|
113
154
|
</section>
|
|
155
|
+
<% end %>
|
|
114
156
|
|
|
115
157
|
<%# ═══════════════════════════════════════ FINAL CTA ════════════════════════════════════════ %>
|
|
116
158
|
<section class="landing-section landing-cta">
|