Use node-llama-cpp

This commit is contained in:
2025-08-14 19:13:42 +00:00
parent ff70707e95
commit a1c7204d38
5 changed files with 96 additions and 54 deletions

View File

@@ -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

BIN
bun.lockb

Binary file not shown.

143
index.ts
View File

@@ -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
View File

@@ -0,0 +1,2 @@
*.gguf
*.gguf.ipull

View File

@@ -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"
} }