diff --git a/Cargo.lock b/Cargo.lock index 8e40b59..f9e01e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -154,8 +154,13 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" name = "cube" version = "0.1.0" dependencies = [ + "axum", "cube-core", + "reqwest", + "serde", + "serde_json", "tokio", + "tracing", ] [[package]] diff --git a/apps/cube/Cargo.toml b/apps/cube/Cargo.toml index 7d772a8..8395de3 100644 --- a/apps/cube/Cargo.toml +++ b/apps/cube/Cargo.toml @@ -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 } diff --git a/apps/cube/frontend/src/App.vue b/apps/cube/frontend/src/App.vue index 0481a02..817bda6 100644 --- a/apps/cube/frontend/src/App.vue +++ b/apps/cube/frontend/src/App.vue @@ -1,5 +1,6 @@ @@ -33,6 +34,8 @@ import { apps } from './apps' cube · monorepo at famzheng.me/gitea/fam/cube + + diff --git a/apps/cube/frontend/src/components/Chatbot.vue b/apps/cube/frontend/src/components/Chatbot.vue new file mode 100644 index 0000000..fa0091a --- /dev/null +++ b/apps/cube/frontend/src/components/Chatbot.vue @@ -0,0 +1,292 @@ + + + + + + + + 反馈 / 提问 + + + + + + + cube · chat + + + ↺ + ✕ + + + + + + 问 cube 平台的事,或反馈 bug / 想法 — 我会帮你提到 fam/cube issue。 + + + + {{ m.content }} + + 已建 issue #{{ m.issue.number }} → + + + + + + + + + + + + + diff --git a/apps/cube/frontend/tsconfig.tsbuildinfo b/apps/cube/frontend/tsconfig.tsbuildinfo deleted file mode 100644 index 6c1949f..0000000 --- a/apps/cube/frontend/tsconfig.tsbuildinfo +++ /dev/null @@ -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"} \ No newline at end of file diff --git a/apps/cube/k8s/all.yaml b/apps/cube/k8s/all.yaml index 4fcc49c..2cb907c 100644 --- a/apps/cube/k8s/all.yaml +++ b/apps/cube/k8s/all.yaml @@ -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 diff --git a/apps/cube/src/chat.rs b/apps/cube/src/chat.rs new file mode 100644 index 0000000..086c12e --- /dev/null +++ b/apps/cube/src/chat.rs @@ -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, +} + +#[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, +} + +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.me,Fam 的小 app 平台)入口页上的聊天助手。\n\ + 你可以做两件事:\n\ + 1. 回答用户关于 cube 上各个 app(werewolf / 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>, + Json(req): Json, +) -> Result, ChatError> { + if req.messages.is_empty() { + return Err(ChatError::Empty); + } + + // 拼 messages:注入 system + 用户历史 + let mut messages: Vec = 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 { + 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 { + 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()); + } +} diff --git a/apps/cube/src/main.rs b/apps/cube/src/main.rs index 3a4a4e3..8867950 100644 --- a/apps/cube/src/main.rs +++ b/apps/cube/src/main.rs @@ -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 }
问 cube 平台的事,或反馈 bug / 想法 — 我会帮你提到 fam/cube issue。
fam/cube