fix: restore deploy manifests and polish script assistant
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 10s

This commit is contained in:
iye 2026-05-28 18:56:43 +08:00
parent df7b90934a
commit e06a16e200
5 changed files with 387 additions and 56 deletions

View File

@ -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

24
k8s/ingress.yaml Normal file
View File

@ -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

View File

@ -0,0 +1,8 @@
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: redirect-https
spec:
redirectScheme:
scheme: https
permanent: true

59
k8s/web-deployment.yaml Normal file
View File

@ -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

View File

@ -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 @@
<div class="stage-script">
<div class="pane shot-list">
<div class="pane-h">
<div class="shot-headline">
<strong>镜头脚本</strong>
<span class="muted-2 mono" id="shots-meta" style="font-size:11px;">· 空 · 待生成</span>
</div>
<div class="script-brief-summary" aria-label="当前创作方向">
<span class="pill neutral script-brief-pill"><span class="k">来源</span><span class="v" id="brief-source">未选择</span></span>
<span class="pill neutral script-brief-pill"><span class="k">风格</span><span class="v" id="brief-style">待确认</span></span>
<span class="pill neutral script-brief-pill"><span class="k">人物</span><span class="v" id="brief-persona">待确认</span></span>
</div>
<div class="script-tags" id="script-tags">
<div class="tag-group" data-kind="char">
<span class="tg-lbl">// 人物</span>
@ -1662,7 +1730,7 @@
<div class="chat-input">
<div class="chat-input-card">
<div class="chat-attach-row" id="chat-attach-row" hidden></div>
<textarea class="chat-input-area" id="chat-textarea" placeholder='聊聊你的脚本想法,或输入 "@" 引用镜头……' rows="2"></textarea>
<textarea class="chat-input-area" id="chat-textarea" placeholder="直接说怎么改,如:更像小红书种草 / 换成熬夜党" rows="2"></textarea>
<div class="chat-input-foot">
<button class="chat-icon-btn" id="chat-upload-btn" title="上传脚本附件" aria-label="上传脚本附件">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></svg>
@ -2802,10 +2870,177 @@ const Stage1 = (function () {
let chatMsgs = []; // [{ role, html, time }]
let mode = null;
let scriptTags = { char: [], scene: [] }; // 自动抓取的人物 / 场景 · 可编辑
const MODE_LABELS = { ai: 'AI 全生', theme: '一句话主题', manual: '自带脚本' };
const MODE_USER_COPY = {
ai: '我用 AI 全生',
theme: '我用一句话主题',
manual: '我用自带脚本',
};
const BRIEF_OPTIONS = {
style: ['真实测评', '痛点种草', '小红书种草', '开箱测评', '对比展示'],
persona: ['通勤敏感肌女生', '熬夜党通勤女性', '学生党女生', '精致宝妈', '成分党用户'],
};
function makeBrief(nextMode) {
if (!nextMode) {
return {
source: '未选择',
style: '待确认',
persona: '待确认',
styleNote: '选择脚本来源后由助手推荐',
personaNote: '选择脚本来源后由助手推荐',
};
}
return {
source: MODE_LABELS[nextMode] || '未选择',
style: nextMode === 'theme' ? '痛点种草' : '真实测评',
persona: '通勤敏感肌女生',
styleNote: nextMode === 'manual' ? '参考文本识别不足 · 根据商品类目推荐' : '根据商品信息推荐',
personaNote: '根据商品目标人群推荐',
};
}
let scriptBrief = makeBrief(null);
const $cb = () => document.getElementById('chat-body');
const $sb = () => document.getElementById('shots-body');
const $sm = () => document.getElementById('shots-meta');
const safeHtml = s => String(s || '').replace(/[&<>"']/g, m => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[m]));
function renderBriefSummary() {
const source = document.getElementById('brief-source');
const style = document.getElementById('brief-style');
const persona = document.getElementById('brief-persona');
if (source) source.textContent = scriptBrief.source || '未选择';
if (style) style.textContent = scriptBrief.style || '待确认';
if (persona) persona.textContent = scriptBrief.persona || '待确认';
}
function briefConfirmHtml(intro) {
const optionMenu = (kind, current) => `
<span class="script-brief-select">
<button class="script-brief-value" type="button" data-brief-act="${kind}" aria-haspopup="listbox" aria-expanded="false" aria-label="选择${kind === 'style' ? '脚本风格' : '人物设定'}">
<span class="v">${safeHtml(current)}</span>
</button>
<span class="chip-menu align-right" role="listbox">
${BRIEF_OPTIONS[kind].map(opt => `
<button class="mi${opt === current ? ' selected' : ''}" type="button" data-brief-pick="${kind}" data-value="${safeHtml(opt)}" role="option" aria-selected="${opt === current ? 'true' : 'false'}">
<svg class="mi-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="m20 6-11 11-5-5"/></svg>
${safeHtml(opt)}
</button>`).join('')}
</span>
</span>`;
return `${intro}
<div class="script-brief-card">
<div class="script-brief-row">
<span class="k">风格</span>
${optionMenu('style', scriptBrief.style)}
<span class="why">${safeHtml(scriptBrief.styleNote)}</span>
</div>
<div class="script-brief-row">
<span class="k">人物</span>
${optionMenu('persona', scriptBrief.persona)}
<span class="why">${safeHtml(scriptBrief.personaNote)}</span>
</div>
<div class="script-brief-actions">
<div class="action-row">
<button class="btn btn-ghost" type="button" data-brief-act="reroll">重新推荐</button>
<button class="btn" type="button" data-brief-act="accept">确定</button>
</div>
</div>
</div>`;
}
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('<div class="script-brief-card"');
const intro = cardIndex > -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', '<span class="ai-thinking">正在解析商品卖点与创作方向 <span class="dots"><span></span><span></span><span></span></span></span>');
}
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 `<div class="msg user"><div class="bubble">${msg.html}</div><div class="time">${msg.time}</div></div>`;
}).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,62 +3284,19 @@ 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', '<span class="ai-thinking">正在解析商品卖点与目标人群 <span class="dots"><span></span><span></span><span></span></span></span>');
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', '好,请给我一句话主题(530 字),例如:<br>· 熬夜党的急救面膜<br>· 加班吃啥不内疚<br>下面输入框直接打就行,我会按这句话扩成一稿镜头脚本。');
saveState();
renderChat();
pushMsg('ai', '好,请给我一句话主题(5-30 字),例如:<br>· 熬夜党的急救面膜<br>· 加班吃啥不内疚<br>我会先根据这句话补全风格和人物设定,再让你确认。');
} else if (m === 'manual') {
pushMsg('ai', '好,把你的脚本(旁白 / 镜头描述均可)粘贴到下面输入框,我会按场自然切分并适配商品卖点。');
pushMsg('ai', '好,把你的脚本(旁白 / 镜头描述均可)粘贴到下面输入框。我会先识别风格和人物设定;识别不到的部分,再用商品信息补齐。');
}
saveState();
renderChat();
}
}
function init() {
loadState();
@ -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(() => {
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);