From 9bf9d8a55de09c62989787ee12856de623058daf Mon Sep 17 00:00:00 2001 From: Voksik Date: Sun, 14 Dec 2025 06:31:00 +0300 Subject: [PATCH] add Custom provider --- app.py | 79 ++++++++------------- config.py | 13 +++- handlers/gradioHandler.py | 72 ++++++++++--------- handlers/metadataHandler.py | 21 ++++++ pandoc.txt | 7 ++ services/llm_factory.py | 7 +- services/llm_providers/custom_provider.py | 44 ++++++++++++ services/llm_providers/gemini_provider.py | 2 +- services/llm_providers/gpt4free_provider.py | 19 +++++ services/llm_providers/ionet_provider.py | 37 ++++++---- 10 files changed, 197 insertions(+), 104 deletions(-) create mode 100644 handlers/metadataHandler.py create mode 100644 pandoc.txt create mode 100644 services/llm_providers/custom_provider.py diff --git a/app.py b/app.py index 6f0eb48..95ba445 100644 --- a/app.py +++ b/app.py @@ -1,13 +1,7 @@ import gradio as gr - -# Загрузка параметров конфигурации from config import * - -# Подгрузка сервисов from services.llm_factory import get_llm_provider from services.fasterWhisper import FasterWhisper - -# Загрузка доп. модулей from handlers.gradioHandler import GradioHandlers from handlers.fileHandlers import FileHandlers from handlers.convertMdToPdf import ConvertMdToPdf @@ -17,16 +11,9 @@ gh = GradioHandlers(get_llm_provider, ConvertMdToPdf, FileHandlers, FasterWhispe def main(): with gr.Blocks() as demo: - gr.HTML(''' -
-

- Faster Whisper WebUI -

-
- ''') + gr.HTML('

Faster Whisper WebUI

') with gr.Row(): - # Вкладка с основным взаимодействием with gr.Tab('Actions'): isPipelineEnabledCheckbox = gr.Checkbox(label='is pipeline enabled', value=True, interactive=True) @@ -36,79 +23,64 @@ def main(): audioFiles = gr.Files(label='Load audio for transcribe', type="filepath") images = gr.Files(label='Upload images', file_types=['image']) recognizeBtn = gr.Button('recognize and integrate', variant='primary') - with gr.Accordion(label='Recognized text'): recognizedText = gr.TextArea(label='') with gr.Accordion(label='LLM'): with gr.Column(): refineTextBtn = gr.Button('refine text', variant='secondary', interactive=False) - with gr.Accordion(label='Refined text raw'): refinedText = gr.Textbox(label='', show_copy_button=True) - with gr.Accordion(label='Refined text md formated'): refinedTextMD = gr.Markdown(label='') - # Вкладка с настройками with gr.Tab('Settings'): with gr.Column(): - # Первое поле на всю ширину в акордионе настроек with gr.Accordion('File settings'): saveFileCheckbox = gr.Checkbox(label='save file', value=True, interactive=True) - filename = gr.Textbox(label='Output filename', value='output.txt', interactive=True) + filename = gr.Textbox(label='Output filename', value='output.md', interactive=True) filenamePdf = gr.Textbox(label='Output filename for pdf', value='output.pdf', interactive=True) - # Акордион настроек faster whisper with gr.Accordion(label='Faster whisper settings'): with gr.Row(): - # Левая колонка в акордионе with gr.Column(): device = gr.Dropdown(label='Device', choices=DEVICES, value=DEVICES[1], interactive=True) compute_type = gr.Dropdown(label='compute_type', choices=COMPUTE_TYPE, value=COMPUTE_TYPE[0], interactive=True) fastWhisperModel = gr.Dropdown(label='Model', choices=FAST_WHISPER_MODELS, value=FAST_WHISPER_MODELS[11], interactive=True) - beamSize = gr.Number(label='beam_size', value=8, interactive=True) noSpeechThreshold = gr.Number(label='no_speech_threshold', value=0.5, interactive=True) vadFilter = gr.Checkbox(label='vad_filter', value=True, interactive=True) wordTimestamps = gr.Checkbox(label='word_timestamps', value=True, interactive=True) conditionOnPreviousText = gr.Checkbox(label='condition_on_previous_text', value=False, interactive=True) - - # Правая колонка в акордионе with gr.Column(): with gr.Accordion(label='Vad parameters'): minSilenceDurationMs = gr.Number(label='min_silence_duration_ms', value=300, interactive=True) speechPadMs = gr.Number(label='speech_pad_ms', value=200, interactive=True) - with gr.Accordion(label='Temperature'): temp0 = gr.Number(label='temp_0', value=0.0, interactive=True) temp1 = gr.Number(label='temp_1', value=0.2, interactive=True) temp2 = gr.Number(label='temp_2', value=0.4, interactive=True) - # Нижний акордион настроек для api ключа llm with gr.Accordion(label='LLM settings'): apiKey = gr.Textbox(label='API key (required for io.net, Gemini)', value=DEFAULT_API_KEY, interactive=True) - with gr.Accordion(label='System prompt'): systemPrompt = gr.Textbox(label='', value=DEFAULT_SYSTEM_PROMPT, interactive=True) with gr.Row(): - # ВЫБОР ПРОВАЙДЕРА - llmProvider = gr.Dropdown( - label='LLM Provider', - choices=LLM_PROVIDERS, - value=LLM_PROVIDERS[0], - interactive=True - ) - # СПИСОК МОДЕЛЕЙ (теперь зависит от провайдера) - llmModel = gr.Dropdown( - label='Models', - choices=LLM_MODELS[LLM_PROVIDERS[0]], # Модели для провайдера по умолчанию - value=LLM_MODELS[LLM_PROVIDERS[0]][1], - interactive=True - ) + llmProvider = gr.Dropdown(label='LLM Provider', choices=LLM_PROVIDERS, value=LLM_PROVIDERS[0], interactive=True) + llmModel = gr.Dropdown(label='Models', choices=LLM_MODELS[LLM_PROVIDERS[0]], value=LLM_MODELS[LLM_PROVIDERS[0]][1], interactive=True) llmTemperature = gr.Number(label='Temperature', value=0.8, interactive=True) + + # Настройки Custom провайдера + with gr.Accordion(label='Custom Provider Settings', open=True): + customBaseUrl = gr.Textbox( + label='Base URL', + value='http://127.0.0.1:1234/v1/', + interactive=True, + visible=False # Скрыто по умолчанию + ) + # Обработчики событий isPipelineEnabledCheckbox.change(gh.updateButton, inputs=[isPipelineEnabledCheckbox], outputs=refineTextBtn) saveFileCheckbox.change(gh.updateTextbox, inputs=saveFileCheckbox, outputs=filename) saveFileCheckbox.change(gh.updateTextbox, inputs=saveFileCheckbox, outputs=filenamePdf) @@ -119,33 +91,38 @@ def main(): vadFilter, minSilenceDurationMs, speechPadMs, temp0, temp1, temp2, wordTimestamps, noSpeechThreshold, conditionOnPreviousText, gr.State(GLUED_AUDIO_FILENAME), gr.State(OUTPUT_PATH)], outputs=[recognizedText], - ) + ) - # Если пайплайн включен то тогда делаем автоматически - # автоматический пайплайн + # --- ИСПРАВЛЕНИЕ: ДОБАВЛЕН customBaseUrl В INPUTS --- recognizedText.change( gh.generateByCondition, inputs=[apiKey, llmProvider, llmModel, systemPrompt, recognizedText, llmTemperature, - isPipelineEnabledCheckbox, gr.State("change"), saveFileCheckbox, filename, filenamePdf, gr.State(OUTPUT_PATH)], + isPipelineEnabledCheckbox, gr.State("change"), saveFileCheckbox, filename, filenamePdf, gr.State(OUTPUT_PATH), customBaseUrl], outputs=[refinedText, refinedTextMD] ) - - - # ручной запуск по кнопке + # Обновление выпадающего списка моделей и поля API ключа llmProvider.change( gh.update_model_dropdown, inputs=llmProvider, outputs=[llmModel, apiKey] ) + + # Переключение видимости URL для Custom провайдера + llmProvider.change( + fn=gh.toggle_custom_url, + inputs=llmProvider, + outputs=[customBaseUrl] + ) + refineTextBtn.click( gh.generateByCondition, inputs=[apiKey, llmProvider, llmModel, systemPrompt, recognizedText, llmTemperature, - isPipelineEnabledCheckbox, gr.State("click"), saveFileCheckbox, filename, filenamePdf, gr.State(OUTPUT_PATH)], + isPipelineEnabledCheckbox, gr.State("click"), saveFileCheckbox, filename, filenamePdf, gr.State(OUTPUT_PATH), customBaseUrl], outputs=[refinedText, refinedTextMD] ) demo.launch() if __name__ == '__main__': - main() + main() \ No newline at end of file diff --git a/config.py b/config.py index b710fc6..708ac2f 100644 --- a/config.py +++ b/config.py @@ -14,7 +14,7 @@ DEFAULT_API_KEY=IO_API_KEY # Словарь провайдеров и их моделей -LLM_PROVIDERS = ['io.net', 'Gemini', 'gpt4free'] +LLM_PROVIDERS = ['io.net', 'Gemini', 'gpt4free', 'Custom'] LLM_MODELS = { 'io.net': [ 'openai/gpt-oss-120b', 'Qwen/Qwen3-235B-A22B-Thinking-2507', @@ -30,7 +30,7 @@ LLM_MODELS = { 'gemini-2.5-flash', 'gemini-2.5-flash-lite' ], - 'gpt4free': [ # Модели могут меняться, проверьте документацию g4f + 'gpt4free': [ 'default', 'gpt-4', 'sonar-reasoning', @@ -41,7 +41,14 @@ LLM_MODELS = { 'gpt-4o-mini', 'deepseek-r1', 'PollinationsAI:gpt-5-nano' - ] + ], + 'Custom': [ + 'qwen/qwen3-vl-30b', + 'qwen/qwen3-coder-30b', + 'openai/gpt-oss-20b', + 'qwen3-vl-8b-thinking', + 'qwen/qwen3-vl-8b', + ], } # Задаем выходную директорию diff --git a/handlers/gradioHandler.py b/handlers/gradioHandler.py index fa57382..b64c439 100644 --- a/handlers/gradioHandler.py +++ b/handlers/gradioHandler.py @@ -1,50 +1,52 @@ -from config import LLM_MODELS # Импортируем словарь моделей +from config import LLM_MODELS, GEMINI_API_KEY, IO_API_KEY import gradio as gr -from config import GEMINI_API_KEY, IO_API_KEY class GradioHandlers: def __init__(self, llm_factory, ConvertMdToPdf, FileHandlers, FasterWhisper, GlueAudio): - # Объект для работы с файлами self.fh = FileHandlers() self.ga = GlueAudio() self.ConvertMdToPdf = ConvertMdToPdf() self.FasterWhisper = FasterWhisper() - self.llm_factory = llm_factory # Сохраняем фабрику + self.llm_factory = llm_factory - def handleRecognizeBtn( - self, audioFiles, model, device, compute_type, beamSize, vadFilter, + def handleRecognizeBtn(self, audioFiles, model, device, compute_type, beamSize, vadFilter, minSilenceDurationMs, speechPadMs, temp0, temp1, temp2, - wordTimestamps, noSpeechThreshold, conditionOnPreviousText, filename, outPath - ): + wordTimestamps, noSpeechThreshold, conditionOnPreviousText, filename, outPath): try: glued_audio_path = self.ga.glue( - audio_files=[f.name for f in audioFiles], # Передаем список путей + audio_files=[f.name for f in audioFiles], output_path=outPath, output_filename=filename ) except (FileNotFoundError, RuntimeError) as e: - # Если FFmpeg не найден или произошла ошибка, сообщаем пользователю gr.Warning(str(e)) - return "" # Возвращаем пустую строку в текстовое поле + return "" - # Передаем путь к склеенному файлу в FasterWhisper return self.FasterWhisper.recognize(model, device, compute_type, str(glued_audio_path), beamSize, vadFilter, minSilenceDurationMs, speechPadMs, temp0, temp1, temp2, wordTimestamps, noSpeechThreshold, conditionOnPreviousText) - # Функция улучшения текста + # Добавил аргумент custom_base_url в конец def generateByCondition(self, api_key, llm_provider, llm_model, system_prompt, recognized_text, llm_temperature, is_pipeline_enabled, trigger, - isSaveFile, filename, filenamePdf, output_path): + isSaveFile, filename, filenamePdf, output_path, custom_base_url): + try: - # Получаем нужный провайдер через фабрику - provider = self.llm_factory(llm_provider, api_key) + if llm_provider == "Custom": + # Передаем base_url только для Custom + provider = self.llm_factory(llm_provider, api_key, base_url=custom_base_url) + else: + provider = self.llm_factory(llm_provider, api_key) except ValueError as e: - # Если API ключ не предоставлен для нужного провайдера, выводим ошибку gr.Warning(str(e)) return gr.skip(), gr.skip() def process(): - result, md = provider.generate(llm_model, system_prompt, recognized_text, llm_temperature) + # Добавлена обработка ошибок генерации + try: + result, md = provider.generate(llm_model, system_prompt, recognized_text, llm_temperature) + except Exception as e: + raise gr.Error(f"Ошибка генерации LLM: {e}") + pdf, unicodeText = self.ConvertMdToPdf.convertLatexToText(md) if isSaveFile: self.fh.saveFile(filenamePdf, pdf, output_path) @@ -56,30 +58,30 @@ class GradioHandlers: return gr.skip(), gr.skip() - # НОВАЯ ФУНКЦИЯ для обновления списка моделей def update_model_dropdown(self, provider): - """ - Вызывается при изменении llmProvider. - Возвращает обновленный компонент Dropdown для моделей. - """ - # Получаем список моделей для выбранного провайдера models = LLM_MODELS.get(provider, []) - - # Выбираем первое значение по умолчанию, если список не пуст default_value = models[0] if models else None - # Возвращаем обновленный компонент. Используем 'gr' напрямую. - if provider == 'io.net': return gr.update(choices=models, value=default_value), gr.update(label='API key (required for io.net, Gemini)', value=IO_API_KEY, interactive=True) - if provider == 'Gemini': return gr.update(choices=models, value=default_value), gr.update(label='API key (required for Oio.net, Gemini)', value=GEMINI_API_KEY, interactive=True) - if provider == 'gpt4free': return gr.update(choices=models, value=default_value), gr.update(label='API key (required for Oio.net, Gemini)', value="", interactive=True) + # Обновляем список моделей и настройки поля API Key + if provider == 'io.net': + return gr.update(choices=models, value=default_value), gr.update(label='API key', value=IO_API_KEY, interactive=True, visible=True) + if provider == 'Gemini': + return gr.update(choices=models, value=default_value), gr.update(label='API key', value=GEMINI_API_KEY, interactive=True, visible=True) + if provider == 'gpt4free': + return gr.update(choices=models, value=default_value), gr.update(label='API key (not required)', value="", interactive=False, visible=True) + if provider == "Custom": + return gr.update(choices=models, value=default_value), gr.update(label='API key (optional)', value="", interactive=True, visible=True) # Для Custom ключ может понадобиться - # Функция для динамического обновления кнопки def updateButton(self, isChecked): - if not isChecked: - variant = 'primary' - else: - variant = 'secondary' + variant = 'secondary' if isChecked else 'primary' return gr.update(interactive=not isChecked, variant=variant) + + def toggle_custom_url(self, provider): + """Показывает поле Base URL только если выбран Custom""" + return gr.update(visible=(provider == 'Custom')) + + def update_custom_url(self, base_url): + return None def updateTextbox(self, isChecked): return gr.update(visible=isChecked) \ No newline at end of file diff --git a/handlers/metadataHandler.py b/handlers/metadataHandler.py new file mode 100644 index 0000000..9c3c89e --- /dev/null +++ b/handlers/metadataHandler.py @@ -0,0 +1,21 @@ +import os +from PIL import Image, ExifTags +import subprocess +import json +from datetime import datetime + +class MetadataHandler: + def get_image_timestamp(self, image_path: str) -> datetime | None: + """Извлекает метку времени из метаданных изображения, если она доступна.""" + try: + image = Image.open(image_path) + exif_data = image._getexif() + if exif_data: + for tag, value in exif_data.items(): + decoded_tag = ExifTags.TAGS.get(tag, tag) + if decoded_tag == 'DateTimeOriginal': + return value + return None + except Exception as e: + print(f"Error extracting metadata from image: {e}") + return None \ No newline at end of file diff --git a/pandoc.txt b/pandoc.txt new file mode 100644 index 0000000..9013885 --- /dev/null +++ b/pandoc.txt @@ -0,0 +1,7 @@ +pandoc "out.md" -o output1310.pdf \ + --pdf-engine=xelatex \ + -V geometry:margin=2.5cm \ + -V fontsize=12pt \ + -V mainfont="Times New Roman" \ + -V colorlinks=true \ + -V linkcolor=blue\ diff --git a/services/llm_factory.py b/services/llm_factory.py index 32aed24..cecbdbe 100644 --- a/services/llm_factory.py +++ b/services/llm_factory.py @@ -3,8 +3,9 @@ from services.llm_providers.ionet_provider import IoNetProvider from services.llm_providers.gemini_provider import GeminiProvider from services.llm_providers.gpt4free_provider import Gpt4FreeProvider from services.llm_providers.base_provider import BaseLLMProvider +from services.llm_providers.custom_provider import CustomProvider -def get_llm_provider(provider_name: str, api_key: str | None) -> BaseLLMProvider: +def get_llm_provider(provider_name: str, api_key: str | None = None, base_url: str | None = None) -> BaseLLMProvider: """ Фабричная функция для получения экземпляра провайдера LLM. """ @@ -18,5 +19,9 @@ def get_llm_provider(provider_name: str, api_key: str | None) -> BaseLLMProvider return GeminiProvider(api_key) elif provider_name == 'gpt4free': return Gpt4FreeProvider() + elif provider_name == 'Custom': + if not base_url: + raise ValueError("Base URL обязателен для Custom провайдера") + return CustomProvider(api_key, base_url) # base_url будет установлен позже else: raise ValueError(f"Неизвестный провайдер: {provider_name}") \ No newline at end of file diff --git a/services/llm_providers/custom_provider.py b/services/llm_providers/custom_provider.py new file mode 100644 index 0000000..5e7cc50 --- /dev/null +++ b/services/llm_providers/custom_provider.py @@ -0,0 +1,44 @@ +import requests +from .base_provider import BaseLLMProvider + +class CustomProvider(BaseLLMProvider): + def __init__(self, api_key: str | None = None, base_url: str | None = None): + super().__init__(api_key) + self.base_url = base_url or 'http://127.0.0.1:1234/v1/' + + # ГАРАНТИРУЕМ наличие слеша в конце URL + if not self.base_url.endswith('/'): + self.base_url += '/' + + def generate(self, model: str, system_prompt: str, user_prompt: str, temp: float): + url = f"{self.base_url}chat/completions" + + # Некоторые Custom провайдеры (как vLLM или Ollama) могут требовать API Key, даже если он фиктивный + headers = {"Content-Type": "application/json"} + if self.api_key: + headers["Authorization"] = f"Bearer {self.api_key}" + + data = { + "model": model, + "messages": [ + {'role': 'system', 'content': system_prompt}, + {'role': 'user', 'content': user_prompt}, + ], + "temperature": temp, + "stream": False + } + + try: + response = requests.post(url, headers=headers, json=data, timeout=300) + response.raise_for_status() + result = response.json() + + # Обработка разных форматов ответа (на всякий случай) + if 'choices' in result and len(result['choices']) > 0: + text = str(result['choices'][0]['message']['content']) + return text, text + else: + return f"Неожиданный ответ от сервера: {result}", str(result) + + except requests.exceptions.RequestException as e: + return f"Ошибка при запросе к CustomProvider API: {e}", f"Ошибка: {e}" \ No newline at end of file diff --git a/services/llm_providers/gemini_provider.py b/services/llm_providers/gemini_provider.py index f87ac36..dca4f39 100644 --- a/services/llm_providers/gemini_provider.py +++ b/services/llm_providers/gemini_provider.py @@ -41,7 +41,7 @@ class GeminiProvider(BaseLLMProvider): try: # 4. Отправляем POST-запрос с данными и настройками прокси - response = requests.post(api_url, json=data, proxies=proxies, timeout=90) + response = requests.post(api_url, json=data, proxies=proxies, timeout=400) # Проверяем, не вернул ли сервер ошибку (например, 4xx или 5xx) response.raise_for_status() diff --git a/services/llm_providers/gpt4free_provider.py b/services/llm_providers/gpt4free_provider.py index 25c954c..6b4bb27 100644 --- a/services/llm_providers/gpt4free_provider.py +++ b/services/llm_providers/gpt4free_provider.py @@ -3,6 +3,13 @@ from g4f.client import Client from .base_provider import BaseLLMProvider class Gpt4FreeProvider(BaseLLMProvider): + """ + Провайдер для работы с моделью GPT через библиотеку gpt4free. + + Этот класс реализует интерфейс BaseLLMProvider и предоставляет возможность + взаимодействия с различными LLM через сервис gpt4free, который не требует + API ключа для работы. + """ # gpt4free не требует API ключа def __init__(self, api_key: str | None = None): super().__init__(api_key) @@ -10,6 +17,18 @@ class Gpt4FreeProvider(BaseLLMProvider): def generate(self, model: str, system_prompt: str, user_prompt: str, temp: float): + """ + Генерирует ответ от модели GPT с использованием gpt4free. + + Args: + model (str): Название модели для генерации ответа + system_prompt (str): Системное сообщение для контекста + user_prompt (str): Пользовательский запрос + temp (float): Температура генерации ( controls randomness of responses) + + Returns: + tuple: Кортеж из двух одинаковых строк - сгенерированного ответа и его копии + """ # temp в g4f может работать не для всех внутренних провайдеров try: response = self.client.chat.completions.create( diff --git a/services/llm_providers/ionet_provider.py b/services/llm_providers/ionet_provider.py index 9840f71..c5d3a8d 100644 --- a/services/llm_providers/ionet_provider.py +++ b/services/llm_providers/ionet_provider.py @@ -1,23 +1,34 @@ -import openai +import requests from .base_provider import BaseLLMProvider class IoNetProvider(BaseLLMProvider): def __init__(self, api_key: str): super().__init__(api_key) - self.client = openai.OpenAI( - api_key=self.api_key, - base_url='https://api.intelligence.io.solutions/api/v1/' - ) + self.base_url = 'https://api.intelligence.io.solutions/api/v1' def generate(self, model: str, system_prompt: str, user_prompt: str, temp: float): - response = self.client.chat.completions.create( - model=model, - messages=[ + url = f"{self.base_url}/chat/completions" + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {self.api_key}" + } + + data = { + "model": model, + "messages": [ {'role': 'system', 'content': system_prompt}, {'role': 'user', 'content': user_prompt}, ], - temperature=temp, - stream=False - ) - text = str(response.choices[0].message.content) - return text, text # Возвращаем как чистый текст, так и Markdown \ No newline at end of file + "temperature": temp + } + + try: + response = requests.post(url, headers=headers, json=data) + response.raise_for_status() + + result = response.json() + text = str(result['choices'][0]['message']['content']) + return text, text # Возвращаем как чистый текст, так и Markdown + + except requests.exceptions.RequestException as e: + raise Exception(f"Ошибка при запросе к IO.net API: {e}") \ No newline at end of file