portal: add atlasbot profiles
This commit is contained in:
parent
3ea68a7464
commit
3a27cc9fc1
@ -19,34 +19,42 @@ def register(app) -> None:
|
|||||||
payload = request.get_json(silent=True) or {}
|
payload = request.get_json(silent=True) or {}
|
||||||
user_message = (payload.get("message") or "").strip()
|
user_message = (payload.get("message") or "").strip()
|
||||||
history = payload.get("history") or []
|
history = payload.get("history") or []
|
||||||
|
profile = (payload.get("profile") or payload.get("mode") or "atlas-quick").strip().lower()
|
||||||
|
|
||||||
if not user_message:
|
if not user_message:
|
||||||
return jsonify({"error": "message required"}), 400
|
return jsonify({"error": "message required"}), 400
|
||||||
|
|
||||||
started = time.time()
|
started = time.time()
|
||||||
atlasbot_reply = _atlasbot_answer(user_message)
|
if profile in {"stock", "stock-ai", "stock_ai"}:
|
||||||
if atlasbot_reply:
|
reply = _stock_answer(user_message, history)
|
||||||
|
source = "stock"
|
||||||
|
else:
|
||||||
|
mode = "smart" if profile in {"atlas-smart", "smart"} else "quick"
|
||||||
|
reply = _atlasbot_answer(user_message, mode)
|
||||||
|
source = f"atlas-{mode}"
|
||||||
|
if reply:
|
||||||
elapsed_ms = int((time.time() - started) * 1000)
|
elapsed_ms = int((time.time() - started) * 1000)
|
||||||
return jsonify({"reply": atlasbot_reply, "latency_ms": elapsed_ms, "source": "atlasbot"})
|
return jsonify({"reply": reply, "latency_ms": elapsed_ms, "source": source})
|
||||||
elapsed_ms = int((time.time() - started) * 1000)
|
elapsed_ms = int((time.time() - started) * 1000)
|
||||||
return jsonify(
|
return jsonify(
|
||||||
{
|
{
|
||||||
"reply": "Atlasbot is busy. Please try again in a moment.",
|
"reply": "Atlasbot is busy. Please try again in a moment.",
|
||||||
"latency_ms": elapsed_ms,
|
"latency_ms": elapsed_ms,
|
||||||
"source": "atlasbot",
|
"source": source,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.route("/api/chat/info", methods=["GET"])
|
@app.route("/api/chat/info", methods=["GET"])
|
||||||
@app.route("/api/ai/info", methods=["GET"])
|
@app.route("/api/ai/info", methods=["GET"])
|
||||||
def ai_info() -> Any:
|
def ai_info() -> Any:
|
||||||
meta = _discover_ai_meta()
|
profile = (request.args.get("profile") or "atlas-quick").strip().lower()
|
||||||
|
meta = _discover_ai_meta(profile)
|
||||||
return jsonify(meta)
|
return jsonify(meta)
|
||||||
|
|
||||||
_start_keep_warm()
|
_start_keep_warm()
|
||||||
|
|
||||||
|
|
||||||
def _atlasbot_answer(message: str) -> str:
|
def _atlasbot_answer(message: str, mode: str) -> str:
|
||||||
endpoint = settings.AI_ATLASBOT_ENDPOINT
|
endpoint = settings.AI_ATLASBOT_ENDPOINT
|
||||||
if not endpoint:
|
if not endpoint:
|
||||||
return ""
|
return ""
|
||||||
@ -55,7 +63,7 @@ def _atlasbot_answer(message: str) -> str:
|
|||||||
headers["X-Internal-Token"] = settings.AI_ATLASBOT_TOKEN
|
headers["X-Internal-Token"] = settings.AI_ATLASBOT_TOKEN
|
||||||
try:
|
try:
|
||||||
with httpx.Client(timeout=settings.AI_ATLASBOT_TIMEOUT_SEC) as client:
|
with httpx.Client(timeout=settings.AI_ATLASBOT_TIMEOUT_SEC) as client:
|
||||||
resp = client.post(endpoint, json={"prompt": message}, headers=headers)
|
resp = client.post(endpoint, json={"prompt": message, "mode": mode}, headers=headers)
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
return ""
|
return ""
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
@ -64,13 +72,55 @@ def _atlasbot_answer(message: str) -> str:
|
|||||||
except (httpx.RequestError, ValueError):
|
except (httpx.RequestError, ValueError):
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
def _discover_ai_meta() -> dict[str, str]:
|
def _stock_answer(message: str, history: list[dict[str, Any]]) -> str:
|
||||||
|
body = {
|
||||||
|
"model": settings.AI_CHAT_MODEL,
|
||||||
|
"messages": _build_messages(message, history),
|
||||||
|
"stream": False,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=settings.AI_CHAT_TIMEOUT_SEC) as client:
|
||||||
|
resp = client.post(f"{settings.AI_CHAT_API}/api/chat", json=body)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
except (httpx.RequestError, ValueError):
|
||||||
|
return ""
|
||||||
|
message_data = data.get("message") if isinstance(data, dict) else None
|
||||||
|
if isinstance(message_data, dict) and message_data.get("content"):
|
||||||
|
return str(message_data["content"]).strip()
|
||||||
|
if isinstance(data, dict) and data.get("response"):
|
||||||
|
return str(data["response"]).strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _build_messages(message: str, history: list[dict[str, Any]]) -> list[dict[str, str]]:
|
||||||
|
messages = [{"role": "system", "content": settings.AI_CHAT_SYSTEM_PROMPT}]
|
||||||
|
for entry in history:
|
||||||
|
role = entry.get("role")
|
||||||
|
content = entry.get("content")
|
||||||
|
if role in {"user", "assistant"} and isinstance(content, str) and content.strip():
|
||||||
|
messages.append({"role": role, "content": content})
|
||||||
|
messages.append({"role": "user", "content": message})
|
||||||
|
return messages
|
||||||
|
|
||||||
|
|
||||||
|
def _discover_ai_meta(profile: str) -> dict[str, str]:
|
||||||
meta = {
|
meta = {
|
||||||
"node": settings.AI_NODE_NAME,
|
"node": settings.AI_NODE_NAME,
|
||||||
"gpu": settings.AI_GPU_DESC,
|
"gpu": settings.AI_GPU_DESC,
|
||||||
"model": settings.AI_CHAT_MODEL,
|
"model": settings.AI_CHAT_MODEL,
|
||||||
"endpoint": settings.AI_PUBLIC_ENDPOINT or "/api/chat",
|
"endpoint": settings.AI_PUBLIC_ENDPOINT or "/api/chat",
|
||||||
|
"profile": profile,
|
||||||
}
|
}
|
||||||
|
if profile in {"atlas-smart", "smart"}:
|
||||||
|
meta["model"] = settings.AI_ATLASBOT_MODEL_SMART or settings.AI_CHAT_MODEL
|
||||||
|
meta["endpoint"] = "/api/ai/chat"
|
||||||
|
elif profile in {"atlas-quick", "quick"}:
|
||||||
|
meta["model"] = settings.AI_ATLASBOT_MODEL_FAST or settings.AI_CHAT_MODEL
|
||||||
|
meta["endpoint"] = "/api/ai/chat"
|
||||||
|
elif profile in {"stock", "stock-ai", "stock_ai"}:
|
||||||
|
meta["model"] = settings.AI_CHAT_MODEL
|
||||||
|
meta["endpoint"] = "/api/ai/chat"
|
||||||
|
|
||||||
sa_path = Path("/var/run/secrets/kubernetes.io/serviceaccount")
|
sa_path = Path("/var/run/secrets/kubernetes.io/serviceaccount")
|
||||||
token_path = sa_path / "token"
|
token_path = sa_path / "token"
|
||||||
|
|||||||
@ -29,6 +29,8 @@ AI_CHAT_TIMEOUT_SEC = float(os.getenv("AI_CHAT_TIMEOUT_SEC", "20"))
|
|||||||
AI_ATLASBOT_ENDPOINT = os.getenv("AI_ATLASBOT_ENDPOINT", "").strip()
|
AI_ATLASBOT_ENDPOINT = os.getenv("AI_ATLASBOT_ENDPOINT", "").strip()
|
||||||
AI_ATLASBOT_TOKEN = os.getenv("AI_ATLASBOT_TOKEN", "").strip()
|
AI_ATLASBOT_TOKEN = os.getenv("AI_ATLASBOT_TOKEN", "").strip()
|
||||||
AI_ATLASBOT_TIMEOUT_SEC = float(os.getenv("AI_ATLASBOT_TIMEOUT_SEC", "5"))
|
AI_ATLASBOT_TIMEOUT_SEC = float(os.getenv("AI_ATLASBOT_TIMEOUT_SEC", "5"))
|
||||||
|
AI_ATLASBOT_MODEL_FAST = os.getenv("AI_ATLASBOT_MODEL_FAST", "").strip()
|
||||||
|
AI_ATLASBOT_MODEL_SMART = os.getenv("AI_ATLASBOT_MODEL_SMART", "").strip()
|
||||||
AI_NODE_NAME = os.getenv("AI_CHAT_NODE_NAME") or os.getenv("AI_NODE_NAME") or "ai-cluster"
|
AI_NODE_NAME = os.getenv("AI_CHAT_NODE_NAME") or os.getenv("AI_NODE_NAME") or "ai-cluster"
|
||||||
AI_GPU_DESC = os.getenv("AI_CHAT_GPU_DESC") or "local GPU (dynamic)"
|
AI_GPU_DESC = os.getenv("AI_CHAT_GPU_DESC") or "local GPU (dynamic)"
|
||||||
AI_PUBLIC_ENDPOINT = os.getenv("AI_PUBLIC_CHAT_ENDPOINT", "https://chat.ai.bstein.dev/api/chat")
|
AI_PUBLIC_ENDPOINT = os.getenv("AI_PUBLIC_CHAT_ENDPOINT", "https://chat.ai.bstein.dev/api/chat")
|
||||||
|
|||||||
@ -12,16 +12,16 @@
|
|||||||
<div class="hero-facts">
|
<div class="hero-facts">
|
||||||
<div class="fact">
|
<div class="fact">
|
||||||
<span class="label mono">Model</span>
|
<span class="label mono">Model</span>
|
||||||
<span class="value mono">{{ meta.model }}</span>
|
<span class="value mono">{{ current.meta.model }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="fact">
|
<div class="fact">
|
||||||
<span class="label mono">GPU</span>
|
<span class="label mono">GPU</span>
|
||||||
<span class="value mono">{{ meta.gpu }}</span>
|
<span class="value mono">{{ current.meta.gpu }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="fact">
|
<div class="fact">
|
||||||
<span class="label mono">Endpoint</span>
|
<span class="label mono">Endpoint</span>
|
||||||
<button class="endpoint-copy mono" type="button" @click="copyCurl">
|
<button class="endpoint-copy mono" type="button" @click="copyCurl">
|
||||||
{{ meta.endpoint || apiDisplay }}
|
{{ current.meta.endpoint || apiDisplay }}
|
||||||
<span v-if="copied" class="copied">copied</span>
|
<span v-if="copied" class="copied">copied</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -29,8 +29,20 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card chat-card">
|
<section class="card chat-card">
|
||||||
|
<div class="profile-tabs">
|
||||||
|
<button
|
||||||
|
v-for="profile in profiles"
|
||||||
|
:key="profile.id"
|
||||||
|
type="button"
|
||||||
|
class="profile-tab mono"
|
||||||
|
:class="{ active: activeProfile === profile.id }"
|
||||||
|
@click="activeProfile = profile.id"
|
||||||
|
>
|
||||||
|
{{ profile.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div class="chat-window" ref="chatWindow">
|
<div class="chat-window" ref="chatWindow">
|
||||||
<div v-for="(msg, idx) in messages" :key="idx" :class="['chat-row', msg.role]">
|
<div v-for="(msg, idx) in current.messages" :key="idx" :class="['chat-row', msg.role]">
|
||||||
<div class="bubble" :class="{ streaming: msg.streaming }">
|
<div class="bubble" :class="{ streaming: msg.streaming }">
|
||||||
<div class="role mono">{{ msg.role === 'assistant' ? 'Atlas AI' : 'you' }}</div>
|
<div class="role mono">{{ msg.role === 'assistant' ? 'Atlas AI' : 'you' }}</div>
|
||||||
<p class="message">{{ msg.content }}</p>
|
<p class="message">{{ msg.content }}</p>
|
||||||
@ -38,10 +50,10 @@
|
|||||||
<div v-else-if="msg.latency_ms" class="meta mono">{{ msg.latency_ms }} ms</div>
|
<div v-else-if="msg.latency_ms" class="meta mono">{{ msg.latency_ms }} ms</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="error" class="chat-row error">
|
<div v-if="current.error" class="chat-row error">
|
||||||
<div class="bubble">
|
<div class="bubble">
|
||||||
<div class="role mono">error</div>
|
<div class="role mono">error</div>
|
||||||
<p>{{ error }}</p>
|
<p>{{ current.error }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -65,32 +77,49 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, onUpdated, ref } from "vue";
|
import { computed, onMounted, onUpdated, reactive, ref, watch } from "vue";
|
||||||
|
|
||||||
const API_URL = (import.meta.env.VITE_AI_ENDPOINT || "/api/chat").trim();
|
const API_URL = (import.meta.env.VITE_AI_ENDPOINT || "/api/chat").trim();
|
||||||
const apiUrl = new URL(API_URL, window.location.href);
|
const apiUrl = new URL(API_URL, window.location.href);
|
||||||
const apiDisplay = apiUrl.host + apiUrl.pathname;
|
const apiDisplay = apiUrl.host + apiUrl.pathname;
|
||||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
const meta = ref({
|
const profiles = [
|
||||||
model: "loading...",
|
{ id: "atlas-quick", label: "Atlas Quick" },
|
||||||
gpu: "local GPU (dynamic)",
|
{ id: "atlas-smart", label: "Atlas Smart" },
|
||||||
node: "unknown",
|
{ id: "stock-ai", label: "Stock AI" },
|
||||||
endpoint: apiUrl.toString(),
|
];
|
||||||
});
|
const activeProfile = ref("atlas-quick");
|
||||||
const messages = ref([
|
const profileState = reactive(
|
||||||
{
|
Object.fromEntries(
|
||||||
role: "assistant",
|
profiles.map((profile) => [
|
||||||
content: "Hi! I'm Atlas AI. How can I help?",
|
profile.id,
|
||||||
},
|
{
|
||||||
]);
|
meta: {
|
||||||
|
model: "loading...",
|
||||||
|
gpu: "local GPU (dynamic)",
|
||||||
|
node: "unknown",
|
||||||
|
endpoint: apiUrl.toString(),
|
||||||
|
},
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: "Hi! I'm Atlas AI. How can I help?",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
error: "",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const current = computed(() => profileState[activeProfile.value]);
|
||||||
const draft = ref("");
|
const draft = ref("");
|
||||||
const sending = ref(false);
|
const sending = ref(false);
|
||||||
const error = ref("");
|
|
||||||
const chatWindow = ref(null);
|
const chatWindow = ref(null);
|
||||||
const copied = ref(false);
|
const copied = ref(false);
|
||||||
|
|
||||||
onMounted(fetchMeta);
|
onMounted(() => fetchMeta(activeProfile.value));
|
||||||
|
watch(activeProfile, (profile) => fetchMeta(profile));
|
||||||
|
|
||||||
onUpdated(() => {
|
onUpdated(() => {
|
||||||
if (chatWindow.value) {
|
if (chatWindow.value) {
|
||||||
@ -98,16 +127,16 @@ onUpdated(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function fetchMeta() {
|
async function fetchMeta(profile) {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch("/api/ai/info");
|
const resp = await fetch(`/api/ai/info?profile=${encodeURIComponent(profile)}`);
|
||||||
if (!resp.ok) return;
|
if (!resp.ok) return;
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
meta.value = {
|
current.value.meta = {
|
||||||
model: data.model || meta.value.model,
|
model: data.model || current.value.meta.model,
|
||||||
gpu: data.gpu || meta.value.gpu,
|
gpu: data.gpu || current.value.meta.gpu,
|
||||||
node: data.node || meta.value.node,
|
node: data.node || current.value.meta.node,
|
||||||
endpoint: data.endpoint || meta.value.endpoint || apiDisplay,
|
endpoint: data.endpoint || current.value.meta.endpoint || apiDisplay,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
// swallow
|
// swallow
|
||||||
@ -118,20 +147,21 @@ async function sendMessage() {
|
|||||||
if (!draft.value.trim() || sending.value) return;
|
if (!draft.value.trim() || sending.value) return;
|
||||||
const text = draft.value.trim();
|
const text = draft.value.trim();
|
||||||
draft.value = "";
|
draft.value = "";
|
||||||
error.value = "";
|
const state = current.value;
|
||||||
|
state.error = "";
|
||||||
const userEntry = { role: "user", content: text };
|
const userEntry = { role: "user", content: text };
|
||||||
messages.value.push(userEntry);
|
state.messages.push(userEntry);
|
||||||
const assistantEntry = { role: "assistant", content: "", streaming: true };
|
const assistantEntry = { role: "assistant", content: "", streaming: true };
|
||||||
messages.value.push(assistantEntry);
|
state.messages.push(assistantEntry);
|
||||||
sending.value = true;
|
sending.value = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const history = messages.value.filter((m) => !m.streaming).map((m) => ({ role: m.role, content: m.content }));
|
const history = state.messages.filter((m) => !m.streaming).map((m) => ({ role: m.role, content: m.content }));
|
||||||
const start = performance.now();
|
const start = performance.now();
|
||||||
const resp = await fetch(API_URL, {
|
const resp = await fetch(API_URL, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ message: text, history }),
|
body: JSON.stringify({ message: text, history, profile: activeProfile.value }),
|
||||||
});
|
});
|
||||||
const contentType = resp.headers.get("content-type") || "";
|
const contentType = resp.headers.get("content-type") || "";
|
||||||
|
|
||||||
@ -164,7 +194,7 @@ async function sendMessage() {
|
|||||||
await typeReveal(assistantEntry, textReply);
|
await typeReveal(assistantEntry, textReply);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err.message || "Unexpected error";
|
state.error = err.message || "Unexpected error";
|
||||||
assistantEntry.content = assistantEntry.content || "(no response)";
|
assistantEntry.content = assistantEntry.content || "(no response)";
|
||||||
assistantEntry.streaming = false;
|
assistantEntry.streaming = false;
|
||||||
} finally {
|
} finally {
|
||||||
@ -191,7 +221,7 @@ function handleKeydown(e) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function copyCurl() {
|
async function copyCurl() {
|
||||||
const target = meta.value.endpoint || apiUrl.toString();
|
const target = current.value.meta.endpoint || apiUrl.toString();
|
||||||
const curl = `curl -X POST ${target} -H 'content-type: application/json' -d '{\"message\":\"hi\"}'`;
|
const curl = `curl -X POST ${target} -H 'content-type: application/json' -d '{\"message\":\"hi\"}'`;
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(curl);
|
await navigator.clipboard.writeText(curl);
|
||||||
@ -264,11 +294,34 @@ async function copyCurl() {
|
|||||||
.chat-card {
|
.chat-card {
|
||||||
margin-top: 18px;
|
margin-top: 18px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: 1fr auto;
|
grid-template-rows: auto 1fr auto;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
min-height: 60vh;
|
min-height: 60vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-tabs {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-tab {
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s ease, color 0.2s ease, background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-tab.active {
|
||||||
|
border-color: rgba(0, 229, 197, 0.6);
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: rgba(0, 229, 197, 0.12);
|
||||||
|
box-shadow: var(--glow-soft);
|
||||||
|
}
|
||||||
|
|
||||||
.chat-window {
|
.chat-window {
|
||||||
background: rgba(255, 255, 255, 0.02);
|
background: rgba(255, 255, 255, 0.02);
|
||||||
border: 1px solid var(--card-border);
|
border: 1px solid var(--card-border);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user