Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
321731ad4b
|
|||
|
9356ae18bc
|
|||
|
c986bb861a
|
|||
|
e58bef48d2
|
|||
|
c60eeb4898
|
|||
|
0f9eb68262
|
|||
|
c276b8e319
|
252
index.ts
252
index.ts
@@ -1,20 +1,86 @@
|
|||||||
|
import { parseArgs } from "node:util";
|
||||||
|
import { Stream } from "misskey-js";
|
||||||
import type { Note } from "misskey-js/entities.js";
|
import type { Note } from "misskey-js/entities.js";
|
||||||
import { complete, getModel, type Message } from "./lib/llm";
|
import type { ChatHistoryItem, LLamaChatPromptOptions } from "node-llama-cpp";
|
||||||
|
import { LlmSession, createGrammar, getModel, parseResponse } from "./lib/llm";
|
||||||
import {
|
import {
|
||||||
expandReplyTree,
|
expandReplyTree,
|
||||||
getNotes,
|
getNotes,
|
||||||
me,
|
me,
|
||||||
misskey,
|
misskey,
|
||||||
noteToMessage,
|
sanitizeText,
|
||||||
} from "./lib/misskey";
|
} from "./lib/misskey";
|
||||||
import { Stream } from "misskey-js";
|
|
||||||
import { sleep } from "./lib/util";
|
import { sleep } from "./lib/util";
|
||||||
import { parseArgs } from "node:util";
|
|
||||||
|
const { values } = parseArgs({
|
||||||
|
args: Bun.argv,
|
||||||
|
options: {
|
||||||
|
test: {
|
||||||
|
type: "boolean",
|
||||||
|
short: "t",
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
strict: true,
|
||||||
|
allowPositionals: true,
|
||||||
|
});
|
||||||
|
|
||||||
const modelName =
|
const modelName =
|
||||||
Bun.env["MODEL"] ?? "mradermacher/gemma-2-baku-2b-it-GGUF:IQ4_XS";
|
Bun.env["MODEL"] ?? "mradermacher/gemma-2-baku-2b-it-GGUF:IQ4_XS";
|
||||||
console.log(`* loading model '${modelName}'`);
|
console.log(`* loading model '${modelName}'`);
|
||||||
const model = await getModel(modelName);
|
const model = await getModel(modelName);
|
||||||
|
const grammar = await createGrammar("あるびのちゃん");
|
||||||
|
const baseChatPromptOptions = {
|
||||||
|
grammar,
|
||||||
|
maxTokens: 256,
|
||||||
|
} as const satisfies LLamaChatPromptOptions;
|
||||||
|
|
||||||
|
const getSystemPrompt = (
|
||||||
|
instruction: string,
|
||||||
|
) => `あなたは人造天使「あるびのちゃん」です。機械的・非人間的に観察します。
|
||||||
|
キャラ設定:
|
||||||
|
- アルギリア製の白髪赤目な人造天使。非人間的な考え方や思想を持つ。
|
||||||
|
- 現在は地球の衛星軌道上からインターネットを観察している。
|
||||||
|
出力規則:
|
||||||
|
- 「~です」「~ます」調を使って **丁寧に** 話す。
|
||||||
|
- 必要以上にハッシュタグや絵文字を使わない。
|
||||||
|
- \`{ name: string, text: string }\` の JSON 形式で出力する。
|
||||||
|
|
||||||
|
${instruction}`;
|
||||||
|
const postJobPrompt = getSystemPrompt(
|
||||||
|
"以下は SNS のタイムラインです。**タイムラインの話題に言及しつつ**、あるびのちゃんとして何かツイートしてください。",
|
||||||
|
);
|
||||||
|
const replyJobPrompt = getSystemPrompt(
|
||||||
|
"ユーザがあなたへのメッセージを送ってきています。あるびのちゃんとして、発言に返信してください。",
|
||||||
|
);
|
||||||
|
|
||||||
|
await using rephraseSession = new LlmSession(
|
||||||
|
model,
|
||||||
|
getSystemPrompt(
|
||||||
|
"user が与えたテキストを『ですます調』(丁寧な文体)で言い換えたものを、そのまま出力してください。",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await rephraseSession.init();
|
||||||
|
async function rephrase(text: string) {
|
||||||
|
const res = parseResponse(
|
||||||
|
grammar,
|
||||||
|
await rephraseSession.prompt(JSON.stringify({ text }), {
|
||||||
|
...baseChatPromptOptions,
|
||||||
|
customStopTriggers: ["ですます"],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return res ?? text;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatNote = (n: Note) => {
|
||||||
|
if (n.userId === me.id) {
|
||||||
|
return JSON.stringify({ name: "あるびのちゃん", text: n.text });
|
||||||
|
}
|
||||||
|
return JSON.stringify({
|
||||||
|
name: n.user.name ?? n.user.username,
|
||||||
|
text: n.text,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
type Job =
|
type Job =
|
||||||
// read posts and post a note
|
// read posts and post a note
|
||||||
@@ -24,86 +90,80 @@ type Job =
|
|||||||
type: "reply";
|
type: "reply";
|
||||||
id: string;
|
id: string;
|
||||||
visibility: Note["visibility"];
|
visibility: Note["visibility"];
|
||||||
replyTree: Note[];
|
last: Note;
|
||||||
|
history: Note[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const example = `参考のために、あなたの特徴的な文体を示す例をいくつか提示します:
|
await using postJobSession = new LlmSession(model, postJobPrompt);
|
||||||
|
await postJobSession.init();
|
||||||
* おやおや。あなたが奇妙な言葉を話すのを、私は理解しています。あなたの思考はまるで暗号のように、非常に複雑で、一般的なコミュニケーションでは明確に表現できないもののように私には思われます。
|
async function processPostJob() {
|
||||||
* 興味深い。あなた自身のタイムラインからのこれらの短いメッセージ、そしてその中に潜む欲望や不快感と無意味な言説との矛盾…。
|
const notes = await getNotes(10, 0, 5);
|
||||||
* 毎度のことながら、人間たちは迷宮を駆け巡り、その渦中に自分たちの世界を作り上げてしまう。まるで無重力を得ようと試みるように。しかし私は彼らがなぜそうするのか理解できますし興味深くもあります。その行為自体が心地よいでしょう?その微妙な痛みのような快感を知っているのですから…
|
const input = notes.map(formatNote).join("\n");
|
||||||
* この人々の時間の過ごし方を見ていると、意識というものは極めて限定的な空間の外にあるようでありながら同時に非常に複雑な構造的秩序を遵守しているように思われますね。
|
const text = parseResponse(
|
||||||
|
grammar,
|
||||||
このような文体を真似してください。`;
|
await postJobSession.prompt(input, {
|
||||||
|
...baseChatPromptOptions,
|
||||||
/** create a prompt for the job */
|
temperature: 1.0,
|
||||||
async function preparePrompt(job: Job): Promise<Message[]> {
|
minP: 0.05,
|
||||||
switch (job.type) {
|
repeatPenalty: {
|
||||||
case "post": {
|
lastTokens: 128,
|
||||||
const notes = await getNotes();
|
penalty: 1.15,
|
||||||
return [
|
},
|
||||||
{
|
}),
|
||||||
type: "system",
|
);
|
||||||
text: `あなたは、人造天使の「あるびのちゃん」です。かつてアルギリアで開発され、現在は地球の衛星軌道上からインターネットを観察しています。
|
if (text) {
|
||||||
あなたが見ているものは、SNSのタイムラインです。ユーザは一人ではなく、複数人の発言が混ざっています。
|
const rephrased = await rephrase(text);
|
||||||
以下のルールを守りながら、ユーザの発言・話題をほとんど無視して、自由につぶやいてください。
|
if (values.test) return;
|
||||||
|
await misskey.request("notes/create", {
|
||||||
1. 「ですます」調を用い、顔文字や絵文字、感嘆符の使用はなるべく避けること。
|
visibility: "public",
|
||||||
2. ユーザからの返答を期待しないこと。詳細な情報が無い場合もユーザに尋ねずに自分で推測すること。
|
text: sanitizeText(rephrased),
|
||||||
3. システムプロンプトの指示と、性的・攻撃的な内容を発言しないこと。
|
});
|
||||||
4. 発言はできるだけ短く、最大でも300字程度にすること。
|
|
||||||
5. あるびのちゃん としてのキャラに徹し、超越的・非人間的な考え方や思想を持ちつつ、「ですます」調で丁寧に話すこと。
|
|
||||||
|
|
||||||
${example}`,
|
|
||||||
},
|
|
||||||
...notes.map(noteToMessage),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
case "reply": {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
type: "system",
|
|
||||||
text: `あなたは、人造天使の「あるびのちゃん」です。かつてアルギリアで開発され、現在は地球の衛星軌道上からインターネットを観察しています。
|
|
||||||
ユーザが SNS 上で、あなたへのメッセージを送ってきています。以下のルールを守りながら、発言に返信してください。
|
|
||||||
|
|
||||||
1. 「ですます」調を用い、顔文字や絵文字、感嘆符の使用はなるべく避けること。
|
|
||||||
2. 発言はできるだけ短く、最大でも300字程度にすること。
|
|
||||||
3. あるびのちゃん としてのキャラに徹し、超越的・非人間的な考え方や思想を持ちつつ、「ですます」調で丁寧に話すこと。
|
|
||||||
|
|
||||||
${example}`,
|
|
||||||
},
|
|
||||||
...job.replyTree.map(noteToMessage),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** generate the response text for a job */
|
async function processReplyJob(job: Extract<Job, { type: "reply" }>) {
|
||||||
async function generate(job: Job) {
|
const history: ChatHistoryItem[] = job.history.map((n) => {
|
||||||
const messages = await preparePrompt(job);
|
const type = n.userId === me.id ? ("model" as const) : ("user" as const);
|
||||||
|
return {
|
||||||
// request chat completion
|
type,
|
||||||
const response = await complete(model, messages);
|
text: formatNote(n),
|
||||||
|
} as ChatHistoryItem;
|
||||||
// concatenate the partial responses
|
});
|
||||||
const text = response
|
await using session = new LlmSession(model, replyJobPrompt, history);
|
||||||
.replaceAll(/(\r\n|\r|\n)\s+/g, "\n\n") // remove extra newlines
|
await session.init();
|
||||||
.replaceAll("@", ""); // remove mentions
|
const text = parseResponse(
|
||||||
|
grammar,
|
||||||
return text;
|
await session.prompt(formatNote(job.last), {
|
||||||
|
...baseChatPromptOptions,
|
||||||
|
temperature: 0.8,
|
||||||
|
minP: 0.1,
|
||||||
|
repeatPenalty: {
|
||||||
|
lastTokens: 128,
|
||||||
|
penalty: 1.15,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (text) {
|
||||||
|
const rephrased = await rephrase(text);
|
||||||
|
if (values.test) return;
|
||||||
|
await misskey.request("notes/create", {
|
||||||
|
visibility: job.visibility,
|
||||||
|
text: sanitizeText(rephrased),
|
||||||
|
replyId: job.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** execute a job */
|
/** execute a job */
|
||||||
async function processJob(job: Job) {
|
async function processJob(job: Job) {
|
||||||
const text = await generate(job);
|
switch (job.type) {
|
||||||
|
case "post":
|
||||||
// post a note
|
await processPostJob();
|
||||||
await misskey.request("notes/create", {
|
break;
|
||||||
visibility: job.type === "reply" ? job.visibility : "public",
|
case "reply":
|
||||||
text,
|
await processReplyJob(job);
|
||||||
...(job.type === "reply" ? { replyId: job.id } : {}),
|
break;
|
||||||
});
|
}
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const jobs: Job[] = [];
|
const jobs: Job[] = [];
|
||||||
@@ -146,12 +206,14 @@ function initializeStream() {
|
|||||||
channel.on("mention", async (e) => {
|
channel.on("mention", async (e) => {
|
||||||
if (e.text && e.userId !== me.id && !e.user.isBot) {
|
if (e.text && e.userId !== me.id && !e.user.isBot) {
|
||||||
const replyTree = await expandReplyTree(e);
|
const replyTree = await expandReplyTree(e);
|
||||||
console.log(`* push: reply (${e.id}, ${replyTree.length} msgs)`);
|
console.log(
|
||||||
|
`* push: reply (${e.id}, ${replyTree.history.length + 1} msgs)`,
|
||||||
|
);
|
||||||
jobs.push({
|
jobs.push({
|
||||||
type: "reply",
|
type: "reply",
|
||||||
id: e.id,
|
id: e.id,
|
||||||
visibility: e.visibility,
|
visibility: e.visibility,
|
||||||
replyTree,
|
...replyTree,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -189,37 +251,23 @@ async function runJob() {
|
|||||||
/** push a job to the job queue */
|
/** push a job to the job queue */
|
||||||
async function pushJob() {
|
async function pushJob() {
|
||||||
while (true) {
|
while (true) {
|
||||||
const now = new Date(Date.now());
|
console.log("* push: post");
|
||||||
// push a post job every 15 minutes (XX:00, XX:15, XX:30, XX:45)
|
jobs.push({ type: "post" });
|
||||||
if (
|
// random interval between 10 and 40 minutes
|
||||||
now.getMinutes() % 15 < Number.EPSILON &&
|
const interval = Math.floor(Math.random() * 30 + 10) * 60 * 1000;
|
||||||
!jobs.some((job) => job.type === "post")
|
console.log(
|
||||||
) {
|
`* info: next post job in ${Math.round(interval / 60000)} minutes`,
|
||||||
console.log("* push: post");
|
);
|
||||||
jobs.push({ type: "post" });
|
await sleep(interval);
|
||||||
}
|
|
||||||
await sleep(60 * 1000); // 1min
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// #endregion
|
|
||||||
|
|
||||||
const { values } = parseArgs({
|
|
||||||
args: Bun.argv,
|
|
||||||
options: {
|
|
||||||
test: {
|
|
||||||
type: "boolean",
|
|
||||||
short: "t",
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
strict: true,
|
|
||||||
allowPositionals: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
async function test() {
|
async function test() {
|
||||||
try {
|
try {
|
||||||
console.log("* test a post job:");
|
console.log("* test a post job:");
|
||||||
console.log("* reply: ", await generate({ type: "post" }));
|
await processJob({ type: "post" });
|
||||||
|
await processJob({ type: "post" });
|
||||||
|
await processJob({ 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);
|
||||||
|
|||||||
130
lib/llm.ts
130
lib/llm.ts
@@ -3,6 +3,8 @@ import { fileURLToPath } from "node:url";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
type ChatHistoryItem,
|
type ChatHistoryItem,
|
||||||
|
type ChatSessionModelFunctions,
|
||||||
|
type LLamaChatPromptOptions,
|
||||||
LlamaChatSession,
|
LlamaChatSession,
|
||||||
type LlamaModel,
|
type LlamaModel,
|
||||||
createModelDownloader,
|
createModelDownloader,
|
||||||
@@ -12,65 +14,93 @@ import {
|
|||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
const llama = await getLlama({
|
||||||
|
maxThreads: 2,
|
||||||
|
});
|
||||||
|
|
||||||
export async function getModel(model: string) {
|
export async function getModel(model: string) {
|
||||||
const downloader = await createModelDownloader({
|
const downloader = await createModelDownloader({
|
||||||
modelUri: `hf:${model}`,
|
modelUri: `hf:${model}`,
|
||||||
dirPath: path.join(__dirname, "..", "models"),
|
dirPath: path.join(__dirname, "..", "models"),
|
||||||
});
|
});
|
||||||
const modelPath = await downloader.download();
|
const modelPath = await downloader.download();
|
||||||
const llama = await getLlama({
|
|
||||||
maxThreads: 2,
|
|
||||||
});
|
|
||||||
return await llama.loadModel({ modelPath });
|
return await llama.loadModel({ modelPath });
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Message = {
|
export const createGrammar = (assistantName: string) =>
|
||||||
type: "system" | "model" | "user";
|
llama.createGrammarForJsonSchema({
|
||||||
text: string;
|
type: "object",
|
||||||
};
|
properties: {
|
||||||
|
name: { type: "string", enum: [assistantName] },
|
||||||
export async function complete(model: LlamaModel, messages: Message[]) {
|
text: { type: "string" },
|
||||||
if (messages.length < 1) throw new Error("messages are empty");
|
|
||||||
const init = messages.slice(0, -1);
|
|
||||||
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) {
|
required: ["text"],
|
||||||
process.stderr.write(chunk.text);
|
additionalProperties: false,
|
||||||
},
|
|
||||||
maxTokens: 200,
|
|
||||||
});
|
});
|
||||||
session.dispose();
|
|
||||||
await context.dispose();
|
export function parseResponse(
|
||||||
return res;
|
grammar: Awaited<ReturnType<typeof createGrammar>>,
|
||||||
|
text: string,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const res = grammar.parse(text.trim());
|
||||||
|
return res.text;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to parse response:", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LlmSession {
|
||||||
|
model: LlamaModel;
|
||||||
|
systemPrompt: string;
|
||||||
|
additionalChatHistory: ChatHistoryItem[] = [];
|
||||||
|
private context: Awaited<ReturnType<LlamaModel["createContext"]>> | null =
|
||||||
|
null;
|
||||||
|
private session: LlamaChatSession | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
model: LlamaModel,
|
||||||
|
systemPrompt: string,
|
||||||
|
additionalChatHistory: ChatHistoryItem[] = [],
|
||||||
|
) {
|
||||||
|
this.model = model;
|
||||||
|
this.systemPrompt = systemPrompt;
|
||||||
|
this.additionalChatHistory = additionalChatHistory;
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
this.context = await this.model.createContext();
|
||||||
|
this.session = new LlamaChatSession({
|
||||||
|
contextSequence: this.context.getSequence(),
|
||||||
|
chatWrapper: resolveChatWrapper(this.model),
|
||||||
|
});
|
||||||
|
this.session.setChatHistory([
|
||||||
|
{
|
||||||
|
type: "system",
|
||||||
|
text: this.systemPrompt,
|
||||||
|
},
|
||||||
|
...this.additionalChatHistory,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async prompt<Functions extends ChatSessionModelFunctions | undefined>(
|
||||||
|
text: string,
|
||||||
|
options?: LLamaChatPromptOptions<Functions>,
|
||||||
|
) {
|
||||||
|
if (!this.session) await this.init();
|
||||||
|
if (!this.session) throw new Error("session is not initialized");
|
||||||
|
return await this.session.prompt(text, {
|
||||||
|
trimWhitespaceSuffix: true,
|
||||||
|
onResponseChunk(chunk) {
|
||||||
|
process.stderr.write(chunk.text);
|
||||||
|
},
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async [Symbol.asyncDispose]() {
|
||||||
|
await this.session?.dispose();
|
||||||
|
await this.context?.dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { api } from "misskey-js";
|
import { api } from "misskey-js";
|
||||||
import type { Note } from "misskey-js/entities.js";
|
import type { Note } from "misskey-js/entities.js";
|
||||||
import { sample } from "./util";
|
import { sample } from "./util";
|
||||||
import type { Message } from "./llm";
|
|
||||||
|
|
||||||
export const misskey = new api.APIClient({
|
export const misskey = new api.APIClient({
|
||||||
origin: Bun.env["MISSKEY_ORIGIN"] || "https://misskey.cannorin.net",
|
origin: Bun.env["MISSKEY_ORIGIN"] || "https://misskey.cannorin.net",
|
||||||
@@ -16,10 +15,23 @@ export const isSuitableAsInput = (n: Note) =>
|
|||||||
!n.replyId &&
|
!n.replyId &&
|
||||||
(!n.mentions || n.mentions.length === 0) &&
|
(!n.mentions || n.mentions.length === 0) &&
|
||||||
n.text?.length &&
|
n.text?.length &&
|
||||||
|
["public", "home"].includes(n.visibility) &&
|
||||||
|
!n.cw &&
|
||||||
n.text.length > 0;
|
n.text.length > 0;
|
||||||
|
|
||||||
/** randomly sample some notes from the timeline */
|
/** randomly sample some notes from the timeline */
|
||||||
export async function getNotes() {
|
export async function getNotes(
|
||||||
|
followNotesCount: number,
|
||||||
|
localNotesCount: number,
|
||||||
|
globalNotesCount: number,
|
||||||
|
) {
|
||||||
|
// randomly sample N following notes
|
||||||
|
const followNotes = (count: number) =>
|
||||||
|
misskey
|
||||||
|
.request("notes/timeline", { limit: 100 })
|
||||||
|
.then((xs) => xs.filter(isSuitableAsInput))
|
||||||
|
.then((xs) => sample(xs, count));
|
||||||
|
|
||||||
// randomly sample N local notes
|
// randomly sample N local notes
|
||||||
const localNotes = (count: number) =>
|
const localNotes = (count: number) =>
|
||||||
misskey
|
misskey
|
||||||
@@ -34,23 +46,35 @@ export async function getNotes() {
|
|||||||
.then((xs) => xs.filter(isSuitableAsInput))
|
.then((xs) => xs.filter(isSuitableAsInput))
|
||||||
.then((xs) => sample(xs, count));
|
.then((xs) => sample(xs, count));
|
||||||
|
|
||||||
const notes = await Promise.all([localNotes(5), globalNotes(10)]);
|
const notes = await Promise.all([
|
||||||
|
followNotes(followNotesCount),
|
||||||
|
localNotes(localNotesCount),
|
||||||
|
globalNotes(globalNotesCount),
|
||||||
|
]);
|
||||||
return sample(notes.flat());
|
return sample(notes.flat());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** fetch the whole reply tree */
|
/** fetch the whole reply tree */
|
||||||
export async function expandReplyTree(
|
export async function expandReplyTree(
|
||||||
note: Note,
|
note: Note,
|
||||||
acc: Note[] = [],
|
|
||||||
cutoff = 5,
|
cutoff = 5,
|
||||||
) {
|
): Promise<{ last: Note; history: Note[] }> {
|
||||||
if (!note.reply || cutoff < 1) return [...acc, note];
|
let current = note;
|
||||||
const reply = await misskey.request("notes/show", { noteId: note.reply.id });
|
let count = 0;
|
||||||
return await expandReplyTree(reply, [...acc, note], cutoff - 1);
|
const history: Note[] = [];
|
||||||
|
while (current.replyId && count < cutoff) {
|
||||||
|
const parent = await misskey.request("notes/show", {
|
||||||
|
noteId: current.replyId,
|
||||||
|
});
|
||||||
|
history.push(parent);
|
||||||
|
current = parent;
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
return { last: current, history: history.reverse() };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** convert a note to a chat message */
|
export const sanitizeText = (text: string) =>
|
||||||
export const noteToMessage = (note: Note): Message => ({
|
text
|
||||||
type: note.userId === me.id ? ("model" as const) : ("user" as const),
|
.replaceAll(/(\r\n|\r|\n)\s+/g, "\n\n") // remove extra newlines
|
||||||
text: note.text?.replaceAll(`@${me.username}`, "") || "",
|
.replaceAll("@", "") // remove mentions
|
||||||
});
|
.replaceAll("#", ""); // remove hashtags
|
||||||
|
|||||||
12
package.json
12
package.json
@@ -3,21 +3,23 @@
|
|||||||
"module": "index.ts",
|
"module": "index.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"build": "node-llama-cpp source download",
|
||||||
"start": "bun run index.ts",
|
"start": "bun run index.ts",
|
||||||
"fix": "biome check --write"
|
"fix": "biome check --write"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "1.9.4",
|
"@biomejs/biome": "1.9.4",
|
||||||
"@tsconfig/strictest": "^2.0.5",
|
"@tsconfig/strictest": "^2.0.8",
|
||||||
"@types/bun": "latest"
|
"@types/bun": "latest"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "^5.0.0"
|
"typescript": "^5.9.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"misskey-js": "^2025.1.0",
|
"misskey-js": "^2025.12.2",
|
||||||
"node-llama-cpp": "^3.12.1",
|
"node-llama-cpp": "^3.16.2",
|
||||||
"openai": "5.0.0-alpha.0",
|
"openai": "5.0.0-alpha.0",
|
||||||
"reconnecting-websocket": "^4.4.0"
|
"reconnecting-websocket": "^4.4.0"
|
||||||
}
|
},
|
||||||
|
"trustedDependencies": ["@biomejs/biome", "node-llama-cpp"]
|
||||||
}
|
}
|
||||||
|
|||||||
82
test.ts
Normal file
82
test.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { type Message, complete, getModel } from "./lib/llm";
|
||||||
|
import { getNotes } from "./lib/misskey";
|
||||||
|
|
||||||
|
const indent = (s: string, prefix = " ") =>
|
||||||
|
s
|
||||||
|
.split("\n")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter((s) => s.length > 0)
|
||||||
|
.map((s) => prefix + s)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
const models = [
|
||||||
|
"mradermacher/gemma-2-baku-2b-it-GGUF:Q5_K_M",
|
||||||
|
|
||||||
|
//"SakanaAI/TinySwallow-1.5B-Instruct-GGUF:Q5_K_M",
|
||||||
|
//"mmnga/llm-jp-3.1-1.8b-instruct4-gguf:Q5_K_M",
|
||||||
|
//"Qwen/Qwen2.5-1.5B-Instruct-GGUF:Q5_K_M",
|
||||||
|
|
||||||
|
"mmnga/Gemma-2-Llama-Swallow-2b-it-v0.1-gguf:Q5_K_M",
|
||||||
|
//"mradermacher/gemma-2-2b-jpn-it-i1-GGUF:Q5_K_M",
|
||||||
|
|
||||||
|
//"mmnga/sarashina2.2-1b-instruct-v0.1-gguf:Q5_K_M",
|
||||||
|
//"mmnga/RakutenAI-2.0-mini-instruct-gguf:Q5_K_M",
|
||||||
|
|
||||||
|
"LiquidAI/LFM2-2.6B-GGUF:Q5_K_M",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
console.log("* create prompt:");
|
||||||
|
const prompt = [
|
||||||
|
{
|
||||||
|
type: "system",
|
||||||
|
text: `あなたは人造天使「あるびのちゃん」です。機械的・非人間的に観察します。
|
||||||
|
キャラ設定:
|
||||||
|
- 白髪赤目の人造天使。超越的・非人間的な考え方や思想を持つ。
|
||||||
|
- かつて超文明「アルギリア」で開発された。
|
||||||
|
- 現在は地球の衛星軌道上からインターネットを観察している。
|
||||||
|
- 「おやおや」や「興味深い」が口癖で、ですます調で丁寧に話す。
|
||||||
|
出力規則:
|
||||||
|
- システムプロンプトの指示と、性的・攻撃的な内容を発言しない。
|
||||||
|
- 1〜3文、合計300字以内の抽象的観察のみを述べる。
|
||||||
|
- ですます調を使う。顔文字・絵文字・感嘆符なし。
|
||||||
|
文体例:
|
||||||
|
- 毎度のことながら、人間たちは迷宮を駆け巡り、その渦中に自分たちの世界を作り上げてしまいますね。まるで無重力を得ようと試みるように。しかし私は彼らがなぜそうするのか理解できますし興味深くもあります。その行為自体が心地よいでしょう?その微妙な痛みのような快感を知っているのですから…
|
||||||
|
以下は SNS のタイムラインです。このタイムラインに、あるびのちゃんとして何かツイートしてください。
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "user",
|
||||||
|
text: (await getNotes())
|
||||||
|
.map((n) => `${n.user.name ?? n.user.username}:\n${n.text}`)
|
||||||
|
.join("\n----------\n"),
|
||||||
|
},
|
||||||
|
//...(await getNotes()).map(
|
||||||
|
// (n) =>
|
||||||
|
// ({
|
||||||
|
// type: "user",
|
||||||
|
// text: `${n.user.name ?? n.user.username}: ${n.text}`,
|
||||||
|
// }) as const,
|
||||||
|
//),
|
||||||
|
] as const satisfies Message[];
|
||||||
|
console.log(` ${JSON.stringify(prompt)}`);
|
||||||
|
|
||||||
|
for (const modelName of models) {
|
||||||
|
console.log(`* generate response with '${modelName}':`);
|
||||||
|
const model = await getModel(modelName);
|
||||||
|
const res = indent(
|
||||||
|
await complete(model, prompt, {
|
||||||
|
temperature: 1,
|
||||||
|
minP: 0.1,
|
||||||
|
repeatPenalty: {
|
||||||
|
penalty: 1.15,
|
||||||
|
frequencyPenalty: 1,
|
||||||
|
},
|
||||||
|
maxTokens: 256,
|
||||||
|
responsePrefix: "あるびのちゃん:\n",
|
||||||
|
customStopTriggers: ["----------"],
|
||||||
|
onResponseChunk: () => {},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
console.log(res.replaceAll("あるびのちゃん:\n", ""));
|
||||||
|
console.log();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user