collavre 0.1.1 → 0.2.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/comments_popup.css +293 -8
- data/app/assets/stylesheets/collavre/mention_menu.css +26 -0
- data/app/assets/stylesheets/collavre/popup.css +7 -0
- data/app/assets/stylesheets/collavre/print.css +18 -0
- data/app/channels/collavre/comments_presence_channel.rb +33 -0
- data/app/components/collavre/autocomplete_popup_component.html.erb +3 -0
- data/app/components/collavre/autocomplete_popup_component.rb +18 -0
- data/app/components/collavre/command_menu_component.rb +7 -0
- data/app/components/collavre/plans_timeline_component.html.erb +1 -1
- data/app/components/collavre/plans_timeline_component.rb +29 -32
- data/app/components/collavre/user_mention_menu_component.rb +4 -5
- data/app/controllers/collavre/comments_controller.rb +111 -10
- data/app/controllers/collavre/creatives_controller.rb +8 -0
- data/app/controllers/collavre/google_auth_controller.rb +5 -1
- data/app/controllers/collavre/plans_controller.rb +65 -9
- data/app/controllers/collavre/topics_controller.rb +42 -0
- data/app/controllers/collavre/users_controller.rb +4 -14
- data/app/errors/collavre/approval_pending_error.rb +54 -0
- data/app/errors/collavre/cancelled_error.rb +9 -0
- data/app/helpers/collavre/navigation_helper.rb +3 -1
- data/app/javascript/collavre.js +1 -0
- data/app/javascript/controllers/comments/__tests__/popup_controller.test.js +2 -1
- data/app/javascript/controllers/comments/form_controller.js +2 -1
- data/app/javascript/controllers/comments/list_controller.js +185 -2
- data/app/javascript/controllers/comments/popup_controller.js +95 -20
- data/app/javascript/controllers/comments/presence_controller.js +30 -1
- data/app/javascript/controllers/comments/topics_controller.js +314 -4
- data/app/javascript/modules/__tests__/creative_progress.test.js +50 -0
- data/app/javascript/modules/command_menu.js +116 -0
- data/app/javascript/modules/creative_progress.js +14 -0
- data/app/javascript/modules/creative_row_editor.js +104 -20
- data/app/javascript/modules/plans_timeline.js +15 -4
- data/app/javascript/modules/share_modal.js +3 -0
- data/app/jobs/collavre/ai_agent_job.rb +35 -21
- data/app/models/collavre/calendar_event.rb +7 -1
- data/app/models/collavre/comment.rb +35 -2
- data/app/models/collavre/creative.rb +1 -3
- data/app/models/collavre/mcp_tool.rb +4 -0
- data/app/models/collavre/plan.rb +23 -0
- data/app/models/collavre/topic.rb +12 -0
- data/app/models/collavre/user.rb +15 -1
- data/app/services/collavre/ai_agent_service.rb +174 -66
- data/app/services/collavre/ai_client.rb +31 -2
- data/app/services/collavre/comments/action_executor.rb +47 -1
- data/app/services/collavre/comments/calendar_command.rb +117 -18
- data/app/services/collavre/google_calendar_service.rb +38 -15
- data/app/services/collavre/markdown_importer.rb +47 -8
- data/app/services/collavre/mcp_service.rb +23 -10
- data/app/services/collavre/system_events/router.rb +50 -26
- data/app/services/collavre/tools/creative_create_service.rb +97 -0
- data/app/services/collavre/tools/creative_update_service.rb +116 -0
- data/app/views/collavre/comments/_comment.html.erb +2 -2
- data/app/views/collavre/comments/_comments_popup.html.erb +40 -6
- data/app/views/collavre/comments/fullscreen.html.erb +5 -0
- data/app/views/collavre/creatives/_inline_edit_form.html.erb +11 -3
- data/app/views/collavre/creatives/_integration_modals.html.erb +6 -0
- data/app/views/collavre/creatives/_integration_triggers.html.erb +8 -0
- data/app/views/collavre/creatives/_integrations_menu.html.erb +12 -0
- data/app/views/collavre/creatives/_mobile_actions_menu.html.erb +13 -1
- data/app/views/collavre/creatives/_share_button.html.erb +1 -1
- data/app/views/collavre/creatives/index.html.erb +22 -4
- data/app/views/collavre/users/edit_ai.html.erb +15 -0
- data/app/views/collavre/users/new_ai.html.erb +15 -0
- data/app/views/layouts/collavre/chat.html.erb +46 -0
- data/config/locales/ai_agent.en.yml +15 -0
- data/config/locales/ai_agent.ko.yml +15 -0
- data/config/locales/comments.en.yml +15 -3
- data/config/locales/comments.ko.yml +15 -3
- data/config/locales/creatives.en.yml +3 -31
- data/config/locales/creatives.ko.yml +3 -27
- data/config/locales/plans.en.yml +4 -0
- data/config/locales/plans.ko.yml +4 -0
- data/config/locales/users.en.yml +3 -0
- data/config/locales/users.ko.yml +3 -0
- data/config/routes.rb +8 -3
- data/db/migrate/20260120045354_encrypt_oauth_tokens.rb +1 -1
- data/db/migrate/20260131100000_migrate_active_storage_attachment_record_types.rb +21 -0
- data/db/migrate/20260201100000_make_google_event_id_nullable.rb +5 -0
- data/lib/collavre/engine.rb +171 -6
- data/lib/collavre/integration_registry.rb +129 -0
- data/lib/collavre/version.rb +1 -1
- data/lib/collavre.rb +2 -0
- data/lib/navigation/registry.rb +130 -0
- metadata +22 -15
- data/app/components/collavre/user_mention_menu_component.html.erb +0 -3
- data/app/controllers/collavre/notion_auth_controller.rb +0 -25
- data/app/jobs/collavre/notion_export_job.rb +0 -30
- data/app/jobs/collavre/notion_sync_job.rb +0 -48
- data/app/models/collavre/notion_account.rb +0 -17
- data/app/models/collavre/notion_block_link.rb +0 -10
- data/app/models/collavre/notion_page_link.rb +0 -19
- data/app/services/collavre/notion_client.rb +0 -231
- data/app/services/collavre/notion_creative_exporter.rb +0 -296
- data/app/services/collavre/notion_service.rb +0 -249
- data/app/views/collavre/creatives/_notion_integration_modal.html.erb +0 -90
- data/db/migrate/20241201000000_create_notion_integrations.rb +0 -29
- data/db/migrate/20250312000000_create_notion_block_links.rb +0 -16
- data/db/migrate/20250312010000_allow_multiple_notion_blocks_per_creative.rb +0 -5
|
@@ -3,6 +3,7 @@ import apiQueue from '../lib/api/queue_manager'
|
|
|
3
3
|
import { $getCharacterOffsets, $getSelection, $isRangeSelection, $isTextNode, $isRootOrShadowRoot } from 'lexical'
|
|
4
4
|
import { createInlineEditor } from './lexical_inline_editor'
|
|
5
5
|
import { renderCreativeTree, dispatchCreativeTreeUpdated } from '../creatives/tree_renderer'
|
|
6
|
+
import { isProgressComplete, progressBaselineValueFrom, progressValueChangedFrom } from './creative_progress'
|
|
6
7
|
// Import Stimulus application from the global window (set by host app)
|
|
7
8
|
const application = window.Stimulus
|
|
8
9
|
|
|
@@ -75,6 +76,9 @@ export function initializeCreativeRowEditor() {
|
|
|
75
76
|
const editorContainer = template.querySelector('[data-lexical-editor-root]');
|
|
76
77
|
const progressInput = document.getElementById('inline-creative-progress');
|
|
77
78
|
const progressValue = document.getElementById('inline-progress-value');
|
|
79
|
+
const progressCompleteLabel = progressInput?.dataset.completeLabel || 'Complete';
|
|
80
|
+
const progressIncompleteLabel = progressInput?.dataset.incompleteLabel || 'Incomplete';
|
|
81
|
+
const progressHiddenInput = document.querySelector('input[type="hidden"][name="creative[progress]"]');
|
|
78
82
|
const upBtn = document.getElementById('inline-move-up');
|
|
79
83
|
const downBtn = document.getElementById('inline-move-down');
|
|
80
84
|
const addBtn = document.getElementById('inline-add');
|
|
@@ -136,18 +140,53 @@ export function initializeCreativeRowEditor() {
|
|
|
136
140
|
let originalProgress = 0;
|
|
137
141
|
let originalOriginId = '';
|
|
138
142
|
let isDirty = false;
|
|
143
|
+
let completionCascadePending = false;
|
|
139
144
|
|
|
140
145
|
function formatProgressDisplay(value) {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
146
|
+
return isProgressComplete(value) ? progressCompleteLabel : progressIncompleteLabel;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function readProgressValue() {
|
|
150
|
+
if (!progressInput) return 0;
|
|
151
|
+
return progressInput.checked ? 1 : 0;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function progressValueChanged() {
|
|
155
|
+
if (!progressInput) return false;
|
|
156
|
+
return progressValueChangedFrom(originalProgress, progressInput.checked);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function setProgressState(value) {
|
|
160
|
+
if (!progressInput) return;
|
|
161
|
+
const complete = isProgressComplete(value);
|
|
162
|
+
progressInput.checked = complete;
|
|
163
|
+
if (progressValue) {
|
|
164
|
+
progressValue.textContent = formatProgressDisplay(value);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function updateProgressInputAvailability(value) {
|
|
169
|
+
if (!progressInput) return;
|
|
170
|
+
const hasChildren = currentRowHasChildren();
|
|
171
|
+
const complete = isProgressComplete(value);
|
|
172
|
+
const shouldDisable = hasChildren && complete;
|
|
173
|
+
progressInput.disabled = shouldDisable;
|
|
174
|
+
if (progressHiddenInput) {
|
|
175
|
+
progressHiddenInput.disabled = false;
|
|
176
|
+
progressHiddenInput.value = shouldDisable ? '1' : '0';
|
|
177
|
+
}
|
|
145
178
|
}
|
|
146
179
|
|
|
147
180
|
function treeRowElement(node) {
|
|
148
181
|
return node && node.closest ? node.closest('creative-tree-row') : null;
|
|
149
182
|
}
|
|
150
183
|
|
|
184
|
+
function currentRowHasChildren() {
|
|
185
|
+
const row = currentRowElement || (currentTree ? treeRowElement(currentTree) : null);
|
|
186
|
+
if (!row) return false;
|
|
187
|
+
return !!(row.hasChildren || row.getAttribute?.('has-children'));
|
|
188
|
+
}
|
|
189
|
+
|
|
151
190
|
function hasDatasetValue(element, key) {
|
|
152
191
|
if (!element || !element.dataset) return false;
|
|
153
192
|
return Object.prototype.hasOwnProperty.call(element.dataset, key);
|
|
@@ -245,8 +284,9 @@ export function initializeCreativeRowEditor() {
|
|
|
245
284
|
isDirty = false;
|
|
246
285
|
const progressNumber = Number(data.progress ?? 0);
|
|
247
286
|
const normalizedProgress = Number.isNaN(progressNumber) ? 0 : progressNumber;
|
|
248
|
-
|
|
249
|
-
|
|
287
|
+
setProgressState(normalizedProgress);
|
|
288
|
+
updateProgressInputAvailability(normalizedProgress);
|
|
289
|
+
completionCascadePending = false;
|
|
250
290
|
const fallbackParent = tree?.dataset?.parentId || '';
|
|
251
291
|
parentInput.value = data.parent_id ?? fallbackParent ?? '';
|
|
252
292
|
beforeInput.value = '';
|
|
@@ -892,8 +932,17 @@ export function initializeCreativeRowEditor() {
|
|
|
892
932
|
|
|
893
933
|
// Capture values being saved to update dirty state on success
|
|
894
934
|
const savedContent = descriptionInput.value;
|
|
895
|
-
const
|
|
935
|
+
const shouldPersistProgress = progressValueChanged();
|
|
936
|
+
const savedProgress = shouldPersistProgress ? readProgressValue() : progressBaselineValueFrom(originalProgress);
|
|
896
937
|
const savedOriginId = originIdInput ? originIdInput.value : '';
|
|
938
|
+
const cascadeProgressUpdate = completionCascadePending;
|
|
939
|
+
const progressInputsDisabled = progressInput?.disabled ?? false;
|
|
940
|
+
const hiddenProgressDisabled = progressHiddenInput?.disabled ?? false;
|
|
941
|
+
|
|
942
|
+
if (!shouldPersistProgress) {
|
|
943
|
+
if (progressInput) progressInput.disabled = true;
|
|
944
|
+
if (progressHiddenInput) progressHiddenInput.disabled = true;
|
|
945
|
+
}
|
|
897
946
|
|
|
898
947
|
savePromise = creativesApi.save(form.action, method, form).then(function (r) {
|
|
899
948
|
if (!r.ok) return r;
|
|
@@ -902,12 +951,14 @@ export function initializeCreativeRowEditor() {
|
|
|
902
951
|
}).then(function (data) {
|
|
903
952
|
// Update dirty state to reflect successful save
|
|
904
953
|
originalContent = savedContent;
|
|
905
|
-
|
|
954
|
+
if (shouldPersistProgress) {
|
|
955
|
+
originalProgress = savedProgress;
|
|
956
|
+
}
|
|
906
957
|
originalOriginId = savedOriginId;
|
|
907
958
|
|
|
908
959
|
// If current values match what was just saved, clear dirty flag
|
|
909
960
|
if (descriptionInput.value === savedContent &&
|
|
910
|
-
|
|
961
|
+
readProgressValue() === savedProgress &&
|
|
911
962
|
originIdInput.value === savedOriginId) {
|
|
912
963
|
isDirty = false;
|
|
913
964
|
}
|
|
@@ -954,6 +1005,10 @@ export function initializeCreativeRowEditor() {
|
|
|
954
1005
|
} else if (method === 'PATCH') {
|
|
955
1006
|
if (tree) refreshRow(tree);
|
|
956
1007
|
}
|
|
1008
|
+
if (cascadeProgressUpdate && tree) {
|
|
1009
|
+
refreshChildren(tree);
|
|
1010
|
+
completionCascadePending = false;
|
|
1011
|
+
}
|
|
957
1012
|
|
|
958
1013
|
// Delete removed attachments after successful save
|
|
959
1014
|
if (lexicalEditor && typeof lexicalEditor.getDeletedAttachments === 'function') {
|
|
@@ -966,6 +1021,10 @@ export function initializeCreativeRowEditor() {
|
|
|
966
1021
|
});
|
|
967
1022
|
}).finally(function () {
|
|
968
1023
|
saving = false;
|
|
1024
|
+
if (!shouldPersistProgress) {
|
|
1025
|
+
if (progressInput) progressInput.disabled = progressInputsDisabled;
|
|
1026
|
+
if (progressHiddenInput) progressHiddenInput.disabled = hiddenProgressDisabled;
|
|
1027
|
+
}
|
|
969
1028
|
});
|
|
970
1029
|
return savePromise;
|
|
971
1030
|
});
|
|
@@ -1071,7 +1130,8 @@ export function initializeCreativeRowEditor() {
|
|
|
1071
1130
|
// CRITICAL: Capture ALL values BEFORE awaiting, because the editor may switch
|
|
1072
1131
|
// to a different creative while we're waiting for uploads
|
|
1073
1132
|
let currentContent = descriptionInput.value;
|
|
1074
|
-
let currentProgress =
|
|
1133
|
+
let currentProgress = readProgressValue();
|
|
1134
|
+
let shouldPersistProgress = progressValueChanged();
|
|
1075
1135
|
const currentParentId = tree.dataset.parentId || '';
|
|
1076
1136
|
const currentBeforeId = tree.previousElementSibling ? creativeIdFrom(tree.previousElementSibling) : '';
|
|
1077
1137
|
const currentAfterId = tree.nextElementSibling ? creativeIdFrom(tree.nextElementSibling) : '';
|
|
@@ -1092,17 +1152,21 @@ export function initializeCreativeRowEditor() {
|
|
|
1092
1152
|
// This ensures we capture the final HTML with signed IDs instead of blob URLs
|
|
1093
1153
|
if (form.dataset.creativeId === startCreativeId) {
|
|
1094
1154
|
currentContent = descriptionInput.value;
|
|
1095
|
-
currentProgress =
|
|
1155
|
+
currentProgress = readProgressValue();
|
|
1156
|
+
shouldPersistProgress = progressValueChanged();
|
|
1096
1157
|
}
|
|
1097
1158
|
|
|
1098
1159
|
// Build request body
|
|
1099
1160
|
// Note: before_id and after_id must be top-level params, not nested under creative[]
|
|
1100
1161
|
// because CreativesController reads params[:before_id] and params[:after_id] for positioning
|
|
1101
1162
|
const body = {
|
|
1102
|
-
'creative[description]': currentContent
|
|
1103
|
-
'creative[progress]': currentProgress
|
|
1163
|
+
'creative[description]': currentContent
|
|
1104
1164
|
};
|
|
1105
1165
|
|
|
1166
|
+
if (shouldPersistProgress) {
|
|
1167
|
+
body['creative[progress]'] = currentProgress;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1106
1170
|
// Always include parent_id, even if empty (for moving to root)
|
|
1107
1171
|
body['creative[parent_id]'] = currentParentId;
|
|
1108
1172
|
|
|
@@ -1122,7 +1186,9 @@ export function initializeCreativeRowEditor() {
|
|
|
1122
1186
|
row.dataset.descriptionHtml = currentContent;
|
|
1123
1187
|
row.descriptionHtml = currentContent;
|
|
1124
1188
|
row.dataset.descriptionRawHtml = currentContent;
|
|
1125
|
-
|
|
1189
|
+
if (shouldPersistProgress) {
|
|
1190
|
+
row.dataset.progressValue = String(currentProgress);
|
|
1191
|
+
}
|
|
1126
1192
|
if (currentParentId) {
|
|
1127
1193
|
tree.dataset.parentId = currentParentId;
|
|
1128
1194
|
row.parentId = currentParentId;
|
|
@@ -1156,6 +1222,9 @@ export function initializeCreativeRowEditor() {
|
|
|
1156
1222
|
|
|
1157
1223
|
// Reset dirty state
|
|
1158
1224
|
originalContent = currentContent;
|
|
1225
|
+
if (shouldPersistProgress) {
|
|
1226
|
+
originalProgress = currentProgress;
|
|
1227
|
+
}
|
|
1159
1228
|
isDirty = false;
|
|
1160
1229
|
pendingSave = false;
|
|
1161
1230
|
clearTimeout(saveTimer);
|
|
@@ -1540,8 +1609,8 @@ export function initializeCreativeRowEditor() {
|
|
|
1540
1609
|
resetOriginTracking();
|
|
1541
1610
|
descriptionInput.value = '';
|
|
1542
1611
|
lexicalEditor.reset(`new-${Date.now()}`);
|
|
1543
|
-
|
|
1544
|
-
|
|
1612
|
+
setProgressState(0);
|
|
1613
|
+
originalProgress = 0;
|
|
1545
1614
|
if (unconvertBtn) unconvertBtn.style.display = 'none';
|
|
1546
1615
|
pendingSave = false;
|
|
1547
1616
|
lexicalEditor.focus();
|
|
@@ -1666,10 +1735,25 @@ export function initializeCreativeRowEditor() {
|
|
|
1666
1735
|
return !!node && $isRootOrShadowRoot(node);
|
|
1667
1736
|
}
|
|
1668
1737
|
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1738
|
+
if (progressInput) {
|
|
1739
|
+
progressInput.addEventListener('change', function () {
|
|
1740
|
+
if (progressValue) {
|
|
1741
|
+
progressValue.textContent = formatProgressDisplay(readProgressValue());
|
|
1742
|
+
}
|
|
1743
|
+
completionCascadePending = false;
|
|
1744
|
+
const hasChildren = currentRowHasChildren();
|
|
1745
|
+
const shouldCascade = hasChildren && progressValueChanged();
|
|
1746
|
+
if (shouldCascade) {
|
|
1747
|
+
completionCascadePending = true;
|
|
1748
|
+
const alertMessage = progressInput.dataset.childrenAlertMessage;
|
|
1749
|
+
if (alertMessage) {
|
|
1750
|
+
alert(alertMessage);
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
updateProgressInputAvailability(readProgressValue());
|
|
1754
|
+
scheduleSave();
|
|
1755
|
+
});
|
|
1756
|
+
}
|
|
1673
1757
|
|
|
1674
1758
|
if (parentSuggestBtn && parentSuggestions) {
|
|
1675
1759
|
parentSuggestBtn.addEventListener('click', function () {
|
|
@@ -10,6 +10,9 @@ if (!plansTimelineScriptInitialized) {
|
|
|
10
10
|
var plans = [];
|
|
11
11
|
try { plans = JSON.parse(container.dataset.plans || '[]'); } catch (e) { }
|
|
12
12
|
plans = plans.map(function (p) {
|
|
13
|
+
if (p.start_date) {
|
|
14
|
+
p.start_date = new Date(p.start_date);
|
|
15
|
+
}
|
|
13
16
|
p.created_at = new Date(p.created_at);
|
|
14
17
|
p.target_date = new Date(p.target_date);
|
|
15
18
|
return p;
|
|
@@ -64,8 +67,9 @@ if (!plansTimelineScriptInitialized) {
|
|
|
64
67
|
el.className = 'plan-bar';
|
|
65
68
|
el.dataset.path = plan.path;
|
|
66
69
|
el.dataset.id = plan.id;
|
|
67
|
-
var
|
|
68
|
-
var
|
|
70
|
+
var startDateValue = plan.start_date || plan.created_at;
|
|
71
|
+
var left = dayDiff(startDateValue, startDate) * dayWidth;
|
|
72
|
+
var width = (dayDiff(plan.target_date, startDateValue) + 1) * dayWidth;
|
|
69
73
|
el.style.left = left + 'px';
|
|
70
74
|
el.style.top = (idx * rowHeight + 40) + 'px';
|
|
71
75
|
el.style.width = width + 'px';
|
|
@@ -146,8 +150,9 @@ if (!plansTimelineScriptInitialized) {
|
|
|
146
150
|
var visibleWidth = dayDiff(endDate, startDate) * dayWidth;
|
|
147
151
|
planEls.forEach(function (item, idx) {
|
|
148
152
|
var plan = item.plan;
|
|
149
|
-
var
|
|
150
|
-
var
|
|
153
|
+
var startDateValue = plan.start_date || plan.created_at;
|
|
154
|
+
var left = dayDiff(startDateValue, startDate) * dayWidth;
|
|
155
|
+
var width = (dayDiff(plan.target_date, startDateValue) + 1) * dayWidth;
|
|
151
156
|
var right = left + width;
|
|
152
157
|
|
|
153
158
|
if (right < 0 || left > visibleWidth) {
|
|
@@ -193,6 +198,9 @@ if (!plansTimelineScriptInitialized) {
|
|
|
193
198
|
.then(function (r) { return r.json(); })
|
|
194
199
|
.then(function (newPlans) {
|
|
195
200
|
plans = newPlans.map(function (p) {
|
|
201
|
+
if (p.start_date) {
|
|
202
|
+
p.start_date = new Date(p.start_date);
|
|
203
|
+
}
|
|
196
204
|
p.created_at = new Date(p.created_at);
|
|
197
205
|
p.target_date = new Date(p.target_date);
|
|
198
206
|
return p;
|
|
@@ -362,6 +370,9 @@ if (!plansTimelineScriptInitialized) {
|
|
|
362
370
|
return r.json().then(function (j) { throw j; });
|
|
363
371
|
}).then(function (plan) {
|
|
364
372
|
// Add to local plans if available via closure or re-fetch
|
|
373
|
+
if (plan.start_date) {
|
|
374
|
+
plan.start_date = new Date(plan.start_date);
|
|
375
|
+
}
|
|
365
376
|
plan.created_at = new Date(plan.created_at);
|
|
366
377
|
plan.target_date = new Date(plan.target_date);
|
|
367
378
|
|
|
@@ -1,27 +1,41 @@
|
|
|
1
1
|
module Collavre
|
|
2
|
-
class AiAgentJob < ApplicationJob
|
|
3
|
-
|
|
2
|
+
class AiAgentJob < ApplicationJob
|
|
3
|
+
queue_as :default
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
# Allow resuming a task that was pending approval
|
|
6
|
+
def perform(agent_id_or_task, event_name = nil, context = nil)
|
|
7
|
+
if agent_id_or_task.is_a?(Task)
|
|
8
|
+
# Resume existing task
|
|
9
|
+
task = agent_id_or_task
|
|
10
|
+
return if task.reload.status == "cancelled"
|
|
11
|
+
task.update!(status: "running")
|
|
12
|
+
else
|
|
13
|
+
# Create new task
|
|
14
|
+
agent = User.find(agent_id_or_task)
|
|
15
|
+
task = Task.create!(
|
|
16
|
+
name: "Response to #{event_name}",
|
|
17
|
+
status: "running",
|
|
18
|
+
trigger_event_name: event_name,
|
|
19
|
+
trigger_event_payload: context,
|
|
20
|
+
agent: agent
|
|
21
|
+
)
|
|
22
|
+
end
|
|
7
23
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
raise e
|
|
24
|
+
begin
|
|
25
|
+
AiAgentService.new(task).call
|
|
26
|
+
task.update!(status: "done")
|
|
27
|
+
rescue ApprovalPendingError
|
|
28
|
+
# Task status already set to pending_approval by AiAgentService
|
|
29
|
+
# Don't mark as failed, just let the job complete gracefully
|
|
30
|
+
Rails.logger.info("AiAgentJob paused for task #{task.id}: awaiting tool approval")
|
|
31
|
+
rescue CancelledError
|
|
32
|
+
# Task status already set to "cancelled" by Comment callback
|
|
33
|
+
Rails.logger.info("AiAgentJob cancelled for task #{task.id}: trigger message deleted")
|
|
34
|
+
rescue StandardError => e
|
|
35
|
+
task.update!(status: "failed")
|
|
36
|
+
Rails.logger.error("AiAgentJob failed for task #{task.id}: #{e.message}")
|
|
37
|
+
raise e
|
|
38
|
+
end
|
|
24
39
|
end
|
|
25
40
|
end
|
|
26
41
|
end
|
|
27
|
-
end
|
|
@@ -5,13 +5,19 @@ module Collavre
|
|
|
5
5
|
belongs_to :user, class_name: Collavre.configuration.user_class_name
|
|
6
6
|
belongs_to :creative, class_name: "Collavre::Creative", optional: true
|
|
7
7
|
|
|
8
|
-
validates :
|
|
8
|
+
validates :start_time, :end_time, presence: true
|
|
9
9
|
|
|
10
10
|
after_commit :delete_google_event, on: :destroy
|
|
11
11
|
|
|
12
|
+
def synced_to_google?
|
|
13
|
+
google_event_id.present?
|
|
14
|
+
end
|
|
15
|
+
|
|
12
16
|
private
|
|
13
17
|
|
|
14
18
|
def delete_google_event
|
|
19
|
+
return unless google_event_id.present?
|
|
20
|
+
|
|
15
21
|
GoogleCalendarService.new(user: user).delete_event(google_event_id)
|
|
16
22
|
rescue StandardError => e
|
|
17
23
|
Rails.logger.error("Failed to delete Google event #{google_event_id}: #{e.message}")
|
|
@@ -2,6 +2,8 @@ module Collavre
|
|
|
2
2
|
class Comment < ApplicationRecord
|
|
3
3
|
self.table_name = "comments"
|
|
4
4
|
|
|
5
|
+
STREAMING_PLACEHOLDER_CONTENT = "..."
|
|
6
|
+
|
|
5
7
|
# Use non-namespaced partial path for backward compatibility
|
|
6
8
|
def to_partial_path
|
|
7
9
|
"comments/comment"
|
|
@@ -26,9 +28,9 @@ module Collavre
|
|
|
26
28
|
validate :creative_must_be_origin_creative
|
|
27
29
|
validate :images_must_be_images
|
|
28
30
|
|
|
29
|
-
after_create_commit :broadcast_create, :notify_write_users, :notify_mentions, :broadcast_badges
|
|
31
|
+
after_create_commit :broadcast_create, :notify_write_users, :notify_mentions, :notify_approver, :broadcast_badges
|
|
30
32
|
after_update_commit :broadcast_update
|
|
31
|
-
after_destroy_commit :broadcast_destroy, :broadcast_badges
|
|
33
|
+
after_destroy_commit :broadcast_destroy, :broadcast_badges, :cancel_pending_tasks
|
|
32
34
|
|
|
33
35
|
# public for db migration
|
|
34
36
|
def creative_snippet
|
|
@@ -75,6 +77,14 @@ module Collavre
|
|
|
75
77
|
|
|
76
78
|
private
|
|
77
79
|
|
|
80
|
+
def cancel_pending_tasks
|
|
81
|
+
Task.where(status: %w[pending running]).each do |task|
|
|
82
|
+
if task.trigger_event_payload&.dig("comment", "id") == id
|
|
83
|
+
task.update!(status: "cancelled")
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
78
88
|
def create_inbox_item(owner, key, params = {})
|
|
79
89
|
origin = creative&.effective_origin
|
|
80
90
|
metadata = params.to_h.stringify_keys
|
|
@@ -95,6 +105,11 @@ module Collavre
|
|
|
95
105
|
)
|
|
96
106
|
end
|
|
97
107
|
|
|
108
|
+
# AI agent streaming placeholder: skip inbox notifications for "..." content
|
|
109
|
+
def streaming_placeholder?
|
|
110
|
+
user&.ai_user? && content == STREAMING_PLACEHOLDER_CONTENT
|
|
111
|
+
end
|
|
112
|
+
|
|
98
113
|
def mentioned_emails
|
|
99
114
|
return [] unless content
|
|
100
115
|
content.scan(/@([\w.\-+]+@[a-zA-Z0-9\-.]+\.[a-zA-Z]{2,})/)
|
|
@@ -147,6 +162,7 @@ module Collavre
|
|
|
147
162
|
|
|
148
163
|
def notify_write_users
|
|
149
164
|
return if private? || !user
|
|
165
|
+
return if streaming_placeholder?
|
|
150
166
|
base_creative = creative.effective_origin
|
|
151
167
|
present_ids = CommentPresenceStore.list(base_creative.id)
|
|
152
168
|
recipients = base_creative.all_shared_users(:write).map(&:user)
|
|
@@ -167,6 +183,7 @@ module Collavre
|
|
|
167
183
|
|
|
168
184
|
def notify_mentions
|
|
169
185
|
return if private?
|
|
186
|
+
return if streaming_placeholder?
|
|
170
187
|
mentioned_users.each do |mentioned|
|
|
171
188
|
create_inbox_item(
|
|
172
189
|
mentioned,
|
|
@@ -176,6 +193,22 @@ module Collavre
|
|
|
176
193
|
end
|
|
177
194
|
end
|
|
178
195
|
|
|
196
|
+
def notify_approver
|
|
197
|
+
return unless approver.present? && action.present?
|
|
198
|
+
return if approver == user
|
|
199
|
+
|
|
200
|
+
create_inbox_item(
|
|
201
|
+
approver,
|
|
202
|
+
"inbox.approval_requested",
|
|
203
|
+
{ user: user&.display_name, tool_name: parsed_action_tool_name, creative: creative_snippet }
|
|
204
|
+
)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def parsed_action_tool_name
|
|
208
|
+
parsed = JSON.parse(action) rescue nil
|
|
209
|
+
parsed&.dig("tool_name") || "unknown"
|
|
210
|
+
end
|
|
211
|
+
|
|
179
212
|
def assign_default_user
|
|
180
213
|
self.user ||= Collavre.current_user
|
|
181
214
|
end
|
|
@@ -51,7 +51,7 @@ module Collavre
|
|
|
51
51
|
attr_accessor :filtered_progress
|
|
52
52
|
|
|
53
53
|
belongs_to :origin, class_name: "Collavre::Creative", optional: true
|
|
54
|
-
has_many :linked_creatives, class_name: "Collavre::Creative", foreign_key: :origin_id, dependent: :
|
|
54
|
+
has_many :linked_creatives, class_name: "Collavre::Creative", foreign_key: :origin_id, dependent: :destroy
|
|
55
55
|
belongs_to :user, class_name: Collavre.configuration.user_class_name, optional: true
|
|
56
56
|
|
|
57
57
|
has_many :creative_shares, class_name: "Collavre::CreativeShare", dependent: :destroy
|
|
@@ -60,8 +60,6 @@ module Collavre
|
|
|
60
60
|
has_many :creative_expanded_states, class_name: "Collavre::CreativeExpandedState", dependent: :delete_all
|
|
61
61
|
has_many :invitations, class_name: "Collavre::Invitation", dependent: :delete_all
|
|
62
62
|
has_many :github_repository_links, dependent: :destroy
|
|
63
|
-
has_many :notion_page_links, dependent: :destroy
|
|
64
|
-
has_many :notion_block_links, dependent: :destroy
|
|
65
63
|
has_many :topics, class_name: "Collavre::Topic", dependent: :destroy
|
|
66
64
|
has_many :mcp_tools, dependent: :destroy
|
|
67
65
|
has_many :activity_logs, class_name: "Collavre::ActivityLog", dependent: :destroy
|
data/app/models/collavre/plan.rb
CHANGED
|
@@ -3,6 +3,7 @@ require "set"
|
|
|
3
3
|
module Collavre
|
|
4
4
|
class Plan < Label
|
|
5
5
|
validates :target_date, presence: true
|
|
6
|
+
validate :start_date_not_after_target_date
|
|
6
7
|
|
|
7
8
|
def progress(_user = nil)
|
|
8
9
|
tagged_ids = Tag.where(label_id: id).pluck(:creative_id)
|
|
@@ -16,5 +17,27 @@ module Collavre
|
|
|
16
17
|
|
|
17
18
|
values.sum.to_f / values.size
|
|
18
19
|
end
|
|
20
|
+
|
|
21
|
+
# Delegate start_date to the associated creative's created_at
|
|
22
|
+
def start_date
|
|
23
|
+
creative&.created_at&.to_date
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def start_date=(value)
|
|
27
|
+
return unless creative
|
|
28
|
+
|
|
29
|
+
date = value.is_a?(Date) ? value : Date.parse(value.to_s)
|
|
30
|
+
creative.update_column(:created_at, date.to_datetime)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def start_date_not_after_target_date
|
|
36
|
+
return if start_date.blank? || target_date.blank?
|
|
37
|
+
|
|
38
|
+
return unless start_date > target_date
|
|
39
|
+
|
|
40
|
+
errors.add(:start_date, "must be on or before target date")
|
|
41
|
+
end
|
|
19
42
|
end
|
|
20
43
|
end
|
|
@@ -8,5 +8,17 @@ module Collavre
|
|
|
8
8
|
has_many :comments, class_name: "Collavre::Comment", dependent: :destroy
|
|
9
9
|
|
|
10
10
|
validates :name, presence: true, uniqueness: { scope: :creative_id }
|
|
11
|
+
|
|
12
|
+
before_create :set_default_position
|
|
13
|
+
|
|
14
|
+
default_scope { order(:position) }
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def set_default_position
|
|
19
|
+
return if position_changed? && position != 0
|
|
20
|
+
|
|
21
|
+
self.position = (Topic.unscoped.where(creative_id: creative_id).maximum(:position) || -1) + 1
|
|
22
|
+
end
|
|
11
23
|
end
|
|
12
24
|
end
|
data/app/models/collavre/user.rb
CHANGED
|
@@ -17,7 +17,6 @@ module Collavre
|
|
|
17
17
|
has_many :contact_users, through: :contacts
|
|
18
18
|
has_many :contact_memberships, class_name: "Collavre::Contact", foreign_key: :contact_user_id, dependent: :destroy, inverse_of: :contact_user
|
|
19
19
|
has_one :github_account, class_name: "Collavre::GithubAccount", dependent: :destroy
|
|
20
|
-
has_one :notion_account, class_name: "Collavre::NotionAccount", dependent: :destroy
|
|
21
20
|
has_many :tasks, class_name: "Collavre::Task", foreign_key: :agent_id, dependent: :destroy
|
|
22
21
|
|
|
23
22
|
# Associations that reference creatives - must be destroyed BEFORE creatives
|
|
@@ -68,12 +67,27 @@ module Collavre
|
|
|
68
67
|
attribute :llm_vendor, :string
|
|
69
68
|
attribute :llm_model, :string
|
|
70
69
|
attribute :llm_api_key, :string
|
|
70
|
+
attribute :gateway_url, :string
|
|
71
71
|
attribute :tools, :json, default: -> { [] }
|
|
72
72
|
|
|
73
73
|
encrypts :llm_api_key, deterministic: false
|
|
74
74
|
encrypts :google_access_token, deterministic: false
|
|
75
75
|
encrypts :google_refresh_token, deterministic: false
|
|
76
76
|
|
|
77
|
+
# Override encrypted attribute getters to handle decryption errors gracefully.
|
|
78
|
+
# This prevents the entire request from failing when a token can't be decrypted
|
|
79
|
+
# (e.g., if it was encrypted with a different key or is corrupted).
|
|
80
|
+
%i[google_access_token google_refresh_token llm_api_key].each do |attr|
|
|
81
|
+
define_method(attr) do
|
|
82
|
+
super()
|
|
83
|
+
rescue ActiveSupport::MessageEncryptor::InvalidMessage,
|
|
84
|
+
ActiveRecord::Encryption::Errors::Decryption,
|
|
85
|
+
OpenSSL::Cipher::CipherError => e
|
|
86
|
+
Rails.logger.error("Failed to decrypt #{attr} for user #{id}: #{e.class}")
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
77
91
|
SUPPORTED_LLM_MODELS = [
|
|
78
92
|
"gemini-2.5-flash",
|
|
79
93
|
"gemini-1.5-flash",
|