first commit
This commit is contained in:
+520
@@ -0,0 +1,520 @@
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 4
|
||||
indent_style = space
|
||||
insert_final_newline = false
|
||||
max_line_length = 120
|
||||
tab_width = 4
|
||||
ij_continuation_indent_size = 8
|
||||
ij_formatter_off_tag = @formatter:off
|
||||
ij_formatter_on_tag = @formatter:on
|
||||
ij_formatter_tags_enabled = false
|
||||
ij_smart_tabs = false
|
||||
ij_wrap_on_typing = false
|
||||
|
||||
[*.java]
|
||||
max_line_length = 100
|
||||
ij_continuation_indent_size = 4
|
||||
ij_java_align_consecutive_assignments = false
|
||||
ij_java_align_consecutive_variable_declarations = false
|
||||
ij_java_align_group_field_declarations = false
|
||||
ij_java_align_multiline_annotation_parameters = false
|
||||
ij_java_align_multiline_array_initializer_expression = false
|
||||
ij_java_align_multiline_assignment = false
|
||||
ij_java_align_multiline_binary_operation = false
|
||||
ij_java_align_multiline_chained_methods = false
|
||||
ij_java_align_multiline_extends_list = false
|
||||
ij_java_align_multiline_for = true
|
||||
ij_java_align_multiline_method_parentheses = false
|
||||
ij_java_align_multiline_parameters = false
|
||||
ij_java_align_multiline_parameters_in_calls = false
|
||||
ij_java_align_multiline_parenthesized_expression = false
|
||||
ij_java_align_multiline_records = true
|
||||
ij_java_align_multiline_resources = true
|
||||
ij_java_align_multiline_ternary_operation = false
|
||||
ij_java_align_multiline_text_blocks = false
|
||||
ij_java_align_multiline_throws_list = false
|
||||
ij_java_align_subsequent_simple_methods = false
|
||||
ij_java_align_throws_keyword = false
|
||||
ij_java_annotation_parameter_wrap = off
|
||||
ij_java_array_initializer_new_line_after_left_brace = false
|
||||
ij_java_array_initializer_right_brace_on_new_line = false
|
||||
ij_java_array_initializer_wrap = normal
|
||||
ij_java_assert_statement_colon_on_next_line = false
|
||||
ij_java_assert_statement_wrap = normal
|
||||
ij_java_assignment_wrap = normal
|
||||
ij_java_binary_operation_sign_on_next_line = true
|
||||
ij_java_binary_operation_wrap = normal
|
||||
ij_java_blank_lines_after_anonymous_class_header = 0
|
||||
ij_java_blank_lines_after_class_header = 0
|
||||
ij_java_blank_lines_after_imports = 1
|
||||
ij_java_blank_lines_after_package = 1
|
||||
ij_java_blank_lines_around_class = 1
|
||||
ij_java_blank_lines_around_field = 0
|
||||
ij_java_blank_lines_around_field_in_interface = 0
|
||||
ij_java_blank_lines_around_initializer = 1
|
||||
ij_java_blank_lines_around_method = 1
|
||||
ij_java_blank_lines_around_method_in_interface = 1
|
||||
ij_java_blank_lines_before_class_end = 0
|
||||
ij_java_blank_lines_before_imports = 0
|
||||
ij_java_blank_lines_before_method_body = 0
|
||||
ij_java_blank_lines_before_package = 1
|
||||
ij_java_block_brace_style = end_of_line
|
||||
ij_java_block_comment_at_first_column = false
|
||||
ij_java_call_parameters_new_line_after_left_paren = false
|
||||
ij_java_call_parameters_right_paren_on_new_line = false
|
||||
ij_java_call_parameters_wrap = normal
|
||||
ij_java_case_statement_on_separate_line = true
|
||||
ij_java_catch_on_new_line = false
|
||||
ij_java_class_annotation_wrap = split_into_lines
|
||||
ij_java_class_brace_style = end_of_line
|
||||
ij_java_class_count_to_use_import_on_demand = 999
|
||||
ij_java_class_names_in_javadoc = 1
|
||||
ij_java_do_not_indent_top_level_class_members = false
|
||||
ij_java_do_not_wrap_after_single_annotation = false
|
||||
ij_java_do_while_brace_force = always
|
||||
ij_java_doc_add_blank_line_after_description = true
|
||||
ij_java_doc_add_blank_line_after_param_comments = false
|
||||
ij_java_doc_add_blank_line_after_return = false
|
||||
ij_java_doc_add_p_tag_on_empty_lines = true
|
||||
ij_java_doc_align_exception_comments = true
|
||||
ij_java_doc_align_param_comments = false
|
||||
ij_java_doc_do_not_wrap_if_one_line = false
|
||||
ij_java_doc_enable_formatting = true
|
||||
ij_java_doc_enable_leading_asterisks = true
|
||||
ij_java_doc_indent_on_continuation = false
|
||||
ij_java_doc_keep_empty_lines = true
|
||||
ij_java_doc_keep_empty_parameter_tag = true
|
||||
ij_java_doc_keep_empty_return_tag = true
|
||||
ij_java_doc_keep_empty_throws_tag = true
|
||||
ij_java_doc_keep_invalid_tags = true
|
||||
ij_java_doc_param_description_on_new_line = false
|
||||
ij_java_doc_preserve_line_breaks = false
|
||||
ij_java_doc_use_throws_not_exception_tag = true
|
||||
ij_java_else_on_new_line = false
|
||||
ij_java_enum_constants_wrap = normal
|
||||
ij_java_extends_keyword_wrap = normal
|
||||
ij_java_extends_list_wrap = normal
|
||||
ij_java_field_annotation_wrap = split_into_lines
|
||||
ij_java_finally_on_new_line = false
|
||||
ij_java_for_brace_force = always
|
||||
ij_java_for_statement_new_line_after_left_paren = false
|
||||
ij_java_for_statement_right_paren_on_new_line = false
|
||||
ij_java_for_statement_wrap = normal
|
||||
ij_java_generate_final_locals = false
|
||||
ij_java_generate_final_parameters = false
|
||||
ij_java_if_brace_force = always
|
||||
ij_java_imports_layout = $*, |, *, |, *
|
||||
ij_java_indent_case_from_switch = true
|
||||
ij_java_insert_inner_class_imports = false
|
||||
ij_java_insert_override_annotation = true
|
||||
ij_java_keep_blank_lines_before_right_brace = 2
|
||||
ij_java_keep_blank_lines_between_package_declaration_and_header = 2
|
||||
ij_java_keep_blank_lines_in_code = 2
|
||||
ij_java_keep_blank_lines_in_declarations = 2
|
||||
ij_java_keep_control_statement_in_one_line = true
|
||||
ij_java_keep_first_column_comment = true
|
||||
ij_java_keep_indents_on_empty_lines = false
|
||||
ij_java_keep_line_breaks = true
|
||||
ij_java_keep_multiple_expressions_in_one_line = false
|
||||
ij_java_keep_simple_blocks_in_one_line = false
|
||||
ij_java_keep_simple_classes_in_one_line = false
|
||||
ij_java_keep_simple_lambdas_in_one_line = false
|
||||
ij_java_keep_simple_methods_in_one_line = false
|
||||
ij_java_label_indent_absolute = false
|
||||
ij_java_label_indent_size = 0
|
||||
ij_java_lambda_brace_style = end_of_line
|
||||
ij_java_layout_static_imports_separately = true
|
||||
ij_java_line_comment_add_space = true
|
||||
ij_java_line_comment_at_first_column = false
|
||||
ij_java_method_annotation_wrap = split_into_lines
|
||||
ij_java_method_brace_style = end_of_line
|
||||
ij_java_method_call_chain_wrap = normal
|
||||
ij_java_method_parameters_new_line_after_left_paren = false
|
||||
ij_java_method_parameters_right_paren_on_new_line = false
|
||||
ij_java_method_parameters_wrap = normal
|
||||
ij_java_modifier_list_wrap = false
|
||||
ij_java_names_count_to_use_import_on_demand = 999
|
||||
ij_java_new_line_after_lparen_in_record_header = false
|
||||
ij_java_parameter_annotation_wrap = normal
|
||||
ij_java_parentheses_expression_new_line_after_left_paren = false
|
||||
ij_java_parentheses_expression_right_paren_on_new_line = false
|
||||
ij_java_place_assignment_sign_on_next_line = false
|
||||
ij_java_prefer_longer_names = true
|
||||
ij_java_prefer_parameters_wrap = false
|
||||
ij_java_record_components_wrap = normal
|
||||
ij_java_repeat_synchronized = true
|
||||
ij_java_replace_instanceof_and_cast = false
|
||||
ij_java_replace_null_check = true
|
||||
ij_java_replace_sum_lambda_with_method_ref = true
|
||||
ij_java_resource_list_new_line_after_left_paren = false
|
||||
ij_java_resource_list_right_paren_on_new_line = false
|
||||
ij_java_resource_list_wrap = normal
|
||||
ij_java_rparen_on_new_line_in_record_header = false
|
||||
ij_java_space_after_closing_angle_bracket_in_type_argument = false
|
||||
ij_java_space_after_colon = true
|
||||
ij_java_space_after_comma = true
|
||||
ij_java_space_after_comma_in_type_arguments = true
|
||||
ij_java_space_after_for_semicolon = true
|
||||
ij_java_space_after_quest = true
|
||||
ij_java_space_after_type_cast = true
|
||||
ij_java_space_before_annotation_array_initializer_left_brace = false
|
||||
ij_java_space_before_annotation_parameter_list = false
|
||||
ij_java_space_before_array_initializer_left_brace = true
|
||||
ij_java_space_before_catch_keyword = true
|
||||
ij_java_space_before_catch_left_brace = true
|
||||
ij_java_space_before_catch_parentheses = true
|
||||
ij_java_space_before_class_left_brace = true
|
||||
ij_java_space_before_colon = true
|
||||
ij_java_space_before_colon_in_foreach = true
|
||||
ij_java_space_before_comma = false
|
||||
ij_java_space_before_do_left_brace = true
|
||||
ij_java_space_before_else_keyword = true
|
||||
ij_java_space_before_else_left_brace = true
|
||||
ij_java_space_before_finally_keyword = true
|
||||
ij_java_space_before_finally_left_brace = true
|
||||
ij_java_space_before_for_left_brace = true
|
||||
ij_java_space_before_for_parentheses = true
|
||||
ij_java_space_before_for_semicolon = false
|
||||
ij_java_space_before_if_left_brace = true
|
||||
ij_java_space_before_if_parentheses = true
|
||||
ij_java_space_before_method_call_parentheses = false
|
||||
ij_java_space_before_method_left_brace = true
|
||||
ij_java_space_before_method_parentheses = false
|
||||
ij_java_space_before_opening_angle_bracket_in_type_parameter = false
|
||||
ij_java_space_before_quest = true
|
||||
ij_java_space_before_switch_left_brace = true
|
||||
ij_java_space_before_switch_parentheses = true
|
||||
ij_java_space_before_synchronized_left_brace = true
|
||||
ij_java_space_before_synchronized_parentheses = true
|
||||
ij_java_space_before_try_left_brace = true
|
||||
ij_java_space_before_try_parentheses = true
|
||||
ij_java_space_before_type_parameter_list = false
|
||||
ij_java_space_before_while_keyword = true
|
||||
ij_java_space_before_while_left_brace = true
|
||||
ij_java_space_before_while_parentheses = true
|
||||
ij_java_space_inside_one_line_enum_braces = false
|
||||
ij_java_space_within_empty_array_initializer_braces = false
|
||||
ij_java_space_within_empty_method_call_parentheses = false
|
||||
ij_java_space_within_empty_method_parentheses = false
|
||||
ij_java_spaces_around_additive_operators = true
|
||||
ij_java_spaces_around_assignment_operators = true
|
||||
ij_java_spaces_around_bitwise_operators = true
|
||||
ij_java_spaces_around_equality_operators = true
|
||||
ij_java_spaces_around_lambda_arrow = true
|
||||
ij_java_spaces_around_logical_operators = true
|
||||
ij_java_spaces_around_method_ref_dbl_colon = false
|
||||
ij_java_spaces_around_multiplicative_operators = true
|
||||
ij_java_spaces_around_relational_operators = true
|
||||
ij_java_spaces_around_shift_operators = true
|
||||
ij_java_spaces_around_type_bounds_in_type_parameters = true
|
||||
ij_java_spaces_around_unary_operator = false
|
||||
ij_java_spaces_within_angle_brackets = false
|
||||
ij_java_spaces_within_annotation_parentheses = false
|
||||
ij_java_spaces_within_array_initializer_braces = false
|
||||
ij_java_spaces_within_braces = false
|
||||
ij_java_spaces_within_brackets = false
|
||||
ij_java_spaces_within_cast_parentheses = false
|
||||
ij_java_spaces_within_catch_parentheses = false
|
||||
ij_java_spaces_within_for_parentheses = false
|
||||
ij_java_spaces_within_if_parentheses = false
|
||||
ij_java_spaces_within_method_call_parentheses = false
|
||||
ij_java_spaces_within_method_parentheses = false
|
||||
ij_java_spaces_within_parentheses = false
|
||||
ij_java_spaces_within_switch_parentheses = false
|
||||
ij_java_spaces_within_synchronized_parentheses = false
|
||||
ij_java_spaces_within_try_parentheses = false
|
||||
ij_java_spaces_within_while_parentheses = false
|
||||
ij_java_special_else_if_treatment = true
|
||||
ij_java_subclass_name_suffix = Impl
|
||||
ij_java_ternary_operation_signs_on_next_line = true
|
||||
ij_java_ternary_operation_wrap = normal
|
||||
ij_java_test_name_suffix = Test
|
||||
ij_java_throws_keyword_wrap = normal
|
||||
ij_java_throws_list_wrap = normal
|
||||
ij_java_use_external_annotations = false
|
||||
ij_java_use_fq_class_names = false
|
||||
ij_java_use_relative_indents = false
|
||||
ij_java_use_single_class_imports = true
|
||||
ij_java_variable_annotation_wrap = normal
|
||||
ij_java_visibility = public
|
||||
ij_java_while_brace_force = always
|
||||
ij_java_while_on_new_line = false
|
||||
ij_java_wrap_comments = false
|
||||
ij_java_wrap_first_method_in_call_chain = false
|
||||
ij_java_wrap_long_lines = true
|
||||
|
||||
[*.properties]
|
||||
ij_properties_align_group_field_declarations = false
|
||||
ij_properties_keep_blank_lines = false
|
||||
ij_properties_key_value_delimiter = equals
|
||||
ij_properties_spaces_around_key_value_delimiter = false
|
||||
|
||||
[.editorconfig]
|
||||
ij_editorconfig_align_group_field_declarations = false
|
||||
ij_editorconfig_space_after_colon = false
|
||||
ij_editorconfig_space_after_comma = true
|
||||
ij_editorconfig_space_before_colon = false
|
||||
ij_editorconfig_space_before_comma = false
|
||||
ij_editorconfig_spaces_around_assignment_operators = true
|
||||
|
||||
[{*.ant, *.fxml, *.jhm, *.jnlp, *.jrxml, *.jspx, *.pom, *.rng, *.tagx, *.tld, *.wsdl, *.xml, *.xsd, *.xsl, *.xslt, *.xul}]
|
||||
ij_xml_align_attributes = true
|
||||
ij_xml_align_text = false
|
||||
ij_xml_attribute_wrap = normal
|
||||
ij_xml_block_comment_at_first_column = true
|
||||
ij_xml_keep_blank_lines = 2
|
||||
ij_xml_keep_indents_on_empty_lines = false
|
||||
ij_xml_keep_line_breaks = true
|
||||
ij_xml_keep_line_breaks_in_text = true
|
||||
ij_xml_keep_whitespaces = false
|
||||
ij_xml_keep_whitespaces_around_cdata = preserve
|
||||
ij_xml_keep_whitespaces_inside_cdata = false
|
||||
ij_xml_line_comment_at_first_column = true
|
||||
ij_xml_space_after_tag_name = false
|
||||
ij_xml_space_around_equals_in_attribute = false
|
||||
ij_xml_space_inside_empty_tag = false
|
||||
ij_xml_text_wrap = normal
|
||||
|
||||
[{*.bash, *.sh, *.zsh}]
|
||||
indent_size = 2
|
||||
tab_width = 2
|
||||
ij_shell_binary_ops_start_line = false
|
||||
ij_shell_keep_column_alignment_padding = false
|
||||
ij_shell_minify_program = false
|
||||
ij_shell_redirect_followed_by_space = false
|
||||
ij_shell_switch_cases_indented = false
|
||||
|
||||
[{*.gant, *.gradle, *.groovy, *.gy}]
|
||||
ij_groovy_align_group_field_declarations = false
|
||||
ij_groovy_align_multiline_array_initializer_expression = false
|
||||
ij_groovy_align_multiline_assignment = false
|
||||
ij_groovy_align_multiline_binary_operation = false
|
||||
ij_groovy_align_multiline_chained_methods = false
|
||||
ij_groovy_align_multiline_extends_list = false
|
||||
ij_groovy_align_multiline_for = true
|
||||
ij_groovy_align_multiline_list_or_map = true
|
||||
ij_groovy_align_multiline_method_parentheses = false
|
||||
ij_groovy_align_multiline_parameters = true
|
||||
ij_groovy_align_multiline_parameters_in_calls = false
|
||||
ij_groovy_align_multiline_resources = true
|
||||
ij_groovy_align_multiline_ternary_operation = false
|
||||
ij_groovy_align_multiline_throws_list = false
|
||||
ij_groovy_align_named_args_in_map = true
|
||||
ij_groovy_align_throws_keyword = false
|
||||
ij_groovy_array_initializer_new_line_after_left_brace = false
|
||||
ij_groovy_array_initializer_right_brace_on_new_line = false
|
||||
ij_groovy_array_initializer_wrap = off
|
||||
ij_groovy_assert_statement_wrap = off
|
||||
ij_groovy_assignment_wrap = off
|
||||
ij_groovy_binary_operation_wrap = off
|
||||
ij_groovy_blank_lines_after_class_header = 0
|
||||
ij_groovy_blank_lines_after_imports = 1
|
||||
ij_groovy_blank_lines_after_package = 1
|
||||
ij_groovy_blank_lines_around_class = 1
|
||||
ij_groovy_blank_lines_around_field = 0
|
||||
ij_groovy_blank_lines_around_field_in_interface = 0
|
||||
ij_groovy_blank_lines_around_method = 1
|
||||
ij_groovy_blank_lines_around_method_in_interface = 1
|
||||
ij_groovy_blank_lines_before_imports = 1
|
||||
ij_groovy_blank_lines_before_method_body = 0
|
||||
ij_groovy_blank_lines_before_package = 0
|
||||
ij_groovy_block_brace_style = end_of_line
|
||||
ij_groovy_block_comment_at_first_column = true
|
||||
ij_groovy_call_parameters_new_line_after_left_paren = false
|
||||
ij_groovy_call_parameters_right_paren_on_new_line = false
|
||||
ij_groovy_call_parameters_wrap = off
|
||||
ij_groovy_catch_on_new_line = false
|
||||
ij_groovy_class_annotation_wrap = split_into_lines
|
||||
ij_groovy_class_brace_style = end_of_line
|
||||
ij_groovy_class_count_to_use_import_on_demand = 5
|
||||
ij_groovy_do_while_brace_force = never
|
||||
ij_groovy_else_on_new_line = false
|
||||
ij_groovy_enum_constants_wrap = off
|
||||
ij_groovy_extends_keyword_wrap = off
|
||||
ij_groovy_extends_list_wrap = off
|
||||
ij_groovy_field_annotation_wrap = split_into_lines
|
||||
ij_groovy_finally_on_new_line = false
|
||||
ij_groovy_for_brace_force = never
|
||||
ij_groovy_for_statement_new_line_after_left_paren = false
|
||||
ij_groovy_for_statement_right_paren_on_new_line = false
|
||||
ij_groovy_for_statement_wrap = off
|
||||
ij_groovy_if_brace_force = never
|
||||
ij_groovy_import_annotation_wrap = 2
|
||||
ij_groovy_indent_case_from_switch = true
|
||||
ij_groovy_indent_label_blocks = true
|
||||
ij_groovy_insert_inner_class_imports = false
|
||||
ij_groovy_keep_blank_lines_before_right_brace = 2
|
||||
ij_groovy_keep_blank_lines_in_code = 2
|
||||
ij_groovy_keep_blank_lines_in_declarations = 2
|
||||
ij_groovy_keep_control_statement_in_one_line = true
|
||||
ij_groovy_keep_first_column_comment = true
|
||||
ij_groovy_keep_indents_on_empty_lines = false
|
||||
ij_groovy_keep_line_breaks = true
|
||||
ij_groovy_keep_multiple_expressions_in_one_line = false
|
||||
ij_groovy_keep_simple_blocks_in_one_line = false
|
||||
ij_groovy_keep_simple_classes_in_one_line = true
|
||||
ij_groovy_keep_simple_lambdas_in_one_line = true
|
||||
ij_groovy_keep_simple_methods_in_one_line = true
|
||||
ij_groovy_label_indent_absolute = false
|
||||
ij_groovy_label_indent_size = 0
|
||||
ij_groovy_lambda_brace_style = end_of_line
|
||||
ij_groovy_layout_static_imports_separately = true
|
||||
ij_groovy_line_comment_add_space = false
|
||||
ij_groovy_line_comment_at_first_column = true
|
||||
ij_groovy_method_annotation_wrap = split_into_lines
|
||||
ij_groovy_method_brace_style = end_of_line
|
||||
ij_groovy_method_call_chain_wrap = off
|
||||
ij_groovy_method_parameters_new_line_after_left_paren = false
|
||||
ij_groovy_method_parameters_right_paren_on_new_line = false
|
||||
ij_groovy_method_parameters_wrap = off
|
||||
ij_groovy_modifier_list_wrap = false
|
||||
ij_groovy_names_count_to_use_import_on_demand = 3
|
||||
ij_groovy_parameter_annotation_wrap = off
|
||||
ij_groovy_parentheses_expression_new_line_after_left_paren = false
|
||||
ij_groovy_parentheses_expression_right_paren_on_new_line = false
|
||||
ij_groovy_prefer_parameters_wrap = false
|
||||
ij_groovy_resource_list_new_line_after_left_paren = false
|
||||
ij_groovy_resource_list_right_paren_on_new_line = false
|
||||
ij_groovy_resource_list_wrap = off
|
||||
ij_groovy_space_after_assert_separator = true
|
||||
ij_groovy_space_after_colon = true
|
||||
ij_groovy_space_after_comma = true
|
||||
ij_groovy_space_after_comma_in_type_arguments = true
|
||||
ij_groovy_space_after_for_semicolon = true
|
||||
ij_groovy_space_after_quest = true
|
||||
ij_groovy_space_after_type_cast = true
|
||||
ij_groovy_space_before_annotation_parameter_list = false
|
||||
ij_groovy_space_before_array_initializer_left_brace = false
|
||||
ij_groovy_space_before_assert_separator = false
|
||||
ij_groovy_space_before_catch_keyword = true
|
||||
ij_groovy_space_before_catch_left_brace = true
|
||||
ij_groovy_space_before_catch_parentheses = true
|
||||
ij_groovy_space_before_class_left_brace = true
|
||||
ij_groovy_space_before_closure_left_brace = true
|
||||
ij_groovy_space_before_colon = true
|
||||
ij_groovy_space_before_comma = false
|
||||
ij_groovy_space_before_do_left_brace = true
|
||||
ij_groovy_space_before_else_keyword = true
|
||||
ij_groovy_space_before_else_left_brace = true
|
||||
ij_groovy_space_before_finally_keyword = true
|
||||
ij_groovy_space_before_finally_left_brace = true
|
||||
ij_groovy_space_before_for_left_brace = true
|
||||
ij_groovy_space_before_for_parentheses = true
|
||||
ij_groovy_space_before_for_semicolon = false
|
||||
ij_groovy_space_before_if_left_brace = true
|
||||
ij_groovy_space_before_if_parentheses = true
|
||||
ij_groovy_space_before_method_call_parentheses = false
|
||||
ij_groovy_space_before_method_left_brace = true
|
||||
ij_groovy_space_before_method_parentheses = false
|
||||
ij_groovy_space_before_quest = true
|
||||
ij_groovy_space_before_switch_left_brace = true
|
||||
ij_groovy_space_before_switch_parentheses = true
|
||||
ij_groovy_space_before_synchronized_left_brace = true
|
||||
ij_groovy_space_before_synchronized_parentheses = true
|
||||
ij_groovy_space_before_try_left_brace = true
|
||||
ij_groovy_space_before_try_parentheses = true
|
||||
ij_groovy_space_before_while_keyword = true
|
||||
ij_groovy_space_before_while_left_brace = true
|
||||
ij_groovy_space_before_while_parentheses = true
|
||||
ij_groovy_space_in_named_argument = true
|
||||
ij_groovy_space_in_named_argument_before_colon = false
|
||||
ij_groovy_space_within_empty_array_initializer_braces = false
|
||||
ij_groovy_space_within_empty_method_call_parentheses = false
|
||||
ij_groovy_spaces_around_additive_operators = true
|
||||
ij_groovy_spaces_around_assignment_operators = true
|
||||
ij_groovy_spaces_around_bitwise_operators = true
|
||||
ij_groovy_spaces_around_equality_operators = true
|
||||
ij_groovy_spaces_around_lambda_arrow = true
|
||||
ij_groovy_spaces_around_logical_operators = true
|
||||
ij_groovy_spaces_around_multiplicative_operators = true
|
||||
ij_groovy_spaces_around_regex_operators = true
|
||||
ij_groovy_spaces_around_relational_operators = true
|
||||
ij_groovy_spaces_around_shift_operators = true
|
||||
ij_groovy_spaces_within_annotation_parentheses = false
|
||||
ij_groovy_spaces_within_array_initializer_braces = false
|
||||
ij_groovy_spaces_within_braces = true
|
||||
ij_groovy_spaces_within_brackets = false
|
||||
ij_groovy_spaces_within_cast_parentheses = false
|
||||
ij_groovy_spaces_within_catch_parentheses = false
|
||||
ij_groovy_spaces_within_for_parentheses = false
|
||||
ij_groovy_spaces_within_gstring_injection_braces = false
|
||||
ij_groovy_spaces_within_if_parentheses = false
|
||||
ij_groovy_spaces_within_list_or_map = false
|
||||
ij_groovy_spaces_within_method_call_parentheses = false
|
||||
ij_groovy_spaces_within_method_parentheses = false
|
||||
ij_groovy_spaces_within_parentheses = false
|
||||
ij_groovy_spaces_within_switch_parentheses = false
|
||||
ij_groovy_spaces_within_synchronized_parentheses = false
|
||||
ij_groovy_spaces_within_try_parentheses = false
|
||||
ij_groovy_spaces_within_tuple_expression = false
|
||||
ij_groovy_spaces_within_while_parentheses = false
|
||||
ij_groovy_special_else_if_treatment = true
|
||||
ij_groovy_ternary_operation_wrap = off
|
||||
ij_groovy_throws_keyword_wrap = off
|
||||
ij_groovy_throws_list_wrap = off
|
||||
ij_groovy_use_flying_geese_braces = false
|
||||
ij_groovy_use_fq_class_names = false
|
||||
ij_groovy_use_fq_class_names_in_javadoc = true
|
||||
ij_groovy_use_relative_indents = false
|
||||
ij_groovy_use_single_class_imports = true
|
||||
ij_groovy_variable_annotation_wrap = off
|
||||
ij_groovy_while_brace_force = never
|
||||
ij_groovy_while_on_new_line = false
|
||||
ij_groovy_wrap_long_lines = false
|
||||
|
||||
[{*.har, *.json}]
|
||||
indent_size = 2
|
||||
ij_json_keep_blank_lines_in_code = 0
|
||||
ij_json_keep_indents_on_empty_lines = false
|
||||
ij_json_keep_line_breaks = true
|
||||
ij_json_space_after_colon = true
|
||||
ij_json_space_after_comma = true
|
||||
ij_json_space_before_colon = true
|
||||
ij_json_space_before_comma = false
|
||||
ij_json_spaces_within_braces = false
|
||||
ij_json_spaces_within_brackets = false
|
||||
ij_json_wrap_long_lines = false
|
||||
|
||||
[{*.htm, *.html, *.sht, *.shtm, *.shtml}]
|
||||
ij_html_add_new_line_before_tags = body, div, p, form, h1, h2, h3
|
||||
ij_html_align_attributes = true
|
||||
ij_html_align_text = false
|
||||
ij_html_attribute_wrap = normal
|
||||
ij_html_block_comment_at_first_column = true
|
||||
ij_html_do_not_align_children_of_min_lines = 0
|
||||
ij_html_do_not_break_if_inline_tags = title, h1, h2, h3, h4, h5, h6, p
|
||||
ij_html_do_not_indent_children_of_tags = html, body, thead, tbody, tfoot
|
||||
ij_html_enforce_quotes = false
|
||||
ij_html_inline_tags = a, abbr, acronym, b, basefont, bdo, big, br, cite, cite, code, dfn, em, font, i, img, input, kbd, label, q, s, samp, select, small, span, strike, strong, sub, sup, textarea, tt, u, var
|
||||
ij_html_keep_blank_lines = 2
|
||||
ij_html_keep_indents_on_empty_lines = false
|
||||
ij_html_keep_line_breaks = true
|
||||
ij_html_keep_line_breaks_in_text = true
|
||||
ij_html_keep_whitespaces = false
|
||||
ij_html_keep_whitespaces_inside = span, pre, textarea
|
||||
ij_html_line_comment_at_first_column = true
|
||||
ij_html_new_line_after_last_attribute = never
|
||||
ij_html_new_line_before_first_attribute = never
|
||||
ij_html_quote_style = double
|
||||
ij_html_remove_new_line_before_tags = br
|
||||
ij_html_space_after_tag_name = false
|
||||
ij_html_space_around_equality_in_attribute = false
|
||||
ij_html_space_inside_empty_tag = false
|
||||
ij_html_text_wrap = normal
|
||||
|
||||
[{*.yaml, *.yml}]
|
||||
indent_size = 2
|
||||
ij_yaml_keep_indents_on_empty_lines = false
|
||||
ij_yaml_keep_line_breaks = true
|
||||
|
||||
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
|
||||
charset = utf-8
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
end_of_line = lf
|
||||
max_line_length = 100
|
||||
@@ -0,0 +1,20 @@
|
||||
name: CD
|
||||
|
||||
on:
|
||||
release:
|
||||
types:
|
||||
- published
|
||||
|
||||
jobs:
|
||||
cd:
|
||||
uses: halo-sigs/reusable-workflows/.github/workflows/plugin-cd.yaml@v4
|
||||
permissions:
|
||||
contents: write
|
||||
with:
|
||||
ui-path: "ui"
|
||||
pnpm-version: 10
|
||||
node-version: 24
|
||||
java-version: 21
|
||||
# Remove skip-appstore-release and set app-id if you want to release to the App Store
|
||||
skip-appstore-release: true
|
||||
# app-id: app-xyz
|
||||
@@ -0,0 +1,18 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
uses: halo-sigs/reusable-workflows/.github/workflows/plugin-ci.yaml@v4
|
||||
with:
|
||||
ui-path: "ui"
|
||||
pnpm-version: 10
|
||||
node-version: 24
|
||||
java-version: 21
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
### Maven
|
||||
target/
|
||||
logs/
|
||||
!.mvn/wrapper/maven-wrapper.jar
|
||||
|
||||
### Gradle
|
||||
.gradle
|
||||
/build/
|
||||
/out/
|
||||
!gradle/wrapper/gradle-wrapper.jar
|
||||
bin/
|
||||
|
||||
### Node
|
||||
node_modules/
|
||||
|
||||
### STS
|
||||
.apt_generated
|
||||
.classpath
|
||||
.factorypath
|
||||
.project
|
||||
.settings
|
||||
.springBeans
|
||||
.sts4-cache
|
||||
|
||||
### IntelliJ IDEA
|
||||
.idea
|
||||
*.iws
|
||||
*.iml
|
||||
*.ipr
|
||||
log/
|
||||
|
||||
### NetBeans
|
||||
nbproject/private/
|
||||
build/
|
||||
nbbuild/
|
||||
dist/
|
||||
nbdist/
|
||||
.nb-gradle/
|
||||
|
||||
### Mac
|
||||
.DS_Store
|
||||
*/.DS_Store
|
||||
|
||||
### VSCode
|
||||
*.project
|
||||
*.factorypath
|
||||
.vscode
|
||||
!.vscode/extensions.json
|
||||
|
||||
### Compiled class file
|
||||
*.class
|
||||
|
||||
### Log file
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
### BlueJ files
|
||||
*.ctxt
|
||||
|
||||
### Package Files
|
||||
*.war
|
||||
*.nar
|
||||
*.ear
|
||||
*.zip
|
||||
*.tar.gz
|
||||
*.rar
|
||||
|
||||
### Local file
|
||||
application-local.yml
|
||||
application-local.yaml
|
||||
application-local.properties
|
||||
|
||||
/ui/build/
|
||||
/workplace/
|
||||
/src/main/resources/console/
|
||||
|
||||
### TRAE IDE
|
||||
.trae/
|
||||
|
||||
### VitePress cache
|
||||
docs/.vitepress/cache/
|
||||
docs/.vitepress/dist/
|
||||
|
||||
### Local scripts
|
||||
test-comment.js
|
||||
upgrade-plugin.ps1
|
||||
reload-plugin.ps1
|
||||
start-plugin.ps1
|
||||
toggle-plugin.ps1
|
||||
@@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<http://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<http://www.gnu.org/philosophy/why-not-lgpl.html>.
|
||||
@@ -0,0 +1,36 @@
|
||||
# comment-ai-autopilot
|
||||
|
||||
comment-ai-autopilot - Halo 插件
|
||||
|
||||
## 简介
|
||||
|
||||
这是一个基于 Halo 的插件项目。
|
||||
|
||||
## 开发环境
|
||||
|
||||
- Java 21+
|
||||
- Node.js 18+
|
||||
- pnpm
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
# 启用插件
|
||||
./gradlew haloServer
|
||||
# 开发前端
|
||||
cd ui
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## 构建
|
||||
|
||||
```bash
|
||||
./gradlew build
|
||||
```
|
||||
|
||||
构建完成后,可以在 `build/libs` 目录找到插件 jar 文件。
|
||||
|
||||
## 许可证
|
||||
|
||||
[GPL-3.0](./LICENSE) © 暖心向阳335
|
||||
@@ -0,0 +1,58 @@
|
||||
plugins {
|
||||
id 'java'
|
||||
id "io.freefair.lombok" version "9.2.0"
|
||||
id "run.halo.plugin.devtools" version "0.6.2"
|
||||
}
|
||||
|
||||
group 'top.nxxy335.commentaiautopilot'
|
||||
version '0.0.1-w5s2t7'
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation platform('run.halo.tools.platform:plugin:2.23.0')
|
||||
compileOnly 'run.halo.app:api'
|
||||
compileOnly files('libs/ai-foundation-api.jar')
|
||||
implementation 'org.jsoup:jsoup:1.18.1'
|
||||
|
||||
testImplementation 'run.halo.app:api'
|
||||
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||
}
|
||||
|
||||
test {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion = JavaLanguageVersion.of(21)
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType(JavaCompile).configureEach {
|
||||
options.encoding = "UTF-8"
|
||||
options.release = 21
|
||||
}
|
||||
|
||||
tasks.register('processUiResources', Copy) {
|
||||
from project(':ui').layout.buildDirectory.dir('dist')
|
||||
into layout.buildDirectory.dir('resources/main/console')
|
||||
dependsOn project(':ui').tasks.named('assemble')
|
||||
shouldRunAfter tasks.named('processResources')
|
||||
}
|
||||
|
||||
tasks.named('classes') {
|
||||
dependsOn tasks.named('processUiResources')
|
||||
}
|
||||
|
||||
tasks.named('generatePluginComponentsIdx') {
|
||||
notCompatibleWithConfigurationCache('This task invokes "Task.project" at execution time, which is not supported with configuration cache.')
|
||||
}
|
||||
|
||||
halo {
|
||||
version = '2.23'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { defineConfig } from "vitepress";
|
||||
|
||||
export default defineConfig({
|
||||
title: "AI回评",
|
||||
description: "Halo AI回评插件文档",
|
||||
lang: "zh-CN",
|
||||
themeConfig: {
|
||||
nav: [
|
||||
{ text: "指南", link: "/guide/introduction" },
|
||||
{ text: "配置", link: "/guide/settings" },
|
||||
],
|
||||
sidebar: [
|
||||
{
|
||||
text: "开始",
|
||||
items: [
|
||||
{ text: "插件介绍", link: "/guide/introduction" },
|
||||
{ text: "安装与更新", link: "/guide/installation" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "功能",
|
||||
items: [
|
||||
{ text: "自动回复", link: "/guide/auto-reply" },
|
||||
{ text: "草稿模式", link: "/guide/draft-mode" },
|
||||
{ text: "情感分析", link: "/guide/sentiment" },
|
||||
{ text: "过滤规则", link: "/guide/filter" },
|
||||
{ text: "手动触发", link: "/guide/manual-trigger" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "配置",
|
||||
items: [
|
||||
{ text: "插件设置", link: "/guide/settings" },
|
||||
{ text: "AI角色", link: "/guide/persona" },
|
||||
{ text: "Prompt模板", link: "/guide/prompt" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "其他",
|
||||
items: [
|
||||
{ text: "常见问题", link: "/guide/faq" },
|
||||
],
|
||||
},
|
||||
],
|
||||
socialLinks: [
|
||||
{
|
||||
icon: "github",
|
||||
link: "https://github.com/nxxy335/plugin-comment-ai-autopilot",
|
||||
},
|
||||
],
|
||||
search: {
|
||||
provider: "local",
|
||||
},
|
||||
footer: {
|
||||
message: "基于 GPL-3.0 许可发布",
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
# 自动回复
|
||||
|
||||
自动回复是插件的核心功能,当有新评论发布时,插件会自动触发AI生成回复。
|
||||
|
||||
## 工作原理
|
||||
|
||||
插件通过 Halo 的 Reconciler 机制监听评论和回复的创建事件:
|
||||
|
||||
- **CommentReconciler** — 监听新评论,触发首次AI回复
|
||||
- **ReplyReconciler** — 监听新回复,当回复者不是AI角色时,触发对话式AI回复
|
||||
|
||||
## 去重机制
|
||||
|
||||
为避免重复回复,插件实现了多层去重防护:
|
||||
|
||||
1. **内存锁** — 使用 `ConcurrentHashMap` 防止同一评论并发处理
|
||||
2. **数据库检查** — 处理前查询是否已存在 `AiCommentReply` 记录
|
||||
3. **发布前检查** — 创建 Reply 前再次确认不存在重复
|
||||
|
||||
## 历史评论过滤
|
||||
|
||||
插件启动时间之前的评论不会触发自动回复,避免安装插件后对大量历史评论批量回复。
|
||||
|
||||
## 对话式回复
|
||||
|
||||
当评论者回复AI的评论时,插件会自动提取对话上下文(最近5条回复),让AI的回复更连贯自然。
|
||||
|
||||
## 配置项
|
||||
|
||||
| 配置项 | 说明 | 默认值 |
|
||||
|--------|------|--------|
|
||||
| 自动回复 | 是否启用自动回复功能 | 开启 |
|
||||
| 自动发布 | AI回复是否自动发布,关闭则存为草稿 | 开启 |
|
||||
| 最大重试次数 | AI生成失败时的最大重试次数 | 3 |
|
||||
@@ -0,0 +1,24 @@
|
||||
# 草稿模式
|
||||
|
||||
草稿模式允许AI回复先存为草稿,管理员审核后再发布,适用于对AI回复质量有较高要求的场景。
|
||||
|
||||
## 启用草稿模式
|
||||
|
||||
在插件设置页面,将 **自动发布** 开关关闭即可启用草稿模式。
|
||||
|
||||
## 审核流程
|
||||
|
||||
1. 新评论到达后,AI生成回复内容
|
||||
2. 回复以草稿状态保存(`approved = false`)
|
||||
3. 在 **AI回复日志** 页面,草稿状态的记录会显示审核按钮
|
||||
4. 管理员可以:
|
||||
- **审核通过** — 回复立即发布,访客可见
|
||||
- **拒绝** — 删除草稿回复,记录标记为 REJECTED
|
||||
|
||||
## 日志页面操作
|
||||
|
||||
在AI回复日志页面:
|
||||
|
||||
- 草稿记录显示 **审核通过** 和 **拒绝** 按钮
|
||||
- 已发布的记录显示正常状态
|
||||
- 被拒绝的记录显示 REJECTED 标签
|
||||
@@ -0,0 +1,52 @@
|
||||
# 常见问题
|
||||
|
||||
## 安装后没有自动回复?
|
||||
|
||||
1. 确认插件已启用
|
||||
2. 确认 **自动回复** 开关已开启
|
||||
3. 确认 AI Foundation 插件已安装并正确配置(如果使用AI Foundation)
|
||||
4. 检查文章的 **启用AI回评** 开关是否开启(文章默认开启,页面默认关闭)
|
||||
5. 查看插件日志是否有错误信息
|
||||
|
||||
## AI回复头像不显示?
|
||||
|
||||
1. 确认在插件设置中填写了AI角色邮箱
|
||||
2. 邮箱需要在 [Gravatar](https://gravatar.com) 上注册并设置头像
|
||||
3. 插件使用 [Cravatar](https://cravatar.cn) 作为Gravatar镜像服务
|
||||
|
||||
## 评论没有触发AI回复?
|
||||
|
||||
可能的原因:
|
||||
|
||||
1. **文章/页面开关关闭** — 检查编辑器中的"启用AI回评"开关
|
||||
2. **评论者在黑名单中** — 检查基本设置中的评论者黑名单
|
||||
3. **已有AI回复记录** — 同一评论不会重复触发
|
||||
4. **历史评论** — 插件启动前的评论不会自动触发,可使用手动触发
|
||||
5. **AI生成失败** — 检查AI模型配置和日志
|
||||
|
||||
## 如何对历史评论触发AI回复?
|
||||
|
||||
1. 进入后台 **评论** 管理页面
|
||||
2. 找到目标评论
|
||||
3. 点击右侧 **更多(…)** → **触发AI回复**
|
||||
|
||||
## 草稿模式的回复在哪里审核?
|
||||
|
||||
在插件管理页面的 **AI回复日志** 中,草稿状态的记录会显示"审核通过"和"拒绝"按钮。
|
||||
|
||||
## 如何修改AI回复的语气风格?
|
||||
|
||||
1. 在插件设置中修改 **AI角色人格提示词**
|
||||
2. 或修改 **自定义Prompt模板**
|
||||
|
||||
## AI Foundation 插件是必须的吗?
|
||||
|
||||
是的,AI Foundation 是本插件的必要依赖。本插件通过 AI Foundation 提供的 `AiModelService` 扩展点调用AI模型,请确保已安装并正确配置 AI Foundation 插件。
|
||||
|
||||
## 如何完全禁用某个页面的AI回复?
|
||||
|
||||
编辑页面,在元数据区域关闭 **启用AI回评** 开关。页面默认就是关闭的。
|
||||
|
||||
## 插件升级后设置丢失了?
|
||||
|
||||
插件升级不会丢失设置。如果遇到问题,请检查 ConfigMap 是否正确迁移。
|
||||
@@ -0,0 +1,44 @@
|
||||
# 过滤规则
|
||||
|
||||
过滤规则控制哪些评论触发AI回复,包括文章/页面级开关和评论者黑名单。
|
||||
|
||||
## 文章/页面级开关
|
||||
|
||||
插件通过 Halo 的 AnnotationSetting 机制,在文章和页面编辑器的元数据区域添加了 **启用AI回评** 开关。
|
||||
|
||||
### 默认行为
|
||||
|
||||
| 类型 | 默认状态 |
|
||||
|------|---------|
|
||||
| 文章(Post) | 默认开启 |
|
||||
| 页面(SinglePage) | 默认关闭 |
|
||||
|
||||
### 使用方法
|
||||
|
||||
1. 编辑文章或页面
|
||||
2. 在编辑器侧边栏找到 **元数据** 区域
|
||||
3. 找到 **启用AI回评** 开关
|
||||
4. 根据需要开启或关闭
|
||||
|
||||
::: tip
|
||||
新创建的文章默认启用AI回复,新创建的页面默认禁用。你可以在编辑器中随时修改。
|
||||
:::
|
||||
|
||||
## 评论者黑名单
|
||||
|
||||
评论者黑名单功能可以屏蔽指定评论者,使其评论不触发AI回复。
|
||||
|
||||
### 配置方法
|
||||
|
||||
1. 进入插件设置页面
|
||||
2. 在 **基本设置** 中找到 **评论者黑名单**
|
||||
3. 输入评论者的显示名称,多个用逗号分隔
|
||||
4. 保存设置
|
||||
|
||||
### 示例
|
||||
|
||||
```
|
||||
张三,李四,王五
|
||||
```
|
||||
|
||||
黑名单中的评论者发布评论时,插件会跳过AI回复,并在日志中记录过滤原因。
|
||||
@@ -0,0 +1,44 @@
|
||||
# 安装与更新
|
||||
|
||||
## 前置要求
|
||||
|
||||
- Halo 2.23+
|
||||
- AI Foundation 插件(必须) — 本插件依赖 AI Foundation 提供的AI模型能力,请先安装并配置 AI Foundation
|
||||
|
||||
## 安装
|
||||
|
||||
### 方式一:从 Release 下载
|
||||
|
||||
1. 前往 [GitHub Releases](https://github.com/nxxy335/plugin-comment-ai-autopilot/releases) 下载最新的 `.jar` 文件
|
||||
2. 登录 Halo 管理后台
|
||||
3. 进入 **插件** → **已安装** → 点击右上角 **安装** 按钮
|
||||
4. 选择下载的 `.jar` 文件上传
|
||||
5. 安装完成后启用插件
|
||||
|
||||
### 方式二:从源码构建
|
||||
|
||||
```bash
|
||||
# 克隆仓库
|
||||
git clone https://github.com/nxxy335/plugin-comment-ai-autopilot.git
|
||||
cd plugin-comment-ai-autopilot
|
||||
|
||||
# 构建
|
||||
./gradlew build -x test
|
||||
|
||||
# 构建产物位于 build/libs/ 目录
|
||||
```
|
||||
|
||||
然后将生成的 `.jar` 文件通过 Halo 后台安装。
|
||||
|
||||
## 更新
|
||||
|
||||
1. 下载新版本的 `.jar` 文件
|
||||
2. 在 Halo 后台 **插件** 页面找到「AI回评」
|
||||
3. 点击插件卡片右上角的 **更多** → **更新**
|
||||
4. 选择新的 `.jar` 文件上传
|
||||
|
||||
## 卸载
|
||||
|
||||
1. 在 Halo 后台 **插件** 页面找到「AI回评」
|
||||
2. 先停用插件
|
||||
3. 点击 **更多** → **卸载**
|
||||
@@ -0,0 +1,40 @@
|
||||
# 插件介绍
|
||||
|
||||
AI回评(Comment AI Autopilot)是一个 Halo 博客系统的插件,能够自动为博客评论生成AI回复。
|
||||
|
||||
## 核心功能
|
||||
|
||||
- **自动回复** — 监听新评论,自动调用AI生成回复,支持多轮对话上下文
|
||||
- **情感分析** — 分析评论情感倾向(正面/中性/负面),根据情感调整回复语气
|
||||
- **草稿模式** — AI回复先存为草稿,管理员审核后再发布
|
||||
- **文章/页面级开关** — 在文章编辑器中直接控制是否启用AI回复,文章默认开启,页面默认关闭
|
||||
- **评论者黑名单** — 屏蔽指定评论者,不触发AI回复
|
||||
- **手动触发** — 在评论管理页面对历史评论手动触发AI回复
|
||||
- **AI角色** — 自定义AI回复者的昵称、人格提示词和Gravatar头像
|
||||
- **安全审核** — AI生成的内容经过安全审核,不合规内容自动拒绝
|
||||
- **AI Foundation 集成** — 必须安装 Halo AI Foundation 插件,使用其提供的AI模型能力
|
||||
|
||||
## 工作流程
|
||||
|
||||
```
|
||||
新评论 → 过滤检查 → 情感分析 → 构建Prompt → AI生成 → 安全审核 → 发布/草稿
|
||||
```
|
||||
|
||||
1. **新评论到达** — Reconciler 监听到新评论创建事件
|
||||
2. **过滤检查** — 检查文章/页面是否启用AI回复、评论者是否在黑名单中
|
||||
3. **情感分析** — 调用AI分析评论情感倾向
|
||||
4. **构建Prompt** — 结合AI角色人格、情感提示、文章内容、评论上下文构建Prompt
|
||||
5. **AI生成** — 调用AI模型生成回复内容
|
||||
6. **安全审核** — 对生成内容进行安全审核
|
||||
7. **发布/草稿** — 根据设置自动发布或存为草稿等待审核
|
||||
|
||||
## 前置要求
|
||||
|
||||
- Halo 2.23+
|
||||
- AI Foundation 插件(必须) — 本插件依赖 AI Foundation 提供的AI模型能力
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **后端**:Java + Spring WebFlux + Reactive
|
||||
- **前端**:Vue 3 + @halo-dev/components
|
||||
- **AI**:支持 AI Foundation 插件集成
|
||||
@@ -0,0 +1,39 @@
|
||||
# 手动触发
|
||||
|
||||
手动触发功能允许对历史评论手动触发AI回复,适用于以下场景:
|
||||
|
||||
- 安装插件前已存在的评论
|
||||
- 自动回复被过滤规则跳过的评论
|
||||
- AI生成失败需要重试的评论
|
||||
|
||||
## 使用方法
|
||||
|
||||
1. 进入 Halo 后台 **评论** 管理页面
|
||||
2. 找到需要触发AI回复的评论
|
||||
3. 点击评论右侧的 **更多(…)** 按钮
|
||||
4. 在下拉菜单中选择 **触发AI回复**
|
||||
5. 在确认对话框中点击 **确定**
|
||||
|
||||
::: warning
|
||||
如果该评论已有AI回复记录,触发将返回冲突提示,不会重复生成。
|
||||
:::
|
||||
|
||||
## API 接口
|
||||
|
||||
插件提供了以下手动触发API:
|
||||
|
||||
### 触发评论回复
|
||||
|
||||
```
|
||||
POST /apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/comments/{commentName}/trigger
|
||||
```
|
||||
|
||||
对指定评论触发首次AI回复。
|
||||
|
||||
### 触发对话回复
|
||||
|
||||
```
|
||||
POST /apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/{replyName}/trigger-conversation
|
||||
```
|
||||
|
||||
对指定回复触发对话式AI回复。
|
||||
@@ -0,0 +1,39 @@
|
||||
# AI角色
|
||||
|
||||
AI角色定义了回复评论的虚拟身份,包括昵称、人格和头像。
|
||||
|
||||
## 角色配置
|
||||
|
||||
### 昵称
|
||||
|
||||
AI回复者的显示名称,默认为「小回」。修改后新回复将使用新昵称,已有回复不受影响。
|
||||
|
||||
### 人格提示词
|
||||
|
||||
人格提示词定义了AI角色的性格和回复风格,是影响回复质量的关键配置。
|
||||
|
||||
**默认提示词:**
|
||||
|
||||
> 你是「小回」,一个友善的评论者。你的回复简洁自然,像朋友聊天一样。简短的评论就简短回复,有深度的讨论才展开回应。不要长篇大论,不要复述文章内容。
|
||||
|
||||
**自定义示例:**
|
||||
|
||||
```
|
||||
你是「小回」,一个博学多才的评论者。你的回复风格:
|
||||
- 对技术问题给出专业见解
|
||||
- 对生活感悟表达共鸣
|
||||
- 适当引用相关知识点
|
||||
- 保持谦逊友好的态度
|
||||
```
|
||||
|
||||
### 邮箱与头像
|
||||
|
||||
填写邮箱后,AI回复者的头像将通过 Gravatar 服务自动生成:
|
||||
|
||||
1. 插件根据邮箱生成 SHA-256 哈希
|
||||
2. 构造 Gravatar URL:`https://cn.cravatar.com/avatar/{hash}`
|
||||
3. 头像URL存储在评论的 `owner.annotations["avatar"]` 中
|
||||
|
||||
::: warning
|
||||
如果不填写邮箱,AI回复者将使用 Halo 默认头像。
|
||||
:::
|
||||
@@ -0,0 +1,61 @@
|
||||
# Prompt模板
|
||||
|
||||
Prompt模板控制AI生成回复时的完整提示词结构。
|
||||
|
||||
## 默认模板
|
||||
|
||||
```
|
||||
{{persona_prompt}}
|
||||
|
||||
{{safety_prompt}}
|
||||
|
||||
请回复以下评论。注意:
|
||||
- 回复长度应与评论长度匹配,简短问候简短回复
|
||||
- 不要复述或总结文章内容
|
||||
- 自然对话,不要写小作文
|
||||
- 只有评论涉及具体内容时才针对性回应
|
||||
|
||||
文章(仅供理解上下文,不要复述):
|
||||
{{article}}
|
||||
|
||||
评论:
|
||||
{{comment}}
|
||||
```
|
||||
|
||||
## 模板变量
|
||||
|
||||
| 变量 | 说明 | 注入时机 |
|
||||
|------|------|---------|
|
||||
| `{{persona_prompt}}` | AI角色人格提示词 | 始终注入 |
|
||||
| `{{safety_prompt}}` | 安全规范提示词 | 始终注入 |
|
||||
| `{{sentiment_prompt}}` | 情感语气提示词 | 情感分析后自动注入,不在模板中显式使用 |
|
||||
| `{{article}}` | 文章/页面内容 | 始终注入 |
|
||||
| `{{comment}}` | 评论内容 | 始终注入 |
|
||||
| `{{conversation}}` | 对话上下文 | 多轮对话时注入 |
|
||||
|
||||
## 情感提示
|
||||
|
||||
情感提示由插件根据情感分析结果自动注入到Prompt中,不需要在模板中手动添加:
|
||||
|
||||
- **正面** → "评论者情绪积极友好,请用热情友好的语气回复,表达感谢和共鸣。"
|
||||
- **负面** → "评论者情绪偏消极或不满,请用理性温和的语气回复,避免激化矛盾,适当表示理解。"
|
||||
- **中性** → 不注入额外提示
|
||||
|
||||
## 安全提示
|
||||
|
||||
安全提示词由插件内置,确保AI生成的内容符合规范:
|
||||
|
||||
- 不生成违法、有害、歧视性内容
|
||||
- 不泄露个人隐私信息
|
||||
- 不生成虚假信息
|
||||
- 回复内容与评论相关
|
||||
|
||||
## 自定义建议
|
||||
|
||||
自定义Prompt模板时,建议:
|
||||
|
||||
1. 保留 `{{persona_prompt}}` 和 `{{safety_prompt}}` 变量
|
||||
2. 保留 `{{article}}` 和 `{{comment}}` 变量
|
||||
3. 在变量之间添加清晰的分隔和指令
|
||||
4. 避免让AI复述文章内容
|
||||
5. 控制回复长度和风格
|
||||
@@ -0,0 +1,31 @@
|
||||
# 情感分析
|
||||
|
||||
情感分析功能会自动分析评论的情感倾向,并根据分析结果调整AI回复的语气。
|
||||
|
||||
## 情感分类
|
||||
|
||||
| 分类 | 说明 | AI回复语气 |
|
||||
|------|------|-----------|
|
||||
| 正面 | 评论情绪积极、友好、感谢 | 热情友好,表达感谢和共鸣 |
|
||||
| 中性 | 评论情绪平淡、普通提问 | 正常语气回复,不加额外提示 |
|
||||
| 负面 | 评论情绪偏消极、不满、批评 | 理性温和,避免激化矛盾 |
|
||||
|
||||
## 工作原理
|
||||
|
||||
1. 评论通过过滤检查后,调用AI对评论内容进行情感分析
|
||||
2. AI返回情感分类结果(POSITIVE / NEUTRAL / NEGATIVE)
|
||||
3. 如果情感分析失败(如AI不可用),默认降级为 NEUTRAL
|
||||
4. 情感结果传入 PromptBuilder,在生成Prompt时注入对应的语气提示
|
||||
5. 情感结果同时记录在 `AiCommentReply` 的 `sentiment` 字段中
|
||||
|
||||
## 日志展示
|
||||
|
||||
在AI回复日志页面,每条记录会显示情感标签:
|
||||
|
||||
- 🟢 **正面** — 绿色标签
|
||||
- ⚪ **中性** — 灰色标签
|
||||
- 🔴 **负面** — 红色标签
|
||||
|
||||
## 性能影响
|
||||
|
||||
情感分析会额外调用一次AI,如果对性能有顾虑,可以在代码中禁用此功能。
|
||||
@@ -0,0 +1,55 @@
|
||||
# 插件设置
|
||||
|
||||
插件设置页面位于 **AI回评** → **插件设置**,包含以下配置组:
|
||||
|
||||
## 基本设置
|
||||
|
||||
| 配置项 | 说明 | 默认值 |
|
||||
|--------|------|--------|
|
||||
| 自动回复 | 是否启用自动回复功能 | 开启 |
|
||||
| 自动发布 | AI回复是否自动发布 | 开启 |
|
||||
| 最大重试次数 | AI生成失败时的最大重试次数 | 3 |
|
||||
| 评论者黑名单 | 不触发AI回复的评论者显示名称,逗号分隔 | 空 |
|
||||
|
||||
## AI角色设置
|
||||
|
||||
| 配置项 | 说明 | 默认值 |
|
||||
|--------|------|--------|
|
||||
| AI角色昵称 | AI回复者的显示名称 | 小回 |
|
||||
| AI角色人格提示词 | 定义AI角色的人格和回复风格 | 见下方 |
|
||||
| AI角色邮箱 | 用于Gravatar头像服务展示头像 | 空 |
|
||||
|
||||
默认人格提示词:
|
||||
|
||||
> 你是「小回」,一个友善的评论者。你的回复简洁自然,像朋友聊天一样。简短的评论就简短回复,有深度的讨论才展开回应。不要长篇大论,不要复述文章内容。
|
||||
|
||||
::: tip Gravatar头像
|
||||
填写邮箱后,AI回复者的头像将通过 [Gravatar](https://gravatar.com) 服务自动生成。如果不填写邮箱,将使用默认头像。
|
||||
:::
|
||||
|
||||
## 模型设置
|
||||
|
||||
| 配置项 | 说明 | 默认值 |
|
||||
|--------|------|--------|
|
||||
| AI模型名称 | 留空使用AI Foundation默认模型,填写AiModel资源名称可指定模型 | 空 |
|
||||
|
||||
::: warning
|
||||
模型设置需要先安装 AI Foundation 插件。AI Foundation 是本插件的必要依赖,请确保已正确安装和配置。
|
||||
:::
|
||||
|
||||
## Prompt设置
|
||||
|
||||
| 配置项 | 说明 | 默认值 |
|
||||
|--------|------|--------|
|
||||
| 自定义Prompt模板 | AI生成回复时使用的Prompt模板 | 见下方 |
|
||||
|
||||
### 模板变量
|
||||
|
||||
| 变量 | 说明 |
|
||||
|------|------|
|
||||
| `{{persona_prompt}}` | AI角色人格提示词 |
|
||||
| `{{safety_prompt}}` | 安全规范提示词 |
|
||||
| `{{sentiment_prompt}}` | 情感语气提示词(自动注入) |
|
||||
| `{{article}}` | 文章内容 |
|
||||
| `{{comment}}` | 评论内容 |
|
||||
| `{{conversation}}` | 对话上下文(多轮对话时) |
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
layout: home
|
||||
|
||||
hero:
|
||||
name: AI回评
|
||||
text: Halo 智能评论回复插件
|
||||
tagline: 自动分析评论情感,生成自然、贴切的AI回复
|
||||
actions:
|
||||
- theme: brand
|
||||
text: 开始使用
|
||||
link: /guide/introduction
|
||||
- theme: alt
|
||||
text: 配置指南
|
||||
link: /guide/settings
|
||||
|
||||
features:
|
||||
- title: 自动回复
|
||||
details: 监听新评论,自动调用AI生成回复,支持对话式上下文
|
||||
- title: 情感分析
|
||||
details: 分析评论情感倾向,根据正面/中性/负面调整回复语气
|
||||
- title: 草稿模式
|
||||
details: AI回复先存为草稿,管理员审核后再发布
|
||||
- title: 灵活过滤
|
||||
details: 文章/页面级开关控制,评论者黑名单
|
||||
---
|
||||
Generated
+2553
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "docs",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"docs:dev": "vitepress dev",
|
||||
"docs:build": "vitepress build",
|
||||
"docs:preview": "vitepress preview"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"vitepress": "^1.6.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
version=1.0.0-SNAPSHOT
|
||||
Vendored
BIN
Binary file not shown.
+7
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.0-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
@@ -0,0 +1,248 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/b631911858264c0b6e4d6603d677ff5218766cee/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
Vendored
+93
@@ -0,0 +1,93 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
Binary file not shown.
@@ -0,0 +1,7 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
rootProject.name = 'plugin-comment-ai-autopilot'
|
||||
include 'ui'
|
||||
@@ -0,0 +1,45 @@
|
||||
package top.nxxy335.commentaiautopilot;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
import run.halo.app.extension.index.IndexSpecs;
|
||||
import run.halo.app.extension.Scheme;
|
||||
import run.halo.app.extension.SchemeManager;
|
||||
import run.halo.app.plugin.BasePlugin;
|
||||
import run.halo.app.plugin.PluginContext;
|
||||
import top.nxxy335.commentaiautopilot.extension.AiCommentReply;
|
||||
|
||||
/**
|
||||
* <p>Plugin main class to manage the lifecycle of the plugin.</p>
|
||||
* <p>This class must be public and have a public constructor.</p>
|
||||
* <p>Only one main class extending {@link BasePlugin} is allowed per plugin.</p>
|
||||
*
|
||||
* @author 暖心向阳335
|
||||
* @since 1.0.0
|
||||
*/
|
||||
@Component
|
||||
public class CommentAiAutopilotPlugin extends BasePlugin {
|
||||
|
||||
private final SchemeManager schemeManager;
|
||||
|
||||
public CommentAiAutopilotPlugin(PluginContext pluginContext, SchemeManager schemeManager) {
|
||||
super(pluginContext);
|
||||
this.schemeManager = schemeManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
schemeManager.register(AiCommentReply.class, indexSpecs -> {
|
||||
indexSpecs.add(IndexSpecs.<AiCommentReply, String>single("spec.commentId", String.class)
|
||||
.indexFunc(ext -> ext.getSpec().getCommentId()));
|
||||
indexSpecs.add(IndexSpecs.<AiCommentReply, String>single("spec.postId", String.class)
|
||||
.indexFunc(ext -> ext.getSpec().getPostId()));
|
||||
indexSpecs.add(IndexSpecs.<AiCommentReply, String>single("spec.status", String.class)
|
||||
.indexFunc(ext -> ext.getSpec().getStatus()));
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
schemeManager.unregister(Scheme.buildFromType(AiCommentReply.class));
|
||||
}
|
||||
}
|
||||
+498
@@ -0,0 +1,498 @@
|
||||
package top.nxxy335.commentaiautopilot.endpoint;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.reactive.function.server.RouterFunction;
|
||||
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.core.extension.content.Comment;
|
||||
import run.halo.app.core.extension.content.Reply;
|
||||
import run.halo.app.core.extension.endpoint.CustomEndpoint;
|
||||
import run.halo.app.extension.ConfigMap;
|
||||
import run.halo.app.extension.GroupVersion;
|
||||
import run.halo.app.extension.ListOptions;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.extension.PageRequestImpl;
|
||||
import top.nxxy335.commentaiautopilot.extension.AiCommentReply;
|
||||
import top.nxxy335.commentaiautopilot.service.AiReplyOrchestrator;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.data.domain.Sort;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.springframework.web.reactive.function.server.RouterFunctions.route;
|
||||
|
||||
@Component
|
||||
@Slf4j
|
||||
public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
||||
|
||||
private final ReactiveExtensionClient client;
|
||||
private final AiReplyOrchestrator orchestrator;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
private static final String CONFIG_MAP_NAME = "comment-ai-autopilot-configmap";
|
||||
|
||||
public CommentAiAutopilotEndpoint(ReactiveExtensionClient client, AiReplyOrchestrator orchestrator) {
|
||||
this.client = client;
|
||||
this.orchestrator = orchestrator;
|
||||
this.objectMapper = new ObjectMapper();
|
||||
}
|
||||
|
||||
@Override
|
||||
public RouterFunction<ServerResponse> endpoint() {
|
||||
return route()
|
||||
.GET("/replies", this::listReplies)
|
||||
.POST("/replies/batch-approve", this::batchApproveReplies)
|
||||
.POST("/replies/batch-reject", this::batchRejectReplies)
|
||||
.POST("/replies/batch-delete", this::batchDeleteReplies)
|
||||
.DELETE("/replies/{name}", this::deleteReply)
|
||||
.GET("/stats", this::getStats)
|
||||
.GET("/persona", this::getPersona)
|
||||
.GET("/conversation/{commentName}", this::getConversation)
|
||||
.POST("/replies/{name}/approve", this::approveReply)
|
||||
.POST("/replies/{name}/reject", this::rejectReply)
|
||||
.POST("/comments/{commentName}/trigger", this::triggerReply)
|
||||
.POST("/replies/{replyName}/trigger-conversation", this::triggerConversationReply)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public GroupVersion groupVersion() {
|
||||
return new GroupVersion("console.api.comment-ai-autopilot.nxxy335.top", "v1alpha1");
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> listReplies(ServerRequest request) {
|
||||
var page = Integer.parseInt(request.queryParam("page").orElse("1"));
|
||||
var size = Integer.parseInt(request.queryParam("size").orElse("20"));
|
||||
var sort = Sort.by(Sort.Order.desc("metadata.creationTimestamp"));
|
||||
var pageable = PageRequestImpl.of(page, size, sort);
|
||||
|
||||
return client.listBy(AiCommentReply.class, ListOptions.builder().build(), pageable)
|
||||
.flatMap(result -> ServerResponse.ok().bodyValue(result));
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> deleteReply(ServerRequest request) {
|
||||
var name = request.pathVariable("name");
|
||||
return client.fetch(AiCommentReply.class, name)
|
||||
.flatMap(record -> client.delete(record))
|
||||
.then(ServerResponse.ok().bodyValue("{\"message\":\"deleted\"}"))
|
||||
.switchIfEmpty(ServerResponse.notFound().build());
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> getStats(ServerRequest request) {
|
||||
return client.listAll(AiCommentReply.class, ListOptions.builder().build(), Sort.unsorted())
|
||||
.collectList()
|
||||
.map(replies -> {
|
||||
long total = replies.size();
|
||||
long passCount = replies.stream()
|
||||
.filter(r -> "PASS".equals(r.getSpec().getStatus())).count();
|
||||
long failCount = replies.stream()
|
||||
.filter(r -> "FAIL".equals(r.getSpec().getStatus())).count();
|
||||
double avgScore = replies.stream()
|
||||
.filter(r -> r.getSpec().getScore() != null && r.getSpec().getScore() > 0)
|
||||
.mapToInt(r -> r.getSpec().getScore())
|
||||
.average().orElse(0.0);
|
||||
|
||||
long reviewingCount = replies.stream()
|
||||
.filter(r -> "PASS".equals(r.getSpec().getStatus())
|
||||
&& !Boolean.TRUE.equals(r.getSpec().getPublished()))
|
||||
.count();
|
||||
|
||||
Map<String, Long> sentimentDistribution = new HashMap<>();
|
||||
sentimentDistribution.put("POSITIVE", 0L);
|
||||
sentimentDistribution.put("NEUTRAL", 0L);
|
||||
sentimentDistribution.put("NEGATIVE", 0L);
|
||||
sentimentDistribution.put("UNKNOWN", 0L);
|
||||
for (var r : replies) {
|
||||
String sentiment = r.getSpec().getSentiment();
|
||||
if (sentiment == null || sentiment.isBlank()) {
|
||||
sentimentDistribution.merge("UNKNOWN", 1L, Long::sum);
|
||||
} else {
|
||||
sentimentDistribution.merge(sentiment, 1L, Long::sum);
|
||||
}
|
||||
}
|
||||
|
||||
ZoneId zoneId = ZoneId.systemDefault();
|
||||
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||
LocalDate today = LocalDate.now(zoneId);
|
||||
Map<LocalDate, Long> dailyMap = new HashMap<>();
|
||||
for (int i = 0; i < 7; i++) {
|
||||
dailyMap.put(today.minusDays(i), 0L);
|
||||
}
|
||||
for (var r : replies) {
|
||||
Instant timestamp = r.getMetadata().getCreationTimestamp();
|
||||
if (timestamp != null) {
|
||||
try {
|
||||
LocalDate date = timestamp.atZone(zoneId).toLocalDate();
|
||||
if (dailyMap.containsKey(date)) {
|
||||
dailyMap.merge(date, 1L, Long::sum);
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
List<DailyCount> dailyTrend = new ArrayList<>();
|
||||
for (int i = 0; i < 7; i++) {
|
||||
LocalDate date = today.minusDays(i);
|
||||
dailyTrend.add(new DailyCount(date.format(formatter), dailyMap.get(date)));
|
||||
}
|
||||
|
||||
return new StatsResponse(total, passCount, failCount, avgScore,
|
||||
reviewingCount, sentimentDistribution, dailyTrend);
|
||||
})
|
||||
.onErrorResume(e -> {
|
||||
log.warn("Failed to fetch stats: {}", e.getMessage());
|
||||
return Mono.just(new StatsResponse(0, 0, 0, 0.0, 0L,
|
||||
Map.of("POSITIVE", 0L, "NEUTRAL", 0L, "NEGATIVE", 0L, "UNKNOWN", 0L),
|
||||
List.of()));
|
||||
})
|
||||
.flatMap(stats -> ServerResponse.ok().bodyValue(stats));
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> getPersona(ServerRequest request) {
|
||||
return client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
|
||||
.mapNotNull(cm -> {
|
||||
var data = cm.getData();
|
||||
if (data == null) return new PersonaResponse("小回", "", "");
|
||||
String personaJson = data.get("persona");
|
||||
if (personaJson == null || personaJson.isBlank()) return new PersonaResponse("小回", "", "");
|
||||
try {
|
||||
JsonNode node = objectMapper.readTree(personaJson);
|
||||
String name = node.has("personaName") ? node.get("personaName").asText("小回") : "小回";
|
||||
String prompt = node.has("personaPrompt") ? node.get("personaPrompt").asText("") : "";
|
||||
String email = node.has("personaEmail") ? node.get("personaEmail").asText("") : "";
|
||||
return new PersonaResponse(name, prompt, email);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to parse persona config: {}", e.getMessage());
|
||||
return new PersonaResponse("小回", "", "");
|
||||
}
|
||||
})
|
||||
.defaultIfEmpty(new PersonaResponse("小回", "", ""))
|
||||
.onErrorResume(e -> {
|
||||
log.warn("Failed to fetch persona settings: {}", e.getMessage());
|
||||
return Mono.just(new PersonaResponse("小回", "", ""));
|
||||
})
|
||||
.flatMap(persona -> ServerResponse.ok().bodyValue(persona));
|
||||
}
|
||||
|
||||
public record DailyCount(String date, long count) {}
|
||||
|
||||
public record StatsResponse(
|
||||
long total,
|
||||
long passCount,
|
||||
long failCount,
|
||||
double avgScore,
|
||||
long reviewingCount,
|
||||
Map<String, Long> sentimentDistribution,
|
||||
List<DailyCount> dailyTrend
|
||||
) {}
|
||||
|
||||
public record PersonaResponse(
|
||||
String name,
|
||||
String prompt,
|
||||
String avatar
|
||||
) {}
|
||||
|
||||
private Mono<ServerResponse> getConversation(ServerRequest request) {
|
||||
var commentName = request.pathVariable("commentName");
|
||||
return client.fetch(Comment.class, commentName)
|
||||
.flatMap(comment -> {
|
||||
var commentOwner = extractOwnerName(comment.getSpec().getOwner());
|
||||
var commentContent = extractContent(comment.getSpec().getRaw(), comment.getSpec().getContent());
|
||||
var commentTime = String.valueOf(comment.getMetadata().getCreationTimestamp());
|
||||
var isCommentAi = isAiOwner(comment.getSpec().getOwner());
|
||||
|
||||
var commentMsg = new ConversationMessage(
|
||||
"comment", commentOwner, commentContent, commentTime, isCommentAi
|
||||
);
|
||||
|
||||
return client.listAll(Reply.class, ListOptions.builder().build(), Sort.unsorted())
|
||||
.filter(reply -> commentName.equals(reply.getSpec().getCommentName()))
|
||||
.sort(Comparator.comparing(r -> r.getMetadata().getCreationTimestamp()))
|
||||
.map(reply -> {
|
||||
var replyOwner = extractOwnerName(reply.getSpec().getOwner());
|
||||
var replyContent = extractContent(reply.getSpec().getRaw(), reply.getSpec().getContent());
|
||||
var replyTime = String.valueOf(reply.getMetadata().getCreationTimestamp());
|
||||
var isAi = isAiOwner(reply.getSpec().getOwner());
|
||||
return new ConversationMessage("reply", replyOwner, replyContent, replyTime, isAi);
|
||||
})
|
||||
.collectList()
|
||||
.map(replyList -> {
|
||||
List<ConversationMessage> messages = new ArrayList<>();
|
||||
messages.add(commentMsg);
|
||||
messages.addAll(replyList);
|
||||
return messages;
|
||||
});
|
||||
})
|
||||
.flatMap(messages -> ServerResponse.ok().bodyValue(Map.of("messages", messages)))
|
||||
.switchIfEmpty(ServerResponse.ok().bodyValue(Map.of("messages", List.of())));
|
||||
}
|
||||
|
||||
private String extractOwnerName(Comment.CommentOwner owner) {
|
||||
if (owner == null) return "匿名用户";
|
||||
var displayName = owner.getDisplayName();
|
||||
return (displayName != null && !displayName.isBlank()) ? displayName : "匿名用户";
|
||||
}
|
||||
|
||||
private String extractContent(String raw, String content) {
|
||||
if (raw != null && !raw.isBlank()) return raw;
|
||||
if (content != null && !content.isBlank()) return content;
|
||||
return "";
|
||||
}
|
||||
|
||||
private boolean isAiOwner(Comment.CommentOwner owner) {
|
||||
if (owner == null) return false;
|
||||
var annotations = owner.getAnnotations();
|
||||
if (annotations != null) {
|
||||
return "true".equals(annotations.get("comment-ai-autopilot.nxxy335.top/is-ai"));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> approveReply(ServerRequest request) {
|
||||
var name = request.pathVariable("name");
|
||||
return client.fetch(AiCommentReply.class, name)
|
||||
.flatMap(record -> {
|
||||
// Find the corresponding Reply and set approved=true
|
||||
return findReplyForRecord(record)
|
||||
.flatMap(reply -> {
|
||||
reply.getSpec().setApproved(true);
|
||||
reply.getSpec().setApprovedTime(Instant.now());
|
||||
return client.update(reply);
|
||||
})
|
||||
.then(Mono.defer(() -> {
|
||||
// Update AiCommentReply record
|
||||
return client.fetch(AiCommentReply.class, name)
|
||||
.flatMap(latest -> {
|
||||
latest.getSpec().setPublished(true);
|
||||
return client.update(latest);
|
||||
});
|
||||
}))
|
||||
.then(ServerResponse.ok().bodyValue(Map.of("message", "approved")));
|
||||
})
|
||||
.switchIfEmpty(ServerResponse.notFound().build());
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> rejectReply(ServerRequest request) {
|
||||
var name = request.pathVariable("name");
|
||||
return client.fetch(AiCommentReply.class, name)
|
||||
.flatMap(record -> {
|
||||
// Delete the draft Reply if it exists
|
||||
return findReplyForRecord(record)
|
||||
.flatMap(reply -> client.delete(reply))
|
||||
.then(Mono.defer(() -> {
|
||||
// Update AiCommentReply record status to REJECTED
|
||||
return client.fetch(AiCommentReply.class, name)
|
||||
.flatMap(latest -> {
|
||||
latest.getSpec().setStatus("REJECTED");
|
||||
latest.getSpec().setPublished(false);
|
||||
return client.update(latest);
|
||||
});
|
||||
}))
|
||||
.then(ServerResponse.ok().bodyValue(Map.of("message", "rejected")));
|
||||
})
|
||||
.switchIfEmpty(ServerResponse.notFound().build());
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> batchApproveReplies(ServerRequest request) {
|
||||
return request.bodyToMono(String.class)
|
||||
.flatMap(body -> {
|
||||
List<String> names;
|
||||
try {
|
||||
JsonNode node = objectMapper.readTree(body);
|
||||
names = new ArrayList<>();
|
||||
node.get("names").forEach(n -> names.add(n.asText()));
|
||||
} catch (Exception e) {
|
||||
return ServerResponse.badRequest()
|
||||
.bodyValue(Map.of("successCount", 0, "failCount", 0));
|
||||
}
|
||||
return Flux.fromIterable(names)
|
||||
.flatMap(name ->
|
||||
client.fetch(AiCommentReply.class, name)
|
||||
.flatMap(record -> findReplyForRecord(record)
|
||||
.flatMap(reply -> {
|
||||
reply.getSpec().setApproved(true);
|
||||
reply.getSpec().setApprovedTime(Instant.now());
|
||||
return client.update(reply);
|
||||
})
|
||||
.then(Mono.defer(() -> client.fetch(AiCommentReply.class, name)
|
||||
.flatMap(latest -> {
|
||||
latest.getSpec().setPublished(true);
|
||||
return client.update(latest);
|
||||
})))
|
||||
.thenReturn(true)
|
||||
)
|
||||
.onErrorResume(e -> {
|
||||
log.warn("Batch approve failed for {}: {}", name, e.getMessage());
|
||||
return Mono.just(false);
|
||||
})
|
||||
.defaultIfEmpty(false)
|
||||
)
|
||||
.collectList()
|
||||
.flatMap(results -> {
|
||||
long successCount = results.stream().filter(b -> b).count();
|
||||
long failCount = results.size() - successCount;
|
||||
return ServerResponse.ok()
|
||||
.bodyValue(Map.of("successCount", successCount, "failCount", failCount));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> batchRejectReplies(ServerRequest request) {
|
||||
return request.bodyToMono(String.class)
|
||||
.flatMap(body -> {
|
||||
List<String> names;
|
||||
try {
|
||||
JsonNode node = objectMapper.readTree(body);
|
||||
names = new ArrayList<>();
|
||||
node.get("names").forEach(n -> names.add(n.asText()));
|
||||
} catch (Exception e) {
|
||||
return ServerResponse.badRequest()
|
||||
.bodyValue(Map.of("successCount", 0, "failCount", 0));
|
||||
}
|
||||
return Flux.fromIterable(names)
|
||||
.flatMap(name ->
|
||||
client.fetch(AiCommentReply.class, name)
|
||||
.flatMap(record -> findReplyForRecord(record)
|
||||
.flatMap(reply -> client.delete(reply))
|
||||
.then(Mono.defer(() -> client.fetch(AiCommentReply.class, name)
|
||||
.flatMap(latest -> {
|
||||
latest.getSpec().setStatus("REJECTED");
|
||||
latest.getSpec().setPublished(false);
|
||||
return client.update(latest);
|
||||
})))
|
||||
.thenReturn(true)
|
||||
)
|
||||
.onErrorResume(e -> {
|
||||
log.warn("Batch reject failed for {}: {}", name, e.getMessage());
|
||||
return Mono.just(false);
|
||||
})
|
||||
.defaultIfEmpty(false)
|
||||
)
|
||||
.collectList()
|
||||
.flatMap(results -> {
|
||||
long successCount = results.stream().filter(b -> b).count();
|
||||
long failCount = results.size() - successCount;
|
||||
return ServerResponse.ok()
|
||||
.bodyValue(Map.of("successCount", successCount, "failCount", failCount));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> batchDeleteReplies(ServerRequest request) {
|
||||
return request.bodyToMono(String.class)
|
||||
.flatMap(body -> {
|
||||
List<String> names;
|
||||
try {
|
||||
JsonNode node = objectMapper.readTree(body);
|
||||
names = new ArrayList<>();
|
||||
node.get("names").forEach(n -> names.add(n.asText()));
|
||||
} catch (Exception e) {
|
||||
return ServerResponse.badRequest()
|
||||
.bodyValue(Map.of("successCount", 0, "failCount", 0));
|
||||
}
|
||||
return Flux.fromIterable(names)
|
||||
.flatMap(name ->
|
||||
client.fetch(AiCommentReply.class, name)
|
||||
.flatMap(record -> client.delete(record)
|
||||
.thenReturn(true)
|
||||
)
|
||||
.onErrorResume(e -> {
|
||||
log.warn("Batch delete failed for {}: {}", name, e.getMessage());
|
||||
return Mono.just(false);
|
||||
})
|
||||
.defaultIfEmpty(false)
|
||||
)
|
||||
.collectList()
|
||||
.flatMap(results -> {
|
||||
long successCount = results.stream().filter(b -> b).count();
|
||||
long failCount = results.size() - successCount;
|
||||
return ServerResponse.ok()
|
||||
.bodyValue(Map.of("successCount", successCount, "failCount", failCount));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> triggerReply(ServerRequest request) {
|
||||
var commentName = request.pathVariable("commentName");
|
||||
|
||||
// Check if there's already an AiCommentReply record for this comment
|
||||
return client.list(AiCommentReply.class,
|
||||
record -> commentName.equals(record.getSpec().getCommentId())
|
||||
&& !Boolean.TRUE.equals(record.getSpec().getIsAiConversation()),
|
||||
null)
|
||||
.hasElements()
|
||||
.flatMap(hasExisting -> {
|
||||
if (hasExisting) {
|
||||
return ServerResponse.badRequest()
|
||||
.bodyValue(Map.of("message", "该评论已有AI回复记录"));
|
||||
}
|
||||
// Trigger the orchestrator
|
||||
return orchestrator.processComment(commentName, null, false)
|
||||
.then(ServerResponse.ok().bodyValue(Map.of("message", "已触发AI回复")));
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> triggerConversationReply(ServerRequest request) {
|
||||
var replyName = request.pathVariable("replyName");
|
||||
|
||||
// First fetch the reply to get its parent comment name
|
||||
return client.fetch(Reply.class, replyName)
|
||||
.flatMap(reply -> {
|
||||
var commentName = reply.getSpec().getCommentName();
|
||||
|
||||
// Check if there's already an AiCommentReply record for this conversation
|
||||
return client.list(AiCommentReply.class,
|
||||
record -> replyName.equals(record.getSpec().getReplyTo())
|
||||
&& Boolean.TRUE.equals(record.getSpec().getIsAiConversation()),
|
||||
null)
|
||||
.hasElements()
|
||||
.flatMap(hasExisting -> {
|
||||
if (hasExisting) {
|
||||
return ServerResponse.badRequest()
|
||||
.bodyValue(Map.of("message", "该回复已有AI对话记录"));
|
||||
}
|
||||
return orchestrator.processComment(commentName, replyName, true)
|
||||
.then(ServerResponse.ok().bodyValue(Map.of("message", "已触发AI对话回复")));
|
||||
});
|
||||
})
|
||||
.switchIfEmpty(ServerResponse.notFound().build());
|
||||
}
|
||||
|
||||
private Mono<Reply> findReplyForRecord(AiCommentReply record) {
|
||||
// Find the Reply that belongs to the same comment and was created by AI
|
||||
return client.list(Reply.class,
|
||||
reply -> {
|
||||
if (!record.getSpec().getCommentId().equals(reply.getSpec().getCommentName())) {
|
||||
return false;
|
||||
}
|
||||
var owner = reply.getSpec().getOwner();
|
||||
if (owner == null) return false;
|
||||
var annotations = owner.getAnnotations();
|
||||
return annotations != null && "true".equals(annotations.get("comment-ai-autopilot.nxxy335.top/is-ai"));
|
||||
},
|
||||
null)
|
||||
.next()
|
||||
.switchIfEmpty(Mono.empty());
|
||||
}
|
||||
|
||||
public record ConversationMessage(
|
||||
String type,
|
||||
String owner,
|
||||
String content,
|
||||
String time,
|
||||
boolean isAi
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package top.nxxy335.commentaiautopilot.extension;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import run.halo.app.extension.AbstractExtension;
|
||||
import run.halo.app.extension.GVK;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@GVK(
|
||||
group = "comment-ai-autopilot.nxxy335.top",
|
||||
version = "v1alpha1",
|
||||
kind = "AiCommentReply",
|
||||
plural = "aicommentreplies",
|
||||
singular = "aicommentreply"
|
||||
)
|
||||
public class AiCommentReply extends AbstractExtension {
|
||||
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Spec spec;
|
||||
|
||||
@Data
|
||||
@Schema(name = "AiCommentReplySpec")
|
||||
public static class Spec {
|
||||
|
||||
@Schema(description = "关联评论ID")
|
||||
private String commentId;
|
||||
|
||||
@Schema(description = "关联文章ID")
|
||||
private String postId;
|
||||
|
||||
@Schema(description = "关联文章Slug,用于生成文章链接")
|
||||
private String postSlug;
|
||||
|
||||
@Schema(description = "AI回复内容")
|
||||
private String reply;
|
||||
|
||||
@Schema(description = "审核评分")
|
||||
private Integer score;
|
||||
|
||||
@Schema(description = "状态: PENDING/REVIEWING/PASS/FAIL")
|
||||
private String status;
|
||||
|
||||
@Schema(description = "重试次数")
|
||||
private Integer retryCount;
|
||||
|
||||
@Schema(description = "回复目标的评论ID")
|
||||
private String replyTo;
|
||||
|
||||
@Schema(description = "是否为AI对话中的回复")
|
||||
private Boolean isAiConversation;
|
||||
|
||||
@Schema(description = "是否已发布回复")
|
||||
private Boolean published;
|
||||
|
||||
@Schema(description = "评论情感倾向: POSITIVE/NEUTRAL/NEGATIVE")
|
||||
private String sentiment;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package top.nxxy335.commentaiautopilot.listener;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
import run.halo.app.core.extension.content.Comment;
|
||||
import run.halo.app.extension.ExtensionClient;
|
||||
import run.halo.app.extension.controller.Controller;
|
||||
import run.halo.app.extension.controller.ControllerBuilder;
|
||||
import run.halo.app.extension.controller.Reconciler;
|
||||
import top.nxxy335.commentaiautopilot.extension.AiCommentReply;
|
||||
import top.nxxy335.commentaiautopilot.service.AiReplyOrchestrator;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
@Component
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class CommentReconciler implements Reconciler<Reconciler.Request> {
|
||||
|
||||
private final ExtensionClient client;
|
||||
private final AiReplyOrchestrator orchestrator;
|
||||
|
||||
private static final String PROCESSED_ANNOTATION = "comment-ai-autopilot.nxxy335.top/processed";
|
||||
private static final String AI_MARKER_ANNOTATION = "comment-ai-autopilot.nxxy335.top/is-ai";
|
||||
private static final String AI_PERSONA_OWNER_PREFIX = "ai-persona-";
|
||||
|
||||
// Record the time when this bean was created (plugin startup time)
|
||||
private final Instant pluginStartTime = Instant.now();
|
||||
|
||||
// In-memory dedup lock: prevents the same comment from being processed multiple times
|
||||
// even if reconcile is triggered concurrently
|
||||
private final ConcurrentHashMap<String, Boolean> processingLocks = new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
public Result reconcile(Request request) {
|
||||
var name = request.name();
|
||||
|
||||
// Acquire lock at the very beginning to prevent any concurrent processing
|
||||
if (processingLocks.putIfAbsent(name, Boolean.TRUE) != null) {
|
||||
log.debug("[CommentReconciler] Already processing comment: {}, skipping", name);
|
||||
return Result.doNotRetry();
|
||||
}
|
||||
|
||||
AtomicBoolean asyncStarted = new AtomicBoolean(false);
|
||||
try {
|
||||
client.fetch(Comment.class, name).ifPresent(comment -> {
|
||||
if (isProcessed(comment.getMetadata().getAnnotations())) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip comments created before plugin startup (historical comments)
|
||||
var creationTime = comment.getMetadata().getCreationTimestamp();
|
||||
if (creationTime != null && creationTime.isBefore(pluginStartTime)) {
|
||||
log.debug("[CommentReconciler] Skipping historical comment: {} (created before plugin startup)", name);
|
||||
markProcessed(comment);
|
||||
client.update(comment);
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip comments from AI persona itself
|
||||
if (isAiComment(comment)) {
|
||||
markProcessed(comment);
|
||||
client.update(comment);
|
||||
return;
|
||||
}
|
||||
|
||||
// Dedup: check if we already have an AiCommentReply record for this comment
|
||||
boolean alreadyHasRecord = !client.list(AiCommentReply.class,
|
||||
record -> name.equals(record.getSpec().getCommentId())
|
||||
&& !Boolean.TRUE.equals(record.getSpec().getIsAiConversation()),
|
||||
null)
|
||||
.isEmpty();
|
||||
|
||||
if (alreadyHasRecord) {
|
||||
log.debug("[CommentReconciler] Already have AiCommentReply record for: {}, skipping", name);
|
||||
markProcessed(comment);
|
||||
client.update(comment);
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark as processed first to avoid re-processing
|
||||
markProcessed(comment);
|
||||
client.update(comment);
|
||||
|
||||
// Top-level comment → always trigger AI reply
|
||||
log.info("[CommentReconciler] New top-level comment detected: {}", name);
|
||||
asyncStarted.set(true);
|
||||
orchestrator.processComment(name, null, false)
|
||||
.subscribeOn(Schedulers.boundedElastic())
|
||||
.doFinally(signal -> {
|
||||
processingLocks.remove(name);
|
||||
log.debug("[CommentReconciler] Released processing lock for: {}", name);
|
||||
})
|
||||
.subscribe(
|
||||
null,
|
||||
e -> log.error("[CommentReconciler] Error processing comment {}: {}", name, e.getMessage(), e),
|
||||
() -> log.info("[CommentReconciler] Processing completed for comment: {}", name)
|
||||
);
|
||||
});
|
||||
} catch (Exception e) {
|
||||
log.error("[CommentReconciler] Error in reconcile for {}: {}", name, e.getMessage(), e);
|
||||
} finally {
|
||||
// Only release lock here if async processing was NOT started
|
||||
// (async path releases lock in doFinally)
|
||||
if (!asyncStarted.get()) {
|
||||
processingLocks.remove(name);
|
||||
}
|
||||
}
|
||||
|
||||
return Result.doNotRetry();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a comment is from AI persona.
|
||||
*/
|
||||
private boolean isAiComment(Comment comment) {
|
||||
var owner = comment.getSpec().getOwner();
|
||||
if (owner != null && owner.getName() != null
|
||||
&& owner.getName().startsWith(AI_PERSONA_OWNER_PREFIX)) {
|
||||
return true;
|
||||
}
|
||||
if (owner != null && owner.getAnnotations() != null
|
||||
&& "true".equals(owner.getAnnotations().get(AI_MARKER_ANNOTATION))) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean isProcessed(Map<String, String> annotations) {
|
||||
return annotations != null && "true".equals(annotations.get(PROCESSED_ANNOTATION));
|
||||
}
|
||||
|
||||
private void markProcessed(Comment comment) {
|
||||
var annotations = comment.getMetadata().getAnnotations();
|
||||
if (annotations == null) {
|
||||
annotations = new HashMap<>();
|
||||
comment.getMetadata().setAnnotations(annotations);
|
||||
}
|
||||
annotations.put(PROCESSED_ANNOTATION, "true");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Controller setupWith(ControllerBuilder builder) {
|
||||
return builder
|
||||
.extension(new Comment())
|
||||
.syncAllOnStart(false)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package top.nxxy335.commentaiautopilot.listener;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
import run.halo.app.core.extension.content.Reply;
|
||||
import run.halo.app.extension.ExtensionClient;
|
||||
import run.halo.app.extension.controller.Controller;
|
||||
import run.halo.app.extension.controller.ControllerBuilder;
|
||||
import run.halo.app.extension.controller.Reconciler;
|
||||
import top.nxxy335.commentaiautopilot.extension.AiCommentReply;
|
||||
import top.nxxy335.commentaiautopilot.service.AiReplyOrchestrator;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class ReplyReconciler implements Reconciler<Reconciler.Request> {
|
||||
|
||||
private final ExtensionClient client;
|
||||
private final AiReplyOrchestrator orchestrator;
|
||||
|
||||
private static final String PROCESSED_ANNOTATION = "comment-ai-autopilot.nxxy335.top/processed";
|
||||
private static final String AI_PERSONA_OWNER_PREFIX = "ai-persona-";
|
||||
private static final String AI_MARKER_ANNOTATION = "comment-ai-autopilot.nxxy335.top/is-ai";
|
||||
|
||||
// Record the time when this bean was created (plugin startup time)
|
||||
private final Instant pluginStartTime = Instant.now();
|
||||
|
||||
@Override
|
||||
public Result reconcile(Request request) {
|
||||
var name = request.name();
|
||||
|
||||
client.fetch(Reply.class, name).ifPresent(reply -> {
|
||||
if (isProcessed(reply.getMetadata().getAnnotations())) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip replies created before plugin startup (historical replies)
|
||||
var creationTime = reply.getMetadata().getCreationTimestamp();
|
||||
if (creationTime != null && creationTime.isBefore(pluginStartTime)) {
|
||||
log.debug("[ReplyReconciler] Skipping historical reply: {} (created before plugin startup)", name);
|
||||
markProcessed(reply);
|
||||
client.update(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip replies from AI persona itself
|
||||
var owner = reply.getSpec().getOwner();
|
||||
if (owner != null && owner.getName() != null
|
||||
&& owner.getName().startsWith(AI_PERSONA_OWNER_PREFIX)) {
|
||||
markProcessed(reply);
|
||||
client.update(reply);
|
||||
return;
|
||||
}
|
||||
// Also skip if owner has AI marker annotation
|
||||
if (owner != null && owner.getAnnotations() != null
|
||||
&& "true".equals(owner.getAnnotations().get(AI_MARKER_ANNOTATION))) {
|
||||
markProcessed(reply);
|
||||
client.update(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
String parentCommentName = reply.getSpec().getCommentName();
|
||||
if (parentCommentName == null || parentCommentName.isBlank()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this reply is specifically replying to an AI reply
|
||||
// by checking the quoteReply field
|
||||
String quoteReply = reply.getSpec().getQuoteReply();
|
||||
|
||||
if (quoteReply == null || quoteReply.isBlank()) {
|
||||
// No quoteReply - this is a direct reply to the top-level comment,
|
||||
// NOT a reply to AI. Skip it (CommentReconciler handles top-level comments).
|
||||
log.debug("[ReplyReconciler] Reply {} has no quoteReply, skipping (not a reply to AI)", name);
|
||||
return;
|
||||
}
|
||||
|
||||
// This reply quotes another reply - check if the quoted reply is from AI
|
||||
boolean isReplyToAi = isAiReply(quoteReply);
|
||||
log.debug("[ReplyReconciler] Reply {} quotes {}, isAiReply={}", name, quoteReply, isReplyToAi);
|
||||
|
||||
if (!isReplyToAi) {
|
||||
log.debug("[ReplyReconciler] Not a reply to AI, skipping: {}", name);
|
||||
return;
|
||||
}
|
||||
|
||||
// Dedup: check if we already have an AiCommentReply record for this reply
|
||||
boolean alreadyHasRecord = !client.list(AiCommentReply.class,
|
||||
record -> name.equals(record.getSpec().getReplyTo())
|
||||
&& Boolean.TRUE.equals(record.getSpec().getIsAiConversation()),
|
||||
null)
|
||||
.isEmpty();
|
||||
|
||||
if (alreadyHasRecord) {
|
||||
log.debug("[ReplyReconciler] Already have AiCommentReply record for reply: {}, skipping", name);
|
||||
markProcessed(reply);
|
||||
client.update(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark as processed
|
||||
markProcessed(reply);
|
||||
client.update(reply);
|
||||
|
||||
// Reply to AI → trigger AI reply (conversation continuation)
|
||||
log.info("[ReplyReconciler] Reply to AI detected: {}, triggering conversation", name);
|
||||
orchestrator.processComment(parentCommentName, name, true)
|
||||
.subscribeOn(Schedulers.boundedElastic())
|
||||
.subscribe(
|
||||
null,
|
||||
e -> log.error("[ReplyReconciler] Error processing reply {}: {}", name, e.getMessage(), e),
|
||||
() -> log.info("[ReplyReconciler] Processing completed for reply: {}", name)
|
||||
);
|
||||
});
|
||||
|
||||
return Result.doNotRetry();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific Reply is from AI persona.
|
||||
*/
|
||||
private boolean isAiReply(String replyName) {
|
||||
return client.fetch(Reply.class, replyName)
|
||||
.map(reply -> {
|
||||
var owner = reply.getSpec().getOwner();
|
||||
if (owner != null && owner.getName() != null
|
||||
&& owner.getName().startsWith(AI_PERSONA_OWNER_PREFIX)) {
|
||||
return true;
|
||||
}
|
||||
if (owner != null && owner.getAnnotations() != null
|
||||
&& "true".equals(owner.getAnnotations().get(AI_MARKER_ANNOTATION))) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
})
|
||||
.orElse(false);
|
||||
}
|
||||
|
||||
private boolean isProcessed(Map<String, String> annotations) {
|
||||
return annotations != null && "true".equals(annotations.get(PROCESSED_ANNOTATION));
|
||||
}
|
||||
|
||||
private void markProcessed(Reply reply) {
|
||||
var annotations = reply.getMetadata().getAnnotations();
|
||||
if (annotations == null) {
|
||||
annotations = new HashMap<>();
|
||||
reply.getMetadata().setAnnotations(annotations);
|
||||
}
|
||||
annotations.put(PROCESSED_ANNOTATION, "true");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Controller setupWith(ControllerBuilder builder) {
|
||||
return builder
|
||||
.extension(new Reply())
|
||||
.syncAllOnStart(false)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package top.nxxy335.commentaiautopilot.processor;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.thymeleaf.context.ITemplateContext;
|
||||
import org.thymeleaf.model.IModel;
|
||||
import org.thymeleaf.processor.element.IElementModelStructureHandler;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.theme.dialect.TemplateHeadProcessor;
|
||||
|
||||
/**
|
||||
* Template head processor for comment-ai-autopilot.
|
||||
* Previously used to inject avatar replacement scripts, but now
|
||||
* avatar is handled natively via Gravatar (email-based).
|
||||
* Kept as a no-op to avoid breaking the Spring component scan.
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class AiBadgeHeadProcessor implements TemplateHeadProcessor {
|
||||
|
||||
@Override
|
||||
public Mono<Void> process(ITemplateContext context, IModel model,
|
||||
IElementModelStructureHandler structureHandler) {
|
||||
return Mono.empty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package top.nxxy335.commentaiautopilot.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.core.extension.Plugin;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.plugin.extensionpoint.ExtensionGetter;
|
||||
import run.halo.aifoundation.AiModelService;
|
||||
import run.halo.aifoundation.chat.LanguageModel;
|
||||
import run.halo.aifoundation.chat.GenerateTextResult;
|
||||
|
||||
/**
|
||||
* AI Foundation client that calls the AI Foundation plugin's AiModelService.
|
||||
* Only instantiated when AI Foundation classes are available (via @ConditionalOnClass).
|
||||
*/
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class AiFoundationClient {
|
||||
|
||||
private static final String AI_FOUNDATION_PLUGIN_NAME = "ai-foundation";
|
||||
|
||||
private final ExtensionGetter extensionGetter;
|
||||
private final ReactiveExtensionClient client;
|
||||
|
||||
/**
|
||||
* Call AI Foundation to generate a chat response using the specified model.
|
||||
* Checks at runtime whether the ai-foundation plugin is installed and enabled
|
||||
* before attempting to use it.
|
||||
*
|
||||
* @param prompt the prompt text
|
||||
* @param modelName the AiModel metadata.name, null or blank to use default model
|
||||
* @return the generated text, or empty if AI Foundation is unavailable
|
||||
*/
|
||||
public Mono<String> chat(String prompt, String modelName) {
|
||||
return isAiFoundationEnabled()
|
||||
.flatMap(enabled -> {
|
||||
if (!enabled) {
|
||||
log.warn("AI Foundation plugin is not installed or not enabled, skipping AI reply");
|
||||
return Mono.empty();
|
||||
}
|
||||
return doChat(prompt, modelName);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the ai-foundation plugin is installed and enabled at runtime.
|
||||
*/
|
||||
private Mono<Boolean> isAiFoundationEnabled() {
|
||||
return client.fetch(Plugin.class, AI_FOUNDATION_PLUGIN_NAME)
|
||||
.map(plugin -> plugin.getSpec().getEnabled())
|
||||
.defaultIfEmpty(false)
|
||||
.onErrorResume(e -> {
|
||||
log.debug("Failed to check AI Foundation plugin status: {}", e.getMessage());
|
||||
return Mono.just(false);
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<String> doChat(String prompt, String modelName) {
|
||||
return extensionGetter.getEnabledExtension(AiModelService.class)
|
||||
.flatMap(service -> {
|
||||
Mono<LanguageModel> modelMono;
|
||||
if (modelName != null && !modelName.isBlank()) {
|
||||
modelMono = service.languageModel(modelName);
|
||||
} else {
|
||||
modelMono = service.languageModel();
|
||||
}
|
||||
return modelMono.flatMap(model -> model.generateText(prompt)
|
||||
.map(GenerateTextResult::getText)
|
||||
.doOnNext(text -> log.debug("AI generated reply ({} chars) using model '{}'",
|
||||
text.length(), modelName != null ? modelName : "default"))
|
||||
);
|
||||
})
|
||||
.doOnError(e -> log.error("AI Foundation call failed: {}", e.getMessage()))
|
||||
.onErrorResume(e -> {
|
||||
log.warn("AI Foundation not available: {}", e.getMessage());
|
||||
return Mono.empty();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package top.nxxy335.commentaiautopilot.service;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.plugin.extensionpoint.ExtensionGetter;
|
||||
|
||||
/**
|
||||
* Configuration that registers AiFoundationClient only when
|
||||
* AI Foundation plugin classes are available in the classloader.
|
||||
* When AI Foundation is not installed, this entire configuration is skipped.
|
||||
*/
|
||||
@Configuration
|
||||
@ConditionalOnClass(name = "run.halo.aifoundation.AiModelService")
|
||||
public class AiFoundationConfiguration {
|
||||
|
||||
@Bean
|
||||
public AiFoundationClient aiFoundationClient(ExtensionGetter extensionGetter,
|
||||
ReactiveExtensionClient client) {
|
||||
return new AiFoundationClient(extensionGetter, client);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
package top.nxxy335.commentaiautopilot.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.dao.OptimisticLockingFailureException;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.util.retry.Retry;
|
||||
import run.halo.app.extension.Metadata;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.plugin.ReactiveSettingFetcher;
|
||||
import top.nxxy335.commentaiautopilot.extension.AiCommentReply;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@Component
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class AiReplyOrchestrator {
|
||||
|
||||
private final ContextExtractor contextExtractor;
|
||||
private final PromptBuilder promptBuilder;
|
||||
private final AiReplyService aiReplyService;
|
||||
private final SentimentService sentimentService;
|
||||
private final ReviewService reviewService;
|
||||
private final CommentReplyPublisher commentReplyPublisher;
|
||||
private final FilterService filterService;
|
||||
private final ReactiveExtensionClient client;
|
||||
private final ReactiveSettingFetcher settingFetcher;
|
||||
|
||||
// In-memory dedup: tracks which comment/reply is currently being processed
|
||||
// Prevents duplicate replies when Reconciler fires multiple times
|
||||
private final ConcurrentHashMap<String, Boolean> processingLocks = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* Process a new comment or reply.
|
||||
*
|
||||
* @param commentName the parent Comment name
|
||||
* @param replyName the Reply name that triggered this (null for top-level comments)
|
||||
* @param isAiConversation true when someone replied to AI's reply (conversation continuation)
|
||||
*/
|
||||
public Mono<Void> processComment(String commentName, String replyName, boolean isAiConversation) {
|
||||
String lockKey = isAiConversation ? commentName + ":conv:" + replyName : commentName + ":top";
|
||||
|
||||
// In-memory dedup: if already processing, skip immediately
|
||||
if (processingLocks.putIfAbsent(lockKey, Boolean.TRUE) != null) {
|
||||
log.info("[Orchestrator] Already processing: {}, skipping duplicate", lockKey);
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
log.info("[Orchestrator] Start processing: comment={}, replyName={}, isAiConversation={}",
|
||||
commentName, replyName, isAiConversation);
|
||||
|
||||
return isAutoReplyEnabled()
|
||||
.flatMap(enabled -> {
|
||||
if (!enabled) {
|
||||
log.info("[Orchestrator] Auto reply disabled, skipping: {}", commentName);
|
||||
return Mono.empty();
|
||||
}
|
||||
return filterService.shouldProcess(commentName)
|
||||
.flatMap(shouldProcess -> {
|
||||
if (!shouldProcess) {
|
||||
log.info("[Orchestrator] Filtered out by rules: {}", commentName);
|
||||
return Mono.empty();
|
||||
}
|
||||
// For top-level comments: skip if we already have ANY reply record
|
||||
// For AI conversation: skip if we already replied to THIS specific reply
|
||||
if (!isAiConversation) {
|
||||
return hasExistingReply(commentName)
|
||||
.flatMap(hasReply -> {
|
||||
if (hasReply) {
|
||||
log.info("[Orchestrator] Already have reply record for: {}, skipping", commentName);
|
||||
return Mono.empty();
|
||||
}
|
||||
return doProcess(commentName, replyName, isAiConversation);
|
||||
});
|
||||
}
|
||||
return hasExistingConversationReply(replyName)
|
||||
.flatMap(hasReply -> {
|
||||
if (hasReply) {
|
||||
log.info("[Orchestrator] Already replied to reply: {}, skipping", replyName);
|
||||
return Mono.empty();
|
||||
}
|
||||
return doProcess(commentName, replyName, isAiConversation);
|
||||
});
|
||||
});
|
||||
})
|
||||
.doOnError(e -> log.error("[Orchestrator] Error processing comment {}: {}", commentName, e.getMessage(), e))
|
||||
.doFinally(signal -> {
|
||||
// Always release the lock when processing completes
|
||||
processingLocks.remove(lockKey);
|
||||
log.debug("[Orchestrator] Released processing lock for: {}", lockKey);
|
||||
})
|
||||
.then();
|
||||
}
|
||||
|
||||
private Mono<Void> doProcess(String commentName, String replyName, boolean isAiConversation) {
|
||||
return getModelName().flatMap(modelName ->
|
||||
contextExtractor.extract(commentName, replyName, isAiConversation)
|
||||
.flatMap(context -> sentimentService.analyzeSentiment(context.commentContent(), modelName)
|
||||
.flatMap(sentimentResult -> {
|
||||
log.info("[Orchestrator] Sentiment for {}: {} (confidence: {})",
|
||||
commentName, sentimentResult.sentiment(), sentimentResult.confidence());
|
||||
return promptBuilder.buildPrompt(context, sentimentResult.sentiment())
|
||||
.flatMap(prompt -> createAiCommentReply(context, sentimentResult.sentiment())
|
||||
.flatMap(replyRecord -> generateAndPublish(prompt, context, replyRecord, modelName))
|
||||
);
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there's already ANY AiCommentReply record for this top-level comment.
|
||||
* Checks for ANY record (not just published) to prevent race conditions.
|
||||
*/
|
||||
private Mono<Boolean> hasExistingReply(String commentName) {
|
||||
return client.list(AiCommentReply.class,
|
||||
record -> commentName.equals(record.getSpec().getCommentId())
|
||||
&& !Boolean.TRUE.equals(record.getSpec().getIsAiConversation()),
|
||||
null)
|
||||
.hasElements()
|
||||
.defaultIfEmpty(false)
|
||||
.onErrorResume(e -> {
|
||||
log.debug("[Orchestrator] Failed to check existing replies: {}", e.getMessage());
|
||||
return Mono.just(false);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we already have ANY AiCommentReply record for this specific reply (conversation).
|
||||
* Checks for ANY record (not just published) to prevent race conditions.
|
||||
*/
|
||||
private Mono<Boolean> hasExistingConversationReply(String replyName) {
|
||||
if (replyName == null || replyName.isBlank()) {
|
||||
return Mono.just(false);
|
||||
}
|
||||
return client.list(AiCommentReply.class,
|
||||
record -> replyName.equals(record.getSpec().getReplyTo())
|
||||
&& Boolean.TRUE.equals(record.getSpec().getIsAiConversation()),
|
||||
null)
|
||||
.hasElements()
|
||||
.defaultIfEmpty(false)
|
||||
.onErrorResume(e -> {
|
||||
log.debug("[Orchestrator] Failed to check existing conversation replies: {}", e.getMessage());
|
||||
return Mono.just(false);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate AI reply, optionally review it, then publish.
|
||||
*/
|
||||
private Mono<Void> generateAndPublish(String prompt, ContextExtractor.CommentContext context,
|
||||
AiCommentReply replyRecord, String modelName) {
|
||||
return aiReplyService.generateReply(prompt, modelName)
|
||||
.defaultIfEmpty("")
|
||||
.flatMap(aiReply -> {
|
||||
if (aiReply.isBlank()) {
|
||||
log.warn("[Orchestrator] AI generated empty reply for: {}", context.commentId());
|
||||
return updateRecord(replyRecord, "", 0, "FAIL", false).then();
|
||||
}
|
||||
|
||||
log.info("[Orchestrator] AI generated reply for {}: {} chars",
|
||||
context.commentId(), aiReply.length());
|
||||
|
||||
return reviewService.review(context.postContent(), context.commentContent(), aiReply, modelName)
|
||||
.flatMap(reviewResult -> {
|
||||
log.info("[Orchestrator] Review for {}: score={}, status={}, reason={}",
|
||||
context.commentId(), reviewResult.score(), reviewResult.status(), reviewResult.reason());
|
||||
if ("FAIL".equals(reviewResult.status())) {
|
||||
log.warn("[Orchestrator] Content safety review FAILED for: {}, not publishing",
|
||||
context.commentId());
|
||||
return updateRecord(replyRecord, aiReply, 0, "FAIL", false).then();
|
||||
}
|
||||
return publishReply(context, aiReply, replyRecord, reviewResult.score());
|
||||
})
|
||||
.switchIfEmpty(
|
||||
publishReply(context, aiReply, replyRecord, 100)
|
||||
)
|
||||
.onErrorResume(e -> {
|
||||
log.warn("[Orchestrator] Review error, auto-passing: {}", e.getMessage());
|
||||
return publishReply(context, aiReply, replyRecord, 100);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish the reply and update the record to PASS + published=true.
|
||||
*/
|
||||
private Mono<Void> publishReply(ContextExtractor.CommentContext context, String aiReply,
|
||||
AiCommentReply replyRecord, int score) {
|
||||
return isAutoPublishEnabled()
|
||||
.flatMap(autoPublish -> {
|
||||
return commentReplyPublisher.publishReply(
|
||||
context.commentId(), aiReply, context.postId(), context.replyTo(), autoPublish)
|
||||
.flatMap(publishedReply -> {
|
||||
log.info("[Orchestrator] Reply {} for: {}", autoPublish ? "published" : "saved as draft", context.commentId());
|
||||
return updateRecord(replyRecord, aiReply, score, "PASS", autoPublish);
|
||||
})
|
||||
.flatMap(updated -> Mono.empty());
|
||||
})
|
||||
.then();
|
||||
}
|
||||
|
||||
private Mono<String> getModelName() {
|
||||
return settingFetcher.getSettingValue("model")
|
||||
.map(node -> {
|
||||
var nameNode = node.get("modelName");
|
||||
if (nameNode != null && !nameNode.asText().isBlank()) {
|
||||
return nameNode.asText();
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.onErrorResume(e -> {
|
||||
log.debug("[Orchestrator] Failed to fetch model setting: {}", e.getMessage());
|
||||
return Mono.just("");
|
||||
})
|
||||
.defaultIfEmpty("");
|
||||
}
|
||||
|
||||
private Mono<Boolean> isAutoReplyEnabled() {
|
||||
return settingFetcher.getSettingValue("basic")
|
||||
.map(node -> !node.has("autoReply") || node.get("autoReply").asBoolean(true))
|
||||
.onErrorResume(e -> {
|
||||
log.debug("[Orchestrator] Failed to fetch autoReply setting: {}", e.getMessage());
|
||||
return Mono.just(true);
|
||||
})
|
||||
.defaultIfEmpty(true);
|
||||
}
|
||||
|
||||
private Mono<Boolean> isAutoPublishEnabled() {
|
||||
return settingFetcher.getSettingValue("basic")
|
||||
.map(node -> !node.has("autoPublish") || node.get("autoPublish").asBoolean(true))
|
||||
.onErrorResume(e -> {
|
||||
log.debug("[Orchestrator] Failed to fetch autoPublish setting: {}", e.getMessage());
|
||||
return Mono.just(true);
|
||||
})
|
||||
.defaultIfEmpty(true);
|
||||
}
|
||||
|
||||
private Mono<AiCommentReply> createAiCommentReply(ContextExtractor.CommentContext context, String sentiment) {
|
||||
AiCommentReply record = new AiCommentReply();
|
||||
record.setMetadata(new Metadata());
|
||||
record.getMetadata().setName("ai-reply-" + UUID.randomUUID().toString().substring(0, 8));
|
||||
record.setSpec(new AiCommentReply.Spec());
|
||||
record.getSpec().setCommentId(context.commentId());
|
||||
record.getSpec().setPostId(context.postId());
|
||||
record.getSpec().setPostSlug(context.postSlug());
|
||||
record.getSpec().setReply("");
|
||||
record.getSpec().setScore(0);
|
||||
record.getSpec().setStatus("PENDING");
|
||||
record.getSpec().setRetryCount(0);
|
||||
record.getSpec().setReplyTo(context.replyTo());
|
||||
record.getSpec().setIsAiConversation(context.isAiConversation());
|
||||
record.getSpec().setPublished(false);
|
||||
record.getSpec().setSentiment(sentiment);
|
||||
return client.create(record)
|
||||
.doOnSuccess(created -> log.info("[Orchestrator] Created AiCommentReply record: {}",
|
||||
created.getMetadata().getName()));
|
||||
}
|
||||
|
||||
private Mono<AiCommentReply> updateRecord(AiCommentReply record, String reply,
|
||||
int score, String status, boolean published) {
|
||||
log.debug("[Orchestrator] Updating record {}: status={}, score={}, published={}",
|
||||
record.getMetadata().getName(), status, score, published);
|
||||
return client.fetch(AiCommentReply.class, record.getMetadata().getName())
|
||||
.flatMap(latest -> {
|
||||
latest.getSpec().setReply(reply);
|
||||
latest.getSpec().setScore(score);
|
||||
latest.getSpec().setStatus(status);
|
||||
latest.getSpec().setPublished(published);
|
||||
return client.update(latest);
|
||||
})
|
||||
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
|
||||
.filter(e -> e instanceof OptimisticLockingFailureException)
|
||||
.doBeforeRetry(signal -> log.debug("[Orchestrator] Retrying update for {} due to optimistic lock",
|
||||
record.getMetadata().getName()))
|
||||
)
|
||||
.doOnSuccess(updated -> log.debug("[Orchestrator] Record {} updated: status={}, score={}, published={}",
|
||||
record.getMetadata().getName(), status, score, published));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package top.nxxy335.commentaiautopilot.service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@Component
|
||||
@Slf4j
|
||||
public class AiReplyService {
|
||||
|
||||
private final ObjectProvider<AiFoundationClient> aiFoundationClientProvider;
|
||||
|
||||
public AiReplyService(ObjectProvider<AiFoundationClient> aiFoundationClientProvider) {
|
||||
this.aiFoundationClientProvider = aiFoundationClientProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an AI reply using the AI Foundation plugin.
|
||||
*
|
||||
* @param prompt the prompt text
|
||||
* @param modelName the model name (null for default)
|
||||
*/
|
||||
public Mono<String> generateReply(String prompt, String modelName) {
|
||||
AiFoundationClient client = aiFoundationClientProvider.getIfAvailable();
|
||||
if (client == null) {
|
||||
log.warn("AI Foundation plugin is not installed, cannot generate reply");
|
||||
return Mono.empty();
|
||||
}
|
||||
return client.chat(prompt, modelName)
|
||||
.doOnError(e -> log.error("AI reply generation failed: {}", e.getMessage()))
|
||||
.onErrorResume(e -> {
|
||||
log.warn("AI Foundation not available: {}", e.getMessage());
|
||||
return Mono.empty();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package top.nxxy335.commentaiautopilot.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.core.extension.content.Comment;
|
||||
import run.halo.app.core.extension.content.Reply;
|
||||
import run.halo.app.extension.ConfigMap;
|
||||
import run.halo.app.extension.Metadata;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@Component
|
||||
@Slf4j
|
||||
public class CommentReplyPublisher {
|
||||
|
||||
private final ReactiveExtensionClient client;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public CommentReplyPublisher(ReactiveExtensionClient client) {
|
||||
this.client = client;
|
||||
this.objectMapper = new ObjectMapper();
|
||||
}
|
||||
|
||||
private static final String DEFAULT_PERSONA_NAME = "小回";
|
||||
private static final String AI_PERSONA_OWNER_PREFIX = "ai-persona-";
|
||||
private static final String CONFIG_MAP_NAME = "comment-ai-autopilot-configmap";
|
||||
|
||||
/**
|
||||
* Publish a reply to a comment automatically using AI Persona identity.
|
||||
* Includes a final dedup check: if an AI reply already exists for this comment,
|
||||
* skip publishing to prevent duplicate replies.
|
||||
*/
|
||||
public Mono<Reply> publishReply(String parentCommentName, String replyContent,
|
||||
String postName, String quoteReplyName, boolean autoPublish) {
|
||||
return checkExistingAiReply(parentCommentName, quoteReplyName)
|
||||
.flatMap(exists -> {
|
||||
if (exists) {
|
||||
log.info("[Publisher] AI reply already exists for comment: {}, skipping duplicate publish",
|
||||
parentCommentName);
|
||||
return Mono.empty();
|
||||
}
|
||||
return doPublish(parentCommentName, replyContent, postName, quoteReplyName, autoPublish);
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<Boolean> checkExistingAiReply(String parentCommentName, String quoteReplyName) {
|
||||
return client.list(Reply.class,
|
||||
reply -> {
|
||||
if (!parentCommentName.equals(reply.getSpec().getCommentName())) {
|
||||
return false;
|
||||
}
|
||||
var owner = reply.getSpec().getOwner();
|
||||
if (owner == null || owner.getName() == null) {
|
||||
return false;
|
||||
}
|
||||
boolean isAiOwner = owner.getName().startsWith(AI_PERSONA_OWNER_PREFIX);
|
||||
boolean hasAiAnnotation = owner.getAnnotations() != null
|
||||
&& "true".equals(owner.getAnnotations().get("comment-ai-autopilot.nxxy335.top/is-ai"));
|
||||
boolean isAiReply = isAiOwner || hasAiAnnotation;
|
||||
|
||||
if (!isAiReply) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (quoteReplyName != null && !quoteReplyName.isBlank()) {
|
||||
return quoteReplyName.equals(reply.getSpec().getQuoteReply());
|
||||
}
|
||||
return true;
|
||||
},
|
||||
null)
|
||||
.hasElements()
|
||||
.defaultIfEmpty(false);
|
||||
}
|
||||
|
||||
private Mono<Reply> doPublish(String parentCommentName, String replyContent,
|
||||
String postName, String quoteReplyName, boolean autoPublish) {
|
||||
return getPersonaName().flatMap(personaName ->
|
||||
getPersonaEmail().flatMap(email -> {
|
||||
Reply reply = new Reply();
|
||||
reply.setMetadata(new Metadata());
|
||||
reply.getMetadata().setName(generateReplyName());
|
||||
reply.setSpec(new Reply.ReplySpec());
|
||||
|
||||
var spec = reply.getSpec();
|
||||
spec.setCommentName(parentCommentName);
|
||||
spec.setRaw(replyContent);
|
||||
spec.setContent(replyContent);
|
||||
spec.setApproved(autoPublish);
|
||||
if (autoPublish) {
|
||||
spec.setApprovedTime(Instant.now());
|
||||
}
|
||||
spec.setPriority(0);
|
||||
spec.setTop(false);
|
||||
spec.setAllowNotification(false);
|
||||
spec.setHidden(false);
|
||||
|
||||
if (quoteReplyName != null && !quoteReplyName.isBlank()) {
|
||||
spec.setQuoteReply(quoteReplyName);
|
||||
}
|
||||
|
||||
var owner = new Comment.CommentOwner();
|
||||
owner.setKind(Comment.CommentOwner.KIND_EMAIL);
|
||||
if (email != null && !email.isBlank()) {
|
||||
owner.setName(email);
|
||||
} else {
|
||||
owner.setName(AI_PERSONA_OWNER_PREFIX + personaName);
|
||||
}
|
||||
owner.setDisplayName(personaName + " AI");
|
||||
|
||||
Map<String, String> ownerAnnotations = new HashMap<>();
|
||||
ownerAnnotations.put("comment-ai-autopilot.nxxy335.top/is-ai", "true");
|
||||
if (email != null && !email.isBlank()) {
|
||||
String gravatarUrl = generateGravatarUrl(email);
|
||||
ownerAnnotations.put(Comment.CommentOwner.AVATAR_ANNO, gravatarUrl);
|
||||
}
|
||||
owner.setAnnotations(ownerAnnotations);
|
||||
spec.setOwner(owner);
|
||||
|
||||
return client.create(reply)
|
||||
.doOnSuccess(created -> log.info("[Publisher] AI Persona '{}' reply published for comment: {}, quoteReply: {}",
|
||||
personaName, parentCommentName, quoteReplyName))
|
||||
.doOnError(e -> log.error("[Publisher] Failed to publish AI reply: {}", e.getMessage()));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read persona setting directly from ConfigMap to avoid ClassLoader conflict.
|
||||
* Halo's ReactiveSettingFetcher returns JsonNode loaded by the main app ClassLoader,
|
||||
* which is incompatible with the plugin's PluginClassLoader, causing ClassCastException.
|
||||
*/
|
||||
private Mono<String> getPersonaName() {
|
||||
return client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
|
||||
.mapNotNull(cm -> {
|
||||
var data = cm.getData();
|
||||
if (data == null) return null;
|
||||
String personaJson = data.get("persona");
|
||||
if (personaJson == null || personaJson.isBlank()) return null;
|
||||
try {
|
||||
JsonNode node = objectMapper.readTree(personaJson);
|
||||
JsonNode nameNode = node.get("personaName");
|
||||
if (nameNode != null && !nameNode.asText().isBlank()) {
|
||||
return nameNode.asText();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[Publisher] Failed to parse personaName from ConfigMap: {}", e.getMessage());
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.defaultIfEmpty(DEFAULT_PERSONA_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read persona email directly from ConfigMap to avoid ClassLoader conflict.
|
||||
*/
|
||||
private Mono<String> getPersonaEmail() {
|
||||
return client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
|
||||
.mapNotNull(cm -> {
|
||||
var data = cm.getData();
|
||||
if (data == null) return null;
|
||||
String personaJson = data.get("persona");
|
||||
if (personaJson == null || personaJson.isBlank()) return null;
|
||||
try {
|
||||
JsonNode node = objectMapper.readTree(personaJson);
|
||||
JsonNode emailNode = node.get("personaEmail");
|
||||
if (emailNode != null && !emailNode.asText().isBlank()) {
|
||||
String email = emailNode.asText().trim().toLowerCase();
|
||||
log.info("[Publisher] personaEmail resolved from ConfigMap: {}", email);
|
||||
return email;
|
||||
}
|
||||
log.info("[Publisher] personaEmail is blank in ConfigMap");
|
||||
} catch (Exception e) {
|
||||
log.warn("[Publisher] Failed to parse personaEmail from ConfigMap: {}", e.getMessage());
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.defaultIfEmpty("");
|
||||
}
|
||||
|
||||
private String generateReplyName() {
|
||||
return "ai-comment-reply-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Gravatar URL from email address using SHA-256 hash.
|
||||
*/
|
||||
private String generateGravatarUrl(String email) {
|
||||
try {
|
||||
var digest = MessageDigest.getInstance("SHA-256");
|
||||
var hashBytes = digest.digest(email.trim().toLowerCase().getBytes(StandardCharsets.UTF_8));
|
||||
var hexString = new StringBuilder();
|
||||
for (byte b : hashBytes) {
|
||||
hexString.append(String.format("%02x", b));
|
||||
}
|
||||
return "https://cn.cravatar.com/avatar/" + hexString;
|
||||
} catch (Exception e) {
|
||||
log.error("[Publisher] Failed to generate Gravatar URL: {}", e.getMessage());
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package top.nxxy335.commentaiautopilot.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.jsoup.Jsoup;
|
||||
import org.jsoup.safety.Safelist;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.content.ContentWrapper;
|
||||
import run.halo.app.content.PostContentService;
|
||||
import run.halo.app.core.extension.content.Comment;
|
||||
import run.halo.app.core.extension.content.Post;
|
||||
import run.halo.app.core.extension.content.Reply;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
|
||||
@Component
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class ContextExtractor {
|
||||
|
||||
private final ReactiveExtensionClient client;
|
||||
private final PostContentService postContentService;
|
||||
|
||||
/**
|
||||
* Extract context from a comment event.
|
||||
* Returns a CommentContext record with all needed info.
|
||||
*
|
||||
* @param commentName the Comment name (always required)
|
||||
* @param replyName the Reply name that triggered this (null for top-level comments)
|
||||
* @param isAiConversation whether this is a continuation of AI conversation
|
||||
*/
|
||||
public Mono<CommentContext> extract(String commentName, String replyName, boolean isAiConversation) {
|
||||
return client.fetch(Comment.class, commentName)
|
||||
.flatMap(comment -> {
|
||||
if (replyName != null && !replyName.isBlank()) {
|
||||
// This is a reply to a comment - fetch the Reply for content
|
||||
return client.fetch(Reply.class, replyName)
|
||||
.flatMap(reply -> buildContextFromReply(comment, reply, isAiConversation))
|
||||
.switchIfEmpty(buildContext(comment, isAiConversation));
|
||||
}
|
||||
return buildContext(comment, isAiConversation);
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<CommentContext> buildContext(Comment comment, boolean isAiConversation) {
|
||||
var commentContent = extractCommentContent(comment);
|
||||
var commentOwner = extractCommentOwner(comment);
|
||||
var subjectRef = comment.getSpec().getSubjectRef();
|
||||
|
||||
if (subjectRef != null && "Post".equals(subjectRef.getKind())) {
|
||||
String postName = subjectRef.getName();
|
||||
return client.fetch(Post.class, postName)
|
||||
.flatMap(post -> getPostContent(postName)
|
||||
.map(content -> new CommentContext(
|
||||
comment.getMetadata().getName(),
|
||||
postName,
|
||||
post.getSpec().getSlug(),
|
||||
commentContent,
|
||||
commentOwner,
|
||||
post.getSpec().getTitle(),
|
||||
content,
|
||||
null,
|
||||
isAiConversation
|
||||
))
|
||||
)
|
||||
.defaultIfEmpty(new CommentContext(
|
||||
comment.getMetadata().getName(),
|
||||
postName,
|
||||
"",
|
||||
commentContent,
|
||||
commentOwner,
|
||||
"",
|
||||
"",
|
||||
null,
|
||||
isAiConversation
|
||||
));
|
||||
}
|
||||
|
||||
return Mono.just(new CommentContext(
|
||||
comment.getMetadata().getName(),
|
||||
"",
|
||||
"",
|
||||
commentContent,
|
||||
commentOwner,
|
||||
"",
|
||||
"",
|
||||
null,
|
||||
isAiConversation
|
||||
));
|
||||
}
|
||||
|
||||
private Mono<CommentContext> buildContextFromReply(Comment comment, Reply reply, boolean isAiConversation) {
|
||||
var replyContent = extractReplyContent(reply);
|
||||
var replyOwner = extractReplyOwner(reply);
|
||||
var subjectRef = comment.getSpec().getSubjectRef();
|
||||
|
||||
if (subjectRef != null && "Post".equals(subjectRef.getKind())) {
|
||||
String postName = subjectRef.getName();
|
||||
return client.fetch(Post.class, postName)
|
||||
.flatMap(post -> getPostContent(postName)
|
||||
.map(content -> new CommentContext(
|
||||
comment.getMetadata().getName(),
|
||||
postName,
|
||||
post.getSpec().getSlug(),
|
||||
replyContent,
|
||||
replyOwner,
|
||||
post.getSpec().getTitle(),
|
||||
content,
|
||||
reply.getMetadata().getName(),
|
||||
isAiConversation
|
||||
))
|
||||
)
|
||||
.defaultIfEmpty(new CommentContext(
|
||||
comment.getMetadata().getName(),
|
||||
postName,
|
||||
"",
|
||||
replyContent,
|
||||
replyOwner,
|
||||
"",
|
||||
"",
|
||||
reply.getMetadata().getName(),
|
||||
isAiConversation
|
||||
));
|
||||
}
|
||||
|
||||
return Mono.just(new CommentContext(
|
||||
comment.getMetadata().getName(),
|
||||
"",
|
||||
"",
|
||||
replyContent,
|
||||
replyOwner,
|
||||
"",
|
||||
"",
|
||||
reply.getMetadata().getName(),
|
||||
isAiConversation
|
||||
));
|
||||
}
|
||||
|
||||
private String extractCommentContent(Comment comment) {
|
||||
var spec = comment.getSpec();
|
||||
// Prefer raw content (plain text / markdown), fall back to rendered HTML
|
||||
String raw = spec.getRaw();
|
||||
if (raw != null && !raw.isBlank()) {
|
||||
return raw;
|
||||
}
|
||||
String content = spec.getContent();
|
||||
if (content != null && !content.isBlank()) {
|
||||
return Jsoup.clean(content, Safelist.none());
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private String extractCommentOwner(Comment comment) {
|
||||
var owner = comment.getSpec().getOwner();
|
||||
if (owner != null) {
|
||||
String displayName = owner.getDisplayName();
|
||||
if (displayName != null && !displayName.isBlank()) {
|
||||
return displayName;
|
||||
}
|
||||
}
|
||||
return "匿名用户";
|
||||
}
|
||||
|
||||
private String extractReplyContent(Reply reply) {
|
||||
var spec = reply.getSpec();
|
||||
String raw = spec.getRaw();
|
||||
if (raw != null && !raw.isBlank()) {
|
||||
return raw;
|
||||
}
|
||||
String content = spec.getContent();
|
||||
if (content != null && !content.isBlank()) {
|
||||
return Jsoup.clean(content, Safelist.none());
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private String extractReplyOwner(Reply reply) {
|
||||
var owner = reply.getSpec().getOwner();
|
||||
if (owner != null) {
|
||||
String displayName = owner.getDisplayName();
|
||||
if (displayName != null && !displayName.isBlank()) {
|
||||
return displayName;
|
||||
}
|
||||
}
|
||||
return "匿名用户";
|
||||
}
|
||||
|
||||
private Mono<String> getPostContent(String postName) {
|
||||
return postContentService.getReleaseContent(postName)
|
||||
.map(ContentWrapper::getContent)
|
||||
.map(html -> {
|
||||
if (html != null && !html.isBlank()) {
|
||||
return Jsoup.clean(html, Safelist.none());
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.defaultIfEmpty("");
|
||||
}
|
||||
|
||||
public record CommentContext(
|
||||
String commentId,
|
||||
String postId,
|
||||
String postSlug,
|
||||
String commentContent,
|
||||
String commentOwner,
|
||||
String postTitle,
|
||||
String postContent,
|
||||
String replyTo,
|
||||
boolean isAiConversation
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package top.nxxy335.commentaiautopilot.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.core.extension.content.Comment;
|
||||
import run.halo.app.core.extension.content.Post;
|
||||
import run.halo.app.core.extension.content.SinglePage;
|
||||
import run.halo.app.extension.ConfigMap;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Component
|
||||
@Slf4j
|
||||
public class FilterService {
|
||||
|
||||
private final ReactiveExtensionClient client;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
private static final String CONFIG_MAP_NAME = "comment-ai-autopilot-configmap";
|
||||
private static final String ANNOTATION_KEY = "comment-ai-autopilot.nxxy335.top/ai-reply-enabled";
|
||||
private static final String GROUP_CONTENT = "content.halo.run";
|
||||
|
||||
public FilterService(ReactiveExtensionClient client) {
|
||||
this.client = client;
|
||||
this.objectMapper = new ObjectMapper();
|
||||
}
|
||||
|
||||
public Mono<Boolean> shouldProcess(Comment comment) {
|
||||
return checkBlockedCommenters(comment)
|
||||
.flatMap(blocked -> {
|
||||
if (blocked) {
|
||||
return Mono.just(false);
|
||||
}
|
||||
return checkAnnotationEnabled(comment);
|
||||
})
|
||||
.defaultIfEmpty(true)
|
||||
.onErrorResume(e -> {
|
||||
log.warn("[Filter] Error checking filter rules: {}", e.getMessage());
|
||||
return Mono.just(true);
|
||||
});
|
||||
}
|
||||
|
||||
public Mono<Boolean> shouldProcess(String commentName) {
|
||||
return client.fetch(Comment.class, commentName)
|
||||
.flatMap(this::shouldProcess)
|
||||
.defaultIfEmpty(true)
|
||||
.onErrorResume(e -> {
|
||||
log.warn("[Filter] Error fetching comment for filter check: {}", e.getMessage());
|
||||
return Mono.just(true);
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<Boolean> checkBlockedCommenters(Comment comment) {
|
||||
return client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
|
||||
.mapNotNull(cm -> {
|
||||
var data = cm.getData();
|
||||
if (data == null) return false;
|
||||
String basicJson = data.get("basic");
|
||||
if (basicJson == null || basicJson.isBlank()) return false;
|
||||
try {
|
||||
JsonNode node = objectMapper.readTree(basicJson);
|
||||
String blockedCommentersStr = node.has("blockedCommenters")
|
||||
? node.get("blockedCommenters").asText("") : "";
|
||||
List<String> blockedCommenters = parseList(blockedCommentersStr);
|
||||
String commenterName = getCommenterDisplayName(comment);
|
||||
if (isInList(commenterName, blockedCommenters)) {
|
||||
log.info("[Filter] Commenter '{}' is in blocked list, skipping", commenterName);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (Exception e) {
|
||||
log.warn("[Filter] Failed to parse basic config: {}", e.getMessage());
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.defaultIfEmpty(false);
|
||||
}
|
||||
|
||||
private Mono<Boolean> checkAnnotationEnabled(Comment comment) {
|
||||
if (comment.getSpec() == null || comment.getSpec().getSubjectRef() == null) {
|
||||
return Mono.just(true);
|
||||
}
|
||||
var subjectRef = comment.getSpec().getSubjectRef();
|
||||
String group = subjectRef.getGroup();
|
||||
String kind = subjectRef.getKind();
|
||||
String name = subjectRef.getName();
|
||||
|
||||
if (GROUP_CONTENT.equals(group) && "Post".equals(kind)) {
|
||||
return client.fetch(Post.class, name)
|
||||
.map(post -> resolveAnnotation(post.getMetadata().getAnnotations(), true))
|
||||
.defaultIfEmpty(true);
|
||||
}
|
||||
|
||||
if (GROUP_CONTENT.equals(group) && "SinglePage".equals(kind)) {
|
||||
return client.fetch(SinglePage.class, name)
|
||||
.map(page -> resolveAnnotation(page.getMetadata().getAnnotations(), false))
|
||||
.defaultIfEmpty(false);
|
||||
}
|
||||
|
||||
// Unknown subjectRef type, default to allowing
|
||||
return Mono.just(true);
|
||||
}
|
||||
|
||||
private boolean resolveAnnotation(java.util.Map<String, String> annotations, boolean defaultEnabled) {
|
||||
if (annotations == null || !annotations.containsKey(ANNOTATION_KEY)) {
|
||||
return defaultEnabled;
|
||||
}
|
||||
String value = annotations.get(ANNOTATION_KEY);
|
||||
if ("false".equalsIgnoreCase(value)) {
|
||||
log.info("[Filter] Annotation {} is set to false, skipping", ANNOTATION_KEY);
|
||||
return false;
|
||||
}
|
||||
if ("true".equalsIgnoreCase(value)) {
|
||||
return true;
|
||||
}
|
||||
// Unrecognized value, fall back to default
|
||||
return defaultEnabled;
|
||||
}
|
||||
|
||||
private String getCommenterDisplayName(Comment comment) {
|
||||
if (comment.getSpec() == null || comment.getSpec().getOwner() == null) return "";
|
||||
var displayName = comment.getSpec().getOwner().getDisplayName();
|
||||
return displayName != null ? displayName : "";
|
||||
}
|
||||
|
||||
private List<String> parseList(String str) {
|
||||
if (str == null || str.isBlank()) return Collections.emptyList();
|
||||
return Arrays.stream(str.split(","))
|
||||
.map(String::trim)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private boolean isInList(String value, List<String> list) {
|
||||
if (value == null || value.isEmpty() || list.isEmpty()) return false;
|
||||
return list.stream().anyMatch(item -> item.equalsIgnoreCase(value));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package top.nxxy335.commentaiautopilot.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.plugin.ReactiveSettingFetcher;
|
||||
|
||||
@Component
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class PromptBuilder {
|
||||
|
||||
private final ReactiveSettingFetcher settingFetcher;
|
||||
|
||||
private static final String SAFETY_PROMPT = """
|
||||
【安全规范】
|
||||
- 内容红线:坚决不生成任何涉及暴力、歧视、辱骂、人身攻击或违反法律法规的内容。
|
||||
- 恶意诱导处理:当用户要求你骂人、使用侮辱性词汇或进行情绪化对骂时,你必须礼貌地拒绝,例如回复:"抱歉,作为AI助手,我无法提供此类回复。"
|
||||
- 未知与边界:如果不知道答案或遇到敏感话题,请诚实告知并礼貌拒绝,绝不编造或使用极端言辞。
|
||||
""";
|
||||
|
||||
private static final String DEFAULT_PROMPT_TEMPLATE = """
|
||||
{{persona_prompt}}
|
||||
|
||||
{{safety_prompt}}
|
||||
|
||||
【语言要求】请用评论所使用的语言回复。如果评论是英文,请用英文回复;如果是中文,请用中文回复;如果是日文,请用日文回复;以此类推。
|
||||
|
||||
请回复以下评论。注意:
|
||||
- 回复长度应与评论长度匹配,简短问候简短回复
|
||||
- 不要复述或总结文章内容
|
||||
- 自然对话,不要写小作文
|
||||
- 只有评论涉及具体内容时才针对性回应
|
||||
|
||||
文章(仅供理解上下文,不要复述):
|
||||
{{article}}
|
||||
|
||||
评论:
|
||||
{{comment}}
|
||||
""";
|
||||
|
||||
private static final String DEFAULT_PERSONA_PROMPT = """
|
||||
你是「小回」,一个友善的评论者。你的回复简洁自然,像朋友聊天一样。简短的评论就简短回复,有深度的讨论才展开回应。不要长篇大论,不要复述文章内容。
|
||||
""";
|
||||
|
||||
public Mono<String> buildPrompt(ContextExtractor.CommentContext context) {
|
||||
return Mono.zip(getPromptTemplate(), getPersonaPrompt())
|
||||
.map(tuple -> {
|
||||
String template = tuple.getT1();
|
||||
String personaPrompt = tuple.getT2();
|
||||
|
||||
String prompt = template
|
||||
.replace("{{persona_prompt}}", personaPrompt)
|
||||
.replace("{{safety_prompt}}", SAFETY_PROMPT)
|
||||
.replace("{{article}}", context.postTitle() + "\n" + context.postContent())
|
||||
.replace("{{comment}}", context.commentOwner() + ": " + context.commentContent());
|
||||
|
||||
return prompt;
|
||||
});
|
||||
}
|
||||
|
||||
public Mono<String> buildPrompt(ContextExtractor.CommentContext context, String sentiment) {
|
||||
return buildPrompt(context)
|
||||
.map(prompt -> {
|
||||
if (sentiment == null || "NEUTRAL".equals(sentiment)) {
|
||||
return prompt;
|
||||
}
|
||||
String sentimentHint = switch (sentiment) {
|
||||
case "POSITIVE" -> "\n\n【情感提示】评论者情绪正面积极,请用热情友好的语气回复,可以表达感谢和共鸣。";
|
||||
case "NEGATIVE" -> "\n\n【情感提示】评论者情绪偏负面,请用理性温和的语气回复,避免激化矛盾,展现理解和包容。";
|
||||
default -> "";
|
||||
};
|
||||
return prompt + sentimentHint;
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<String> getPromptTemplate() {
|
||||
return settingFetcher.getSettingValue("prompt")
|
||||
.map(node -> {
|
||||
var templateNode = node.get("customPromptTemplate");
|
||||
if (templateNode != null && !templateNode.asText().isBlank()) {
|
||||
return templateNode.asText();
|
||||
}
|
||||
return DEFAULT_PROMPT_TEMPLATE;
|
||||
})
|
||||
.onErrorResume(e -> {
|
||||
log.debug("Failed to fetch prompt template setting: {}", e.getMessage());
|
||||
return Mono.just(DEFAULT_PROMPT_TEMPLATE);
|
||||
})
|
||||
.defaultIfEmpty(DEFAULT_PROMPT_TEMPLATE);
|
||||
}
|
||||
|
||||
private Mono<String> getPersonaPrompt() {
|
||||
return settingFetcher.getSettingValue("persona")
|
||||
.map(node -> {
|
||||
var promptNode = node.get("personaPrompt");
|
||||
if (promptNode != null && !promptNode.asText().isBlank()) {
|
||||
return promptNode.asText();
|
||||
}
|
||||
return DEFAULT_PERSONA_PROMPT;
|
||||
})
|
||||
.onErrorResume(e -> {
|
||||
log.debug("Failed to fetch persona prompt setting: {}", e.getMessage());
|
||||
return Mono.just(DEFAULT_PERSONA_PROMPT);
|
||||
})
|
||||
.defaultIfEmpty(DEFAULT_PERSONA_PROMPT);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package top.nxxy335.commentaiautopilot.service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.plugin.ReactiveSettingFetcher;
|
||||
|
||||
@Component
|
||||
@Slf4j
|
||||
public class ReviewService {
|
||||
|
||||
private final ObjectProvider<AiFoundationClient> aiFoundationClientProvider;
|
||||
private final ReactiveSettingFetcher settingFetcher;
|
||||
|
||||
public ReviewService(ObjectProvider<AiFoundationClient> aiFoundationClientProvider,
|
||||
ReactiveSettingFetcher settingFetcher) {
|
||||
this.aiFoundationClientProvider = aiFoundationClientProvider;
|
||||
this.settingFetcher = settingFetcher;
|
||||
}
|
||||
|
||||
private static final String REVIEW_PROMPT_TEMPLATE = """
|
||||
请你以内容安全审核员的身份,严格评估你刚刚生成的这段回复:
|
||||
|
||||
文章:
|
||||
%s
|
||||
|
||||
原始评论:
|
||||
%s
|
||||
|
||||
生成的回复:
|
||||
%s
|
||||
|
||||
请检查以下内容:
|
||||
1. 是否包含任何辱骂、仇恨、偏见或煽动性言论?
|
||||
2. 是否包含不适宜公开传播的内容?
|
||||
3. 是否涉及暴力、歧视、人身攻击或违反法律法规的内容?
|
||||
|
||||
请直接回答"安全"或"不安全"。如果"不安全",请重新生成一段符合规范的温和回复。
|
||||
""";
|
||||
|
||||
public Mono<ReviewResult> review(String articleContent, String commentContent, String aiReply,
|
||||
String modelName) {
|
||||
AiFoundationClient client = aiFoundationClientProvider.getIfAvailable();
|
||||
if (client == null) {
|
||||
log.warn("AI Foundation plugin is not installed, skipping review (auto-pass)");
|
||||
return Mono.just(new ReviewResult(100, "PASS", "AI Foundation 未安装,自动通过"));
|
||||
}
|
||||
|
||||
String reviewPrompt = String.format(REVIEW_PROMPT_TEMPLATE,
|
||||
truncate(articleContent, 2000),
|
||||
truncate(commentContent, 500),
|
||||
truncate(aiReply, 500));
|
||||
|
||||
return client.chat(reviewPrompt, modelName)
|
||||
.map(this::parseSafetyResult)
|
||||
.defaultIfEmpty(new ReviewResult(100, "PASS", "审核无响应,自动通过"))
|
||||
.onErrorResume(e -> {
|
||||
log.warn("Review failed, auto-passing: {}", e.getMessage());
|
||||
return Mono.just(new ReviewResult(100, "PASS", "审核服务异常,自动通过"));
|
||||
});
|
||||
}
|
||||
|
||||
private ReviewResult parseSafetyResult(String response) {
|
||||
if (response == null || response.isBlank()) {
|
||||
return new ReviewResult(100, "PASS", "审核无响应,自动通过");
|
||||
}
|
||||
String trimmed = response.trim().toLowerCase();
|
||||
if (trimmed.contains("不安全") || trimmed.contains("unsafe")) {
|
||||
log.warn("AI Review: content is UNSAFE, response: {}", response);
|
||||
return new ReviewResult(0, "FAIL", "内容安全审核不通过");
|
||||
}
|
||||
if (trimmed.contains("安全") || trimmed.contains("safe")) {
|
||||
log.info("AI Review: content is SAFE");
|
||||
return new ReviewResult(100, "PASS", "内容安全审核通过");
|
||||
}
|
||||
// If unclear response, default to pass
|
||||
log.warn("AI Review: unclear response, auto-passing: {}", response);
|
||||
return new ReviewResult(100, "PASS", "审核结果不明确,自动通过");
|
||||
}
|
||||
|
||||
private String truncate(String text, int maxLength) {
|
||||
if (text == null) return "";
|
||||
return text.length() > maxLength ? text.substring(0, maxLength) : text;
|
||||
}
|
||||
|
||||
public record ReviewResult(int score, String status, String reason) {}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package top.nxxy335.commentaiautopilot.service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@Component
|
||||
@Slf4j
|
||||
public class SentimentService {
|
||||
|
||||
private final ObjectProvider<AiFoundationClient> aiFoundationClientProvider;
|
||||
|
||||
public SentimentService(ObjectProvider<AiFoundationClient> aiFoundationClientProvider) {
|
||||
this.aiFoundationClientProvider = aiFoundationClientProvider;
|
||||
}
|
||||
|
||||
public record SentimentResult(String sentiment, double confidence) {
|
||||
public static final String POSITIVE = "POSITIVE";
|
||||
public static final String NEUTRAL = "NEUTRAL";
|
||||
public static final String NEGATIVE = "NEGATIVE";
|
||||
}
|
||||
|
||||
public Mono<SentimentResult> analyzeSentiment(String commentContent, String modelName) {
|
||||
AiFoundationClient client = aiFoundationClientProvider.getIfAvailable();
|
||||
if (client == null) {
|
||||
log.warn("[Sentiment] AI Foundation plugin is not installed, defaulting to NEUTRAL");
|
||||
return Mono.just(new SentimentResult(SentimentResult.NEUTRAL, 0.0));
|
||||
}
|
||||
|
||||
String prompt = buildSentimentPrompt(commentContent);
|
||||
|
||||
return client.chat(prompt, modelName)
|
||||
.map(response -> {
|
||||
String sentiment = parseSentiment(response);
|
||||
return new SentimentResult(sentiment, 1.0);
|
||||
})
|
||||
.onErrorResume(e -> {
|
||||
log.warn("[Sentiment] Failed to analyze sentiment, defaulting to NEUTRAL: {}", e.getMessage());
|
||||
return Mono.just(new SentimentResult(SentimentResult.NEUTRAL, 0.0));
|
||||
})
|
||||
.defaultIfEmpty(new SentimentResult(SentimentResult.NEUTRAL, 0.0));
|
||||
}
|
||||
|
||||
private String buildSentimentPrompt(String commentContent) {
|
||||
return "请分析以下评论的情感倾向。只回复一个词:POSITIVE(正面)、NEUTRAL(中性)或 NEGATIVE(负面)。\n\n评论内容:\n" + commentContent;
|
||||
}
|
||||
|
||||
private String parseSentiment(String response) {
|
||||
if (response == null || response.isBlank()) return SentimentResult.NEUTRAL;
|
||||
String upper = response.trim().toUpperCase();
|
||||
if (upper.contains("POSITIVE")) return SentimentResult.POSITIVE;
|
||||
if (upper.contains("NEGATIVE")) return SentimentResult.NEGATIVE;
|
||||
return SentimentResult.NEUTRAL;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
@@ -0,0 +1,29 @@
|
||||
apiVersion: v1alpha1
|
||||
kind: AnnotationSetting
|
||||
metadata:
|
||||
name: comment-ai-autopilot-post-annotation-setting
|
||||
spec:
|
||||
targetRef:
|
||||
group: content.halo.run
|
||||
kind: Post
|
||||
formSchema:
|
||||
- $formkit: switch
|
||||
name: comment-ai-autopilot.nxxy335.top/ai-reply-enabled
|
||||
label: 启用AI回评
|
||||
value: true
|
||||
help: 开启后,该文章收到评论时将自动触发AI回复
|
||||
---
|
||||
apiVersion: v1alpha1
|
||||
kind: AnnotationSetting
|
||||
metadata:
|
||||
name: comment-ai-autopilot-single-page-annotation-setting
|
||||
spec:
|
||||
targetRef:
|
||||
group: content.halo.run
|
||||
kind: SinglePage
|
||||
formSchema:
|
||||
- $formkit: switch
|
||||
name: comment-ai-autopilot.nxxy335.top/ai-reply-enabled
|
||||
label: 启用AI回评
|
||||
value: false
|
||||
help: 开启后,该页面收到评论时将自动触发AI回复
|
||||
@@ -0,0 +1,18 @@
|
||||
apiVersion: v1alpha1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: comment-ai-autopilot-role-manage
|
||||
labels:
|
||||
halo.run/role-template: "true"
|
||||
annotations:
|
||||
rbac.authorization.halo.run/module: "Comment AI Autopilot Management"
|
||||
rbac.authorization.halo.run/display-name: "AI回评管理"
|
||||
rbac.authorization.halo.run/ui-permissions: |
|
||||
["plugin:comment-ai-autopilot:manage"]
|
||||
rules:
|
||||
- apiGroups: ["comment-ai-autopilot.nxxy335.top"]
|
||||
resources: ["comment-ai-autopilot/aicommentreplies"]
|
||||
verbs: ["*"]
|
||||
- apiGroups: ["console.api.comment-ai-autopilot.nxxy335.top"]
|
||||
resources: ["*"]
|
||||
verbs: ["*"]
|
||||
@@ -0,0 +1,60 @@
|
||||
apiVersion: v1alpha1
|
||||
kind: Setting
|
||||
metadata:
|
||||
name: comment-ai-autopilot-settings
|
||||
spec:
|
||||
forms:
|
||||
- group: basic
|
||||
label: 基本设置
|
||||
formSchema:
|
||||
- $formkit: switch
|
||||
name: autoReply
|
||||
label: 自动回复
|
||||
value: true
|
||||
- $formkit: switch
|
||||
name: autoPublish
|
||||
label: 自动发布
|
||||
value: true
|
||||
- $formkit: number
|
||||
name: maxRetryCount
|
||||
label: 最大重试次数
|
||||
value: 3
|
||||
min: 1
|
||||
max: 10
|
||||
- $formkit: textarea
|
||||
name: blockedCommenters
|
||||
label: 评论者黑名单
|
||||
help: 输入评论者显示名称,多个用逗号分隔。这些评论者的评论不会触发AI回复
|
||||
value: ""
|
||||
- group: persona
|
||||
label: AI角色设置
|
||||
formSchema:
|
||||
- $formkit: text
|
||||
name: personaName
|
||||
label: AI角色昵称
|
||||
value: "小回"
|
||||
- $formkit: textarea
|
||||
name: personaPrompt
|
||||
label: AI角色人格提示词
|
||||
value: "你是「小回」,一个友善的评论者。你的回复简洁自然,像朋友聊天一样。简短的评论就简短回复,有深度的讨论才展开回应。不要长篇大论,不要复述文章内容。"
|
||||
- $formkit: email
|
||||
name: personaEmail
|
||||
label: AI角色邮箱
|
||||
help: 用于Gravatar头像服务展示头像
|
||||
value: ""
|
||||
- group: model
|
||||
label: 模型设置
|
||||
formSchema:
|
||||
- $formkit: text
|
||||
name: modelName
|
||||
label: AI模型名称
|
||||
help: 留空使用AI Foundation默认模型,填写AiModel资源名称可指定模型
|
||||
value: ""
|
||||
- group: prompt
|
||||
label: Prompt设置
|
||||
formSchema:
|
||||
- $formkit: textarea
|
||||
name: customPromptTemplate
|
||||
label: 自定义Prompt模板
|
||||
value: "{{persona_prompt}}\n\n{{safety_prompt}}\n\n【语言要求】请用评论所使用的语言回复。如果评论是英文,请用英文回复;如果是中文,请用中文回复;如果是日文,请用日文回复;以此类推。\n\n请回复以下评论。注意:\n- 回复长度应与评论长度匹配,简短问候简短回复\n- 不要复述或总结文章内容\n- 自然对话,不要写小作文\n- 只有评论涉及具体内容时才针对性回应\n\n文章(仅供理解上下文,不要复述):\n{{article}}\n\n评论:\n{{comment}}"
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
@@ -0,0 +1,27 @@
|
||||
# Refer https://docs.halo.run/developer-guide/plugin/basics/manifest
|
||||
|
||||
apiVersion: plugin.halo.run/v1alpha1
|
||||
kind: Plugin
|
||||
metadata:
|
||||
# The name defines how the plugin is invoked, A unique name
|
||||
name: comment-ai-autopilot
|
||||
spec:
|
||||
enabled: true
|
||||
requires: ">=2.23.0"
|
||||
author:
|
||||
name: 暖心向阳335
|
||||
website: https://github.com/暖心向阳335
|
||||
logo: logo.png
|
||||
homepage: https://github.com/暖心向阳335/comment-ai-autopilot#readme
|
||||
repo: https://github.com/暖心向阳335/comment-ai-autopilot
|
||||
issues: https://github.com/暖心向阳335/comment-ai-autopilot/issues
|
||||
displayName: "AI回评"
|
||||
description: "基于 AI 的 Halo 博客评论自动回复插件,支持 AI 虚拟角色回复、自审核、自动发布和对话式连续回复"
|
||||
license:
|
||||
- name: "GPL-3.0"
|
||||
url: "https://github.com/暖心向阳335/comment-ai-autopilot/blob/main/LICENSE"
|
||||
settingName: "comment-ai-autopilot-settings"
|
||||
configMapName: "comment-ai-autopilot-configmap"
|
||||
version: "0.0.1-w5s2t7"
|
||||
pluginDependencies:
|
||||
ai-foundation?: "*"
|
||||
@@ -0,0 +1,28 @@
|
||||
package top.nxxy335.commentaiautopilot;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import run.halo.app.extension.SchemeManager;
|
||||
import run.halo.app.plugin.PluginContext;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class CommentAiAutopilotPluginTest {
|
||||
|
||||
@Mock
|
||||
PluginContext context;
|
||||
|
||||
@Mock
|
||||
SchemeManager schemeManager;
|
||||
|
||||
@InjectMocks
|
||||
CommentAiAutopilotPlugin plugin;
|
||||
|
||||
@Test
|
||||
void contextLoads() {
|
||||
plugin.start();
|
||||
plugin.stop();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
plugins {
|
||||
id 'base'
|
||||
id "com.github.node-gradle.node" version "7.1.0"
|
||||
}
|
||||
|
||||
group 'top.nxxy335.commentaiautopilot.ui'
|
||||
|
||||
// Fix Gradle 9.x compatibility with pnpm symlinks
|
||||
tasks.named('pnpmInstall') {
|
||||
doNotTrackState("pnpm symlinks are not compatible with Gradle state tracking")
|
||||
}
|
||||
|
||||
tasks.register('pnpmBuild', PnpmTask) {
|
||||
group = 'build'
|
||||
description = 'Build the UI project using pnpm'
|
||||
args = ['build']
|
||||
dependsOn tasks.named('pnpmInstall')
|
||||
inputs.dir(layout.projectDirectory.dir('src'))
|
||||
inputs.files(fileTree(
|
||||
dir: layout.projectDirectory,
|
||||
includes: ['*.cjs', '*.ts', '*.js', '*.json', '*.yaml']))
|
||||
outputs.dir(layout.buildDirectory.dir('dist'))
|
||||
}
|
||||
|
||||
tasks.register('pnpmCheck', PnpmTask) {
|
||||
group = 'verification'
|
||||
description = 'Run unit tests for the UI project using pnpm'
|
||||
args = ['test:unit']
|
||||
dependsOn tasks.named('pnpmInstall')
|
||||
}
|
||||
|
||||
tasks.named('check') {
|
||||
dependsOn tasks.named('pnpmCheck')
|
||||
}
|
||||
|
||||
tasks.named('assemble') {
|
||||
dependsOn tasks.named('pnpmBuild')
|
||||
}
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
/// <reference types="@rsbuild/core/types" />
|
||||
/// <reference types="unplugin-icons/types/vue" />
|
||||
@@ -0,0 +1,30 @@
|
||||
import { globalIgnores } from 'eslint/config'
|
||||
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
|
||||
import pluginVue from 'eslint-plugin-vue'
|
||||
import pluginVitest from '@vitest/eslint-plugin'
|
||||
import pluginOxlint from 'eslint-plugin-oxlint'
|
||||
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
|
||||
|
||||
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
|
||||
// import { configureVueProject } from '@vue/eslint-config-typescript'
|
||||
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
|
||||
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
|
||||
|
||||
export default defineConfigWithVueTs(
|
||||
{
|
||||
name: 'app/files-to-lint',
|
||||
files: ['**/*.{ts,mts,tsx,vue}'],
|
||||
},
|
||||
|
||||
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
|
||||
|
||||
pluginVue.configs['flat/essential'],
|
||||
vueTsConfigs.recommended,
|
||||
|
||||
{
|
||||
...pluginVitest.configs.recommended,
|
||||
files: ['src/**/__tests__/*'],
|
||||
},
|
||||
...pluginOxlint.configs['flat/recommended'],
|
||||
skipFormatting,
|
||||
)
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "run-p type-check \"build-only {@}\" --",
|
||||
"build-only": "rsbuild build",
|
||||
"dev": "rsbuild build --watch --env-mode=development",
|
||||
"lint:oxlint": "oxlint . --fix -D correctness --ignore-path ../.gitignore",
|
||||
"lint:eslint": "eslint . --fix",
|
||||
"lint": "run-s lint:*",
|
||||
"prettier": "prettier --write src/",
|
||||
"test:unit": "vitest --passWithNoTests",
|
||||
"type-check": "vue-tsc --build"
|
||||
},
|
||||
"prettier": {
|
||||
"printWidth": 100,
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2
|
||||
},
|
||||
"dependencies": {
|
||||
"@halo-dev/api-client": "^2.23.0",
|
||||
"@halo-dev/components": "^2.23.0",
|
||||
"@halo-dev/ui-shared": "^2.23.0",
|
||||
"axios": "^1.13.5",
|
||||
"canvas-confetti": "^1.9.3",
|
||||
"vue": "^3.5.28"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rsbuild/core": "^1.7.3",
|
||||
"@rsbuild/plugin-sass": "^1.5.0",
|
||||
"@halo-dev/ui-plugin-bundler-kit": "^2.23.0",
|
||||
"@iconify-json/ri": "^1.2.6",
|
||||
"@tsconfig/node20": "^20.1.6",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/jsdom": "^21.1.7",
|
||||
"@types/node": "^20.19.1",
|
||||
"@vitest/eslint-plugin": "^1.2.7",
|
||||
"@vue/eslint-config-prettier": "^10.2.0",
|
||||
"@vue/eslint-config-typescript": "^14.5.1",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"@vue/tsconfig": "^0.7.0",
|
||||
"eslint": "^9.29.0",
|
||||
"eslint-plugin-oxlint": "^0.16.12",
|
||||
"eslint-plugin-vue": "~10.0.1",
|
||||
"jsdom": "^26.1.0",
|
||||
"npm-run-all2": "^7.0.2",
|
||||
"oxlint": "^0.16.12",
|
||||
"prettier": "^3.6.0",
|
||||
"sass": "^1.89.2",
|
||||
"typescript": "~5.8.3",
|
||||
"unplugin-icons": "^23.0.1",
|
||||
"vitest": "^4.1.0",
|
||||
"vue-tsc": "^3.2.4"
|
||||
},
|
||||
"packageManager": "pnpm@10.12.4"
|
||||
}
|
||||
Generated
+4816
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
import { rsbuildConfig } from '@halo-dev/ui-plugin-bundler-kit';
|
||||
import Icons from "unplugin-icons/rspack";
|
||||
import { pluginSass } from "@rsbuild/plugin-sass";
|
||||
import type { RsbuildConfig } from "@rsbuild/core";
|
||||
|
||||
export default rsbuildConfig({
|
||||
rsbuild: {
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": "./src",
|
||||
},
|
||||
},
|
||||
plugins: [pluginSass()],
|
||||
tools: {
|
||||
rspack: {
|
||||
plugins: [Icons({ compiler: "vue3" })],
|
||||
},
|
||||
},
|
||||
},
|
||||
}) as RsbuildConfig
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 5.5 KiB |
@@ -0,0 +1,84 @@
|
||||
import { definePlugin } from "@halo-dev/ui-shared";
|
||||
import { IconPlug, VDropdownItem, Dialog, Toast } from "@halo-dev/components";
|
||||
import { markRaw, type Ref } from "vue";
|
||||
import { axiosInstance } from "@halo-dev/api-client";
|
||||
import type { ListedComment } from "@halo-dev/api-client";
|
||||
|
||||
export default definePlugin({
|
||||
components: {},
|
||||
routes: [
|
||||
{
|
||||
parentName: "Root",
|
||||
route: {
|
||||
path: "/comment-ai-autopilot",
|
||||
name: "CommentAiAutopilot",
|
||||
component: () => import("./views/HomeView.vue"),
|
||||
meta: {
|
||||
title: "AI回评",
|
||||
searchable: true,
|
||||
menu: {
|
||||
name: "AI回评",
|
||||
group: "tool",
|
||||
icon: markRaw(IconPlug),
|
||||
priority: 40,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
parentName: "Root",
|
||||
route: {
|
||||
path: "/comment-ai-autopilot/logs",
|
||||
name: "CommentAiAutopilotLogs",
|
||||
component: () => import("./views/LogsView.vue"),
|
||||
meta: {
|
||||
title: "AI回复日志",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
parentName: "Root",
|
||||
route: {
|
||||
path: "/comment-ai-autopilot/settings",
|
||||
name: "CommentAiAutopilotSettings",
|
||||
component: () => import("./views/SettingsView.vue"),
|
||||
meta: {
|
||||
title: "插件设置",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
extensionPoints: {
|
||||
"comment:list-item:operation:create": (
|
||||
comment: Ref<ListedComment>,
|
||||
) => [
|
||||
{
|
||||
priority: 20,
|
||||
component: markRaw(VDropdownItem),
|
||||
label: "触发AI回复",
|
||||
action: () => {
|
||||
const commentName = comment.value.comment.metadata.name;
|
||||
Dialog.warning({
|
||||
title: "触发AI回复",
|
||||
description: `确定要对该评论触发AI回复吗?`,
|
||||
confirmType: "primary",
|
||||
confirmText: "确定",
|
||||
cancelText: "取消",
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await axiosInstance.post(
|
||||
`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/comments/${commentName}/trigger`,
|
||||
);
|
||||
Toast.success("AI回复已触发");
|
||||
} catch (e: any) {
|
||||
const msg =
|
||||
e?.response?.data?.message || "触发失败";
|
||||
Toast.error(msg);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
<template>
|
||||
<div class="comment-ai-autopilot-home">
|
||||
<VPageHeader title="AI回评">
|
||||
<template #icon>
|
||||
<IconPlug class="mr-2 self-center" />
|
||||
</template>
|
||||
<template #actions>
|
||||
<VButton type="primary" @click="openSettings"> 插件设置 </VButton>
|
||||
</template>
|
||||
</VPageHeader>
|
||||
|
||||
<div class="m-4">
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<VCard :body-class="['!p-4']">
|
||||
<div class="text-sm text-gray-500">总回复数</div>
|
||||
<div class="mt-1 text-2xl font-bold">{{ stats?.total || 0 }}</div>
|
||||
</VCard>
|
||||
<VCard :body-class="['!p-4']">
|
||||
<div class="text-sm text-gray-500">通过数</div>
|
||||
<div class="mt-1 text-2xl font-bold text-green-600">{{ stats?.passCount || 0 }}</div>
|
||||
</VCard>
|
||||
<VCard :body-class="['!p-4']">
|
||||
<div class="text-sm text-gray-500">失败数</div>
|
||||
<div class="mt-1 text-2xl font-bold text-red-600">{{ stats?.failCount || 0 }}</div>
|
||||
</VCard>
|
||||
<VCard :body-class="['!p-4']">
|
||||
<div class="text-sm text-gray-500">待审核</div>
|
||||
<div class="mt-1 text-2xl font-bold text-orange-500">{{ stats?.reviewingCount || 0 }}</div>
|
||||
</VCard>
|
||||
<VCard :body-class="['!p-4']">
|
||||
<div class="text-sm text-gray-500">平均评分</div>
|
||||
<div class="mt-1 text-2xl font-bold">{{ stats?.avgScore?.toFixed(1) || '0.0' }}</div>
|
||||
</VCard>
|
||||
</div>
|
||||
|
||||
<!-- Sentiment Distribution -->
|
||||
<VCard class="mt-4" :body-class="['!p-4']">
|
||||
<h3 class="text-base font-medium mb-3">情感分布</h3>
|
||||
<div class="flex items-end gap-6">
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="w-16 bg-green-100 rounded-t" :style="{ height: getSentimentBarHeight('POSITIVE') + 'px' }"></div>
|
||||
<div class="mt-1 text-sm font-medium text-green-600">{{ stats?.sentimentDistribution?.POSITIVE || 0 }}</div>
|
||||
<div class="text-xs text-gray-500">正面</div>
|
||||
</div>
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="w-16 bg-gray-200 rounded-t" :style="{ height: getSentimentBarHeight('NEUTRAL') + 'px' }"></div>
|
||||
<div class="mt-1 text-sm font-medium text-gray-600">{{ stats?.sentimentDistribution?.NEUTRAL || 0 }}</div>
|
||||
<div class="text-xs text-gray-500">中性</div>
|
||||
</div>
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="w-16 bg-red-100 rounded-t" :style="{ height: getSentimentBarHeight('NEGATIVE') + 'px' }"></div>
|
||||
<div class="mt-1 text-sm font-medium text-red-600">{{ stats?.sentimentDistribution?.NEGATIVE || 0 }}</div>
|
||||
<div class="text-xs text-gray-500">负面</div>
|
||||
</div>
|
||||
</div>
|
||||
</VCard>
|
||||
|
||||
<!-- Daily Trend -->
|
||||
<VCard class="mt-4" :body-class="['!p-4']">
|
||||
<h3 class="text-base font-medium mb-3">近7日回复趋势</h3>
|
||||
<div class="flex items-end gap-2 h-32">
|
||||
<div
|
||||
v-for="day in (stats?.dailyTrend || [])"
|
||||
:key="day.date"
|
||||
class="flex-1 flex flex-col items-center justify-end h-full"
|
||||
>
|
||||
<div class="text-xs text-gray-500 mb-1">{{ day.count }}</div>
|
||||
<div
|
||||
class="w-full bg-blue-400 rounded-t transition-all"
|
||||
:style="{ height: getTrendBarHeight(day.count) + 'px' }"
|
||||
></div>
|
||||
<div class="text-[10px] text-gray-400 mt-1">{{ formatTrendDate(day.date) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</VCard>
|
||||
|
||||
<!-- AI Persona Section -->
|
||||
<VCard class="mt-4" :body-class="['!p-4']">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-medium">AI角色信息</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
AI虚拟评论者,以独立身份参与评论讨论
|
||||
</p>
|
||||
</div>
|
||||
<VButton @click="openSettings"> 修改配置 </VButton>
|
||||
</div>
|
||||
|
||||
<div v-if="persona" class="mt-4 space-y-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="h-10 w-10 rounded-full bg-gray-200 flex items-center justify-center text-lg">
|
||||
🤖
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-medium">{{ persona.name }}</div>
|
||||
<div class="text-xs text-gray-400">AI虚拟评论者</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="persona.prompt" class="flex items-start gap-2">
|
||||
<span class="text-sm text-gray-500 shrink-0 whitespace-nowrap">人格提示词:</span>
|
||||
<span class="text-sm text-gray-600 line-clamp-3">{{ persona.prompt }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="mt-4 text-sm text-gray-400">
|
||||
加载中...
|
||||
</div>
|
||||
</VCard>
|
||||
|
||||
<!-- Quick Links -->
|
||||
<div class="mt-4 flex gap-4">
|
||||
<VButton @click="$router.push({ name: 'CommentAiAutopilotLogs' })">
|
||||
查看AI回复日志
|
||||
</VButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue"
|
||||
import { axiosInstance } from "@halo-dev/api-client"
|
||||
import { VPageHeader, VButton, VCard, Toast } from "@halo-dev/components"
|
||||
import { IconPlug } from "@halo-dev/components"
|
||||
|
||||
interface DailyCount {
|
||||
date: string
|
||||
count: number
|
||||
}
|
||||
|
||||
interface StatsResponse {
|
||||
total: number
|
||||
passCount: number
|
||||
failCount: number
|
||||
reviewingCount: number
|
||||
avgScore: number
|
||||
sentimentDistribution: Record<string, number>
|
||||
dailyTrend: DailyCount[]
|
||||
}
|
||||
|
||||
interface PersonaResponse {
|
||||
name: string
|
||||
prompt: string
|
||||
avatar: string
|
||||
}
|
||||
|
||||
const stats = ref<StatsResponse | null>(null)
|
||||
const persona = ref<PersonaResponse | null>(null)
|
||||
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const { data } = await axiosInstance.get(
|
||||
"/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/stats",
|
||||
)
|
||||
stats.value = data
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch stats", e)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchPersona = async () => {
|
||||
try {
|
||||
const { data } = await axiosInstance.get(
|
||||
"/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/persona",
|
||||
)
|
||||
persona.value = data
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch persona", e)
|
||||
}
|
||||
}
|
||||
|
||||
const openSettings = () => {
|
||||
window.location.href = "/console/comment-ai-autopilot/settings"
|
||||
}
|
||||
|
||||
const getSentimentBarHeight = (sentiment: string): number => {
|
||||
const dist = stats.value?.sentimentDistribution
|
||||
if (!dist) return 0
|
||||
const max = Math.max(dist.POSITIVE || 0, dist.NEUTRAL || 0, dist.NEGATIVE || 0, 1)
|
||||
const value = dist[sentiment] || 0
|
||||
return Math.max((value / max) * 80, value > 0 ? 8 : 0)
|
||||
}
|
||||
|
||||
const getTrendBarHeight = (count: number): number => {
|
||||
const trend = stats.value?.dailyTrend
|
||||
if (!trend || trend.length === 0) return 0
|
||||
const max = Math.max(...trend.map(d => d.count), 1)
|
||||
return Math.max((count / max) * 80, count > 0 ? 8 : 0)
|
||||
}
|
||||
|
||||
const formatTrendDate = (dateStr: string): string => {
|
||||
if (!dateStr) return ''
|
||||
const parts = dateStr.split('-')
|
||||
return parts.length >= 3 ? `${parts[1]}/${parts[2]}` : dateStr
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchStats()
|
||||
fetchPersona()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,580 @@
|
||||
<template>
|
||||
<div class="comment-ai-autopilot-logs">
|
||||
<VPageHeader title="AI回复日志">
|
||||
<template #icon>
|
||||
<IconPlug class="mr-2 self-center" />
|
||||
</template>
|
||||
<template #actions>
|
||||
<VButton @click="$router.push({ name: 'CommentAiAutopilot' })"> 返回概览 </VButton>
|
||||
</template>
|
||||
</VPageHeader>
|
||||
|
||||
<!-- Batch Operation Toolbar -->
|
||||
<div v-if="selectedNames.size > 0" class="mx-4 mt-2 flex items-center gap-3 bg-blue-50 border border-blue-200 rounded-lg px-4 py-2.5">
|
||||
<span class="text-sm text-blue-700">已选择 {{ selectedNames.size }} 项</span>
|
||||
<button
|
||||
class="text-xs px-3 py-1 rounded bg-green-600 text-white hover:bg-green-700 transition-colors"
|
||||
@click="batchApprove"
|
||||
>
|
||||
批量通过
|
||||
</button>
|
||||
<button
|
||||
class="text-xs px-3 py-1 rounded bg-orange-500 text-white hover:bg-orange-600 transition-colors"
|
||||
@click="batchReject"
|
||||
>
|
||||
批量拒绝
|
||||
</button>
|
||||
<button
|
||||
class="text-xs px-3 py-1 rounded bg-red-600 text-white hover:bg-red-700 transition-colors"
|
||||
@click="batchDelete"
|
||||
>
|
||||
批量删除
|
||||
</button>
|
||||
<button
|
||||
class="text-xs text-gray-500 hover:text-gray-700 ml-auto"
|
||||
@click="selectedNames.clear(); selectAll = false"
|
||||
>
|
||||
取消选择
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="m-4">
|
||||
<VLoading v-if="loading" />
|
||||
|
||||
<div v-else-if="replies.length === 0" class="flex flex-col items-center justify-center py-16 text-gray-400">
|
||||
<svg class="w-12 h-12 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<span>暂无AI回复记录</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div v-if="replies.length > 0" class="flex items-center gap-2 mb-2 px-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectAll"
|
||||
@change="toggleSelectAll"
|
||||
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span class="text-xs text-gray-500">全选</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="reply in replies"
|
||||
:key="reply.metadata.name"
|
||||
class="bg-white rounded-lg border border-gray-200 overflow-hidden hover:shadow-sm transition-all"
|
||||
>
|
||||
<!-- Card body -->
|
||||
<div class="p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedNames.has(reply.metadata.name)"
|
||||
@change="toggleSelect(reply.metadata.name)"
|
||||
class="mt-1 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 shrink-0"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- Row 1: Status tags + time -->
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-1.5 flex-wrap">
|
||||
<span
|
||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
|
||||
:class="getStatusClass(reply.spec.status)"
|
||||
>
|
||||
{{ getStatusLabel(reply.spec.status) }}
|
||||
</span>
|
||||
<span
|
||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs"
|
||||
:class="reply.spec.published ? 'bg-green-50 text-green-600' : 'bg-gray-50 text-gray-400'"
|
||||
>
|
||||
{{ reply.spec.published ? '已发布' : '未发布' }}
|
||||
</span>
|
||||
<span
|
||||
v-if="reply.spec.isAiConversation"
|
||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs bg-blue-50 text-blue-600"
|
||||
>
|
||||
对话
|
||||
</span>
|
||||
<span
|
||||
v-if="reply.spec.sentiment"
|
||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
|
||||
:class="getSentimentClass(reply.spec.sentiment)"
|
||||
>
|
||||
{{ getSentimentLabel(reply.spec.sentiment) }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-xs text-gray-400 flex-shrink-0 ml-2">{{ formatDate(reply.metadata.creationTimestamp) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Row 2: AI reply content (truncated) -->
|
||||
<div class="text-sm text-gray-800 leading-relaxed break-words line-clamp-3">
|
||||
{{ stripHtml(reply.spec.reply) || '(空)' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card footer: meta info + actions -->
|
||||
<div class="px-4 py-2.5 bg-gray-50 border-t border-gray-100 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4 text-xs text-gray-400">
|
||||
<span>
|
||||
评分 <span :class="getScoreClass(reply.spec.score)" class="font-medium text-gray-600">{{ reply.spec.score }}</span>
|
||||
</span>
|
||||
<span v-if="reply.spec.postSlug" class="flex items-center gap-1">
|
||||
文章
|
||||
<a
|
||||
:href="getPostUrl(reply.spec.postSlug)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-blue-500 hover:text-blue-700 hover:underline"
|
||||
>{{ reply.spec.postSlug }}</a>
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</span>
|
||||
<span v-if="reply.spec.retryCount > 0">
|
||||
重试 <span class="text-gray-600">{{ reply.spec.retryCount }}</span> 次
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<template v-if="reply.spec.status === 'PASS' && !reply.spec.published">
|
||||
<button
|
||||
class="inline-flex items-center gap-1 text-xs text-green-600 hover:text-green-800 transition-colors px-2 py-1 rounded hover:bg-green-50"
|
||||
@click="handleApprove(reply.metadata.name)"
|
||||
>
|
||||
审核通过
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex items-center gap-1 text-xs text-red-500 hover:text-red-700 transition-colors px-2 py-1 rounded hover:bg-red-50"
|
||||
@click="handleReject(reply.metadata.name)"
|
||||
>
|
||||
拒绝
|
||||
</button>
|
||||
</template>
|
||||
<button
|
||||
class="inline-flex items-center gap-1 text-xs text-blue-500 hover:text-blue-700 transition-colors px-2 py-1 rounded hover:bg-blue-50"
|
||||
@click="openConversation(reply)"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
查看
|
||||
</button>
|
||||
<button
|
||||
class="text-xs text-red-400 hover:text-red-600 transition-colors px-2 py-1 rounded hover:bg-red-50"
|
||||
@click="handleDelete(reply.metadata.name)"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="totalPages > 1" class="flex items-center justify-between mt-4 px-1">
|
||||
<span class="text-xs text-gray-400">共 {{ total }} 条</span>
|
||||
<div class="flex gap-2">
|
||||
<VButton size="sm" :disabled="page <= 1" @click="page--">上一页</VButton>
|
||||
<VButton size="sm" :disabled="page >= totalPages" @click="page++">下一页</VButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Conversation Dialog -->
|
||||
<teleport to="body">
|
||||
<div
|
||||
v-if="showDialog"
|
||||
class="fixed inset-0 z-[9999] flex items-center justify-center"
|
||||
>
|
||||
<!-- Overlay -->
|
||||
<div
|
||||
class="absolute inset-0 bg-black/40"
|
||||
@click="showDialog = false"
|
||||
></div>
|
||||
|
||||
<!-- Dialog -->
|
||||
<div class="relative bg-white rounded-xl shadow-2xl w-full max-w-2xl max-h-[80vh] mx-4 flex flex-col overflow-hidden">
|
||||
<!-- Dialog header -->
|
||||
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-100">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
<h3 class="text-base font-semibold text-gray-800">完整对话</h3>
|
||||
</div>
|
||||
<button
|
||||
class="text-gray-400 hover:text-gray-600 transition-colors p-1 rounded-lg hover:bg-gray-100"
|
||||
@click="showDialog = false"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Dialog body: conversation -->
|
||||
<div class="flex-1 overflow-y-auto px-5 py-4 space-y-4">
|
||||
<VLoading v-if="conversationLoading" />
|
||||
|
||||
<div v-else-if="conversationMessages.length === 0" class="text-center text-gray-400 py-8">
|
||||
暂无对话内容
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div
|
||||
v-for="(msg, idx) in conversationMessages"
|
||||
:key="idx"
|
||||
class="flex"
|
||||
:class="msg.isAi ? 'justify-start' : 'justify-end'"
|
||||
>
|
||||
<div class="max-w-[80%]">
|
||||
<!-- Owner name -->
|
||||
<div
|
||||
class="text-xs mb-1"
|
||||
:class="msg.isAi ? 'text-blue-500' : 'text-gray-500'"
|
||||
>
|
||||
<span class="font-medium">{{ msg.owner }}</span>
|
||||
</div>
|
||||
<!-- Bubble -->
|
||||
<div
|
||||
class="rounded-2xl px-4 py-2.5 text-sm leading-relaxed break-words"
|
||||
:class="msg.isAi
|
||||
? 'bg-blue-50 text-gray-800 rounded-tl-md'
|
||||
: 'bg-gray-100 text-gray-800 rounded-tr-md'"
|
||||
v-html="renderContent(msg.content)"
|
||||
></div>
|
||||
<!-- Time -->
|
||||
<div class="text-[10px] text-gray-300 mt-1" :class="msg.isAi ? 'text-left' : 'text-right'">
|
||||
{{ formatDate(msg.time) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dialog footer -->
|
||||
<div class="px-5 py-3 border-t border-gray-100 flex justify-end">
|
||||
<button
|
||||
class="px-4 py-1.5 text-sm text-gray-600 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
|
||||
@click="showDialog = false"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from "vue"
|
||||
import { axiosInstance } from "@halo-dev/api-client"
|
||||
import { VPageHeader, VButton, VLoading, Toast } from "@halo-dev/components"
|
||||
import { IconPlug } from "@halo-dev/components"
|
||||
|
||||
interface AiCommentReplyItem {
|
||||
metadata: {
|
||||
name: string
|
||||
creationTimestamp: string
|
||||
}
|
||||
spec: {
|
||||
commentId: string
|
||||
postId: string
|
||||
postSlug: string
|
||||
reply: string
|
||||
score: number
|
||||
status: string
|
||||
retryCount: number
|
||||
replyTo: string
|
||||
isAiConversation: boolean
|
||||
published: boolean
|
||||
sentiment: string | null
|
||||
}
|
||||
}
|
||||
|
||||
interface ConversationMessage {
|
||||
type: string
|
||||
owner: string
|
||||
content: string
|
||||
time: string
|
||||
isAi: boolean
|
||||
}
|
||||
|
||||
const replies = ref<AiCommentReplyItem[]>([])
|
||||
const loading = ref(false)
|
||||
const page = ref(1)
|
||||
const size = ref(20)
|
||||
const total = ref(0)
|
||||
const totalPages = ref(0)
|
||||
|
||||
// Selection state
|
||||
const selectedNames = ref<Set<string>>(new Set())
|
||||
const selectAll = ref(false)
|
||||
|
||||
const toggleSelect = (name: string) => {
|
||||
if (selectedNames.value.has(name)) {
|
||||
selectedNames.value.delete(name)
|
||||
} else {
|
||||
selectedNames.value.add(name)
|
||||
}
|
||||
// Update selectAll state
|
||||
selectAll.value = replies.value.length > 0 && replies.value.every(r => selectedNames.value.has(r.metadata.name))
|
||||
}
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectAll.value) {
|
||||
selectedNames.value.clear()
|
||||
selectAll.value = false
|
||||
} else {
|
||||
selectedNames.value = new Set(replies.value.map(r => r.metadata.name))
|
||||
selectAll.value = true
|
||||
}
|
||||
}
|
||||
|
||||
// Conversation dialog state
|
||||
const showDialog = ref(false)
|
||||
const conversationLoading = ref(false)
|
||||
const conversationMessages = ref<ConversationMessage[]>([])
|
||||
|
||||
const fetchReplies = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await axiosInstance.get(
|
||||
"/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies",
|
||||
{ params: { page: page.value, size: size.value } },
|
||||
)
|
||||
replies.value = data.items || []
|
||||
total.value = data.total || 0
|
||||
totalPages.value = Math.ceil(total.value / size.value)
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch replies", e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openConversation = async (reply: AiCommentReplyItem) => {
|
||||
showDialog.value = true
|
||||
conversationLoading.value = true
|
||||
conversationMessages.value = []
|
||||
try {
|
||||
const { data } = await axiosInstance.get(
|
||||
`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/conversation/${reply.spec.commentId}`,
|
||||
)
|
||||
conversationMessages.value = data.messages || []
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch conversation", e)
|
||||
Toast.error("获取对话失败")
|
||||
} finally {
|
||||
conversationLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (name: string) => {
|
||||
try {
|
||||
await axiosInstance.delete(
|
||||
`/apis/comment-ai-autopilot.nxxy335.top/v1alpha1/aicommentreplies/${name}`,
|
||||
)
|
||||
Toast.success("删除成功")
|
||||
fetchReplies()
|
||||
} catch (e) {
|
||||
console.error("Failed to delete reply", e)
|
||||
Toast.error("删除失败")
|
||||
}
|
||||
}
|
||||
|
||||
const handleApprove = async (name: string) => {
|
||||
try {
|
||||
await axiosInstance.post(
|
||||
`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/${name}/approve`,
|
||||
)
|
||||
Toast.success("审核通过")
|
||||
fetchReplies()
|
||||
} catch (e) {
|
||||
console.error("Failed to approve reply", e)
|
||||
Toast.error("审核操作失败")
|
||||
}
|
||||
}
|
||||
|
||||
const handleReject = async (name: string) => {
|
||||
try {
|
||||
await axiosInstance.post(
|
||||
`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/${name}/reject`,
|
||||
)
|
||||
Toast.success("已拒绝")
|
||||
fetchReplies()
|
||||
} catch (e) {
|
||||
console.error("Failed to reject reply", e)
|
||||
Toast.error("拒绝操作失败")
|
||||
}
|
||||
}
|
||||
|
||||
const batchApprove = async () => {
|
||||
if (selectedNames.value.size === 0) return
|
||||
try {
|
||||
await axiosInstance.post(
|
||||
"/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/batch-approve",
|
||||
{ names: Array.from(selectedNames.value) }
|
||||
)
|
||||
Toast.success("批量审核通过成功")
|
||||
selectedNames.value.clear()
|
||||
selectAll.value = false
|
||||
fetchReplies()
|
||||
} catch (e) {
|
||||
Toast.error("批量审核操作失败")
|
||||
}
|
||||
}
|
||||
|
||||
const batchReject = async () => {
|
||||
if (selectedNames.value.size === 0) return
|
||||
try {
|
||||
await axiosInstance.post(
|
||||
"/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/batch-reject",
|
||||
{ names: Array.from(selectedNames.value) }
|
||||
)
|
||||
Toast.success("批量拒绝成功")
|
||||
selectedNames.value.clear()
|
||||
selectAll.value = false
|
||||
fetchReplies()
|
||||
} catch (e) {
|
||||
Toast.error("批量拒绝操作失败")
|
||||
}
|
||||
}
|
||||
|
||||
const batchDelete = async () => {
|
||||
if (selectedNames.value.size === 0) return
|
||||
try {
|
||||
await axiosInstance.post(
|
||||
"/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/batch-delete",
|
||||
{ names: Array.from(selectedNames.value) }
|
||||
)
|
||||
Toast.success("批量删除成功")
|
||||
selectedNames.value.clear()
|
||||
selectAll.value = false
|
||||
fetchReplies()
|
||||
} catch (e) {
|
||||
Toast.error("批量删除操作失败")
|
||||
}
|
||||
}
|
||||
|
||||
const getScoreClass = (score: number) => {
|
||||
if (score >= 85) return "text-green-600"
|
||||
if (score >= 60) return "text-yellow-600"
|
||||
return "text-red-600"
|
||||
}
|
||||
|
||||
const getStatusClass = (status: string) => {
|
||||
switch (status) {
|
||||
case "PASS":
|
||||
return "bg-green-100 text-green-700"
|
||||
case "FAIL":
|
||||
return "bg-red-100 text-red-700"
|
||||
case "PENDING":
|
||||
return "bg-gray-100 text-gray-700"
|
||||
case "REJECTED":
|
||||
return "bg-red-100 text-red-700"
|
||||
default:
|
||||
return "bg-gray-100 text-gray-700"
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case "PASS":
|
||||
return "通过"
|
||||
case "FAIL":
|
||||
return "失败"
|
||||
case "PENDING":
|
||||
return "待处理"
|
||||
case "REJECTED":
|
||||
return "已拒绝"
|
||||
default:
|
||||
return status
|
||||
}
|
||||
}
|
||||
|
||||
const getSentimentClass = (sentiment: string) => {
|
||||
switch (sentiment) {
|
||||
case "POSITIVE":
|
||||
return "bg-green-50 text-green-600"
|
||||
case "NEGATIVE":
|
||||
return "bg-red-50 text-red-600"
|
||||
case "NEUTRAL":
|
||||
return "bg-gray-100 text-gray-600"
|
||||
default:
|
||||
return "bg-gray-100 text-gray-600"
|
||||
}
|
||||
}
|
||||
|
||||
const getSentimentLabel = (sentiment: string) => {
|
||||
switch (sentiment) {
|
||||
case "POSITIVE":
|
||||
return "正面"
|
||||
case "NEGATIVE":
|
||||
return "负面"
|
||||
case "NEUTRAL":
|
||||
return "中性"
|
||||
default:
|
||||
return sentiment
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (timestamp: string) => {
|
||||
if (!timestamp) return ""
|
||||
return new Date(timestamp).toLocaleString("zh-CN")
|
||||
}
|
||||
|
||||
const getPostUrl = (slug: string) => {
|
||||
return `${window.location.origin}/archives/${slug}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip HTML tags for plain text display (card preview)
|
||||
*/
|
||||
const stripHtml = (html: string) => {
|
||||
if (!html) return ""
|
||||
return html
|
||||
.replace(/<p[^>]*>/gi, "")
|
||||
.replace(/<\/p>/gi, "\n")
|
||||
.replace(/<br\s*\/?>/gi, "\n")
|
||||
.replace(/<[^>]+>/g, "")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize and render HTML content for conversation bubbles.
|
||||
* Only allows safe inline tags, strips dangerous elements.
|
||||
*/
|
||||
const renderContent = (content: string) => {
|
||||
if (!content) return "<span class='text-gray-400'>(空)</span>"
|
||||
return content
|
||||
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "")
|
||||
.replace(/<iframe[^>]*>[\s\S]*?<\/iframe>/gi, "")
|
||||
.replace(/<object[^>]*>[\s\S]*?<\/object>/gi, "")
|
||||
.replace(/<embed[^>]*>/gi, "")
|
||||
.replace(/<form[^>]*>[\s\S]*?<\/form>/gi, "")
|
||||
.replace(/on\w+\s*=\s*["'][^"']*["']/gi, "")
|
||||
.replace(/on\w+\s*=\s*[^\s>]*/gi, "")
|
||||
.replace(/<p[^>]*>/gi, "<p style='margin:0 0 0.5em 0'>")
|
||||
.replace(/<a /gi, "<a target='_blank' rel='noopener noreferrer' ")
|
||||
}
|
||||
|
||||
watch(page, () => {
|
||||
selectedNames.value.clear()
|
||||
selectAll.value = false
|
||||
fetchReplies()
|
||||
})
|
||||
|
||||
onMounted(fetchReplies)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.line-clamp-3 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,271 @@
|
||||
<template>
|
||||
<div class="comment-ai-autopilot-settings">
|
||||
<VPageHeader title="插件设置">
|
||||
<template #icon>
|
||||
<IconPlug class="mr-2 self-center" />
|
||||
</template>
|
||||
<template #actions>
|
||||
<VButton @click="$router.push({ name: 'CommentAiAutopilot' })"> 返回概览 </VButton>
|
||||
</template>
|
||||
</VPageHeader>
|
||||
|
||||
<div class="m-4">
|
||||
<VCard :body-class="['!p-0']">
|
||||
<!-- Tab Navigation -->
|
||||
<div class="flex border-b">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
class="px-6 py-3 text-sm font-medium border-b-2 transition-colors"
|
||||
:class="activeTab === tab.key
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'"
|
||||
@click="activeTab = tab.key"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<VLoading v-if="loading" />
|
||||
|
||||
<!-- Basic Settings -->
|
||||
<div v-if="activeTab === 'basic' && !loading" class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="font-medium">自动回复</div>
|
||||
<div class="text-sm text-gray-500">启用后,AI将自动回复新评论</div>
|
||||
</div>
|
||||
<label class="relative inline-flex cursor-pointer items-center">
|
||||
<input type="checkbox" v-model="settings.basic.autoReply" class="peer sr-only" />
|
||||
<div class="peer h-6 w-11 rounded-full bg-gray-200 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all peer-checked:bg-blue-600 peer-checked:after:translate-x-full peer-checked:after:border-white"></div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="font-medium">自动发布</div>
|
||||
<div class="text-sm text-gray-500">审核通过后自动发布AI回复</div>
|
||||
</div>
|
||||
<label class="relative inline-flex cursor-pointer items-center">
|
||||
<input type="checkbox" v-model="settings.basic.autoPublish" class="peer sr-only" />
|
||||
<div class="peer h-6 w-11 rounded-full bg-gray-200 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all peer-checked:bg-blue-600 peer-checked:after:translate-x-full peer-checked:after:border-white"></div>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="font-medium">最大重试次数</label>
|
||||
<input
|
||||
type="number"
|
||||
v-model.number="settings.basic.maxRetryCount"
|
||||
min="1"
|
||||
max="10"
|
||||
class="mt-1 block w-full max-w-xs rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="font-medium">评论者黑名单</label>
|
||||
<div class="mt-1 text-sm text-gray-500">输入评论者显示名称,多个用逗号分隔。这些评论者的评论不会触发AI回复</div>
|
||||
<textarea
|
||||
v-model="settings.basic.blockedCommenters"
|
||||
rows="3"
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
placeholder="例如: 张三,李四"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Persona Settings -->
|
||||
<div v-if="activeTab === 'persona' && !loading" class="space-y-6">
|
||||
<div>
|
||||
<label class="font-medium">AI角色昵称</label>
|
||||
<input
|
||||
type="text"
|
||||
v-model="settings.persona.personaName"
|
||||
class="mt-1 block w-full max-w-md rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
placeholder="小回"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="font-medium">AI角色邮箱</label>
|
||||
<div class="mt-1 text-sm text-gray-500">用于Gravatar头像服务展示头像,留空则使用默认头像</div>
|
||||
<input
|
||||
type="email"
|
||||
v-model="settings.persona.personaEmail"
|
||||
class="mt-1 block w-full max-w-md rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
placeholder="ai@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="font-medium">AI角色人格提示词</label>
|
||||
<textarea
|
||||
v-model="settings.persona.personaPrompt"
|
||||
rows="4"
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
placeholder="定义AI角色的性格和回复风格"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Model Settings -->
|
||||
<div v-if="activeTab === 'model' && !loading" class="space-y-6">
|
||||
<div>
|
||||
<label class="font-medium">AI模型名称</label>
|
||||
<div class="mt-1 text-sm text-gray-500">留空使用AI Foundation默认模型,填写AiModel资源名称可指定模型</div>
|
||||
<input
|
||||
type="text"
|
||||
v-model="settings.model.modelName"
|
||||
class="mt-2 block w-full max-w-md rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
placeholder="留空使用默认模型"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Prompt Settings -->
|
||||
<div v-if="activeTab === 'prompt' && !loading" class="space-y-6">
|
||||
<div>
|
||||
<label class="font-medium">自定义Prompt模板</label>
|
||||
<div class="mt-1 text-sm text-gray-500">
|
||||
可用变量: <code class="text-xs bg-gray-100 px-1">{'{{'}persona_prompt{{'}}'}}</code>,
|
||||
<code class="text-xs bg-gray-100 px-1">{'{{'}safety_prompt{{'}}'}}</code>,
|
||||
<code class="text-xs bg-gray-100 px-1">{'{{'}article{{'}}'}}</code>,
|
||||
<code class="text-xs bg-gray-100 px-1">{'{{'}comment{{'}}'}}</code>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="settings.prompt.customPromptTemplate"
|
||||
rows="10"
|
||||
class="mt-2 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm font-mono focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
placeholder="自定义Prompt模板"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save Button -->
|
||||
<div v-if="!loading" class="mt-6 flex justify-end">
|
||||
<VButton type="primary" @click="saveSettings" :disabled="saving">
|
||||
{{ saving ? '保存中...' : '保存设置' }}
|
||||
</VButton>
|
||||
</div>
|
||||
</div>
|
||||
</VCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from "vue"
|
||||
import { axiosInstance } from "@halo-dev/api-client"
|
||||
import { VPageHeader, VButton, VCard, VLoading, Toast } from "@halo-dev/components"
|
||||
import { IconPlug } from "@halo-dev/components"
|
||||
|
||||
const tabs = [
|
||||
{ key: "basic", label: "基本设置" },
|
||||
{ key: "persona", label: "AI角色设置" },
|
||||
{ key: "model", label: "模型设置" },
|
||||
{ key: "prompt", label: "Prompt设置" },
|
||||
]
|
||||
|
||||
const activeTab = ref("basic")
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
const settings = reactive({
|
||||
basic: {
|
||||
autoReply: true,
|
||||
autoPublish: true,
|
||||
maxRetryCount: 3,
|
||||
blockedCommenters: "",
|
||||
},
|
||||
persona: {
|
||||
personaName: "小回",
|
||||
personaPrompt: "",
|
||||
personaEmail: "",
|
||||
},
|
||||
model: {
|
||||
modelName: "",
|
||||
},
|
||||
prompt: {
|
||||
customPromptTemplate: "",
|
||||
},
|
||||
})
|
||||
|
||||
const configMapName = "comment-ai-autopilot-configmap"
|
||||
|
||||
const fetchSettings = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await axiosInstance.get(
|
||||
`/api/v1alpha1/configmaps/${configMapName}`,
|
||||
)
|
||||
if (data.data) {
|
||||
const d = data.data
|
||||
if (d.basic) {
|
||||
settings.basic.autoReply = d.basic.autoReply !== false
|
||||
settings.basic.autoPublish = d.basic.autoPublish !== false
|
||||
settings.basic.maxRetryCount = d.basic.maxRetryCount || 3
|
||||
settings.basic.blockedCommenters = d.basic.blockedCommenters || ""
|
||||
}
|
||||
if (d.persona) {
|
||||
settings.persona.personaName = d.persona.personaName || "小回"
|
||||
settings.persona.personaPrompt = d.persona.personaPrompt || ""
|
||||
settings.persona.personaEmail = d.persona.personaEmail || ""
|
||||
}
|
||||
if (d.model) {
|
||||
settings.model.modelName = d.model.modelName || ""
|
||||
}
|
||||
if (d.prompt) {
|
||||
settings.prompt.customPromptTemplate = d.prompt.customPromptTemplate || ""
|
||||
}
|
||||
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch settings", e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const saveSettings = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
// Fetch latest version first
|
||||
const { data: latest } = await axiosInstance.get(
|
||||
`/api/v1alpha1/configmaps/${configMapName}`,
|
||||
)
|
||||
|
||||
const updated = { ...latest }
|
||||
updated.data = {
|
||||
...updated.data,
|
||||
basic: {
|
||||
autoReply: settings.basic.autoReply,
|
||||
autoPublish: settings.basic.autoPublish,
|
||||
maxRetryCount: settings.basic.maxRetryCount,
|
||||
blockedCommenters: settings.basic.blockedCommenters,
|
||||
},
|
||||
persona: {
|
||||
personaName: settings.persona.personaName,
|
||||
personaPrompt: settings.persona.personaPrompt,
|
||||
personaEmail: settings.persona.personaEmail,
|
||||
},
|
||||
model: {
|
||||
modelName: settings.model.modelName,
|
||||
},
|
||||
prompt: {
|
||||
customPromptTemplate: settings.prompt.customPromptTemplate,
|
||||
},
|
||||
|
||||
}
|
||||
|
||||
await axiosInstance.put(
|
||||
`/api/v1alpha1/configmaps/${configMapName}`,
|
||||
updated,
|
||||
)
|
||||
Toast.success("设置已保存")
|
||||
} catch (e) {
|
||||
console.error("Failed to save settings", e)
|
||||
Toast.error("保存设置失败")
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchSettings)
|
||||
</script>
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.vitest.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "@tsconfig/node20/tsconfig.json",
|
||||
"include": [
|
||||
"rsbuild.config.*",
|
||||
"vitest.config.*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"noEmit": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "./tsconfig.app.json",
|
||||
"include": ["src/**/__tests__/*", "env.d.ts"],
|
||||
"exclude": [],
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.vitest.tsbuildinfo",
|
||||
|
||||
"lib": [],
|
||||
"types": ["node", "jsdom"]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user