Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

34 changes: 34 additions & 0 deletions crates/tw-gateway/src/plugin/defaults/current-date.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// 附加当前日期
//
// 在系统提示词末尾附上今天的日期。模型本身不知道今天是哪一天,问到截止日期、版本新旧
// 这类问题时,容易按训练数据所在的年份回答。
//
// 沙箱里的时钟是 UTC,所以「今天」按设置里的时区算,默认 UTC+8(北京时间)。
//
// 日期每天变一次,系统提示词随之变化,上游的提示词缓存每天重建一次;同一天里每一轮
// 附上的内容相同,缓存照常命中。
//
// 权限:system,只读写系统提示词。
// 设置:时区,相对 UTC 的小时数,-12 到 14,可以带小数(例如 5.5)。

export const manifest = {
name: "附加当前日期",
api: 1,
description: "在系统提示词末尾附上今天的日期,按设置的时区计算。",
permissions: ["system"],
settings: {
utc_offset: { type: "number", label: "时区(相对 UTC 的小时数)", default: 8 },
},
};

export function onRequest(req, ctx) {
const offset = Number(ctx.settings.utc_offset ?? 8);
if (!Number.isFinite(offset) || offset < -12 || offset > 14) {
throw new Error(`设置「时区」要在 -12 到 14 之间,当前是 ${ctx.settings.utc_offset}`);
}
const date = new Date(Date.now() + offset * 3600 * 1000).toISOString().slice(0, 10);
const zone = `UTC${offset < 0 ? "-" : "+"}${Math.abs(offset)}`;
const line = `Today's date: ${date} (${zone}).`;
req.system = req.system ? `${req.system}\n\n${line}` : line;
return req;
}
118 changes: 118 additions & 0 deletions crates/tw-gateway/src/plugin/defaults/deepseek-flags.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// DeepSeek:替换会被拒收的旗帜表情
//
// DeepSeek 接口会拒收含特定地区旗帜表情的请求:模型还没运行就回 400 Content Exists Risk。
// 这类表情一旦进入对话历史(例如工具抓回的网页、读到的文件),之后这个会话的每一次请求
// 都会被拒收,会话就无法继续(deepseek-ai/deepseek-harness 讨论 #7310,DeepSeek Harness
// 与 OpenCode 上都能复现)。
//
// - 请求:系统提示词和对话消息里(含工具结果、此前工具调用的参数)出现这些表情时,换成
// 一段 ASCII 占位文字。占位文字不会自然出现,经过 JSON 转义也保持原样。
// - 回答:回答文字和工具调用参数里出现占位文字时换回原来的表情,客户端写出的文件里仍是
// 原样。逐段模式下,末尾可能是占位文字开头的几个字先扣住,等下一段到了再判断。
//
// 换哪些字符、换成什么写死在这里,没有设置项:插件持有 reply.tool_calls,替换不能被设置
// 引向别处。同样的输入每次换出同样的结果,上游的提示词缓存照常命中;请求里没有这些表情时
// 原样发出,一个字节都不动。
//
// 适用范围默认是发往上游的模型名以 deepseek 开头的请求(路由改写模型名之后的那个名字),
// 客户端用别的名字、经路由转到 DeepSeek 的请求也在范围内。
//
// 思考内容只读,其中的表情换不掉。
//
// 权限:system、messages(请求一侧替换),reply.text、reply.tool_calls(回答一侧换回)。

export const manifest = {
name: "DeepSeek:替换会被拒收的旗帜表情",
api: 1,
description:
"DeepSeek 接口会拒收含特定地区旗帜表情的请求,含有它们的会话因此无法继续。请求发出前把这些表情换成 ASCII 占位文字,回答里再换回原样。默认对发往 deepseek 开头的模型的请求生效。",
permissions: ["system", "messages", "reply.text", "reply.tool_calls"],
match: { models: ["deepseek*"] },
reply: "stream",
};

// 被拒收的字符序列,和各自的占位文字
const SEQUENCES = [
// U+1F1F9 U+1F1FC
{ chars: String.fromCodePoint(0x1f1f9, 0x1f1fc), placeholder: "[[emoji:1F1F9-1F1FC]]" },
];

function hide(s) {
let out = s;
for (const { chars, placeholder } of SEQUENCES) out = out.replaceAll(chars, placeholder);
return out;
}

function reveal(s) {
let out = s;
for (const { chars, placeholder } of SEQUENCES) out = out.replaceAll(placeholder, chars);
return out;
}

// JSON 值里的每个字符串(连同对象的键)
function deep(value, f) {
if (typeof value === "string") return f(value);
if (Array.isArray(value)) return value.map((item) => deep(item, f));
if (value !== null && typeof value === "object") {
return Object.fromEntries(Object.entries(value).map(([k, v]) => [f(k), deep(v, f)]));
}
return value;
}

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);

export function onRequest(req) {
let changed = false;
const fix = (s) => {
const next = hide(s);
if (next !== s) changed = true;
return next;
};
req.system = fix(req.system);
for (const m of req.messages) {
for (const p of m.parts) {
if (p.type === "text" || p.type === "tool_result") {
p.text = fix(p.text);
} else if (p.type === "tool_call") {
const input = deep(p.input, hide);
if (!same(input, p.input)) {
p.input = input;
changed = true;
}
}
}
}
// 没有要换的就不返回:请求原样发出
return changed ? req : undefined;
}

// 同一个回答里的几次调用共用一个实例:held 是上一段末尾扣住的、可能是占位文字开头的那几个字
let held = "";

export function onReplyText(text) {
const s = reveal(held + text);
let keep = 0;
for (const { placeholder } of SEQUENCES) {
for (let k = Math.min(placeholder.length - 1, s.length); k > keep; k--) {
if (placeholder.startsWith(s.slice(s.length - k))) {
keep = k;
break;
}
}
}
held = s.slice(s.length - keep);
const out = s.slice(0, s.length - keep);
return out === text ? undefined : out;
}

export function onReplyTextEnd() {
const out = held;
held = "";
return out === "" ? undefined : out;
}

export function onToolCall(call) {
const input = deep(call.input, reveal);
if (same(input, call.input)) return undefined;
return { id: call.id, name: call.name, input };
}
33 changes: 33 additions & 0 deletions crates/tw-gateway/src/plugin/defaults/reply-language.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// 指定回答语言
//
// 在系统提示词末尾附上一句固定的要求:用设置里的语言回答。附上的这句话每次都一样,
// 上游的提示词缓存照常命中。
//
// 设置里只能写语言的名称(字母、空格、括号和连字符,最多 40 个字符),写不进句子:
// 这句要求是插件定好的,设置改不出别的指令。
//
// 权限:system,只读写系统提示词。
// 设置:回答语言,默认简体中文。

export const manifest = {
name: "指定回答语言",
api: 1,
description: "在系统提示词末尾要求模型用指定的语言回答。",
permissions: ["system"],
settings: {
language: { type: "string", label: "回答语言", default: "简体中文" },
},
};

const NAME = /^[\p{L}\p{M}][\p{L}\p{M} ()\-]{0,39}$/u;

export function onRequest(req, ctx) {
const language = String(ctx.settings.language ?? "").trim();
if (!NAME.test(language)) {
throw new Error("设置「回答语言」只能是语言的名称,例如 简体中文、English");
}
const line = `Always respond in ${language}, unless the user explicitly asks for another language.`;
if (req.system.includes(line)) return undefined;
req.system = req.system ? `${req.system}\n\n${line}` : line;
return req;
}
63 changes: 63 additions & 0 deletions crates/tw-gateway/src/plugin/defaults/reply-redact.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// 回答内容打码
//
// 回答里出现符合格式的内容时,换成一段固定文字,例如内部主机名、工单号、员工编号。
// 只改发给客户端的回答,不改请求。出厂没有任何格式,要在设置里写上才会生效。
//
// 整段模式:一段文字到齐之后才处理,符合格式的内容不会被流式输出切成两半而漏掉。
// 代价是这段文字要等到齐了才出现在客户端里。
//
// 权限:reply.text,只改回答里的文字。
// 设置:格式,每行一个正则表达式(最多 50 个);替换文字(最多 100 个字符);是否区分大小写。

export const manifest = {
name: "回答内容打码",
api: 1,
description: "把回答里符合格式的内容换成固定文字。格式在设置里每行写一个正则表达式。",
permissions: ["reply.text"],
settings: {
patterns: { type: "string", label: "格式(每行一个正则表达式)", default: "" },
replacement: { type: "string", label: "替换为", default: "[已隐藏]" },
ignore_case: { type: "boolean", label: "不区分大小写", default: true },
},
};

const MAX_PATTERNS = 50;
const MAX_REPLACEMENT = 100;

let compiled = null;

function patterns(ctx) {
if (compiled !== null) return compiled;
const flags = ctx.settings.ignore_case === false ? "gu" : "giu";
const list = [];
const lines = String(ctx.settings.patterns ?? "").split(/\r?\n/);
lines.forEach((line, n) => {
if (line.trim() === "") return;
try {
list.push(new RegExp(line.trim(), flags));
} catch (e) {
throw new Error(`格式第 ${n + 1} 行不是有效的正则表达式:${e.message}`);
}
});
if (list.length > MAX_PATTERNS) {
throw new Error(`格式最多 ${MAX_PATTERNS} 行`);
}
compiled = list;
return compiled;
}

export function onReplyText(text, ctx) {
const list = patterns(ctx);
if (list.length === 0) return undefined;
const replacement = String(ctx.settings.replacement ?? "");
if (replacement.length > MAX_REPLACEMENT) {
throw new Error(`替换文字最多 ${MAX_REPLACEMENT} 个字符`);
}
let out = text;
for (const re of list) {
// 用函数而不是字符串作替换:替换文字里的 $& 之类原样输出。匹配到空串的不算命中,
// 否则「a*」这样的格式会在每个字之间插一遍替换文字
out = out.replace(re, (m) => (m === "" ? "" : replacement));
}
return out === text ? undefined : out;
}
91 changes: 91 additions & 0 deletions crates/tw-gateway/src/plugin/defaults/term-unify.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// 统一用词
//
// 把回答里的用词换成统一的写法,例如「登陆」换成「登录」。
//
// 逐段模式:回答照常随流输出。一段文字的末尾恰好是某个原词的开头时(例如收到「请先登」),
// 这几个字先扣住,等下一段到了再判断;这段文字结束时扣住的全部放出。原词有重叠时,
// 长的优先。
//
// 权限:reply.text,只改回答里的文字。
// 设置:替换表,每行一条「原词=新词」。最多 100 条,每个词最多 64 个字符;空行忽略。

export const manifest = {
name: "统一用词",
api: 1,
description: "把回答里的用词换成统一的写法,替换表在设置里每行写一条「原词=新词」。",
permissions: ["reply.text"],
reply: "stream",
settings: {
terms: {
type: "string",
label: "替换表(每行一条:原词=新词)",
default: "登陆=登录\n帐号=账号",
},
},
};

const MAX_TERMS = 100;
const MAX_LENGTH = 64;

// 同一个回答里的几次调用共用一个实例,所以模块里的变量在这个回答里一直有效;
// 回答结束后实例丢弃,下一个回答从头开始
let table = null;
let held = "";

function terms(ctx) {
if (table !== null) return table;
const list = [];
for (const line of String(ctx.settings.terms ?? "").split(/\r?\n/)) {
if (line.trim() === "") continue;
const at = line.indexOf("=");
const from = at < 0 ? "" : line.slice(0, at).trim();
const to = at < 0 ? "" : line.slice(at + 1).trim();
if (from === "") {
throw new Error(`替换表里的「${line.trim()}」不是「原词=新词」的写法`);
}
if (from.length > MAX_LENGTH || to.length > MAX_LENGTH) {
throw new Error(`替换表里的词最多 ${MAX_LENGTH} 个字符`);
}
list.push([from, to]);
}
if (list.length > MAX_TERMS) {
throw new Error(`替换表最多 ${MAX_TERMS} 条`);
}
// 长的原词优先:「登陆页」和「登陆」都在表里时,先认「登陆页」
list.sort((a, b) => b[0].length - a[0].length);
table = list;
return table;
}

// 从头扫到尾。剩下的部分还可能是某个(更长的)原词的开头时先停下,留到下一段再判断 ——
// 「登陆」已经对上、而「登陆页」还差一个字时也要等;否则命中原词就换,长的优先
function convert(text, ctx, last) {
const list = terms(ctx);
let out = "";
let i = 0;
scan: while (i < text.length) {
const rest = text.slice(i);
if (!last && list.some(([from]) => from.length > rest.length && from.startsWith(rest))) {
break;
}
for (const [from, to] of list) {
if (text.startsWith(from, i)) {
out += to;
i += from.length;
continue scan;
}
}
out += text[i];
i += 1;
}
held = text.slice(i);
return out;
}

export function onReplyText(text, ctx) {
return convert(held + text, ctx, false);
}

export function onReplyTextEnd(ctx) {
return convert(held, ctx, true);
}
Loading
Loading