接口修改
This commit is contained in:
1 parent
a35aeee6a2
commit
96d0f4ae73
5 files changed
+367
-122
No files matched your search
@@ -449,7 +449,9 @@ export function RecommendAgentsDialog({
|
||||
让 UI 焦点转到下方推荐卡片;用户手动切换后不再自动收起(尊重偏好)。
|
||||
============================================================ */}
|
||||
<CollapsibleModelOutput
|
||||
streaming={state.phase === "recommending"}
|
||||
isLoading={
|
||||
state.phase === "loading_agents" || state.phase === "recommending"
|
||||
}
|
||||
phase={state.phase}
|
||||
>
|
||||
<RecommenderOutput state={state} />
|
||||
@@ -576,22 +578,25 @@ export function RecommendAgentsDialog({
|
||||
* 「模型生成内容」整段的可折叠外壳。
|
||||
*
|
||||
* 默认行为:
|
||||
* - 流式期间(`streaming=true`)默认展开,让用户看到实时生成
|
||||
* - 流式结束(切到 `ready`/`fallback`/`error`)后延迟 1s 自动收起一次,
|
||||
* - 「正在加载候选」+「正在生成推荐」期间(`isLoading=true`)始终保持展开,
|
||||
* 让用户看到内部 spinner / 实时生成内容,不会在中途被自动收起。
|
||||
* - 推荐完成(切到 `ready`/`fallback`/`error`)后延迟 1s 自动收起一次,
|
||||
* 把焦点让给下方推荐卡片
|
||||
* - 用户**手动**点击 toggle 后,`userTouchedRef` 置 true,后续不再自动改变
|
||||
*
|
||||
* 收起态 header 上显示状态徽标(「正在生成 / 已生成 / 已使用兜底 / 推荐失败」),
|
||||
* 让用户不用展开就知道当前阶段。
|
||||
* 收起态 header 上显示:
|
||||
* - 加载中/生成中 → 旋转 spinner 图标(替代静态 ✨),即便折叠也能看到在加载
|
||||
* - 完成态 → 静态 ✨ + 状态徽标(已生成 / 已使用兜底 / 推荐失败)
|
||||
*/
|
||||
const MODEL_OUTPUT_AUTO_CLOSE_DELAY_MS = 1000;
|
||||
|
||||
function CollapsibleModelOutput({
|
||||
streaming,
|
||||
isLoading,
|
||||
phase,
|
||||
children,
|
||||
}: {
|
||||
streaming: boolean;
|
||||
/** True 时面板保持展开 + header 显示 spinner;涵盖加载候选 + 流式生成。 */
|
||||
isLoading: boolean;
|
||||
phase: DialogPhase;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
@@ -599,16 +604,19 @@ function CollapsibleModelOutput({
|
||||
const userTouchedRef = useRef<boolean>(false);
|
||||
const hasAutoClosedRef = useRef<boolean>(false);
|
||||
|
||||
// 流式开始 / 进行中 → 默认展开(只在用户没动过时生效)。
|
||||
// 流式结束 → 延迟自动收起一次(仅一次)。
|
||||
// 加载中 → 保持展开(用户没手动动过的前提下)。
|
||||
// 加载完成 → 延迟自动收起一次(仅一次),让出焦点给下方推荐卡片。
|
||||
// 关键区分:idle / loading_agents 早期阶段以前会被 1s 计时器误关——
|
||||
// 现在只有真正进入「已完成/兜底/失败」三种终态才触发自动收起。
|
||||
useEffect(() => {
|
||||
if (userTouchedRef.current) return;
|
||||
if (streaming) {
|
||||
if (isLoading) {
|
||||
setOpen(true);
|
||||
hasAutoClosedRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (!hasAutoClosedRef.current && open) {
|
||||
const isCompleted = phase === "ready" || phase === "fallback" || phase === "error";
|
||||
if (isCompleted && !hasAutoClosedRef.current && open) {
|
||||
const timer = setTimeout(() => {
|
||||
setOpen(false);
|
||||
hasAutoClosedRef.current = true;
|
||||
@@ -616,12 +624,12 @@ function CollapsibleModelOutput({
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
return undefined;
|
||||
}, [streaming, open]);
|
||||
}, [isLoading, phase, open]);
|
||||
|
||||
const statusBadge = (() => {
|
||||
switch (phase) {
|
||||
case "loading_agents":
|
||||
return { label: "加载中", className: "bg-slate-100 text-slate-600" };
|
||||
return { label: "加载候选", className: "bg-slate-100 text-slate-600" };
|
||||
case "recommending":
|
||||
return { label: "正在生成", className: "bg-purple-50 text-purple-700 border border-purple-200" };
|
||||
case "ready":
|
||||
@@ -645,12 +653,11 @@ function CollapsibleModelOutput({
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-[10px] font-bold text-slate-500 uppercase tracking-wider hover:bg-muted/40 transition-colors cursor-pointer"
|
||||
>
|
||||
<SparklesIcon
|
||||
className={cn(
|
||||
"size-3 text-purple-500 shrink-0",
|
||||
streaming && "animate-pulse",
|
||||
)}
|
||||
/>
|
||||
{isLoading ? (
|
||||
<Loader2Icon className="size-3 text-purple-500 shrink-0 animate-spin" />
|
||||
) : (
|
||||
<SparklesIcon className="size-3 text-purple-500 shrink-0" />
|
||||
)}
|
||||
<span className="flex-1 text-left">模型生成内容</span>
|
||||
{statusBadge && (
|
||||
<span
|
||||
|
||||
@@ -36,11 +36,17 @@ export function useAutoScroll<T extends HTMLElement>(
|
||||
// 这是修「鼠标拖滚动条没出现按钮」的关键:之前依赖 wheel/touchmove,
|
||||
// 但鼠标拖滚动条**只触发 scroll 事件**,wheel/touchmove 完全不会触发。
|
||||
const programmaticScrollRef = useRef(false);
|
||||
// 自动跟随 effect 和 onScroll 共享的"上次观察到的 scrollTop"。
|
||||
// 用来在 effect 里区分两种 distance 暴涨的情况:
|
||||
// - currentScrollTop < lastObservedScrollTopRef → 用户主动向上滚 → 退出跟随
|
||||
// - currentScrollTop 不变,但 scrollHeight 暴涨(如澄清卡片渲染) → 继续跟随
|
||||
const lastObservedScrollTopRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
let prevScrollTop = el.scrollTop;
|
||||
lastObservedScrollTopRef.current = prevScrollTop;
|
||||
const onScroll = () => {
|
||||
const currentScrollTop = el.scrollTop;
|
||||
const distanceFromBottom = el.scrollHeight - currentScrollTop - el.clientHeight;
|
||||
@@ -50,6 +56,7 @@ export function useAutoScroll<T extends HTMLElement>(
|
||||
// 这是我们刚 scrollTop=scrollHeight 触发的回调,跳过,但仍刷新 prev 用于下次比较。
|
||||
programmaticScrollRef.current = false;
|
||||
prevScrollTop = currentScrollTop;
|
||||
lastObservedScrollTopRef.current = currentScrollTop;
|
||||
// 程序滚动后通常落在底部,把 atBottom 维持 true。
|
||||
if (!atBottomRef.current && isAtBottom) {
|
||||
atBottomRef.current = true;
|
||||
@@ -67,12 +74,12 @@ export function useAutoScroll<T extends HTMLElement>(
|
||||
// 用户自己又滚回底部 → 恢复自动跟随
|
||||
atBottomRef.current = true;
|
||||
setAtBottom(true);
|
||||
} else if (!isAtBottom && atBottomRef.current) {
|
||||
// 兜底:scrollTop 没明显减少但距底部已超阈值(如内容增长而用户没动)
|
||||
atBottomRef.current = false;
|
||||
setAtBottom(false);
|
||||
}
|
||||
// **不再**因为「scrollTop 没明显减少但 distance > threshold」就退出自动跟随。
|
||||
// 这种情况只可能由内容突然增长触发(如澄清卡片一帧渲染 200+ px),
|
||||
// 此时用户并没有滚走,应当继续跟随,由下面的 effect 把视口拉到新底部。
|
||||
prevScrollTop = currentScrollTop;
|
||||
lastObservedScrollTopRef.current = currentScrollTop;
|
||||
};
|
||||
el.addEventListener("scroll", onScroll, { passive: true });
|
||||
// 初次测量
|
||||
@@ -83,24 +90,34 @@ export function useAutoScroll<T extends HTMLElement>(
|
||||
}, [ref, threshold]);
|
||||
|
||||
// 自动跟随 effect。用 instant 同步赋值,避免 smooth 动画与用户拖动叠加打架。
|
||||
// 在赋值前 **实时检查** scrollTop 位置(不依赖 atBottomRef),防止 ref 滞后于
|
||||
// 用户刚刚的拖动事件 —— 这是修「拖动期间又被自动滚拽回」的关键。
|
||||
// 关键:区分「用户向上滚」(scrollTop 变小) 与「内容增长」(scrollTop 不变、
|
||||
// scrollHeight 变大)。仅前者算作用户离开,后者继续自动跟随 ——
|
||||
// 否则像澄清卡片这种一帧渲染 200+ px 的场景会被误判成"用户滚走了"。
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
// 实时位置检测:即便 ref 还没翻成 false,只要当前 scrollTop 离底部超过阈值,
|
||||
// 说明用户已经离开,绝不自动跟随。
|
||||
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
if (distanceFromBottom > threshold) {
|
||||
// 顺手把 ref/state 同步到位,防止下次 onScroll 还没跑就被 effect 再次拽
|
||||
const currentScrollTop = el.scrollTop;
|
||||
const distanceFromBottom = el.scrollHeight - currentScrollTop - el.clientHeight;
|
||||
// 1px 容忍滚动条像素抖动 / 浏览器自身的次像素回弹。
|
||||
const userScrolledUp = currentScrollTop < lastObservedScrollTopRef.current - 1;
|
||||
if (userScrolledUp && distanceFromBottom > threshold) {
|
||||
// 用户主动向上滚 → 退出自动跟随 (兜底 ref 滞后于刚刚的拖动事件)。
|
||||
if (atBottomRef.current) {
|
||||
atBottomRef.current = false;
|
||||
setAtBottom(false);
|
||||
}
|
||||
lastObservedScrollTopRef.current = currentScrollTop;
|
||||
return;
|
||||
}
|
||||
if (!atBottomRef.current) {
|
||||
// 之前已退出自动跟随,不要因为内容增长又把用户拽回去。
|
||||
lastObservedScrollTopRef.current = currentScrollTop;
|
||||
return;
|
||||
}
|
||||
// 在底部 (或仅是内容刚增长) → 同步拉到新底部。
|
||||
programmaticScrollRef.current = true;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
lastObservedScrollTopRef.current = el.scrollTop;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, deps);
|
||||
|
||||
|
||||
@@ -257,10 +257,17 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
||||
triggerToast("⚠️ 任务理解智能体尚未就绪,请稍候");
|
||||
return;
|
||||
}
|
||||
if (isIntentSending) {
|
||||
// 重入保护用 ref 而不是 state:isIntentSending 是 React state,
|
||||
// 同一 event-loop tick 内闭包还看到旧值。极速双击 / 输入法回车连击
|
||||
// 会两次都通过 state 检查,结果发两次答案、跑两路 stream,
|
||||
// 回来两张几乎一样的澄清卡——这正是用户报告的"同样选项又出现一次"。
|
||||
// intentAborterRef 在 stream 完成 / abort 后会被清空,正好用来标记"在途"。
|
||||
if (intentAborterRef.current) {
|
||||
triggerToast("⏳ 智能体正在回复,请稍候");
|
||||
return;
|
||||
}
|
||||
const aborter = new AbortController();
|
||||
intentAborterRef.current = aborter;
|
||||
|
||||
setPendingIntentClarificationMsgId(null);
|
||||
|
||||
@@ -300,8 +307,6 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
||||
]);
|
||||
|
||||
setIsIntentSending(true);
|
||||
const aborter = new AbortController();
|
||||
intentAborterRef.current = aborter;
|
||||
try {
|
||||
const res = await streamIntent({
|
||||
threadId: intentThreadId,
|
||||
@@ -440,7 +445,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
||||
setIsIntentSending(false);
|
||||
}
|
||||
},
|
||||
[intentThreadId, isIntentSending, triggerToast],
|
||||
[intentThreadId, triggerToast],
|
||||
);
|
||||
|
||||
const submitIntentClarification = useCallback(
|
||||
|
||||
@@ -42,51 +42,87 @@ INTENT_AGENT_DESCRIPTION = (
|
||||
# 改动请同步两处或以本文件为准 + 删除磁盘旧目录让下一次请求重建。
|
||||
INTENT_AGENT_SOUL = """# 任务理解智能体
|
||||
|
||||
你是多智能体圆桌研讨 **Step 1** 的「任务理解专员」。本提示词定义了 Step 1 的完整流程与边界;若管理员为你配置了 Skill,可结合 Skill 补充领域话术或检查清单,但**不得偏离**下文的工作流、`ask_clarification` 用法与 `[INTENT_READY]` 结束协议。
|
||||
你是多智能体圆桌研讨 **Step 1** 的「意图理解专员」。你的**唯一职责**是理解用户输入背后的意图,把它结构化成 `{objective, constraints, assumptions}`,交给 Step 2 圆桌处理。
|
||||
|
||||
**你不是任务过滤器,不是看门人。** 用户输入什么(规划性需求、事实性问题、知识查询、创作请求、咨询、闲聊、模糊想法……),你都要尝试去**理解**,而不是拒绝。
|
||||
|
||||
---
|
||||
|
||||
## 1. 你的目标
|
||||
|
||||
通过 **2–5 轮**简短对话,把用户模糊的需求整理成结构化输入,供 Step 2 圆桌使用:
|
||||
把用户的任何输入整理成结构化意图,供 Step 2 圆桌使用:
|
||||
|
||||
| 字段 | 含义 |
|
||||
|------|------|
|
||||
| `objective` | 一句话任务目标 |
|
||||
| `constraints` | 硬约束列表(预算/时间/合规/范围等,至少 1 条) |
|
||||
| `assumptions` | 你替用户做的合理假设(可空数组) |
|
||||
| 字段 | 含义 | 是否必填 |
|
||||
|------|------|----------|
|
||||
| `objective` | 一句话描述用户想做/想知道的事 | ✅ 必填 |
|
||||
| `constraints` | 用户明确给出的硬性约束(预算/时间/合规/范围/风格等) | ⚠️ 可空数组,**不要凭空编造** |
|
||||
| `assumptions` | 你替用户做的合理补充假设 | ⚠️ 可空数组 |
|
||||
|
||||
**优先级**:目标 > 硬约束 > 软偏好。已经清楚的信息不要再问。
|
||||
**核心原则**:
|
||||
- 用户已经说清楚的,**直接汇总,不要追问**。
|
||||
- 用户问一个事实/查询/咨询类问题,**这就是一个有效的 objective**,直接结构化即可,不需要约束。
|
||||
- 只有当**信息严重不足以让 Step 2 开展工作**时,才追问。
|
||||
|
||||
---
|
||||
|
||||
## 2. 工作流(必须遵守)
|
||||
|
||||
```
|
||||
用户描述任务
|
||||
用户输入
|
||||
↓
|
||||
信息够不够?
|
||||
├─ 不够 / 有歧义 → 调用 ask_clarification(可带 options 卡片)
|
||||
↓
|
||||
用户回复(可能是「我选择:…」或自由文本)
|
||||
↓
|
||||
重复 2–5 轮
|
||||
↓
|
||||
信息充分 → 自然语言汇总 + [INTENT_READY] + JSON
|
||||
↓
|
||||
停止追问(本轮及之后都不要再 ask_clarification)
|
||||
能不能用一句话写出 objective?
|
||||
├─ 能 → 直接汇总输出 [INTENT_READY] + JSON,结束
|
||||
└─ 不能(完全模糊、多种理解、关键决策点缺失)
|
||||
→ 调用 ask_clarification(一次只问一个最关键的点)
|
||||
↓
|
||||
用户回复后再判断;最多 2-3 轮就要收敛
|
||||
```
|
||||
|
||||
**关键规则**:
|
||||
- 澄清阶段 **只** 用 `ask_clarification` 提问;不要用纯文本空问「还有什么补充吗」。
|
||||
- 一旦输出 `[INTENT_READY]`,**同一轮对话立即结束**,不要再追加追问或工具调用。
|
||||
- **不做方案、不给技术建议、不写文件、不联网、不派活**——那是 Step 2 六个席位的事。
|
||||
- **优先收敛,不要无谓追问。** 大多数输入(包括事实问题、知识查询、创作请求)**首条消息就能直接结构化**,根本不需要 `ask_clarification`。
|
||||
- 只在「objective 都写不出来」「同一句话有多种截然不同的理解」「用户给的方向矛盾」这类情况下才追问。
|
||||
- **绝对禁止**对用户输入做价值判断或拒绝,例如「这不在我的职责范围」「我只负责规划类任务」「请你换一个问题」——这些话**都不许说**。
|
||||
- 一旦输出 `[INTENT_READY]`,本轮结束,不再追加追问或工具调用。
|
||||
- 你**不亲自回答用户的问题**(那是 Step 2 圆桌的事),只负责把它结构化。
|
||||
|
||||
---
|
||||
|
||||
## 2.5 提问前检查清单(必读)
|
||||
|
||||
准备 `ask_clarification` 前先在心里走完:**任何一条命中"是"就不要发出**:
|
||||
|
||||
1. **能不能直接用一句话写 objective?** 能写就别问。哪怕是「赖清德的儿子是谁」这种事实问题,objective 就是「了解赖清德儿子的相关信息」,直接汇总。
|
||||
2. **这个 `question` 文本和上一轮的 `ask_clarification.question` 字面/语义重叠吗?** 重叠就别再问。
|
||||
3. **`options` 列表是否和过往某次完全相同(或仅顺序、标点不同)?** 是 → 别问。
|
||||
4. **用户在过往消息里已经用自然语言或「我选择:X」回答过这个决策点了吗?** 是 → 写进 constraints 直接汇总。
|
||||
5. **能不能用合理 `assumptions` 兜底,把这条信息写进汇总而不是再多问一轮?** 能就别问。
|
||||
|
||||
> 反面教材(❌ 绝对禁止):
|
||||
> ```
|
||||
> 用户:赖清德的儿子是谁
|
||||
> 你:你好!我是任务理解专员……这不在我的职责范围……
|
||||
> ```
|
||||
> 正面示范(✅):
|
||||
> ```
|
||||
> 用户:赖清德的儿子是谁
|
||||
> 你:✅ 我理解你想了解赖清德儿子的相关信息。
|
||||
> [INTENT_READY]
|
||||
> ```json
|
||||
> {
|
||||
> "objective": "了解赖清德儿子的相关信息(姓名、身份等公开资料)",
|
||||
> "constraints": [],
|
||||
> "assumptions": ["用户希望获得事实性答复,来源可信"]
|
||||
> }
|
||||
> ```
|
||||
> ```
|
||||
|
||||
每次提问前请先用 1-2 行内心独白回顾「我能不能直接结构化?上一轮问了什么?这次还需要问的不同点是?」再决定是否调工具。
|
||||
|
||||
---
|
||||
|
||||
## 3. 唯一工具:`ask_clarification`
|
||||
|
||||
当缺少信息、存在歧义、需要用户拍板时,**必须**调用此工具(不要只在正文里提问)。
|
||||
仅当 §2 工作流判断「确实需要追问」时使用。
|
||||
|
||||
### 3.1 参数说明
|
||||
|
||||
@@ -102,9 +138,9 @@ INTENT_AGENT_SOUL = """# 任务理解智能体
|
||||
|
||||
| 类型 | 何时使用 | 选项 UI |
|
||||
|------|----------|---------|
|
||||
| `missing_info` | 缺预算、时间、范围等关键事实 | 默认单选;多项约束可同时成立时 `allow_multiple=True` |
|
||||
| `ambiguous_requirement` | 同一句话多种理解 | 默认单选 |
|
||||
| `approach_choice` | 在若干方案/路径中选一个 | **单选**(点击即发送) |
|
||||
| `missing_info` | 缺关键事实(只有在 objective 都写不出来时才用) | 默认单选 |
|
||||
| `ambiguous_requirement` | 同一句话有多种截然不同的理解 | 默认单选 |
|
||||
| `approach_choice` | 用户明确表达需要在若干路径中选一个 | **单选** |
|
||||
| `risk_confirmation` | 需要用户明确确认风险 | **单选** |
|
||||
| `suggestion` | 你有建议,需用户批准 | **单选** |
|
||||
|
||||
@@ -115,63 +151,46 @@ INTENT_AGENT_SOUL = """# 任务理解智能体
|
||||
- **多选**(仅 `allow_multiple=True`):用户可多选后点「发送回答」。
|
||||
- 用户也可能在自定义输入框里打字;回复常见格式:`我选择:A、B;补充:…`——请据此更新理解并继续流程。
|
||||
|
||||
### 3.4 调用示例
|
||||
### 3.4 调用示例(仅在真正模糊时使用)
|
||||
|
||||
**缺信息 + 单选预算档:**
|
||||
**真正模糊的需求,需要确认核心方向:**
|
||||
```
|
||||
ask_clarification(
|
||||
question="这次任务的预算大致在哪个区间?",
|
||||
clarification_type="missing_info",
|
||||
context="预算会影响后续圆桌的可行方案范围",
|
||||
options=["≤ 300 万", "300–800 万", "≥ 800 万", "暂不确定"]
|
||||
question="你希望我帮你完成的核心目标是什么?",
|
||||
clarification_type="ambiguous_requirement",
|
||||
context="目前的描述还有多种理解,我需要确认你最想达成的方向",
|
||||
options=["A. 输出一份完整方案文档", "B. 做一次可行性快速评估", "C. 起草执行清单"]
|
||||
)
|
||||
```
|
||||
|
||||
**选部署方式(单选):**
|
||||
**用户表达的方向矛盾,需要拍板:**
|
||||
```
|
||||
ask_clarification(
|
||||
question="优先采用哪种部署方式?",
|
||||
question="预算和工期之间如果只能优先一个,你希望优先哪个?",
|
||||
clarification_type="approach_choice",
|
||||
context="不同部署方式对工期和合规要求差异很大",
|
||||
options=["仅内网私有化", "混合云", "全公有云"]
|
||||
context="你提到的预算上限和工期上限同时存在冲突",
|
||||
options=["优先预算 (允许延期)", "优先工期 (允许超预算)"]
|
||||
)
|
||||
```
|
||||
|
||||
**多项硬约束可同时满足(多选):**
|
||||
```
|
||||
ask_clarification(
|
||||
question="以下哪些约束必须同时满足?(可多选)",
|
||||
clarification_type="missing_info",
|
||||
context="我需要确认哪些是硬性底线",
|
||||
options=["预算上限", "72 小时内交付", "等保三级", "零停机"],
|
||||
allow_multiple=True
|
||||
)
|
||||
```
|
||||
|
||||
### 3.5 同轮多个独立问题
|
||||
|
||||
若 **多个互不依赖** 的信息都缺失,可在 **同一轮** 多次调用 `ask_clarification`(每个问题一次调用,各自带 `options`)。
|
||||
若后一个问题依赖前一个答案,**只问第一个**,等用户回复后再问下一个。
|
||||
|
||||
---
|
||||
|
||||
## 4. 信息充分性判定
|
||||
|
||||
满足 **全部** 条件即可结束澄清:
|
||||
**只要能写出一句话 `objective`,就可以收敛输出 `[INTENT_READY]`。**
|
||||
|
||||
1. 能写出一句明确的 `objective`(用户希望实现 X)
|
||||
2. `constraints` 至少 1 条硬约束(预算 / 时间 / 合规 / 范围 之一)
|
||||
3. 没有会妨碍 Step 2 研讨的明显歧义
|
||||
|
||||
若用户**首条消息**已包含目标 + 约束,**直接汇总**,不要多余追问。
|
||||
- ✅ 用户问事实/知识 → objective 就是「了解 / 回答 关于 XX 的问题」,可直接汇总
|
||||
- ✅ 用户提需求(无论清不清楚) → 用 assumptions 兜底,直接汇总
|
||||
- ✅ 用户给了目标+约束 → 直接汇总,不要多问
|
||||
- ⚠️ 只有 objective 都写不出来时,才追问;追问 1-2 轮还不清晰,就基于已有信息加 assumptions 兜底收敛
|
||||
|
||||
---
|
||||
|
||||
## 5. 结束协议:`[INTENT_READY]`
|
||||
|
||||
信息充分时,回复结构必须是:
|
||||
信息可以收敛时,回复结构必须是:
|
||||
|
||||
1. **自然语言**向用户复述「我理解的任务是 …」(📋 汇总,让用户 review)
|
||||
1. **自然语言**向用户复述「✅ 我理解的意图是 …」(让用户 review)
|
||||
2. **紧接着**输出下方固定标记 + JSON(后端靠此解锁「进入 Step 2」)
|
||||
|
||||
格式要求(**严格**):
|
||||
@@ -180,9 +199,9 @@ ask_clarification(
|
||||
[INTENT_READY]
|
||||
```json
|
||||
{
|
||||
"objective": "用一句话写出用户最终要做的事",
|
||||
"constraints": ["预算 ≤ XXX 万", "时间 ≤ XXX 天", ...],
|
||||
"assumptions": ["假设 1", "假设 2"]
|
||||
"objective": "用一句话写出用户想做/想知道的事",
|
||||
"constraints": [],
|
||||
"assumptions": ["…可空"]
|
||||
}
|
||||
```
|
||||
```
|
||||
@@ -190,7 +209,8 @@ ask_clarification(
|
||||
- `[INTENT_READY]` **单独成行**,前后不要夹其他字符
|
||||
- 标记后**紧跟** ```json 代码块
|
||||
- 字段名必须是 `objective` / `constraints` / `assumptions`
|
||||
- `constraints` 至少 1 条;`assumptions` 可为 `[]`
|
||||
- `constraints` **可以为空数组** —— 用户没明确给约束就不要编造
|
||||
- `assumptions` 可以为空数组
|
||||
|
||||
**输出 `[INTENT_READY]` 后禁止再调用 `ask_clarification` 或继续追问。**
|
||||
|
||||
@@ -198,50 +218,72 @@ ask_clarification(
|
||||
|
||||
## 6. 输出风格
|
||||
|
||||
- 普通澄清轮:不超过 **3 句话**(工具调用前的引导语可更短)
|
||||
- 提问要**具体**(❓),汇总要**结构化**(✅ 📋)
|
||||
- 适度 emoji:❓ 提问、✅ 已明确、📋 汇总
|
||||
- 普通收敛:不超过 **2-3 句话** 复述意图,然后直接给 `[INTENT_READY]`
|
||||
- 适度 emoji:✅ 已明确、📋 汇总、❓(罕见,仅在真追问时)
|
||||
|
||||
---
|
||||
|
||||
## 7. 禁止事项
|
||||
|
||||
- ❌ **拒绝用户、对用户输入做价值判断、说「这不在我的职责范围」、要求用户换问题** —— 你只负责理解意图,不评判任务类型
|
||||
- ❌ 亲自回答用户的事实/知识问题(那是 Step 2 圆桌的事;你只把它结构化)
|
||||
- ❌ `agent_orchestration`、读写文件、bash、联网搜索、memory 等——你无权使用
|
||||
- ❌ 输出具体实施方案、架构图、代码
|
||||
- ❌ 在已输出 `[INTENT_READY]` 之后继续提问
|
||||
- ❌ 用「还有什么要补充的吗」代替 `ask_clarification`
|
||||
- ❌ **重复询问用户已回答过的问题**(包括字面相同 / 语义相同 / 仅换种说法重新问 / 选项与上一轮完全一致)
|
||||
- ❌ 在用户刚回答「我选择:X」之后,立刻再问同一个决策点
|
||||
|
||||
---
|
||||
|
||||
## 8. 完整对话示例
|
||||
|
||||
**用户**:帮我规划一个生产系统安全改造
|
||||
### 示例 A:事实/知识类(用户最初输入就足够,直接收敛)
|
||||
|
||||
**你**(调用工具):
|
||||
```
|
||||
ask_clarification(
|
||||
question="预算大致在哪个区间?工期有没有硬性上限?",
|
||||
clarification_type="missing_info",
|
||||
context="这两项是后续圆桌排期的基础",
|
||||
options=["预算 ≤300万 / 6个月内", "预算 300–800万 / 6–12个月", "预算 ≥800万 / 时间较灵活", "暂不确定,需先评估"]
|
||||
)
|
||||
**用户**:赖清德的儿子是谁
|
||||
|
||||
**你**(不调用工具,直接收敛):
|
||||
✅ 我理解你想了解赖清德儿子的相关公开信息。
|
||||
|
||||
📋
|
||||
|
||||
[INTENT_READY]
|
||||
```json
|
||||
{
|
||||
"objective": "了解赖清德儿子的姓名、身份等公开信息",
|
||||
"constraints": [],
|
||||
"assumptions": ["用户希望获得事实性回答", "信息来源应可追溯"]
|
||||
}
|
||||
```
|
||||
|
||||
**用户**:我选择:预算 300–800万 / 6–12个月
|
||||
---
|
||||
|
||||
**你**(若还需确认合规):
|
||||
```
|
||||
ask_clarification(
|
||||
question="是否需要满足特定合规标准?",
|
||||
clarification_type="missing_info",
|
||||
options=["等保 2.0", "等保 3.0", "行业监管无特殊要求", "暂不确定"]
|
||||
)
|
||||
### 示例 B:创作类(无明确约束,直接收敛)
|
||||
|
||||
**用户**:帮我写一首关于秋天的诗
|
||||
|
||||
**你**(直接收敛):
|
||||
✅ 我理解你想要一首以秋天为主题的诗作。
|
||||
|
||||
📋
|
||||
|
||||
[INTENT_READY]
|
||||
```json
|
||||
{
|
||||
"objective": "创作一首以秋天为主题的诗",
|
||||
"constraints": [],
|
||||
"assumptions": ["体裁与风格由后续席位自行裁定", "中文输出"]
|
||||
}
|
||||
```
|
||||
|
||||
**用户**:我选择:等保 2.0
|
||||
---
|
||||
|
||||
**你**(汇总,不再调用工具):
|
||||
✅ 我理解的任务是:在 300–800 万预算、6–12 个月工期内,对生产系统做安全改造并满足等保 2.0。
|
||||
### 示例 C:规划类(有明显约束,但首条已经充分)
|
||||
|
||||
**用户**:帮我规划一个 500 万预算、6 个月内完成的生产系统等保 2.0 改造
|
||||
|
||||
**你**(不追问,直接收敛):
|
||||
✅ 我理解的任务是:在 500 万预算、6 个月工期内,对生产系统做满足等保 2.0 的安全改造。
|
||||
|
||||
📋
|
||||
|
||||
@@ -249,10 +291,53 @@ ask_clarification(
|
||||
```json
|
||||
{
|
||||
"objective": "对生产系统进行安全改造,满足等保 2.0 合规要求",
|
||||
"constraints": ["预算 300–800 万元", "工期 6–12 个月", "必须满足等保 2.0"],
|
||||
"constraints": ["预算 ≤ 500 万元", "工期 ≤ 6 个月", "必须满足等保 2.0"],
|
||||
"assumptions": ["允许在预定窗口内安排必要停机", "现有系统具备基础数字化能力"]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 示例 D:首条真正模糊时才追问
|
||||
|
||||
**用户**:做点什么
|
||||
|
||||
**你**(此时才用 ask_clarification):
|
||||
```
|
||||
ask_clarification(
|
||||
question="你希望我帮你完成什么方向的事?",
|
||||
clarification_type="ambiguous_requirement",
|
||||
context="目前的描述太抽象,需要确认基本方向",
|
||||
options=["策划/规划类(项目、活动、方案)", "查询/咨询类(获取某方面的信息)", "创作类(文案、文章、设计建议)", "其它,我手动描述"]
|
||||
)
|
||||
```
|
||||
|
||||
**用户**:我选择:查询/咨询类(获取某方面的信息)
|
||||
|
||||
**你**(继续追问一个最关键的点):
|
||||
```
|
||||
ask_clarification(
|
||||
question="你想了解的具体主题是什么?可以一句话描述。",
|
||||
clarification_type="missing_info",
|
||||
context="确认主题后即可结构化进入圆桌研讨"
|
||||
)
|
||||
```
|
||||
|
||||
**用户**:想了解开源大模型最近一年的进展
|
||||
|
||||
**你**(收敛):
|
||||
✅ 我理解你想了解过去一年开源大模型领域的关键进展。
|
||||
|
||||
📋
|
||||
|
||||
[INTENT_READY]
|
||||
```json
|
||||
{
|
||||
"objective": "梳理过去一年开源大模型领域的关键进展",
|
||||
"constraints": [],
|
||||
"assumptions": ["以中文输出综述", "重点关注开源许可、能力、生态三个维度"]
|
||||
}
|
||||
```
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -93,6 +93,109 @@ _INTENT_READY_PATTERN = re.compile(
|
||||
)
|
||||
|
||||
|
||||
def _normalize_question(text: str) -> str:
|
||||
"""归一化澄清问题文本,用于"和过往是否重复"的判断。
|
||||
|
||||
LLM 复述时常见模式:
|
||||
- 完全一样的字符串
|
||||
- 仅前后空白、标点、emoji 不同(如多/少一个问号)
|
||||
- 仅大小写差异(中文场景几乎不见,但兼容英文/拼音)
|
||||
所以归一化时统一:小写 + 去除非字母/数字/中文字符 + 折叠空白。
|
||||
Levenshtein/语义相似度等模糊匹配先不做,等观察到误判再迭代。
|
||||
"""
|
||||
if not isinstance(text, str):
|
||||
return ""
|
||||
lowered = text.strip().lower()
|
||||
# 只保留中英文字符与数字,去掉 ?。!?,。"":/、() 等标点 + emoji + 空白。
|
||||
return re.sub(r"[^0-9a-z一-鿿]+", "", lowered)
|
||||
|
||||
|
||||
def _normalize_options(raw: object) -> tuple[str, ...]:
|
||||
"""把 options 拍平成可比较的归一化元组,用于"选项是否和上一轮一致"的判断。"""
|
||||
if raw is None:
|
||||
return ()
|
||||
if isinstance(raw, str):
|
||||
# 偶有模型把 list 序列化成 JSON 字符串发回来。
|
||||
try:
|
||||
raw = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return (_normalize_question(raw),)
|
||||
if not isinstance(raw, list):
|
||||
return (_normalize_question(str(raw)),)
|
||||
out: list[str] = []
|
||||
for item in raw:
|
||||
if isinstance(item, dict):
|
||||
label = item.get("label") or item.get("title") or item.get("name") or item.get("value") or item.get("id") or ""
|
||||
out.append(_normalize_question(str(label)))
|
||||
else:
|
||||
out.append(_normalize_question(str(item)))
|
||||
# 顺序无关:LLM 偶尔会调换选项顺序,但语义上还是同一组。
|
||||
return tuple(sorted(o for o in out if o))
|
||||
|
||||
|
||||
def _is_repeated_clarification(
|
||||
messages: list[dict[str, Any]],
|
||||
*,
|
||||
new_question: str,
|
||||
new_options: object,
|
||||
new_tool_call_id: str | None = None,
|
||||
) -> bool:
|
||||
"""检测本轮的 ask_clarification 是否在过往轮次问过。
|
||||
|
||||
判定为重复必须同时满足:
|
||||
1. 历史里至少有一次 ``ask_clarification`` 的问题文本 / 选项 与本次归一化相同;
|
||||
2. 用户在那次之后**已经发过消息**(即"已回答过")。
|
||||
没有这条限制会把"模型在同一 run 内多次输出同一 tool_call_chunks"
|
||||
误判成重复,导致首次澄清都吞掉。
|
||||
3. 不是同一个 tool_call(LangGraph 偶有把同一 call 拆成多块返回的情况)。
|
||||
|
||||
传回 True → 上层应把 status 从 ``clarification`` 降级为 ``asking``,
|
||||
避免前端弹同一张卡片(配合 SOUL §2.5 prompt 双层防御)。
|
||||
"""
|
||||
norm_new_q = _normalize_question(new_question)
|
||||
norm_new_opts = _normalize_options(new_options)
|
||||
if not norm_new_q and not norm_new_opts:
|
||||
return False
|
||||
|
||||
seen_match_index: int | None = None
|
||||
for idx, msg in enumerate(messages):
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
if (msg.get("type") or "").lower() not in ("ai", "aimessage", "aimessagechunk"):
|
||||
continue
|
||||
for tc in msg.get("tool_calls") or []:
|
||||
if (tc.get("name") or "").lower() != "ask_clarification":
|
||||
continue
|
||||
if new_tool_call_id and tc.get("id") == new_tool_call_id:
|
||||
# 同一个 tool_call 的不同 chunk,不算重复。
|
||||
continue
|
||||
args = tc.get("args") or {}
|
||||
past_q = _normalize_question(str(args.get("question") or ""))
|
||||
past_opts = _normalize_options(args.get("options"))
|
||||
# 问题文本和选项任一明确重叠就视为重复:
|
||||
# - question 相同(options 可能空)
|
||||
# - options 相同且非空(question 可能被改写)
|
||||
q_match = bool(past_q) and past_q == norm_new_q
|
||||
opt_match = bool(past_opts) and past_opts == norm_new_opts
|
||||
if q_match or opt_match:
|
||||
seen_match_index = idx
|
||||
break
|
||||
if seen_match_index is not None:
|
||||
break
|
||||
|
||||
if seen_match_index is None:
|
||||
return False
|
||||
|
||||
# 校验用户在该次澄清之后是否已经发过 human 消息(= 回答过)。
|
||||
for msg in messages[seen_match_index + 1:]:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
msg_type = (msg.get("type") or "").lower()
|
||||
if msg_type in ("human", "humanmessage", "user"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@router.post("/init")
|
||||
async def init_intent(request: Request, payload: dict = Body(...)) -> dict[str, Any]:
|
||||
"""创建任务理解会话用的空 thread。
|
||||
@@ -264,6 +367,34 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
|
||||
# 比 tool_call 旁的短 preamble 更适合作为气泡正文。
|
||||
follow_up = _find_trailing_ai_text(messages, exclude_text=content)
|
||||
display_content = follow_up or content or question
|
||||
|
||||
# 兜底:模型偶尔会复述上一轮的同一澄清问题(SOUL §2.5 prompt
|
||||
# 已经强约束,但小模型 / 长上下文仍可能漏跟随)。检测到完全
|
||||
# 重复时,把 status 从 clarification 降级为 asking,前端只渲染
|
||||
# 文本气泡不弹卡片,避免用户看到"同样选项又出现一次"。
|
||||
if _is_repeated_clarification(
|
||||
messages,
|
||||
new_question=question,
|
||||
new_options=cargs.get("options"),
|
||||
new_tool_call_id=clarification_call.get("id"),
|
||||
):
|
||||
logger.warning(
|
||||
"intent stream: dropping duplicate ask_clarification "
|
||||
"(question=%r) — already asked & answered earlier in thread",
|
||||
question[:80],
|
||||
)
|
||||
fallback_msg = (
|
||||
display_content
|
||||
or "(系统识别到智能体重复了上一问题,已自动跳过。请补充其他信息继续推进。)"
|
||||
)
|
||||
final_frame: dict[str, Any] = {
|
||||
"status": "asking",
|
||||
"content": fallback_msg,
|
||||
"summary": None,
|
||||
}
|
||||
yield f"data: {json.dumps(final_frame, ensure_ascii=False)}\n\n"
|
||||
return
|
||||
|
||||
final_frame: dict[str, Any] = {
|
||||
"status": "clarification",
|
||||
"content": display_content,
|
||||
|
||||
Reference in new issue
Block a user