shitton
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
import {AiTool} from "../tool-types";
|
||||
import path from "node:path";
|
||||
import {readFile, writeFile} from "node:fs/promises";
|
||||
import {NOTES_HEADER, notesDir, notesRootFile} from "../../index";
|
||||
import {asNonEmptyString} from "./utils";
|
||||
import fs from "node:fs";
|
||||
|
||||
export type CreateNoteResult =
|
||||
| { success: true; filePath: string }
|
||||
| { success: false; error: string };
|
||||
|
||||
export const createNoteTool = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "create_note",
|
||||
description: "Create a new Markdown note with a valid file name, optional title, and Markdown-formatted content.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
fileName: {
|
||||
type: "string",
|
||||
description: "The valid file name for the note. It must be suitable for use as a file name and must not contain forbidden or unsafe characters such as /, \\, :, *, ?, \", <, >, |, or control characters. Use a clear, concise name based on the note topic. Include the .md extension if the user provides it or if Markdown files are expected."
|
||||
},
|
||||
title: {
|
||||
type: "string",
|
||||
description: "The title of the note. Use a concise, human-readable title based on the user's request or the note content."
|
||||
},
|
||||
content: {
|
||||
type: "string",
|
||||
description: "The full content of the note formatted as valid Markdown. Preserve existing Markdown formatting when provided. If the source content has little or no formatting, add appropriate Markdown structure such as headings, paragraphs, lists, links, code blocks, tables, or emphasis where useful, without changing the meaning."
|
||||
}
|
||||
},
|
||||
required: ["fileName", "content"],
|
||||
}
|
||||
}
|
||||
} satisfies AiTool;
|
||||
|
||||
export async function createNote(
|
||||
args?: Record<string, unknown>
|
||||
): Promise<CreateNoteResult> {
|
||||
console.log("CREATE_NOTE; ARGS: ", args);
|
||||
|
||||
const fileName = asNonEmptyString(args?.fileName) ?? "";
|
||||
if (!fileName.trim().length) {
|
||||
return {success: false, error: "No file name provided"};
|
||||
}
|
||||
const title = asNonEmptyString(args?.title) ?? fileName;
|
||||
|
||||
const content = asNonEmptyString(args?.content) ?? "";
|
||||
if (!content.trim().length) {
|
||||
return {success: false, error: "No content provided"};
|
||||
}
|
||||
|
||||
const newFilePath = path.join(notesDir, fileName.endsWith(".md") ? fileName : fileName + ".md");
|
||||
const linkMarkdown = `* [${title}](${path.relative(path.dirname(notesRootFile), newFilePath)})`;
|
||||
|
||||
try {
|
||||
if (fs.existsSync(newFilePath)) {
|
||||
return {success: false, error: "File already exists"};
|
||||
}
|
||||
|
||||
await writeFile(newFilePath, content, "utf-8");
|
||||
|
||||
let rootContent: string;
|
||||
try {
|
||||
rootContent = await readFile(notesRootFile, "utf-8");
|
||||
} catch (e) {
|
||||
rootContent = "";
|
||||
}
|
||||
|
||||
const notesHeaderIndex = rootContent.indexOf(NOTES_HEADER);
|
||||
if (notesHeaderIndex >= 0) {
|
||||
rootContent += "\n" + linkMarkdown;
|
||||
} else {
|
||||
rootContent = NOTES_HEADER + "\n" + linkMarkdown;
|
||||
}
|
||||
|
||||
await writeFile(notesRootFile, rootContent, "utf-8");
|
||||
return {success: true, filePath: newFilePath};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return {success: false, error: `Failed to process files: ${errorMessage}`};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
import {AiTool} from "../tool-types";
|
||||
import path from "node:path";
|
||||
import {readdir, readFile, unlink, writeFile} from "node:fs/promises";
|
||||
import {notesDir, notesRootFile} from "../../index";
|
||||
import {asNonEmptyString} from "./utils";
|
||||
|
||||
export type NoteListItem = {
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
relativePath: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export type ListNotesResult =
|
||||
| { success: true; notes: NoteListItem[] }
|
||||
| { success: false; error: string };
|
||||
|
||||
export type GetNoteContentResult =
|
||||
| {
|
||||
success: true;
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
relativePath: string;
|
||||
title: string;
|
||||
content: string;
|
||||
} | { success: false; error: string };
|
||||
|
||||
export const listNotesTool = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "list_notes",
|
||||
description: "Display all available Markdown notes from the notes directory.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
} satisfies AiTool;
|
||||
|
||||
export const getNoteContentTool = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_note_content",
|
||||
description: "Get the full Markdown content of a specific note by its file name.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
fileName: {
|
||||
type: "string",
|
||||
description:
|
||||
"The file name of the note to read. It may be provided with or without the .md extension. Must not contain forbidden or unsafe characters such as /, \\, :, *, ?, \", <, >, |, or control characters.",
|
||||
},
|
||||
},
|
||||
required: ["fileName"],
|
||||
},
|
||||
},
|
||||
} satisfies AiTool;
|
||||
|
||||
export async function listNotes(): Promise<ListNotesResult> {
|
||||
console.log("LIST_NOTES");
|
||||
|
||||
try {
|
||||
const entries = await readdir(notesDir, {withFileTypes: true});
|
||||
|
||||
const markdownFiles = entries
|
||||
.filter((entry) => entry.isFile())
|
||||
.map((entry) => entry.name)
|
||||
.filter((fileName) => fileName.endsWith(".md"));
|
||||
|
||||
const notes: NoteListItem[] = await Promise.all(
|
||||
markdownFiles.map(async (fileName) => {
|
||||
const filePath = path.join(notesDir, fileName);
|
||||
const relativePath = path.relative(path.dirname(notesRootFile), filePath);
|
||||
|
||||
let content = "";
|
||||
try {
|
||||
content = await readFile(filePath, "utf-8");
|
||||
} catch {
|
||||
// Ignore content read errors for individual files.
|
||||
}
|
||||
|
||||
return {
|
||||
fileName,
|
||||
filePath,
|
||||
relativePath,
|
||||
title: extractNoteTitle(fileName, content),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
notes.sort((a, b) => a.title.localeCompare(b.title));
|
||||
|
||||
return {success: true, notes};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return {success: false, error: `Failed to list notes: ${errorMessage}`};
|
||||
}
|
||||
}
|
||||
|
||||
export async function getNoteContent(
|
||||
args?: Record<string, unknown>,
|
||||
): Promise<GetNoteContentResult> {
|
||||
console.log("GET_NOTE_CONTENT; ARGS: ", args);
|
||||
|
||||
const fileName = asNonEmptyString(args?.fileName) ?? "";
|
||||
if (!fileName.trim().length) {
|
||||
return {success: false, error: "No file name provided"};
|
||||
}
|
||||
|
||||
const noteFilePath = buildSafeNoteFilePath(fileName);
|
||||
if (!noteFilePath) {
|
||||
return {success: false, error: "Invalid or unsafe file name provided"};
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await readFile(noteFilePath, "utf-8");
|
||||
const normalizedFileName = path.basename(noteFilePath);
|
||||
const relativePath = path.relative(path.dirname(notesRootFile), noteFilePath);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
fileName: normalizedFileName,
|
||||
filePath: noteFilePath,
|
||||
relativePath,
|
||||
title: extractNoteTitle(normalizedFileName, content),
|
||||
content,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return {success: false, error: `Failed to read note: ${errorMessage}`};
|
||||
}
|
||||
}
|
||||
|
||||
function extractNoteTitle(fileName: string, content: string): string {
|
||||
const headingMatch = content.match(/^#\s+(.+)$/m);
|
||||
const heading = headingMatch?.[1]?.trim();
|
||||
|
||||
if (heading) {
|
||||
return heading;
|
||||
}
|
||||
|
||||
return path.basename(fileName, ".md");
|
||||
}
|
||||
|
||||
export function buildSafeNoteFilePath(fileName: string): string | null {
|
||||
const normalizedFileName = fileName.endsWith(".md") ? fileName : `${fileName}.md`;
|
||||
|
||||
if (!normalizedFileName.trim().length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const unsafeFileNamePattern = /[/\\:*?"<>|\x00-\x1F]/;
|
||||
if (unsafeFileNamePattern.test(normalizedFileName)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolvedNotesDir = path.resolve(notesDir);
|
||||
const resolvedFilePath = path.resolve(notesDir, normalizedFileName);
|
||||
|
||||
if (!resolvedFilePath.startsWith(resolvedNotesDir + path.sep)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return resolvedFilePath;
|
||||
}
|
||||
|
||||
export type UpdateNoteContentResult =
|
||||
| { success: true; filePath: string }
|
||||
| { success: false; error: string };
|
||||
|
||||
export type DeleteNoteResult =
|
||||
| { success: true; filePath: string }
|
||||
| { success: false; error: string };
|
||||
|
||||
export const updateNoteContentTool = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "update_note_content",
|
||||
description: "Update the full Markdown content of an existing note by its file name.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
fileName: {
|
||||
type: "string",
|
||||
description:
|
||||
"The file name of the note to update. It may be provided with or without the .md extension. Must not contain forbidden or unsafe characters such as /, \\, :, *, ?, \", <, >, |, or control characters.",
|
||||
},
|
||||
content: {
|
||||
type: "string",
|
||||
description:
|
||||
"The new full content of the note formatted as valid Markdown. This replaces the previous content completely.",
|
||||
},
|
||||
},
|
||||
required: ["fileName", "content"],
|
||||
},
|
||||
},
|
||||
} satisfies AiTool;
|
||||
|
||||
export const deleteNoteTool = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "delete_note",
|
||||
description: "Delete an existing Markdown note by its file name and remove its link from the notes root file if present.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
fileName: {
|
||||
type: "string",
|
||||
description:
|
||||
"The file name of the note to delete. It may be provided with or without the .md extension. Must not contain forbidden or unsafe characters such as /, \\, :, *, ?, \", <, >, |, or control characters.",
|
||||
},
|
||||
},
|
||||
required: ["fileName"],
|
||||
},
|
||||
},
|
||||
} satisfies AiTool;
|
||||
|
||||
export async function updateNoteContent(
|
||||
args?: Record<string, unknown>,
|
||||
): Promise<UpdateNoteContentResult> {
|
||||
console.log("UPDATE_NOTE_CONTENT; ARGS: ", args);
|
||||
|
||||
const fileName = asNonEmptyString(args?.fileName) ?? "";
|
||||
if (!fileName.trim().length) {
|
||||
return {success: false, error: "No file name provided"};
|
||||
}
|
||||
|
||||
const content = asNonEmptyString(args?.content) ?? "";
|
||||
if (!content.trim().length) {
|
||||
return {success: false, error: "No content provided"};
|
||||
}
|
||||
|
||||
const noteFilePath = buildSafeNoteFilePath(fileName);
|
||||
if (!noteFilePath) {
|
||||
return {success: false, error: "Invalid or unsafe file name provided"};
|
||||
}
|
||||
|
||||
try {
|
||||
await readFile(noteFilePath, "utf-8");
|
||||
await writeFile(noteFilePath, content, "utf-8");
|
||||
|
||||
return {success: true, filePath: noteFilePath};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return {success: false, error: `Failed to update note: ${errorMessage}`};
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteNote(
|
||||
args?: Record<string, unknown>,
|
||||
): Promise<DeleteNoteResult> {
|
||||
console.log("DELETE_NOTE; ARGS: ", args);
|
||||
|
||||
const fileName = asNonEmptyString(args?.fileName) ?? "";
|
||||
if (!fileName.trim().length) {
|
||||
return {success: false, error: "No file name provided"};
|
||||
}
|
||||
|
||||
const noteFilePath = buildSafeNoteFilePath(fileName);
|
||||
if (!noteFilePath) {
|
||||
return {success: false, error: "Invalid or unsafe file name provided"};
|
||||
}
|
||||
|
||||
try {
|
||||
await unlink(noteFilePath);
|
||||
await removeNoteLinkFromRoot(noteFilePath);
|
||||
|
||||
return {success: true, filePath: noteFilePath};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return {success: false, error: `Failed to delete note: ${errorMessage}`};
|
||||
}
|
||||
}
|
||||
|
||||
async function removeNoteLinkFromRoot(noteFilePath: string): Promise<void> {
|
||||
let rootContent: string;
|
||||
|
||||
try {
|
||||
rootContent = await readFile(notesRootFile, "utf-8");
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const relativePath = path.relative(path.dirname(notesRootFile), noteFilePath);
|
||||
const normalizedRelativePath = relativePath.replaceAll("\\", "\\\\");
|
||||
|
||||
const escapedRelativePath = escapeRegExp(normalizedRelativePath);
|
||||
const linkLinePattern = new RegExp(
|
||||
`^\\s*[-*]\\s+\\[[^\\]]+]\\(${escapedRelativePath}\\)\\s*$\\n?`,
|
||||
"gm",
|
||||
);
|
||||
|
||||
const updatedRootContent = rootContent.replace(linkLinePattern, "");
|
||||
|
||||
if (updatedRootContent !== rootContent) {
|
||||
await writeFile(notesRootFile, updatedRootContent.trimEnd() + "\n", "utf-8");
|
||||
}
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
@@ -25,11 +25,31 @@ import {
|
||||
updateFile,
|
||||
updateFileTool
|
||||
} from "./file-system";
|
||||
import {createNote, createNoteTool} from "./create-note";
|
||||
import {
|
||||
deleteNote,
|
||||
deleteNoteTool,
|
||||
getNoteContent,
|
||||
getNoteContentTool,
|
||||
listNotes,
|
||||
listNotesTool,
|
||||
updateNoteContent,
|
||||
updateNoteContentTool
|
||||
} from "./list-notes";
|
||||
import {getNoteFile, getNoteFileTool} from "./send-note-file";
|
||||
import {searchNotes, searchNotesTool} from "./search-notes";
|
||||
|
||||
export const getTools = () => {
|
||||
const tools: AiTool[] = [
|
||||
getCurrentDateTimeTool,
|
||||
getMarketRatesTool,
|
||||
createNoteTool,
|
||||
listNotesTool,
|
||||
getNoteContentTool,
|
||||
updateNoteContentTool,
|
||||
deleteNoteTool,
|
||||
getNoteFileTool,
|
||||
searchNotesTool
|
||||
];
|
||||
|
||||
if (Environment.ENABLE_PYTHON_INTERPRETER) {
|
||||
@@ -61,13 +81,30 @@ export const getTools = () => {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return tools;
|
||||
// return [
|
||||
// createNoteTool,
|
||||
// listNotesTool,
|
||||
// getNoteContentTool,
|
||||
// updateNoteContentTool,
|
||||
// deleteNoteTool,
|
||||
// getNoteFileTool,
|
||||
// searchNotesTool
|
||||
// ];
|
||||
};
|
||||
|
||||
export const getToolHandlers = () => {
|
||||
let handlers: Record<string, ToolHandler> = {
|
||||
get_datetime: getCurrentDateTime,
|
||||
get_market_rates: getMarketRates,
|
||||
create_note: createNote,
|
||||
list_notes: listNotes,
|
||||
get_note_content: getNoteContent,
|
||||
update_note_content: updateNoteContent,
|
||||
delete_note: deleteNote,
|
||||
get_note_file: getNoteFile,
|
||||
search_notes: searchNotes
|
||||
};
|
||||
|
||||
if (Environment.ENABLE_PYTHON_INTERPRETER) {
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
import {AiTool} from "../tool-types";
|
||||
import path from "node:path";
|
||||
import {readdir, readFile} from "node:fs/promises";
|
||||
import {notesDir, notesRootFile} from "../../index";
|
||||
import {asNonEmptyString} from "./utils";
|
||||
|
||||
export type SearchNoteMatchedField = "file_name" | "title" | "content";
|
||||
|
||||
export type SearchNoteItem = {
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
relativePath: string;
|
||||
title: string;
|
||||
score: number;
|
||||
matchedFields: SearchNoteMatchedField[];
|
||||
snippet?: string;
|
||||
};
|
||||
|
||||
export type SearchNotesResult =
|
||||
| { success: true; results: SearchNoteItem[] }
|
||||
| { success: false; error: string };
|
||||
|
||||
export const searchNotesTool = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "search_notes",
|
||||
description:
|
||||
"Search Markdown notes by file name, note title, and full note content. Supports fuzzy matching. Use this when the user refers to a note by title, topic, partial title, approximate name, keyword, or something written inside the note. Returns success=true and results[], where each result contains fileName, title, score, matchedFields, relativePath, and optional snippet. Later note tools should use results[0].fileName unless multiple results are ambiguous.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: {
|
||||
type: "string",
|
||||
description:
|
||||
"Search query for finding notes by file name, title, topic, keywords, or content. Can be partial, approximate, or contain typos. Use a short clean phrase, not the full user sentence.",
|
||||
},
|
||||
limit: {
|
||||
type: "integer",
|
||||
description:
|
||||
"Maximum number of search results to return. Defaults to 3. Maximum is 10.",
|
||||
minimum: 1,
|
||||
maximum: 10,
|
||||
default: 3,
|
||||
},
|
||||
},
|
||||
required: ["query"],
|
||||
},
|
||||
},
|
||||
} satisfies AiTool;
|
||||
|
||||
export async function searchNotes(
|
||||
args?: Record<string, unknown>,
|
||||
): Promise<SearchNotesResult> {
|
||||
console.log("SEARCH_NOTES; ARGS: ", args);
|
||||
|
||||
const query = asNonEmptyString(args?.query) ?? "";
|
||||
if (!query.trim().length) {
|
||||
return {success: false, error: "No query provided"};
|
||||
}
|
||||
|
||||
const limit = parseSearchLimit(args?.limit);
|
||||
|
||||
try {
|
||||
const entries = await readdir(notesDir, {withFileTypes: true});
|
||||
|
||||
const markdownFiles = entries
|
||||
.filter((entry) => entry.isFile())
|
||||
.map((entry) => entry.name)
|
||||
.filter((fileName) => fileName.endsWith(".md"));
|
||||
|
||||
const notes = await Promise.all(
|
||||
markdownFiles.map(async (fileName) => {
|
||||
const filePath = path.join(notesDir, fileName);
|
||||
const relativePath = path.relative(path.dirname(notesRootFile), filePath);
|
||||
|
||||
let content = "";
|
||||
try {
|
||||
content = await readFile(filePath, "utf-8");
|
||||
} catch {
|
||||
// Ignore content read errors for individual files.
|
||||
}
|
||||
|
||||
const title = extractNoteTitle(fileName, content);
|
||||
const fileNameWithoutExtension = path.basename(fileName, ".md");
|
||||
|
||||
const fileNameScore = calculateFuzzyScore(query, fileNameWithoutExtension);
|
||||
const titleScore = calculateFuzzyScore(query, title);
|
||||
const contentScore = calculateContentScore(query, content);
|
||||
|
||||
const matchedFields: SearchNoteMatchedField[] = [];
|
||||
|
||||
if (fileNameScore > 0) {
|
||||
matchedFields.push("file_name");
|
||||
}
|
||||
|
||||
if (titleScore > 0) {
|
||||
matchedFields.push("title");
|
||||
}
|
||||
|
||||
if (contentScore > 0) {
|
||||
matchedFields.push("content");
|
||||
}
|
||||
|
||||
const score = Math.max(
|
||||
fileNameScore,
|
||||
titleScore,
|
||||
contentScore,
|
||||
);
|
||||
|
||||
return {
|
||||
fileName,
|
||||
filePath,
|
||||
relativePath,
|
||||
title,
|
||||
score,
|
||||
matchedFields,
|
||||
snippet:
|
||||
contentScore > 0
|
||||
? buildContentSnippet(query, content)
|
||||
: undefined,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const results = notes
|
||||
.filter((note) => note.score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, limit);
|
||||
|
||||
return {success: true, results};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return {success: false, error: `Failed to search notes: ${errorMessage}`};
|
||||
}
|
||||
}
|
||||
|
||||
function parseSearchLimit(value: unknown): number {
|
||||
const parsed =
|
||||
typeof value === "number"
|
||||
? value
|
||||
: typeof value === "string"
|
||||
? Number.parseInt(value, 10)
|
||||
: 3;
|
||||
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return 3;
|
||||
}
|
||||
|
||||
return Math.max(1, Math.min(10, Math.floor(parsed)));
|
||||
}
|
||||
|
||||
function extractNoteTitle(fileName: string, content: string): string {
|
||||
const headingMatch = content.match(/^#\s+(.+)$/m);
|
||||
const heading = headingMatch?.[1]?.trim();
|
||||
|
||||
if (heading) {
|
||||
return heading;
|
||||
}
|
||||
|
||||
return path.basename(fileName, ".md");
|
||||
}
|
||||
|
||||
function calculateFuzzyScore(query: string, value: string): number {
|
||||
const normalizedQuery = normalizeSearchText(query);
|
||||
const normalizedValue = normalizeSearchText(value);
|
||||
|
||||
if (!normalizedQuery.length || !normalizedValue.length) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (normalizedValue === normalizedQuery) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
if (normalizedValue.startsWith(normalizedQuery)) {
|
||||
return 90;
|
||||
}
|
||||
|
||||
if (normalizedValue.includes(normalizedQuery)) {
|
||||
return 85;
|
||||
}
|
||||
|
||||
const queryWords = normalizedQuery.split(" ").filter(Boolean);
|
||||
const valueWords = normalizedValue.split(" ").filter(Boolean);
|
||||
|
||||
const wordMatchScore = calculateWordMatchScore(queryWords, valueWords);
|
||||
const subsequenceScore = isSubsequence(normalizedQuery, normalizedValue) ? 55 : 0;
|
||||
const distanceScore = calculateLevenshteinScore(normalizedQuery, normalizedValue);
|
||||
|
||||
return Math.max(wordMatchScore, subsequenceScore, distanceScore);
|
||||
}
|
||||
|
||||
function calculateContentScore(query: string, content: string): number {
|
||||
const normalizedQuery = normalizeSearchText(query);
|
||||
const normalizedContent = normalizeSearchText(content);
|
||||
|
||||
if (!normalizedQuery.length || !normalizedContent.length) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (normalizedContent.includes(normalizedQuery)) {
|
||||
return 70;
|
||||
}
|
||||
|
||||
const queryWords = normalizedQuery.split(" ").filter(Boolean);
|
||||
const contentWords = new Set(normalizedContent.split(" ").filter(Boolean));
|
||||
|
||||
if (!queryWords.length) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let matchedWords = 0;
|
||||
|
||||
for (const queryWord of queryWords) {
|
||||
if (contentWords.has(queryWord)) {
|
||||
matchedWords++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const hasPartialMatch = [...contentWords].some((contentWord) => {
|
||||
if (contentWord.includes(queryWord) || queryWord.includes(contentWord)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (queryWord.length < 4 || contentWord.length < 4) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const distance = levenshteinDistance(queryWord, contentWord);
|
||||
const maxLength = Math.max(queryWord.length, contentWord.length);
|
||||
const similarity = 1 - distance / maxLength;
|
||||
|
||||
return similarity >= 0.75;
|
||||
});
|
||||
|
||||
if (hasPartialMatch) {
|
||||
matchedWords += 0.75;
|
||||
}
|
||||
}
|
||||
|
||||
const matchRatio = matchedWords / queryWords.length;
|
||||
|
||||
if (matchRatio <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.round(matchRatio * 60);
|
||||
}
|
||||
|
||||
function normalizeSearchText(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/ё/g, "е")
|
||||
.replace(/[^a-zа-я0-9\s-]/gi, " ")
|
||||
.replace(/[-_]+/g, " ")
|
||||
.replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
function calculateWordMatchScore(queryWords: string[], valueWords: string[]): number {
|
||||
if (!queryWords.length || !valueWords.length) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let matchedWords = 0;
|
||||
|
||||
for (const queryWord of queryWords) {
|
||||
const bestWordScore = Math.max(
|
||||
...valueWords.map((valueWord) => {
|
||||
if (valueWord === queryWord) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (valueWord.startsWith(queryWord) || valueWord.includes(queryWord)) {
|
||||
return 0.85;
|
||||
}
|
||||
|
||||
const distance = levenshteinDistance(queryWord, valueWord);
|
||||
const maxLength = Math.max(queryWord.length, valueWord.length);
|
||||
const similarity = 1 - distance / maxLength;
|
||||
|
||||
return similarity >= 0.7 ? similarity : 0;
|
||||
}),
|
||||
);
|
||||
|
||||
if (bestWordScore > 0) {
|
||||
matchedWords += bestWordScore;
|
||||
}
|
||||
}
|
||||
|
||||
const ratio = matchedWords / queryWords.length;
|
||||
return Math.round(ratio * 75);
|
||||
}
|
||||
|
||||
function calculateLevenshteinScore(query: string, value: string): number {
|
||||
const distance = levenshteinDistance(query, value);
|
||||
const maxLength = Math.max(query.length, value.length);
|
||||
|
||||
if (maxLength === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const similarity = 1 - distance / maxLength;
|
||||
|
||||
if (similarity < 0.45) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.round(similarity * 65);
|
||||
}
|
||||
|
||||
function isSubsequence(query: string, value: string): boolean {
|
||||
let queryIndex = 0;
|
||||
|
||||
for (const valueChar of value) {
|
||||
if (valueChar === query[queryIndex]) {
|
||||
queryIndex++;
|
||||
}
|
||||
|
||||
if (queryIndex === query.length) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function levenshteinDistance(a: string, b: string): number {
|
||||
const matrix: number[][] = Array.from({length: a.length + 1}, () =>
|
||||
Array.from({length: b.length + 1}, () => 0),
|
||||
);
|
||||
|
||||
for (let i = 0; i <= a.length; i++) {
|
||||
matrix[i][0] = i;
|
||||
}
|
||||
|
||||
for (let j = 0; j <= b.length; j++) {
|
||||
matrix[0][j] = j;
|
||||
}
|
||||
|
||||
for (let i = 1; i <= a.length; i++) {
|
||||
for (let j = 1; j <= b.length; j++) {
|
||||
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
||||
|
||||
matrix[i][j] = Math.min(
|
||||
matrix[i - 1][j] + 1,
|
||||
matrix[i][j - 1] + 1,
|
||||
matrix[i - 1][j - 1] + cost,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return matrix[a.length][b.length];
|
||||
}
|
||||
|
||||
function buildContentSnippet(query: string, content: string): string | undefined {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const normalizedContent = content.toLowerCase();
|
||||
|
||||
let matchIndex = normalizedContent.indexOf(normalizedQuery);
|
||||
|
||||
if (matchIndex < 0) {
|
||||
const queryWords = normalizeSearchText(query)
|
||||
.split(" ")
|
||||
.filter((word) => word.length >= 3);
|
||||
|
||||
for (const word of queryWords) {
|
||||
matchIndex = normalizedContent.indexOf(word);
|
||||
if (matchIndex >= 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (matchIndex < 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const snippetRadius = 120;
|
||||
const start = Math.max(0, matchIndex - snippetRadius);
|
||||
const end = Math.min(content.length, matchIndex + normalizedQuery.length + snippetRadius);
|
||||
|
||||
const prefix = start > 0 ? "..." : "";
|
||||
const suffix = end < content.length ? "..." : "";
|
||||
|
||||
return `${prefix}${content.slice(start, end).replace(/\s+/g, " ").trim()}${suffix}`;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import {AiTool} from "../tool-types";
|
||||
import path from "node:path";
|
||||
import {readFile, stat} from "node:fs/promises";
|
||||
import {notesRootFile} from "../../index";
|
||||
import {asNonEmptyString} from "./utils";
|
||||
import {buildSafeNoteFilePath} from "./list-notes";
|
||||
import z from "zod";
|
||||
|
||||
export type NoteFileAttachment = {
|
||||
type: "local_file";
|
||||
fileName: string;
|
||||
// filePath: string;
|
||||
relativePath: string;
|
||||
mimeType: "text/markdown";
|
||||
sizeBytes: number;
|
||||
};
|
||||
|
||||
export type GetNoteFileResult =
|
||||
| {
|
||||
success: true;
|
||||
attachment: NoteFileAttachment;
|
||||
} | { success: false; error: string };
|
||||
|
||||
export const NoteFileAttachmentSchema = z.object({
|
||||
type: z.literal("local_file"),
|
||||
fileName: z.string(),
|
||||
// filePath: z.string(),
|
||||
relativePath: z.string(),
|
||||
mimeType: z.literal("text/markdown"),
|
||||
sizeBytes: z.number(),
|
||||
});
|
||||
|
||||
export const GetNoteFileResultSchema = z.discriminatedUnion("success", [
|
||||
z.object({
|
||||
success: z.literal(true),
|
||||
attachment: NoteFileAttachmentSchema,
|
||||
}),
|
||||
z.object({
|
||||
success: z.literal(false),
|
||||
error: z.string(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const getNoteFileTool = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_note_file",
|
||||
description:
|
||||
"Prepare a Markdown note file to be sent to the user as a .md attachment. Returns a local file descriptor that the host application should use to upload or send the file.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
fileName: {
|
||||
type: "string",
|
||||
description:
|
||||
"The file name of the note to send. It may be provided with or without the .md extension. Must not contain forbidden or unsafe characters such as /, \\, :, *, ?, \", <, >, |, or control characters.",
|
||||
},
|
||||
},
|
||||
required: ["fileName"],
|
||||
},
|
||||
},
|
||||
} satisfies AiTool;
|
||||
|
||||
export async function getNoteFile(
|
||||
args?: Record<string, unknown>,
|
||||
): Promise<GetNoteFileResult> {
|
||||
console.log("GET_NOTE_FILE; ARGS: ", args);
|
||||
|
||||
const fileName = asNonEmptyString(args?.fileName) ?? "";
|
||||
if (!fileName.trim().length) {
|
||||
return {success: false, error: "No file name provided"};
|
||||
}
|
||||
|
||||
const noteFilePath = buildSafeNoteFilePath(fileName);
|
||||
if (!noteFilePath) {
|
||||
return {success: false, error: "Invalid or unsafe file name provided"};
|
||||
}
|
||||
|
||||
try {
|
||||
// Проверяем, что файл существует и действительно читается.
|
||||
await readFile(noteFilePath, "utf-8");
|
||||
|
||||
const fileStat = await stat(noteFilePath);
|
||||
if (!fileStat.isFile()) {
|
||||
return {success: false, error: "Note path is not a file"};
|
||||
}
|
||||
|
||||
const normalizedFileName = path.basename(noteFilePath);
|
||||
const relativePath = path.relative(path.dirname(notesRootFile), noteFilePath);
|
||||
|
||||
const result: GetNoteFileResult = {
|
||||
success: true,
|
||||
attachment: {
|
||||
type: "local_file",
|
||||
fileName: normalizedFileName,
|
||||
// filePath: noteFilePath,
|
||||
relativePath,
|
||||
mimeType: "text/markdown",
|
||||
sizeBytes: fileStat.size,
|
||||
},
|
||||
};
|
||||
|
||||
console.log("GET_NOTE_FILE; RESULT: ", result);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return {success: false, error: `Failed to prepare note file: ${errorMessage}`};
|
||||
}
|
||||
}
|
||||
+24
-1
@@ -1,4 +1,5 @@
|
||||
import {Ollama} from "ollama";
|
||||
import {z} from "zod";
|
||||
|
||||
export function asNonEmptyString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0
|
||||
@@ -78,7 +79,7 @@ export async function unloadAllOllamaModels(ollama: Ollama, exceptFor?: string[]
|
||||
);
|
||||
|
||||
await Promise.all(unloadPromises);
|
||||
console.log("All models have been requested to unload" + exceptFor?.length ? ` except for [${exceptFor?.join(", ")}].` : ".");
|
||||
console.log("All models have been requested to unload" + (exceptFor?.length ? ` except for [${exceptFor?.join(", ")}].` : "."));
|
||||
} catch (error) {
|
||||
console.error("Error unloading models:", error);
|
||||
}
|
||||
@@ -100,3 +101,25 @@ export async function loadOllamaModel(model: string, ollama: Ollama, contextLeng
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export type ToolPlanStep = {
|
||||
t: string;
|
||||
h: string;
|
||||
from: string;
|
||||
};
|
||||
|
||||
export type RouterPlan = {
|
||||
s: ToolPlanStep[];
|
||||
m: string;
|
||||
};
|
||||
|
||||
export const ToolPlanStepSchema = z.object({
|
||||
t: z.string(),
|
||||
h: z.string(),
|
||||
from: z.string(),
|
||||
});
|
||||
|
||||
export const RouterPlanSchema = z.object({
|
||||
s: z.array(ToolPlanStepSchema),
|
||||
m: z.string()
|
||||
});
|
||||
+163
-40
@@ -1,9 +1,9 @@
|
||||
import {Message} from "typescript-telegram-bot-api";
|
||||
import fs, {openAsBlob} from "node:fs";
|
||||
import fs, {createReadStream, openAsBlob} from "node:fs";
|
||||
import path from "node:path";
|
||||
import {AiProvider} from "../model/ai-provider";
|
||||
import {Environment} from "../common/environment";
|
||||
import {bot, photoGenDir} from "../index";
|
||||
import {bot, notesDir, photoGenDir} from "../index";
|
||||
import {clamp, collectReplyChainText, delay, ifTrue, logError, replyToMessage} from "../util/utils";
|
||||
import {MessageStore} from "../common/message-store";
|
||||
import {
|
||||
@@ -20,7 +20,7 @@ import {AiDownloadedFile, attachmentsToDownloadedFiles, cleanupDownloads} from "
|
||||
import {getModelCapabilities, getRuntimeCapabilities} from "./provider-model-runtime";
|
||||
import {StoredAttachment} from "../model/stored-attachment";
|
||||
import {AiChatMessage, ChatMessage} from "./chat-messages-types";
|
||||
import {ChatRequest, ListResponse, Tool} from "ollama";
|
||||
import {ChatRequest, ListResponse, Ollama, Tool} from "ollama";
|
||||
import {executeToolCall, ToolRuntimeContext} from "./tools/runtime";
|
||||
import {MessageImagePart, MessagePart} from "../common/message-part";
|
||||
import {enqueueTelegramApiCall} from "../util/telegram-api-queue";
|
||||
@@ -29,7 +29,7 @@ import {getCurrentDateTimeTool} from "./tools/datetime";
|
||||
import {getMarketRatesTool} from "./tools/market-rates";
|
||||
import {getWeatherTool} from "./tools/weather";
|
||||
import {aiProviderRequestQueue, type AiRequestQueueTarget} from "./provider-request-queue";
|
||||
import {loadOllamaModel, unloadAllOllamaModels} from "./tools/utils";
|
||||
import {loadOllamaModel, RouterPlanSchema, unloadAllOllamaModels} from "./tools/utils";
|
||||
import {prepareOllamaDocumentRag} from "./ollama-rag";
|
||||
import {PYTHON_INTERPRETER_TOOL_NAME, pythonInterpreterToolPrompt} from "./tools/python-interpretator";
|
||||
import {
|
||||
@@ -47,12 +47,6 @@ import {
|
||||
resolveSpeechToTextProviderForUser,
|
||||
transcribeSpeechDownloads,
|
||||
} from "./speech-to-text";
|
||||
import {
|
||||
isTextToSpeechConfigured,
|
||||
resolveTextToSpeechProviderForUser,
|
||||
sendSynthesizedSpeech,
|
||||
synthesizeSpeech,
|
||||
} from "./text-to-speech";
|
||||
import {OpenAIChatMessage} from "./openai-chat-message";
|
||||
import {ResponseInputMessageContentList} from "openai/resources/responses/responses.js";
|
||||
import {MistralChatMessage} from "./mistral-chat-message";
|
||||
@@ -70,6 +64,7 @@ import {
|
||||
getGeminiApiMode,
|
||||
resolveAiRuntimeTarget,
|
||||
} from "./ai-runtime-target";
|
||||
import {GetNoteFileResult, GetNoteFileResultSchema} from "./tools/send-note-file";
|
||||
|
||||
const TELEGRAM_LIMIT = 4096;
|
||||
const MAX_TOOL_ROUNDS = 20;
|
||||
@@ -135,8 +130,10 @@ type RuntimeConfigSnapshot = {
|
||||
useNamesInPrompt: boolean;
|
||||
useSystemPrompt: boolean;
|
||||
systemPrompt?: string;
|
||||
rankerToolPrompt?: string;
|
||||
|
||||
ollamaChatTarget: AiRuntimeTarget;
|
||||
ollamaToolRankerTarget?: AiRuntimeTarget;
|
||||
ollamaVisionTarget: AiRuntimeTarget;
|
||||
ollamaThinkingTarget: AiRuntimeTarget;
|
||||
ollamaAudioTarget: AiRuntimeTarget;
|
||||
@@ -165,8 +162,15 @@ export function snapshotRuntimeConfig(): RuntimeConfigSnapshot {
|
||||
useSystemPrompt: Environment.USE_SYSTEM_PROMPT,
|
||||
|
||||
systemPrompt: Environment.SYSTEM_PROMPT,
|
||||
rankerToolPrompt: Environment.RANKER_TOOL_PROMPT,
|
||||
|
||||
ollamaChatTarget: resolveAiRuntimeTarget(AiProvider.OLLAMA, "chat"),
|
||||
ollamaToolRankerTarget: {
|
||||
provider: AiProvider.OLLAMA,
|
||||
purpose: "tools",
|
||||
model: "gemma4:e2b",
|
||||
baseUrl: "http://meloda-zen.lan:11434"
|
||||
},
|
||||
ollamaVisionTarget: resolveAiRuntimeTarget(AiProvider.OLLAMA, "vision"),
|
||||
ollamaThinkingTarget: resolveAiRuntimeTarget(AiProvider.OLLAMA, "thinking"),
|
||||
ollamaAudioTarget: resolveAiRuntimeTarget(AiProvider.OLLAMA, "audio"),
|
||||
@@ -379,8 +383,8 @@ export function ollamaModelNames(response: ListResponse): string[] {
|
||||
.filter((value): value is string => typeof value === "string" && value.length > 0);
|
||||
}
|
||||
|
||||
async function isOllamaModelActive(target: AiRuntimeTarget): Promise<boolean> {
|
||||
const active = await createOllamaClient(target).ps();
|
||||
async function isOllamaModelActive(ollama: Ollama, target: AiRuntimeTarget): Promise<boolean> {
|
||||
const active = await ollama.ps();
|
||||
return ollamaModelNames(active).includes(target.model);
|
||||
}
|
||||
|
||||
@@ -916,23 +920,21 @@ function appendTranscriptToChatMessages(chatMessages: AiChatMessage[], provider:
|
||||
}
|
||||
}
|
||||
|
||||
async function sendVoiceResponseIfNeeded(options: UnifiedRunOptions, downloads: AiDownloadedFile[], text: string): Promise<void> {
|
||||
if (!downloads.some(isTranscribableAudioDownload)) return;
|
||||
if (!options.msg.from?.id) return;
|
||||
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
try {
|
||||
const provider = isTextToSpeechConfigured(options.provider)
|
||||
? options.provider
|
||||
: (await resolveTextToSpeechProviderForUser(options.msg.from.id)).provider;
|
||||
const speech = await synthesizeSpeech({provider, text: trimmed});
|
||||
await sendSynthesizedSpeech(options.msg, speech);
|
||||
} catch (e) {
|
||||
logError(e);
|
||||
}
|
||||
}
|
||||
// async function sendVoiceResponseIfNeeded(options: UnifiedRunOptions, downloads: AiDownloadedFile[], text: string): Promise<void> {
|
||||
// if (!downloads.some(isTranscribableAudioDownload)) return;
|
||||
// if (!options.msg.from?.id) return;
|
||||
//
|
||||
// const trimmed = text.trim();
|
||||
// if (!trimmed) return;
|
||||
//
|
||||
// try {
|
||||
// const provider = (await resolveTextToSpeechProviderForUser(options.msg.from.id)).provider;
|
||||
// const speech = await synthesizeSpeech({provider, text: trimmed});
|
||||
// await sendSynthesizedSpeech(options.msg, speech);
|
||||
// } catch (e) {
|
||||
// logError(e);
|
||||
// }
|
||||
// }
|
||||
|
||||
async function deleteMistralLibrary(libraryId: string | undefined, target: AiRuntimeTarget): Promise<void> {
|
||||
if (!libraryId) return;
|
||||
@@ -1429,9 +1431,24 @@ async function runOllama(
|
||||
const maxContextLength = contextKey ? <number>modelInfo?.model_info?.[contextKey] : DEFAULT_OLLAMA_CONTEXT_SIZE;
|
||||
const context = clamp(contextSize ?? contextSize === -1 ? MAX_OLLAMA_CONTEXT_SIZE : DEFAULT_OLLAMA_CONTEXT_SIZE, MIN_OLLAMA_CONTEXT_SIZE, maxContextLength ?? DEFAULT_OLLAMA_CONTEXT_SIZE);
|
||||
|
||||
await unloadAllOllamaModels(ollama, [model, config.ollamaDocumentsTarget.model]);
|
||||
const modelsToLoad = [model];
|
||||
|
||||
if (!(await isOllamaModelActive(target))) {
|
||||
try {
|
||||
const activeModels = (await ollama.ps()).models.map(m => m.model);
|
||||
const oldSet = new Set(activeModels);
|
||||
const newSet = new Set(modelsToLoad);
|
||||
|
||||
const added = modelsToLoad.filter(m => !oldSet.has(m));
|
||||
const removed = activeModels.filter(m => !newSet.has(m));
|
||||
const diff = [...added, ...removed];
|
||||
if (diff.length) {
|
||||
await unloadAllOllamaModels(ollama, modelsToLoad);
|
||||
}
|
||||
} catch (e) {
|
||||
logError(e);
|
||||
}
|
||||
|
||||
if (!(await isOllamaModelActive(ollama, target))) {
|
||||
const currentStatus = streamMessage.getStatus();
|
||||
streamMessage.setStatus(Environment.getLoadingModelText(model));
|
||||
await streamMessage.flush();
|
||||
@@ -1468,27 +1485,97 @@ async function runOllama(
|
||||
|
||||
try {
|
||||
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
|
||||
console.log("CONTENT_LENGTH", context);
|
||||
console.log("CONTEXT_LENGTH", context);
|
||||
|
||||
const request: ChatRequest = {
|
||||
model: model,
|
||||
messages: messages,
|
||||
think: audioCount ? false : think,
|
||||
options: {
|
||||
temperature: messages.length <= 2 ? 0 : 0.6,
|
||||
temperature: messages.length <= 2 ? 0.6 : 0.6,
|
||||
num_ctx: context,
|
||||
}
|
||||
};
|
||||
|
||||
if ((await getModelCapabilities(AiProvider.OLLAMA, model, "tools"))?.tools?.supported) {
|
||||
if (fromId !== Environment.CREATOR_ID) {
|
||||
request.tools = [
|
||||
const ollamaTools: Tool[] = fromId !== Environment.CREATOR_ID ?
|
||||
[
|
||||
getCurrentDateTimeTool,
|
||||
getMarketRatesTool,
|
||||
getWeatherTool
|
||||
];
|
||||
] : getOllamaTools() as Tool[];
|
||||
|
||||
if (!config.ollamaToolRankerTarget) {
|
||||
request.tools = ollamaTools;
|
||||
} else {
|
||||
request.tools = getOllamaTools() as Tool[];
|
||||
try {
|
||||
const toolRankerPrompt = config.rankerToolPrompt
|
||||
?.replace(
|
||||
"{{user_request}}",
|
||||
messages.reverse().find(m => m.role === "user")?.content ?? ""
|
||||
)
|
||||
?.replace(
|
||||
"{{tools}}",
|
||||
ollamaTools.map(t => JSON.stringify(t)).join("\n")
|
||||
);
|
||||
|
||||
streamMessage.setStatus("🦽 Подбираю лучшие инструменты...");
|
||||
await streamMessage.flush();
|
||||
|
||||
const client = createOllamaClient(config.ollamaToolRankerTarget);
|
||||
|
||||
const modelsToLoad = [config.ollamaToolRankerTarget.model];
|
||||
|
||||
const activeModels = (await client.ps()).models.map(m => m.model);
|
||||
const oldSet = new Set(activeModels);
|
||||
const newSet = new Set(modelsToLoad);
|
||||
|
||||
const added = modelsToLoad.filter(m => !oldSet.has(m));
|
||||
const removed = activeModels.filter(m => !newSet.has(m));
|
||||
const diff = [...added, ...removed];
|
||||
if (diff.length) {
|
||||
await unloadAllOllamaModels(client, modelsToLoad);
|
||||
}
|
||||
|
||||
const result = await client.generate({
|
||||
model: config.ollamaToolRankerTarget.model,
|
||||
stream: false,
|
||||
prompt: toolRankerPrompt ?? ""
|
||||
});
|
||||
|
||||
console.log("TOOL_RANKER_RESULT: ", result.response);
|
||||
|
||||
const raw = JSON.parse(result.response);
|
||||
const res = RouterPlanSchema.safeParse(raw);
|
||||
if (res.success) {
|
||||
const toolNames = res.data.s.flatMap(s => s.t);
|
||||
const tools = toolNames.map(n => ollamaTools.find(t => t.function.name === n) as Tool);
|
||||
// const tools = ollamaTools.filter(t => toolNames.includes(t.function.name ?? ""));
|
||||
request.tools = tools;
|
||||
|
||||
|
||||
const m = messages.reverse().find(m => m.role === "user");
|
||||
let content = m?.content ?? "";
|
||||
content += "\n" + JSON.stringify({
|
||||
toolPlan: {
|
||||
steps: res.data.s.map(s => {
|
||||
return {
|
||||
tool_name: s.t,
|
||||
hint: s.h,
|
||||
from: s.from,
|
||||
};
|
||||
}),
|
||||
missing_info: res.data.m
|
||||
}
|
||||
});
|
||||
|
||||
if (m) m.content = content;
|
||||
// messages.reverse().find(m => m.role === "user")?.content += "";
|
||||
// messages[messages.length - 1].content = content;
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
logError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1616,7 +1703,42 @@ async function runOllama(
|
||||
}))
|
||||
});
|
||||
|
||||
appendOllamaToolResults(messages, calls, await executeToolBatch(calls, streamMessage, toolContext));
|
||||
const toolResults = await executeToolBatch(calls, streamMessage, toolContext);
|
||||
|
||||
let successGetNoteFileResult: GetNoteFileResult | undefined = undefined;
|
||||
|
||||
for (const toolResult of toolResults) {
|
||||
try {
|
||||
const raw = JSON.parse(toolResult);
|
||||
const res = GetNoteFileResultSchema.safeParse(raw);
|
||||
if (res.success && res.data.success) {
|
||||
successGetNoteFileResult = res.data;
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
await replyToMessage({message: msg, text: e + ""}).catch(logError);
|
||||
}
|
||||
}
|
||||
|
||||
if (successGetNoteFileResult) {
|
||||
await bot.sendDocument({
|
||||
chat_id: msg.chat.id,
|
||||
reply_parameters: {
|
||||
message_id: msg.message_id
|
||||
},
|
||||
document: createReadStream(path.join(notesDir, successGetNoteFileResult.attachment.relativePath))
|
||||
}).catch(logError);
|
||||
}
|
||||
|
||||
const reversedMessages = messages.reverse();
|
||||
let lastUserMessageIndex = reversedMessages.findIndex(m => m.role === "user");
|
||||
if (lastUserMessageIndex >= 0) {
|
||||
lastUserMessageIndex = messages.indexOf(reversedMessages[lastUserMessageIndex]);
|
||||
if (lastUserMessageIndex >= 0) {
|
||||
messages[lastUserMessageIndex].content += "\n\nLast tool_call result: " + JSON.stringify(toolResults);
|
||||
}
|
||||
}
|
||||
|
||||
appendOllamaToolResults(messages, calls, toolResults);
|
||||
}
|
||||
} finally {
|
||||
if (interval) clearInterval(interval);
|
||||
@@ -1990,7 +2112,8 @@ async function executeUnifiedAiRequest(
|
||||
|
||||
if (transcript.trim()) {
|
||||
if (options.voiceMode === AI_VOICE_MODE_TRANSCRIPT) {
|
||||
streamMessage.replaceText(transcript.trim());
|
||||
// TODO: 12.05.2026, Danil Nikolaev: extract to string
|
||||
streamMessage.replaceText(`[Расшифровка]\n${transcript.trim()}`);
|
||||
await streamMessage.finish();
|
||||
return {mistralLibraryId};
|
||||
}
|
||||
@@ -2057,7 +2180,7 @@ async function executeUnifiedAiRequest(
|
||||
streamMessage.replaceText(streamMessage.getText().slice(0, TELEGRAM_LIMIT - 3) + "...");
|
||||
}
|
||||
await streamMessage.finish();
|
||||
await sendVoiceResponseIfNeeded(options, downloads, streamMessage.getText());
|
||||
// await sendVoiceResponseIfNeeded(options, downloads, streamMessage.getText());
|
||||
|
||||
return {mistralLibraryId};
|
||||
} catch (e) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import {StoredMessage} from "../model/stored-message";
|
||||
import {cutPrefixes, logError} from "../util/utils";
|
||||
import {runUnifiedAi} from "../ai/unified-ai-runner";
|
||||
import {AI_REGENERATE_CALLBACK, parseAiRegenerateCallbackData} from "../ai/regenerate-callback";
|
||||
import {isAiProviderConfigured, resolveEffectiveAiProviderForUser} from "../common/user-ai-settings";
|
||||
import {resolveEffectiveAiProviderForUser} from "../common/user-ai-settings";
|
||||
import {Environment} from "../common/environment";
|
||||
|
||||
export class AiRegenerate extends CallbackCommand {
|
||||
@@ -28,9 +28,11 @@ export class AiRegenerate extends CallbackCommand {
|
||||
const sourceFromId = source.stored?.fromId ?? source.message.from?.id;
|
||||
if (!sourceFromId || (sourceFromId !== query.from.id && query.from.id !== Environment.CREATOR_ID)) return;
|
||||
|
||||
const provider = isAiProviderConfigured(parsed.provider)
|
||||
? parsed.provider
|
||||
: await resolveEffectiveAiProviderForUser(source.message.from?.id ?? query.from.id);
|
||||
const provider =
|
||||
// isAiProviderConfigured(parsed.provider)
|
||||
// ? parsed.provider
|
||||
// :
|
||||
await resolveEffectiveAiProviderForUser(source.message.from?.id ?? query.from.id);
|
||||
const text = cutPrefixes(source.stored ?? source.message) ?? "";
|
||||
|
||||
runUnifiedAi({
|
||||
|
||||
@@ -168,6 +168,7 @@ const RuntimeEnvSchema = z.object({
|
||||
),
|
||||
|
||||
SYSTEM_PROMPT: optionalStringSchema,
|
||||
RANKER_TOOL_PROMPT: optionalStringSchema,
|
||||
USE_NAMES_IN_PROMPT: booleanWithDefaultSchema(false),
|
||||
USE_SYSTEM_PROMPT: booleanWithDefaultSchema(true),
|
||||
|
||||
@@ -234,7 +235,9 @@ export class Environment {
|
||||
|
||||
private static lastEnvMtimeMs: number | undefined;
|
||||
private static lastSystemPromptMtimeMs: number | undefined;
|
||||
private static lastRankerToolPromptMtimeMs: number | undefined;
|
||||
private static envSystemPrompt: string | undefined;
|
||||
private static envRankerToolPrompt: string | undefined;
|
||||
|
||||
static BOT_TOKEN: string = "";
|
||||
static TEST_ENVIRONMENT: boolean = false;
|
||||
@@ -273,6 +276,7 @@ export class Environment {
|
||||
static DEFAULT_AI_PROVIDER: AiProvider = AiProvider.OLLAMA;
|
||||
|
||||
static SYSTEM_PROMPT?: string;
|
||||
static RANKER_TOOL_PROMPT?: string;
|
||||
static USE_NAMES_IN_PROMPT: boolean = false;
|
||||
static USE_SYSTEM_PROMPT: boolean = true;
|
||||
static SEND_TIME_TOOK: boolean = false;
|
||||
@@ -1665,6 +1669,10 @@ export class Environment {
|
||||
return path.join(Environment.DATA_PATH, "SYSTEM_PROMPT.md");
|
||||
}
|
||||
|
||||
private static getRankerToolPromptPath(): string {
|
||||
return path.join(Environment.DATA_PATH, "TOOL_RANKER_PROMPT.md");
|
||||
}
|
||||
|
||||
private static readSystemPrompt(): string | undefined {
|
||||
const promptPath = Environment.getSystemPromptPath();
|
||||
|
||||
@@ -1676,10 +1684,25 @@ export class Environment {
|
||||
return prompt.length > 0 ? prompt : undefined;
|
||||
}
|
||||
|
||||
private static readRankerToolPromptPath(): string | undefined {
|
||||
const promptPath = Environment.getRankerToolPromptPath();
|
||||
|
||||
if (!fs.existsSync(promptPath)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const prompt = fs.readFileSync(promptPath, "utf8").trim();
|
||||
return prompt.length > 0 ? prompt : undefined;
|
||||
}
|
||||
|
||||
private static refreshSystemPrompt(): void {
|
||||
Environment.SYSTEM_PROMPT = Environment.readSystemPrompt() ?? Environment.envSystemPrompt;
|
||||
}
|
||||
|
||||
private static refreshRankerToolPrompt(): void {
|
||||
Environment.RANKER_TOOL_PROMPT = Environment.readRankerToolPromptPath() ?? Environment.envRankerToolPrompt;
|
||||
}
|
||||
|
||||
private static applyStartupEnv(env: StartupEnv): void {
|
||||
Environment.BOT_TOKEN = env.BOT_TOKEN;
|
||||
Environment.TEST_ENVIRONMENT = env.TEST_ENVIRONMENT;
|
||||
@@ -1722,7 +1745,9 @@ export class Environment {
|
||||
Environment.DEFAULT_AI_PROVIDER = env.DEFAULT_AI_PROVIDER;
|
||||
|
||||
Environment.envSystemPrompt = env.SYSTEM_PROMPT;
|
||||
Environment.envRankerToolPrompt = env.RANKER_TOOL_PROMPT;
|
||||
Environment.SYSTEM_PROMPT = env.SYSTEM_PROMPT;
|
||||
Environment.RANKER_TOOL_PROMPT = env.RANKER_TOOL_PROMPT;
|
||||
Environment.USE_NAMES_IN_PROMPT = env.USE_NAMES_IN_PROMPT;
|
||||
Environment.USE_SYSTEM_PROMPT = env.USE_SYSTEM_PROMPT;
|
||||
Environment.SEND_TIME_TOOK = env.SEND_TIME_TOOK ?? false;
|
||||
@@ -1783,18 +1808,22 @@ export class Environment {
|
||||
Environment.applyRuntimeEnv(runtimeEnv);
|
||||
|
||||
Environment.refreshSystemPrompt();
|
||||
Environment.refreshRankerToolPrompt();
|
||||
|
||||
Environment.lastEnvMtimeMs = Environment.getFileMtimeMs(Environment.ENV_FILE_PATH);
|
||||
Environment.lastSystemPromptMtimeMs = Environment.getFileMtimeMs(Environment.getSystemPromptPath());
|
||||
Environment.lastRankerToolPromptMtimeMs = Environment.getFileMtimeMs(Environment.getRankerToolPromptPath());
|
||||
}
|
||||
|
||||
static reloadRuntimeConfigIfChanged(): void {
|
||||
try {
|
||||
const envMtimeMs = Environment.getFileMtimeMs(Environment.ENV_FILE_PATH);
|
||||
const systemPromptMtimeMs = Environment.getFileMtimeMs(Environment.getSystemPromptPath());
|
||||
const rankerToolPromptMtimeMs = Environment.getFileMtimeMs(Environment.getRankerToolPromptPath());
|
||||
|
||||
const envChanged = envMtimeMs !== Environment.lastEnvMtimeMs;
|
||||
const systemPromptChanged = systemPromptMtimeMs !== Environment.lastSystemPromptMtimeMs;
|
||||
const rankerToolPromptChanged = rankerToolPromptMtimeMs !== Environment.lastRankerToolPromptMtimeMs;
|
||||
|
||||
Localization.reloadIfChanged();
|
||||
|
||||
@@ -1808,6 +1837,7 @@ export class Environment {
|
||||
|
||||
Environment.applyRuntimeEnv(runtimeEnv);
|
||||
Environment.refreshSystemPrompt();
|
||||
Environment.refreshRankerToolPrompt();
|
||||
Environment.lastEnvMtimeMs = envMtimeMs;
|
||||
}
|
||||
|
||||
@@ -1815,6 +1845,11 @@ export class Environment {
|
||||
Environment.refreshSystemPrompt();
|
||||
Environment.lastSystemPromptMtimeMs = systemPromptMtimeMs;
|
||||
}
|
||||
|
||||
if (rankerToolPromptChanged) {
|
||||
Environment.refreshRankerToolPrompt();
|
||||
Environment.lastRankerToolPromptMtimeMs = rankerToolPromptMtimeMs;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to reload runtime environment config", e);
|
||||
}
|
||||
|
||||
+22
-8
@@ -189,6 +189,11 @@ export const videoDir = path.join(cacheDir, "video");
|
||||
export const videoNotesDir = path.join(cacheDir, "video-note");
|
||||
export const videoTempDir = path.join(videoDir, "temp");
|
||||
|
||||
|
||||
export const NOTES_HEADER = "## Notes\n";
|
||||
export const notesDir = path.join(Environment.DATA_PATH, "notes");
|
||||
export const notesRootFile = path.join(notesDir, "index.md");
|
||||
|
||||
let isShuttingDown = false;
|
||||
|
||||
async function shutdown(signal: NodeJS.Signals) {
|
||||
@@ -209,21 +214,30 @@ async function shutdown(signal: NodeJS.Signals) {
|
||||
async function main() {
|
||||
const start = Date.now();
|
||||
|
||||
console.log(
|
||||
`TEST_ENVIRONMENT: ${Environment.TEST_ENVIRONMENT}\n` +
|
||||
`DATA_PATH: ${Environment.DATA_PATH}\n` +
|
||||
`MAX_PHOTO_SIZE: ${Environment.MAX_PHOTO_SIZE}\n` +
|
||||
`ONLY_FOR_CREATOR: ${Environment.ONLY_FOR_CREATOR_MODE}\n` +
|
||||
`DEFAULT_AI_PROVIDER: ${Environment.DEFAULT_AI_PROVIDER}`
|
||||
);
|
||||
console.log([
|
||||
`TEST_ENVIRONMENT: ${Environment.TEST_ENVIRONMENT}`,
|
||||
`IS_DOCKER: ${Environment.IS_DOCKER}`,
|
||||
`DATA_PATH: ${Environment.DATA_PATH}`,
|
||||
`DB_PATH: ${Environment.DB_PATH}`
|
||||
].join("\n"));
|
||||
|
||||
const dirsToCheck = [cacheDir, photoDir, photoGenDir, documentDir, audioDir, videoDir, videoNotesDir, videoTempDir];
|
||||
const dirsToCheck = [cacheDir, photoDir, photoGenDir, documentDir, audioDir, videoDir, videoNotesDir, videoTempDir, notesDir];
|
||||
dirsToCheck.forEach(dir => {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, {recursive: true});
|
||||
}
|
||||
});
|
||||
|
||||
const notesRootFilePath = path.join(notesDir, "index.md");
|
||||
if (!fs.existsSync(notesRootFilePath)) {
|
||||
fs.writeFileSync(notesRootFilePath, "\n" + NOTES_HEADER);
|
||||
}
|
||||
|
||||
if (!(fs.readFileSync(notesRootFilePath).toString().includes(NOTES_HEADER))) {
|
||||
fs.appendFileSync(notesRootFilePath, "\n" + NOTES_HEADER);
|
||||
}
|
||||
|
||||
// TODO: 13/05/2026, Danil Nikolaev: maybe add clean cache option (or just save summarizations)
|
||||
// const now = new Date();
|
||||
|
||||
// const midnight = new Date();
|
||||
|
||||
Reference in New Issue
Block a user