Files

1968 lines
76 KiB
HTML

<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>图片创作 · Airshelf</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="assets/restraint.css?v=2026052607">
<style>
/* viewport-fit · 工作台铺满 */
.app { height: 100vh; overflow: hidden; }
main { display: flex; flex-direction: column; min-height: 0; }
#page-content { flex: 1; min-height: 0; display: flex; flex-direction: column; padding: 0; }
/* ===== 整体两栏 · 左会话列表 + 中央对话流 ===== */
.io-app {
flex: 1; min-height: 0;
display: grid;
grid-template-columns: 240px 1fr;
transition: grid-template-columns var(--t-base);
}
.io-app.side-collapsed { grid-template-columns: 0 1fr; }
@media (max-width: 1100px) {
.io-app { grid-template-columns: 200px 1fr; }
.io-app.side-collapsed { grid-template-columns: 0 1fr; }
}
/* ========== 左 · 会话栏 ========== */
.io-side {
border-right: 1px solid var(--border-faint);
background: var(--surface);
display: flex; flex-direction: column;
min-height: 0; overflow: hidden;
transition: opacity var(--t-base), transform var(--t-base);
}
.io-app.side-collapsed .io-side {
opacity: 0;
transform: translateX(-8px);
pointer-events: none;
}
.io-side-h {
display: flex; align-items: center; gap: 8px;
padding: 14px 14px 10px;
border-bottom: 1px solid var(--border-faint);
}
.io-side-h .ti { font-size: 13.5px; font-weight: 600; color: var(--accent-black); }
.io-side-h .back-pill {
display: inline-flex; align-items: center; gap: 6px;
height: 34px; padding: 0 13px 0 11px;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-pill);
color: var(--accent-black);
font-size: 13px; font-weight: 500;
font-family: inherit;
cursor: pointer;
transition: background var(--t-base), border-color var(--t-base), color var(--t-base);
}
.io-side-h .back-pill:hover {
background: var(--black-alpha-4);
border-color: var(--black-alpha-24);
color: var(--accent-black);
}
.io-side-h .back-pill svg { width: 14px; height: 14px; }
.io-side-h .fold {
margin-left: auto;
width: 26px; height: 26px;
background: transparent; border: 0; border-radius: var(--r-sm);
display: grid; place-items: center;
color: var(--black-alpha-48); cursor: pointer;
transition: background var(--t-base), color var(--t-base);
}
.io-side-h .fold:hover { background: var(--black-alpha-4); color: var(--accent-black); }
.io-side-h .fold svg { width: 14px; height: 14px; }
.io-new-conv {
margin: 10px 12px 0;
height: 36px;
display: inline-flex; align-items: center; gap: 8px;
padding: 0 12px;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
color: var(--accent-black);
font-size: 13px; font-weight: 500;
font-family: inherit;
cursor: pointer;
transition: border-color var(--t-base), background var(--t-base), color var(--t-base);
}
.io-new-conv:hover {
border-color: var(--heat-20); background: var(--heat-12); color: var(--heat);
}
.io-new-conv svg { width: 13px; height: 13px; }
.io-side-sec-h {
margin: 16px 14px 6px;
font-family: var(--font-mono); font-size: 10px;
color: var(--black-alpha-48);
letter-spacing: .08em;
text-transform: uppercase;
}
.io-conv-list {
flex: 1; min-height: 0;
overflow-y: auto;
padding: 0 6px 14px;
display: flex; flex-direction: column; gap: 2px;
}
.io-conv-item {
display: flex; align-items: center; gap: 10px;
padding: 8px 10px;
border-radius: var(--r-sm);
cursor: pointer;
color: var(--accent-black);
transition: background var(--t-base);
position: relative;
}
.io-conv-item:hover { background: var(--background-lighter); }
.io-conv-item.active { background: var(--heat-12); }
.io-conv-item .thumb {
flex-shrink: 0;
width: 28px; height: 28px;
background: var(--background-lighter);
border: 1px solid var(--border-faint);
border-radius: var(--r-sm);
background-size: cover; background-position: center;
display: grid; place-items: center;
color: var(--black-alpha-32);
}
.io-conv-item .thumb svg { width: 13px; height: 13px; }
.io-conv-item.default .thumb {
background: var(--accent-black); color: var(--accent-white); border-color: var(--accent-black);
}
.io-conv-item .nm {
flex: 1; min-width: 0;
font-size: 12.5px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.io-conv-item.active .nm { color: var(--heat); font-weight: 600; }
.io-conv-item .del {
display: none;
width: 22px; height: 22px;
background: transparent; border: 0; border-radius: var(--r-sm);
color: var(--black-alpha-48); cursor: pointer;
align-items: center; justify-content: center;
}
.io-conv-item:hover .del { display: inline-flex; }
.io-conv-item .del:hover { color: var(--accent-crimson); background: var(--crimson-bg, #fdebea); }
.io-conv-item .del svg { width: 11px; height: 11px; }
/* ========== 右 · 对话流 ========== */
.io-main {
display: flex; flex-direction: column;
min-height: 0;
position: relative;
}
.io-toolbar {
flex-shrink: 0;
display: flex; align-items: center; gap: 10px;
padding: 12px 28px;
border-bottom: 1px solid var(--border-faint);
background: var(--surface);
}
.io-toolbar .spacer { flex: 1; }
.io-toolbar .side-restore-btn {
height: 32px;
padding: 0 10px;
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-sm);
color: var(--black-alpha-72);
font-family: inherit;
font-size: 12.5px;
cursor: pointer;
}
.io-toolbar .side-restore-btn:hover { border-color: var(--heat-20); color: var(--heat); }
.io-toolbar .side-restore-btn[hidden] { display: none; }
.io-toolbar .side-restore-btn svg { width: 14px; height: 14px; }
.io-toolbar-search {
position: relative;
width: min(320px, 32vw);
min-width: 220px;
}
.io-toolbar-search[hidden] { display: none; }
.io-toolbar-search svg {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
width: 13px;
height: 13px;
color: var(--black-alpha-48);
pointer-events: none;
}
.io-toolbar-search input {
width: 100%;
height: 32px;
padding: 0 32px 0 30px;
background: var(--background-lighter);
border: 1px solid var(--border-faint);
border-radius: var(--r-sm);
color: var(--accent-black);
font-family: inherit;
font-size: 12.5px;
outline: none;
}
.io-toolbar-search input:focus { border-color: var(--heat-40); background: var(--surface); }
.io-toolbar-search .clear-search {
position: absolute;
right: 4px;
top: 50%;
transform: translateY(-50%);
width: 24px;
height: 24px;
border: 0;
border-radius: var(--r-sm);
background: transparent;
color: var(--black-alpha-48);
cursor: pointer;
display: grid;
place-items: center;
}
.io-toolbar-search .clear-search:hover { background: var(--black-alpha-4); color: var(--accent-black); }
.io-toolbar-search .clear-search svg {
position: static;
transform: none;
width: 12px;
height: 12px;
}
.io-toolbar .search-btn {
width: 32px; height: 32px;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-sm);
color: var(--black-alpha-72);
cursor: pointer;
display: grid; place-items: center;
}
.io-toolbar .search-btn:hover { border-color: var(--heat-20); color: var(--heat); }
.io-toolbar .search-btn.active { background: var(--heat-12); border-color: var(--heat-20); color: var(--heat); }
.io-toolbar .search-btn svg { width: 14px; height: 14px; }
.io-tool-filter { position: relative; display: inline-flex; }
.io-toolbar .tb-chip {
display: inline-flex; align-items: center; gap: 6px;
height: 32px; padding: 0 10px;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-sm);
font-size: 12.5px; color: var(--black-alpha-72);
font-family: inherit; cursor: pointer;
transition: border-color var(--t-base), color var(--t-base);
}
.io-toolbar .tb-chip:hover { border-color: var(--heat-20); color: var(--heat); }
.io-toolbar .tb-chip.active { background: var(--heat-12); border-color: var(--heat-20); color: var(--heat); }
.io-toolbar .tb-chip svg { width: 10px; height: 10px; opacity: .6; }
.io-tool-filter.open .tb-chip svg { transform: rotate(180deg); }
.io-tool-menu {
position: absolute;
top: calc(100% + 4px);
right: 0;
min-width: 156px;
display: none;
padding: 4px;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
box-shadow: var(--shadow-floating);
z-index: 30;
}
.io-tool-filter.open .io-tool-menu { display: block; }
.io-tool-menu .mi {
width: 100%;
min-height: 32px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 10px;
border: 0;
border-radius: var(--r-sm);
background: transparent;
color: var(--accent-black);
font-family: inherit;
font-size: 12.5px;
text-align: left;
cursor: pointer;
}
.io-tool-menu .mi:hover { background: var(--black-alpha-4); }
.io-tool-menu .mi.selected { background: var(--heat-12); color: var(--heat); font-weight: 500; }
.io-tool-menu .mi svg {
width: 12px;
height: 12px;
opacity: 0;
color: var(--heat);
}
.io-tool-menu .mi.selected svg { opacity: 1; }
/* 对话流主体 */
.io-stream {
flex: 1; min-height: 0;
overflow-y: auto;
padding: 28px 28px 220px; /* 底部留出输入框高度(避免被遮挡) */
background: var(--background-base);
}
@media (max-width: 1100px) { .io-stream { padding: 22px 18px 220px; } }
.io-stream-inner {
max-width: 1180px; margin: 0 auto;
display: flex; flex-direction: column; gap: 32px;
}
/* 单条对话(气泡式 · 提示词块 + 结果网格) */
.io-msg { display: flex; flex-direction: column; gap: 14px; }
.io-msg-prompt {
display: flex; align-items: flex-start; gap: 12px;
padding-left: 4px;
}
.io-msg-prompt .quote {
flex-shrink: 0;
width: 28px; height: 28px;
border-radius: var(--r-sm);
background: var(--surface);
border: 1px solid var(--border-faint);
color: var(--heat);
display: grid; place-items: center;
font-family: var(--font-mono); font-size: 13px;
}
.io-msg-prompt .quote svg { width: 13px; height: 13px; }
.io-msg-prompt .pt {
flex: 1; min-width: 0;
padding-top: 4px;
}
.io-msg-prompt .pt-text {
font-size: 14px; color: var(--accent-black);
line-height: 1.55;
word-break: break-word;
}
.io-msg-prompt .pt-tags {
margin-top: 8px;
display: flex; flex-wrap: wrap; gap: 6px;
font-family: var(--font-mono); font-size: 11px;
color: var(--black-alpha-48); letter-spacing: .02em;
align-items: center;
}
.io-msg-prompt .pt-tags .meta-chip {
padding: 2px 8px;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-sm);
}
.io-msg-prompt .pt-tags .sep { color: var(--black-alpha-24); }
/* 结果网格 — 4 张/行 */
.io-msg-grid {
display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px;
}
@media (max-width: 1280px) { .io-msg-grid { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 900px) { .io-msg-grid { grid-template-columns: repeat(2, 1fr); } }
.io-cell {
position: relative;
background: var(--background-lighter);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
overflow: hidden;
cursor: pointer;
transition: border-color var(--t-base);
}
.io-cell:hover { border-color: var(--black-alpha-32); }
.io-cell.r-1-1 { aspect-ratio: 1 / 1; }
.io-cell.r-16-9 { aspect-ratio: 16 / 9; }
.io-cell.r-9-16 { aspect-ratio: 9 / 16; }
.io-cell.r-4-3 { aspect-ratio: 4 / 3; }
.io-cell.r-3-4 { aspect-ratio: 3 / 4; }
.io-cell .ph-frame {
position: absolute; inset: 0;
display: grid; place-items: center;
font-family: var(--font-mono); font-size: 11px;
color: var(--black-alpha-32); letter-spacing: .02em;
background: repeating-linear-gradient(135deg, transparent 0 6px, rgba(0,0,0,.03) 6px 7px);
}
.io-cell.gen .ph-frame { animation: io-pulse 1.4s ease-in-out infinite; }
@keyframes io-pulse {
0%, 100% { opacity: 1; }
50% { opacity: .55; }
}
.io-cell.err {
border-color: var(--accent-crimson, #c43d3d);
}
.io-cell.err .ph-frame {
color: var(--accent-crimson, #c43d3d);
background: rgba(196, 61, 61, .05);
}
/* 右上 hover 操作组 */
.io-cell .cell-ops {
position: absolute; top: 6px; right: 6px;
display: flex; gap: 4px;
opacity: 0;
transition: opacity var(--t-base);
}
.io-cell:hover .cell-ops { opacity: 1; }
.io-cell .cell-ops button {
width: 26px; height: 26px;
background: rgba(255, 255, 255, .92);
border: 1px solid var(--border-faint);
border-radius: var(--r-sm);
color: var(--accent-black);
cursor: pointer;
display: grid; place-items: center;
backdrop-filter: blur(4px);
transition: border-color var(--t-base), color var(--t-base);
}
.io-cell .cell-ops button:hover { border-color: var(--heat); color: var(--heat); }
.io-cell .cell-ops button svg { width: 12px; height: 12px; }
/* 更多 · 下拉气泡(删除 / 加入资产库) */
.io-cell .cell-more-wrap { position: relative; }
.io-cell .cell-more-menu {
position: absolute; top: calc(100% + 4px); right: 0;
min-width: 132px;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
box-shadow: 0 6px 24px rgba(0,0,0,.10);
padding: 4px;
display: none;
z-index: 12;
}
.io-cell .cell-more-wrap.open .cell-more-menu { display: block; }
.io-cell .cell-more-menu button {
width: 100%;
display: inline-flex !important; align-items: center; gap: 8px;
padding: 7px 10px !important;
background: transparent !important;
border: 0 !important; border-radius: var(--r-sm) !important;
font-size: 12.5px;
color: var(--accent-black) !important;
font-family: inherit;
text-align: left;
cursor: pointer;
backdrop-filter: none !important;
height: auto !important;
justify-content: flex-start !important;
}
.io-cell .cell-more-menu button:hover {
background: var(--background-lighter) !important;
color: var(--heat) !important;
}
.io-cell .cell-more-menu button.danger:hover {
color: var(--accent-crimson) !important;
background: var(--crimson-bg, #fdebea) !important;
}
.io-cell .cell-more-menu button svg { width: 13px !important; height: 13px !important; }
/* 操作行(重新编辑 / 再次生成 / ...) */
.io-msg-ops {
display: flex; gap: 8px;
padding-left: 4px;
}
.io-msg-ops button {
display: inline-flex; align-items: center; gap: 6px;
height: 30px; padding: 0 12px;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
font-size: 12.5px;
color: var(--accent-black);
font-family: inherit;
cursor: pointer;
transition: border-color var(--t-base), color var(--t-base), background var(--t-base);
}
.io-msg-ops button:hover {
border-color: var(--heat-20); color: var(--heat); background: var(--heat-12);
}
.io-msg-ops button.icon {
width: 30px; padding: 0;
justify-content: center;
}
.io-msg-ops button svg { width: 13px; height: 13px; }
/* 操作行 · 更多气泡(全部加入资产库 / 删除该批结果) */
.io-msg-ops .msg-more-wrap { position: relative; }
.io-msg-ops .msg-more-menu {
position: absolute; bottom: calc(100% + 6px); left: 0;
min-width: 168px;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
box-shadow: 0 6px 24px rgba(0,0,0,.10);
padding: 4px;
display: none;
z-index: 12;
}
.io-msg-ops .msg-more-wrap.open .msg-more-menu { display: block; }
.io-msg-ops .msg-more-menu button {
width: 100%;
display: inline-flex !important; align-items: center; gap: 8px;
height: auto !important;
padding: 7px 10px !important;
background: transparent !important;
border: 0 !important; border-radius: var(--r-sm) !important;
font-size: 12.5px;
color: var(--accent-black) !important;
text-align: left;
justify-content: flex-start !important;
}
.io-msg-ops .msg-more-menu button:hover {
background: var(--background-lighter) !important;
color: var(--heat) !important;
}
.io-msg-ops .msg-more-menu button.danger:hover {
color: var(--accent-crimson) !important;
background: var(--crimson-bg, #fdebea) !important;
}
.io-msg-ops .msg-more-menu button svg { width: 13px !important; height: 13px !important; flex-shrink: 0; }
/* 重复加入资产库 · 确认弹窗 */
.io-dup-modal-bg {
position: fixed; inset: 0; z-index: 1200;
background: rgba(21, 20, 15, .42);
display: grid; place-items: center;
padding: 16px;
}
.io-dup-modal-bg[hidden] { display: none; }
.io-dup-modal {
width: min(420px, 92vw);
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
box-shadow: 0 16px 48px rgba(21, 20, 15, .18);
overflow: hidden;
}
.io-dup-modal .dh {
display: flex; align-items: center; gap: 12px;
padding: 18px 20px 14px;
}
.io-dup-modal .dh .ic {
width: 36px; height: 36px; flex-shrink: 0;
border-radius: var(--r-md);
background: var(--heat-12); color: var(--heat);
display: grid; place-items: center;
}
.io-dup-modal .dh .ic svg { width: 18px; height: 18px; }
.io-dup-modal .dh .ti { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.io-dup-modal .dh .ti strong { font-size: 14.5px; color: var(--accent-black); font-weight: 600; }
.io-dup-modal .dh .ti .mono { font-family: var(--font-mono); font-size: 11.5px; color: var(--black-alpha-48); letter-spacing: .02em; }
.io-dup-modal .df {
display: flex; gap: 8px;
padding: 0 20px 18px;
justify-content: flex-end;
}
.io-dup-modal .df button {
height: 32px; padding: 0 14px;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
font-size: 12.5px; color: var(--accent-black);
font-family: inherit; cursor: pointer;
transition: border-color var(--t-base), background var(--t-base), color var(--t-base);
}
.io-dup-modal .df button:hover { border-color: var(--heat-20); background: var(--heat-12); color: var(--heat); }
.io-dup-modal .df button.primary {
background: var(--heat); border-color: var(--heat); color: var(--accent-white);
}
.io-dup-modal .df button.primary:hover { filter: brightness(1.05); color: var(--accent-white); }
/* 空态 */
.io-empty {
flex: 1; min-height: 100%;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
gap: 16px;
padding: 40px;
color: var(--black-alpha-56);
text-align: center;
}
.io-empty .badge {
font-family: var(--font-mono); font-size: 11px;
letter-spacing: .08em; color: var(--black-alpha-48);
text-transform: uppercase;
}
.io-empty h2 {
font-size: 22px; font-weight: 600;
color: var(--accent-black);
letter-spacing: -.015em;
}
.io-empty p { font-size: 13px; max-width: 460px; line-height: 1.6; }
.io-empty .ic {
width: 64px; height: 64px;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
display: grid; place-items: center;
color: var(--heat);
}
.io-empty .ic svg { width: 28px; height: 28px; }
.io-empty .examples {
margin-top: 10px;
display: flex; flex-wrap: wrap; gap: 8px; justify-content: center;
max-width: 720px;
}
.io-empty .examples .ex {
padding: 6px 12px;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-pill);
font-size: 12px;
color: var(--black-alpha-72);
cursor: pointer;
transition: border-color var(--t-base), color var(--t-base), background var(--t-base);
}
.io-empty .examples .ex:hover {
border-color: var(--heat-20); color: var(--heat); background: var(--heat-12);
}
/* 浮动 "回到底部" 按钮 */
.io-jump-bottom {
position: absolute;
bottom: 200px; left: 50%;
transform: translateX(-50%) translateY(0);
display: inline-flex; align-items: center; gap: 4px;
padding: 6px 12px;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-pill);
font-size: 12px;
color: var(--black-alpha-72);
cursor: pointer;
box-shadow: 0 4px 16px rgba(0,0,0,.06);
z-index: 6;
opacity: 0; pointer-events: none;
transition: opacity var(--t-base), transform var(--t-base);
}
.io-jump-bottom.show {
opacity: 1; pointer-events: auto;
transform: translateX(-50%) translateY(-6px);
}
.io-jump-bottom:hover { color: var(--heat); border-color: var(--heat-20); }
.io-jump-bottom svg { width: 12px; height: 12px; }
/* ========== 底部 · fixed 输入栏 ========== */
.io-input-wrap {
position: absolute; left: 0; right: 0; bottom: 0;
padding: 14px 28px 22px;
background: linear-gradient(to bottom, transparent 0, var(--background-base) 24px);
z-index: 5;
}
@media (max-width: 1100px) { .io-input-wrap { padding: 14px 18px 18px; } }
.io-input {
max-width: 1180px; margin: 0 auto;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: 18px;
padding: 12px 14px 10px;
display: flex; flex-direction: column; gap: 8px;
box-shadow: 0 6px 24px rgba(0,0,0,.06);
transition: border-color var(--t-base);
}
.io-input:focus-within { border-color: var(--heat-40); }
/* 上行 · 参考图 + 加号按钮 (同一 flex 行, 视觉同尺寸) */
.io-input-top {
display: flex; align-items: center; gap: 8px;
flex-wrap: wrap;
}
.io-input-top .add-btn {
flex-shrink: 0;
width: 64px; height: 64px;
background: var(--background-lighter);
border: 1px dashed var(--border-faint);
border-radius: var(--r-md);
display: grid; place-items: center;
color: var(--black-alpha-56);
cursor: pointer;
transition: border-color var(--t-base), color var(--t-base), background var(--t-base);
}
.io-input-top .add-btn:hover { border-color: var(--heat-40); color: var(--heat); background: var(--heat-12); }
.io-input-top .add-btn svg { width: 22px; height: 22px; }
/* 中行 · textarea 满宽 */
.io-input textarea#io-input-text {
width: 100%;
border: 0; outline: 0; resize: none;
background: transparent;
font-family: inherit;
font-size: 14px;
line-height: 1.5;
color: var(--accent-black);
min-height: 44px; max-height: 220px;
padding: 4px 2px;
}
.io-input textarea#io-input-text::placeholder { color: var(--black-alpha-48); }
/* 发送按钮 (放底栏右端) */
.io-input .send-btn {
flex-shrink: 0;
width: 32px; height: 32px;
background: var(--heat); color: var(--accent-white);
border: 0; border-radius: var(--r-md);
cursor: pointer;
display: grid; place-items: center;
transition: opacity var(--t-base), filter var(--t-base);
margin-left: 8px;
}
.io-input .send-btn:hover { filter: brightness(1.05); }
.io-input .send-btn:disabled { opacity: .4; cursor: not-allowed; }
.io-input .send-btn svg { width: 15px; height: 15px; }
/* 参考图缩略 · 容器扁平化, 让子项直接参与 .io-input-top 的 flex 行 */
.io-input-refs { display: contents; }
.io-input-ref {
position: relative;
width: 64px; height: 64px;
border-radius: var(--r-md);
overflow: hidden;
background: var(--background-lighter);
border: 1px solid var(--border-faint);
flex-shrink: 0;
}
.io-input-ref img { width: 100%; height: 100%; object-fit: cover; }
.io-input-ref .x {
position: absolute; top: 3px; right: 3px;
width: 18px; height: 18px;
background: rgba(0,0,0,.7); color: var(--accent-white);
border: 0; border-radius: 50%;
display: grid; place-items: center;
cursor: pointer;
}
.io-input-ref .x svg { width: 10px; height: 10px; }
/* 输入栏底部 · 参数胶囊行 (比例 / 模型 / 张数) */
.io-input-bottom {
display: flex; align-items: center; gap: 6px;
flex-wrap: wrap;
}
.io-input-bottom .param {
position: relative;
display: inline-flex; align-items: center; gap: 4px;
height: 26px; padding: 0 9px;
background: var(--background-lighter);
border: 1px solid transparent;
border-radius: var(--r-pill);
font-size: 11.5px; color: var(--black-alpha-72);
cursor: pointer;
transition: border-color var(--t-base), color var(--t-base), background var(--t-base);
}
.io-input-bottom .param[hidden] { display: none; }
.io-input-bottom .param:hover { background: var(--surface); border-color: var(--border-faint); }
.io-input-bottom .param.active { background: var(--heat-12); color: var(--heat); }
.io-input-bottom .param .lbl-mono {
font-family: var(--font-mono); font-size: 10.5px;
color: var(--black-alpha-48); letter-spacing: .02em;
margin-right: 1px;
}
.io-input-bottom .param.active .lbl-mono { color: var(--heat); }
.io-input-bottom .param svg { width: 10px; height: 10px; opacity: .6; }
.io-input-bottom .right-meta {
margin-left: auto;
font-family: var(--font-mono); font-size: 10.5px;
color: var(--black-alpha-48); letter-spacing: .02em;
}
.io-input-bottom .right-meta .val { color: var(--accent-black); }
/* 参数下拉气泡 */
.io-param-menu {
position: absolute; bottom: calc(100% + 6px); left: -2px;
min-width: 140px;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
box-shadow: 0 6px 24px rgba(0,0,0,.08);
padding: 4px;
display: none;
z-index: 30;
}
.io-input-bottom .param.open .io-param-menu { display: block; }
.io-param-menu .mi {
display: flex; align-items: center; gap: 8px;
padding: 7px 10px;
border-radius: var(--r-sm);
font-size: 12.5px;
color: var(--accent-black);
cursor: pointer;
}
.io-param-menu .mi:hover { background: var(--background-lighter); }
.io-param-menu .mi.selected { color: var(--heat); font-weight: 600; }
.io-param-menu .mi .mi-sub {
margin-left: auto;
font-family: var(--font-mono); font-size: 10px;
color: var(--black-alpha-48); letter-spacing: .02em;
}
.io-param-menu .mi .mi-check {
width: 12px; height: 12px; opacity: 0;
}
.io-param-menu .mi.selected .mi-check { opacity: 1; }
/* 隐藏 file input */
#io-file-input { display: none; }
</style>
</head>
<body>
<div id="page">
<div class="io-app">
<!-- ===== 左 · 会话列表 ===== -->
<aside class="io-side">
<div class="io-side-h">
<button class="back-pill" type="button" onclick="history.length > 1 ? history.back() : location.href='asset-factory.html'" title="返回">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"/></svg>
<span>返回</span>
</button>
<button class="fold" type="button" title="折叠侧栏">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M9 3v18"/></svg>
</button>
</div>
<button class="io-new-conv" type="button" id="io-new-conv">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 113 3L7 19l-4 1 1-4z"/></svg>
新对话
</button>
<div class="io-side-sec-h">默认</div>
<div class="io-conv-list" style="flex: 0 0 auto;">
<div class="io-conv-item default active" data-default>
<div class="thumb">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 16l5-5 4 4 3-3 6 6"/></svg>
</div>
<span class="nm">默认创作</span>
</div>
</div>
<div class="io-side-sec-h">最近</div>
<div class="io-conv-list" id="io-conv-list">
<!-- JS 渲染最近会话 -->
</div>
</aside>
<!-- ===== 右 · 对话流 ===== -->
<section class="io-main">
<div class="io-toolbar">
<button class="side-restore-btn" type="button" id="io-side-restore" hidden title="展开会话栏">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M9 3v18"/></svg>
会话
</button>
<span class="spacer"></span>
<button class="search-btn" type="button" title="搜索">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
</button>
<div class="io-toolbar-search" id="io-toolbar-search" hidden>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
<input id="io-toolbar-search-input" type="text" placeholder="搜索当前对话结果">
<button class="clear-search" type="button" id="io-toolbar-search-clear" title="清空搜索">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>
</div>
<div class="io-tool-filter" data-filter="time">
<button class="tb-chip" type="button"><span data-filter-label>时间</span><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 6l4 4 4-4"/></svg></button>
<div class="io-tool-menu" data-filter-menu></div>
</div>
<div class="io-tool-filter" data-filter="mode">
<button class="tb-chip" type="button"><span data-filter-label>生成模式</span><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 6l4 4 4-4"/></svg></button>
<div class="io-tool-menu" data-filter-menu></div>
</div>
<div class="io-tool-filter" data-filter="action">
<button class="tb-chip" type="button"><span data-filter-label>操作类型</span><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 6l4 4 4-4"/></svg></button>
<div class="io-tool-menu" data-filter-menu></div>
</div>
</div>
<div class="io-stream" id="io-stream">
<div class="io-stream-inner" id="io-stream-inner">
<!-- JS 渲染对话流 / 空态 -->
</div>
</div>
<button class="io-jump-bottom" type="button" id="io-jump-bottom">
回到底部
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6l4 4 4-4"/></svg>
</button>
<!-- 底部输入栏 -->
<div class="io-input-wrap">
<div class="io-input">
<!-- 上行 · 参考图 + 加号按钮 (同一行, 64×64 同尺寸) -->
<div class="io-input-top">
<div class="io-input-refs" id="io-input-refs"></div>
<button class="add-btn" type="button" id="io-add-btn" title="上传参考图">
<svg 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>
</button>
<input type="file" id="io-file-input" accept="image/*" multiple>
</div>
<!-- 中行 · textarea 满宽 -->
<textarea id="io-input-text" rows="1" placeholder="输入想法、剧本或上传参考,支持 “/” 使用技能, @ 添加主体, 和 Agent 一起创作"></textarea>
<!-- 参数胶囊行 -->
<div class="io-input-bottom">
<div class="param" data-param="model" tabindex="0" hidden>
<span class="lbl-mono">模型</span>
<span id="io-param-model-lbl">Airshelf v2</span>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 6l4 4 4-4"/></svg>
<div class="io-param-menu" id="io-menu-model"></div>
</div>
<div class="param" data-param="ratio" tabindex="0">
<span class="lbl-mono">比例</span>
<span id="io-param-ratio-lbl">1:1</span>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 6l4 4 4-4"/></svg>
<div class="io-param-menu" id="io-menu-ratio"></div>
</div>
<div class="param" data-param="style" tabindex="0">
<span class="lbl-mono">风格</span>
<span id="io-param-style-lbl">默认</span>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 6l4 4 4-4"/></svg>
<div class="io-param-menu" id="io-menu-style"></div>
</div>
<div class="param" data-param="count" tabindex="0">
<span class="lbl-mono">张数</span>
<span id="io-param-count-lbl">4</span>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 6l4 4 4-4"/></svg>
<div class="io-param-menu" id="io-menu-count"></div>
</div>
<span class="right-meta">预估 <span class="val" id="io-cost-val">¥0.40</span> · 余额 <span class="val">¥327.40</span></span>
<button class="send-btn" type="button" id="io-send-btn" disabled title="生成">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
</button>
</div>
</div>
</div>
</section>
</div>
</div>
<!-- ===== 重复加入资产库 · 确认弹窗 ===== -->
<div class="io-dup-modal-bg" id="io-dup-bg" hidden>
<div class="io-dup-modal">
<div class="dh">
<div class="ic">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 8v4M12 16h.01"/></svg>
</div>
<div class="ti">
<strong id="io-dup-title">图片已在资产库</strong>
<span id="io-dup-sub" class="mono">// 选择处理方式</span>
</div>
</div>
<div class="df">
<button type="button" data-act="cancel">取消</button>
<button type="button" data-act="dup">新增副本</button>
<button type="button" class="primary" data-act="overwrite">覆盖原图</button>
</div>
</div>
</div>
<script src="assets/icons.js?v=2026052608"></script>
<script src="assets/shell.js?v=2026052607"></script>
<script>
Shell.render({
active: 'asset-factory',
crumbs: [
{ label: '工作台', href: 'index.html' },
{ label: '图片生成', href: 'asset-factory.html' },
{ label: '图片创作' }
]
});
</script>
<script>
/* ============================================================
图片创作 · 即梦风格 chat 工作台
----------------------------------------------------------
持久化:localStorage['fs-io-chat']
结构:[{ id, title, messages:[ {id, prompt, model, ratio, style, count,
refImages:[{id,name}], results:[{id,status,label}], createdAt} ] }]
============================================================ */
(function () {
'use strict';
const STORAGE_KEY = 'fs-io-chat';
const PRICE_PER = 0.10;
const MODELS = [
{ id: 'studio-v2', label: 'Airshelf v2', sub: '通用 · 速度优' },
{ id: 'studio-v2-pro', label: 'Airshelf v2 Pro', sub: '细节 · 商用' },
{ id: 'realistic', label: '写实增强', sub: '商品 · 人像' },
{ id: 'anime', label: '国风动漫', sub: '二次元 · 海报' },
];
const RATIOS = [
{ id: '1:1', label: '1:1', sub: '通用方图' },
{ id: '16:9', label: '16:9', sub: '横屏 · 横幅' },
{ id: '9:16', label: '9:16', sub: '竖屏 · 短视频' },
{ id: '4:3', label: '4:3', sub: '横向标准' },
{ id: '3:4', label: '3:4', sub: '纵向标准' },
];
const STYLES = [
{ id: 'auto', label: '默认' },
{ id: 'realistic', label: '写实' },
{ id: 'cinematic', label: '电影感' },
{ id: 'anime', label: '动漫' },
{ id: 'oil', label: '油画' },
{ id: 'cn-ink', label: '国风水墨' },
{ id: 'cyber', label: '赛博' },
];
const COUNTS = [
{ id: 1, label: '1' },
{ id: 2, label: '2' },
{ id: 4, label: '4' },
];
const EXAMPLES = [
'一只穿着宇航服的橘猫,漂浮在霓虹色星云中,赛博朋克风',
'极简北欧风格的茶杯,白底,自然柔光,产品摄影',
'国风水墨海报,主体一只白鹤立于水边,留白构图',
'电影感都市夜景,街道湿漉漉反射霓虹,4K 高清',
];
/* ---------- DOM ---------- */
const $ = sel => document.querySelector(sel);
const streamInner = $('#io-stream-inner');
const stream = $('#io-stream');
const inputText = $('#io-input-text');
const sendBtn = $('#io-send-btn');
const addBtn = $('#io-add-btn');
const fileInput = $('#io-file-input');
const inputRefs = $('#io-input-refs');
const costVal = $('#io-cost-val');
const convList = $('#io-conv-list');
const newConvBtn = $('#io-new-conv');
const jumpBtn = $('#io-jump-bottom');
const ioApp = $('.io-app');
const foldBtn = $('.io-side-h .fold');
const sideRestore = $('#io-side-restore');
const toolbarSearchBtn = $('.io-toolbar .search-btn');
const toolbarSearch = $('#io-toolbar-search');
const toolbarSearchInput = $('#io-toolbar-search-input');
const toolbarSearchClear = $('#io-toolbar-search-clear');
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
function uid() { return 'm-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5); }
function relTime(ts) {
const diff = (Date.now() - ts) / 1000;
if (diff < 60) return '刚刚';
if (diff < 3600) return Math.floor(diff / 60) + ' 分钟前';
if (diff < 86400) return Math.floor(diff / 3600) + ' 小时前';
if (diff < 86400 * 7) return Math.floor(diff / 86400) + ' 天前';
const d = new Date(ts), z = n => (n < 10 ? '0' + n : '' + n);
return d.getFullYear() + '-' + z(d.getMonth() + 1) + '-' + z(d.getDate());
}
/* ---------- state ---------- */
const state = {
convs: [], // 历史会话列表(元数据 id/title/thumb/updatedAt)
activeConvId: 'default',
messages: [], // 当前激活会话的对话流(从 convMessages 镜像出来)
convMessages: { default: [] }, // 所有会话的 messages 按 convId 持久化
refImages: [],
toolbar: {
q: '',
time: 'all',
mode: 'all',
action: 'all',
},
param: {
model: 'studio-v2',
ratio: '1:1',
style: 'auto',
count: 4,
},
};
const TOOL_FILTERS = {
time: [
{ id: 'all', label: '时间' },
{ id: 'today', label: '今天' },
{ id: 'week', label: '近 7 天' },
],
mode: [
{ id: 'all', label: '生成模式' },
{ id: 'studio-v2', label: 'Airshelf v2' },
{ id: 'studio-v2-pro', label: 'v2 Pro' },
{ id: 'realistic', label: '写实增强' },
{ id: 'anime', label: '国风动漫' },
],
action: [
{ id: 'all', label: '操作类型' },
{ id: 'ok', label: '已完成' },
{ id: 'gen', label: '生成中' },
{ id: 'err', label: '失败' },
],
};
function slimMessages(msgs) {
// dataUrl 体积大,持久化时剥离;只保留元数据
return (msgs || []).map(m => ({
...m,
refImages: (m.refImages || []).map(r => ({ id: r.id, name: r.name })),
}));
}
function loadAll() {
const fallback = { convs: [], activeConvId: 'default', convMessages: { default: [] } };
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return fallback;
const data = JSON.parse(raw) || {};
// 兼容旧结构 { convs, current: { id, messages } } → 迁移到 convMessages
if (!data.convMessages && data.current) {
data.convMessages = {};
data.convMessages[data.current.id || 'default'] = data.current.messages || [];
data.activeConvId = data.current.id || 'default';
delete data.current;
}
if (!data.convMessages) data.convMessages = { default: [] };
if (!data.convMessages.default) data.convMessages.default = [];
if (!Array.isArray(data.convs)) data.convs = [];
if (!data.activeConvId) data.activeConvId = 'default';
return data;
} catch (e) { return fallback; }
}
function saveAll() {
try {
// 当前对话内容同步进字典
state.convMessages[state.activeConvId] = slimMessages(state.messages);
const data = {
convs: state.convs.map(c => ({ id: c.id, title: c.title, thumb: c.thumb, updatedAt: c.updatedAt })),
activeConvId: state.activeConvId,
convMessages: state.convMessages,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
} catch (e) { /* quota */ }
}
/* ---------- 任务中心 · 同步图片创作任务到共享 localStorage ---------- */
const IMG_TASK_KEY = 'fs-image-tasks-image';
function _loadImgTasks() {
try { return JSON.parse(localStorage.getItem(IMG_TASK_KEY) || '[]'); }
catch (e) { return []; }
}
function _saveImgTasks(arr) {
try { localStorage.setItem(IMG_TASK_KEY, JSON.stringify(arr)); } catch (e) {}
}
function _msgStatus(msg) {
if (!msg.results || !msg.results.length) return 'gen';
if (msg.results.some(r => r.status === 'gen')) return 'gen';
if (msg.results.every(r => r.status === 'err')) return 'err';
return 'ok';
}
function _timeNow(ts) {
const d = new Date(ts || Date.now());
return ('0'+(d.getMonth()+1)).slice(-2) + '.' + ('0'+d.getDate()).slice(-2)
+ ' ' + ('0'+d.getHours()).slice(-2) + ':' + ('0'+d.getMinutes()).slice(-2);
}
function syncImageTask(msg) {
if (!msg || !msg.id) return;
const arr = _loadImgTasks();
const promptShort = (msg.prompt || '').trim().replace(/\s+/g, ' ').slice(0, 24);
const ratio = msg.ratio || '';
const taskRec = {
id: 'img-' + msg.id,
type: 'image',
name: '图片创作 · ' + (promptShort || '未命名 prompt'),
snap: {
prompt: msg.prompt,
count: msg.count || (msg.results ? msg.results.length : 1),
ratio: ratio, style: msg.style, model: msg.model,
},
status: _msgStatus(msg),
time: _timeNow(msg.createdAt),
createdAt: msg.createdAt || Date.now(),
};
const idx = arr.findIndex(t => t.id === taskRec.id);
if (idx >= 0) arr[idx] = taskRec;
else arr.unshift(taskRec);
// 限制最多保留 200 条
if (arr.length > 200) arr.length = 200;
_saveImgTasks(arr);
}
/* ---------- 渲染:左侧会话列表 ---------- */
function renderSide() {
document.querySelectorAll('.io-conv-item.default').forEach(d =>
d.classList.toggle('active', state.activeConvId === 'default'));
if (!state.convs.length) {
convList.innerHTML = `<div style="padding:14px 12px;font-size:11.5px;color:var(--black-alpha-48);line-height:1.55;">
还没有最近会话<br><span style="font-family:var(--font-mono);font-size:10.5px;letter-spacing:.02em;display:inline-block;margin-top:4px">// NO HISTORY</span>
</div>`;
return;
}
convList.innerHTML = state.convs.map(c => `
<div class="io-conv-item${state.activeConvId === c.id ? ' active' : ''}" data-id="${c.id}">
<div class="thumb"${c.thumb ? ` style="background-image:url(${c.thumb})"` : ''}>
${c.thumb ? '' : '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5-9 9"/></svg>'}
</div>
<span class="nm">${esc(c.title)}</span>
<button class="del" type="button" data-del="${c.id}" title="删除">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2"/><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6"/></svg>
</button>
</div>
`).join('');
convList.querySelectorAll('.io-conv-item').forEach(it => {
it.addEventListener('click', e => {
if (e.target.closest('[data-del]')) return;
switchConv(it.dataset.id);
});
});
convList.querySelectorAll('[data-del]').forEach(b => {
b.addEventListener('click', e => {
e.stopPropagation();
deleteConv(b.dataset.del);
});
});
}
document.querySelectorAll('.io-conv-item.default').forEach(d => {
d.addEventListener('click', () => switchConv('default'));
});
function switchConv(id) {
if (id === state.activeConvId) return;
// 先把当前会话内容写回字典(slim),再切换
state.convMessages[state.activeConvId] = slimMessages(state.messages);
state.activeConvId = id;
// 从字典读出目标会话的 messages(没有就空)
state.messages = (state.convMessages[id] || []).slice();
saveAll();
renderSide();
renderStream();
setTimeout(() => stream.scrollTo({ top: stream.scrollHeight }), 30);
}
function deleteConv(id) {
state.convs = state.convs.filter(c => c.id !== id);
delete state.convMessages[id];
if (state.activeConvId === id) {
state.activeConvId = 'default';
state.messages = (state.convMessages['default'] || []).slice();
}
saveAll();
renderSide();
renderStream();
}
/* ---------- 渲染:中央对话流 ---------- */
function getVisibleMessages() {
const t = state.toolbar;
const q = (t.q || '').trim().toLowerCase();
const now = Date.now();
return state.messages.filter(m => {
if (q) {
const hay = [
m.prompt,
m.ratio,
modelLabel(m.model),
styleLabel(m.style),
...(m.results || []).map(r => r.label || r.status),
].join(' ').toLowerCase();
if (!hay.includes(q)) return false;
}
if (t.time === 'today' && now - (m.createdAt || now) > 86400000) return false;
if (t.time === 'week' && now - (m.createdAt || now) > 86400000 * 7) return false;
if (t.mode !== 'all' && m.model !== t.mode) return false;
if (t.action !== 'all' && !(m.results || []).some(r => r.status === t.action)) return false;
return true;
});
}
function renderStream() {
if (!state.messages.length) {
streamInner.innerHTML = `
<div class="io-empty">
<div class="ic">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.7 4.6L18 9l-4.3 1.4L12 15l-1.7-4.6L6 9l4.3-1.4L12 3z"/><path d="M19 16l.85 2.3L22 19l-2.15.7L19 22l-.85-2.3L16 19l2.15-.7L19 16z"/></svg>
</div>
<div class="badge">// IMAGE STUDIO</div>
<h2>开始你的创作</h2>
<p>描述你想要的画面,AI 会按提示词 + 参考图 + 模型偏好,生成符合电商场景的视觉素材。<br>支持详情图、海报、灵感速写、3D 化等多场景。</p>
<div class="examples">
${EXAMPLES.map(e => `<button class="ex" type="button" data-ex="${esc(e)}">${esc(e)}</button>`).join('')}
</div>
</div>`;
// 示例点击
streamInner.querySelectorAll('.ex').forEach(b => {
b.addEventListener('click', () => {
inputText.value = b.dataset.ex;
syncSendDisabled();
inputText.focus();
autoResize();
});
});
return;
}
const visible = getVisibleMessages();
if (!visible.length) {
streamInner.innerHTML = `
<div class="io-empty">
<div class="ic">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
</div>
<div class="badge">// NO MATCH</div>
<h2>没有符合筛选的结果</h2>
<p>当前对话里没有匹配的批次,可以清空搜索或切回全部筛选。</p>
<div class="examples"><button class="ex" type="button" id="io-clear-toolbar-filters">清空筛选</button></div>
</div>`;
$('#io-clear-toolbar-filters')?.addEventListener('click', () => {
state.toolbar = { q: '', time: 'all', mode: 'all', action: 'all' };
if (toolbarSearchInput) toolbarSearchInput.value = '';
syncToolbarFilters();
renderStream();
});
return;
}
streamInner.innerHTML = visible.map(messageHTML).join('');
bindMessageEvents();
}
function rClass(r) { return 'r-' + r.replace(':', '-'); }
function modelLabel(id) { const m = MODELS.find(x => x.id === id); return m ? m.label : id; }
function styleLabel(id) { const s = STYLES.find(x => x.id === id); return s ? s.label : id; }
function messageHTML(m) {
const cellsHTML = m.results.map(r => `
<div class="io-cell ${rClass(m.ratio)} ${r.status}" data-msg-id="${m.id}" data-cell-id="${r.id}">
<div class="ph-frame">${r.status === 'gen' ? '生成中…' : (r.status === 'err' ? '失败 · 点重新生成' : esc(r.label))}</div>
${r.status === 'ok' ? `
<div class="cell-ops">
<button type="button" data-act="cell-rerun" title="再次生成">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-3-6.7L21 8M21 3v5h-5"/></svg>
</button>
<button type="button" data-act="dl" title="下载">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg>
</button>
<div class="cell-more-wrap">
<button type="button" data-act="more" title="更多">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="5" cy="12" r="1.6" fill="currentColor" stroke="none"/><circle cx="12" cy="12" r="1.6" fill="currentColor" stroke="none"/><circle cx="19" cy="12" r="1.6" fill="currentColor" stroke="none"/></svg>
</button>
<div class="cell-more-menu">
<button type="button" data-act="save-lib"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 012-2h10a2 2 0 012 2z"/></svg>加入资产库</button>
<button type="button" class="danger" data-act="cell-del"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2"/><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6"/></svg>删除</button>
</div>
</div>
</div>
` : ''}
</div>
`).join('');
const tagsHTML = `
<span class="meta-chip">${esc(modelLabel(m.model))}</span>
<span class="sep">|</span>
<span>${esc(m.ratio)}</span>
<span class="sep">|</span>
<span>1K</span>
${m.style && m.style !== 'auto' ? `<span class="sep">|</span><span>${esc(styleLabel(m.style))}</span>` : ''}
<span class="sep">·</span>
<span>${esc(relTime(m.createdAt))}</span>
`;
return `<div class="io-msg" data-id="${m.id}">
<div class="io-msg-prompt">
<div class="quote">
<svg viewBox="0 0 24 24" fill="currentColor" stroke="none"><path d="M3 21V11a8 8 0 0 1 8-8h1v3h-1a5 5 0 0 0-5 5h6v10H3zm12 0V11a8 8 0 0 1 8-8h1v3h-1a5 5 0 0 0-5 5h6v10h-9z"/></svg>
</div>
<div class="pt">
<div class="pt-text">${esc(m.prompt)}</div>
<div class="pt-tags">${tagsHTML}</div>
</div>
</div>
<div class="io-msg-grid">${cellsHTML}</div>
<div class="io-msg-ops">
<button type="button" data-act="edit" data-id="${m.id}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 113 3L7 19l-4 1 1-4z"/></svg>
重新编辑
</button>
<button type="button" data-act="rerun" data-id="${m.id}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-3-6.7L21 8M21 3v5h-5"/></svg>
再次生成
</button>
<div class="msg-more-wrap">
<button type="button" class="icon" data-act="msg-more" data-id="${m.id}" title="更多">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="5" cy="12" r="1.6" fill="currentColor" stroke="none"/><circle cx="12" cy="12" r="1.6" fill="currentColor" stroke="none"/><circle cx="19" cy="12" r="1.6" fill="currentColor" stroke="none"/></svg>
</button>
<div class="msg-more-menu" role="menu">
<button type="button" data-act="msg-save-all" data-id="${m.id}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
全部加入资产库
</button>
<button type="button" class="danger" data-act="msg-del" data-id="${m.id}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2"/><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6"/></svg>
删除该批结果
</button>
</div>
</div>
</div>
</div>`;
}
function bindMessageEvents() {
streamInner.querySelectorAll('[data-act="edit"]').forEach(b => {
b.addEventListener('click', () => editMessage(b.dataset.id));
});
streamInner.querySelectorAll('[data-act="rerun"]').forEach(b => {
b.addEventListener('click', () => rerunMessage(b.dataset.id));
});
// 更多按钮 · 切换批次 menu
streamInner.querySelectorAll('[data-act="msg-more"]').forEach(b => {
b.addEventListener('click', e => {
e.stopPropagation();
const wrap = b.closest('.msg-more-wrap');
const isOpen = wrap.classList.contains('open');
document.querySelectorAll('.msg-more-wrap.open').forEach(w => w.classList.remove('open'));
if (!isOpen) wrap.classList.add('open');
});
});
// 删除该批结果
streamInner.querySelectorAll('[data-act="msg-del"]').forEach(b => {
b.addEventListener('click', e => {
e.stopPropagation();
const wrap = b.closest('.msg-more-wrap');
if (wrap) wrap.classList.remove('open');
state.messages = state.messages.filter(m => m.id !== b.dataset.id);
saveAll(); renderStream();
if (typeof Shell !== 'undefined' && Shell.toast) Shell.toast('已删除该批结果');
});
});
// 全部加入资产库
streamInner.querySelectorAll('[data-act="msg-save-all"]').forEach(b => {
b.addEventListener('click', e => {
e.stopPropagation();
const wrap = b.closest('.msg-more-wrap');
if (wrap) wrap.classList.remove('open');
saveBatchToLibrary(b.dataset.id);
});
});
streamInner.querySelectorAll('[data-act="dl"]').forEach(b => {
b.addEventListener('click', e => {
e.stopPropagation();
if (typeof Shell !== 'undefined' && Shell.toast) Shell.toast('已加入下载', '占位 · mock 演示');
});
});
// 更多 → 切换菜单
streamInner.querySelectorAll('[data-act="more"]').forEach(b => {
b.addEventListener('click', e => {
e.stopPropagation();
const wrap = b.closest('.cell-more-wrap');
const isOpen = wrap.classList.contains('open');
// 关闭其它所有 menu
document.querySelectorAll('.cell-more-wrap.open').forEach(w => w.classList.remove('open'));
if (!isOpen) wrap.classList.add('open');
});
});
// 加入资产库
streamInner.querySelectorAll('[data-act="save-lib"]').forEach(b => {
b.addEventListener('click', e => {
e.stopPropagation();
const cell = b.closest('.io-cell');
const wrap = b.closest('.cell-more-wrap');
if (wrap) wrap.classList.remove('open');
saveToLibrary(cell.dataset.msgId, cell.dataset.cellId);
});
});
// 单张删除
streamInner.querySelectorAll('[data-act="cell-del"]').forEach(b => {
b.addEventListener('click', e => {
e.stopPropagation();
const cell = b.closest('.io-cell');
const wrap = b.closest('.cell-more-wrap');
if (wrap) wrap.classList.remove('open');
deleteCell(cell.dataset.msgId, cell.dataset.cellId);
});
});
// 单张再次生成
streamInner.querySelectorAll('[data-act="cell-rerun"]').forEach(b => {
b.addEventListener('click', e => {
e.stopPropagation();
const cell = b.closest('.io-cell');
rerunCell(cell.dataset.msgId, cell.dataset.cellId);
});
});
}
/* ---------- 单张再次生成 ---------- */
function rerunCell(msgId, cellId) {
const m = state.messages.find(x => x.id === msgId);
if (!m) return;
const r = (m.results || []).find(x => x.id === cellId);
if (!r) return;
r.status = 'gen';
saveAll();
renderStream();
setTimeout(() => {
r.status = Math.random() < 0.06 ? 'err' : 'ok';
saveAll();
renderStream();
syncImageTask(m);
}, 1100 + Math.random() * 800);
if (typeof Shell !== 'undefined' && Shell.toast) Shell.toast('已重跑', '该图重新生成中');
}
/* ---------- 单张删除 / 加入资产库 ---------- */
function deleteCell(msgId, cellId) {
const m = state.messages.find(x => x.id === msgId);
if (!m) return;
m.results = (m.results || []).filter(r => r.id !== cellId);
// 如果该 msg 下没有 result 了,顺手删掉整条
if (!m.results.length) state.messages = state.messages.filter(x => x.id !== msgId);
saveAll();
renderStream();
if (typeof Shell !== 'undefined' && Shell.toast) Shell.toast('已删除');
}
/* ---------- 资产库 IO ---------- */
const LIB_KEY = 'fs-library-unclassified';
function readLib() {
let list;
try { list = JSON.parse(localStorage.getItem(LIB_KEY) || '[]'); } catch (e) { list = []; }
return Array.isArray(list) ? list : [];
}
function writeLib(list) {
try { localStorage.setItem(LIB_KEY, JSON.stringify(list)); } catch (e) {}
}
function buildLibEntry(m, cellId) {
return {
id: 'lib-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5),
cellId: cellId,
name: (m.prompt || '未命名').slice(0, 18),
prompt: m.prompt || '',
ratio: m.ratio,
model: m.model,
style: m.style,
source: '图片创作',
kind: '未分类',
addedAt: Date.now(),
};
}
/* ---------- 重复确认弹窗 ---------- */
function confirmDup({ count, isBatch }) {
return new Promise(resolve => {
const bg = document.getElementById('io-dup-bg');
document.getElementById('io-dup-title').textContent = isBatch
? `${count} 张已在资产库`
: '该图已在资产库';
document.getElementById('io-dup-sub').textContent = isBatch
? '// 新增副本 = 各自独立 · 覆盖 = 更新时间到顶'
: '// 新增副本 = 多一份独立条目 · 覆盖 = 更新时间到顶';
bg.hidden = false;
const buttons = bg.querySelectorAll('button[data-act]');
function done(choice) {
bg.hidden = true;
buttons.forEach(b => b.onclick = null);
bg.onclick = null;
document.removeEventListener('keydown', escHandler);
resolve(choice);
}
function escHandler(e) { if (e.key === 'Escape') done('cancel'); }
buttons.forEach(b => b.onclick = () => done(b.dataset.act));
bg.onclick = e => { if (e.target === bg) done('cancel'); };
document.addEventListener('keydown', escHandler);
});
}
/* ---------- 单张加入资产库 ---------- */
async function saveToLibrary(msgId, cellId) {
const m = state.messages.find(x => x.id === msgId);
if (!m) return;
const r = (m.results || []).find(x => x.id === cellId);
if (!r) return;
const list = readLib();
const dupIdx = list.findIndex(x => x.cellId === cellId);
if (dupIdx >= 0) {
const choice = await confirmDup({ count: 1, isBatch: false });
if (choice === 'cancel') return;
if (choice === 'overwrite') {
// 移除旧的,把新的放到最前
const [old] = list.splice(dupIdx, 1);
list.unshift({ ...old, addedAt: Date.now(), prompt: m.prompt || old.prompt, ratio: m.ratio, model: m.model, style: m.style });
writeLib(list);
if (typeof Shell !== 'undefined' && Shell.toast) Shell.toast('已覆盖原图', '资产库 → 未分类');
return;
}
// dup: 新增副本 — 不去重,继续走 unshift
}
list.unshift(buildLibEntry(m, cellId));
writeLib(list);
if (typeof Shell !== 'undefined' && Shell.toast) Shell.toast('已加入资产库', '+ 1 张 · 资产库 → 未分类');
}
/* ---------- 整批加入资产库 ---------- */
async function saveBatchToLibrary(msgId) {
const m = state.messages.find(x => x.id === msgId);
if (!m || !m.results || !m.results.length) {
if (typeof Shell !== 'undefined' && Shell.toast) Shell.toast('无可加入的结果', '该批次为空');
return;
}
const list = readLib();
const existing = new Set(list.map(x => x.cellId));
const dupCells = m.results.filter(r => existing.has(r.id));
const newCells = m.results.filter(r => !existing.has(r.id));
let dupAction = 'skip'; // 默认仅加入新的
if (dupCells.length > 0) {
const choice = await confirmDup({ count: dupCells.length, isBatch: true });
if (choice === 'cancel') return;
dupAction = choice; // 'dup' = 新增副本 / 'overwrite' = 覆盖
}
let added = 0, overwritten = 0;
// 先处理新增的 cell
newCells.forEach(r => {
list.unshift(buildLibEntry(m, r.id));
added++;
});
// 处理重复的
if (dupAction === 'dup') {
dupCells.forEach(r => { list.unshift(buildLibEntry(m, r.id)); added++; });
} else if (dupAction === 'overwrite') {
dupCells.forEach(r => {
const idx = list.findIndex(x => x.cellId === r.id);
if (idx >= 0) {
const [old] = list.splice(idx, 1);
list.unshift({ ...old, addedAt: Date.now(), prompt: m.prompt || old.prompt, ratio: m.ratio, model: m.model, style: m.style });
overwritten++;
}
});
}
writeLib(list);
if (typeof Shell !== 'undefined' && Shell.toast) {
const parts = [];
if (added > 0) parts.push(`+ ${added} 张`);
if (overwritten > 0) parts.push(`覆盖 ${overwritten} 张`);
Shell.toast(added > 0 || overwritten > 0 ? '已加入资产库' : '已取消', parts.length ? parts.join(' · ') + ' · 资产库 → 未分类' : '无变更');
}
}
function editMessage(id) {
const m = state.messages.find(x => x.id === id);
if (!m) return;
inputText.value = m.prompt;
state.param = { model: m.model, ratio: m.ratio, style: m.style, count: m.count };
syncParamLabels();
syncSendDisabled();
autoResize();
inputText.focus();
stream.scrollTo({ top: stream.scrollHeight, behavior: 'smooth' });
}
function rerunMessage(id) {
const m = state.messages.find(x => x.id === id);
if (!m) return;
const newMsg = createMessage(m.prompt, { model: m.model, ratio: m.ratio, style: m.style, count: m.count, refImages: m.refImages || [] });
state.messages.push(newMsg);
saveAll();
renderStream();
scheduleResults(newMsg);
setTimeout(() => stream.scrollTo({ top: stream.scrollHeight, behavior: 'smooth' }), 30);
}
/* ---------- 生成 ---------- */
function createMessage(prompt, opts) {
const p = opts || {};
const count = p.count || state.param.count;
return {
id: uid(),
prompt: prompt,
model: p.model || state.param.model,
ratio: p.ratio || state.param.ratio,
style: p.style || state.param.style,
count: count,
refImages: p.refImages || state.refImages.slice(),
results: Array.from({ length: count }, (_, i) => ({
id: 'r-' + uid(), status: 'gen', label: (p.ratio || state.param.ratio) + ' · #' + (i + 1),
})),
createdAt: Date.now(),
};
}
function scheduleResults(msg) {
msg.results.forEach((r, idx) => {
setTimeout(() => {
r.status = Math.random() < 0.06 ? 'err' : 'ok';
saveAll();
renderStream();
syncImageTask(msg);
}, 1100 + idx * 300 + Math.random() * 600);
});
}
function send() {
const txt = (inputText.value || '').trim();
if (!txt) return;
const msg = createMessage(txt);
state.messages.push(msg);
inputText.value = '';
state.refImages = [];
renderRefs();
syncSendDisabled();
autoResize();
saveAll();
renderStream();
scheduleResults(msg);
syncImageTask(msg);
setTimeout(() => stream.scrollTo({ top: stream.scrollHeight, behavior: 'smooth' }), 30);
}
/* ---------- 输入栏:参考图 ---------- */
function renderRefs() {
if (!state.refImages.length) {
inputRefs.classList.remove('show');
inputRefs.innerHTML = '';
return;
}
inputRefs.classList.add('show');
inputRefs.innerHTML = state.refImages.map(r => `
<div class="io-input-ref" data-id="${r.id}">
<img src="${r.dataUrl}" alt="">
<button class="x" type="button"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8"/></svg></button>
</div>
`).join('');
inputRefs.querySelectorAll('.x').forEach(b => {
b.onclick = e => {
e.stopPropagation();
const id = b.closest('.io-input-ref').dataset.id;
state.refImages = state.refImages.filter(r => r.id !== id);
renderRefs();
};
});
}
addBtn.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', e => {
const fs = [...e.target.files].filter(f => f.type.startsWith('image/'));
const room = 3 - state.refImages.length;
if (room <= 0) { e.target.value = ''; return; }
const incoming = fs.slice(0, room);
let done = 0;
incoming.forEach(f => {
const reader = new FileReader();
reader.onload = ev => {
state.refImages.push({ id: 'rf-' + uid(), dataUrl: ev.target.result, name: f.name });
if (++done === incoming.length) renderRefs();
};
reader.readAsDataURL(f);
});
e.target.value = '';
});
/* ---------- 输入框自适应高度 ---------- */
function autoResize() {
inputText.style.height = 'auto';
inputText.style.height = Math.min(180, inputText.scrollHeight) + 'px';
}
inputText.addEventListener('input', () => {
syncSendDisabled();
autoResize();
});
function syncSendDisabled() {
sendBtn.disabled = !(inputText.value || '').trim();
}
// 回车发送(Shift+Enter 换行)
inputText.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (!sendBtn.disabled) send();
}
});
sendBtn.addEventListener('click', send);
/* ---------- 参数胶囊下拉 ---------- */
function buildParamMenus() {
function fill(menuEl, items, key, getLabel) {
menuEl.innerHTML = items.map(it => {
const v = it.id;
return `<div class="mi${state.param[key] === v ? ' selected' : ''}" data-val="${v}">
<svg class="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="3 8 7 12 13 4"/></svg>
<span>${esc(it.label)}</span>
${it.sub ? `<span class="mi-sub">${esc(it.sub)}</span>` : ''}
</div>`;
}).join('');
menuEl.addEventListener('click', e => {
const mi = e.target.closest('.mi');
if (!mi) return;
e.stopPropagation();
const val = key === 'count' ? parseInt(mi.dataset.val, 10) : mi.dataset.val;
state.param[key] = val;
menuEl.parentElement.classList.remove('open');
syncParamLabels();
updateCost();
// 重新渲染当前菜单的 selected 状态
menuEl.querySelectorAll('.mi').forEach(x => x.classList.toggle('selected', x.dataset.val == String(val)));
});
}
fill($('#io-menu-model'), MODELS, 'model');
fill($('#io-menu-ratio'), RATIOS, 'ratio');
fill($('#io-menu-style'), STYLES, 'style');
fill($('#io-menu-count'), COUNTS, 'count');
}
function syncParamLabels() {
$('#io-param-model-lbl').textContent = (MODELS.find(m => m.id === state.param.model) || {}).label || state.param.model;
$('#io-param-ratio-lbl').textContent = state.param.ratio;
$('#io-param-style-lbl').textContent = (STYLES.find(s => s.id === state.param.style) || {}).label || state.param.style;
$('#io-param-count-lbl').textContent = state.param.count;
}
function updateCost() {
costVal.textContent = '¥' + (state.param.count * PRICE_PER).toFixed(2);
}
// 点击 chip 切换下拉
document.querySelectorAll('.io-input-bottom .param').forEach(p => {
p.addEventListener('click', e => {
// 菜单内 click 已 stop,这里只处理 chip 本体
if (e.target.closest('.io-param-menu')) return;
const isOpen = p.classList.contains('open');
document.querySelectorAll('.io-input-bottom .param.open').forEach(x => x.classList.remove('open'));
if (!isOpen) p.classList.add('open');
});
});
document.addEventListener('click', e => {
if (!e.target.closest('.io-input-bottom .param')) {
document.querySelectorAll('.io-input-bottom .param.open').forEach(x => x.classList.remove('open'));
}
if (!e.target.closest('.io-tool-filter')) {
document.querySelectorAll('.io-tool-filter.open').forEach(x => x.classList.remove('open'));
}
if (!e.target.closest('.cell-more-wrap')) {
document.querySelectorAll('.cell-more-wrap.open').forEach(x => x.classList.remove('open'));
}
if (!e.target.closest('.msg-more-wrap')) {
document.querySelectorAll('.msg-more-wrap.open').forEach(x => x.classList.remove('open'));
}
});
/* ---------- 顶部工具栏:折叠 / 搜索 / 筛选 ---------- */
function setSideCollapsed(collapsed) {
ioApp?.classList.toggle('side-collapsed', collapsed);
if (sideRestore) sideRestore.hidden = !collapsed;
try { localStorage.setItem('fs-io-side-collapsed', collapsed ? '1' : '0'); } catch (e) {}
}
foldBtn?.addEventListener('click', () => setSideCollapsed(true));
sideRestore?.addEventListener('click', () => setSideCollapsed(false));
toolbarSearchBtn?.addEventListener('click', () => {
const open = toolbarSearch?.hidden;
if (toolbarSearch) toolbarSearch.hidden = !open;
toolbarSearchBtn.classList.toggle('active', !!open || !!state.toolbar.q);
if (open) requestAnimationFrame(() => toolbarSearchInput?.focus());
});
toolbarSearchInput?.addEventListener('input', e => {
state.toolbar.q = e.target.value.trim();
toolbarSearchBtn?.classList.toggle('active', !!state.toolbar.q || !toolbarSearch?.hidden);
renderStream();
});
toolbarSearchClear?.addEventListener('click', () => {
state.toolbar.q = '';
if (toolbarSearchInput) toolbarSearchInput.value = '';
toolbarSearchBtn?.classList.toggle('active', !toolbarSearch?.hidden);
renderStream();
toolbarSearchInput?.focus();
});
function syncToolbarFilters() {
document.querySelectorAll('.io-tool-filter').forEach(wrap => {
const key = wrap.dataset.filter;
const value = state.toolbar[key] || 'all';
const items = TOOL_FILTERS[key] || [];
const selected = items.find(x => x.id === value) || items[0];
wrap.querySelector('[data-filter-label]').textContent = selected?.label || '';
wrap.querySelector('.tb-chip')?.classList.toggle('active', value !== 'all');
const menu = wrap.querySelector('[data-filter-menu]');
if (!menu) return;
menu.innerHTML = items.map(it => `
<button class="mi${it.id === value ? ' selected' : ''}" type="button" data-val="${esc(it.id)}">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="3 8 7 12 13 4"/></svg>
<span>${esc(it.label)}</span>
</button>
`).join('');
});
}
document.querySelectorAll('.io-tool-filter').forEach(wrap => {
const btn = wrap.querySelector('.tb-chip');
const menu = wrap.querySelector('[data-filter-menu]');
btn?.addEventListener('click', e => {
e.stopPropagation();
const open = wrap.classList.contains('open');
document.querySelectorAll('.io-tool-filter.open').forEach(x => x.classList.remove('open'));
if (!open) wrap.classList.add('open');
});
menu?.addEventListener('click', e => {
const item = e.target.closest('.mi');
if (!item) return;
e.stopPropagation();
state.toolbar[wrap.dataset.filter] = item.dataset.val;
wrap.classList.remove('open');
syncToolbarFilters();
renderStream();
});
});
/* ---------- 新对话 ---------- */
newConvBtn.addEventListener('click', () => {
// 若 default 上有内容,把它归档到「最近」(新 id + 转存 messages),然后清空 default
if (state.activeConvId === 'default' && state.messages.length) {
const first = state.messages[0];
const title = (first.prompt || '').slice(0, 18) || '未命名对话';
const newId = 'c-' + uid();
state.convMessages[newId] = slimMessages(state.messages);
state.convs.unshift({
id: newId,
title,
thumb: '',
updatedAt: Date.now(),
});
// 限制 20 条 + 同步清掉超额会话的 messages
if (state.convs.length > 20) {
const dropped = state.convs.slice(20);
state.convs = state.convs.slice(0, 20);
dropped.forEach(c => { delete state.convMessages[c.id]; });
}
}
// 清空 default 并切回 default
state.convMessages['default'] = [];
state.messages = [];
state.activeConvId = 'default';
saveAll();
renderSide();
renderStream();
inputText.focus();
});
/* ---------- 浮动"回到底部" ---------- */
stream.addEventListener('scroll', () => {
const near = stream.scrollTop + stream.clientHeight >= stream.scrollHeight - 120;
jumpBtn.classList.toggle('show', !near && state.messages.length > 0);
});
jumpBtn.addEventListener('click', () => {
stream.scrollTo({ top: stream.scrollHeight, behavior: 'smooth' });
});
/* ---------- 初始化 ---------- */
(function init() {
const data = loadAll();
state.convs = data.convs || [];
state.convMessages = data.convMessages || { default: [] };
state.activeConvId = data.activeConvId || 'default';
state.messages = (state.convMessages[state.activeConvId] || []).slice();
// URL ?prompt= 预填
try {
const q = new URLSearchParams(location.search);
const seed = q.get('prompt');
if (seed) inputText.value = decodeURIComponent(seed);
} catch (e) {}
buildParamMenus();
syncParamLabels();
syncToolbarFilters();
try { setSideCollapsed(localStorage.getItem('fs-io-side-collapsed') === '1'); } catch (e) {}
updateCost();
renderRefs();
syncSendDisabled();
autoResize();
renderSide();
renderStream();
setTimeout(() => stream.scrollTo({ top: stream.scrollHeight }), 50);
})();
})();
</script>
</body>
</html>