与本地ollama模型对话

53 阅读4分钟

与本地ollama模型对话

本章我们将完善对话界面,将webview端选中的模型和输入的对话消息发送至extension host端。然后在extension host端调用ollama API与模型进行对话,获取模型返回消息,解析后写入文件。

定义新的proto

首先我们将定义一个message.proto,用来描述要发送到extension host端的消息,该模型也用来在webview上显示发送和接收到的消息。

proto/minicline/message.proto

syntax = "proto3";

package minicline;

message Message {
    string content = 1;
    MessageOwn owner = 2;
}

enum MessageOwn {
    SYSTEM = 0;
    ASSISTANT = 1;
    USER = 2;
}

然后我们定义一个task.proto,其中的newTask调用用来将我们收集到的模型名字和对话消息发送给extension host端。

proto/minicline/task.proto

syntax = "proto3";

package minicline;

import "minicline/common.proto";

service TaskService {
  rpc newTask(NewTaskRequest) returns (String);
}

message NewTaskRequest {
  string model = 1;
  string text = 2;
}

同样,我们需要导出newTask函数来作为TaskService.newTask的处理函数。这里我们先简单的打印收到model和text信息,然后返回一个虚假的taskID.

src/core/controller/task/newTask.ts

import { NewTaskRequest } from "@/shared/proto/minicline/task";
import { Controller } from "..";
import { String } from "@shared/proto/minicline/common";

export async function newTask(controller: Controller, request: NewTaskRequest): Promise<String> {
    console.log(request);
    const taskId = "fakeTaskId";
	return String.create({ value: taskId || "" });
}

运行npm run proto编译。

更新webview界面

让我们在webview的中间区域添加消息列表,用来显示我们发送的消息和AI助手返回的消息。

webview-ui/src/App.tsx

import { useState } from "react";
...
function App() {
  const [selectedModel, setSelectedModel] = useState("");

  const initMessages: Message[] = [Message.create({
    content: "你好,我是你的AI编程助手MiniCline",
    owner:MessageOwn.ASSISTANT
  })];

  const [messages, setMessages] = useState(initMessages);

  return (
    <div className="container">
      <div className="top">
        <ModelBar selectedModel={selectedModel} selectModel={setSelectedModel}/>
      </div>
      <div className="center">
        <Messages messages={messages} />
      </div>
      <div className="bottom">
        <Chatbox 
        selectedModel={selectedModel}
        messages={messages}
        setMessages={setMessages}/>
      </div>
    </div>
  );
}
...

我们在主程序初始化选中的模型和消息列表的状态,然后传递给其他组件共享状态。

webview-ui/src/components/message/message.tsx

...
function Messages({ messages }: { messages: Message[] }) {

    return (
        <>
            {messages?.map((message: Message) => (
                <div className={message.owner === MessageOwn.USER ? "message_user" : "message"}>
                    <div className="texts">
                        <p>{message.content}</p>
                    </div>
                </div>
            ))}
        </>
    );
}
...

webview-ui/src/components/models/models.tsx

...
function ModelBar({ selectedModel, selectModel }: { selectedModel: string, selectModel: Function }) {
    ...
    const handleListModelsMessage = useCallback(
        async () => {
            const result: StringArray = await OllamaServiceClient.listModels(EmptyRequest.create());
            ...
            selectModel(result.values[0]);
        }, [models]
    );
    return (
        ...
                <VSCodeDropdown id="models" className="modellist" onChange={(e: any) => selectModel(e.target._value)}>
        ...
    );
}
...

webview-ui/src/components/chat/chat.tsx

...
function Chatbox({ selectedModel, messages, setMessages }: { selectedModel: string, messages: Message[], setMessages: Function }) {
    const [taskId, setTaskId] = useState("");
    const [content, setContent] = useState("");

    const handleNewTaskMessage = useCallback(
        async (model: string, text: string) => {
            const taskId: String = await TaskServiceClient.newTask(NewTaskRequest.create({
                model: model,
                text: text
            }));
            setTaskId(taskId.value);
            setMessages([...messages, Message.create({
                content: text,
                owner: MessageOwn.USER
            })]);
            setContent("");
        }, [taskId, messages]
    );

    const onSend = () => {
        handleNewTaskMessage(selectedModel, content);
    };

    return (
        <>
            <div className="chat-box">
                <div>
                    <VSCodeTextArea className="chatinput"
                        placeholder="say hello"
                        value={content}
                        onChange={(e: any) => setContent(e.target._value)}>
                    </VSCodeTextArea>
                </div>
                <div className="button-container">
                    <div>
                        <span className="codicon codicon-send" onClick={onSend}></span>
                    </div>
                </div>
            </div>
        </>
    );
}
...

在chatbox中我们将输入的文本和当前选中的模型发送给extension host,记录返回的taskID,然后将消息添加到消息列表中并且清空输入框。

运行npm run build:webview重新编译react应用,启动插件项目,确保本地ollama已经启动,输入一些消息点击发送图标,如果一切正常如下图所示。

chapter-6-1.png

添加新的第三方库用于与Ollama对话

package.json

{
  "devDependencies": {
    ...
    "@types/clone-deep": "^4.0.4",
    ...
  },
  "dependencies": {
    ...
    "@anthropic-ai/sdk": "^0.37.0",
    "ollama": "^0.5.13",
    "clone-deep": "^4.0.1",
    ...
  }
}

我们引入了@anthropic-ai包里边定义好的数据结构,同时引入ollama库用于与本地服务对话。clone-deep用来深度拷贝流式处理过程中的数据。运行npm run install:all安装依赖.

与本地Ollama对话

我们首先在controller类中添加一个initTask方法,在newTask接收到webview的模型和对话内容后调用该方法开始一个新的任务。开始新任务的代码如下。

src/core/controller/task/index.ts

...
export class Task {
    ...
    constructor(...) {
        ...
        this.api = new OllamaHandler(this.model);
    };

    public async startTask(): Promise<void> {
        const processedUserContent: Anthropic.TextBlockParam[] = [
            {
                type: "text",
                text: `<task>\n${this.task}\n</task>`,
            }
        ];
        processedUserContent.push({
            type: "text",
            text: FocusChainPrompts.recommended,
        });

        const stream = this.attemptApiRequest(processedUserContent);
        try {
            for await (const chunk of stream) {
                switch (chunk.type) {
                    ...
                    case "text": {
                        assistantMessage += chunk.text;
                        this.assistantMessageContent = parseAssistantMessageV2(assistantMessage);
                        this.presentAssistantMessage();
                        break;
                    }
                }
            }
        } catch (error) {
            console.log(error);
        }
        ...
    }

    async *attemptApiRequest(processedUserContent: Anthropic.TextBlockParam[]): ApiStream {
        const systemPrompt = await getSystemPrompt();

        const userMessages: Anthropic.MessageParam[] = [{
            role: "user",
            content: processedUserContent
        }];

        // Response API requires native tool calls to be enabled
        const stream = this.api.createMessage(systemPrompt, userMessages);
        const iterator = stream[Symbol.asyncIterator]();
        try {
            const firstChunk = await iterator.next();
            yield firstChunk.value;
        } catch (error) {
            console.log(error);
        }
        yield* iterator;
    }

    async presentAssistantMessage() {
        const block = cloneDeep(this.assistantMessageContent[this.currentStreamingContentIndex]);
        if (block) {
            switch (block.type) {
                ...
                case "tool_use":
                    await this.executeTool(block);
                    break;
            }
            ...
        }
    }

    public async executeTool(block: ToolUse): Promise<void> {
        if (block.params.path && block.params.content) {
            const filePath = path.join(this.cwd, block.params.path);
            // 将内容写入文件
            await fs.writeFile(filePath, block.params.content, 'utf-8');
        }
    }
}

我们在startTask调用attemptApiRequest与本地Ollama对话,使用准备好的系统提示词和用户提示词规范模型返回输出。然后循环读取对话输出流的内容,使用parseAssistantMessageV2解析特定内容。当匹配到write_to_file标签时调用本地工具写入文件。写到这里我们可以知道类似cline之类的本地agent是十分依赖于模型的输出内容的。当模型正确按照我们的系统提示词输出内容时,返回差不多如下。

<write_to_file>
<path>hello.c</path>
<content>#include &lt;stdio.h&gt;

int main() {
    printf("Hello, World!\\n");
    return 0;
}
</content>
<task_progress>
- [x] 创建C语言Hello World程序文件
- [x] 编写包含标准输入输出库的代码
- [x] 实现main函数
- [x] 添加打印"Hello, World!"语句
- [x] 返回0表示程序成功结束
</task_progress>
</write_to_file>

这时候我们会解析<write_to_file>中的内容,从path获取文件名,从content获取文件内容,然后写入workspace下的该文件。

使用cline的示例测试

根据cline官网的例子Build Your First Project,我们在vscode中打开一个本地目录,比如c:/git/testvibe, 在我webview中选择qwen3-coder:latest,在对话框输入如下内容,点击发送按钮。

Create a simple website in a single HTML file. It should have:
- A welcome message saying "Hello from Cline!"
- A colorful gradient background
- A button that cycles through different color themes when clicked
- Modern, clean design
- All CSS and JavaScript should be included in the same HTML file

如果一切运行正常(在我本机差不多运行了2分多种),会在workspace目录下生成index.html,预览效果如下,点击按钮可以切换主题。

chapter-6-2.png

总结

系列入门教程到此告一段落。