fix(ai): add inline proposal confirmation buttons
* fix(ai): add quick proposal confirmation buttons (all locales) When the assistant proposes an action, render Yes/No buttons below the message so the user does not need to type a short reply — fixing the issue where short replies caused the LLM to re-ask instead of executing. - aiProposalDetect.ts: pure detectors for proposal sentences/short replies - AiAssistant.vue: inline Yes/No buttons on last assistant proposal message - ai.ts: system-prompt rule to skip re-confirmation on short affirmatives - i18n: 3 keys added to all 7 locales (en/zh-CN/zh-TW/ja/es/it/pt-BR) Tests: 55/55. TypeCheck: passed. * fix(ai): add i18n keys and UI changes for proposal confirmation
This commit is contained in:
1 parent
21ec91a3d5
commit
bbd3ccf864
11 files changed
+375
No files matched your search
@@ -35,6 +35,7 @@ import { parseExplainResult, type ParsedExplainPlan } from "@/lib/explainPlan";
|
||||
import { copyToClipboard } from "@/lib/clipboard";
|
||||
import { formatAiTableMention, parseAiTableMentions, type AiTableMention } from "@/lib/aiTableMentions";
|
||||
import { isAiPromptImeCompositionEvent, shouldSubmitAiPromptOnKeydown } from "@/lib/aiPromptKeyboard";
|
||||
import { looksLikeActionProposal, containsChinese } from "@/lib/aiProposalDetect";
|
||||
|
||||
const { t } = useI18n();
|
||||
const settings = useSettingsStore();
|
||||
@@ -221,6 +222,37 @@ const isWaitingForFirstDelta = computed(() => {
|
||||
return isGenerating.value && last?.role === "assistant" && !last.content && !last.reasoning;
|
||||
});
|
||||
|
||||
/**
|
||||
* The last assistant message whose final line looks like an action
|
||||
* proposal question. Used to render an inline "Yes / No" confirmation bar
|
||||
* so the user can answer without typing. `null` while the assistant is
|
||||
* still generating or when no such message exists.
|
||||
*/
|
||||
const proposalConfirmMessage = computed<ChatMessage | null>(() => {
|
||||
if (isGenerating.value) return null;
|
||||
for (let i = messages.value.length - 1; i >= 0; i--) {
|
||||
const msg = messages.value[i];
|
||||
if (msg.kind === "contextSummary") continue;
|
||||
if (msg.role !== "assistant") return null;
|
||||
if (!msg.content) return null;
|
||||
return looksLikeActionProposal(msg.content) ? msg : null;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
function sendProposalReply(positive: boolean) {
|
||||
// Disable while a stream is in flight or no proposal is currently active.
|
||||
if (isGenerating.value) return;
|
||||
const target = proposalConfirmMessage.value;
|
||||
if (!target) return;
|
||||
const isZh = containsChinese(target.content || "");
|
||||
const replyZh = positive ? "请执行上面你刚提议的操作,不要再反问确认。" : "不用执行上面提到的操作,继续当前对话。";
|
||||
const replyEn = positive ? "Execute the action you just proposed above; do not ask for confirmation again." : "Do not execute the action mentioned above; continue the current conversation.";
|
||||
prompt.value = isZh ? replyZh : replyEn;
|
||||
// Use the existing send pipeline so the message is added to history, persisted, etc.
|
||||
send();
|
||||
}
|
||||
|
||||
const activePlaceholder = computed(() => `${t(`ai.placeholders.${activeAction.value}`)} ${t("ai.tableMentionPlaceholderHint")}`);
|
||||
const activeModeHint = computed(() => t(`ai.modeHints.${assistantMode.value}`));
|
||||
const assistantModeItems = computed(() => [
|
||||
@@ -1106,6 +1138,16 @@ const messageRenderer = computed(() => {
|
||||
<pre class="ai-code-block whitespace-pre-wrap break-words p-3 text-xs leading-relaxed text-zinc-900 dark:text-zinc-100"><code v-html="seg.html"></code></pre>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="msg === proposalConfirmMessage" class="mt-2 flex gap-2" :title="t('ai.proposalConfirmTitle')">
|
||||
<Button size="sm" variant="default" class="h-7 gap-1 text-[11px]" @click="sendProposalReply(true)">
|
||||
<Check class="h-3 w-3" />
|
||||
{{ t("ai.proposalConfirmYes") }}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" class="h-7 gap-1 text-[11px]" @click="sendProposalReply(false)">
|
||||
<X class="h-3 w-3" />
|
||||
{{ t("ai.proposalConfirmNo") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1040,6 +1040,9 @@ export default {
|
||||
truncated: "Context truncated",
|
||||
contextSummary: "{database} · {tables} tables",
|
||||
autoSqlBlocked: "The AI-generated SQL looked too risky to auto-execute. Review it manually before running.",
|
||||
proposalConfirmYes: "Yes, proceed",
|
||||
proposalConfirmNo: "No thanks",
|
||||
proposalConfirmTitle: "Action confirmation required",
|
||||
agentSteps: {
|
||||
generated: "SQL generated",
|
||||
noSql: "No SQL found",
|
||||
|
||||
@@ -916,6 +916,9 @@ export default {
|
||||
truncated: "Contexto truncado",
|
||||
contextSummary: "{database} · {tables} tablas",
|
||||
autoSqlBlocked: "El SQL generado por la IA parecía demasiado riesgoso para ejecutarse automáticamente. Revísalo manualmente antes de ejecutar.",
|
||||
proposalConfirmYes: "Sí, continuar",
|
||||
proposalConfirmNo: "No, gracias",
|
||||
proposalConfirmTitle: "Confirmación de acción requerida",
|
||||
proxy: "Proxy",
|
||||
proxyEnable: "Enviar solicitudes de IA mediante proxy",
|
||||
proxyUrl: "URL del proxy",
|
||||
|
||||
@@ -993,6 +993,9 @@ export default {
|
||||
truncated: "Contesto troncato",
|
||||
contextSummary: "{database} · {tables} tabelle",
|
||||
autoSqlBlocked: "L'SQL generato dall'AI è sembrato troppo rischioso per l'esecuzione automatica. Rivedilo manualmente prima di eseguirlo.",
|
||||
proposalConfirmYes: "Sì, procedi",
|
||||
proposalConfirmNo: "No, grazie",
|
||||
proposalConfirmTitle: "Conferma azione richiesta",
|
||||
agentSteps: {
|
||||
generated: "SQL generato",
|
||||
noSql: "Nessun SQL trovato",
|
||||
|
||||
@@ -1045,6 +1045,9 @@ export default {
|
||||
truncated: "コンテキストが切り詰められました",
|
||||
contextSummary: "{database} · {tables}テーブル",
|
||||
autoSqlBlocked: "AIが生成したSQLは自動実行するにはリスクが高すぎると判断されました。実行前に手動で確認してください。",
|
||||
proposalConfirmYes: "はい、続行",
|
||||
proposalConfirmNo: "いいえ",
|
||||
proposalConfirmTitle: "操作の確認",
|
||||
agentSteps: {
|
||||
generated: "SQL生成完了",
|
||||
noSql: "SQLが見つかりません",
|
||||
|
||||
@@ -993,6 +993,9 @@ export default {
|
||||
truncated: "Contexto truncado",
|
||||
contextSummary: "{database} · {tables} tabelas",
|
||||
autoSqlBlocked: "O SQL gerado pela AI pareceu arriscado demais para execução automática. Revise-o manualmente antes de executar.",
|
||||
proposalConfirmYes: "Sim, continuar",
|
||||
proposalConfirmNo: "Não, obrigado",
|
||||
proposalConfirmTitle: "Confirmação de ação necessária",
|
||||
agentSteps: {
|
||||
generated: "SQL gerado",
|
||||
noSql: "Nenhum SQL encontrado",
|
||||
|
||||
@@ -1039,6 +1039,9 @@ export default {
|
||||
truncated: "上下文已截断",
|
||||
contextSummary: "{database} · {tables} 张表",
|
||||
autoSqlBlocked: "AI 生成的 SQL 风险较高,已阻止自动执行,请手动检查后再运行。",
|
||||
proposalConfirmYes: "是,请继续",
|
||||
proposalConfirmNo: "不用了",
|
||||
proposalConfirmTitle: "需要确认是否执行以下操作",
|
||||
agentSteps: {
|
||||
generated: "已生成 SQL",
|
||||
noSql: "未找到 SQL",
|
||||
|
||||
@@ -964,6 +964,9 @@ export default {
|
||||
truncated: "上下文已截斷",
|
||||
contextSummary: "{database} · {tables} 張資料表",
|
||||
autoSqlBlocked: "AI 產生的 SQL 風險較高,已阻止自動執行,請手動檢查後再執行。",
|
||||
proposalConfirmYes: "是,請繼續",
|
||||
proposalConfirmNo: "不用了",
|
||||
proposalConfirmTitle: "需要確認是否執行以下操作",
|
||||
agentSteps: {
|
||||
generated: "已產生 SQL",
|
||||
noSql: "未找到 SQL",
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { containsChinese, isShortAffirmative, isShortNegative, looksLikeActionProposal } from "@/lib/aiProposalDetect";
|
||||
|
||||
describe("looksLikeActionProposal", () => {
|
||||
// ---- zh-CN proposals ----
|
||||
it("detects zh proposal: 需要我执行这个查询吗?", () => {
|
||||
expect(looksLikeActionProposal("需要我执行这个查询吗?")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects zh proposal: 要不要我拉取这两个表的数据?", () => {
|
||||
expect(looksLikeActionProposal("要不要我拉取这两个表的数据?")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects zh proposal: 是否需要我帮你查一下订单表?", () => {
|
||||
expect(looksLikeActionProposal("是否需要我帮你查一下订单表?")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects zh proposal: 我来执行这个 SQL?", () => {
|
||||
expect(looksLikeActionProposal("我来执行这个 SQL?")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects zh proposal in last line of multi-line message", () => {
|
||||
const multi = "我已经查看了 orders 表的结构。\n现在需要我查看一下 users 表吗?";
|
||||
expect(looksLikeActionProposal(multi)).toBe(true);
|
||||
});
|
||||
|
||||
it("detects zh proposal: 我去看看这两个表的数据?", () => {
|
||||
expect(looksLikeActionProposal("需要我去看看这两个表的数据?")).toBe(true);
|
||||
});
|
||||
|
||||
// ---- en proposals ----
|
||||
it("detects en proposal: Should I execute this query?", () => {
|
||||
expect(looksLikeActionProposal("Should I execute this query?")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects en proposal: Do you want me to fetch the data?", () => {
|
||||
expect(looksLikeActionProposal("Do you want me to fetch the data?")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects en proposal: Shall I run the analysis?", () => {
|
||||
expect(looksLikeActionProposal("Shall I run the analysis?")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects en proposal: Can I pull the sample data?", () => {
|
||||
expect(looksLikeActionProposal("Can I pull the sample data?")).toBe(true);
|
||||
});
|
||||
|
||||
// ---- negative cases (NOT proposals) ----
|
||||
it("rejects generic clarifying question without action", () => {
|
||||
expect(looksLikeActionProposal("请问 orders 表有哪些字段?")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects zh question without ask phrase", () => {
|
||||
expect(looksLikeActionProposal("这个查询的执行结果正确吗?")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects en question without ask phrase", () => {
|
||||
expect(looksLikeActionProposal("What columns does the orders table have?")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects statement ending with period", () => {
|
||||
expect(looksLikeActionProposal("需要我执行这个查询。")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects empty content", () => {
|
||||
expect(looksLikeActionProposal("")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects en question with no actionable verb", () => {
|
||||
expect(looksLikeActionProposal("Should I proceed?")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects zh proposal ending with period", () => {
|
||||
expect(looksLikeActionProposal("需要我查看订单表的数据。")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects message with no question mark", () => {
|
||||
expect(looksLikeActionProposal("需要我执行查询")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects mixed punctuation but not a question", () => {
|
||||
expect(looksLikeActionProposal("可以了,谢谢!")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isShortAffirmative", () => {
|
||||
// zh
|
||||
it("matches 需要", () => expect(isShortAffirmative("需要")).toBe(true));
|
||||
it("matches 好", () => expect(isShortAffirmative("好")).toBe(true));
|
||||
it("matches 好的", () => expect(isShortAffirmative("好的")).toBe(true));
|
||||
it("matches 可以", () => expect(isShortAffirmative("可以")).toBe(true));
|
||||
it("matches 是", () => expect(isShortAffirmative("是")).toBe(true));
|
||||
it("matches 对", () => expect(isShortAffirmative("对")).toBe(true));
|
||||
it("matches 嗯", () => expect(isShortAffirmative("嗯")).toBe(true));
|
||||
|
||||
// zh with punctuation
|
||||
it("strips punctuation: 好!", () => expect(isShortAffirmative("好!")).toBe(true));
|
||||
it("strips punctuation: 好的。", () => expect(isShortAffirmative("好的。")).toBe(true));
|
||||
|
||||
// en
|
||||
it("matches yes", () => expect(isShortAffirmative("yes")).toBe(true));
|
||||
it("matches Yes (capitalized)", () => expect(isShortAffirmative("Yes")).toBe(true));
|
||||
it("matches ok", () => expect(isShortAffirmative("ok")).toBe(true));
|
||||
it("matches OK", () => expect(isShortAffirmative("OK")).toBe(true));
|
||||
it("matches sure", () => expect(isShortAffirmative("sure")).toBe(true));
|
||||
it("matches go ahead", () => expect(isShortAffirmative("go ahead")).toBe(true));
|
||||
it("matches do it", () => expect(isShortAffirmative("do it")).toBe(true));
|
||||
it("matches proceed", () => expect(isShortAffirmative("proceed")).toBe(true));
|
||||
|
||||
// negative
|
||||
it("rejects long message", () => expect(isShortAffirmative("好的,我来看看具体情况")).toBe(false));
|
||||
it("rejects unrelated word", () => expect(isShortAffirmative("其他")).toBe(false));
|
||||
it("rejects empty", () => expect(isShortAffirmative("")).toBe(false));
|
||||
});
|
||||
|
||||
describe("isShortNegative", () => {
|
||||
// zh
|
||||
it("matches 不用", () => expect(isShortNegative("不用")).toBe(true));
|
||||
it("matches 不用了", () => expect(isShortNegative("不用了")).toBe(true));
|
||||
it("matches 不需要", () => expect(isShortNegative("不需要")).toBe(true));
|
||||
it("matches 不要", () => expect(isShortNegative("不要")).toBe(true));
|
||||
it("matches 先不", () => expect(isShortNegative("先不")).toBe(true));
|
||||
|
||||
// zh with punctuation
|
||||
it("strips punctuation: 不用了。", () => expect(isShortNegative("不用了。")).toBe(true));
|
||||
|
||||
// en
|
||||
it("matches no", () => expect(isShortNegative("no")).toBe(true));
|
||||
it("matches nope", () => expect(isShortNegative("nope")).toBe(true));
|
||||
it("matches not needed", () => expect(isShortNegative("not needed")).toBe(true));
|
||||
it("matches don't", () => expect(isShortNegative("don't")).toBe(true));
|
||||
it("matches cancel", () => expect(isShortNegative("cancel")).toBe(true));
|
||||
|
||||
// negative
|
||||
it("rejects long message", () => expect(isShortNegative("不用了,谢谢,我自己来查")).toBe(false));
|
||||
it("rejects empty", () => expect(isShortNegative("")).toBe(false));
|
||||
});
|
||||
|
||||
describe("containsChinese", () => {
|
||||
it("detects Chinese characters", () => expect(containsChinese("需要我执行")).toBe(true));
|
||||
it("rejects pure ASCII", () => expect(containsChinese("Should I execute")).toBe(false));
|
||||
it("handles mixed CJK punctuation-only", () => expect(containsChinese("?。,")).toBe(false));
|
||||
});
|
||||
@@ -191,6 +191,7 @@ function buildBasePromptLines(isZh: boolean): string[] {
|
||||
isZh ? "不要生成多语句 SQL,除非用户明确要求。不要在同一个回答里混合 SELECT 和写操作。" : "Do not generate multi-statement SQL unless the user explicitly asks for it. Do not mix SELECT statements and write operations in the same answer.",
|
||||
isZh ? "对于 DROP、DELETE、TRUNCATE、ALTER 或没有 WHERE 的 UPDATE,简要警告并优先提供安全的 SELECT 预览。" : "For destructive statements (DROP, DELETE, TRUNCATE, ALTER, UPDATE without WHERE), warn briefly and prefer a safer SELECT preview.",
|
||||
isZh ? "对于 UPDATE 或 DELETE,必须带 WHERE 并说明影响范围;生产库写操作只给建议,不主动建议执行。" : "For UPDATE or DELETE, require a WHERE clause and explain the affected scope; for production writes, provide guidance but do not proactively suggest execution.",
|
||||
isZh ? "当用户回复简短肯定词时(例如:需要、好、可以、对),直接执行你之前提议的动作,不要再反问确认。" : "When the user replies with a short affirmative (e.g.: Yes, OK, Sure, Do it), directly execute the action you previously proposed — do not ask for confirmation again.",
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Pure detectors for recognizing AI assistant action proposals and user
|
||||
* short replies (affirmative / negative). Used by the chat UI to render
|
||||
* a quick action confirmation bar instead of requiring the user to type.
|
||||
*
|
||||
* Detectors are intentionally narrow:
|
||||
* - A "proposal" requires both a first-person/asking phrase AND an
|
||||
* actionable intent verb (execute query, fetch data, investigate, etc.)
|
||||
* - Affirmative / negative detectors only match short replies (the user
|
||||
* typing a couple of words). Long replies fall through.
|
||||
*
|
||||
* Side-effect free. Safe to import from anywhere.
|
||||
*/
|
||||
|
||||
/** Trim whitespace, leading/trailing punctuation (CJK + ASCII) for short-reply matching. */
|
||||
function stripOuterPunctuation(input: string): string {
|
||||
return input
|
||||
.replace(/^[\s ]+/, "")
|
||||
.replace(/[\s ]+$/, "")
|
||||
.replace(/^[!?.,;:!?。,;:]+/, "")
|
||||
.replace(/[!?.,;:!?。,;:]+$/, "");
|
||||
}
|
||||
|
||||
/** Returns the last non-empty line/sentence-ish chunk of the assistant message. */
|
||||
function lastNonEmptyLine(content: string): string {
|
||||
const lines = content.split(/\r?\n/);
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const trimmed = lines[i].trim();
|
||||
if (trimmed) return trimmed;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/** True when the string contains at least one CJK ideograph. */
|
||||
export function containsChinese(input: string): boolean {
|
||||
return /[一-鿿]/.test(input);
|
||||
}
|
||||
|
||||
// Phrases that indicate the assistant is asking permission to take an action.
|
||||
const ZH_ASK_PHRASES = [
|
||||
/需要我/, // "do you need me to"
|
||||
/要不要我/, // "shall I"
|
||||
/是否需要/, // "is it needed"
|
||||
/帮我?(?:你)?/, // "shall I help" (loose)
|
||||
/我来/, // "let me"
|
||||
/我帮你/, // "let me help you"
|
||||
/我现在/, // "shall I now"
|
||||
/我可以/, // "may I"
|
||||
/我去/, // "shall I go"
|
||||
/我能/, // "may I"
|
||||
];
|
||||
|
||||
const EN_ASK_PHRASES = [/\bshould\s+i\b/i, /\bdo\s+you\s+want\s+me\s+to\b/i, /\bwould\s+you\s+like\s+me\s+to\b/i, /\bwant\s+me\s+to\b/i, /\bshall\s+i\b/i, /\bcan\s+i\b/i, /\blet\s+me\s+know\s+if\s+you\s+want\s+me\s+to\b/i];
|
||||
|
||||
// Actionable intent verbs/phrases. Must co-occur with an ask phrase to count.
|
||||
const ZH_ACTION_PHRASES = [
|
||||
/执行/, // execute
|
||||
/运行/, // run
|
||||
/查询/, // query
|
||||
/查看/, // view
|
||||
/看看/,
|
||||
/查一下/, // check
|
||||
/拉取/, // fetch
|
||||
/获取/, // get/fetch
|
||||
/读取/, // read
|
||||
/取出?/, // take out / extract
|
||||
/取数据/,
|
||||
/看看数据/,
|
||||
/看一下/,
|
||||
/分析/, // analyze
|
||||
/统计/, // count/aggregate
|
||||
/排查/, // investigate
|
||||
/调查/, // investigate
|
||||
/生成/, // generate
|
||||
/创建/, // create
|
||||
/列出/, // list
|
||||
/对比/, // compare
|
||||
];
|
||||
|
||||
const EN_ACTION_PHRASES = [
|
||||
/\bexecute\b/i,
|
||||
/\brun\b/i,
|
||||
/\bquery\b/i,
|
||||
/\bfetch\b/i,
|
||||
/\bpull\b/i,
|
||||
/\bretrieve\b/i,
|
||||
/\bread\b/i,
|
||||
/\bget\s+(?:the\s+)?(?:data|rows|sample|columns|results)\b/i,
|
||||
/\banalyze\b/i,
|
||||
/\binvestigate\b/i,
|
||||
/\bcheck\b/i,
|
||||
/\binspect\b/i,
|
||||
/\bgenerate\b/i,
|
||||
/\bcreate\b/i,
|
||||
/\blist\b/i,
|
||||
/\bcompare\b/i,
|
||||
/\bcount\b/i,
|
||||
/\bsample\b/i,
|
||||
];
|
||||
|
||||
/**
|
||||
* Detect whether the assistant message ends with a proposal-style question
|
||||
* such as "Should I execute this query?" / "需要我拉取这两个表的数据吗?".
|
||||
*
|
||||
* Heuristic:
|
||||
* - Looks at the last non-empty line.
|
||||
* - Must end with a question mark (Chinese ? or English ?).
|
||||
* - Must contain BOTH a first-person ask phrase AND an actionable intent.
|
||||
*
|
||||
* Returns false for generic clarifying questions (no actionable verb).
|
||||
*/
|
||||
export function looksLikeActionProposal(content: string): boolean {
|
||||
if (!content) return false;
|
||||
const lastLine = lastNonEmptyLine(content);
|
||||
if (!lastLine) return false;
|
||||
|
||||
// Must be a question.
|
||||
if (!/[??]\s*$/.test(lastLine)) return false;
|
||||
|
||||
const isZh = containsChinese(lastLine);
|
||||
const askPhrases = isZh ? ZH_ASK_PHRASES : EN_ASK_PHRASES;
|
||||
const actionPhrases = isZh ? ZH_ACTION_PHRASES : EN_ACTION_PHRASES;
|
||||
|
||||
const hasAsk = askPhrases.some((re) => re.test(lastLine));
|
||||
if (!hasAsk) return false;
|
||||
|
||||
const hasAction = actionPhrases.some((re) => re.test(lastLine));
|
||||
return hasAction;
|
||||
}
|
||||
|
||||
// Short affirmative words/phrases. Must match (after stripping punctuation /
|
||||
// whitespace) the entire reply for it to count — long replies fall through.
|
||||
const ZH_AFFIRMATIVE = [/^需要$/, /^需要的$/, /^要$/, /^要的$/, /^好$/, /^好的$/, /^可以$/, /^行$/, /^是$/, /^是的$/, /^对$/, /^对的$/, /^嗯$/, /^嗯嗯$/, /^OK$/i, /^OKK*$/i, /^没问题$/, /^请$/, /^麻烦了$/, /^来吧$/, /^继续$/];
|
||||
|
||||
const EN_AFFIRMATIVE = [/^yes$/i, /^yeah$/i, /^yep$/i, /^y$/i, /^ok$/i, /^okay$/i, /^k$/i, /^sure$/i, /^go$/i, /^go\s+ahead$/i, /^do\s+it$/i, /^please\s+do$/i, /^please$/i, /^proceed$/i, /^continue$/i, /^confirm(?:ed)?$/i];
|
||||
|
||||
const ZH_NEGATIVE = [/^不$/, /^不用$/, /^不用了$/, /^不需要$/, /^不要$/, /^别$/, /^先不$/, /^先不(?:用|要)$/, /^算了$/, /^不必$/, /^取消$/, /^停$/, /^先别$/];
|
||||
|
||||
const EN_NEGATIVE = [/^no$/i, /^nope$/i, /^nah$/i, /^n$/i, /^don'?t$/i, /^do\s+not$/i, /^not\s+needed$/i, /^skip$/i, /^cancel$/i, /^stop$/i, /^never\s*mind$/i, /^nvm$/i, /^hold\s+off$/i, /^not\s+now$/i];
|
||||
|
||||
/**
|
||||
* Detect "short affirmative" user replies like 需要 / 好 / 可以 / 是 / 对 /
|
||||
* 嗯 / yes / ok / sure / go ahead / do it.
|
||||
*
|
||||
* Long messages (multiple sentences, or words mixed with extra context)
|
||||
* intentionally do NOT match — those should be sent to the LLM normally.
|
||||
*/
|
||||
export function isShortAffirmative(content: string): boolean {
|
||||
const cleaned = stripOuterPunctuation(content);
|
||||
if (!cleaned) return false;
|
||||
// Reject when the reply has multiple words/lines unless still very short
|
||||
// and matches a known phrase (e.g. "go ahead" / "do it" / "好的").
|
||||
if (cleaned.length > 24) return false;
|
||||
const all = [...ZH_AFFIRMATIVE, ...EN_AFFIRMATIVE];
|
||||
return all.some((re) => re.test(cleaned));
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect "short negative" user replies like 不用 / 不需要 / 不要 / no /
|
||||
* nope / don't / not needed.
|
||||
*/
|
||||
export function isShortNegative(content: string): boolean {
|
||||
const cleaned = stripOuterPunctuation(content);
|
||||
if (!cleaned) return false;
|
||||
if (cleaned.length > 24) return false;
|
||||
const all = [...ZH_NEGATIVE, ...EN_NEGATIVE];
|
||||
return all.some((re) => re.test(cleaned));
|
||||
}
|
||||
Reference in new issue
Block a user