6
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

DelphiでローカルLLMを利用したアプリを開発 その2 LM Studioとチャット

6
Last updated at Posted at 2025-12-03

本投稿はDelphi AdventCalender 2025 #04の記事です。

この記事は,Delphi13でコードを書き,Windows11で動作を確認しています。

はじめに

Delphi13を使ってLM Studio上のLLMとチャットするアプリを作ります。
この記事は,

の続きになります。
この記事て使ったコードが前提になりますので,この記事を読む前にぜひこちらをご覧ください。

LM Studioの設定

DelphiでローカルLLMを利用したアプリを開発 その1 で作ったKP.ListString.pasを使って,ローカルに配置されたLM StudioとOllamaを利用するクラスを作ります。

LM Studioをインストール

LM StudioはAPIからモデルをアンロードできません。またcontent lengthもWebアプリごとに指定することができませんが,GUIで管理できるので最初の運用は簡単です。

WindowsにLM Studioをインストール

上記の公式サイトからインストーラをダウンロードしてインストールします。

MacOSにLM Studioをインストール

macOSにインストールする場合は,Homebrewを使うと簡単です。

brew install --cask lm-studio

サーバを起動する

LM Studioを起動したら,右下にある⚙アイコンをクリックして設定を開き,アプリの更新を確認し,更新します。
また,言語に日本語を選択できます。
LM Studioの左上にある開発者を選択し,StatusをRunningにするとAPIが利用できます。
サーバとして運用するなら,DeveloperタブのローカルLLMサービス(ヘッドレス)をチェックします。
その隣のServer Settingを開いて,ローカルネットワークで提供をチェックするとLAN内の別のPCからLM Studioを利用できるようになります。右上にサーバのURLが表示されます。

モデルをロードする

LM Studioの左上にある探索を選択し,検索窓にLLMモデル名を入れて利用するモデルをダウンロードします。
検索されたモデルを選ぶと,そのPCのVRAMで動作可能かが表示されます。
まずはGoogleのGemma 3n か Gemma 3を選択するとよいのではないかと思います。後で交換可能です。
今回はGemma 3 12Bを利用します。

2025-12-04-01.png

動作確認する

LM Studioの左上のチャットを選択し,画面の上部中央にある「モデルを選択してください」をクリックして,Gemma 3 12Bを選択し,モデルを読み込みます。
モデルのコンテキスト長はここで変更可能です。より詳しい設定や,設定を記憶させることも可能です。
簡単な質問をしてモデルの動作を確認してください。

LM StudioとOllamaを使うクラスを作る

LM StudioのAPIは,OpenAIのAPIと互換性があり,OllamaのOpenAI APIと同じように利用できます。そこで,以前作ったOllamaを利用するRESTと同じように利用できるクラスを作りました。LLMモデルの選択とチャットができるようになっています。
※ 2025/12/06更新 回答が空なら回答できなかった扱いにするように変更しました。

KP.AICompThreads.pas
KP.AICompThreads.pas
unit KP.AICompThreads;
(*
OllamaとLM Studioのローカル生成AIを利用するスレッドクラス

LICENSE
Copyright (c) 2025 Yuzuru Kato
Released under the MIT license
http://opensource.org/licenses/mit-license.php

2025/12/06 回答が空なら回答できなかった扱いにする
*)
interface

uses
  System.Types, System.Classes,
  REST.Client, // REST
  KP.ListString;

const
  gAICompErrorText: string = 'The following questions could not be answered:'; // エラー時に履歴に残す先頭文字列
  gPromptTarget  = '####';     // プロンプトの中の####をインプットの内容に置き換える
  gAICompTimeout =  60000;     // GenerateとChatのタイムアウト
  gAttachHeader  = ':attach:'; // 画像を添付する場合のデリミタ 以降にファイルの場所を記述
  gTimeoutFirstRate = 5  ;     // 最初のタイムアウトは通常の5倍

type
  TAICompType = (Ollama, Ollama_OpenAI, LM_Studio);
  // Ollama:        Ollama API
  // Ollama_OpenAI: OllamaのOpenAI互換API API Keyのヘッダが不要
  // LM_Studio:     LM StudioのOpenAI互換API API Keyのヘッダが不要
  // ※OpenAI互換APIの処理は共通 デフォルトのポート番号が違うため分けている

//==============================================================================
// OpenAI / Ollama 共通
type
  TAICompThreadError = procedure(AText:string) of object;
  // for ModelList
  TAICompThreadModelListOut = procedure(AAICompModelList: TStringDynArray) of object;

// AICompThreadModelList
type
  TAICompThreadModelList = class(TThread)
  private
    { Private 宣言 }
    FAICompHost: string;
  protected
    FAICompThreadModelListOut: TAICompThreadModelListOut;
    FAICompThreadError: TAICompThreadError;
    procedure Execute; override; abstract;
  public
    constructor Create(AHost:string;
      AAICompThreadModelListOut: TAICompThreadModelListOut;
      AAICompThreadError:TAICompThreadError); virtual;
  end;

type
  TAICompParamIn = record
    AICompType: TAICompType;
    Host:  string;
    Model: string;
    SystemPrompt: string;
    Prompt:       string;
    temperature:  single;
    top_p:        Single;  // top-kと連携します。値が大きいほど(0.95など),テキストの多様性が増し,値を小さくする(0.5など)テキストは,より焦点を絞った保守的なテキストが生成されます。(デフォルト: 0.9)
    Sleep:        integer; // OllamaThreadChatQueueのループの休み
    Timeout:      integer; // GenerateとChatのタイムアウト
    // 以下はOllamaのみ適用される
    num_ctx:      integer; // コンテキスト長
    num_predict:  integer; // 無限生成
    top_k:        integer; // ナンセンスを生成する確率を減らします。値が大きいほど(例:100),回答が多様化し,値が低いほど(例:10)保守的になります。(デフォルト: 40)
    min_p:        Single;  // top_pに代わるもので,品質と多様性のバランスを確保することを目指しています。パラメーター p は,最も可能性の高いトークンの確率に対する,トークンが考慮される最小の確率を表します。たとえば,p=0.05 で,最も可能性の高いトークンの確率が 0.9 の場合,値が 0.045 未満のロジットはフィルターで除外されます。(デフォルト: 0.0)
    KeepAlive:    string;  // Load Keep Alive 5s default
    procedure Init;
  end;

  TAICompParamOut = record
    prompt_tokens    :integer;
    completion_tokens:integer;
    total_tokens     :integer;
    tick_time        :Int64;
  end;

  TAICompThreadIn     = procedure(AText:TListString) of object;
  TAICompThreadOut    = procedure(AText:String; AAICompParamOut:TAICompParamOut) of object;
  TAICompThreadFinish = procedure of object;

type
  TAICompThreadChat = class(TThread)
  private
    { Private 宣言 }
  protected
    FAICompParamIn: TAICompParamIn;
    FText: string;
    FAICompThreadIn     : TAICompThreadIn;
    FAICompThreadOut    : TAICompThreadOut;
    FAICompThreadFinish : TAICompThreadFinish;
    FAICompThreadError  : TAICompThreadError;
    procedure Execute; override; abstract;
  public
    constructor Create(AAICompParamIn:TAICompParamIn;
      AAICompThreadIn     : TAICompThreadIn;
      AAICompThreadOut    : TAICompThreadOut;
      AAICompThreadFinish : TAICompThreadFinish;
      AAICompThreadError  : TAICompThreadError); virtual;
  end;

// プライベートホスト名 タイプごとのデフォルトポート番号を割り当てる 正しくない場合は空を返す
function PrivateHostNameMod(AHostName: string; AAICompType: TAICompType):string;

//==============================================================================
// OpenAI
// OpenAIThreadModelList
type
  TOpenAIThreadModelList = class(TAICompThreadModelList)
  private
    { Private 宣言 }
  protected
    procedure Execute; override;
  public
  end;

// OpenAIThreadChat
type
  TOpenAIThreadChat = class(TAICompThreadChat)
  private
    { Private 宣言 }
  protected
    procedure Execute; override;
  public
  end;

//==============================================================================
// Ollama

// OllamaThreadModelList
type
  TOllamaThreadModelList = class(TAICompThreadModelList)
  private
    { Private 宣言 }
  protected
    procedure Execute; override;
  end;

// OllamaThreadChat
type
  TOllamaThreadChat = class(TAICompThreadChat)
  private
    { Private 宣言 }
  protected
    procedure Execute; override;
  public
  end;

implementation

uses
  System.SysUtils, System.Math,
  System.Diagnostics, // TStopWatch
  System.JSON, System.NetEncoding,
  System.JSON.Types, System.JSON.Serializers,  // TJsonSerializer
  System.RegularExpressions,
  REST.Types; // REST

//==============================================================================
// 共通

const
  OpenAILastErrors:array[-3..-1] of string
  =('Couldn''t parse output.',       // -3 パースエラー
    'Couldn''t connect to OpenAI. ', // -2 接続エラー
    'OpenAI Error');                 // -1 その他のエラー

  OllamaLastErrors:array[-3..-1] of string
  =('Couldn''t parse output.',       // -3 パースエラー
    'Couldn''t connect to Ollama. ', // -2 接続エラー
    'Ollama Error');                 // -1 その他のエラー

function InPutEx(APrompt, AInput:string): string; // InputにPromptを追加
begin
  if APrompt='' then begin
    Result:=AInput;
  end else
  if pos(gPromptTarget,APrompt)<>0 then begin
    Result:=StringReplace(APrompt,gPromptTarget,AInput,[]); // プロンプトの中の####をインプットの内容に置き換える
  end else begin
    Result:=APrompt+' '+AInput;
  end;
end;

function FileNameToBase64Str(AFileName:string): string;
var
  m: TMemoryStream;
  s: TStringStream;
begin
  if FileExists(AFileName) then begin
    m:=TMemoryStream.Create;
    m.LoadFromFile(AFileName);
    s:=TStringStream.Create;
    TNetEncoding.Base64.Encode(m, s);
    m.Free;
    Result:=StringReplace(s.DataString,#13#10,'',[rfReplaceAll]);
    s.Free;
  end else begin
    Result:='';
  end;
end;

procedure StringSplitAttach(AInput:string; var s, f, e:string); // 入力を文字列とファイル名と拡張子情報に分解
var
  j:integer;
begin
  j:=pos(gAttachHeader,AInput);
  if j>0 then begin
    s:=copy(AInput, 1, j-1);
    f:=copy(AInput, j+Length(gAttachHeader), Length(AInput));
    if FileExists(f) then begin
      e:=LowerCase(ExtractFileExt(f));
      if (e='.png') then begin
        e:='png';
      end else
      if (e='.jpg')or(e='.jpeg') then begin
        e:='jpeg';
      end else begin
        f:='';
      end;
    end else begin
      f:='';
    end;
  end else begin
    s:=AInput;
    f:='';
  end;
end;

//------------------------------------------------------------------------------
{ TAICompParamIn }
procedure TAICompParamIn.Init;
begin
  AICompType   := TAICompType.Ollama;
  SystemPrompt := '';
  Prompt       := '';
  temperature  :=  0.1; // 0.8;    0 - 2
  top_p:=0.1;  // 0.9;  // top-kと連携します。値が大きいほど(0.95など),テキストの多様性が増し,値を小さくする(0.5など)テキストは,より焦点を絞った保守的なテキストが生成されます。(デフォルト: 0.9)
  // スレッド用のパラメータ
  Sleep        := 10;             // OllamaThreadChatQueueのループの休み
  Timeout      := gAICompTimeout; // GenerateとChatのタイムアウト
  // 以下はOllamaのみ適用される
  num_ctx      := 2048; // コンテキスト長
  num_predict  :=   -1; // -1; 無限生成
  top_k        :=   10; // 40; ナンセンスを生成する確率を減らします。値が大きいほど(例:100),回答が多様化し,値が低いほど(例:10)保守的になります。(デフォルト: 40)
  min_p        :=  0.0; // top_pに代わるもので,品質と多様性のバランスを確保することを目指しています。パラメーター p は,最も可能性の高いトークンの確率に対する,トークンが考慮される最小の確率を表します。たとえば,p=0.05 で,最も可能性の高いトークンの確率が 0.9 の場合,値が 0.045 未満のロジットはフィルターで除外されます。(デフォルト: 0.0)
  KeepAlive    := '30m'; // '5m'; '5s'; ModelのKeep Alive -1:無制限 0:即時停止
end;

//------------------------------------------------------------------------------
{ TAICompThreadModelList }
constructor TAICompThreadModelList.Create(AHost: string;
  AAICompThreadModelListOut: TAICompThreadModelListOut;
  AAICompThreadError: TAICompThreadError);
begin
  FAICompHost := AHost;
  FAICompThreadModelListOut := AAICompThreadModelListOut;
  FAICompThreadError        := AAICompThreadError;
  FreeOnTerminate := True;
  inherited Create(False);
end;

//------------------------------------------------------------------------------
{ TAICompThreadChat }
constructor TAICompThreadChat.Create(AAICompParamIn: TAICompParamIn;
  AAICompThreadIn: TAICompThreadIn; AAICompThreadOut: TAICompThreadOut;
  AAICompThreadFinish: TAICompThreadFinish;
  AAICompThreadError: TAICompThreadError);
begin
  FAICompParamIn      := AAICompParamIn;
  FAICompThreadIn     := AAICompThreadIn;
  FAICompThreadOut    := AAICompThreadOut;
  FAICompThreadFinish := AAICompThreadFinish;
  FAICompThreadError  := AAICompThreadError;
  FreeOnTerminate := True;
  inherited Create(False);
end;

//------------------------------------------------------------------------------
// HostName
procedure HostNameParse(AHostName:string; out p1, p2, p3:string);
var
  match:TMatch;
begin
  match := TRegEx.Match(AHostName, '^(|https?:\/\/)([^:]*)(|:\d{1,5})$');
  if match.Success then begin
    p1 := match.Groups[1].Value;
    p2 := match.Groups[2].Value;
    p3 := match.Groups[3].Value;
  end else begin
    p1 := '';
    p2 := '127.0.0.1';
    p3 := '';
  end;
end;

function isPrivateHostName(AHostName:string):boolean;
begin
  if (AHostName='localhost')or(AHostName='127.0.0.1') then begin
    Result:=True;
  end else
  if TRegEx.Match(AHostName, '^10\.([0-9]\n[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\.([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\.([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])$').Success then begin
    Result := True;
  end else
  if TRegEx.Match(AHostName, '^172\.(1[6-9]|2[0-9]|3[0-1])\.([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\.([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])$').Success then begin
    Result := True;
  end else
  if TRegEx.Match(AHostName, '192\.168\.([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\.([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])$').Success then begin
    Result := True;
  end else begin
    Result := False;
  end;
end;

function PrivateHostNameMod(AHostName: string; AAICompType: TAICompType):string;
var
  p1,p2,p3:string;
begin
  HostNameParse(AHostName, p1, p2, p3);
  if p1='' then begin
    p1 := 'http://';
  end;
  if p3='' then begin
    case AAICompType of
      Ollama, Ollama_OpenAI: p3 := ':11434';
      LM_Studio:             p3 := ':1234';
    end;
  end;
  if p2='localhost' then begin
    Result := p1+'127.0.0.1'+p3;
  end else
  if p2='127.0.0.1' then begin
    Result := p1+p2+p3;
  end else
 if TRegEx.Match(p2, '^10\.([0-9]\n[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\.([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\.([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])$').Success then begin
    Result := p1+p2+p3;
  end else
  if TRegEx.Match(p2, '^172\.(1[6-9]|2[0-9]|3[0-1])\.([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\.([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])$').Success then begin
    Result := p1+p2+p3;
  end else
  if TRegEx.Match(p2, '192\.168\.([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\.([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])$').Success then begin
    Result := p1+p2+p3;
  end else begin
    Result := '';
  end;
end;

//==============================================================================
{ OpenAI }

function OpenAIURL(AHost, AMethod:string):string;
begin
  Result:=AHost+'/v1/'+AMethod;
end;

//------------------------------------------------------------------------------
{ TOpenAIThreadModelList }

procedure OpenAIRESTRequestGET(ARESTRequest: TRESTRequest; AHost, AMethod: string);
begin
  ARESTRequest.Client   := TRESTClient.Create(ARESTRequest);
  ARESTRequest.Client.SynchronizedEvents := False;
  ARESTRequest.Client.BaseURL := OpenAIURL(AHost, AMethod);
  ARESTRequest.Method   := TRESTRequestMethod.rmGET;
  ARESTRequest.Response := TRESTResponse.Create(ARESTRequest);
  ARESTRequest.Params.Clear; // アイテムは不要
  ARESTRequest.SynchronizedEvents := False;
end;

function OpenAIJSONListToText(AContent:string;var List:TStringDynArray):
    integer;
type
  TOpenAIModel=record
    id:string;
  end;
  TOpenAIModelList=record
    data:array of TOpenAIModel;
  end;
var
  st:TStringList;
  m:TOpenAIModel;
begin
  Result:=-3;
  SetLength(List,0);
  var JsonSerializer := TJsonSerializer.Create;
  try
    var JSONed := JsonSerializer.Deserialize<TOpenAIModelList>(AContent);
    st:=TStringList.Create;
    for m in JSONed.data do begin
      st.Add(m.id);
    end;
    List:=st.ToStringArray;
    st.Free;
    Result:=0;
  finally
    JsonSerializer.Free;
  end;
end;

function OpenAIRESTExecute(ARESTRequest:TRESTRequest; var AResponse: string): integer; // 0:成功 -2:接続エラー
var
  i:integer;
begin
  Result := -1;
  try
    ARESTRequest.Execute;
  except
    Result := -2;
  end;
  if (Result=-1)and(ARESTRequest.Response.StatusCode=200) then begin
    AResponse := ARESTRequest.Response.Content;
    Result    := 0;
  end else begin
    AResponse := '';
    Result    := -2; // -2:接続エラー
  end;
end;

procedure TOpenAIThreadModelList.Execute;
var
  LRESTRequest: TRESTRequest;
  LResponse: string;
  LOllamaModelList:TStringDynArray;
  LastError: integer;
begin
  LastError:=-1;
  LRESTRequest:=TRESTRequest.Create(nil);
  OpenAIRESTRequestGET(LRESTRequest, FAICompHost, 'models');

  try
    LastError := OpenAIRESTExecute(LRESTRequest, LResponse);
    if LastError=0 then begin
      LastError := OpenAIJSONListToText(LResponse, LOllamaModelList);
      if LastError=0 then begin
        if Assigned(FAICompThreadModelListOut) then begin
          Synchronize(
            procedure
            begin
              FAICompThreadModelListOut(LOllamaModelList);
            end
          );
        end;
      end;
    end;
    if LastError<0 then begin
      Synchronize(
        procedure
        begin
          FAICompThreadError(OpenAILastErrors[LastError]);
        end
      );
    end;
  finally
    LRESTRequest.Response.Free;
    LRESTRequest.Client.Free;
    LRESTRequest.Free;
  end;
end;

//------------------------------------------------------------------------------
{ TOpenAIThreadChat }

procedure OpenAIRESTRequestPOST(ARESTRequest: TRESTRequest);
begin
  ARESTRequest.Client   := TRESTClient.Create(ARESTRequest);
  ARESTRequest.Client.SynchronizedEvents := False;
  ARESTRequest.Method   := TRESTRequestMethod.rmPOST;
  ARESTRequest.Response := TRESTResponse.Create(ARESTRequest);
  ARESTRequest.Params.Clear;
  // 空のアイテムを作っておく
  ARESTRequest.Params.AddItem('OpenAIJSON', '', TRESTRequestParameterKind.pkREQUESTBODY,
    [], TRESTContentType.ctAPPLICATION_JSON);
  ARESTRequest.SynchronizedEvents := False;
end;

function OpenAIChatToJSON(AAICompParamIn:TAICompParamIn; AInput:TListString):string;
var
  LJsonObj: TJSONObject;
  LJsonArray: TJSONArray;
  LJsonObjs: array of TJSONObject;
  LJsonArrays: array of TJSONArray;
  LJsonObjs2: array of array[0..2]of TJSONObject;
  i,j,c:integer;
  s,f,e:string;
begin
  LJsonObj := TJSONObject.Create;
  LJsonObj.AddPair('model', AAICompParamIn.Model);

  c:=AInput.Count;
  SetLength(LJsonArrays,c);
  SetLength(LJsonObjs2,c);
  if AAICompParamIn.SystemPrompt='' then begin
    SetLength(LJsonObjs,c);
    j := 0;
  end else begin
    SetLength(LJsonObjs,c+1);
    LJsonObjs[0]:=TJSONObject.Create;
    LJsonObjs[0].AddPair('role', 'developer');
    LJsonObjs[0].AddPair('content', AAICompParamIn.SystemPrompt);
    j := 1;
  end;
  for i:=0 to c-1 do begin
    LJsonObjs[j]:=TJSONObject.Create;
    if (i mod 2)=0 then begin
      LJsonObjs[j].AddPair('role', 'user');
    end else begin
      LJsonObjs[j].AddPair('role', 'assistant');
    end;
    StringSplitAttach(AInput[i], s, f, e); // 入力を文字列とファイル名と拡張子情報に分解
    s:=InputEx(AAICompParamIn.Prompt, s);
    if f='' then begin
      LJsonObjs[j].AddPair('content', s);
    end else begin
      LJsonObjs2[i,0]:=TJSONObject.Create;
      LJsonObjs2[i,0].AddPair('type', 'text');
      LJsonObjs2[i,0].AddPair('text', s);
      LJsonObjs2[i,1]:=TJSONObject.Create;
      LJsonObjs2[i,1].AddPair('type', 'image_url');
      LJsonObjs2[i,2]:=TJSONObject.Create;
      LJsonObjs2[i,2].AddPair('url','data:image/'+e+';base64,'+FileNameToBase64Str(f));
      LJsonObjs2[i,1].AddPair('image_url', LJsonObjs2[i,2]);
      LJsonArrays[i]:=TJSONArray.Create;
      LJsonArrays[i].Add(LJsonObjs2[i,0]);
      LJsonArrays[i].Add(LJsonObjs2[i,1]);
      LJsonObjs[j].AddPair('content', LJsonArrays[i]);
    end;
    inc(j);
  end;
  LJsonArray := TJSONArray.Create;
  for i:= 0 to j-1 do begin
    LJsonArray.Add(LJsonObjs[i]);
  end;
  LJsonObj.AddPair('messages', LJsonArray);

  LJsonObj.AddPair('temperature', AAICompParamIn.temperature);
  LJsonObj.AddPair('top_p',       AAICompParamIn.top_p);
  LJsonObj.AddPair('stream',      False);
  Result:= LJsonObj.ToString;

  LJsonObj.Free;
end;

function OpenAIJSONToChat(AContent:string;var AOutput:string;
  var AAICompParamOut:TAICompParamOut): integer;
type
  TOpenAIMessage=record
    content:string;
  end;
  TOpenAIChoice=record
    [JsonName('message')] _message:TOpenAIMessage;
  end;
  TOpenAIUsage=record
    prompt_tokens    :integer;
    completion_tokens:integer;
    total_tokens     :integer;
  end;
  TOpenAIChat=record
    choices:array of TOpenAIChoice;
    usage:TOpenAIUsage;
  end;
begin
  Result:=-3;
  var JsonSerializer := TJsonSerializer.Create;
  try
    var JSONed  := JsonSerializer.Deserialize<TOpenAIChat>(AContent);
    AOutput := JSONed.choices[0]._message.content;
    AAICompParamOut.prompt_tokens     := JSONed.usage.prompt_tokens;
    AAICompParamOut.completion_tokens := JSONed.usage.completion_tokens;
    AAICompParamOut.total_tokens      := JSONed.usage.total_tokens;
    Result:=0;
  finally
    JsonSerializer.Free;
  end;
end;

function OpenAIChat(ARESTRequest:TRESTRequest; AAICompParamIn:TAICompParamIn;
    AListString: TListString; var AOutPut: string;
    var AAICompParamOut:TAICompParamOut): integer;
var
  LastError:integer;
  LResponse:string;
begin
  LastError := -1;
  ARESTRequest.Client.ReadTimeout := AAICompParamIn.Timeout;
  ARESTRequest.Client.BaseURL     := OpenAIURL(AAICompParamIn.Host,'chat/completions');
  ARESTRequest.Params[0].Value    := OpenAIChatToJSON(AAICompParamIn, AListString);
  LastError := OpenAIRESTExecute(ARESTRequest, LResponse);
  if LastError=0 then begin
    LastError := OpenAIJSONToChat(LResponse,AOutput,AAICompParamOut);
    if LastError<>0 then begin
      AOutput := '';
      AAICompParamOut.prompt_tokens     := 0;
      AAICompParamOut.completion_tokens := 0;
      AAICompParamOut.total_tokens      := 0;
    end;
  end;
  Result := LastError;
end;

procedure TOpenAIThreadChat.Execute;
var
  LRESTRequest: TRESTRequest;
  LText:string;
  FListString:TListString;
  LAICompParamOut:TAICompParamOut;
  LastError:integer;
  StopWatch: TStopWatch;
  isFirst: boolean;
  dTimeOut:integer;
begin
  FListString:=TListString.Create;
  LRESTRequest:=TRESTRequest.Create(nil);
  OpenAIRESTRequestPOST(LRESTRequest);
  isFirst  := True;
  dTimeOut := FAICompParamIn.Timeout;
  while True do begin
    if Terminated then begin
      LRESTRequest.Response.Free;
      LRESTRequest.Client.Free;
      LRESTRequest.Free;
      FListString.Free;
      // LastErrorを表示する処理が必要か?
      if Assigned(FAICompThreadFinish) then begin
        Synchronize(
          procedure
          begin
            FAICompThreadFinish;
          end
        );
      end;
      exit;
    end;
    if Assigned(FAICompThreadIn) then begin
      Synchronize(
        procedure
        begin
          FAICompThreadIn(FListString);
        end
      );
    end;
    if FListString.Count>0 then begin
      if isFirst then begin
        FAICompParamIn.Timeout:=dTimeOut*gTimeOutFirstRate;
        isFirst := False;
      end else begin
        FAICompParamIn.Timeout:=dTimeOut;
      end;
      StopWatch := TStopWatch.StartNew;
      LastError := OpenAIChat(LRESTRequest, FAICompParamIn, FListString, LText, LAICompParamOut);
      LAICompParamOut.tick_time := StopWatch.ElapsedMilliseconds;
      if LastError=0 then begin
        if LText='' then begin // 回答が空なら回答できなかった扱いにする
          LText := gAICompErrorText+' '+FListString[FListString.Count-1];
        end;
        if Assigned(FAICompThreadOut) then begin
          Synchronize(
            procedure
            begin
              FAICompThreadOut(LText, LAICompParamOut);
            end
          );
        end;
      end else begin
        LText := gAICompErrorText+' '+FListString[FListString.Count-1];
        if Assigned(FAICompThreadOut) then begin
          Synchronize(
            procedure
            begin
              FAICompThreadOut(LText, LAICompParamOut);
            end
          );
        end;
        if Assigned(FAICompThreadError) then begin
          Synchronize(
            procedure
            begin
              FAICompThreadError(OllamaLastErrors[LastError]);
            end
          );
        end;
      end;
    end;
    if FAICompParamIn.Sleep>0 then sleep(FAICompParamIn.Sleep);
  end;
end;

//==============================================================================
{ Ollama }

function OllamaURL(AHost,AMethod:string):string;
// RESTRequest.Client.BaseURL := OllamaURL(AHost,AMethod);
begin
  Result:=AHost+'/api/'+AMethod;
end;

//------------------------------------------------------------------------------
{ TOllamaThreadModelList }

procedure OllamaRESTRequestGET(ARESTRequest: TRESTRequest; AHost, AMethod: string);
begin
  ARESTRequest.Client   := TRESTClient.Create(ARESTRequest);
  ARESTRequest.Client.SynchronizedEvents := False;
  ARESTRequest.Client.BaseURL := OllamaURL(AHost,AMethod);
  ARESTRequest.Method   := TRESTRequestMethod.rmGET;
  ARESTRequest.Response := TRESTResponse.Create(ARESTRequest);
  ARESTRequest.Params.Clear; // アイテムは不要
  ARESTRequest.SynchronizedEvents := False;
end;


function OllamaRESTExecute(ARESTRequest:TRESTRequest; var AResponse: string): integer; // 0:成功 -2:接続エラー
var
  i:integer;
begin
  Result := -1;
  try
    ARESTRequest.Execute;
  except
    Result := -2;
  end;
  if (Result=-1)and(ARESTRequest.Response.StatusCode=200) then begin
    AResponse := ARESTRequest.Response.Content;
    Result    := 0;
  end else begin
    AResponse := '';
    Result    := -2; // -2:接続エラー
  end;
end;

function OllamaJSONListToText(AContent:string;var List:TStringDynArray):
    integer;
type
  TOllamaModel=record
    name:string;
  end;
  TOllama=record
    models:array of TOllamaModel;
  end;
var
  st:TStringList;
  m:TOllamaModel;
begin
  Result:=-3;
  SetLength(List,0);
  var JsonSerializer := TJsonSerializer.Create;
  try
    var JSONed  := JsonSerializer.Deserialize<TOllama>(AContent);
    st:=TStringList.Create;
    for m in JSONed.models do begin
      st.Add(m.name);
    end;
    List:=st.ToStringArray;
    st.Free;
    Result:=0;
  finally
    JsonSerializer.Free;
  end;
end;

procedure TOllamaThreadModelList.Execute;
var
  LRESTRequest: TRESTRequest;
  LResponse: string;
  LOllamaModelList:TStringDynArray;
  LastError: integer;
begin
  LastError:=-1;
  LRESTRequest:=TRESTRequest.Create(nil);
  OllamaRESTRequestGET(LRESTRequest, FAICompHost, 'tags');

  try
    LastError := OllamaRESTExecute(LRESTRequest, LResponse);
    if LastError=0 then begin
      LastError := OllamaJSONListToText(LResponse, LOllamaModelList);
      if LastError=0 then begin
        if Assigned(FAICompThreadModelListOut) then begin
          Synchronize(
            procedure
            begin
              FAICompThreadModelListOut(LOllamaModelList);
            end
          );
        end;
      end;
    end;
    if LastError<0 then begin
      Synchronize(
        procedure
        begin
          FAICompThreadError(OllamaLastErrors[LastError]);
        end
      );
    end;
  finally
    LRESTRequest.Response.Free;
    LRESTRequest.Client.Free;
    LRESTRequest.Free;
  end;
end;

//------------------------------------------------------------------------------

procedure OllamaRESTRequestPOST(ARESTRequest: TRESTRequest);
begin
  ARESTRequest.Client   := TRESTClient.Create(ARESTRequest);
  ARESTRequest.Client.SynchronizedEvents := False;
  ARESTRequest.Method   := TRESTRequestMethod.rmPOST;
  ARESTRequest.Response := TRESTResponse.Create(ARESTRequest);
  ARESTRequest.Params.Clear;
  // 空のアイテムを作っておく
  ARESTRequest.Params.AddItem('OllamaJSON', '', TRESTRequestParameterKind.pkREQUESTBODY,
    [], TRESTContentType.ctAPPLICATION_JSON);
  ARESTRequest.SynchronizedEvents := False;
end;

//------------------------------------------------------------------------------
// OllamaModelLoad
function OllamaLoadTextToJSON(AModel:string; AKeepALive:string): string;
type
  TOllama=record
    model:string;
    keep_alive:string;
  end;
var
  Ollama:TOllama;
begin
  Result:='';
  Ollama.model := AModel;
  Ollama.keep_alive := AKeepALive; // AKeepALive:-1 無制限
  var JsonSerializer := TJsonSerializer.Create;
  try
    JsonSerializer.Formatting := TJsonFormatting.Indented;
    Result := JsonSerializer.Serialize<TOllama>(Ollama);
  finally
    JsonSerializer.Free;
  end;
end;

function OllamaLoadJSONToBool(AContent:string;var isDone:boolean): integer;
type
  TOllama=record
    done:boolean;
  end;
begin
  isDone:=False;
  Result:=-3;
  var JsonSerializer := TJsonSerializer.Create;
  try
    var JSONed  := JsonSerializer.Deserialize<TOllama>(AContent);
    isDone:=JSONed.done;
    Result:=0;
  finally
    JsonSerializer.Free;
  end;
end;

function OllamaModelLoad(ARESTRequest:TRESTRequest; AAICompParamIn:TAICompParamIn; var isDone:boolean):integer;
var
  LastError:integer;
  LResponse:string;
begin
  LastError := -2;
  ARESTRequest.Client.ReadTimeout := AAICompParamIn.Timeout*gTimeOutFirstRate;
  ARESTRequest.Client.BaseURL     := OllamaURL(AAICompParamIn.Host,'generate');
  ARESTRequest.Params[0].Value := OllamaLoadTextToJSON(AAICompParamIn.Model, AAICompParamIn.KeepAlive);
  LastError := OllamaRESTExecute(ARESTRequest, LResponse);
  if LastError=0 then begin
    LastError := OllamaLoadJSONToBool(LResponse,isDone);
  end;
  Result:=LastError;
end;

//------------------------------------------------------------------------------
// OllamaModelUnLoad
function OllamaUnLoadTextToJSON(AModel:string): string;
type
  TOllama=record
    model:string;
    keep_alive:integer;
  end;
var
  Ollama:TOllama;
begin
  Result:='';
  Ollama.model      := AModel;
  Ollama.keep_alive := 0; // 秒単位
  var JsonSerializer := TJsonSerializer.Create;
  try
    JsonSerializer.Formatting := TJsonFormatting.Indented;
    Result := JsonSerializer.Serialize<TOllama>(Ollama);
  finally
    JsonSerializer.Free;
  end;
end;

function OllamaUnLoadJSONToBool(AContent:string;var isDone:boolean): integer;
type
  TOllama=record
    model:string;
    created_at:TDateTime;
    response:string;
    done:boolean;
    done_reason:string;
  end;
begin
  Result:=-3;
  var JsonSerializer := TJsonSerializer.Create;
  try
    var JSONed := JsonSerializer.Deserialize<TOllama>(AContent);
    isDone:=(JSONed.done)and(JSONed.done_reason='unload');
    Result:=0;
  finally
    JsonSerializer.Free;
  end;
end;

function OllamaModelUnload(ARESTRequest:TRESTRequest; AAICompParamIn:TAICompParamIn; var isDone:boolean):integer;
var
  LastError:integer;
  LResponse:string;
begin
  LastError:=-1;
  ARESTRequest.Client.ReadTimeout := AAICompParamIn.Timeout*gTimeOutFirstRate;
  ARESTRequest.Client.BaseURL     := OllamaURL(AAICompParamIn.Host,'generate');
  ARESTRequest.Params[0].Value    := OllamaUnLoadTextToJSON(AAICompParamIn.Model);
  LastError := OllamaRESTExecute(ARESTRequest, LResponse);
  if LastError=0 then begin
    LastError := OllamaUnLoadJSONToBool(LResponse,isDone);
  end;
  Result:=LastError;
end;
//------------------------------------------------------------------------------
{ TOllamaThreadChat }

function OllamaChatToJSON(AAICompParamIn:TAICompParamIn; AInput:TListString): string;
type
  TOllamaMessage=record
    role:string;
    content:string;
    images:array of string;
  end;
  TOllamaOptions=record
    num_ctx:integer;
    temperature: single;
    num_predict: integer; // テキストを生成するときに予測するトークンの最大数。(デフォルト: -1,無限生成)
    top_k: integer; // ナンセンスを生成する確率を減らします。値が大きいほど(例:100),回答が多様化し,値が低いほど(例:10)保守的になります。(デフォルト: 40)
    top_p: Single;  // top-kと連携します。値が大きいほど(0.95など),テキストの多様性が増し,値を小さくする(0.5など)テキストは,より焦点を絞った保守的なテキストが生成されます。(デフォルト: 0.9)
    min_p: Single;  // top_pに代わるもので,品質と多様性のバランスを確保することを目指しています。パラメーター p は,最も可能性の高いトークンの確率に対する,トークンが考慮される最小の確率を表します。たとえば,p=0.05 で,最も可能性の高いトークンの確率が 0.9 の場合,値が 0.045 未満のロジットはフィルターで除外されます。(デフォルト: 0.0)
  end;
  TOllama=record
    model:string;
    messages:array of TOllamaMessage;
    stream:boolean;
    options:TOllamaOptions;
  end;
var
  Ollama:TOllama;
  i,j:integer;
  s,f,e:string;
begin
  Result:='';
  Ollama.model:= AAICompParamIn.Model;
  if AAICompParamIn.SystemPrompt='' then begin
    SetLength(Ollama.Messages,AInput.Count);
    j:=-1;
  end else begin
    SetLength(Ollama.Messages,AInput.Count+1);
    Ollama.Messages[0].role   :='system';
    Ollama.Messages[0].content:=AAICompParamIn.SystemPrompt;
    j:=0;
  end;
  for i:=1 to AInput.Count do begin
    if i mod 2=1 then begin
      Ollama.Messages[i+j].role :='user';
    end else begin
      Ollama.Messages[i+j].role :='assistant';
    end;
    StringSplitAttach(AInput[i-1], s, f, e); // 入力を文字列とファイル名と拡張子情報に分解
    Ollama.Messages[i+j].content:=InputEx(AAICompParamIn.Prompt, s);
    if (f<>'')and(FileExists(f)) then begin
      SetLength(Ollama.Messages[i+j].images,1);
      Ollama.Messages[i+j].images[0]:=FileNameToBase64Str(f);
    end else begin
      SetLength(Ollama.Messages[i+j].images,0);
    end;
  end;

  Ollama.options.num_ctx:= AAICompParamIn.num_ctx;
  Ollama.options.temperature := AAICompParamIn.temperature;
  Ollama.options.num_predict := AAICompParamIn.num_predict;
  Ollama.options.top_k := AAICompParamIn.top_k;
  Ollama.options.top_p := AAICompParamIn.top_p;
  Ollama.options.min_p := AAICompParamIn.min_p;

  Ollama.stream := False;
  var JsonSerializer := TJsonSerializer.Create;
  try
    JsonSerializer.Formatting := TJsonFormatting.Indented;
    Result := JsonSerializer.Serialize<TOllama>(Ollama);
  finally
    JsonSerializer.Free;
  end;
end;

function OllamaJSONToChat(AContent:string;var AOutput:string;var AAICompParamOut:TAICompParamOut): integer;
type
  TOllamaMessage=record
    role:string;
    content:string;
  end;
  TOllama=record
    model:string;
    created_at:TDateTime;
    message:TOllamaMessage;
    done:boolean;
    prompt_eval_count:integer;
    eval_count:integer;
    prompt_eval_duration:Int64;
    eval_duration:Int64;
  end;
begin
  Result := -3;
  var JsonSerializer := TJsonSerializer.Create;
  try
    var JSONed  := JsonSerializer.Deserialize<TOllama>(AContent);
    AOutput := JSONed.message.content;
    AAICompParamOut.prompt_tokens     := JSONed.prompt_eval_count;
    AAICompParamOut.completion_tokens := JSONed.eval_count;
    AAICompParamOut.total_tokens      := JSONed.prompt_eval_count+JSONed.eval_count;
    AAICompParamOut.tick_time         := JSONed.prompt_eval_duration+JSONed.eval_duration;
    Result := 0;
  finally
    JsonSerializer.Free;
  end;
end;

function OllamaChat(ARESTRequest:TRESTRequest; AAICompParamIn:TAICompParamIn;
 AListString: TListString; var AOutPut: string;var AAICompParamOut:TAICompParamOut): integer;
var
  LastError:integer;
  LResponse:string;
begin
  LastError:=-1;
  ARESTRequest.Client.ReadTimeout := AAICompParamIn.Timeout;
  ARESTRequest.Client.BaseURL     := OllamaURL(AAICompParamIn.Host,'chat');
  ARESTRequest.Params[0].Value    := OllamaChatToJSON(AAICompParamIn, AListString);
  LastError := OllamaRESTExecute(ARESTRequest, LResponse);
  if LastError=0 then begin
    LastError := OllamaJSONToChat(LResponse,AOutput,AAICompParamOut);
    if LastError<>0 then begin
      AOutput := '';
      AAICompParamOut.prompt_tokens     := 0;
      AAICompParamOut.completion_tokens := 0;
      AAICompParamOut.total_tokens      := 0;
      AAICompParamOut.tick_time         := 0;
    end;
  end;
  Result:=LastError;
end;

procedure TOllamaThreadChat.Execute;
var
  LRESTRequest: TRESTRequest;
  LText:string;
  FListString:TListString;
  LAICompParamOut:TAICompParamOut;
  isDone:boolean;
  LastError:integer;
  StopWatch: TStopWatch;
begin
  FListString:=TListString.Create;
  LRESTRequest:=TRESTRequest.Create(nil);
  OllamaRESTRequestPOST(LRESTRequest);
  LastError:=OllamaModelLoad(LRESTRequest, FAICompParamIn, isDone);
  if isDone then begin
    while True do begin
      if Terminated then begin
        LastError:=OllamaModelUnload(LRESTRequest, FAICompParamIn, isDone);
        LRESTRequest.Response.Free;
        LRESTRequest.Client.Free;
        LRESTRequest.Free;
        FListString.Free;
        // LastErrorを表示する処理が必要か?
        if Assigned(FAICompThreadFinish) then begin
          Synchronize(
            procedure
            begin
              FAICompThreadFinish;
            end
          );
        end;
        exit;
      end;
      if Assigned(FAICompThreadIn) then begin
        Synchronize(
          procedure
          begin
            FAICompThreadIn(FListString);
          end
        );
      end;
      if FListString.Count>0 then begin
        StopWatch := TStopWatch.StartNew;
        LastError := OllamaChat(LRESTRequest, FAICompParamIn, FListString, LText, LAICompParamOut);
        LAICompParamOut.tick_time:=StopWatch.ElapsedMilliseconds;
        if LastError=0 then begin
          if LText='' then begin // 回答が空なら回答できなかった扱いにする
            LText := gAICompErrorText+' '+FListString[FListString.Count-1];
          end;
          if Assigned(FAICompThreadOut) then begin
            Synchronize(
              procedure
              begin
                FAICompThreadOut(LText, LAICompParamOut);
              end
            );
          end;
        end else begin
          LText := gAICompErrorText+' '+FListString[FListString.Count-1];
          if Assigned(FAICompThreadOut) then begin
            Synchronize(
              procedure
              begin
                FAICompThreadOut(LText, LAICompParamOut);
              end
            );
          end;
          if Assigned(FAICompThreadError) then begin
            Synchronize(
              procedure
              begin
                FAICompThreadError(OllamaLastErrors[LastError]);
              end
            );
          end;
        end;
      end;
      if FAICompParamIn.Sleep>0 then sleep(FAICompParamIn.Sleep);
    end;
  end else begin
    if Assigned(FAICompThreadError) then begin
      Synchronize(
        procedure
        begin
          FAICompThreadError(OllamaLastErrors[LastError]);
        end
      );
    end;
  end;
end;

end.

LM_Studioとのチャットのデモ

その1で作成したKP.ListString.pasと,上記のKP.AICompThreads.pasを利用し,簡単なデモプログラムを作ります。
以下のようにその1のForm1に,Edit1:TEditButton2:TButtonCombobox1:TComboboxButton3:TButtonコンポーネントを追加で配置しています。
それから,ComboBox1.StylecsDropDownListに設定します。

2025-12-04-02.png

それぞれ以下の機能を持っています。
Button1: チャットの送信
Edit1: LM Studioのホスト名(プロトコルとポート番号は自動で追加されます)
Button2: ホストにダウンロードされているモデル一覧を取得する
Combobox1: モデル一覧
Button3: チャットの開始と終了

必要なイベントを追加して,以下のようにコードを書きます。

Unit1.pas
unit Unit1;

interface

uses
  Winapi.Windows, Winapi.Messages, System.SysUtils, System.Variants, System.Classes,
  System.Types, // TStringDynArray
  Vcl.Forms, Vcl.Graphics, Vcl.StdCtrls, Vcl.Controls,
  Winapi.WebView2, Winapi.ActiveX, Vcl.Edge,
  KP.ListString, KP.AICompThreads;

type
  TForm1 = class(TForm)
    Button1: TButton;
    EdgeBrowser1: TEdgeBrowser;
    Edit1: TEdit;
    Button2: TButton;
    ComboBox1: TComboBox;
    Button3: TButton;
    Memo2: TMemo;
    procedure FormCreate(Sender: TObject);
    procedure FormDestroy(Sender: TObject);
    procedure Button1Click(Sender: TObject);
    procedure Button2Click(Sender: TObject);
    procedure Button3Click(Sender: TObject);
    procedure EdgeBrowser1NavigationCompleted(Sender: TCustomEdgeBrowser;
        IsSuccess: Boolean; WebErrorStatus: COREWEBVIEW2_WEB_ERROR_STATUS);
  private
    { Private 宣言 }
    isThreadStart: boolean;
    ListStringQ, ListStringA: TListString;
    ListStringCount: integer; // 憶えている会話の数
    OpenAIThreadChat:      TOpenAIThreadChat;

    procedure ThreadError(AText:string);
    procedure ThreadModelListOut(AAICompModelList: TStringDynArray);

    procedure ThreadChatIn(AText:TListString);
    procedure ThreadChatOut(AText:String; AAICompParamOut:TAICompParamOut);
    procedure ThreadChatFinish;

    procedure ListStringToEdgeBrowser1;
  public
    { Public 宣言 }
  end;

var
  Form1: TForm1;

implementation

{$R *.dfm}

uses
  Vcl.Dialogs; // MessageDlg

procedure TForm1.ThreadError(AText: string);
begin
  if AText<>'' then begin
    MessageDlg(AText, TMsgDlgType.mtError, [TMsgDlgBtn.mbOK], 0);
  end;
end;

procedure TForm1.ThreadModelListOut(AAICompModelList: TStringDynArray);
var
  LModel, LItem: string;
  i:integer;
begin
  LItem:=ComboBox1.Items[ComboBox1.ItemIndex];
  ComboBox1.Items.Clear;
  for LModel in AAICompModelList do begin
    ComboBox1.Items.Add(LModel);
  end;
  i := ComboBox1.Items.IndexOf(LItem);
  if i=-1 then i := 0;
  ComboBox1.ItemIndex := i;
  Button3.Enabled     := Combobox1.Items.Count>0
end;

procedure TForm1.ThreadChatIn(AText: TListString);
begin
  ListStringQAMarge(ListStringQ, ListStringA, AText);
  if AText.Count>0 then begin
    Button1.Enabled   := False;
  end;
end;

procedure TForm1.ThreadChatOut(AText: String; AAICompParamOut: TAICompParamOut);
begin
  if (ListStringA.Count=ListStringCount) then begin
    ListStringQ.Delete(0);
    ListStringA.Delete(0);
  end;
  ListStringA.Add(AText);

//  StatusDisp(AAICompParamOut); // StatusBarにTokenの情報を出力
  ListStringToEdgeBrowser1;
  Button1.Enabled := True;
end;

procedure TForm1.ThreadChatFinish;
begin
  // Terminateをした後の処理
  Button1.Enabled   := False;
  Button2.Enabled   := True;
  Button3.Enabled   := True;
  Button3.Caption   := 'Start';
  Edit1.Enabled     := True;
  ComboBox1.Enabled := True;

  isThreadStart := False;
end;

procedure TForm1.ListStringToEdgeBrowser1;
var
  s: string;
begin
  s:=ChatQAToHTML(ListStringQ, ListStringA);
  if not EdgeBrowser1.WebViewCreated then
  begin
    EdgeBrowser1.CreateWebView;
    while not EdgeBrowser1.WebViewCreated do begin
      Application.ProcessMessages;
      Sleep(100);
    end;
  end;
  EdgeBrowser1.NavigateToString(s);
end;

procedure TForm1.FormCreate(Sender: TObject);
begin
  Memo2.Lines.TrailingLineBreak := False;

  ListStringQ      := TListString.Create;
  ListStringA      := TListString.Create;

  isThreadStart    := False;
  OpenAIThreadChat := nil;
  ListStringCount  := 100; // 憶えている会話の数

  Edit1.Text       := 'localhost';
  Button1.Enabled  := False;
  Button2.Enabled  := True;
  Button3.Enabled  := False;
  Button3.Caption  := 'Start';
end;

procedure TForm1.FormDestroy(Sender: TObject);
begin
  if Assigned(OpenAIThreadChat) then begin
    OpenAIThreadChat.Terminate;
    while isThreadStart do begin
      Application.ProcessMessages;
    end;
  end;
  ListStringQ.Free;
  ListStringA.Free;
end;

procedure TForm1.EdgeBrowser1NavigationCompleted(Sender: TCustomEdgeBrowser;
    IsSuccess: Boolean; WebErrorStatus: COREWEBVIEW2_WEB_ERROR_STATUS);
begin
  // 一番下までスクロールする
  EdgeBrowser1.ExecuteScript('window.scroll(0, Number.MAX_SAFE_INTEGER);');
end;

procedure TForm1.Button1Click(Sender: TObject);
begin
  if Memo2.Lines.Text='' then exit;
  if not isThreadStart then begin
    Button3Click(Self);
  end;
  ListStringQ.Add(Memo2.Lines.Text);

//  Memo2.Lines.Text := '';
//  edtAttach1.Text  := '';
  ListStringToEdgeBrowser1;
end;

procedure TForm1.Button2Click(Sender: TObject);
var
  LHost: string;
begin
  LHost:= PrivateHostNameMod(Edit1.Text, TAICompType.LM_Studio);
  if LHost='' then exit;

  Button2.Enabled  := False;
  Edit1.Enabled    := False;

  TOpenAIThreadModelList.Create(LHost, ThreadModelListOut, ThreadError);
end;

procedure TForm1.Button3Click(Sender: TObject);
var
  LHost: string;
  LAICompParamIn: TAICompParamIn;
begin
  LHost:= PrivateHostNameMod(Edit1.Text, TAICompType.LM_Studio);
  if LHost='' then exit;
  if Combobox1.Text='' then exit;
  if isThreadStart then begin
    OpenAIThreadChat.Terminate;
  end else begin
    LAICompParamIn.Init;
    with LAICompParamIn do begin
      AICompType   := TAICompType.LM_Studio;
      Host         := LHost;
      Model        := ComboBox1.Text;
      SystemPrompt := '';
      Prompt       := '';
      Timeout      := 30*1000; // タイムアウトは30秒
      num_ctx      := 4098;    // コンテキスト長 LM_Studioでは使わない
    end;

    OpenAIThreadChat := TOpenAIThreadChat.Create(LAICompParamIn, ThreadChatIn,
      ThreadChatOut, ThreadChatFinish, ThreadError);
    isThreadStart := True;

    Button1.Enabled   := True;
    Button2.Enabled   := False;
    Button3.Caption   := 'Stop';
    Edit1.Enabled     := False;
    ComboBox1.Enabled := False;
  end;
end;

end.

Edit1にLocalhostや192.168.1.20などのLM Studioを動かしているPCのIPアドレスを入力し,Button2をクリックするとComboBox1にモデルの一覧が表示されます。
利用したいモデルを選んで,Button3をクリックして,Memo2に質問を書いてButton1をクリックすると,入力した質問がEdgeBrowser1に表示され,LM Studio上で動いているモデルからの回答が表示されます。

2025-12-04-03.png

改良のヒント

タイムアウトの調整

回答のタイムアウトはButton3Clickイベント内で 30*1000 つまり 30秒が指定されていますが,それでもLM Studioからの回答がタイムアウトするなら,Button3Clickイベントで指定している,Timeoutを調整してください。

コンテキスト長の調整

コンテキスト長の調整はLM Studioのモデルの設定で行います。PCのメモリがひっ迫するなら,調整が必要です。

統計情報の表示

ThreadChatOutメソッドは生成AIからの出力結果を反映するときに呼ばれるイベントです。
コメントアウトされているStatusDisp(AAICompParamOut);は,レポートされた統計情報を表示するための仮の関数です。
KP.AICompThreads.pasで定義されていて,以下のような統計情報が返されるので,ステータスバーなどに表示するのもよいかもしれません。
トークン数はコンテキスト長と比較できる数なので,コンテキスト長と合計のトークン数の差が小さくなるようにしたほうが良いようです。

  TAICompParamOut = record
    prompt_tokens    :integer; // 入力トークン数
    completion_tokens:integer; // 出力のために使ったトークン数
    total_tokens     :integer; // 合計のトークン数
    tick_time        :Int64;   // かかった時間
  end;

謝辞

この記事やコードを書くにあたり,以下の記事やサイトを参考にしました。この場を借りて感謝します。

6
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?