hazo_collab_forms 3.1.6 → 5.0.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.
- package/CHANGE_LOG.md +207 -0
- package/README.md +3 -0
- package/dist/components/clarification/clarification_item_body.d.ts +19 -1
- package/dist/components/clarification/clarification_item_body.d.ts.map +1 -1
- package/dist/components/clarification/clarification_item_body.js +114 -6
- package/dist/components/clarification/clarification_item_body.js.map +1 -1
- package/dist/components/clarification/clarification_thread.js +1 -1
- package/dist/components/clarification/clarification_thread.js.map +1 -1
- package/dist/components/clarification/index.d.ts +2 -0
- package/dist/components/clarification/index.d.ts.map +1 -1
- package/dist/components/clarification/index.js +1 -0
- package/dist/components/clarification/index.js.map +1 -1
- package/dist/components/clarification/resolution_status_strip.d.ts +18 -0
- package/dist/components/clarification/resolution_status_strip.d.ts.map +1 -0
- package/dist/components/clarification/resolution_status_strip.js +20 -0
- package/dist/components/clarification/resolution_status_strip.js.map +1 -0
- package/dist/components/hazo_fb_form/context.d.ts +1 -1
- package/dist/components/hazo_fb_form/context.d.ts.map +1 -1
- package/dist/components/hazo_fb_form/hazo_fb_form.d.ts.map +1 -1
- package/dist/components/hazo_fb_form/hazo_fb_form.js +330 -113
- package/dist/components/hazo_fb_form/hazo_fb_form.js.map +1 -1
- package/dist/components/hazo_fb_form/hooks/use_fb_form_state.d.ts +3 -3
- package/dist/components/hazo_fb_form/hooks/use_fb_form_state.d.ts.map +1 -1
- package/dist/components/hazo_fb_form/hooks/use_fb_form_state.js +340 -61
- package/dist/components/hazo_fb_form/hooks/use_fb_form_state.js.map +1 -1
- package/dist/components/hazo_fb_form/hooks/use_llm_run.d.ts +3 -1
- package/dist/components/hazo_fb_form/hooks/use_llm_run.d.ts.map +1 -1
- package/dist/components/hazo_fb_form/hooks/use_llm_run.js +89 -11
- package/dist/components/hazo_fb_form/hooks/use_llm_run.js.map +1 -1
- package/dist/components/hazo_fb_form/shared/agent_stepper.js +1 -1
- package/dist/components/hazo_fb_form/shared/agent_stepper.js.map +1 -1
- package/dist/components/hazo_fb_form/shared/file_status_accordion.d.ts +9 -0
- package/dist/components/hazo_fb_form/shared/file_status_accordion.d.ts.map +1 -0
- package/dist/components/hazo_fb_form/shared/file_status_accordion.js +39 -0
- package/dist/components/hazo_fb_form/shared/file_status_accordion.js.map +1 -0
- package/dist/components/hazo_fb_form/shared/format.d.ts.map +1 -1
- package/dist/components/hazo_fb_form/shared/format.js +8 -3
- package/dist/components/hazo_fb_form/shared/format.js.map +1 -1
- package/dist/components/hazo_fb_form/shared/send_back_item_card.d.ts +7 -1
- package/dist/components/hazo_fb_form/shared/send_back_item_card.d.ts.map +1 -1
- package/dist/components/hazo_fb_form/shared/send_back_item_card.js +6 -3
- package/dist/components/hazo_fb_form/shared/send_back_item_card.js.map +1 -1
- package/dist/components/hazo_fb_form/types.d.ts +3 -1
- package/dist/components/hazo_fb_form/types.d.ts.map +1 -1
- package/dist/components/hazo_fb_form/views/back_office_view.js +1 -1
- package/dist/components/hazo_fb_form/views/back_office_view.js.map +1 -1
- package/dist/components/hazo_fb_form/views/clarifications_view.js +2 -2
- package/dist/components/hazo_fb_form/views/clarifications_view.js.map +1 -1
- package/dist/components/hazo_fb_form/views/front_office_view.d.ts.map +1 -1
- package/dist/components/hazo_fb_form/views/front_office_view.js +62 -41
- package/dist/components/hazo_fb_form/views/front_office_view.js.map +1 -1
- package/dist/components/hazo_fb_form/views/interim_view.js +3 -3
- package/dist/components/hazo_fb_form/views/interim_view.js.map +1 -1
- package/dist/components/hazo_fb_form/views/review_queue_view.d.ts.map +1 -1
- package/dist/components/hazo_fb_form/views/review_queue_view.js +22 -9
- package/dist/components/hazo_fb_form/views/review_queue_view.js.map +1 -1
- package/dist/components/hazo_validation_rule_editor/components/rule_editor.d.ts.map +1 -1
- package/dist/components/hazo_validation_rule_editor/components/rule_editor.js +32 -3
- package/dist/components/hazo_validation_rule_editor/components/rule_editor.js.map +1 -1
- package/dist/components/hazo_validation_rule_editor/components/variable_chain_input.d.ts +20 -0
- package/dist/components/hazo_validation_rule_editor/components/variable_chain_input.d.ts.map +1 -0
- package/dist/components/hazo_validation_rule_editor/components/variable_chain_input.js +34 -0
- package/dist/components/hazo_validation_rule_editor/components/variable_chain_input.js.map +1 -0
- package/dist/components/hazo_validation_rule_editor/context.d.ts +3 -2
- package/dist/components/hazo_validation_rule_editor/context.d.ts.map +1 -1
- package/dist/components/hazo_validation_rule_editor/context.js +15 -3
- package/dist/components/hazo_validation_rule_editor/context.js.map +1 -1
- package/dist/components/hazo_validation_rule_editor/types.d.ts +7 -1
- package/dist/components/hazo_validation_rule_editor/types.d.ts.map +1 -1
- package/dist/components/hazo_validation_rule_editor/validation_rule_editor.d.ts +1 -1
- package/dist/components/hazo_validation_rule_editor/validation_rule_editor.d.ts.map +1 -1
- package/dist/components/hazo_validation_rule_editor/validation_rule_editor.js +2 -2
- package/dist/components/hazo_validation_rule_editor/validation_rule_editor.js.map +1 -1
- package/dist/components/index.d.ts +2 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +2 -0
- package/dist/components/index.js.map +1 -1
- package/dist/components/shared/document_type_editor.d.ts +31 -0
- package/dist/components/shared/document_type_editor.d.ts.map +1 -0
- package/dist/components/shared/document_type_editor.js +60 -0
- package/dist/components/shared/document_type_editor.js.map +1 -0
- package/dist/components/shared/file_bar/file_bar.d.ts +7 -1
- package/dist/components/shared/file_bar/file_bar.d.ts.map +1 -1
- package/dist/components/shared/file_bar/file_bar.js +5 -3
- package/dist/components/shared/file_bar/file_bar.js.map +1 -1
- package/dist/components/shared/file_bar/file_bar_validation_dialog.js +4 -4
- package/dist/components/shared/file_bar/file_bar_validation_dialog.js.map +1 -1
- package/dist/components/shared/file_status_icon.d.ts +23 -0
- package/dist/components/shared/file_status_icon.d.ts.map +1 -0
- package/dist/components/shared/file_status_icon.js +38 -0
- package/dist/components/shared/file_status_icon.js.map +1 -0
- package/dist/components/shared/json_data_panel/json_data_panel.d.ts +1 -1
- package/dist/components/shared/json_data_panel/json_data_panel.d.ts.map +1 -1
- package/dist/components/shared/json_data_panel/json_data_panel.js +27 -2
- package/dist/components/shared/json_data_panel/json_data_panel.js.map +1 -1
- package/dist/components/shared/rule_result_card.d.ts.map +1 -1
- package/dist/components/shared/rule_result_card.js +5 -4
- package/dist/components/shared/rule_result_card.js.map +1 -1
- package/dist/components/thread_form/components/add_question_dialog.d.ts +12 -0
- package/dist/components/thread_form/components/add_question_dialog.d.ts.map +1 -0
- package/dist/components/thread_form/components/add_question_dialog.js +36 -0
- package/dist/components/thread_form/components/add_question_dialog.js.map +1 -0
- package/dist/components/thread_form/components/agent_compose_dialog.d.ts +30 -0
- package/dist/components/thread_form/components/agent_compose_dialog.d.ts.map +1 -0
- package/dist/components/thread_form/components/agent_compose_dialog.js +45 -0
- package/dist/components/thread_form/components/agent_compose_dialog.js.map +1 -0
- package/dist/components/thread_form/components/clarification.d.ts +14 -0
- package/dist/components/thread_form/components/clarification.d.ts.map +1 -0
- package/dist/components/thread_form/components/clarification.js +12 -0
- package/dist/components/thread_form/components/clarification.js.map +1 -0
- package/dist/components/thread_form/components/collected_data_view.d.ts +15 -0
- package/dist/components/thread_form/components/collected_data_view.d.ts.map +1 -0
- package/dist/components/thread_form/components/collected_data_view.js +121 -0
- package/dist/components/thread_form/components/collected_data_view.js.map +1 -0
- package/dist/components/thread_form/components/coverage_card.d.ts +11 -0
- package/dist/components/thread_form/components/coverage_card.d.ts.map +1 -0
- package/dist/components/thread_form/components/coverage_card.js +60 -0
- package/dist/components/thread_form/components/coverage_card.js.map +1 -0
- package/dist/components/thread_form/components/file_bar.d.ts +93 -0
- package/dist/components/thread_form/components/file_bar.d.ts.map +1 -0
- package/dist/components/thread_form/components/file_bar.js +251 -0
- package/dist/components/thread_form/components/file_bar.js.map +1 -0
- package/dist/components/thread_form/components/file_info_dialog.d.ts +15 -0
- package/dist/components/thread_form/components/file_info_dialog.d.ts.map +1 -0
- package/dist/components/thread_form/components/file_info_dialog.js +64 -0
- package/dist/components/thread_form/components/file_info_dialog.js.map +1 -0
- package/dist/components/thread_form/components/issue_group_tree.d.ts +20 -0
- package/dist/components/thread_form/components/issue_group_tree.d.ts.map +1 -0
- package/dist/components/thread_form/components/issue_group_tree.js +164 -0
- package/dist/components/thread_form/components/issue_group_tree.js.map +1 -0
- package/dist/components/thread_form/components/pdf_side_panel.d.ts +20 -0
- package/dist/components/thread_form/components/pdf_side_panel.d.ts.map +1 -0
- package/dist/components/thread_form/components/pdf_side_panel.js +63 -0
- package/dist/components/thread_form/components/pdf_side_panel.js.map +1 -0
- package/dist/components/thread_form/components/rule_decision_row.d.ts +31 -0
- package/dist/components/thread_form/components/rule_decision_row.d.ts.map +1 -0
- package/dist/components/thread_form/components/rule_decision_row.js +20 -0
- package/dist/components/thread_form/components/rule_decision_row.js.map +1 -0
- package/dist/components/thread_form/components/send_back_message.d.ts +32 -0
- package/dist/components/thread_form/components/send_back_message.d.ts.map +1 -0
- package/dist/components/thread_form/components/send_back_message.js +82 -0
- package/dist/components/thread_form/components/send_back_message.js.map +1 -0
- package/dist/components/thread_form/components/shared.d.ts +54 -0
- package/dist/components/thread_form/components/shared.d.ts.map +1 -0
- package/dist/components/thread_form/components/shared.js +136 -0
- package/dist/components/thread_form/components/shared.js.map +1 -0
- package/dist/components/thread_form/components/task_card.d.ts +90 -0
- package/dist/components/thread_form/components/task_card.d.ts.map +1 -0
- package/dist/components/thread_form/components/task_card.js +63 -0
- package/dist/components/thread_form/components/task_card.js.map +1 -0
- package/dist/components/thread_form/components/text_doc_check.d.ts +15 -0
- package/dist/components/thread_form/components/text_doc_check.d.ts.map +1 -0
- package/dist/components/thread_form/components/text_doc_check.js +16 -0
- package/dist/components/thread_form/components/text_doc_check.js.map +1 -0
- package/dist/components/thread_form/components/text_extraction.d.ts +14 -0
- package/dist/components/thread_form/components/text_extraction.d.ts.map +1 -0
- package/dist/components/thread_form/components/text_extraction.js +16 -0
- package/dist/components/thread_form/components/text_extraction.js.map +1 -0
- package/dist/components/thread_form/components/thread_composer.d.ts +15 -0
- package/dist/components/thread_form/components/thread_composer.d.ts.map +1 -0
- package/dist/components/thread_form/components/thread_composer.js +93 -0
- package/dist/components/thread_form/components/thread_composer.js.map +1 -0
- package/dist/components/thread_form/components/thread_timeline.d.ts +65 -0
- package/dist/components/thread_form/components/thread_timeline.d.ts.map +1 -0
- package/dist/components/thread_form/components/thread_timeline.js +225 -0
- package/dist/components/thread_form/components/thread_timeline.js.map +1 -0
- package/dist/components/thread_form/hooks/use_file_pipeline.d.ts +126 -0
- package/dist/components/thread_form/hooks/use_file_pipeline.d.ts.map +1 -0
- package/dist/components/thread_form/hooks/use_file_pipeline.js +760 -0
- package/dist/components/thread_form/hooks/use_file_pipeline.js.map +1 -0
- package/dist/components/thread_form/hooks/use_thread_form.d.ts +36 -0
- package/dist/components/thread_form/hooks/use_thread_form.d.ts.map +1 -0
- package/dist/components/thread_form/hooks/use_thread_form.js +126 -0
- package/dist/components/thread_form/hooks/use_thread_form.js.map +1 -0
- package/dist/components/thread_form/index.d.ts +33 -0
- package/dist/components/thread_form/index.d.ts.map +1 -0
- package/dist/components/thread_form/index.js +30 -0
- package/dist/components/thread_form/index.js.map +1 -0
- package/dist/components/thread_form/sample_data.d.ts +8 -0
- package/dist/components/thread_form/sample_data.d.ts.map +1 -0
- package/dist/components/thread_form/sample_data.js +658 -0
- package/dist/components/thread_form/sample_data.js.map +1 -0
- package/dist/components/thread_form/thread_form.d.ts +7 -0
- package/dist/components/thread_form/thread_form.d.ts.map +1 -0
- package/dist/components/thread_form/thread_form.js +1385 -0
- package/dist/components/thread_form/thread_form.js.map +1 -0
- package/dist/components/thread_form/types.d.ts +402 -0
- package/dist/components/thread_form/types.d.ts.map +1 -0
- package/dist/components/thread_form/types.js +23 -0
- package/dist/components/thread_form/types.js.map +1 -0
- package/dist/components/thread_form/utils/file_decision_state.d.ts +22 -0
- package/dist/components/thread_form/utils/file_decision_state.d.ts.map +1 -0
- package/dist/components/thread_form/utils/file_decision_state.js +37 -0
- package/dist/components/thread_form/utils/file_decision_state.js.map +1 -0
- package/dist/components/thread_form/utils/merge_send_back.d.ts +13 -0
- package/dist/components/thread_form/utils/merge_send_back.d.ts.map +1 -0
- package/dist/components/thread_form/utils/merge_send_back.js +23 -0
- package/dist/components/thread_form/utils/merge_send_back.js.map +1 -0
- package/dist/lib/autofill_handler.d.ts.map +1 -1
- package/dist/lib/autofill_handler.js +5 -44
- package/dist/lib/autofill_handler.js.map +1 -1
- package/dist/lib/classification_handler.d.ts +105 -0
- package/dist/lib/classification_handler.d.ts.map +1 -0
- package/dist/lib/classification_handler.js +342 -0
- package/dist/lib/classification_handler.js.map +1 -0
- package/dist/lib/content_gate_handler.d.ts +37 -0
- package/dist/lib/content_gate_handler.d.ts.map +1 -0
- package/dist/lib/content_gate_handler.js +126 -0
- package/dist/lib/content_gate_handler.js.map +1 -0
- package/dist/lib/index.d.ts +10 -0
- package/dist/lib/index.d.ts.map +1 -1
- package/dist/lib/index.js +5 -0
- package/dist/lib/index.js.map +1 -1
- package/dist/lib/periodic_coverage_runner.d.ts +24 -0
- package/dist/lib/periodic_coverage_runner.d.ts.map +1 -0
- package/dist/lib/periodic_coverage_runner.js +121 -0
- package/dist/lib/periodic_coverage_runner.js.map +1 -0
- package/dist/lib/resolution_handler.d.ts +150 -0
- package/dist/lib/resolution_handler.d.ts.map +1 -0
- package/dist/lib/resolution_handler.js +597 -0
- package/dist/lib/resolution_handler.js.map +1 -0
- package/dist/lib/resolve_variable.d.ts +25 -0
- package/dist/lib/resolve_variable.d.ts.map +1 -0
- package/dist/lib/resolve_variable.js +77 -0
- package/dist/lib/resolve_variable.js.map +1 -0
- package/dist/lib/validation_handler.d.ts +27 -3
- package/dist/lib/validation_handler.d.ts.map +1 -1
- package/dist/lib/validation_handler.js +338 -288
- package/dist/lib/validation_handler.js.map +1 -1
- package/dist/types/clarification.d.ts +54 -0
- package/dist/types/clarification.d.ts.map +1 -1
- package/dist/types/fb_form_data.d.ts +273 -123
- package/dist/types/fb_form_data.d.ts.map +1 -1
- package/dist/types/fb_form_data.js +44 -58
- package/dist/types/fb_form_data.js.map +1 -1
- package/dist/types/fb_form_data_v1.d.ts +250 -0
- package/dist/types/fb_form_data_v1.d.ts.map +1 -0
- package/dist/types/fb_form_data_v1.js +117 -0
- package/dist/types/fb_form_data_v1.js.map +1 -0
- package/dist/types/fb_form_instance.d.ts +1 -1
- package/dist/types/fb_form_instance.d.ts.map +1 -1
- package/dist/types/index.d.ts +5 -3
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +2 -1
- package/dist/types/index.js.map +1 -1
- package/dist/types/validation.d.ts +134 -12
- package/dist/types/validation.d.ts.map +1 -1
- package/dist/utils/expectation_extractor.d.ts +31 -0
- package/dist/utils/expectation_extractor.d.ts.map +1 -0
- package/dist/utils/expectation_extractor.js +142 -0
- package/dist/utils/expectation_extractor.js.map +1 -0
- package/dist/utils/fb_data_adapter.d.ts +7 -2
- package/dist/utils/fb_data_adapter.d.ts.map +1 -1
- package/dist/utils/fb_data_adapter.js +58 -7
- package/dist/utils/fb_data_adapter.js.map +1 -1
- package/dist/utils/fb_data_adapter_v2.d.ts +17 -0
- package/dist/utils/fb_data_adapter_v2.d.ts.map +1 -0
- package/dist/utils/fb_data_adapter_v2.js +483 -0
- package/dist/utils/fb_data_adapter_v2.js.map +1 -0
- package/dist/utils/fb_data_helpers.d.ts +1 -1
- package/dist/utils/fb_data_helpers.d.ts.map +1 -1
- package/dist/utils/fb_data_mutations.d.ts +1 -1
- package/dist/utils/fb_data_mutations.d.ts.map +1 -1
- package/dist/utils/fb_data_mutations_v2.d.ts +46 -0
- package/dist/utils/fb_data_mutations_v2.d.ts.map +1 -0
- package/dist/utils/fb_data_mutations_v2.js +341 -0
- package/dist/utils/fb_data_mutations_v2.js.map +1 -0
- package/dist/utils/fb_data_queries.d.ts +81 -0
- package/dist/utils/fb_data_queries.d.ts.map +1 -0
- package/dist/utils/fb_data_queries.js +354 -0
- package/dist/utils/fb_data_queries.js.map +1 -0
- package/dist/utils/index.d.ts +4 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +6 -0
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/issue_bucketing.d.ts +36 -0
- package/dist/utils/issue_bucketing.d.ts.map +1 -0
- package/dist/utils/issue_bucketing.js +107 -0
- package/dist/utils/issue_bucketing.js.map +1 -0
- package/dist/utils/validation_result.d.ts +32 -0
- package/dist/utils/validation_result.d.ts.map +1 -0
- package/dist/utils/validation_result.js +55 -0
- package/dist/utils/validation_result.js.map +1 -0
- package/package.json +16 -4
|
@@ -0,0 +1,1385 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ThreadForm — main component.
|
|
3
|
+
* Conversational task-based form with agent/client views.
|
|
4
|
+
*/
|
|
5
|
+
'use client';
|
|
6
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
7
|
+
import { useState, useCallback, useRef, useMemo } from 'react';
|
|
8
|
+
import { cn } from './components/shared.js';
|
|
9
|
+
import { TaskCard } from './components/task_card.js';
|
|
10
|
+
import { AddQuestionDialog } from './components/add_question_dialog.js';
|
|
11
|
+
import { AgentComposeDialog, build_compose_from_check, build_compose_from_field } from './components/agent_compose_dialog.js';
|
|
12
|
+
import { CollectedDataView } from './components/collected_data_view.js';
|
|
13
|
+
import { ThreadPdfSidePanel } from './components/pdf_side_panel.js';
|
|
14
|
+
import { use_file_pipeline } from './hooks/use_file_pipeline.js';
|
|
15
|
+
import { extract_expectation } from '../../utils/expectation_extractor.js';
|
|
16
|
+
import { infer_mime_type } from '../hazo_fb_form/shared/format.js';
|
|
17
|
+
import { merge_send_back_message } from './utils/merge_send_back.js';
|
|
18
|
+
import { compute_file_decision_state } from './utils/file_decision_state.js';
|
|
19
|
+
/** Pick the best MIME type we can. Browsers can hand back empty string OR
|
|
20
|
+
* application/octet-stream for files dragged from email/Finder/etc., and the
|
|
21
|
+
* classification API (Gemini) rejects octet-stream outright. Sniff the
|
|
22
|
+
* extension as a fallback. */
|
|
23
|
+
function normalize_file_mime(file_type, file_name) {
|
|
24
|
+
if (file_type && file_type !== 'application/octet-stream')
|
|
25
|
+
return file_type;
|
|
26
|
+
return infer_mime_type(file_name);
|
|
27
|
+
}
|
|
28
|
+
export function ThreadForm({ data, role, current_user, on_form_change, on_task_update, on_message_add, on_message_edit, on_message_delete, on_send_form, on_close_form, on_export_data, on_request_analysis, on_request_reanalysis, file_manager, pdf_viewer, show_json_viewer, classification_api_url, validation_api_url, validation_rules_api_url, content_gate_api_url, resolution_api_url, response_extraction_api_url, available_document_types, available_tags, on_update_content_item, on_log, view_button_variant, show_info_icon, }) {
|
|
29
|
+
const [expanded_tasks, set_expanded_tasks] = useState(() => {
|
|
30
|
+
const first_open = data.tasks.find(t => t.status !== 'completed')?.task_id;
|
|
31
|
+
return new Set(first_open ? [first_open] : []);
|
|
32
|
+
});
|
|
33
|
+
const [active_tab, set_active_tab] = useState('tasks');
|
|
34
|
+
const [backoffice_running, set_backoffice_running] = useState(false);
|
|
35
|
+
/**
|
|
36
|
+
* Set of task_ids that currently have an aggregate resolver in-flight.
|
|
37
|
+
* Drives the spinner badge inside SendBackMessage's Resolution Status panel
|
|
38
|
+
* so the user sees that bullets may be stale while a recompute is running.
|
|
39
|
+
*/
|
|
40
|
+
const [resolution_running, set_resolution_running] = useState(new Set());
|
|
41
|
+
const [show_json, set_show_json] = useState(false);
|
|
42
|
+
const [show_add_question, set_show_add_question] = useState(false);
|
|
43
|
+
const [compose_context, set_compose_context] = useState(null);
|
|
44
|
+
const [viewed_item, set_viewed_item] = useState(null);
|
|
45
|
+
// Keep a ref to data for callbacks that need the latest state
|
|
46
|
+
const data_ref = useRef(data);
|
|
47
|
+
data_ref.current = data;
|
|
48
|
+
const pipeline = use_file_pipeline({
|
|
49
|
+
classification_api_url,
|
|
50
|
+
validation_api_url,
|
|
51
|
+
validation_rules_api_url,
|
|
52
|
+
content_gate_api_url,
|
|
53
|
+
response_extraction_api_url,
|
|
54
|
+
document_types: available_document_types,
|
|
55
|
+
available_tags,
|
|
56
|
+
file_manager: file_manager?.callbacks,
|
|
57
|
+
on_log,
|
|
58
|
+
});
|
|
59
|
+
// ── Derived state ──
|
|
60
|
+
const pending_count = data.tasks.reduce((acc, task) => acc + task.thread.filter(m => m.type === 'ai_analysis' && m.ai_analysis?.items.some(i => i.review_status === 'pending')).length, 0);
|
|
61
|
+
const review_tasks = data.tasks.filter(task => task.thread.some(m => m.type === 'ai_analysis' && m.ai_analysis?.items.some(i => i.review_status === 'pending')));
|
|
62
|
+
// 1-based question numbers keyed on position in the full task list, so they
|
|
63
|
+
// remain stable across tab filters (tasks vs review).
|
|
64
|
+
const question_numbers = useMemo(() => new Map(data.tasks.map((t, i) => [t.task_id, i + 1])), [data.tasks]);
|
|
65
|
+
/**
|
|
66
|
+
* Set of source_content_ids whose spawned send-back task is now resolved
|
|
67
|
+
* (status === 'completed'). Used to suppress the parent file's "Sent Back"
|
|
68
|
+
* pill once the issue has been addressed in the child question.
|
|
69
|
+
*/
|
|
70
|
+
const sent_back_resolved_ids = useMemo(() => {
|
|
71
|
+
const ids = new Set();
|
|
72
|
+
for (const t of data.tasks) {
|
|
73
|
+
if (t.status !== 'completed')
|
|
74
|
+
continue;
|
|
75
|
+
const first = t.thread[0];
|
|
76
|
+
if (first?.type === 'send_back' && first.send_back?.source_content_id) {
|
|
77
|
+
ids.add(first.send_back.source_content_id);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return ids;
|
|
81
|
+
}, [data.tasks]);
|
|
82
|
+
/**
|
|
83
|
+
* Resolve a spawned task's back-reference to the file+issue it came from.
|
|
84
|
+
* A spawned task's first message is a `send_back`; we look up the file's
|
|
85
|
+
* originating task in the current form data.
|
|
86
|
+
*/
|
|
87
|
+
const get_parent_ref = useCallback((task) => {
|
|
88
|
+
const first = task.thread[0];
|
|
89
|
+
if (!first || first.type !== 'send_back' || !first.send_back)
|
|
90
|
+
return null;
|
|
91
|
+
const { source_content_id, source_file, issues } = first.send_back;
|
|
92
|
+
const parent_task = data.tasks.find(t => t.thread.some(m => m.content_items?.some(ci => ci.content_id === source_content_id)));
|
|
93
|
+
if (!parent_task)
|
|
94
|
+
return null;
|
|
95
|
+
const rule_names = Array.from(new Set(issues.map(i => i.rule_name).filter((n) => !!n)));
|
|
96
|
+
return {
|
|
97
|
+
parent_task_id: parent_task.task_id,
|
|
98
|
+
parent_question_number: question_numbers.get(parent_task.task_id) ?? 0,
|
|
99
|
+
file_name: source_file?.file_name,
|
|
100
|
+
rule_names,
|
|
101
|
+
};
|
|
102
|
+
}, [data.tasks, question_numbers]);
|
|
103
|
+
/** Expand the target task and scroll to it with a brief highlight. */
|
|
104
|
+
const handle_navigate_to_task = useCallback((task_id) => {
|
|
105
|
+
set_expanded_tasks(prev => {
|
|
106
|
+
if (prev.has(task_id))
|
|
107
|
+
return prev;
|
|
108
|
+
const next = new Set(prev);
|
|
109
|
+
next.add(task_id);
|
|
110
|
+
return next;
|
|
111
|
+
});
|
|
112
|
+
// Defer scroll so the expansion has rendered.
|
|
113
|
+
setTimeout(() => {
|
|
114
|
+
const el = document.getElementById(`task-${task_id}`);
|
|
115
|
+
if (!el)
|
|
116
|
+
return;
|
|
117
|
+
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
118
|
+
el.classList.add('ring-2', 'ring-amber-300');
|
|
119
|
+
setTimeout(() => el.classList.remove('ring-2', 'ring-amber-300'), 1500);
|
|
120
|
+
}, 50);
|
|
121
|
+
}, []);
|
|
122
|
+
// ── Helpers ──
|
|
123
|
+
/** Read a File as base64 (data portion only, no prefix). */
|
|
124
|
+
const file_to_base64 = useCallback((file) => {
|
|
125
|
+
return new Promise((resolve, reject) => {
|
|
126
|
+
const reader = new FileReader();
|
|
127
|
+
reader.onload = () => {
|
|
128
|
+
const result = reader.result;
|
|
129
|
+
// Strip "data:...;base64," prefix
|
|
130
|
+
resolve(result.split(',')[1] || '');
|
|
131
|
+
};
|
|
132
|
+
reader.onerror = reject;
|
|
133
|
+
reader.readAsDataURL(file);
|
|
134
|
+
});
|
|
135
|
+
}, []);
|
|
136
|
+
const format_file_size = useCallback((bytes) => {
|
|
137
|
+
if (bytes < 1024)
|
|
138
|
+
return `${bytes}B`;
|
|
139
|
+
if (bytes < 1048576)
|
|
140
|
+
return `${Math.round(bytes / 1024)}KB`;
|
|
141
|
+
return `${(bytes / 1048576).toFixed(1)}MB`;
|
|
142
|
+
}, []);
|
|
143
|
+
// ── Derived state for agent's backoffice action ──
|
|
144
|
+
const pending_backoffice_items = useMemo(() => {
|
|
145
|
+
const out = [];
|
|
146
|
+
for (const task of data.tasks) {
|
|
147
|
+
// Skip tasks the agent has already approved — files inside show "Resolved"
|
|
148
|
+
// and there's no need to re-validate them.
|
|
149
|
+
if (task.status === 'completed')
|
|
150
|
+
continue;
|
|
151
|
+
for (const msg of task.thread) {
|
|
152
|
+
for (const ci of msg.content_items || []) {
|
|
153
|
+
if (ci.type !== 'file')
|
|
154
|
+
continue;
|
|
155
|
+
if (ci.backoffice_validated_at)
|
|
156
|
+
continue;
|
|
157
|
+
if (ci.replaced_by_content_id)
|
|
158
|
+
continue; // skip superseded files
|
|
159
|
+
if (ci.accepted)
|
|
160
|
+
continue; // skip files the agent already accepted
|
|
161
|
+
out.push(ci);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}, [data.tasks]);
|
|
167
|
+
// ── Callbacks ──
|
|
168
|
+
/** Agent action: run backoffice validation on every file that hasn't had it yet. */
|
|
169
|
+
const handle_run_backoffice = useCallback(async () => {
|
|
170
|
+
if (backoffice_running || pending_backoffice_items.length === 0)
|
|
171
|
+
return;
|
|
172
|
+
set_backoffice_running(true);
|
|
173
|
+
try {
|
|
174
|
+
const results = await pipeline.run_backoffice_validation(pending_backoffice_items);
|
|
175
|
+
const now = new Date().toISOString();
|
|
176
|
+
let processed = 0;
|
|
177
|
+
for (const item of pending_backoffice_items) {
|
|
178
|
+
const r = results.get(item.content_id);
|
|
179
|
+
// Only stamp backoffice_validated_at when the pipeline actually returned
|
|
180
|
+
// results for this item — otherwise we'd silently mark failed/skipped
|
|
181
|
+
// items as done and the agent couldn't retry.
|
|
182
|
+
if (!r)
|
|
183
|
+
continue;
|
|
184
|
+
const prev = (item.validation_rule_results ?? []);
|
|
185
|
+
const new_backoffice = r.validation_results ?? [];
|
|
186
|
+
const new_rule_ids = new Set(new_backoffice.map(n => n.rule_id));
|
|
187
|
+
const kept = prev.filter(p => !(p.check_type === 'backoffice' && new_rule_ids.has(p.rule_id)));
|
|
188
|
+
on_update_content_item?.(item.content_id, {
|
|
189
|
+
validation_rule_results: [...kept, ...new_backoffice],
|
|
190
|
+
backoffice_validated_at: now,
|
|
191
|
+
});
|
|
192
|
+
processed++;
|
|
193
|
+
}
|
|
194
|
+
const skipped = pending_backoffice_items.length - processed;
|
|
195
|
+
on_log?.(`${current_user.name} (${current_user.id}): Ran backoffice validation on ${processed} file(s)${skipped > 0 ? ` (${skipped} skipped)` : ''}`);
|
|
196
|
+
// Per-group periodic_coverage pass — runs after per-file backoffice so each
|
|
197
|
+
// file's extracted_data is available. Run scoped to each task so the
|
|
198
|
+
// resulting CoverageCard renders inside its owning task.
|
|
199
|
+
// FIXME(periodic-coverage): session vars hardcoded to AU FY 2024-25 to match
|
|
200
|
+
// the test-app's rental statement fixtures. Consumers should source these
|
|
201
|
+
// from config, form fields, or a tax-year selector before production use.
|
|
202
|
+
const coverage_ctx = {
|
|
203
|
+
form_data: null,
|
|
204
|
+
session: {
|
|
205
|
+
Beginning_Of_Tax_Year: '2024-07-01',
|
|
206
|
+
End_Of_Tax_Year: '2025-06-30',
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
let any_coverage_results = false;
|
|
210
|
+
for (const task of data_ref.current.tasks) {
|
|
211
|
+
const task_file_items = task.thread
|
|
212
|
+
.flatMap(m => m.content_items ?? [])
|
|
213
|
+
.filter(i => i.type === 'file');
|
|
214
|
+
if (task_file_items.length === 0)
|
|
215
|
+
continue;
|
|
216
|
+
const cov = await pipeline.run_periodic_coverage_pass(task_file_items, coverage_ctx);
|
|
217
|
+
// Persist on the task (overwrites any prior results — re-running
|
|
218
|
+
// backoffice fully recomputes coverage for that task).
|
|
219
|
+
on_task_update?.({ ...task, coverage_results: cov });
|
|
220
|
+
if (cov.length === 0)
|
|
221
|
+
continue;
|
|
222
|
+
any_coverage_results = true;
|
|
223
|
+
for (const c of cov) {
|
|
224
|
+
if (c.skipped) {
|
|
225
|
+
on_log?.(`${current_user.name} (${current_user.id}): Periodic coverage [${c.rule_name}] on group "${c.group_key}" — skipped (period bounds did not resolve)`);
|
|
226
|
+
}
|
|
227
|
+
else if (c.gaps.length === 0) {
|
|
228
|
+
on_log?.(`${current_user.name} (${current_user.id}): Periodic coverage [${c.rule_name}] on group "${c.group_key}" — full coverage, no gaps`);
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
const gap_summary = c.gaps.map(g => `${g.start}…${g.end}`).join(', ');
|
|
232
|
+
on_log?.(`${current_user.name} (${current_user.id}): Periodic coverage [${c.rule_name}] on group "${c.group_key}" — ${c.gaps.length} gap(s): ${gap_summary}`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (!any_coverage_results) {
|
|
237
|
+
on_log?.(`${current_user.name} (${current_user.id}): Periodic coverage check — no periodic_coverage rules matched any group's document_type`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
catch (err) {
|
|
241
|
+
console.error('[thread-form] Backoffice validation failed:', err);
|
|
242
|
+
}
|
|
243
|
+
finally {
|
|
244
|
+
set_backoffice_running(false);
|
|
245
|
+
}
|
|
246
|
+
}, [backoffice_running, pending_backoffice_items, pipeline, on_update_content_item, on_log, current_user]);
|
|
247
|
+
/**
|
|
248
|
+
* Run the aggregate resolver (sum_match) for a clarification task.
|
|
249
|
+
* Walks every client-uploaded file in the task, gathers extracted_fields
|
|
250
|
+
* + classification, and posts to the resolution API. Emits one
|
|
251
|
+
* resolution_verdict message + per-file doc_check messages, and updates
|
|
252
|
+
* task status to completed/needs_attention based on the verdict.
|
|
253
|
+
*
|
|
254
|
+
* Called from:
|
|
255
|
+
* - handle_send (after a file-bearing send) — passes fresh classifications
|
|
256
|
+
* from the just-uploaded files via fresh_cls_map + the b64 cache.
|
|
257
|
+
* - handle_replacement_files (after a Replace Document upload finishes).
|
|
258
|
+
* - handle_remove_file (after the user deletes a file from the task).
|
|
259
|
+
*
|
|
260
|
+
* Pass `data_snapshot` when the caller has a fresher view of the data
|
|
261
|
+
* than data_ref.current (e.g. immediately after on_form_change).
|
|
262
|
+
*/
|
|
263
|
+
const run_resolution = useCallback(async (task_id, opts = {}) => {
|
|
264
|
+
if (!resolution_api_url)
|
|
265
|
+
return;
|
|
266
|
+
const source = opts.data_snapshot ?? data_ref.current;
|
|
267
|
+
const parent_task = source.tasks.find(t => t.task_id === task_id);
|
|
268
|
+
const send_back_msg = parent_task?.thread.find(m => m.type === 'send_back' && m.send_back);
|
|
269
|
+
const issues_with_amount = send_back_msg?.send_back?.issues ?? [];
|
|
270
|
+
const sb_issue = issues_with_amount.find(i => i.amount) ??
|
|
271
|
+
issues_with_amount.find(i => /\$\s*\d+(?:[.,]\d+)?/.test(i.issue_description));
|
|
272
|
+
if (!sb_issue)
|
|
273
|
+
return;
|
|
274
|
+
const text_amount = sb_issue.issue_description.match(/\$\s*\d+(?:[.,]\d+)?/);
|
|
275
|
+
const text_date = sb_issue.issue_description.match(/\b\d{1,2}\s+[A-Za-z]+\s+\d{4}\s+(?:-|–|—|to)\s+\d{1,2}\s+[A-Za-z]+\s+\d{4}\b|\b\d{4}-\d{2}-\d{2}\s+(?:-|–|—|to)\s+\d{4}-\d{2}-\d{2}\b/);
|
|
276
|
+
const issue_for_extract = {
|
|
277
|
+
issue_id: sb_issue.issue_id ?? sb_issue.rule_id,
|
|
278
|
+
issue_description: sb_issue.issue_description,
|
|
279
|
+
amount: sb_issue.amount ?? text_amount?.[0],
|
|
280
|
+
date: sb_issue.date ?? text_date?.[0],
|
|
281
|
+
};
|
|
282
|
+
const expectation = extract_expectation(issue_for_extract, { default_currency: 'AUD' });
|
|
283
|
+
if (!expectation)
|
|
284
|
+
return;
|
|
285
|
+
// Walk the task thread and gather every client-uploaded file. Gate on
|
|
286
|
+
// having at least one file — running with zero files would always fail
|
|
287
|
+
// and isn't useful (the panel will keep its prior verdict, which gets
|
|
288
|
+
// cleared by the bookkeeping below if the task is now empty of files).
|
|
289
|
+
const messages = parent_task?.thread ?? [];
|
|
290
|
+
const send_back_idx = messages.findIndex(m => m.type === 'send_back');
|
|
291
|
+
const seen_file_ids = new Set();
|
|
292
|
+
const aggregated = [];
|
|
293
|
+
for (let i = 0; i < messages.length; i++) {
|
|
294
|
+
if (i <= send_back_idx)
|
|
295
|
+
continue;
|
|
296
|
+
const m = messages[i];
|
|
297
|
+
if (m.type !== 'content')
|
|
298
|
+
continue;
|
|
299
|
+
for (const ci of m.content_items ?? []) {
|
|
300
|
+
if (ci.type !== 'file' || !ci.file?.file_id)
|
|
301
|
+
continue;
|
|
302
|
+
if (seen_file_ids.has(ci.file.file_id))
|
|
303
|
+
continue;
|
|
304
|
+
seen_file_ids.add(ci.file.file_id);
|
|
305
|
+
const fresh = opts.fresh_cls_map?.get(ci.content_id);
|
|
306
|
+
const cls = fresh ?? ci.classification_result;
|
|
307
|
+
const ef = cls?.extracted_fields;
|
|
308
|
+
aggregated.push({
|
|
309
|
+
content_id: ci.content_id,
|
|
310
|
+
file_id: ci.file.file_id,
|
|
311
|
+
file_name: ci.file.file_name,
|
|
312
|
+
mime_type: ci.file.mime_type,
|
|
313
|
+
...(cls?.document_type ? { document_type: cls.document_type } : {}),
|
|
314
|
+
...(cls?.document_nature ? { document_nature: cls.document_nature } : {}),
|
|
315
|
+
...(ef ? { extracted_fields: ef } : {}),
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (aggregated.length === 0)
|
|
320
|
+
return;
|
|
321
|
+
on_log?.(`Aggregate resolver: running sum_match for "${sb_issue.issue_description}" (${aggregated.length} file${aggregated.length === 1 ? '' : 's'})`);
|
|
322
|
+
set_resolution_running(prev => {
|
|
323
|
+
const next = new Set(prev);
|
|
324
|
+
next.add(task_id);
|
|
325
|
+
return next;
|
|
326
|
+
});
|
|
327
|
+
try {
|
|
328
|
+
// Resolve base64 for each file. Current-send files are in the
|
|
329
|
+
// caller-supplied b64 cache (handle_send); prior-send files come
|
|
330
|
+
// from file_manager.get_download_url + fetch.
|
|
331
|
+
const fm = file_manager?.callbacks;
|
|
332
|
+
const file_b64_resolved = new Map();
|
|
333
|
+
await Promise.all(aggregated.map(async (f) => {
|
|
334
|
+
const cached = opts.current_send_b64_by_content_id?.get(f.content_id);
|
|
335
|
+
if (cached) {
|
|
336
|
+
file_b64_resolved.set(f.file_id, cached);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
if (!fm?.get_download_url)
|
|
340
|
+
return;
|
|
341
|
+
try {
|
|
342
|
+
const url = fm.get_download_url(f.file_id, 'public');
|
|
343
|
+
if (!url)
|
|
344
|
+
return;
|
|
345
|
+
const blob_res = await fetch(url);
|
|
346
|
+
if (!blob_res.ok)
|
|
347
|
+
return;
|
|
348
|
+
const buf = await blob_res.arrayBuffer();
|
|
349
|
+
const bytes = new Uint8Array(buf);
|
|
350
|
+
let binary = '';
|
|
351
|
+
const chunk = 0x8000;
|
|
352
|
+
for (let i = 0; i < bytes.length; i += chunk) {
|
|
353
|
+
binary += String.fromCharCode.apply(null, Array.from(bytes.subarray(i, i + chunk)));
|
|
354
|
+
}
|
|
355
|
+
file_b64_resolved.set(f.file_id, btoa(binary));
|
|
356
|
+
}
|
|
357
|
+
catch (err) {
|
|
358
|
+
console.warn('[ThreadForm] Resolver: failed to fetch base64 for', f.file_name, err);
|
|
359
|
+
}
|
|
360
|
+
}));
|
|
361
|
+
on_log?.(`Aggregate resolver: ${file_b64_resolved.size}/${aggregated.length} file(s) have file_b64 — sending to LLM`);
|
|
362
|
+
const response_files = aggregated.map(f => ({
|
|
363
|
+
file_id: f.file_id,
|
|
364
|
+
file_name: f.file_name,
|
|
365
|
+
...(f.mime_type ? { mime_type: f.mime_type } : {}),
|
|
366
|
+
...(f.document_type ? { document_type: f.document_type } : {}),
|
|
367
|
+
...(f.document_nature ? { document_nature: f.document_nature } : {}),
|
|
368
|
+
...(f.extracted_fields ? { extracted_fields: f.extracted_fields } : {}),
|
|
369
|
+
...(file_b64_resolved.has(f.file_id) ? { file_b64: file_b64_resolved.get(f.file_id) } : {}),
|
|
370
|
+
}));
|
|
371
|
+
const res = await fetch(resolution_api_url, {
|
|
372
|
+
method: 'POST',
|
|
373
|
+
headers: { 'Content-Type': 'application/json' },
|
|
374
|
+
body: JSON.stringify({ expectation, response_files }),
|
|
375
|
+
});
|
|
376
|
+
const data_resp = await res.json();
|
|
377
|
+
if (!res.ok || data_resp?.success === false) {
|
|
378
|
+
on_log?.(`Aggregate resolver: API error — skipping verdict`);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
const check = data_resp.check;
|
|
382
|
+
if (!check)
|
|
383
|
+
return;
|
|
384
|
+
on_log?.(`Aggregate resolver: ${check.passed ? 'passed' : 'failed'} — ${check.summary}`);
|
|
385
|
+
on_message_add?.(task_id, {
|
|
386
|
+
message_id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
|
387
|
+
role: 'system',
|
|
388
|
+
type: 'resolution_verdict',
|
|
389
|
+
visibility: 'all',
|
|
390
|
+
resolution_verdict: check,
|
|
391
|
+
time: new Date().toISOString(),
|
|
392
|
+
});
|
|
393
|
+
const file_rel = check.file_relevance ?? {};
|
|
394
|
+
for (const f of aggregated) {
|
|
395
|
+
const r = file_rel[f.file_id];
|
|
396
|
+
if (!r)
|
|
397
|
+
continue;
|
|
398
|
+
const status = r.verdict === 'matches' ? 'passed' : 'issues';
|
|
399
|
+
on_message_add?.(task_id, {
|
|
400
|
+
message_id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
|
401
|
+
role: 'system',
|
|
402
|
+
type: 'doc_check',
|
|
403
|
+
visibility: 'all',
|
|
404
|
+
doc_check: {
|
|
405
|
+
content_ref: f.content_id,
|
|
406
|
+
source_type: 'file',
|
|
407
|
+
status,
|
|
408
|
+
summary: r.reason,
|
|
409
|
+
relevance: r.verdict,
|
|
410
|
+
relevance_reason: r.reason,
|
|
411
|
+
},
|
|
412
|
+
time: new Date().toISOString(),
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
// Re-read the task from data_ref so on_task_update receives the
|
|
416
|
+
// most recent thread (verdict messages we just emitted may not yet
|
|
417
|
+
// be reflected here, which is fine — task_update only mutates status).
|
|
418
|
+
const latest_task = data_ref.current.tasks.find(t => t.task_id === task_id);
|
|
419
|
+
if (latest_task) {
|
|
420
|
+
const next_status = check.passed ? 'completed' : 'needs_attention';
|
|
421
|
+
if (latest_task.status !== next_status) {
|
|
422
|
+
on_task_update?.({
|
|
423
|
+
...latest_task,
|
|
424
|
+
status: next_status,
|
|
425
|
+
completed_at: check.passed ? new Date().toISOString() : undefined,
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
catch (err) {
|
|
431
|
+
console.warn('[ThreadForm] resolution call failed', err);
|
|
432
|
+
}
|
|
433
|
+
finally {
|
|
434
|
+
set_resolution_running(prev => {
|
|
435
|
+
if (!prev.has(task_id))
|
|
436
|
+
return prev;
|
|
437
|
+
const next = new Set(prev);
|
|
438
|
+
next.delete(task_id);
|
|
439
|
+
return next;
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
}, [resolution_api_url, file_manager, on_message_add, on_task_update, on_log]);
|
|
443
|
+
/** Combined send: text + files. Uploads files via file_manager, then triggers classification pipeline. */
|
|
444
|
+
const handle_send = useCallback(async (task_id, text, files) => {
|
|
445
|
+
const has_text = text.trim().length > 0;
|
|
446
|
+
const has_files = files.length > 0;
|
|
447
|
+
if (!has_text && !has_files)
|
|
448
|
+
return;
|
|
449
|
+
// Upload files via file_manager if available, otherwise create local-only references
|
|
450
|
+
const content_items = [];
|
|
451
|
+
const file_b64_map = new Map(); // content_id → base64
|
|
452
|
+
for (const file of files) {
|
|
453
|
+
const content_id = `ct_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
|
454
|
+
let file_id;
|
|
455
|
+
let file_size_str;
|
|
456
|
+
if (file_manager?.callbacks?.upload) {
|
|
457
|
+
try {
|
|
458
|
+
const attachment = await file_manager.callbacks.upload(file, {
|
|
459
|
+
entity_id: task_id,
|
|
460
|
+
entity_type: 'thread_task',
|
|
461
|
+
visibility: 'public',
|
|
462
|
+
uploaded_by: current_user.id,
|
|
463
|
+
});
|
|
464
|
+
file_id = attachment.file_id;
|
|
465
|
+
file_size_str = format_file_size(attachment.file_size);
|
|
466
|
+
}
|
|
467
|
+
catch (err) {
|
|
468
|
+
console.error('[ThreadForm] File upload failed:', err);
|
|
469
|
+
// Fall back to local ID
|
|
470
|
+
file_id = `f_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
|
471
|
+
file_size_str = format_file_size(file.size);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
else {
|
|
475
|
+
file_id = `f_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
|
476
|
+
file_size_str = format_file_size(file.size);
|
|
477
|
+
}
|
|
478
|
+
content_items.push({
|
|
479
|
+
content_id,
|
|
480
|
+
type: 'file',
|
|
481
|
+
file: {
|
|
482
|
+
file_id,
|
|
483
|
+
file_name: file.name,
|
|
484
|
+
file_size: file_size_str,
|
|
485
|
+
mime_type: normalize_file_mime(file.type, file.name),
|
|
486
|
+
},
|
|
487
|
+
});
|
|
488
|
+
// Read base64 for pipeline (classification + validation)
|
|
489
|
+
if (classification_api_url) {
|
|
490
|
+
try {
|
|
491
|
+
const b64 = await file_to_base64(file);
|
|
492
|
+
file_b64_map.set(content_id, b64);
|
|
493
|
+
}
|
|
494
|
+
catch {
|
|
495
|
+
console.error('[ThreadForm] Failed to read file as base64:', file.name);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
const msg = {
|
|
500
|
+
message_id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
|
501
|
+
role: role,
|
|
502
|
+
type: has_files ? 'content' : 'comment',
|
|
503
|
+
visibility: 'all',
|
|
504
|
+
content_text: has_text ? text.trim() : undefined,
|
|
505
|
+
content_items: has_files ? content_items : undefined,
|
|
506
|
+
user_name: current_user.name,
|
|
507
|
+
user_id: current_user.id,
|
|
508
|
+
time: new Date().toISOString(),
|
|
509
|
+
};
|
|
510
|
+
on_message_add?.(task_id, msg);
|
|
511
|
+
// Build a post-add snapshot so the resolver definitely sees the just-uploaded
|
|
512
|
+
// files, even if React hasn't re-rendered the parent (and refreshed data_ref)
|
|
513
|
+
// by the time the pipeline finishes. Earlier this caused a race where new
|
|
514
|
+
// files were missing from the resolver's file list — verdict reflected only
|
|
515
|
+
// the prior batch and the new total looked off (e.g. $137.50 of $437.47 when
|
|
516
|
+
// the new file should have brought it to $437.47 exactly).
|
|
517
|
+
const post_add_snapshot = {
|
|
518
|
+
...data_ref.current,
|
|
519
|
+
updated_at: msg.time,
|
|
520
|
+
tasks: data_ref.current.tasks.map(t => t.task_id === task_id ? { ...t, thread: [...t.thread, msg] } : t),
|
|
521
|
+
};
|
|
522
|
+
// Trigger classification + validation pipeline for each file (async, non-blocking).
|
|
523
|
+
// Capture each promise so the aggregate resolver below can await them and
|
|
524
|
+
// read the classification result directly — closure-captured pipeline.classification_results
|
|
525
|
+
// is stale by the time it runs.
|
|
526
|
+
const pipeline_promises = [];
|
|
527
|
+
if (classification_api_url) {
|
|
528
|
+
on_log?.(`Upload path (compose): ${content_items.length} file(s) → triggering pipeline`);
|
|
529
|
+
for (const item of content_items) {
|
|
530
|
+
const b64 = file_b64_map.get(item.content_id);
|
|
531
|
+
if (b64 && item.type === 'file') {
|
|
532
|
+
const p = pipeline.process_file(item, b64).then(result => {
|
|
533
|
+
// Inject doc_check and ai_analysis messages into the thread
|
|
534
|
+
if (result.doc_check) {
|
|
535
|
+
on_message_add?.(task_id, {
|
|
536
|
+
message_id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
|
537
|
+
role: 'system', type: 'doc_check', visibility: 'all',
|
|
538
|
+
doc_check: result.doc_check, time: new Date().toISOString(),
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
if (result.ai_result) {
|
|
542
|
+
on_message_add?.(task_id, {
|
|
543
|
+
message_id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
|
544
|
+
role: 'system', type: 'ai_analysis', visibility: 'agent_only',
|
|
545
|
+
ai_analysis: { items: [result.ai_result] }, time: new Date().toISOString(),
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
// Persist classification + validation results on the ContentItem.
|
|
549
|
+
// Keep the full ValidationRuleResult shape (issues, check_type, …)
|
|
550
|
+
// so UI-side normalizers work the same after a refresh.
|
|
551
|
+
if (result.classification || result.validation_results.length > 0) {
|
|
552
|
+
setTimeout(() => {
|
|
553
|
+
handle_update_content_item(item.content_id, {
|
|
554
|
+
classification_result: result.classification || undefined,
|
|
555
|
+
validation_rule_results: result.validation_results,
|
|
556
|
+
});
|
|
557
|
+
}, 100);
|
|
558
|
+
}
|
|
559
|
+
return { content_id: item.content_id, classification: result.classification };
|
|
560
|
+
}).catch(err => {
|
|
561
|
+
console.error('[ThreadForm] Pipeline error for', item.file?.file_name, err);
|
|
562
|
+
return { content_id: item.content_id, classification: null };
|
|
563
|
+
});
|
|
564
|
+
pipeline_promises.push(p);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
// ── Aggregate resolver (sum_match) ──
|
|
569
|
+
// Fire after pipeline_promises resolve so the just-uploaded files'
|
|
570
|
+
// classifications are available for the resolver. Prior-send files
|
|
571
|
+
// are read from data_ref via run_resolution.
|
|
572
|
+
if (has_files) {
|
|
573
|
+
(async () => {
|
|
574
|
+
const fresh_results = await Promise.all(pipeline_promises);
|
|
575
|
+
const fresh_cls_map = new Map();
|
|
576
|
+
for (const r of fresh_results)
|
|
577
|
+
fresh_cls_map.set(r.content_id, r.classification);
|
|
578
|
+
await run_resolution(task_id, {
|
|
579
|
+
fresh_cls_map,
|
|
580
|
+
current_send_b64_by_content_id: file_b64_map,
|
|
581
|
+
data_snapshot: post_add_snapshot,
|
|
582
|
+
});
|
|
583
|
+
})();
|
|
584
|
+
}
|
|
585
|
+
// Trigger text validation pipeline for text-only messages (async, non-blocking).
|
|
586
|
+
// Run the content gate first so conversational replies never trigger validation
|
|
587
|
+
// and never produce a client-visible "N issues found" banner. Client-authored
|
|
588
|
+
// doc_checks go out as agent_only so any AI language stays on the agent's side.
|
|
589
|
+
if (validation_api_url && has_text && !has_files && text.trim().length > 20) {
|
|
590
|
+
pipeline.gate_text_content(text.trim()).then(gate => {
|
|
591
|
+
on_log?.(`Content gate (${msg.role}): has_content=${gate.has_content}${gate.reason ? ` — ${gate.reason}` : ''}`);
|
|
592
|
+
if (!gate.has_content)
|
|
593
|
+
return; // Skip — nothing worth validating
|
|
594
|
+
pipeline.process_text(msg.message_id, text.trim()).then(result => {
|
|
595
|
+
if (result.doc_check) {
|
|
596
|
+
on_message_add?.(task_id, {
|
|
597
|
+
message_id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
|
598
|
+
role: 'system', type: 'doc_check',
|
|
599
|
+
visibility: msg.role === 'client' ? 'agent_only' : 'all',
|
|
600
|
+
doc_check: result.doc_check, time: new Date().toISOString(),
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
}).catch(err => {
|
|
604
|
+
console.error('[ThreadForm] Text pipeline error:', err);
|
|
605
|
+
});
|
|
606
|
+
}).catch(err => {
|
|
607
|
+
console.error('[ThreadForm] Content gate error:', err);
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
}, [role, current_user, on_message_add, file_manager, classification_api_url, validation_api_url, resolution_api_url, pipeline, file_to_base64, format_file_size, on_log]);
|
|
611
|
+
const handle_send_comment = useCallback((task_id, text) => {
|
|
612
|
+
handle_send(task_id, text, []);
|
|
613
|
+
}, [handle_send]);
|
|
614
|
+
const handle_toggle_complete = useCallback((task_id) => {
|
|
615
|
+
const task = data.tasks.find(t => t.task_id === task_id);
|
|
616
|
+
if (!task)
|
|
617
|
+
return;
|
|
618
|
+
const was_completed = task.status === 'completed';
|
|
619
|
+
const updated = {
|
|
620
|
+
...task,
|
|
621
|
+
status: was_completed ? 'in_progress' : 'completed',
|
|
622
|
+
completed_by: was_completed ? undefined : current_user.id,
|
|
623
|
+
completed_at: was_completed ? undefined : new Date().toISOString(),
|
|
624
|
+
};
|
|
625
|
+
// Add status change message
|
|
626
|
+
const status_msg = {
|
|
627
|
+
message_id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
|
628
|
+
role: 'system',
|
|
629
|
+
type: 'status_change',
|
|
630
|
+
visibility: 'all',
|
|
631
|
+
status_change: { from: task.status, to: updated.status },
|
|
632
|
+
user_name: current_user.name,
|
|
633
|
+
user_id: current_user.id,
|
|
634
|
+
time: new Date().toISOString(),
|
|
635
|
+
};
|
|
636
|
+
const with_msg = {
|
|
637
|
+
...updated,
|
|
638
|
+
thread: [...updated.thread, status_msg],
|
|
639
|
+
};
|
|
640
|
+
on_task_update?.(with_msg);
|
|
641
|
+
on_log?.(`${current_user.name} (${current_user.id}): Task "${task.title}" → ${updated.status}`);
|
|
642
|
+
}, [data.tasks, current_user, on_task_update, on_log]);
|
|
643
|
+
const handle_add_task = useCallback((task) => {
|
|
644
|
+
// Fire form change with new task added
|
|
645
|
+
const updated = {
|
|
646
|
+
...data,
|
|
647
|
+
updated_at: new Date().toISOString(),
|
|
648
|
+
tasks: [...data.tasks, task],
|
|
649
|
+
};
|
|
650
|
+
on_form_change?.(updated);
|
|
651
|
+
}, [data, on_form_change]);
|
|
652
|
+
const handle_view_file = useCallback((item) => {
|
|
653
|
+
set_viewed_item(item);
|
|
654
|
+
}, []);
|
|
655
|
+
const handle_close_pdf = useCallback(() => {
|
|
656
|
+
set_viewed_item(null);
|
|
657
|
+
}, []);
|
|
658
|
+
const viewed_file_url = viewed_item?.file
|
|
659
|
+
? file_manager?.callbacks?.get_download_url?.(viewed_item.file.file_id, 'public') || null
|
|
660
|
+
: null;
|
|
661
|
+
/** Upload a single file via file_manager, return a ContentItem. Used by MessageEditor. */
|
|
662
|
+
const handle_upload_file = useCallback(async (file) => {
|
|
663
|
+
const content_id = `ct_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
|
664
|
+
let file_id;
|
|
665
|
+
let file_size_str;
|
|
666
|
+
if (file_manager?.callbacks?.upload) {
|
|
667
|
+
const attachment = await file_manager.callbacks.upload(file, {
|
|
668
|
+
entity_id: 'thread_edit',
|
|
669
|
+
entity_type: 'thread_task',
|
|
670
|
+
visibility: 'public',
|
|
671
|
+
uploaded_by: current_user.id,
|
|
672
|
+
});
|
|
673
|
+
file_id = attachment.file_id;
|
|
674
|
+
file_size_str = format_file_size(attachment.file_size);
|
|
675
|
+
}
|
|
676
|
+
else {
|
|
677
|
+
file_id = `f_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
|
678
|
+
file_size_str = format_file_size(file.size);
|
|
679
|
+
}
|
|
680
|
+
const item = {
|
|
681
|
+
content_id,
|
|
682
|
+
type: 'file',
|
|
683
|
+
file: {
|
|
684
|
+
file_id,
|
|
685
|
+
file_name: file.name,
|
|
686
|
+
file_size: file_size_str,
|
|
687
|
+
mime_type: file.type || 'application/octet-stream',
|
|
688
|
+
},
|
|
689
|
+
};
|
|
690
|
+
// Trigger pipeline for this file (fire-and-forget)
|
|
691
|
+
if (classification_api_url) {
|
|
692
|
+
on_log?.(`Upload path (edit): ${file.name} → triggering pipeline`);
|
|
693
|
+
file_to_base64(file).then(b64 => {
|
|
694
|
+
pipeline.process_file(item, b64).then(result => {
|
|
695
|
+
// Find the task containing this item and inject messages
|
|
696
|
+
for (const task of data.tasks) {
|
|
697
|
+
const has_item = task.thread.some(m => m.content_items?.some(ci => ci.content_id === content_id));
|
|
698
|
+
if (!has_item)
|
|
699
|
+
continue;
|
|
700
|
+
if (result.doc_check) {
|
|
701
|
+
on_message_add?.(task.task_id, {
|
|
702
|
+
message_id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
|
703
|
+
role: 'system', type: 'doc_check', visibility: 'all',
|
|
704
|
+
doc_check: result.doc_check, time: new Date().toISOString(),
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
if (result.ai_result) {
|
|
708
|
+
on_message_add?.(task.task_id, {
|
|
709
|
+
message_id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
|
710
|
+
role: 'system', type: 'ai_analysis', visibility: 'agent_only',
|
|
711
|
+
ai_analysis: { items: [result.ai_result] }, time: new Date().toISOString(),
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
break;
|
|
715
|
+
}
|
|
716
|
+
// Persist classification + validation on the item so results survive refresh.
|
|
717
|
+
if (result.classification || result.validation_results.length > 0) {
|
|
718
|
+
setTimeout(() => {
|
|
719
|
+
on_update_content_item?.(content_id, {
|
|
720
|
+
classification_result: result.classification || undefined,
|
|
721
|
+
validation_rule_results: result.validation_results,
|
|
722
|
+
});
|
|
723
|
+
}, 100);
|
|
724
|
+
}
|
|
725
|
+
}).catch(err => console.error('[ThreadForm] Pipeline error:', err));
|
|
726
|
+
}).catch(err => console.error('[ThreadForm] Base64 read error:', err));
|
|
727
|
+
}
|
|
728
|
+
return item;
|
|
729
|
+
}, [file_manager, current_user, classification_api_url, pipeline, data.tasks, on_message_add, file_to_base64, format_file_size]);
|
|
730
|
+
/** Remove a file from its parent message's content_items */
|
|
731
|
+
const handle_remove_file = useCallback((item) => {
|
|
732
|
+
// Close PDF panel if we're removing the viewed file
|
|
733
|
+
if (viewed_item?.content_id === item.content_id) {
|
|
734
|
+
set_viewed_item(null);
|
|
735
|
+
}
|
|
736
|
+
// Remove from file_manager if available
|
|
737
|
+
if (item.file && file_manager?.callbacks?.remove) {
|
|
738
|
+
file_manager.callbacks.remove(item.file.file_id, `ref_${item.file.file_id}`).catch(err => {
|
|
739
|
+
console.error('[ThreadForm] Failed to remove file:', err);
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
// Track which task held the removed file so we can re-run the resolver
|
|
743
|
+
// for that task once the data update propagates.
|
|
744
|
+
let owning_task_id;
|
|
745
|
+
// Remove the content item from the message
|
|
746
|
+
const updated = {
|
|
747
|
+
...data,
|
|
748
|
+
updated_at: new Date().toISOString(),
|
|
749
|
+
tasks: data.tasks.map(task => ({
|
|
750
|
+
...task,
|
|
751
|
+
thread: task.thread.map(msg => {
|
|
752
|
+
if (!msg.content_items)
|
|
753
|
+
return msg;
|
|
754
|
+
const filtered = msg.content_items.filter(ci => ci.content_id !== item.content_id);
|
|
755
|
+
if (filtered.length === msg.content_items.length)
|
|
756
|
+
return msg; // not in this message
|
|
757
|
+
owning_task_id = task.task_id;
|
|
758
|
+
return {
|
|
759
|
+
...msg,
|
|
760
|
+
content_items: filtered.length > 0 ? filtered : undefined,
|
|
761
|
+
// If no files left and no text, keep as comment type
|
|
762
|
+
type: filtered.length === 0 && msg.content_text ? 'comment' : msg.type,
|
|
763
|
+
};
|
|
764
|
+
}),
|
|
765
|
+
})),
|
|
766
|
+
};
|
|
767
|
+
on_form_change?.(updated);
|
|
768
|
+
// Re-run the resolver for the owning clarification task so the panel
|
|
769
|
+
// reflects the new file set. Pass the post-deletion snapshot directly
|
|
770
|
+
// because data_ref hasn't been refreshed by the parent re-render yet.
|
|
771
|
+
if (owning_task_id) {
|
|
772
|
+
run_resolution(owning_task_id, { data_snapshot: updated });
|
|
773
|
+
}
|
|
774
|
+
}, [data, on_form_change, file_manager, viewed_item, run_resolution]);
|
|
775
|
+
const handle_accept = useCallback((content_ref) => {
|
|
776
|
+
// Update review_status in the data
|
|
777
|
+
const updated = {
|
|
778
|
+
...data,
|
|
779
|
+
updated_at: new Date().toISOString(),
|
|
780
|
+
tasks: data.tasks.map(task => ({
|
|
781
|
+
...task,
|
|
782
|
+
thread: task.thread.map(msg => {
|
|
783
|
+
if (msg.type !== 'ai_analysis' || !msg.ai_analysis)
|
|
784
|
+
return msg;
|
|
785
|
+
return {
|
|
786
|
+
...msg,
|
|
787
|
+
ai_analysis: {
|
|
788
|
+
...msg.ai_analysis,
|
|
789
|
+
items: msg.ai_analysis.items.map(item => item.content_ref === content_ref ? { ...item, review_status: 'accepted' } : item),
|
|
790
|
+
},
|
|
791
|
+
};
|
|
792
|
+
}),
|
|
793
|
+
})),
|
|
794
|
+
};
|
|
795
|
+
on_form_change?.(updated);
|
|
796
|
+
on_log?.(`${current_user.name} (${current_user.id}): Accepted AI analysis for ${content_ref.slice(-5)}`);
|
|
797
|
+
}, [data, on_form_change, current_user, on_log]);
|
|
798
|
+
const handle_dismiss = useCallback((content_ref) => {
|
|
799
|
+
const updated = {
|
|
800
|
+
...data,
|
|
801
|
+
updated_at: new Date().toISOString(),
|
|
802
|
+
tasks: data.tasks.map(task => ({
|
|
803
|
+
...task,
|
|
804
|
+
thread: task.thread.map(msg => {
|
|
805
|
+
if (msg.type !== 'ai_analysis' || !msg.ai_analysis)
|
|
806
|
+
return msg;
|
|
807
|
+
return {
|
|
808
|
+
...msg,
|
|
809
|
+
ai_analysis: {
|
|
810
|
+
...msg.ai_analysis,
|
|
811
|
+
items: msg.ai_analysis.items.map(item => item.content_ref === content_ref ? { ...item, review_status: 'dismissed' } : item),
|
|
812
|
+
},
|
|
813
|
+
};
|
|
814
|
+
}),
|
|
815
|
+
})),
|
|
816
|
+
};
|
|
817
|
+
on_form_change?.(updated);
|
|
818
|
+
on_log?.(`${current_user.name} (${current_user.id}): Dismissed AI analysis for ${content_ref.slice(-5)}`);
|
|
819
|
+
}, [data, on_form_change, current_user, on_log]);
|
|
820
|
+
const handle_send_to_client = useCallback((content_ref) => {
|
|
821
|
+
// Find the AI result to build compose context
|
|
822
|
+
for (const task of data.tasks) {
|
|
823
|
+
for (const msg of task.thread) {
|
|
824
|
+
if (msg.type !== 'ai_analysis' || !msg.ai_analysis)
|
|
825
|
+
continue;
|
|
826
|
+
for (const item of msg.ai_analysis.items) {
|
|
827
|
+
if (item.content_ref !== content_ref)
|
|
828
|
+
continue;
|
|
829
|
+
// Build context from the first failing check or field issue
|
|
830
|
+
const failing_check = item.validation.checks.find(c => c.status !== 'passed');
|
|
831
|
+
if (failing_check) {
|
|
832
|
+
const file_name = task.thread
|
|
833
|
+
.flatMap(m => m.content_items || [])
|
|
834
|
+
.find(ci => ci.content_id === content_ref)?.file?.file_name || content_ref;
|
|
835
|
+
set_compose_context(build_compose_from_check(file_name, failing_check, content_ref, data.client.name));
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
// Or from extracted data issue
|
|
839
|
+
for (const group of item.extracted_data || []) {
|
|
840
|
+
const field_with_issue = group.fields.find(f => f.issue);
|
|
841
|
+
if (field_with_issue) {
|
|
842
|
+
set_compose_context(build_compose_from_field(field_with_issue, group.category, content_ref, data.client.name));
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
}, [data]);
|
|
850
|
+
const handle_compose_send = useCallback((message, content_ref) => {
|
|
851
|
+
// Find which task contains this content_ref and add an agent comment
|
|
852
|
+
for (const task of data.tasks) {
|
|
853
|
+
const has_ref = task.thread.some(m => m.ai_analysis?.items.some(i => i.content_ref === content_ref));
|
|
854
|
+
if (!has_ref)
|
|
855
|
+
continue;
|
|
856
|
+
const comment_msg = {
|
|
857
|
+
message_id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
|
858
|
+
role: 'agent',
|
|
859
|
+
type: 'comment',
|
|
860
|
+
visibility: 'all',
|
|
861
|
+
content_text: message,
|
|
862
|
+
user_name: current_user.name,
|
|
863
|
+
user_id: current_user.id,
|
|
864
|
+
time: new Date().toISOString(),
|
|
865
|
+
};
|
|
866
|
+
on_message_add?.(task.task_id, comment_msg);
|
|
867
|
+
// Mark as sent_to_client
|
|
868
|
+
handle_accept(content_ref); // Reuse — sets review_status but we want 'sent_to_client'
|
|
869
|
+
const updated = {
|
|
870
|
+
...data,
|
|
871
|
+
tasks: data.tasks.map(t => ({
|
|
872
|
+
...t,
|
|
873
|
+
thread: t.thread.map(msg => {
|
|
874
|
+
if (msg.type !== 'ai_analysis' || !msg.ai_analysis)
|
|
875
|
+
return msg;
|
|
876
|
+
return {
|
|
877
|
+
...msg,
|
|
878
|
+
ai_analysis: {
|
|
879
|
+
...msg.ai_analysis,
|
|
880
|
+
items: msg.ai_analysis.items.map(item => item.content_ref === content_ref
|
|
881
|
+
? { ...item, review_status: 'sent_to_client', sent_message_id: comment_msg.message_id }
|
|
882
|
+
: item),
|
|
883
|
+
},
|
|
884
|
+
};
|
|
885
|
+
}),
|
|
886
|
+
})),
|
|
887
|
+
};
|
|
888
|
+
on_form_change?.(updated);
|
|
889
|
+
break;
|
|
890
|
+
}
|
|
891
|
+
set_compose_context(null);
|
|
892
|
+
}, [data, current_user, on_message_add, on_form_change]);
|
|
893
|
+
const handle_add_question = useCallback((_task_id) => {
|
|
894
|
+
set_show_add_question(true);
|
|
895
|
+
}, []);
|
|
896
|
+
/** Agent sends back selected issues as a new task (Approach F) */
|
|
897
|
+
const handle_send_back_issues = useCallback((source_item, issues, accepted_issue_keys) => {
|
|
898
|
+
// Accept-all path — issues=[] and accepted_issue_keys has all clarification IDs.
|
|
899
|
+
// Mark the source ContentItem as accepted so the Pending Review pill flips to
|
|
900
|
+
// "Accepted" and no new task is created.
|
|
901
|
+
if (issues.length === 0) {
|
|
902
|
+
if (accepted_issue_keys.length > 0) {
|
|
903
|
+
on_update_content_item?.(source_item.content_id, { accepted: true });
|
|
904
|
+
const file_name = source_item.file?.file_name || 'Document';
|
|
905
|
+
on_log?.(`${current_user.name} (${current_user.id}): Accepted client response for ${file_name}`);
|
|
906
|
+
}
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
const make_id = (prefix) => `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
|
910
|
+
const file_name = source_item.file?.file_name || 'Document';
|
|
911
|
+
// Find the parent task that originally contained this file — its title becomes
|
|
912
|
+
// the "original question" in the send-back title, and its task_id becomes the
|
|
913
|
+
// merge key for routing this Send Back into an existing open clarifications
|
|
914
|
+
// task (Y1 grouping — see docs/superpowers/specs/2026-04-30-send-back-merging-design.md).
|
|
915
|
+
const parent_task = data_ref.current.tasks.find(t => t.thread.some(m => m.content_items?.some(ci => ci.content_id === source_item.content_id)));
|
|
916
|
+
const parent_title = parent_task?.title || file_name;
|
|
917
|
+
const parent_task_id = parent_task?.task_id;
|
|
918
|
+
const send_back_data = {
|
|
919
|
+
source_content_id: source_item.content_id,
|
|
920
|
+
source_file: source_item.file,
|
|
921
|
+
issues,
|
|
922
|
+
history: [],
|
|
923
|
+
};
|
|
924
|
+
const send_back_msg = {
|
|
925
|
+
message_id: make_id('msg'),
|
|
926
|
+
role: 'agent',
|
|
927
|
+
type: 'send_back',
|
|
928
|
+
visibility: 'all',
|
|
929
|
+
send_back: send_back_data,
|
|
930
|
+
user_name: current_user.name,
|
|
931
|
+
user_id: current_user.id,
|
|
932
|
+
time: new Date().toISOString(),
|
|
933
|
+
};
|
|
934
|
+
// If we know the parent task, route through the merge helper so that
|
|
935
|
+
// subsequent Send Backs from the same parent land in one open
|
|
936
|
+
// clarifications task. Without a parent (legacy/orphan path), fall back to
|
|
937
|
+
// creating a new task as before.
|
|
938
|
+
let next_tasks;
|
|
939
|
+
if (parent_task_id) {
|
|
940
|
+
const build_new_task = (msg) => ({
|
|
941
|
+
task_id: make_id('task'),
|
|
942
|
+
title: `Clarifications regarding ${parent_title}`,
|
|
943
|
+
description: '',
|
|
944
|
+
status: 'needs_attention',
|
|
945
|
+
sort_order: data.tasks.length + 1,
|
|
946
|
+
created_at: new Date().toISOString(),
|
|
947
|
+
created_by: current_user.id,
|
|
948
|
+
thread: [msg],
|
|
949
|
+
parent_task_id,
|
|
950
|
+
});
|
|
951
|
+
next_tasks = merge_send_back_message(data.tasks, parent_task_id, send_back_msg, build_new_task);
|
|
952
|
+
}
|
|
953
|
+
else {
|
|
954
|
+
const new_task = {
|
|
955
|
+
task_id: make_id('task'),
|
|
956
|
+
title: `Clarifications regarding ${parent_title}`,
|
|
957
|
+
description: '',
|
|
958
|
+
status: 'needs_attention',
|
|
959
|
+
sort_order: data.tasks.length + 1,
|
|
960
|
+
created_at: new Date().toISOString(),
|
|
961
|
+
created_by: current_user.id,
|
|
962
|
+
thread: [send_back_msg],
|
|
963
|
+
};
|
|
964
|
+
next_tasks = [...data.tasks, new_task];
|
|
965
|
+
}
|
|
966
|
+
const updated = {
|
|
967
|
+
...data,
|
|
968
|
+
updated_at: new Date().toISOString(),
|
|
969
|
+
tasks: next_tasks,
|
|
970
|
+
};
|
|
971
|
+
on_form_change?.(updated);
|
|
972
|
+
// Mark the source ContentItem as sent_back for pill display
|
|
973
|
+
setTimeout(() => {
|
|
974
|
+
on_update_content_item?.(source_item.content_id, { sent_back: true });
|
|
975
|
+
}, 50);
|
|
976
|
+
const total_child_count = issues.reduce((sum, iss) => sum + (iss.child_issues?.length ?? 1), 0);
|
|
977
|
+
const has_groups = issues.some(iss => iss.child_issues && iss.child_issues.length > 0);
|
|
978
|
+
const log_count_str = has_groups
|
|
979
|
+
? `${issues.length} group(s) covering ${total_child_count} issue(s)`
|
|
980
|
+
: `${issues.length} issue(s)`;
|
|
981
|
+
on_log?.(`${current_user.name} (${current_user.id}): Sent back ${log_count_str} for ${file_name}`);
|
|
982
|
+
}, [data, current_user, on_form_change, on_update_content_item, on_log]);
|
|
983
|
+
/** Agent submits a batch of per-rule decisions from the Pending Review panel.
|
|
984
|
+
* - 'accepted' rules → stamp rule_actions[rule_id] = 'accepted'
|
|
985
|
+
* - 'sent_back' rules → build one send_back message per rule and route
|
|
986
|
+
* through merge_send_back_message so they all land in one open
|
|
987
|
+
* clarifications task for the parent question (Y1 grouping).
|
|
988
|
+
* Both side effects (form_change + content_item update) run in the same
|
|
989
|
+
* synchronous turn so React 18 auto-batches them into a single render. */
|
|
990
|
+
const handle_submit_decisions = useCallback((source_item, decisions) => {
|
|
991
|
+
if (decisions.length === 0)
|
|
992
|
+
return;
|
|
993
|
+
const make_id = (prefix) => `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
|
994
|
+
const file_name = source_item.file?.file_name || 'Document';
|
|
995
|
+
const parent_task = data_ref.current.tasks.find(t => t.thread.some(m => m.content_items?.some(ci => ci.content_id === source_item.content_id)));
|
|
996
|
+
const parent_title = parent_task?.title || file_name;
|
|
997
|
+
const parent_task_id = parent_task?.task_id;
|
|
998
|
+
let next_tasks = data.tasks;
|
|
999
|
+
let send_back_index = 0;
|
|
1000
|
+
for (const d of decisions) {
|
|
1001
|
+
if (d.action !== 'sent_back')
|
|
1002
|
+
continue;
|
|
1003
|
+
send_back_index++;
|
|
1004
|
+
const child_issues = d.rule_result.issues.map(issue => ({
|
|
1005
|
+
issue_id: issue.issue_id,
|
|
1006
|
+
issue_description: issue.issue_description,
|
|
1007
|
+
date: issue.date,
|
|
1008
|
+
kind: issue.kind,
|
|
1009
|
+
amount: issue.amount,
|
|
1010
|
+
}));
|
|
1011
|
+
const send_back_payload = {
|
|
1012
|
+
rule_id: d.rule_result.rule_id,
|
|
1013
|
+
rule_name: d.rule_result.rule_name || d.rule_result.rule_id,
|
|
1014
|
+
issue_description: d.rule_result.summary || d.rule_result.rule_name || d.rule_result.rule_id,
|
|
1015
|
+
agent_feedback: d.comment ?? '',
|
|
1016
|
+
child_issues,
|
|
1017
|
+
};
|
|
1018
|
+
const send_back_data = {
|
|
1019
|
+
source_content_id: source_item.content_id,
|
|
1020
|
+
source_file: source_item.file,
|
|
1021
|
+
issues: [send_back_payload],
|
|
1022
|
+
history: [],
|
|
1023
|
+
};
|
|
1024
|
+
const send_back_msg = {
|
|
1025
|
+
message_id: make_id('msg'),
|
|
1026
|
+
role: 'agent',
|
|
1027
|
+
type: 'send_back',
|
|
1028
|
+
visibility: 'all',
|
|
1029
|
+
send_back: send_back_data,
|
|
1030
|
+
user_name: current_user.name,
|
|
1031
|
+
user_id: current_user.id,
|
|
1032
|
+
time: new Date().toISOString(),
|
|
1033
|
+
};
|
|
1034
|
+
if (parent_task_id) {
|
|
1035
|
+
const build_new_task = (msg) => ({
|
|
1036
|
+
task_id: make_id('task'),
|
|
1037
|
+
title: `Clarifications regarding ${parent_title}`,
|
|
1038
|
+
description: '',
|
|
1039
|
+
status: 'needs_attention',
|
|
1040
|
+
sort_order: next_tasks.length + 1,
|
|
1041
|
+
created_at: new Date().toISOString(),
|
|
1042
|
+
created_by: current_user.id,
|
|
1043
|
+
thread: [msg],
|
|
1044
|
+
parent_task_id,
|
|
1045
|
+
});
|
|
1046
|
+
next_tasks = merge_send_back_message(next_tasks, parent_task_id, send_back_msg, build_new_task);
|
|
1047
|
+
}
|
|
1048
|
+
else {
|
|
1049
|
+
const new_task = {
|
|
1050
|
+
task_id: make_id('task'),
|
|
1051
|
+
title: `Clarifications regarding ${parent_title}`,
|
|
1052
|
+
description: '',
|
|
1053
|
+
status: 'needs_attention',
|
|
1054
|
+
sort_order: next_tasks.length + 1,
|
|
1055
|
+
created_at: new Date().toISOString(),
|
|
1056
|
+
created_by: current_user.id,
|
|
1057
|
+
thread: [send_back_msg],
|
|
1058
|
+
};
|
|
1059
|
+
next_tasks = [...next_tasks, new_task];
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
// Stamp rule_actions and recompute derived flags atomically with the tasks change.
|
|
1063
|
+
const merged_rule_actions = {
|
|
1064
|
+
...(source_item.rule_actions ?? {}),
|
|
1065
|
+
};
|
|
1066
|
+
for (const d of decisions) {
|
|
1067
|
+
merged_rule_actions[d.rule_id] = d.action;
|
|
1068
|
+
}
|
|
1069
|
+
const all_rules_with_issues = (source_item.validation_rule_results ?? [])
|
|
1070
|
+
.filter(r => r.issues.length > 0);
|
|
1071
|
+
const decision_state = compute_file_decision_state(merged_rule_actions, all_rules_with_issues);
|
|
1072
|
+
// Atomic update: tasks + per-file fields together.
|
|
1073
|
+
if (next_tasks !== data.tasks) {
|
|
1074
|
+
const updated = {
|
|
1075
|
+
...data,
|
|
1076
|
+
updated_at: new Date().toISOString(),
|
|
1077
|
+
tasks: next_tasks,
|
|
1078
|
+
};
|
|
1079
|
+
on_form_change?.(updated);
|
|
1080
|
+
}
|
|
1081
|
+
on_update_content_item?.(source_item.content_id, {
|
|
1082
|
+
rule_actions: merged_rule_actions,
|
|
1083
|
+
accepted: decision_state.accepted,
|
|
1084
|
+
sent_back: decision_state.sent_back,
|
|
1085
|
+
});
|
|
1086
|
+
on_log?.(`${current_user.name} (${current_user.id}): Submitted ${decisions.length} decision(s) for ${file_name} (${send_back_index} send-back, ${decisions.length - send_back_index} accept)`);
|
|
1087
|
+
}, [data, current_user, on_form_change, on_update_content_item, on_log]);
|
|
1088
|
+
const handle_edit_message = useCallback((task_id, message_id, updated) => {
|
|
1089
|
+
on_message_edit?.(task_id, message_id, updated);
|
|
1090
|
+
}, [on_message_edit]);
|
|
1091
|
+
const handle_delete_message = useCallback((task_id, message_id) => {
|
|
1092
|
+
on_message_delete?.(task_id, message_id);
|
|
1093
|
+
}, [on_message_delete]);
|
|
1094
|
+
/** Delete a task from the form */
|
|
1095
|
+
const handle_delete_task = useCallback((task_id) => {
|
|
1096
|
+
const updated = {
|
|
1097
|
+
...data,
|
|
1098
|
+
updated_at: new Date().toISOString(),
|
|
1099
|
+
tasks: data.tasks.filter(t => t.task_id !== task_id),
|
|
1100
|
+
};
|
|
1101
|
+
on_form_change?.(updated);
|
|
1102
|
+
const task_title = data.tasks.find(t => t.task_id === task_id)?.title || task_id;
|
|
1103
|
+
on_log?.(`${current_user.name} (${current_user.id}): Deleted task "${task_title}"`);
|
|
1104
|
+
}, [data, on_form_change, current_user, on_log]);
|
|
1105
|
+
/** Update a ContentItem's fields — delegates to prop (functional update, safe from stale closures) */
|
|
1106
|
+
const handle_update_content_item = useCallback((content_id, updates) => {
|
|
1107
|
+
on_update_content_item?.(content_id, updates);
|
|
1108
|
+
}, [on_update_content_item]);
|
|
1109
|
+
/** Agent edits a file's document type via the FileBar pencil. Updates the
|
|
1110
|
+
* classification and re-runs backoffice validation against the rules of the
|
|
1111
|
+
* newly selected document_type. Stale validation results from the prior type
|
|
1112
|
+
* are cleared so the UI reflects the new rule set, not a mix. */
|
|
1113
|
+
const handle_change_document_type = useCallback(async (item, new_type_id) => {
|
|
1114
|
+
const old_classification = item.classification_result || pipeline.classification_results.get(item.content_id);
|
|
1115
|
+
const updated_classification = {
|
|
1116
|
+
// Sensible defaults if there was no classification yet (file uploaded as unknown).
|
|
1117
|
+
tags: [],
|
|
1118
|
+
confidence: 1,
|
|
1119
|
+
...(old_classification ?? {}),
|
|
1120
|
+
document_type: new_type_id,
|
|
1121
|
+
};
|
|
1122
|
+
// 1. Persist the new doc type, clear stale validation results, and mark as
|
|
1123
|
+
// needing revalidation (clearing backoffice_validated_at puts it back in
|
|
1124
|
+
// the agent's "Run Backoffice (N)" pending pool while we re-run below).
|
|
1125
|
+
on_update_content_item?.(item.content_id, {
|
|
1126
|
+
classification_result: updated_classification,
|
|
1127
|
+
validation_rule_results: [],
|
|
1128
|
+
backoffice_validated_at: undefined,
|
|
1129
|
+
accepted: false,
|
|
1130
|
+
sent_back: false,
|
|
1131
|
+
});
|
|
1132
|
+
on_log?.(`${current_user.name} (${current_user.id}): Changed document type to "${new_type_id}" for ${item.file?.file_name ?? item.content_id}`);
|
|
1133
|
+
// 2. Re-run backoffice validation. Pass a synthetic item that carries the
|
|
1134
|
+
// new classification so run_backoffice_validation reads the right
|
|
1135
|
+
// document_type when looking up rules.
|
|
1136
|
+
const synthetic = { ...item, classification_result: updated_classification };
|
|
1137
|
+
try {
|
|
1138
|
+
const results_map = await pipeline.run_backoffice_validation([synthetic]);
|
|
1139
|
+
const r = results_map.get(item.content_id);
|
|
1140
|
+
if (r) {
|
|
1141
|
+
on_update_content_item?.(item.content_id, {
|
|
1142
|
+
validation_rule_results: r.validation_results,
|
|
1143
|
+
backoffice_validated_at: new Date().toISOString(),
|
|
1144
|
+
});
|
|
1145
|
+
on_log?.(`Re-ran ${r.validation_results.length} rule(s) for ${item.file?.file_name ?? item.content_id} under "${new_type_id}"`);
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
catch (err) {
|
|
1149
|
+
console.error('[ThreadForm] Re-validation after doc-type change failed:', err);
|
|
1150
|
+
on_log?.(`Re-validation failed for ${item.file?.file_name ?? item.content_id} — see console`);
|
|
1151
|
+
}
|
|
1152
|
+
}, [pipeline, on_update_content_item, on_log, current_user]);
|
|
1153
|
+
/** Handle replacement files from clarification response — append to the original message and run pipeline */
|
|
1154
|
+
const handle_replacement_files = useCallback(async (source_content_id, files) => {
|
|
1155
|
+
if (files.length === 0)
|
|
1156
|
+
return;
|
|
1157
|
+
// Use ref for latest data to avoid stale closure issues with chained replacements
|
|
1158
|
+
const current_data = data_ref.current;
|
|
1159
|
+
// Find the task and message containing the source content item
|
|
1160
|
+
let found_task;
|
|
1161
|
+
let found_msg;
|
|
1162
|
+
for (const t of current_data.tasks) {
|
|
1163
|
+
const m = t.thread.find(m => m.content_items?.some(ci => ci.content_id === source_content_id));
|
|
1164
|
+
if (m) {
|
|
1165
|
+
found_task = t;
|
|
1166
|
+
found_msg = m;
|
|
1167
|
+
break;
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
if (!found_task || !found_msg) {
|
|
1171
|
+
console.error('[ThreadForm] Could not find task/message for source content:', source_content_id);
|
|
1172
|
+
return;
|
|
1173
|
+
}
|
|
1174
|
+
// Convert FormFileAttachments to ContentItems, linking to the replaced file
|
|
1175
|
+
const new_items = files.map(f => ({
|
|
1176
|
+
content_id: `ct_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
|
1177
|
+
type: 'file',
|
|
1178
|
+
file: {
|
|
1179
|
+
file_id: f.file_id,
|
|
1180
|
+
file_name: f.file_name,
|
|
1181
|
+
file_size: format_file_size(f.file_size),
|
|
1182
|
+
mime_type: f.mime_type || 'application/octet-stream',
|
|
1183
|
+
},
|
|
1184
|
+
replaces_content_id: source_content_id,
|
|
1185
|
+
}));
|
|
1186
|
+
// Mark the original file as replaced, and append replacement files to the message
|
|
1187
|
+
const first_replacement_id = new_items[0].content_id;
|
|
1188
|
+
const updated_items = (found_msg.content_items || []).map(ci => ci.content_id === source_content_id
|
|
1189
|
+
? { ...ci, replaced_by_content_id: first_replacement_id }
|
|
1190
|
+
: ci);
|
|
1191
|
+
updated_items.push(...new_items);
|
|
1192
|
+
on_message_edit?.(found_task.task_id, found_msg.message_id, {
|
|
1193
|
+
content_text: found_msg.content_text,
|
|
1194
|
+
content_items: updated_items,
|
|
1195
|
+
});
|
|
1196
|
+
on_log?.(`${current_user.name}: Uploaded ${files.length} replacement file${files.length > 1 ? 's' : ''}`);
|
|
1197
|
+
// Trigger classification + validation pipeline for each replacement file.
|
|
1198
|
+
// Collect each file's classification promise so we can run the aggregate
|
|
1199
|
+
// resolver once they all complete — replacement files participate in the
|
|
1200
|
+
// same sum_match check as fresh sends.
|
|
1201
|
+
const replacement_pipeline_promises = [];
|
|
1202
|
+
const replacement_b64_by_content_id = new Map();
|
|
1203
|
+
if (classification_api_url && file_manager?.callbacks?.get_download_url) {
|
|
1204
|
+
for (const item of new_items) {
|
|
1205
|
+
if (!item.file)
|
|
1206
|
+
continue;
|
|
1207
|
+
const url = file_manager.callbacks.get_download_url(item.file.file_id, 'public');
|
|
1208
|
+
if (!url)
|
|
1209
|
+
continue;
|
|
1210
|
+
try {
|
|
1211
|
+
const response = await fetch(url);
|
|
1212
|
+
const blob = await response.blob();
|
|
1213
|
+
const b64 = await file_to_base64(new File([blob], item.file.file_name, { type: item.file.mime_type }));
|
|
1214
|
+
replacement_b64_by_content_id.set(item.content_id, b64);
|
|
1215
|
+
const p = pipeline.process_file(item, b64).then(result => {
|
|
1216
|
+
if (result.doc_check) {
|
|
1217
|
+
on_message_add?.(found_task.task_id, {
|
|
1218
|
+
message_id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
|
1219
|
+
role: 'system', type: 'doc_check', visibility: 'all',
|
|
1220
|
+
doc_check: result.doc_check, time: new Date().toISOString(),
|
|
1221
|
+
});
|
|
1222
|
+
}
|
|
1223
|
+
if (result.ai_result) {
|
|
1224
|
+
on_message_add?.(found_task.task_id, {
|
|
1225
|
+
message_id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
|
1226
|
+
role: 'system', type: 'ai_analysis', visibility: 'agent_only',
|
|
1227
|
+
ai_analysis: { items: [result.ai_result] }, time: new Date().toISOString(),
|
|
1228
|
+
});
|
|
1229
|
+
}
|
|
1230
|
+
if (result.classification || result.validation_results.length > 0) {
|
|
1231
|
+
setTimeout(() => {
|
|
1232
|
+
handle_update_content_item(item.content_id, {
|
|
1233
|
+
classification_result: result.classification || undefined,
|
|
1234
|
+
validation_rule_results: result.validation_results,
|
|
1235
|
+
});
|
|
1236
|
+
}, 100);
|
|
1237
|
+
}
|
|
1238
|
+
return { content_id: item.content_id, classification: result.classification };
|
|
1239
|
+
}).catch(err => {
|
|
1240
|
+
console.error('[ThreadForm] Pipeline error for replacement:', item.file?.file_name, err);
|
|
1241
|
+
return { content_id: item.content_id, classification: null };
|
|
1242
|
+
});
|
|
1243
|
+
replacement_pipeline_promises.push(p);
|
|
1244
|
+
}
|
|
1245
|
+
catch (err) {
|
|
1246
|
+
console.error('[ThreadForm] Failed to fetch replacement file for pipeline:', item.file.file_name, err);
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
// Once replacement classifications complete, re-run the aggregate
|
|
1251
|
+
// resolver so the panel reflects the new file set.
|
|
1252
|
+
if (replacement_pipeline_promises.length > 0) {
|
|
1253
|
+
(async () => {
|
|
1254
|
+
const fresh_results = await Promise.all(replacement_pipeline_promises);
|
|
1255
|
+
const fresh_cls_map = new Map();
|
|
1256
|
+
for (const r of fresh_results)
|
|
1257
|
+
fresh_cls_map.set(r.content_id, r.classification);
|
|
1258
|
+
await run_resolution(found_task.task_id, {
|
|
1259
|
+
fresh_cls_map,
|
|
1260
|
+
current_send_b64_by_content_id: replacement_b64_by_content_id,
|
|
1261
|
+
});
|
|
1262
|
+
})();
|
|
1263
|
+
}
|
|
1264
|
+
}, [current_user, on_message_edit, on_message_add, file_manager, classification_api_url, pipeline, file_to_base64, format_file_size, handle_update_content_item, on_log, run_resolution]);
|
|
1265
|
+
/** Agent approves a client-completed task */
|
|
1266
|
+
const handle_approve_task = useCallback((task_id) => {
|
|
1267
|
+
const updated = {
|
|
1268
|
+
...data,
|
|
1269
|
+
updated_at: new Date().toISOString(),
|
|
1270
|
+
tasks: data.tasks.map(t => t.task_id === task_id ? { ...t, agent_review: 'approved' } : t),
|
|
1271
|
+
};
|
|
1272
|
+
on_form_change?.(updated);
|
|
1273
|
+
const task_title = data.tasks.find(t => t.task_id === task_id)?.title || task_id;
|
|
1274
|
+
on_log?.(`${current_user.name} (${current_user.id}): Approved task "${task_title}"`);
|
|
1275
|
+
}, [data, on_form_change, current_user, on_log]);
|
|
1276
|
+
/** Agent renames a task title */
|
|
1277
|
+
const handle_rename_task = useCallback((task_id, title) => {
|
|
1278
|
+
const task = data.tasks.find(t => t.task_id === task_id);
|
|
1279
|
+
if (!task || task.title === title)
|
|
1280
|
+
return;
|
|
1281
|
+
const updated = { ...task, title };
|
|
1282
|
+
on_task_update?.(updated);
|
|
1283
|
+
on_log?.(`${current_user.name} (${current_user.id}): Renamed task → "${title}"`);
|
|
1284
|
+
}, [data.tasks, on_task_update, current_user, on_log]);
|
|
1285
|
+
/** Agent reopens a client-completed task */
|
|
1286
|
+
const handle_reopen_task = useCallback((task_id) => {
|
|
1287
|
+
const updated = {
|
|
1288
|
+
...data,
|
|
1289
|
+
updated_at: new Date().toISOString(),
|
|
1290
|
+
tasks: data.tasks.map(t => t.task_id === task_id
|
|
1291
|
+
? { ...t, status: 'in_progress', agent_review: 'reopened', reopened_by: current_user.id, completed_at: undefined, completed_by: undefined }
|
|
1292
|
+
: t),
|
|
1293
|
+
};
|
|
1294
|
+
on_form_change?.(updated);
|
|
1295
|
+
const task_title = data.tasks.find(t => t.task_id === task_id)?.title || task_id;
|
|
1296
|
+
on_log?.(`${current_user.name} (${current_user.id}): Reopened task "${task_title}"`);
|
|
1297
|
+
}, [data, current_user, on_form_change, on_log]);
|
|
1298
|
+
const handle_selection_change = useCallback((selections) => {
|
|
1299
|
+
// Build a map of all file ContentItems so we can walk replacement chains.
|
|
1300
|
+
// Toggling Keep on either end of a chain (replaced or replacement) keeps
|
|
1301
|
+
// both ends in sync — they represent the same logical document.
|
|
1302
|
+
const items_by_id = new Map();
|
|
1303
|
+
for (const task of data.tasks) {
|
|
1304
|
+
for (const msg of task.thread) {
|
|
1305
|
+
for (const ci of msg.content_items ?? []) {
|
|
1306
|
+
if (ci.type === 'file')
|
|
1307
|
+
items_by_id.set(ci.content_id, ci);
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
const collect_chain = (start_id) => {
|
|
1312
|
+
const seen = new Set();
|
|
1313
|
+
const queue = [start_id];
|
|
1314
|
+
while (queue.length > 0) {
|
|
1315
|
+
const id = queue.shift();
|
|
1316
|
+
if (seen.has(id))
|
|
1317
|
+
continue;
|
|
1318
|
+
seen.add(id);
|
|
1319
|
+
const it = items_by_id.get(id);
|
|
1320
|
+
if (!it)
|
|
1321
|
+
continue;
|
|
1322
|
+
if (it.replaces_content_id && !seen.has(it.replaces_content_id))
|
|
1323
|
+
queue.push(it.replaces_content_id);
|
|
1324
|
+
if (it.replaced_by_content_id && !seen.has(it.replaced_by_content_id))
|
|
1325
|
+
queue.push(it.replaced_by_content_id);
|
|
1326
|
+
}
|
|
1327
|
+
return Array.from(seen);
|
|
1328
|
+
};
|
|
1329
|
+
const expanded = {};
|
|
1330
|
+
for (const [id, value] of Object.entries(selections)) {
|
|
1331
|
+
for (const chain_id of collect_chain(id)) {
|
|
1332
|
+
expanded[chain_id] = value;
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
const updated = {
|
|
1336
|
+
...data,
|
|
1337
|
+
collected_data_selections: { ...data.collected_data_selections, ...expanded },
|
|
1338
|
+
};
|
|
1339
|
+
on_form_change?.(updated);
|
|
1340
|
+
}, [data, on_form_change]);
|
|
1341
|
+
// Build effective Keep state for every file across all tasks. Default selection
|
|
1342
|
+
// mirrors CollectedDataView: replaced originals default to unchecked; everything
|
|
1343
|
+
// else defaults to checked. Explicit `data.collected_data_selections` overrides
|
|
1344
|
+
// the default. Used for the file_bar checkbox and the Collected Data tab count.
|
|
1345
|
+
const keep_selections_effective = useMemo(() => {
|
|
1346
|
+
const explicit = data.collected_data_selections ?? {};
|
|
1347
|
+
const out = {};
|
|
1348
|
+
for (const task of data.tasks) {
|
|
1349
|
+
for (const msg of task.thread) {
|
|
1350
|
+
for (const ci of msg.content_items ?? []) {
|
|
1351
|
+
if (ci.type !== 'file')
|
|
1352
|
+
continue;
|
|
1353
|
+
const default_selected = !ci.replaced_by_content_id;
|
|
1354
|
+
out[ci.content_id] = explicit[ci.content_id] ?? default_selected;
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
return out;
|
|
1359
|
+
}, [data]);
|
|
1360
|
+
const kept_file_count = useMemo(() => Object.values(keep_selections_effective).filter(Boolean).length, [keep_selections_effective]);
|
|
1361
|
+
// ── Render ──
|
|
1362
|
+
const tasks_to_show = active_tab === 'review' ? review_tasks : data.tasks;
|
|
1363
|
+
// Client view gets a subtle orange wash so it's instantly distinguishable
|
|
1364
|
+
// from the agent (back-office) view at a glance — same content, different
|
|
1365
|
+
// role context.
|
|
1366
|
+
const is_client = role === 'client';
|
|
1367
|
+
return (_jsxs("div", { className: cn('flex flex-col h-full', is_client ? 'bg-[#FFF7ED]' : 'bg-[#F8FAFC]'), children: [is_client && (_jsx("div", { className: "h-0.5 bg-[#F97316] flex-shrink-0" })), _jsxs("div", { className: cn('px-5 py-3 border-b', is_client ? 'border-[#FED7AA] bg-[#FFFBEB]' : 'border-[#E2E8F0] bg-white'), children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center gap-2", children: [is_client && (_jsx("span", { className: "inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold tracking-wide uppercase text-[#9A3412] bg-[#FED7AA]", title: "You are viewing this form as the client", children: "Client view" })), _jsxs("div", { children: [_jsx("h2", { className: "font-semibold text-[#0F172A]", children: data.title }), _jsxs("div", { className: "flex items-center gap-3 text-xs text-[#94A3B8] mt-0.5", children: [_jsx("span", { children: data.client.name }), _jsx("span", { children: "\u00B7" }), _jsx("span", { children: data.agents.map(a => a.name).join(', ') })] })] })] }), _jsxs("div", { className: "flex items-center gap-2", children: [role === 'agent' && (() => {
|
|
1368
|
+
const has_pending = pending_backoffice_items.length > 0 && !backoffice_running;
|
|
1369
|
+
return (_jsxs("button", { type: "button", onClick: handle_run_backoffice, disabled: backoffice_running || pending_backoffice_items.length === 0, className: cn('text-xs font-medium px-3 py-1.5 rounded cursor-pointer flex items-center gap-1.5 disabled:cursor-not-allowed', has_pending
|
|
1370
|
+
? 'text-[#92400E] border border-[#F59E0B] bg-[#FEF3C7] hover:bg-[#FDE68A]'
|
|
1371
|
+
: 'text-[#334155] border border-[#CBD5E1] bg-white hover:bg-[#F8FAFC] disabled:text-[#CBD5E1] disabled:bg-[#F8FAFC]'), title: pending_backoffice_items.length === 0
|
|
1372
|
+
? 'All files have had backoffice validation run'
|
|
1373
|
+
: `Run backoffice validation on ${pending_backoffice_items.length} file(s)`, children: [has_pending && (_jsxs("span", { className: "relative flex h-2 w-2 flex-shrink-0", children: [_jsx("span", { className: "absolute inline-flex h-full w-full rounded-full bg-[#F59E0B] opacity-75 animate-ping" }), _jsx("span", { className: "relative inline-flex h-2 w-2 rounded-full bg-[#F59E0B]" })] })), backoffice_running && (_jsxs("svg", { className: "w-3 h-3 animate-spin", xmlns: "http://www.w3.org/2000/svg", fill: "none", viewBox: "0 0 24 24", children: [_jsx("circle", { className: "opacity-25", cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "4" }), _jsx("path", { className: "opacity-75", fill: "currentColor", d: "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" })] })), backoffice_running ? 'Running…' : `Run Backoffice${pending_backoffice_items.length > 0 ? ` (${pending_backoffice_items.length})` : ''}`] }));
|
|
1374
|
+
})(), role === 'agent' && show_json_viewer && (_jsx("button", { onClick: () => set_show_json(!show_json), className: "text-xs text-[#94A3B8] hover:text-[#64748B] cursor-pointer", children: show_json ? 'Hide JSON' : 'JSON' })), role === 'agent' && data.status === 'draft' && (_jsx("button", { onClick: on_send_form, className: "text-xs font-medium text-white bg-[#2563EB] px-3 py-1.5 rounded hover:bg-[#1D4ED8] cursor-pointer", children: "Send to Client" })), role === 'agent' && data.status !== 'completed' && data.status !== 'draft' && (_jsx("button", { onClick: on_close_form, className: "text-xs text-[#64748B] border border-[#E2E8F0] rounded px-3 py-1 hover:bg-[#F1F5F9] cursor-pointer", children: "Close Form" }))] })] }), role === 'agent' && (_jsx("div", { className: "flex gap-4 mt-3 border-b border-[#E2E8F0] -mx-5 px-5", children: ['tasks', 'review', 'collected'].map(tab => (_jsx("button", { onClick: () => set_active_tab(tab), className: cn('text-[13px] pb-2 transition-colors cursor-pointer', active_tab === tab
|
|
1375
|
+
? 'text-[#0F172A] font-medium border-b-2 border-[#2563EB]'
|
|
1376
|
+
: 'text-[#94A3B8] hover:text-[#64748B]'), children: tab === 'tasks' ? 'Tasks' : tab === 'review' ? `Review${pending_count > 0 ? ` (${pending_count})` : ''}` : `Collected Data${kept_file_count > 0 ? ` (${kept_file_count})` : ''}` }, tab))) }))] }), _jsxs("div", { className: "flex-1 flex overflow-hidden", children: [_jsxs("div", { className: "flex-1 overflow-y-auto px-5 py-2", children: [(active_tab === 'tasks' || active_tab === 'review') && (_jsxs("div", { children: [tasks_to_show.length === 0 && active_tab === 'review' && (_jsx("p", { className: "text-[13px] text-[#94A3B8] text-center py-8", children: "Nothing to review" })), tasks_to_show.map(task => (_jsx(TaskCard, { task: task, role: role, question_number: question_numbers.get(task.task_id), parent_ref: get_parent_ref(task), on_navigate_to_task: handle_navigate_to_task, user_name: current_user.name, user_id: current_user.id, expanded: active_tab === 'review' || expanded_tasks.has(task.task_id), on_toggle: () => set_expanded_tasks(prev => {
|
|
1377
|
+
const next = new Set(prev);
|
|
1378
|
+
if (next.has(task.task_id))
|
|
1379
|
+
next.delete(task.task_id);
|
|
1380
|
+
else
|
|
1381
|
+
next.add(task.task_id);
|
|
1382
|
+
return next;
|
|
1383
|
+
}), on_toggle_complete: handle_toggle_complete, on_delete_task: handle_delete_task, on_approve_task: handle_approve_task, on_reopen_task: handle_reopen_task, on_rename_task: handle_rename_task, on_send: handle_send, on_send_comment: handle_send_comment, on_add_question: handle_add_question, on_view_file: handle_view_file, on_remove_file: handle_remove_file, on_accept: handle_accept, on_edit_message: handle_edit_message, on_delete_message: handle_delete_message, upload_file: handle_upload_file, on_dismiss: handle_dismiss, on_send_to_client: handle_send_to_client, on_send_back_issues: handle_send_back_issues, on_submit_decisions: handle_submit_decisions, on_update_content_item: handle_update_content_item, file_statuses: pipeline.file_statuses, classification_results: pipeline.classification_results, validation_results: pipeline.validation_results, file_manager: file_manager?.callbacks, on_replacement_files: handle_replacement_files, view_button_variant: view_button_variant, show_info_icon: show_info_icon, sent_back_resolved_ids: sent_back_resolved_ids, resolving: resolution_running.has(task.task_id), available_document_types: available_document_types, on_change_document_type: handle_change_document_type, keep_selections: keep_selections_effective, on_keep_change: handle_selection_change }, task.task_id))), active_tab === 'review' && review_tasks.length > 0 && (_jsxs("div", { className: "flex items-center gap-3 pt-4", children: [_jsx("button", { className: "text-xs text-[#2563EB] hover:underline cursor-pointer", children: "Accept all passed" }), _jsx("button", { className: "text-xs text-[#2563EB] hover:underline cursor-pointer", children: "Send all issues" })] }))] })), active_tab === 'collected' && (_jsx(CollectedDataView, { data: data, classification_results: pipeline.classification_results, on_selection_change: handle_selection_change, on_export: on_export_data, on_view_file: handle_view_file }))] }), viewed_item && (_jsx(ThreadPdfSidePanel, { item: viewed_item, file_url: viewed_file_url, PdfViewer: pdf_viewer || null, on_close: handle_close_pdf }))] }), show_json && (_jsxs("div", { className: "fixed right-0 top-0 bottom-0 w-[500px] bg-white border-l shadow-lg z-50 flex flex-col", children: [_jsxs("div", { className: "flex items-center justify-between px-4 py-3 border-b", children: [_jsx("span", { className: "text-sm font-medium", children: "JSON" }), _jsx("button", { onClick: () => set_show_json(false), className: "text-gray-400 hover:text-gray-600 cursor-pointer", children: "\u2715" })] }), _jsx("pre", { className: "flex-1 overflow-auto p-4 text-xs font-mono bg-gray-50", children: JSON.stringify(data, null, 2) })] })), _jsx(AddQuestionDialog, { open: show_add_question, on_close: () => set_show_add_question(false), on_add: handle_add_task, agent_id: current_user.id }), _jsx(AgentComposeDialog, { open: !!compose_context, context: compose_context || undefined, on_close: () => set_compose_context(null), on_send: handle_compose_send, agent_name: current_user.name })] }));
|
|
1384
|
+
}
|
|
1385
|
+
//# sourceMappingURL=thread_form.js.map
|