diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..631b97999 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# 敏感信息 +API/.env + +# 运行时数据 +assets/ +data/ + +# 系统文件 +.DS_Store +__pycache__/ +*.pyc +history.json diff --git a/.qoder/better-loop/2026-07-21/160331-infinite-canvas/canvas.json b/.qoder/better-loop/2026-07-21/160331-infinite-canvas/canvas.json new file mode 100644 index 000000000..9e4924ab3 --- /dev/null +++ b/.qoder/better-loop/2026-07-21/160331-infinite-canvas/canvas.json @@ -0,0 +1,123 @@ +{ + "schemaVersion": 1, + "summary": { + "evidenceMode": "session-limited", + "evidenceBoundary": { + "manifest": { + "schemaVersion": 2, + "sourceFingerprint": "7254844991a82cce", + "adapterVersion": "qoder-task-loop-source-v2", + "platform": "qoder", + "selection": { + "strategy": "all-eligible", + "eligibleCount": 0, + "analyzedCount": 0, + "confidence": "Low" + } + }, + "deliveryEvidenceLevels": [], + "sourceGaps": [] + }, + "semanticFacets": { + "schemaVersion": 1, + "status": "supplementary", + "entries": [ + { + "id": "session-insight:source-coverage", + "kind": "redacted-summary", + "episodeRef": null, + "status": "candidate", + "labels": [ + "source-coverage", + "Low" + ], + "summary": "Analyzed 0 of 0 sessions; 0/5 enabled source roots exist. Use this as the current workspace evidence boundary for final insight cards.", + "evidenceRefs": [], + "modelVersion": "session-insights-v1" + }, + { + "id": "session-insight:validation-behavior", + "kind": "redacted-summary", + "episodeRef": null, + "status": "candidate", + "labels": [ + "validation-behavior", + "Low" + ], + "summary": "No validation command category was observed in the analyzed session sample. Inspect more sessions or add explicit validation guidance before claiming validation-after-edit behavior.", + "evidenceRefs": [], + "modelVersion": "session-insights-v1" + }, + { + "id": "session-insight:post-edit-validation", + "kind": "rework-correction", + "episodeRef": null, + "status": "candidate", + "labels": [ + "post-edit-validation", + "Low" + ], + "summary": "No edit event was observed in the analyzed sample. Inspect more sessions before making claims about edit or validation habits.", + "evidenceRefs": [], + "modelVersion": "session-insights-v1" + }, + { + "id": "session-insight:execution-friction", + "kind": "friction-taxonomy", + "episodeRef": null, + "status": "candidate", + "labels": [ + "execution-friction", + "Low" + ], + "summary": "No strong execution friction signal in the analyzed sample. Keep friction claims narrow unless additional failed commands, rejected actions, or warnings are inspected.", + "evidenceRefs": [], + "modelVersion": "session-insights-v1" + } + ] + }, + "learningCapture": { + "schemaVersion": 1, + "state": "N/A", + "summary": "暂不适用——需要两个可比的观察窗口和一次改进对比。", + "interventions": [] + } + }, + "dimensions": [ + { + "id": "task-understanding" + }, + { + "id": "controlled-execution" + }, + { + "id": "change-validation" + }, + { + "id": "reliable-delivery" + }, + { + "id": "learning-capture" + } + ], + "findings": [ + { + "id": "agent-context-entrypoint-missing" + }, + { + "id": "monolith-scope-localization" + }, + { + "id": "no-automated-validation" + }, + { + "id": "no-quality-enforcement-gate" + }, + { + "id": "dependency-not-pinned" + }, + { + "id": "no-change-acceptance-path" + } + ] +} diff --git a/.qoder/better-loop/2026-07-21/160331-infinite-canvas/findings.json b/.qoder/better-loop/2026-07-21/160331-infinite-canvas/findings.json new file mode 100644 index 000000000..aefc1b7d1 --- /dev/null +++ b/.qoder/better-loop/2026-07-21/160331-infinite-canvas/findings.json @@ -0,0 +1,191 @@ +{ + "summary": { + "projectName": "Infinite-Canvas", + "locale": "zh-CN", + "modelId": "agent-work-loop-v4", + "reportContractVersion": 24, + "overview": "Infinite-Canvas 是一个 FastAPI 单体项目,具备基本启动脚本和依赖声明,但缺乏 agent 上下文指引、自动化验证、质量门禁和变更验收机制。本次审查为 session-limited 模式(无合格会话),结论仅基于项目静态证据。", + "aiAgentPractice": { + "inspectedSurfaces": [ + "Workflows", + "Plugins", + "Memories" + ], + "coverageRows": [ + { + "surface": "Workflows", + "scopes": [ + "Project" + ], + "count": 7, + "paths": [ + "workflows/2511.json", + "workflows/Flux2-Klein.json", + "workflows/LTXDirectorv2-API.json", + "workflows/upscale.json", + "workflows/Z-Image.json", + "workflows/Z-Image-Enhance.json", + "workflows/LTXDirectorv2-API.config.json" + ] + }, + { + "surface": "Plugins", + "scopes": [ + "Plugin" + ], + "count": 2, + "paths": [ + "better-loop", + "qoder-create-plugin" + ] + }, + { + "surface": "Memories", + "scopes": [ + "Project" + ], + "count": 2, + "paths": [ + "~/.qoder/memories/019f6f6d/projects/Users-yangfan-Desktop-Infinite-Canvas/project_introduction/Infinite-Canvas_AI_Agent项目概述.md", + "~/.qoder/memories/019f6f6d/projects/Users-yangfan-Desktop-Infinite-Canvas/project_tech_stack/Python后端技术栈与协议支持.md" + ] + } + ] + }, + "suggestions": [], + "assignmentSummaries": [], + "dimensions": [ + { + "id": "task-understanding", + "label": "任务理解", + "score": 25, + "summary": "README 提供了 fork 约束等基本信息,但无 agent 指令文件或模块化结构,agent 无法高效定位任务边界和风险区域。", + "findingRefs": [ + "agent-context-entrypoint-missing", + "monolith-scope-localization" + ] + }, + { + "id": "controlled-execution", + "label": "可控执行", + "score": 30, + "summary": "存在一键启动脚本和基本依赖声明,但依赖未锁定版本,无 doctor/reset 路径,环境可复现性不足。", + "findingRefs": [ + "dependency-not-pinned" + ] + }, + { + "id": "change-validation", + "label": "改动验证", + "score": 5, + "summary": "项目中不存在任何测试、lint、typecheck 或 CI 配置,agent 变更后无任何自动化反馈信号。", + "findingRefs": [ + "no-automated-validation" + ] + }, + { + "id": "reliable-delivery", + "label": "可靠交付", + "score": 10, + "summary": "无 CI 流水线、分支保护、PR 验收路径或回滚机制,变更可直接合入主分支而无机械化检查。", + "findingRefs": [ + "no-quality-enforcement-gate", + "no-change-acceptance-path" + ] + }, + { + "id": "learning-capture", + "label": "经验沉淀", + "score": 35, + "summary": "存在 2 条项目记忆和 7 个工作流资产,但无可观察的循环检测、复用改进或后续验证证据。", + "findingRefs": [] + } + ] + }, + "findings": [ + { + "id": "agent-context-entrypoint-missing", + "title": "缺少 Agent 上下文入口文件", + "severity": "Medium", + "reason": "项目无 AGENTS.md、CLAUDE.md 或等效的 agent 指令文件。agent 无法从项目结构中获得任务边界、高风险区域、模块职责或下一步指引,只能依赖通读 README 和遍历目录推断上下文。", + "expectedOutput": [ + "创建一个结构化的 agent 入口文件,覆盖项目定位、目录说明、启动路径、高风险区域和任务路由。" + ], + "expectedArtifact": "AGENTS.md", + "aiFixPrompt": "/better-loop fix this issue\n\n为 Infinite-Canvas 创建一个简洁的 AGENTS.md 入口文件(80-150 行),包含:项目定位、目录职责、启动命令、高风险区域、常见任务路径。\n\n## Validation\n\n- 确认文件存在且内容覆盖上述五个部分\n- 确认命令与 mac-启动服务.sh 和 main.py 一致", + "dimensionRefs": [ + "task-understanding" + ] + }, + { + "id": "monolith-scope-localization", + "title": "17,787 行单文件单体阻碍变更范围定位", + "severity": "High", + "reason": "main.py 包含全部路由、WebSocket 管理、LLM 调用、生图逻辑和文件处理(17,787 行),无模块或包拆分。agent 无法通过目录结构定位变更范围,任何修改都需要在巨大单文件中搜索目标,增加误改风险。", + "expectedOutput": [ + "将 main.py 按功能域拆分为独立模块,每个模块职责单一,agent 可通过目录结构定位变更范围。" + ], + "expectedArtifact": "模块拆分方案", + "aiFixPrompt": "/better-loop fix this issue\n\n制定 main.py 的模块拆分方案:按功能域(路由、WebSocket、LLM、生图、文件处理)拆分为独立模块,保持现有 API 兼容。\n\n## Validation\n\n- 服务正常启动且端口 3000 可访问\n- 各模块可独立导入无循环依赖", + "dimensionRefs": [ + "task-understanding" + ] + }, + { + "id": "no-automated-validation", + "title": "无任何自动化验证手段", + "severity": "High", + "reason": "项目中不存在测试文件、lint 配置(ruff/flake8)、类型检查(mypy/pyright)或 CI 流水线。agent 变更后唯一的反馈是「服务是否启动不报错」,无法检测逻辑回归、类型错误或代码风格问题。", + "expectedOutput": [ + "配置 ruff lint 和 pytest 冒烟测试,agent 变更后可在 2 分钟内获得可操作的反馈信号。" + ], + "expectedArtifact": "验证配置", + "aiFixPrompt": "/better-loop fix this issue\n\n为项目添加最小验证层:1) 配置 ruff 作为 linter;2) 添加 pytest 及至少覆盖核心路由的冒烟测试;3) 在 requirements.txt 中声明开发依赖。\n\n## Validation\n\n- ruff check 可运行并输出可操作的诊断\n- pytest 至少有一个通过的冒烟测试\n- 验证命令可在 2 分钟内完成", + "dimensionRefs": [ + "change-validation" + ] + }, + { + "id": "no-quality-enforcement-gate", + "title": "无质量执行门禁", + "severity": "Medium", + "reason": "无 pre-commit hooks、CI pipeline、schema 校验或架构边界检查。质量完全依赖人工审查,agent 产出的代码无机械化拦截路径,违反约束的变更可无阻碍地进入代码库。", + "expectedOutput": [ + "配置 pre-commit hook,在提交前自动运行 lint 检查,违规时阻止提交并给出修复方向。" + ], + "expectedArtifact": "质量门禁配置", + "aiFixPrompt": "/better-loop fix this issue\n\n添加最小质量门禁:1) 配置 pre-commit hook 运行 ruff check;2) 添加基本的 .pre-commit-config.yaml。\n\n## Validation\n\n- pre-commit run --all-files 可执行\n- 违规时输出包含文件和规则信息", + "dimensionRefs": [ + "reliable-delivery" + ] + }, + { + "id": "dependency-not-pinned", + "title": "依赖未锁定版本,环境不可复现", + "severity": "Medium", + "reason": "requirements.txt 中 7 个依赖均无版本约束(无 == 或 >= 限定),无 pip-tools/poetry lockfile。不同时间或环境安装可能产生不兼容版本,agent 无法保证确定性环境复现。", + "expectedOutput": [ + "生成带版本约束的依赖文件,确保不同环境安装结果一致。" + ], + "expectedArtifact": "锁定的依赖文件", + "aiFixPrompt": "/better-loop fix this issue\n\n锁定依赖版本:1) 使用 pip freeze 或 pip-compile 生成带版本约束的 requirements.txt;2) 或迁移到 poetry/pip-tools 管理 lockfile。\n\n## Validation\n\n- requirements.txt 中每个依赖有明确版本约束\n- 从 lockfile 安装后服务正常启动", + "dimensionRefs": [ + "controlled-execution" + ] + }, + { + "id": "no-change-acceptance-path", + "title": "无变更验收路径和安全边界", + "severity": "Medium", + "reason": "无分支保护、PR 模板、CODEOWNERS 或合并前检查。agent 产出的变更可直接合入主分支而无验收步骤。同时 data/ 目录下的运行时数据虽被 .gitignore 排除但存在于工作目录,agent 操作可能意外修改用户数据而无感知。", + "expectedOutput": [ + "声明变更验收路径和运行时数据操作边界,agent 产出变更有明确的验收步骤。" + ], + "expectedArtifact": "验收流程文档", + "aiFixPrompt": "/better-loop fix this issue\n\n建立最小变更验收路径:1) 在 README 或 AGENTS.md 中声明变更需通过分支+PR 流程;2) 对 data/ 目录添加操作边界说明。\n\n## Validation\n\n- 文档中明确了变更验收流程\n- data/ 目录的操作边界有明确说明", + "dimensionRefs": [ + "reliable-delivery" + ] + } + ] +} diff --git a/.qoder/better-loop/2026-07-21/160331-infinite-canvas/report.canvas.tsx b/.qoder/better-loop/2026-07-21/160331-infinite-canvas/report.canvas.tsx new file mode 100644 index 000000000..2036a5510 --- /dev/null +++ b/.qoder/better-loop/2026-07-21/160331-infinite-canvas/report.canvas.tsx @@ -0,0 +1,1825 @@ +import { + AreaChart, + Button, + Callout, + Card, + CardBody, + CardHeader, + CollapsibleSection, + Dialog, + Divider, + Fluency, + Grid, + H1, + H2, + IconButton, + MetricsGrid, + Row, + RiskHeatmap, + SendToChatButton, + Stack, + Table, + Tag, + Text, +} from "qoder/canvas"; +import hostReportData from "./findings.json"; +import canvasData from "./canvas.json"; + +function mergeCanvasRows(hostRows, canvasRows) { + const detailById = new Map( + (Array.isArray(canvasRows) ? canvasRows : []) + .filter((row) => row && typeof row === "object" && typeof row.id === "string") + .map((row) => [row.id, row]), + ); + return (Array.isArray(hostRows) ? hostRows : []).map((row) => ({ ...detailById.get(row?.id), ...row })); +} + +function mergeCanvasObjects(host, detail) { + if (!host || typeof host !== "object" || Array.isArray(host)) return detail; + if (!detail || typeof detail !== "object" || Array.isArray(detail)) return host; + const merged = { ...host }; + for (const [key, value] of Object.entries(detail)) { + merged[key] = value && typeof value === "object" && !Array.isArray(value) + ? mergeCanvasObjects(host[key], value) + : value; + } + return merged; +} + +function mergeCanvasReport(host, detail) { + const summary = host?.summary ?? {}; + if (!detail || typeof detail !== "object" || Array.isArray(detail)) return host; + return { + summary: { + ...(detail?.summary ?? {}), + ...summary, + atAGlance: mergeCanvasObjects(detail?.summary?.atAGlance, summary.atAGlance), + dimensions: mergeCanvasRows(summary.dimensions, detail?.dimensions), + }, + findings: mergeCanvasRows(host?.findings, detail?.findings), + }; +} + +const report = mergeCanvasReport(hostReportData, canvasData); + +const pageStyle = { maxWidth: 960, margin: "0 auto", padding: 16, boxSizing: "border-box" }; +const taskLoopPageStyle = { ...pageStyle, maxWidth: 1200, padding: 20 }; +const taskLoopReaderCopyStyle = { maxWidth: 680 }; + +const DIMENSION_SUMMARY_EXAMPLE = "Example: project guidance makes the main workflow clear, but ownership for cross-cutting changes is not documented."; + +function list(value) { + return Array.isArray(value) ? value : []; +} + +function clampScore(value) { + const score = Number(value); + if (!Number.isFinite(score)) return 0; + return Math.max(0, Math.min(100, score)); +} + +function projectName() { + return report.summary?.projectName ?? "Qoder Harness Report"; +} + +function textValue(value) { + return typeof value === "string" ? value.trim() : ""; +} + +function openingStrengths() { + const explicit = list(report.summary?.strengths).map(textValue).filter(Boolean); + return explicit.length ? explicit.slice(0, 3) : ["Reviewed project signals are organized into dimensions and issue findings."]; +} + +function averageScore(dimensions) { + if (dimensions.length === 0) return 0; + return Math.round(dimensions.reduce((sum, row) => sum + clampScore(row.score), 0) / dimensions.length); +} + +function scoreTone(score) { + if (score >= 70) return "success"; + if (score >= 40) return "warning"; + return "danger"; +} + +function stageStatus(score) { + if (score >= 70) return "high"; + if (score >= 40) return "medium"; + if (score > 0) return "low"; + return "blocked"; +} + +function fluencyReason(row) { + return textValue(row?.summary) || taskLoopCopy( + "No reviewed score explanation is available for this dimension.", + "这个维度暂时没有经过复核的评分说明。", + ); +} + +function splitFluencyTooltipReason(value) { + let remaining = textValue(value).replace(/\s+/g, " "); + const chunks = []; + const limits = /[\u3400-\u9fff]/.test(remaining) ? [20, 20, 20, 20] : [34, 30, 30, 30]; + for (const limit of limits) { + if (!remaining) break; + if (remaining.length <= limit) { + chunks.push(remaining); + remaining = ""; + break; + } + let cut = remaining.lastIndexOf(" ", limit); + if (cut < Math.floor(limit * 0.55)) cut = limit; + chunks.push(remaining.slice(0, cut).trim()); + remaining = remaining.slice(cut).trim(); + } + if (remaining && chunks.length) { + chunks[chunks.length - 1] = `${chunks[chunks.length - 1].slice(0, 29)}…`; + } + return chunks; +} + +function dimensionFluencyStages(dimensions) { + return dimensions.map((row) => { + const score = clampScore(row.score); + const usesGenericBand = row.id !== "learning-capture"; + return { + id: row.id, + name: taskLoopDimensionLabel(row.id), + score, + ...(usesGenericBand ? { status: stageStatus(score), blocker: score <= 20 } : {}), + }; + }); +} + +function dimensionFluencyTooltip(row) { + const [title, ...rows] = splitFluencyTooltipReason(fluencyReason(row)); + return { + title, + rows: rows.map((value) => ({ value })), + }; +} + +function severityTone(value) { + if (value === "Critical" || value === "High") return "danger"; + if (value === "Medium") return "warning"; + if (value === "Low") return "success"; + return "neutral"; +} + +function severityRank(value) { + if (value === "Critical") return 0; + if (value === "High") return 1; + if (value === "Medium") return 2; + if (value === "Low") return 3; + return 4; +} + +function dimensionLabel(id, dimensions) { + const match = dimensions.find((row) => row.id === id); + return match?.label ?? id.replace(/-/g, " "); +} + +function aiAgentPractice() { + return report.summary?.aiAgentPractice ?? {}; +} + +function practiceRows() { + const rows = aiAgentPractice().coverageRows; + return Array.isArray(rows) ? rows : []; +} + +function inspectedSurfaces() { + const surfaces = aiAgentPractice().inspectedSurfaces; + return Array.isArray(surfaces) ? surfaces : []; +} + +function visiblePracticePaths(value) { + return list(value).map(textValue).filter((candidate) => candidate + && !candidate.includes("SharedClientCache/projects/") + && !candidate.startsWith("/") + && !/^[A-Za-z]:[\\/]/.test(candidate) + && !candidate.split(/[\\/]/).includes("..")); +} + +function practiceDescription(surface) { + const descriptions = { + Rules: ["Standing project guidance and task-routing instructions.", "项目常驻指引与任务路由说明。"], + Skills: ["Reusable agent workflows available to the project.", "项目可用的可复用 Agent 工作流。"], + "Custom Agents": ["Specialized agent profiles available for delegated work.", "可用于委派工作的专用 Agent 配置。"], + Hooks: ["Lifecycle automation around agent and delivery events.", "围绕 Agent 与交付事件的生命周期自动化。"], + MCP: ["External tools and resources exposed through MCP.", "通过 MCP 暴露的外部工具与资源。"], + Commands: ["Named command entry points for repeatable agent work.", "可重复 Agent 工作的命令入口。"], + Workflows: ["Reusable multi-step project workflows.", "可复用的多步骤项目工作流。"], + Plugins: ["Installed packages that contribute agent capabilities.", "提供 Agent 能力的已安装插件。"], + "Session Insights": ["Task-session evidence available for report analysis.", "可用于报告分析的任务会话证据。"], + Memories: ["Representative project or global Memory note files.", "项目级或全局 Memory 的代表性笔记文件。"], + }; + const copy = descriptions[surface] ?? ["Recorded agent capability sources.", "已记录的 Agent 能力来源。"]; + return taskLoopCopy(copy[0], copy[1]); +} + +function TaskLoopPracticePaths({ paths }) { + if (paths.length === 0) return null; + const preview = paths.slice(0, 2); + const remaining = paths.slice(2); + return ( + + {preview.map((path, index) => ( + {path} + ))} + {remaining.length ? ( + + {taskLoopCopy(`View ${remaining.length} more locations`, `查看其余 ${remaining.length} 个位置`)} + + )} + bodyStyle={{ padding: "4px 0 0 16px" }} + headerStyle={{ borderBottom: "none", minHeight: 24 }} + > + + {remaining.map((path, index) => ( + {path} + ))} + + + ) : null} + + ); +} + +function PracticeSourceCard({ row }) { + const paths = visiblePracticePaths(row.paths); + const scopes = list(row.scopes); + return ( + + + + {row.surface ?? taskLoopCopy("Surface", "能力面")} + + )} + trailing={Number.isInteger(Number(row.count)) ? {row.count} : undefined} + /> + + + {practiceDescription(row.surface)} + {scopes.length || paths.length ? ( + + {scopes.length ? ( + + {scopes.map((scope) => {scope})} + + ) : null} + {paths.length ? ( + + {taskLoopCopy("Sources", "来源")} + + + ) : null} + + ) : null} + + + + ); +} + +function OpeningStrengths() { + const strengths = openingStrengths(); + return ( + + + {strengths.map((strength, index) => ( + {strength} + ))} + + + ); +} + +function DimensionSummary({ dimensions }) { + return ( + + {dimensions.map((row) => { + const score = clampScore(row.score); + return ( + + {taskLoopDimensionLabel(row.id)}} + trailing={{score}%} + /> + + + {textValue(row.summary) || DIMENSION_SUMMARY_EXAMPLE} + {list(row.findingRefs).length ? ( + Linked findings: {row.findingRefs.join(", ")} + ) : null} + + + + ); + })} + + ); +} + +function FindingItem({ row, dimensions }) { + return ( + + {row.title ?? row.id}} + trailing={{row.severity ?? "Unrated"}} + /> + + + + {list(row.dimensionRefs).slice(0, 1).map((ref) => ( + {dimensionLabel(ref, dimensions)} + ))} + + + + AI Fix + + + + + + ); +} + +function PracticeCoverage() { + const rows = practiceRows(); + const surfaces = inspectedSurfaces(); + + return ( + + + AI Agent Practices + {surfaces.length ? {surfaces.length} surfaces : null} + + {rows.length ? ( + + {rows.map((row, index) => )} + + ) : No AI Agent practice rows recorded.} + + ); +} + +function usesChineseReaderCopy() { + const locale = textValue(report.summary?.locale); + if (locale) return locale.toLowerCase().startsWith("zh"); + const readerSample = [ + ...list(report.summary?.strengths), + ...list(report.findings).slice(0, 3).flatMap((row) => [row?.title, row?.reason, row?.reader]), + ].map(textValue).join(" "); + return /[\u3400-\u9fff]/.test(readerSample); +} + +function taskLoopCopy(english, chinese) { + return usesChineseReaderCopy() ? chinese : english; +} + +function taskLoopDimensionLabel(id) { + if (!id) return taskLoopCopy("not observed", "未观察到"); + return dimensionLabel(id, list(report.summary?.dimensions)); +} + +function learningStateLabel(value) { + const labels = { + "N/A": ["Needs a comparison", "需要比较"], + pending: ["Comparison planned", "已计划比较"], + improving: ["Improving", "正在改善"], + unchanged: ["No clear change", "没有明显变化"], + regressing: ["Worse — stop or revert", "变差——停止或回退"], + "outcome-supported": ["A later result supports it", "后续结果支持它"], + }[value]; + return labels ? taskLoopCopy(labels[0], labels[1]) : taskLoopCopy("Not observed", "未观察到"); +} + +function taskLoopSummary() { + return report.summary?.atAGlance ?? {}; +} + +function taskLoopSessionOverview() { + const entries = list(report.summary?.semanticFacets?.entries); + const usage = entries.find((entry) => entry?.id === "session-insight:session-usage-efficiency"); + return textValue(usage?.summary); +} + +function taskLoopUsageActivity() { + const activity = report.summary?.usageActivity; + return activity && list(activity.dates).length ? activity : null; +} + +function taskLoopUsageEfficiency() { + const usage = report.summary?.usageEfficiency; + if (!usage || typeof usage !== "object" || Array.isArray(usage)) return null; + return usage.selection || usage.accounting || usage.longSessions || usage.modelUsage || usage.reviewLead + ? usage + : null; +} + +function taskLoopUsageCoverageInfo(activity, usage, sessionOverview) { + if (textValue(sessionOverview)) return sessionOverview; + const analyzed = Number(usage?.selection?.analyzedSessionCount); + const eligible = Number(usage?.selection?.eligibleSessionCount); + if (Number.isFinite(analyzed) && analyzed >= 0 && Number.isFinite(eligible) && eligible >= 0) { + return analyzed === eligible + ? taskLoopCopy(`This analysis covered ${formatUsageNumber(analyzed)} sessions.`, `本次分析覆盖 ${formatUsageNumber(analyzed)} 个会话。`) + : taskLoopCopy( + `This analysis covered ${formatUsageNumber(analyzed)} of ${formatUsageNumber(eligible)} eligible sessions.`, + `本次分析覆盖 ${formatUsageNumber(eligible)} 个候选会话中的 ${formatUsageNumber(analyzed)} 个。`, + ); + } + const total = Number(activity?.sessions?.total); + return Number.isFinite(total) && total >= 0 + ? taskLoopCopy(`This analysis covered ${formatUsageNumber(total)} sessions.`, `本次分析覆盖 ${formatUsageNumber(total)} 个会话。`) + : ""; +} + +function usageSeriesTotal(series) { + return list(series).reduce((sum, row) => sum + Number(row?.total ?? 0), 0); +} + +function usageActivityMatrix(activity) { + const sourceDates = list(activity?.dates); + if (!sourceDates.length) return null; + const last = new Date(`${sourceDates.at(-1)}T00:00:00.000Z`); + const windowStart = new Date(last.getTime() - (364 * 86_400_000)); + const start = new Date(windowStart); + start.setUTCDate(start.getUTCDate() - start.getUTCDay()); + const weekCount = 53; + const columns = Array.from({ length: weekCount }, (_, index) => { + const date = new Date(start.getTime() + (index * 7 * 86_400_000)); + const previous = index > 0 ? new Date(start.getTime() + ((index - 1) * 7 * 86_400_000)) : null; + if (!previous || previous.getUTCMonth() === date.getUTCMonth()) return ""; + return usesChineseReaderCopy() + ? `${date.getUTCMonth() + 1}月` + : date.toLocaleDateString("en-US", { month: "short", timeZone: "UTC" }); + }); + const values = Array.from({ length: 7 }, () => Array(weekCount).fill(null)); + for (let dayOffset = 0; dayOffset < 365; dayOffset += 1) { + const date = new Date(windowStart.getTime() + (dayOffset * 86_400_000)); + const week = Math.floor((date.getTime() - start.getTime()) / (7 * 86_400_000)); + const dateKey = date.toISOString().slice(0, 10); + values[date.getUTCDay()][week] = { + id: dateKey, + value: 0, + ariaLabel: taskLoopCopy(`${dateKey}: no observed activity`, `${dateKey}:未观察到活动`), + }; + } + sourceDates.forEach((date, index) => { + const parsed = new Date(`${date}T00:00:00.000Z`); + const week = Math.floor((parsed.getTime() - start.getTime()) / (7 * 86_400_000)); + if (week < 0 || week >= weekCount) return; + const active = Number(activity?.sessions?.activeMinutes?.[index] ?? 0); + values[parsed.getUTCDay()][week] = { + id: date, + value: active, + ariaLabel: taskLoopCopy(`${date}: ${formatActivityMinutes(active)}`, `${date}:${formatActivityMinutes(active)}`), + }; + }); + return { columns, values, start: windowStart.toISOString().slice(0, 10), end: sourceDates.at(-1) }; +} + +function usageChartWindow(activity) { + const dates = list(activity?.dates); + const offset = Math.max(0, dates.length - 30); + return { offset, categories: dates.slice(offset).map((date) => String(date).slice(5)) }; +} + +function visibleUsageSeries(series, offset, limit = 5) { + const rows = list(series); + const named = rows.filter((row) => row?.name !== "Other"); + const primary = named.slice(0, limit).map((row) => ({ name: usageSeriesLabel(row.name), data: list(row.daily).slice(offset), total: Number(row.total ?? 0) })); + const remainder = [...named.slice(limit), ...rows.filter((row) => row?.name === "Other")]; + if (remainder.length > 0) { + const length = primary[0]?.data.length ?? list(remainder[0]?.daily).slice(offset).length; + primary.push({ + name: "Other", + total: remainder.reduce((sum, row) => sum + Number(row?.total ?? 0), 0), + data: Array.from({ length }, (_, index) => remainder.reduce((sum, row) => sum + Number(list(row?.daily).slice(offset)[index] ?? 0), 0)), + }); + } + return primary; +} + +function formatUsageNumber(value) { + return Math.round(Number(value ?? 0)).toLocaleString(usesChineseReaderCopy() ? "zh-CN" : "en-US"); +} + +function usageSeriesLabel(value) { + if (value === "Unknown model") return taskLoopCopy("Unattributed model", "未归属模型"); + if (value === "Unknown Skill") return taskLoopCopy("Unattributed Skill", "未归属 Skill"); + return value; +} + +function formatActivityMinutes(value) { + const formatted = Number(value ?? 0).toLocaleString(usesChineseReaderCopy() ? "zh-CN" : "en-US", { + maximumFractionDigits: 1, + }); + return `${formatted} ${taskLoopCopy("min", "分钟")}`; +} + +function taskLoopCoverage() { + return taskLoopSummary().coverage ?? {}; +} + +function confidenceLabel(value) { + const normalized = textValue(value).toLowerCase(); + const labels = { + high: ["High confidence", "高可信度"], + medium: ["Medium confidence", "中等可信度"], + low: ["Low confidence", "低可信度"], + }[normalized]; + return labels ? taskLoopCopy(labels[0], labels[1]) : taskLoopCopy("Confidence not recorded", "未记录可信度"); +} + +function confidenceTone(value) { + const normalized = textValue(value).toLowerCase(); + if (normalized === "high") return "success"; + if (normalized === "medium") return "warning"; + return "neutral"; +} + +function taskLoopStateLabel(value) { + const labels = { + Wired: ["Wired", "机制已接入"], + Present: ["Present", "已发现机制"], + Unobserved: ["Unobserved", "未观察到"], + observed: ["Observed", "已观察到"], + "Not applicable": ["Not applicable", "暂不适用"], + "N/A": ["Needs a comparison", "需要比较"], + }[value]; + return labels ? taskLoopCopy(labels[0], labels[1]) : textValue(value) || "—"; +} + +function taskLoopSubdimensionLabel(id) { + for (const dimension of list(report.summary?.dimensions)) { + const match = list(dimension?.subdimensions).find((row) => row?.id === id); + if (match) return match.label ?? id; + } + return id; +} + +function evidenceReferenceLabel(item) { + return textValue(item?.label) || textValue(item?.id) || taskLoopCopy("Unnamed evidence", "未命名证据"); +} + +function evidenceReferenceMeta(item) { + return [ + textValue(item?.status), + textValue(item?.type), + Number.isFinite(Number(item?.line)) ? `${taskLoopCopy("line", "行")} ${item.line}` : "", + ].filter(Boolean).join(" · "); +} + +function EvidenceReferenceList({ items }) { + return ( + + {items.map((item, index) => ( + + + {item?.group ? {item.group} : null} + {item?.kind ? {item.kind} : null} + {evidenceReferenceLabel(item)} + + {evidenceReferenceMeta(item) ? {evidenceReferenceMeta(item)} : null} + + ))} + + ); +} + +function severityLabel(value) { + const labels = { + Critical: ["Critical", "紧急"], + High: ["High", "高"], + Medium: ["Medium", "中"], + Low: ["Low", "低"], + }[value]; + return labels ? taskLoopCopy(labels[0], labels[1]) : value ?? "—"; +} + +function practiceSurfaceGlyph(surface) { + return ({ Rules: "R", Skills: "S", "Custom Agents": "A", Hooks: "H", MCP: "M" })[surface] ?? textValue(surface).slice(0, 1).toUpperCase() ?? "?"; +} + +function PracticeSurfaceIcon({ row }) { + return {practiceSurfaceGlyph(row?.surface)}; +} + +function TaskLoopReportHeader({ findings }) { + const sources = practiceRows().filter((row) => Number(row?.count) > 0); + const overview = textValue(report.summary?.overview); + return ( + +

{projectName()}

+ {overview ? {overview} : null} + + + {taskLoopCopy(`${findings.length} prioritized improvements`, `${findings.length} 项优先优化`)} + + {sources.length ? ( + + {taskLoopCopy(`${sources.length} practice source types`, `${sources.length} 类实践来源`)} + + ) : null} + +
+ ); +} + +function TaskLoopFluency({ dimensions }) { + if (dimensions.length === 0) return null; + return ( + + +

{taskLoopCopy("Agent Work Loop", "Agent 工作流")}

+ {dimensions.length} {taskLoopCopy("dimensions", "个维度")} +
+ + dimensionFluencyTooltip(dimensions[index])} + height={180} + highThreshold={70} + mediumThreshold={40} + showStageLabels + /> + +
+ ); +} + +function practiceCount(row) { + const value = Number(row?.count); + return Number.isInteger(value) ? value : "—"; +} + +function practiceScopeCell(row) { + const scopes = list(row?.scopes).map(textValue).filter(Boolean); + if (!scopes.length) return ; + return ( + + {scopes.map((scope) => {scope})} + + ); +} + +function practiceSourceCell(row) { + const [firstPath] = visiblePracticePaths(row?.paths); + return firstPath + ? {firstPath} + : {taskLoopCopy("No source location recorded", "未记录来源位置")}; +} + +function practiceSourceDetail(row) { + const remaining = visiblePracticePaths(row?.paths).slice(1); + if (!remaining.length) return null; + const pathListStyle = remaining.length > 8 + ? { maxHeight: 220, overflowY: "auto", paddingRight: 4 } + : { paddingRight: 4 }; + return ( + + {taskLoopCopy(`View ${remaining.length} more locations`, `查看其余 ${remaining.length} 个位置`)} + + )} + bodyStyle={{ padding: "6px 0 2px 16px" }} + headerStyle={{ borderBottom: "none", minHeight: 24 }} + > + + {remaining.map((path, index) => ( + {path} + ))} + + + ); +} + +function taskLoopPracticeColumns() { + return [ + { + key: "surface", + title: taskLoopCopy("Asset", "资产"), + minWidth: "300px", + render: (row) => ( + + + + {row.surface ?? taskLoopCopy("Surface", "能力面")} + + {practiceDescription(row.surface)} + + ), + }, + { + key: "coverage", + title: taskLoopCopy("Coverage", "覆盖范围"), + width: "170px", + minWidth: "150px", + render: (row) => ( + + + {taskLoopCopy(`${practiceCount(row)} sources`, `${practiceCount(row)} 个来源`)} + + {practiceScopeCell(row)} + + ), + }, + { + key: "source", + title: taskLoopCopy("Representative source", "代表来源"), + minWidth: "260px", + render: practiceSourceCell, + }, + ]; +} + +function TaskLoopPracticeTable({ rows = practiceRows() }) { + if (rows.length === 0) { + return {taskLoopCopy("No Agent assets recorded.", "未记录 Agent 工程资产。")}; + } + return ( + row.surface ?? "surface"} + density="compact" + renderDetail={practiceSourceDetail} + emptyText={taskLoopCopy("No Agent asset coverage recorded", "未记录 Agent 工程资产覆盖")} + /> + ); +} + +function TaskLoopActivityHeatmap({ activity }) { + const matrix = usageActivityMatrix(activity); + if (!matrix) return {taskLoopCopy("No dated session activity was observed.", "没有观察到带日期的会话活动。")}; + return ( + + + {taskLoopCopy("Daily activity (active minutes)", "每日活动(活跃分钟)")} + {matrix.start} — {matrix.end} + + formatActivityMinutes(value)} + cellSize={16} + columnWidth={16} + rowLabelWidth={34} + responsive + minCellSize={8} + minGap={2} + initialScrollPosition="end" + colorTemplate={{ + none: { background: "rgba(127, 127, 127, 0.1)", border: "transparent" }, + low: { background: "rgba(64, 166, 103, 0.22)", border: "transparent" }, + medium: { background: "rgba(54, 158, 94, 0.42)", border: "transparent" }, + high: { background: "rgba(38, 139, 78, 0.66)", border: "transparent" }, + critical: { background: "rgba(24, 115, 63, 0.9)", border: "transparent" }, + }} + maxHeight={190} + labels={{ ariaLabel: taskLoopCopy("Daily session activity", "每日会话活动") }} + /> + + ); +} + +function UsageStatRow({ label, value }) { + if (value === undefined || value === null || value === "") return null; + return ( + + {label} + {value} + + ); +} + +function UsageRankList({ series, limit = 5 }) { + const rows = list(series).slice(0, limit); + if (!rows.length) return {taskLoopCopy("No usage observed.", "未观察到用量。")}; + return ( + + {rows.map((row, index) => ( + + + {index + 1} + {usageSeriesLabel(row.name)} + + {formatUsageNumber(row.total)} + + ))} + + ); +} + +function taskLoopModelUsageColumns() { + return [ + { + key: "model", + title: taskLoopCopy("Model", "模型"), + minWidth: "180px", + render: (row) => {usageSeriesLabel(row.model)}, + }, + { + key: "responseCount", + title: taskLoopCopy("Responses", "响应数"), + width: "110px", + align: "right", + render: (row) => {formatUsageNumber(row.responseCount)}, + }, + { + key: "usageFieldObservedCount", + title: taskLoopCopy("Usage fields observed", "观察到用量字段"), + minWidth: "160px", + align: "right", + render: (row) => {formatUsageNumber(row.usageFieldObservedCount)}, + }, + { + key: "nonZeroUsageCount", + title: taskLoopCopy("Non-zero usage", "非零用量记录"), + minWidth: "140px", + align: "right", + render: (row) => {formatUsageNumber(row.nonZeroUsageCount)}, + }, + ]; +} + +function TaskLoopModelUsageTable({ rows }) { + if (!rows.length) return null; + return ( + + + {taskLoopCopy("Model response accounting", "模型响应明细")} + {rows.length} {taskLoopCopy("models", "个模型")} + + + {taskLoopCopy( + "These are response counts, not model-active session counts or a quality comparison.", + "这里统计的是响应次数,不是模型活跃会话数,也不代表模型质量对比。", + )} + +
row.model} + density="compact" + /> + + ); +} + +function TaskLoopLongSessionReview({ usage }) { + const lead = usage?.reviewLead; + const samples = list(usage?.longSessions?.samples); + if (!lead || !samples.length) return null; + const estimate = usage.longSessions?.estimate; + const coverage = lead.sampleCoverage; + const pendingCount = coverage?.shown ?? samples.length; + const analyzedCount = usage.selection?.analyzedSessionCount ?? 0; + const longestActiveMinutes = usage.longSessions?.longestActiveMinutes ?? Math.max(...samples.map((sample) => Number(sample.activeMinutes ?? 0))); + return ( + + + + +

{taskLoopCopy(`${pendingCount} long sessions need review`, `${pendingCount} 个长会话待复核`)}

+ {pendingCount} {taskLoopCopy("pending", "待复核")} +
+ + {taskLoopCopy( + `${pendingCount} of ${formatUsageNumber(analyzedCount)} analyzed sessions crossed the ${estimate?.activeThresholdMinutes ?? 45}-minute estimate threshold; the longest estimate is ${formatActivityMinutes(longestActiveMinutes)}. Treat them as investigation leads until reviewed.`, + `${formatUsageNumber(analyzedCount)} 个已分析会话中有 ${pendingCount} 个超过 ${estimate?.activeThresholdMinutes ?? 45} 分钟估算阈值,最长估算为 ${formatActivityMinutes(longestActiveMinutes)}。在人工复核前,只将其视为调查线索。`, + )} + +
+ + {taskLoopCopy(`Review ${pendingCount} sessions`, `复核 ${pendingCount} 个会话`)} + +
+ + {samples.map((sample, index) => { + const failureCount = Number(sample.failureCount ?? 0); + const roleLabel = sample.role === "user-thread-candidate" + ? taskLoopCopy("Main-thread candidate", "主线程候选") + : sample.role === "child-agent-candidate" + ? taskLoopCopy("Child-Agent candidate", "子 Agent 候选") + : sample.role; + return ( + + + + {sample.alias} + + {sample.userInputSummary} + {taskLoopCopy("Role", "角色")}: {roleLabel} + + + + {taskLoopCopy("Estimated active time", "估算活跃时长")} + {formatActivityMinutes(sample.activeMinutes)} + + + {formatUsageNumber(failureCount)} {taskLoopCopy("failures", "失败事件")} + + + {index < samples.length - 1 ? : null} + + ); + })} + + {estimate ? ( + + {taskLoopCopy( + `Estimate boundary: event gaps are capped at ${estimate.gapCapMinutes} minutes and gaps over ${estimate.idleGapMinutes} minutes are treated as idle.`, + `估算边界:事件间隔最多计 ${estimate.gapCapMinutes} 分钟,超过 ${estimate.idleGapMinutes} 分钟按空闲处理。`, + )} + + ) : null} +
+ ); +} + +function TaskLoopProjectUsage({ activity, usage }) { + if (!activity && !usage) return null; + const activeMinutes = list(activity?.sessions?.activeMinutes).reduce((sum, value) => sum + Number(value ?? 0), 0); + const census = usage?.selection; + const longSessions = usage?.longSessions; + const skillUses = usageSeriesTotal(activity?.skills); + const analyzedSessions = census + ? `${formatUsageNumber(census.analyzedSessionCount)} / ${formatUsageNumber(census.eligibleSessionCount)}` + : activity ? formatUsageNumber(activity.sessions?.total) : null; + return ( + + {activity ? : null} + {activity ? : null} + + + {taskLoopCopy("Activity insights", "使用概览")} + + {activity ? : null} + {activity ? : null} + {longSessions ? : null} + + + {taskLoopCopy("Most used Skills", "最常使用的 Skills")} + + + + + ); +} + +function TaskLoopUsageMethodology({ usage }) { + if (!usage) return null; + const census = usage.selection; + const accounting = usage.accounting; + const roles = usage.roles; + const outcomeReview = usage.outcomeReview; + const taskSelection = taskLoopCoverage().selection ?? {}; + const modelUsage = list(usage.modelUsage); + const hasCoverage = census || Object.keys(taskSelection).length || roles || accounting; + if (!hasCoverage && !modelUsage.length && !usage.reviewLead) return null; + + return ( + + {census || Object.keys(taskSelection).length ? ( + + {taskLoopMeasurementBoundaryText(taskSelection, census)} + + ) : null} + {roles || accounting ? ( + + {roles ? ( + + {taskLoopCopy("Session composition", "会话构成")} + + + + ) : null} + {accounting ? ( + + {taskLoopCopy("Measurement coverage", "计量覆盖")} + + + + + + + ) : null} + + ) : null} + {modelUsage.length ? : null} + + {accounting?.mode === "effort-proxy" + ? taskLoopCopy("Active time and model-session counts are effort proxies; exact token or credit savings are unavailable.", "活跃时间和模型会话数仅代表投入;目前无法精确计算 token 或 credit 节省。") + : taskLoopCopy("Usage totals describe observed activity, not counterfactual savings.", "用量只描述已观察活动,不代表反事实节省。")} + {outcomeReview && !outcomeReview.comparableModelOutcomeEvidence + ? taskLoopCopy(" Model outcomes need a controlled A/B before comparison.", " 模型效果需要通过受控 A/B 后才能比较。") + : ""} + + + ); +} + +function usageTrendLeader(series) { + return series.reduce((leader, row) => Number(row.total ?? 0) > Number(leader?.total ?? -1) ? row : leader, null); +} + +function usageTrendRange(categories) { + if (!categories.length) return taskLoopCopy("Latest observations", "最近观测"); + if (categories.length === 1) return categories[0]; + return `${categories[0]} – ${categories[categories.length - 1]}`; +} + +function TaskLoopUsageTrend({ title, totalLabel, leaderDescription, series, categories }) { + if (!series.length) return null; + const total = series.reduce((sum, row) => sum + Number(row.total ?? 0), 0); + const leader = usageTrendLeader(series); + const range = usageTrendRange(categories); + return ( + + + + + {title} + + {taskLoopCopy( + `${range} · ${formatUsageNumber(total)} ${totalLabel}`, + `${range} · 共 ${formatUsageNumber(total)} ${totalLabel}`, + )} + + + + {leader ? ( + + {usageSeriesLabel(leader.name)} · {formatUsageNumber(leader.total)} {totalLabel} + {leaderDescription} + + ) : null} + + + + ); +} + +function TaskLoopUsageTrends({ activity }) { + if (!activity) return null; + const chartWindow = usageChartWindow(activity); + const categories = chartWindow.categories; + const modelSeries = visibleUsageSeries(activity.models, chartWindow.offset); + const skillSeries = visibleUsageSeries(activity.skills, chartWindow.offset); + if (!modelSeries.length && !skillSeries.length) return null; + return ( + +

{taskLoopCopy("Usage trends", "用量趋势")}

+ + + + +
+ ); +} + +function taskLoopSessionInsightTitle(id) { + const labels = { + "session-insight:source-coverage": ["Source coverage", "数据覆盖"], + "session-insight:validation-behavior": ["Validation behavior", "验证行为"], + "session-insight:post-edit-validation": ["Post-edit validation", "改动后验证"], + "session-insight:execution-friction": ["Execution friction", "执行摩擦"], + "session-insight:tool-mix": ["Tool mix", "工具使用"], + "session-insight:observed-hooks": ["Observed hooks", "Hook 执行"], + "session-insight:planning-workflow": ["Planning workflow", "规划工作流"], + "session-insight:session-complexity": ["Session complexity", "会话复杂度"], + "session-insight:session-usage-efficiency": ["Session effort", "会话投入"], + }[id]; + return labels ? taskLoopCopy(labels[0], labels[1]) : id; +} + +function taskLoopSessionInsightConfidence(row) { + return list(row?.labels).map(textValue).find((value) => ["High", "Medium", "Low"].includes(value)) ?? ""; +} + +function taskLoopSessionInsightColumns() { + return [ + { + key: "id", + title: taskLoopCopy("Observation", "观察主题"), + minWidth: "150px", + render: (row) => {taskLoopSessionInsightTitle(row.id)}, + }, + { + key: "summary", + title: taskLoopCopy("What was observed", "观察说明"), + minWidth: "420px", + render: (row) => {row.summary ?? "—"}, + }, + { + key: "confidence", + title: taskLoopCopy("Confidence", "可信度"), + minWidth: "120px", + render: (row) => { + const confidence = taskLoopSessionInsightConfidence(row); + return confidence ? {confidenceLabel(confidence)} : ; + }, + }, + { + key: "evidenceRefs", + title: taskLoopCopy("Evidence", "证据"), + width: "80px", + align: "right", + render: (row) => {list(row.evidenceRefs).length}, + }, + ]; +} + +function taskLoopSessionInsightDetail(row) { + const evidenceRefs = list(row?.evidenceRefs); + return ( + {taskLoopCopy("View raw observation metadata", "查看原始观察元数据")}} + bodyStyle={{ padding: "6px 0 2px 16px" }} + headerStyle={{ borderBottom: "none", minHeight: 24 }} + > + + + {taskLoopCopy("Insight ID", "洞察 ID")}: {row.id} · {taskLoopCopy("Status", "状态")}: {row.status ?? "—"} · {taskLoopCopy("Kind", "类型")}: {row.kind ?? "—"} · {taskLoopCopy("Model", "模型版本")}: {row.modelVersion ?? "—"} + + {list(row.labels).length ? ( + {row.labels.map((label) => {label})} + ) : null} + {evidenceRefs.length ? : {taskLoopCopy("No raw evidence references recorded.", "未记录原始证据引用。")}} + + + ); +} + +function taskLoopRepresentativeSessionInsights(entries) { + const preferredIds = [ + "session-insight:post-edit-validation", + "session-insight:execution-friction", + "session-insight:tool-mix", + ]; + const preferred = preferredIds + .map((id) => entries.find((entry) => entry?.id === id)) + .filter((entry) => entry && list(entry.evidenceRefs).length > 0); + const remaining = entries + .filter((entry) => list(entry?.evidenceRefs).length > 0 && !preferred.includes(entry)) + .sort((left, right) => list(right.evidenceRefs).length - list(left.evidenceRefs).length); + return [...preferred, ...remaining].slice(0, 3); +} + +function TaskLoopSessionInsightsDialog({ entries }) { + return ( + + {taskLoopCopy(`View all ${entries.length}`, `查看全部 ${entries.length} 条`)} + + )} + title={taskLoopCopy("All session observations", "全部会话观察")} + closeLabel={taskLoopCopy("Close", "关闭")} + maxWidth={1040} + > + + + {taskLoopCopy( + "These are candidate observations projected from session evidence. Read them as investigation leads, not confirmed user intent or outcome claims.", + "这些是从会话证据投影出的候选观察,用于指引后续调查,不等同于已确认的用户意图或结果结论。", + )} + +
row.id} + density="compact" + renderDetail={taskLoopSessionInsightDetail} + /> + + + ); +} + +function TaskLoopSessionInsights() { + const entries = list(report.summary?.semanticFacets?.entries); + if (!entries.length) return null; + const representativeEntries = taskLoopRepresentativeSessionInsights(entries); + const rowTones = ["success", "warning", "info"]; + return ( + + +

{taskLoopCopy("Session observations", "会话观察")}

+ {entries.length} {taskLoopCopy("observations", "条观察")} +
+ + {taskLoopCopy( + "Representative evidence-bearing observations for follow-up investigation and priority judgment.", + "按主题呈现的代表性观察,用于指引后续调查与优先级判断。", + )} + + + {representativeEntries.map((row, index) => { + const confidence = taskLoopSessionInsightConfidence(row); + const evidenceCount = list(row.evidenceRefs).length; + return ( + + + + + {index + 1} + {taskLoopSessionInsightTitle(row.id)} + + + {row.summary ?? "—"} + + + {confidence ? {confidenceLabel(confidence)} : null} + + {evidenceCount} {taskLoopCopy(evidenceCount === 1 ? "evidence item" : "evidence items", "条证据")} + + + + + + ); + })} + + + + +
+ ); +} + +function TaskLoopFindingDialog({ row }) { + const expectedOutput = list(row.expectedOutput).filter((item) => textValue(item)); + const dimensionRefs = list(row.dimensionRefs); + return ( + {taskLoopCopy("View details", "查看详情")} + )} + title={row.title ?? row.id} + closeLabel={taskLoopCopy("Close", "关闭")} + maxWidth={880} + footer={( + + + {taskLoopCopy("Plan AI Fix", "规划 AI 修复")} + + + )} + > + + + {severityLabel(row.severity)} + {dimensionRefs.map((dimensionRef) => ( + {taskLoopDimensionLabel(dimensionRef)} + ))} + + + {taskLoopCopy("Cause", "原因")} + + {textValue(row.reason) || taskLoopCopy("No cause was recorded.", "未记录原因。")} + + + + + {taskLoopCopy("Expected Output", "预期结果")} + {expectedOutput.length ? expectedOutput.map((output, index) => ( + + {index + 1} + {output} + + )) : ( + {taskLoopCopy("No expected output was recorded.", "未记录预期结果。")} + )} + + + + ); +} + +function TaskLoopFindingCard({ row }) { + const [dimensionRef] = list(row.dimensionRefs); + return ( + + + + + + {severityLabel(row.severity)} + {dimensionRef ? {taskLoopDimensionLabel(dimensionRef)} : null} + + + {row.title ?? row.id} + + {textValue(row.reason) ? ( + + {row.reason} + + ) : null} + + + + + + {taskLoopCopy("Plan AI Fix", "规划 AI 修复")} + + + + + + + + ); +} + +function TaskLoopFindingCards({ findings }) { + return ( + + {findings.map((row) => )} + + ); +} + +function taskLoopSuggestionKindLabel(kind) { + const labels = { + "try-existing": ["Try existing", "试用已有能力"], + "working-pattern": ["Working pattern", "有效模式"], + "loop-candidate": ["Loop candidate", "循环候选"], + horizon: ["Horizon", "中长期"], + }[kind] ?? ["Suggestion", "建议"]; + return taskLoopCopy(labels[0], labels[1]); +} + +function TaskLoopSuggestionDialog({ row }) { + const prerequisites = list(row.prerequisites).map(textValue).filter(Boolean); + const blockedBy = list(row.blockedBy).map(textValue).filter(Boolean); + return ( + {taskLoopCopy("Review suggestion", "查看建议")}} + title={row.title ?? row.id} + closeLabel={taskLoopCopy("Close", "关闭")} + maxWidth={760} + > + + + {taskLoopSuggestionKindLabel(row.kind)} + {confidenceLabel(row.confidence)} + + + {taskLoopCopy("Why this is worth trying", "为什么值得尝试")} + {row.reason} + + + + + {taskLoopCopy("Owner", "负责人")} + {row.owner} + + + {taskLoopCopy("Validation", "验证")} + {row.validation} + + + + {taskLoopCopy("Next step", "下一步")} + {row.nextStep} + + {prerequisites.length ? ( + + {taskLoopCopy("Prerequisites", "前置条件")} + {prerequisites.map((item, index) => ( + + {index + 1} + {item} + + ))} + + ) : null} + {blockedBy.length ? ( + + {taskLoopCopy("Blocked by", "阻塞项")} + {blockedBy.map((item, index) => ( + + {index + 1} + {item} + + ))} + + ) : null} + + + ); +} + +function TaskLoopSuggestionCard({ row }) { + return ( + + + + + {taskLoopSuggestionKindLabel(row.kind)} + {confidenceLabel(row.confidence)} + + + {row.title ?? row.id} + + + {row.reason} + + + {taskLoopCopy("Next step", "下一步")} + {row.nextStep} + + + + {row.owner} + + + + + + ); +} + +function TaskLoopSuggestionCards({ suggestions }) { + return ( + + {suggestions.map((row) => )} + + ); +} + +function taskLoopDeliveryOutcomeLabel(boundary) { + if (!boundary || !Object.hasOwn(boundary, "deliveryEvidenceLevels")) { + return taskLoopCopy("Not supplied", "未提供"); + } + const levels = list(boundary?.deliveryEvidenceLevels).map(textValue).filter(Boolean); + if (!levels.length || levels.every((value) => ["none", "unobserved", "not-observed"].includes(value.toLowerCase()))) { + return taskLoopCopy("Not observed", "未观察到"); + } + return levels.join(", "); +} + +function taskLoopCoverageFraction(value, analyzedField, eligibleField) { + if (!value || !Object.hasOwn(value, analyzedField) || !Object.hasOwn(value, eligibleField)) return null; + const analyzed = Number(value[analyzedField]); + const eligible = Number(value[eligibleField]); + return Number.isInteger(analyzed) && analyzed >= 0 && Number.isInteger(eligible) && eligible >= 0 + ? `${formatUsageNumber(analyzed)}/${formatUsageNumber(eligible)}` + : null; +} + +function taskLoopMeasurementBoundaryText(selection, usageSelection) { + const sample = taskLoopCoverageFraction(selection, "analyzedCount", "eligibleCount"); + const activity = taskLoopCoverageFraction(usageSelection, "analyzedSessionCount", "eligibleSessionCount"); + if (sample && activity) { + return taskLoopCopy( + `Work-stage conclusions use ${sample} stratified sample sessions; activity accounting covers ${activity}. Use this evidence to locate review leads, not to prove efficiency or model quality.`, + `工作环节结论来自 ${sample} 个分层抽样会话;活动统计覆盖 ${activity}。当前证据可用于定位线索,不足以证明效率或模型质量。`, + ); + } + if (sample) { + return taskLoopCopy( + `Work-stage conclusions use ${sample} stratified sample sessions. Activity and model accounting were not supplied, so no usage conclusion is available.`, + `工作环节结论来自 ${sample} 个分层抽样会话。未提供活动与模型统计,因此无法形成用量结论。`, + ); + } + if (activity) { + return taskLoopCopy( + `Activity accounting covers ${activity}. Sampling provenance was not supplied, so usage is shown without a work-stage sampling conclusion.`, + `活动统计覆盖 ${activity}。未提供抽样来源,因此这里只展示用量,不形成工作环节抽样结论。`, + ); + } + return taskLoopCopy( + "Session measurement context was not supplied, so no sampling, usage, or model conclusion is available.", + "未提供会话计量上下文,因此无法形成抽样、用量或模型结论。", + ); +} + +function taskLoopMeasurementModelLabel(usage, modelUsage) { + if (!usage) return taskLoopCopy("Usage unavailable", "用量不可用"); + return `${modelUsage.length} ${taskLoopCopy("models", "个模型")}`; +} + +function taskLoopSelectionDetailText(selection) { + const coverage = taskLoopCoverageFraction(selection, "analyzedCount", "eligibleCount"); + if (!coverage) return taskLoopCopy("Not supplied", "未提供"); + return taskLoopCopy( + `${textValue(selection.strategy) || "unknown"}; ${coverage} eligible sessions analyzed.`, + `${textValue(selection.strategy) || "未知"};已分析 ${coverage} 个符合条件的会话。`, + ); +} + +function taskLoopTaskEvidenceText(coverage) { + const fields = ["episodeCount", "editedEpisodeCount", "closedEpisodeCount", "recoveredEpisodeCount"]; + if (!fields.every((field) => Object.hasOwn(coverage, field))) return taskLoopCopy("Not supplied", "未提供"); + const [episodes, edited, closed, recovered] = fields.map((field) => Number(coverage[field])); + if (![episodes, edited, closed, recovered].every((value) => Number.isInteger(value) && value >= 0)) { + return taskLoopCopy("Not supplied", "未提供"); + } + return taskLoopCopy( + `${episodes} episodes; ${edited} with changes; ${closed} closed; ${recovered} repaired and passed.`, + `${episodes} 个任务片段;${edited} 个包含改动;${closed} 个已闭环;${recovered} 个修复并通过。`, + ); +} + +function taskLoopLearningDetailText(learning) { + if (!Object.hasOwn(learning, "state") && !Array.isArray(learning.interventions)) { + return taskLoopCopy("Not supplied", "未提供"); + } + return taskLoopCopy( + `${learningStateLabel(learning.state)}; ${list(learning.interventions).length} declared intervention(s).`, + `${learningStateLabel(learning.state)};${list(learning.interventions).length} 项已声明的改进。`, + ); +} + +function TaskLoopEvidenceFact({ label, value, tone = "neutral" }) { + return ( + + + + {label} + + {value} + + + + + ); +} + +function TaskLoopEvidenceDetails({ usage, boundary, manifest, selection, learning, coverage }) { + const hasDetailedBoundary = Object.keys(boundary).length > 0 + || Object.keys(coverage).length > 0 + || Object.keys(learning).length > 0; + return ( + + {hasDetailedBoundary ? ( + + {taskLoopCopy("Sampling and provenance", "抽样与来源")} + {taskLoopCopy("Session selection", "会话抽样")}: {taskLoopSelectionDetailText(selection)} + {taskLoopCopy("Task evidence", "任务证据")}: {taskLoopTaskEvidenceText(coverage)} + {taskLoopCopy("Delivery outcomes", "交付结果")}: {taskLoopDeliveryOutcomeLabel(boundary)}. + {taskLoopCopy("Learning comparison", "学习循环比较")}: {taskLoopLearningDetailText(learning)} + {textValue(learning.summary) ? {learning.summary} : null} + + ) : ( + + {taskLoopCopy( + "This run did not supply task-episode or delivery-outcome evidence, so the report only shows repository signals it could verify.", + "本次运行没有提供任务片段或交付结果证据,因此报告只展示能够验证的仓库信号。", + )} + + )} + + + + + + + + + {usage ? ( + <> + + + + ) : null} + + ); +} + +function TaskLoopEvidenceBoundary({ usage }) { + const boundary = report.summary?.evidenceBoundary ?? {}; + const manifest = boundary.manifest ?? {}; + const selection = manifest.selection ?? {}; + const learning = report.summary?.learningCapture ?? {}; + const coverage = taskLoopCoverage(); + const modelUsage = list(usage?.modelUsage); + const longSessionSamples = list(usage?.longSessions?.samples); + const sessionInsights = list(report.summary?.semanticFacets?.entries); + const activitySelection = usage?.selection ?? {}; + const samplingConfidence = textValue(selection.confidence) + || taskLoopCopy("Not supplied", "未提供"); + const sourceGapValue = Array.isArray(boundary.sourceGaps) ? boundary.sourceGaps.length : "—"; + + return ( + +

{taskLoopCopy("Evidence and methodology", "证据与方法")}

+ + + {taskLoopMeasurementBoundaryText(selection, activitySelection)} + + + + + + + + {usage?.reviewLead && longSessionSamples.length ? ( + <> + + + + ) : null} + {sessionInsights.length ? ( + <> + + + + ) : null} + + + {taskLoopCopy("View measurement and model details", "查看计量与模型明细")} + + {taskLoopCopy("Response accounting, model detail, and sampling method", "响应计量、模型明细与抽样方法")} + + + )} + trailing={{taskLoopMeasurementModelLabel(usage, modelUsage)}} + bodyStyle={{ padding: "12px 0 2px 16px" }} + headerStyle={{ minHeight: 44 }} + > + + +
+ ); +} + +function TaskLoopReport() { + const dimensions = list(report.summary?.dimensions); + const findings = list(report.findings).sort((left, right) => severityRank(left.severity) - severityRank(right.severity)); + const suggestions = list(report.summary?.suggestions); + const sessionOverview = taskLoopSessionOverview(); + const activity = taskLoopUsageActivity(); + const usage = taskLoopUsageEfficiency(); + const usageCoverageInfo = taskLoopUsageCoverageInfo(activity, usage, sessionOverview); + const highFindings = findings.filter((row) => row.severity === "Critical" || row.severity === "High"); + const mediumFindings = findings.filter((row) => row.severity === "Medium"); + return ( + + + + + + {activity || usage ? ( + + +

{taskLoopCopy("Project usage", "项目用量")}

+ {usageCoverageInfo ? ( + + i + + ) : null} +
+ +
+ ) : null} + + + +

{taskLoopCopy("Prioritized improvements", "优先优化项")}

+ + {taskLoopCopy( + `${findings.length} total · ${highFindings.length} high · ${mediumFindings.length} medium`, + `共 ${findings.length} 项 · ${highFindings.length} 个高优先级 · ${mediumFindings.length} 个中优先级`, + )} + +
+ + {suggestions.length ? ( + <> + + + + {taskLoopCopy("Suggestions", "建议")} + + {taskLoopCopy( + "Evidence-bound capabilities and patterns worth trying next. Suggestions are advisory and do not include an AI Fix action.", + "基于证据、值得下一步尝试的能力与模式。建议仅供参考,不包含 AI 修复动作。", + )} + + + {suggestions.length} {taskLoopCopy("suggestions", "条建议")} + + + + ) : null} +
+ + +

{taskLoopCopy("Agent Customize", "Agent 自定义")}

+ + {taskLoopCopy( + "Discovered sources, not a quality or maturity score.", + "这里只展示已发现来源,不代表质量或成熟度评分。", + )} + + +
+ + + + +
+ ); +} + +export default function QoderHarnessReport() { + return ; +} diff --git a/API/.env b/API/.env deleted file mode 100644 index e69de29bb..000000000 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..7ed1a29e6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,124 @@ +# 版本更新大纲 + +#### v2.1 — 灵感库(Civitai 图源 + 真搜索 + 标签筛选) + +**灵感库:** +- 新增“灵感库”面板:内置 Civitai 海量 AI 图片,排序浏览(热门/最新/点赞/收藏) +- 真关键词搜索:接入 Civitai 的 Meilisearch 搜索服务,中文/英文都能搜(如“猫”“海报”) +- 中文搜索词典:常用中文词自动映射成 Civitai 英文标签 +- 分类标签筛选:chips 换行布局 + “更多标签”分组下拉面板(6 组 58 个标签) + +**导入画布:** +- 一键导入喜欢的图当参考,自动下载到本地(刷新不丢、加载秒开) +- 双重去重:按图片 ID 命名 + 画布节点查重,不重复导入 +- 来源署名:保留 Civitai 链接与作者 + +**体验优化:** +- 图片按真实宽高比预留高度,懒加载时不再上下跳动 +- 图片加载失败自动重试 + 点击重试 +- 429 限流友好提示、TTL 缓存、浏览去重 +- API Key 强制前置配置(无 Key 进配置页) + +#### v2.0 — Agent 架构大改版(LLM-first 意图决策) + +**架构重构:** +- 废弃旧版多层正则防护,改为 LLM 统一意图决策 +- 8 种意图类型:generate / edit / analyze / refine / composite / clarify / meta / cancel +- 极窄快速路径:纯文生图、简单修改跳过 LLM 零延迟执行 +- 最小安全网:剥离 @mention 后分析动词强制 text_only + +**流式输出:** +- 后端 `canvas_llm_stream`:流式调用 LLM,通过 WebSocket 逐 token 推送 +- 思维模式接入流式(`?stream=true`) +- 流式气泡:max-height 200px + 滚动 + 停止按钮 + +**意图分发:** +- analyze:分析图片 + 行动引导选项(点击即可继续操作) +- refine:反推/扩写提示词,只输出 prompt 不引导 +- clarify:意图不明时主动提问 + 可点击选项 +- cancel:取消操作 +- meta:回答 Agent 自身信息 + +**新 UI 组件:** +- 生成后快捷操作栏(修改 / 变体 / 反推) +- 分析结果卡片(结构化标签 + “用这个Prompt生图”按钮) +- 提示词建议卡片(等宽字体 + “直接生图”按钮) +- 降级提示条(模型能力不足时显示) +- 流式气泡(逐字显示 + 停止按钮) + +**其他:** +- 模型无关设计:不硬编码任何模型名称 +- LLM 失败回退策略:明确生图意图→直接生图,明确分析意图→提示重试 +- mac 启动脚本添加 no_proxy 避免 Clash 劫持本地请求 + +#### v1.0 — AI Agent 面板基线 +- AI Agent 侧边聊天面板(OneBox 风格),画布联动生图 +- `@` 引用画布图片,附件统一管理(Skill 文档 + 图片) +- 多对话管理(新建/删除/列表,与画布绑定) +- 生图参数面板:质量/比例(9种)/分辨率(1K-4K)/数量(1-8) +- LLM 结构化确认流程(选项按钮 → 确认/修改) +- 生图占位机制(占位骨架 → 完成替换,顶部对齐排列) +- 选中图片悬浮工具栏「发送至 Agent」按钮 + +#### v1.1 — 对话理解与生图稳定性 +- 系统提示词重写(5 种对话模式,强制中文提示词) +- 修改场景智能区分(风格修改 vs 主体更换) +- LLM 任务后端化(`/api/agent-llm-task` + WebSocket 实时通知) +- 刷新恢复生图任务(`taskIds` + `placeholderNodeId`) +- agy CLI 生图修复(纯文字回退 `gpt-image-2-skill`) +- 占位按选中比例展示 + 生图后尺寸重算 +- 占位顶部对齐 + 正计时 + 并发多图不叠加 +- 提示词展开/收起,复制优先 prompt + +#### v1.3 — 思维模式与稳定性 +- 思维模式开关(LLM 扩写 → 确认/重新生成/修改 三步流程) +- 绕过机制(修改后跳过二次确认直接生图) +- 修改请求关键词识别(改成/换成/重新画) +- 全模型模糊需求统一触发风格选项 +- 发送按钮失效修复(`agentBypassThinkingNext` 未声明) +- LLM 任务 5 分钟超时保护 + 恢复逻辑超时清理 +- 思维模式开关视觉优化(active 态 + Tooltip) + +#### v1.4 — 多图确认流程重构 + Skill 完整保留 +- **全部确认后统一生图**:确认流程从「逐张确认即生图」改为「全部确认/跳过后统一生图」,占位节点整齐排列 +- **prompts 状态机**:`pending → current → confirmed/skipped`,支持逐条确认/跳过/反悔 +- **内联编辑**:修改提示词改为卡片内 textarea 编辑,不再跳出确认流程 +- **全部确认/取消快捷按钮**:prompts ≥ 2 时显示「全部确认并生成」「全部取消」 +- **确认中发送新消息拦截**:有未确认 prompts 时弹窗提示 +- **刷新恢复确认进度**:中断后恢复到确认卡片,不触发生图 +- **画布对齐修复**:占位节点串行创建,解决阶梯上升/重叠 +- **生图数量校准**:按工具栏/输入框数量自动补充或截断 prompts/generations +- **Skill 完整保留**:首因+近因效应中英双语指令,确保所有 LLM provider 逐字保留 Skill 描述 +- **用户消息注入 Skill 提醒**:适配 gemini-cli 等 system_prompt 处理较弱的 provider +- **思维模式兜底 bug 修复**:LLM 回复含「正在为您生成」时不再绕过确认流程直接生图 + +#### v1.5 — 硬软参数分层 + Skill 定位明确 +- **硬软参数分层设计**: + - 软参数(出图数量):输入框显式要求 > 工具栏设置 + - 硬参数(比例/分辨率):工具栏说了算,输入框不覆盖 +- **Skill 定位明确**:Skill 描述「单张图样式」(含画面内元素排列如横3竖4),不决定出图数量 +- **统一数量决策函数** `resolveFinalGenCount`:前端决策数量,LLM 不碰参数,agy 等弱 provider 也不翻车 +- **数量提取增强**:支持 1-8,增加条/只/名/版/款等量词,中文数字支持到八 +- **系统提示词重构**:明确告知 LLM「数量已由系统决定,你无需判断」,消除 Skill「合集/一整页」被误读为只出1张的歧义 +- **用户消息 Skill 提醒加数量归属**:提示用户当前数量来自输入框还是工具栏 +- **直接模式重复生图修复**:数量校准改为追加新 generation(count=1)而非增加 count,强制所有 generation count=1 + +#### v1.5.1 — 单 generation 多图泄漏修复 +- **API 单次返回多图修复**:某些 provider 单次生图调用会返回多张图,导致单个 generation 节点出现多张图。现在限制每个 generation 最多只取 `gen.count` 张图 +- **参考图 URL 过滤**:某些 provider 会在响应中回显输入的参考图 URL,造成「重复」问题。现在过滤掉与参考图 URL 相同的结果 +- **count=1 强制范围扩大**:直接模式下无论 `requestedCount` 是否大于 1,都强制所有 generation 的 `count=1`(之前只在 `requestedCount > 1` 时才强制,存在漏洞) + +#### v1.6 — LLM 与生图解耦 + 思维模式多轮维度采集 +- **LLM 与生图解耦**:思维模式 OFF 时前端跳过 LLM,直接构建 generations 并调用生图 API,降低延迟与成本 +- **思维模式多轮维度采集**:从「两阶段流程」重构为「渐进式多维采集」——逐轮提问风格/场景/构图/配色/细节等维度,每轮返回选项 + 自定义输入,所有维度确认后生成最终提示词 +- **参考图分析规则**:思维模式下 LLM 先分析参考图共同特征,再让用户选择保留哪些特征 +- **LLM 模型选择移入思维模式面板**:模型选择栏只保留生图模型,理解模型选择整合到思维模式按钮的下拉面板中(点击思维按钮即展开) +- **框选批量发送至 Agent**:画布框选图片节点后,底部居中显示「发送至Agent(x张)」按钮,一键批量添加为参考图 +- **附件拖拽排序**:Agent 聊天框中的参考图附件支持拖拽调整顺序 +- **发送按钮 bug 修复**: + - 移除 `agentThinkingModelBtn` 的 CSS `display:inline-flex` 覆盖 `hidden` 属性导致按钮挤压发送按钮的问题 + - `agentSendBtn` 事件监听提前到 `initAgentPanel` 首行,防止中间初始化异常导致监听未注册 + - 思维模式 OFF 路径补充 `agentSending` 状态管理(`true` → `finally false`),防止按钮卡在 disabled + - 页面加载时 `agentSending` 安全重置 +- **`parseAgentResponse` 防御性修复**:所有返回路径补全 `options`/`prompts` 字段,`processAgentLlmResult` 开头添加数组类型检查,消除 "Cannot read properties of undefined (reading 'length')" 错误 +- **点击空白处清除批量发送按钮**:`shell.onclick` 补充 `syncSelectionUi()` 调用 diff --git a/README.md b/README.md index 76b5537da..b57afb35e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,61 @@ # Infinite-Canvas Supports comfyui/API calls/modelscope calls +---- + +## 本分支改动说明(AI Agent 版) + +> 本仓库是基于原项目 [hero8152/Infinite-Canvas](https://github.com/hero8152/Infinite-Canvas) 的二次开发 fork,新增了智能画布 AI Agent 面板。 +> +> **当前版本:v2.1**(查看 [版本更新大纲](./CHANGELOG.md)) + +### 重要提示 +- **自动更新已开启**:更新源指向主项目 [hero8152/Infinite-Canvas](https://github.com/hero8152/Infinite-Canvas),导航栏会自动检测并提示新版本。 +- **禁止商业用途**:沿用原作者版权声明(见文末),二次开发须保持开源并注明来源作者。 +- **不要提交 API Key到仓库**:请在软件自带的「API 设置」界面填写 Key/URL,不要写入代码或提交到仓库(`.gitignore` 已排除敏感文件与运行时数据)。 + +> This fork adds an AI Agent panel to the canvas. Auto-update is enabled and points to the main project hero8152/Infinite-Canvas. Do not commit API keys. Commercial use remains prohibited. + +### 版本更新日志 + +| 版本 | 日期 | 说明 | +|------|------|------| +| v1.0 | 2026-07-18 | 首发:右侧加了 AI 聊天框,能边聊边在画布上画图;可建多个对话;能调图片的比例、清晰度、数量 | +| v1.1 | 2026-07-19 | AI 更懂你的话(自动分清“改风格”还是“换主体”);刷新网页不丢正在画的图;画图有实时提醒 | +| v1.3 | 2026-07-19 | 新增“思维模式”:AI 先帮你把想法补全再画;需求说不清时自动弹选项让你选;修复发送按钮失灵 | +| v1.4 | 2026-07-19 | 多张图全部确认后再一起生成;提示词可直接在卡片上改;AI 记忆更稳 | +| v1.5 | 2026-07-19 | “数量”用输入框填、“比例/清晰度”用按钮选,分工更清楚 | +| v1.5.1 | 2026-07-19 | 修复“只要 1 张却给好几张”的问题 | +| v1.6 | 2026-07-20 | 关掉思维模式直接画图、不等 AI;思维模式会一步步追问帮你补细节;可框选多张图一起发给 AI;附件能拖动排序 | +| v2.0 | 2026-07-21 | AI 大脑升级:自动判断你想“画图/修改/分析/反推提示词”;回复逐字显示;生成后有“修改/变体/反推”快捷按钮;听不懂会主动问你(详见下方大纲) | +| **v2.1** | **2026-07-22** | **新增“灵感库”**:内置海量 AI 图片,支持中文/英文搜索(如搜“猫”“海报”)和分类标签筛选;喜欢的图一键导入画布当参考;图片自动存本地、刷新不丢;浏览更顺滑、不再上下跳动 | + +### v2.1 更新大纲 + +本次新增“灵感库”——一个内置的 AI 图片灵感面板: + +1. **海量 AI 图片**:接入 Civitai,按热门/最新/点赞/收藏随便刷 +2. **真搜索**:中文/英文都能搜(比如搜“猫”“海报”),常用中文词会自动翻译成英文标签 +3. **标签筛选**:分类标签换行显示,还有“更多标签”分组面板(人物/风格/场景等 6 组共58 个) +4. **一键导入**:喜欢的图一键导入画布当参考,自动存到本地(刷新不丢),不重复导入,保留作者来源 +5. **浏览更顺滑**:图片按真实比例预留位置,滚动时不再上下跳动 + +### v2.0 改版大纲 + +本次为 Agent 核心架构的大版本重构,从“补丁式正则”演进为“LLM-first 意图决策”架构: + +1. **意图路由重构**:废弃旧版多层正则防护,改为 LLM 统一意图决策(generate/edit/analyze/refine/composite/clarify/meta/cancel) +2. **快速路径**:纯文生图、简单修改等明确场景跳过 LLM,零延迟直接执行 +3. **流式输出**:思维模式 LLM 回复逐字显示(WebSocket 推送),带停止按钮和高度限制 +4. **分析/反推分离**:反推只输出 prompt;分析输出结果 + 行动引导选项(点击即可继续操作) +5. **意图澄清(clarify)**:用户意图不明时主动提问并提供可点击选项 +6. **生成后快捷操作**:每张生成图下方显示「修改 / 变体 / 反推」快捷按钮 +7. **新 UI 组件**:分析结果卡片、提示词建议卡片、快捷操作栏、降级提示条、流式气泡 +8. **模型无关设计**:不硬编码任何模型名称,所有能力通过用户配置动态获取 +9. **安全网机制**:LLM 失败时智能回退(明确生图意图→直接生图,明确分析意图→提示重试) + +---- + 配套的chrome采集插件已经上线:https://chromewebstore.google.com/detail/infinite-canvas-%E5%9B%BE%E5%83%8F%E8%A7%86%E9%A2%91%E6%96%87%E5%AD%97%E6%8A%93%E5%8F%96%E5%B7%A5/ajfhnbklbmpfaaookhfakohabnpmlcic?authuser=0&hl=en 详细教程:[https://youtu.be/1y9ShTvgC_w](https://youtu.be/r_y_9ALr7fg) diff --git a/VERSION b/VERSION index 28c2a0583..1b2a674f8 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2026.07.17 \ No newline at end of file +2026.07.22 diff --git "a/mac-\344\277\256\345\244\215\346\235\203\351\231\220.command" "b/mac-\344\277\256\345\244\215\346\235\203\351\231\220.command" old mode 100644 new mode 100755 diff --git "a/mac-\345\220\257\345\212\250\346\234\215\345\212\241.command" "b/mac-\345\220\257\345\212\250\346\234\215\345\212\241.command" old mode 100644 new mode 100755 index a3d7a9fac..d1fc1fec6 --- "a/mac-\345\220\257\345\212\250\346\234\215\345\212\241.command" +++ "b/mac-\345\220\257\345\212\250\346\234\215\345\212\241.command" @@ -4,6 +4,11 @@ cd "$(dirname "$0")" +# 加载用户 shell 配置(确保代理等环境变量可用) +[ -f "$HOME/.zshrc" ] && source "$HOME/.zshrc" 2>/dev/null +[ -f "$HOME/.bash_profile" ] && source "$HOME/.bash_profile" 2>/dev/null +[ -f "$HOME/.profile" ] && source "$HOME/.profile" 2>/dev/null + echo "============================================" echo " ComfyUI-API-Modelscope" echo "============================================" @@ -21,6 +26,24 @@ chmod +x main.py 2>/dev/null echo "权限已修复!" echo "" +# 检查并安装 socksio(系统代理使用 SOCKS5 时 httpx 需要此包) +PYTHON_BIN="" +if [ -x /opt/homebrew/bin/python3 ]; then + PYTHON_BIN="/opt/homebrew/bin/python3" +elif [ -x /usr/local/bin/python3 ]; then + PYTHON_BIN="/usr/local/bin/python3" +elif command -v python3 >/dev/null 2>&1; then + PYTHON_BIN="python3" +fi +if [ -n "$PYTHON_BIN" ]; then + if ! $PYTHON_BIN -c "import socksio" 2>/dev/null; then + echo "检测到缺少 socksio 依赖(SOCKS 代理需要),正在安装..." + $PYTHON_BIN -m pip install socksio -q 2>/dev/null + echo "socksio 安装完成!" + echo "" + fi +fi + # 清理占用 3000 端口的旧进程,避免 address already in use OLD_PID=$(lsof -ti :3000 2>/dev/null) if [ -n "$OLD_PID" ]; then @@ -40,6 +63,9 @@ echo "本机访问: http://127.0.0.1:3000/" echo "============================================" echo "" +# 后台延迟3秒自动打开浏览器(等服务器启动完成) +(sleep 3 && open "http://127.0.0.1:3000/") & + # 优先使用 Homebrew Python,避免部分工具管理的 Python 签名问题 if [ -x /opt/homebrew/bin/python3 ]; then /opt/homebrew/bin/python3 main.py diff --git "a/mac-\345\220\257\345\212\250\346\234\215\345\212\241.sh" "b/mac-\345\220\257\345\212\250\346\234\215\345\212\241.sh" index f57bc1d9d..30f0bfebe 100644 --- "a/mac-\345\220\257\345\212\250\346\234\215\345\212\241.sh" +++ "b/mac-\345\220\257\345\212\250\346\234\215\345\212\241.sh" @@ -18,6 +18,10 @@ echo "" # Open browser after 3 seconds sleep 3 && open "${APP_URL}" & +# 本地代理地址不走系统代理,避免 Clash 等工具劫持 127.0.0.1 请求 +export no_proxy="127.0.0.1,localhost,0.0.0.0" +export NO_PROXY="127.0.0.1,localhost,0.0.0.0" + python3 main.py echo "" diff --git a/main.py b/main.py old mode 100644 new mode 100755 index 29df0a65e..eead6400b --- a/main.py +++ b/main.py @@ -75,6 +75,14 @@ def filter(self, record): allow_headers=["*"], ) +@app.middleware("http") +async def no_cache_html_middleware(request: Request, call_next): + response = await call_next(request) + path = request.url.path.lower() + if path.endswith(".html"): + response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" + return response + # --- WebSocket 状态管理器 --- class ConnectionManager: def __init__(self): @@ -152,6 +160,32 @@ async def broadcast_asset_library_updated(self, updated_at: int = 0): print(f"Broadcast asset library error: {e}") self.active_connections.remove(connection) + async def broadcast_canvas_task_done(self, task_id: str, status: str): + data = json.dumps({"type": "canvas_task_done", "task_id": task_id, "status": status}) + for connection in self.active_connections[:]: + try: + await connection.send_text(data) + except Exception as e: + print(f"Broadcast canvas task done error: {e}") + self.active_connections.remove(connection) + + async def broadcast_agent_llm_done(self, task_id: str, status: str): + data = json.dumps({"type": "agent_llm_done", "task_id": task_id, "status": status}) + for connection in self.active_connections[:]: + try: + await connection.send_text(data) + except Exception as e: + print(f"Broadcast agent llm done error: {e}") + self.active_connections.remove(connection) + + async def broadcast_agent_llm_token(self, task_id: str, token: str): + data = json.dumps({"type": "agent_llm_token", "task_id": task_id, "token": token}) + for connection in self.active_connections[:]: + try: + await connection.send_text(data) + except Exception: + self.active_connections.remove(connection) + async def send_personal_message(self, message: dict, client_id: str): ws = self.user_connections.get(client_id) if ws: @@ -162,7 +196,7 @@ async def send_personal_message(self, message: dict, client_id: str): manager = ConnectionManager() GLOBAL_LOOP = None -APP_VERSION = "2026.06.03" +APP_VERSION = "2026.06.04" GITHUB_REPO_URL = "https://github.com/hero8152/Infinite-Canvas" GITHUB_VERSION_URL = "https://raw.githubusercontent.com/hero8152/Infinite-Canvas/main/VERSION" GITHUB_TREE_URL = "https://api.github.com/repos/hero8152/Infinite-Canvas/git/trees/main?recursive=1" @@ -605,7 +639,18 @@ def load_env_file(): VIDEO_PROMPT_MAX_LENGTH = int(os.getenv("VIDEO_PROMPT_MAX_LENGTH", "4000")) LLM_MESSAGE_MAX_LENGTH = int(os.getenv("LLM_MESSAGE_MAX_LENGTH", "20000")) CHAT_ATTACHMENT_MAX = int(os.getenv("CHAT_ATTACHMENT_MAX", "20")) -ONLINE_IMAGE_REFERENCE_MAX = int(os.getenv("ONLINE_IMAGE_REFERENCE_MAX", "20")) +ONLINE_IMAGE_REFERENCE_MAX = int(os.getenv("ONLINE_IMAGE_REFERENCE_MAX", "10")) + +def provider_max_reference_images(provider): + """返回当前 provider 的生图参考图上限。 + agnes-ai (openai-json): 6 张(上游硬限制) + 其他 provider: ONLINE_IMAGE_REFERENCE_MAX(20 张)""" + if not provider: + return ONLINE_IMAGE_REFERENCE_MAX + image_request_mode = effective_image_request_mode(provider, "") + if image_request_mode == "openai-json": + return 6 + return ONLINE_IMAGE_REFERENCE_MAX FIELD_LABELS = { "prompt": "提示词", @@ -1340,6 +1385,7 @@ def public_provider(provider): "has_key": bool(key), "key_preview": mask_secret(key), "key_env": provider_key_env(provider["id"]), + "max_reference_images": provider_max_reference_images(provider), } if provider.get("id") == "runninghub": wallet_key = runninghub_wallet_key_value() @@ -1753,6 +1799,224 @@ def parse_prompt_template_markdown(text: str): }) return templates +YOUMIND_PROMPTS_API = "https://youmind.com/youmarketing-api/prompts" + +@app.get("/api/inspire-prompts") +async def inspire_prompts( + q: str = "", + category: str = "", + page: int = 1, + limit: int = 20, + sort_by: str = "views", +): + """灵感库提示词数据代理:转发 YouMind 公开提示词 API(提示词 + 参考图 + 分类 + 分页)。 + YouMind API 有简单防盗链(校验 Origin/Referer),此处伪造请求头绕过;图片无防盗链,前端直连。""" + payload = { + "model": "gpt-image-2", + "sortBy": sort_by if sort_by in ("views", "latest", "likes") else "views", + "sortOrder": "desc", + "page": max(1, int(page)), + "limit": min(60, max(1, int(limit))), + "locale": "zh-CN", + } + if q and q.strip(): + payload["q"] = q.strip() + if category and category.strip(): + payload["categories"] = category.strip() + headers = { + "Content-Type": "application/json", + "Origin": "https://youmind.com", + "Referer": "https://youmind.com/zh-CN/gpt-image-2-prompts/explore", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36", + } + try: + async with httpx.AsyncClient(timeout=httpx.Timeout(connect=15.0, read=40.0, write=20.0, pool=15.0)) as client: + resp = await client.post(YOUMIND_PROMPTS_API, json=payload, headers=headers) + except httpx.HTTPError as e: + raise HTTPException(status_code=502, detail=f"提示词库请求失败:{e}") + if resp.status_code != 200: + raise HTTPException(status_code=502, detail=f"提示词库上游错误:{resp.status_code}") + try: + data = resp.json() + except Exception: + raise HTTPException(status_code=502, detail="提示词库响应解析失败") + items = [] + for p in data.get("prompts", []) or []: + media = p.get("media") or [] + thumbs = p.get("mediaThumbnails") or [] + refs = p.get("referenceImages") or [] + cats = p.get("promptCategories") or [] + cat_slugs = [c.get("slug") if isinstance(c, dict) else c for c in cats] + items.append({ + "id": p.get("id"), + "title": p.get("title") or "", + "description": p.get("description") or "", + "prompt": p.get("content") or "", + "promptZh": p.get("translatedContent") or "", + "image": media[0] if media else "", + "thumb": thumbs[0] if thumbs else (media[0] if media else ""), + "refs": refs, + "needRefs": bool(p.get("needReferenceImages")), + "categories": cat_slugs, + "likes": p.get("likes") or 0, + }) + return { + "items": items, + "total": data.get("total") or 0, + "page": data.get("page") or page, + "totalPages": data.get("totalPages") or 0, + "hasMore": bool(data.get("hasMore")), + } + +CIVITAI_IMAGES_API = "https://civitai.com/api/v1/images" +CIVITAI_SEARCH_API = "https://search-new.civitai.com/multi-search" +# Civitai 前端内置的 Meilisearch 公开搜索 key(仅搜索、无写入权限,免登录可用) +CIVITAI_SEARCH_KEY = "8c46eb2508e21db1e9828a97968d91ab1ca1caa5f70a00e88a2ba1e286603b61" +CIVITAI_IMG_CDN = "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA" + +async def civitai_meili_search(query: str, limit: int, cursor: str): + """通过 Civitai 的 Meilisearch 搜索图片(真正的关键词搜索,返回 prompt+tags)。""" + offset = int(cursor) if str(cursor).isdigit() else 0 + body = {"queries": [{ + "q": query, + "indexUid": "images_v6", + "limit": limit, + "offset": offset, + "filter": ["(poi != true) AND (combinedNsfwLevel=1)"] + }]} + headers = {"Content-Type": "application/json", "Authorization": f"Bearer {CIVITAI_SEARCH_KEY}"} + try: + async with httpx.AsyncClient(timeout=httpx.Timeout(connect=15.0, read=40.0, write=20.0, pool=15.0)) as client: + resp = await client.post(CIVITAI_SEARCH_API, json=body, headers=headers) + except httpx.HTTPError as e: + raise HTTPException(status_code=502, detail=f"Civitai 搜索失败:{e}") + if resp.status_code == 429: + raise HTTPException(status_code=429, detail="Civitai 限流:请稍后再试") + if resp.status_code != 200: + raise HTTPException(status_code=502, detail=f"Civitai 搜索错误:{resp.status_code}") + data = resp.json() + result = (data.get("results") or [{}])[0] + hits = result.get("hits") or [] + total = result.get("estimatedTotalHits") or 0 + items = [] + for h in hits: + url = h.get("url") or "" + if not url: continue + prompt = h.get("prompt") or "" + items.append({ + "id": h.get("id"), + "image": f"{CIVITAI_IMG_CDN}/{url}/original=true/{url}.jpeg", + "thumb": f"{CIVITAI_IMG_CDN}/{url}/width=500/{url}.jpeg", + "prompt": prompt, + "promptZh": "", + "title": "", + "description": prompt[:100], + "width": h.get("width"), + "height": h.get("height"), + "model": "", + "username": (h.get("user") or {}).get("username") or "", + "tags": h.get("tagNames") or [], + }) + next_offset = offset + limit + has_more = bool(hits) and next_offset < total + return {"items": items, "cursor": str(next_offset) if has_more else "", "hasMore": has_more, "total": total} + +@app.get("/api/inspire-civitai") +async def inspire_civitai(request: Request, cursor: str = "", limit: int = 24, sort: str = "Most Reactions", tag: str = ""): + """灵感库 Civitai 数据源:用户生成的 AI 图片(参考性强)。 + Civitai 接口必须带 User-Agent 头,否则返回空。游标分页。 + 可选 API Key(前端经 X-Civitai-Key 头传入),带 Key 限额更高、更稳定。 + tag 非空时走 Meilisearch 真搜索;否则走 /api/v1/images 排序浏览。""" + limit = min(100, max(1, int(limit))) + search_query = (tag or "").strip() + # 搜索模式:tag 非空 → Meilisearch 关键词搜索 + if search_query: + return await civitai_meili_search(search_query, limit, cursor) + # 浏览模式:/api/v1/images + sort + params = {"limit": str(limit), "nsfw": "false"} + if sort: params["sort"] = sort + if cursor: params["cursor"] = cursor + headers = { + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36", + "Content-Type": "application/json", + } + civitai_key = (request.headers.get("x-civitai-key") or "").strip() + if civitai_key: + headers["Authorization"] = f"Bearer {civitai_key}" + try: + async with httpx.AsyncClient(timeout=httpx.Timeout(connect=15.0, read=40.0, write=20.0, pool=15.0)) as client: + resp = await client.get(CIVITAI_IMAGES_API, params=params, headers=headers) + except httpx.HTTPError as e: + raise HTTPException(status_code=502, detail=f"Civitai 请求失败:{e}") + if resp.status_code == 429: + raise HTTPException(status_code=429, detail="Civitai 限流:请求太频繁,请稍后再试(配置 API Key 可提高限额)") + if resp.status_code != 200: + raise HTTPException(status_code=502, detail=f"Civitai 上游错误:{resp.status_code}") + try: + data = resp.json() + except Exception: + raise HTTPException(status_code=502, detail="Civitai 响应解析失败") + items = [] + for it in data.get("items", []) or []: + url = it.get("url") or "" + if not url: continue + thumb = url.replace("original=true", "width=400") # 缩略图(宽400,兼顾清晰与加载速度) + meta = it.get("meta") or {} + prompt = meta.get("prompt") or "" + tags = [t.get("name") if isinstance(t, dict) else t for t in (it.get("tags") or [])] + items.append({ + "id": it.get("id"), + "image": url, + "thumb": thumb, + "prompt": prompt, + "promptZh": "", + "title": "", + "description": prompt[:100] if prompt else "", + "width": it.get("width"), + "height": it.get("height"), + "model": it.get("baseModel") or "", + "username": it.get("username") or "", + "tags": tags, + }) + next_cursor = (data.get("metadata") or {}).get("nextCursor") or "" + return { + "items": items, + "cursor": next_cursor, + "hasMore": bool(next_cursor), + } + +class InspireLocalizeRequest(BaseModel): + url: str + id: str = "" + +@app.post("/api/inspire-localize") +async def inspire_localize(payload: InspireLocalizeRequest): + """把 Civitai 图片下载保存到本地(中等高清 width=1024),按 civitaiId 命名实现去重。 + 已本地化过的直接返回本地地址,不重复下载。""" + url = (payload.url or "").strip() + cid = str(payload.id or "").strip() + if not url: + raise HTTPException(status_code=400, detail="缺少图片地址") + safe_id = re.sub(r"[^0-9a-zA-Z_-]", "", cid) or uuid.uuid4().hex[:10] + filename = f"civitai_{safe_id}.jpg" + path = output_path_for(filename, "output") + # 去重:已本地化过直接返回本地地址 + if os.path.isfile(path): + return {"url": output_url_for(filename, "output"), "cached": True} + # 中等高清:width=1024 + dl_url = url.replace("original=true", "width=1024") + try: + timeout = httpx.Timeout(connect=20.0, read=120.0, write=60.0, pool=20.0) + async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client: + resp = await client.get(dl_url, headers={"User-Agent": "Mozilla/5.0 Chrome/124.0 Safari/537.36"}) + resp.raise_for_status() + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "wb") as f: + f.write(resp.content) + except Exception as e: + raise HTTPException(status_code=502, detail=f"图片本地化失败:{e}") + return {"url": output_url_for(filename, "output"), "cached": False} + @app.get("/api/app-info") def app_info(): version = current_app_version() @@ -2511,6 +2775,8 @@ class ImageTaskQueryRequest(BaseModel): CANVAS_TASKS: Dict[str, Dict[str, Any]] = {} CANVAS_TASK_LOCK = Lock() +AGENT_LLM_TASKS: Dict[str, Dict[str, Any]] = {} +AGENT_LLM_TASK_LOCK = Lock() class CanvasVideoRequest(BaseModel): prompt: str = Field(min_length=1, max_length=VIDEO_PROMPT_MAX_LENGTH) @@ -4434,6 +4700,7 @@ async def run_codex_cli(prompt, model="", image_paths=None, timeout=None, output proc = await asyncio.create_subprocess_exec( *args, cwd=BASE_DIR, + env=os.environ, stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, @@ -4587,14 +4854,14 @@ def gpt_image_2_skill_size_arg(size="", model="", prompt="", provider="openai"): size_text = str(size or "").strip() if str(provider or "").strip().lower() == "codex": if "1k" in text or "1024" in text: - return "1K" + return "auto" if "2k" in text or "2048" in text: return "2K" if "4k" in text or "3840" in text: return "4K" width, height = parse_size_pair(size_text) if 0 < max(width, height) < 1800: - return "1K" + return "auto" if 1800 <= max(width, height) < 3000: return "2K" return "4K" @@ -4617,7 +4884,7 @@ def gpt_image_2_skill_size_arg(size="", model="", prompt="", provider="openai"): if "4k" in text or "3840" in text: return "4K" if "1k" in text or "1024" in text: - return "1K" + return "auto" return "2K" def gpt_image_2_skill_prompt_arg(prompt="", size="", provider="openai"): @@ -4625,6 +4892,7 @@ def gpt_image_2_skill_prompt_arg(prompt="", size="", provider="openai"): if str(provider or "").strip().lower() != "codex": return prompt_text size_arg = gpt_image_2_skill_size_arg(size, "", prompt, provider) + size_display = "1K" if size_arg == "auto" else size_arg size_text = str(size or "").strip() width, height = parse_size_pair(size_text) ratio_text = "" @@ -4638,15 +4906,15 @@ def gpt_image_2_skill_prompt_arg(prompt="", size="", provider="openai"): height = int(ratio_match.group(2)) ratio_text = f"{width}:{height}" if not ratio_text: - return f"{prompt_text} 画质要求:目标输出 {size_arg} 高分辨率图片。 Image quality requirement: output a {size_arg} high-resolution image." + return f"{prompt_text} 画质要求:目标输出 {size_display} 高分辨率图片。 Image quality requirement: output a {size_display} high-resolution image." orientation_zh = "横版/宽幅" if width > height else ("竖版/长幅" if height > width else "正方形") orientation_en = "landscape/wide" if width > height else ("portrait/tall" if height > width else "square") return ( f"{prompt_text} " - f"画质要求:目标输出 {size_arg} 高分辨率图片。" + f"画质要求:目标输出 {size_display} 高分辨率图片。" f"画幅要求:必须生成 {orientation_zh} 图片,宽高比 {ratio_text}。" f"请不要交换宽高,不要输出反向比例。" - f" Image quality requirement: output a {size_arg} high-resolution image." + f" Image quality requirement: output a {size_display} high-resolution image." f" Canvas requirement: generate a {orientation_en} image with aspect ratio {ratio_text}; " "do not swap width and height." ) @@ -4954,6 +5222,16 @@ async def generate_codex_provider_image(prompt, size, model, reference_images=No except Exception: pass +def system_prompt_requires_json(system_prompt): + """检测系统提示词是否要求 JSON 格式输出(如画布 Agent 的结构化回复)。 + + 当系统提示词包含 JSON 格式要求时(如 AGENT_FORMAT_INSTRUCTION), + 不应再追加"输出纯文本"指令,否则会导致 LLM 返回自然语言而非 JSON, + 前端 parseAgentResponse 无法解析,进而无法显示"确认/修改"按钮和触发生图。 + """ + text = str(system_prompt or "").strip().lower() + return "json" in text or '"reply"' in text or '"generations"' in text or '"options"' in text + def codex_chat_prompt(payload, history_messages=None): parts = [] system_prompt = str(getattr(payload, "system_prompt", "") or "").strip() @@ -4967,7 +5245,11 @@ def codex_chat_prompt(payload, history_messages=None): parts.append(f"{label}:\n{content}") message = str(getattr(payload, "message", "") or "").strip() parts.append(f"用户:\n{message}") - parts.append("请直接回答用户,输出纯文本,不要修改项目文件。") + # 如果系统要求 JSON 格式输出(如画布 Agent),保持格式要求;否则要求纯文本 + if system_prompt_requires_json(system_prompt): + parts.append("请严格按照系统要求中的格式输出,不要添加 markdown 代码块标记或额外说明,不要修改项目文件。") + else: + parts.append("请直接回答用户,输出纯文本,不要修改项目文件。") return "\n\n".join(part for part in parts if part).strip() async def codex_chat_text(payload, history_messages=None): @@ -5116,9 +5398,18 @@ async def run_gemini_cli(prompt, model="", timeout=None, allow_tools=False): args.extend(["--prompt", str(prompt or "")]) proc = None try: + # 补全大写代理变量(Go CLI 工具通常只认大写 HTTP_PROXY/HTTPS_PROXY) + _env = dict(os.environ) + if not _env.get('HTTP_PROXY') and _env.get('http_proxy'): + _env['HTTP_PROXY'] = _env['http_proxy'] + if not _env.get('HTTPS_PROXY') and _env.get('https_proxy'): + _env['HTTPS_PROXY'] = _env['https_proxy'] + if not _env.get('ALL_PROXY') and _env.get('all_proxy'): + _env['ALL_PROXY'] = _env['all_proxy'] proc = await asyncio.create_subprocess_exec( *args, cwd=BASE_DIR, + env=_env, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) @@ -5230,6 +5521,10 @@ async def generate_gemini_cli_provider_image(prompt, size, model, reference_imag if url and url not in urls: urls.append(url) if not urls: + # agy CLI 无法直接生图时,回退到 gpt-image-2-skill(与 Codex CLI 相同的生图路径) + skill_result = await generate_codex_provider_image_via_gpt_image_2_skill(prompt, size, model, ref_paths) + if skill_result: + return skill_result status_text = (raw.get("text") or raw.get("_stdout") or raw.get("_stderr") or "")[:1200] raise HTTPException(status_code=502, detail=f"{gemini_cli_display_name()} 已返回,但没有在输出目录发现图片:{status_text}") return {"type": "url", "value": urls[0]}, {"images": urls, "text": raw.get("text"), "provider": "gemini-cli", "raw": raw.get("raw")} @@ -5269,7 +5564,12 @@ async def gemini_cli_chat_text(payload, history_messages=None): image_paths, temp_paths = await gemini_cli_reference_paths(image_values) if image_paths: prompt = f"{prompt}\n\n可参考的本地图片路径:\n" + "\n".join(image_paths) - prompt = f"{prompt}\n\n请直接回答用户,输出纯文本,不要修改项目文件。" + # 如果系统要求 JSON 格式输出(如画布 Agent),保持格式要求;否则要求纯文本 + system_prompt = str(getattr(payload, "system_prompt", "") or "").strip() + if system_prompt_requires_json(system_prompt): + prompt = f"{prompt}\n\n请严格按照系统要求中的格式输出,不要添加 markdown 代码块标记或额外说明,不要修改项目文件。" + else: + prompt = f"{prompt}\n\n请直接回答用户,输出纯文本,不要修改项目文件。" raw = await run_gemini_cli( prompt, model=getattr(payload, "model", "") or GEMINI_CLI_DEFAULT_CHAT_MODELS[0], @@ -5277,6 +5577,11 @@ async def gemini_cli_chat_text(payload, history_messages=None): allow_tools=False, ) text = str(raw.get("text") or "").strip() + # 如果系统要求 JSON,优先使用 stdout 原始文本(避免 gemini_cli_text_from_raw 拆解 JSON 结构) + if system_prompt_requires_json(system_prompt): + stdout_text = str(raw.get("_stdout") or "").strip() + if stdout_text and '{' in stdout_text: + text = stdout_text return text or f"{gemini_cli_display_name()} 返回了空回复。", raw finally: for path in temp_paths: @@ -7809,8 +8114,20 @@ def convert_output_to_jpg(url, quality=88): print(f"转换 JPG 失败: {e}") return url +def ref_image_max_size(num_refs): + """根据参考图数量动态计算 max_size,控制总 payload 在 nginx 限制内。 + nginx 默认 client_max_body_size=1MB,base64 膨胀 33%,需留余量。""" + if not num_refs or num_refs <= 1: + return 1536 + if num_refs <= 3: + return 1024 + if num_refs <= 6: + return 768 + return 512 + def reference_to_data_url(ref, max_size=None): - """把本地输出文件转为 data URL(base64)。max_size 限制最长边像素,避免 payload 过大。""" + """把本地输出文件转为 data URL(base64)。max_size 限制最长边像素,避免 payload 过大。 + 始终使用 JPEG 格式(RGBA 合成到白底),避免 PNG 过大导致 413。""" path = output_file_from_url(ref.get("url", "")) if not path: return ref.get("url", "") @@ -7821,20 +8138,56 @@ def reference_to_data_url(ref, max_size=None): w, h = img.size if max(w, h) > max_size: img.thumbnail((max_size, max_size), Image.LANCZOS) - if img.mode not in ("RGB", "RGBA"): + # 始终转 RGB:RGBA 合成到白底,P/L 等模式直接转 + if img.mode == "RGBA": + bg = Image.new("RGB", img.size, (255, 255, 255)) + bg.paste(img, mask=img.split()[3]) + img = bg + elif img.mode != "RGB": img = img.convert("RGB") buf = BytesIO() - fmt = "PNG" if img.mode == "RGBA" else "JPEG" - img.save(buf, format=fmt, quality=88 if fmt == "JPEG" else None) + img.save(buf, format="JPEG", quality=85) encoded = base64.b64encode(buf.getvalue()).decode("ascii") - mime = "image/png" if fmt == "PNG" else "image/jpeg" - return f"data:{mime};base64,{encoded}" + return f"data:image/jpeg;base64,{encoded}" except Exception as e: print(f"reference resize failed, fallback to raw: {e}") with open(path, "rb") as f: encoded = base64.b64encode(f.read()).decode("ascii") return f"data:{content_type_for_path(path)};base64,{encoded}" +def prepare_reference_for_multipart(ref, max_size=1536): + """准备参考图用于 multipart 上传。 + 图片 > max_size 时缩放到 max_size 并保存到临时文件(JPEG quality 88)。 + 图片 <= max_size 时返回原始路径(不压缩、不转码)。 + 返回 (path, cleanup_temp_path_or_None)。""" + raw_url = ref.get("url", "") if isinstance(ref, dict) else str(ref or "") + path = output_file_from_url(raw_url) + if not path: + return None, None + if not max_size: + return path, None + try: + with Image.open(path) as img: + img.load() + w, h = img.size + if max(w, h) <= max_size: + return path, None + img.thumbnail((max_size, max_size), Image.LANCZOS) + # 始终转 RGB:RGBA 合成到白底,避免 PNG 过大 + if img.mode == "RGBA": + bg = Image.new("RGB", img.size, (255, 255, 255)) + bg.paste(img, mask=img.split()[3]) + img = bg + elif img.mode != "RGB": + img = img.convert("RGB") + tmp_fd, tmp_path = tempfile.mkstemp(suffix=".jpg", prefix="ref_multipart_") + os.close(tmp_fd) + img.save(tmp_path, format="JPEG", quality=85) + return tmp_path, tmp_path + except Exception as e: + print(f"prepare_reference_for_multipart resize failed, fallback to raw: {e}") + return path, None + def is_image_reference(ref): if not isinstance(ref, dict): return False @@ -9462,11 +9815,13 @@ async def generate_modelscope_provider_image(prompt, size, model, reference_imag raise HTTPException(status_code=400, detail="未配置 ModelScope API Key,请在 API 设置中填写。") width, height = parse_size_pair(size) refs = [] - for ref in (reference_images or [])[:ONLINE_IMAGE_REFERENCE_MAX]: + _ms_refs = (reference_images or [])[:ONLINE_IMAGE_REFERENCE_MAX] + _ms_max = ref_image_max_size(len(_ms_refs)) + for ref in _ms_refs: if not ref.get("url"): continue # 本地参考图转为 data URL;前端已生成的 data URL 保持原样,贴近旧版稳定链路。 - refs.append(modelscope_image_url(ref.get("url", ""), max_size=1536)) + refs.append(modelscope_image_url(ref.get("url", ""), max_size=_ms_max)) headers = { "Authorization": f"Bearer {clean_token}", "Content-Type": "application/json", @@ -9537,8 +9892,8 @@ def gemini_image_config(size): aspect_ratio, resolution = apimart_size_resolution(size) return {"aspectRatio": aspect_ratio, "imageSize": resolution.upper()} -def gemini_reference_part(ref): - value = reference_to_data_url(ref, max_size=1536) +def gemini_reference_part(ref, max_size=1536): + value = reference_to_data_url(ref, max_size=max_size) if not value: return None if isinstance(value, str) and value.startswith("data:image/") and ";base64," in value: @@ -9553,8 +9908,10 @@ async def generate_gemini_provider_image(prompt, size, model, reference_images=N model_name = gemini_model_name(model) endpoint = gemini_endpoint_url(provider, model_name) parts = [{"text": prompt.strip()}] - for ref in (reference_images or [])[:ONLINE_IMAGE_REFERENCE_MAX]: - part = gemini_reference_part(ref) + _refs = (reference_images or [])[:ONLINE_IMAGE_REFERENCE_MAX] + _max_sz = ref_image_max_size(len(_refs)) + for ref in _refs: + part = gemini_reference_part(ref, max_size=_max_sz) if part: parts.append(part) body = { @@ -9573,8 +9930,8 @@ async def generate_gemini_provider_image(prompt, size, model, reference_images=N def volcengine_endpoint_url(provider): return provider_endpoint_url(provider, "image_generation_endpoint", "/api/v3/images/generations") -def volcengine_image_payload(ref): - value = reference_to_data_url(ref, max_size=1536) +def volcengine_image_payload(ref, max_size=1536): + value = reference_to_data_url(ref, max_size=max_size) if not value: return None return value @@ -9588,7 +9945,9 @@ async def generate_volcengine_provider_image(prompt, size, model, reference_imag "size": size, "response_format": "url", } - images = [volcengine_image_payload(ref) for ref in (reference_images or [])[:ONLINE_IMAGE_REFERENCE_MAX]] + _vrefs = (reference_images or [])[:ONLINE_IMAGE_REFERENCE_MAX] + _vmax = ref_image_max_size(len(_vrefs)) + images = [volcengine_image_payload(ref, max_size=_vmax) for ref in _vrefs] images = [value for value in images if value] if images: body["image"] = images @@ -10885,7 +11244,8 @@ def post_video_proxy_multipart(): # 文生图只传 extra_body.response_format,图生图把参考图放进 extra_body.image。 extra_body = {"response_format": "url"} if image_refs: - extra_body["image"] = [reference_to_data_url(ref, max_size=1536) for ref in image_refs[:ONLINE_IMAGE_REFERENCE_MAX]] + _ojson_refs = image_refs[:ONLINE_IMAGE_REFERENCE_MAX] + extra_body["image"] = [reference_to_data_url(ref, max_size=ref_image_max_size(len(_ojson_refs))) for ref in _ojson_refs] body = {"model": model, "prompt": prompt, "size": size, "extra_body": extra_body} response = await client.post(gen_url, headers=api_headers(provider=provider, model=model), json=body) elif is_apimart: @@ -10901,7 +11261,8 @@ def post_video_proxy_multipart(): "official_fallback": False, } if image_refs: - body["image_urls"] = [reference_to_data_url(ref, max_size=1536) for ref in image_refs[:ONLINE_IMAGE_REFERENCE_MAX]] + _apimart_refs = image_refs[:ONLINE_IMAGE_REFERENCE_MAX] + body["image_urls"] = [reference_to_data_url(ref, max_size=ref_image_max_size(len(_apimart_refs))) for ref in _apimart_refs] response = await client.post(gen_url, headers=api_headers(provider=provider, model=model), json=body) elif is_gpt2 and not image_refs and not mask_refs: body = {"model": model, "prompt": prompt, "size": size} @@ -10915,16 +11276,19 @@ def post_video_proxy_multipart(): # GPT-Image-2 参考图不能走 /images/generations JSON,否则部分平台会忽略原图或报 Images API unsupported。 files = [] opened = [] + temp_paths = [] edit_failed_status = None edit_failed_text = "" try: for ref in image_refs[:ONLINE_IMAGE_REFERENCE_MAX]: - path = output_file_from_url(ref.get("url", "")) - if not path: + ref_path, tmp_cleanup = prepare_reference_for_multipart(ref, max_size=1536) + if not ref_path: continue - fh = open(path, "rb") + if tmp_cleanup: + temp_paths.append(tmp_cleanup) + fh = open(ref_path, "rb") opened.append(fh) - files.append(("image", (os.path.basename(path), fh, content_type_for_path(path)))) + files.append(("image", (os.path.basename(ref_path), fh, content_type_for_path(ref_path)))) if mask_refs: mask_path = output_file_from_url(mask_refs[0].get("url", "")) if mask_path: @@ -10944,6 +11308,11 @@ def post_video_proxy_multipart(): finally: for fh in opened: fh.close() + for tp in temp_paths: + try: + os.unlink(tp) + except Exception: + pass # 2) edits 失败 → 非 GPT-Image-2 可回退到 /images/generations + JSON image:[urls/base64](grsai 风格) if response is None: if is_gpt2: @@ -10952,7 +11321,8 @@ def post_video_proxy_multipart(): detail=f"GPT-Image-2 编辑接口 /images/edits 调用失败:{edit_failed_text[:300] or edit_failed_status}。已停止自动重试,避免上游可能已扣费后再次请求。" ) print(f"/images/edits failed ({edit_failed_status}): {edit_failed_text[:200]} → 回退到 /images/generations + image:[] JSON") - image_payload = [reference_to_data_url(ref, max_size=1536) for ref in image_refs[:ONLINE_IMAGE_REFERENCE_MAX]] + _fallback_refs = image_refs[:ONLINE_IMAGE_REFERENCE_MAX] + image_payload = [reference_to_data_url(ref, max_size=ref_image_max_size(len(_fallback_refs))) for ref in _fallback_refs] body = { "model": model, "prompt": prompt, "size": size, "response_format": "url", "n": 1, @@ -13467,9 +13837,26 @@ async def build_online_image_result(payload: OnlineImageRequest): request_size = snap_size_to_multiple(payload.size, 16) refs = [ref.dict() for ref in payload.reference_images if ref.url] image_refs = image_references(refs) + _max_refs = provider_max_reference_images(provider) + if len(image_refs) > _max_refs: + print(f"[ref-limit] provider={provider.get('id')} refs={len(image_refs)}>max={_max_refs}, truncating") + image_refs = image_refs[:_max_refs] count = max(1, min(8, int(payload.n or 1))) async def generate_one(): - image_data, raw_item = await generate_ai_image(payload.prompt, request_size, payload.quality, model, image_refs, provider["id"]) + # 网络错误自动重试(httpx.HTTPError 但非 HTTPStatusError) + # 解决高并发时间歇性网络中断导致"请求上游生图接口失败"但图实际已生成的问题 + max_retries = 2 + for attempt in range(max_retries + 1): + try: + image_data, raw_item = await generate_ai_image(payload.prompt, request_size, payload.quality, model, image_refs, provider["id"]) + break + except httpx.HTTPStatusError: + raise # HTTP 状态码错误不重试(如 400/401/403) + except httpx.HTTPError as exc: + if attempt + 1 <= max_retries: + await asyncio.sleep(1.5 * (attempt + 1)) + continue + raise try: image_items = extract_images(raw_item) if isinstance(raw_item, dict) else [image_data] except HTTPException: @@ -13666,6 +14053,10 @@ async def run_canvas_image_task(task_id: str, payload: OnlineImageRequest): "error": "", "updated_at": time.time(), }) + try: + await manager.broadcast_canvas_task_done(task_id, "succeeded") + except Exception: + pass except JimengPendingError as exc: # 即梦云端还在排队:标记为 jimeng_pending,前端据 submit_id 持久续查(任务未丢失) info = jimeng_pending_payload(exc) @@ -13680,6 +14071,10 @@ async def run_canvas_image_task(task_id: str, payload: OnlineImageRequest): "error": "", "updated_at": time.time(), }) + try: + await manager.broadcast_canvas_task_done(task_id, "jimeng_pending") + except Exception: + pass except Exception as exc: detail = getattr(exc, "detail", None) or str(exc) status_code = getattr(exc, "status_code", 500) @@ -13692,6 +14087,70 @@ async def run_canvas_image_task(task_id: str, payload: OnlineImageRequest): "upstream_task_id": upstream_task_id, "updated_at": time.time(), }) + try: + await manager.broadcast_canvas_task_done(task_id, "failed") + except Exception: + pass + +async def run_agent_llm_task(task_id: str, payload: CanvasLLMRequest, stream: bool = False): + with AGENT_LLM_TASK_LOCK: + if task_id in AGENT_LLM_TASKS: + AGENT_LLM_TASKS[task_id]["status"] = "running" + AGENT_LLM_TASKS[task_id]["updated_at"] = time.time() + try: + if stream: + result = await canvas_llm_stream(task_id, payload) + else: + result = await canvas_llm(payload) + with AGENT_LLM_TASK_LOCK: + AGENT_LLM_TASKS[task_id].update({ + "status": "succeeded", + "result": result, + "error": "", + "updated_at": time.time(), + }) + try: + await manager.broadcast_agent_llm_done(task_id, "succeeded") + except Exception: + pass + except Exception as exc: + detail = getattr(exc, "detail", None) or str(exc) + status_code = getattr(exc, "status_code", 500) + with AGENT_LLM_TASK_LOCK: + AGENT_LLM_TASKS[task_id].update({ + "status": "failed", + "error": str(detail), + "status_code": status_code, + "updated_at": time.time(), + }) + try: + await manager.broadcast_agent_llm_done(task_id, "failed") + except Exception: + pass + +@app.post("/api/agent-llm-task") +async def create_agent_llm_task(payload: CanvasLLMRequest, stream: bool = False): + task_id = f"agent_llm_{uuid.uuid4().hex}" + with AGENT_LLM_TASK_LOCK: + AGENT_LLM_TASKS[task_id] = { + "id": task_id, + "type": "agent-llm", + "status": "queued", + "created_at": time.time(), + "updated_at": time.time(), + "result": None, + "error": "", + } + asyncio.create_task(run_agent_llm_task(task_id, payload, stream=stream)) + return {"task_id": task_id, "status": "queued"} + +@app.get("/api/agent-llm-task/{task_id}") +async def get_agent_llm_task(task_id: str): + with AGENT_LLM_TASK_LOCK: + task = dict(AGENT_LLM_TASKS.get(task_id) or {}) + if not task: + raise HTTPException(status_code=404, detail="Agent LLM 任务不存在,可能服务已重启") + return task @app.post("/api/canvas-image-tasks") async def create_canvas_image_task(payload: OnlineImageRequest): @@ -14643,7 +15102,9 @@ def append_volcengine_image(url: str, role: str): # enable_upsample / aspect_ratio(仅 16:9、9:16)。无 duration 字段, # 时长由模型本身决定,所以这里不传 duration/seconds。 yuli_images = [] - for ref in payload.images[:3]: + _veo_refs = payload.images[:3] + _veo_max = ref_image_max_size(len(_veo_refs)) + for ref in _veo_refs: ref_url = str(getattr(ref, "url", "") or "").strip() if not ref_url: continue @@ -14651,7 +15112,7 @@ def append_volcengine_image(url: str, role: str): yuli_images.append(ref_url) else: # 本地/dataURL 图片转成 data URL 兜底传递 - data_url = reference_to_data_url(ref.dict(), max_size=1536) + data_url = reference_to_data_url(ref.dict(), max_size=_veo_max) if data_url: yuli_images.append(data_url) prompt_text = str(payload.prompt or "") @@ -14671,10 +15132,12 @@ def append_volcengine_image(url: str, role: str): if payload.enable_upsample: body["enable_upsample"] = True else: + _veo3_refs = payload.images[:4] + _veo3_max = ref_image_max_size(len(_veo3_refs)) image_payload = [] - for ref in payload.images[:4]: + for ref in _veo3_refs: if ref.url: - image_payload.append(reference_to_data_url(ref.dict(), max_size=1536)) + image_payload.append(reference_to_data_url(ref.dict(), max_size=_veo3_max)) body = { "prompt": payload.prompt, "model": selected_model(payload.model, "veo3-fast"), @@ -14822,6 +15285,71 @@ def append_volcengine_image(url: str, role: str): # --- Canvas LLM --- +async def canvas_llm_stream(task_id: str, payload: CanvasLLMRequest): + """流式调用 LLM,通过 WebSocket 逐 token 广播,最终返回完整结果。""" + _provider = get_api_provider(payload.provider) + # CLI 协议不支持流式,回退为普通调用 + if is_codex_provider(_provider) or is_gemini_cli_provider(_provider): + return await canvas_llm(payload) + chat_base, chat_hdrs, model = resolve_chat_provider(payload.provider, payload.model, payload.ms_model) + _llm_provider = get_api_provider(payload.provider) if payload.provider not in ("modelscope",) else {} + _is_apimart = is_apimart_provider(_llm_provider) + # APIMart 不支持流式,回退 + if _is_apimart: + return await canvas_llm(payload) + system_prompt = (payload.system_prompt or "").strip() + upstream_messages = [{"role": "system", "content": system_prompt}] if system_prompt else [] + for item in payload.messages[-MAX_HISTORY_MESSAGES:]: + role = item.get("role") + content = item.get("content") + if role in {"user", "assistant"} and content: + upstream_messages.append({"role": role, "content": content}) + image_inputs = [img for img in (payload.images or []) if is_image_reference_value(img)] + if image_inputs: + content_parts = [{"type": "text", "text": payload.message}] + for img in image_inputs[:8]: + if not img or not isinstance(img, str): + continue + ref_url = media_reference_to_url(img, max_image_size=1024) + if ref_url: + content_parts.append({"type": "image_url", "image_url": {"url": ref_url}}) + upstream_messages.append({"role": "user", "content": content_parts}) + else: + upstream_messages.append({"role": "user", "content": payload.message}) + # 流式请求 + full_text = "" + try: + async with httpx.AsyncClient(timeout=AI_REQUEST_TIMEOUT) as client: + req_body = {"model": model, "messages": upstream_messages, "stream": True} + async with client.stream("POST", f"{chat_base}/chat/completions", headers=chat_hdrs, json=req_body) as response: + response.raise_for_status() + async for line in response.aiter_lines(): + if not line or not line.startswith("data: "): + continue + data_str = line[6:].strip() + if data_str == "[DONE]": + break + try: + chunk = json.loads(data_str) + delta = (chunk.get("choices") or [{}])[0].get("delta") or {} + token = delta.get("content") or "" + if token: + full_text += token + try: + await manager.broadcast_agent_llm_token(task_id, token) + except Exception: + pass + except (json.JSONDecodeError, IndexError, KeyError): + continue + except httpx.HTTPStatusError as exc: + body = exc.response.text or "" if hasattr(exc.response, 'text') else "" + friendly = friendly_chat_error_detail(body, model, _llm_provider) + raise HTTPException(status_code=exc.response.status_code, detail=friendly or f"上游接口错误:{body[:300]}") from exc + except httpx.HTTPError as exc: + raise HTTPException(status_code=502, detail=f"请求上游接口失败:{exc}") from exc + text = full_text.strip() or "接口返回了空回复。" + return {"text": text, "model": model, "raw_usage": None, "raw": None} + @app.post("/api/canvas-llm") async def canvas_llm(payload: CanvasLLMRequest): _provider = get_api_provider(payload.provider) @@ -16828,7 +17356,7 @@ async def generate_angle_cloud(req: CloudGenRequest): payload = { "model": model, "prompt": req.prompt.strip(), - "image_url": [modelscope_image_url(url, max_size=1536) for url in req.image_urls] + "image_url": [modelscope_image_url(url, max_size=ref_image_max_size(len(req.image_urls))) for url in req.image_urls] } if req.resolution: payload["size"] = modelscope_size(req.resolution) @@ -17018,7 +17546,8 @@ async def ms_generate(req: MsGenerateRequest): elif req.size: payload["size"] = modelscope_size(req.size) if req.image_urls: - payload["image_url"] = [modelscope_image_url(url, max_size=1536) for url in req.image_urls] + _ms_urls = req.image_urls + payload["image_url"] = [modelscope_image_url(url, max_size=ref_image_max_size(len(_ms_urls))) for url in _ms_urls] if req.loras is not None: payload["loras"] = req.loras diff --git a/static/angle.html b/static/angle.html index 6bf839ebd..194eb7e29 100644 --- a/static/angle.html +++ b/static/angle.html @@ -17,21 +17,21 @@ } catch(e) {} })(); - - - - - - + + + + + + - + diff --git a/static/api-settings.html b/static/api-settings.html index 508dc8b72..631efd994 100644 --- a/static/api-settings.html +++ b/static/api-settings.html @@ -16,12 +16,12 @@ } catch(e) {} })(); - - - - - - + + + + + +
@@ -565,6 +565,6 @@
- + diff --git a/static/asset-manager.html b/static/asset-manager.html index fc7edc8e0..fd3a9ba42 100644 --- a/static/asset-manager.html +++ b/static/asset-manager.html @@ -16,10 +16,10 @@ } catch(e) {} })(); - - - - + + + +
@@ -47,6 +47,6 @@
- + diff --git a/static/canvas-list.html b/static/canvas-list.html index 3d5b1aa1b..90583ab03 100644 --- a/static/canvas-list.html +++ b/static/canvas-list.html @@ -16,13 +16,13 @@ } catch(e) {} })(); - - - - - - - + + + + + + +
@@ -90,6 +90,6 @@
- + diff --git a/static/canvas.html b/static/canvas.html index 680cd5cd9..fb6d5ebd4 100644 --- a/static/canvas.html +++ b/static/canvas.html @@ -16,13 +16,13 @@ } catch(e) {} })(); - - - - - - - + + + + + + +
@@ -350,7 +350,7 @@
- - + + diff --git a/static/comfyui-settings.html b/static/comfyui-settings.html index dd82ccecb..e0fde2ff7 100644 --- a/static/comfyui-settings.html +++ b/static/comfyui-settings.html @@ -15,12 +15,12 @@ } catch(e) {} })(); - - - - - - + + + + + +
@@ -122,6 +122,6 @@ preview
- + diff --git a/static/css/smart-canvas.css b/static/css/smart-canvas.css index 3547f4b2b..22d26c4fc 100644 --- a/static/css/smart-canvas.css +++ b/static/css/smart-canvas.css @@ -20,6 +20,8 @@ html[data-studio-scale="off"].studio-scale-managed .smart-log-toggle, html[data-studio-scale="off"].studio-scale-managed .smart-shortcut-toggle, html[data-studio-scale="off"].studio-scale-managed .smart-workflow-toggle, html[data-studio-scale="off"].studio-scale-managed .asset-toggle, +html[data-studio-scale="off"].studio-scale-managed .agent-toggle, +html[data-studio-scale="off"].studio-scale-managed .inspire-toggle, /* 注意:.composer 不能加进这份 zoom 列表——它在 .world 内用世界坐标定位, 单独 zoom 会让坐标按 scale 重算,离原点越远偏移越大(小屏下直接飞出屏幕外) */ html[data-studio-scale="off"].studio-scale-managed .smart-minimap, @@ -27,6 +29,8 @@ html[data-studio-scale="off"].studio-scale-managed .smart-log-panel, html[data-studio-scale="off"].studio-scale-managed .shortcut-panel, html[data-studio-scale="off"].studio-scale-managed .workflow-panel, html[data-studio-scale="off"].studio-scale-managed .asset-panel, +html[data-studio-scale="off"].studio-scale-managed .agent-panel, +html[data-studio-scale="off"].studio-scale-managed .inspire-panel, html[data-studio-scale="off"].studio-scale-managed .asset-dock, html[data-studio-scale="off"].studio-scale-managed .asset-dialog, html[data-studio-scale="off"].studio-scale-managed .asset-hover-preview, @@ -1034,11 +1038,13 @@ input[type=range].smart-range::-webkit-slider-thumb { -webkit-appearance:none; w .ref-line { display:none; } .ref-thumb { width:30px; height:30px; border-radius:8px; object-fit:cover; border:1px solid var(--line); background:var(--soft); flex:0 0 auto; } .ref-chip { height:20px; max-width:130px; padding:0 7px; border-radius:999px; display:inline-flex; align-items:center; background:var(--soft); border:1px solid var(--line); color:var(--muted); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } -.smart-minimap { position:absolute; right:22px; bottom:22px; z-index:8; width:190px; height:128px; border-radius:18px; background:var(--panel); border:1px solid var(--line); box-shadow:0 18px 44px var(--shadow); backdrop-filter:blur(16px); overflow:hidden; pointer-events:auto; cursor:crosshair; } -.minimap-arrange-btn { position:absolute; right:22px; bottom:158px; z-index:9; height:32px; padding:0 10px; border-radius:10px; display:flex; align-items:center; justify-content:center; gap:6px; color:var(--muted); background:var(--card); border:1px solid var(--line); box-shadow:0 10px 24px rgba(15,23,42,.13); opacity:0; pointer-events:none; cursor:pointer; font-size:11px; font-weight:850; transition:opacity .14s ease, color .14s ease, background .14s ease, transform .14s ease; } +.smart-minimap { position:absolute; left:22px; bottom:22px; z-index:8; width:190px; height:128px; border-radius:18px; background:var(--panel); border:1px solid var(--line); box-shadow:0 18px 44px var(--shadow); backdrop-filter:blur(16px); overflow:hidden; pointer-events:auto; cursor:crosshair; } +.minimap-arrange-btn { position:absolute; left:22px; bottom:158px; z-index:9; height:32px; padding:0 10px; border-radius:10px; display:flex; align-items:center; justify-content:center; gap:6px; color:var(--muted); background:var(--card); border:1px solid var(--line); box-shadow:0 10px 24px rgba(15,23,42,.13); opacity:0; pointer-events:none; cursor:pointer; font-size:11px; font-weight:850; transition:opacity .14s ease, color .14s ease, background .14s ease, transform .14s ease; } .minimap-arrange-btn.visible { opacity:1; pointer-events:auto; } .minimap-arrange-btn:hover { color:var(--strong); background:var(--panel); transform:translateY(-1px); } .minimap-arrange-btn i,.minimap-arrange-btn svg { width:14px; height:14px; } +#smartSendAgentBtn { left:50%; right:auto; bottom:22px; transform:translateX(-50%); } +#smartSendAgentBtn:hover { transform:translateX(-50%) translateY(-1px); } .smart-minimap-content { position:absolute; inset:10px; border-radius:12px; overflow:hidden; background-image:radial-gradient(var(--grid) 1px, transparent 1px); background-size:12px 12px; opacity:.95; } .minimap-node { position:absolute; min-width:4px; min-height:4px; border-radius:3px; background:var(--strong); opacity:.8; } .smart-minimap-viewport { position:absolute; border-radius:8px; border:2px solid var(--strong); background:rgba(255,255,255,.12); } @@ -1136,7 +1142,8 @@ input[type=range].smart-range::-webkit-slider-thumb { -webkit-appearance:none; w .shortcut-modal { right:14px; top:118px; width:calc(100vw - 28px); max-height:calc(100vh - 132px); } .workflow-transfer-panel { right:14px; top:118px; width:calc(100vw - 28px); max-height:calc(100vh - 132px); } } -.asset-head { display:flex; align-items:center; justify-content:space-between; gap:8px; } +.asset-head { display:flex; align-items:center; gap:4px; } +.asset-head .asset-title { flex:1; } .asset-title { display:flex; align-items:center; gap:7px; font-size:12px; font-weight:800; color:var(--text); } .asset-title i,.asset-title svg { width:15px; height:15px; } .asset-mini-btn { width:26px; height:26px; border-radius:9px; background:transparent; color:var(--muted); display:inline-flex; align-items:center; justify-content:center; border:1px solid transparent; } @@ -1160,6 +1167,401 @@ input[type=range].smart-range::-webkit-slider-thumb { -webkit-appearance:none; w .theme-dark .asset-grid { scrollbar-color:rgba(148,163,184,.34) transparent; } .theme-dark .asset-grid::-webkit-scrollbar-thumb { background:rgba(148,163,184,.32); background-clip:content-box; } .theme-dark .asset-grid::-webkit-scrollbar-thumb:hover { background:rgba(203,213,225,.54); background-clip:content-box; } + +/* --- AI Agent 面板 --- */ +.agent-toggle { position:absolute; right:454px; top:22px; z-index:56; height:40px; padding:0 16px; border-radius:999px; background:var(--panel); color:var(--text); border:1px solid var(--line); box-shadow:0 14px 34px var(--shadow); backdrop-filter:blur(16px); display:flex; align-items:center; justify-content:center; gap:8px; font-size:12px; font-weight:750; white-space:nowrap; transition:transform .14s ease, border-color .14s ease; } +.agent-toggle:hover { border-color:var(--text); transform:translateY(-1px); } +.agent-toggle.active { background:var(--strong); color:var(--strong-text); border-color:var(--strong); box-shadow:0 16px 38px var(--shadow); } +.agent-toggle i,.agent-toggle svg { width:16px; height:16px; } +.agent-panel { position:absolute; right:22px; top:66px; bottom:22px; z-index:55; width:360px; max-width:calc(100vw - 44px); min-height:0; display:flex; flex-direction:column; gap:10px; padding:12px; border-radius:18px; background:var(--panel); border:1px solid var(--line); box-shadow:0 22px 58px var(--shadow); backdrop-filter:blur(20px); transform:translateX(16px); opacity:0; visibility:hidden; pointer-events:none; transition:opacity .16s ease, transform .16s ease, visibility .16s ease; } +.agent-panel.open { opacity:1; visibility:visible; pointer-events:auto; transform:translateX(0); } + +/* --- 灵感库(画布左侧提示词库) --- */ +.inspire-toggle { position:absolute; right:574px; top:22px; z-index:57; height:40px; padding:0 16px; border-radius:999px; background:var(--panel); color:var(--text); border:1px solid var(--line); box-shadow:0 14px 34px var(--shadow); backdrop-filter:blur(16px); display:flex; align-items:center; justify-content:center; gap:8px; font-size:12px; font-weight:750; white-space:nowrap; transition:transform .14s ease, border-color .14s ease; } +.inspire-toggle:hover { border-color:var(--text); transform:translateY(-1px); } +.inspire-toggle.active { background:var(--strong); color:var(--strong-text); border-color:var(--strong); box-shadow:0 16px 38px var(--shadow); } +.inspire-toggle i,.inspire-toggle svg { width:16px; height:16px; } +.inspire-panel { position:absolute; left:0; top:0; bottom:0; z-index:58; width:40vw; max-width:680px; min-width:340px; display:flex; flex-direction:column; gap:10px; padding:14px; background:var(--panel); border-right:1px solid var(--line); box-shadow:18px 0 50px var(--shadow); backdrop-filter:blur(20px); transform:translateX(-102%); opacity:0; visibility:hidden; pointer-events:none; transition:opacity .2s ease, transform .22s cubic-bezier(.4,0,.2,1), visibility .2s ease; } +.inspire-panel.open { opacity:1; visibility:visible; pointer-events:auto; transform:translateX(0); } +.inspire-head { display:flex; align-items:center; gap:8px; } +.inspire-head .asset-title { flex:0 0 auto; } +.inspire-count { flex:1 1 auto; font-size:11px; color:var(--faint); font-weight:600; } +.inspire-search-wrap { position:relative; display:flex; align-items:center; } +.inspire-search-btn { position:absolute; left:6px; width:26px; height:26px; display:flex; align-items:center; justify-content:center; border:none; background:transparent; color:var(--faint); cursor:pointer; border-radius:7px; transition:all .14s ease; z-index:1; } +.inspire-search-btn:hover { color:var(--strong); background:var(--soft); } +.inspire-search-btn i,.inspire-search-btn svg { width:15px; height:15px; } +.inspire-search { width:100%; height:38px; padding:0 12px 0 34px; border-radius:11px; border:1px solid var(--line); background:var(--card); color:var(--text); font-size:12.5px; outline:none; transition:border-color .14s ease; } +.inspire-search:focus { border-color:var(--strong); } +.inspire-cats { display:flex; flex-wrap:wrap; gap:6px; padding-bottom:2px; flex:0 0 auto; } +.inspire-cats::-webkit-scrollbar { display:none; } +.inspire-cat { flex:0 0 auto; height:27px; padding:0 12px; border-radius:999px; border:1px solid var(--line); background:var(--card); color:var(--muted); font-size:11px; font-weight:700; cursor:pointer; white-space:nowrap; transition:all .13s ease; } +.inspire-cat:hover { color:var(--text); border-color:var(--strong); } +.inspire-cat.active { background:var(--strong); color:var(--strong-text); border-color:var(--strong); } +.inspire-cat.more { border-style:dashed; color:var(--faint); } +.inspire-more-tags { flex:0 0 auto; border:1px solid var(--line); background:var(--card); border-radius:12px; padding:10px 10px 4px; margin-top:2px; max-height:264px; overflow-y:auto; scrollbar-width:thin; } +.inspire-more-tags[hidden] { display:none; } +.inspire-mt-group { margin-bottom:9px; } +.inspire-mt-name { font-size:10px; font-weight:800; color:var(--faint); margin-bottom:6px; letter-spacing:.5px; } +.inspire-mt-tags { display:flex; flex-wrap:wrap; gap:5px; } +.inspire-mt-tag { height:24px; padding:0 10px; border-radius:999px; border:1px solid var(--line); background:var(--soft); color:var(--muted); font-size:11px; font-weight:600; cursor:pointer; white-space:nowrap; transition:all .13s ease; } +.inspire-mt-tag:hover { color:var(--text); border-color:var(--strong); } +.inspire-mt-tag.active { background:var(--strong); color:var(--strong-text); border-color:var(--strong); } +.inspire-scroll { flex:1 1 auto; min-height:0; overflow-y:auto; overscroll-behavior:contain; margin:0 -6px; padding:0 6px; scrollbar-width:thin; scrollbar-color:rgba(148,163,184,.42) transparent; } +.inspire-grid { column-count:3; column-gap:4px; } +@media (max-width:1100px){ .inspire-grid { column-count:2; } } +.inspire-card { position:relative; break-inside:avoid; margin-bottom:4px; overflow:hidden; background:var(--soft); cursor:pointer; line-height:0; } +.inspire-card img { width:100%; display:block; opacity:0; transition:opacity .3s ease; } +.inspire-card img.loaded { opacity:1; } +.inspire-card-ph { width:100%; background:var(--soft); animation:inspirePulse 1.2s ease-in-out infinite; } +@keyframes inspirePulse { 0%,100%{opacity:.55} 50%{opacity:.9} } +.inspire-overlay { position:absolute; inset:0; display:flex; flex-direction:column; justify-content:flex-end; gap:8px; padding:10px; background:linear-gradient(to top, rgba(0,0,0,.86) 0%, rgba(0,0,0,.62) 45%, rgba(0,0,0,.28) 100%); opacity:0; transition:opacity .18s ease; line-height:1.5; } +.inspire-card:hover .inspire-overlay { opacity:1; } +.inspire-overlay-title { color:#fff; font-size:12px; font-weight:800; line-height:1.4; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; } +.inspire-overlay-author { color:rgba(255,255,255,.7); font-size:9.5px; font-weight:700; margin-bottom:2px; } +.inspire-overlay-prompt { color:rgba(255,255,255,.92); font-size:10.5px; line-height:1.55; display:-webkit-box; -webkit-line-clamp:5; -webkit-box-orient:vertical; overflow:hidden; } +.inspire-overlay-btn { align-self:flex-start; display:inline-flex; align-items:center; gap:5px; height:28px; padding:0 12px; border-radius:8px; border:none; background:#fff; color:#111; font-size:11px; font-weight:800; cursor:pointer; transition:transform .12s ease, background .12s ease; } +.inspire-overlay-btn:hover { transform:translateY(-1px); background:#f1f5f9; } +.inspire-overlay-btn i,.inspire-overlay-btn svg { width:13px; height:13px; } +.inspire-loader { display:flex; align-items:center; justify-content:center; gap:8px; padding:18px 0; color:var(--muted); font-size:11px; font-weight:600; } +.inspire-empty { padding:40px 0; text-align:center; color:var(--faint); font-size:12px; } +.inspire-gallery { display:flex; flex-direction:column; gap:10px; flex:1 1 auto; min-height:0; } +.inspire-config { flex:1 1 auto; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:14px; padding:28px 24px; text-align:center; } +.inspire-config[hidden] { display:none; } +.inspire-config-icon { width:56px; height:56px; border-radius:16px; background:var(--soft); border:1px solid var(--line); display:flex; align-items:center; justify-content:center; color:var(--strong); } +.inspire-config-icon i,.inspire-config-icon svg { width:26px; height:26px; } +.inspire-config-title { font-size:15px; font-weight:800; color:var(--text); } +.inspire-config-desc { font-size:11.5px; line-height:1.7; color:var(--muted); max-width:300px; } +.inspire-key-input { width:100%; max-width:300px; height:40px; padding:0 14px; border-radius:11px; border:1px solid var(--line); background:var(--card); color:var(--text); font-size:12.5px; outline:none; transition:border-color .14s ease; } +.inspire-key-input:focus { border-color:var(--strong); } +.inspire-getkey-btn { display:inline-flex; align-items:center; gap:6px; height:34px; padding:0 16px; border-radius:999px; border:1px solid var(--line); background:var(--card); color:var(--text); font-size:12px; font-weight:700; cursor:pointer; transition:all .14s ease; } +.inspire-getkey-btn:hover { border-color:var(--strong); color:var(--strong); } +.inspire-getkey-btn i,.inspire-getkey-btn svg { width:13px; height:13px; } +.inspire-savekey-btn { height:38px; padding:0 28px; border-radius:999px; border:none; background:var(--strong); color:var(--strong-text); font-size:13px; font-weight:800; cursor:pointer; transition:opacity .14s ease, transform .14s ease; } +.inspire-savekey-btn:hover { opacity:.88; transform:translateY(-1px); } +.inspire-config-tip { font-size:10px; color:var(--faint); } +.inspire-end { padding:18px 0; text-align:center; color:var(--faint); font-size:11px; } +.agent-skill-zone { display:flex; flex-direction:column; gap:6px; } +.agent-skill-drop { min-height:44px; border:1px dashed var(--line); border-radius:13px; display:flex; align-items:center; justify-content:center; gap:7px; text-align:center; padding:8px; color:var(--faint); font-size:10.5px; font-weight:600; background:var(--soft); cursor:pointer; transition:border-color .14s ease, color .14s ease, background .14s ease; } +.agent-skill-drop:hover { border-color:var(--strong); color:var(--text); } +.agent-skill-drop.drag-over { border-color:var(--strong); color:var(--text); background:var(--card); } +.agent-skill-drop i,.agent-skill-drop svg { width:14px; height:14px; } +.agent-skill-card { display:flex; align-items:center; gap:8px; padding:8px 10px; border-radius:12px; border:1px solid var(--line); background:var(--card); } +.agent-skill-card > i,.agent-skill-card > svg { width:15px; height:15px; color:var(--strong); flex:0 0 auto; } +.agent-skill-meta { flex:1; min-width:0; } +.agent-skill-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:11px; font-weight:750; color:var(--text); } +.agent-skill-size { font-size:9.5px; font-weight:600; color:var(--faint); } +.agent-models { display:flex; flex-direction:column; gap:6px; padding:9px; border-radius:13px; background:var(--soft); border:1px solid var(--line); } +.agent-model-row { display:flex; align-items:center; gap:5px; } +.agent-model-label { flex:0 0 52px; font-size:10px; font-weight:750; color:var(--muted); } +.agent-select { min-width:0; flex:1; height:28px; border-radius:9px; border:1px solid var(--line); background:var(--card); color:var(--text); padding:0 6px; font-size:10.5px; outline:none; } +.agent-select:focus { border-color:var(--strong); } +.agent-autocontext { display:flex; align-items:center; gap:6px; font-size:10px; font-weight:650; color:var(--muted); cursor:pointer; user-select:none; } +.agent-autocontext input { accent-color:var(--strong); } +.agent-messages { min-height:0; flex:1 1 auto; overflow-x:hidden; overflow-y:auto; overscroll-behavior:contain; display:flex; flex-direction:column; gap:10px; padding:4px 4px 8px 2px; scrollbar-width:thin; scrollbar-color:rgba(148,163,184,.42) transparent; } +.agent-messages::-webkit-scrollbar { width:7px; } +.agent-messages::-webkit-scrollbar-thumb { background:rgba(148,163,184,.42); border-radius:999px; border:2px solid transparent; background-clip:content-box; } +.agent-chat-list-panel { position:absolute; top:46px; left:12px; right:12px; max-height:300px; overflow-y:auto; border-radius:13px; border:1px solid var(--line); background:var(--card); box-shadow:0 8px 32px rgba(15,23,42,.14); z-index:70; padding:6px; display:flex; flex-direction:column; gap:2px; } +.agent-chat-list-panel[hidden] { display:none; } +.agent-chat-item { display:flex; align-items:center; gap:8px; padding:8px 10px; border-radius:9px; border:none; background:none; color:var(--text); font-size:10.5px; font-weight:650; cursor:pointer; text-align:left; transition:background .14s ease; } +.agent-chat-item:hover { background:var(--soft); } +.agent-chat-item.active { background:var(--soft); font-weight:750; } +.agent-chat-item-title { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.agent-chat-item-time { flex:0 0 auto; font-size:9px; color:var(--faint); } +.agent-chat-item-delete { flex:0 0 auto; width:20px; height:20px; border-radius:6px; border:none; background:none; color:var(--faint); cursor:pointer; display:flex; align-items:center; justify-content:center; padding:0; } +.agent-chat-item-delete:hover { color:#ef4444; background:rgba(239,68,68,.1); } +.agent-chat-item-delete i,.agent-chat-item-delete svg { width:11px; height:11px; } +.agent-chat-empty { padding:16px; text-align:center; font-size:10.5px; color:var(--faint); } + +/* 消息选项按钮 */ +.agent-msg-options { display:flex; flex-direction:column; gap:6px; margin-top:6px; align-items:stretch; } +.agent-msg-option-btn { min-height:28px; height:auto; padding:6px 12px; border-radius:8px; border:1px solid var(--line); background:var(--card); color:var(--text); font-size:10.5px; font-weight:700; cursor:pointer; text-align:left; white-space:normal; word-break:break-word; line-height:1.4; transition:border-color .14s ease, background .14s ease; } +.agent-msg-option-btn:hover { border-color:var(--strong); background:var(--soft); } + +/* 思维模式开关按钮 */ +.agent-thinking-btn { + display:inline-flex; align-items:center; gap:3px; + height:28px; padding:0 8px; border-radius:8px; border:none; + background:var(--soft); color:var(--muted); + font-size:10.5px; font-weight:700; cursor:pointer; position:relative; + transition:all .14s ease; white-space:nowrap; +} +.agent-thinking-btn:hover { color:var(--text); background:var(--line); } +.agent-thinking-btn .agent-thinking-icon { width:14px; height:14px; flex-shrink:0; } +.agent-thinking-btn .agent-thinking-label { line-height:1; } +.agent-thinking-btn.active { + background:var(--strong); color:var(--strong-text); border-color:var(--strong); +} +.agent-thinking-btn.active .agent-thinking-icon { color:var(--strong-text); } +.agent-thinking-btn.active:hover { opacity:.88; } +.agent-thinking-tooltip { + position:absolute; bottom:calc(100% + 8px); right:0; + width:200px; + background:#1e293b; color:#f1f5f9; + padding:8px 12px; border-radius:8px; + text-align:left; white-space:normal; line-height:1.5; + pointer-events:none; opacity:0; visibility:hidden; + transform:translateY(4px); + transition:opacity .14s ease, transform .14s ease, visibility .14s ease; + z-index:9999; + box-shadow:0 4px 12px rgba(0,0,0,.25); +} +.agent-thinking-btn:hover .agent-thinking-tooltip { opacity:1; visibility:visible; transform:translateY(0); } +.agent-tooltip-title { font-size:12px; font-weight:700; } +.agent-tooltip-sub { font-size:10px; opacity:.7; margin-top:3px; } + +/* 思维模式提示词确认卡片 */ +.agent-prompt-card { + margin-top:6px; border:1px solid var(--line); border-radius:10px; + background:var(--card); overflow:hidden; + width:100%; min-width:0; box-sizing:border-box; +} +.agent-prompt-card-header { + padding:5px 10px; font-size:10px; font-weight:700; color:var(--muted); + background:var(--soft); display:flex; align-items:center; gap:4px; +} +.agent-prompt-card-body { + padding:8px 10px; font-size:11px; line-height:1.5; color:var(--text); + word-break:break-word; white-space:pre-wrap; + max-height:min(260px, 40vh); overflow-y:auto; + scrollbar-width:thin; scrollbar-color:rgba(148,163,184,.42) transparent; +} +.agent-prompt-card-actions { display:flex; gap:4px; padding:6px 8px; min-width:0; width:100%; box-sizing:border-box; } +.agent-prompt-btn { + flex:1 1 0; min-width:0; min-height:26px; padding:4px 6px; border-radius:6px; + border:1px solid var(--line); background:var(--card); color:var(--text); + font-size:10px; font-weight:650; cursor:pointer; text-align:center; + transition:border-color .14s ease, background .14s ease; white-space:nowrap; + overflow:hidden; text-overflow:ellipsis; +} +.agent-prompt-btn:hover { border-color:var(--muted); background:var(--soft); } +.agent-prompt-btn.primary { background:var(--strong); color:var(--strong-text); border-color:var(--strong); } +.agent-prompt-btn.primary:hover { opacity:.88; } +.agent-prompt-edit-area { + width:100%; min-width:0; box-sizing:border-box; min-height:60px; max-height:200px; margin:0; + padding:8px 10px; border:none; border-top:1px solid var(--line); + background:var(--card); color:var(--text); + font-size:11px; line-height:1.5; resize:vertical; + font-family:inherit; outline:none; word-break:break-word; white-space:pre-wrap; +} +.agent-prompt-edit-area:focus { background:var(--soft); } +.agent-prompt-list { display:flex; flex-direction:column; } +.agent-prompt-list-item { border-bottom:1px solid var(--line); } +.agent-prompt-list-item:last-child { border-bottom:none; } +.agent-prompt-list-item.confirmed, .agent-prompt-list-item.skipped { cursor:pointer; opacity:.7; transition:opacity .14s ease; } +.agent-prompt-list-item.confirmed:hover, .agent-prompt-list-item.skipped:hover { opacity:1; } +.agent-prompt-list-item.pending { opacity:.45; } +.agent-prompt-list-header { + display:flex; align-items:center; gap:5px; padding:5px 10px; + font-size:10.5px; line-height:1.4; +} +.agent-prompt-list-icon { font-size:11px; font-weight:700; flex-shrink:0; } +.agent-prompt-list-item.confirmed .agent-prompt-list-icon { color:#16a34a; } +.agent-prompt-list-item.skipped .agent-prompt-list-icon { color:var(--faint); } +.agent-prompt-list-item.current .agent-prompt-list-icon { color:var(--strong); } +.agent-prompt-list-index { font-weight:650; color:var(--muted); flex-shrink:0; font-size:9.5px; } +.agent-prompt-list-text { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--text); } +.agent-prompt-item-actions { display:flex; gap:4px; padding:4px 8px 6px; min-width:0; width:100%; box-sizing:border-box; } +.agent-prompt-card-footer { display:flex; gap:4px; padding:6px 8px; border-top:1px solid var(--line); background:var(--soft); min-width:0; width:100%; box-sizing:border-box; } +.agent-prompt-confirm-all { flex:2; } +.agent-prompt-cancel-all { flex:1; } +.agent-msg-free-hint { margin-top:4px; font-size:9.5px; opacity:.45; } + +/* 消息操作按钮 */ +.agent-msg-actions { display:flex; gap:2px; opacity:0; transition:opacity .14s ease; } +.agent-msg:hover .agent-msg-actions { opacity:1; } +.agent-msg-action-btn { width:22px; height:22px; border-radius:6px; border:none; background:none; color:var(--faint); cursor:pointer; display:flex; align-items:center; justify-content:center; padding:0; transition:color .14s ease, background .14s ease; } +.agent-msg-action-btn:hover { color:var(--text); background:var(--soft); } +.agent-msg-action-btn i,.agent-msg-action-btn svg { width:11px; height:11px; } + +.agent-empty { margin:auto; text-align:center; color:var(--faint); font-size:11px; line-height:1.7; padding:20px 12px; } +.agent-empty i,.agent-empty svg { width:26px; height:26px; display:block; margin:0 auto 8px; } +.agent-msg { display:flex; flex-direction:column; gap:5px; max-width:88%; min-width:0; } +.agent-msg.user { align-self:flex-end; align-items:flex-end; } +.agent-msg.assistant { align-self:flex-start; align-items:flex-start; } +.agent-msg-bubble { padding:8px 11px; border-radius:14px; font-size:11.5px; line-height:1.55; white-space:pre-wrap; word-break:break-word; } +.agent-msg.user .agent-msg-bubble { background:var(--strong); color:var(--strong-text); border-bottom-right-radius:5px; } +.agent-msg.assistant .agent-msg-bubble { background:var(--card); color:var(--text); border:1px solid var(--line); border-bottom-left-radius:5px; } +.agent-msg-thumbs { display:grid; grid-template-columns:repeat(3, 1fr); gap:5px; max-width:240px; } +.agent-msg-thumbs img { width:100%; aspect-ratio:1/1; object-fit:cover; border-radius:9px; border:1px solid var(--line); cursor:pointer; display:block; } +.agent-gen-thumb-wrap { position:relative; } +.agent-gen-img-num { position:absolute; top:3px; left:3px; min-width:15px; height:15px; border-radius:4px; background:var(--strong); color:#fff; font-size:9px; font-weight:600; line-height:15px; text-align:center; padding:0 3px; z-index:2; pointer-events:none; } +.agent-gen-card { width:250px; max-width:100%; border-radius:12px; border:1px solid var(--line); background:var(--card); padding:8px 10px; display:flex; flex-direction:column; gap:6px; } +.agent-gen-prompt { font-size:10px; color:var(--muted); line-height:1.45; position:relative; } +.agent-gen-prompt-collapsed { display:-webkit-box; -webkit-line-clamp:5; -webkit-box-orient:vertical; overflow:hidden; } +.agent-gen-prompt-expanded { /* 展开后不截断 */ } +.agent-gen-prompt-toggle { display:inline-block; margin-left:4px; padding:0 2px; border:none; background:none; color:var(--strong); font-size:10px; font-weight:700; cursor:pointer; vertical-align:baseline; white-space:nowrap; } +.agent-gen-prompt-toggle:hover { text-decoration:underline; } +.agent-gen-status { display:flex; align-items:center; gap:6px; font-size:10px; font-weight:750; color:var(--muted); } +.agent-gen-status.error { color:#e11d48; } +.agent-gen-status.done { color:#059669; } +.agent-gen-spinner { width:12px; height:12px; border-radius:50%; border:2px solid var(--line); border-top-color:var(--strong); animation:agentSpin .8s linear infinite; flex:0 0 auto; } +@keyframes agentSpin { to { transform:rotate(360deg); } } + +/* 生成后快捷操作栏 */ +.agent-gen-quick-actions { display:flex; gap:4px; padding-top:4px; border-top:1px solid var(--line); } +.agent-quick-btn { display:inline-flex; align-items:center; gap:3px; height:24px; padding:0 8px; border-radius:6px; border:1px solid var(--line); background:var(--card); color:var(--muted); font-size:10px; font-weight:600; cursor:pointer; transition:all .14s ease; white-space:nowrap; } +.agent-quick-btn:hover { color:var(--text); border-color:var(--strong); background:var(--soft); } +.agent-quick-btn.primary { background:var(--strong); color:var(--strong-text); border-color:var(--strong); } +.agent-quick-btn.primary:hover { opacity:.85; } +.agent-quick-btn i,.agent-quick-btn svg { width:11px; height:11px; } + +/* 变体数量选择气泡 */ +.agent-variant-wrap { position:relative; display:inline-flex; } +.agent-variant-pop { position:absolute; bottom:calc(100% + 6px); left:50%; transform:translateX(-50%); display:none; gap:2px; padding:4px 6px; border-radius:8px; border:1px solid var(--line); background:var(--card); box-shadow:0 4px 12px rgba(0,0,0,.12); z-index:20; white-space:nowrap; } +.agent-variant-pop.open { display:flex; } +.agent-variant-opt { display:inline-flex; align-items:center; justify-content:center; min-width:28px; height:24px; padding:0 6px; border-radius:6px; border:1px solid var(--line); background:var(--soft); color:var(--text); font-size:11px; font-weight:700; cursor:pointer; transition:all .12s ease; } +.agent-variant-opt:hover { background:var(--strong); color:var(--strong-text); border-color:var(--strong); } + +/* 分析结果卡片 */ +.agent-analysis-card { width:100%; max-width:320px; border-radius:12px; border:1px solid var(--line); background:var(--card); overflow:hidden; } +.agent-analysis-body { padding:10px 12px; font-size:11px; line-height:1.6; color:var(--text); white-space:pre-wrap; word-break:break-word; max-height:200px; overflow-y:auto; } +.agent-analysis-actions { display:flex; gap:6px; padding:8px 10px; border-top:1px solid var(--line); background:var(--soft); } + +/* 提示词建议卡片 */ +.agent-prompt-suggest-card { width:100%; max-width:320px; border-radius:12px; border:1px solid var(--line); background:var(--card); overflow:hidden; } +.agent-prompt-suggest-body { padding:10px 12px; font-size:11px; line-height:1.6; color:var(--text); white-space:pre-wrap; word-break:break-word; font-family:'SF Mono',Menlo,monospace; max-height:200px; overflow-y:auto; background:var(--soft); } + +/* 降级提示条 */ +.agent-degrade-notice { display:flex; align-items:center; gap:6px; padding:6px 10px; border-radius:8px; background:#fef3c7; border:1px solid #f59e0b; font-size:10px; color:#92400e; margin-bottom:6px; } +.agent-degrade-notice i,.agent-degrade-notice svg { width:12px; height:12px; flex-shrink:0; } + +/* 流式输出气泡 */ +.agent-stream-bubble { position:relative; } +.agent-stream-body { font-size:11.5px; line-height:1.6; white-space:pre-wrap; word-break:break-word; max-height:200px; overflow-y:auto; scroll-behavior:smooth; } +.agent-stream-stop { position:absolute; top:4px; right:4px; width:20px; height:20px; border-radius:5px; border:1px solid var(--line); background:var(--card); color:var(--muted); font-size:9px; cursor:pointer; display:flex; align-items:center; justify-content:center; transition:all .14s ease; } +.agent-stream-stop:hover { color:#e11d48; border-color:#e11d48; background:#fef2f2; } + +.agent-input-area { display:flex; flex-direction:column; gap:0; position:relative; } +.agent-onebox { border:1px solid var(--line); border-radius:16px; background:var(--card); transition:border-color .14s ease; } +.agent-onebox:focus-within { border-color:var(--strong); } +.agent-attach-row { display:flex; flex-wrap:wrap; gap:6px; padding:8px 10px 0; } +.agent-attach-row:empty { display:none; } +.agent-attach-chip { position:relative; width:44px; height:44px; border-radius:10px; overflow:hidden; border:1px solid var(--line); cursor:pointer; transition:border-color .14s ease; } +.agent-attach-chip:hover { border-color:var(--strong); } +.agent-attach-chip img { width:100%; height:100%; object-fit:cover; display:block; } +.agent-att-num { position:absolute; top:2px; left:2px; min-width:14px; height:14px; border-radius:4px; background:var(--strong); color:#fff; font-size:9px; font-weight:600; line-height:14px; text-align:center; padding:0 3px; z-index:2; pointer-events:none; } +.agent-attach-chip button { position:absolute; top:2px; right:2px; width:15px; height:15px; border-radius:50%; background:rgba(15,23,42,.78); color:#fff; display:flex; align-items:center; justify-content:center; border:none; cursor:pointer; padding:0; opacity:0; transition:opacity .12s ease; } +.agent-attach-chip:hover button { opacity:1; } +.agent-attach-chip button i,.agent-attach-chip button svg { width:9px; height:9px; } +.agent-attach-chip.dragging { opacity:.4; } +.agent-attach-chip.drop-before { box-shadow:-2px 0 0 0 var(--strong); } +.agent-attach-chip.drop-after { box-shadow:2px 0 0 0 var(--strong); } +.agent-ref-confirm { position:absolute; bottom:100%; left:0; right:0; margin-bottom:6px; background:var(--card); border:1px solid var(--line); border-radius:14px; box-shadow:0 -4px 24px rgba(0,0,0,.12); z-index:50; max-height:280px; display:flex; flex-direction:column; } +.agent-ref-confirm[hidden] { display:none; } +.agent-ref-confirm-head { display:flex; align-items:center; justify-content:space-between; padding:10px 14px 6px; border-bottom:1px solid var(--line); } +.agent-ref-confirm-title { font-size:12px; font-weight:600; color:var(--text); } +.agent-ref-confirm-close { width:22px; height:22px; border-radius:6px; border:none; background:none; color:var(--muted); cursor:pointer; display:flex; align-items:center; justify-content:center; } +.agent-ref-confirm-close:hover { color:var(--text); background:var(--soft); } +.agent-ref-confirm-close i,.agent-ref-confirm-close svg { width:13px; height:13px; } +.agent-ref-confirm-body { flex:1; overflow-y:auto; padding:8px 14px; } +.agent-ref-confirm-task { display:flex; align-items:center; gap:8px; padding:6px 0; border-bottom:1px solid var(--line); } +.agent-ref-confirm-task:last-child { border-bottom:none; } +.agent-ref-confirm-task-num { min-width:20px; height:20px; border-radius:10px; background:var(--soft); color:var(--strong); font-size:10px; font-weight:600; line-height:20px; text-align:center; } +.agent-ref-confirm-task-refs { display:flex; gap:3px; flex-wrap:wrap; } +.agent-ref-confirm-task-ref { min-width:18px; height:18px; border-radius:9px; background:rgba(59,130,246,.12); color:#3b82f6; font-size:9px; font-weight:600; line-height:18px; text-align:center; padding:0 4px; } +.agent-ref-confirm-task-prompt { flex:1; font-size:10px; color:var(--muted); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.agent-ref-confirm-task-mode { font-size:9px; padding:1px 6px; border-radius:4px; font-weight:500; } +.agent-ref-confirm-task-mode.split { background:rgba(34,197,94,.12); color:#22c55e; } +.agent-ref-confirm-task-mode.combine { background:rgba(249,115,22,.12); color:#f97316; } +.agent-ref-confirm-task-mode.single { background:rgba(99,102,241,.12); color:#6366f1; } +.agent-ref-confirm-actions { display:flex; gap:8px; padding:8px 14px 10px; border-top:1px solid var(--line); } +.agent-ref-confirm-btn { flex:1; height:32px; border-radius:8px; border:none; font-size:12px; font-weight:500; cursor:pointer; transition:opacity .14s ease; } +.agent-ref-confirm-btn.primary { background:var(--strong); color:#fff; } +.agent-ref-confirm-btn.primary:hover { opacity:.88; } +.agent-ref-confirm-btn.secondary { background:var(--soft); color:var(--text); } +.agent-ref-confirm-btn.secondary:hover { opacity:.88; } +.agent-onebox textarea { display:block; width:100%; min-width:0; resize:none; max-height:200px; border:none; background:transparent; color:var(--text); padding:8px 12px; font-size:11.5px; line-height:1.5; outline:none; font-family:inherit; } +.agent-toolbar { display:flex; align-items:center; gap:4px; padding:4px 8px 6px; } +.agent-toolbar-icon { width:28px; height:28px; border-radius:8px; display:inline-flex; align-items:center; justify-content:center; border:none; background:none; color:var(--muted); cursor:pointer; transition:color .14s ease, background .14s ease; } +.agent-toolbar-icon:hover { color:var(--text); background:var(--soft); } +.agent-toolbar-icon i,.agent-toolbar-icon svg { width:14px; height:14px; } +.agent-toolbar-dropdown-wrap { position:relative; } +.agent-toolbar-btn { display:inline-flex; align-items:center; gap:3px; height:28px; padding:0 8px; border-radius:8px; border:none; background:none; color:var(--muted); font-size:10.5px; font-weight:650; cursor:pointer; transition:color .14s ease, background .14s ease; white-space:nowrap; } +.agent-toolbar-btn:hover { color:var(--text); background:var(--soft); } +.agent-toolbar-btn i,.agent-toolbar-btn svg { width:11px; height:11px; } +.agent-toolbar-spacer { flex:1; } +.agent-dropdown-panel { position:fixed; min-width:200px; max-width:calc(100vw - 60px); border-radius:13px; border:1px solid var(--line); background:var(--card); box-shadow:0 -4px 24px rgba(15,23,42,.12); z-index:9999; padding:8px; display:flex; flex-direction:column; gap:6px; } +.agent-dropdown-panel[hidden] { display:none; } +.agent-dropdown-row { display:flex; align-items:center; gap:6px; } +.agent-dropdown-row label { flex:0 0 36px; font-size:10px; font-weight:750; color:var(--muted); } +.agent-dropdown-selects { flex:1; display:flex; gap:4px; } +.agent-dropdown-selects select { flex:1; min-width:0; height:26px; border-radius:8px; border:1px solid var(--line); background:var(--soft); color:var(--text); font-size:10px; font-weight:650; padding:0 6px; outline:none; } +.agent-dropdown-selects select:focus { border-color:var(--strong); } + +/* 比例图标 */ +.agent-ratio-grid { display:grid; grid-template-columns:repeat(3, 1fr); gap:5px; } +.agent-ratio-btn { display:flex; flex-direction:column; align-items:center; justify-content:center; gap:4px; height:52px; border-radius:9px; border:1px solid var(--line); background:var(--card); color:var(--muted); font-size:9.5px; font-weight:700; cursor:pointer; transition:border-color .14s ease, color .14s ease, background .14s ease; padding:6px 4px; } +.agent-ratio-btn:hover { border-color:var(--strong); color:var(--text); } +.agent-ratio-btn.active { border-color:var(--strong); background:var(--soft); color:var(--text); } +.agent-ratio-icon { display:block; width:20px; max-height:24px; border:1.5px solid currentColor; border-radius:3px; } +.agent-res-grid { display:grid; grid-template-columns:repeat(4, 1fr); gap:5px; } +.agent-res-btn { height:30px; border-radius:9px; border:1px solid var(--line); background:var(--card); color:var(--muted); font-size:9.5px; font-weight:700; cursor:pointer; transition:border-color .14s ease, color .14s ease, background .14s ease; } +.agent-res-btn:hover { border-color:var(--strong); color:var(--text); } +.agent-res-btn.active { border-color:var(--strong); background:var(--soft); color:var(--text); } + +/* 折叠菜单 */ +.agent-more-wrap { position:relative; } +.agent-more-panel { position:absolute; top:100%; right:0; margin-top:4px; min-width:140px; border-radius:10px; border:1px solid var(--line); background:var(--card); box-shadow:0 8px 32px rgba(15,23,42,.14); z-index:80; padding:4px; display:flex; flex-direction:column; gap:2px; } +.agent-more-panel[hidden] { display:none; } +.agent-more-item { display:flex; align-items:center; gap:6px; width:100%; padding:7px 10px; border-radius:7px; border:none; background:none; color:var(--text); font-size:10.5px; font-weight:650; cursor:pointer; text-align:left; transition:background .14s ease; } +.agent-more-item:hover { background:var(--soft); } +.agent-more-item i,.agent-more-item svg { width:13px; height:13px; color:var(--faint); } + +/* 参数面板(OneBox 风格) */ +.agent-params-panel { min-width:280px; padding:12px; gap:14px; } +.agent-params-section { display:flex; flex-direction:column; gap:6px; } +.agent-params-title { font-size:10px; font-weight:750; color:var(--muted); } +.agent-quality-seg { display:flex; border-radius:10px; background:var(--soft); border:1px solid var(--line); overflow:hidden; } +.agent-quality-btn { flex:1; height:30px; border:none; background:none; color:var(--muted); font-size:10.5px; font-weight:700; cursor:pointer; transition:background .14s ease, color .14s ease; } +.agent-quality-btn.active { background:var(--card); color:var(--text); box-shadow:0 1px 4px rgba(15,23,42,.1); border-radius:9px; margin:1px; } +.agent-quality-btn:not(.active):hover { color:var(--text); } +.agent-size-grid { display:grid; grid-template-columns:repeat(4, 1fr); gap:5px; } +.agent-size-btn { height:34px; border-radius:9px; border:1px solid var(--line); background:var(--card); color:var(--muted); font-size:9.5px; font-weight:700; cursor:pointer; transition:border-color .14s ease, color .14s ease, background .14s ease; display:flex; align-items:center; justify-content:center; } +.agent-size-btn:hover { border-color:var(--strong); color:var(--text); } +.agent-size-btn.active { border-color:var(--strong); background:var(--soft); color:var(--text); } +.agent-count-grid { display:grid; grid-template-columns:repeat(4, 1fr); gap:5px; } +.agent-count-btn { height:30px; border-radius:9px; border:1px solid var(--line); background:var(--card); color:var(--muted); font-size:9.5px; font-weight:700; cursor:pointer; transition:border-color .14s ease, color .14s ease, background .14s ease; } +.agent-count-btn:hover { border-color:var(--strong); color:var(--text); } +.agent-count-btn.active { border-color:var(--strong); background:var(--soft); color:var(--text); } +.agent-send-btn { flex:0 0 auto; width:28px; height:28px; border-radius:9px; background:var(--strong); color:var(--strong-text); display:inline-flex; align-items:center; justify-content:center; border:none; cursor:pointer; } +.agent-send-btn:disabled { opacity:.45; cursor:not-allowed; } +.agent-send-btn i,.agent-send-btn svg { width:13px; height:13px; } +.agent-panel.drag-over-input .agent-onebox { border-color:var(--strong); } + +/* 图片附件引用(OneBox 风格:缩略图+名称+点击跳转) */ +.agent-attach-ref { position:relative; display:flex; align-items:center; gap:6px; height:32px; padding:0 24px 0 4px; border-radius:8px; border:1px solid var(--line); background:var(--card); max-width:160px; cursor:pointer; transition:border-color .14s ease, background .14s ease; } +.agent-attach-ref:hover { border-color:var(--strong); background:var(--soft); } +.agent-attach-ref img { width:24px; height:24px; border-radius:6px; object-fit:cover; flex:0 0 auto; } +.agent-attach-ref-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:10px; font-weight:650; color:var(--text); } +.agent-attach-ref button { position:absolute; top:2px; right:2px; width:14px; height:14px; border-radius:50%; background:rgba(15,23,42,.78); color:#fff; display:flex; align-items:center; justify-content:center; border:none; cursor:pointer; padding:0; } +.agent-attach-ref button i,.agent-attach-ref button svg { width:8px; height:8px; } + +/* Skill 附件卡片(在附件行中) */ +.agent-attach-skill { position:relative; display:flex; align-items:center; gap:6px; height:44px; padding:0 28px 0 8px; border-radius:10px; border:1px solid var(--line); background:var(--card); max-width:160px; } +.agent-attach-skill > i,.agent-attach-skill > svg { width:14px; height:14px; color:var(--strong); flex:0 0 auto; } +.agent-attach-skill-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:10px; font-weight:700; color:var(--text); } +.agent-attach-skill button { position:absolute; top:2px; right:2px; width:15px; height:15px; border-radius:50%; background:rgba(15,23,42,.78); color:#fff; display:flex; align-items:center; justify-content:center; border:none; cursor:pointer; padding:0; } +.agent-attach-skill button i,.agent-attach-skill button svg { width:9px; height:9px; } + +/* @ 引用面板 */ +.agent-mention-panel { position:absolute; bottom:100%; left:0; right:0; max-height:240px; overflow-y:auto; border-radius:13px; border:1px solid var(--line); background:var(--card); box-shadow:0 -4px 24px rgba(15,23,42,.12); z-index:50; margin-bottom:4px; } +.agent-mention-panel[hidden] { display:none; } +.agent-mention-panel:empty { display:none; } +.agent-mention-item { display:flex; align-items:center; gap:8px; padding:7px 10px; cursor:pointer; border:none; background:none; width:100%; text-align:left; } +.agent-mention-item:hover,.agent-mention-item.active { background:var(--soft); } +.agent-mention-item img { width:32px; height:32px; border-radius:7px; object-fit:cover; flex:0 0 auto; border:1px solid var(--line); } +.agent-mention-item-info { flex:1; min-width:0; } +.agent-mention-item-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:10.5px; font-weight:700; color:var(--text); } +.agent-mention-item-time { font-size:9px; font-weight:600; color:var(--faint); } +.agent-mention-empty { padding:12px; text-align:center; font-size:10.5px; color:var(--faint); } + +/* 输入框拖拽手柄 */ +.agent-input-resize { height:6px; cursor:ns-resize; display:flex; align-items:center; justify-content:center; border-radius:0 0 13px 13px; transition:background .14s ease; } +.agent-input-resize:hover,.agent-input-resize.dragging { background:var(--soft); } +.agent-input-resize::after { content:''; width:32px; height:3px; border-radius:999px; background:var(--line); transition:background .14s ease; } +.agent-input-resize:hover::after,.agent-input-resize.dragging::after { background:var(--strong); } +.agent-input-area { position:relative; } + .asset-item { min-width:0; align-self:start; border-radius:12px; border:1px solid var(--line); background:var(--card); overflow:hidden; cursor:grab; } .asset-item:active { cursor:grabbing; } .asset-thumb { width:100%; aspect-ratio:1/1; background:var(--soft); display:block; object-fit:cover; } diff --git a/static/enhance.html b/static/enhance.html index af5bd7b98..216523159 100644 --- a/static/enhance.html +++ b/static/enhance.html @@ -17,14 +17,14 @@ } catch(e) {} })(); - - - - - - + + + + + + - + diff --git a/static/gpt-chat.html b/static/gpt-chat.html index a5fc38955..58ba997db 100644 --- a/static/gpt-chat.html +++ b/static/gpt-chat.html @@ -16,13 +16,13 @@ } catch(e) {} })(); - - - - - + + + + + - + diff --git a/static/online.html b/static/online.html index 95c64929a..cb8b835c1 100644 --- a/static/online.html +++ b/static/online.html @@ -16,14 +16,14 @@ } catch(e) {} })(); - - - - - - + + + + + + - + - + diff --git "a/\345\256\211\350\243\205\345\215\263\346\242\246CLI.command" "b/\345\256\211\350\243\205\345\215\263\346\242\246CLI.command" old mode 100644 new mode 100755 diff --git "a/\347\231\273\345\275\225\345\215\263\346\242\246CLI.command" "b/\347\231\273\345\275\225\345\215\263\346\242\246CLI.command" old mode 100644 new mode 100755