新增用户意图确认功能
This commit is contained in:
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -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
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+55
-16
@@ -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:
|
||||||
|
|||||||
Generated
+2
@@ -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
@@ -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}"
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -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}"
|
||||||
@@ -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}"
|
||||||
@@ -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}"
|
||||||
@@ -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"
|
||||||
@@ -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}"
|
||||||
@@ -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}"
|
||||||
Reference in new issue
Block a user