diff --git a/k8s/cert-manager-issuer.yaml b/k8s/cert-manager-issuer.yaml new file mode 100644 index 0000000..b78a0e3 --- /dev/null +++ b/k8s/cert-manager-issuer.yaml @@ -0,0 +1,15 @@ +# ClusterIssuer for Let's Encrypt automatic certificate generation & renewal +apiVersion: cert-manager.io/v1 +kind: ClusterIssuer +metadata: + name: letsencrypt-prod +spec: + acme: + server: https://acme-v02.api.letsencrypt.org/directory + email: airlabsv001@gmail.com + privateKeySecretRef: + name: letsencrypt-prod-key + solvers: + - http01: + ingress: + class: traefik diff --git a/k8s/ingress.yaml b/k8s/ingress.yaml new file mode 100644 index 0000000..c31894e --- /dev/null +++ b/k8s/ingress.yaml @@ -0,0 +1,24 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: airshelf-ingress + annotations: + kubernetes.io/ingress.class: "traefik" + cert-manager.io/cluster-issuer: "letsencrypt-prod" + traefik.ingress.kubernetes.io/router.middlewares: "default-redirect-https@kubernetescrd" +spec: + tls: + - hosts: + - airshelf.airlabs.art + secretName: airshelf-tls + rules: + - host: airshelf.airlabs.art + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: airshelf-web + port: + number: 80 diff --git a/k8s/redirect-https-middleware.yaml b/k8s/redirect-https-middleware.yaml new file mode 100644 index 0000000..e5eedb9 --- /dev/null +++ b/k8s/redirect-https-middleware.yaml @@ -0,0 +1,8 @@ +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: redirect-https +spec: + redirectScheme: + scheme: https + permanent: true diff --git a/k8s/web-deployment.yaml b/k8s/web-deployment.yaml new file mode 100644 index 0000000..ff9091f --- /dev/null +++ b/k8s/web-deployment.yaml @@ -0,0 +1,59 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: airshelf-web + labels: + app: airshelf-web +spec: + replicas: 1 + selector: + matchLabels: + app: airshelf-web + template: + metadata: + labels: + app: airshelf-web + spec: + imagePullSecrets: + - name: cr-pull-secret + containers: + - name: airshelf-web + image: ${CI_REGISTRY_IMAGE}/airshelf-web:latest + imagePullPolicy: Always + ports: + - containerPort: 80 + livenessProbe: + httpGet: + path: / + port: 80 + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + readinessProbe: + httpGet: + path: / + port: 80 + initialDelaySeconds: 5 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 3 + resources: + requests: + memory: "32Mi" + cpu: "20m" + limits: + memory: "128Mi" + cpu: "150m" +--- +apiVersion: v1 +kind: Service +metadata: + name: airshelf-web +spec: + selector: + app: airshelf-web + ports: + - protocol: TCP + port: 80 + targetPort: 80 diff --git a/电商AI平台/pipeline.html b/电商AI平台/pipeline.html index ec61853..cb41e78 100644 --- a/电商AI平台/pipeline.html +++ b/电商AI平台/pipeline.html @@ -438,6 +438,12 @@ } /* 镜头脚本顶栏 · 自动从脚本抓取的人物/场景标签 · 可编辑/删除/添加 */ + .shot-list > .pane-h { flex-wrap: wrap; row-gap: 8px; } + .shot-headline { display: inline-flex; align-items: center; gap: 8px; min-width: 0; } + .script-brief-summary { display: inline-flex; align-items: center; gap: 6px; flex-wrap: wrap; min-width: 0; } + .script-brief-pill { gap: 4px; padding: 3px 8px; font-size: 11px; } + .script-brief-pill .k { font-family: var(--font-mono); font-size: 10px; color: var(--black-alpha-48); letter-spacing: .04em; } + .script-brief-pill .v { color: var(--accent-black); max-width: 116px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .script-tags { display: inline-flex; align-items: center; flex-wrap: wrap; gap: 14px; margin-left: 6px; } .script-tags .tag-group { display: inline-flex; align-items: center; gap: 6px; flex-wrap: wrap; } .script-tags .tg-lbl { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .04em; text-transform: uppercase; flex-shrink: 0; } @@ -465,6 +471,61 @@ .chat-mode:hover { background: var(--heat-12); border-color: var(--heat); color: var(--heat); } .chat-mode.primary { background: var(--heat-12); border-color: var(--heat); color: var(--heat); } .chat-mode svg { width: 13px; height: 13px; } + .script-brief-card { margin-top: 8px; padding: 12px; background: var(--background-base); border: 1px solid var(--border-faint); border-radius: var(--r-md); display: flex; flex-direction: column; gap: 10px; } + .script-brief-row { display: grid; grid-template-columns: 56px minmax(0, 1fr); column-gap: 10px; row-gap: 4px; align-items: center; } + .script-brief-row .k { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .04em; } + .script-brief-row .why { grid-column: 2 / 3; font-size: 11.5px; color: var(--black-alpha-48); line-height: 1.5; } + .script-brief-select { position: relative; display: inline-flex; width: 100%; min-width: 0; } + .script-brief-value { + width: 100%; + min-width: 0; + height: 36px; + padding: 0 12px; + border: 1px solid var(--border-faint); + border-radius: var(--r-md); + background: var(--surface); + color: var(--accent-black); + font-size: 13px; + font-weight: 500; + font-family: inherit; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: space-between; + gap: 8px; + transition: background var(--t-base), border-color var(--t-base), color var(--t-base); + } + .script-brief-value .v { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left; } + .script-brief-value::after { + content: ''; + width: 5px; + height: 5px; + border-right: 1px solid currentColor; + border-bottom: 1px solid currentColor; + transform: rotate(45deg) translateY(-1px); + transition: transform var(--t-base); + color: var(--black-alpha-48); + flex-shrink: 0; + } + .script-brief-value:hover { + background: var(--heat-12); + border-color: var(--heat-20); + color: var(--heat); + } + .script-brief-select.open .script-brief-value { + background: var(--heat-12); + border-color: var(--heat); + color: var(--heat); + } + .script-brief-select.open .script-brief-value::after { + color: var(--heat); + transform: rotate(225deg) translate(-1px, -1px); + } + .script-brief-select.open .chip-menu { display: block; } + .script-brief-select .chip-menu { min-width: 168px; right: 0; left: auto; z-index: 80; } + .script-brief-select .chip-menu .mi { width: 100%; border: 0; background: transparent; font-family: inherit; text-align: left; } + .script-brief-actions { display: grid; grid-template-columns: 56px minmax(0, 1fr); column-gap: 10px; align-items: center; padding-top: 2px; } + .script-brief-actions .action-row { grid-column: 2 / 3; display: flex; align-items: center; justify-content: space-between; gap: 8px; min-width: 0; } /* AI 思考态 typing indicator */ .ai-thinking .dots { display: inline-flex; gap: 3px; } @@ -1626,8 +1687,15 @@
- 镜头脚本 - · 空 · 待生成 +
+ 镜头脚本 + · 空 · 待生成 +
+
+ 来源未选择 + 风格待确认 + 人物待确认 +
// 人物 @@ -1662,7 +1730,7 @@
- +
+ + ${BRIEF_OPTIONS[kind].map(opt => ` + `).join('')} + + `; + return `${intro} +
+
+ 风格 + ${optionMenu('style', scriptBrief.style)} + ${safeHtml(scriptBrief.styleNote)} +
+
+ 人物 + ${optionMenu('persona', scriptBrief.persona)} + ${safeHtml(scriptBrief.personaNote)} +
+
+
+ + +
+
+
`; + } + function migrateBriefMessages() { + let changed = false; + chatMsgs = chatMsgs.map(msg => { + if (!msg || typeof msg.html !== 'string') return msg; + if (msg.role === 'ai' && /script-brief-card/.test(msg.html)) { + const cardIndex = msg.html.indexOf('
-1 ? msg.html.slice(0, cardIndex).trim() : ''; + changed = true; + return { + ...msg, + html: briefConfirmHtml(intro || '已更新创作方向。确认后我会按这个方向重写镜头脚本。'), + }; + } + if (msg.role === 'user' && msg.html === '保持推荐,生成镜头脚本') { + changed = true; + return { ...msg, html: '确定' }; + } + return msg; + }); + return changed; + } + function tuneBriefByText(text) { + if (/小红书|种草/.test(text)) scriptBrief.style = '小红书种草'; + else if (/测评|评测/.test(text)) scriptBrief.style = '真实测评'; + else if (/痛点/.test(text)) scriptBrief.style = '痛点种草'; + if (/熬夜/.test(text)) scriptBrief.persona = '熬夜党通勤女性'; + else if (/学生/.test(text)) scriptBrief.persona = '学生党女生'; + else if (/宝妈|妈妈/.test(text)) scriptBrief.persona = '精致宝妈'; + } + function startScriptGeneration() { + ProjectStore.startJob('stage1-script', { + stage: 1, + label: '脚本初稿生成', + finishAt: Date.now() + 6500, + }); + if (!chatMsgs.some(x => /正在解析商品卖点/.test(x.html))) { + pushMsg('ai', '正在解析商品卖点与创作方向 '); + } + ProjectStore.record('stage1.script.generate', { mode, brief: scriptBrief }); + saveState(); + renderChat(); + window.setTimeout(completeAiJob, 6500); + } + function refreshLatestBriefMessage(intro) { + for (let i = chatMsgs.length - 1; i >= 0; i--) { + if (chatMsgs[i].role === 'ai' && /script-brief-card/.test(chatMsgs[i].html)) { + chatMsgs[i].html = briefConfirmHtml(intro); + return; + } + } + pushMsg('ai', briefConfirmHtml(intro)); + } + function handleBriefPick(kind, value) { + if (!value) return; + if (kind === 'style') { + scriptBrief.style = value; + scriptBrief.styleNote = '已手动选择'; + } else if (kind === 'persona') { + scriptBrief.persona = value; + scriptBrief.personaNote = '已手动选择'; + } + refreshLatestBriefMessage('已更新创作方向。确认后我会按这个方向重写镜头脚本。'); + ProjectStore.record('stage1.brief.option.selected', { kind, value }); + saveState(); + renderChat(); + } + function handleBriefAction(action, trigger) { + if (action === 'accept') { + pushMsg('user', '确定'); + startScriptGeneration(); + } else if (action === 'reroll') { + scriptBrief = { + ...scriptBrief, + style: scriptBrief.style === '真实测评' ? '痛点种草' : '真实测评', + persona: scriptBrief.persona === '通勤敏感肌女生' ? '熬夜党通勤女性' : '通勤敏感肌女生', + styleNote: '已根据商品卖点重新推荐', + personaNote: '已根据使用场景重新推荐', + }; + pushMsg('ai', briefConfirmHtml('我重新给你配了一组创作方向,确认后就生成镜头脚本。')); + saveState(); + renderChat(); + } else if (action === 'style') { + const box = trigger?.closest('.script-brief-select'); + if (!box) return; + document.querySelectorAll('.script-brief-select.open').forEach(el => { if (el !== box) el.classList.remove('open'); }); + box.classList.toggle('open'); + trigger.setAttribute('aria-expanded', box.classList.contains('open') ? 'true' : 'false'); + } else if (action === 'persona') { + const box = trigger?.closest('.script-brief-select'); + if (!box) return; + document.querySelectorAll('.script-brief-select.open').forEach(el => { if (el !== box) el.classList.remove('open'); }); + box.classList.toggle('open'); + trigger.setAttribute('aria-expanded', box.classList.contains('open') ? 'true' : 'false'); + } + } /* 自动从脚本(painting + dialog 文本)抽取人物 / 场景关键词 · 白名单匹配 */ const CHAR_KEYWORDS = ['女主', '男主', '同事', '闺蜜', '男友', '女友', '妈妈', '爸爸', '老师', '同学', '朋友', '路人', '主播', '老板']; @@ -2872,17 +3107,24 @@ const Stage1 = (function () { } function pushMsg(role, html) { chatMsgs.push({ role, html, time: now() }); } function saveState() { - ProjectStore.saveStage('stage1', { shots, chatMsgs, mode, scriptTags }); + ProjectStore.saveStage('stage1', { shots, chatMsgs, mode, scriptTags, scriptBrief }); + renderBriefSummary(); } function loadState() { const saved = ProjectStore.data.stage1; - if (!saved) return; + if (!saved) { renderBriefSummary(); return; } if (Array.isArray(saved.shots)) shots = saved.shots; if (Array.isArray(saved.chatMsgs)) chatMsgs = saved.chatMsgs; if (saved.mode) mode = saved.mode; + if (saved.scriptBrief && typeof saved.scriptBrief === 'object') scriptBrief = { ...scriptBrief, ...saved.scriptBrief }; + else if (mode) scriptBrief = makeBrief(mode); if (saved.scriptTags && Array.isArray(saved.scriptTags.char) && Array.isArray(saved.scriptTags.scene)) { scriptTags = saved.scriptTags; } + if (migrateBriefMessages()) { + ProjectStore.saveStage('stage1', { shots, chatMsgs, mode, scriptTags, scriptBrief }); + } + renderBriefSummary(); } function getDefaultDraft() { return [ @@ -2945,6 +3187,18 @@ const Stage1 = (function () { } return `
${msg.html}
${msg.time}
`; }).join(''); + body.querySelectorAll('[data-brief-act]').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + handleBriefAction(btn.dataset.briefAct, btn); + }); + }); + body.querySelectorAll('[data-brief-pick]').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + handleBriefPick(btn.dataset.briefPick, btn.dataset.value); + }); + }); body.scrollTop = body.scrollHeight; } @@ -3030,61 +3284,18 @@ const Stage1 = (function () { function pickMode(m) { mode = m; + scriptBrief = makeBrief(m); ProjectStore.record('stage1.mode.selected', { mode: m }); + pushMsg('user', MODE_USER_COPY[m] || '选择脚本来源'); if (m === 'ai') { - ProjectStore.startJob('stage1-script', { - stage: 1, - label: '脚本初稿生成', - finishAt: Date.now() + 6500, - }); - pushMsg('user', '帮我 AI 全自动生成一稿脚本'); - saveState(); - renderChat(); - setTimeout(() => { - pushMsg('ai', '正在解析商品卖点与目标人群 '); - saveState(); - renderChat(); - }, 300); - // 7 镜 · 0-40s · 与 Stage 2 / 4 的 3 场切分对齐(场 1 深夜办公桌 0-15s / 场 2 面膜包装 15-27s / 场 3 化妆台定格 27-40s) - const draft = [ - // ─ 场 1 · 深夜办公桌(15s)─ - { id: 'sh1', painting: '中景慢推 · 深夜居家书桌全景。屏幕仍亮着 PPT,女主背影瘫在椅子上,屏幕冷光 + 台灯暖光对比。字幕"凌晨 02:14"淡入。', dialog: '(无台词 · BGM 渐起)', duration: 5 }, - { id: 'sh2', painting: '近景 · 卫生间镜前。女主低头看脸,T 区起皮、暗沉特写,冷白灯偏惨。', dialog: '"做完这版稿又是凌晨两点……(叹气)脸已经不能看了。"', duration: 5 }, - { id: 'sh3', painting: '俯拍特写 · 回到书桌,拉开抽屉。囤好的透真补水面膜露半角,手伸进去抽出一片。', dialog: '"还好抽屉里囤了透真玻尿酸面膜。"', duration: 5 }, - // ─ 场 2 · 面膜包装/特写(12s)─ - { id: 'sh4', painting: '桌面微距特写 · 撕开锡纸包装的瞬间。30g 厚精华液缓缓滴落,面膜布展开,质地拉丝可见。', dialog: '"30g 一片,精华液比普通面膜厚整整三倍。"', duration: 6 }, - { id: 'sh5', painting: '床头近景 · 女主敷好面膜闭眼躺下,台灯暖光打在脸侧。膜布贴合脸型,边缘服帖。', dialog: '"贴上去那一瞬间 —— 凉凉的,像把皮肤泡了一次澡。"', duration: 6 }, - // ─ 场 3 · 化妆台/产品定格(13s)─ - { id: 'sh6', painting: '中景 · 第二天清晨化妆台。阳光透过窗帘,女主对镜上妆,皮肤透亮、粉底服帖。同事画外音"你最近用啥了"。', dialog: '"第二天脸是软的,粉底都不卡了。同事都跑来问。"', duration: 8 }, - { id: 'sh7', painting: '平铺俯拍 · 桌面五片装产品 + 单片包装。价格 "618 · 5 片 ¥39.9" 弹出,购物车图标右下角浮现。', dialog: '"618 五片 39.9,自用送人都合适。链接放评论区。"', duration: 5 }, - ]; - let cur = 0; - const step = () => { - if (cur >= draft.length) { - // remove thinking msg - chatMsgs = chatMsgs.filter(x => !(x.role === 'ai' && /ai-thinking/.test(x.html))); - pushMsg('ai', '初稿完成。点击任意卡片文字可直接编辑;鼠标移到卡片之间会出现「+ 添加分镜」。'); - ProjectStore.finishJob('stage1-script'); - ProjectStore.record('stage1.script.ready', { shots: shots.length }); - saveState(); - renderChat(); - return; - } - shots.push(draft[cur++]); - saveState(); - renderShots(); - setTimeout(step, 700); - }; - setTimeout(step, 1100); + pushMsg('ai', briefConfirmHtml('我会根据商品信息直接生成第一版。生成前先确认创作方向。')); } else if (m === 'theme') { - pushMsg('ai', '好,请给我一句话主题(5–30 字),例如:
· 熬夜党的急救面膜
· 加班吃啥不内疚
下面输入框直接打就行,我会按这句话扩成一稿镜头脚本。'); - saveState(); - renderChat(); + pushMsg('ai', '好,请给我一句话主题(5-30 字),例如:
· 熬夜党的急救面膜
· 加班吃啥不内疚
我会先根据这句话补全风格和人物设定,再让你确认。'); } else if (m === 'manual') { - pushMsg('ai', '好,把你的脚本(旁白 / 镜头描述均可)粘贴到下面输入框,我会按场自然切分并适配商品卖点。'); - saveState(); - renderChat(); + pushMsg('ai', '好,把你的脚本(旁白 / 镜头描述均可)粘贴到下面输入框。我会先识别风格和人物设定;识别不到的部分,再用商品信息补齐。'); } + saveState(); + renderChat(); } function init() { @@ -3095,6 +3306,7 @@ const Stage1 = (function () { ProjectStore.restoreFields(); document.getElementById('chat-clear-btn')?.addEventListener('click', () => { chatMsgs = []; mode = null; shots = []; scriptTags = { char: [], scene: [] }; + scriptBrief = makeBrief(null); ProjectStore.clearJob('stage1-script'); ProjectStore.record('stage1.cleared'); saveState(); @@ -3104,6 +3316,10 @@ const Stage1 = (function () { document.getElementById('chat-regen-btn')?.addEventListener('click', () => { Shell.toast('已请求整体重写', 'POST /script/regen'); }); + document.addEventListener('click', (e) => { + if (e.target.closest('.script-brief-select')) return; + document.querySelectorAll('.script-brief-select.open').forEach(el => el.classList.remove('open')); + }); const sendBtn = document.getElementById('chat-send-btn'); const ta = document.getElementById('chat-textarea'); const attachRow = document.getElementById('chat-attach-row'); @@ -3155,7 +3371,16 @@ const Stage1 = (function () { saveState(); renderChat(); setTimeout(() => { - pushMsg('ai', '收到。我会按这个方向调整脚本(静态演示;实际接 LLM API)。'); + const directionIntent = /小红书|种草|测评|评测|痛点|熬夜|学生|宝妈|妈妈|人物|风格|口吻|换成/.test(v); + if (mode && (!shots.length || directionIntent)) { + tuneBriefByText(v); + const intro = directionIntent + ? '已更新创作方向。确认后我会按这个方向重写镜头脚本。' + : '我先根据你给的内容补全创作方向,确认后再生成镜头脚本。'; + pushMsg('ai', briefConfirmHtml(intro)); + } else { + pushMsg('ai', '收到。我会按这个方向调整脚本(静态演示;实际接 LLM API)。'); + } saveState(); renderChat(); }, 400);