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
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
// Inline typo correction for the chat composer (Phase 1).
|
|
2
|
+
//
|
|
3
|
+
// Wires the chat textarea to the server endpoint and paints a *volatile*
|
|
4
|
+
// highlight overlay: a mirror "backdrop" div sitting exactly behind the textarea
|
|
5
|
+
// with <mark> spans over each edit span. The textarea value stays the single
|
|
6
|
+
// source of truth — the backdrop is aria-hidden and never serialized, so
|
|
7
|
+
// markdown-canonical storage never sees a highlight.
|
|
8
|
+
//
|
|
9
|
+
// Two highlight states (shape + colour, for colour-blind safety):
|
|
10
|
+
// - auto-applied (confidence >= threshold): faint straight underline
|
|
11
|
+
// - candidate (below threshold): wavy dotted underline (needs decision)
|
|
12
|
+
//
|
|
13
|
+
// Clicking a highlight opens a creatable combobox (reusing CommonPopup for
|
|
14
|
+
// positioning + list + keyboard nav) pre-filled with the word currently in the
|
|
15
|
+
// document, so Enter = keep. Typing adds the typed word as an always-present,
|
|
16
|
+
// auto-selected custom option.
|
|
17
|
+
|
|
18
|
+
import CommonPopup from '../lib/common_popup'
|
|
19
|
+
import csrfFetch from '../lib/api/csrf_fetch'
|
|
20
|
+
import {
|
|
21
|
+
detectDevice,
|
|
22
|
+
shouldRun,
|
|
23
|
+
buildCandidateList,
|
|
24
|
+
anchorEdits,
|
|
25
|
+
applyEditAt,
|
|
26
|
+
shiftEditsAfter,
|
|
27
|
+
partitionByThreshold,
|
|
28
|
+
} from '../lib/typo_correction'
|
|
29
|
+
|
|
30
|
+
const DEBOUNCE_MS = 600
|
|
31
|
+
const PRINTABLE_KEYDOWN_TTL_MS = 50
|
|
32
|
+
|
|
33
|
+
let initialized = false
|
|
34
|
+
|
|
35
|
+
// Mirror the textarea's box metrics onto the backdrop so highlight spans land on
|
|
36
|
+
// the exact glyphs. Only the properties that affect text layout are copied.
|
|
37
|
+
const MIRRORED_STYLES = [
|
|
38
|
+
'boxSizing', 'width', 'height', 'paddingTop', 'paddingRight', 'paddingBottom',
|
|
39
|
+
'paddingLeft', 'borderTopWidth', 'borderRightWidth', 'borderBottomWidth',
|
|
40
|
+
'borderLeftWidth', 'fontFamily', 'fontSize', 'fontWeight', 'fontStyle',
|
|
41
|
+
'lineHeight', 'letterSpacing', 'textTransform', 'wordSpacing', 'textIndent',
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
// Escape by round-tripping through textContent: the browser never reinterprets
|
|
45
|
+
// it as markup, and it's the canonical DOM-text sanitizer (recognized as a
|
|
46
|
+
// barrier by static XSS analysis) for the values we splice into the backdrop and
|
|
47
|
+
// popup HTML — both of which carry user-typed / model-suggested text.
|
|
48
|
+
function escapeHtml(value) {
|
|
49
|
+
const el = document.createElement('div')
|
|
50
|
+
el.textContent = String(value)
|
|
51
|
+
return el.innerHTML
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class TypoCorrector {
|
|
55
|
+
constructor(textarea, { settings, location = 'chat', endpoint = '/typo_corrections', getVoiceActive = () => false, labels = {} } = {}) {
|
|
56
|
+
this.textarea = textarea
|
|
57
|
+
this.settings = settings
|
|
58
|
+
this.location = location
|
|
59
|
+
this.endpoint = endpoint
|
|
60
|
+
this.getVoiceActive = getVoiceActive
|
|
61
|
+
this.labels = {
|
|
62
|
+
keep: labels.keep || 'keep',
|
|
63
|
+
custom: labels.custom || 'custom',
|
|
64
|
+
inputLabel: labels.inputLabel || 'correction',
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
this.edits = [] // anchored edits currently painted: {start,end,original,suggestion,confidence,state}
|
|
68
|
+
this.lastPrintableKeydownAt = null
|
|
69
|
+
this.lastInputAt = null
|
|
70
|
+
this.debounceTimer = null
|
|
71
|
+
this.requestSeq = 0
|
|
72
|
+
|
|
73
|
+
this._buildBackdrop()
|
|
74
|
+
this._bind()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
_buildBackdrop() {
|
|
78
|
+
const ta = this.textarea
|
|
79
|
+
const wrap = document.createElement('div')
|
|
80
|
+
wrap.className = 'typo-input-wrap'
|
|
81
|
+
ta.parentNode.insertBefore(wrap, ta)
|
|
82
|
+
|
|
83
|
+
this.backdrop = document.createElement('div')
|
|
84
|
+
this.backdrop.className = 'typo-backdrop'
|
|
85
|
+
this.backdrop.setAttribute('aria-hidden', 'true')
|
|
86
|
+
this.highlightLayer = document.createElement('div')
|
|
87
|
+
this.highlightLayer.className = 'typo-highlights'
|
|
88
|
+
this.backdrop.appendChild(this.highlightLayer)
|
|
89
|
+
|
|
90
|
+
wrap.appendChild(this.backdrop)
|
|
91
|
+
wrap.appendChild(ta)
|
|
92
|
+
this._syncBackdropStyle()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
_syncBackdropStyle() {
|
|
96
|
+
const computed = getComputedStyle(this.textarea)
|
|
97
|
+
MIRRORED_STYLES.forEach((prop) => {
|
|
98
|
+
this.highlightLayer.style[prop] = computed[prop]
|
|
99
|
+
})
|
|
100
|
+
// The textarea's own border is transparent-mirrored via padding offsets.
|
|
101
|
+
this.highlightLayer.style.borderColor = 'transparent'
|
|
102
|
+
this.highlightLayer.style.borderStyle = 'solid'
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
_bind() {
|
|
106
|
+
this._onKeydown = (event) => {
|
|
107
|
+
// A printable keydown immediately preceding the input marks a physical key.
|
|
108
|
+
if (event.key && event.key.length === 1 && !event.ctrlKey && !event.metaKey) {
|
|
109
|
+
this.lastPrintableKeydownAt = performance.now()
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
this._onInput = () => {
|
|
113
|
+
// Stamp the input time *now*, while the keydown→input relationship is still
|
|
114
|
+
// fresh. Classifying at detect time (after the debounce) would read a
|
|
115
|
+
// ~600ms gap and misread every physical keypress as a soft keyboard,
|
|
116
|
+
// defeating the default-off physical-keyboard gate.
|
|
117
|
+
this.lastInputAt = performance.now()
|
|
118
|
+
this._syncScroll()
|
|
119
|
+
this._repaint() // keep existing highlights aligned with edited text
|
|
120
|
+
// Our own auto-apply dispatches a synthetic `input` so other listeners
|
|
121
|
+
// (auto-resize, draft save, send-button state) update. We must NOT let it
|
|
122
|
+
// re-trigger detection: the corrected text has no typos, so the follow-up
|
|
123
|
+
// would return [] and wipe the just-applied underline/undo affordance
|
|
124
|
+
// before the user can click it. Real keystrokes still schedule detection.
|
|
125
|
+
if (this.suppressNextDetect) {
|
|
126
|
+
this.suppressNextDetect = false
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
this._scheduleDetect()
|
|
130
|
+
}
|
|
131
|
+
this._onScroll = () => this._syncScroll()
|
|
132
|
+
// form.reset() (after a comment sends, or on cancel) empties the textarea
|
|
133
|
+
// WITHOUT firing an `input` event, so our repaint never runs and stale marks
|
|
134
|
+
// would linger over the now-empty composer — still clickable, applying stale
|
|
135
|
+
// offsets to the empty value. The `reset` event fires before the control is
|
|
136
|
+
// cleared, so drop the edits now and repaint next frame once it's empty.
|
|
137
|
+
this._onReset = () => {
|
|
138
|
+
clearTimeout(this.debounceTimer)
|
|
139
|
+
this.requestSeq++ // invalidate any in-flight detection response
|
|
140
|
+
this.edits = []
|
|
141
|
+
requestAnimationFrame(() => this._repaint())
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
this.form = this.textarea.form
|
|
145
|
+
this.textarea.addEventListener('keydown', this._onKeydown)
|
|
146
|
+
this.textarea.addEventListener('input', this._onInput)
|
|
147
|
+
this.textarea.addEventListener('scroll', this._onScroll)
|
|
148
|
+
this.form?.addEventListener('reset', this._onReset)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
destroy() {
|
|
152
|
+
clearTimeout(this.debounceTimer)
|
|
153
|
+
this.textarea.removeEventListener('keydown', this._onKeydown)
|
|
154
|
+
this.textarea.removeEventListener('input', this._onInput)
|
|
155
|
+
this.textarea.removeEventListener('scroll', this._onScroll)
|
|
156
|
+
this.form?.removeEventListener('reset', this._onReset)
|
|
157
|
+
// Drop the combobox popup: it lives on document.body, so without this a Turbo
|
|
158
|
+
// page-cache snapshot serializes an orphan popup (stale options, no live
|
|
159
|
+
// instance) and the next corrector appends a duplicate. hide() also detaches
|
|
160
|
+
// CommonPopup's document-level outside-click listeners.
|
|
161
|
+
this.popup?.hide()
|
|
162
|
+
this.popupEl?.remove()
|
|
163
|
+
this.popupEl = null
|
|
164
|
+
this.popupInput = null
|
|
165
|
+
this.popup = null
|
|
166
|
+
// Unwrap the textarea and drop the injected backdrop so a Turbo page-cache
|
|
167
|
+
// snapshot doesn't serialize stale overlay DOM (it would duplicate on the
|
|
168
|
+
// next build) or the bind marker (a restored snapshot keeps typoBound=true
|
|
169
|
+
// but loses the JS listeners, so re-init is skipped and correction stays
|
|
170
|
+
// dead until a full reload).
|
|
171
|
+
const wrap = this.backdrop?.parentNode
|
|
172
|
+
if (wrap && wrap.parentNode) {
|
|
173
|
+
wrap.parentNode.insertBefore(this.textarea, wrap)
|
|
174
|
+
wrap.remove()
|
|
175
|
+
}
|
|
176
|
+
delete this.textarea.dataset.typoBound
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
_syncScroll() {
|
|
180
|
+
this.backdrop.scrollTop = this.textarea.scrollTop
|
|
181
|
+
this.backdrop.scrollLeft = this.textarea.scrollLeft
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
_currentDevice() {
|
|
185
|
+
return detectDevice({
|
|
186
|
+
voiceActive: !!this.getVoiceActive(),
|
|
187
|
+
lastPrintableKeydownAt: this.lastPrintableKeydownAt,
|
|
188
|
+
inputAt: this.lastInputAt,
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
_scheduleDetect() {
|
|
193
|
+
clearTimeout(this.debounceTimer)
|
|
194
|
+
this.debounceTimer = setTimeout(() => this._detect(), DEBOUNCE_MS)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async _detect() {
|
|
198
|
+
const device = this._currentDevice();
|
|
199
|
+
// Reset the printable-keydown marker so a stale value can't mis-classify the
|
|
200
|
+
// next standalone input (e.g. soft-keyboard insert after a physical keypress).
|
|
201
|
+
if (this.lastPrintableKeydownAt != null && performance.now() - this.lastPrintableKeydownAt > PRINTABLE_KEYDOWN_TTL_MS) {
|
|
202
|
+
this.lastPrintableKeydownAt = null
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (!shouldRun(this.settings, { device, location: this.location })) return
|
|
206
|
+
const text = this.textarea.value
|
|
207
|
+
if (!text.trim()) { this._clear(); return }
|
|
208
|
+
|
|
209
|
+
const seq = ++this.requestSeq
|
|
210
|
+
let payload
|
|
211
|
+
try {
|
|
212
|
+
const res = await csrfFetch(this.endpoint, {
|
|
213
|
+
method: 'POST',
|
|
214
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
215
|
+
body: JSON.stringify({ text, device, location: this.location }),
|
|
216
|
+
})
|
|
217
|
+
if (!res.ok) return
|
|
218
|
+
payload = await res.json()
|
|
219
|
+
} catch {
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
if (seq !== this.requestSeq) return // a newer keystroke superseded this one
|
|
223
|
+
// The text may have changed while the request was in flight; only act if the
|
|
224
|
+
// edits still anchor to the *current* textarea value.
|
|
225
|
+
if (this.textarea.value !== text) return
|
|
226
|
+
|
|
227
|
+
this._applyResult(payload)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
_applyResult({ edits = [], threshold = 80 } = {}) {
|
|
231
|
+
const original = this.textarea.value
|
|
232
|
+
const anchored = anchorEdits(original, edits)
|
|
233
|
+
const { autoApplied, candidates } = partitionByThreshold(anchored, threshold)
|
|
234
|
+
|
|
235
|
+
// This round's edits. `apply` rewrites the word; `keep` leaves the text as-is
|
|
236
|
+
// (a low-confidence candidate awaiting a decision).
|
|
237
|
+
const incoming = [
|
|
238
|
+
...autoApplied.map((e) => ({ ...e, kind: 'apply' })),
|
|
239
|
+
...candidates.map((e) => ({ ...e, kind: 'keep', state: 'candidate', currentValue: e.original })),
|
|
240
|
+
]
|
|
241
|
+
|
|
242
|
+
// Carry forward earlier corrections that are still anchored in the live text
|
|
243
|
+
// and don't collide with this round. The server is stateless and never
|
|
244
|
+
// re-reports an already-corrected word, so without this every detection round
|
|
245
|
+
// would wipe the prior correction's mark and leave only the most recent one
|
|
246
|
+
// clickable. Already-applied survivors sit in the text unchanged, so they're
|
|
247
|
+
// re-emitted in place (kind 'keep'), not re-applied.
|
|
248
|
+
const survivors = this.edits
|
|
249
|
+
.filter((p) => original.slice(p.start, p.end) === p.currentValue)
|
|
250
|
+
.filter((p) => !incoming.some((n) => n.start < p.end && p.start < n.end))
|
|
251
|
+
.map((p) => ({ ...p, kind: 'keep' }))
|
|
252
|
+
|
|
253
|
+
// Walk all edits left-to-right in one pass, rebuilding the text while
|
|
254
|
+
// tracking a running delta. This assigns each tracked edit an *exact* final
|
|
255
|
+
// span, rather than re-searching the corrected word by value afterwards —
|
|
256
|
+
// that search would bind to an earlier identical word (e.g. correcting the
|
|
257
|
+
// second "teh" in "the teh" lands the highlight on the first "the", so undo
|
|
258
|
+
// would rewrite the wrong word).
|
|
259
|
+
const events = [...incoming, ...survivors].sort((a, b) => a.start - b.start)
|
|
260
|
+
|
|
261
|
+
const originalCaret = this.textarea.selectionStart
|
|
262
|
+
let out = ''
|
|
263
|
+
let cursor = 0
|
|
264
|
+
let delta = 0
|
|
265
|
+
let caret = originalCaret
|
|
266
|
+
const tracked = []
|
|
267
|
+
for (const e of events) {
|
|
268
|
+
if (e.start < cursor) continue // overlapping span; skip defensively
|
|
269
|
+
out += original.slice(cursor, e.start)
|
|
270
|
+
const finalStart = e.start + delta
|
|
271
|
+
if (e.kind === 'apply') {
|
|
272
|
+
out += e.suggestion
|
|
273
|
+
const d = e.suggestion.length - (e.end - e.start)
|
|
274
|
+
if (e.start < originalCaret) caret += d
|
|
275
|
+
delta += d
|
|
276
|
+
tracked.push({
|
|
277
|
+
original: e.original, suggestion: e.suggestion, confidence: e.confidence,
|
|
278
|
+
state: 'applied', currentValue: e.suggestion,
|
|
279
|
+
start: finalStart, end: finalStart + e.suggestion.length,
|
|
280
|
+
})
|
|
281
|
+
} else {
|
|
282
|
+
// 'keep': a new candidate or a carried-forward survivor — text unchanged,
|
|
283
|
+
// so re-emit the current slice and preserve its existing state.
|
|
284
|
+
const slice = original.slice(e.start, e.end)
|
|
285
|
+
out += slice
|
|
286
|
+
tracked.push({
|
|
287
|
+
original: e.original, suggestion: e.suggestion, confidence: e.confidence,
|
|
288
|
+
state: e.state, currentValue: e.currentValue,
|
|
289
|
+
start: finalStart, end: finalStart + slice.length,
|
|
290
|
+
})
|
|
291
|
+
}
|
|
292
|
+
cursor = e.end
|
|
293
|
+
}
|
|
294
|
+
out += original.slice(cursor)
|
|
295
|
+
|
|
296
|
+
this.edits = tracked
|
|
297
|
+
if (out !== original) {
|
|
298
|
+
this.textarea.value = out
|
|
299
|
+
this.textarea.setSelectionRange(caret, caret)
|
|
300
|
+
// Our own mutation: notify other listeners but don't re-run detection (it
|
|
301
|
+
// would return [] on the now-clean text and wipe these fresh highlights).
|
|
302
|
+
this.suppressNextDetect = true
|
|
303
|
+
this.textarea.dispatchEvent(new Event('input', { bubbles: true }))
|
|
304
|
+
}
|
|
305
|
+
this._repaint()
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
_clear() {
|
|
309
|
+
this.edits = []
|
|
310
|
+
this._repaint()
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Paint the backdrop: plain text with <mark> spans over each edit. Marks carry
|
|
314
|
+
// the state class so CSS renders the two underline styles.
|
|
315
|
+
_repaint() {
|
|
316
|
+
// Drop edits that no longer match the live text (user kept typing).
|
|
317
|
+
const text = this.textarea.value
|
|
318
|
+
this.edits = this.edits.filter((e) => text.slice(e.start, e.end) === e.currentValue)
|
|
319
|
+
|
|
320
|
+
if (this.edits.length === 0) {
|
|
321
|
+
this.highlightLayer.textContent = text
|
|
322
|
+
return
|
|
323
|
+
}
|
|
324
|
+
const sorted = [...this.edits].sort((a, b) => a.start - b.start)
|
|
325
|
+
let html = ''
|
|
326
|
+
let cursor = 0
|
|
327
|
+
sorted.forEach((edit, i) => {
|
|
328
|
+
if (edit.start < cursor) return // overlapping; skip
|
|
329
|
+
html += escapeHtml(text.slice(cursor, edit.start))
|
|
330
|
+
const cls = edit.state === 'applied' ? 'typo-mark typo-mark-applied' : 'typo-mark typo-mark-candidate'
|
|
331
|
+
html += `<mark class="${cls}" data-typo-index="${i}">${escapeHtml(text.slice(edit.start, edit.end))}</mark>`
|
|
332
|
+
cursor = edit.end
|
|
333
|
+
})
|
|
334
|
+
html += escapeHtml(text.slice(cursor))
|
|
335
|
+
this.highlightLayer.innerHTML = html
|
|
336
|
+
this._wireMarks(sorted)
|
|
337
|
+
this._syncScroll()
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// <mark> has pointer-events:auto (the rest of the backdrop is none), so clicks
|
|
341
|
+
// land on a highlighted word and open the combobox there.
|
|
342
|
+
_wireMarks(sorted) {
|
|
343
|
+
this.highlightLayer.querySelectorAll('.typo-mark').forEach((mark) => {
|
|
344
|
+
const idx = Number(mark.dataset.typoIndex)
|
|
345
|
+
const edit = sorted[idx]
|
|
346
|
+
if (!edit) return
|
|
347
|
+
mark.addEventListener('mousedown', (event) => {
|
|
348
|
+
event.preventDefault()
|
|
349
|
+
this._openPopup(edit, mark)
|
|
350
|
+
})
|
|
351
|
+
})
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
_ensurePopup() {
|
|
355
|
+
if (this.popupEl) return
|
|
356
|
+
const el = document.createElement('div')
|
|
357
|
+
el.className = 'typo-popup common-popup'
|
|
358
|
+
el.style.display = 'none'
|
|
359
|
+
el.innerHTML = `
|
|
360
|
+
<input type="text" class="typo-popup-input" />
|
|
361
|
+
<ul class="typo-popup-list" data-popup-list></ul>`
|
|
362
|
+
document.body.appendChild(el)
|
|
363
|
+
this.popupEl = el
|
|
364
|
+
this.popupInput = el.querySelector('.typo-popup-input')
|
|
365
|
+
// Localized assistive label (set via setAttribute, not innerHTML, so the
|
|
366
|
+
// value is never parsed as markup).
|
|
367
|
+
this.popupInput.setAttribute('aria-label', this.labels.inputLabel)
|
|
368
|
+
|
|
369
|
+
this.popup = new CommonPopup(el, {
|
|
370
|
+
listElement: el.querySelector('.typo-popup-list'),
|
|
371
|
+
// item.label is user-typed / model-suggested text; escapeHtml round-trips
|
|
372
|
+
// it through textContent so it can never be reinterpreted as markup.
|
|
373
|
+
renderItem: (item) => {
|
|
374
|
+
const tag = item.role === 'original' ? ` <span class="typo-popup-role">(${escapeHtml(this.labels.keep)})</span>`
|
|
375
|
+
: item.role === 'custom' ? ` <span class="typo-popup-role">(${escapeHtml(this.labels.custom)})</span>` : ''
|
|
376
|
+
return `<span class="typo-popup-value">${escapeHtml(item.label)}</span>${tag}`
|
|
377
|
+
},
|
|
378
|
+
onSelect: (item) => this._chooseValue(item.value),
|
|
379
|
+
onClose: () => { this._activeEdit = null },
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
// Typing builds a creatable option and keeps input ↔ list bound.
|
|
383
|
+
this.popupInput.addEventListener('input', () => this._refreshPopupItems(this.popupInput.value))
|
|
384
|
+
this.popupInput.addEventListener('keydown', (event) => {
|
|
385
|
+
if (event.key === 'Enter') {
|
|
386
|
+
event.preventDefault()
|
|
387
|
+
this._chooseValue(this.popupInput.value)
|
|
388
|
+
return
|
|
389
|
+
}
|
|
390
|
+
if (this.popup.handleKey(event)) {
|
|
391
|
+
// Sync the input to the active list item (arrow navigation).
|
|
392
|
+
const active = this.popup.items[this.popup.activeIndex]
|
|
393
|
+
if (active) this.popupInput.value = active.value
|
|
394
|
+
}
|
|
395
|
+
})
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
_refreshPopupItems(typed) {
|
|
399
|
+
const edit = this._activeEdit
|
|
400
|
+
if (!edit) return
|
|
401
|
+
let items = buildCandidateList({
|
|
402
|
+
currentValue: edit.currentValue,
|
|
403
|
+
originalWord: edit.original,
|
|
404
|
+
suggestions: [{ suggestion: edit.suggestion, confidence: edit.confidence }],
|
|
405
|
+
})
|
|
406
|
+
const trimmed = (typed || '').trim()
|
|
407
|
+
if (trimmed && !items.some((i) => i.value === trimmed)) {
|
|
408
|
+
// Creatable: the typed word is always present and auto-selected.
|
|
409
|
+
items = [{ value: trimmed, label: trimmed, role: 'custom', isCurrent: false }, ...items]
|
|
410
|
+
}
|
|
411
|
+
this.popup.setItems(items)
|
|
412
|
+
// Auto-select the typed custom entry, else the current document value.
|
|
413
|
+
const selectIndex = trimmed
|
|
414
|
+
? Math.max(0, items.findIndex((i) => i.value === trimmed))
|
|
415
|
+
: Math.max(0, items.findIndex((i) => i.isCurrent))
|
|
416
|
+
this.popup.setActiveIndex(selectIndex)
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
_openPopup(edit, anchorEl) {
|
|
420
|
+
this._ensurePopup()
|
|
421
|
+
this._activeEdit = edit
|
|
422
|
+
this.popupInput.value = edit.currentValue
|
|
423
|
+
this._refreshPopupItems('')
|
|
424
|
+
this.popup.showAt(anchorEl.getBoundingClientRect())
|
|
425
|
+
// Desktop: focus + select-all so a keystroke immediately replaces the word.
|
|
426
|
+
// Mobile keyboard is left to an explicit tap on the input (no auto-focus) —
|
|
427
|
+
// chip taps are the primary path.
|
|
428
|
+
// showAt() keeps the popup visibility:hidden until its own rAF (so it can
|
|
429
|
+
// position off-screen first); focus()/select() are no-ops while hidden, so
|
|
430
|
+
// defer them into a rAF that runs after showAt's (FIFO, registered later).
|
|
431
|
+
if (!this._isCoarsePointer()) {
|
|
432
|
+
requestAnimationFrame(() => {
|
|
433
|
+
this.popupInput.focus()
|
|
434
|
+
this.popupInput.select()
|
|
435
|
+
})
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
_isCoarsePointer() {
|
|
440
|
+
return typeof window.matchMedia === 'function' && window.matchMedia('(pointer: coarse)').matches
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
_chooseValue(value) {
|
|
444
|
+
const edit = this._activeEdit
|
|
445
|
+
if (edit == null || value == null) { this.popup?.hide(); return }
|
|
446
|
+
const replacement = String(value)
|
|
447
|
+
|
|
448
|
+
if (replacement !== edit.currentValue) {
|
|
449
|
+
const { text, delta } = applyEditAt(this.textarea.value, {
|
|
450
|
+
start: edit.start, end: edit.end, replacement,
|
|
451
|
+
})
|
|
452
|
+
let caret = this.textarea.selectionStart
|
|
453
|
+
if (edit.start < caret) caret += delta
|
|
454
|
+
this.textarea.value = text
|
|
455
|
+
this.textarea.setSelectionRange(caret, caret)
|
|
456
|
+
// Shift later edits, then drop this one (it's now user-confirmed).
|
|
457
|
+
this.edits = shiftEditsAfter(
|
|
458
|
+
this.edits.filter((e) => e !== edit),
|
|
459
|
+
edit.start,
|
|
460
|
+
delta,
|
|
461
|
+
)
|
|
462
|
+
// Our own mutation: don't re-detect (it would overwrite the remaining
|
|
463
|
+
// candidate highlights we just shifted to keep them aligned).
|
|
464
|
+
this.suppressNextDetect = true
|
|
465
|
+
this.textarea.dispatchEvent(new Event('input', { bubbles: true }))
|
|
466
|
+
} else {
|
|
467
|
+
// "Keep" — user resolved it; remove the highlight.
|
|
468
|
+
this.edits = this.edits.filter((e) => e !== edit)
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
this.popup?.hide()
|
|
472
|
+
this._repaint()
|
|
473
|
+
this.textarea.focus()
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Module-scope init, mirroring mention_menu.js. Reads settings exposed by the
|
|
478
|
+
// server on #comments-popup and only attaches when the master switch is on.
|
|
479
|
+
function readSettings(root) {
|
|
480
|
+
if (!root) return null
|
|
481
|
+
const d = root.dataset
|
|
482
|
+
if (d.typoEnabled == null) return null
|
|
483
|
+
return {
|
|
484
|
+
enabled: d.typoEnabled === 'true',
|
|
485
|
+
threshold: parseInt(d.typoThreshold, 10) || 80,
|
|
486
|
+
onVoice: d.typoOnVoice === 'true',
|
|
487
|
+
onSoftKeyboard: d.typoOnSoftKeyboard === 'true',
|
|
488
|
+
onPhysicalKeyboard: d.typoOnPhysicalKeyboard === 'true',
|
|
489
|
+
inChat: d.typoInChat === 'true',
|
|
490
|
+
inEditor: d.typoInEditor === 'true',
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
let activeInstance = null
|
|
495
|
+
|
|
496
|
+
export function initTypoCorrector() {
|
|
497
|
+
const textarea = document.querySelector('#new-comment-form textarea')
|
|
498
|
+
const root = document.getElementById('comments-popup')
|
|
499
|
+
const settings = readSettings(root)
|
|
500
|
+
if (!textarea || !settings || !settings.enabled) return null
|
|
501
|
+
if (textarea.dataset.typoBound === 'true') return null
|
|
502
|
+
textarea.dataset.typoBound = 'true'
|
|
503
|
+
|
|
504
|
+
const voiceButton = document.getElementById('voice-comments-btn')
|
|
505
|
+
activeInstance = new TypoCorrector(textarea, {
|
|
506
|
+
settings,
|
|
507
|
+
location: 'chat',
|
|
508
|
+
// Use the mounted engine path (e.g. /collavre/typo_corrections) so requests
|
|
509
|
+
// don't 404 when Collavre is mounted at a subpath instead of root.
|
|
510
|
+
endpoint: root.dataset.typoEndpoint || '/typo_corrections',
|
|
511
|
+
getVoiceActive: () => voiceButton?.dataset.voiceState === 'listening',
|
|
512
|
+
labels: {
|
|
513
|
+
keep: root.dataset.typoKeepLabel,
|
|
514
|
+
custom: root.dataset.typoCustomLabel,
|
|
515
|
+
inputLabel: root.dataset.typoInputLabel,
|
|
516
|
+
},
|
|
517
|
+
})
|
|
518
|
+
return activeInstance
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
export function teardownTypoCorrector() {
|
|
522
|
+
if (!activeInstance) return
|
|
523
|
+
activeInstance.destroy()
|
|
524
|
+
activeInstance = null
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
if (!initialized) {
|
|
528
|
+
initialized = true
|
|
529
|
+
document.addEventListener('turbo:load', initTypoCorrector)
|
|
530
|
+
// Turbo caches a DOM snapshot on navigation but not the JS listeners. Tear the
|
|
531
|
+
// instance down (clearing the bind marker + injected DOM) before the snapshot
|
|
532
|
+
// is taken so a restored page re-initializes cleanly instead of skipping init.
|
|
533
|
+
document.addEventListener('turbo:before-cache', teardownTypoCorrector)
|
|
534
|
+
}
|
|
@@ -101,11 +101,14 @@ module Collavre
|
|
|
101
101
|
|
|
102
102
|
# Reserve resources before starting work
|
|
103
103
|
tracker = Orchestration::ResourceTracker.for(agent)
|
|
104
|
-
#
|
|
105
|
-
#
|
|
106
|
-
#
|
|
104
|
+
# Reserve under the stable task.id, not the per-run job_id: a task can
|
|
105
|
+
# outlive the job that reserved its slot (Claude Channel tasks wait on an
|
|
106
|
+
# MCP reply, pending_approval tasks pause on ApprovalPendingError), and
|
|
107
|
+
# every external release path — TasksController#cancel, stuck recovery,
|
|
108
|
+
# agent teardown — releases! by task.id. A job_id key would leave those
|
|
109
|
+
# releases unmatched, leaking the slot until the cache expiry.
|
|
107
110
|
is_claude_channel_agent = agent.claude_channel_agent?
|
|
108
|
-
resource_id =
|
|
111
|
+
resource_id = task.id
|
|
109
112
|
tracker.reserve!(resource_id)
|
|
110
113
|
should_release = true
|
|
111
114
|
|
|
@@ -77,9 +77,13 @@ module Collavre
|
|
|
77
77
|
# Store comment IDs to delete before creating the new one (all originals including /compress command)
|
|
78
78
|
comment_ids_to_delete = all_comments.pluck(:id)
|
|
79
79
|
|
|
80
|
-
# Create the summary comment in the same topic
|
|
80
|
+
# Create the summary comment in the same topic.
|
|
81
|
+
# Author it as the AI agent (not the human who ran /compress) so the
|
|
82
|
+
# comment is recognized as AI-generated content: this makes ai_user? true,
|
|
83
|
+
# which is what gates the "Review" button in the comment view. The summary
|
|
84
|
+
# body is AI output, so the agent is the correct author.
|
|
81
85
|
summary_comment = creative.comments.create!(
|
|
82
|
-
user:
|
|
86
|
+
user: agent,
|
|
83
87
|
topic_id: topic_id,
|
|
84
88
|
content: summary_content,
|
|
85
89
|
skip_dispatch: true # system-generated summary, not user input
|
|
@@ -19,7 +19,7 @@ class PermissionCacheJob < ApplicationJob
|
|
|
19
19
|
when :rebuild_for_creative
|
|
20
20
|
rebuild_for_creative(args[:creative_id])
|
|
21
21
|
when :propagate_share
|
|
22
|
-
propagate_share(args[:creative_share_id])
|
|
22
|
+
propagate_share(args[:creative_share_id], purge_stale: args.fetch(:purge_stale, false))
|
|
23
23
|
when :remove_share
|
|
24
24
|
remove_share(args[:creative_share_id], args[:creative_id], args[:user_id])
|
|
25
25
|
when :rebuild_user_cache_for_subtree
|
|
@@ -49,7 +49,18 @@ class PermissionCacheJob < ApplicationJob
|
|
|
49
49
|
Creatives::PermissionCacheBuilder.rebuild_for_creative(creative)
|
|
50
50
|
end
|
|
51
51
|
|
|
52
|
-
def propagate_share(creative_share_id)
|
|
52
|
+
def propagate_share(creative_share_id, purge_stale: false)
|
|
53
|
+
# A relocated/reassigned share leaves stale cache rows keyed to it at the old
|
|
54
|
+
# location. When asked, delete them here — in this same job, immediately
|
|
55
|
+
# before re-propagating — never as a separate job. Both the purge and the
|
|
56
|
+
# propagate key on source_share_id, and the authz queue runs two threads, so
|
|
57
|
+
# a standalone purge could execute AFTER propagate and delete the rows it
|
|
58
|
+
# just wrote, leaving the share with no cache until an unrelated rebuild.
|
|
59
|
+
# Folding the purge in guarantees purge-before-propagate ordering while
|
|
60
|
+
# keeping the work off the commit path (the async, prolonged-grant window the
|
|
61
|
+
# CTO accepted is preserved). Purge runs before the find_by so it still fires
|
|
62
|
+
# even if the share was destroyed after enqueue.
|
|
63
|
+
purge_share_cache(creative_share_id) if purge_stale
|
|
53
64
|
share = CreativeShare.find_by(id: creative_share_id)
|
|
54
65
|
return unless share
|
|
55
66
|
Creatives::PermissionCacheBuilder.propagate_share(share)
|
|
@@ -62,6 +73,13 @@ class PermissionCacheJob < ApplicationJob
|
|
|
62
73
|
Creatives::PermissionCacheBuilder.rebuild_from_ancestors_for_user(creative, user_id)
|
|
63
74
|
end
|
|
64
75
|
|
|
76
|
+
# Purge the stale cache rows a relocated/reassigned share left behind. Only
|
|
77
|
+
# ever called from propagate_share (never enqueued standalone) so the delete
|
|
78
|
+
# cannot race the re-propagation on a separate authz-queue thread.
|
|
79
|
+
def purge_share_cache(creative_share_id)
|
|
80
|
+
CreativeSharesCache.where(source_share_id: creative_share_id).delete_all
|
|
81
|
+
end
|
|
82
|
+
|
|
65
83
|
def rebuild_user_cache_for_subtree(creative_id, user_id)
|
|
66
84
|
creative = Creative.find_by(id: creative_id)
|
|
67
85
|
return unless creative
|
|
@@ -86,17 +86,34 @@ module Collavre
|
|
|
86
86
|
# These are NOT valid task results — the agent never actually worked.
|
|
87
87
|
MAX_INFRA_RETRIES = 3
|
|
88
88
|
|
|
89
|
-
|
|
90
|
-
|
|
89
|
+
# Generic, vendor-neutral infrastructure-error signatures. Vendor engines
|
|
90
|
+
# register their own provider-specific signatures (e.g. an OpenClaw gateway
|
|
91
|
+
# error banner) via register_infrastructure_error_pattern, so core names no
|
|
92
|
+
# vendor here.
|
|
93
|
+
BUILTIN_INFRASTRUCTURE_ERROR_PATTERNS = [
|
|
91
94
|
/(?:request|connection|server)\s+timed?\s*out/i,
|
|
92
95
|
/connection\s*(refused|reset|closed)/i,
|
|
93
96
|
/internal\s*server\s*error/i,
|
|
94
97
|
/\b50[234]\b.*(?:error|gateway|unavailable)/i
|
|
95
98
|
].freeze
|
|
96
99
|
|
|
100
|
+
class << self
|
|
101
|
+
def registered_infrastructure_error_patterns
|
|
102
|
+
@registered_infrastructure_error_patterns ||= []
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def register_infrastructure_error_pattern(pattern)
|
|
106
|
+
registered_infrastructure_error_patterns << pattern unless registered_infrastructure_error_patterns.include?(pattern)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def infrastructure_error_patterns
|
|
110
|
+
BUILTIN_INFRASTRUCTURE_ERROR_PATTERNS + registered_infrastructure_error_patterns
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
97
114
|
def infrastructure_error?(comment)
|
|
98
115
|
content = comment.content.to_s
|
|
99
|
-
|
|
116
|
+
self.class.infrastructure_error_patterns.any? { |pattern| content.match?(pattern) }
|
|
100
117
|
end
|
|
101
118
|
|
|
102
119
|
# Retry without consuming an iteration — the agent never actually worked.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Collavre
|
|
4
|
+
# Re-derives a creative's MCP tool definitions from its description HTML.
|
|
5
|
+
# Runs off the request/save path (enqueued from Creative after_commit) so the
|
|
6
|
+
# Nokogiri parsing and tool upserts never block a synchronous save.
|
|
7
|
+
class UpdateMcpToolsJob < ApplicationJob
|
|
8
|
+
queue_as :default
|
|
9
|
+
|
|
10
|
+
def perform(creative_id)
|
|
11
|
+
creative = Creative.find_by(id: creative_id)
|
|
12
|
+
return unless creative
|
|
13
|
+
|
|
14
|
+
McpService.new.update_from_creative(creative)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|