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