From bbd3ccf864081d59a3d45463f3ede73714af00be Mon Sep 17 00:00:00 2001 From: Abeautifulsnow Date: Tue, 23 Jun 2026 18:25:57 +0800 Subject: [PATCH] fix(ai): add inline proposal confirmation buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .../src/components/editor/AiAssistant.vue | 42 +++++ apps/desktop/src/i18n/locales/en.ts | 3 + apps/desktop/src/i18n/locales/es.ts | 3 + apps/desktop/src/i18n/locales/it.ts | 3 + apps/desktop/src/i18n/locales/ja.ts | 3 + apps/desktop/src/i18n/locales/pt-BR.ts | 3 + apps/desktop/src/i18n/locales/zh-CN.ts | 3 + apps/desktop/src/i18n/locales/zh-TW.ts | 3 + .../lib/__tests__/aiProposalDetect.spec.ts | 143 +++++++++++++++ apps/desktop/src/lib/ai.ts | 1 + apps/desktop/src/lib/aiProposalDetect.ts | 168 ++++++++++++++++++ 11 files changed, 375 insertions(+) create mode 100644 apps/desktop/src/lib/__tests__/aiProposalDetect.spec.ts create mode 100644 apps/desktop/src/lib/aiProposalDetect.ts diff --git a/apps/desktop/src/components/editor/AiAssistant.vue b/apps/desktop/src/components/editor/AiAssistant.vue index e567663174..46d5e3589b 100644 --- a/apps/desktop/src/components/editor/AiAssistant.vue +++ b/apps/desktop/src/components/editor/AiAssistant.vue @@ -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(() => { + 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(() => {
+
+ + +
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index cc63227328..4362d6ee19 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 69cd5f039d..2261d5d2c4 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 9ffd30095c..88199a1b7e 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index fbfdfeccfc..9bf4c6d4ed 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1045,6 +1045,9 @@ export default { truncated: "コンテキストが切り詰められました", contextSummary: "{database} · {tables}テーブル", autoSqlBlocked: "AIが生成したSQLは自動実行するにはリスクが高すぎると判断されました。実行前に手動で確認してください。", + proposalConfirmYes: "はい、続行", + proposalConfirmNo: "いいえ", + proposalConfirmTitle: "操作の確認", agentSteps: { generated: "SQL生成完了", noSql: "SQLが見つかりません", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index cff5291deb..21264f867f 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index e0bb7be7c4..36cbe6d662 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1039,6 +1039,9 @@ export default { truncated: "上下文已截断", contextSummary: "{database} · {tables} 张表", autoSqlBlocked: "AI 生成的 SQL 风险较高,已阻止自动执行,请手动检查后再运行。", + proposalConfirmYes: "是,请继续", + proposalConfirmNo: "不用了", + proposalConfirmTitle: "需要确认是否执行以下操作", agentSteps: { generated: "已生成 SQL", noSql: "未找到 SQL", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 6e131602ab..387ae71c29 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -964,6 +964,9 @@ export default { truncated: "上下文已截斷", contextSummary: "{database} · {tables} 張資料表", autoSqlBlocked: "AI 產生的 SQL 風險較高,已阻止自動執行,請手動檢查後再執行。", + proposalConfirmYes: "是,請繼續", + proposalConfirmNo: "不用了", + proposalConfirmTitle: "需要確認是否執行以下操作", agentSteps: { generated: "已產生 SQL", noSql: "未找到 SQL", diff --git a/apps/desktop/src/lib/__tests__/aiProposalDetect.spec.ts b/apps/desktop/src/lib/__tests__/aiProposalDetect.spec.ts new file mode 100644 index 0000000000..a9804b5eb9 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/aiProposalDetect.spec.ts @@ -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)); +}); diff --git a/apps/desktop/src/lib/ai.ts b/apps/desktop/src/lib/ai.ts index 9e48826937..6654ba1dc3 100644 --- a/apps/desktop/src/lib/ai.ts +++ b/apps/desktop/src/lib/ai.ts @@ -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.", ]; } diff --git a/apps/desktop/src/lib/aiProposalDetect.ts b/apps/desktop/src/lib/aiProposalDetect.ts new file mode 100644 index 0000000000..9873f87105 --- /dev/null +++ b/apps/desktop/src/lib/aiProposalDetect.ts @@ -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)); +}