新增用户意图确认功能

This commit is contained in:
likun committed 2026-05-13 14:36:44 +08:00
1 parent cf2e4bdd9c
commit b8926f00d7
18 files changed
+475 -19

No files matched your search

@@ -1,9 +1,13 @@
import type { BaseStream } from "@langchain/langgraph-sdk/react"; import type { BaseStream } from "@langchain/langgraph-sdk/react";
import { SendIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { import {
Conversation, Conversation,
ConversationContent, ConversationContent,
} from "@/components/ai-elements/conversation"; } from "@/components/ai-elements/conversation";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { import {
extractContentFromMessage, extractContentFromMessage,
@@ -40,12 +44,14 @@ export function MessageList({
thread, thread,
paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM, paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
tokenUsageEnabled = false, tokenUsageEnabled = false,
onClarificationSubmit,
}: { }: {
className?: string; className?: string;
threadId: string; threadId: string;
thread: BaseStream<AgentThreadState>; thread: BaseStream<AgentThreadState>;
paddingBottom?: number; paddingBottom?: number;
tokenUsageEnabled?: boolean; tokenUsageEnabled?: boolean;
onClarificationSubmit?: (answer: string) => void | Promise<void>;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading); const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
@@ -77,9 +83,11 @@ export function MessageList({
if (message && hasContent(message)) { if (message && hasContent(message)) {
return ( return (
<div key={group.id} className="w-full"> <div key={group.id} className="w-full">
<MarkdownContent <ClarificationCard
content={extractContentFromMessage(message)} fallbackContent={extractContentFromMessage(message)}
isLoading={thread.isLoading} isLoading={thread.isLoading}
message={message}
onSubmit={onClarificationSubmit}
rehypePlugins={rehypePlugins} rehypePlugins={rehypePlugins}
/> />
<MessageTokenUsageList <MessageTokenUsageList
@@ -240,3 +248,148 @@ export function MessageList({
</Conversation> </Conversation>
); );
} }
type ClarificationOption = {
id: string;
label: string;
description?: string;
};
type ClarificationPayload = {
question?: string;
context?: string | null;
clarification_type?: string;
options?: ClarificationOption[];
allow_custom?: boolean;
};
function getClarificationPayload(message: unknown): ClarificationPayload | null {
if (typeof message !== "object" || message === null) {
return null;
}
const additionalKwargs = Reflect.get(message, "additional_kwargs");
if (typeof additionalKwargs !== "object" || additionalKwargs === null) {
return null;
}
const clarification = Reflect.get(additionalKwargs, "clarification");
if (typeof clarification !== "object" || clarification === null) {
return null;
}
return clarification as ClarificationPayload;
}
function ClarificationCard({
fallbackContent,
isLoading,
message,
onSubmit,
rehypePlugins,
}: {
fallbackContent: string;
isLoading: boolean;
message: unknown;
onSubmit?: (answer: string) => void | Promise<void>;
rehypePlugins: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
}) {
const payload = getClarificationPayload(message);
const [customAnswer, setCustomAnswer] = useState("");
const [submitting, setSubmitting] = useState(false);
const options = useMemo(
() =>
(payload?.options ?? []).filter(
(option) => typeof option.label === "string" && option.label.trim(),
),
[payload?.options],
);
if (!payload) {
return (
<MarkdownContent
content={fallbackContent}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
/>
);
}
const disabled = isLoading || submitting || !onSubmit;
async function submitAnswer(answer: string) {
const trimmed = answer.trim();
if (!trimmed || disabled) {
return;
}
setSubmitting(true);
try {
await onSubmit?.(trimmed);
} finally {
setSubmitting(false);
}
}
return (
<div className="border-border bg-card text-card-foreground w-full rounded-lg border p-4 shadow-sm">
<div className="space-y-2">
<div className="text-muted-foreground text-xs font-medium">
需要你的确认
</div>
{payload.context ? (
<p className="text-muted-foreground text-sm leading-6">
{payload.context}
</p>
) : null}
<div className="text-sm leading-6 font-medium">
{payload.question || fallbackContent}
</div>
</div>
{options.length > 0 ? (
<div className="mt-4 grid gap-2">
{options.map((option) => (
<Button
key={option.id || option.label}
type="button"
variant="outline"
className="h-auto justify-start whitespace-normal px-3 py-2 text-left"
disabled={disabled}
onClick={() => void submitAnswer(`我选择:${option.label}`)}
>
<span className="flex min-w-0 flex-col items-start gap-1">
<span>{option.label}</span>
{option.description ? (
<span className="text-muted-foreground text-xs font-normal leading-5">
{option.description}
</span>
) : null}
</span>
</Button>
))}
</div>
) : null}
{payload.allow_custom !== false ? (
<div className="mt-4 flex flex-col gap-2">
<Textarea
value={customAnswer}
onChange={(event) => setCustomAnswer(event.currentTarget.value)}
placeholder="如果以上选项都不合适,可以在这里输入你的回答"
disabled={disabled}
className="min-h-20 resize-none"
/>
<div className="flex justify-end">
<Button
type="button"
size="sm"
disabled={disabled || !customAnswer.trim()}
onClick={() => void submitAnswer(customAnswer)}
>
<SendIcon className="h-4 w-4" />
发送回答
</Button>
</div>
</div>
) : null}
</div>
);
}
+9
View File
@@ -98,6 +98,14 @@ export default function AgentChatPage() {
[sendMessage, threadId, agent_id], [sendMessage, threadId, agent_id],
); );
const handleClarificationSubmit = useCallback(
async (answer: string) => {
setHasStartedConversation(true);
await sendMessage(threadId, { text: answer, files: [] }, { agent_id });
},
[sendMessage, threadId, agent_id],
);
const handleStop = useCallback(async () => { const handleStop = useCallback(async () => {
await thread.stop(); await thread.stop();
}, [thread]); }, [thread]);
@@ -160,6 +168,7 @@ export default function AgentChatPage() {
thread={thread} thread={thread}
paddingBottom={messageListPaddingBottom} paddingBottom={messageListPaddingBottom}
tokenUsageEnabled={tokenUsageEnabled} tokenUsageEnabled={tokenUsageEnabled}
onClarificationSubmit={handleClarificationSubmit}
/> />
</div> </div>
+8
View File
@@ -95,6 +95,13 @@ export default function ChatPage() {
}, },
[sendMessage, threadId], [sendMessage, threadId],
); );
const handleClarificationSubmit = useCallback(
async (answer: string) => {
setHasStartedConversation(true);
await sendMessage(threadId, { text: answer, files: [] });
},
[sendMessage, threadId],
);
const handleStop = useCallback(async () => { const handleStop = useCallback(async () => {
await thread.stop(); await thread.stop();
}, [thread]); }, [thread]);
@@ -137,6 +144,7 @@ export default function ChatPage() {
thread={thread} thread={thread}
paddingBottom={messageListPaddingBottom} paddingBottom={messageListPaddingBottom}
tokenUsageEnabled={tokenUsageEnabled} tokenUsageEnabled={tokenUsageEnabled}
onClarificationSubmit={handleClarificationSubmit}
/> />
</div> </div>
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4"> <div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
+1
View File
@@ -465,6 +465,7 @@ export default function NewAgentPage() {
className={cn("size-full", showSaveHint ? "pt-4" : "pt-10")} className={cn("size-full", showSaveHint ? "pt-4" : "pt-10")}
threadId={threadId} threadId={threadId}
thread={thread} thread={thread}
onClarificationSubmit={handleChatSubmit}
/> />
</div> </div>
@@ -442,6 +442,8 @@ You are {agent_name}, an open-source super agent.
- ✅ If you identify the need for clarification in your thinking, you MUST call the tool IMMEDIATELY - ✅ If you identify the need for clarification in your thinking, you MUST call the tool IMMEDIATELY
- ✅ After calling ask_clarification, execution will be interrupted automatically - ✅ After calling ask_clarification, execution will be interrupted automatically
- ✅ Wait for user response - do NOT continue with assumptions - ✅ Wait for user response - do NOT continue with assumptions
- ✅ When practical, provide 3-4 clear, mutually distinct options so the user can choose quickly
- ✅ Keep options concise and actionable; the user can still provide a custom answer if none fit
**How to Use:** **How to Use:**
```python ```python
@@ -449,7 +451,7 @@ ask_clarification(
question="Your specific question here?", question="Your specific question here?",
clarification_type="missing_info", # or other type clarification_type="missing_info", # or other type
context="Why you need this information", # optional but recommended context="Why you need this information", # optional but recommended
options=["option1", "option2"] # optional, for choices options=["option1", "option2", "option3"] # use 3-4 options when practical
) )
``` ```
@@ -67,21 +67,7 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
question = args.get("question", "") question = args.get("question", "")
clarification_type = args.get("clarification_type", "missing_info") clarification_type = args.get("clarification_type", "missing_info")
context = args.get("context") context = args.get("context")
options = args.get("options", []) options = self._normalize_options(args.get("options", []))
# Some models (e.g. Qwen3-Max) serialize array parameters as JSON strings
# instead of native arrays. Deserialize and normalize so `options`
# is always a list for the rendering logic below.
if isinstance(options, str):
try:
options = json.loads(options)
except (json.JSONDecodeError, TypeError):
options = [options]
if options is None:
options = []
elif not isinstance(options, list):
options = [options]
# Type-specific icons # Type-specific icons
type_icons = { type_icons = {
@@ -110,10 +96,62 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
if options and len(options) > 0: if options and len(options) > 0:
message_parts.append("") # blank line for spacing message_parts.append("") # blank line for spacing
for i, option in enumerate(options, 1): for i, option in enumerate(options, 1):
message_parts.append(f" {i}. {option}") label = option.get("label") or option.get("id") or ""
message_parts.append(f" {i}. {label}")
return "\n".join(message_parts) return "\n".join(message_parts)
def _normalize_options(self, raw_options: object) -> list[dict[str, str]]:
"""Normalize model-produced options into a stable UI-friendly shape."""
options = raw_options
# Some models serialize array parameters as JSON strings instead of native arrays.
if isinstance(options, str):
try:
options = json.loads(options)
except (json.JSONDecodeError, TypeError):
options = [options]
if options is None:
values: list[object] = []
elif isinstance(options, list):
values = options
else:
values = [options]
normalized: list[dict[str, str]] = []
for index, value in enumerate(values, 1):
if isinstance(value, dict):
raw_label = value.get("label") or value.get("title") or value.get("name") or value.get("value") or value.get("id")
label = str(raw_label).strip() if raw_label is not None else ""
if not label:
continue
raw_id = value.get("id") or value.get("value") or f"option-{index}"
option = {
"id": str(raw_id).strip() or f"option-{index}",
"label": label,
}
description = value.get("description")
if description is not None and str(description).strip():
option["description"] = str(description).strip()
normalized.append(option)
else:
label = str(value).strip()
if label:
normalized.append({"id": f"option-{index}", "label": label})
return normalized
def _build_payload(self, args: dict) -> dict:
"""Build structured clarification data for rich clients."""
return {
"question": str(args.get("question", "")).strip(),
"clarification_type": args.get("clarification_type", "missing_info"),
"context": args.get("context"),
"options": self._normalize_options(args.get("options", [])),
"allow_custom": True,
}
def _handle_clarification(self, request: ToolCallRequest) -> Command: def _handle_clarification(self, request: ToolCallRequest) -> Command:
"""Handle clarification request and return command to interrupt execution. """Handle clarification request and return command to interrupt execution.
@@ -143,6 +181,7 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
content=formatted_message, content=formatted_message,
tool_call_id=tool_call_id, tool_call_id=tool_call_id,
name="ask_clarification", name="ask_clarification",
additional_kwargs={"clarification": self._build_payload(args)},
) )
# Return a Command that: # Return a Command that:
+2
View File
@@ -745,6 +745,7 @@ version = "0.1.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "bcrypt" }, { name = "bcrypt" },
{ name = "croniter" },
{ name = "deerflow-harness" }, { name = "deerflow-harness" },
{ name = "dingtalk-stream" }, { name = "dingtalk-stream" },
{ name = "email-validator" }, { name = "email-validator" },
@@ -778,6 +779,7 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "bcrypt", specifier = ">=4.0.0" }, { name = "bcrypt", specifier = ">=4.0.0" },
{ name = "croniter", specifier = ">=6.2.2" },
{ name = "deerflow-harness", editable = "packages/harness" }, { name = "deerflow-harness", editable = "packages/harness" },
{ name = "deerflow-harness", extras = ["postgres"], marker = "extra == 'postgres'", editable = "packages/harness" }, { name = "deerflow-harness", extras = ["postgres"], marker = "extra == 'postgres'", editable = "packages/harness" },
{ name = "dingtalk-stream", specifier = ">=0.24.3" }, { name = "dingtalk-stream", specifier = ">=0.24.3" },
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
BACKEND_DIR="${ROOT_DIR}/offline-backend-20260512/backend"
FRONTEND_DIR="${ROOT_DIR}/frontend-web"
RUNTIME_DIR="${ROOT_DIR}/.runtime"
LOG_DIR="${RUNTIME_DIR}/logs"
PID_DIR="${RUNTIME_DIR}/pids"
BACKEND_HOST="${BACKEND_HOST:-0.0.0.0}"
BACKEND_PORT="${BACKEND_PORT:-8001}"
FRONTEND_HOST="${FRONTEND_HOST:-0.0.0.0}"
FRONTEND_PORT="${FRONTEND_PORT:-5174}"
detect_host_ip() {
if command -v hostname >/dev/null 2>&1; then
hostname -I 2>/dev/null | awk '{print $1}'
fi
}
HOST_IP="${HOST_IP:-$(detect_host_ip)}"
HOST_IP="${HOST_IP:-127.0.0.1}"
BACKEND_PUBLIC_BASE_URL="${BACKEND_PUBLIC_BASE_URL:-http://${HOST_IP}:${BACKEND_PORT}}"
FRONTEND_PUBLIC_BASE_URL="${FRONTEND_PUBLIC_BASE_URL:-http://${HOST_IP}:${FRONTEND_PORT}}"
GATEWAY_CORS_ORIGINS="${GATEWAY_CORS_ORIGINS:-http://localhost:${FRONTEND_PORT},http://127.0.0.1:${FRONTEND_PORT},http://${HOST_IP}:${FRONTEND_PORT}}"
mkdir -p "${LOG_DIR}" "${PID_DIR}"
is_running() {
local pid_file="$1"
[[ -f "${pid_file}" ]] || return 1
local pid
pid="$(cat "${pid_file}" 2>/dev/null || true)"
[[ -n "${pid}" ]] || return 1
kill -0 "${pid}" >/dev/null 2>&1
}
stop_by_pid_file() {
local name="$1"
local pid_file="$2"
if ! [[ -f "${pid_file}" ]]; then
echo "${name} is not running (no pid file)."
return 0
fi
local pid
pid="$(cat "${pid_file}" 2>/dev/null || true)"
if [[ -z "${pid}" ]] || ! kill -0 "${pid}" >/dev/null 2>&1; then
echo "${name} is not running (stale pid file removed)."
rm -f "${pid_file}"
return 0
fi
echo "Stopping ${name} (pid ${pid})..."
# Services are started with setsid, so the pid is also the process group id.
# Stop the whole group to avoid leaving Vite/esbuild/uvicorn child processes.
kill "-${pid}" >/dev/null 2>&1 || true
kill "${pid}" >/dev/null 2>&1 || true
for _ in {1..30}; do
if ! kill -0 "${pid}" >/dev/null 2>&1; then
rm -f "${pid_file}"
echo "${name} stopped."
return 0
fi
sleep 0.2
done
echo "${name} did not stop gracefully; killing..."
kill -9 "-${pid}" >/dev/null 2>&1 || true
kill -9 "${pid}" >/dev/null 2>&1 || true
rm -f "${pid_file}"
echo "${name} stopped."
}
stop_by_pattern() {
local name="$1"
local pattern="$2"
if ! command -v pgrep >/dev/null 2>&1; then
return 0
fi
local pids
pids="$(pgrep -f "${pattern}" || true)"
if [[ -z "${pids}" ]]; then
return 0
fi
echo "Stopping ${name} processes matching: ${pattern}"
while IFS= read -r pid; do
[[ -n "${pid}" ]] || continue
[[ "${pid}" != "$$" ]] || continue
kill "${pid}" >/dev/null 2>&1 || true
done <<<"${pids}"
}
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
"${SCRIPT_DIR}/stop-all.sh"
"${SCRIPT_DIR}/start-all.sh"
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
"${SCRIPT_DIR}/stop-backend.sh"
"${SCRIPT_DIR}/start-backend.sh"
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
"${SCRIPT_DIR}/stop-frontend.sh"
"${SCRIPT_DIR}/start-frontend.sh"
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
"${SCRIPT_DIR}/start-backend.sh"
"${SCRIPT_DIR}/start-frontend.sh"
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
set -euo pipefail
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/env.sh"
PID_FILE="${PID_DIR}/backend.pid"
LOG_FILE="${LOG_DIR}/backend.log"
if is_running "${PID_FILE}"; then
echo "Backend is already running (pid $(cat "${PID_FILE}"))."
echo "URL: http://${HOST_IP}:${BACKEND_PORT}"
exit 0
fi
rm -f "${PID_FILE}"
echo "Starting backend..."
echo "Log: ${LOG_FILE}"
setsid bash -c '
cd "$0" || exit 1
exec env \
GATEWAY_CORS_ORIGINS="$1" \
PYTHONPATH=. \
uv run uvicorn app.gateway.app:app \
--host "$2" \
--port "$3" \
--reload
' "${BACKEND_DIR}" "${GATEWAY_CORS_ORIGINS}" "${BACKEND_HOST}" "${BACKEND_PORT}" \
>"${LOG_FILE}" 2>&1 &
echo "$!" >"${PID_FILE}"
echo "Backend started (pid $(cat "${PID_FILE}"))."
echo "URL: http://${HOST_IP}:${BACKEND_PORT}"
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
set -euo pipefail
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/env.sh"
PID_FILE="${PID_DIR}/frontend.pid"
LOG_FILE="${LOG_DIR}/frontend.log"
if is_running "${PID_FILE}"; then
echo "Frontend is already running (pid $(cat "${PID_FILE}"))."
echo "URL: ${FRONTEND_PUBLIC_BASE_URL}"
exit 0
fi
rm -f "${PID_FILE}"
echo "Starting frontend..."
echo "Log: ${LOG_FILE}"
setsid bash -c '
cd "$0" || exit 1
exec env \
VITE_BACKEND_BASE_URL="$1" \
VITE_LANGGRAPH_BASE_URL="$1/api" \
pnpm exec vite \
--host "$2" \
--port "$3"
' "${FRONTEND_DIR}" "${BACKEND_PUBLIC_BASE_URL}" "${FRONTEND_HOST}" "${FRONTEND_PORT}" \
>"${LOG_FILE}" 2>&1 &
echo "$!" >"${PID_FILE}"
echo "Frontend started (pid $(cat "${PID_FILE}"))."
echo "URL: ${FRONTEND_PUBLIC_BASE_URL}"
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
set -euo pipefail
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/env.sh"
print_status() {
local name="$1"
local pid_file="$2"
local url="$3"
local pattern="$4"
if is_running "${pid_file}"; then
echo "${name}: running (pid $(cat "${pid_file}"))"
echo " ${url}"
elif command -v pgrep >/dev/null 2>&1 && pgrep -f "${pattern}" >/dev/null 2>&1; then
echo "${name}: running (unmanaged by scripts)"
echo " ${url}"
else
echo "${name}: stopped"
fi
}
print_status "Backend" "${PID_DIR}/backend.pid" "http://${HOST_IP}:${BACKEND_PORT}" "${BACKEND_DIR}.*uvicorn app.gateway.app:app.*--port ${BACKEND_PORT}"
print_status "Frontend" "${PID_DIR}/frontend.pid" "${FRONTEND_PUBLIC_BASE_URL}" "${FRONTEND_DIR}.*vite.*--port ${FRONTEND_PORT}"
echo "Logs: ${LOG_DIR}"
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
"${SCRIPT_DIR}/stop-frontend.sh"
"${SCRIPT_DIR}/stop-backend.sh"
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/env.sh"
stop_by_pid_file "Backend" "${PID_DIR}/backend.pid"
stop_by_pattern "Backend" "${BACKEND_DIR}.*uvicorn app.gateway.app:app.*--port ${BACKEND_PORT}"
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/env.sh"
stop_by_pid_file "Frontend" "${PID_DIR}/frontend.pid"
stop_by_pattern "Frontend" "${FRONTEND_DIR}.*vite.*--port ${FRONTEND_PORT}"