AI 小说工坊:主编 + 作家多模型协作写作应用

- Nuxt 4 + Nuxt UI 4 + AI SDK 7 + Drizzle/SQLite
- 主编规划大纲与章节简报、选派作家;作家流式写入章节文件并实时推送前端
- 逐章确认模式(默认)与自动模式;摘要与故事记忆
- 酒馆角色卡(PNG/JSON,V1-V3)与世界书导入
- 自定义最高优先级系统提示词、审稿/修改/多版本、任务日志与提示词检视
- 模拟 LLM 服务与端到端测试脚本

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jocay
2026-10-18 02:04:40 +08:00
commit 3c9b65d982
106 changed files with 16286 additions and 0 deletions
+97
View File
@@ -0,0 +1,97 @@
import { createAnthropic } from '@ai-sdk/anthropic'
import { createGoogle } from '@ai-sdk/google'
import { createOpenAICompatible } from '@ai-sdk/openai-compatible'
import type { LanguageModel } from 'ai'
import { eq } from 'drizzle-orm'
import type { ProviderType } from '#shared/types'
type ProviderRow = typeof tables.providers.$inferSelect
type ModelRow = typeof tables.models.$inferSelect
export const DEFAULT_BASE_URL: Record<ProviderType, string> = {
'openai-compatible': 'https://api.openai.com/v1',
'anthropic': 'https://api.anthropic.com/v1',
'google': 'https://generativelanguage.googleapis.com/v1beta'
}
function trimUrl(url: string) {
return url.trim().replace(/\/+$/, '')
}
export function createLanguageModel(provider: ProviderRow, modelId: string): LanguageModel {
const apiKey = decryptSecret(provider.apiKeyEnc)
const baseURL = trimUrl(provider.baseUrl) || DEFAULT_BASE_URL[provider.type]
switch (provider.type) {
case 'anthropic':
return createAnthropic({ apiKey, baseURL })(modelId)
case 'google':
return createGoogle({ apiKey, baseURL })(modelId)
default:
return createOpenAICompatible({ name: 'openai-compatible', apiKey, baseURL, includeUsage: true })(modelId)
}
}
export interface ResolvedModel {
model: LanguageModel
label: string
settings: { temperature?: number, topP?: number, maxOutputTokens?: number }
}
export function resolveModel(modelRowId: string | null | undefined): ResolvedModel {
if (!modelRowId) throw createError({ statusCode: 400, message: '该成员尚未指定模型,请先在「AI 团队」里选择模型' })
const db = useDb()
const row = db.select().from(tables.models).where(eq(tables.models.id, modelRowId)).get()
if (!row) throw createError({ statusCode: 400, message: '指定的模型不存在,可能已被删除' })
const provider = db.select().from(tables.providers).where(eq(tables.providers.id, row.providerId)).get()
if (!provider) throw createError({ statusCode: 400, message: '模型所属的 API 提供方不存在' })
return {
model: createLanguageModel(provider, row.modelId),
label: `${provider.name} / ${row.label || row.modelId}`,
settings: modelSettings(row)
}
}
export function modelSettings(row: ModelRow) {
const s: ResolvedModel['settings'] = {}
if (row.temperature != null) s.temperature = row.temperature
if (row.topP != null) s.topP = row.topP
if (row.maxOutputTokens != null) s.maxOutputTokens = row.maxOutputTokens
return s
}
/** 从提供方拉取可用模型列表 */
export async function listRemoteModels(type: ProviderType, baseUrl: string, apiKey: string): Promise<string[]> {
const base = trimUrl(baseUrl) || DEFAULT_BASE_URL[type]
if (type === 'anthropic') {
const res = await $fetch<{ data: { id: string }[] }>(`${base}/models`, {
headers: { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' },
query: { limit: 1000 }
})
return res.data.map(m => m.id)
}
if (type === 'google') {
const res = await $fetch<{ models: { name: string, supportedGenerationMethods?: string[] }[] }>(`${base}/models`, {
headers: { 'x-goog-api-key': apiKey },
query: { pageSize: 1000 }
})
return res.models
.filter(m => !m.supportedGenerationMethods || m.supportedGenerationMethods.includes('generateContent'))
.map(m => m.name.replace(/^models\//, ''))
}
const res = await $fetch<{ data: { id: string }[] }>(`${base}/models`, {
headers: { Authorization: `Bearer ${apiKey}` }
})
return res.data.map(m => m.id).sort()
}
/** 把 AI SDK / HTTP 错误整理成用户能看懂的信息 */
export function describeError(err: unknown): string {
if (!err) return '未知错误'
const e = err as { name?: string, message?: string, statusCode?: number, responseBody?: string, data?: unknown, cause?: unknown }
if (e.name === 'AbortError') return '已取消'
let msg = e.message || String(err)
if (e.statusCode) msg = `[HTTP ${e.statusCode}] ${msg}`
if (e.responseBody) msg += `\n${String(e.responseBody).slice(0, 800)}`
else if (e.data) msg += `\n${JSON.stringify(e.data).slice(0, 800)}`
return msg
}