feat: 初版
knowledge-base / deploy (push) Failing after 11s

This commit is contained in:
2026-06-23 19:09:11 +08:00
commit 73b9c9fb7a
61 changed files with 9549 additions and 0 deletions
+70
View File
@@ -0,0 +1,70 @@
import type { ChatMessage, RequestAIParams } from "./types"
/**
* Generic, reusable OpenAI-compatible chat completion request.
* Not coupled to any specific business logic.
*/
export async function requestOpenAICompatible(params: RequestAIParams): Promise<string> {
const {
baseUrl,
apiKey,
model,
systemPrompt,
customPrompt,
messages,
temperature = 0.3,
maxTokens = 4000,
stream = false,
signal,
} = params
const finalMessages: ChatMessage[] = []
if (systemPrompt) {
finalMessages.push({ role: "system", content: systemPrompt })
}
if (customPrompt) {
finalMessages.push({ role: "user", content: customPrompt })
}
finalMessages.push(...messages)
const url = `${baseUrl.replace(/\/$/, "")}/chat/completions`
let response: Response
try {
response = await fetch(url, {
method: "POST",
signal,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model,
messages: finalMessages,
temperature,
max_tokens: maxTokens,
stream,
}),
})
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") throw err
throw new Error(`无法连接到 AI 服务:${(err as Error).message}`)
}
if (!response.ok) {
const errorText = await response.text().catch(() => "")
throw new Error(`AI 请求失败:${response.status} ${errorText}`)
}
const data = await response.json()
const content = data?.choices?.[0]?.message?.content
if (!content) {
throw new Error("AI 没有返回有效内容")
}
return content as string
}
+91
View File
@@ -0,0 +1,91 @@
import { createClient } from "@/lib/supabase/client"
export type AIConversation = {
id: string
knowledge_item_id: string | null
title: string | null
created_at: string
updated_at: string
}
export type AIMessageRow = {
id: string
conversation_id: string
role: "user" | "assistant" | "system"
content: string
created_at: string
}
/** Get the latest conversation for a knowledge item, if any. */
export async function getLatestConversation(knowledgeItemId: string): Promise<AIConversation | null> {
const supabase = createClient()
const { data, error } = await supabase
.from("ai_conversations")
.select("*")
.eq("knowledge_item_id", knowledgeItemId)
.order("updated_at", { ascending: false })
.limit(1)
.maybeSingle()
if (error) throw error
return (data as AIConversation) ?? null
}
export async function createConversation(knowledgeItemId: string, title: string): Promise<AIConversation> {
const supabase = createClient()
const { data, error } = await supabase
.from("ai_conversations")
.insert({ knowledge_item_id: knowledgeItemId, title })
.select("*")
.single()
if (error) throw error
return data as AIConversation
}
export async function fetchMessages(conversationId: string): Promise<AIMessageRow[]> {
const supabase = createClient()
const { data, error } = await supabase
.from("ai_messages")
.select("*")
.eq("conversation_id", conversationId)
.order("created_at", { ascending: true })
if (error) throw error
return (data ?? []) as AIMessageRow[]
}
export async function addMessage(
conversationId: string,
role: "user" | "assistant",
content: string,
): Promise<AIMessageRow> {
const supabase = createClient()
const { data, error } = await supabase
.from("ai_messages")
.insert({ conversation_id: conversationId, role, content })
.select("*")
.single()
if (error) throw error
// touch conversation updated_at
await supabase.from("ai_conversations").update({ updated_at: new Date().toISOString() }).eq("id", conversationId)
return data as AIMessageRow
}
export async function clearMessages(conversationId: string): Promise<void> {
const supabase = createClient()
const { error } = await supabase.from("ai_messages").delete().eq("conversation_id", conversationId)
if (error) throw error
}
export async function deleteLastAssistantMessage(conversationId: string): Promise<void> {
const supabase = createClient()
const { data, error } = await supabase
.from("ai_messages")
.select("id, role")
.eq("conversation_id", conversationId)
.order("created_at", { ascending: false })
.limit(1)
.maybeSingle()
if (error) throw error
if (data && (data as { role: string }).role === "assistant") {
await supabase.from("ai_messages").delete().eq("id", (data as { id: string }).id)
}
}
+103
View File
@@ -0,0 +1,103 @@
import {
AI_CATEGORY_ENUM,
AI_DIFFICULTY_ENUM,
AI_MASTERY_ENUM,
AI_TAG_ENUM,
type AIDraftItem,
} from "./types"
export type ParseResult =
| { ok: true; items: AIDraftItem[] }
| { ok: false; error: string; raw: string }
/** Strip markdown code fences and locate the JSON payload. */
function stripFences(input: string): string {
let text = input.trim()
// remove ```json ... ``` or ``` ... ``` wrappers
const fenceMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/i)
if (fenceMatch) {
text = fenceMatch[1].trim()
}
// fall back: slice from first { to last }
if (!text.startsWith("{")) {
const first = text.indexOf("{")
const last = text.lastIndexOf("}")
if (first !== -1 && last !== -1 && last > first) {
text = text.slice(first, last + 1)
}
}
return text
}
function toStr(value: unknown): string {
if (typeof value === "string") return value
if (value == null) return ""
return String(value)
}
function sanitizeTags(value: unknown): string[] {
if (!Array.isArray(value)) return []
const allowed = new Set<string>(AI_TAG_ENUM)
const out: string[] = []
for (const t of value) {
const tag = toStr(t).trim()
if (allowed.has(tag) && !out.includes(tag)) out.push(tag)
}
return out
}
function sanitizeItem(raw: Record<string, unknown>): AIDraftItem {
const category = toStr(raw.category).trim()
const difficulty = toStr(raw.difficulty).trim()
const mastery = toStr(raw.mastery).trim()
return {
title: toStr(raw.title).trim(),
summary: toStr(raw.summary).trim(),
content: toStr(raw.content).trim(),
code_snippet: toStr(raw.code_snippet),
tags: sanitizeTags(raw.tags),
category: (AI_CATEGORY_ENUM as readonly string[]).includes(category) ? category : "其他",
difficulty: (AI_DIFFICULTY_ENUM as readonly string[]).includes(difficulty)
? (difficulty as AIDraftItem["difficulty"])
: "medium",
mastery: (AI_MASTERY_ENUM as readonly string[]).includes(mastery)
? (mastery as AIDraftItem["mastery"])
: "new",
source_url: toStr(raw.source_url).trim(),
notes: toStr(raw.notes).trim(),
}
}
/**
* Safely parse AI output into a list of draft knowledge items.
* - tolerant of markdown fences
* - validates items is an array
* - filters/normalizes enum fields with sensible fallbacks
* - drops items without a title
*/
export function safeParseAIJson(input: string): ParseResult {
const cleaned = stripFences(input)
let data: unknown
try {
data = JSON.parse(cleaned)
} catch {
return { ok: false, error: "无法解析 AI 返回的 JSON", raw: input }
}
const itemsRaw = (data as { items?: unknown })?.items
if (!Array.isArray(itemsRaw)) {
return { ok: false, error: 'JSON 顶层缺少数组字段 "items"', raw: input }
}
const items = itemsRaw
.filter((it): it is Record<string, unknown> => !!it && typeof it === "object")
.map(sanitizeItem)
.filter((it) => it.title.length > 0)
if (items.length === 0) {
return { ok: false, error: "未能从返回内容中提取到有效条目", raw: input }
}
return { ok: true, items }
}
+119
View File
@@ -0,0 +1,119 @@
import type { KnowledgeItem } from "@/lib/types"
import { CATEGORIES, DIFFICULTIES, MASTERY_LEVELS, TAGS } from "@/lib/types"
const ITEM_FIELDS = [
"title",
"summary",
"content",
"code_snippet",
"tags",
"category",
"difficulty",
"mastery",
"source_url",
"notes",
] as const
export interface BatchImportPromptOptions {
/** tags 可选枚举,默认取 lib/types 中的 TAGS */
tags?: readonly string[]
/** category 可选枚举,默认取 lib/types 中的 CATEGORIES */
categories?: readonly string[]
/** difficulty 可选枚举,默认取 lib/types 中的 DIFFICULTIES */
difficulties?: readonly string[]
/** mastery 默认值,默认取 MASTERY_LEVELS 的第一项 */
defaultMastery?: string
}
/**
* 根据类型定义生成批量导入提示词。
* 枚举字段全部从 lib/types 的常量派生,修改类型后提示词会自动同步。
*/
export function buildBatchImportPrompt(options: BatchImportPromptOptions = {}): string {
const {
tags = TAGS,
categories = CATEGORIES,
difficulties = DIFFICULTIES,
defaultMastery = MASTERY_LEVELS[0],
} = options
const fieldsList = ITEM_FIELDS.map((f) => ` - ${f}`).join("\n")
return `请把用户输入的多个前端题目整理成知识库 JSON。
要求:
1. 只返回严格 JSON。
2. JSON 顶层格式必须是:{ "items": [] }
3. 每个 item 必须包含:
${fieldsList}
4. tags 只能从这些枚举中选择:
${tags.join("、")}
5. category 只能从这些枚举中选择:
${categories.join("、")}
6. difficulty 只能是:
${difficulties.join("、")}
7. mastery 默认是 ${defaultMastery}
8. 如果题目适合写代码示例,请放入 code_snippet
9. content 要适合后续复习,结构清晰
10. 不要返回 Markdown,不要返回解释`
}
/** 默认批量导入提示词(使用 lib/types 中的枚举常量) */
export const BATCH_IMPORT_PROMPT = buildBatchImportPrompt()
export function buildItemContext(item: KnowledgeItem): string {
return `当前知识点:
标题:${item.title}
摘要:${item.summary ?? ""}
正文:${item.content ?? ""}
代码:${item.code_snippet ?? ""}
标签:${item.tags.join("、")}
难度:${item.difficulty}`
}
export function buildChatSystemPrompt(item: KnowledgeItem): string {
return `你正在帮助用户复习一个前端知识点。
${buildItemContext(item)}
请基于这个知识点回答用户问题。
回答要求:
- 解释清晰
- 尽量结合面试场景
- 必要时给代码示例
- 如果用户回答错误,要指出问题并给出正确理解
- 不要编造不存在的上下文`
}
export function buildOptimizePrompt(item: { title: string; summary: string; content: string }): string {
return `请优化下面这个前端知识点,使其更适合复习记忆。
只返回严格 JSON,格式为:{ "summary": "...", "content": "..." }
不要返回 Markdown,不要返回解释。
标题:${item.title}
当前摘要:${item.summary}
当前正文:${item.content}
要求:
- summary 为一句话精炼摘要
- content 结构清晰、分点、突出重点与易错点,适合反复复习`
}
export function buildCodeGenPrompt(item: { title: string; summary: string; content: string }): string {
return `请为下面这个前端知识点生成一段简洁、可运行、有代表性的示例代码。
只返回代码本身,不要返回 Markdown 代码块标记,不要返回解释。
标题:${item.title}
摘要:${item.summary}
正文:${item.content}`
}
export function buildInterviewPrompt(item: KnowledgeItem): string {
return `请把下面这个前端知识点整理成"面试回答版"。
要求:
- 模拟面试场景下口语化但专业的回答
- 先给结论,再展开原理,最后补充延伸/注意点
- 必要时给简短代码示例
- 不要返回 Markdown 代码块以外的多余解释
${buildItemContext(item)}`
}
+41
View File
@@ -0,0 +1,41 @@
import { DEFAULT_AI_SETTINGS, type AISettings } from "./types"
const STORAGE_KEY = "devvault.ai-settings"
export function loadAISettings(): AISettings {
if (typeof window === "undefined") return { ...DEFAULT_AI_SETTINGS }
try {
const raw = window.localStorage.getItem(STORAGE_KEY)
if (!raw) return { ...DEFAULT_AI_SETTINGS }
const parsed = JSON.parse(raw) as Partial<AISettings>
return { ...DEFAULT_AI_SETTINGS, ...parsed }
} catch {
return { ...DEFAULT_AI_SETTINGS }
}
}
export function saveAISettings(settings: AISettings): void {
if (typeof window === "undefined") return
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(settings))
}
export function clearApiKey(): AISettings {
const current = loadAISettings()
const next = { ...current, apiKey: "" }
saveAISettings(next)
return next
}
export function resetAISettings(): AISettings {
const next = { ...DEFAULT_AI_SETTINGS }
saveAISettings(next)
return next
}
/** Returns an error message if the settings are not usable for a request, otherwise null. */
export function validateForRequest(settings: AISettings): string | null {
if (!settings.baseUrl.trim()) return "请先在设置中填写 API Base URL"
if (!settings.model.trim()) return "请先在设置中填写 Model"
if (!settings.apiKey.trim()) return "请先在设置中填写 API Key"
return null
}
+79
View File
@@ -0,0 +1,79 @@
export type AISettings = {
baseUrl: string
apiKey: string
model: string
temperature: number
maxTokens: number
systemPrompt: string
stream: boolean
}
export type ChatMessage = {
role: "system" | "user" | "assistant"
content: string
}
export type RequestAIParams = {
baseUrl: string
apiKey: string
model: string
systemPrompt?: string
customPrompt?: string
messages: ChatMessage[]
temperature?: number
maxTokens?: number
stream?: boolean
signal?: AbortSignal
}
export const DEFAULT_AI_SETTINGS: AISettings = {
baseUrl: "https://api.openai.com/v1",
apiKey: "",
model: "gpt-4o-mini",
temperature: 0.3,
maxTokens: 4000,
stream: false,
systemPrompt: `你是一个前端学习助手,擅长把零散的前端题目、知识点、面试题整理成结构化知识库数据。
你必须返回严格 JSON,不要返回 Markdown,不要返回解释。`,
}
// Allowed enums for AI-generated knowledge items (per spec)
export const AI_TAG_ENUM = [
"八股",
"JavaScript",
"TypeScript",
"Vue",
"React",
"Next.js",
"CSS",
"HTML",
"浏览器",
"工程化",
"性能优化",
"算法",
"网络",
"Node.js",
"面试",
"项目经验",
"其他",
] as const
export const AI_CATEGORY_ENUM = ["基础", "框架", "工程化", "算法", "面试", "项目", "其他"] as const
export const AI_DIFFICULTY_ENUM = ["easy", "medium", "hard"] as const
export const AI_MASTERY_ENUM = ["new", "learning", "mastered"] as const
// Shape of a single item produced by the batch-import flow.
export type AIDraftItem = {
title: string
summary: string
content: string
code_snippet: string
tags: string[]
category: string
difficulty: (typeof AI_DIFFICULTY_ENUM)[number]
mastery: (typeof AI_MASTERY_ENUM)[number]
source_url: string
notes: string
}