Files
dbx/apps/desktop/src/lib/aiProposalDetect.ts
T
Abeautifulsnow bbd3ccf864 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
2026-06-23 18:25:57 +08:00

169 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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));
}