mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-02 21:28:08 +00:00
Compare commits
22 Commits
feat/creat
...
uy/in-app-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5654c4edab | ||
|
|
ef57ee29ea | ||
|
|
b751e717b3 | ||
|
|
80b1c3cd71 | ||
|
|
462029b004 | ||
|
|
65021e2b8a | ||
|
|
e8d8ab412c | ||
|
|
7f8e7f7fb2 | ||
|
|
9182ef4948 | ||
|
|
a94b3d541b | ||
|
|
8ce6f6e234 | ||
|
|
b571db1897 | ||
|
|
c1b5a5166c | ||
|
|
11e0446bb8 | ||
|
|
e45a1bed17 | ||
|
|
ddb0a181ea | ||
|
|
927ba00e91 | ||
|
|
8a61e9aa72 | ||
|
|
636608664d | ||
|
|
499a706081 | ||
|
|
fb40f2fdb9 | ||
|
|
2c9cce86d7 |
30
.cursor/rules/agent-panel-layout.md
Normal file
30
.cursor/rules/agent-panel-layout.md
Normal file
@@ -0,0 +1,30 @@
|
||||
---
|
||||
description: Agent chat panel layout rule — always full viewport height, never nested under the header bar
|
||||
globs:
|
||||
- src/components/LiteGraphCanvasSplitterOverlay.vue
|
||||
- src/platform/agent/**
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Agent Panel Layout
|
||||
|
||||
The Comfy Agent chat panel must always span the **full viewport height** — from the very top of the screen to the bottom, alongside the header bar and canvas, not below them.
|
||||
|
||||
## Correct structure
|
||||
|
||||
`LiteGraphCanvasSplitterOverlay` uses a top-level **`flex-row`** so the agent panel is a sibling of the entire left column (tabs + canvas), not a child inside it:
|
||||
|
||||
```
|
||||
div.flex-row (viewport)
|
||||
├── div.flex-col.flex-1 ← left side: everything else
|
||||
│ ├── slot#workflow-tabs ← header bar
|
||||
│ └── div.flex-1 ← canvas + sidebar panels
|
||||
└── div.shrink-0 (agent panel) ← RIGHT: full viewport height
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
- **Never** place the agent panel inside the `div` that sits below `slot#workflow-tabs`. That causes the panel to start below the header bar.
|
||||
- The agent panel div must be a **direct child** of the outermost `div.flex-row` container in `LiteGraphCanvasSplitterOverlay.vue`.
|
||||
- The left side (`flex-1 flex-col`) wraps both `slot#workflow-tabs` AND the canvas/splitter row.
|
||||
- The agent panel has `h-full` and `shrink-0` so it fills the full height and does not flex-shrink.
|
||||
34
.cursor/rules/icon-button-tooltip.mdc
Normal file
34
.cursor/rules/icon-button-tooltip.mdc
Normal file
@@ -0,0 +1,34 @@
|
||||
---
|
||||
description: Icon buttons must always have a tooltip
|
||||
globs: src/**/*.vue
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Icon Button Tooltip Requirement
|
||||
|
||||
Every icon-only button (`size="icon"` or any button containing only an icon with no visible label) **must** be wrapped in a `Tooltip` so users can discover what it does.
|
||||
|
||||
## Required Pattern
|
||||
|
||||
```vue
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Button size="icon" border-interface-stroke" :aria-label="$t('...')">
|
||||
<i class="icon-[lucide--some-icon] size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{{ $t('...') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
```
|
||||
|
||||
## Imports
|
||||
|
||||
```ts
|
||||
import Tooltip from '@/components/ui/tooltip/Tooltip.vue'
|
||||
import TooltipContent from '@/components/ui/tooltip/TooltipContent.vue'
|
||||
import TooltipTrigger from '@/components/ui/tooltip/TooltipTrigger.vue'
|
||||
```
|
||||
|
||||
- Always use `side="top"` unless a different direction is needed for layout reasons.
|
||||
- The `aria-label` on the button and the tooltip text should be the same translated string.
|
||||
- Use `vue-i18n` (`$t(...)`) for the label — never hardcode strings.
|
||||
1
global.d.ts
vendored
1
global.d.ts
vendored
@@ -5,6 +5,7 @@ declare const __SENTRY_DSN__: string
|
||||
declare const __ALGOLIA_APP_ID__: string
|
||||
declare const __ALGOLIA_API_KEY__: string
|
||||
declare const __USE_PROD_CONFIG__: boolean
|
||||
declare const __GIT_BRANCH_PREFIX__: string
|
||||
|
||||
interface ImpactQueueFunction {
|
||||
(...args: unknown[]): void
|
||||
|
||||
@@ -120,6 +120,7 @@
|
||||
"primevue": "catalog:",
|
||||
"reka-ui": "catalog:",
|
||||
"semver": "^7.7.2",
|
||||
"shiki": "catalog:",
|
||||
"three": "catalog:",
|
||||
"tiptap-markdown": "^0.8.10",
|
||||
"typegpu": "catalog:",
|
||||
|
||||
77
pnpm-lock.yaml
generated
77
pnpm-lock.yaml
generated
@@ -321,6 +321,9 @@ catalogs:
|
||||
rollup-plugin-visualizer:
|
||||
specifier: ^6.0.4
|
||||
version: 6.0.4
|
||||
shiki:
|
||||
specifier: ^3.0.0
|
||||
version: 3.23.0
|
||||
storybook:
|
||||
specifier: ^10.2.10
|
||||
version: 10.2.10
|
||||
@@ -606,6 +609,9 @@ importers:
|
||||
semver:
|
||||
specifier: ^7.7.2
|
||||
version: 7.7.4
|
||||
shiki:
|
||||
specifier: 'catalog:'
|
||||
version: 3.23.0
|
||||
three:
|
||||
specifier: 'catalog:'
|
||||
version: 0.184.0
|
||||
@@ -3439,18 +3445,30 @@ packages:
|
||||
pinia:
|
||||
optional: true
|
||||
|
||||
'@shikijs/core@3.23.0':
|
||||
resolution: {integrity: sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==}
|
||||
|
||||
'@shikijs/core@4.1.0':
|
||||
resolution: {integrity: sha512-jLJtSJeuFffqX6/inRE1zqU5aFv2hrszvYgq3OjbAgFRZiWv7abKMDdQzYxuSDfmUPQozZvI/kuy6VMTvnvqTQ==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/engine-javascript@3.23.0':
|
||||
resolution: {integrity: sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==}
|
||||
|
||||
'@shikijs/engine-javascript@4.1.0':
|
||||
resolution: {integrity: sha512-YquhawCUgaBfhsS72e2Y/dI59gCBNPHu3fEO/tvLaXrTssxZrY5ddjtNLTwndrMgPo8b3IscE+xoICDzpTmlFQ==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/engine-oniguruma@3.23.0':
|
||||
resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==}
|
||||
|
||||
'@shikijs/engine-oniguruma@4.1.0':
|
||||
resolution: {integrity: sha512-axLpjVs45YBvvINa+dJF+NPW+KtFkNXsFr4SDw2BMj9GdeMnGxVB9PQb2xXlJYovslt/nz6giedAyOANkfc7hg==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/langs@3.23.0':
|
||||
resolution: {integrity: sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==}
|
||||
|
||||
'@shikijs/langs@4.1.0':
|
||||
resolution: {integrity: sha512-nwOMruEkbgdZfQ/b8CgpNBVOpvG1k0N5tbmgiFeqsan401+x3ILqlzZJowSla4Agmq4hG2Uf2wh5jLTEhR8VSg==}
|
||||
engines: {node: '>=20'}
|
||||
@@ -3459,10 +3477,16 @@ packages:
|
||||
resolution: {integrity: sha512-zx2/2Uwj2q9X3KSyYREEhXO23xBw5WUhP4orK2lE4r+t9JGITmEe0JH+wPmJhqHpOT2bRRs6lAL945+LDvOAGw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/themes@3.23.0':
|
||||
resolution: {integrity: sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==}
|
||||
|
||||
'@shikijs/themes@4.1.0':
|
||||
resolution: {integrity: sha512-emCcTnUM7yO2wltYbaxm+yLvcCI4+h8XBKc4KmJ7EZUXoSGjcCHifkI//R4OFit9ewpg7H2/9tjOuXrT2v/Knw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/types@3.23.0':
|
||||
resolution: {integrity: sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==}
|
||||
|
||||
'@shikijs/types@4.1.0':
|
||||
resolution: {integrity: sha512-3EQWX54fMpniOrDblzAhiwiJwpiTMW6+B9DWyUd9ska483tbayFYuw47UxwuPknI31bKnySfVQ/QW+jFL4rFdA==}
|
||||
engines: {node: '>=20'}
|
||||
@@ -7775,6 +7799,9 @@ packages:
|
||||
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
shiki@3.23.0:
|
||||
resolution: {integrity: sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==}
|
||||
|
||||
shiki@4.1.0:
|
||||
resolution: {integrity: sha512-l/ABZPUR5v70jI10EzqfMS/I96vjSGv2y0ihUV+WYFzv0EfvW4s54m0Lg8wCrrL+2IkwBzFTuxkZjPf8b2NX9Q==}
|
||||
engines: {node: '>=20'}
|
||||
@@ -8732,8 +8759,8 @@ packages:
|
||||
vue-component-type-helpers@3.3.2:
|
||||
resolution: {integrity: sha512-l4Z2Y34m7nFMlx8vrslJaVtXxUpzgDMSESC7TakG/c5kwjYT/do+E0NcT2/vWDzaoIhsShg/2OKwX7Q4nbzC0g==}
|
||||
|
||||
vue-component-type-helpers@3.3.5:
|
||||
resolution: {integrity: sha512-Fe1jyPJoUGpJOYKOri44jduR7My4yYINOMJISuMAbmrs+L5LbIDUc8NTWZYY3EJLK0yPLuCmcd5zoCsE4k2/KA==}
|
||||
vue-component-type-helpers@3.3.6:
|
||||
resolution: {integrity: sha512-FkljacAwJ9BUoSUdpFe3VDy0sGigNlTH9+2zcXUWmZOjN8swiCkl3t48wOJun0OsUd2cEIda1l04tsxMiKIIrQ==}
|
||||
|
||||
vue-demi@0.14.10:
|
||||
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
||||
@@ -11326,6 +11353,13 @@ snapshots:
|
||||
optionalDependencies:
|
||||
pinia: 3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3))
|
||||
|
||||
'@shikijs/core@3.23.0':
|
||||
dependencies:
|
||||
'@shikijs/types': 3.23.0
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.4
|
||||
hast-util-to-html: 9.0.5
|
||||
|
||||
'@shikijs/core@4.1.0':
|
||||
dependencies:
|
||||
'@shikijs/primitive': 4.1.0
|
||||
@@ -11334,17 +11368,32 @@ snapshots:
|
||||
'@types/hast': 3.0.4
|
||||
hast-util-to-html: 9.0.5
|
||||
|
||||
'@shikijs/engine-javascript@3.23.0':
|
||||
dependencies:
|
||||
'@shikijs/types': 3.23.0
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
oniguruma-to-es: 4.3.6
|
||||
|
||||
'@shikijs/engine-javascript@4.1.0':
|
||||
dependencies:
|
||||
'@shikijs/types': 4.1.0
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
oniguruma-to-es: 4.3.6
|
||||
|
||||
'@shikijs/engine-oniguruma@3.23.0':
|
||||
dependencies:
|
||||
'@shikijs/types': 3.23.0
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
|
||||
'@shikijs/engine-oniguruma@4.1.0':
|
||||
dependencies:
|
||||
'@shikijs/types': 4.1.0
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
|
||||
'@shikijs/langs@3.23.0':
|
||||
dependencies:
|
||||
'@shikijs/types': 3.23.0
|
||||
|
||||
'@shikijs/langs@4.1.0':
|
||||
dependencies:
|
||||
'@shikijs/types': 4.1.0
|
||||
@@ -11355,10 +11404,19 @@ snapshots:
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.4
|
||||
|
||||
'@shikijs/themes@3.23.0':
|
||||
dependencies:
|
||||
'@shikijs/types': 3.23.0
|
||||
|
||||
'@shikijs/themes@4.1.0':
|
||||
dependencies:
|
||||
'@shikijs/types': 4.1.0
|
||||
|
||||
'@shikijs/types@3.23.0':
|
||||
dependencies:
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.4
|
||||
|
||||
'@shikijs/types@4.1.0':
|
||||
dependencies:
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
@@ -11466,7 +11524,7 @@ snapshots:
|
||||
storybook: 10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
type-fest: 2.19.0
|
||||
vue: 3.5.34(typescript@5.9.3)
|
||||
vue-component-type-helpers: 3.3.5
|
||||
vue-component-type-helpers: 3.3.6
|
||||
|
||||
'@swc/helpers@0.5.21':
|
||||
dependencies:
|
||||
@@ -16507,6 +16565,17 @@ snapshots:
|
||||
|
||||
shebang-regex@3.0.0: {}
|
||||
|
||||
shiki@3.23.0:
|
||||
dependencies:
|
||||
'@shikijs/core': 3.23.0
|
||||
'@shikijs/engine-javascript': 3.23.0
|
||||
'@shikijs/engine-oniguruma': 3.23.0
|
||||
'@shikijs/langs': 3.23.0
|
||||
'@shikijs/themes': 3.23.0
|
||||
'@shikijs/types': 3.23.0
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.4
|
||||
|
||||
shiki@4.1.0:
|
||||
dependencies:
|
||||
'@shikijs/core': 4.1.0
|
||||
@@ -17637,7 +17706,7 @@ snapshots:
|
||||
|
||||
vue-component-type-helpers@3.3.2: {}
|
||||
|
||||
vue-component-type-helpers@3.3.5: {}
|
||||
vue-component-type-helpers@3.3.6: {}
|
||||
|
||||
vue-demi@0.14.10(vue@3.5.34(typescript@5.9.3)):
|
||||
dependencies:
|
||||
|
||||
@@ -116,6 +116,7 @@ catalog:
|
||||
primevue: ^4.2.5
|
||||
reka-ui: 2.5.0
|
||||
rollup-plugin-visualizer: ^6.0.4
|
||||
shiki: ^3.0.0
|
||||
storybook: ^10.2.10
|
||||
stylelint: ^16.26.1
|
||||
tailwindcss: ^4.3.0
|
||||
|
||||
@@ -1,5 +1,221 @@
|
||||
@import '@comfyorg/design-system/css/style.css';
|
||||
|
||||
/* Markdown prose styles for the agent chat, matching Figma DES-455 tokens */
|
||||
.agent-markdown h1,
|
||||
.agent-markdown h2,
|
||||
.agent-markdown p,
|
||||
.agent-markdown ol,
|
||||
.agent-markdown ul,
|
||||
.agent-markdown li,
|
||||
.agent-markdown table {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.agent-markdown h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
line-height: normal;
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.agent-markdown h2 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
padding-top: 0.875rem;
|
||||
padding-bottom: 0.375rem;
|
||||
}
|
||||
|
||||
.agent-markdown p {
|
||||
font-size: 0.875rem;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.agent-markdown p:has(> em:only-child) {
|
||||
padding-top: 1.25rem;
|
||||
}
|
||||
|
||||
.agent-markdown ol,
|
||||
.agent-markdown ul {
|
||||
font-size: 0.875rem;
|
||||
padding-left: 1.25rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.agent-markdown ol {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
.agent-markdown ul {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.agent-markdown strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.agent-markdown a {
|
||||
color: var(--color-primary-background);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.agent-markdown blockquote {
|
||||
margin: 0.5rem 0;
|
||||
padding: 0.375rem 0.875rem;
|
||||
border-left: 3px solid var(--color-border-default);
|
||||
color: var(--color-muted-foreground);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.agent-markdown table {
|
||||
width: 100%;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 0.5rem;
|
||||
border-collapse: collapse;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
background-color: var(--color-secondary-background);
|
||||
}
|
||||
|
||||
.agent-markdown th {
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
padding: 0.625rem 1rem;
|
||||
border-bottom: 1px solid var(--color-border-default);
|
||||
background-color: var(--color-secondary-background-hover);
|
||||
}
|
||||
|
||||
.agent-markdown td {
|
||||
padding: 0.625rem 1rem;
|
||||
border-bottom: 1px solid var(--color-border-default);
|
||||
}
|
||||
|
||||
.agent-markdown tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.agent-markdown > *:first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.agent-markdown > *:last-child {
|
||||
padding-bottom: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Scroll-driven fade mask for conversation containers.
|
||||
Top edge fades in as you scroll away from the start;
|
||||
bottom edge fades out when you reach the end. */
|
||||
@property --sf-top {
|
||||
syntax: '<length>';
|
||||
inherits: false;
|
||||
initial-value: 0;
|
||||
}
|
||||
|
||||
@property --sf-bottom {
|
||||
syntax: '<length>';
|
||||
inherits: false;
|
||||
initial-value: 40px;
|
||||
}
|
||||
|
||||
@keyframes sf-grow-top {
|
||||
from { --sf-top: 0; }
|
||||
to { --sf-top: 40px; }
|
||||
}
|
||||
|
||||
@keyframes sf-shrink-bottom {
|
||||
from { --sf-bottom: 40px; }
|
||||
to { --sf-bottom: 0; }
|
||||
}
|
||||
|
||||
.scroll-fade {
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0,
|
||||
black var(--sf-top),
|
||||
black calc(100% - var(--sf-bottom)),
|
||||
transparent 100%
|
||||
);
|
||||
animation: sf-grow-top linear both, sf-shrink-bottom linear both;
|
||||
animation-timeline: scroll(self y), scroll(self y);
|
||||
animation-range: 0 80px, calc(100% - 80px) 100%;
|
||||
}
|
||||
|
||||
.agent-code-block {
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.agent-code-block-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.375rem 0.75rem;
|
||||
background-color: var(--color-secondary-background-hover);
|
||||
border-bottom: 1px solid var(--color-border-default);
|
||||
}
|
||||
|
||||
.agent-code-block-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
color: var(--color-muted-foreground);
|
||||
font-size: 0.6875rem;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
}
|
||||
|
||||
.agent-code-block-filename {
|
||||
color: var(--color-base-foreground);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.agent-code-block-copy {
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: 0.25rem;
|
||||
color: var(--color-muted-foreground);
|
||||
cursor: pointer;
|
||||
font-size: 0.6875rem;
|
||||
font-family: inherit;
|
||||
padding: 0.125rem 0.5rem;
|
||||
line-height: 1.5;
|
||||
transition: background-color 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.agent-code-block-copy:hover {
|
||||
background-color: var(--color-secondary-background);
|
||||
color: var(--color-base-foreground);
|
||||
}
|
||||
|
||||
.agent-code-block pre {
|
||||
margin: 0;
|
||||
padding: 0.75rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.agent-code-block code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.6875rem;
|
||||
line-height: 1.6;
|
||||
color: var(--color-base-foreground);
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
|
||||
.agent-inline-code {
|
||||
background-color: var(--color-secondary-background-hover);
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: 0.25rem;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.875em;
|
||||
padding: 0.125rem 0.375rem;
|
||||
}
|
||||
|
||||
/* Use 0.001ms instead of 0s so transitionend/animationend events still fire
|
||||
and JS listeners aren't broken. */
|
||||
.disable-animations *,
|
||||
|
||||
@@ -1,133 +1,154 @@
|
||||
<template>
|
||||
<div
|
||||
class="pointer-events-none absolute top-0 left-0 z-999 flex size-full flex-col"
|
||||
class="pointer-events-none absolute top-0 left-0 z-999 flex size-full flex-row"
|
||||
>
|
||||
<slot name="workflow-tabs" />
|
||||
<!-- Left column: workflow tabs + canvas/panels -->
|
||||
<div class="pointer-events-none flex flex-1 flex-col overflow-hidden">
|
||||
<slot name="workflow-tabs" />
|
||||
|
||||
<div
|
||||
class="pointer-events-none flex flex-1 overflow-hidden"
|
||||
:class="{
|
||||
'flex-row': sidebarLocation === 'left',
|
||||
'flex-row-reverse': sidebarLocation === 'right'
|
||||
}"
|
||||
>
|
||||
<div class="side-toolbar-container">
|
||||
<slot name="side-toolbar" />
|
||||
</div>
|
||||
|
||||
<Splitter
|
||||
:key="splitterRefreshKey"
|
||||
class="pointer-events-none flex-1 overflow-hidden border-none bg-transparent"
|
||||
:state-key="
|
||||
isSelectMode
|
||||
? sidebarLocation === 'left'
|
||||
? 'builder-splitter'
|
||||
: 'builder-splitter-right'
|
||||
: sidebarStateKey
|
||||
"
|
||||
state-storage="local"
|
||||
@resizestart="onResizestart"
|
||||
@resizeend="normalizeSavedSizes"
|
||||
<div
|
||||
class="pointer-events-none flex flex-1 overflow-hidden"
|
||||
:class="{
|
||||
'flex-row': sidebarLocation === 'left',
|
||||
'flex-row-reverse': sidebarLocation === 'right'
|
||||
}"
|
||||
>
|
||||
<!-- First panel: sidebar when left, properties when right -->
|
||||
<SplitterPanel
|
||||
v-if="firstPanelVisible"
|
||||
:class="
|
||||
sidebarLocation === 'left'
|
||||
? cn(
|
||||
'side-bar-panel pointer-events-auto bg-comfy-menu-bg',
|
||||
sidebarPanelVisible && 'min-w-78'
|
||||
)
|
||||
: 'pointer-events-auto bg-comfy-menu-bg'
|
||||
"
|
||||
:min-size="
|
||||
sidebarLocation === 'left' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
|
||||
"
|
||||
:size="SIDE_PANEL_SIZE"
|
||||
:style="firstPanelStyle"
|
||||
:role="sidebarLocation === 'left' ? 'complementary' : undefined"
|
||||
:aria-label="
|
||||
sidebarLocation === 'left' ? t('sideToolbar.sidebar') : undefined
|
||||
<div class="side-toolbar-container">
|
||||
<slot name="side-toolbar" />
|
||||
</div>
|
||||
|
||||
<Splitter
|
||||
:key="splitterRefreshKey"
|
||||
class="pointer-events-none flex-1 overflow-hidden border-none bg-transparent"
|
||||
:state-key="
|
||||
isSelectMode
|
||||
? sidebarLocation === 'left'
|
||||
? 'builder-splitter'
|
||||
: 'builder-splitter-right'
|
||||
: sidebarStateKey
|
||||
"
|
||||
state-storage="local"
|
||||
@resizestart="onResizestart"
|
||||
@resizeend="normalizeSavedSizes"
|
||||
>
|
||||
<slot
|
||||
v-if="sidebarLocation === 'left' && sidebarPanelVisible"
|
||||
name="side-bar-panel"
|
||||
/>
|
||||
<slot
|
||||
v-else-if="sidebarLocation === 'right'"
|
||||
name="right-side-panel"
|
||||
/>
|
||||
</SplitterPanel>
|
||||
|
||||
<!-- Main panel (always present) -->
|
||||
<SplitterPanel :size="centerPanelDefaultSize" class="flex flex-col">
|
||||
<slot name="topmenu" :sidebar-panel-visible />
|
||||
|
||||
<Splitter
|
||||
class="splitter-overlay-bottom pointer-events-none mx-1 mb-1 flex-1 border-none bg-transparent"
|
||||
layout="vertical"
|
||||
:pt:gutter="
|
||||
cn(
|
||||
'rounded-t-lg',
|
||||
!(bottomPanelVisible && !focusMode) && 'hidden'
|
||||
)
|
||||
<!-- First panel: sidebar when left, properties when right -->
|
||||
<SplitterPanel
|
||||
v-if="firstPanelVisible"
|
||||
:class="
|
||||
sidebarLocation === 'left'
|
||||
? cn(
|
||||
'side-bar-panel pointer-events-auto bg-comfy-menu-bg',
|
||||
sidebarPanelVisible && 'min-w-78'
|
||||
)
|
||||
: 'pointer-events-auto bg-comfy-menu-bg'
|
||||
"
|
||||
:min-size="
|
||||
sidebarLocation === 'left' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
|
||||
"
|
||||
:size="SIDE_PANEL_SIZE"
|
||||
:style="firstPanelStyle"
|
||||
:role="sidebarLocation === 'left' ? 'complementary' : undefined"
|
||||
:aria-label="
|
||||
sidebarLocation === 'left' ? t('sideToolbar.sidebar') : undefined
|
||||
"
|
||||
state-key="bottom-panel-splitter"
|
||||
state-storage="local"
|
||||
@resizestart="onResizestart"
|
||||
>
|
||||
<SplitterPanel class="graph-canvas-panel relative overflow-visible">
|
||||
<slot name="graph-canvas-panel" />
|
||||
</SplitterPanel>
|
||||
<SplitterPanel
|
||||
v-show="bottomPanelVisible && !focusMode"
|
||||
class="bottom-panel pointer-events-auto max-w-full overflow-x-auto rounded-lg border border-(--p-panel-border-color) bg-comfy-menu-bg"
|
||||
>
|
||||
<slot name="bottom-panel" />
|
||||
</SplitterPanel>
|
||||
</Splitter>
|
||||
</SplitterPanel>
|
||||
<slot
|
||||
v-if="sidebarLocation === 'left' && sidebarPanelVisible"
|
||||
name="side-bar-panel"
|
||||
/>
|
||||
<slot
|
||||
v-else-if="sidebarLocation === 'right'"
|
||||
name="right-side-panel"
|
||||
/>
|
||||
</SplitterPanel>
|
||||
|
||||
<!-- Last panel: properties when left, sidebar when right -->
|
||||
<SplitterPanel
|
||||
v-if="lastPanelVisible"
|
||||
:class="
|
||||
sidebarLocation === 'right'
|
||||
? cn(
|
||||
'side-bar-panel pointer-events-auto bg-comfy-menu-bg',
|
||||
sidebarPanelVisible && 'min-w-78'
|
||||
<!-- Main panel (always present) -->
|
||||
<SplitterPanel :size="centerPanelDefaultSize" class="flex flex-col">
|
||||
<slot name="topmenu" :sidebar-panel-visible />
|
||||
|
||||
<Splitter
|
||||
class="splitter-overlay-bottom pointer-events-none mx-1 mb-1 flex-1 border-none bg-transparent"
|
||||
layout="vertical"
|
||||
:pt:gutter="
|
||||
cn(
|
||||
'rounded-t-lg',
|
||||
!(bottomPanelVisible && !focusMode) && 'hidden'
|
||||
)
|
||||
: 'pointer-events-auto bg-comfy-menu-bg'
|
||||
"
|
||||
:min-size="
|
||||
sidebarLocation === 'right' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
|
||||
"
|
||||
:size="SIDE_PANEL_SIZE"
|
||||
:style="lastPanelStyle"
|
||||
:role="sidebarLocation === 'right' ? 'complementary' : undefined"
|
||||
:aria-label="
|
||||
sidebarLocation === 'right' ? t('sideToolbar.sidebar') : undefined
|
||||
"
|
||||
>
|
||||
<slot v-if="sidebarLocation === 'left'" name="right-side-panel" />
|
||||
<slot
|
||||
v-else-if="sidebarLocation === 'right' && sidebarPanelVisible"
|
||||
name="side-bar-panel"
|
||||
/>
|
||||
</SplitterPanel>
|
||||
</Splitter>
|
||||
"
|
||||
state-key="bottom-panel-splitter"
|
||||
state-storage="local"
|
||||
@resizestart="onResizestart"
|
||||
>
|
||||
<SplitterPanel
|
||||
class="graph-canvas-panel relative overflow-visible"
|
||||
>
|
||||
<slot name="graph-canvas-panel" />
|
||||
</SplitterPanel>
|
||||
<SplitterPanel
|
||||
v-show="bottomPanelVisible && !focusMode"
|
||||
class="bottom-panel pointer-events-auto max-w-full overflow-x-auto rounded-lg border border-(--p-panel-border-color) bg-comfy-menu-bg"
|
||||
>
|
||||
<slot name="bottom-panel" />
|
||||
</SplitterPanel>
|
||||
</Splitter>
|
||||
</SplitterPanel>
|
||||
|
||||
<!-- Last panel: properties when left, sidebar when right -->
|
||||
<SplitterPanel
|
||||
v-if="lastPanelVisible"
|
||||
:class="
|
||||
sidebarLocation === 'right'
|
||||
? cn(
|
||||
'side-bar-panel pointer-events-auto bg-comfy-menu-bg',
|
||||
sidebarPanelVisible && 'min-w-78'
|
||||
)
|
||||
: 'pointer-events-auto bg-comfy-menu-bg'
|
||||
"
|
||||
:min-size="
|
||||
sidebarLocation === 'right' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
|
||||
"
|
||||
:size="SIDE_PANEL_SIZE"
|
||||
:style="lastPanelStyle"
|
||||
:role="sidebarLocation === 'right' ? 'complementary' : undefined"
|
||||
:aria-label="
|
||||
sidebarLocation === 'right' ? t('sideToolbar.sidebar') : undefined
|
||||
"
|
||||
>
|
||||
<slot v-if="sidebarLocation === 'left'" name="right-side-panel" />
|
||||
<slot
|
||||
v-else-if="sidebarLocation === 'right' && sidebarPanelVisible"
|
||||
name="side-bar-panel"
|
||||
/>
|
||||
</SplitterPanel>
|
||||
</Splitter>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right column: agent panel, full viewport height -->
|
||||
<div
|
||||
v-if="agentPanelVisible"
|
||||
class="pointer-events-auto relative h-full shrink-0 overflow-hidden border-l border-interface-stroke bg-comfy-menu-bg"
|
||||
:style="{ width: `${agentPanelWidth}px` }"
|
||||
>
|
||||
<div
|
||||
class="agent-resize-handle absolute top-0 left-0 z-10 h-full w-[5px] cursor-col-resize"
|
||||
:data-resizing="isResizing"
|
||||
@pointerdown="onResizePointerDown"
|
||||
@lostpointercapture="isResizing = false"
|
||||
/>
|
||||
<slot name="agent-panel" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Splitter from 'primevue/splitter'
|
||||
import type { SplitterResizeStartEvent } from 'primevue/splitter'
|
||||
import SplitterPanel from 'primevue/splitterpanel'
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
@@ -137,6 +158,7 @@ import {
|
||||
SIDEBAR_MIN_SIZE,
|
||||
SIDE_PANEL_SIZE
|
||||
} from '@/constants/splitterConstants'
|
||||
import { useAgentPanelStore } from '@/platform/agent/stores/agentPanelStore'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
@@ -147,6 +169,26 @@ const workspaceStore = useWorkspaceStore()
|
||||
const settingStore = useSettingStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const sidebarTabStore = useSidebarTabStore()
|
||||
const agentPanelStore = useAgentPanelStore()
|
||||
const { isOpen: agentPanelVisible, width: agentPanelWidth } =
|
||||
storeToRefs(agentPanelStore)
|
||||
|
||||
const isResizing = ref(false)
|
||||
let resizeStartX = 0
|
||||
let resizeStartWidth = 0
|
||||
|
||||
function onResizePointerDown(e: PointerEvent) {
|
||||
isResizing.value = true
|
||||
resizeStartX = e.clientX
|
||||
resizeStartWidth = agentPanelStore.width
|
||||
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
useEventListener(document, 'pointermove', (e: PointerEvent) => {
|
||||
if (!isResizing.value) return
|
||||
agentPanelStore.setWidth(resizeStartWidth + (resizeStartX - e.clientX))
|
||||
})
|
||||
const { t } = useI18n()
|
||||
const sidebarLocation = computed<'left' | 'right'>(() =>
|
||||
settingStore.get('Comfy.Sidebar.Location')
|
||||
@@ -304,4 +346,14 @@ const lastPanelStyle = computed(() => {
|
||||
.splitter-overlay-bottom :deep(.p-splitter-gutter) {
|
||||
transform: translateY(5px);
|
||||
}
|
||||
|
||||
.agent-resize-handle:hover {
|
||||
transition: background-color 0.2s ease 300ms;
|
||||
background-color: var(--p-primary-color);
|
||||
}
|
||||
|
||||
.agent-resize-handle[data-resizing='true'] {
|
||||
transition: none;
|
||||
background-color: var(--p-primary-color);
|
||||
}
|
||||
</style>
|
||||
|
||||
35
src/components/ai-elements/code-block/CodeBlock.vue
Normal file
35
src/components/ai-elements/code-block/CodeBlock.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { computed, provide } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import CodeBlockContainer from './CodeBlockContainer.vue'
|
||||
import CodeBlockContent from './CodeBlockContent.vue'
|
||||
import { CodeBlockKey } from './context'
|
||||
|
||||
const {
|
||||
code,
|
||||
language,
|
||||
showLineNumbers = false,
|
||||
class: className
|
||||
} = defineProps<{
|
||||
code: string
|
||||
language: string
|
||||
showLineNumbers?: boolean
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
provide(CodeBlockKey, { code: computed(() => code) })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CodeBlockContainer :class="cn('text-xs', className)" :language="language">
|
||||
<slot />
|
||||
<CodeBlockContent
|
||||
:code="code"
|
||||
:language="language"
|
||||
:show-line-numbers="showLineNumbers"
|
||||
/>
|
||||
</CodeBlockContainer>
|
||||
</template>
|
||||
15
src/components/ai-elements/code-block/CodeBlockActions.vue
Normal file
15
src/components/ai-elements/code-block/CodeBlockActions.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('flex items-center gap-1', className)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
30
src/components/ai-elements/code-block/CodeBlockContainer.vue
Normal file
30
src/components/ai-elements/code-block/CodeBlockContainer.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className, language } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
language: string
|
||||
}>()
|
||||
|
||||
const containerStyle = {
|
||||
contentVisibility: 'auto' as const,
|
||||
containIntrinsicSize: 'auto 200px'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'group relative w-full overflow-hidden rounded-md border border-border-default bg-base-background text-base-foreground',
|
||||
className
|
||||
)
|
||||
"
|
||||
:data-language="language"
|
||||
:style="containerStyle"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
105
src/components/ai-elements/code-block/CodeBlockContent.vue
Normal file
105
src/components/ai-elements/code-block/CodeBlockContent.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<script setup lang="ts">
|
||||
import type { BundledLanguage, ThemedToken } from 'shiki'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import type { TokenizedCode } from './utils'
|
||||
import {
|
||||
createRawTokens,
|
||||
highlightCode,
|
||||
isBold,
|
||||
isItalic,
|
||||
isUnderline
|
||||
} from './utils'
|
||||
|
||||
const {
|
||||
code,
|
||||
language,
|
||||
showLineNumbers = false
|
||||
} = defineProps<{
|
||||
code: string
|
||||
language: string
|
||||
showLineNumbers?: boolean
|
||||
}>()
|
||||
|
||||
const rawTokens = computed(() => createRawTokens(code))
|
||||
const tokenized = ref<TokenizedCode>(
|
||||
highlightCode(code, language as BundledLanguage) ?? rawTokens.value
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [code, language],
|
||||
() => {
|
||||
tokenized.value =
|
||||
highlightCode(code, language as BundledLanguage) ?? rawTokens.value
|
||||
highlightCode(code, language as BundledLanguage, (result) => {
|
||||
tokenized.value = result
|
||||
})
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const preStyle = computed(() => ({
|
||||
color: tokenized.value.fg
|
||||
}))
|
||||
|
||||
interface KeyedToken {
|
||||
token: ThemedToken
|
||||
key: string
|
||||
}
|
||||
interface KeyedLine {
|
||||
tokens: KeyedToken[]
|
||||
key: string
|
||||
}
|
||||
|
||||
const keyedLines = computed<KeyedLine[]>(() =>
|
||||
tokenized.value.tokens.map((line, lineIdx) => ({
|
||||
key: `line-${lineIdx}`,
|
||||
tokens: line.map((token, tokenIdx) => ({
|
||||
token,
|
||||
key: `line-${lineIdx}-${tokenIdx}`
|
||||
}))
|
||||
}))
|
||||
)
|
||||
|
||||
const lineNumberClasses = cn(
|
||||
'block',
|
||||
'before:content-[counter(line)]',
|
||||
'before:inline-block',
|
||||
'before:[counter-increment:line]',
|
||||
'before:w-8',
|
||||
'before:mr-4',
|
||||
'before:text-right',
|
||||
'before:text-muted-foreground/50',
|
||||
'before:font-mono',
|
||||
'before:select-none'
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative overflow-auto">
|
||||
<pre
|
||||
class="m-0 overflow-auto bg-base-background p-4 text-sm"
|
||||
:style="preStyle"
|
||||
><code
|
||||
:class="
|
||||
cn(
|
||||
'font-mono text-sm',
|
||||
showLineNumbers && '[counter-increment:line_0] [counter-reset:line]',
|
||||
)
|
||||
"
|
||||
><template v-for="line in keyedLines" :key="line.key"><span :class="showLineNumbers ? lineNumberClasses : 'block'"><span
|
||||
v-for="tokenObj in line.tokens"
|
||||
:key="tokenObj.key"
|
||||
:style="{
|
||||
color: tokenObj.token.color,
|
||||
backgroundColor: tokenObj.token.bgColor,
|
||||
fontStyle: isItalic(tokenObj.token.fontStyle) ? 'italic' : undefined,
|
||||
fontWeight: isBold(tokenObj.token.fontStyle) ? 'bold' : undefined,
|
||||
textDecoration: isUnderline(tokenObj.token.fontStyle)
|
||||
? 'underline'
|
||||
: undefined,
|
||||
}"
|
||||
>{{ tokenObj.token.content }}</span></span></template></code></pre>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { computed, inject, onBeforeUnmount, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Tooltip from '@/components/ui/tooltip/Tooltip.vue'
|
||||
import TooltipContent from '@/components/ui/tooltip/TooltipContent.vue'
|
||||
import TooltipTrigger from '@/components/ui/tooltip/TooltipTrigger.vue'
|
||||
|
||||
import { CodeBlockKey } from './context'
|
||||
|
||||
const { timeout = 2000, class: className } = defineProps<{
|
||||
timeout?: number
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
copy: []
|
||||
error: [error: Error]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const context = inject(CodeBlockKey)
|
||||
if (!context)
|
||||
throw new Error('CodeBlockCopyButton must be used within a CodeBlock')
|
||||
|
||||
const { code } = context
|
||||
const isCopied = ref(false)
|
||||
let resetTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const label = computed(() => (isCopied.value ? t('g.copied') : t('g.copy')))
|
||||
|
||||
async function copyToClipboard() {
|
||||
if (!navigator?.clipboard?.writeText) {
|
||||
emit('error', new Error('Clipboard API not available'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(code.value)
|
||||
isCopied.value = true
|
||||
emit('copy')
|
||||
clearTimeout(resetTimer)
|
||||
resetTimer = setTimeout(() => {
|
||||
isCopied.value = false
|
||||
}, timeout)
|
||||
} catch (error) {
|
||||
emit('error', error instanceof Error ? error : new Error('Copy failed'))
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => clearTimeout(resetTimer))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
:class="cn('shrink-0', className)"
|
||||
size="icon-sm"
|
||||
variant="muted-textonly"
|
||||
:aria-label="label"
|
||||
@click="copyToClipboard"
|
||||
>
|
||||
<i
|
||||
:class="isCopied ? 'icon-[lucide--check]' : 'icon-[lucide--copy]'"
|
||||
class="size-3.5"
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{{ label }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</template>
|
||||
17
src/components/ai-elements/code-block/CodeBlockFilename.vue
Normal file
17
src/components/ai-elements/code-block/CodeBlockFilename.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
:class="cn('font-mono text-xs font-medium text-base-foreground', className)"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
22
src/components/ai-elements/code-block/CodeBlockHeader.vue
Normal file
22
src/components/ai-elements/code-block/CodeBlockHeader.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex items-center justify-between border-b border-border-default bg-secondary-background-hover px-3 py-1.5 text-muted-foreground',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
15
src/components/ai-elements/code-block/CodeBlockTitle.vue
Normal file
15
src/components/ai-elements/code-block/CodeBlockTitle.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('flex items-center gap-1.5', className)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
7
src/components/ai-elements/code-block/context.ts
Normal file
7
src/components/ai-elements/code-block/context.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { ComputedRef, InjectionKey } from 'vue'
|
||||
|
||||
export interface CodeBlockContext {
|
||||
code: ComputedRef<string>
|
||||
}
|
||||
|
||||
export const CodeBlockKey: InjectionKey<CodeBlockContext> = Symbol('CodeBlock')
|
||||
91
src/components/ai-elements/code-block/utils.ts
Normal file
91
src/components/ai-elements/code-block/utils.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import type {
|
||||
BundledLanguage,
|
||||
BundledTheme,
|
||||
HighlighterGeneric,
|
||||
ThemedToken
|
||||
} from 'shiki'
|
||||
import { createHighlighter } from 'shiki'
|
||||
|
||||
export const isItalic = (fontStyle: number | undefined): boolean =>
|
||||
!!(fontStyle && fontStyle & 1)
|
||||
export const isBold = (fontStyle: number | undefined): boolean =>
|
||||
!!(fontStyle && fontStyle & 2)
|
||||
export const isUnderline = (fontStyle: number | undefined): boolean =>
|
||||
!!(fontStyle && fontStyle & 4)
|
||||
|
||||
export interface TokenizedCode {
|
||||
tokens: ThemedToken[][]
|
||||
fg: string
|
||||
bg: string
|
||||
}
|
||||
|
||||
const THEME: BundledTheme = 'one-dark-pro'
|
||||
|
||||
const highlighterCache = new Map<
|
||||
string,
|
||||
Promise<HighlighterGeneric<BundledLanguage, BundledTheme>>
|
||||
>()
|
||||
const tokensCache = new Map<string, TokenizedCode>()
|
||||
const subscribers = new Map<string, Set<(result: TokenizedCode) => void>>()
|
||||
|
||||
function cacheKey(code: string, language: BundledLanguage): string {
|
||||
const start = code.slice(0, 100)
|
||||
const end = code.length > 100 ? code.slice(-100) : ''
|
||||
return `${language}:${code.length}:${start}:${end}`
|
||||
}
|
||||
|
||||
function getHighlighter(
|
||||
language: BundledLanguage
|
||||
): Promise<HighlighterGeneric<BundledLanguage, BundledTheme>> {
|
||||
const cached = highlighterCache.get(language)
|
||||
if (cached) return cached
|
||||
|
||||
const promise = createHighlighter({ themes: [THEME], langs: [language] })
|
||||
highlighterCache.set(language, promise)
|
||||
return promise
|
||||
}
|
||||
|
||||
export function createRawTokens(code: string): TokenizedCode {
|
||||
return {
|
||||
tokens: code
|
||||
.split('\n')
|
||||
.map((line) =>
|
||||
line === '' ? [] : [{ content: line, color: 'inherit' } as ThemedToken]
|
||||
),
|
||||
fg: 'inherit',
|
||||
bg: 'transparent'
|
||||
}
|
||||
}
|
||||
|
||||
export function highlightCode(
|
||||
code: string,
|
||||
language: BundledLanguage,
|
||||
callback?: (result: TokenizedCode) => void
|
||||
): TokenizedCode | null {
|
||||
const key = cacheKey(code, language)
|
||||
const cached = tokensCache.get(key)
|
||||
if (cached) return cached
|
||||
|
||||
if (callback) {
|
||||
if (!subscribers.has(key)) subscribers.set(key, new Set())
|
||||
subscribers.get(key)!.add(callback)
|
||||
}
|
||||
|
||||
getHighlighter(language)
|
||||
.then((highlighter) => {
|
||||
const loadedLangs = highlighter.getLoadedLanguages()
|
||||
const lang = loadedLangs.includes(language) ? language : 'text'
|
||||
const result = highlighter.codeToTokens(code, { lang, theme: THEME })
|
||||
const tokenized: TokenizedCode = {
|
||||
tokens: result.tokens,
|
||||
fg: result.fg ?? 'inherit',
|
||||
bg: result.bg ?? 'transparent'
|
||||
}
|
||||
tokensCache.set(key, tokenized)
|
||||
subscribers.get(key)?.forEach((sub) => sub(tokenized))
|
||||
subscribers.delete(key)
|
||||
})
|
||||
.catch(() => subscribers.delete(key))
|
||||
|
||||
return null
|
||||
}
|
||||
52
src/components/ai-elements/conversation/Conversation.vue
Normal file
52
src/components/ai-elements/conversation/Conversation.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { useMutationObserver } from '@vueuse/core'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { provide, ref, useTemplateRef } from 'vue'
|
||||
|
||||
import { conversationKey } from './context'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const scrollEl = useTemplateRef<HTMLDivElement>('scrollEl')
|
||||
const isAtBottom = ref(true)
|
||||
|
||||
function updateAtBottom() {
|
||||
const el = scrollEl.value
|
||||
if (!el) return
|
||||
isAtBottom.value = el.scrollHeight - el.scrollTop - el.clientHeight < 24
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
const el = scrollEl.value
|
||||
if (!el) return
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
useMutationObserver(
|
||||
scrollEl,
|
||||
() => {
|
||||
if (isAtBottom.value) {
|
||||
requestAnimationFrame(scrollToBottom)
|
||||
}
|
||||
},
|
||||
{ childList: true, subtree: true, characterData: true }
|
||||
)
|
||||
|
||||
provide(conversationKey, { isAtBottom, scrollToBottom })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative flex-1 overflow-hidden">
|
||||
<div
|
||||
ref="scrollEl"
|
||||
:class="cn('scroll-fade absolute inset-0 scrollbar-custom', className)"
|
||||
@scroll="updateAtBottom"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
<slot name="overlay" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('flex flex-col gap-4 p-4', className)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-1 flex-col items-center justify-center gap-4 p-8 text-center',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Tooltip from '@/components/ui/tooltip/Tooltip.vue'
|
||||
import TooltipContent from '@/components/ui/tooltip/TooltipContent.vue'
|
||||
import TooltipTrigger from '@/components/ui/tooltip/TooltipTrigger.vue'
|
||||
|
||||
import { useConversation } from './context'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { isAtBottom, scrollToBottom } = useConversation()
|
||||
const label = t('agent.scrollToBottom')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="!isAtBottom"
|
||||
class="pointer-events-none absolute inset-x-0 bottom-2 z-10 flex justify-center"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Button
|
||||
size="icon"
|
||||
:class="
|
||||
cn(
|
||||
'pointer-events-auto rounded-full shadow-md ring-1 ring-muted-foreground',
|
||||
className
|
||||
)
|
||||
"
|
||||
:aria-label="label"
|
||||
@click="scrollToBottom"
|
||||
>
|
||||
<i class="icon-[lucide--chevron-down] size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{{ label }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</template>
|
||||
18
src/components/ai-elements/conversation/context.ts
Normal file
18
src/components/ai-elements/conversation/context.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { InjectionKey, Ref } from 'vue'
|
||||
import { inject } from 'vue'
|
||||
|
||||
export interface ConversationContext {
|
||||
isAtBottom: Ref<boolean>
|
||||
scrollToBottom: () => void
|
||||
}
|
||||
|
||||
export const conversationKey: InjectionKey<ConversationContext> =
|
||||
Symbol('conversation')
|
||||
|
||||
export function useConversation(): ConversationContext {
|
||||
const context = inject(conversationKey)
|
||||
if (!context) {
|
||||
throw new Error('Conversation parts must be used within <Conversation>')
|
||||
}
|
||||
return context
|
||||
}
|
||||
23
src/components/ai-elements/message/Message.vue
Normal file
23
src/components/ai-elements/message/Message.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
const { from, class: className } = defineProps<{
|
||||
from: 'user' | 'assistant' | 'system'
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'group flex w-full gap-2',
|
||||
from === 'user' ? 'is-user ml-auto justify-end' : 'is-assistant',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
36
src/components/ai-elements/message/MessageAction.vue
Normal file
36
src/components/ai-elements/message/MessageAction.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import Tooltip from '@/components/ui/tooltip/Tooltip.vue'
|
||||
import TooltipContent from '@/components/ui/tooltip/TooltipContent.vue'
|
||||
import TooltipTrigger from '@/components/ui/tooltip/TooltipTrigger.vue'
|
||||
|
||||
const { tooltip, pressed = false } = defineProps<{
|
||||
tooltip: string
|
||||
pressed?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ click: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Tooltip :delay-duration="500">
|
||||
<TooltipTrigger>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="tooltip"
|
||||
:aria-pressed="pressed"
|
||||
:class="
|
||||
pressed
|
||||
? 'text-base-foreground'
|
||||
: 'text-muted-foreground hover:text-base-foreground'
|
||||
"
|
||||
class="flex cursor-pointer items-center justify-center rounded-sm border-0 bg-transparent p-1 transition-colors hover:bg-secondary-background-hover"
|
||||
@click="emit('click')"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" class="whitespace-nowrap">{{
|
||||
tooltip
|
||||
}}</TooltipContent>
|
||||
</Tooltip>
|
||||
</template>
|
||||
5
src/components/ai-elements/message/MessageActions.vue
Normal file
5
src/components/ai-elements/message/MessageActions.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-end gap-0.5">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
48
src/components/ai-elements/message/MessageAttachments.vue
Normal file
48
src/components/ai-elements/message/MessageAttachments.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import type { MessageAttachment } from '@/platform/agent/composables/useAgentChatPrototype'
|
||||
|
||||
const { attachments } = defineProps<{
|
||||
attachments: readonly MessageAttachment[]
|
||||
}>()
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<div
|
||||
v-for="(attachment, i) in attachments"
|
||||
:key="i"
|
||||
class="flex items-center gap-3 rounded-lg border border-border-default bg-secondary-background p-2"
|
||||
>
|
||||
<div
|
||||
class="size-10 shrink-0 overflow-hidden rounded-md border border-border-default"
|
||||
>
|
||||
<img
|
||||
v-if="attachment.type.startsWith('image/')"
|
||||
:src="attachment.url"
|
||||
:alt="attachment.name"
|
||||
class="size-full object-cover"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex size-full items-center justify-center bg-secondary-background-hover"
|
||||
>
|
||||
<i class="icon-[lucide--file] size-4 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<span class="block truncate text-xs font-medium text-base-foreground">
|
||||
{{ attachment.name }}
|
||||
</span>
|
||||
<span class="block text-xs text-muted-foreground">
|
||||
{{ formatFileSize(attachment.size) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
22
src/components/ai-elements/message/MessageContent.vue
Normal file
22
src/components/ai-elements/message/MessageContent.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex w-full flex-col gap-2 overflow-hidden text-xs text-base-foreground',
|
||||
'group-[.is-user]:ml-auto group-[.is-user]:w-fit group-[.is-user]:rounded-lg group-[.is-user]:bg-secondary-background group-[.is-user]:px-4 group-[.is-user]:py-3',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
16
src/components/ai-elements/message/MessageResponse.vue
Normal file
16
src/components/ai-elements/message/MessageResponse.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import Response from '@/components/ai-elements/response/Response.vue'
|
||||
|
||||
const { content, class: className } = defineProps<{
|
||||
content?: string
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Response :content="content" :class="className">
|
||||
<slot />
|
||||
</Response>
|
||||
</template>
|
||||
17
src/components/ai-elements/message/MessageThinking.vue
Normal file
17
src/components/ai-elements/message/MessageThinking.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<div class="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<i class="icon-[lucide--brain] size-3.5 animate-pulse" />
|
||||
<span>{{ $t('agent.thinking') }}</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Skeleton class="h-2.5 w-52" />
|
||||
<Skeleton class="h-2.5 w-40" />
|
||||
<Skeleton class="h-2.5 w-60" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
102
src/components/ai-elements/message/MessageToolCalls.vue
Normal file
102
src/components/ai-elements/message/MessageToolCalls.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import type { ToolCall } from '@/platform/agent/composables/useAgentChatPrototype'
|
||||
|
||||
const { toolCalls, complete = false } = defineProps<{
|
||||
toolCalls: readonly ToolCall[]
|
||||
complete?: boolean
|
||||
}>()
|
||||
|
||||
const expanded = ref(!complete)
|
||||
const shouldAnimate = ref(!complete)
|
||||
const totalDurationMs = toolCalls.reduce((sum, c) => sum + c.durationMs, 0)
|
||||
|
||||
watch(
|
||||
() => complete,
|
||||
(done) => {
|
||||
if (done)
|
||||
setTimeout(() => {
|
||||
expanded.value = false
|
||||
shouldAnimate.value = false
|
||||
}, 1200)
|
||||
}
|
||||
)
|
||||
|
||||
function formatDuration(ms: number) {
|
||||
return `${(ms / 1000).toFixed(1)}s`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-8 cursor-pointer items-center gap-2 rounded-md border-0 bg-transparent px-2 text-left text-sm text-muted-foreground transition-colors hover:bg-secondary-background-hover hover:text-base-foreground"
|
||||
@click="expanded = !expanded"
|
||||
>
|
||||
<i class="icon-[lucide--wrench] size-4 shrink-0" />
|
||||
<span class="flex-1">
|
||||
{{
|
||||
$t('agent.toolCalls.summary', {
|
||||
count: toolCalls.length,
|
||||
duration: formatDuration(totalDurationMs)
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<i
|
||||
:class="
|
||||
expanded ? 'icon-[lucide--chevron-up]' : 'icon-[lucide--chevron-down]'
|
||||
"
|
||||
class="size-4 shrink-0"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition-opacity duration-150 ease-out"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition-opacity duration-100 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<ul v-if="expanded" class="flex list-none flex-col pl-0">
|
||||
<li
|
||||
v-for="(call, i) in toolCalls"
|
||||
:key="i"
|
||||
:class="
|
||||
cn(
|
||||
'relative pl-6',
|
||||
shouldAnimate &&
|
||||
'animate-in fade-in-0 fill-mode-both slide-in-from-top-1'
|
||||
)
|
||||
"
|
||||
:style="
|
||||
shouldAnimate
|
||||
? { animationDelay: `${i * 80}ms`, animationDuration: '200ms' }
|
||||
: {}
|
||||
"
|
||||
>
|
||||
<div class="absolute inset-y-0 left-4 w-px bg-border-default" />
|
||||
<div class="flex h-8 items-center gap-2 rounded-md px-2">
|
||||
<i
|
||||
:class="
|
||||
call.status === 'success'
|
||||
? 'icon-[lucide--circle-check] text-muted-foreground'
|
||||
: 'icon-[lucide--circle-x] text-muted-foreground/50'
|
||||
"
|
||||
class="size-4 shrink-0"
|
||||
/>
|
||||
<span class="flex-1 truncate text-sm text-muted-foreground">{{
|
||||
call.name
|
||||
}}</span>
|
||||
<span class="text-sm text-muted-foreground/60 tabular-nums">{{
|
||||
formatDuration(call.durationMs)
|
||||
}}</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
29
src/components/ai-elements/prompt-input/PromptInput.vue
Normal file
29
src/components/ai-elements/prompt-input/PromptInput.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { provide, ref } from 'vue'
|
||||
|
||||
import { PROMPT_INPUT_FOCUSED_KEY } from './promptInputContext'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [event: Event]
|
||||
}>()
|
||||
|
||||
const isFocused = ref(false)
|
||||
provide(PROMPT_INPUT_FOCUSED_KEY, isFocused)
|
||||
|
||||
function onSubmit(event: Event) {
|
||||
event.preventDefault()
|
||||
emit('submit', event)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form :class="cn('w-full', className)" @submit="onSubmit">
|
||||
<slot />
|
||||
</form>
|
||||
</template>
|
||||
@@ -0,0 +1,94 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Tooltip from '@/components/ui/tooltip/Tooltip.vue'
|
||||
import TooltipContent from '@/components/ui/tooltip/TooltipContent.vue'
|
||||
import TooltipTrigger from '@/components/ui/tooltip/TooltipTrigger.vue'
|
||||
|
||||
const { attachments } = defineProps<{
|
||||
attachments: File[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
remove: [index: number]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const objectUrls = ref<string[]>([])
|
||||
|
||||
watch(
|
||||
() => attachments,
|
||||
(files) => {
|
||||
objectUrls.value.forEach(URL.revokeObjectURL)
|
||||
objectUrls.value = files.map((f) =>
|
||||
f.type.startsWith('image/') ? URL.createObjectURL(f) : ''
|
||||
)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
objectUrls.value.forEach(URL.revokeObjectURL)
|
||||
})
|
||||
|
||||
function fileTypeIcon(file: File): string {
|
||||
if (file.type.startsWith('audio/')) return 'icon-[lucide--music]'
|
||||
if (file.type.startsWith('video/')) return 'icon-[lucide--video]'
|
||||
if (file.type === 'application/pdf') return 'icon-[lucide--file-text]'
|
||||
if (file.type.startsWith('text/')) return 'icon-[lucide--file-text]'
|
||||
return 'icon-[lucide--paperclip]'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="attachments.length" class="flex flex-wrap gap-1.5 px-4 pt-3">
|
||||
<div
|
||||
v-for="(file, i) in attachments"
|
||||
:key="i"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-8 items-center gap-1.5 rounded-md border border-border-default select-none',
|
||||
'bg-secondary-background px-1.5 text-sm font-medium transition-colors'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="size-5 shrink-0 overflow-hidden rounded-sm">
|
||||
<img
|
||||
v-if="file.type.startsWith('image/')"
|
||||
:src="objectUrls[i]"
|
||||
:alt="file.name"
|
||||
class="size-full object-cover"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex size-full items-center justify-center bg-secondary-background-hover"
|
||||
>
|
||||
<i :class="fileTypeIcon(file)" class="size-3 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="max-w-36 truncate text-xs text-base-foreground">{{
|
||||
file.name
|
||||
}}</span>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="muted-textonly"
|
||||
class="size-4 shrink-0"
|
||||
:aria-label="t('g.remove')"
|
||||
@click="emit('remove', i)"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-2.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{{ t('g.remove') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
28
src/components/ai-elements/prompt-input/PromptInputBody.vue
Normal file
28
src/components/ai-elements/prompt-input/PromptInputBody.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { inject } from 'vue'
|
||||
|
||||
import type { PromptInputFocusedContext } from './promptInputContext'
|
||||
import { PROMPT_INPUT_FOCUSED_KEY } from './promptInputContext'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const isFocused = inject<PromptInputFocusedContext>(PROMPT_INPUT_FOCUSED_KEY)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-col rounded-2xl border bg-secondary-background transition-colors',
|
||||
isFocused ? 'border-muted-foreground' : 'border-border-default',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { ButtonVariants } from '@/components/ui/button/button.variants'
|
||||
|
||||
const {
|
||||
class: className,
|
||||
variant = 'muted-textonly',
|
||||
size = 'icon'
|
||||
} = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
variant?: ButtonVariants['variant']
|
||||
size?: ButtonVariants['size']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Button type="button" :variant="variant" :size="size" :class="className">
|
||||
<slot />
|
||||
</Button>
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const model = defineModel<string>({ default: 'Auto' })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Button type="button" variant="muted-textonly" size="sm" :class="className">
|
||||
{{ model }}
|
||||
<i class="icon-[lucide--chevron-down] size-3" />
|
||||
</Button>
|
||||
</template>
|
||||
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { ButtonVariants } from '@/components/ui/button/button.variants'
|
||||
|
||||
import type { ChatStatus } from './types'
|
||||
|
||||
const {
|
||||
class: className,
|
||||
status = 'ready',
|
||||
variant = 'inverted',
|
||||
size = 'icon',
|
||||
disabled = false
|
||||
} = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
status?: ChatStatus
|
||||
variant?: ButtonVariants['variant']
|
||||
size?: ButtonVariants['size']
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const iconClass = computed(() => {
|
||||
switch (status) {
|
||||
case 'submitted':
|
||||
return 'icon-[lucide--loader-circle] size-4 animate-spin'
|
||||
case 'streaming':
|
||||
return 'icon-[lucide--square] size-4'
|
||||
case 'error':
|
||||
return 'icon-[lucide--x] size-4'
|
||||
default:
|
||||
return 'icon-[lucide--arrow-up] size-4'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Button
|
||||
type="submit"
|
||||
:variant="variant"
|
||||
:size="size"
|
||||
:disabled="disabled"
|
||||
:class="cn('rounded-xl', className)"
|
||||
:aria-label="$t('agent.send')"
|
||||
>
|
||||
<slot>
|
||||
<i :class="iconClass" />
|
||||
</slot>
|
||||
</Button>
|
||||
</template>
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { inject, ref } from 'vue'
|
||||
|
||||
import type { PromptInputFocusedContext } from './promptInputContext'
|
||||
import { PROMPT_INPUT_FOCUSED_KEY } from './promptInputContext'
|
||||
|
||||
const { class: className, placeholder } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
placeholder?: string
|
||||
}>()
|
||||
|
||||
const model = defineModel<string>({ default: '' })
|
||||
|
||||
const isComposing = ref(false)
|
||||
const textareaEl = ref<HTMLTextAreaElement | null>(null)
|
||||
const isFocused = inject<PromptInputFocusedContext>(PROMPT_INPUT_FOCUSED_KEY)
|
||||
|
||||
function onFocus() {
|
||||
if (isFocused) isFocused.value = true
|
||||
}
|
||||
|
||||
function onBlur() {
|
||||
if (isFocused) isFocused.value = false
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'Enter' || event.shiftKey || isComposing.value) return
|
||||
event.preventDefault()
|
||||
const form = (event.target as HTMLElement).closest('form')
|
||||
form?.requestSubmit()
|
||||
}
|
||||
|
||||
defineExpose({ focus: () => textareaEl.value?.focus() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<textarea
|
||||
ref="textareaEl"
|
||||
v-model="model"
|
||||
rows="1"
|
||||
:placeholder="placeholder"
|
||||
:class="
|
||||
cn(
|
||||
'field-sizing-content max-h-48 min-h-20 w-full resize-none border-none bg-transparent px-4 py-3 font-[inherit] text-sm text-base-foreground placeholder:text-muted-foreground focus:outline-none',
|
||||
className
|
||||
)
|
||||
"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
@keydown="onKeydown"
|
||||
@compositionstart="isComposing = true"
|
||||
@compositionend="isComposing = false"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="cn('flex items-center justify-between gap-1 px-3 py-2', className)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
14
src/components/ai-elements/prompt-input/PromptInputTools.vue
Normal file
14
src/components/ai-elements/prompt-input/PromptInputTools.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('flex items-center gap-1', className)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
export const PROMPT_INPUT_FOCUSED_KEY = Symbol('promptInputFocused')
|
||||
export type PromptInputFocusedContext = Ref<boolean>
|
||||
1
src/components/ai-elements/prompt-input/types.ts
Normal file
1
src/components/ai-elements/prompt-input/types.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type ChatStatus = 'ready' | 'submitted' | 'streaming' | 'error'
|
||||
151
src/components/ai-elements/response/MarkdownRenderer.vue
Normal file
151
src/components/ai-elements/response/MarkdownRenderer.vue
Normal file
@@ -0,0 +1,151 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil'
|
||||
|
||||
import CodeBlock from '../code-block/CodeBlock.vue'
|
||||
import CodeBlockActions from '../code-block/CodeBlockActions.vue'
|
||||
import CodeBlockCopyButton from '../code-block/CodeBlockCopyButton.vue'
|
||||
import CodeBlockFilename from '../code-block/CodeBlockFilename.vue'
|
||||
import CodeBlockHeader from '../code-block/CodeBlockHeader.vue'
|
||||
import CodeBlockTitle from '../code-block/CodeBlockTitle.vue'
|
||||
|
||||
const { content, class: className } = defineProps<{
|
||||
content: string
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
// Matches complete fenced code blocks: ```lang\n...content...\n```
|
||||
const FENCE_RE = /^```([^\n]*)\n([\s\S]*?)^```[ \t]*$/gm
|
||||
|
||||
// Matches an opening fence with no closing fence — used to detect mid-stream blocks.
|
||||
// Captures: [1] newline-or-start before the fence, [2] language info, [3] code content so far.
|
||||
const OPEN_FENCE_RE = /(^|\n)```([^\n]*)\n([\s\S]*)$/
|
||||
|
||||
interface HtmlSegment {
|
||||
type: 'html'
|
||||
key: string
|
||||
html: string
|
||||
}
|
||||
|
||||
interface CodeSegment {
|
||||
type: 'code'
|
||||
key: string
|
||||
code: string
|
||||
language: string
|
||||
filename: string
|
||||
}
|
||||
|
||||
type Segment = HtmlSegment | CodeSegment
|
||||
|
||||
function parseCodeInfo(info: string): { language: string; filename: string } {
|
||||
const colonIdx = info.indexOf(':')
|
||||
return {
|
||||
language: colonIdx >= 0 ? info.slice(0, colonIdx) : info,
|
||||
filename: colonIdx >= 0 ? info.slice(colonIdx + 1) : ''
|
||||
}
|
||||
}
|
||||
|
||||
const segments = computed<Segment[]>(() => {
|
||||
if (!content) return []
|
||||
|
||||
const result: Segment[] = []
|
||||
let lastIdx = 0
|
||||
let keyIdx = 0
|
||||
|
||||
for (const match of content.matchAll(FENCE_RE)) {
|
||||
const before = content.slice(lastIdx, match.index)
|
||||
if (before) {
|
||||
result.push({
|
||||
type: 'html',
|
||||
key: `h${keyIdx++}`,
|
||||
html: renderMarkdownToHtml(before)
|
||||
})
|
||||
}
|
||||
|
||||
const { language, filename } = parseCodeInfo(match[1].trim())
|
||||
result.push({
|
||||
type: 'code',
|
||||
key: `c${keyIdx++}`,
|
||||
code: match[2].replace(/\n$/, ''),
|
||||
language,
|
||||
filename
|
||||
})
|
||||
|
||||
lastIdx = match.index! + match[0].length
|
||||
}
|
||||
|
||||
const tail = content.slice(lastIdx)
|
||||
|
||||
const openMatch = tail.match(OPEN_FENCE_RE)
|
||||
if (openMatch) {
|
||||
const fenceStart = openMatch.index! + openMatch[1].length
|
||||
const before = tail.slice(0, fenceStart)
|
||||
if (before) {
|
||||
result.push({
|
||||
type: 'html',
|
||||
key: `h${keyIdx++}`,
|
||||
html: renderMarkdownToHtml(before)
|
||||
})
|
||||
}
|
||||
const { language, filename } = parseCodeInfo(openMatch[2].trim())
|
||||
result.push({
|
||||
type: 'code',
|
||||
key: `c${keyIdx}`,
|
||||
code: openMatch[3],
|
||||
language,
|
||||
filename
|
||||
})
|
||||
} else if (tail) {
|
||||
result.push({
|
||||
type: 'html',
|
||||
key: `h${keyIdx}`,
|
||||
html: renderMarkdownToHtml(tail)
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('agent-markdown', className)">
|
||||
<template v-for="segment in segments" :key="segment.key">
|
||||
<div
|
||||
v-if="segment.type === 'html'"
|
||||
class="contents"
|
||||
v-html="segment.html"
|
||||
/>
|
||||
<CodeBlock
|
||||
v-else
|
||||
class="mb-2"
|
||||
:code="segment.code"
|
||||
:language="segment.language"
|
||||
>
|
||||
<CodeBlockHeader>
|
||||
<CodeBlockTitle>
|
||||
<i
|
||||
:class="
|
||||
segment.filename
|
||||
? 'icon-[lucide--file-code]'
|
||||
: 'icon-[lucide--code-2]'
|
||||
"
|
||||
class="size-3.5 shrink-0"
|
||||
/>
|
||||
<CodeBlockFilename v-if="segment.filename">
|
||||
{{ segment.filename }}
|
||||
</CodeBlockFilename>
|
||||
<span v-else class="font-mono text-xs">
|
||||
{{ segment.language || 'plaintext' }}
|
||||
</span>
|
||||
</CodeBlockTitle>
|
||||
<CodeBlockActions>
|
||||
<CodeBlockCopyButton />
|
||||
</CodeBlockActions>
|
||||
</CodeBlockHeader>
|
||||
</CodeBlock>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
29
src/components/ai-elements/response/Response.vue
Normal file
29
src/components/ai-elements/response/Response.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { computed, useSlots } from 'vue'
|
||||
|
||||
import MarkdownRenderer from './MarkdownRenderer.vue'
|
||||
|
||||
const { content, class: className } = defineProps<{
|
||||
content?: string
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const slots = useSlots()
|
||||
|
||||
const markdown = computed(() => {
|
||||
if (content !== undefined) return content
|
||||
const nodes = slots.default?.() ?? []
|
||||
return nodes
|
||||
.map((node) => (typeof node.children === 'string' ? node.children : ''))
|
||||
.join('')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MarkdownRenderer
|
||||
:content="markdown"
|
||||
:class="cn('text-xs/relaxed', className)"
|
||||
/>
|
||||
</template>
|
||||
28
src/components/ai-elements/suggestion/Suggestion.vue
Normal file
28
src/components/ai-elements/suggestion/Suggestion.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
const { suggestion, class: className } = defineProps<{
|
||||
suggestion: string
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [suggestion: string]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
:class="
|
||||
cn(
|
||||
'text-foreground flex h-8 w-full cursor-pointer items-center justify-start gap-2 rounded-full border-0 bg-secondary-background px-3 text-sm whitespace-nowrap transition-colors outline-none hover:bg-secondary-background-hover @[460px]:w-auto',
|
||||
className
|
||||
)
|
||||
"
|
||||
@click="emit('select', suggestion)"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
21
src/components/ai-elements/suggestion/Suggestions.vue
Normal file
21
src/components/ai-elements/suggestion/Suggestions.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex w-full flex-wrap justify-start gap-2 @[460px]:justify-center',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -50,6 +50,15 @@
|
||||
class="pointer-events-auto"
|
||||
/>
|
||||
</template>
|
||||
<template #agent-panel>
|
||||
<div class="size-full p-2">
|
||||
<div
|
||||
class="size-full overflow-hidden rounded-lg border border-(--interface-stroke)"
|
||||
>
|
||||
<AgentChatPanel />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</LiteGraphCanvasSplitterOverlay>
|
||||
<canvas
|
||||
id="graph-canvas"
|
||||
@@ -141,6 +150,7 @@ import NodeDragPreview from '@/components/graph/NodeDragPreview.vue'
|
||||
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
|
||||
import TitleEditor from '@/components/graph/TitleEditor.vue'
|
||||
import NodePropertiesPanel from '@/components/rightSidePanel/RightSidePanel.vue'
|
||||
import AgentChatPanel from '@/platform/agent/components/AgentChatPanel.vue'
|
||||
import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vue'
|
||||
import SideToolbar from '@/components/sidebar/SideToolbar.vue'
|
||||
import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
|
||||
|
||||
@@ -84,6 +84,22 @@
|
||||
data-testid="integrated-tab-bar-actions"
|
||||
class="ml-auto flex shrink-0 items-center gap-2 px-2"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="no-drag flex h-6 shrink-0 cursor-pointer items-center gap-2 rounded-sm border px-2 text-xs text-base-foreground transition-colors"
|
||||
:class="
|
||||
cn(
|
||||
isAgentPanelOpen
|
||||
? 'border-plum-500 bg-plum-600/20'
|
||||
: 'border-plum-600 bg-ink-700 hover:border-plum-500'
|
||||
)
|
||||
"
|
||||
:aria-label="$t('agent.ask')"
|
||||
@click="agentPanelStore.toggle()"
|
||||
>
|
||||
<i class="icon-[comfy--comfy-c] size-3 text-brand-yellow" />
|
||||
{{ $t('agent.ask') }}
|
||||
</button>
|
||||
<Button
|
||||
v-if="isCloud || isNightly"
|
||||
v-tooltip="{ value: $t('actionbar.feedbackTooltip'), showDelay: 300 }"
|
||||
@@ -93,7 +109,7 @@
|
||||
:aria-label="$t('actionbar.feedback')"
|
||||
@click="openFeedback"
|
||||
>
|
||||
<i class="icon-[lucide--message-square-text]" />
|
||||
<i class="icon-[lucide--megaphone]" />
|
||||
</Button>
|
||||
<CurrentUserButton v-if="showCurrentUser" compact class="shrink-0 p-1" />
|
||||
<LoginButton
|
||||
@@ -106,7 +122,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { useScroll } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import ScrollPanel from 'primevue/scrollpanel'
|
||||
import SelectButton from 'primevue/selectbutton'
|
||||
import { computed, nextTick, onUpdated, ref, watch } from 'vue'
|
||||
@@ -124,6 +142,7 @@ import { buildFeedbackTypeformUrl } from '@/platform/support/config'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useAgentPanelStore } from '@/platform/agent/stores/agentPanelStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
|
||||
@@ -145,6 +164,8 @@ const workspaceStore = useWorkspaceStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const workflowService = useWorkflowService()
|
||||
const commandStore = useCommandStore()
|
||||
const agentPanelStore = useAgentPanelStore()
|
||||
const { isOpen: isAgentPanelOpen } = storeToRefs(agentPanelStore)
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
|
||||
// Dismiss a tab's terminal status badge once it has been viewed
|
||||
|
||||
23
src/components/ui/empty/Empty.vue
Normal file
23
src/components/ui/empty/Empty.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="empty"
|
||||
:class="
|
||||
cn(
|
||||
'flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg p-6 text-center text-balance md:p-12',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
18
src/components/ui/empty/EmptyDescription.vue
Normal file
18
src/components/ui/empty/EmptyDescription.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p
|
||||
data-slot="empty-description"
|
||||
:class="cn('text-sm text-muted-foreground', className)"
|
||||
>
|
||||
<slot />
|
||||
</p>
|
||||
</template>
|
||||
20
src/components/ui/empty/EmptyHeader.vue
Normal file
20
src/components/ui/empty/EmptyHeader.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="empty-header"
|
||||
:class="
|
||||
cn('flex max-w-sm flex-col items-center gap-2 text-center', className)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
27
src/components/ui/empty/EmptyMedia.vue
Normal file
27
src/components/ui/empty/EmptyMedia.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { variant = 'default', class: className } = defineProps<{
|
||||
variant?: 'default' | 'icon'
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="empty-media"
|
||||
:data-variant="variant"
|
||||
:class="
|
||||
cn(
|
||||
'mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
variant === 'icon' &&
|
||||
'text-foreground size-10 rounded-lg bg-muted [&_svg:not([class*=\'size-\'])]:size-6',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
18
src/components/ui/empty/EmptyTitle.vue
Normal file
18
src/components/ui/empty/EmptyTitle.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="empty-title"
|
||||
:class="cn('text-lg font-medium tracking-tight', className)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
42
src/components/ui/tooltip/Tooltip.vue
Normal file
42
src/components/ui/tooltip/Tooltip.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { onUnmounted, provide, ref } from 'vue'
|
||||
|
||||
import type { TooltipContext } from './tooltipContext'
|
||||
import { TOOLTIP_KEY } from './tooltipContext'
|
||||
|
||||
const { delayDuration = 300 } = defineProps<{
|
||||
delayDuration?: number
|
||||
}>()
|
||||
|
||||
const open = ref(false)
|
||||
const triggerEl = ref<HTMLElement | null>(null)
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function scheduleOpen() {
|
||||
timer = setTimeout(() => {
|
||||
open.value = true
|
||||
}, delayDuration)
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
open.value = false
|
||||
}
|
||||
|
||||
onUnmounted(close)
|
||||
|
||||
provide<TooltipContext>(TOOLTIP_KEY, {
|
||||
open,
|
||||
triggerEl,
|
||||
delayDuration,
|
||||
scheduleOpen,
|
||||
close
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<slot />
|
||||
</template>
|
||||
81
src/components/ui/tooltip/TooltipContent.vue
Normal file
81
src/components/ui/tooltip/TooltipContent.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import type { CSSProperties, HTMLAttributes } from 'vue'
|
||||
import { inject, ref, watch } from 'vue'
|
||||
|
||||
import { TOOLTIP_KEY } from './tooltipContext'
|
||||
|
||||
const { class: className, side = 'bottom' } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
side?: 'top' | 'bottom' | 'left' | 'right'
|
||||
}>()
|
||||
|
||||
const ctx = inject(TOOLTIP_KEY)
|
||||
const style = ref<CSSProperties>({})
|
||||
|
||||
function computeStyle() {
|
||||
if (!ctx?.triggerEl.value) return {}
|
||||
const rect = ctx.triggerEl.value.getBoundingClientRect()
|
||||
const gap = 6
|
||||
|
||||
if (side === 'top') {
|
||||
return {
|
||||
left: `${rect.left + rect.width / 2}px`,
|
||||
top: `${rect.top - gap}px`,
|
||||
transform: 'translate(-50%, -100%)'
|
||||
}
|
||||
}
|
||||
if (side === 'left') {
|
||||
return {
|
||||
left: `${rect.left - gap}px`,
|
||||
top: `${rect.top + rect.height / 2}px`,
|
||||
transform: 'translate(-100%, -50%)'
|
||||
}
|
||||
}
|
||||
if (side === 'right') {
|
||||
return {
|
||||
left: `${rect.right + gap}px`,
|
||||
top: `${rect.top + rect.height / 2}px`,
|
||||
transform: 'translateY(-50%)'
|
||||
}
|
||||
}
|
||||
return {
|
||||
left: `${rect.left + rect.width / 2}px`,
|
||||
top: `${rect.bottom + gap}px`,
|
||||
transform: 'translateX(-50%)'
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => ctx?.open.value,
|
||||
(open) => {
|
||||
if (open) style.value = computeStyle()
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
enter-active-class="transition-opacity duration-100"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition-opacity duration-75"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="ctx?.open.value"
|
||||
:style="style"
|
||||
:class="
|
||||
cn(
|
||||
'pointer-events-none fixed z-9999 max-w-xs rounded-md border border-node-component-tooltip-border bg-node-component-tooltip-surface px-2 py-1 text-xs leading-none text-node-component-tooltip',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
50
src/components/ui/tooltip/TooltipTrigger.vue
Normal file
50
src/components/ui/tooltip/TooltipTrigger.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import { inject, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import { TOOLTIP_KEY } from './tooltipContext'
|
||||
|
||||
const ctx = inject(TOOLTIP_KEY)
|
||||
|
||||
const el = ref<HTMLElement | null>(null)
|
||||
|
||||
function onMouseEnter() {
|
||||
ctx?.scheduleOpen()
|
||||
}
|
||||
|
||||
function onMouseLeave() {
|
||||
ctx?.close()
|
||||
}
|
||||
|
||||
function onFocus() {
|
||||
ctx?.scheduleOpen()
|
||||
}
|
||||
|
||||
function onBlur() {
|
||||
ctx?.close()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!el.value || !ctx) return
|
||||
// display:contents removes the wrapper's box; use the real child for positioning
|
||||
ctx.triggerEl.value =
|
||||
(el.value.firstElementChild as HTMLElement | null) ?? el.value
|
||||
el.value.addEventListener('mouseenter', onMouseEnter)
|
||||
el.value.addEventListener('mouseleave', onMouseLeave)
|
||||
el.value.addEventListener('focus', onFocus)
|
||||
el.value.addEventListener('blur', onBlur)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (!el.value) return
|
||||
el.value.removeEventListener('mouseenter', onMouseEnter)
|
||||
el.value.removeEventListener('mouseleave', onMouseLeave)
|
||||
el.value.removeEventListener('focus', onFocus)
|
||||
el.value.removeEventListener('blur', onBlur)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="el" class="contents">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
11
src/components/ui/tooltip/tooltipContext.ts
Normal file
11
src/components/ui/tooltip/tooltipContext.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { InjectionKey, Ref } from 'vue'
|
||||
|
||||
export interface TooltipContext {
|
||||
open: Ref<boolean>
|
||||
triggerEl: Ref<HTMLElement | null>
|
||||
delayDuration: number
|
||||
scheduleOpen: () => void
|
||||
close: () => void
|
||||
}
|
||||
|
||||
export const TOOLTIP_KEY: InjectionKey<TooltipContext> = Symbol('tooltip')
|
||||
@@ -9,6 +9,7 @@ import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
|
||||
const DEFAULT_TITLE = 'ComfyUI'
|
||||
const TITLE_SUFFIX = ' - ComfyUI'
|
||||
const BRANCH_PREFIX = __GIT_BRANCH_PREFIX__ ? `[${__GIT_BRANCH_PREFIX__}] ` : ''
|
||||
|
||||
export const useBrowserTabTitle = () => {
|
||||
const executionStore = useExecutionStore()
|
||||
@@ -90,6 +91,8 @@ export const useBrowserTabTitle = () => {
|
||||
(newMenuEnabled.value ? workflowNameText.value : DEFAULT_TITLE)
|
||||
)
|
||||
|
||||
const title = computed(() => nodeExecutionTitle.value || workflowTitle.value)
|
||||
const title = computed(
|
||||
() => BRANCH_PREFIX + (nodeExecutionTitle.value || workflowTitle.value)
|
||||
)
|
||||
useTitle(title)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,48 @@
|
||||
{
|
||||
"agent": {
|
||||
"title": "Comfy Agent",
|
||||
"label": "Agent",
|
||||
"ask": "Ask Comfy Agent",
|
||||
"alpha": "ALPHA",
|
||||
"newChat": "New chat",
|
||||
"togglePanel": "Toggle panel",
|
||||
"greeting": "Hello,",
|
||||
"greetingNamed": "Hello {name},",
|
||||
"greetingQuestion": "What do you want to make?",
|
||||
"placeholder": "Ask Comfy Agent…",
|
||||
"attach": "Attach files or photos",
|
||||
"mention": "Mention",
|
||||
"send": "Send",
|
||||
"scrollToBottom": "Scroll to latest",
|
||||
"disclaimer": "Agent generation does not impact the graph.",
|
||||
"suggestions": {
|
||||
"duck": "Generate a yellow duck with a hockey mask",
|
||||
"savedWorkflows": "List my saved workflows",
|
||||
"skinUpscaling": "Find the best workflow for skin upscaling",
|
||||
"explainNode": "Explain the selected node",
|
||||
"imageToVideo": "Build a workflow for image to video with 3 models"
|
||||
},
|
||||
"thinking": "Thinking…",
|
||||
"toolCalls": {
|
||||
"summary": "Ran {count} tool calls for {duration}"
|
||||
},
|
||||
"message": {
|
||||
"thumbsUp": "Good response",
|
||||
"thumbsDown": "Bad response",
|
||||
"copy": "Copy"
|
||||
},
|
||||
"history": {
|
||||
"title": "Chat History",
|
||||
"current": "Current",
|
||||
"today": "Today",
|
||||
"yesterday": "Yesterday",
|
||||
"last7Days": "Last 7 days",
|
||||
"last30Days": "Last 30 days",
|
||||
"emptyTitle": "No chats yet",
|
||||
"emptyDescription": "Start a conversation and it will appear here.",
|
||||
"startChat": "Start a chat"
|
||||
}
|
||||
},
|
||||
"g": {
|
||||
"shortcutSuffix": " ({shortcut})",
|
||||
"user": "User",
|
||||
|
||||
37
src/platform/agent/components/AgentChatEmptyState.vue
Normal file
37
src/platform/agent/components/AgentChatEmptyState.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import Empty from '@/components/ui/empty/Empty.vue'
|
||||
import EmptyHeader from '@/components/ui/empty/EmptyHeader.vue'
|
||||
import EmptyMedia from '@/components/ui/empty/EmptyMedia.vue'
|
||||
import EmptyTitle from '@/components/ui/empty/EmptyTitle.vue'
|
||||
|
||||
const { name } = defineProps<{
|
||||
name?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Empty class="pt-12">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia>
|
||||
<div class="rounded-xl border border-plum-600">
|
||||
<img
|
||||
src="/assets/images/comfy-logo-single.svg"
|
||||
alt=""
|
||||
class="block size-12"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</EmptyMedia>
|
||||
<EmptyTitle
|
||||
class="text-base/snug font-semibold text-base-foreground"
|
||||
>
|
||||
<span class="block">
|
||||
{{
|
||||
name ? $t('agent.greetingNamed', { name }) : $t('agent.greeting')
|
||||
}}
|
||||
</span>
|
||||
<span class="block">{{ $t('agent.greetingQuestion') }}</span>
|
||||
</EmptyTitle>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
</template>
|
||||
54
src/platform/agent/components/AgentChatHeader.vue
Normal file
54
src/platform/agent/components/AgentChatHeader.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Tooltip from '@/components/ui/tooltip/Tooltip.vue'
|
||||
import TooltipContent from '@/components/ui/tooltip/TooltipContent.vue'
|
||||
import TooltipTrigger from '@/components/ui/tooltip/TooltipTrigger.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
newChat: []
|
||||
close: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex h-12 shrink-0 items-center justify-between border-b border-component-node-border px-4"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-base-foreground">{{ $t('agent.title') }}</span>
|
||||
<span
|
||||
class="rounded-full border border-border-default px-2 py-0.5 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ $t('agent.alpha') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<Tooltip :delay-duration="300">
|
||||
<TooltipTrigger>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
:aria-label="$t('agent.newChat')"
|
||||
@click="emit('newChat')"
|
||||
>
|
||||
<i class="icon-[lucide--message-circle-plus] size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{{ $t('agent.newChat') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip :delay-duration="300">
|
||||
<TooltipTrigger>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
:aria-label="$t('g.close')"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{{ $t('g.close') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
120
src/platform/agent/components/AgentChatHistory.vue
Normal file
120
src/platform/agent/components/AgentChatHistory.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Empty from '@/components/ui/empty/Empty.vue'
|
||||
import EmptyDescription from '@/components/ui/empty/EmptyDescription.vue'
|
||||
import EmptyHeader from '@/components/ui/empty/EmptyHeader.vue'
|
||||
import EmptyMedia from '@/components/ui/empty/EmptyMedia.vue'
|
||||
import EmptyTitle from '@/components/ui/empty/EmptyTitle.vue'
|
||||
import type { AgentConversation } from '@/platform/agent/composables/useAgentChatPrototype'
|
||||
import AgentChatHistoryGroupLabel from './AgentChatHistoryGroupLabel.vue'
|
||||
import AgentChatHistoryItem from './AgentChatHistoryItem.vue'
|
||||
|
||||
const { conversations, activeId } = defineProps<{
|
||||
conversations: readonly AgentConversation[]
|
||||
activeId?: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
back: []
|
||||
select: [id: string]
|
||||
delete: [id: string]
|
||||
copy: [id: string]
|
||||
newChat: []
|
||||
}>()
|
||||
|
||||
type GroupKey = 'today' | 'yesterday' | 'last7Days' | 'last30Days'
|
||||
|
||||
interface Group {
|
||||
key: GroupKey
|
||||
labelKey: string
|
||||
items: AgentConversation[]
|
||||
}
|
||||
|
||||
function getGroupKey(date: Date): GroupKey {
|
||||
const now = new Date()
|
||||
const diffDays = (now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24)
|
||||
|
||||
if (diffDays < 1) return 'today'
|
||||
if (diffDays < 2) return 'yesterday'
|
||||
if (diffDays < 7) return 'last7Days'
|
||||
return 'last30Days'
|
||||
}
|
||||
|
||||
const labelKeys: Record<GroupKey, string> = {
|
||||
today: 'agent.history.today',
|
||||
yesterday: 'agent.history.yesterday',
|
||||
last7Days: 'agent.history.last7Days',
|
||||
last30Days: 'agent.history.last30Days'
|
||||
}
|
||||
|
||||
const order: GroupKey[] = ['today', 'yesterday', 'last7Days', 'last30Days']
|
||||
|
||||
const groups = computed<Group[]>(() => {
|
||||
const buckets: Record<GroupKey, AgentConversation[]> = {
|
||||
today: [],
|
||||
yesterday: [],
|
||||
last7Days: [],
|
||||
last30Days: []
|
||||
}
|
||||
|
||||
for (const conv of conversations) {
|
||||
buckets[getGroupKey(conv.createdAt)].push(conv)
|
||||
}
|
||||
|
||||
return order
|
||||
.filter((key) => buckets[key].length > 0)
|
||||
.map((key) => ({ key, labelKey: labelKeys[key], items: buckets[key] }))
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col overflow-hidden">
|
||||
<div class="flex shrink-0 items-center px-2 py-1.5">
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-6 cursor-pointer items-center gap-1 rounded-sm border-0 bg-transparent px-2 text-xs text-muted-foreground hover:bg-secondary-background-hover"
|
||||
@click="emit('back')"
|
||||
>
|
||||
<i class="icon-[lucide--arrow-left] size-3" />
|
||||
<span>{{ $t('agent.history.title') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 flex-col overflow-y-auto p-2">
|
||||
<Empty v-if="groups.length === 0">
|
||||
<EmptyMedia variant="icon">
|
||||
<i class="icon-[lucide--history] size-5" />
|
||||
</EmptyMedia>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>{{ $t('agent.history.emptyTitle') }}</EmptyTitle>
|
||||
<EmptyDescription>{{
|
||||
$t('agent.history.emptyDescription')
|
||||
}}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<Button variant="primary" size="lg" @click="emit('newChat')">
|
||||
{{ $t('agent.history.startChat') }}
|
||||
</Button>
|
||||
</Empty>
|
||||
|
||||
<div v-for="group in groups" :key="group.key" class="mb-3">
|
||||
<AgentChatHistoryGroupLabel>{{
|
||||
$t(group.labelKey)
|
||||
}}</AgentChatHistoryGroupLabel>
|
||||
<ul class="flex list-none flex-col gap-0.5 pl-0">
|
||||
<AgentChatHistoryItem
|
||||
v-for="item in group.items"
|
||||
:key="item.id"
|
||||
:active="item.id === activeId"
|
||||
@select="emit('select', item.id)"
|
||||
@delete="emit('delete', item.id)"
|
||||
@copy="emit('copy', item.id)"
|
||||
>
|
||||
<span class="truncate">{{ item.title }}</span>
|
||||
</AgentChatHistoryItem>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<p class="my-0 py-0 text-xs font-medium text-muted-foreground">
|
||||
<slot />
|
||||
</p>
|
||||
</template>
|
||||
61
src/platform/agent/components/AgentChatHistoryItem.vue
Normal file
61
src/platform/agent/components/AgentChatHistoryItem.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import Tooltip from '@/components/ui/tooltip/Tooltip.vue'
|
||||
import TooltipContent from '@/components/ui/tooltip/TooltipContent.vue'
|
||||
import TooltipTrigger from '@/components/ui/tooltip/TooltipTrigger.vue'
|
||||
|
||||
const { active = false } = defineProps<{
|
||||
active?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: []
|
||||
delete: []
|
||||
copy: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li
|
||||
class="group flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-secondary-background-hover"
|
||||
:class="{ 'bg-secondary-background': active }"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex flex-1 cursor-pointer items-center gap-2 overflow-hidden border-0 bg-transparent text-left text-sm text-base-foreground"
|
||||
@click="emit('select')"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--circle-check] size-3.5 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<slot />
|
||||
</button>
|
||||
<div class="hidden shrink-0 items-center gap-0.5 group-hover:flex">
|
||||
<Tooltip :delay-duration="300">
|
||||
<TooltipTrigger>
|
||||
<button
|
||||
type="button"
|
||||
class="flex cursor-pointer items-center justify-center rounded-sm border-0 bg-transparent p-0.5 text-muted-foreground hover:bg-secondary-background-hover hover:text-base-foreground"
|
||||
:aria-label="$t('g.copy')"
|
||||
@click.stop="emit('copy')"
|
||||
>
|
||||
<i class="icon-[lucide--copy] size-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{{ $t('g.copy') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip :delay-duration="300">
|
||||
<TooltipTrigger>
|
||||
<button
|
||||
type="button"
|
||||
class="hover:text-danger flex cursor-pointer items-center justify-center rounded-sm border-0 bg-transparent p-0.5 text-muted-foreground hover:bg-destructive-background/10"
|
||||
:aria-label="$t('g.delete')"
|
||||
@click.stop="emit('delete')"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2] size-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{{ $t('g.delete') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
300
src/platform/agent/components/AgentChatPanel.vue
Normal file
300
src/platform/agent/components/AgentChatPanel.vue
Normal file
@@ -0,0 +1,300 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
import Conversation from '@/components/ai-elements/conversation/Conversation.vue'
|
||||
import ConversationContent from '@/components/ai-elements/conversation/ConversationContent.vue'
|
||||
import ConversationEmptyState from '@/components/ai-elements/conversation/ConversationEmptyState.vue'
|
||||
import ConversationScrollButton from '@/components/ai-elements/conversation/ConversationScrollButton.vue'
|
||||
import Message from '@/components/ai-elements/message/Message.vue'
|
||||
import MessageAction from '@/components/ai-elements/message/MessageAction.vue'
|
||||
import MessageActions from '@/components/ai-elements/message/MessageActions.vue'
|
||||
import MessageAttachments from '@/components/ai-elements/message/MessageAttachments.vue'
|
||||
import MessageContent from '@/components/ai-elements/message/MessageContent.vue'
|
||||
import MessageResponse from '@/components/ai-elements/message/MessageResponse.vue'
|
||||
import MessageThinking from '@/components/ai-elements/message/MessageThinking.vue'
|
||||
import MessageToolCalls from '@/components/ai-elements/message/MessageToolCalls.vue'
|
||||
import PromptInput from '@/components/ai-elements/prompt-input/PromptInput.vue'
|
||||
import PromptInputAttachments from '@/components/ai-elements/prompt-input/PromptInputAttachments.vue'
|
||||
import PromptInputBody from '@/components/ai-elements/prompt-input/PromptInputBody.vue'
|
||||
import PromptInputButton from '@/components/ai-elements/prompt-input/PromptInputButton.vue'
|
||||
import PromptInputModelSelect from '@/components/ai-elements/prompt-input/PromptInputModelSelect.vue'
|
||||
import PromptInputSubmit from '@/components/ai-elements/prompt-input/PromptInputSubmit.vue'
|
||||
import PromptInputTextarea from '@/components/ai-elements/prompt-input/PromptInputTextarea.vue'
|
||||
import PromptInputToolbar from '@/components/ai-elements/prompt-input/PromptInputToolbar.vue'
|
||||
import PromptInputTools from '@/components/ai-elements/prompt-input/PromptInputTools.vue'
|
||||
import { useAgentChatPrototype } from '@/platform/agent/composables/useAgentChatPrototype'
|
||||
import { useAgentPanelStore } from '@/platform/agent/stores/agentPanelStore'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
import Tooltip from '@/components/ui/tooltip/Tooltip.vue'
|
||||
import TooltipContent from '@/components/ui/tooltip/TooltipContent.vue'
|
||||
import TooltipTrigger from '@/components/ui/tooltip/TooltipTrigger.vue'
|
||||
|
||||
import AgentChatEmptyState from './AgentChatEmptyState.vue'
|
||||
import AgentChatHeader from './AgentChatHeader.vue'
|
||||
import AgentChatHistory from './AgentChatHistory.vue'
|
||||
import AgentPromptSuggestions from './AgentPromptSuggestions.vue'
|
||||
|
||||
const {
|
||||
messages,
|
||||
input,
|
||||
status,
|
||||
isEmpty,
|
||||
chatHistory,
|
||||
currentConversationId,
|
||||
send,
|
||||
stop,
|
||||
applySuggestion,
|
||||
startNewChat,
|
||||
loadConversation,
|
||||
deleteConversation,
|
||||
copyConversation
|
||||
} = useAgentChatPrototype()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const agentPanelStore = useAgentPanelStore()
|
||||
|
||||
const model = ref('Auto')
|
||||
const showHistory = ref(false)
|
||||
const promptTextarea = ref<{ focus: () => void } | null>(null)
|
||||
const reactions = ref<Record<string, 'liked' | 'disliked' | null>>({})
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const attachments = ref<File[]>([])
|
||||
|
||||
const userName = computed(
|
||||
() => authStore.currentUser?.displayName?.split(' ')[0] ?? ''
|
||||
)
|
||||
|
||||
const conversationTitle = computed(
|
||||
() => messages.value.find((message) => message.role === 'user')?.text
|
||||
)
|
||||
|
||||
const submitDisabled = computed(
|
||||
() => status.value === 'ready' && input.value.trim() === ''
|
||||
)
|
||||
|
||||
function onSubmit() {
|
||||
if (status.value === 'submitted' || status.value === 'streaming') {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
send(undefined, attachments.value)
|
||||
attachments.value = []
|
||||
}
|
||||
|
||||
function removeAttachment(index: number) {
|
||||
attachments.value = attachments.value.filter((_, i) => i !== index)
|
||||
}
|
||||
|
||||
function close() {
|
||||
agentPanelStore.close()
|
||||
}
|
||||
|
||||
function openFilePicker() {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
function onFilesSelected(e: Event) {
|
||||
const files = (e.target as HTMLInputElement).files
|
||||
if (!files) return
|
||||
attachments.value = [...attachments.value, ...Array.from(files)]
|
||||
;(e.target as HTMLInputElement).value = ''
|
||||
}
|
||||
|
||||
function toggleReaction(id: string, reaction: 'liked' | 'disliked') {
|
||||
reactions.value[id] = reactions.value[id] === reaction ? null : reaction
|
||||
}
|
||||
|
||||
function copyMessage(text: string) {
|
||||
navigator.clipboard.writeText(text)
|
||||
}
|
||||
|
||||
function onSelectConversation(id: string) {
|
||||
loadConversation(id)
|
||||
showHistory.value = false
|
||||
}
|
||||
|
||||
function onNewChatFromHistory() {
|
||||
startNewChat()
|
||||
showHistory.value = false
|
||||
nextTick(() => promptTextarea.value?.focus())
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col overflow-hidden bg-base-background">
|
||||
<AgentChatHeader @new-chat="startNewChat" @close="close" />
|
||||
|
||||
<template v-if="showHistory">
|
||||
<AgentChatHistory
|
||||
:conversations="chatHistory"
|
||||
:active-id="currentConversationId"
|
||||
@back="showHistory = false"
|
||||
@select="onSelectConversation"
|
||||
@delete="deleteConversation"
|
||||
@copy="copyConversation"
|
||||
@new-chat="onNewChatFromHistory"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="flex shrink-0 items-center px-2 py-1.5">
|
||||
<Tooltip :delay-duration="500">
|
||||
<TooltipTrigger>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-6 cursor-pointer items-center gap-1 rounded-sm border-0 bg-transparent px-2 text-xs text-muted-foreground hover:bg-secondary-background-hover"
|
||||
@click="showHistory = true"
|
||||
>
|
||||
<i class="icon-[lucide--align-justify] size-3.5" />
|
||||
<span class="max-w-56 truncate">
|
||||
{{ conversationTitle ?? $t('agent.newChat') }}
|
||||
</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{{ $t('agent.history.title') }}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<ConversationEmptyState v-if="isEmpty">
|
||||
<AgentChatEmptyState :name="userName" />
|
||||
</ConversationEmptyState>
|
||||
<Conversation v-else>
|
||||
<template #overlay>
|
||||
<ConversationScrollButton />
|
||||
</template>
|
||||
<ConversationContent class="mx-auto w-full max-w-[640px]">
|
||||
<Message
|
||||
v-for="message in messages"
|
||||
:key="message.id"
|
||||
:from="message.role"
|
||||
>
|
||||
<!-- User messages: attachments float above the text bubble -->
|
||||
<template v-if="message.role === 'user'">
|
||||
<div class="flex flex-col items-end gap-2">
|
||||
<MessageAttachments
|
||||
v-if="message.attachments?.length"
|
||||
:attachments="message.attachments"
|
||||
/>
|
||||
<MessageContent v-if="message.text">
|
||||
<MessageResponse
|
||||
:content="message.text"
|
||||
class="agent-markdown"
|
||||
/>
|
||||
</MessageContent>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Assistant messages -->
|
||||
<MessageContent v-else>
|
||||
<MessageThinking v-if="message.thinking" />
|
||||
<MessageToolCalls
|
||||
v-else-if="message.toolCalls?.length"
|
||||
:tool-calls="message.toolCalls"
|
||||
:complete="
|
||||
status === 'ready' ||
|
||||
message !== messages[messages.length - 1]
|
||||
"
|
||||
/>
|
||||
<MessageResponse
|
||||
v-if="message.text"
|
||||
:content="message.text"
|
||||
class="agent-markdown"
|
||||
/>
|
||||
<MessageActions
|
||||
v-if="
|
||||
message.text &&
|
||||
(status === 'ready' ||
|
||||
message !== messages[messages.length - 1])
|
||||
"
|
||||
>
|
||||
<MessageAction
|
||||
:tooltip="$t('agent.message.thumbsUp')"
|
||||
:pressed="reactions[message.id] === 'liked'"
|
||||
@click="toggleReaction(message.id, 'liked')"
|
||||
>
|
||||
<i class="icon-[lucide--thumbs-up] size-3.5" />
|
||||
</MessageAction>
|
||||
<MessageAction
|
||||
:tooltip="$t('agent.message.thumbsDown')"
|
||||
:pressed="reactions[message.id] === 'disliked'"
|
||||
@click="toggleReaction(message.id, 'disliked')"
|
||||
>
|
||||
<i class="icon-[lucide--thumbs-down] size-3.5" />
|
||||
</MessageAction>
|
||||
<MessageAction
|
||||
:tooltip="$t('agent.message.copy')"
|
||||
@click="copyMessage(message.text)"
|
||||
>
|
||||
<i class="icon-[lucide--copy] size-3.5" />
|
||||
</MessageAction>
|
||||
</MessageActions>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
</ConversationContent>
|
||||
</Conversation>
|
||||
|
||||
<div class="flex shrink-0 flex-col gap-4 p-4">
|
||||
<div
|
||||
class="@container mx-auto flex w-full max-w-[640px] flex-col gap-4"
|
||||
>
|
||||
<AgentPromptSuggestions v-if="isEmpty" @select="applySuggestion" />
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<PromptInput @submit="onSubmit">
|
||||
<PromptInputBody>
|
||||
<PromptInputAttachments
|
||||
:attachments="attachments"
|
||||
@remove="removeAttachment"
|
||||
/>
|
||||
<PromptInputTextarea
|
||||
ref="promptTextarea"
|
||||
v-model="input"
|
||||
:placeholder="$t('agent.placeholder')"
|
||||
/>
|
||||
<PromptInputToolbar>
|
||||
<PromptInputTools>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
multiple
|
||||
class="hidden"
|
||||
@change="onFilesSelected"
|
||||
/>
|
||||
<Tooltip :delay-duration="500">
|
||||
<TooltipTrigger>
|
||||
<PromptInputButton
|
||||
:aria-label="$t('agent.attach')"
|
||||
@click="openFilePicker"
|
||||
>
|
||||
<i class="icon-[lucide--paperclip] size-4" />
|
||||
</PromptInputButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" class="whitespace-nowrap">
|
||||
{{ $t('agent.attach') }}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<PromptInputButton :aria-label="$t('agent.mention')">
|
||||
<i class="icon-[lucide--at-sign] size-4" />
|
||||
</PromptInputButton>
|
||||
</PromptInputTools>
|
||||
<PromptInputTools>
|
||||
<PromptInputModelSelect v-model="model" />
|
||||
<PromptInputSubmit
|
||||
:status="status"
|
||||
:disabled="submitDisabled"
|
||||
/>
|
||||
</PromptInputTools>
|
||||
</PromptInputToolbar>
|
||||
</PromptInputBody>
|
||||
</PromptInput>
|
||||
<p class="my-0 text-center text-xs text-muted-foreground">
|
||||
{{ $t('agent.disclaimer') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
30
src/platform/agent/components/AgentPromptSuggestions.vue
Normal file
30
src/platform/agent/components/AgentPromptSuggestions.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import Suggestion from '@/components/ai-elements/suggestion/Suggestion.vue'
|
||||
import Suggestions from '@/components/ai-elements/suggestion/Suggestions.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [suggestion: string]
|
||||
}>()
|
||||
|
||||
const suggestions = [
|
||||
{ key: 'duck', icon: 'icon-[lucide--lightbulb]' },
|
||||
{ key: 'savedWorkflows', icon: 'icon-[lucide--list]' },
|
||||
{ key: 'skinUpscaling', icon: 'icon-[lucide--search]' },
|
||||
{ key: 'explainNode', icon: 'icon-[lucide--message-circle-warning]' },
|
||||
{ key: 'imageToVideo', icon: 'icon-[lucide--workflow]' }
|
||||
] as const
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Suggestions>
|
||||
<Suggestion
|
||||
v-for="item in suggestions"
|
||||
:key="item.key"
|
||||
:suggestion="$t(`agent.suggestions.${item.key}`)"
|
||||
@select="emit('select', $event)"
|
||||
>
|
||||
<i :class="item.icon" class="size-3 shrink-0 text-muted-foreground" />
|
||||
<span>{{ $t(`agent.suggestions.${item.key}`) }}</span>
|
||||
</Suggestion>
|
||||
</Suggestions>
|
||||
</template>
|
||||
81
src/platform/agent/composables/useAgentChatPrototype.test.ts
Normal file
81
src/platform/agent/composables/useAgentChatPrototype.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useAgentChatPrototype } from './useAgentChatPrototype'
|
||||
|
||||
describe('useAgentChatPrototype', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
useAgentChatPrototype().startNewChat()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('starts empty', () => {
|
||||
const { messages, isEmpty } = useAgentChatPrototype()
|
||||
expect(messages.value).toHaveLength(0)
|
||||
expect(isEmpty.value).toBe(true)
|
||||
})
|
||||
|
||||
it('appends a user message and clears the input on send', () => {
|
||||
const { messages, input, send, isEmpty } = useAgentChatPrototype()
|
||||
|
||||
input.value = 'make a duck'
|
||||
send()
|
||||
|
||||
expect(messages.value).toHaveLength(1)
|
||||
expect(messages.value[0]).toMatchObject({
|
||||
role: 'user',
|
||||
text: 'make a duck'
|
||||
})
|
||||
expect(input.value).toBe('')
|
||||
expect(isEmpty.value).toBe(false)
|
||||
})
|
||||
|
||||
it('streams a mocked assistant reply after sending', () => {
|
||||
const { messages, send, status } = useAgentChatPrototype()
|
||||
|
||||
send('make a duck')
|
||||
expect(status.value).toBe('submitted')
|
||||
|
||||
vi.advanceTimersByTime(10_000)
|
||||
|
||||
expect(status.value).toBe('ready')
|
||||
expect(messages.value).toHaveLength(2)
|
||||
const reply = messages.value[1]
|
||||
expect(reply.role).toBe('assistant')
|
||||
expect(reply.text).toContain('make a duck')
|
||||
})
|
||||
|
||||
it('ignores send while a reply is in progress', () => {
|
||||
const { messages, send } = useAgentChatPrototype()
|
||||
|
||||
send('first')
|
||||
send('second')
|
||||
|
||||
expect(messages.value).toHaveLength(1)
|
||||
expect(messages.value[0].text).toBe('first')
|
||||
})
|
||||
|
||||
it('applies a suggestion to the input', () => {
|
||||
const { input, applySuggestion } = useAgentChatPrototype()
|
||||
|
||||
applySuggestion('List my saved workflows')
|
||||
|
||||
expect(input.value).toBe('List my saved workflows')
|
||||
})
|
||||
|
||||
it('clears the conversation on startNewChat', () => {
|
||||
const { messages, send, startNewChat, isEmpty } = useAgentChatPrototype()
|
||||
|
||||
send('make a duck')
|
||||
vi.advanceTimersByTime(10_000)
|
||||
expect(messages.value.length).toBeGreaterThan(0)
|
||||
|
||||
startNewChat()
|
||||
|
||||
expect(messages.value).toHaveLength(0)
|
||||
expect(isEmpty.value).toBe(true)
|
||||
})
|
||||
})
|
||||
479
src/platform/agent/composables/useAgentChatPrototype.ts
Normal file
479
src/platform/agent/composables/useAgentChatPrototype.ts
Normal file
@@ -0,0 +1,479 @@
|
||||
import { computed, readonly, ref } from 'vue'
|
||||
|
||||
import type { ChatStatus } from '@/components/ai-elements/prompt-input/types'
|
||||
|
||||
export interface ToolCall {
|
||||
name: string
|
||||
status: 'success' | 'error'
|
||||
durationMs: number
|
||||
}
|
||||
|
||||
export interface MessageAttachment {
|
||||
name: string
|
||||
type: string
|
||||
url: string
|
||||
size: number
|
||||
}
|
||||
|
||||
interface AgentMessage {
|
||||
id: string
|
||||
role: 'user' | 'assistant'
|
||||
text: string
|
||||
attachments?: readonly MessageAttachment[]
|
||||
thinking?: boolean
|
||||
toolCalls?: readonly ToolCall[]
|
||||
}
|
||||
|
||||
export interface AgentConversation {
|
||||
id: string
|
||||
title: string
|
||||
createdAt: Date
|
||||
messages: readonly AgentMessage[]
|
||||
}
|
||||
|
||||
const STREAM_INTERVAL_MS = 40
|
||||
const THINKING_DELAY_MS = 500
|
||||
const TOOL_CALLS_DELAY_MS = 1200
|
||||
|
||||
const MOCK_TOOL_CALLS: ToolCall[] = [
|
||||
{ name: 'Opening template', status: 'success', durationMs: 200 },
|
||||
{ name: 'New workflow', status: 'success', durationMs: 1300 },
|
||||
{ name: 'Set node widget', status: 'error', durationMs: 1200 },
|
||||
{ name: 'Pointing to node', status: 'success', durationMs: 1100 },
|
||||
{ name: 'Set node widget', status: 'error', durationMs: 200 }
|
||||
]
|
||||
|
||||
const daysAgo = (n: number) => new Date(Date.now() - n * 24 * 60 * 60 * 1000)
|
||||
|
||||
const FENCE = '```'
|
||||
|
||||
const DEMO_CONVERSATIONS: AgentConversation[] = [
|
||||
{
|
||||
id: 'demo-code-block',
|
||||
title: 'Code block',
|
||||
createdAt: daysAgo(0),
|
||||
messages: [
|
||||
{ id: 'demo-code-1', role: 'user', text: 'Show me a workflow as code' },
|
||||
{
|
||||
id: 'demo-code-2',
|
||||
role: 'assistant',
|
||||
text: `${FENCE}javascript:workflow.js
|
||||
export default {
|
||||
nodes: [
|
||||
{ id: 1, type: "CheckpointLoaderSimple", inputs: { ckpt_name: "flux1-dev-fp8.safetensors" } },
|
||||
{ id: 2, type: "CLIPTextEncode", inputs: { text: "a golden hour sunset over mountains" } },
|
||||
{ id: 3, type: "KSampler", inputs: { seed: 42, steps: 20, cfg: 7, sampler_name: "euler" } },
|
||||
{ id: 4, type: "VAEDecode" },
|
||||
{ id: 5, type: "SaveImage", inputs: { filename_prefix: "output" } },
|
||||
],
|
||||
links: [
|
||||
[1, 0, 3, 0], // model → KSampler
|
||||
[2, 0, 3, 1], // conditioning → KSampler
|
||||
[3, 0, 4, 0], // latent → VAEDecode
|
||||
[4, 0, 5, 0], // image → SaveImage
|
||||
],
|
||||
}
|
||||
${FENCE}`
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'demo-markdown',
|
||||
title: 'Markdown',
|
||||
createdAt: daysAgo(0),
|
||||
messages: [
|
||||
{
|
||||
id: 'demo-md-1',
|
||||
role: 'user',
|
||||
text: 'Explain how to build a basic workflow'
|
||||
},
|
||||
{
|
||||
id: 'demo-md-2',
|
||||
role: 'assistant',
|
||||
text: `# Workflow Overview
|
||||
|
||||
This guide explains how to **build a basic image generation workflow** in ComfyUI.
|
||||
|
||||
## Key Concepts
|
||||
|
||||
- **Nodes** are the building blocks — each one performs a single operation
|
||||
- **Edges** carry data between nodes
|
||||
- Use _italics_ for emphasis and \`inline code\` for node names
|
||||
|
||||
## Steps
|
||||
|
||||
1. Load a checkpoint with \`CheckpointLoaderSimple\`
|
||||
2. Add \`CLIPTextEncode\` and write your prompt
|
||||
3. Connect both to \`KSampler\` to run diffusion
|
||||
4. Decode the result with \`VAEDecode\`
|
||||
5. Save the image with \`SaveImage\`
|
||||
|
||||
> Start with a simple 4-node chain and expand from there.
|
||||
|
||||
See the full reference at [docs.comfy.org](https://docs.comfy.org).`
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'demo-table',
|
||||
title: 'Table',
|
||||
createdAt: daysAgo(0),
|
||||
messages: [
|
||||
{
|
||||
id: 'demo-table-1',
|
||||
role: 'user',
|
||||
text: 'Compare the available samplers'
|
||||
},
|
||||
{
|
||||
id: 'demo-table-2',
|
||||
role: 'assistant',
|
||||
text: `Here is a comparison of common samplers:
|
||||
|
||||
| Sampler | Steps | Quality | Speed |
|
||||
| --- | --- | --- | --- |
|
||||
| euler | 20 | Good | Fast |
|
||||
| euler_a | 20 | Great | Fast |
|
||||
| dpm++ 2m | 25 | Excellent | Medium |
|
||||
| dpm++ sde | 30 | Best | Slow |
|
||||
| ddim | 50 | Good | Slow |
|
||||
|
||||
Use **euler** or **euler_a** to get started quickly.`
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'demo-thinking',
|
||||
title: 'Thinking',
|
||||
createdAt: daysAgo(0),
|
||||
messages: [
|
||||
{ id: 'demo-think-1', role: 'user', text: 'Analyze my current workflow' },
|
||||
{ id: 'demo-think-2', role: 'assistant', text: '', thinking: true }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'demo-tool-calls',
|
||||
title: 'Tool calls',
|
||||
createdAt: daysAgo(0),
|
||||
messages: [
|
||||
{
|
||||
id: 'demo-tools-1',
|
||||
role: 'user',
|
||||
text: 'Build a workflow for image to video'
|
||||
},
|
||||
{
|
||||
id: 'demo-tools-2',
|
||||
role: 'assistant',
|
||||
text: 'I set up the nodes and connections for your image-to-video workflow. The KSampler is configured with sensible defaults — adjust the steps and CFG scale to taste.',
|
||||
toolCalls: MOCK_TOOL_CALLS
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'demo-attachments',
|
||||
title: 'Attachments',
|
||||
createdAt: daysAgo(0),
|
||||
messages: [
|
||||
{
|
||||
id: 'demo-attach-1',
|
||||
role: 'user',
|
||||
text: 'Use this image as a reference',
|
||||
attachments: [
|
||||
{
|
||||
name: 'reference.png',
|
||||
type: 'image/png',
|
||||
url: '',
|
||||
size: 204800
|
||||
},
|
||||
{
|
||||
name: 'style-guide.pdf',
|
||||
type: 'application/pdf',
|
||||
url: '',
|
||||
size: 512000
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'demo-attach-2',
|
||||
role: 'assistant',
|
||||
text: "I can see the reference image. I'll use the visual style and color palette as a guide when configuring the workflow nodes."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const messages = ref<AgentMessage[]>([])
|
||||
const input = ref('')
|
||||
const status = ref<ChatStatus>('ready')
|
||||
const currentConversationId = ref<string | null>(null)
|
||||
const chatHistory = ref<AgentConversation[]>([
|
||||
...DEMO_CONVERSATIONS,
|
||||
{
|
||||
id: 'h-yesterday',
|
||||
title: 'Generate a yellow duck with a hockey mask',
|
||||
createdAt: daysAgo(1),
|
||||
messages: [
|
||||
{
|
||||
id: 'h-y-1',
|
||||
role: 'user',
|
||||
text: 'Generate a yellow duck with a hockey mask'
|
||||
},
|
||||
{
|
||||
id: 'h-y-2',
|
||||
role: 'assistant',
|
||||
text: buildMockReply('Generate a yellow duck with a hockey mask'),
|
||||
toolCalls: MOCK_TOOL_CALLS
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'h-last7',
|
||||
title: 'Build a workflow for image to video with 3 models',
|
||||
createdAt: daysAgo(4),
|
||||
messages: [
|
||||
{
|
||||
id: 'h-l7-1',
|
||||
role: 'user',
|
||||
text: 'Build a workflow for image to video with 3 models'
|
||||
},
|
||||
{
|
||||
id: 'h-l7-2',
|
||||
role: 'assistant',
|
||||
text: buildMockReply(
|
||||
'Build a workflow for image to video with 3 models'
|
||||
),
|
||||
toolCalls: MOCK_TOOL_CALLS
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'h-last30',
|
||||
title: 'Find the best workflow for skin upscaling',
|
||||
createdAt: daysAgo(15),
|
||||
messages: [
|
||||
{
|
||||
id: 'h-l30-1',
|
||||
role: 'user',
|
||||
text: 'Find the best workflow for skin upscaling'
|
||||
},
|
||||
{
|
||||
id: 'h-l30-2',
|
||||
role: 'assistant',
|
||||
text: buildMockReply('Find the best workflow for skin upscaling'),
|
||||
toolCalls: MOCK_TOOL_CALLS
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
let idCounter = 0
|
||||
let streamTimer: ReturnType<typeof setInterval> | null = null
|
||||
let thinkingTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function nextId() {
|
||||
idCounter += 1
|
||||
return `agent-msg-${idCounter}`
|
||||
}
|
||||
|
||||
function buildMockReply(prompt: string) {
|
||||
return [
|
||||
`# Plan for ${prompt}`,
|
||||
'',
|
||||
'## Overview',
|
||||
'',
|
||||
`This is a mocked response for **${prompt}**. It demonstrates the markdown rendering capabilities of the agent chat panel.`,
|
||||
'',
|
||||
'## Steps',
|
||||
'',
|
||||
'1. Inspect the current graph and selected nodes.',
|
||||
'2. Assemble the nodes needed for the request.',
|
||||
'3. Wire the connections and set sensible defaults.',
|
||||
'4. Validate the output and iterate as needed.',
|
||||
'',
|
||||
'## Key Concepts',
|
||||
'',
|
||||
'- **Nodes** are the building blocks of a workflow.',
|
||||
'- **Edges** connect nodes and carry data between them.',
|
||||
'- Use _italics_ for emphasis and `inline code` for node names.',
|
||||
'',
|
||||
'## Before You Start',
|
||||
'',
|
||||
'> Make sure your checkpoint model is downloaded and placed in the `models/checkpoints` folder. The workflow will not run without it.',
|
||||
'',
|
||||
'## Node Reference',
|
||||
'',
|
||||
'| Node | Type | Description |',
|
||||
'| --- | --- | --- |',
|
||||
'| KSampler | Sampler | Runs the diffusion sampling loop |',
|
||||
'| CLIPTextEncode | Conditioning | Encodes a text prompt |',
|
||||
'| VAEDecode | Latent | Decodes latent image to pixels |',
|
||||
'',
|
||||
'## Example Workflow',
|
||||
'',
|
||||
'```javascript:workflow.js',
|
||||
'export default {',
|
||||
' nodes: [',
|
||||
' { id: 1, type: "CheckpointLoaderSimple", inputs: { ckpt_name: "flux1-dev-fp8.safetensors" } },',
|
||||
' { id: 2, type: "CLIPTextEncode", inputs: { text: "a photo of a mountain at sunset" } },',
|
||||
' { id: 3, type: "KSampler", inputs: { seed: 42, steps: 20, cfg: 7, sampler_name: "euler" } },',
|
||||
' { id: 4, type: "VAEDecode" },',
|
||||
' { id: 5, type: "SaveImage", inputs: { filename_prefix: "output" } },',
|
||||
' ],',
|
||||
' links: [',
|
||||
' [1, 0, 3, 0], // model → KSampler',
|
||||
' [2, 0, 3, 1], // conditioning → KSampler',
|
||||
' [3, 0, 4, 0], // latent → VAEDecode',
|
||||
' [4, 0, 5, 0], // image → SaveImage',
|
||||
' ],',
|
||||
'}',
|
||||
'```',
|
||||
'',
|
||||
'## Resources',
|
||||
'',
|
||||
'Download the completed workflow: https://comfyhub.com/workflows/flux-img2img-v2.json',
|
||||
'',
|
||||
'Or grab the model checkpoint from the registry:',
|
||||
'https://comfy.org/models/flux1-dev-fp8.safetensors',
|
||||
'',
|
||||
'_This is a prototype response and does not modify your graph._'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function clearTimers() {
|
||||
if (streamTimer) {
|
||||
clearInterval(streamTimer)
|
||||
streamTimer = null
|
||||
}
|
||||
if (thinkingTimer) {
|
||||
clearTimeout(thinkingTimer)
|
||||
thinkingTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function streamReply(reply: string) {
|
||||
messages.value.push({
|
||||
id: nextId(),
|
||||
role: 'assistant',
|
||||
text: '',
|
||||
thinking: true,
|
||||
toolCalls: undefined
|
||||
})
|
||||
const message = messages.value[messages.value.length - 1]
|
||||
|
||||
thinkingTimer = setTimeout(() => {
|
||||
thinkingTimer = null
|
||||
message.thinking = false
|
||||
message.toolCalls = MOCK_TOOL_CALLS
|
||||
status.value = 'streaming'
|
||||
|
||||
const tokens = reply.split(' ')
|
||||
let index = 0
|
||||
streamTimer = setInterval(() => {
|
||||
if (index >= tokens.length) {
|
||||
clearTimers()
|
||||
status.value = 'ready'
|
||||
return
|
||||
}
|
||||
message.text += (index === 0 ? '' : ' ') + tokens[index]
|
||||
index += 1
|
||||
}, STREAM_INTERVAL_MS)
|
||||
}, TOOL_CALLS_DELAY_MS)
|
||||
}
|
||||
|
||||
function send(text?: string, files: File[] = []) {
|
||||
const content = (text ?? input.value).trim()
|
||||
if (!content || status.value !== 'ready') return
|
||||
|
||||
const attachments: MessageAttachment[] = files.map((f) => ({
|
||||
name: f.name,
|
||||
type: f.type,
|
||||
url: URL.createObjectURL(f),
|
||||
size: f.size
|
||||
}))
|
||||
|
||||
messages.value.push({
|
||||
id: nextId(),
|
||||
role: 'user',
|
||||
text: content,
|
||||
attachments: attachments.length ? attachments : undefined
|
||||
})
|
||||
input.value = ''
|
||||
status.value = 'submitted'
|
||||
|
||||
if (!currentConversationId.value) {
|
||||
const id = `conv-${Date.now()}`
|
||||
currentConversationId.value = id
|
||||
chatHistory.value.unshift({
|
||||
id,
|
||||
title: content,
|
||||
createdAt: new Date(),
|
||||
messages: messages.value
|
||||
})
|
||||
}
|
||||
|
||||
thinkingTimer = setTimeout(() => {
|
||||
thinkingTimer = null
|
||||
streamReply(buildMockReply(content))
|
||||
}, THINKING_DELAY_MS)
|
||||
}
|
||||
|
||||
function stop() {
|
||||
clearTimers()
|
||||
status.value = 'ready'
|
||||
}
|
||||
|
||||
function applySuggestion(text: string) {
|
||||
input.value = text
|
||||
}
|
||||
|
||||
function startNewChat() {
|
||||
clearTimers()
|
||||
messages.value = []
|
||||
input.value = ''
|
||||
status.value = 'ready'
|
||||
currentConversationId.value = null
|
||||
}
|
||||
|
||||
function loadConversation(id: string) {
|
||||
const conv = chatHistory.value.find((c) => c.id === id)
|
||||
if (!conv) return
|
||||
clearTimers()
|
||||
messages.value = conv.messages.map((m) => ({ ...m }))
|
||||
currentConversationId.value = id
|
||||
status.value = 'ready'
|
||||
}
|
||||
|
||||
function deleteConversation(id: string) {
|
||||
const idx = chatHistory.value.findIndex((c) => c.id === id)
|
||||
if (idx !== -1) chatHistory.value.splice(idx, 1)
|
||||
if (currentConversationId.value === id) startNewChat()
|
||||
}
|
||||
|
||||
async function copyConversation(id: string) {
|
||||
const conv = chatHistory.value.find((c) => c.id === id)
|
||||
if (!conv) return
|
||||
const lines =
|
||||
conv.messages.length > 0
|
||||
? conv.messages.map(
|
||||
(m) => `${m.role === 'user' ? 'You' : 'Assistant'}: ${m.text}`
|
||||
)
|
||||
: [conv.title]
|
||||
await navigator.clipboard.writeText(lines.join('\n\n'))
|
||||
}
|
||||
|
||||
export function useAgentChatPrototype() {
|
||||
return {
|
||||
messages: readonly(messages),
|
||||
input,
|
||||
status: readonly(status),
|
||||
chatHistory: readonly(chatHistory),
|
||||
currentConversationId: readonly(currentConversationId),
|
||||
isEmpty: computed(() => messages.value.length === 0),
|
||||
send,
|
||||
stop,
|
||||
applySuggestion,
|
||||
startNewChat,
|
||||
loadConversation,
|
||||
deleteConversation,
|
||||
copyConversation
|
||||
}
|
||||
}
|
||||
35
src/platform/agent/stores/agentPanelStore.ts
Normal file
35
src/platform/agent/stores/agentPanelStore.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const PANEL_MIN_WIDTH = 420
|
||||
const PANEL_MAX_WIDTH = 960
|
||||
|
||||
export const useAgentPanelStore = defineStore('agentPanel', () => {
|
||||
const isOpen = ref(false)
|
||||
const width = ref(PANEL_MIN_WIDTH)
|
||||
|
||||
function open() {
|
||||
isOpen.value = true
|
||||
}
|
||||
|
||||
function close() {
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
isOpen.value = !isOpen.value
|
||||
}
|
||||
|
||||
function setWidth(px: number) {
|
||||
width.value = Math.min(PANEL_MAX_WIDTH, Math.max(PANEL_MIN_WIDTH, px))
|
||||
}
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
width,
|
||||
open,
|
||||
close,
|
||||
toggle,
|
||||
setWidth
|
||||
}
|
||||
})
|
||||
@@ -96,6 +96,41 @@ describe('markdownRendererUtil', () => {
|
||||
expect(html).toContain('rel="noopener noreferrer"')
|
||||
})
|
||||
|
||||
it('should render code blocks with header and copy button', () => {
|
||||
const markdown = '```typescript\nconst x = 1\n```'
|
||||
const html = renderMarkdownToHtml(markdown)
|
||||
|
||||
expect(html).toContain('class="agent-code-block"')
|
||||
expect(html).toContain('class="agent-code-block-header"')
|
||||
expect(html).toContain('class="agent-code-block-copy"')
|
||||
expect(html).toContain('typescript')
|
||||
expect(html).toContain('const x = 1')
|
||||
})
|
||||
|
||||
it('should show filename in code block header when lang:filename syntax is used', () => {
|
||||
const markdown = '```typescript:utils.ts\nconst x = 1\n```'
|
||||
const html = renderMarkdownToHtml(markdown)
|
||||
|
||||
expect(html).toContain('class="agent-code-block-filename"')
|
||||
expect(html).toContain('utils.ts')
|
||||
})
|
||||
|
||||
it('should render inline code with agent-inline-code class', () => {
|
||||
const markdown = 'Use the `KSampler` node.'
|
||||
const html = renderMarkdownToHtml(markdown)
|
||||
|
||||
expect(html).toContain('class="agent-inline-code"')
|
||||
expect(html).toContain('KSampler')
|
||||
})
|
||||
|
||||
it('should HTML-escape code block content', () => {
|
||||
const markdown = '```html\n<script>alert("xss")</script>\n```'
|
||||
const html = renderMarkdownToHtml(markdown)
|
||||
|
||||
expect(html).toContain('<script>')
|
||||
expect(html).not.toContain('<script>alert')
|
||||
})
|
||||
|
||||
it('should render complex markdown with links, images, and text', () => {
|
||||
const markdown = `
|
||||
# Release Notes
|
||||
|
||||
@@ -17,10 +17,37 @@ const ALLOWED_ATTRS = [
|
||||
const MEDIA_SRC_REGEX =
|
||||
/(<(?:img|source|video)[^>]*\ssrc=['"])(?!(?:\/|https?:\/\/))([^'"\s>]+)(['"])/gi
|
||||
|
||||
const FILE_ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><polyline points="14 2 14 8 20 8"/></svg>`
|
||||
|
||||
const CODE_ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m18 16 4-4-4-4"/><path d="m6 8-4 4 4 4"/><path d="m14.5 4-5 16"/></svg>`
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
// Create a marked Renderer that prefixes relative URLs with base
|
||||
export function createMarkdownRenderer(baseUrl?: string): Renderer {
|
||||
const normalizedBase = baseUrl ? baseUrl.replace(/\/+$/, '') : ''
|
||||
const renderer = new Renderer()
|
||||
|
||||
renderer.code = ({ text, lang: rawLang }) => {
|
||||
const info = rawLang ?? ''
|
||||
const colonIdx = info.indexOf(':')
|
||||
const lang = colonIdx >= 0 ? info.slice(0, colonIdx) : info
|
||||
const filename = colonIdx >= 0 ? info.slice(colonIdx + 1) : ''
|
||||
const langLabel = lang || 'plaintext'
|
||||
|
||||
const icon = filename ? FILE_ICON : CODE_ICON
|
||||
const label = filename
|
||||
? `<span class="agent-code-block-filename">${filename}</span>`
|
||||
: `<span>${langLabel}</span>`
|
||||
|
||||
return `<div class="agent-code-block"><div class="agent-code-block-header"><div class="agent-code-block-label">${icon}${label}</div><button class="agent-code-block-copy" type="button">Copy</button></div><pre><code>${escapeHtml(text)}</code></pre></div>`
|
||||
}
|
||||
|
||||
renderer.codespan = ({ text }) =>
|
||||
`<code class="agent-inline-code">${text}</code>`
|
||||
|
||||
renderer.image = ({ href, title, text }) => {
|
||||
let src = href
|
||||
if (normalizedBase && !/^(?:\/|https?:\/\/)/.test(href)) {
|
||||
|
||||
@@ -74,6 +74,26 @@ if (!GIT_COMMIT) {
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the current branch name at build time.
|
||||
// Priority: VERCEL_GIT_COMMIT_REF (set by Vercel's own build infrastructure) →
|
||||
// git rev-parse --abbrev-ref HEAD (for local builds deployed via vercel deploy) → ''
|
||||
let GIT_BRANCH = process.env.VERCEL_GIT_COMMIT_REF || ''
|
||||
if (!GIT_BRANCH) {
|
||||
try {
|
||||
const branch = execSync('git rev-parse --abbrev-ref HEAD', {
|
||||
timeout: 5000,
|
||||
windowsHide: true
|
||||
})
|
||||
.toString()
|
||||
.trim()
|
||||
if (branch !== 'HEAD') GIT_BRANCH = branch
|
||||
} catch {
|
||||
GIT_BRANCH = ''
|
||||
}
|
||||
}
|
||||
const PRODUCTION_BRANCHES = new Set(['main', 'master'])
|
||||
const GIT_BRANCH_PREFIX = PRODUCTION_BRANCHES.has(GIT_BRANCH) ? '' : GIT_BRANCH
|
||||
|
||||
// Disable Vue DevTools for production cloud distribution
|
||||
const DISABLE_VUE_PLUGINS =
|
||||
process.env.DISABLE_VUE_PLUGINS === 'true' ||
|
||||
@@ -629,6 +649,7 @@ export default defineConfig({
|
||||
process.env.npm_package_version
|
||||
),
|
||||
__COMFYUI_FRONTEND_COMMIT__: JSON.stringify(GIT_COMMIT),
|
||||
__GIT_BRANCH_PREFIX__: JSON.stringify(GIT_BRANCH_PREFIX),
|
||||
__SENTRY_ENABLED__: JSON.stringify(
|
||||
!(process.env.NODE_ENV === 'development' || !process.env.SENTRY_DSN)
|
||||
),
|
||||
|
||||
@@ -56,6 +56,7 @@ globalThis.__ALGOLIA_API_KEY__ = ''
|
||||
globalThis.__USE_PROD_CONFIG__ = false
|
||||
globalThis.__DISTRIBUTION__ = 'localhost'
|
||||
globalThis.__IS_NIGHTLY__ = false
|
||||
globalThis.__GIT_BRANCH_PREFIX__ = ''
|
||||
|
||||
// Define runtime config for tests
|
||||
window.__CONFIG__ = {
|
||||
|
||||
Reference in New Issue
Block a user