295 lines
7.5 KiB
TypeScript
295 lines
7.5 KiB
TypeScript
import { parseArgs } from "node:util";
|
|
import { Stream } from "misskey-js";
|
|
import type { Note } from "misskey-js/entities.js";
|
|
import type { ChatHistoryItem, LLamaChatPromptOptions } from "node-llama-cpp";
|
|
import { createHarness, type HarnessName, isHarnessName } from "./lib/harness";
|
|
import { createGrammar, getModel, LlmSession } from "./lib/llm";
|
|
import { expandReplyTree, getNotes, me, misskey } from "./lib/misskey";
|
|
import {
|
|
formatConversationNote,
|
|
formatTimeline,
|
|
postJobPrompt,
|
|
replyJobPrompt,
|
|
} from "./lib/prompt";
|
|
import { ensurePolite, isPolitenessMode } from "./lib/rephrase";
|
|
import {
|
|
getSamplingOptions,
|
|
isSamplingProfile,
|
|
type SamplingProfile,
|
|
} from "./lib/sampling";
|
|
import { sanitizeText, sleep } from "./lib/util";
|
|
|
|
const { values } = parseArgs({
|
|
args: Bun.argv,
|
|
options: {
|
|
test: {
|
|
type: "boolean",
|
|
short: "t",
|
|
default: false,
|
|
},
|
|
harness: {
|
|
type: "string",
|
|
default: Bun.env["HARNESS"] ?? "tagged",
|
|
},
|
|
politeness: {
|
|
type: "string",
|
|
default: Bun.env["POLITENESS"] ?? "auto",
|
|
},
|
|
sampling: {
|
|
type: "string",
|
|
default: Bun.env["SAMPLING"] ?? "creative",
|
|
},
|
|
},
|
|
strict: true,
|
|
allowPositionals: true,
|
|
});
|
|
|
|
if (!isHarnessName(values.harness)) {
|
|
throw new Error(`Unknown harness: ${values.harness}`);
|
|
}
|
|
const harnessName: HarnessName = values.harness;
|
|
if (!isPolitenessMode(values.politeness)) {
|
|
throw new Error(`Unknown politeness mode: ${values.politeness}`);
|
|
}
|
|
const politenessMode = values.politeness;
|
|
if (!isSamplingProfile(values.sampling)) {
|
|
throw new Error(`Unknown sampling profile: ${values.sampling}`);
|
|
}
|
|
const samplingProfile: SamplingProfile = values.sampling;
|
|
|
|
const modelName =
|
|
Bun.env["MODEL"] ?? "mradermacher/gemma-2-baku-2b-it-GGUF:IQ4_XS";
|
|
console.log(`* loading model '${modelName}'`);
|
|
const model = await getModel(modelName);
|
|
const grammar = await createGrammar("あるびのちゃん");
|
|
const harness = createHarness(harnessName, grammar);
|
|
console.log(`* output harness: ${harness.name}`);
|
|
console.log(`* sampling profile: ${samplingProfile}`);
|
|
const baseChatPromptOptions = {
|
|
maxTokens: 192,
|
|
} as const satisfies LLamaChatPromptOptions;
|
|
|
|
type Job =
|
|
// read posts and post a note
|
|
| { type: "post" }
|
|
// reply to a specific note
|
|
| {
|
|
type: "reply";
|
|
id: string;
|
|
visibility: Note["visibility"];
|
|
last: Note;
|
|
history: Note[];
|
|
};
|
|
|
|
async function processPostJob() {
|
|
const notes = await getNotes(10, 0, 5);
|
|
const input = formatTimeline(notes, me.id);
|
|
const text = await (async () => {
|
|
await using postJobSession = new LlmSession(
|
|
model,
|
|
postJobPrompt + harness.promptSuffix,
|
|
);
|
|
await postJobSession.init();
|
|
const raw = await postJobSession.prompt(input, {
|
|
...baseChatPromptOptions,
|
|
...harness.options,
|
|
...getSamplingOptions(modelName, samplingProfile, "post"),
|
|
});
|
|
process.stderr.write("\n");
|
|
return harness.parse(raw);
|
|
})();
|
|
if (text) {
|
|
const output = sanitizeText(
|
|
(await ensurePolite(model, text, politenessMode)).text,
|
|
);
|
|
if (values.test) {
|
|
console.log(`\n${output}\n`);
|
|
return;
|
|
}
|
|
await misskey.request("notes/create", {
|
|
visibility: "public",
|
|
text: output,
|
|
});
|
|
}
|
|
}
|
|
|
|
async function processReplyJob(job: Extract<Job, { type: "reply" }>) {
|
|
const history: ChatHistoryItem[] = job.history.map((n) => {
|
|
if (n.userId === me.id) {
|
|
return {
|
|
type: "model",
|
|
response: [harness.formatHistory(n.text ?? "")],
|
|
} as const;
|
|
}
|
|
return {
|
|
type: "user",
|
|
text: formatConversationNote(n, me.id),
|
|
} as const;
|
|
});
|
|
const text = await (async () => {
|
|
await using session = new LlmSession(
|
|
model,
|
|
replyJobPrompt + harness.promptSuffix,
|
|
history,
|
|
);
|
|
await session.init();
|
|
const raw = await session.prompt(formatConversationNote(job.last, me.id), {
|
|
...baseChatPromptOptions,
|
|
...harness.options,
|
|
...getSamplingOptions(modelName, samplingProfile, "reply"),
|
|
});
|
|
process.stderr.write("\n");
|
|
return harness.parse(raw);
|
|
})();
|
|
|
|
if (text) {
|
|
const output = sanitizeText(
|
|
(await ensurePolite(model, text, politenessMode)).text,
|
|
);
|
|
if (values.test) {
|
|
console.log(`\n${output}\n`);
|
|
return;
|
|
}
|
|
await misskey.request("notes/create", {
|
|
visibility: job.visibility,
|
|
text: output,
|
|
replyId: job.id,
|
|
});
|
|
}
|
|
}
|
|
|
|
/** execute a job */
|
|
async function processJob(job: Job) {
|
|
switch (job.type) {
|
|
case "post":
|
|
await processPostJob();
|
|
break;
|
|
case "reply":
|
|
await processReplyJob(job);
|
|
break;
|
|
}
|
|
}
|
|
|
|
const jobs: Job[] = [];
|
|
|
|
let stream: Stream;
|
|
let channel: ReturnType<typeof stream.useChannel<"main">>;
|
|
|
|
/** dispose stream for recreation */
|
|
function disposeStream() {
|
|
channel.removeAllListeners();
|
|
channel.dispose();
|
|
stream.removeAllListeners();
|
|
stream.close();
|
|
}
|
|
|
|
/** connect to streaming API and add handlers */
|
|
function initializeStream() {
|
|
stream = new Stream(
|
|
Bun.env["MISSKEY_ORIGIN"] ?? "https://misskey.cannorin.net",
|
|
{
|
|
token: Bun.env["MISSKEY_CREDENTIAL"] ?? "",
|
|
},
|
|
{
|
|
binaryType: "arraybuffer",
|
|
},
|
|
);
|
|
channel = stream.useChannel("main");
|
|
|
|
// notify when connected
|
|
stream.on("_connected_", () => {
|
|
console.log("* connected");
|
|
});
|
|
|
|
// notify when disconnected (it will reconnect automatically)
|
|
stream.on("_disconnected_", () => {
|
|
console.log("* disconnected");
|
|
});
|
|
|
|
// push a reply job when receiving a mention
|
|
channel.on("mention", async (e) => {
|
|
if (e.text && e.userId !== me.id && !e.user.isBot) {
|
|
const replyTree = await expandReplyTree(e);
|
|
console.log(
|
|
`* push: reply (${e.id}, ${replyTree.history.length + 1} msgs)`,
|
|
);
|
|
jobs.push({
|
|
type: "reply",
|
|
id: e.id,
|
|
visibility: e.visibility,
|
|
...replyTree,
|
|
});
|
|
}
|
|
});
|
|
|
|
// follow back non-bot users
|
|
channel.on("followed", async (e) => {
|
|
if (!e.isBot) {
|
|
await misskey.request("following/create", { userId: e.id });
|
|
}
|
|
});
|
|
}
|
|
|
|
/** take jobs from the queue in arrival order */
|
|
async function runJob() {
|
|
while (true) {
|
|
const job = jobs.shift();
|
|
if (job) {
|
|
console.log(`* take: ${job.type}`);
|
|
try {
|
|
await processJob(job);
|
|
console.log("* job complete");
|
|
} catch (e) {
|
|
console.log(`* error: ${JSON.stringify(e)}`);
|
|
if (e instanceof Error) console.log(e.stack);
|
|
}
|
|
}
|
|
await sleep(1000); // 1sec
|
|
}
|
|
}
|
|
|
|
/** push a job to the job queue */
|
|
async function pushJob() {
|
|
while (true) {
|
|
console.log("* push: post");
|
|
jobs.push({ type: "post" });
|
|
// random interval between 10 and 40 minutes
|
|
const interval = Math.floor(Math.random() * 30 + 10) * 60 * 1000;
|
|
console.log(
|
|
`* info: next post job in ${Math.round(interval / 60000)} minutes`,
|
|
);
|
|
await sleep(interval);
|
|
}
|
|
}
|
|
|
|
async function test() {
|
|
try {
|
|
console.log("* test a post job:");
|
|
await processJob({ type: "post" });
|
|
await processJob({ type: "post" });
|
|
await processJob({ type: "post" });
|
|
await processJob({ type: "post" });
|
|
await processJob({ type: "post" });
|
|
} catch (e) {
|
|
console.error(e);
|
|
if (e instanceof Error) console.log(e.stack);
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
try {
|
|
initializeStream();
|
|
try {
|
|
await Promise.all([runJob(), pushJob()]);
|
|
} catch (e) {
|
|
console.error(e);
|
|
if (e instanceof Error) console.log(e.stack);
|
|
}
|
|
} finally {
|
|
disposeStream();
|
|
}
|
|
}
|
|
|
|
if (values.test) await test();
|
|
else await main();
|