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.
Files changed (110) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/collavre/actiontext.css +73 -71
  3. data/app/assets/stylesheets/collavre/activity_logs.css +18 -45
  4. data/app/assets/stylesheets/collavre/comments_popup.css +197 -35
  5. data/app/assets/stylesheets/collavre/creatives.css +101 -51
  6. data/app/assets/stylesheets/collavre/dark_mode.css +221 -88
  7. data/app/assets/stylesheets/collavre/design_tokens.css +334 -0
  8. data/app/assets/stylesheets/collavre/mention_menu.css +13 -9
  9. data/app/assets/stylesheets/collavre/popup.css +57 -27
  10. data/app/assets/stylesheets/collavre/slide_view.css +6 -6
  11. data/app/assets/stylesheets/collavre/user_menu.css +4 -5
  12. data/app/components/collavre/plans_timeline_component.html.erb +2 -2
  13. data/app/controllers/collavre/admin/orchestration_controller.rb +9 -2
  14. data/app/controllers/collavre/admin/settings_controller.rb +199 -0
  15. data/app/controllers/collavre/comments/reactions_controller.rb +1 -9
  16. data/app/controllers/collavre/comments_controller.rb +39 -162
  17. data/app/controllers/collavre/creatives_controller.rb +18 -58
  18. data/app/controllers/collavre/users_controller.rb +31 -3
  19. data/app/helpers/collavre/application_helper.rb +97 -0
  20. data/app/helpers/collavre/creatives_helper.rb +10 -202
  21. data/app/javascript/collavre.js +0 -1
  22. data/app/javascript/components/creative_tree_row.js +3 -2
  23. data/app/javascript/controllers/comment_controller.js +309 -4
  24. data/app/javascript/controllers/comments/form_controller.js +52 -0
  25. data/app/javascript/controllers/comments/presence_controller.js +13 -0
  26. data/app/javascript/controllers/creatives/tree_controller.js +2 -1
  27. data/app/javascript/controllers/link_creative_controller.js +29 -3
  28. data/app/javascript/lib/__tests__/html_code_block_wrapper.test.js +201 -0
  29. data/app/javascript/lib/html_code_block_wrapper.js +168 -0
  30. data/app/javascript/lib/utils/markdown.js +2 -1
  31. data/app/javascript/modules/creative_row_editor.js +5 -1
  32. data/app/javascript/utils/emoji_parser.js +21 -0
  33. data/app/jobs/collavre/ai_agent_job.rb +6 -2
  34. data/app/jobs/collavre/cron_action_job.rb +18 -6
  35. data/app/jobs/collavre/cron_scheduler_job.rb +112 -0
  36. data/app/models/collavre/comment/approvable.rb +50 -0
  37. data/app/models/collavre/comment/broadcastable.rb +119 -0
  38. data/app/models/collavre/comment/notifiable.rb +111 -0
  39. data/app/models/collavre/comment.rb +13 -258
  40. data/app/models/collavre/comment_reaction.rb +15 -0
  41. data/app/models/collavre/creative/describable.rb +86 -0
  42. data/app/models/collavre/creative/linkable.rb +77 -0
  43. data/app/models/collavre/creative/permissible.rb +103 -0
  44. data/app/models/collavre/creative.rb +3 -289
  45. data/app/models/collavre/orchestrator_policy.rb +1 -1
  46. data/app/models/collavre/system_setting.rb +27 -1
  47. data/app/models/collavre/user.rb +42 -0
  48. data/app/models/collavre/user_theme.rb +10 -0
  49. data/app/services/collavre/ai_agent/approval_handler.rb +110 -0
  50. data/app/services/collavre/ai_agent/message_builder.rb +129 -0
  51. data/app/services/collavre/ai_agent/review_handler.rb +70 -0
  52. data/app/services/collavre/ai_agent_service.rb +93 -150
  53. data/app/services/collavre/ai_client.rb +23 -4
  54. data/app/services/collavre/auto_theme_generator.rb +168 -50
  55. data/app/services/collavre/command_menu_service.rb +70 -0
  56. data/app/services/collavre/comment_move_service.rb +94 -0
  57. data/app/services/collavre/comments/action_executor.rb +10 -0
  58. data/app/services/collavre/comments/mcp_command.rb +1 -2
  59. data/app/services/collavre/creatives/create_service.rb +86 -0
  60. data/app/services/collavre/creatives/destroy_service.rb +41 -0
  61. data/app/services/collavre/creatives/index_query.rb +3 -0
  62. data/app/services/collavre/markdown_converter.rb +240 -0
  63. data/app/services/collavre/mention_parser.rb +63 -0
  64. data/app/services/collavre/orchestration/agent_context_builder.rb +24 -8
  65. data/app/services/collavre/orchestration/agent_orchestrator.rb +59 -10
  66. data/app/services/collavre/orchestration/loop_breaker.rb +12 -7
  67. data/app/services/collavre/orchestration/policy_resolver.rb +16 -2
  68. data/app/services/collavre/orchestration/scheduler.rb +4 -3
  69. data/app/services/collavre/orchestration/stuck_detector.rb +1 -1
  70. data/app/services/collavre/system_events/context_builder.rb +1 -6
  71. data/app/services/collavre/tools/creative_batch_service.rb +107 -0
  72. data/app/services/collavre/tools/creative_update_service.rb +17 -12
  73. data/app/services/collavre/tools/cron_create_service.rb +17 -5
  74. data/app/views/admin/shared/_tabs.html.erb +2 -1
  75. data/app/views/collavre/admin/orchestration/show.html.erb +11 -0
  76. data/app/views/collavre/admin/settings/_system_tab.html.erb +138 -0
  77. data/app/views/collavre/admin/settings/_uiux_tab.html.erb +44 -0
  78. data/app/views/collavre/admin/settings/index.html.erb +11 -0
  79. data/app/views/collavre/admin/settings/uiux.html.erb +11 -0
  80. data/app/views/collavre/comments/_comment.html.erb +15 -5
  81. data/app/views/collavre/comments/_comments_popup.html.erb +9 -2
  82. data/app/views/collavre/creatives/_mobile_actions_menu.html.erb +0 -3
  83. data/app/views/collavre/creatives/_share_button.html.erb +0 -52
  84. data/app/views/collavre/creatives/_share_modal.html.erb +52 -0
  85. data/app/views/collavre/creatives/index.html.erb +5 -8
  86. data/app/views/collavre/shared/navigation/_panels.html.erb +2 -2
  87. data/app/views/collavre/user_themes/index.html.erb +7 -9
  88. data/app/views/collavre/users/_contact_management.html.erb +2 -1
  89. data/app/views/collavre/users/edit_ai.html.erb +7 -0
  90. data/app/views/collavre/users/index.html.erb +16 -1
  91. data/app/views/collavre/users/new_ai.html.erb +18 -8
  92. data/app/views/collavre/users/passkeys.html.erb +1 -1
  93. data/app/views/collavre/users/show.html.erb +1 -1
  94. data/app/views/layouts/collavre/slide.html.erb +8 -1
  95. data/config/locales/admin.en.yml +88 -0
  96. data/config/locales/admin.ko.yml +88 -0
  97. data/config/locales/ai_agent.en.yml +5 -1
  98. data/config/locales/ai_agent.ko.yml +5 -1
  99. data/config/locales/comments.en.yml +5 -1
  100. data/config/locales/comments.ko.yml +5 -1
  101. data/config/locales/orchestration.en.yml +8 -0
  102. data/config/locales/orchestration.ko.yml +8 -0
  103. data/config/locales/users.en.yml +12 -0
  104. data/config/locales/users.ko.yml +12 -0
  105. data/config/routes.rb +7 -1
  106. data/db/migrate/20260212011655_add_quoted_comment_to_comments.rb +7 -0
  107. data/db/migrate/20260213044247_add_agent_conf_to_users.rb +5 -0
  108. data/lib/collavre/engine.rb +25 -0
  109. data/lib/collavre/version.rb +1 -1
  110. metadata +32 -1
@@ -1,30 +1,55 @@
1
1
  module Collavre
2
2
  class AutoThemeGenerator
3
3
  REQUIRED_VARIABLES = %w[
4
- --color-bg
5
- --color-text
4
+ --surface-bg
5
+ --surface-nav
6
+ --surface-section
7
+ --surface-input
8
+ --surface-btn
9
+ --surface-secondary
10
+ --text-primary
11
+ --text-muted
12
+ --text-on-btn
13
+ --text-nav
14
+ --text-nav-btn
15
+ --text-chat-btn
16
+ --text-on-badge
17
+ --text-input
6
18
  --color-link
7
- --color-nav-bg
8
- --color-section-bg
9
- --color-btn-bg
10
- --color-btn-text
11
- --color-border
12
- --color-muted
13
- --color-complete
14
- --color-chip-bg
15
- --color-drag-over
16
- --color-drag-over-edge
17
- --hover-brightness
19
+ --color-brand
20
+ --color-active
21
+ --color-danger
22
+ --color-success
23
+ --color-warning
24
+ --color-highlight
18
25
  --color-badge-bg
19
- --color-badge-text
20
- --color-secondary-active
21
- --color-secondary-background
22
- --color-nav-btn-text
23
- --color-chat-btn-text
24
- --color-input-bg
25
- --color-input-text
26
- --color-nav-text
26
+ --color-accent-border
27
+ --color-accent-text
28
+ --color-code-bg
29
+ --color-code-text
30
+ --border-color
31
+ --border-drag-over
32
+ --border-drag-edge
33
+ --hover-brightness
27
34
  --creative-loading-emojis
35
+ --creative-h1-size
36
+ --creative-h2-size
37
+ --creative-h3-size
38
+ --creative-h1-weight
39
+ --creative-h2-weight
40
+ --creative-h3-weight
41
+ --creative-h1-color
42
+ --creative-h2-color
43
+ --creative-h3-color
44
+ --creative-childless-size
45
+ --creative-childless-weight
46
+ --creative-bullet-size
47
+ --creative-bullet-color
48
+ --creative-tree-line-color
49
+ --creative-tree-line-opacity
50
+ --creative-h1-bg
51
+ --creative-h2-bg
52
+ --creative-h3-bg
28
53
  ].freeze
29
54
 
30
55
  def initialize(client: default_client)
@@ -33,35 +58,128 @@ module Collavre
33
58
 
34
59
  def generate(prompt)
35
60
  system_prompt = <<~PROMPT
36
- You are an expert UI/UX designer specialized in creating color themes for web applications.
37
- Your task is to generate a JSON object containing CSS variables.
38
- Generate a CSS theme as a JSON object based on the prompt: "#{prompt}".
39
- The JSON must strictly contain ONLY these keys: #{REQUIRED_VARIABLES.join(', ')}.
40
-
41
- CRITICAL DESIGN RULES:
42
- 1. Use **only 'oklch()' color format** for all colors. Do not use hex, rgb, or hsl.
43
- 2. Ensure "--color-nav-btn-text" has High Contrast (WCAG AA/AAA) against "--color-bg" (which is used as the button background in the nav).
44
- 3. Ensure "--color-chat-btn-text" has High Contrast against "--color-section-bg" (where chat messages reside).
45
- 4. Ensure "--color-nav-text" has High Contrast against "--color-nav-bg".
46
- 5. Names of these text colors should be visually distinct from their background colors to ensure readability.
47
- 6. For "--creative-loading-emojis", provide a comma-separated string of exactly 6 emojis that match the theme mood (e.g., "🌵,🏜️,☀️,🦎,🌾,🐪").
48
- 7. Do not include any other keys or newlines.
49
- 8. Return valid JSON only.
50
-
51
- The JSON object must strictly follow this structure:
52
- {
53
- "--color-bg": "oklch(95% 0.01 200)",
54
- "--color-text": "oklch(20% 0.02 200)",
55
- ...
56
- }
57
-
58
- REQUIRED VARIABLES:
59
- #{REQUIRED_VARIABLES.join("\n")}
60
-
61
- GUIDELINES:
62
- - Ensure high contrast between text and background.
63
- - Maintain a consistent aesthetic suitable for the description.
64
- - Return ONLY the JSON object. No markdown formatting, no explanations.
61
+ You are a color theme designer for a workspace app.
62
+ Generate a JSON with ONLY these keys: #{REQUIRED_VARIABLES.join(', ')}.
63
+
64
+ FORMAT: hex colors (#rrggbb). --hover-brightness: "90%" (light) or "110%" (dark).
65
+ --creative-loading-emojis: 6 emojis. Return ONLY valid JSON, no markdown.
66
+
67
+ === REFERENCE THEMES (these are GOOD match this quality) ===
68
+
69
+ "바나나" (yellow, light):
70
+ {"--surface-bg":"#fdf7d0","--surface-nav":"#e8d36b","--surface-section":"#feeec1",
71
+ "--surface-input":"#ffffff","--surface-btn":"#e3b831","--surface-secondary":"#f4d9bb",
72
+ "--text-primary":"#1a1200","--text-muted":"#5a4e00","--text-on-btn":"#241100",
73
+ "--text-nav":"#1a1200","--text-nav-btn":"#1a1200","--text-chat-btn":"#1a1200",
74
+ "--text-on-badge":"#fff8e0","--text-input":"#1a1200",
75
+ "--color-link":"#c69900","--color-brand":"#c69900","--color-active":"#c08500",
76
+ "--color-danger":"#cc3300","--color-success":"#4a8c00","--color-warning":"#cc8800",
77
+ "--color-highlight":"#fff3a0","--color-badge-bg":"#ca9600",
78
+ "--color-accent-border":"#a67a00","--color-accent-text":"#c69900",
79
+ "--color-code-bg":"#f5efc0","--color-code-text":"#1a1200",
80
+ "--border-color":"#c4b060","--border-drag-over":"#d4a800","--border-drag-edge":"#c69900",
81
+ "--hover-brightness":"90%","--creative-loading-emojis":"🍌🌻💛🍋✨🌼",
82
+ "--creative-h1-size":"1.3em","--creative-h2-size":"1.2em","--creative-h3-size":"1.1em",
83
+ "--creative-h1-weight":"700","--creative-h2-weight":"600","--creative-h3-weight":"500",
84
+ "--creative-h1-color":"#1a1200","--creative-h2-color":"#1a1200","--creative-h3-color":"#5a4e00",
85
+ "--creative-childless-size":"1em","--creative-childless-weight":"400",
86
+ "--creative-bullet-size":"5px","--creative-bullet-color":"#1a1200",
87
+ "--creative-tree-line-color":"#c4b060","--creative-tree-line-opacity":"0.5",
88
+ "--creative-h1-bg":"transparent","--creative-h2-bg":"transparent","--creative-h3-bg":"transparent"}
89
+
90
+ "숲속" (green, light):
91
+ {"--surface-bg":"#ecf4ef","--surface-nav":"#d5e2d7","--surface-section":"#e2f4e7",
92
+ "--surface-input":"#f6f9f7","--surface-btn":"#357153","--surface-secondary":"#dbe8e3",
93
+ "--text-primary":"#0a1f10","--text-muted":"#3a5040","--text-on-btn":"#f3fbf6",
94
+ "--text-nav":"#0a1f10","--text-nav-btn":"#0a1f10","--text-chat-btn":"#0a1f10",
95
+ "--text-on-badge":"#f3fbf6","--text-input":"#0a1f10",
96
+ "--color-link":"#25984d","--color-brand":"#25984d","--color-active":"#008a39",
97
+ "--color-danger":"#cc3300","--color-success":"#00880a","--color-warning":"#cc8800",
98
+ "--color-highlight":"#c8f0d0","--color-badge-bg":"#005734",
99
+ "--color-accent-border":"#337344","--color-accent-text":"#1a3520",
100
+ "--color-code-bg":"#e0ede4","--color-code-text":"#0a1f10",
101
+ "--border-color":"#a0bca6","--border-drag-over":"#40905a","--border-drag-edge":"#25984d",
102
+ "--hover-brightness":"90%","--creative-loading-emojis":"🌲🍃🌿🌱✨🦎",
103
+ "--creative-h1-size":"1.3em","--creative-h2-size":"1.15em","--creative-h3-size":"1.05em",
104
+ "--creative-h1-weight":"700","--creative-h2-weight":"600","--creative-h3-weight":"500",
105
+ "--creative-h1-color":"#0a1f10","--creative-h2-color":"#0a1f10","--creative-h3-color":"#3a5040",
106
+ "--creative-childless-size":"1em","--creative-childless-weight":"400",
107
+ "--creative-bullet-size":"5px","--creative-bullet-color":"#25984d",
108
+ "--creative-tree-line-color":"#a0bca6","--creative-tree-line-opacity":"0.5",
109
+ "--creative-h1-bg":"transparent","--creative-h2-bg":"transparent","--creative-h3-bg":"transparent"}
110
+
111
+ "토마토" (red/warm, light):
112
+ {"--surface-bg":"#fbefea","--surface-nav":"#f7ded6","--surface-section":"#f5e8e4",
113
+ "--surface-input":"#fff6f3","--surface-btn":"#cc0000","--surface-secondary":"#f0cfc4",
114
+ "--text-primary":"#1f0800","--text-muted":"#5a3020","--text-on-btn":"#fff0e8",
115
+ "--text-nav":"#1f0800","--text-nav-btn":"#1f0800","--text-chat-btn":"#1f0800",
116
+ "--text-on-badge":"#fff0e8","--text-input":"#1f0800",
117
+ "--color-link":"#d40924","--color-brand":"#d40924","--color-active":"#ff4040",
118
+ "--color-danger":"#cc0000","--color-success":"#4a8c00","--color-warning":"#cc8800",
119
+ "--color-highlight":"#ffe0d0","--color-badge-bg":"#ba0d01",
120
+ "--color-accent-border":"#c85b32","--color-accent-text":"#b22800",
121
+ "--color-code-bg":"#f0e4de","--color-code-text":"#1f0800",
122
+ "--border-color":"#d0a898","--border-drag-over":"#e04020","--border-drag-edge":"#cc0000",
123
+ "--hover-brightness":"90%","--creative-loading-emojis":"🍅🔴🌶️🫕✨🍝",
124
+ "--creative-h1-size":"1.3em","--creative-h2-size":"1.2em","--creative-h3-size":"1.1em",
125
+ "--creative-h1-weight":"700","--creative-h2-weight":"600","--creative-h3-weight":"500",
126
+ "--creative-h1-color":"#1f0800","--creative-h2-color":"#1f0800","--creative-h3-color":"#5a3020",
127
+ "--creative-childless-size":"1em","--creative-childless-weight":"400",
128
+ "--creative-bullet-size":"5px","--creative-bullet-color":"#d40924",
129
+ "--creative-tree-line-color":"#d0a898","--creative-tree-line-opacity":"0.5",
130
+ "--creative-h1-bg":"transparent","--creative-h2-bg":"transparent","--creative-h3-bg":"transparent"}
131
+
132
+ "사이버펑크" (neon, dark):
133
+ {"--surface-bg":"#070b14","--surface-nav":"#0f101f","--surface-section":"#12161f",
134
+ "--surface-input":"#02060d","--surface-btn":"#2f1d4a","--surface-secondary":"#181b1f",
135
+ "--text-primary":"#dcdde5","--text-muted":"#8a8c99","--text-on-btn":"#e0d0ff",
136
+ "--text-nav":"#dcdde5","--text-nav-btn":"#dcdde5","--text-chat-btn":"#dcdde5",
137
+ "--text-on-badge":"#dcdde5","--text-input":"#dcdde5",
138
+ "--color-link":"#0099f0","--color-brand":"#e749df","--color-active":"#0089e9",
139
+ "--color-danger":"#ff3050","--color-success":"#00cc66","--color-warning":"#ffaa00",
140
+ "--color-highlight":"#2a1848","--color-badge-bg":"#692278",
141
+ "--color-accent-border":"#0094c9","--color-accent-text":"#eb63c5",
142
+ "--color-code-bg":"#0e1220","--color-code-text":"#c0c4d0",
143
+ "--border-color":"#2a2e3a","--border-drag-over":"#6030a0","--border-drag-edge":"#e749df",
144
+ "--hover-brightness":"110%","--creative-loading-emojis":"🌃💜⚡🤖✨🎮",
145
+ "--creative-h1-size":"1.4em","--creative-h2-size":"1.2em","--creative-h3-size":"1.1em",
146
+ "--creative-h1-weight":"800","--creative-h2-weight":"600","--creative-h3-weight":"500",
147
+ "--creative-h1-color":"#e749df","--creative-h2-color":"#0099f0","--creative-h3-color":"#8a8c99",
148
+ "--creative-childless-size":"1em","--creative-childless-weight":"400",
149
+ "--creative-bullet-size":"4px","--creative-bullet-color":"#e749df",
150
+ "--creative-tree-line-color":"#2a2e3a","--creative-tree-line-opacity":"0.7",
151
+ "--creative-h1-bg":"#0f101f","--creative-h2-bg":"#12161f","--creative-h3-bg":"transparent"}
152
+
153
+ === RULES ===
154
+
155
+ 1) SURFACES must be TINTED with the theme color — never gray or neutral.
156
+ bg is lightest (pastel), then input, section, secondary, nav (deeper), btn (deepest or vivid).
157
+ 2) ALL text must be DARK (#0a-#2f range) on light themes, LIGHT (#cc-#ff range) on dark themes.
158
+ Exception: text-on-btn must contrast with surface-btn. If btn is dark, text-on-btn is light.
159
+ 3) brand/link = vivid theme color. danger=red, success=green, warning=amber ALWAYS.
160
+ 4) code-bg = same hue as surface-bg, slightly darker. NEVER a different hue family.
161
+ 5) Multi-color prompts (e.g. "Google logo", "rainbow", "Italian flag"):
162
+ - Surfaces: use the DOMINANT color's hue (tinted, not gray)
163
+ - Distribute each distinct color to: brand, active, badge-bg, accent-border, accent-text
164
+ - Each color must be RECOGNIZABLE — never blend into one muddy tone
165
+ 6) Match the vibe: "토마토" = warm reds/oranges, "바나나" = bright yellows, "숲" = rich greens.
166
+ The user should IMMEDIATELY recognize the theme from its colors.
167
+ 7) CREATIVE TREE STYLING — the app has a hierarchical list (outliner) with levels:
168
+ - Level 1-3: headings (h1/h2/h3). Level 4+: bullet items.
169
+ - Items at level 1-3 WITHOUT children render as plain text (childless style).
170
+ - creative-h{1,2,3}-size: font size (em units). h1 largest, descending. Range: 1.0em-1.5em.
171
+ - creative-h{1,2,3}-weight: font weight. h1 boldest. Values: 400-800.
172
+ - creative-h{1,2,3}-color: heading colors. Use text-primary or theme accent for h1.
173
+ h3 can use muted color. MUST be readable on surface-bg.
174
+ - creative-childless-size: usually 1em (normal text size).
175
+ - creative-childless-weight: usually 400 (normal weight).
176
+ - creative-bullet-size: bullet dot diameter in px. Range: 3px-6px.
177
+ - creative-bullet-color: bullet color. Can match brand or text-primary.
178
+ - creative-tree-line-color: vertical guide line color. Usually border-color or muted.
179
+ - creative-tree-line-opacity: 0.3-0.8. Subtle but visible.
180
+ - creative-h{1,2,3}-bg: heading row background color. Usually "transparent".
181
+ For dark/accent themes, h1 can use a subtle darker surface. Must not clash with text.
182
+ - For playful themes, vary heading colors. For minimal themes, keep sizes uniform.
65
183
  PROMPT
66
184
 
67
185
  response = @client.chat([
@@ -0,0 +1,70 @@
1
+ module Collavre
2
+ class CommandMenuService
3
+ def initialize(user:)
4
+ @user = user
5
+ end
6
+
7
+ def items
8
+ built_in_items + mcp_items
9
+ end
10
+
11
+ private
12
+
13
+ attr_reader :user
14
+
15
+ def built_in_items
16
+ [
17
+ {
18
+ name: "calendar",
19
+ label: "/calendar",
20
+ aliases: [ "/cal" ],
21
+ description: I18n.t("collavre.comments.command_menu.calendar_description"),
22
+ args: I18n.t("collavre.comments.command_menu.calendar_args")
23
+ },
24
+ {
25
+ name: "topic",
26
+ label: "/topic",
27
+ description: I18n.t("collavre.comments.command_menu.topic_description"),
28
+ args: I18n.t("collavre.comments.command_menu.topic_args")
29
+ }
30
+ ]
31
+ end
32
+
33
+ def mcp_items
34
+ Collavre::McpService.available_tools(user).filter_map do |tool|
35
+ tool_name = tool[:name] || tool["name"]
36
+ next unless tool_name
37
+
38
+ {
39
+ name: tool_name,
40
+ label: "/#{tool_name}",
41
+ description: tool[:description] || tool["description"],
42
+ args: format_args(tool[:params] || tool["params"])
43
+ }
44
+ end
45
+ end
46
+
47
+ def format_args(params)
48
+ return if params.blank?
49
+
50
+ if params.is_a?(Array)
51
+ return params.map do |param|
52
+ name = param[:name] || param["name"]
53
+ required = param[:required] || param["required"]
54
+ name.to_s + (required ? "*" : "")
55
+ end.join(", ")
56
+ end
57
+
58
+ properties = params[:properties] || params["properties"]
59
+ return unless properties.is_a?(Hash)
60
+
61
+ required = params[:required] || params["required"] || []
62
+ required = Array(required).map(&:to_s)
63
+
64
+ properties.keys.map do |key|
65
+ key = key.to_s
66
+ required.include?(key) ? "#{key}*" : key
67
+ end.join(", ")
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,94 @@
1
+ module Collavre
2
+ class CommentMoveService
3
+ class MoveError < StandardError; end
4
+
5
+ def initialize(creative:, user:)
6
+ @creative = creative
7
+ @user = user
8
+ end
9
+
10
+ # Returns { success: true, moved_count: N }
11
+ # Raises MoveError on validation failures
12
+ def call(comment_ids:, target_creative_id: nil, target_topic_id: nil)
13
+ comment_ids = Array(comment_ids).map(&:presence).compact.map(&:to_i)
14
+ raise MoveError, I18n.t("collavre.comments.move_no_selection") if comment_ids.empty?
15
+
16
+ target_origin, new_topic_id = resolve_target(target_creative_id, target_topic_id)
17
+
18
+ validate_permissions!(target_origin)
19
+ comments = fetch_visible_comments(comment_ids)
20
+
21
+ moved_count = perform_move(comments, target_origin, new_topic_id)
22
+
23
+ Comment.broadcast_badges(@creative)
24
+ Comment.broadcast_badges(target_origin) unless target_origin == @creative
25
+
26
+ { success: true, moved_count: moved_count }
27
+ end
28
+
29
+ private
30
+
31
+ attr_reader :creative, :user
32
+
33
+ def resolve_target(target_creative_id, target_topic_id)
34
+ if target_creative_id.present?
35
+ target_creative = Creative.find_by(id: target_creative_id)
36
+ raise MoveError, I18n.t("collavre.comments.move_invalid_target") unless target_creative
37
+ [ target_creative.effective_origin, nil ]
38
+ elsif !target_topic_id.nil?
39
+ new_topic_id = target_topic_id.presence
40
+ if new_topic_id.present? && !creative.topics.exists?(id: new_topic_id)
41
+ raise MoveError, I18n.t("collavre.comments.move_invalid_topic", default: "Invalid topic")
42
+ end
43
+ [ creative, new_topic_id ]
44
+ else
45
+ raise MoveError, I18n.t("collavre.comments.move_invalid_target")
46
+ end
47
+ end
48
+
49
+ def validate_permissions!(target_origin)
50
+ unless creative.has_permission?(user, :feedback) && target_origin.has_permission?(user, :feedback)
51
+ raise MoveError, I18n.t("collavre.comments.move_not_allowed")
52
+ end
53
+ end
54
+
55
+ def fetch_visible_comments(comment_ids)
56
+ scope = creative.comments.where(
57
+ "comments.private = ? OR comments.user_id = ? OR comments.approver_id = ?",
58
+ false, user.id, user.id
59
+ )
60
+ comments = scope.where(id: comment_ids).to_a
61
+ raise MoveError, I18n.t("collavre.comments.move_not_allowed") if comments.length != comment_ids.length
62
+ comments
63
+ end
64
+
65
+ def perform_move(comments, target_origin, new_topic_id)
66
+ moved_count = 0
67
+ ActiveRecord::Base.transaction do
68
+ comments.each do |comment|
69
+ same_creative = comment.creative_id == target_origin.id
70
+ same_topic = comment.topic_id.to_s == new_topic_id.to_s
71
+ next if same_creative && same_topic
72
+
73
+ if same_creative
74
+ comment.update!(topic_id: new_topic_id)
75
+ else
76
+ comment.update!(creative: target_origin, topic_id: new_topic_id)
77
+ broadcast_move_removal(comment, comment.creative)
78
+ end
79
+ moved_count += 1
80
+ end
81
+ end
82
+ moved_count
83
+ end
84
+
85
+ def broadcast_move_removal(comment, original_creative)
86
+ return if comment.private?
87
+
88
+ Turbo::StreamsChannel.broadcast_remove_to(
89
+ [ original_creative, :comments ],
90
+ target: ActionView::RecordIdentifier.dom_id(comment)
91
+ )
92
+ end
93
+ end
94
+ end
@@ -73,6 +73,7 @@ module Collavre
73
73
  SUPPORTED_ACTIONS = {
74
74
  "create_creative" => :create_creative,
75
75
  "update_creative" => :update_creative,
76
+ "delete_creative" => :delete_creative,
76
77
  "approve_tool" => :approve_tool,
77
78
  "execute_tool" => :execute_tool
78
79
  }.freeze
@@ -150,6 +151,15 @@ module Collavre
150
151
  raise InvalidActionError, e.record.errors.full_messages.to_sentence
151
152
  end
152
153
 
154
+ def delete_creative(payload)
155
+ creative = find_target_creative(payload)
156
+ executor = comment.user || Current.user
157
+ unless creative.has_permission?(executor, :write)
158
+ raise InvalidActionError, I18n.t("collavre.comments.approve_no_write_permission")
159
+ end
160
+ creative.destroy!
161
+ end
162
+
153
163
  def approve_tool(payload)
154
164
  tool_name = payload["tool_name"]
155
165
  raise InvalidActionError, "Tool name is required" if tool_name.blank?
@@ -79,8 +79,7 @@ module Collavre
79
79
  end
80
80
 
81
81
  def format_response(response)
82
- # TODO: i18n text
83
- return "Error running /#{tool_name}: #{response[:error]}" if response[:error].present?
82
+ return I18n.t("collavre.comments.mcp_command.error_running", tool_name: tool_name, error: response[:error]) if response[:error].present?
84
83
 
85
84
  result = response[:result]
86
85
  content = case result
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module Creatives
5
+ # Handles creative creation including sequencing and tagging.
6
+ class CreateService
7
+ Result = Struct.new(:creative, :success?, :errors, keyword_init: true)
8
+
9
+ def initialize(creative_params:, user:, child_id: nil, before_id: nil, after_id: nil, tag_ids: nil)
10
+ @creative_params = creative_params
11
+ @user = user
12
+ @child_id = child_id
13
+ @before_id = before_id
14
+ @after_id = after_id
15
+ @tag_ids = Array(tag_ids).compact
16
+ end
17
+
18
+ def call
19
+ creative = Creative.new(@creative_params)
20
+ creative.user = creative.parent ? creative.parent.user : @user
21
+
22
+ unless creative.save
23
+ return Result.new(creative: creative, success?: false, errors: creative.errors.full_messages)
24
+ end
25
+
26
+ reparent_child(creative)
27
+ insert_at_position(creative)
28
+ apply_tags(creative)
29
+
30
+ Result.new(creative: creative, success?: true, errors: [])
31
+ end
32
+
33
+ private
34
+
35
+ def reparent_child(creative)
36
+ return unless @child_id.present?
37
+
38
+ child = Creative.find_by(id: @child_id)
39
+ child&.update(parent: creative)
40
+ end
41
+
42
+ def insert_at_position(creative)
43
+ if @before_id.present?
44
+ insert_before(creative, @before_id)
45
+ elsif @after_id.present?
46
+ insert_after(creative, @after_id)
47
+ end
48
+ end
49
+
50
+ def insert_before(creative, before_id)
51
+ before_creative = Creative.find_by(id: before_id)
52
+ return unless before_creative && before_creative.parent_id == creative.parent_id
53
+
54
+ siblings = fetch_siblings(creative)
55
+ index = siblings.index { |s| s.id == before_creative.id } || 0
56
+ siblings.insert(index, creative)
57
+ resequence(siblings)
58
+ end
59
+
60
+ def insert_after(creative, after_id)
61
+ after_creative = Creative.find_by(id: after_id)
62
+ return unless after_creative && after_creative.parent_id == creative.parent_id
63
+
64
+ siblings = fetch_siblings(creative)
65
+ index = siblings.index { |s| s.id == after_creative.id } || -1
66
+ siblings.insert(index + 1, creative)
67
+ resequence(siblings)
68
+ end
69
+
70
+ def fetch_siblings(creative)
71
+ collection = creative.parent ? creative.parent.children.order(:sequence) : Creative.roots.order(:sequence)
72
+ collection.to_a.reject { |s| s.id == creative.id }
73
+ end
74
+
75
+ def resequence(siblings)
76
+ siblings.each_with_index { |c, idx| c.update_column(:sequence, idx) }
77
+ end
78
+
79
+ def apply_tags(creative)
80
+ @tag_ids.each do |tag_id|
81
+ creative.tags.create(label_id: tag_id)
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module Creatives
5
+ # Handles creative destruction with optional recursive child deletion.
6
+ class DestroyService
7
+ def initialize(creative:, user:, delete_with_children: false)
8
+ @creative = creative
9
+ @user = user
10
+ @delete_with_children = delete_with_children
11
+ end
12
+
13
+ def call
14
+ if @delete_with_children
15
+ destroy_descendants_recursively(@creative)
16
+ else
17
+ reparent_children
18
+ end
19
+
20
+ CreativeShare.where(creative: @creative).destroy_all
21
+ @creative.destroy
22
+ end
23
+
24
+ private
25
+
26
+ def reparent_children
27
+ parent = @creative.parent
28
+ @creative.children.each { |child| child.update(parent: parent) }
29
+ end
30
+
31
+ def destroy_descendants_recursively(creative)
32
+ deletable_children = creative.children_with_permission(@user, :admin)
33
+ deletable_children.each do |child|
34
+ destroy_descendants_recursively(child)
35
+ CreativeShare.where(creative: child).destroy_all
36
+ child.destroy
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -82,6 +82,9 @@ module Creatives
82
82
  # Sort by comment updated_at for comment filter
83
83
  if params[:comment] == "true"
84
84
  matched_creatives = matched_creatives.sort_by { |c| c.comments.maximum(:updated_at) || c.updated_at }.reverse
85
+ elsif params[:search].present? && params[:simple].present?
86
+ # Sort by description length (shorter = more relevant match)
87
+ matched_creatives = matched_creatives.sort_by { |c| c.description.to_s.length }
85
88
  end
86
89
 
87
90
  parent = params[:id] ? Creative.find_by(id: params[:id]) : nil