mioku-plugin-chat 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,94 @@
1
+ import type { AISkill, SkillPermissionRole } from "mioku";
2
+ import type { ChatConfig } from "../types";
3
+
4
+ function normalizeSkillName(name: unknown): string {
5
+ return String(name || "").trim();
6
+ }
7
+
8
+ export function getAllowedExternalSkillNameSet(
9
+ config: ChatConfig,
10
+ ): Set<string> | null {
11
+ const entries = Array.isArray(config.allowedExternalSkills)
12
+ ? config.allowedExternalSkills
13
+ .map((item) => normalizeSkillName(item))
14
+ .filter(Boolean)
15
+ : [];
16
+
17
+ return entries.length > 0 ? new Set(entries) : null;
18
+ }
19
+
20
+ export function isExternalSkillAllowed(
21
+ config: ChatConfig,
22
+ skillName: string,
23
+ ): boolean {
24
+ const allowedSkillNames = getAllowedExternalSkillNameSet(config);
25
+ if (!allowedSkillNames) {
26
+ return true;
27
+ }
28
+
29
+ return allowedSkillNames.has(normalizeSkillName(skillName));
30
+ }
31
+
32
+ const SKILL_PERMISSION_RANK: Record<SkillPermissionRole, number> = {
33
+ member: 0,
34
+ admin: 1,
35
+ owner: 2,
36
+ };
37
+
38
+ export function normalizeSkillPermissionRole(
39
+ role: unknown,
40
+ ): SkillPermissionRole {
41
+ const normalized = String(role || "")
42
+ .trim()
43
+ .toLowerCase();
44
+ if (
45
+ normalized === "member" ||
46
+ normalized === "admin" ||
47
+ normalized === "owner"
48
+ ) {
49
+ return normalized;
50
+ }
51
+ return "member";
52
+ }
53
+
54
+ export function getSkillRequiredPermissionRole(
55
+ skill: AISkill | undefined,
56
+ ): SkillPermissionRole {
57
+ if (!skill) {
58
+ return "member";
59
+ }
60
+ return normalizeSkillPermissionRole(skill.permission);
61
+ }
62
+
63
+ export function hasSkillPermission(
64
+ triggerRole: SkillPermissionRole,
65
+ requiredRole: SkillPermissionRole,
66
+ ): boolean {
67
+ return (
68
+ SKILL_PERMISSION_RANK[triggerRole] >= SKILL_PERMISSION_RANK[requiredRole]
69
+ );
70
+ }
71
+
72
+ export function isSkillAllowedForRole(
73
+ skill: AISkill | undefined,
74
+ triggerRole: SkillPermissionRole,
75
+ ): boolean {
76
+ return hasSkillPermission(triggerRole, getSkillRequiredPermissionRole(skill));
77
+ }
78
+
79
+ export function filterAllowedExternalSkills(
80
+ config: ChatConfig,
81
+ skills: AISkill[],
82
+ triggerRole?: SkillPermissionRole,
83
+ ): AISkill[] {
84
+ const allowedSkillNames = getAllowedExternalSkillNameSet(config);
85
+ return skills.filter((skill) => {
86
+ if (
87
+ allowedSkillNames &&
88
+ !allowedSkillNames.has(normalizeSkillName(skill.name))
89
+ ) {
90
+ return false;
91
+ }
92
+ return !(triggerRole && !isSkillAllowedForRole(skill, triggerRole));
93
+ });
94
+ }
@@ -0,0 +1,47 @@
1
+ import type { ChatConfig } from "../types";
2
+
3
+ export type FeatureName =
4
+ | "markdown"
5
+ | "audio"
6
+ | "web_search"
7
+ | "web_read_page"
8
+ | "recall_memory";
9
+
10
+ export interface FeatureMeta {
11
+ name: FeatureName;
12
+ configKey: string;
13
+ hasTools: boolean;
14
+ }
15
+
16
+ export const FEATURE_METAS: FeatureMeta[] = [
17
+ {
18
+ name: "markdown",
19
+ configKey: "enableMarkdownScreenshot",
20
+ hasTools: false,
21
+ },
22
+ { name: "audio", configKey: "audio.enabled", hasTools: false },
23
+ { name: "web_search", configKey: "searxng.enabled", hasTools: true },
24
+ { name: "web_read_page", configKey: "webReader.enabled", hasTools: true },
25
+ { name: "recall_memory", configKey: "memory.enabled", hasTools: true },
26
+ ];
27
+
28
+ export function isFeatureEnabled(
29
+ config: ChatConfig,
30
+ feature: FeatureMeta,
31
+ ): boolean {
32
+ const key = feature.configKey;
33
+ const parts = key.split(".");
34
+ let value: any = config;
35
+ for (const part of parts) {
36
+ value = value?.[part];
37
+ }
38
+ return Boolean(value);
39
+ }
40
+
41
+ export function isBuiltinFeature(name: string): name is FeatureName {
42
+ return FEATURE_METAS.some((f) => f.name === name);
43
+ }
44
+
45
+ export function getFeatureMeta(name: FeatureName): FeatureMeta | undefined {
46
+ return FEATURE_METAS.find((f) => f.name === name);
47
+ }
@@ -0,0 +1,73 @@
1
+ import type { AudioConfig } from "../../types";
2
+
3
+ interface TTSResponse {
4
+ audio_base64?: string;
5
+ detail?: string;
6
+ error?: string;
7
+ }
8
+
9
+ function ensureTrailingSlash(value: string): string {
10
+ return value.endsWith("/") ? value : `${value}/`;
11
+ }
12
+
13
+ function detectAudioLanguage(text: string): string {
14
+ if (/[\u3040-\u30ff]/.test(text)) {
15
+ return "all_ja";
16
+ }
17
+ if (/[\u4e00-\u9fff]/.test(text)) {
18
+ return "all_zh";
19
+ }
20
+ return "en";
21
+ }
22
+
23
+ export async function synthesizeAudioBase64(
24
+ config: AudioConfig,
25
+ text: string,
26
+ ): Promise<string> {
27
+ const trimmedText = String(text || "").trim();
28
+ if (!config.enabled) {
29
+ throw new Error("Audio output is disabled");
30
+ }
31
+ if (!trimmedText) {
32
+ throw new Error("Audio text cannot be empty");
33
+ }
34
+ if (!config.baseUrl?.trim()) {
35
+ throw new Error("Audio service baseUrl is not configured");
36
+ }
37
+
38
+ const endpoint = new URL("tts", ensureTrailingSlash(config.baseUrl.trim()));
39
+ const headers: Record<string, string> = {
40
+ "Content-Type": "application/json",
41
+ };
42
+
43
+ const apiKey = config.apiKey?.trim();
44
+ if (apiKey) {
45
+ headers["X-API-Key"] = apiKey;
46
+ }
47
+
48
+ const response = await fetch(endpoint, {
49
+ method: "POST",
50
+ headers,
51
+ body: JSON.stringify({
52
+ text: trimmedText,
53
+ text_lang: detectAudioLanguage(trimmedText),
54
+ }),
55
+ signal: AbortSignal.timeout(Math.max(1000, config.timeoutMs || 20_000)),
56
+ });
57
+
58
+ const payload = (await response.json().catch(() => ({}))) as TTSResponse;
59
+ if (!response.ok) {
60
+ throw new Error(
61
+ payload?.detail ||
62
+ payload?.error ||
63
+ `TTS request failed with status ${response.status}`,
64
+ );
65
+ }
66
+
67
+ const audioBase64 = String(payload.audio_base64 || "").trim();
68
+ if (!audioBase64) {
69
+ throw new Error("TTS response missing audio_base64");
70
+ }
71
+
72
+ return `base64://${audioBase64}`;
73
+ }
@@ -0,0 +1,118 @@
1
+ import { logger } from "mioki";
2
+ import sharp from "sharp";
3
+
4
+ /**
5
+ * GIF 帧提取结果
6
+ */
7
+ export interface GifFramesResult {
8
+ frames: string[]; // base64 编码的帧(PNG 格式)
9
+ buffer: Buffer; // 原始 GIF buffer
10
+ }
11
+
12
+ /**
13
+ * 从 GIF 提取三帧(第一帧、中间帧、末尾帧)
14
+ * @param gifUrl GIF 图片 URL
15
+ * @returns 三帧的 base64 编码和原始 buffer
16
+ */
17
+ export async function extractGifFrames(
18
+ gifUrl: string,
19
+ ): Promise<GifFramesResult | null> {
20
+ try {
21
+ // 下载 GIF
22
+ const response = await fetch(gifUrl);
23
+ if (!response.ok) {
24
+ logger.error(
25
+ `[gif-extractor] Failed to fetch GIF: ${response.statusText}`,
26
+ );
27
+ return null;
28
+ }
29
+
30
+ const buffer = Buffer.from(await response.arrayBuffer());
31
+
32
+ // 获取 GIF 元数据以确定总帧数
33
+ const metadata = await sharp(buffer, { animated: true }).metadata();
34
+ const totalPages = metadata.pages || 1;
35
+
36
+ if (totalPages === 1) {
37
+ // 只有一帧,提取第一帧
38
+ const frame = await sharp(buffer, { animated: false, page: 0 })
39
+ .png()
40
+ .toBuffer();
41
+ const base64 = `data:image/png;base64,${frame.toString("base64")}`;
42
+ return { frames: [base64], buffer };
43
+ }
44
+
45
+ // 计算要提取的帧索引
46
+ const firstIndex = 0;
47
+ const middleIndex = Math.floor(totalPages / 2);
48
+ const lastIndex = totalPages - 1;
49
+
50
+ const frames: string[] = [];
51
+
52
+ // 提取第一帧
53
+ const firstFrame = await sharp(buffer, {
54
+ animated: false,
55
+ page: firstIndex,
56
+ })
57
+ .png()
58
+ .toBuffer();
59
+ frames.push(`data:image/png;base64,${firstFrame.toString("base64")}`);
60
+
61
+ // 提取中间帧
62
+ if (middleIndex !== firstIndex && middleIndex !== lastIndex) {
63
+ const middleFrame = await sharp(buffer, {
64
+ animated: false,
65
+ page: middleIndex,
66
+ })
67
+ .png()
68
+ .toBuffer();
69
+ frames.push(`data:image/png;base64,${middleFrame.toString("base64")}`);
70
+ }
71
+
72
+ // 提取末尾帧
73
+ if (lastIndex !== firstIndex) {
74
+ const lastFrame = await sharp(buffer, {
75
+ animated: false,
76
+ page: lastIndex,
77
+ })
78
+ .png()
79
+ .toBuffer();
80
+ frames.push(`data:image/png;base64,${lastFrame.toString("base64")}`);
81
+ }
82
+
83
+ logger.info(
84
+ `[gif-extractor] Extracted ${frames.length} frames from GIF (${totalPages} total)`,
85
+ );
86
+ return { frames, buffer };
87
+ } catch (err) {
88
+ logger.error(`[gif-extractor] Failed to extract frames: ${err}`);
89
+ return null;
90
+ }
91
+ }
92
+
93
+ /**
94
+ * 检查 URL 是否为 GIF
95
+ * 优先检查 URL 后缀,其次检测文件内容
96
+ */
97
+ export async function isGifUrl(url: string): Promise<boolean> {
98
+ if (url.toLowerCase().includes(".gif")) {
99
+ return true;
100
+ }
101
+
102
+ try {
103
+ const response = await fetch(url, {
104
+ method: "GET",
105
+ headers: {
106
+ "User-Agent":
107
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
108
+ Referer: "https://qq.com/",
109
+ },
110
+ });
111
+ const contentType = response.headers.get("content-type");
112
+ if (contentType && contentType.includes("image/gif")) {
113
+ return true;
114
+ }
115
+ } catch {}
116
+
117
+ return false;
118
+ }