* 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
169 lines
6.1 KiB
TypeScript
169 lines
6.1 KiB
TypeScript
/**
|
||
* 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));
|
||
}
|