Привет всем любителям и исследователям искусственного интеллекта! В данной статье я хотел бы рассказать об интересном проекте: модульной системе универсального искусственного интеллекта (МСУИИ) «Amiga Virtual» (AV, «Виртуальная Подружка»). Я расскажу об основных принципах её работы и опишу некоторые детали реализации, а самые любопытные смогут исследовать все исходные коды. Разработка ведётся на Delphi, но модули теоретически могут быть написаны на любом ЯП. Данная система будет интересна как конечным пользователям чат-ботов и связанных с ними систем, так и разработчикам ИИ — ведь на её основе можно разработать практически любой тип ИИ.
Для начала немного истории. Проект был начат в 2012-м году, когда я решил заняться разработкой чат-бота. А решил я так после знакомства с «Sayme 2» — простой флеш-игрой про общение с виртуальной девушкой (к сожалению, амбициозное продолжение этой игры — 3-я версия — по всей видимости умерло в зародыше. R.I.P., «Sayme 3»). Тогда AV представляла собой монолит, с вынесенными в одну DLL картинками с частями аватара — чтобы не качать лишние мегабайты, если аватар не нужен.
Так выглядела первая версия Amiga Virtual.
С тех пор концепция изменилась — теперь собственно AV — только платформа для организации работы модулей. Все «органы» ИИ вынесены в модули.
Несколько слов о языке программирования. В 2012 я выбрал Delphi, так как не знал практически ничего, кроме него (не считая Turbo Pascal). Позже я перепробовал множество языков… Больше всего мне понравилась Ada, но обучающих материалов по ней мало, да и программы получаются медленными. Ещё я пробовал Oxygen (Delphi Prism), но по нему материалов ещё меньше. C++ и другие си-подобные языки мне не нравятся вообще. Поэтому в итоге я вернулся к Delphi, который я хорошо знаю и по которому просто море обучающего материала в Интернете, да и готовых решений тоже много.
Теперь собственно о проекте. Amiga Virtual — это многоцелевая многоагентная система. В общих чертах она устроена так: есть основная программа (ОП) и набор модулей (представлены в виде DLL и являются программными агентами), у которых реализованы те или иные интерфейсы из определённого списка; во время старта ОП загружает и идентифицирует (определяет, что модуль может делать; назначение модуля программе не сообщается — это не имеет смысла) все модули, затем пользователь выбирает модули, с которыми он хочет работать, и запускает многопоточную обработку интерфейсов ввода-вывода выбранных модулей. Вот так происходит загрузка и идентифицирование модулей:
procedure LoadModules;
var
i: Integer;
begin
for i := 0 to Length(Module) - 1 do
begin
if Module[i].Handle > 0 then
FreeModule(Module[i]);
if LoadModule(Module[i]) then
DetermineModuleType(Module[i]);
end;
end;
function LoadModule(var M: TModule): boolean;
var
B: String;
C: Integer;
begin
with M do
begin
Handle := LoadLibrary(PWideChar(FileName));
if Handle > 0 then
begin
@SetLanguage := GetProcAddress(Handle, 'SetLanguage');
IncAndUpdateProgress; // Для прогресс-бара
@SendLanguageData := GetProcAddress(Handle, 'SendLanguageData');
IncAndUpdateProgress;
@GetName := GetProcAddress(Handle, 'GetName');
IncAndUpdateProgress;
// Тут много строк, подключающих интерфейсы
// Вынимаем из модуля немного информации
if @GetName <> nil then
begin
B := GetName;
C := Pos(ControlCodeMarker, B);
if C > 0 then
begin
Name := Copy(B, C + 1, Length(B));
ControlCode := Copy(B, 1, C - 1);
ControlCodeFormated := FormatControlCode(ControlCode);
end
else
begin
Name := B;
ControlCode := MainForm.LanguageData[133];
end;
end
else
begin
Name := MainForm.LanguageData[114];
ControlCode := MainForm.LanguageData[133];
end;
if (@OpenWindow <> nil) and (@CloseWindow <> nil) then
WindowState := closed
else
WindowState := window_does_not_exist;
if (@Sleep <> nil) and (@WakeUp <> nil) then
ModuleState := working
else
ModuleState := module_cant_sleep;
LoadStatistics; // Это пока не реализовано
Result := true;
end
else
Result := false;
end;
end;
function DetermineModuleType(var M: TModule): TModuleType;
begin
with M do
begin
MType := undetermined;
if (@SetLanguage = nil) and ... (* много сравнений интерфейсов с nil *) then
MType := erroneous_or_empty
else
begin
if (@SetSource <> nil) or (@NextData <> nil) or (@Progress <> nil) or
(@RestartParsing <> nil) then
MType := parser
else
begin
if (@GetData <> nil) and (@SendData <> nil) then
MType := input_and_output
else if (@GetData <> nil) and (@SendData = nil) then
MType := only_input
else if (@GetData = nil) and (@SendData <> nil) then
MType := only_output
else if (@GetData = nil) and (@SendData = nil) then
MType := no_input_and_no_output;
end;
end;
Result := MType;
end;
end;
ОП реализует только текстовый интерфейс взаимодействия пользователя с модулями, или, проще говоря, окно чата. Все модули общаются через этот чат, но обычно технические сообщения модулей не показываются пользователю, т.к. не представляют для него интереса (в целях отладки эти сообщения можно отобразить). Технические сообщения помечаются как несколько непечатных символов в начале строки. Этот код («контроль-код») задаётся разработчиком модуля. Загрузка двух модулей с одним контроль-кодом может привести к зацикливанию системы — эти два модуля будут бесконечно обмениваться сообщениями, как два поставленных друг против друга зеркала. Короче, контроль-код нужен для того, чтобы модули игнорировали сообщения, которые адресованы не им. Однако некоторые модули могут обрабатывать все сообщения — например, модули типа «интеллект-ядро».
Каждый модуль, имеющий интерфейсы ввода, вывода или ввода-вывода обрабатывается в отдельном потоке ОП. Это позволяет сохранить работоспособность ОП, если какой-то модуль зависнет или просто «задумается». Остальные модули используются пользователем в ручном режиме через их собственные окна. Следующий класс реализует поток ввода-вывода:
TIOThread = class(TThread)
public
const
SleepTime = 1000;
var
Module: TModule;
SelfID: Integer;
constructor Create(M: TModule; ID: Integer);
protected
procedure Execute; override;
end;
А вот как происходит обработка ввода-вывода:
procedure TIOThread.Execute;
procedure GetData;
var
M: String;
begin
M := String(Module.GetData);
if M <> SCM_No_Message then
Synchronize(
procedure
begin
Pool.AddRecord(M, SelfID);
end);
end;
procedure SendData;
var
i: Integer;
begin
with Pool do
if not Empty then
begin
for i := 0 to Length(Records) - 1 do
with Records[i] do
if not ModuleGot[SelfID] and (AuthorID <> SelfID) then
begin
Module.SendData(PChar(Text));
ModuleGot[SelfID] := true;
end;
Synchronize(
procedure
begin
CheckAndDeleteOddRecords;
end);
end;
end;
begin
inherited;
while not Terminated do
begin
case Module.MType of
only_input:
GetData;
only_output:
SendData;
input_and_output:
begin
SendData;
GetData;
end;
end;
Sleep(SleepTime);
end;
end;
Для обмена сообщениями ОП реализует пул сообщений. В него сохраняются все сообщения, пришедшие из окна чата и из модулей, и хранятся до тех пор, пока все модули не получат эти сообщения, затем они удаляются. Следующий класс реализует пул сообщений:
TPoolRecord = record
Text: String;
AuthorID: Integer;
ModuleGot: array of Boolean;
end;
TPool = class
Records: array of TPoolRecord;
Empty: Boolean;
procedure AddRecord(RecordText: String; RecordAuthor: Integer);
procedure CheckAndDeleteOddRecords;
constructor Create;
procedure Show;
end;
procedure TPool.AddRecord(RecordText: String; RecordAuthor: Integer);
var
i, RL: Integer;
begin
RL := Length(Records);
SetLength(Records, RL + 1);
with Records[RL] do
begin
Text := RecordText;
AuthorID := RecordAuthor;
SetLength(ModuleGot, OutputModulesCount);
for i := 0 to OutputModulesCount - 1 do
if i = AuthorID then
ModuleGot[i] := true
else
ModuleGot[i] := false;
end;
with MainForm, MainForm.ChatBox.Lines do
case RecordAuthor of
- 1:
Add(User.Name + ': ' + RecordText);
else
if RecordText = SCM_Dont_Know_Answer then
begin
if DontKnowCheckBtn.Checked then
Add(LanguageData[156]);
end
else
Add(AVirtual.Name + ': ' + RecordText);
end;
Empty := false;
end;
procedure TPool.CheckAndDeleteOddRecords;
function ItsOdd(ID: Integer): Boolean;
var
i: Integer;
begin
ItsOdd := true;
with Records[ID] do
for i := 0 to Length(ModuleGot) - 1 do
if not ModuleGot[i] then
begin
ItsOdd := false;
exit;
end;
end;
procedure DeleteRecord(ID: Integer);
var
i: Integer;
begin
for i := ID to Length(Records) - 2 do
Records[i] := Records[i + 1];
SetLength(Records, Length(Records) - 1);
end;
var
i: Integer;
begin
if not Empty then
begin
for i := Length(Records) - 1 downto 0 do
if ItsOdd(i) then
DeleteRecord(i);
if Length(Records) = 0 then
Empty := true;
end;
if MainForm.PoolShowBtn.Checked then
Show;
end;
Теперь поговорим о возможных классах модулей и их возможностях.
Самый важный, ключевой класс модулей, ради которых была задумана и реализована Подружка — это «интеллект-ядра» (ИЯ, или просто ядра). Ядро обрабатывает все сообщения, поступающие в чат, в соответствии с заложенным в него алгоритмом. То есть он выполняет основную интеллектуальную работу. Конкретный алгоритм и его реализация зависит от разработчика ядра. Как сделать хорошее ядро — интересный вопрос, и я рассмотрю его в другой статье на примере моей серии ядер. Другой интересный вопрос — как разделить ядро на отдельные модули. У меня не получается подразделить ядра, над которыми я сейчас работаю. Но, в принципе, ничто не мешает вам сделать составное ядро.
Все остальные модули разделяются на несколько категорий: рецепторы (генерируют сообщения в чат — модуль зрения, модуль слуха и т.п.), эффекторы (совершают действия в ответ на сообщения из чата), инструменты (не относятся к ИИ напрямую, используются пользователем вручную), парсеры (используются обучающим модулем «Dream Fusion») и другие модули, которые могут использоваться какими-то особыми модулями.
Некоторые рецепторы и эффекторы соответственно кодируют и декодируют сообщения, записанные в стиле «контроль-код + команда». Такие сообщения обычно не показываются пользователю. Суть этих сообщений в следующем: рецептор распознаёт некое действие, совершённое пользователем, кодирует в сообщение и отправляет его ядру на хранение. Когда ядро выдаст это сообщение, соответствующий эффектор раскодирует сообщение, получив команду, соответствующую действию пользователя, и выполнит это действие. Поскольку ядру нет разницы, какие сообщения запоминать и выдавать, а подключить к системе мы можем любые рецепторы и эффекторы — интеллект получается универсальным, то есть его можно научить чему угодно. Здорово, правда?
Следует заменить, что для удобства родственные рецепторы и эффекторы могут быть реализованы в одном модуле. Пример — класс модулей под общим названием «эмоциональный аватар» (я называю это «эмотар»): пользователь собирает из частей лицо, выражающее определённую эмоцию, и модуль создаёт сообщение, кодирующее выбранную эмоцию, т.е. действует как рецептор; когда ядро выдает это сообщение в чат, эмотар декодирует его и выстраивает изображение лица с соответствующей эмоцией, т.е. действует как эффектор. Разделять эмотар на два отдельных модуля я не вижу смысла.
Вот пример шаблона для создания модуля на Delphi:
library ИмяМодуля;
uses
System.SysUtils,
System.Classes,
SystemControlMessagesUnit
in '....AmigaVirtualSystemControlMessagesUnit.pas',
MainFormUnit in 'MainFormUnit.pas',
const
ControlCode = три+непечатных+символа;
Name = ControlCode + '>Имя Модуля';
Help = 'Многострочная' + #13 + 'справка';
var
FormState: (closed, opened);
Buffer, VirtualName: String;
NewMessageGot: Boolean;
function GetName: PChar; stdcall;
begin
Result := PChar(Name);
end;
function GetHelp: PChar; stdcall;
begin
Result := PChar(Help);
end;
procedure OpenWindow; stdcall;
begin
if MainForm = nil then
MainForm := TMainForm.Create(nil);
MainForm.Show;
FormState := opened;
end;
procedure CloseWindow; stdcall;
begin
if FormState = opened then
begin
MainForm.Close;
FormState := closed;
MainForm.Release;
MainForm := nil;
end;
end;
procedure SendData(Data: PChar); stdcall;
begin
Buffer := Data;
end;
function GetData: PChar; stdcall;
begin
if NewMessageGot then
begin
Result := PChar(Buffer);
NewMessageGot := false;
end
else
Result := PChar(SCM_No_Message);
end;
procedure Start; stdcall;
begin
NewMessageGot := false;
if MainForm = nil then
MainForm := TMainForm.Create(nil);
end;
procedure SetVirtual(Name: PChar); stdcall;
begin
VirtualName := Name;
end;
procedure LoadData; stdcall;
begin
// Загрузка базы данных
end;
procedure SaveData; stdcall;
begin
// Сохранение базы данных
end;
exports GetName, GetHelp, OpenWindow, CloseWindow, SendData, GetData, Start,
SetVirtual, LoadData, SaveData;
begin
end.
На данный момент есть 21 интерфейс:
SetLanguage: function(Language: PChar): boolean; stdcall;
SendLanguageData: function(Data: array of PChar): boolean; stdcall;
GetName: function: PChar; stdcall;
GetHelp: function: PChar; stdcall;
Start: procedure; stdcall;
Sleep: function: boolean; stdcall;
WakeUp: procedure; stdcall;
OpenWindow: procedure; stdcall;
CloseWindow: procedure; stdcall;
SetVirtual: procedure(Name: PChar); stdcall;
SaveData: procedure; stdcall;
LoadData: procedure; stdcall;
Reset: procedure; stdcall;
SetNewMainWindow: procedure(Position, Size: TPoint); stdcall;
GetTimerInterval: function: Integer; stdcall;
SendData: procedure(Data: PChar); stdcall;
GetData: function: PChar; stdcall;
SetSource: procedure(SourcePath: PChar); stdcall;
NextData: function: PChar; stdcall;
Progress: function: Real; stdcall;
RestartParsing: procedure; stdcall;
Модуль может реализовывать любой набор из вышеперечисленных интерфейсов.
Является ли типичный модуль интеллектуальным агентом? Зависит от реализации. Я вижу два варианта реализации. Рассмотрим их на примере модуля зрения. Первый вариант — простой программный агент: он кодирует картинки как есть, то есть конвертирует битмапы в текстовую строку. Второй вариант — сложный интеллектуальный агент: например, искусственная нейронная сеть, которая распознаёт на картинках объекты и описывает их словами в сообщении. Если используется несколько модулей второго типа, то можно сказать, что универсальный ИИ построен из набора слабых ИИ. Сильный это интеллект или нет — зависит от реализации ядра.
А теперь о деталях основной программы. Их три: организация баз данных ИИ по именам, регистрация пользователей со сбором статистики и «Центр Обмена» для обмена контентом.
Знаете платформу чат-ботов iii? По сути, Amiga Virtual — это во много раз более продвинутая версия iii. Только я называю экземпляры ИИ (поименованные базы данных) не «Инфами», а «Виртуалами». Каждый Виртуал обучается пользователем с нуля и может быть легко передан на другой компьютер. А с помощью модулей-аватаров (или эмотаров) может быть создан уникальный визуальный образ Виртуала. Вкладка менеджера Виртуалов выглядит так:
Выбор используемых модулей производится с проверкой на коллизии по контроль-коду:
type
CResult = (no_collision, collision);
function CheckModulesCollision: CResult;
var
i, j: Integer;
CC: String;
begin
Result := no_collision;
with ModulesList do
for i := 0 to Items.Count - 1 do
if Checked[i] then
begin
CC := FindModuleByFileName(ExtractDLLName(Items[i])).ControlCode;
for j := 0 to Items.Count - 1 do
if Checked[j] and (i <> j) and
(CC = FindModuleByFileName(ExtractDLLName(Items[j])).ControlCode)
then
Result := collision;
end;
end;
Для того, чтобы знать, как используется AV большинством пользователей и в каком направлении развивать проект, я собираю анонимную статистику.
Пока сбор стастистики не реализован.
Центр Обмена (ЦО) — это сервис, доступный из основной программы, предназначенный для обмена модулями, Виртуалами и прочим контентом между пользователями:
Вот так реализовано скачивание контента:
procedure TMainForm.DownloadFilesButtonClick(Sender: TObject);
var
Dir, FileName: String;
begin
SetCurrentDir(ProgramPath);
Dir := Category[ContentCategoryBox.ItemIndex];
Dir := UpCase(Dir[1]) + Copy(Dir, 2, Length(Dir));
if not DirectoryExists(Dir) then
CreateDir(Dir);
SetCurrentDir(Dir);
FileName := ContentList.Items[ContentList.ItemIndex];
if FileExists(FileName) then
MessageDlg(LanguageData[172], mtInformation, [mbOk], 0)
else
begin
DownloadFile(SiteProtocol + OfficialWebsite + ExchangeCenterPage + '?c=' +
Category[ContentCategoryBox.ItemIndex] + '&f=' + FileName + '&l=' +
LanguageData[0], FileName);
// В LanguageData[0] хранится название языка системы
if Copy(FileName, Length(FileName) - 2, 3) = 'zip' then
UnzipFiles(FileName, GetCurrentDir);
case ContentCategoryBox.ItemIndex of
0:
UpdateModulesList;
1:
UpdateVirtualsList;
end;
SetStatusMessage(LanguageData[173] + ' ' + ProgramPath + Dir + '');
end;
end;
procedure TMainForm.DownloadFile(From, SaveTo: String);
var
LoadStream: TMemoryStream;
begin
Downloading := true;
LoadStream := TMemoryStream.Create;
IdHTTP.Get(TIdURI.URLEncode(From), LoadStream);
LoadStream.SaveToFile(SaveTo);
LoadStream.Free;
Downloading := false;
SplashScreen.Close;
end;
Для загрузки контента в ЦО реализована система аккаунтов (пока не работает):
Планирую соединить её с системой аккаунтов официального форума, но пока не знаю, как.
Базовый доступ (только скачивание) обеспечивается в гостевом аккаунте, а для загрузки своего контента требуется зарегистрироваться (пока алгоритм загрузки контента не реализован, загружаю вручную по FTP).
Кроме ЦО также есть форум, который обеспечивает техподдержку пользователей — он открывается во встроенном браузере (стандартный для Delphi — вроде бы используется ядро Trident, но мне многого и не нужно, только отрисовать простую страницу; сам форум на phpBB):
Как видно из скриншота, можно ввести свой адрес и добавить страницу в закладки (закладки пока не реализованы) — чтобы не потерять обсуждение на форуме.
Ещё один момент. Заметили в коде выше LanguageData? Это массив, в котором хранятся текстовые строки для компонентов GUI, соответствующие выбранному языку. Русский и английский упакованы в .exe как ресурсы, и при старте распаковываются в папку Languages. Файлы с другими языками можно будет скачать через Центр Обмена. Т.к. Delphi поддерживает Юникод, язык можно установить какой угодно — японский или арабский, к примеру.
Вот так языковые файлы выгружаются из .exe:
procedure TMainForm.DeployDefaultLanguages;
procedure DeployLanguage(LanguageName: String);
var
ResHandle, MemHandle: THandle;
MemStream: TMemoryStream;
ResPtr: PByte;
ResSize: Longint;
ResName: String;
i: Integer;
begin
ResName := '';
for i := 1 to Length(LanguageName) do
ResName := ResName + UpCase(LanguageName[i]);
ResName := ResName + '_LP';
ResHandle := FindResource(HInstance, PWideChar(ResName), RT_RCDATA);
if ResHandle = 0 then
begin
ShowMessage('Default language "' + LanguageName + '" not found. (' +
ResName + ')');
exit;
end;
MemHandle := LoadResource(HInstance, ResHandle);
ResPtr := LockResource(MemHandle);
MemStream := TMemoryStream.Create;
ResSize := SizeOfResource(HInstance, ResHandle);
MemStream.SetSize(ResSize);
MemStream.Write(ResPtr^, ResSize);
MemStream.Seek(0, 0);
MemStream.SaveToFile(LangFilesDir + '/' + LanguageName +
LanguageFileExtension);
FreeResource(MemHandle);
MemStream.Destroy;
end;
begin
if not DirectoryExists(LangFilesDir) then
CreateDir(LangFilesDir);
DeployLanguage('Russian');
DeployLanguage('English');
end;
Как видно, чтобы встроить новый предустановленный язык, достаточно вставить файл языка в файл ресурсов, добавить строчку вида DeployLanguage('НазваниеЯзыка') и перекомпилировать проект — удобно.
Вот так загружается язык:
procedure TMainForm.ChangeLanguageTo;
function LanguageDataLoaded: Boolean;
var
T: TextFile;
B: RawByteString;
begin
SetCurrentDir(ProgramPath + LangFilesDir);
if FileExists(Language + LanguageFileExtension) then
begin
AssignFile(T, Language + LanguageFileExtension);
Reset(T);
SetLength(LanguageData, 1);
LanguageData[0] := Language;
while not eof(T) do
begin
SetLength(LanguageData, Length(LanguageData) + 1);
ReadLn(T, B);
LanguageData[Length(LanguageData) - 1] := UTF8ToWideString(B);
end;
CloseFile(T);
if Length(LanguageData) - 1 >= LangFileMinSize then
Result := true
else
Result := false;
end
else
Result := false;
end;
procedure SetCaptions;
begin
with HelpForm do
begin
LoadHelpTexts;
if CurrentTopic < HelpLast then
OpenTopic(CurrentTopic)
else
OpenTopic(0);
end;
AddModulesHelpToMainProgramHelp;
ChangeModulesLanguageToProgramLanguage;
AuthorizationTab.Caption := LanguageData[18];
// много-много строк...
CloudSaveNow.Caption := LanguageData[181];
CloudLoadNow.Caption := LanguageData[182];
end;
procedure CheckMenuItem;
var
Item: TMenuItem;
begin
for Item in LanguageMenu.Items do
if Item.Name = LanguageData[0] + 'Lang' then
Item.Checked := true;
end;
begin
if LanguageDataLoaded then
begin
if not Silent then
SetStatusMessage(LanguageData[2] + ' ' + LanguageData[0])
else
FormCaption.Caption := LanguageData[1];
SetCaptions;
end
else
begin
if Language <> SavedLanguage then
ChangeLanguageTo(SavedLanguage)
else
ChangeLanguageTo(DefaultLanguage);
MessageDlg(LanguageData[3] + #13 + LanguageData[2] + ' ' + LanguageData[0] +
'.', mtError, [mbOk], 0);
end;
CheckMenuItem;
end;
А ещё есть окно со справкой:
В него же через главное меню можно вывести информацию о модуле:
Ещё один интересный трюк — самообновление программы, сделано с помощью .bat-скрипта:
procedure TMainForm.UpdateProgram;
procedure DeployBAT;
var
bat: TextFile;
begin
if not FileExists(ProgramPath + 'update.bat') then
begin
AssignFile(bat, ProgramPath + 'update.bat');
Rewrite(bat);
WriteLn(bat, 'taskkill /im av.exe');
WriteLn(bat, 'sleep 1'); // Windows XP
WriteLn(bat, 'timeout /t 1 /nobreak'); // Windows 7+
WriteLn(bat, 'del av.exe');
WriteLn(bat, 'move ' + ZipsDir + 'av.exe %1');
WriteLn(bat, 'del /S /Q ' + ZipsDir);
WriteLn(bat, 'start av.exe');
// WriteLn(bat, 'pause');
CloseFile(bat);
end;
end;
begin
DownloadFile(SiteProtocol + OfficialWebsite + '/av.zip',
ProgramPath + 'av.zip');
UnzipFiles(ProgramPath + 'av.zip', ProgramPath + ZipsDir);
DeployBAT;
SetCurrentDir(ProgramPath);
ShellExecute(Handle, nil, 'update.bat', PChar(ProgramPath), nil, SW_SHOW);
end;
Напоследок хочу заметить, что проект Amiga Virtual включает в себя не только Windows-программу. Кроме неё планируются варианты системы для Android (AV Mobile), роботизированных платформ (AV OS) и суперкомпьютеров (AV Super). А алгоритмы интеллект-ядер могут быть использованы для создания интеллектуальной поисковой системы (реорганизующей результаты поиска Яндекса и Google). Когда будет готова альфа версия по какому-то из этих направлений, я напишу статью с описанием её работы.
Исходные коды проекта и дизайн-документ можно скачать здесь: github.com/TimKruz/AV
Спасибо за внимание.
Автор: TimKruz