mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-18 01:10:14 +01:00
🎥 feat: YouTube Tool (#5582)
* adding youtube tool * refactor: use short `url` param instead of `videoUrl` * refactor: move API key retrieval to a separate credentials module * refactor: remove unnecessary `isEdited` message property * refactor: remove unnecessary `isEdited` message property pt. 2 * refactor: YouTube Tool with new `tool()` generator, handle tools already created by new `tool` generator * fix: only reset request data for multi-convo messages * refactor: enhance YouTube tool by adding transcript parsing and returning structured JSON responses * refactor: update transcript parsing to handle raw response and clean up text output * feat: support toolkits and refactor YouTube tool as a toolkit for better LLM usage * refactor: remove unused OpenAPI specs and streamline tools transformation in loadAsyncEndpoints * refactor: implement manifestToolMap for better tool management and streamline authentication handling * feat: support toolkits for assistants * refactor: rename loadedTools to toolDefinitions for clarity in PluginController and assistant controllers * feat: complete support of toolkits for assistants --------- Co-authored-by: Danilo Pejakovic <danilo.pejakovic@leoninestudios.com>
This commit is contained in:
parent
33f6093775
commit
352565c9a6
29 changed files with 456 additions and 102 deletions
|
|
@ -280,7 +280,6 @@ class PluginsClient extends OpenAIClient {
|
|||
logger.debug('[PluginsClient] sendMessage', { userMessageText: message, opts });
|
||||
const {
|
||||
user,
|
||||
isEdited,
|
||||
conversationId,
|
||||
responseMessageId,
|
||||
saveOptions,
|
||||
|
|
@ -359,7 +358,6 @@ class PluginsClient extends OpenAIClient {
|
|||
conversationId,
|
||||
parentMessageId: userMessage.messageId,
|
||||
isCreatedByUser: false,
|
||||
isEdited,
|
||||
model: this.modelOptions.model,
|
||||
sender: this.sender,
|
||||
promptTokens,
|
||||
|
|
|
|||
|
|
@ -60,7 +60,6 @@ describe('formatMessage', () => {
|
|||
error: false,
|
||||
finish_reason: null,
|
||||
isCreatedByUser: true,
|
||||
isEdited: false,
|
||||
model: null,
|
||||
parentMessageId: Constants.NO_PARENT,
|
||||
sender: 'User',
|
||||
|
|
|
|||
|
|
@ -2,23 +2,40 @@ const availableTools = require('./manifest.json');
|
|||
|
||||
// Structured Tools
|
||||
const DALLE3 = require('./structured/DALLE3');
|
||||
const OpenWeather = require('./structured/OpenWeather');
|
||||
const createYouTubeTools = require('./structured/YouTube');
|
||||
const StructuredWolfram = require('./structured/Wolfram');
|
||||
const StructuredACS = require('./structured/AzureAISearch');
|
||||
const StructuredSD = require('./structured/StableDiffusion');
|
||||
const GoogleSearchAPI = require('./structured/GoogleSearch');
|
||||
const TraversaalSearch = require('./structured/TraversaalSearch');
|
||||
const TavilySearchResults = require('./structured/TavilySearchResults');
|
||||
const OpenWeather = require('./structured/OpenWeather');
|
||||
|
||||
/** @type {Record<string, TPlugin | undefined>} */
|
||||
const manifestToolMap = {};
|
||||
|
||||
/** @type {Array<TPlugin>} */
|
||||
const toolkits = [];
|
||||
|
||||
availableTools.forEach((tool) => {
|
||||
manifestToolMap[tool.pluginKey] = tool;
|
||||
if (tool.toolkit === true) {
|
||||
toolkits.push(tool);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
toolkits,
|
||||
availableTools,
|
||||
manifestToolMap,
|
||||
// Structured Tools
|
||||
DALLE3,
|
||||
OpenWeather,
|
||||
StructuredSD,
|
||||
StructuredACS,
|
||||
GoogleSearchAPI,
|
||||
TraversaalSearch,
|
||||
StructuredWolfram,
|
||||
createYouTubeTools,
|
||||
TavilySearchResults,
|
||||
OpenWeather,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -30,6 +30,20 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "YouTube",
|
||||
"pluginKey": "youtube",
|
||||
"toolkit": true,
|
||||
"description": "Get YouTube video information, retrieve comments, analyze transcripts and search for videos.",
|
||||
"icon": "https://www.youtube.com/s/desktop/7449ebf7/img/favicon_144x144.png",
|
||||
"authConfig": [
|
||||
{
|
||||
"authField": "YOUTUBE_API_KEY",
|
||||
"label": "YouTube API Key",
|
||||
"description": "Your YouTube Data API v3 key."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Wolfram",
|
||||
"pluginKey": "wolfram",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
const { z } = require('zod');
|
||||
const { tool } = require('@langchain/core/tools');
|
||||
const { getEnvironmentVariable } = require('@langchain/core/utils/env');
|
||||
const { getApiKey } = require('./credentials');
|
||||
|
||||
function createTavilySearchTool(fields = {}) {
|
||||
const envVar = 'TAVILY_API_KEY';
|
||||
|
|
@ -8,14 +8,6 @@ function createTavilySearchTool(fields = {}) {
|
|||
const apiKey = fields.apiKey ?? getApiKey(envVar, override);
|
||||
const kwargs = fields?.kwargs ?? {};
|
||||
|
||||
function getApiKey(envVar, override) {
|
||||
const key = getEnvironmentVariable(envVar);
|
||||
if (!key && !override) {
|
||||
throw new Error(`Missing ${envVar} environment variable.`);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
return tool(
|
||||
async (input) => {
|
||||
const { query, ...rest } = input;
|
||||
|
|
|
|||
203
api/app/clients/tools/structured/YouTube.js
Normal file
203
api/app/clients/tools/structured/YouTube.js
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
const { z } = require('zod');
|
||||
const { tool } = require('@langchain/core/tools');
|
||||
const { youtube } = require('@googleapis/youtube');
|
||||
const { YoutubeTranscript } = require('youtube-transcript');
|
||||
const { getApiKey } = require('./credentials');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
function extractVideoId(url) {
|
||||
const rawIdRegex = /^[a-zA-Z0-9_-]{11}$/;
|
||||
if (rawIdRegex.test(url)) {
|
||||
return url;
|
||||
}
|
||||
|
||||
const regex = new RegExp(
|
||||
'(?:youtu\\.be/|youtube(?:\\.com)?/(?:' +
|
||||
'(?:watch\\?v=)|(?:embed/)|(?:shorts/)|(?:live/)|(?:v/)|(?:/))?)' +
|
||||
'([a-zA-Z0-9_-]{11})(?:\\S+)?$',
|
||||
);
|
||||
const match = url.match(regex);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
function parseTranscript(transcriptResponse) {
|
||||
if (!Array.isArray(transcriptResponse)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return transcriptResponse
|
||||
.map((entry) => entry.text.trim())
|
||||
.filter((text) => text)
|
||||
.join(' ')
|
||||
.replaceAll('&#39;', '\'');
|
||||
}
|
||||
|
||||
function createYouTubeTools(fields = {}) {
|
||||
const envVar = 'YOUTUBE_API_KEY';
|
||||
const override = fields.override ?? false;
|
||||
const apiKey = fields.apiKey ?? fields[envVar] ?? getApiKey(envVar, override);
|
||||
|
||||
const youtubeClient = youtube({
|
||||
version: 'v3',
|
||||
auth: apiKey,
|
||||
});
|
||||
|
||||
const searchTool = tool(
|
||||
async ({ query, maxResults = 5 }) => {
|
||||
const response = await youtubeClient.search.list({
|
||||
part: 'snippet',
|
||||
q: query,
|
||||
type: 'video',
|
||||
maxResults: maxResults || 5,
|
||||
});
|
||||
const result = response.data.items.map((item) => ({
|
||||
title: item.snippet.title,
|
||||
description: item.snippet.description,
|
||||
url: `https://www.youtube.com/watch?v=${item.id.videoId}`,
|
||||
}));
|
||||
return JSON.stringify(result, null, 2);
|
||||
},
|
||||
{
|
||||
name: 'youtube_search',
|
||||
description: `Search for YouTube videos by keyword or phrase.
|
||||
- Required: query (search terms to find videos)
|
||||
- Optional: maxResults (number of videos to return, 1-50, default: 5)
|
||||
- Returns: List of videos with titles, descriptions, and URLs
|
||||
- Use for: Finding specific videos, exploring content, research
|
||||
Example: query="cooking pasta tutorials" maxResults=3`,
|
||||
schema: z.object({
|
||||
query: z.string().describe('Search query terms'),
|
||||
maxResults: z.number().int().min(1).max(50).optional().describe('Number of results (1-50)'),
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
const infoTool = tool(
|
||||
async ({ url }) => {
|
||||
const videoId = extractVideoId(url);
|
||||
if (!videoId) {
|
||||
throw new Error('Invalid YouTube URL or video ID');
|
||||
}
|
||||
|
||||
const response = await youtubeClient.videos.list({
|
||||
part: 'snippet,statistics',
|
||||
id: videoId,
|
||||
});
|
||||
|
||||
if (!response.data.items?.length) {
|
||||
throw new Error('Video not found');
|
||||
}
|
||||
const video = response.data.items[0];
|
||||
|
||||
const result = {
|
||||
title: video.snippet.title,
|
||||
description: video.snippet.description,
|
||||
views: video.statistics.viewCount,
|
||||
likes: video.statistics.likeCount,
|
||||
comments: video.statistics.commentCount,
|
||||
};
|
||||
return JSON.stringify(result, null, 2);
|
||||
},
|
||||
{
|
||||
name: 'youtube_info',
|
||||
description: `Get detailed metadata and statistics for a specific YouTube video.
|
||||
- Required: url (full YouTube URL or video ID)
|
||||
- Returns: Video title, description, view count, like count, comment count
|
||||
- Use for: Getting video metrics and basic metadata
|
||||
- DO NOT USE FOR VIDEO SUMMARIES, USE TRANSCRIPTS FOR COMPREHENSIVE ANALYSIS
|
||||
- Accepts both full URLs and video IDs
|
||||
Example: url="https://youtube.com/watch?v=abc123" or url="abc123"`,
|
||||
schema: z.object({
|
||||
url: z.string().describe('YouTube video URL or ID'),
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
const commentsTool = tool(
|
||||
async ({ url, maxResults = 10 }) => {
|
||||
const videoId = extractVideoId(url);
|
||||
if (!videoId) {
|
||||
throw new Error('Invalid YouTube URL or video ID');
|
||||
}
|
||||
|
||||
const response = await youtubeClient.commentThreads.list({
|
||||
part: 'snippet',
|
||||
videoId,
|
||||
maxResults: maxResults || 10,
|
||||
});
|
||||
|
||||
const result = response.data.items.map((item) => ({
|
||||
author: item.snippet.topLevelComment.snippet.authorDisplayName,
|
||||
text: item.snippet.topLevelComment.snippet.textDisplay,
|
||||
likes: item.snippet.topLevelComment.snippet.likeCount,
|
||||
}));
|
||||
return JSON.stringify(result, null, 2);
|
||||
},
|
||||
{
|
||||
name: 'youtube_comments',
|
||||
description: `Retrieve top-level comments from a YouTube video.
|
||||
- Required: url (full YouTube URL or video ID)
|
||||
- Optional: maxResults (number of comments, 1-50, default: 10)
|
||||
- Returns: Comment text, author names, like counts
|
||||
- Use for: Sentiment analysis, audience feedback, engagement review
|
||||
Example: url="abc123" maxResults=20`,
|
||||
schema: z.object({
|
||||
url: z.string().describe('YouTube video URL or ID'),
|
||||
maxResults: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(50)
|
||||
.optional()
|
||||
.describe('Number of comments to retrieve'),
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
const transcriptTool = tool(
|
||||
async ({ url }) => {
|
||||
const videoId = extractVideoId(url);
|
||||
if (!videoId) {
|
||||
throw new Error('Invalid YouTube URL or video ID');
|
||||
}
|
||||
|
||||
try {
|
||||
try {
|
||||
const transcript = await YoutubeTranscript.fetchTranscript(videoId, { lang: 'en' });
|
||||
return parseTranscript(transcript);
|
||||
} catch (e) {
|
||||
logger.error(e);
|
||||
}
|
||||
|
||||
try {
|
||||
const transcript = await YoutubeTranscript.fetchTranscript(videoId, { lang: 'de' });
|
||||
return parseTranscript(transcript);
|
||||
} catch (e) {
|
||||
logger.error(e);
|
||||
}
|
||||
|
||||
const transcript = await YoutubeTranscript.fetchTranscript(videoId);
|
||||
return parseTranscript(transcript);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to fetch transcript: ${error.message}`);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'youtube_transcript',
|
||||
description: `Fetch and parse the transcript/captions of a YouTube video.
|
||||
- Required: url (full YouTube URL or video ID)
|
||||
- Returns: Full video transcript as plain text
|
||||
- Use for: Content analysis, summarization, translation reference
|
||||
- This is the "Go-to" tool for analyzing actual video content
|
||||
- Attempts to fetch English first, then German, then any available language
|
||||
Example: url="https://youtube.com/watch?v=abc123"`,
|
||||
schema: z.object({
|
||||
url: z.string().describe('YouTube video URL or ID'),
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
return [searchTool, infoTool, commentsTool, transcriptTool];
|
||||
}
|
||||
|
||||
module.exports = createYouTubeTools;
|
||||
13
api/app/clients/tools/structured/credentials.js
Normal file
13
api/app/clients/tools/structured/credentials.js
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
const { getEnvironmentVariable } = require('@langchain/core/utils/env');
|
||||
|
||||
function getApiKey(envVar, override) {
|
||||
const key = getEnvironmentVariable(envVar);
|
||||
if (!key && !override) {
|
||||
throw new Error(`Missing ${envVar} environment variable.`);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getApiKey,
|
||||
};
|
||||
|
|
@ -5,16 +5,18 @@ const { createCodeExecutionTool, EnvVar } = require('@librechat/agents');
|
|||
const { getUserPluginAuthValue } = require('~/server/services/PluginService');
|
||||
const {
|
||||
availableTools,
|
||||
manifestToolMap,
|
||||
// Basic Tools
|
||||
GoogleSearchAPI,
|
||||
// Structured Tools
|
||||
DALLE3,
|
||||
OpenWeather,
|
||||
StructuredSD,
|
||||
StructuredACS,
|
||||
TraversaalSearch,
|
||||
StructuredWolfram,
|
||||
createYouTubeTools,
|
||||
TavilySearchResults,
|
||||
OpenWeather,
|
||||
} = require('../');
|
||||
const { primeFiles: primeCodeFiles } = require('~/server/services/Files/Code/process');
|
||||
const { createFileSearchTool, primeFiles: primeSearchFiles } = require('./fileSearch');
|
||||
|
|
@ -146,6 +148,14 @@ const loadToolWithAuth = (userId, authFields, ToolConstructor, options = {}) =>
|
|||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {string} toolKey
|
||||
* @returns {Array<string>}
|
||||
*/
|
||||
const getAuthFields = (toolKey) => {
|
||||
return manifestToolMap[toolKey]?.authConfig.map((auth) => auth.authField) ?? [];
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {object} object
|
||||
|
|
@ -174,19 +184,21 @@ const loadTools = async ({
|
|||
const toolConstructors = {
|
||||
calculator: Calculator,
|
||||
google: GoogleSearchAPI,
|
||||
open_weather: OpenWeather,
|
||||
wolfram: StructuredWolfram,
|
||||
'stable-diffusion': StructuredSD,
|
||||
'azure-ai-search': StructuredACS,
|
||||
traversaal_search: TraversaalSearch,
|
||||
tavily_search_results_json: TavilySearchResults,
|
||||
open_weather: OpenWeather,
|
||||
};
|
||||
|
||||
const customConstructors = {
|
||||
serpapi: async () => {
|
||||
let apiKey = process.env.SERPAPI_API_KEY;
|
||||
const authFields = getAuthFields('serpapi');
|
||||
let envVar = authFields[0] ?? '';
|
||||
let apiKey = process.env[envVar];
|
||||
if (!apiKey) {
|
||||
apiKey = await getUserPluginAuthValue(user, 'SERPAPI_API_KEY');
|
||||
apiKey = await getUserPluginAuthValue(user, envVar);
|
||||
}
|
||||
return new SerpAPI(apiKey, {
|
||||
location: 'Austin,Texas,United States',
|
||||
|
|
@ -194,6 +206,11 @@ const loadTools = async ({
|
|||
gl: 'us',
|
||||
});
|
||||
},
|
||||
youtube: async () => {
|
||||
const authFields = getAuthFields('youtube');
|
||||
const authValues = await loadAuthValues({ userId: user, authFields });
|
||||
return createYouTubeTools(authValues);
|
||||
},
|
||||
};
|
||||
|
||||
const requestedTools = {};
|
||||
|
|
@ -218,16 +235,6 @@ const loadTools = async ({
|
|||
'stable-diffusion': imageGenOptions,
|
||||
};
|
||||
|
||||
const toolAuthFields = {};
|
||||
|
||||
availableTools.forEach((tool) => {
|
||||
if (customConstructors[tool.pluginKey]) {
|
||||
return;
|
||||
}
|
||||
|
||||
toolAuthFields[tool.pluginKey] = tool.authConfig.map((auth) => auth.authField);
|
||||
});
|
||||
|
||||
const toolContextMap = {};
|
||||
const remainingTools = [];
|
||||
const appTools = options.req?.app?.locals?.availableTools ?? {};
|
||||
|
|
@ -282,7 +289,7 @@ const loadTools = async ({
|
|||
const options = toolOptions[tool] || {};
|
||||
const toolInstance = loadToolWithAuth(
|
||||
user,
|
||||
toolAuthFields[tool],
|
||||
getAuthFields(tool),
|
||||
toolConstructors[tool],
|
||||
options,
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue