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
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:
Generated
+5
@@ -154,8 +154,13 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
name = "cube"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"cube-core",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 +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
@@ -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
|
||||
|
||||
@@ -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.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<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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user