KeiStory

Blazor 에서 OllamaSharp 이용해 Ollama 모델로 채팅하기

 

OllamaSharp 을 이용하면 ollama 에 있는 모델을 로드하여 질의할 수 있습니다.

Ollama 설치는 아래 포스팅을 확인합니다.

2024.06.15 - [코딩/Python_AI] - 로컬에서 Llama3-8B 모델 돌려보기 - Ollama

아래 코드는 Blazor 에서 OllamaSharp  을 이용해 로컬에 설치된 모델 중 하나를 선택하여 질의할 수 있게 합니다.

Markdig, Prism 을 이용해 MarkDown 처리를 하였습니다.

 

먼저 OllamaSharpMarkdig  Nuget Package  설치합니다.

index.html 에 아래와 같이 Prism 라이브러리를 사용할 수 있게 합니다.

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>OllamaChatBlazorApp</title>
    <base href="/" />
    <link rel="stylesheet" href="css/app.css" />
    <link href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.24.1/themes/prism.min.css" rel="stylesheet" />
</head>

<body>
    <div id="app">
        <svg class="loading-progress">
            <circle r="40%" cx="50%" cy="50%" />
            <circle r="40%" cx="50%" cy="50%" />
        </svg>
        <div class="loading-progress-text"></div>
    </div>

    <div id="blazor-error-ui">
        An unhandled error has occurred.
        <a href="" class="reload">Reload</a>
        <a class="dismiss">🗙</a>
    </div>
    <script src="_framework/blazor.webassembly.js"></script>

    <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.24.1/components/prism-core.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.24.1/plugins/autoloader/prism-autoloader.min.js"></script>
    <script>
        window.initializePrism = function () {
            Prism.plugins.autoloader.languages_path = 'https://cdnjs.cloudflare.com/ajax/libs/prism/1.24.1/components/';
        }
        window.highlightAll = function () {
            Prism.highlightAll();
        }
    </script>
</body>

</html>

 

아래는 채팅화면입니다.

코드를 보면 알 수 있듯이 Prism 을 이용해 코드블록을 Highlight 하였습니다.

Home.razor

@page "/"
@using System.Net.Http
@using System.Text.Json
@using Microsoft.AspNetCore.Components
@using OllamaSharp
@using Markdig
@using Markdig.Syntax
@using Markdig.Renderers
@using Markdig.Renderers.Html
@inject IJSRuntime JSRuntime

<div class="chat-app">
    <h1>Ollama Chat</h1>

    <!-- 모델 선택 드롭다운 -->
    <div class="model-selector">
        <select @bind="selectedModel">
            @foreach (var model in availableModels)
            {
                <option value="@model">@model</option>
            }
        </select>
    </div>

    <!-- 채팅 메시지 표시 영역 -->
    <div class="chat-container">
        @foreach (var message in chatHistory)
        {
            <div class="@(message.IsUser ? "user-message" : "ai-message")">
                @if (message.IsUser)
                {
                    <p>@message.Content</p>
                }
                else
                {
                    @((MarkupString)RenderMarkdownWithCodeHighlighting(message.Content))
                }
            </div>
        }
    </div>

    <!-- 사용자 입력 영역 -->
    <div class="input-container">
        <input @bind="userInput" @onkeyup="HandleKeyUp" placeholder="메시지를 입력하세요..." />
        <button @onclick="SendMessage" disabled="@isSending">전송</button>
    </div>
</div>

@code {
    private List<ChatMessage> chatHistory = new List<ChatMessage>();
    private string userInput = "";
    private bool isSending = false;
    private OllamaApiClient ollama;
    private List<string> availableModels = new List<string>();
    private string selectedModel = "";

    // 컴포넌트 초기화 시 실행
    protected override async Task OnInitializedAsync()
    {
        var uri = new Uri("http://localhost:11434");
        ollama = new OllamaApiClient(uri);

        // 사용 가능한 모델 목록 가져오기
        var models = await ollama.ListLocalModels();
        availableModels = models.Select(m => m.Name).ToList();

        if (availableModels.Any())
        {
            selectedModel = availableModels.First();
            ollama.SelectedModel = selectedModel;
        }

        await base.OnInitializedAsync();
    }

    // 컴포넌트 렌더링 후 실행
    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            await JSRuntime.InvokeVoidAsync("initializePrism");
        }
        else
        {
            await JSRuntime.InvokeVoidAsync("highlightAll");
        }
    }

    // 키 입력 처리
    private async Task HandleKeyUp(KeyboardEventArgs e)
    {
        if (e.Key == "Enter")
        {
            await SendMessage();
        }
    }

    // 메시지 전송 및 AI 응답 처리
    private async Task SendMessage()
    {
        if (string.IsNullOrWhiteSpace(userInput) || isSending)
            return;

        isSending = true;
        chatHistory.Add(new ChatMessage { Content = userInput, IsUser = true });
        var aiMessage = new ChatMessage { Content = "", IsUser = false };
        chatHistory.Add(aiMessage);

        ollama.SelectedModel = selectedModel;
        string currentResponse = "";
        await foreach (var stream in ollama.Generate(userInput))
        {
            currentResponse += stream.Response;
            aiMessage.Content = currentResponse;
            StateHasChanged();
            await JSRuntime.InvokeVoidAsync("highlightAll");
            await Task.Delay(10); // UI 업데이트를 위한 짧은 지연
        }

        userInput = "";
        isSending = false;
    }

    // 마크다운을 HTML로 렌더링하고 코드 하이라이팅 적용
    private string RenderMarkdownWithCodeHighlighting(string markdown)
    {
        var pipeline = new MarkdownPipelineBuilder()
            .UseAdvancedExtensions()
            .Use<CustomCodeBlockRenderer>()
            .Build();

        var writer = new StringWriter();
        var renderer = new Markdig.Renderers.HtmlRenderer(writer);

        pipeline.Setup(renderer);

        var document = Markdig.Parsers.MarkdownParser.Parse(markdown, pipeline);

        foreach (var node in document.Descendants())
        {
            if (node is CodeBlock codeBlock)
            {
                var fencedCodeBlock = codeBlock as FencedCodeBlock;
                var language = fencedCodeBlock?.Info ?? "plaintext";
                codeBlock.GetAttributes().AddClass($"language-{language}");
            }
        }

        renderer.Render(document);
        writer.Flush();

        return writer.ToString();
    }

    // 커스텀 코드 블록 렌더러
    private class CustomCodeBlockRenderer : IMarkdownExtension
    {
        public void Setup(MarkdownPipelineBuilder pipeline)
        {
        }

        public void Setup(MarkdownPipeline pipeline, IMarkdownRenderer renderer)
        {
            if (renderer is Markdig.Renderers.HtmlRenderer htmlRenderer)
            {
                htmlRenderer.ObjectRenderers.Replace<CodeBlockRenderer>(new CustomHtmlCodeBlockRenderer());
            }
        }
    }

    // 커스텀 HTML 코드 블록 렌더러
    private class CustomHtmlCodeBlockRenderer : HtmlObjectRenderer<CodeBlock>
    {
        protected override void Write(Markdig.Renderers.HtmlRenderer renderer, CodeBlock obj)
        {
            var fencedCodeBlock = obj as FencedCodeBlock;
            var language = fencedCodeBlock?.Info?.ToLower() ?? "plaintext";

            if (renderer.EnableHtmlForBlock)
            {
                renderer.Write("<pre><code class=\"language-");
                renderer.WriteEscape(language);
                renderer.Write("\">");
            }

            var code = obj.Lines.ToString();
            if (renderer.EnableHtmlEscape)
            {
                renderer.WriteEscape(code);
            }
            else
            {
                renderer.Write(code);
            }

            if (renderer.EnableHtmlForBlock)
            {
                renderer.WriteLine("</code></pre>");
            }
        }
    }

    // 채팅 메시지 클래스
    private class ChatMessage
    {
        public string Content { get; set; }
        public bool IsUser { get; set; }
    }
}

<style>
    /* 스타일 정의 */
    .chat-app {
        display: flex;
        flex-direction: column;
        height: 100vh;
        padding: 20px;
        box-sizing: border-box;
    }

    h1 {
        margin-top: 0;
    }

    .model-selector {
        margin-bottom: 10px;
    }

        .model-selector select {
            width: 100%;
            padding: 5px;
        }

    .chat-container {
        flex-grow: 1;
        overflow-y: auto;
        border: 1px solid #ccc;
        padding: 10px;
        margin-bottom: 10px;
    }

    .user-message {
        background-color: #e6f2ff;
        padding: 5px;
        margin: 5px 0;
        border-radius: 5px;
    }

    .ai-message {
        background-color: #f0f0f0;
        padding: 5px;
        margin: 5px 0;
        border-radius: 5px;
    }

    .input-container {
        display: flex;
    }

    input {
        flex-grow: 1;
        padding: 5px;
    }

    button {
        padding: 5px 10px;
        margin-left: 5px;
    }

    /* 코드 블록 스타일링 */
    pre {
        background-color: #f4f4f4;
        border: 1px solid #ddd;
        border-left: 3px solid #f36d33;
        color: #666;
        page-break-inside: avoid;
        font-family: monospace;
        font-size: 15px;
        line-height: 1.6;
        margin-bottom: 1.6em;
        max-width: 100%;
        overflow: auto;
        padding: 1em 1.5em;
        display: block;
        word-wrap: break-word;
    }

    code {
        font-family: monospace;
        font-size: 0.9em;
        background-color: #f4f4f4;
        padding: 2px 4px;
    }

    /* 마크다운 스타일링 */
    .ai-message :deep(h1, h2, h3, h4, h5, h6) {
        margin-top: 0.5em;
        margin-bottom: 0.5em;
    }

    .ai-message :deep(ul, ol) {
        padding-left: 20px;
    }

    .ai-message :deep(blockquote) {
        border-left: 4px solid #ccc;
        margin: 0;
        padding-left: 10px;
    }
</style>

결과 

상단 콤보에서 현재 로컬에 다운받은 Model 목록이 표시됩니다

현재 제가 설치한 모델 리스트입니다.


소스코드

https://github.com/kei-soft/OllamaBlazorApp

 

GitHub - kei-soft/OllamaBlazorApp

Contribute to kei-soft/OllamaBlazorApp development by creating an account on GitHub.

github.com

 

반응형

공유하기

facebook twitter kakaoTalk kakaostory naver band