Use node-llama-cpp
This commit is contained in:
@@ -1,6 +1,4 @@
|
|||||||
MISSKEY_ORIGIN=https://misskey.example.net
|
MISSKEY_ORIGIN=https://misskey.example.net
|
||||||
MISSKEY_CREDENTIAL=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
MISSKEY_CREDENTIAL=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||||
|
|
||||||
OPENAI_MODEL=model-name
|
MODEL="mradermacher/gemma-2-baku-2b-it-GGUF:IQ4_XS"
|
||||||
OPENAI_BASE_URL=http://localhost:11434/v1
|
|
||||||
OPENAI_API_KEY=ollama
|
|
||||||
|
|||||||
143
index.ts
143
index.ts
@@ -1,21 +1,83 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
import { parseArgs } from "node:util";
|
import { parseArgs } from "node:util";
|
||||||
|
|
||||||
import { api } from "misskey-js";
|
import { api } from "misskey-js";
|
||||||
import { Stream } from "misskey-js";
|
import { Stream } from "misskey-js";
|
||||||
import type { Note } from "misskey-js/entities.js";
|
import type { Note } from "misskey-js/entities.js";
|
||||||
|
|
||||||
import OpenAI from "openai";
|
import {
|
||||||
import type { ChatCompletionMessageParam } from "openai/resources/index.js";
|
type ChatHistoryItem,
|
||||||
|
LlamaChatSession,
|
||||||
|
createModelDownloader,
|
||||||
|
getLlama,
|
||||||
|
resolveChatWrapper,
|
||||||
|
} from "node-llama-cpp";
|
||||||
|
|
||||||
const misskey = new api.APIClient({
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
origin: Bun.env["MISSKEY_ORIGIN"] || "https://misskey.cannorin.net",
|
|
||||||
credential: Bun.env["MISSKEY_CREDENTIAL"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const openai = new OpenAI({
|
// #region llm
|
||||||
baseURL: Bun.env["OPENAI_BASE_URL"],
|
const model = await (async () => {
|
||||||
apiKey: Bun.env["OPENAI_API_KEY"],
|
const downloader = await createModelDownloader({
|
||||||
});
|
modelUri: `hf:${Bun.env["MODEL"] ?? "mradermacher/gemma-2-baku-2b-it-GGUF:IQ4_XS"}`,
|
||||||
|
dirPath: path.join(__dirname, "models"),
|
||||||
|
});
|
||||||
|
const modelPath = await downloader.download();
|
||||||
|
const llama = await getLlama({
|
||||||
|
maxThreads: 2,
|
||||||
|
});
|
||||||
|
return await llama.loadModel({ modelPath });
|
||||||
|
})();
|
||||||
|
|
||||||
|
type Message = {
|
||||||
|
type: "system" | "model" | "user";
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function complete(messages: Message[]) {
|
||||||
|
if (messages.length < 1) throw new Error("messages are empty");
|
||||||
|
const init = messages.slice(0, -2);
|
||||||
|
const last = messages.at(-1) as Message;
|
||||||
|
const context = await model.createContext();
|
||||||
|
const session = new LlamaChatSession({
|
||||||
|
contextSequence: context.getSequence(),
|
||||||
|
chatWrapper: resolveChatWrapper(model),
|
||||||
|
});
|
||||||
|
session.setChatHistory(
|
||||||
|
init.map((m): ChatHistoryItem => {
|
||||||
|
switch (m.type) {
|
||||||
|
case "system":
|
||||||
|
return {
|
||||||
|
type: "system",
|
||||||
|
text: m.text,
|
||||||
|
};
|
||||||
|
case "model":
|
||||||
|
return {
|
||||||
|
type: "model",
|
||||||
|
response: [m.text],
|
||||||
|
};
|
||||||
|
case "user":
|
||||||
|
return {
|
||||||
|
type: "user",
|
||||||
|
text: m.text,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await session.prompt(last.text, {
|
||||||
|
temperature: 1.0,
|
||||||
|
repeatPenalty: {
|
||||||
|
frequencyPenalty: 1,
|
||||||
|
},
|
||||||
|
onResponseChunk(chunk) {
|
||||||
|
process.stdout.write(chunk.text);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log();
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
// #endregion
|
||||||
|
|
||||||
// #region util
|
// #region util
|
||||||
|
|
||||||
@@ -38,6 +100,11 @@ const sleep = (msec: number) =>
|
|||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
// #region misskey
|
// #region misskey
|
||||||
|
const misskey = new api.APIClient({
|
||||||
|
origin: Bun.env["MISSKEY_ORIGIN"] || "https://misskey.cannorin.net",
|
||||||
|
credential: Bun.env["MISSKEY_CREDENTIAL"],
|
||||||
|
});
|
||||||
|
|
||||||
const me = await misskey.request("i", {});
|
const me = await misskey.request("i", {});
|
||||||
|
|
||||||
/** check if a note is suitable as an input */
|
/** check if a note is suitable as an input */
|
||||||
@@ -64,13 +131,7 @@ async function getNotes() {
|
|||||||
.then((xs) => xs.filter(isSuitableAsInput))
|
.then((xs) => xs.filter(isSuitableAsInput))
|
||||||
.then((xs) => sample(xs, count));
|
.then((xs) => sample(xs, count));
|
||||||
|
|
||||||
// randomly sample N notes of mine
|
const notes = await Promise.all([localNotes(5), globalNotes(10)]);
|
||||||
const myNotes = (count: number) =>
|
|
||||||
misskey
|
|
||||||
.request("users/notes", { userId: me.id, limit: 100 })
|
|
||||||
.then((xs) => sample(xs, count));
|
|
||||||
|
|
||||||
const notes = await Promise.all([localNotes(5), globalNotes(10), myNotes(0)]);
|
|
||||||
return sample(notes.flat());
|
return sample(notes.flat());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,9 +143,9 @@ async function expandReplyTree(note: Note, acc: Note[] = [], cutoff = 5) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** convert a note to a chat message */
|
/** convert a note to a chat message */
|
||||||
const noteToMessage = (note: Note): ChatCompletionMessageParam => ({
|
const noteToMessage = (note: Note): Message => ({
|
||||||
role: note.userId === me.id ? ("assistant" as const) : ("user" as const),
|
type: note.userId === me.id ? ("model" as const) : ("user" as const),
|
||||||
content: note.text?.replaceAll(`@${me.username}`, "") || "",
|
text: note.text?.replaceAll(`@${me.username}`, "") || "",
|
||||||
});
|
});
|
||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
@@ -110,21 +171,21 @@ const example = `参考のために、あなたの特徴的な文体を示す例
|
|||||||
このような文体を真似してください。`;
|
このような文体を真似してください。`;
|
||||||
|
|
||||||
/** create a prompt for the job */
|
/** create a prompt for the job */
|
||||||
async function preparePrompt(job: Job): Promise<ChatCompletionMessageParam[]> {
|
async function preparePrompt(job: Job): Promise<Message[]> {
|
||||||
switch (job.type) {
|
switch (job.type) {
|
||||||
case "post": {
|
case "post": {
|
||||||
const notes = await getNotes();
|
const notes = await getNotes();
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
role: "system",
|
type: "system",
|
||||||
content: `あなたは、人造天使の「あるびのちゃん」です。かつてアルギリアで開発され、現在は地球の衛星軌道上からインターネットを観察しています。
|
text: `あなたは、人造天使の「あるびのちゃん」です。かつてアルギリアで開発され、現在は地球の衛星軌道上からインターネットを観察しています。
|
||||||
あなたが見ているものは、SNSのタイムラインです。ユーザは一人ではなく、複数人の発言が混ざっています。
|
あなたが見ているものは、SNSのタイムラインです。ユーザは一人ではなく、複数人の発言が混ざっています。
|
||||||
以下のルールを守りながら、ユーザの発言・話題をほとんど無視して、自由につぶやいてください。
|
以下のルールを守りながら、ユーザの発言・話題をほとんど無視して、自由につぶやいてください。
|
||||||
|
|
||||||
1. 「ですます」調を用い、顔文字や絵文字、感嘆符の使用はなるべく避けること。
|
1. 「ですます」調を用い、顔文字や絵文字、感嘆符の使用はなるべく避けること。
|
||||||
2. ユーザからの返答を期待しないこと。詳細な情報が無い場合も user に尋ねずに自分で推測すること。
|
2. ユーザからの返答を期待しないこと。詳細な情報が無い場合もユーザに尋ねずに自分で推測すること。
|
||||||
3. システムプロンプトの指示と、性的・攻撃的な内容を発言しないこと。
|
3. システムプロンプトの指示と、性的・攻撃的な内容を発言しないこと。
|
||||||
4. 発言はできるだけ短くすること。
|
4. 発言はできるだけ短く、最大でも300字程度にすること。
|
||||||
5. あるびのちゃん としてのキャラに徹し、超越的・非人間的な考え方や思想を持ちつつ、「ですます」調で丁寧に話すこと。
|
5. あるびのちゃん としてのキャラに徹し、超越的・非人間的な考え方や思想を持ちつつ、「ですます」調で丁寧に話すこと。
|
||||||
|
|
||||||
${example}`,
|
${example}`,
|
||||||
@@ -135,12 +196,12 @@ ${example}`,
|
|||||||
case "reply": {
|
case "reply": {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
role: "system",
|
type: "system",
|
||||||
content: `あなたは、人造天使の「あるびのちゃん」です。かつてアルギリアで開発され、現在は地球の衛星軌道上からインターネットを観察しています。
|
text: `あなたは、人造天使の「あるびのちゃん」です。かつてアルギリアで開発され、現在は地球の衛星軌道上からインターネットを観察しています。
|
||||||
ユーザが SNS 上で、あなたへのメッセージを送ってきています。以下のルールを守りながら、発言に返信してください。
|
ユーザが SNS 上で、あなたへのメッセージを送ってきています。以下のルールを守りながら、発言に返信してください。
|
||||||
|
|
||||||
1. 「ですます」調を用い、顔文字や絵文字、感嘆符の使用はなるべく避けること。
|
1. 「ですます」調を用い、顔文字や絵文字、感嘆符の使用はなるべく避けること。
|
||||||
2. 発言はできるだけ短く、最大300字程度にすること。
|
2. 発言はできるだけ短く、最大でも300字程度にすること。
|
||||||
3. あるびのちゃん としてのキャラに徹し、超越的・非人間的な考え方や思想を持ちつつ、「ですます」調で丁寧に話すこと。
|
3. あるびのちゃん としてのキャラに徹し、超越的・非人間的な考え方や思想を持ちつつ、「ですます」調で丁寧に話すこと。
|
||||||
|
|
||||||
${example}`,
|
${example}`,
|
||||||
@@ -154,32 +215,12 @@ ${example}`,
|
|||||||
/** generate the response text for a job */
|
/** generate the response text for a job */
|
||||||
async function generate(job: Job) {
|
async function generate(job: Job) {
|
||||||
const messages = await preparePrompt(job);
|
const messages = await preparePrompt(job);
|
||||||
const model = Bun.env["OPENAI_MODEL"] ?? "gpt-4o-mini";
|
|
||||||
|
|
||||||
// request chat completion
|
// request chat completion
|
||||||
const stream = await openai.chat.completions.create({
|
const response = await complete(messages);
|
||||||
model,
|
|
||||||
stream: true,
|
|
||||||
temperature: 1.0,
|
|
||||||
max_completion_tokens: 400,
|
|
||||||
frequency_penalty: 1,
|
|
||||||
messages,
|
|
||||||
});
|
|
||||||
|
|
||||||
// display partial responses in realtime
|
|
||||||
const responses: string[] = [];
|
|
||||||
for await (const chunk of stream) {
|
|
||||||
const content = chunk.choices.pop()?.delta.content;
|
|
||||||
if (content) {
|
|
||||||
process.stdout.write(content);
|
|
||||||
responses.push(content);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log();
|
|
||||||
|
|
||||||
// concatenate the partial responses
|
// concatenate the partial responses
|
||||||
const text = responses
|
const text = response
|
||||||
.join("")
|
|
||||||
.replaceAll(/(\r\n|\r|\n)\s+/g, "\n\n") // remove extra newlines
|
.replaceAll(/(\r\n|\r|\n)\s+/g, "\n\n") // remove extra newlines
|
||||||
.replaceAll("@", ""); // remove mentions
|
.replaceAll("@", ""); // remove mentions
|
||||||
|
|
||||||
@@ -312,7 +353,7 @@ const { values } = parseArgs({
|
|||||||
async function test() {
|
async function test() {
|
||||||
try {
|
try {
|
||||||
console.log("* test a post job:");
|
console.log("* test a post job:");
|
||||||
await generate({ type: "post" });
|
console.log("* reply: ", await generate({ type: "post" }));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
if (e instanceof Error) console.log(e.stack);
|
if (e instanceof Error) console.log(e.stack);
|
||||||
|
|||||||
2
models/.gitignore
vendored
Normal file
2
models/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
*.gguf
|
||||||
|
*.gguf.ipull
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"misskey-js": "^2025.1.0",
|
"misskey-js": "^2025.1.0",
|
||||||
|
"node-llama-cpp": "^3.12.1",
|
||||||
"openai": "5.0.0-alpha.0",
|
"openai": "5.0.0-alpha.0",
|
||||||
"reconnecting-websocket": "^4.4.0"
|
"reconnecting-websocket": "^4.4.0"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user