cube(portal): 加 chatbot + create_issue tool
deploy articulate / build-and-deploy (push) Successful in 1m27s
deploy cube / build-and-deploy (push) Successful in 1m52s
deploy karaoke / build-and-deploy (push) Successful in 1m20s
deploy music / build-and-deploy (push) Successful in 2m29s
deploy simpleasm / build-and-deploy (push) Successful in 1m38s
deploy werewolf / build-and-deploy (push) Successful in 58s

入口页右下角浮动 chat — 走 mochi 同款 LLM gateway (gemma-4-31b-it),
单步 tool calling 实现 `create_issue` 调 gitea API 建 fam/cube issue。
LLM_API_TOKEN + GITEA_TOKEN 走 ns 内 secret `chat-credentials`
(kubectl 手工创建,不进 git);gateway URL / model / 仓库地址走 env。
8 个 cargo test 覆盖 prompt / tool schema / tool_call 解析 / 错误。
顺手 git rm --cached 之前漏掉的 tsbuildinfo(已 gitignore)。
This commit is contained in:
Fam Zheng
2026-05-14 16:46:48 +01:00
parent af697ea6d0
commit 802d5beae9
8 changed files with 716 additions and 4 deletions
+5
View File
@@ -8,4 +8,9 @@ description = "cube.famzheng.me — cube 平台入口门户(app #0"
[dependencies]
cube-core = { path = "../../crates/cube-core" }
axum = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
reqwest = { workspace = true }
+3
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import AppCard from './components/AppCard.vue'
import Chatbot from './components/Chatbot.vue'
import { apps } from './apps'
</script>
@@ -33,6 +34,8 @@ import { apps } from './apps'
<span>cube · monorepo at</span>
<a href="https://famzheng.me/gitea/fam/cube" target="_blank" rel="noopener">famzheng.me/gitea/fam/cube</a>
</footer>
<Chatbot />
</main>
</template>
@@ -0,0 +1,292 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
interface Msg {
role: 'user' | 'assistant'
content: string
issue?: { number: number; url: string; title: string }
}
const open = ref(false)
const messages = ref<Msg[]>([])
const input = ref('')
const busy = ref(false)
const error = ref<string | null>(null)
const scrollEl = ref<HTMLElement | null>(null)
const canSend = computed(() => !busy.value && input.value.trim().length > 0)
watch(messages, async () => {
await nextTick()
scrollEl.value?.scrollTo({ top: scrollEl.value.scrollHeight, behavior: 'smooth' })
}, { deep: true })
async function send() {
if (!canSend.value) return
const text = input.value.trim()
input.value = ''
error.value = null
messages.value.push({ role: 'user', content: text })
busy.value = true
try {
const payload = {
messages: messages.value.map((m) => ({ role: m.role, content: m.content })),
}
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!res.ok) {
const body = await res.text()
throw new Error(body || `HTTP ${res.status}`)
}
const data = (await res.json()) as {
reply: string
created_issue: { number: number; url: string; title: string } | null
}
messages.value.push({
role: 'assistant',
content: data.reply,
issue: data.created_issue ?? undefined,
})
} catch (e) {
const msg = (e as Error).message
error.value = msg
messages.value.push({ role: 'assistant', content: `(出错了:${msg}` })
} finally {
busy.value = false
}
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
send()
}
}
function reset() {
messages.value = []
error.value = null
}
</script>
<template>
<button v-if="!open" class="fab" @click="open = true" aria-label="打开聊天">
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8z" />
</svg>
<span>反馈 / 提问</span>
</button>
<div v-else class="panel">
<header>
<div class="title">
<span class="dot" />
<strong>cube · chat</strong>
</div>
<div class="actions">
<button class="icon" @click="reset" title="清空对话"></button>
<button class="icon" @click="open = false" title="收起"></button>
</div>
</header>
<div class="thread" ref="scrollEl">
<div v-if="messages.length === 0" class="hint">
<p> cube 平台的事或反馈 bug / 想法 我会帮你提到 <code>fam/cube</code> issue</p>
</div>
<div v-for="(m, i) in messages" :key="i" :class="['bubble', m.role]">
<div class="content">{{ m.content }}</div>
<a v-if="m.issue" :href="m.issue.url" target="_blank" rel="noopener" class="issue-link">
已建 issue #{{ m.issue.number }}
</a>
</div>
<div v-if="busy" class="bubble assistant typing">
<span /><span /><span />
</div>
</div>
<footer>
<textarea
v-model="input"
:disabled="busy"
@keydown="onKeydown"
rows="2"
placeholder="说点什么...Enter 发送,Shift+Enter 换行)"
/>
<button class="send" :disabled="!canSend" @click="send">发送</button>
</footer>
</div>
</template>
<style scoped>
.fab {
position: fixed;
right: 20px;
bottom: 20px;
z-index: 1000;
background: linear-gradient(135deg, #7c3aed, #06b6d4);
color: white;
border: none;
padding: 12px 18px;
border-radius: 999px;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 8px;
box-shadow: 0 8px 24px rgba(124, 58, 237, 0.35);
transition: transform 0.15s, box-shadow 0.15s;
}
.fab:hover { transform: translateY(-2px); box-shadow: 0 12px 28px rgba(124, 58, 237, 0.45); }
.panel {
position: fixed;
right: 20px;
bottom: 20px;
z-index: 1000;
width: min(380px, calc(100vw - 32px));
height: min(560px, calc(100vh - 40px));
background: var(--bg-soft, rgba(20, 20, 30, 0.95));
backdrop-filter: blur(12px);
border: 1px solid var(--border, rgba(255, 255, 255, 0.15));
border-radius: 14px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.55);
display: flex;
flex-direction: column;
overflow: hidden;
color: var(--fg, rgba(255, 255, 255, 0.92));
}
header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border-bottom: 1px solid var(--border, rgba(255, 255, 255, 0.15));
}
.title { display: flex; align-items: center; gap: 8px; }
.dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: #4ade80;
box-shadow: 0 0 8px rgba(74, 222, 128, 0.7);
}
.actions { display: flex; gap: 4px; }
.icon {
width: 28px;
height: 28px;
background: transparent;
border: 1px solid var(--border, rgba(255, 255, 255, 0.15));
color: inherit;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
}
.icon:hover { background: rgba(255, 255, 255, 0.06); }
.thread {
flex: 1;
overflow-y: auto;
padding: 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
.hint { color: var(--fg-dim, rgba(255, 255, 255, 0.6)); font-size: 0.9rem; }
.hint code {
background: rgba(255, 255, 255, 0.08);
padding: 1px 5px;
border-radius: 4px;
font-size: 0.85em;
}
.bubble {
max-width: 85%;
padding: 10px 13px;
border-radius: 12px;
line-height: 1.4;
font-size: 0.92rem;
white-space: pre-wrap;
word-wrap: break-word;
}
.bubble.user {
align-self: flex-end;
background: linear-gradient(135deg, #7c3aed, #4f46e5);
color: white;
}
.bubble.assistant {
align-self: flex-start;
background: rgba(255, 255, 255, 0.08);
border: 1px solid var(--border, rgba(255, 255, 255, 0.15));
}
.issue-link {
display: inline-block;
margin-top: 8px;
color: #4ea7f7;
font-size: 0.85rem;
text-decoration: none;
border-top: 1px dashed rgba(255, 255, 255, 0.15);
padding-top: 8px;
}
.issue-link:hover { color: #80c2ff; }
.bubble.typing {
display: inline-flex;
gap: 4px;
padding: 12px 14px;
}
.bubble.typing span {
width: 6px;
height: 6px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.45);
animation: bounce 1.2s ease-in-out infinite;
}
.bubble.typing span:nth-child(2) { animation-delay: 0.15s; }
.bubble.typing span:nth-child(3) { animation-delay: 0.3s; }
@keyframes bounce {
0%, 60%, 100% { transform: translateY(0); opacity: 0.45; }
30% { transform: translateY(-5px); opacity: 1; }
}
footer {
padding: 10px 12px 12px;
border-top: 1px solid var(--border, rgba(255, 255, 255, 0.15));
display: flex;
gap: 8px;
align-items: flex-end;
}
textarea {
flex: 1;
resize: none;
background: rgba(255, 255, 255, 0.06);
border: 1px solid var(--border, rgba(255, 255, 255, 0.15));
border-radius: 8px;
padding: 8px 10px;
font-family: inherit;
font-size: 0.92rem;
color: inherit;
line-height: 1.4;
}
textarea:focus { outline: 2px solid #7c3aed; outline-offset: -1px; }
.send {
background: linear-gradient(135deg, #7c3aed, #06b6d4);
color: white;
border: none;
padding: 10px 16px;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
}
.send:disabled { background: rgba(255, 255, 255, 0.1); color: rgba(255, 255, 255, 0.4); cursor: not-allowed; }
@media (max-width: 420px) {
.panel { right: 12px; bottom: 12px; width: calc(100vw - 24px); height: calc(100vh - 24px); }
}
</style>
-1
View File
@@ -1 +0,0 @@
{"root":["./src/apps.ts","./src/main.ts","./src/App.vue","./src/components/AppCard.vue","./vite-env.d.ts"],"version":"5.7.3"}
+15 -1
View File
@@ -30,6 +30,20 @@ spec:
ports:
- containerPort: 8080
name: http
envFrom:
# secret `chat-credentials` (LLM_API_TOKEN + GITEA_TOKEN) 由 kubectl 手工创建,
# 不在 git manifest 里。kubectl apply -f all.yaml 不会动它。
- secretRef:
name: chat-credentials
env:
- name: LLM_GATEWAY
value: "http://3.135.65.204:8848/v1"
- name: LLM_MODEL
value: "gemma-4-31b-it"
- name: GITEA_URL
value: "https://famzheng.me/gitea"
- name: ISSUE_REPO
value: "fam/cube"
readinessProbe:
httpGet:
path: /healthz
@@ -48,7 +62,7 @@ spec:
memory: 16Mi
limits:
cpu: 200m
memory: 64Mi
memory: 128Mi
---
apiVersion: v1
kind: Service
+381
View File
@@ -0,0 +1,381 @@
//! `/api/chat` — 浏览器 ↔ LLM gateway 中转 + `create_issue` 工具调用。
//!
//! 单步 tool calling:拿到用户消息 → 调一次 LLM with tools → 如果 LLM 决定调
//! `create_issue` 就同步建 issue,把结果(issue 编号 + URL)当作 reply 返回给前端。
//! 不做 agent loop,不递归把工具结果喂回 LLM(重新调一次是浪费,issue 已经建好,
//! 直接告诉用户就行)。
use std::sync::Arc;
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
#[derive(Clone)]
pub struct Config {
pub gateway: String, // http://3.135.65.204:8848/v1
pub llm_token: String, // Bearer for LLM gateway
pub llm_model: String, // gemma-4-31b-it
pub gitea_url: String, // https://famzheng.me/gitea
pub gitea_token: String,
pub issue_repo: String, // fam/cube
}
impl Config {
pub fn from_env() -> Self {
Self {
gateway: env_or("LLM_GATEWAY", "http://3.135.65.204:8848/v1"),
llm_token: env_or("LLM_API_TOKEN", ""),
llm_model: env_or("LLM_MODEL", "gemma-4-31b-it"),
gitea_url: env_or("GITEA_URL", "https://famzheng.me/gitea"),
gitea_token: env_or("GITEA_TOKEN", ""),
issue_repo: env_or("ISSUE_REPO", "fam/cube"),
}
}
}
fn env_or(key: &str, fallback: &str) -> String {
std::env::var(key).unwrap_or_else(|_| fallback.to_string())
}
#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct ChatMessage {
pub role: String, // "user" | "assistant" | "system" | "tool"
pub content: String,
}
#[derive(Deserialize)]
pub struct ChatRequest {
pub messages: Vec<ChatMessage>,
}
#[derive(Serialize)]
pub struct CreatedIssue {
pub number: u64,
pub url: String,
pub title: String,
}
#[derive(Serialize)]
pub struct ChatResponse {
pub reply: String,
pub created_issue: Option<CreatedIssue>,
}
pub enum ChatError {
UpstreamLlm(String),
UpstreamGitea(String),
Empty,
}
impl IntoResponse for ChatError {
fn into_response(self) -> axum::response::Response {
match self {
Self::UpstreamLlm(msg) => {
tracing::error!(%msg, "llm upstream failed");
(StatusCode::BAD_GATEWAY, format!("LLM upstream error: {msg}")).into_response()
}
Self::UpstreamGitea(msg) => {
tracing::error!(%msg, "gitea upstream failed");
(StatusCode::BAD_GATEWAY, format!("Gitea upstream error: {msg}")).into_response()
}
Self::Empty => (StatusCode::BAD_REQUEST, "messages 不能为空").into_response(),
}
}
}
pub fn system_prompt(repo: &str) -> String {
format!(
"你是 cube 平台(cube.famzheng.meFam 的小 app 平台)入口页上的聊天助手。\n\
你可以做两件事:\n\
1. 回答用户关于 cube 上各个 appwerewolf / articulate / karaoke / music / simpleasm 等)的问题,简短直接\n\
2. 当用户表达 bug / feature request / feedback / 想反馈给 Fam 时,调用 `create_issue` 工具,\n\
把它整理成 issue 创建到 `{repo}`。标题要简洁明确(不超过 60 个字符),body 要包含足够上下文\n\
(重现步骤、期望行为、用户原话等)。\n\
\n\
不要主动鼓励用户提 issue —— 只在他明确表达想反馈时才创建。\n\
同一次对话只创建一个 issue。Reply 要简短,不写长段散文。",
)
}
pub fn create_issue_tool_schema() -> Value {
json!({
"type": "function",
"function": {
"name": "create_issue",
"description": "在 fam/cube 仓库创建一个 issue,用于收集用户反馈、bug 报告或 feature request",
"parameters": {
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "Issue 标题,简洁明确,不超过 60 个字符"
},
"body": {
"type": "string",
"description": "Issue 正文 Markdown,包含重现步骤 / 期望行为 / 用户原话等上下文"
}
},
"required": ["title", "body"]
}
}
})
}
pub async fn handle(
State(cfg): State<Arc<Config>>,
Json(req): Json<ChatRequest>,
) -> Result<Json<ChatResponse>, ChatError> {
if req.messages.is_empty() {
return Err(ChatError::Empty);
}
// 拼 messages:注入 system + 用户历史
let mut messages: Vec<Value> = vec![json!({
"role": "system",
"content": system_prompt(&cfg.issue_repo),
})];
for m in &req.messages {
messages.push(json!({ "role": m.role, "content": m.content }));
}
let body = json!({
"model": cfg.llm_model,
"messages": messages,
"tools": [create_issue_tool_schema()],
"tool_choice": "auto",
"stream": false,
"temperature": 0.6,
});
let endpoint = format!("{}/chat/completions", cfg.gateway.trim_end_matches('/'));
let client = reqwest::Client::new();
let resp = client
.post(&endpoint)
.bearer_auth(&cfg.llm_token)
.json(&body)
.send()
.await
.map_err(|e| ChatError::UpstreamLlm(e.to_string()))?;
let status = resp.status();
let bytes = resp
.bytes()
.await
.map_err(|e| ChatError::UpstreamLlm(e.to_string()))?;
if !status.is_success() {
let body = String::from_utf8_lossy(&bytes);
return Err(ChatError::UpstreamLlm(format!("{status}: {body}")));
}
let v: Value = serde_json::from_slice(&bytes)
.map_err(|e| ChatError::UpstreamLlm(format!("invalid json: {e}")))?;
let choice = v
.pointer("/choices/0/message")
.ok_or_else(|| ChatError::UpstreamLlm("no choices/0/message".into()))?;
// 拿 tool_calls 数组;如果有 create_issue 调用就执行,否则返回 content
if let Some(tool_call) = first_create_issue_call(choice) {
let (title, body_md) = extract_issue_args(&tool_call).map_err(ChatError::UpstreamLlm)?;
let created = create_gitea_issue(&cfg, &title, &body_md).await?;
let llm_text = choice
.get("content")
.and_then(Value::as_str)
.unwrap_or("")
.trim();
let reply = if llm_text.is_empty() {
format!("已记下 → issue #{}: {}", created.number, created.title)
} else {
format!("{llm_text}\n\n→ issue #{}: {}", created.number, created.title)
};
return Ok(Json(ChatResponse {
reply,
created_issue: Some(created),
}));
}
// 没工具调用 — 普通回复
let text = choice
.get("content")
.and_then(Value::as_str)
.unwrap_or("")
.trim()
.to_string();
Ok(Json(ChatResponse {
reply: if text.is_empty() {
"嗯?没听清,再说一遍?".to_string()
} else {
text
},
created_issue: None,
}))
}
/// 从 LLM 返回的 message 里挑第一个 `create_issue` tool_call。
pub fn first_create_issue_call(message: &Value) -> Option<Value> {
let arr = message.get("tool_calls")?.as_array()?;
arr.iter()
.find(|tc| {
tc.pointer("/function/name").and_then(Value::as_str) == Some("create_issue")
})
.cloned()
}
/// arguments 是 JSON 字符串(OpenAI 协议),需要二次解析。
pub fn extract_issue_args(tool_call: &Value) -> Result<(String, String), String> {
let args_raw = tool_call
.pointer("/function/arguments")
.and_then(Value::as_str)
.ok_or_else(|| "tool_call 缺少 arguments".to_string())?;
let args: Value = serde_json::from_str(args_raw)
.map_err(|e| format!("arguments 不是合法 JSON: {e}"))?;
let title = args
.get("title")
.and_then(Value::as_str)
.ok_or_else(|| "tool 调用缺少 title".to_string())?
.trim()
.to_string();
let body = args
.get("body")
.and_then(Value::as_str)
.ok_or_else(|| "tool 调用缺少 body".to_string())?
.trim()
.to_string();
if title.is_empty() {
return Err("title 为空".to_string());
}
Ok((title, body))
}
async fn create_gitea_issue(
cfg: &Config,
title: &str,
body: &str,
) -> Result<CreatedIssue, ChatError> {
let url = format!(
"{}/api/v1/repos/{}/issues",
cfg.gitea_url.trim_end_matches('/'),
cfg.issue_repo
);
let body_md = format!(
"{body}\n\n---\n_via cube portal chatbot · cube.famzheng.me_"
);
let payload = json!({
"title": title,
"body": body_md,
"labels": ["chatbot"],
});
let client = reqwest::Client::new();
let resp = client
.post(&url)
.header("Authorization", format!("token {}", cfg.gitea_token))
.json(&payload)
.send()
.await
.map_err(|e| ChatError::UpstreamGitea(e.to_string()))?;
let status = resp.status();
let bytes = resp
.bytes()
.await
.map_err(|e| ChatError::UpstreamGitea(e.to_string()))?;
if !status.is_success() {
return Err(ChatError::UpstreamGitea(format!(
"{status}: {}",
String::from_utf8_lossy(&bytes)
)));
}
let issue: Value = serde_json::from_slice(&bytes)
.map_err(|e| ChatError::UpstreamGitea(format!("invalid json: {e}")))?;
Ok(CreatedIssue {
number: issue.get("number").and_then(Value::as_u64).unwrap_or(0),
url: issue
.get("html_url")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
title: issue
.get("title")
.and_then(Value::as_str)
.unwrap_or(title)
.to_string(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn system_prompt_includes_repo() {
let p = system_prompt("fam/cube");
assert!(p.contains("fam/cube"));
assert!(p.contains("create_issue"));
}
#[test]
fn tool_schema_shape() {
let s = create_issue_tool_schema();
assert_eq!(s.pointer("/type").and_then(Value::as_str), Some("function"));
assert_eq!(
s.pointer("/function/name").and_then(Value::as_str),
Some("create_issue")
);
let req = s
.pointer("/function/parameters/required")
.and_then(Value::as_array)
.unwrap();
assert!(req.iter().any(|v| v == "title"));
assert!(req.iter().any(|v| v == "body"));
}
#[test]
fn first_create_issue_call_picks_only_matching_tool() {
let m = json!({
"tool_calls": [
{"id": "x1", "type": "function", "function": {"name": "other_tool", "arguments": "{}"}},
{"id": "x2", "type": "function", "function": {"name": "create_issue", "arguments": "{\"title\":\"t\",\"body\":\"b\"}"}}
]
});
let tc = first_create_issue_call(&m).expect("should find one");
assert_eq!(tc.pointer("/function/name").and_then(Value::as_str), Some("create_issue"));
}
#[test]
fn first_create_issue_call_returns_none_if_absent() {
let m = json!({ "tool_calls": [] });
assert!(first_create_issue_call(&m).is_none());
let m = json!({});
assert!(first_create_issue_call(&m).is_none());
}
#[test]
fn extract_issue_args_parses_string_arguments() {
let tc = json!({
"function": {
"name": "create_issue",
"arguments": "{\"title\":\"狼人杀: swipe 失灵\",\"body\":\" iOS Safari 上无法 swipe \"}"
}
});
let (t, b) = extract_issue_args(&tc).unwrap();
assert_eq!(t, "狼人杀: swipe 失灵");
assert_eq!(b, "iOS Safari 上无法 swipe");
}
#[test]
fn extract_issue_args_rejects_empty_title() {
let tc = json!({"function": {"arguments": "{\"title\":\"\",\"body\":\"x\"}"}});
assert!(extract_issue_args(&tc).is_err());
}
#[test]
fn extract_issue_args_rejects_malformed_args() {
let tc = json!({"function": {"arguments": "not json"}});
assert!(extract_issue_args(&tc).is_err());
}
#[test]
fn extract_issue_args_rejects_missing_field() {
let tc = json!({"function": {"arguments": "{\"title\":\"x\"}"}});
assert!(extract_issue_args(&tc).is_err());
}
}
+15 -2
View File
@@ -1,9 +1,22 @@
//! cube.famzheng.me — 入口门户。纯静态 SPA,没有自己的 /api 路由
//! cube.famzheng.me — 入口门户 + 反馈聊天助手
//!
//! - 静态 SPA + SPA fallback 由 cube-core::base 处理。
//! - `POST /api/chat` 转发到 LLM gateway,工具 `create_issue` 直接调 gitea 建 issue。
mod chat;
use std::sync::Arc;
#[tokio::main]
async fn main() -> std::io::Result<()> {
cube_core::init_tracing();
let dist = std::env::var("CUBE_DIST_DIR").unwrap_or_else(|_| "/dist".into());
let app = cube_core::base(dist);
let cfg = Arc::new(chat::Config::from_env());
let api = axum::Router::new()
.route("/chat", axum::routing::post(chat::handle))
.with_state(cfg);
let app = cube_core::base(dist).nest("/api", api);
cube_core::serve(app, 8080).await
}