init: F9智慧缫丝系统 - 后端(server) + 主前端(web) + 打印设计器(openprint) 首次提交
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { extractJson } from './generate'
|
||||
import { normalizeTemplate } from './normalize'
|
||||
import { validateTemplate } from '@/core/spec/validator'
|
||||
|
||||
describe('AI 核心:JSON 提取', () => {
|
||||
it('从 ```json 代码块提取', () => {
|
||||
const text = '说明一下:\n```json\n{"version":"1.0.0","document":{"type":"report"}}\n```'
|
||||
const r = extractJson(text) as Record<string, unknown>
|
||||
expect(r.version).toBe('1.0.0')
|
||||
})
|
||||
|
||||
it('从纯 JSON 提取', () => {
|
||||
const r = extractJson('{"a":1}') as Record<string, unknown>
|
||||
expect(r.a).toBe(1)
|
||||
})
|
||||
|
||||
it('从夹带散文的文本中提取首尾花括号', () => {
|
||||
const text = '好的,这是模板:{"document":{"type":"report","sections":[{"type":"body","components":[]}]}} 请查收'
|
||||
const r = extractJson(text) as Record<string, unknown>
|
||||
expect((r.document as Record<string, unknown>).type).toBe('report')
|
||||
})
|
||||
|
||||
it('无 JSON 返回 null', () => {
|
||||
expect(extractJson('没有模板')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('AI 核心:归一化 + 协议校验', () => {
|
||||
it('补齐缺省字段并产出可通过校验的模板', () => {
|
||||
const raw = {
|
||||
document: {
|
||||
page: { width: 100, height: 150, orientation: 'portrait' },
|
||||
sections: [
|
||||
{
|
||||
type: 'body',
|
||||
components: [
|
||||
{ type: 'text', left: 0, top: 0, width: 84, height: 12, value: '标题' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
const tpl = normalizeTemplate(raw)
|
||||
expect(tpl.version).toBe('1.0.0')
|
||||
expect(tpl.document.type).toBe('report')
|
||||
// 缺省 unit 应为 mm,margin 应有默认值
|
||||
expect(tpl.document.page.unit).toBe('mm')
|
||||
expect(tpl.document.page.margin.top).toBeGreaterThanOrEqual(0)
|
||||
// 控件被自动补 id
|
||||
const body = tpl.document.sections.find((s) => s.type === 'body')!
|
||||
expect(body.components![0]!.id).toBeTruthy()
|
||||
|
||||
const result = validateTemplate(tpl)
|
||||
expect(result.valid).toBe(true)
|
||||
})
|
||||
|
||||
it('横向页(width>height)推导 orientation', () => {
|
||||
const tpl = normalizeTemplate({
|
||||
document: {
|
||||
page: { width: 297, height: 210 },
|
||||
sections: [{ type: 'body', components: [] }],
|
||||
},
|
||||
})
|
||||
expect(tpl.document.page.orientation).toBe('landscape')
|
||||
})
|
||||
|
||||
it('坐标纠偏:page-origin 输出被还原为 content-relative', () => {
|
||||
const raw = {
|
||||
document: {
|
||||
page: { width: 100, height: 150, margin: { top: 8, bottom: 8, left: 8, right: 8 } },
|
||||
sections: [
|
||||
{
|
||||
type: 'body',
|
||||
components: [
|
||||
{ type: 'text', left: 8, top: 8, width: 84, height: 12, value: '标题' },
|
||||
{ type: 'text', left: 8, top: 30, width: 84, height: 8, value: '正文' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
const tpl = normalizeTemplate(raw)
|
||||
const body = tpl.document.sections.find((s) => s.type === 'body')!
|
||||
// 双轴最小坐标命中 margin(8) → 统一减去页边距,消除整页右移
|
||||
expect(body.components![0]!.left).toBe(0)
|
||||
expect(body.components![0]!.top).toBe(0)
|
||||
expect(body.components![1]!.top).toBeCloseTo(22, 6)
|
||||
const result = validateTemplate(tpl)
|
||||
expect(result.valid).toBe(true)
|
||||
})
|
||||
|
||||
it('坐标纠偏:正确 content-relative 模板不应被误伤', () => {
|
||||
const raw = {
|
||||
document: {
|
||||
page: { width: 100, height: 150, margin: { top: 8, bottom: 8, left: 8, right: 8 } },
|
||||
sections: [
|
||||
{
|
||||
type: 'body',
|
||||
components: [
|
||||
{ type: 'text', left: 0, top: 0, width: 84, height: 12, value: '标题' },
|
||||
{ type: 'text', left: 10, top: 20, width: 74, height: 8, value: '正文' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
const tpl = normalizeTemplate(raw)
|
||||
const body = tpl.document.sections.find((s) => s.type === 'body')!
|
||||
expect(body.components![0]!.left).toBe(0)
|
||||
expect(body.components![1]!.left).toBe(10) // 保持原值,未被偏移
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* AI 客户端 —— 纯前端直连 OpenAI 兼容 /chat/completions(SSE 流式)。
|
||||
* 不依赖任何后端;baseURL / apiKey / model 全部由用户在前端设置里提供。
|
||||
*/
|
||||
|
||||
export interface ChatMessage {
|
||||
role: 'system' | 'user' | 'assistant'
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface StreamOptions {
|
||||
baseURL: string
|
||||
apiKey: string
|
||||
model: string
|
||||
messages: ChatMessage[]
|
||||
onToken: (delta: string) => void
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
/** LLM 调用错误(携带 HTTP 状态码,便于前端给出友好提示) */
|
||||
export class AiRequestError extends Error {
|
||||
status: number
|
||||
constructor(status: number, message: string) {
|
||||
super(message)
|
||||
this.name = 'AiRequestError'
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
function friendlyError(status: number, body: string): string {
|
||||
if (status === 401) return 'API Key 无效或已过期,请检查设置中的 Key。'
|
||||
if (status === 404) return '接口地址不正确(请确认 baseURL 含 /v1 且路径为 /chat/completions)。'
|
||||
if (status === 429) return '请求过于频繁或额度不足(429)。'
|
||||
if (status >= 500) return `模型服务异常(HTTP ${status})。`
|
||||
// 跨域 / 网络层:fetch 抛 TypeError,没有 status
|
||||
const lower = body.toLowerCase()
|
||||
if (lower.includes('cors') || lower.includes('cross-origin')) {
|
||||
return '跨域(CORS)被拦截:浏览器直连该地址受限。可将 baseURL 改为你自己的代理(如 Cloudflare Worker / Vercel Edge),或在支持浏览器直连的模型服务上使用。'
|
||||
}
|
||||
return body ? `请求失败(HTTP ${status}):${body.slice(0, 200)}` : `请求失败(HTTP ${status})`
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式对话(SSE)。逐 token 回调 onToken,最终返回完整文本。
|
||||
* 兼容 OpenAI / DeepSeek / 通义 等 OpenAI 格式(data: {choices:[{delta:{content}}]})。
|
||||
*/
|
||||
export async function streamChat(opts: StreamOptions): Promise<string> {
|
||||
const url = `${opts.baseURL.replace(/\/+$/, '')}/chat/completions`
|
||||
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${opts.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: opts.model,
|
||||
messages: opts.messages,
|
||||
stream: true,
|
||||
temperature: 0.6,
|
||||
}),
|
||||
signal: opts.signal,
|
||||
})
|
||||
} catch (e) {
|
||||
// 网络层错误(含 CORS):fetch 抛 TypeError,无 status
|
||||
const reason = e instanceof Error ? e.message : String(e)
|
||||
if (reason.toLowerCase().includes('cors') || reason.toLowerCase().includes('cross-origin')) {
|
||||
throw new AiRequestError(
|
||||
0,
|
||||
'跨域(CORS)被拦截:浏览器直连该地址受限。可将 baseURL 改为你自己的代理(如 Cloudflare Worker / Vercel Edge),或在支持浏览器直连的模型服务上使用。',
|
||||
)
|
||||
}
|
||||
if (e instanceof DOMException && e.name === 'AbortError') throw e
|
||||
throw new AiRequestError(0, `无法连接模型服务:${reason}`)
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => '')
|
||||
throw new AiRequestError(res.status, friendlyError(res.status, txt))
|
||||
}
|
||||
|
||||
if (!res.body) {
|
||||
throw new AiRequestError(0, '响应缺少流式数据体。')
|
||||
}
|
||||
|
||||
const reader = res.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let full = ''
|
||||
let buffer = ''
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
|
||||
let nl: number
|
||||
while ((nl = buffer.indexOf('\n')) >= 0) {
|
||||
const line = buffer.slice(0, nl).trim()
|
||||
buffer = buffer.slice(nl + 1)
|
||||
if (!line || line.startsWith(':')) continue
|
||||
if (line === 'data: [DONE]') continue
|
||||
if (!line.startsWith('data:')) continue
|
||||
const payload = line.slice(5).trim()
|
||||
if (!payload) continue
|
||||
try {
|
||||
const obj = JSON.parse(payload)
|
||||
const delta: string | undefined = obj.choices?.[0]?.delta?.content
|
||||
if (delta) {
|
||||
full += delta
|
||||
opts.onToken(delta)
|
||||
}
|
||||
} catch {
|
||||
// 忽略不完整的分片
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// 确保 reader 被释放(中断时也清理)
|
||||
try {
|
||||
await reader.cancel()
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
|
||||
// 兜底:部分 OpenAI 兼容端点忽略 stream:true,直接返回单次 JSON(choices[0].message.content)。
|
||||
// 这种情况下上面没有触发 onToken,这里做一次补偿,保证仍能解析出模板文本。
|
||||
if (!full) {
|
||||
const trimmed = buffer.trim()
|
||||
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
||||
try {
|
||||
const obj = JSON.parse(trimmed) as {
|
||||
choices?: Array<{ message?: { content?: string }; text?: string }>
|
||||
}
|
||||
const content = obj.choices?.[0]?.message?.content ?? obj.choices?.[0]?.text
|
||||
if (content) {
|
||||
full = content
|
||||
opts.onToken(content)
|
||||
}
|
||||
} catch {
|
||||
/* 不是纯 JSON,忽略 */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return full
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* AI 生成编排 —— 组装提示词 → 流式调用 → 解析 JSON → 归一化 → 校验/修复。
|
||||
* 纯前端、一次性问答;不落库,结果由调用方决定如何应用。
|
||||
*/
|
||||
import type { AnyControl } from '@/types/control'
|
||||
import type { TemplateData } from '@/types/template'
|
||||
import { validateTemplate } from '@/core/spec/validator'
|
||||
import { streamChat, AiRequestError } from './client'
|
||||
import { buildSystemPrompt, buildUserPrompt, getFewShot } from './schema'
|
||||
import { normalizeControl, normalizeTemplate } from './normalize'
|
||||
import type { AiSettings } from '@/config/ai-settings'
|
||||
|
||||
export interface GenerateRequest {
|
||||
prompt: string
|
||||
/** 基于当前模板修改时传入 */
|
||||
currentTemplate?: TemplateData<AnyControl>
|
||||
/** 仅针对画布中选中的控件改写(C 功能):传入这些控件的协议 JSON */
|
||||
selectedControls?: AnyControl[]
|
||||
/** 数据字段接地(可选) */
|
||||
datasourceFields?: string[]
|
||||
settings: AiSettings
|
||||
onToken?: (delta: string) => void
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export interface GenerateResult {
|
||||
ok: boolean
|
||||
data?: TemplateData<AnyControl>
|
||||
/** 选区改写模式:返回的新控件集合(用于替换原选中控件) */
|
||||
controls?: AnyControl[]
|
||||
error?: string
|
||||
/** 模型原始输出(用于调试 / 展示) */
|
||||
raw?: string
|
||||
}
|
||||
|
||||
/** 从模型文本中提取 JSON(兼容 ```json 代码块 / 纯 JSON / 首尾花括号) */
|
||||
export function extractJson(text: string): unknown | null {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed) return null
|
||||
|
||||
// 1) 直接可解析
|
||||
try {
|
||||
return JSON.parse(trimmed)
|
||||
} catch {
|
||||
/* fallthrough */
|
||||
}
|
||||
|
||||
// 2) ```json ... ``` 代码块
|
||||
const fence = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i)
|
||||
if (fence?.[1]) {
|
||||
try {
|
||||
return JSON.parse(fence[1].trim())
|
||||
} catch {
|
||||
/* fallthrough */
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 第一个 { 到最后一个 }
|
||||
const first = trimmed.indexOf('{')
|
||||
const last = trimmed.lastIndexOf('}')
|
||||
if (first >= 0 && last > first) {
|
||||
try {
|
||||
return JSON.parse(trimmed.slice(first, last + 1))
|
||||
} catch {
|
||||
/* fallthrough */
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function fmtError(e: unknown): string {
|
||||
if (e instanceof AiRequestError) return e.message
|
||||
if (e instanceof DOMException && e.name === 'AbortError') return '已取消生成。'
|
||||
return e instanceof Error ? e.message : '未知错误'
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成模板。
|
||||
* - 普通 / 基于当前模板改:返回完整 TemplateData,失败时最多把校验错误回传模型重试一次。
|
||||
* - 选区改写(selectedControls):模型返回控件数组,归一化后返回 controls。
|
||||
*/
|
||||
export async function generateTemplate(req: GenerateRequest): Promise<GenerateResult> {
|
||||
const messages = [
|
||||
{ role: 'system' as const, content: buildSystemPrompt() },
|
||||
...getFewShot(),
|
||||
{ role: 'user' as const, content: buildUserPrompt(req) },
|
||||
]
|
||||
|
||||
// —— 选区改写模式:期望模型返回控件数组 ——
|
||||
if (req.selectedControls && req.selectedControls.length) {
|
||||
let raw = ''
|
||||
try {
|
||||
raw = await streamChat({
|
||||
baseURL: req.settings.baseURL,
|
||||
apiKey: req.settings.apiKey,
|
||||
model: req.settings.model,
|
||||
messages,
|
||||
onToken: (d) => req.onToken?.(d),
|
||||
signal: req.signal,
|
||||
})
|
||||
} catch (e) {
|
||||
return { ok: false, error: fmtError(e), raw }
|
||||
}
|
||||
const json = extractJson(raw)
|
||||
const arr = Array.isArray(json) ? json : []
|
||||
const controls = arr
|
||||
.map((c) => (typeof c === 'object' && c ? normalizeControl(c as Record<string, unknown>) : null))
|
||||
.filter((c): c is AnyControl => c !== null)
|
||||
if (!controls.length) {
|
||||
return { ok: false, error: '模型未返回有效的控件 JSON(应为控件数组)。', raw }
|
||||
}
|
||||
return { ok: true, controls, raw }
|
||||
}
|
||||
|
||||
// —— 完整模板模式 ——
|
||||
let raw = ''
|
||||
const MAX_ATTEMPTS = 2
|
||||
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
raw = await streamChat({
|
||||
baseURL: req.settings.baseURL,
|
||||
apiKey: req.settings.apiKey,
|
||||
model: req.settings.model,
|
||||
messages,
|
||||
onToken: (d) => req.onToken?.(d),
|
||||
signal: req.signal,
|
||||
})
|
||||
} catch (e) {
|
||||
return { ok: false, error: fmtError(e), raw }
|
||||
}
|
||||
|
||||
const json = extractJson(raw)
|
||||
if (!json) {
|
||||
return { ok: false, error: '模型未返回有效的模板 JSON。', raw }
|
||||
}
|
||||
|
||||
const normalized = normalizeTemplate(json)
|
||||
const result = validateTemplate(normalized)
|
||||
if (result.valid) {
|
||||
return { ok: true, data: normalized, raw }
|
||||
}
|
||||
|
||||
// 重试:把校验错误反馈给模型
|
||||
if (attempt < MAX_ATTEMPTS - 1) {
|
||||
const issues = result.issues
|
||||
.map((i) => `${i.path || '/'}:${i.message}`)
|
||||
.join(';')
|
||||
messages.push({ role: 'assistant', content: raw })
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: `你的输出未通过模板协议校验,请修正后只输出正确 JSON。错误:${issues}`,
|
||||
})
|
||||
raw = ''
|
||||
continue
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: `模板校验失败:${result.issues
|
||||
.map((i) => `${i.path || '/'}: ${i.message}`)
|
||||
.join(';')}`,
|
||||
raw,
|
||||
}
|
||||
}
|
||||
return { ok: false, error: '生成失败(超出重试次数)。', raw }
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* AI 输出归一化 —— 把模型返回的「接近协议」的 JSON 修成可通过校验的 TemplateData。
|
||||
* 设计器 loadTemplate 也会兜底缺 id,这里提前补齐让校验更稳。
|
||||
*/
|
||||
import type { AnyControl, ControlType } from '@/types/control'
|
||||
import type { TemplateData } from '@/types/template'
|
||||
|
||||
const VALID_TYPES: ControlType[] = [
|
||||
'text',
|
||||
'image',
|
||||
'table',
|
||||
'barcode',
|
||||
'qrcode',
|
||||
'richtext',
|
||||
'rect',
|
||||
'line',
|
||||
'zone',
|
||||
]
|
||||
|
||||
function genId(prefix = 'ctrl'): string {
|
||||
return `${prefix}_${Math.random().toString(36).slice(2, 10)}`
|
||||
}
|
||||
|
||||
function num(v: unknown, fallback: number): number {
|
||||
return typeof v === 'number' && Number.isFinite(v) ? v : fallback
|
||||
}
|
||||
|
||||
function round1(v: number): number {
|
||||
return Math.round(v * 10) / 10
|
||||
}
|
||||
|
||||
export function normalizeControl(raw: Record<string, unknown>): AnyControl | null {
|
||||
const type = raw.type as ControlType
|
||||
if (!VALID_TYPES.includes(type)) return null
|
||||
const base = {
|
||||
id: typeof raw.id === 'string' && raw.id ? raw.id : genId(type),
|
||||
type,
|
||||
left: round1(num(raw.left, 0)),
|
||||
top: round1(num(raw.top, 0)),
|
||||
width: round1(Math.max(num(raw.width, 10), 1)),
|
||||
height: round1(Math.max(num(raw.height, 6), 1)),
|
||||
}
|
||||
const extra: Record<string, unknown> = { ...raw }
|
||||
delete extra.id
|
||||
delete extra.type
|
||||
delete extra.left
|
||||
delete extra.top
|
||||
delete extra.width
|
||||
delete extra.height
|
||||
return { ...base, ...extra } as AnyControl
|
||||
}
|
||||
|
||||
function normalizeSection(raw: Record<string, unknown>): Record<string, unknown> | null {
|
||||
const type = raw.type
|
||||
if (type !== 'header' && type !== 'body' && type !== 'footer') return null
|
||||
const components = Array.isArray(raw.components)
|
||||
? (raw.components as Record<string, unknown>[])
|
||||
.map(normalizeControl)
|
||||
.filter((c): c is AnyControl => c !== null)
|
||||
: []
|
||||
return {
|
||||
type,
|
||||
...(type !== 'body' ? { height: num(raw.height, 20) } : {}),
|
||||
repeat: raw.repeat === false ? false : true,
|
||||
components,
|
||||
}
|
||||
}
|
||||
|
||||
/** 归一化整份模板 */
|
||||
export function normalizeTemplate(raw: unknown): TemplateData<AnyControl> {
|
||||
const obj = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
|
||||
const doc = (obj.document && typeof obj.document === 'object'
|
||||
? obj.document
|
||||
: {}) as Record<string, unknown>
|
||||
const page = (doc.page && typeof doc.page === 'object' ? doc.page : {}) as Record<string, unknown>
|
||||
const sectionsRaw = Array.isArray(doc.sections) ? (doc.sections as Record<string, unknown>[]) : []
|
||||
|
||||
const sections = sectionsRaw
|
||||
.map(normalizeSection)
|
||||
.filter((s): s is Record<string, unknown> => s !== null)
|
||||
|
||||
// 至少保证有一个 body
|
||||
if (!sections.some((s) => s.type === 'body')) {
|
||||
sections.push({ type: 'body', components: [] })
|
||||
}
|
||||
|
||||
// —— 坐标纠偏:把「相对页面(page-origin)」的 AI 输出还原为「相对内容区(content-relative)」 ——
|
||||
// 触发条件:某节内控件的最小 left 接近 margin.left 且最小 top 接近 margin.top(即模型把页边距也算进了坐标)。
|
||||
// 此时统一减去页边距,避免整页内容向右下偏移一个 margin。正确生成的模板 minLeft/minTop≈0,不会触发。
|
||||
const ml = num((page.margin as Record<string, unknown>)?.left, 10)
|
||||
const mt = num((page.margin as Record<string, unknown>)?.top, 10)
|
||||
const EPS = 2
|
||||
for (const section of sections) {
|
||||
const comps = (section.components as AnyControl[] | undefined) ?? []
|
||||
if (!comps.length) continue
|
||||
const minLeft = Math.min(...comps.map((c) => c.left))
|
||||
const minTop = Math.min(...comps.map((c) => c.top))
|
||||
if (minLeft > 1 && Math.abs(minLeft - ml) <= EPS && minTop > 1 && Math.abs(minTop - mt) <= EPS) {
|
||||
for (const c of comps) {
|
||||
c.left = round1(c.left - ml)
|
||||
c.top = round1(c.top - mt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const width = num(page.width, 210)
|
||||
const height = num(page.height, 297)
|
||||
|
||||
const normalized: TemplateData<AnyControl> = {
|
||||
version: typeof obj.version === 'string' ? obj.version : '1.0.0',
|
||||
document: {
|
||||
type: 'report',
|
||||
page: {
|
||||
width,
|
||||
height,
|
||||
unit: page.unit === 'in' || page.unit === 'pt' ? page.unit : 'mm',
|
||||
orientation:
|
||||
page.orientation === 'landscape' || width > height ? 'landscape' : 'portrait',
|
||||
margin: {
|
||||
top: num((page.margin as Record<string, unknown>)?.top, 10),
|
||||
bottom: num((page.margin as Record<string, unknown>)?.bottom, 10),
|
||||
left: num((page.margin as Record<string, unknown>)?.left, 10),
|
||||
right: num((page.margin as Record<string, unknown>)?.right, 10),
|
||||
},
|
||||
...(typeof page.backgroundColor === 'string'
|
||||
? { backgroundColor: page.backgroundColor }
|
||||
: {}),
|
||||
},
|
||||
sections: sections as unknown as TemplateData<AnyControl>['document']['sections'],
|
||||
},
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* AI 助手 —— 前端预设的提示词与 few-shot 示例(无后端、纯常量)。
|
||||
*
|
||||
* 协议要点(节选自项目《OpenPrint-设计方案》§5):
|
||||
* TemplateData = { version, document }
|
||||
* document = { type:'report', page, sections:[...] }
|
||||
* page = { width, height, unit:'mm', orientation:'portrait'|'landscape',
|
||||
* margin:{top,bottom,left,right}, backgroundColor?, watermark? }
|
||||
* section = { type:'header'|'body'|'footer', height?, repeat?, components:[...] }
|
||||
* component 公共:{ id?, type, left, top, width, height, angle?, printable?, ... }
|
||||
* 坐标系:mm。⚠️ left/top 是相对「内容区(页边距内侧)左上角」的偏移,渲染器会自动叠加页边距——你不要再算 margin 进去。
|
||||
*/
|
||||
|
||||
const PROTOCOL_SUMMARY = `你是 OpenPrint 打印模板设计助手。根据用户用中文描述的排版需求,生成一份「打印模板」JSON。
|
||||
|
||||
模板协议(必须遵守):
|
||||
- 顶层:{ "version": "1.0.0", "document": { "type": "report", "page": {...}, "sections": [...] } }
|
||||
- page:{ "width": 数字, "height": 数字, "unit": "mm", "orientation": "portrait"|"landscape", "margin": { "top": 数字, "bottom": 数字, "left": 数字, "right": 数字 }, "backgroundColor"?: "#ffffff" }。横向时 width > height。常用纸张:A4 纵向 210×297、A4 横向 297×210、A5 纵向 148×210。
|
||||
- sections:数组。必须且只能有一个 type:"body";可选 type:"header" / "footer"(页眉页脚,带 height)。
|
||||
- 控件通用字段:type 必填;left/top/width/height 必填(单位 mm,原点 = 内容区左上角,即已扣除页边距);id 可省略(系统自动补)。
|
||||
- 控件类型与要点:
|
||||
· text:静态文字用 value;如需绑数据用 binding(字段路径)或 expression(如 "{{order.total}}")。style 可选:{ fontSize(pt), fill(hex), fontWeight:'bold', textAlign:'left'|'center'|'right', lineHeight }。
|
||||
· image:{ value:{ mode:'url'|'binding', content } },fit 可选。
|
||||
· table:columns:[{ title, field?, width, align? }],options 可选(borders/zebra 等),dataSource 留空表示布局网格。
|
||||
· barcode:{ value, format:'CODE128'|'EAN13'..., showText? };qrcode:{ value, errorLevel? }。
|
||||
· rect / line:{ fill?, stroke?, strokeWidth?, cornerRadius? }。
|
||||
· richtext:{ value: "<p>HTML</p>" }(仅可信静态内容)。
|
||||
|
||||
设计原则:
|
||||
- 间距与对齐要规整(建议控件左/右贴边留白一致,关键元素水平居中)。
|
||||
- 坐标铁律:left/top 是相对「内容区(页边距内侧)左上角」的 mm,渲染器会再叠加页边距,所以你写的坐标【不要包含 margin】。给定页面 width/height 与 margin 后,内容区宽 = width - margin.left - margin.right、高 = height - margin.top - margin.bottom;占满整行写 left:0, width:内容区宽,水平居中配合 style.textAlign:'center'。严禁出现 left 等于 margin.left、top 等于 margin.top 这类把页边距算进坐标的写法。
|
||||
- 只输出「一段简要中文说明(不超过 3 句)」+ 一个 \`\`\`json 代码块包裹的完整模板。不要输出其他内容。
|
||||
- 不依赖真实数据:文字用静态 value;除非用户明确给出字段,否则不要写 binding。
|
||||
- 颜色用 hex(如 #1677ff、#333333)或 rgba。`
|
||||
|
||||
const FEW_SHOT: Array<{ role: 'user' | 'assistant'; content: string }> = [
|
||||
{
|
||||
role: 'user',
|
||||
content: '做一个竖向快递面单,100×150mm,上方标题"快递面单",中间收件人姓名电话和地址,底部一个条码和右下角一个二维码。',
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: `好的,已生成 100×150mm 的竖向快递面单:标题居中、收件信息分区、底部条码 + 右下角二维码。
|
||||
(坐标均为相对内容区:页面 100×150、四周 margin=8,内容区宽 = 100-8-8 = 84)
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"document": {
|
||||
"type": "report",
|
||||
"page": {
|
||||
"width": 100, "height": 150, "unit": "mm", "orientation": "portrait",
|
||||
"margin": { "top": 8, "bottom": 8, "left": 8, "right": 8 },
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"sections": [
|
||||
{
|
||||
"type": "body",
|
||||
"components": [
|
||||
{ "type": "text", "left": 0, "top": 2, "width": 84, "height": 12, "value": "快递面单", "style": { "fontSize": 20, "fontWeight": "bold", "textAlign": "center" } },
|
||||
{ "type": "text", "left": 0, "top": 18, "width": 84, "height": 8, "value": "收件人:张三 13800000000", "style": { "fontSize": 11 } },
|
||||
{ "type": "text", "left": 0, "top": 30, "width": 84, "height": 30, "value": "地址:广东省深圳市南山区科技园路 1 号 XX 大厦 10 层", "style": { "fontSize": 11 } },
|
||||
{ "type": "text", "left": 0, "top": 64, "width": 84, "height": 8, "value": "托寄物:文件 到付", "style": { "fontSize": 11, "fill": "#555555" } },
|
||||
{ "type": "barcode", "left": 0, "top": 78, "width": 84, "height": 22, "value": "SF1234567890", "format": "CODE128", "showText": true },
|
||||
{ "type": "qrcode", "left": 52, "top": 104, "width": 32, "height": 28, "value": "https://example.com/order/SF1234567890" },
|
||||
{ "type": "text", "left": 0, "top": 104, "width": 48, "height": 28, "value": "请于签收前核对信息", "style": { "fontSize": 10, "fill": "#888888" } }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
\`\`\``,
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: '做一个横向会员卡,90×54mm,左侧品牌名和会员等级,右侧会员姓名和大号卡号,底部一条分割线。',
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: `已生成 90×54mm 横向会员卡:左侧品牌与等级、右侧姓名与卡号、底部分割线。
|
||||
(坐标相对内容区:页面 90×54、四周 margin=6,内容区宽 = 90-6-6 = 78)
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"document": {
|
||||
"type": "report",
|
||||
"page": {
|
||||
"width": 90, "height": 54, "unit": "mm", "orientation": "landscape",
|
||||
"margin": { "top": 6, "bottom": 6, "left": 6, "right": 6 },
|
||||
"backgroundColor": "#0c447c"
|
||||
},
|
||||
"sections": [
|
||||
{
|
||||
"type": "body",
|
||||
"components": [
|
||||
{ "type": "text", "left": 0, "top": 4, "width": 40, "height": 10, "value": "STAR 会员", "style": { "fontSize": 16, "fontWeight": "bold", "fill": "#ffffff" } },
|
||||
{ "type": "text", "left": 0, "top": 18, "width": 40, "height": 8, "value": "钻石会员", "style": { "fontSize": 11, "fill": "#9fc3ff" } },
|
||||
{ "type": "text", "left": 46, "top": 6, "width": 32, "height": 10, "value": "李雷", "style": { "fontSize": 15, "fontWeight": "bold", "fill": "#ffffff", "textAlign": "right" } },
|
||||
{ "type": "text", "left": 46, "top": 20, "width": 32, "height": 10, "value": "NO. 8821 0043", "style": { "fontSize": 14, "fill": "#ffffff", "textAlign": "right", "letterSpacing": 1 } },
|
||||
{ "type": "line", "left": 0, "top": 34, "width": 78, "height": 0.4, "stroke": "#ffffff", "strokeWidth": 0.4 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
\`\`\``,
|
||||
},
|
||||
]
|
||||
|
||||
/** 组装 system 提示词 */
|
||||
export function buildSystemPrompt(): string {
|
||||
return PROTOCOL_SUMMARY
|
||||
}
|
||||
|
||||
/** 组装用户提示词(支持「基于当前模板修改」「仅选中控件改写」「数据字段接地」) */
|
||||
export function buildUserPrompt(opts: {
|
||||
prompt: string
|
||||
currentTemplate?: unknown
|
||||
selectedControls?: unknown[]
|
||||
datasourceFields?: string[]
|
||||
}): string {
|
||||
const parts: string[] = []
|
||||
if (opts.datasourceFields && opts.datasourceFields.length) {
|
||||
parts.push(`可用数据字段(如用户要求绑定真实数据,请优先使用这些路径):\n${opts.datasourceFields.join('、')}\n`)
|
||||
}
|
||||
if (opts.selectedControls && opts.selectedControls.length) {
|
||||
parts.push(
|
||||
`下面是一组「用户当前选中的控件」(坐标 left/top 是相对内容区左上角的 mm)。\n` +
|
||||
`请按需求改写这些选中控件并【只输出一个控件 JSON 数组】,要求:\n` +
|
||||
`· 必须保留每个原控件的 id(用于原位替换),不要改变其 type;\n` +
|
||||
`· 可调整 left/top/width/height/value/style 等,实现重排、对齐、换风格、统一间距等;\n` +
|
||||
`· 可新增控件(给新的唯一 id,type 合法),也可删除某些选中控件(不输出即可);\n` +
|
||||
`· 坐标仍是相对内容区(已扣除页边距),不要再加 margin;\n` +
|
||||
`· 只输出控件数组,不要包装成完整模板。\n` +
|
||||
'```json\n' +
|
||||
JSON.stringify(opts.selectedControls, null, 2) +
|
||||
'\n```',
|
||||
)
|
||||
} else if (opts.currentTemplate) {
|
||||
parts.push(
|
||||
`请在下面这份「当前模板 JSON」的基础上按需求修改,输出完整的新模板(不要只输出 diff):\n` +
|
||||
'```json\n' +
|
||||
JSON.stringify(opts.currentTemplate, null, 2) +
|
||||
'\n```',
|
||||
)
|
||||
}
|
||||
parts.push(`需求:${opts.prompt}`)
|
||||
return parts.join('\n\n')
|
||||
}
|
||||
|
||||
/** few-shot 示例(user/assistant 成对) */
|
||||
export function getFewShot(): Array<{ role: 'user' | 'assistant'; content: string }> {
|
||||
return FEW_SHOT
|
||||
}
|
||||
Reference in New Issue
Block a user