collavre 0.3.2 → 0.5.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/app/assets/stylesheets/collavre/actiontext.css +73 -71
- data/app/assets/stylesheets/collavre/activity_logs.css +18 -45
- data/app/assets/stylesheets/collavre/comments_popup.css +197 -35
- data/app/assets/stylesheets/collavre/creatives.css +101 -51
- data/app/assets/stylesheets/collavre/dark_mode.css +221 -88
- data/app/assets/stylesheets/collavre/design_tokens.css +334 -0
- data/app/assets/stylesheets/collavre/mention_menu.css +13 -9
- data/app/assets/stylesheets/collavre/popup.css +57 -27
- data/app/assets/stylesheets/collavre/slide_view.css +6 -6
- data/app/assets/stylesheets/collavre/user_menu.css +4 -5
- data/app/components/collavre/plans_timeline_component.html.erb +2 -2
- data/app/controllers/collavre/admin/orchestration_controller.rb +9 -2
- data/app/controllers/collavre/admin/settings_controller.rb +199 -0
- data/app/controllers/collavre/comments/reactions_controller.rb +1 -9
- data/app/controllers/collavre/comments_controller.rb +39 -162
- data/app/controllers/collavre/creatives_controller.rb +18 -58
- data/app/controllers/collavre/users_controller.rb +31 -3
- data/app/helpers/collavre/application_helper.rb +97 -0
- data/app/helpers/collavre/creatives_helper.rb +10 -202
- data/app/javascript/collavre.js +0 -1
- data/app/javascript/components/creative_tree_row.js +3 -2
- data/app/javascript/controllers/comment_controller.js +309 -4
- data/app/javascript/controllers/comments/form_controller.js +52 -0
- data/app/javascript/controllers/comments/presence_controller.js +13 -0
- data/app/javascript/controllers/creatives/tree_controller.js +2 -1
- data/app/javascript/controllers/link_creative_controller.js +29 -3
- data/app/javascript/lib/__tests__/html_code_block_wrapper.test.js +201 -0
- data/app/javascript/lib/html_code_block_wrapper.js +168 -0
- data/app/javascript/lib/utils/markdown.js +2 -1
- data/app/javascript/modules/creative_row_editor.js +5 -1
- data/app/javascript/utils/emoji_parser.js +21 -0
- data/app/jobs/collavre/ai_agent_job.rb +6 -2
- data/app/jobs/collavre/cron_action_job.rb +18 -6
- data/app/jobs/collavre/cron_scheduler_job.rb +112 -0
- data/app/models/collavre/comment/approvable.rb +50 -0
- data/app/models/collavre/comment/broadcastable.rb +119 -0
- data/app/models/collavre/comment/notifiable.rb +111 -0
- data/app/models/collavre/comment.rb +13 -258
- data/app/models/collavre/comment_reaction.rb +15 -0
- data/app/models/collavre/creative/describable.rb +86 -0
- data/app/models/collavre/creative/linkable.rb +77 -0
- data/app/models/collavre/creative/permissible.rb +103 -0
- data/app/models/collavre/creative.rb +3 -289
- data/app/models/collavre/orchestrator_policy.rb +1 -1
- data/app/models/collavre/system_setting.rb +27 -1
- data/app/models/collavre/user.rb +42 -0
- data/app/models/collavre/user_theme.rb +10 -0
- data/app/services/collavre/ai_agent/approval_handler.rb +110 -0
- data/app/services/collavre/ai_agent/message_builder.rb +129 -0
- data/app/services/collavre/ai_agent/review_handler.rb +70 -0
- data/app/services/collavre/ai_agent_service.rb +93 -150
- data/app/services/collavre/ai_client.rb +23 -4
- data/app/services/collavre/auto_theme_generator.rb +168 -50
- data/app/services/collavre/command_menu_service.rb +70 -0
- data/app/services/collavre/comment_move_service.rb +94 -0
- data/app/services/collavre/comments/action_executor.rb +10 -0
- data/app/services/collavre/comments/mcp_command.rb +1 -2
- data/app/services/collavre/creatives/create_service.rb +86 -0
- data/app/services/collavre/creatives/destroy_service.rb +41 -0
- data/app/services/collavre/creatives/index_query.rb +3 -0
- data/app/services/collavre/markdown_converter.rb +240 -0
- data/app/services/collavre/mention_parser.rb +63 -0
- data/app/services/collavre/orchestration/agent_context_builder.rb +24 -8
- data/app/services/collavre/orchestration/agent_orchestrator.rb +59 -10
- data/app/services/collavre/orchestration/loop_breaker.rb +12 -7
- data/app/services/collavre/orchestration/policy_resolver.rb +16 -2
- data/app/services/collavre/orchestration/scheduler.rb +4 -3
- data/app/services/collavre/orchestration/stuck_detector.rb +1 -1
- data/app/services/collavre/system_events/context_builder.rb +1 -6
- data/app/services/collavre/tools/creative_batch_service.rb +107 -0
- data/app/services/collavre/tools/creative_update_service.rb +17 -12
- data/app/services/collavre/tools/cron_create_service.rb +17 -5
- data/app/views/admin/shared/_tabs.html.erb +2 -1
- data/app/views/collavre/admin/orchestration/show.html.erb +11 -0
- data/app/views/collavre/admin/settings/_system_tab.html.erb +138 -0
- data/app/views/collavre/admin/settings/_uiux_tab.html.erb +44 -0
- data/app/views/collavre/admin/settings/index.html.erb +11 -0
- data/app/views/collavre/admin/settings/uiux.html.erb +11 -0
- data/app/views/collavre/comments/_comment.html.erb +15 -5
- data/app/views/collavre/comments/_comments_popup.html.erb +9 -2
- data/app/views/collavre/creatives/_mobile_actions_menu.html.erb +0 -3
- data/app/views/collavre/creatives/_share_button.html.erb +0 -52
- data/app/views/collavre/creatives/_share_modal.html.erb +52 -0
- data/app/views/collavre/creatives/index.html.erb +5 -8
- data/app/views/collavre/shared/navigation/_panels.html.erb +2 -2
- data/app/views/collavre/user_themes/index.html.erb +7 -9
- data/app/views/collavre/users/_contact_management.html.erb +2 -1
- data/app/views/collavre/users/edit_ai.html.erb +7 -0
- data/app/views/collavre/users/index.html.erb +16 -1
- data/app/views/collavre/users/new_ai.html.erb +18 -8
- data/app/views/collavre/users/passkeys.html.erb +1 -1
- data/app/views/collavre/users/show.html.erb +1 -1
- data/app/views/layouts/collavre/slide.html.erb +8 -1
- data/config/locales/admin.en.yml +88 -0
- data/config/locales/admin.ko.yml +88 -0
- data/config/locales/ai_agent.en.yml +5 -1
- data/config/locales/ai_agent.ko.yml +5 -1
- data/config/locales/comments.en.yml +5 -1
- data/config/locales/comments.ko.yml +5 -1
- data/config/locales/orchestration.en.yml +8 -0
- data/config/locales/orchestration.ko.yml +8 -0
- data/config/locales/users.en.yml +12 -0
- data/config/locales/users.ko.yml +12 -0
- data/config/routes.rb +7 -1
- data/db/migrate/20260212011655_add_quoted_comment_to_comments.rb +7 -0
- data/db/migrate/20260213044247_add_agent_conf_to_users.rb +5 -0
- data/lib/collavre/engine.rb +25 -0
- data/lib/collavre/version.rb +1 -1
- metadata +32 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus'
|
|
2
2
|
import { renderMarkdownInContainer } from '../../lib/utils/markdown'
|
|
3
|
+
import { wrapHtmlInCodeBlocks } from '../../lib/html_code_block_wrapper'
|
|
3
4
|
|
|
4
5
|
export default class extends Controller {
|
|
5
6
|
static targets = [
|
|
@@ -14,6 +15,10 @@ export default class extends Controller {
|
|
|
14
15
|
'imageInput',
|
|
15
16
|
'imageButton',
|
|
16
17
|
'attachmentList',
|
|
18
|
+
'quotedCommentId',
|
|
19
|
+
'quotedText',
|
|
20
|
+
'quoteIndicator',
|
|
21
|
+
'quoteIndicatorText',
|
|
17
22
|
]
|
|
18
23
|
|
|
19
24
|
connect() {
|
|
@@ -53,6 +58,8 @@ export default class extends Controller {
|
|
|
53
58
|
this.imageInputTarget?.addEventListener('change', this.handleImageChange)
|
|
54
59
|
this.textareaTarget.addEventListener('dragover', this.handleDragOver)
|
|
55
60
|
this.textareaTarget.addEventListener('drop', this.handleDrop)
|
|
61
|
+
this.handlePaste = this.handlePaste.bind(this)
|
|
62
|
+
this.textareaTarget.addEventListener('paste', this.handlePaste)
|
|
56
63
|
|
|
57
64
|
|
|
58
65
|
this.recognition = null
|
|
@@ -97,6 +104,7 @@ export default class extends Controller {
|
|
|
97
104
|
this.imageInputTarget?.removeEventListener('change', this.handleImageChange)
|
|
98
105
|
this.textareaTarget.removeEventListener('dragover', this.handleDragOver)
|
|
99
106
|
this.textareaTarget.removeEventListener('drop', this.handleDrop)
|
|
107
|
+
this.textareaTarget.removeEventListener('paste', this.handlePaste)
|
|
100
108
|
this.element.removeEventListener('comments--topics:change', this.handleTopicChange)
|
|
101
109
|
}
|
|
102
110
|
|
|
@@ -154,6 +162,7 @@ export default class extends Controller {
|
|
|
154
162
|
if (this.cancelTarget) this.cancelTarget.style.display = 'none'
|
|
155
163
|
this.presenceController?.clearManualTypingMessage()
|
|
156
164
|
this.clearImageAttachments()
|
|
165
|
+
this.cancelQuote()
|
|
157
166
|
}
|
|
158
167
|
|
|
159
168
|
setSendingState(isSending) {
|
|
@@ -420,6 +429,31 @@ export default class extends Controller {
|
|
|
420
429
|
this.updateAttachmentList()
|
|
421
430
|
}
|
|
422
431
|
|
|
432
|
+
handlePaste(event) {
|
|
433
|
+
const clipboardData = event.clipboardData
|
|
434
|
+
if (!clipboardData) return
|
|
435
|
+
|
|
436
|
+
const text = clipboardData.getData('text/plain') || clipboardData.getData('text')
|
|
437
|
+
if (!text) return
|
|
438
|
+
|
|
439
|
+
const { changed, result } = wrapHtmlInCodeBlocks(text)
|
|
440
|
+
if (!changed) return
|
|
441
|
+
|
|
442
|
+
event.preventDefault()
|
|
443
|
+
|
|
444
|
+
const textarea = this.textareaTarget
|
|
445
|
+
const start = textarea.selectionStart
|
|
446
|
+
const end = textarea.selectionEnd
|
|
447
|
+
const before = textarea.value.substring(0, start)
|
|
448
|
+
const after = textarea.value.substring(end)
|
|
449
|
+
// Ensure blank line before code fence if there's preceding text
|
|
450
|
+
const separator = before.length > 0 && !before.endsWith('\n') ? '\n' : ''
|
|
451
|
+
textarea.value = before + separator + result + after
|
|
452
|
+
const cursorPos = start + separator.length + result.length
|
|
453
|
+
textarea.setSelectionRange(cursorPos, cursorPos)
|
|
454
|
+
textarea.dispatchEvent(new Event('input', { bubbles: true }))
|
|
455
|
+
}
|
|
456
|
+
|
|
423
457
|
handleDragOver(event) {
|
|
424
458
|
if (this.hasImageFromDataTransfer(event.dataTransfer)) {
|
|
425
459
|
event.preventDefault()
|
|
@@ -499,6 +533,24 @@ export default class extends Controller {
|
|
|
499
533
|
})
|
|
500
534
|
}
|
|
501
535
|
|
|
536
|
+
quoteComment(commentId, selectedText) {
|
|
537
|
+
if (!commentId || !selectedText) return
|
|
538
|
+
this.quotedCommentIdTarget.value = commentId
|
|
539
|
+
this.quotedTextTarget.value = selectedText
|
|
540
|
+
this.quoteIndicatorTarget.style.display = ''
|
|
541
|
+
this.quoteIndicatorTextTarget.textContent = selectedText.length > 80
|
|
542
|
+
? selectedText.substring(0, 80) + '…'
|
|
543
|
+
: selectedText
|
|
544
|
+
this.focusTextarea()
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
cancelQuote() {
|
|
548
|
+
this.quotedCommentIdTarget.value = ''
|
|
549
|
+
this.quotedTextTarget.value = ''
|
|
550
|
+
this.quoteIndicatorTarget.style.display = 'none'
|
|
551
|
+
this.quoteIndicatorTextTarget.textContent = ''
|
|
552
|
+
}
|
|
553
|
+
|
|
502
554
|
renderCommentHtml(html, { replaceExisting = false } = {}) {
|
|
503
555
|
const listElement = document.getElementById('comments-list')
|
|
504
556
|
if (!listElement || !html) return
|
|
@@ -2,6 +2,7 @@ import { Controller } from '@hotwired/stimulus'
|
|
|
2
2
|
import { createSubscription } from '../../services/cable'
|
|
3
3
|
|
|
4
4
|
const TYPING_TIMEOUT = 3000
|
|
5
|
+
const AGENT_STATUS_TIMEOUT = 10000 // Safety timeout for agent_status (heartbeat expected every 3s)
|
|
5
6
|
|
|
6
7
|
export default class extends Controller {
|
|
7
8
|
static targets = ['participants', 'typingIndicator', 'textarea', 'privateCheckbox']
|
|
@@ -158,8 +159,20 @@ export default class extends Controller {
|
|
|
158
159
|
}
|
|
159
160
|
if (status === 'thinking' || status === 'streaming') {
|
|
160
161
|
this.typingUsers[id] = name
|
|
162
|
+
// Safety timeout: auto-remove if no heartbeat within AGENT_STATUS_TIMEOUT
|
|
163
|
+
if (!this.agentStatusTimers) this.agentStatusTimers = {}
|
|
164
|
+
if (this.agentStatusTimers[id]) clearTimeout(this.agentStatusTimers[id])
|
|
165
|
+
this.agentStatusTimers[id] = setTimeout(() => {
|
|
166
|
+
delete this.typingUsers[id]
|
|
167
|
+
delete this.agentStatusTimers[id]
|
|
168
|
+
this.renderTypingIndicator()
|
|
169
|
+
}, AGENT_STATUS_TIMEOUT)
|
|
161
170
|
} else {
|
|
162
171
|
delete this.typingUsers[id]
|
|
172
|
+
if (this.agentStatusTimers?.[id]) {
|
|
173
|
+
clearTimeout(this.agentStatusTimers[id])
|
|
174
|
+
delete this.agentStatusTimers[id]
|
|
175
|
+
}
|
|
163
176
|
}
|
|
164
177
|
this.renderTypingIndicator()
|
|
165
178
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus'
|
|
2
2
|
import { renderCreativeTree, dispatchCreativeTreeUpdated } from '../../creatives/tree_renderer'
|
|
3
|
+
import { parseEmojis } from '../../utils/emoji_parser'
|
|
3
4
|
|
|
4
5
|
export default class extends Controller {
|
|
5
6
|
static values = {
|
|
@@ -121,7 +122,7 @@ export default class extends Controller {
|
|
|
121
122
|
emojiString = rootStyle.getPropertyValue('--creative-loading-emojis').replace(/"/g, '').trim()
|
|
122
123
|
}
|
|
123
124
|
|
|
124
|
-
const emojis = emojiString
|
|
125
|
+
const emojis = parseEmojis(emojiString)
|
|
125
126
|
|
|
126
127
|
let emojiIndex = 0
|
|
127
128
|
let frame = 0 // 0: ..., 1: ..E, 2: .E., 3: E..
|
|
@@ -6,7 +6,9 @@ export default class extends CommonPopupController {
|
|
|
6
6
|
|
|
7
7
|
connect() {
|
|
8
8
|
super.connect()
|
|
9
|
-
this.
|
|
9
|
+
this._debounceTimer = null
|
|
10
|
+
this._searchToken = 0
|
|
11
|
+
this.inputTarget.addEventListener('input', this._debouncedSearch.bind(this))
|
|
10
12
|
this.inputTarget.addEventListener('keydown', this.handleInputKeydown.bind(this))
|
|
11
13
|
this.closeTarget.addEventListener('click', () => this.close())
|
|
12
14
|
|
|
@@ -14,6 +16,14 @@ export default class extends CommonPopupController {
|
|
|
14
16
|
this.open = this.open.bind(this)
|
|
15
17
|
}
|
|
16
18
|
|
|
19
|
+
disconnect() {
|
|
20
|
+
if (this._debounceTimer) {
|
|
21
|
+
clearTimeout(this._debounceTimer)
|
|
22
|
+
this._debounceTimer = null
|
|
23
|
+
}
|
|
24
|
+
super.disconnect()
|
|
25
|
+
}
|
|
26
|
+
|
|
17
27
|
open(anchorRect, onSelectCallback, onCloseCallback) {
|
|
18
28
|
this.onSelectCallback = onSelectCallback
|
|
19
29
|
this.onCloseCallback = onCloseCallback
|
|
@@ -27,6 +37,10 @@ export default class extends CommonPopupController {
|
|
|
27
37
|
}
|
|
28
38
|
|
|
29
39
|
close() {
|
|
40
|
+
if (this._debounceTimer) {
|
|
41
|
+
clearTimeout(this._debounceTimer)
|
|
42
|
+
this._debounceTimer = null
|
|
43
|
+
}
|
|
30
44
|
// super.close() calls popup.hide(), which triggers dispatchClose,
|
|
31
45
|
// where we now handle the callback. So we just need to call super.close().
|
|
32
46
|
super.close()
|
|
@@ -42,21 +56,33 @@ export default class extends CommonPopupController {
|
|
|
42
56
|
}
|
|
43
57
|
}
|
|
44
58
|
|
|
59
|
+
_debouncedSearch() {
|
|
60
|
+
if (this._debounceTimer) clearTimeout(this._debounceTimer)
|
|
61
|
+
this._debounceTimer = setTimeout(() => this.search(), 300)
|
|
62
|
+
}
|
|
63
|
+
|
|
45
64
|
search() {
|
|
46
65
|
const query = this.inputTarget.value.trim()
|
|
47
|
-
if (
|
|
66
|
+
if (query.length < 3) {
|
|
67
|
+
this._searchToken++
|
|
48
68
|
this.setItems([])
|
|
49
69
|
return
|
|
50
70
|
}
|
|
51
71
|
|
|
72
|
+
const token = ++this._searchToken
|
|
52
73
|
creativesApi.search(query, { simple: true })
|
|
53
74
|
.then((results) => {
|
|
75
|
+
// Discard stale responses if input changed since this request
|
|
76
|
+
if (token !== this._searchToken) return
|
|
77
|
+
|
|
54
78
|
const items = Array.isArray(results)
|
|
55
79
|
? results.map((result) => ({ id: result.id, label: result.description }))
|
|
56
80
|
: []
|
|
57
81
|
this.setItems(items)
|
|
58
82
|
})
|
|
59
|
-
.catch(() =>
|
|
83
|
+
.catch(() => {
|
|
84
|
+
if (token === this._searchToken) this.setItems([])
|
|
85
|
+
})
|
|
60
86
|
}
|
|
61
87
|
|
|
62
88
|
// Override select to invoke callback
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { wrapHtmlInCodeBlocks } from '../html_code_block_wrapper'
|
|
2
|
+
|
|
3
|
+
describe('wrapHtmlInCodeBlocks', () => {
|
|
4
|
+
// --- No change cases ---
|
|
5
|
+
|
|
6
|
+
test('returns unchanged for null/undefined/empty', () => {
|
|
7
|
+
expect(wrapHtmlInCodeBlocks(null)).toEqual({ changed: false, result: null })
|
|
8
|
+
expect(wrapHtmlInCodeBlocks(undefined)).toEqual({ changed: false, result: undefined })
|
|
9
|
+
expect(wrapHtmlInCodeBlocks('')).toEqual({ changed: false, result: '' })
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
test('returns unchanged for plain text', () => {
|
|
13
|
+
const text = 'Hello world, no HTML here'
|
|
14
|
+
expect(wrapHtmlInCodeBlocks(text)).toEqual({ changed: false, result: text })
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test('returns unchanged for text with angle brackets but no tags', () => {
|
|
18
|
+
expect(wrapHtmlInCodeBlocks('5 > 3 and 2 < 4')).toEqual({
|
|
19
|
+
changed: false,
|
|
20
|
+
result: '5 > 3 and 2 < 4',
|
|
21
|
+
})
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('returns unchanged for already code-blocked content', () => {
|
|
25
|
+
const text = '```html\n<div>hello</div>\n```'
|
|
26
|
+
expect(wrapHtmlInCodeBlocks(text)).toEqual({ changed: false, result: text })
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('returns unchanged for code block with leading whitespace', () => {
|
|
30
|
+
const text = ' ```\n<div>hello</div>\n```'
|
|
31
|
+
expect(wrapHtmlInCodeBlocks(text)).toEqual({ changed: false, result: text })
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
// --- Single HTML block ---
|
|
35
|
+
|
|
36
|
+
test('wraps a single div', () => {
|
|
37
|
+
const { changed, result } = wrapHtmlInCodeBlocks('<div>hello</div>')
|
|
38
|
+
expect(changed).toBe(true)
|
|
39
|
+
expect(result).toBe('```html\n<div>hello</div>\n```')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('wraps a div with attributes', () => {
|
|
43
|
+
const { changed, result } = wrapHtmlInCodeBlocks('<div class="test" id="foo">content</div>')
|
|
44
|
+
expect(changed).toBe(true)
|
|
45
|
+
expect(result).toBe('```html\n<div class="test" id="foo">content</div>\n```')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('wraps nested HTML', () => {
|
|
49
|
+
const html = '<div><p>hello</p><span>world</span></div>'
|
|
50
|
+
const { changed, result } = wrapHtmlInCodeBlocks(html)
|
|
51
|
+
expect(changed).toBe(true)
|
|
52
|
+
expect(result).toBe('```html\n' + html + '\n```')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('wraps self-closing tag', () => {
|
|
56
|
+
const { changed, result } = wrapHtmlInCodeBlocks('<br/>')
|
|
57
|
+
expect(changed).toBe(true)
|
|
58
|
+
expect(result).toBe('```html\n<br/>\n```')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('wraps self-closing tag with space', () => {
|
|
62
|
+
const { changed, result } = wrapHtmlInCodeBlocks('<img src="test.png" />')
|
|
63
|
+
expect(changed).toBe(true)
|
|
64
|
+
expect(result).toBe('```html\n<img src="test.png" />\n```')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
// --- HTML with surrounding text ---
|
|
68
|
+
|
|
69
|
+
test('wraps HTML with text before', () => {
|
|
70
|
+
const { changed, result } = wrapHtmlInCodeBlocks('여기 봐봐 <div>hello</div>')
|
|
71
|
+
expect(changed).toBe(true)
|
|
72
|
+
expect(result).toBe('여기 봐봐 \n\n```html\n<div>hello</div>\n```')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('wraps HTML with text after', () => {
|
|
76
|
+
const { changed, result } = wrapHtmlInCodeBlocks('<div>hello</div> 이거 뭐야?')
|
|
77
|
+
expect(changed).toBe(true)
|
|
78
|
+
expect(result).toBe('```html\n<div>hello</div>\n```\n\n 이거 뭐야?')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('wraps HTML with text before and after', () => {
|
|
82
|
+
const { changed, result } = wrapHtmlInCodeBlocks('여기 봐봐 <div>hello</div> 이거 뭐야?')
|
|
83
|
+
expect(changed).toBe(true)
|
|
84
|
+
expect(result).toBe('여기 봐봐 \n\n```html\n<div>hello</div>\n```\n\n 이거 뭐야?')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
// --- Multiple HTML blocks ---
|
|
88
|
+
|
|
89
|
+
test('wraps multiple separate HTML blocks', () => {
|
|
90
|
+
const { changed, result } = wrapHtmlInCodeBlocks('<div>first</div> text <span>second</span>')
|
|
91
|
+
expect(changed).toBe(true)
|
|
92
|
+
expect(result).toBe('```html\n<div>first</div>\n```\n\n text \n\n```html\n<span>second</span>\n```')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
// --- Complex real-world HTML ---
|
|
96
|
+
|
|
97
|
+
test('wraps complex HTML with attributes and nesting', () => {
|
|
98
|
+
const html = '<div id="llm-model-suggestions" class="common-popup mention-popup" style="display: none;"><ul class="common-popup-list"><li class="common-popup-item"><div class="mention-item">gemini-2.5-flash</div></li></ul></div>'
|
|
99
|
+
const { changed, result } = wrapHtmlInCodeBlocks(html)
|
|
100
|
+
expect(changed).toBe(true)
|
|
101
|
+
expect(result).toBe('```html\n' + html + '\n```')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('wraps complex HTML with surrounding text', () => {
|
|
105
|
+
const html = '<div id="test" class="popup" style="display: none;"><ul><li>item</li></ul></div>'
|
|
106
|
+
const input = '이 코드를 봐줘 ' + html + ' 이게 맞아?'
|
|
107
|
+
const { changed, result } = wrapHtmlInCodeBlocks(input)
|
|
108
|
+
expect(changed).toBe(true)
|
|
109
|
+
expect(result).toBe('이 코드를 봐줘 \n\n```html\n' + html + '\n```\n\n 이게 맞아?')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
// --- Multiline HTML ---
|
|
113
|
+
|
|
114
|
+
test('wraps multiline HTML', () => {
|
|
115
|
+
const html = '<div>\n <p>hello</p>\n <p>world</p>\n</div>'
|
|
116
|
+
const { changed, result } = wrapHtmlInCodeBlocks(html)
|
|
117
|
+
expect(changed).toBe(true)
|
|
118
|
+
expect(result).toBe('```html\n' + html + '\n```')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
// --- Edge cases ---
|
|
122
|
+
|
|
123
|
+
test('handles single unclosed tag (no match)', () => {
|
|
124
|
+
const text = '<div>hello but no closing tag'
|
|
125
|
+
expect(wrapHtmlInCodeBlocks(text)).toEqual({ changed: false, result: text })
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('handles mismatched tags (no match)', () => {
|
|
129
|
+
const text = '<div>hello</span>'
|
|
130
|
+
expect(wrapHtmlInCodeBlocks(text)).toEqual({ changed: false, result: text })
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('handles HTML-like content in markdown', () => {
|
|
134
|
+
const { changed, result } = wrapHtmlInCodeBlocks('<b>bold text</b>')
|
|
135
|
+
expect(changed).toBe(true)
|
|
136
|
+
expect(result).toBe('```html\n<b>bold text</b>\n```')
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('handles table HTML', () => {
|
|
140
|
+
const html = '<table><tr><td>cell</td></tr></table>'
|
|
141
|
+
const { changed, result } = wrapHtmlInCodeBlocks(html)
|
|
142
|
+
expect(changed).toBe(true)
|
|
143
|
+
expect(result).toBe('```html\n' + html + '\n```')
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
test('handles input self-closing tag', () => {
|
|
147
|
+
const { changed, result } = wrapHtmlInCodeBlocks('<input type="text" />')
|
|
148
|
+
expect(changed).toBe(true)
|
|
149
|
+
expect(result).toBe('```html\n<input type="text" />\n```')
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
test('does not wrap markdown syntax', () => {
|
|
153
|
+
const text = '# heading\n\n**bold** and *italic*'
|
|
154
|
+
expect(wrapHtmlInCodeBlocks(text)).toEqual({ changed: false, result: text })
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test('does not wrap template literals with angle brackets', () => {
|
|
158
|
+
const text = 'array.filter(x => x > 0)'
|
|
159
|
+
expect(wrapHtmlInCodeBlocks(text)).toEqual({ changed: false, result: text })
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
// --- Failure scenario: typing text then pasting HTML ---
|
|
163
|
+
|
|
164
|
+
test('text followed by HTML renders as proper markdown code block', () => {
|
|
165
|
+
const { changed, result } = wrapHtmlInCodeBlocks('test <html><h1>hello</h1></html>')
|
|
166
|
+
expect(changed).toBe(true)
|
|
167
|
+
// Must have blank line before code fence for marked to parse correctly
|
|
168
|
+
expect(result).toBe('test \n\n```html\n<html><h1>hello</h1></html>\n```')
|
|
169
|
+
expect(result).toContain('\n\n```html')
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
// --- Double-wrap prevention ---
|
|
173
|
+
|
|
174
|
+
test('does not double-wrap HTML inside existing code blocks', () => {
|
|
175
|
+
const input = '"\n```html\n<button type="button" class="popup-close-btn">×</button>\n```\n"'
|
|
176
|
+
const { changed } = wrapHtmlInCodeBlocks(input)
|
|
177
|
+
expect(changed).toBe(false)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
test('does not wrap HTML inside code blocks but wraps HTML outside', () => {
|
|
181
|
+
const input = 'before\n```html\n<div>inside</div>\n```\nafter <span>outside</span> end'
|
|
182
|
+
const { changed, result } = wrapHtmlInCodeBlocks(input)
|
|
183
|
+
expect(changed).toBe(true)
|
|
184
|
+
expect(result).toContain('```html\n<div>inside</div>\n```')
|
|
185
|
+
expect(result).toContain('```html\n<span>outside</span>\n```')
|
|
186
|
+
// The inside div should NOT be double-wrapped
|
|
187
|
+
expect(result).not.toContain('```html\n```html')
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
test('mixed text with code block containing HTML is not double-wrapped', () => {
|
|
191
|
+
const input = 'some text\n```\n<button>click</button>\n```\nmore text'
|
|
192
|
+
const { changed } = wrapHtmlInCodeBlocks(input)
|
|
193
|
+
expect(changed).toBe(false)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
test('text starting with quote then code block is not double-wrapped', () => {
|
|
197
|
+
const input = '"\n```html\n<button id="close-slack-modal" class="popup-close-btn">×</button>\n```\n"'
|
|
198
|
+
const { changed } = wrapHtmlInCodeBlocks(input)
|
|
199
|
+
expect(changed).toBe(false)
|
|
200
|
+
})
|
|
201
|
+
})
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wraps HTML tag blocks in a string with markdown code blocks.
|
|
3
|
+
* Handles nested tags of the same name correctly.
|
|
4
|
+
*
|
|
5
|
+
* @param {string} text - The input text potentially containing HTML
|
|
6
|
+
* @returns {{ changed: boolean, result: string }}
|
|
7
|
+
*/
|
|
8
|
+
export function wrapHtmlInCodeBlocks(text) {
|
|
9
|
+
if (!text) return { changed: false, result: text }
|
|
10
|
+
|
|
11
|
+
// If entire text is a single code block, skip
|
|
12
|
+
if (text.trimStart().startsWith('```') && text.trimEnd().endsWith('```')) {
|
|
13
|
+
return { changed: false, result: text }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const segments = extractHtmlSegments(text)
|
|
17
|
+
if (!segments) return { changed: false, result: text }
|
|
18
|
+
|
|
19
|
+
let result = ''
|
|
20
|
+
let lastIndex = 0
|
|
21
|
+
|
|
22
|
+
for (const seg of segments) {
|
|
23
|
+
result += text.slice(lastIndex, seg.start)
|
|
24
|
+
result += '\n\n```html\n' + seg.html + '\n```\n\n'
|
|
25
|
+
lastIndex = seg.end
|
|
26
|
+
}
|
|
27
|
+
result += text.slice(lastIndex)
|
|
28
|
+
|
|
29
|
+
// Trim leading/trailing newlines added by wrapping
|
|
30
|
+
result = result.replace(/^\n+/, '').replace(/\n+$/, '')
|
|
31
|
+
|
|
32
|
+
return { changed: true, result }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Self-closing tag pattern: <tagname ... />
|
|
36
|
+
const SELF_CLOSING_RE = /<([a-zA-Z][a-zA-Z0-9]*)\b[^>]*\/>/g
|
|
37
|
+
|
|
38
|
+
// Opening tag pattern: <tagname ...>
|
|
39
|
+
const OPEN_TAG_RE = /<([a-zA-Z][a-zA-Z0-9]*)\b[^>]*>/g
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Find HTML segments (paired open/close tags or self-closing tags) in text.
|
|
43
|
+
* Returns array of { start, end, html } or null if no HTML found.
|
|
44
|
+
*/
|
|
45
|
+
function extractHtmlSegments(text) {
|
|
46
|
+
const segments = []
|
|
47
|
+
|
|
48
|
+
// First pass: find self-closing tags
|
|
49
|
+
const selfClosing = []
|
|
50
|
+
let m
|
|
51
|
+
SELF_CLOSING_RE.lastIndex = 0
|
|
52
|
+
while ((m = SELF_CLOSING_RE.exec(text)) !== null) {
|
|
53
|
+
selfClosing.push({ start: m.index, end: m.index + m[0].length })
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Second pass: find paired open/close tags with nesting support
|
|
57
|
+
let i = 0
|
|
58
|
+
while (i < text.length) {
|
|
59
|
+
// Skip positions inside already-found self-closing tags
|
|
60
|
+
if (selfClosing.some((sc) => i >= sc.start && i < sc.end)) {
|
|
61
|
+
const sc = selfClosing.find((sc) => i >= sc.start && i < sc.end)
|
|
62
|
+
i = sc.end
|
|
63
|
+
continue
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Look for opening tag at position i
|
|
67
|
+
OPEN_TAG_RE.lastIndex = i
|
|
68
|
+
const openMatch = OPEN_TAG_RE.exec(text)
|
|
69
|
+
if (!openMatch || openMatch.index !== i) {
|
|
70
|
+
i++
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Check if this is actually a self-closing tag
|
|
75
|
+
if (openMatch[0].endsWith('/>')) {
|
|
76
|
+
segments.push({
|
|
77
|
+
start: openMatch.index,
|
|
78
|
+
end: openMatch.index + openMatch[0].length,
|
|
79
|
+
html: openMatch[0],
|
|
80
|
+
})
|
|
81
|
+
i = openMatch.index + openMatch[0].length
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const tagName = openMatch[1]
|
|
86
|
+
const searchStart = openMatch.index + openMatch[0].length
|
|
87
|
+
|
|
88
|
+
// Find matching close tag with nesting support
|
|
89
|
+
const closeIndex = findMatchingCloseTag(text, tagName, searchStart)
|
|
90
|
+
if (closeIndex === -1) {
|
|
91
|
+
i++
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const closeTag = `</${tagName}>`
|
|
96
|
+
const endPos = closeIndex + closeTag.length
|
|
97
|
+
segments.push({
|
|
98
|
+
start: openMatch.index,
|
|
99
|
+
end: endPos,
|
|
100
|
+
html: text.slice(openMatch.index, endPos),
|
|
101
|
+
})
|
|
102
|
+
i = endPos
|
|
103
|
+
continue
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Add self-closing tags that aren't inside paired segments
|
|
107
|
+
for (const sc of selfClosing) {
|
|
108
|
+
if (!segments.some((seg) => sc.start >= seg.start && sc.end <= seg.end)) {
|
|
109
|
+
segments.push({ start: sc.start, end: sc.end, html: text.slice(sc.start, sc.end) })
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (segments.length === 0) return null
|
|
114
|
+
|
|
115
|
+
// Sort by position
|
|
116
|
+
segments.sort((a, b) => a.start - b.start)
|
|
117
|
+
|
|
118
|
+
// Filter out segments inside existing markdown code blocks
|
|
119
|
+
const codeBlockRanges = []
|
|
120
|
+
const codeBlockRe = /^```[^\n]*\n[\s\S]*?^```/gm
|
|
121
|
+
let cbMatch
|
|
122
|
+
while ((cbMatch = codeBlockRe.exec(text)) !== null) {
|
|
123
|
+
codeBlockRanges.push({ start: cbMatch.index, end: cbMatch.index + cbMatch[0].length })
|
|
124
|
+
}
|
|
125
|
+
if (codeBlockRanges.length > 0) {
|
|
126
|
+
const filtered = segments.filter(
|
|
127
|
+
(seg) => !codeBlockRanges.some((cb) => seg.start >= cb.start && seg.end <= cb.end)
|
|
128
|
+
)
|
|
129
|
+
return filtered.length > 0 ? filtered : null
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return segments
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Find the matching close tag for tagName, handling nested same-name tags.
|
|
137
|
+
* Returns the index of the matching </tagName> or -1.
|
|
138
|
+
*/
|
|
139
|
+
function findMatchingCloseTag(text, tagName, startFrom) {
|
|
140
|
+
const openPattern = new RegExp(`<${tagName}\\b[^>]*>`, 'gi')
|
|
141
|
+
const closePattern = new RegExp(`</${tagName}>`, 'gi')
|
|
142
|
+
|
|
143
|
+
let depth = 1
|
|
144
|
+
let pos = startFrom
|
|
145
|
+
|
|
146
|
+
while (depth > 0 && pos < text.length) {
|
|
147
|
+
openPattern.lastIndex = pos
|
|
148
|
+
closePattern.lastIndex = pos
|
|
149
|
+
|
|
150
|
+
const nextOpen = openPattern.exec(text)
|
|
151
|
+
const nextClose = closePattern.exec(text)
|
|
152
|
+
|
|
153
|
+
if (!nextClose) return -1 // No closing tag found
|
|
154
|
+
|
|
155
|
+
if (nextOpen && nextOpen.index < nextClose.index && !nextOpen[0].endsWith('/>')) {
|
|
156
|
+
// Nested open tag comes first
|
|
157
|
+
depth++
|
|
158
|
+
pos = nextOpen.index + nextOpen[0].length
|
|
159
|
+
} else {
|
|
160
|
+
// Close tag comes first (or no more open tags)
|
|
161
|
+
depth--
|
|
162
|
+
if (depth === 0) return nextClose.index
|
|
163
|
+
pos = nextClose.index + nextClose[0].length
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return -1
|
|
168
|
+
}
|
|
@@ -11,7 +11,8 @@ export function renderMarkdownInline(html) {
|
|
|
11
11
|
|
|
12
12
|
export function renderCommentMarkdown(text) {
|
|
13
13
|
const content = text || ''
|
|
14
|
-
const
|
|
14
|
+
const useBlock = content.includes('\n') || content.includes('```')
|
|
15
|
+
const html = useBlock ? marked.parse(content) : marked.parseInline(content)
|
|
15
16
|
return DOMPurify.sanitize(html.trim())
|
|
16
17
|
}
|
|
17
18
|
|
|
@@ -1751,7 +1751,11 @@ export function initializeCreativeRowEditor() {
|
|
|
1751
1751
|
}
|
|
1752
1752
|
}
|
|
1753
1753
|
updateProgressInputAvailability(readProgressValue());
|
|
1754
|
-
|
|
1754
|
+
// Save immediately on checkbox change to prevent losing the last toggle
|
|
1755
|
+
// when the user navigates away before the debounce timer fires.
|
|
1756
|
+
pendingSave = true;
|
|
1757
|
+
clearTimeout(saveTimer);
|
|
1758
|
+
saveForm();
|
|
1755
1759
|
});
|
|
1756
1760
|
}
|
|
1757
1761
|
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const DEFAULT_EMOJIS = ['🎨', '💡', '🚀', '✨', '🧩', '🎲']
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Parse a CSS custom property string of emojis into an array.
|
|
5
|
+
* Supports both comma-separated ("🔵, 🔴, 🟡") and concatenated ("🍅🔴🌶️") formats.
|
|
6
|
+
*
|
|
7
|
+
* @param {string} emojiString - raw string from CSS --creative-loading-emojis
|
|
8
|
+
* @returns {string[]} array of individual emoji characters
|
|
9
|
+
*/
|
|
10
|
+
export function parseEmojis(emojiString) {
|
|
11
|
+
if (!emojiString) return DEFAULT_EMOJIS
|
|
12
|
+
|
|
13
|
+
let emojis
|
|
14
|
+
if (emojiString.includes(',')) {
|
|
15
|
+
emojis = emojiString.split(',').map(e => e.trim()).filter(e => e)
|
|
16
|
+
} else {
|
|
17
|
+
emojis = [...emojiString.matchAll(/\p{Extended_Pictographic}(?:\u{FE0F}|\u{200D}\p{Extended_Pictographic})*/gu)].map(m => m[0])
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return emojis.length > 0 ? emojis : DEFAULT_EMOJIS
|
|
21
|
+
}
|
|
@@ -23,10 +23,14 @@ module Collavre
|
|
|
23
23
|
topic_id: context&.dig("topic", "id")
|
|
24
24
|
)
|
|
25
25
|
|
|
26
|
-
# Record task for loop breaker tracking
|
|
26
|
+
# Record task for loop breaker tracking (per-topic, skip user-initiated)
|
|
27
27
|
creative_id = context&.dig("creative", "id")
|
|
28
28
|
if creative_id
|
|
29
|
-
|
|
29
|
+
from_ai = context&.dig("comment", "from_ai") == true
|
|
30
|
+
topic_id = context&.dig("topic", "id")
|
|
31
|
+
Orchestration::LoopBreaker.new(context).record_task(
|
|
32
|
+
creative_id, agent.id, topic_id: topic_id, triggered_by_user: !from_ai
|
|
33
|
+
)
|
|
30
34
|
end
|
|
31
35
|
end
|
|
32
36
|
|