init: F9智慧缫丝系统 - 后端(server) + 主前端(web) + 打印设计器(openprint) 首次提交

This commit is contained in:
2026-08-15 15:37:25 +08:00
commit 7235265749
294 changed files with 59182 additions and 0 deletions
+113
View File
@@ -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 应为 mmmargin 应有默认值
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) // 保持原值,未被偏移
})
})
+150
View File
@@ -0,0 +1,150 @@
/**
* AI 客户端 —— 纯前端直连 OpenAI 兼容 /chat/completionsSSE 流式)。
* 不依赖任何后端;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,直接返回单次 JSONchoices[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
}
+165
View File
@@ -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 }
}
+133
View File
@@ -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
}
+155
View File
@@ -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 可选。
· tablecolumns:[{ 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
}