diff --git a/ai_summary/README.md b/ai_summary/README.md new file mode 100644 index 000000000..b2096aacf --- /dev/null +++ b/ai_summary/README.md @@ -0,0 +1,3 @@ +# indico-plugin-ai-summary + +This plugin automatically generates summaries of meeting minutes stored in Indico utilizing an open-source Large Language Model (LLM). It enables users to select the minutes they wish to summarize, to choose pre-written prompts or write custom ones to generate the summaries. summary-rebase \ No newline at end of file diff --git a/ai_summary/indico_ai_summary/__init__.py b/ai_summary/indico_ai_summary/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/ai_summary/indico_ai_summary/__init__.py @@ -0,0 +1 @@ + diff --git a/ai_summary/indico_ai_summary/blueprint.py b/ai_summary/indico_ai_summary/blueprint.py new file mode 100644 index 000000000..8889aced3 --- /dev/null +++ b/ai_summary/indico_ai_summary/blueprint.py @@ -0,0 +1,18 @@ +# This file is part of the Indico plugins. +# Copyright (C) 2002 - 2025 CERN +# +# The Indico plugins are free software; you can redistribute +# them and/or modify them under the terms of the MIT License; +# see the LICENSE file for more details. + +from indico.core.plugins import IndicoPluginBlueprint + +from indico_ai_summary.controllers import RHLLMPrompts, RHManageCategoryPrompts, RHSummarizeEvent + + +blueprint = IndicoPluginBlueprint('ai_summary', __name__, url_prefix='/plugin/ai-summary') + +blueprint.add_url_rule('!/category//manage/prompts', 'manage_category_prompts', + RHManageCategoryPrompts, methods=('GET', 'POST')) +blueprint.add_url_rule('/llm-prompts/', 'llm_prompts', RHLLMPrompts, methods=('GET',)) +blueprint.add_url_rule('/summarize-event/', 'summarize_event', RHSummarizeEvent, methods=('POST',)) diff --git a/ai_summary/indico_ai_summary/client/CategoryManagePrompts.jsx b/ai_summary/indico_ai_summary/client/CategoryManagePrompts.jsx new file mode 100644 index 000000000..ca90ace05 --- /dev/null +++ b/ai_summary/indico_ai_summary/client/CategoryManagePrompts.jsx @@ -0,0 +1,54 @@ +// This file is part of the Indico plugins. +// Copyright (C) 2002 - 2025 CERN +// +// The Indico plugins are free software; you can redistribute +// them and/or modify them under the terms of the MIT License; +// see the LICENSE file for more details. + +import manageCategoryPrompts from 'indico-url:plugin_ai_summary.manage_category_prompts'; + +import _ from 'lodash'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import {Form as FinalForm} from 'react-final-form'; +import {Form} from 'semantic-ui-react'; + +import {indicoAxios} from 'indico/utils/axios'; +import {FinalSubmitButton, handleSubmitError} from 'indico/react/forms'; + +import {FinalPromptManagerField} from './components/PromptManagerField'; + +export default function CategoryManagePrompts({categoryId, prompts: predefinedPrompts}) { + const onSubmit = async ({prompts}, form) => { + try { + await indicoAxios.post(manageCategoryPrompts({category_id: categoryId}), {prompts}); + form.initialize({prompts}); + } catch (error) { + handleSubmitError(error); + } + }; + + const submitBtn = ; + + return ( + + {fprops => ( +
+ + + )} +
+ ); +} + +window.setupCategoryManagePrompts = function setupCategoryManagePrompts() { + const container = document.getElementById('plugin-ai-summary-prompts'); + const categoryId = parseInt(container.dataset.categoryId, 10); + const prompts = JSON.parse(container.dataset.prompts); + ReactDOM.render(, container); +}; diff --git a/ai_summary/indico_ai_summary/client/components/ActionButtons.jsx b/ai_summary/indico_ai_summary/client/components/ActionButtons.jsx new file mode 100644 index 000000000..8bb107b3b --- /dev/null +++ b/ai_summary/indico_ai_summary/client/components/ActionButtons.jsx @@ -0,0 +1,134 @@ +// This file is part of the Indico plugins. +// Copyright (C) 2002 - 2025 CERN +// +// The Indico plugins are free software; you can redistribute +// them and/or modify them under the terms of the MIT License; +// see the LICENSE file for more details. + +import React, {useState, useEffect} from 'react'; +import {Button, ButtonGroup, Icon, Popup, Dropdown, DropdownMenu, DropdownItem} from 'semantic-ui-react'; +import {Translate} from '../i18n'; + +import './ActionButtons.module.scss'; + +function CopyToClipboardButton({summaryHtml, summaryMarkdown}) { + const [isCopied, setIsCopied] = useState(false); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + + useEffect(() => { + if (isCopied) { + const timer = setTimeout(() => setIsCopied(false), 2000); + return () => clearTimeout(timer); + } + }, [isCopied]); + + function handleCopy(text) { + navigator.clipboard.writeText(text); + setIsCopied(true); + } + + const button = ( + + + + + } + content={isCopied ? Translate.string('Copied!') : Translate.string('Copy summary')} + position="top center" + disabled={isDropdownOpen} + /> + ); + + return ( + setIsDropdownOpen(true)} + onClose={() => setIsDropdownOpen(false)} + > + + { + handleCopy(summaryHtml); + }} + icon="code" + text={Translate.string('Copy HTML')} + /> + { + handleCopy(summaryMarkdown); + }} + icon="file alternate outline" + text={Translate.string('Copy Markdown')} + /> + + + ) +} + +function SaveSummaryButton({onSave, saving}) { + const [showSavedIcon, setShowSavedIcon] = useState(false); + const [prevSaving, setPrevSaving] = useState(false); + + useEffect(() => { + if (prevSaving && !saving) { + setShowSavedIcon(true); + const timer = setTimeout(() => setShowSavedIcon(false), 2000); + return () => clearTimeout(timer); + } + setPrevSaving(saving); + }, [saving, prevSaving]); + + return ( + + {saving ? ( + + ) : showSavedIcon ? ( + + ) : ( + + )} + + } + content={ + saving + ? Translate.string('Saving summary...') + : showSavedIcon + ? Translate.string('Saved!') + : Translate.string('Save the generated summary to the meeting minutes') + } + position="top center" + /> + ); +} + +export default function ActionButtons({loading, error, summaryHtml, summaryMarkdown, saving, onSave, onRetry}) { + if (!loading) { + return ( +
+ + {summaryHtml && !error && ( + + )} + + + + } + content={Translate.string('Retry generating summary')} + position="top center" + /> + + + {summaryHtml && !error && ( + + )} +
+ ); + } + + return null; +} diff --git a/ai_summary/indico_ai_summary/client/components/ActionButtons.module.scss b/ai_summary/indico_ai_summary/client/components/ActionButtons.module.scss new file mode 100644 index 000000000..be0a1dc94 --- /dev/null +++ b/ai_summary/indico_ai_summary/client/components/ActionButtons.module.scss @@ -0,0 +1,41 @@ +// This file is part of the Indico plugins. +// Copyright (C) 2002 - 2025 CERN +// +// The Indico plugins are free software; you can redistribute +// them and/or modify them under the terms of the MIT License; +// see the LICENSE file for more details. + +@use 'base/palette' as *; + +.action-buttons { + display: flex; + justify-content: space-between; + align-items: baseline; + + .llm-info { + color: $dark-gray; + } +} + +.action-buttons-container { + display: flex; + align-items: baseline; + gap: 0; + + .copy-button { + &:hover { + box-shadow: none !important; + } + + &:focus { + box-shadow: none !important; + } + + display: flex; + gap: 5px; + } + + .save-button { + margin-left: 10px; + } +} diff --git a/ai_summary/indico_ai_summary/client/components/PromptManagerField.jsx b/ai_summary/indico_ai_summary/client/components/PromptManagerField.jsx new file mode 100644 index 000000000..0a5df052c --- /dev/null +++ b/ai_summary/indico_ai_summary/client/components/PromptManagerField.jsx @@ -0,0 +1,124 @@ +// This file is part of the Indico plugins. +// Copyright (C) 2002 - 2025 CERN +// +// The Indico plugins are free software; you can redistribute +// them and/or modify them under the terms of the MIT License; +// see the LICENSE file for more details. + +import _ from 'lodash'; +import React, {useCallback, useMemo, useState} from 'react'; +import ReactDOM from 'react-dom'; +import {Form, TextArea, Button, Input, Card, Icon} from 'semantic-ui-react'; +import {Translate} from '../i18n'; + +import {FinalField} from 'indico/react/forms'; + +import './PromptManagerField.module.scss'; + +export function FinalPromptManagerField({submitBtn}) { + return ( + + ); +} + +export default function PromptManagerField({value, onChange, submitBtn}) { + const addPrompt = () => { + onChange([...value, {name: '', text: ''}]); + }; + + const removePrompt = idx => { + onChange(value.filter((_, i) => i !== idx)); + }; + + const updatePrompt = (idx, text) => { + onChange(value.map((p, i) => (i === idx ? {name: p.name, text} : p))); + }; + + const updateName = (idx, name) => { + onChange(value.map((p, i) => (i === idx ? {name, text: p.text} : p))); + }; + + return ( + <> + {value.map((prompt, idx) => ( + removePrompt(idx)} + onChangeText={text => updatePrompt(idx, text)} + onChangeName={name => updateName(idx, name)} + /> + ))} +
+ + {submitBtn} +
+ + ); +} + +function PromptField({name, text, onChangeName, onChangeText, onRemove}) { + return ( + + +
+ +
+ + Prompt Name + onChangeName(v)} + /> + + + Prompt Text +