@@ -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
|
||||
}
|
||||
@@ -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
@@ -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 }
|
||||
}
|
||||
@@ -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)}`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user