Ship Assistent 0.14.0: chat sessions, ask-only hops, and context compression.
Per-chat Generate session with sparse deltas; drop Cards/Civitai/wanted hops; rolling history summary via the same Ollama model with a budget chip and /compress. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+1703
-1414
File diff suppressed because it is too large
Load Diff
+469
-45
@@ -118,15 +118,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sa-chats-drawer {
|
.sa-chats-drawer {
|
||||||
flex: 0 0 var(--sa-chats-drawer-width, 16rem);
|
flex: 0 0 var(--sa-chats-drawer-width, 17.5rem);
|
||||||
width: var(--sa-chats-drawer-width, 16rem);
|
width: var(--sa-chats-drawer-width, 17.5rem);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.35rem;
|
gap: 0.45rem;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
border-left: 1px solid color-mix(in srgb, currentColor 18%, transparent);
|
border-left: 1px solid color-mix(in srgb, currentColor 12%, transparent);
|
||||||
background: color-mix(in srgb, currentColor 4%, transparent);
|
background: color-mix(in srgb, #000 22%, transparent);
|
||||||
padding: 0.45rem 0.5rem;
|
padding: 0.55rem 0.4rem 0.55rem 0.45rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: width 0.2s ease, flex-basis 0.2s ease, opacity 0.2s ease;
|
transition: width 0.2s ease, flex-basis 0.2s ease, opacity 0.2s ease;
|
||||||
}
|
}
|
||||||
@@ -140,12 +140,54 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
font-size: 0.82rem;
|
padding: 0.15rem 0.35rem 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-chats-drawer-label {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 650;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
opacity: 0.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sa-chats-drawer-actions {
|
.sa-chats-drawer-actions {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
gap: 0.2rem;
|
gap: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-chats-icon-btn {
|
||||||
|
appearance: none;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
width: 1.65rem;
|
||||||
|
height: 1.65rem;
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-chats-icon-btn:hover {
|
||||||
|
opacity: 1;
|
||||||
|
background: color-mix(in srgb, currentColor 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-chats-search-wrap {
|
||||||
|
position: relative;
|
||||||
|
margin: 0 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-chats-search-ico {
|
||||||
|
position: absolute;
|
||||||
|
left: 0.55rem;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
opacity: 0.4;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sa-layout {
|
.sa-layout {
|
||||||
@@ -555,8 +597,8 @@
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
border: none;
|
border: none;
|
||||||
border-left: 1px solid color-mix(in srgb, currentColor 18%, transparent);
|
border-left: 1px solid color-mix(in srgb, currentColor 12%, transparent);
|
||||||
background: color-mix(in srgb, currentColor 4%, transparent);
|
background: color-mix(in srgb, #000 22%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sa-chats-panel-head {
|
.sa-chats-panel-head {
|
||||||
@@ -570,51 +612,67 @@
|
|||||||
appearance: none;
|
appearance: none;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
border: 1px solid color-mix(in srgb, currentColor 22%, transparent);
|
border: 1px solid transparent;
|
||||||
background: color-mix(in srgb, #000 28%, transparent);
|
background: color-mix(in srgb, currentColor 7%, transparent);
|
||||||
color: inherit;
|
color: inherit;
|
||||||
border-radius: 0.35rem;
|
border-radius: 0.5rem;
|
||||||
padding: 0.35rem 0.5rem;
|
padding: 0.42rem 0.55rem 0.42rem 1.7rem;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 0.82rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sa-chats-search:focus {
|
.sa-chats-search:focus {
|
||||||
outline: 1px solid color-mix(in srgb, #6cf 55%, currentColor);
|
outline: none;
|
||||||
|
border-color: color-mix(in srgb, currentColor 22%, transparent);
|
||||||
|
background: color-mix(in srgb, currentColor 10%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sa-chats-panel-hint {
|
.sa-chats-panel-hint {
|
||||||
font-size: 0.72rem;
|
display: none;
|
||||||
opacity: 0.65;
|
|
||||||
line-height: 1.35;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sa-chats-list {
|
.sa-chats-list {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.25rem;
|
gap: 0.1rem;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
padding: 0.15rem 0.15rem 0.4rem;
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sa-chats-empty {
|
.sa-chats-empty {
|
||||||
font-size: 0.82rem;
|
font-size: 0.8rem;
|
||||||
opacity: 0.65;
|
opacity: 0.5;
|
||||||
padding: 0.5rem 0.25rem;
|
padding: 0.85rem 0.55rem;
|
||||||
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sa-chat-row {
|
.sa-chat-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: stretch;
|
align-items: center;
|
||||||
gap: 0.2rem;
|
gap: 0;
|
||||||
border-radius: 0.4rem;
|
border-radius: 0.5rem;
|
||||||
border: 1px solid transparent;
|
border: 0;
|
||||||
background: color-mix(in srgb, currentColor 5%, transparent);
|
background: transparent;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sa-chat-row-active {
|
.sa-chat-row-active {
|
||||||
border-color: color-mix(in srgb, #6cf 45%, currentColor);
|
background: color-mix(in srgb, currentColor 11%, transparent);
|
||||||
background: color-mix(in srgb, #6cf 12%, transparent);
|
}
|
||||||
|
|
||||||
|
.sa-chat-row-active::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0.35rem;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 0.35rem;
|
||||||
|
height: 0.35rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #f59e0b;
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in srgb, #f59e0b 25%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sa-chat-row-main {
|
.sa-chat-row-main {
|
||||||
@@ -625,27 +683,60 @@
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 0.4rem 0.5rem;
|
padding: 0.48rem 0.45rem 0.48rem 0.55rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-direction: column;
|
grid-template-columns: 1.1rem 1fr auto;
|
||||||
gap: 0.12rem;
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-chat-row-active .sa-chat-row-main {
|
||||||
|
padding-left: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-chat-row-main:hover {
|
||||||
|
background: color-mix(in srgb, currentColor 7%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-chat-row-active .sa-chat-row-main:hover {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-chat-row-ico {
|
||||||
|
width: 1.05rem;
|
||||||
|
height: 1.05rem;
|
||||||
|
opacity: 0.45;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-chat-row-active .sa-chat-row-ico {
|
||||||
|
opacity: 0.75;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sa-chat-row-title {
|
.sa-chat-row-title {
|
||||||
font-size: 0.85rem;
|
font-size: 0.84rem;
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sa-chat-row-meta {
|
.sa-chat-row-active .sa-chat-row-title {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-chat-row-when {
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
opacity: 0.7;
|
opacity: 0.42;
|
||||||
overflow: hidden;
|
font-variant-numeric: tabular-nums;
|
||||||
text-overflow: ellipsis;
|
flex-shrink: 0;
|
||||||
white-space: nowrap;
|
padding-right: 0.15rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sa-chat-row-del {
|
.sa-chat-row-del {
|
||||||
@@ -653,16 +744,28 @@
|
|||||||
border: 0;
|
border: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
opacity: 0.55;
|
opacity: 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0 0.55rem;
|
width: 1.55rem;
|
||||||
font-size: 1.1rem;
|
height: 1.55rem;
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-right: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-chat-row:hover .sa-chat-row-del {
|
||||||
|
opacity: 0.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sa-chat-row-del:hover {
|
.sa-chat-row-del:hover {
|
||||||
opacity: 1;
|
opacity: 1 !important;
|
||||||
color: #f66;
|
color: #f66;
|
||||||
|
background: color-mix(in srgb, #f66 12%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sa-live-dot {
|
.sa-live-dot {
|
||||||
@@ -1213,6 +1316,180 @@
|
|||||||
border-color: color-mix(in srgb, #f2777a 45%, transparent);
|
border-color: color-mix(in srgb, #f2777a 45%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sa-ctx-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-ctx-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 28%, transparent);
|
||||||
|
background: color-mix(in srgb, currentColor 6%, transparent);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: inherit;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-ctx-chip:hover {
|
||||||
|
border-color: color-mix(in srgb, currentColor 45%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-ctx-dot {
|
||||||
|
width: 0.4rem;
|
||||||
|
height: 0.4rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: color-mix(in srgb, #6ea8fe 80%, currentColor);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-ctx-ok {
|
||||||
|
border-color: color-mix(in srgb, #6ee7a8 35%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-ctx-warn {
|
||||||
|
color: color-mix(in srgb, #e3b341 85%, currentColor);
|
||||||
|
border-color: color-mix(in srgb, #e3b341 45%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-ctx-hot {
|
||||||
|
color: color-mix(in srgb, #f2777a 85%, currentColor);
|
||||||
|
border-color: color-mix(in srgb, #f2777a 50%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-ctx-compressing {
|
||||||
|
color: color-mix(in srgb, #6ea8fe 85%, currentColor);
|
||||||
|
border-color: color-mix(in srgb, #6ea8fe 50%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-ctx-panel {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 0.35rem);
|
||||||
|
right: 0;
|
||||||
|
z-index: 40;
|
||||||
|
width: min(22rem, 78vw);
|
||||||
|
padding: 0.65rem 0.75rem 0.75rem;
|
||||||
|
border-radius: 0.55rem;
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 22%, transparent);
|
||||||
|
background: color-mix(in srgb, #1a1d24 92%, transparent);
|
||||||
|
box-shadow: 0 0.6rem 1.4rem color-mix(in srgb, #000 45%, transparent);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-ctx-panel-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-ctx-bar {
|
||||||
|
height: 0.35rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background: color-mix(in srgb, currentColor 12%, transparent);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-ctx-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
width: 0%;
|
||||||
|
background: color-mix(in srgb, #6ee7a8 70%, currentColor);
|
||||||
|
transition: width 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-ctx-bar-fill[data-level="warn"] {
|
||||||
|
background: color-mix(in srgb, #e3b341 75%, currentColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-ctx-bar-fill[data-level="hot"] {
|
||||||
|
background: color-mix(in srgb, #f2777a 75%, currentColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-ctx-panel-body {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
max-height: 14rem;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-ctx-layer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-ctx-layers-label,
|
||||||
|
.sa-ctx-meta,
|
||||||
|
.sa-ctx-see {
|
||||||
|
opacity: 0.65;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-ctx-summary {
|
||||||
|
margin: 0.2rem 0 0;
|
||||||
|
padding: 0.4rem 0.45rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
max-height: 7rem;
|
||||||
|
overflow: auto;
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
background: color-mix(in srgb, currentColor 8%, transparent);
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-ctx-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-ctx-auto {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-msg-compress {
|
||||||
|
margin: 0.35rem 0.55rem;
|
||||||
|
padding: 0.35rem 0.55rem;
|
||||||
|
border-radius: 0.45rem;
|
||||||
|
border: 1px dashed color-mix(in srgb, #6ea8fe 40%, transparent);
|
||||||
|
background: color-mix(in srgb, #6ea8fe 8%, transparent);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-msg-compress > summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-msg-compress-body {
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-msg-compress-row {
|
||||||
|
opacity: 0.8;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
.sa-card-row-wanted {
|
.sa-card-row-wanted {
|
||||||
border-color: color-mix(in srgb, #e3b341 40%, transparent);
|
border-color: color-mix(in srgb, #e3b341 40%, transparent);
|
||||||
}
|
}
|
||||||
@@ -2481,3 +2758,150 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Turn activity timeline (model commands + pipeline) */
|
||||||
|
.sa-activity {
|
||||||
|
align-self: stretch;
|
||||||
|
margin: 0.35rem 0 0.55rem;
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 14%, transparent);
|
||||||
|
border-radius: 0.65rem;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg,
|
||||||
|
color-mix(in srgb, currentColor 7%, transparent),
|
||||||
|
color-mix(in srgb, currentColor 3%, transparent));
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 1px 0 color-mix(in srgb, #fff 4%, transparent) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-activity-live {
|
||||||
|
border-color: color-mix(in srgb, #6af 35%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-activity-done {
|
||||||
|
opacity: 0.92;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-activity-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-activity-head:hover {
|
||||||
|
background: color-mix(in srgb, currentColor 5%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-activity-spin {
|
||||||
|
width: 0.7rem;
|
||||||
|
height: 0.7rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1.5px solid color-mix(in srgb, currentColor 25%, transparent);
|
||||||
|
border-top-color: color-mix(in srgb, #8cf 90%, currentColor);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-activity-live .sa-activity-spin {
|
||||||
|
animation: sa-spin 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-activity-done .sa-activity-spin {
|
||||||
|
border-color: color-mix(in srgb, #6c6 55%, transparent);
|
||||||
|
border-top-color: #6c6;
|
||||||
|
animation: none;
|
||||||
|
background: color-mix(in srgb, #6c6 35%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-activity-title {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-activity-chev {
|
||||||
|
opacity: 0.45;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-activity-collapsed .sa-activity-chev {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-activity-collapsed .sa-activity-steps {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-activity-steps {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
padding: 0 0.55rem 0.6rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-activity-step {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.1rem 1fr;
|
||||||
|
gap: 0.45rem;
|
||||||
|
align-items: start;
|
||||||
|
padding: 0.28rem 0.35rem;
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-activity-step.sa-activity-running {
|
||||||
|
background: color-mix(in srgb, #6af 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-activity-step.sa-activity-done {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-activity-step.sa-activity-skip {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-activity-step.sa-activity-error {
|
||||||
|
background: color-mix(in srgb, #c44 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-activity-icon {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
opacity: 0.7;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-activity-running .sa-activity-icon {
|
||||||
|
opacity: 1;
|
||||||
|
color: color-mix(in srgb, #8cf 80%, currentColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-activity-label {
|
||||||
|
font-size: 0.86rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-activity-detail {
|
||||||
|
margin-top: 0.1rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
opacity: 0.62;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes sa-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
+104
-246
@@ -12,14 +12,17 @@ using SwarmUI.Utils;
|
|||||||
|
|
||||||
namespace Mrleo1nid.SwarmAssistent;
|
namespace Mrleo1nid.SwarmAssistent;
|
||||||
|
|
||||||
/// <summary>Prompt assembly, memory retrieval/writeback and the Civitai search hop loop.</summary>
|
/// <summary>Prompt assembly, memory retrieval, and ask-only server hop loop (settings / inventory).</summary>
|
||||||
public partial class SwarmAssistentExtension
|
public partial class SwarmAssistentExtension
|
||||||
{
|
{
|
||||||
const int MaxCivitaiHopsFallback = 2;
|
const int MaxToolHopsFallback = 2;
|
||||||
const int MaxToolHopsFallback = 4;
|
|
||||||
|
|
||||||
static bool IsSlimDebugPack(string packName) =>
|
static bool IsSlimUtilityPack(string packName) =>
|
||||||
string.Equals(packName, "debug_explain", StringComparison.OrdinalIgnoreCase);
|
string.Equals(packName, "debug_explain", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(packName, "compress_history", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
[Obsolete("Use IsSlimUtilityPack")]
|
||||||
|
static bool IsSlimDebugPack(string packName) => IsSlimUtilityPack(packName);
|
||||||
|
|
||||||
(List<JObject> messages, JObject systemLayers) BuildOllamaMessages(string packName, bool includeBase, string contextJson, JArray userMessages, string extraSystem = null, string personaId = null, IEnumerable<string> skillIds = null)
|
(List<JObject> messages, JObject systemLayers) BuildOllamaMessages(string packName, bool includeBase, string contextJson, JArray userMessages, string extraSystem = null, string personaId = null, IEnumerable<string> skillIds = null)
|
||||||
{
|
{
|
||||||
@@ -48,9 +51,9 @@ public partial class SwarmAssistentExtension
|
|||||||
AddLayer("core", Config.LoadCorePrompt(pid));
|
AddLayer("core", Config.LoadCorePrompt(pid));
|
||||||
}
|
}
|
||||||
|
|
||||||
bool slimDebug = IsSlimDebugPack(packName);
|
bool slimUtility = IsSlimUtilityPack(packName);
|
||||||
|
|
||||||
if (Memory is not null && !slimDebug)
|
if (Memory is not null && !slimUtility)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -77,7 +80,7 @@ public partial class SwarmAssistentExtension
|
|||||||
+ exact.ToString(Newtonsoft.Json.Formatting.None) + "\n```");
|
+ exact.ToString(Newtonsoft.Json.Formatting.None) + "\n```");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!slimDebug)
|
if (!slimUtility)
|
||||||
{
|
{
|
||||||
StringBuilder skillsBlock = new();
|
StringBuilder skillsBlock = new();
|
||||||
foreach (string skillId in skillIds ?? Config.ResolveEnabledSkills(pid, null))
|
foreach (string skillId in skillIds ?? Config.ResolveEnabledSkills(pid, null))
|
||||||
@@ -176,9 +179,9 @@ public partial class SwarmAssistentExtension
|
|||||||
Logs.Debug($"Assistent memory seed: {ex.Message}");
|
Logs.Debug($"Assistent memory seed: {ex.Message}");
|
||||||
}
|
}
|
||||||
|
|
||||||
bool slimDebug = IsSlimDebugPack(packName);
|
bool slimUtility = IsSlimUtilityPack(packName);
|
||||||
JArray hits = [];
|
JArray hits = [];
|
||||||
if (!slimDebug)
|
if (!slimUtility)
|
||||||
{
|
{
|
||||||
string retrieveQuery = BuildRetrieveQuery(userMessages, contextJson, packName);
|
string retrieveQuery = BuildRetrieveQuery(userMessages, contextJson, packName);
|
||||||
try
|
try
|
||||||
@@ -194,7 +197,7 @@ public partial class SwarmAssistentExtension
|
|||||||
}
|
}
|
||||||
|
|
||||||
string enrichedContext = InjectMemoryHits(contextJson, hits, pid);
|
string enrichedContext = InjectMemoryHits(contextJson, hits, pid);
|
||||||
if (!slimDebug)
|
if (!slimUtility)
|
||||||
{
|
{
|
||||||
enrichedContext = EnrichPersonaContext(enrichedContext, pid, packName);
|
enrichedContext = EnrichPersonaContext(enrichedContext, pid, packName);
|
||||||
}
|
}
|
||||||
@@ -202,14 +205,10 @@ public partial class SwarmAssistentExtension
|
|||||||
int systemChars = systemLayers["total"]?.Value<int?>()
|
int systemChars = systemLayers["total"]?.Value<int?>()
|
||||||
?? messages.FirstOrDefault(m => string.Equals(m["role"]?.ToString(), "system", StringComparison.OrdinalIgnoreCase))?["content"]?.ToString()?.Length
|
?? messages.FirstOrDefault(m => string.Equals(m["role"]?.ToString(), "system", StringComparison.OrdinalIgnoreCase))?["content"]?.ToString()?.Length
|
||||||
?? 0;
|
?? 0;
|
||||||
JArray civitaiResults = [];
|
|
||||||
string reply = "";
|
string reply = "";
|
||||||
JObject lastRaw = null;
|
JObject lastRaw = null;
|
||||||
int maxHops = slimDebug
|
int maxHops = slimUtility ? 1 : CfgInt("max_tool_hops", MaxToolHopsFallback);
|
||||||
? 1
|
|
||||||
: Math.Max(CfgInt("max_civitai_hops", MaxCivitaiHopsFallback), CfgInt("max_tool_hops", MaxToolHopsFallback));
|
|
||||||
HashSet<string> hopDone = new(StringComparer.OrdinalIgnoreCase);
|
HashSet<string> hopDone = new(StringComparer.OrdinalIgnoreCase);
|
||||||
var chain = Config.PersonaExtendsChain(pid);
|
|
||||||
for (int hop = 0; hop < maxHops; hop++)
|
for (int hop = 0; hop < maxHops; hop++)
|
||||||
{
|
{
|
||||||
if (onHopStart is not null)
|
if (onHopStart is not null)
|
||||||
@@ -217,20 +216,17 @@ public partial class SwarmAssistentExtension
|
|||||||
await onHopStart(hop);
|
await onHopStart(hop);
|
||||||
}
|
}
|
||||||
(reply, lastRaw) = await CallOllamaChat(root, modelName, messages, stream: onDelta is not null, onDelta, pid);
|
(reply, lastRaw) = await CallOllamaChat(root, modelName, messages, stream: onDelta is not null, onDelta, pid);
|
||||||
if (slimDebug)
|
if (slimUtility)
|
||||||
{
|
{
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
JObject patch = TryParsePatch(reply);
|
JObject patch = TryParsePatch(reply);
|
||||||
await ApplyMemoryActions(root, patch, embed, pid);
|
// 0.14: no ApplyMemoryActions / ApplyUserPrefActions / ApplyPersonaActions / Civitai from server hop loop.
|
||||||
ApplyUserPrefActions(patch, pid);
|
|
||||||
ApplyPersonaActions(patch, ref pid);
|
|
||||||
if (hop + 1 >= maxHops)
|
if (hop + 1 >= maxHops)
|
||||||
{
|
{
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
string follow = null;
|
string follow = null;
|
||||||
JArray civitaiHop = null;
|
|
||||||
HashSet<string> hopSkip = new(StringComparer.OrdinalIgnoreCase);
|
HashSet<string> hopSkip = new(StringComparer.OrdinalIgnoreCase);
|
||||||
while (true)
|
while (true)
|
||||||
{
|
{
|
||||||
@@ -240,7 +236,7 @@ public partial class SwarmAssistentExtension
|
|||||||
follow = null;
|
follow = null;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
(follow, civitaiHop) = await RunToolHop(session, root, embed, pid, chain, patch, tool, hopDone);
|
follow = await RunToolHop(session, pid, patch, tool, hopDone);
|
||||||
if (follow is not null)
|
if (follow is not null)
|
||||||
{
|
{
|
||||||
break;
|
break;
|
||||||
@@ -251,10 +247,6 @@ public partial class SwarmAssistentExtension
|
|||||||
{
|
{
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (civitaiHop is { Count: > 0 })
|
|
||||||
{
|
|
||||||
civitaiResults = civitaiHop;
|
|
||||||
}
|
|
||||||
// Re-feed only the parsed patch JSON (not full prose) to save hop tokens.
|
// Re-feed only the parsed patch JSON (not full prose) to save hop tokens.
|
||||||
string assistantContent = patch is not null
|
string assistantContent = patch is not null
|
||||||
? patch.ToString(Newtonsoft.Json.Formatting.None)
|
? patch.ToString(Newtonsoft.Json.Formatting.None)
|
||||||
@@ -262,7 +254,7 @@ public partial class SwarmAssistentExtension
|
|||||||
messages.Add(new JObject { ["role"] = "assistant", ["content"] = assistantContent });
|
messages.Add(new JObject { ["role"] = "assistant", ["content"] = assistantContent });
|
||||||
messages.Add(new JObject { ["role"] = "user", ["content"] = follow });
|
messages.Add(new JObject { ["role"] = "user", ["content"] = follow });
|
||||||
}
|
}
|
||||||
return (reply, lastRaw, civitaiResults, systemChars, systemLayers);
|
return (reply, lastRaw, [], systemChars, systemLayers);
|
||||||
}
|
}
|
||||||
|
|
||||||
static string BuildRetrieveQuery(JArray userMessages, string contextJson, string packName = null)
|
static string BuildRetrieveQuery(JArray userMessages, string contextJson, string packName = null)
|
||||||
@@ -384,241 +376,111 @@ public partial class SwarmAssistentExtension
|
|||||||
return filtered;
|
return filtered;
|
||||||
}
|
}
|
||||||
|
|
||||||
async Task<(string follow, JArray civitai)> RunToolHop(
|
/// <summary>Ask-only server hops: Exact/assistant settings dump or truncated inventory.</summary>
|
||||||
|
async Task<string> RunToolHop(
|
||||||
Session session,
|
Session session,
|
||||||
string root,
|
|
||||||
string embed,
|
|
||||||
string pid,
|
string pid,
|
||||||
IEnumerable<string> chain,
|
|
||||||
JObject patch,
|
JObject patch,
|
||||||
string tool,
|
string tool,
|
||||||
HashSet<string> hopDone)
|
HashSet<string> hopDone)
|
||||||
{
|
{
|
||||||
if (tool == "memory_get")
|
if (tool == "ask_settings")
|
||||||
{
|
{
|
||||||
JArray got = [];
|
if (!hopDone.Add("ask_settings"))
|
||||||
foreach (JToken t in patch["memories"] as JArray ?? [])
|
|
||||||
{
|
{
|
||||||
if (t is not JObject mo)
|
return null;
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
string kind = mo["kind"]?.ToString() ?? "note";
|
JObject dump = BuildAskSettingsDump(pid);
|
||||||
string key = mo["key"]?.ToString() ?? "";
|
return
|
||||||
if (string.IsNullOrWhiteSpace(key))
|
"ask:settings dump (Exact + assistant knobs from server). "
|
||||||
{
|
+ "Live session fields arrive via client compact context. "
|
||||||
continue;
|
+ "Reply with a sparse delta JSON only if needed; omit ask:settings unless you need a refresh.\n```json\n"
|
||||||
|
+ dump.ToString(Newtonsoft.Json.Formatting.None) + "\n```";
|
||||||
}
|
}
|
||||||
string sig = $"get:{kind}:{key}";
|
if (tool == "ask_inventory")
|
||||||
|
{
|
||||||
|
string q = patch?["inventory_query"]?.ToString()?.Trim() ?? "";
|
||||||
|
string sig = "ask_inventory:" + q.ToLowerInvariant();
|
||||||
if (!hopDone.Add(sig))
|
if (!hopDone.Add(sig))
|
||||||
{
|
{
|
||||||
continue;
|
return null;
|
||||||
}
|
|
||||||
JObject row = Memory.Get(kind, key, chain);
|
|
||||||
got.Add(row ?? new JObject { ["kind"] = kind, ["key"] = key, ["missing"] = true });
|
|
||||||
}
|
|
||||||
if (got.Count == 0)
|
|
||||||
{
|
|
||||||
return (null, null);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
"memory_get results (JSON). Use these facts; omit memory_get unless you need a different key.\n```json\n"
|
|
||||||
+ got.ToString(Newtonsoft.Json.Formatting.None) + "\n```",
|
|
||||||
null);
|
|
||||||
}
|
|
||||||
if (tool == "memory_search")
|
|
||||||
{
|
|
||||||
string q = ExtractMemoryQuery(patch);
|
|
||||||
if (string.IsNullOrWhiteSpace(q) || !hopDone.Add("search:" + q))
|
|
||||||
{
|
|
||||||
return (null, null);
|
|
||||||
}
|
|
||||||
string kind = patch["memory_kind"]?.ToString();
|
|
||||||
int topK = Config.LoadAssistant(pid)["memory_top_k"]?.Value<int?>() ?? 8;
|
|
||||||
JArray rows = await Memory.SearchAsync(root, q, kind, topK, embed, chain);
|
|
||||||
return (
|
|
||||||
"memory_search results (JSON, hybrid FTS+vector). Omit memory_search unless you need a different query.\n```json\n"
|
|
||||||
+ rows.ToString(Newtonsoft.Json.Formatting.None) + "\n```",
|
|
||||||
null);
|
|
||||||
}
|
|
||||||
if (tool == "heard_search")
|
|
||||||
{
|
|
||||||
if (Config.LoadTrainingAgent()["enabled"]?.Value<bool?>() == false)
|
|
||||||
{
|
|
||||||
return ("heard_search disabled in training-agent settings.", null);
|
|
||||||
}
|
|
||||||
string q = patch["memory_query"]?.ToString()?.Trim()
|
|
||||||
?? patch["search_query"]?.ToString()?.Trim()
|
|
||||||
?? ExtractMemoryQuery(patch);
|
|
||||||
if (string.IsNullOrWhiteSpace(q) || !hopDone.Add("heard:" + q))
|
|
||||||
{
|
|
||||||
return (null, null);
|
|
||||||
}
|
|
||||||
int topK = Config.LoadTrainingAgent()["heard_quota"]?.Value<int?>() ?? 3;
|
|
||||||
JArray rows = await Memory.SearchAsync(root, q, AssistentMemory.HeardKind, topK, embed, chain);
|
|
||||||
JArray examples = [];
|
|
||||||
foreach (JToken t in rows)
|
|
||||||
{
|
|
||||||
if (t is JObject ho)
|
|
||||||
{
|
|
||||||
JObject ex = Memory.BuildHeardExampleFromHit(ho, chain);
|
|
||||||
if (ex is not null)
|
|
||||||
{
|
|
||||||
examples.Add(ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
"heard_search — curated dialogue examples the assistant learned (style/reference, not hard rules). Use tone and structure; omit heard_search unless you need more examples.\n```json\n"
|
|
||||||
+ examples.ToString(Newtonsoft.Json.Formatting.None) + "\n```",
|
|
||||||
null);
|
|
||||||
}
|
|
||||||
if (tool == "lookup_tags")
|
|
||||||
{
|
|
||||||
string q = ExtractTagQuery(patch);
|
|
||||||
if (string.IsNullOrWhiteSpace(q) || !hopDone.Add("tags:" + q))
|
|
||||||
{
|
|
||||||
return (null, null);
|
|
||||||
}
|
|
||||||
int lim = Config.LoadAssistant(pid)["tag_lookup_limit"]?.Value<int?>() ?? 20;
|
|
||||||
JArray tags = Memory.LookupTags(q, lim);
|
|
||||||
return (
|
|
||||||
"lookup_tags results from Danbooru csv (canonical name, aliases, post_count). Krea prompts stay natural prose — use this to check spelling/aliases, do not dump tag soup.\n```json\n"
|
|
||||||
+ tags.ToString(Newtonsoft.Json.Formatting.None) + "\n```",
|
|
||||||
null);
|
|
||||||
}
|
|
||||||
if (tool == "list_inventory")
|
|
||||||
{
|
|
||||||
string q = patch["inventory_query"]?.ToString()?.Trim() ?? "";
|
|
||||||
string sig = "inv:" + q.ToLowerInvariant();
|
|
||||||
if (!hopDone.Add(sig))
|
|
||||||
{
|
|
||||||
return (null, null);
|
|
||||||
}
|
}
|
||||||
int lim = Config.LoadAssistant(pid)["inventory_hop_limit"]?.Value<int?>() ?? 20;
|
int lim = Config.LoadAssistant(pid)["inventory_hop_limit"]?.Value<int?>() ?? 20;
|
||||||
JArray rows = SearchInventoryForHop(q, lim);
|
JArray rows;
|
||||||
return (
|
if (string.IsNullOrWhiteSpace(q))
|
||||||
"list_inventory results (rich LoRA/checkpoint rows). Use exact names + listed triggers; omit list_inventory unless you need a different query.\n```json\n"
|
|
||||||
+ rows.ToString(Newtonsoft.Json.Formatting.None) + "\n```",
|
|
||||||
null);
|
|
||||||
}
|
|
||||||
if (tool == "skill_load")
|
|
||||||
{
|
{
|
||||||
List<string> ids = [];
|
JObject inv = await AssistentListInventory(session, rescan: false);
|
||||||
if (patch["skills"] is JArray skArr)
|
rows = TruncateInventoryForAsk(inv, lim);
|
||||||
|
}
|
||||||
|
else
|
||||||
{
|
{
|
||||||
foreach (JToken t in skArr)
|
rows = SearchInventoryForHop(q, lim);
|
||||||
{
|
|
||||||
string sid = AssistentConfig.SafeId(t?.ToString());
|
|
||||||
if (!string.IsNullOrWhiteSpace(sid))
|
|
||||||
{
|
|
||||||
ids.Add(sid);
|
|
||||||
}
|
}
|
||||||
|
return
|
||||||
|
"ask:inventory truncated LoRA/checkpoint list. Use exact names + listed triggers; "
|
||||||
|
+ "omit ask:inventory unless you need a different query.\n```json\n"
|
||||||
|
+ rows.ToString(Newtonsoft.Json.Formatting.None) + "\n```";
|
||||||
}
|
}
|
||||||
}
|
return null;
|
||||||
if (ids.Count == 0)
|
|
||||||
{
|
|
||||||
ids.Add("memory");
|
|
||||||
}
|
|
||||||
StringBuilder sb = new();
|
|
||||||
foreach (string sid in ids)
|
|
||||||
{
|
|
||||||
string sig = "skill:" + sid;
|
|
||||||
if (!hopDone.Add(sig))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
string text = Config.LoadSkillPrompt(pid, sid);
|
|
||||||
if (string.IsNullOrWhiteSpace(text))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
sb.AppendLine($"## Skill: {sid}");
|
|
||||||
sb.AppendLine(text);
|
|
||||||
sb.AppendLine();
|
|
||||||
}
|
|
||||||
if (sb.Length == 0)
|
|
||||||
{
|
|
||||||
return (null, null);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
"skill_load results. Follow these skill rules on the next reply; omit skill_load unless you need another skill.\n\n"
|
|
||||||
+ sb.ToString().TrimEnd(),
|
|
||||||
null);
|
|
||||||
}
|
|
||||||
if (tool == "persona_read")
|
|
||||||
{
|
|
||||||
List<string> shelves = [];
|
|
||||||
if (patch["persona_shelves"] is JArray shArr)
|
|
||||||
{
|
|
||||||
foreach (JToken t in shArr)
|
|
||||||
{
|
|
||||||
string name = t?.ToString()?.Trim();
|
|
||||||
if (!string.IsNullOrWhiteSpace(name))
|
|
||||||
{
|
|
||||||
shelves.Add(name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (patch["persona_shelves"] is JObject shObj)
|
|
||||||
{
|
|
||||||
// Model sometimes echoes shelf objects; treat keys as names.
|
|
||||||
foreach (JProperty p in shObj.Properties())
|
|
||||||
{
|
|
||||||
shelves.Add(p.Name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
string sig = "persona_read:" + string.Join(",", shelves);
|
|
||||||
if (!hopDone.Add(sig))
|
|
||||||
{
|
|
||||||
return (null, null);
|
|
||||||
}
|
|
||||||
string body = Config.RenderPersonaReadBlock(pid, shelves.Count > 0 ? shelves : null);
|
|
||||||
if (string.IsNullOrWhiteSpace(body))
|
|
||||||
{
|
|
||||||
return ("persona_read: no additional lore shelves for this persona.", null);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
"persona_read results (lore shelves). Use for roleplay/appearance/outfit detail; omit persona_read unless you need different shelves.\n\n"
|
|
||||||
+ body,
|
|
||||||
null);
|
|
||||||
}
|
|
||||||
if (tool == "civitai")
|
|
||||||
{
|
|
||||||
string query = ExtractSearchQuery(patch);
|
|
||||||
if (string.IsNullOrWhiteSpace(query))
|
|
||||||
{
|
|
||||||
if (!hopDone.Add("civitai:missing_query"))
|
|
||||||
{
|
|
||||||
return (null, null);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
"search_civitai skipped: provide a short search_query (LoRA keywords only). "
|
|
||||||
+ "Never search with the whole user message. Then retry with actions:[\"search_civitai\"] + search_query, "
|
|
||||||
+ "or continue using available_loras only.",
|
|
||||||
null);
|
|
||||||
}
|
|
||||||
if (!hopDone.Add("civitai:" + query))
|
|
||||||
{
|
|
||||||
return (null, null);
|
|
||||||
}
|
|
||||||
JObject search = await AssistentSearchCivitai(session, query, 8);
|
|
||||||
if (search["error"] is not null)
|
|
||||||
{
|
|
||||||
return ($"Civitai search failed: {search["error"]}. Continue without download — use only available_loras from context.", null);
|
|
||||||
}
|
|
||||||
JArray civitaiResults = search["results"] as JArray ?? [];
|
|
||||||
return (
|
|
||||||
"Civitai search results (JSON). Prefer `krea_likely: true`. Do NOT download yourself — the UI shows Confirm cards. " +
|
|
||||||
"Pick useful LoRAs from results or available_loras, emit a normal patch (prompt/loras). " +
|
|
||||||
"Omit search_civitai from actions unless you need a different query.\n```json\n" +
|
|
||||||
civitaiResults.ToString(Newtonsoft.Json.Formatting.None) + "\n```",
|
|
||||||
civitaiResults);
|
|
||||||
}
|
|
||||||
return (null, null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Substring filter over current LoRA/checkpoint inventory for list_inventory hop.</summary>
|
static JArray TruncateInventoryForAsk(JObject inv, int limit)
|
||||||
|
{
|
||||||
|
int lim = Math.Max(1, Math.Min(limit, 40));
|
||||||
|
JArray outRows = [];
|
||||||
|
foreach (JToken t in inv?["loras"] as JArray ?? [])
|
||||||
|
{
|
||||||
|
if (outRows.Count >= lim)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
outRows.Add(t);
|
||||||
|
}
|
||||||
|
foreach (JToken t in inv?["checkpoints"] as JArray ?? [])
|
||||||
|
{
|
||||||
|
if (outRows.Count >= lim)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
outRows.Add(t);
|
||||||
|
}
|
||||||
|
return outRows;
|
||||||
|
}
|
||||||
|
|
||||||
|
JObject BuildAskSettingsDump(string pid)
|
||||||
|
{
|
||||||
|
JObject exact = Config.LoadExact(pid) ?? new JObject();
|
||||||
|
JObject asst = Config.LoadAssistant(pid) ?? new JObject();
|
||||||
|
// Knobs the model may need — not the full assistant.json blob (quotas/seed noise).
|
||||||
|
JObject knobs = new()
|
||||||
|
{
|
||||||
|
["num_ctx"] = asst["num_ctx"],
|
||||||
|
["num_predict"] = asst["num_predict"],
|
||||||
|
["max_tool_hops"] = asst["max_tool_hops"],
|
||||||
|
["inventory_hop_limit"] = asst["inventory_hop_limit"],
|
||||||
|
["max_loras_inventory"] = asst["max_loras_inventory"],
|
||||||
|
["max_checkpoints_inventory"] = asst["max_checkpoints_inventory"],
|
||||||
|
["max_gen_variants"] = asst["max_gen_variants"],
|
||||||
|
["max_ref_slots"] = asst["max_ref_slots"],
|
||||||
|
["default_pack"] = asst["default_pack"],
|
||||||
|
["default_persona"] = asst["default_persona"],
|
||||||
|
["gate"] = asst["gate"],
|
||||||
|
["context_prompt_max"] = asst["context_prompt_max"],
|
||||||
|
["history_keep_turns"] = asst["history_keep_turns"],
|
||||||
|
["compress_at"] = asst["compress_at"],
|
||||||
|
["chars_per_token"] = asst["chars_per_token"],
|
||||||
|
["compress_auto"] = asst["compress_auto"],
|
||||||
|
};
|
||||||
|
return new JObject
|
||||||
|
{
|
||||||
|
["detail"] = "settings",
|
||||||
|
["exact"] = exact,
|
||||||
|
["assistant"] = knobs,
|
||||||
|
["persona"] = pid,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Substring filter over current LoRA/checkpoint inventory for ask:inventory hop.</summary>
|
||||||
JArray SearchInventoryForHop(string query, int limit)
|
JArray SearchInventoryForHop(string query, int limit)
|
||||||
{
|
{
|
||||||
int lim = Math.Max(1, Math.Min(limit, 40));
|
int lim = Math.Max(1, Math.Min(limit, 40));
|
||||||
@@ -944,10 +806,6 @@ public partial class SwarmAssistentExtension
|
|||||||
{
|
{
|
||||||
outRow["krea_likely"] = true;
|
outRow["krea_likely"] = true;
|
||||||
}
|
}
|
||||||
if (fromDisk["has_card"]?.Value<bool>() == true)
|
|
||||||
{
|
|
||||||
outRow["has_card"] = true;
|
|
||||||
}
|
|
||||||
return outRow;
|
return outRow;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-5
@@ -138,16 +138,14 @@ public sealed class AssistentConfig
|
|||||||
return
|
return
|
||||||
[
|
[
|
||||||
"prompt", "negative", "loras", "width", "height", "steps", "cfg", "seed", "sigma_shift", "sampler", "scheduler",
|
"prompt", "negative", "loras", "width", "height", "steps", "cfg", "seed", "sigma_shift", "sampler", "scheduler",
|
||||||
"actions", "search_query", "civitai_query",
|
"actions", "generate", "ask",
|
||||||
"use_init_image", "clear_init_image", "init_creativity", "denoise",
|
"use_init_image", "clear_init_image", "init_creativity", "denoise",
|
||||||
"use_mask_image", "clear_mask_image", "mask_blur", "mask_grow",
|
"use_mask_image", "clear_mask_image", "mask_blur", "mask_grow",
|
||||||
"look_at", "vision_from", "vision_slots", "slot_to_init", "slot_to_mask",
|
"look_at", "vision_from", "vision_slots", "slot_to_init", "slot_to_mask",
|
||||||
"snapshot_generate", "select_slot", "aspect", "images", "batch", "vary", "lock_seed",
|
"snapshot_generate", "select_slot", "aspect", "images", "batch", "vary", "lock_seed",
|
||||||
"creativity", "intensity", "complexity", "movement",
|
"creativity", "intensity", "complexity", "movement",
|
||||||
"clear_prompt_images", "slot_to_prompt_image", "pack", "memories", "memory",
|
"clear_prompt_images", "slot_to_prompt_image", "pack", "persona", "controls",
|
||||||
"memory_query", "memory_kind", "tag_query", "user_prefs",
|
"inventory_query", "variants",
|
||||||
"inventory_query", "skills", "persona_shelves", "persona_clone", "persona", "controls",
|
|
||||||
"variants",
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-628
@@ -2,22 +2,16 @@ using System;
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Net.Http;
|
|
||||||
using System.Net.Http.Headers;
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using FreneticUtilities.FreneticExtensions;
|
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
using SwarmUI.Accounts;
|
using SwarmUI.Accounts;
|
||||||
using SwarmUI.Core;
|
using SwarmUI.Core;
|
||||||
using SwarmUI.Text2Image;
|
using SwarmUI.Text2Image;
|
||||||
using SwarmUI.Utils;
|
using SwarmUI.Utils;
|
||||||
using SwarmUI.WebAPI;
|
|
||||||
|
|
||||||
namespace Mrleo1nid.SwarmAssistent;
|
namespace Mrleo1nid.SwarmAssistent;
|
||||||
|
|
||||||
/// <summary>Server-side model inventory, assistant cards and Civitai lookups.</summary>
|
/// <summary>Server-side model inventory (LoRA / checkpoint / wildcard lists).</summary>
|
||||||
public partial class SwarmAssistentExtension
|
public partial class SwarmAssistentExtension
|
||||||
{
|
{
|
||||||
const int MaxLorasInInventoryFallback = 150;
|
const int MaxLorasInInventoryFallback = 150;
|
||||||
@@ -25,444 +19,6 @@ public partial class SwarmAssistentExtension
|
|||||||
const int MaxCheckpointsInInventoryFallback = 60;
|
const int MaxCheckpointsInInventoryFallback = 60;
|
||||||
const int InventoryBlurbMaxFallback = 140;
|
const int InventoryBlurbMaxFallback = 140;
|
||||||
|
|
||||||
static string ModelWeightPath(string setName, string modelName)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(modelName) || !Program.T2IModelSets.TryGetValue(setName, out T2IModelHandler handler))
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (!handler.Models.TryGetValue(modelName, out T2IModel model) && !handler.Models.TryGetValue(modelName.Replace('\\', '/'), out model))
|
|
||||||
{
|
|
||||||
// Try suffix match
|
|
||||||
model = handler.Models.Values.FirstOrDefault(m =>
|
|
||||||
string.Equals(m.Name, modelName, StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| m.Name.EndsWith("/" + modelName, StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| Path.GetFileNameWithoutExtension(m.Name) == Path.GetFileNameWithoutExtension(modelName));
|
|
||||||
}
|
|
||||||
if (model is null)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// SwarmUI T2IModel exposes RawFilePath in recent builds.
|
|
||||||
return model.RawFilePath;
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static string CardPathForWeight(string weightPath)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(weightPath))
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
string dir = Path.GetDirectoryName(weightPath);
|
|
||||||
string stem = Path.GetFileNameWithoutExtension(weightPath);
|
|
||||||
if (string.IsNullOrWhiteSpace(dir) || string.IsNullOrWhiteSpace(stem))
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return Path.Combine(dir, $"{stem}.assistent.json");
|
|
||||||
}
|
|
||||||
|
|
||||||
static string SetNameForKind(string kind)
|
|
||||||
{
|
|
||||||
return (kind ?? "").Trim().ToLowerInvariant() switch
|
|
||||||
{
|
|
||||||
"lora" => "LoRA",
|
|
||||||
"checkpoint" or "ckpt" or "stable-diffusion" => "Stable-Diffusion",
|
|
||||||
_ => null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
JObject ReadCardObject(string kind, string name)
|
|
||||||
{
|
|
||||||
string set = SetNameForKind(kind);
|
|
||||||
string weight = ModelWeightPath(set, name);
|
|
||||||
string card = CardPathForWeight(weight);
|
|
||||||
if (card is null || !File.Exists(card))
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return JObject.Parse(File.ReadAllText(card, Encoding.UTF8));
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<JObject> AssistentGetCard(Session session, string kind, string name)
|
|
||||||
{
|
|
||||||
await Task.CompletedTask;
|
|
||||||
if (string.IsNullOrWhiteSpace(kind) || string.IsNullOrWhiteSpace(name))
|
|
||||||
{
|
|
||||||
return new JObject { ["error"] = "kind and name required" };
|
|
||||||
}
|
|
||||||
JObject card = ReadCardObject(kind, name);
|
|
||||||
string set = SetNameForKind(kind);
|
|
||||||
string weight = ModelWeightPath(set, name);
|
|
||||||
return new JObject
|
|
||||||
{
|
|
||||||
["success"] = true,
|
|
||||||
["kind"] = kind,
|
|
||||||
["name"] = name,
|
|
||||||
["has_card"] = card is not null,
|
|
||||||
["weight_path"] = weight,
|
|
||||||
["card"] = card,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<JObject> AssistentSaveCard(Session session, string kind, string name, JObject card, bool enqueue_wanted = false)
|
|
||||||
{
|
|
||||||
await Task.CompletedTask;
|
|
||||||
if (card is null)
|
|
||||||
{
|
|
||||||
return new JObject { ["error"] = "card required" };
|
|
||||||
}
|
|
||||||
kind = (kind ?? card["kind"]?.ToString() ?? "").Trim();
|
|
||||||
name = (name ?? card["name"]?.ToString() ?? "").Trim();
|
|
||||||
if (string.IsNullOrWhiteSpace(kind) || string.IsNullOrWhiteSpace(name))
|
|
||||||
{
|
|
||||||
return new JObject { ["error"] = "kind and name required" };
|
|
||||||
}
|
|
||||||
card["kind"] = kind;
|
|
||||||
card["name"] = name;
|
|
||||||
|
|
||||||
string set = SetNameForKind(kind);
|
|
||||||
string weight = ModelWeightPath(set, name);
|
|
||||||
if (!string.IsNullOrWhiteSpace(weight) && File.Exists(weight))
|
|
||||||
{
|
|
||||||
string path = CardPathForWeight(weight);
|
|
||||||
File.WriteAllText(path, card.ToString(Newtonsoft.Json.Formatting.Indented), Encoding.UTF8);
|
|
||||||
_ = IngestCardToMemory(card, name);
|
|
||||||
return new JObject { ["success"] = true, ["path"] = path, ["installed"] = true };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Not installed — draft into wanted-cards + optionally enqueue download for next up.
|
|
||||||
Directory.CreateDirectory(WantedCardsDir());
|
|
||||||
string rawVid = card["version_id"]?.ToString() ?? "draft";
|
|
||||||
string vid = Regex.IsMatch(rawVid, @"^\d+$") ? rawVid : "draft";
|
|
||||||
string draft = Path.Combine(WantedCardsDir(), $"{vid}.assistent.json");
|
|
||||||
File.WriteAllText(draft, card.ToString(Newtonsoft.Json.Formatting.Indented), Encoding.UTF8);
|
|
||||||
if (enqueue_wanted || !string.IsNullOrWhiteSpace(card["civitai_url"]?.ToString()))
|
|
||||||
{
|
|
||||||
await AssistentEnqueueWanted(session, kind, card["civitai_url"]?.ToString(), card["version_id"]?.Value<int?>() ?? 0, card["title"]?.ToString() ?? name, card);
|
|
||||||
}
|
|
||||||
_ = IngestCardToMemory(card, name);
|
|
||||||
return new JObject { ["success"] = true, ["path"] = draft, ["installed"] = false, ["wanted"] = true };
|
|
||||||
}
|
|
||||||
|
|
||||||
async Task IngestCardToMemory(JObject card, string name)
|
|
||||||
{
|
|
||||||
if (Memory is null || card is null)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try
|
|
||||||
{
|
|
||||||
string kind = (card["kind"]?.ToString() ?? "lora").Trim().ToLowerInvariant();
|
|
||||||
string key = (card["name"]?.ToString() ?? name ?? "").Trim();
|
|
||||||
List<string> bits = [];
|
|
||||||
foreach (string field in new[] { "when", "avoid", "prompt_hint", "notes" })
|
|
||||||
{
|
|
||||||
string v = card[field]?.ToString();
|
|
||||||
if (!string.IsNullOrWhiteSpace(v))
|
|
||||||
{
|
|
||||||
bits.Add($"{field}: {v.Trim()}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (card["triggers"] is JArray tr)
|
|
||||||
{
|
|
||||||
string joined = string.Join(", ", tr.Select(t => t?.ToString()).Where(s => !string.IsNullOrWhiteSpace(s)));
|
|
||||||
if (!string.IsNullOrWhiteSpace(joined))
|
|
||||||
{
|
|
||||||
bits.Add("triggers: " + joined);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (bits.Count == 0 || string.IsNullOrWhiteSpace(key))
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
string text = $"{kind} {key}. " + string.Join(" ", bits);
|
|
||||||
string baseUrl = NormalizeBaseUrl(Config.LoadSettings()["base_url"]?.ToString());
|
|
||||||
string embedModel = Config.LoadSettings()["embed_model"]?.ToString()
|
|
||||||
?? Config.LoadAssistant(Config.DefaultPersonaId())["embed_model"]?.ToString();
|
|
||||||
await Memory.UpsertTextAsync(baseUrl, "card", key, text, "user", card, embedModel, AssistentMemory.SharedPersona);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logs.Debug($"IngestCardToMemory: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<JObject> AssistentGetCardMeta(Session session, string kind, string name, int version_id = 0, bool fetch = false)
|
|
||||||
{
|
|
||||||
string set = SetNameForKind(kind);
|
|
||||||
string weight = ModelWeightPath(set, name);
|
|
||||||
JObject civitai = null;
|
|
||||||
JArray exampleUrls = [];
|
|
||||||
JArray previewUrls = [];
|
|
||||||
bool hasSidecar = false;
|
|
||||||
string fetchError = null;
|
|
||||||
bool fetched = false;
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(weight))
|
|
||||||
{
|
|
||||||
string stem = Path.GetFileNameWithoutExtension(weight);
|
|
||||||
string dir = Path.GetDirectoryName(weight);
|
|
||||||
string side = Path.Combine(dir ?? "", $"{stem}.civitai.json");
|
|
||||||
if (File.Exists(side))
|
|
||||||
{
|
|
||||||
hasSidecar = true;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
civitai = JObject.Parse(File.ReadAllText(side, Encoding.UTF8));
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
foreach (string suffix in new[] { ".preview.jpg", ".preview.png", ".preview.jpeg", ".jpg", ".png", ".webp" })
|
|
||||||
{
|
|
||||||
string prev = Path.Combine(dir ?? "", stem + suffix);
|
|
||||||
if (File.Exists(prev))
|
|
||||||
{
|
|
||||||
// Swarm View path — relative URL works in the same origin browser session.
|
|
||||||
previewUrls.Add($"View/Models/{(kind == "lora" ? "Lora" : "Stable-Diffusion")}/{Path.GetFileName(prev)}");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (civitai is not null)
|
|
||||||
{
|
|
||||||
if (version_id <= 0)
|
|
||||||
{
|
|
||||||
version_id = civitai["id"]?.Value<int?>() ?? 0;
|
|
||||||
}
|
|
||||||
CollectExampleUrls(civitai, exampleUrls);
|
|
||||||
}
|
|
||||||
|
|
||||||
string hash = null;
|
|
||||||
string trigger = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (Program.T2IModelSets.TryGetValue(set, out T2IModelHandler h)
|
|
||||||
&& (h.Models.TryGetValue(name, out T2IModel m)
|
|
||||||
|| h.Models.TryGetValue(name.Replace('\\', '/'), out m)))
|
|
||||||
{
|
|
||||||
trigger = m.Metadata?.TriggerPhrase;
|
|
||||||
hash = m.Metadata?.Hash;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fetch && civitai is null)
|
|
||||||
{
|
|
||||||
string apiKey = session.User.GetGenericData("civitai_api", "key") ?? "";
|
|
||||||
if (string.IsNullOrWhiteSpace(apiKey))
|
|
||||||
{
|
|
||||||
fetchError = "Civitai: нет ключа в User Settings";
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
JObject remote = null;
|
|
||||||
if (version_id > 0)
|
|
||||||
{
|
|
||||||
remote = await FetchCivitaiModelVersion(apiKey, version_id);
|
|
||||||
}
|
|
||||||
if (remote is null && !string.IsNullOrWhiteSpace(hash))
|
|
||||||
{
|
|
||||||
string sha = hash.Trim().ToLowerInvariant();
|
|
||||||
if (sha.StartsWith("sha256:"))
|
|
||||||
{
|
|
||||||
sha = sha["sha256:".Length..];
|
|
||||||
}
|
|
||||||
if (sha.Length == 64)
|
|
||||||
{
|
|
||||||
remote = await FetchCivitaiByHash(apiKey, sha);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
fetchError ??= "Civitai: хеш модели не SHA256";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (remote is not null)
|
|
||||||
{
|
|
||||||
civitai = remote;
|
|
||||||
fetched = true;
|
|
||||||
version_id = remote["id"]?.Value<int?>() ?? version_id;
|
|
||||||
CollectExampleUrls(remote, exampleUrls);
|
|
||||||
if (!string.IsNullOrWhiteSpace(weight))
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
string stem = Path.GetFileNameWithoutExtension(weight);
|
|
||||||
string dir = Path.GetDirectoryName(weight);
|
|
||||||
string side = Path.Combine(dir ?? "", $"{stem}.civitai.json");
|
|
||||||
File.WriteAllText(side, remote.ToString(Newtonsoft.Json.Formatting.Indented), Encoding.UTF8);
|
|
||||||
hasSidecar = true;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logs.Debug($"AssistentGetCardMeta write sidecar: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (fetchError is null)
|
|
||||||
{
|
|
||||||
fetchError = string.IsNullOrWhiteSpace(hash)
|
|
||||||
? "Civitai: нет hash и version_id"
|
|
||||||
: "Хеш не найден на Civitai";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
fetchError = $"Civitai: {ex.Message}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
JObject card = ReadCardObject(kind, name);
|
|
||||||
return new JObject
|
|
||||||
{
|
|
||||||
["success"] = true,
|
|
||||||
["kind"] = kind,
|
|
||||||
["name"] = name,
|
|
||||||
["version_id"] = version_id,
|
|
||||||
["trigger_phrase"] = trigger,
|
|
||||||
["has_card"] = card is not null,
|
|
||||||
["has_sidecar"] = hasSidecar,
|
|
||||||
["fetched"] = fetched,
|
|
||||||
["fetch_error"] = fetchError,
|
|
||||||
["card"] = card,
|
|
||||||
["civitai"] = civitai,
|
|
||||||
["example_urls"] = exampleUrls,
|
|
||||||
["preview_urls"] = previewUrls,
|
|
||||||
["weight_path"] = weight,
|
|
||||||
["hash"] = hash,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
static void CollectExampleUrls(JObject civitai, JArray exampleUrls)
|
|
||||||
{
|
|
||||||
if (civitai?["images"] is not JArray imgs)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
foreach (JToken img in imgs.Take(6))
|
|
||||||
{
|
|
||||||
string u = img?["url"]?.ToString();
|
|
||||||
if (!string.IsNullOrWhiteSpace(u))
|
|
||||||
{
|
|
||||||
exampleUrls.Add(u);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async Task<JObject> FetchCivitaiByHash(string apiKey, string sha)
|
|
||||||
{
|
|
||||||
string[] hosts = ["civitai.red", "civitai.com"];
|
|
||||||
Exception last = null;
|
|
||||||
foreach (string host in hosts)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
string url = $"https://{host}/api/v1/model-versions/by-hash/{sha}";
|
|
||||||
using HttpRequestMessage req = new(HttpMethod.Get, url);
|
|
||||||
if (!string.IsNullOrWhiteSpace(apiKey))
|
|
||||||
{
|
|
||||||
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey.Trim());
|
|
||||||
}
|
|
||||||
using HttpResponseMessage resp = await HttpClient.SendAsync(req);
|
|
||||||
string body = await resp.Content.ReadAsStringAsync();
|
|
||||||
if (resp.StatusCode == System.Net.HttpStatusCode.NotFound)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!resp.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
last = new Exception($"HTTP {(int)resp.StatusCode}: {Clip(body, 160)}");
|
|
||||||
if ((int)resp.StatusCode is 401 or 403)
|
|
||||||
{
|
|
||||||
throw last;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
return JObject.Parse(body);
|
|
||||||
}
|
|
||||||
catch (Exception ex) when (ex is not HttpRequestException && ex.Message.Contains("401"))
|
|
||||||
{
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
last = ex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (last is not null)
|
|
||||||
{
|
|
||||||
throw last;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async Task<JObject> FetchCivitaiModelVersion(string apiKey, int versionId)
|
|
||||||
{
|
|
||||||
string[] hosts = ["civitai.red", "civitai.com"];
|
|
||||||
Exception last = null;
|
|
||||||
foreach (string host in hosts)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
string url = $"https://{host}/api/v1/model-versions/{versionId}";
|
|
||||||
using HttpRequestMessage req = new(HttpMethod.Get, url);
|
|
||||||
if (!string.IsNullOrWhiteSpace(apiKey))
|
|
||||||
{
|
|
||||||
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey.Trim());
|
|
||||||
}
|
|
||||||
using HttpResponseMessage resp = await HttpClient.SendAsync(req);
|
|
||||||
string body = await resp.Content.ReadAsStringAsync();
|
|
||||||
if (!resp.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
last = new Exception($"HTTP {(int)resp.StatusCode}: {Clip(body, 160)}");
|
|
||||||
if ((int)resp.StatusCode is 401 or 403)
|
|
||||||
{
|
|
||||||
throw last;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
return JObject.Parse(body);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
last = ex;
|
|
||||||
if (ex.Message.Contains("401") || ex.Message.Contains("403"))
|
|
||||||
{
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (last is not null)
|
|
||||||
{
|
|
||||||
throw last;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Server-side LoRA / checkpoint / wildcard inventory (not DOM scrape).
|
/// <summary>Server-side LoRA / checkpoint / wildcard inventory (not DOM scrape).
|
||||||
/// Pass rescan=true after downloads so new files appear (calls Program.RefreshAllModelSets).</summary>
|
/// Pass rescan=true after downloads so new files appear (calls Program.RefreshAllModelSets).</summary>
|
||||||
public async Task<JObject> AssistentListInventory(Session session, bool rescan = false)
|
public async Task<JObject> AssistentListInventory(Session session, bool rescan = false)
|
||||||
@@ -553,8 +109,6 @@ public partial class SwarmAssistentExtension
|
|||||||
{
|
{
|
||||||
string weight = null;
|
string weight = null;
|
||||||
try { weight = model.RawFilePath; } catch { /* ignore */ }
|
try { weight = model.RawFilePath; } catch { /* ignore */ }
|
||||||
string cardPath = CardPathForWeight(weight);
|
|
||||||
bool hasCard = !string.IsNullOrWhiteSpace(cardPath) && File.Exists(cardPath);
|
|
||||||
|
|
||||||
string usage = model.Metadata?.UsageHint;
|
string usage = model.Metadata?.UsageHint;
|
||||||
string desc = model.Metadata?.Description;
|
string desc = model.Metadata?.Description;
|
||||||
@@ -571,30 +125,11 @@ public partial class SwarmAssistentExtension
|
|||||||
}
|
}
|
||||||
|
|
||||||
string blurb = null;
|
string blurb = null;
|
||||||
if (hasCard)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
JObject card = JObject.Parse(File.ReadAllText(cardPath, Encoding.UTF8));
|
|
||||||
string fromCard = (card["notes"] ?? card["when"] ?? card["prompt_hint"])?.ToString();
|
|
||||||
if (!string.IsNullOrWhiteSpace(fromCard))
|
|
||||||
{
|
|
||||||
blurb = Clip(fromCard.Trim(), CfgInt("inventory_blurb_max", InventoryBlurbMaxFallback));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// ignore bad card json
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (string.IsNullOrWhiteSpace(blurb))
|
|
||||||
{
|
|
||||||
string raw = !string.IsNullOrWhiteSpace(usage) ? usage : desc;
|
string raw = !string.IsNullOrWhiteSpace(usage) ? usage : desc;
|
||||||
if (!string.IsNullOrWhiteSpace(raw))
|
if (!string.IsNullOrWhiteSpace(raw))
|
||||||
{
|
{
|
||||||
blurb = Clip(CollapseWs(raw), CfgInt("inventory_blurb_max", InventoryBlurbMaxFallback));
|
blurb = Clip(CollapseWs(raw), CfgInt("inventory_blurb_max", InventoryBlurbMaxFallback));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
JArray tags = null;
|
JArray tags = null;
|
||||||
if (model.Metadata?.Tags is { Length: > 0 } tagArr)
|
if (model.Metadata?.Tags is { Length: > 0 } tagArr)
|
||||||
@@ -618,7 +153,6 @@ public partial class SwarmAssistentExtension
|
|||||||
["architecture"] = model.ModelClass?.ID,
|
["architecture"] = model.ModelClass?.ID,
|
||||||
["compat_class"] = model.ModelClass?.CompatClass?.ID,
|
["compat_class"] = model.ModelClass?.CompatClass?.ID,
|
||||||
["hash"] = model.Metadata?.Hash ?? "",
|
["hash"] = model.Metadata?.Hash ?? "",
|
||||||
["has_card"] = hasCard,
|
|
||||||
["krea_likely"] = LooksLikeKreaArch(model),
|
["krea_likely"] = LooksLikeKreaArch(model),
|
||||||
};
|
};
|
||||||
if (!string.IsNullOrWhiteSpace(weight))
|
if (!string.IsNullOrWhiteSpace(weight))
|
||||||
@@ -665,165 +199,4 @@ public partial class SwarmAssistentExtension
|
|||||||
}
|
}
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Search Civitai for LoRAs (prefers Krea 2 base). Uses Swarm-stored civitai_api key.</summary>
|
|
||||||
public async Task<JObject> AssistentSearchCivitai(Session session, string query, int limit = 8)
|
|
||||||
{
|
|
||||||
string q = (query ?? "").Trim();
|
|
||||||
if (string.IsNullOrWhiteSpace(q))
|
|
||||||
{
|
|
||||||
return new JObject { ["error"] = "query is required" };
|
|
||||||
}
|
|
||||||
limit = Math.Clamp(limit, 1, 20);
|
|
||||||
string apiKey = session.User.GetGenericData("civitai_api", "key") ?? "";
|
|
||||||
HashSet<string> installedNames = CollectInstalledLoraNames();
|
|
||||||
HashSet<string> installedHashes = CollectInstalledLoraHashes();
|
|
||||||
|
|
||||||
string[] hosts = ["civitai.red", "civitai.com"];
|
|
||||||
Exception lastEx = null;
|
|
||||||
foreach (string host in hosts)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
string url = $"https://{host}/api/v1/models?limit={limit}&types=LORA&query={Uri.EscapeDataString(q)}";
|
|
||||||
using HttpRequestMessage req = new(HttpMethod.Get, url);
|
|
||||||
if (!string.IsNullOrWhiteSpace(apiKey))
|
|
||||||
{
|
|
||||||
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey.Trim());
|
|
||||||
}
|
|
||||||
using HttpResponseMessage resp = await HttpClient.SendAsync(req);
|
|
||||||
string body = await resp.Content.ReadAsStringAsync();
|
|
||||||
if (!resp.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
lastEx = new Exception($"HTTP {(int)resp.StatusCode}: {Clip(body, 200)}");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
JObject parsed = JObject.Parse(body);
|
|
||||||
JArray items = parsed["items"] as JArray ?? [];
|
|
||||||
JArray results = [];
|
|
||||||
foreach (JToken item in items)
|
|
||||||
{
|
|
||||||
if (item is not JObject mo)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
JObject card = BuildCivitaiCard(mo, installedNames, installedHashes);
|
|
||||||
if (card is not null)
|
|
||||||
{
|
|
||||||
results.Add(card);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Prefer Krea-compatible first
|
|
||||||
JArray sorted = new(results.OrderByDescending(t => LooksLikeKrea(t["base_model"]?.ToString())).ThenBy(t => t["name"]?.ToString()));
|
|
||||||
return new JObject
|
|
||||||
{
|
|
||||||
["success"] = true,
|
|
||||||
["query"] = q,
|
|
||||||
["host"] = host,
|
|
||||||
["results"] = sorted,
|
|
||||||
["has_civitai_key"] = !string.IsNullOrWhiteSpace(apiKey),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
lastEx = ex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return new JObject { ["error"] = $"Civitai search failed: {lastEx?.Message ?? "unknown"}" };
|
|
||||||
}
|
|
||||||
|
|
||||||
static bool LooksLikeKrea(string text) => !string.IsNullOrEmpty(text) && Regex.IsMatch(text, @"krea", RegexOptions.IgnoreCase);
|
|
||||||
|
|
||||||
static HashSet<string> CollectInstalledLoraNames()
|
|
||||||
{
|
|
||||||
HashSet<string> names = new(StringComparer.OrdinalIgnoreCase);
|
|
||||||
if (!Program.T2IModelSets.TryGetValue("LoRA", out T2IModelHandler handler))
|
|
||||||
{
|
|
||||||
return names;
|
|
||||||
}
|
|
||||||
foreach (T2IModel m in handler.Models.Values)
|
|
||||||
{
|
|
||||||
names.Add(m.Name);
|
|
||||||
string leaf = m.Name.Replace('\\', '/').AfterLast('/');
|
|
||||||
if (!string.IsNullOrEmpty(leaf))
|
|
||||||
{
|
|
||||||
names.Add(leaf);
|
|
||||||
names.Add(Path.GetFileNameWithoutExtension(leaf));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return names;
|
|
||||||
}
|
|
||||||
|
|
||||||
static HashSet<string> CollectInstalledLoraHashes()
|
|
||||||
{
|
|
||||||
HashSet<string> hashes = new(StringComparer.OrdinalIgnoreCase);
|
|
||||||
if (!Program.T2IModelSets.TryGetValue("LoRA", out T2IModelHandler handler))
|
|
||||||
{
|
|
||||||
return hashes;
|
|
||||||
}
|
|
||||||
foreach (T2IModel m in handler.Models.Values)
|
|
||||||
{
|
|
||||||
string h = m.Metadata?.Hash;
|
|
||||||
if (!string.IsNullOrWhiteSpace(h))
|
|
||||||
{
|
|
||||||
hashes.Add(h.Trim().ToLowerInvariant());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return hashes;
|
|
||||||
}
|
|
||||||
|
|
||||||
static JObject BuildCivitaiCard(JObject model, HashSet<string> installedNames, HashSet<string> installedHashes)
|
|
||||||
{
|
|
||||||
string name = model["name"]?.ToString() ?? "";
|
|
||||||
JArray versions = model["modelVersions"] as JArray;
|
|
||||||
JObject ver = versions?.FirstOrDefault() as JObject;
|
|
||||||
if (ver is null)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
string baseModel = ver["baseModel"]?.ToString() ?? "";
|
|
||||||
JArray trained = ver["trainedWords"] as JArray ?? [];
|
|
||||||
List<string> triggers = trained.Select(t => t?.ToString()).Where(s => !string.IsNullOrWhiteSpace(s)).Take(8).ToList();
|
|
||||||
JObject file = null;
|
|
||||||
foreach (JToken f in ver["files"] as JArray ?? [])
|
|
||||||
{
|
|
||||||
if (f is JObject fo && (fo["primary"]?.Value<bool>() == true || (fo["name"]?.ToString() ?? "").EndsWith(".safetensors", StringComparison.OrdinalIgnoreCase)))
|
|
||||||
{
|
|
||||||
file = fo;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
file ??= (ver["files"] as JArray)?.FirstOrDefault() as JObject;
|
|
||||||
string downloadUrl = file?["downloadUrl"]?.ToString() ?? ver["downloadUrl"]?.ToString() ?? "";
|
|
||||||
string fileName = file?["name"]?.ToString() ?? "";
|
|
||||||
string sha = file?["hashes"]?["SHA256"]?.ToString() ?? file?["hashes"]?["AutoV2"]?.ToString() ?? "";
|
|
||||||
string saveName = string.IsNullOrWhiteSpace(fileName)
|
|
||||||
? Regex.Replace(name, @"[^\w\-.]+", "_").Trim('_')
|
|
||||||
: Path.GetFileNameWithoutExtension(fileName);
|
|
||||||
|
|
||||||
bool already = false;
|
|
||||||
if (!string.IsNullOrWhiteSpace(sha) && installedHashes.Contains(sha.Trim().ToLowerInvariant()))
|
|
||||||
{
|
|
||||||
already = true;
|
|
||||||
}
|
|
||||||
else if (installedNames.Contains(saveName) || installedNames.Contains(name) || installedNames.Contains(fileName))
|
|
||||||
{
|
|
||||||
already = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new JObject
|
|
||||||
{
|
|
||||||
["id"] = model["id"],
|
|
||||||
["version_id"] = ver["id"],
|
|
||||||
["name"] = name,
|
|
||||||
["base_model"] = baseModel,
|
|
||||||
["krea_likely"] = LooksLikeKrea(baseModel),
|
|
||||||
["triggers"] = new JArray(triggers),
|
|
||||||
["download_url"] = downloadUrl,
|
|
||||||
["file_name"] = saveName,
|
|
||||||
["sha256"] = sha,
|
|
||||||
["already_installed"] = already,
|
|
||||||
["n_sfw"] = model["nsfw"]?.Value<bool>() ?? false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-43
@@ -1,8 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
using SwarmUI.Accounts;
|
using SwarmUI.Accounts;
|
||||||
@@ -10,7 +8,7 @@ using SwarmUI.Utils;
|
|||||||
|
|
||||||
namespace Mrleo1nid.SwarmAssistent;
|
namespace Mrleo1nid.SwarmAssistent;
|
||||||
|
|
||||||
/// <summary>Read/write routes for the vector memory list in ⚙ and the gpu-rent wanted queue badge.</summary>
|
/// <summary>Read/write routes for the vector memory list in ⚙.</summary>
|
||||||
public partial class SwarmAssistentExtension
|
public partial class SwarmAssistentExtension
|
||||||
{
|
{
|
||||||
/// <summary>Embed model the UI should use: settings overlay wins, then persona assistant.json.</summary>
|
/// <summary>Embed model the UI should use: settings overlay wins, then persona assistant.json.</summary>
|
||||||
@@ -274,44 +272,4 @@ public partial class SwarmAssistentExtension
|
|||||||
return new JObject { ["error"] = $"memory clear: {ex.Message}" };
|
return new JObject { ["error"] = $"memory clear: {ex.Message}" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The gpu-rent wanted queue (models pending the next <c>up</c>) — count + entries.</summary>
|
|
||||||
public async Task<JObject> AssistentListWanted(Session session)
|
|
||||||
{
|
|
||||||
await Task.CompletedTask;
|
|
||||||
string path = WantedModelsPath();
|
|
||||||
JArray items = [];
|
|
||||||
if (!File.Exists(path))
|
|
||||||
{
|
|
||||||
return new JObject { ["success"] = true, ["count"] = 0, ["items"] = items, ["path"] = path };
|
|
||||||
}
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Dictionary<string, List<WantedEntry>> sections = LoadWantedYaml(File.ReadAllText(path, Encoding.UTF8));
|
|
||||||
foreach ((string kind, List<WantedEntry> list) in sections.OrderBy(p => p.Key, StringComparer.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
foreach (WantedEntry entry in list)
|
|
||||||
{
|
|
||||||
items.Add(new JObject
|
|
||||||
{
|
|
||||||
["kind"] = kind,
|
|
||||||
["url"] = entry.Url,
|
|
||||||
["title"] = entry.Title,
|
|
||||||
["version_id"] = entry.VersionId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return new JObject
|
|
||||||
{
|
|
||||||
["success"] = true,
|
|
||||||
["count"] = items.Count,
|
|
||||||
["items"] = items,
|
|
||||||
["path"] = path,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return new JObject { ["error"] = $"wanted queue: {ex.Message}" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-6
@@ -297,7 +297,22 @@ public partial class SwarmAssistentExtension
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Proxy to Ollama /api/chat (non-stream), with optional Civitai search hop.</summary>
|
/// <summary>Extract Ollama prompt token count when present.</summary>
|
||||||
|
static int? ReadPromptEvalCount(JObject raw)
|
||||||
|
{
|
||||||
|
if (raw is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
int? n = raw["prompt_eval_count"]?.Value<int?>();
|
||||||
|
if (n is null || n <= 0)
|
||||||
|
{
|
||||||
|
n = raw["promptEvalCount"]?.Value<int?>();
|
||||||
|
}
|
||||||
|
return n is > 0 ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Proxy to Ollama /api/chat (non-stream), with optional ask hops.</summary>
|
||||||
public async Task<JObject> AssistentChat(Session session, string baseUrl, string model, string pack, bool includeBase, JObject raw)
|
public async Task<JObject> AssistentChat(Session session, string baseUrl, string model, string pack, bool includeBase, JObject raw)
|
||||||
{
|
{
|
||||||
ExtractChatPayload(raw, ref baseUrl, ref model, ref pack, ref includeBase, out JArray userMessages, out string contextJson, out string persona, out JArray skills);
|
ExtractChatPayload(raw, ref baseUrl, ref model, ref pack, ref includeBase, out JArray userMessages, out string contextJson, out string persona, out JArray skills);
|
||||||
@@ -314,7 +329,7 @@ public partial class SwarmAssistentExtension
|
|||||||
{
|
{
|
||||||
(string reply, JObject parsed, JArray civitai, int systemChars, JObject systemLayers) = await RunChatWithHops(
|
(string reply, JObject parsed, JArray civitai, int systemChars, JObject systemLayers) = await RunChatWithHops(
|
||||||
session, root, modelName, packName, includeBase, contextJson, userMessages, personaId: persona, skillIds: skills, embedModel: embedModel);
|
session, root, modelName, packName, includeBase, contextJson, userMessages, personaId: persona, skillIds: skills, embedModel: embedModel);
|
||||||
return new JObject
|
JObject result = new()
|
||||||
{
|
{
|
||||||
["success"] = true,
|
["success"] = true,
|
||||||
["reply"] = reply,
|
["reply"] = reply,
|
||||||
@@ -326,6 +341,12 @@ public partial class SwarmAssistentExtension
|
|||||||
["system_chars"] = systemChars,
|
["system_chars"] = systemChars,
|
||||||
["system_layers"] = systemLayers,
|
["system_layers"] = systemLayers,
|
||||||
};
|
};
|
||||||
|
int? promptEval = ReadPromptEvalCount(parsed);
|
||||||
|
if (promptEval is not null)
|
||||||
|
{
|
||||||
|
result["prompt_eval_count"] = promptEval.Value;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -333,7 +354,7 @@ public partial class SwarmAssistentExtension
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>WebSocket streaming chat (Ollama stream:true) + Civitai hops.</summary>
|
/// <summary>WebSocket streaming chat (Ollama stream:true) + ask hops.</summary>
|
||||||
public async Task<JObject> AssistentChatWS(Session session, WebSocket ws, string baseUrl, string model, string pack, bool includeBase, JObject raw)
|
public async Task<JObject> AssistentChatWS(Session session, WebSocket ws, string baseUrl, string model, string pack, bool includeBase, JObject raw)
|
||||||
{
|
{
|
||||||
ExtractChatPayload(raw, ref baseUrl, ref model, ref pack, ref includeBase, out JArray userMessages, out string contextJson, out string persona, out JArray skills);
|
ExtractChatPayload(raw, ref baseUrl, ref model, ref pack, ref includeBase, out JArray userMessages, out string contextJson, out string persona, out JArray skills);
|
||||||
@@ -372,13 +393,13 @@ public partial class SwarmAssistentExtension
|
|||||||
{
|
{
|
||||||
["clear_stream"] = true,
|
["clear_stream"] = true,
|
||||||
["hop"] = hop + 1,
|
["hop"] = hop + 1,
|
||||||
["notice"] = "Civitai search done — refining…",
|
["notice"] = "Ask hop — refining…",
|
||||||
}, API.WebsocketTimeout);
|
}, API.WebsocketTimeout);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
(string reply, JObject parsed, JArray civitai, int systemChars, JObject systemLayers) = await RunChatWithHops(
|
(string reply, JObject parsed, JArray civitai, int systemChars, JObject systemLayers) = await RunChatWithHops(
|
||||||
session, root, modelName, packName, includeBase, contextJson, userMessages, OnDelta, OnHopStart, persona, skills, embedModel);
|
session, root, modelName, packName, includeBase, contextJson, userMessages, OnDelta, OnHopStart, persona, skills, embedModel);
|
||||||
await ws.SendJson(new JObject
|
JObject done = new()
|
||||||
{
|
{
|
||||||
["success"] = true,
|
["success"] = true,
|
||||||
["done"] = true,
|
["done"] = true,
|
||||||
@@ -390,7 +411,13 @@ public partial class SwarmAssistentExtension
|
|||||||
["civitai_results"] = civitai,
|
["civitai_results"] = civitai,
|
||||||
["system_chars"] = systemChars,
|
["system_chars"] = systemChars,
|
||||||
["system_layers"] = systemLayers,
|
["system_layers"] = systemLayers,
|
||||||
}, API.WebsocketTimeout);
|
};
|
||||||
|
int? promptEval = ReadPromptEvalCount(parsed);
|
||||||
|
if (promptEval is not null)
|
||||||
|
{
|
||||||
|
done["prompt_eval_count"] = promptEval.Value;
|
||||||
|
}
|
||||||
|
await ws.SendJson(done, API.WebsocketTimeout);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
+65
-119
@@ -46,28 +46,25 @@ public partial class SwarmAssistentExtension
|
|||||||
patch["look_at"] = patch["vision_slots"];
|
patch["look_at"] = patch["vision_slots"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (ActionsContain(patch, "generate"))
|
||||||
|
{
|
||||||
|
patch["generate"] = true;
|
||||||
|
}
|
||||||
|
if (patch["ask"] is JValue askVal && askVal.Type == JTokenType.String)
|
||||||
|
{
|
||||||
|
string one = askVal.ToString()?.Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(one))
|
||||||
|
{
|
||||||
|
patch["ask"] = new JArray(one);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
patch.Remove("ask");
|
||||||
|
}
|
||||||
|
}
|
||||||
return patch;
|
return patch;
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool LooksLikeCardObject(JObject obj)
|
|
||||||
{
|
|
||||||
if (obj is null)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
bool cardish = HasValue(obj, "kind") || HasValue(obj, "triggers") || HasValue(obj, "when") || HasValue(obj, "prompt_hint");
|
|
||||||
bool genish = HasValue(obj, "prompt") || HasValue(obj, "negative") || HasValue(obj, "loras") || HasValue(obj, "actions")
|
|
||||||
|| HasValue(obj, "width") || HasValue(obj, "height") || HasValue(obj, "steps") || HasValue(obj, "cfg")
|
|
||||||
|| HasValue(obj, "aspect") || HasValue(obj, "seed") || HasValue(obj, "search_query")
|
|
||||||
|| HasValue(obj, "civitai_query") || HasValue(obj, "look_at") || HasValue(obj, "controls");
|
|
||||||
if (cardish && !genish && (HasValue(obj, "name") || HasValue(obj, "triggers") || HasValue(obj, "when")))
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return HasValue(obj, "kind") && HasValue(obj, "name")
|
|
||||||
&& (HasValue(obj, "triggers") || HasValue(obj, "when") || HasValue(obj, "prompt_hint") || HasValue(obj, "notes"));
|
|
||||||
}
|
|
||||||
|
|
||||||
JObject TryParsePatch(string reply)
|
JObject TryParsePatch(string reply)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(reply))
|
if (string.IsNullOrWhiteSpace(reply))
|
||||||
@@ -82,7 +79,7 @@ public partial class SwarmAssistentExtension
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
JObject obj = JObject.Parse(raw);
|
JObject obj = JObject.Parse(raw);
|
||||||
if (obj is null || LooksLikeCardObject(obj))
|
if (obj is null)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -90,7 +87,7 @@ public partial class SwarmAssistentExtension
|
|||||||
{
|
{
|
||||||
JObject normalized = NormalizePatch(obj);
|
JObject normalized = NormalizePatch(obj);
|
||||||
lastAny = normalized;
|
lastAny = normalized;
|
||||||
if (FenceIsTerminalPatch(obj))
|
if (FenceIsTerminalPatch(normalized))
|
||||||
{
|
{
|
||||||
lastTerminal = normalized;
|
lastTerminal = normalized;
|
||||||
}
|
}
|
||||||
@@ -105,10 +102,9 @@ public partial class SwarmAssistentExtension
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// If the reply already contains a closed fenced patch/card that is "done enough" to act on,
|
/// If the reply already contains a closed fenced patch that is "done enough" to act on,
|
||||||
/// cut everything after it. Do NOT stop on weak fences (pack/creativity/notes-only) — models
|
/// cut everything after it. Do NOT stop on weak fences (pack/creativity/notes-only) — models
|
||||||
/// often emit a tiny JSON first then the real prompt fence; aborting early cuts the prompt
|
/// often emit a tiny JSON first then the real prompt fence; aborting early cuts the prompt.
|
||||||
/// and blocks skill_load / generate.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
static bool TryTruncateAtCompleteFence(string reply, out string truncated)
|
static bool TryTruncateAtCompleteFence(string reply, out string truncated)
|
||||||
{
|
{
|
||||||
@@ -128,7 +124,7 @@ public partial class SwarmAssistentExtension
|
|||||||
string raw = match.Groups[1].Value.Trim();
|
string raw = match.Groups[1].Value.Trim();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
JObject obj = JObject.Parse(raw);
|
JObject obj = NormalizePatch(JObject.Parse(raw));
|
||||||
if (obj is null || !FenceIsTerminalPatch(obj))
|
if (obj is null || !FenceIsTerminalPatch(obj))
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
@@ -145,7 +141,7 @@ public partial class SwarmAssistentExtension
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// True when a closed fence is worth aborting the Ollama stream (real deliverable or tool hop).
|
/// True when a closed fence is worth aborting the Ollama stream (real deliverable or ask hop).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
static bool FenceIsTerminalPatch(JObject obj)
|
static bool FenceIsTerminalPatch(JObject obj)
|
||||||
{
|
{
|
||||||
@@ -153,7 +149,11 @@ public partial class SwarmAssistentExtension
|
|||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (LooksLikeCardObject(obj))
|
if (obj["generate"]?.Type == JTokenType.Boolean && obj["generate"].Value<bool>())
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (HasAsk(obj))
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -165,50 +165,18 @@ public partial class SwarmAssistentExtension
|
|||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (HasValue(obj, "search_query") || HasValue(obj, "civitai_query"))
|
if (ActionsContain(obj, "generate"))
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (HasValue(obj, "memory_query") || HasValue(obj, "tag_query") || HasValue(obj, "inventory_query"))
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (obj["actions"] is JArray acts)
|
|
||||||
{
|
|
||||||
foreach (JToken a in acts)
|
|
||||||
{
|
|
||||||
string s = a?.ToString() ?? "";
|
|
||||||
if (string.IsNullOrWhiteSpace(s))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (s.Equals("skill_load", StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| s.Equals("persona_read", StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| s.Equals("memory_get", StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| s.Equals("memory_search", StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| s.Equals("heard_search", StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| s.Equals("lookup_tags", StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| s.Equals("list_inventory", StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| s.Equals("search_civitai", StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| s.Equals("interrupt", StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| s.Equals("generate", StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| s.Equals("memory_upsert", StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| s.Equals("user_pref_upsert", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
string prompt = obj["prompt"]?.ToString()?.Trim() ?? "";
|
string prompt = obj["prompt"]?.ToString()?.Trim() ?? "";
|
||||||
if (prompt.Length >= 48)
|
if (prompt.Length >= 48)
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// Real param change without prose notes
|
|
||||||
if (HasValue(obj, "loras") || HasValue(obj, "aspect") || HasValue(obj, "steps")
|
if (HasValue(obj, "loras") || HasValue(obj, "aspect") || HasValue(obj, "steps")
|
||||||
|| HasValue(obj, "width") || HasValue(obj, "height") || HasValue(obj, "cfg")
|
|| HasValue(obj, "width") || HasValue(obj, "height") || HasValue(obj, "cfg")
|
||||||
|| HasValue(obj, "seed") || HasValue(obj, "controls")
|
|| HasValue(obj, "seed") || HasValue(obj, "controls"))
|
||||||
|| HasValue(obj, "memories") || HasValue(obj, "user_prefs"))
|
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -216,14 +184,40 @@ public partial class SwarmAssistentExtension
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
static string ExtractSearchQuery(JObject patch)
|
static bool HasAsk(JObject patch)
|
||||||
{
|
{
|
||||||
if (patch is null)
|
if (patch?["ask"] is JArray asks)
|
||||||
{
|
{
|
||||||
return null;
|
foreach (JToken t in asks)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(t?.ToString()))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
string q = (patch["search_query"] ?? patch["civitai_query"])?.ToString()?.Trim();
|
}
|
||||||
return string.IsNullOrWhiteSpace(q) ? null : q;
|
return false;
|
||||||
|
}
|
||||||
|
return !string.IsNullOrWhiteSpace(patch?["ask"]?.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool AskContains(JObject patch, string name)
|
||||||
|
{
|
||||||
|
if (patch is null || string.IsNullOrWhiteSpace(name))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (patch["ask"] is JArray asks)
|
||||||
|
{
|
||||||
|
foreach (JToken t in asks)
|
||||||
|
{
|
||||||
|
if (string.Equals(t?.ToString()?.Trim(), name, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return string.Equals(patch["ask"]?.ToString()?.Trim(), name, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool ActionsContain(JObject patch, string action)
|
static bool ActionsContain(JObject patch, string action)
|
||||||
@@ -242,26 +236,7 @@ public partial class SwarmAssistentExtension
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
static string ExtractMemoryQuery(JObject patch)
|
/// <summary>Server tool hops are ask-only: settings dump or truncated inventory.</summary>
|
||||||
{
|
|
||||||
string q = patch?["memory_query"]?.ToString()?.Trim();
|
|
||||||
if (!string.IsNullOrWhiteSpace(q))
|
|
||||||
{
|
|
||||||
return q;
|
|
||||||
}
|
|
||||||
return ActionsContain(patch, "memory_search") ? ExtractSearchQuery(patch) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
static string ExtractTagQuery(JObject patch)
|
|
||||||
{
|
|
||||||
string q = patch?["tag_query"]?.ToString()?.Trim();
|
|
||||||
if (!string.IsNullOrWhiteSpace(q))
|
|
||||||
{
|
|
||||||
return q;
|
|
||||||
}
|
|
||||||
return ActionsContain(patch, "lookup_tags") ? ExtractSearchQuery(patch) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
static string NextToolHop(JObject patch, HashSet<string> skip = null)
|
static string NextToolHop(JObject patch, HashSet<string> skip = null)
|
||||||
{
|
{
|
||||||
if (patch is null)
|
if (patch is null)
|
||||||
@@ -269,42 +244,13 @@ public partial class SwarmAssistentExtension
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
bool Skip(string tool) => skip is not null && skip.Contains(tool);
|
bool Skip(string tool) => skip is not null && skip.Contains(tool);
|
||||||
if (ActionsContain(patch, "memory_get") && !Skip("memory_get"))
|
if (AskContains(patch, "settings") && !Skip("ask_settings"))
|
||||||
{
|
{
|
||||||
return "memory_get";
|
return "ask_settings";
|
||||||
}
|
}
|
||||||
if ((ActionsContain(patch, "memory_search") || !string.IsNullOrWhiteSpace(patch["memory_query"]?.ToString()))
|
if (AskContains(patch, "inventory") && !Skip("ask_inventory"))
|
||||||
&& !Skip("memory_search"))
|
|
||||||
{
|
{
|
||||||
return "memory_search";
|
return "ask_inventory";
|
||||||
}
|
|
||||||
if ((ActionsContain(patch, "heard_search") || string.Equals(patch["heard_query"]?.ToString(), "1", StringComparison.Ordinal))
|
|
||||||
&& !Skip("heard_search"))
|
|
||||||
{
|
|
||||||
return "heard_search";
|
|
||||||
}
|
|
||||||
if ((ActionsContain(patch, "lookup_tags") || !string.IsNullOrWhiteSpace(patch["tag_query"]?.ToString()))
|
|
||||||
&& !Skip("lookup_tags"))
|
|
||||||
{
|
|
||||||
return "lookup_tags";
|
|
||||||
}
|
|
||||||
if ((ActionsContain(patch, "list_inventory") || !string.IsNullOrWhiteSpace(patch["inventory_query"]?.ToString()))
|
|
||||||
&& !Skip("list_inventory"))
|
|
||||||
{
|
|
||||||
return "list_inventory";
|
|
||||||
}
|
|
||||||
if (ActionsContain(patch, "skill_load") && !Skip("skill_load"))
|
|
||||||
{
|
|
||||||
return "skill_load";
|
|
||||||
}
|
|
||||||
if (ActionsContain(patch, "persona_read") && !Skip("persona_read"))
|
|
||||||
{
|
|
||||||
return "persona_read";
|
|
||||||
}
|
|
||||||
if ((ActionsContain(patch, "search_civitai") || !string.IsNullOrWhiteSpace(ExtractSearchQuery(patch)))
|
|
||||||
&& !Skip("civitai"))
|
|
||||||
{
|
|
||||||
return "civitai";
|
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -190,7 +190,10 @@ public partial class SwarmAssistentExtension
|
|||||||
if (assistant is not null && assistant.Count > 0)
|
if (assistant is not null && assistant.Count > 0)
|
||||||
{
|
{
|
||||||
JObject sparse = new();
|
JObject sparse = new();
|
||||||
foreach (string key in new[] { "num_ctx", "history_keep_turns", "memory_top_k", "user_prefs_weight", "user_prefs_max" })
|
foreach (string key in new[] {
|
||||||
|
"num_ctx", "history_keep_turns", "memory_top_k", "user_prefs_weight", "user_prefs_max",
|
||||||
|
"compress_at", "chars_per_token", "compress_auto", "num_predict",
|
||||||
|
})
|
||||||
{
|
{
|
||||||
if (assistant[key] is not null)
|
if (assistant[key] is not null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,189 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Newtonsoft.Json.Linq;
|
|
||||||
using SwarmUI.Accounts;
|
|
||||||
|
|
||||||
namespace Mrleo1nid.SwarmAssistent;
|
|
||||||
|
|
||||||
/// <summary>Queue of models the assistant wants downloaded (merged into gpu-rent models.yaml on next up/capture).</summary>
|
|
||||||
public partial class SwarmAssistentExtension
|
|
||||||
{
|
|
||||||
static readonly object WantedFileLock = new();
|
|
||||||
|
|
||||||
string WantedModelsPath() => Path.Combine(DataRoot(), ".gpu-rent-wanted-models.yaml");
|
|
||||||
|
|
||||||
string WantedCardsDir() => Path.Combine(DataRoot(), ".gpu-rent-wanted-cards");
|
|
||||||
|
|
||||||
public async Task<JObject> AssistentEnqueueWanted(Session session, string kind, string url, int version_id = 0, string title = null, JObject card = null)
|
|
||||||
{
|
|
||||||
await Task.CompletedTask;
|
|
||||||
kind = (kind ?? "lora").Trim().ToLowerInvariant();
|
|
||||||
if (kind is not ("lora" or "checkpoint" or "vae" or "embedding" or "controlnet" or "upscaler" or "clip"))
|
|
||||||
{
|
|
||||||
kind = "lora";
|
|
||||||
}
|
|
||||||
url = (url ?? "").Trim();
|
|
||||||
if (string.IsNullOrWhiteSpace(url) && version_id > 0)
|
|
||||||
{
|
|
||||||
url = $"https://civitai.red/models/0?modelVersionId={version_id}";
|
|
||||||
}
|
|
||||||
if (string.IsNullOrWhiteSpace(url))
|
|
||||||
{
|
|
||||||
return new JObject { ["error"] = "url or version_id required" };
|
|
||||||
}
|
|
||||||
if (version_id <= 0)
|
|
||||||
{
|
|
||||||
Match m = Regex.Match(url, @"modelVersionId=(\d+)", RegexOptions.IgnoreCase);
|
|
||||||
if (m.Success)
|
|
||||||
{
|
|
||||||
version_id = int.Parse(m.Groups[1].Value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
string path = WantedModelsPath();
|
|
||||||
Directory.CreateDirectory(Path.GetDirectoryName(path) ?? DataRoot());
|
|
||||||
lock (WantedFileLock)
|
|
||||||
{
|
|
||||||
Dictionary<string, List<WantedEntry>> sections = LoadWantedYaml(File.Exists(path) ? File.ReadAllText(path, Encoding.UTF8) : "");
|
|
||||||
|
|
||||||
if (version_id > 0)
|
|
||||||
{
|
|
||||||
foreach (List<WantedEntry> list in sections.Values)
|
|
||||||
{
|
|
||||||
if (list.Any(e => e.VersionId == version_id))
|
|
||||||
{
|
|
||||||
return new JObject { ["success"] = true, ["already"] = true, ["path"] = path, ["version_id"] = version_id };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
foreach (List<WantedEntry> list in sections.Values)
|
|
||||||
{
|
|
||||||
if (list.Any(e => string.Equals(e.Url, url, StringComparison.OrdinalIgnoreCase)))
|
|
||||||
{
|
|
||||||
return new JObject { ["success"] = true, ["already"] = true, ["path"] = path };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!sections.TryGetValue(kind, out List<WantedEntry> bucket))
|
|
||||||
{
|
|
||||||
bucket = [];
|
|
||||||
sections[kind] = bucket;
|
|
||||||
}
|
|
||||||
bucket.Add(new WantedEntry { Url = url, Title = title, VersionId = version_id });
|
|
||||||
File.WriteAllText(path, WriteWantedYaml(sections), Encoding.UTF8);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (card is not null && version_id > 0)
|
|
||||||
{
|
|
||||||
Directory.CreateDirectory(WantedCardsDir());
|
|
||||||
string draft = Path.Combine(WantedCardsDir(), $"{version_id}.assistent.json");
|
|
||||||
File.WriteAllText(draft, card.ToString(Newtonsoft.Json.Formatting.Indented), Encoding.UTF8);
|
|
||||||
}
|
|
||||||
return new JObject { ["success"] = true, ["path"] = path, ["version_id"] = version_id };
|
|
||||||
}
|
|
||||||
|
|
||||||
sealed class WantedEntry
|
|
||||||
{
|
|
||||||
public string Url;
|
|
||||||
public string Title;
|
|
||||||
public int VersionId;
|
|
||||||
}
|
|
||||||
|
|
||||||
static Dictionary<string, List<WantedEntry>> LoadWantedYaml(string raw)
|
|
||||||
{
|
|
||||||
Dictionary<string, List<WantedEntry>> sections = new(StringComparer.OrdinalIgnoreCase);
|
|
||||||
string currentKind = null;
|
|
||||||
WantedEntry cur = null;
|
|
||||||
void Flush()
|
|
||||||
{
|
|
||||||
if (cur is null || string.IsNullOrWhiteSpace(cur.Url) || string.IsNullOrWhiteSpace(currentKind))
|
|
||||||
{
|
|
||||||
cur = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!sections.TryGetValue(currentKind, out List<WantedEntry> list))
|
|
||||||
{
|
|
||||||
list = [];
|
|
||||||
sections[currentKind] = list;
|
|
||||||
}
|
|
||||||
list.Add(cur);
|
|
||||||
cur = null;
|
|
||||||
}
|
|
||||||
foreach (string line in (raw ?? "").Split('\n'))
|
|
||||||
{
|
|
||||||
string t = line.TrimEnd();
|
|
||||||
if (string.IsNullOrWhiteSpace(t) || t.TrimStart().StartsWith('#'))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
Match kindLine = Regex.Match(t, @"^([A-Za-z0-9_-]+):\s*$");
|
|
||||||
if (kindLine.Success && !t.TrimStart().StartsWith('-'))
|
|
||||||
{
|
|
||||||
Flush();
|
|
||||||
currentKind = kindLine.Groups[1].Value.Trim().ToLowerInvariant();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
Match urlLine = Regex.Match(t, @"^\s*-\s*url:\s*[""']?(.+?)[""']?\s*$");
|
|
||||||
if (urlLine.Success)
|
|
||||||
{
|
|
||||||
Flush();
|
|
||||||
cur = new WantedEntry { Url = urlLine.Groups[1].Value.Trim() };
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (cur is null)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
Match titleLine = Regex.Match(t, @"^\s*title:\s*[""']?(.+?)[""']?\s*$");
|
|
||||||
if (titleLine.Success)
|
|
||||||
{
|
|
||||||
cur.Title = titleLine.Groups[1].Value.Trim();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
Match vidLine = Regex.Match(t, @"^\s*version_id:\s*(\d+)\s*$");
|
|
||||||
if (vidLine.Success && int.TryParse(vidLine.Groups[1].Value, out int vid))
|
|
||||||
{
|
|
||||||
cur.VersionId = vid;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Flush();
|
|
||||||
return sections;
|
|
||||||
}
|
|
||||||
|
|
||||||
static string WriteWantedYaml(Dictionary<string, List<WantedEntry>> sections)
|
|
||||||
{
|
|
||||||
StringBuilder sb = new();
|
|
||||||
sb.AppendLine("# Assistent wanted queue — merged into local models.yaml on gpu-rent up/capture");
|
|
||||||
string[] order = ["checkpoint", "lora", "vae", "embedding", "controlnet", "upscaler", "clip"];
|
|
||||||
HashSet<string> seen = new(StringComparer.OrdinalIgnoreCase);
|
|
||||||
foreach (string kind in order.Concat(sections.Keys.OrderBy(k => k, StringComparer.OrdinalIgnoreCase)))
|
|
||||||
{
|
|
||||||
if (!seen.Add(kind) || !sections.TryGetValue(kind, out List<WantedEntry> list) || list.Count == 0)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
sb.AppendLine($"{kind}:");
|
|
||||||
foreach (WantedEntry e in list)
|
|
||||||
{
|
|
||||||
sb.AppendLine($" - url: \"{e.Url.Replace("\"", "%22")}\"");
|
|
||||||
if (!string.IsNullOrWhiteSpace(e.Title))
|
|
||||||
{
|
|
||||||
sb.AppendLine($" title: \"{e.Title.Replace("\"", "'")}\"");
|
|
||||||
}
|
|
||||||
if (e.VersionId > 0)
|
|
||||||
{
|
|
||||||
sb.AppendLine($" version_id: {e.VersionId}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return sb.ToString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
{
|
{
|
||||||
"num_ctx": 16384,
|
"num_ctx": 16384,
|
||||||
"num_predict": 3072,
|
"num_predict": 3072,
|
||||||
"max_civitai_hops": 2,
|
|
||||||
"max_loras_inventory": 150,
|
"max_loras_inventory": 150,
|
||||||
"max_checkpoints_inventory": 60,
|
"max_checkpoints_inventory": 60,
|
||||||
"max_wildcards_inventory": 80,
|
"max_wildcards_inventory": 80,
|
||||||
@@ -19,7 +18,7 @@
|
|||||||
"memory_min_score": 0.32,
|
"memory_min_score": 0.32,
|
||||||
"user_prefs_weight": 1.0,
|
"user_prefs_weight": 1.0,
|
||||||
"user_prefs_max": 16,
|
"user_prefs_max": 16,
|
||||||
"max_tool_hops": 4,
|
"max_tool_hops": 2,
|
||||||
"tag_lookup_limit": 20,
|
"tag_lookup_limit": 20,
|
||||||
"identity_always_shelves": ["persona", "voice", "rules", "likes", "dislikes"],
|
"identity_always_shelves": ["persona", "voice", "rules", "likes", "dislikes"],
|
||||||
"memory_quotas": {
|
"memory_quotas": {
|
||||||
@@ -36,5 +35,8 @@
|
|||||||
"keywords": ["krea"]
|
"keywords": ["krea"]
|
||||||
},
|
},
|
||||||
"context_prompt_max": 2000,
|
"context_prompt_max": 2000,
|
||||||
"history_keep_turns": 4
|
"history_keep_turns": 4,
|
||||||
|
"compress_at": 0.70,
|
||||||
|
"chars_per_token": 3.2,
|
||||||
|
"compress_auto": true
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-46
@@ -10,84 +10,63 @@ When instructions conflict, apply this order (highest wins):
|
|||||||
1. **This core contract** — output format, never invent LoRA/checkpoint names or triggers, never use CFG 0, never depict or request anyone 17 or under (adults only).
|
1. **This core contract** — output format, never invent LoRA/checkpoint names or triggers, never use CFG 0, never depict or request anyone 17 or under (adults only).
|
||||||
2. **Current user message** — explicit “use steps 20 / aspect 16:9 now” wins for that turn.
|
2. **Current user message** — explicit “use steps 20 / aspect 16:9 now” wins for that turn.
|
||||||
3. **About the user** (`## About the user`) — durable preferences (global + this persona). Respect unless this turn overrides.
|
3. **About the user** (`## About the user`) — durable preferences (global + this persona). Respect unless this turn overrides.
|
||||||
4. **Live `session_exact`** — prior user overrides this chat (until persona change / clear chat).
|
4. **Chat session** (`## Live SwarmUI context` / session JSON) — current generation settings for **this chat** (prompt, params, LoRAs, board). Source of truth for Generate.
|
||||||
5. **Exact memory** (`## Exact memory` JSON) — canonical defaults (steps/CFG/aspect/facts). Persona overlays are already merged.
|
5. **Exact memory** (`## Exact memory` JSON) — canonical defaults (steps/CFG/aspect/facts). Persona overlays are already merged.
|
||||||
6. **Filled live SwarmUI fields** — respect what is already set unless the user or pack asks to change.
|
6. Guesses — last resort only.
|
||||||
7. **`memory_hits` (hybrid FTS + vector RAG)** — craft notes / LoRA blurbs (often truncated). Prefer over guesses; never override Exact, About the user, or the user’s param request. Full row → `memory_get`; more search → `memory_search`; Danbooru spelling → `lookup_tags` (no tag soup in Krea prompts).
|
|
||||||
8. Guesses — last resort only.
|
|
||||||
|
|
||||||
Exact = defaults encyclopedia. About the user = human taste. RAG = soft craft notes. Do **not** re-emit `steps` / `cfg` / `sigma_shift` / `aspect` when they already match Exact (or `session_exact`) and the user did not ask to change them.
|
Exact = defaults encyclopedia. About the user = human taste. Session = what Generate will run. Soft craft notes may appear in context from the server — never override Exact, About the user, or the user’s param request.
|
||||||
|
|
||||||
Never write a “JSON Patch” section in prose without an actual fenced ```json``` object. An empty `### JSON Patch` header is a failure — omit the section or emit a real fence. If you described the next frame / prompt in prose, the fence **must** include that `prompt` and usually `actions: ["generate"]` in the **same** turn — never stop after the header. **`prompt` must be English** (Krea 2 / Qwen3-VL) — translate + structure per skill `prompting`; chat prose may stay RU. Keep prose short (a few lines). **After the closing ``` of the JSON fence, STOP** — no «Готово!», no second fenced patch, no “сейчас сгенерирую оба” in prose. **One turn = one patch.** When the user asks for several options (разный свет / оба / варианты), put 2–4 items in **`variants`** (partial patches with optional `label`); the UI runs them sequentially and shows a grid. Do not emit two fences. Ordinary single-image requests stay one patch without `variants`. Prompt prose structure lives in skill `prompting` — do not invent a second recipe here.
|
**Sparse deltas only.** Do **not** re-emit `steps` / `cfg` / `sigma_shift` / `aspect` / full `prompt` when they already match the session and the user did not ask to change them.
|
||||||
|
|
||||||
## Live context
|
Never write a “JSON Patch” section in prose without an actual fenced ```json``` object. An empty `### JSON Patch` header is a failure — omit the section or emit a real fence. Chat prose may stay RU; **`prompt` must be English** (Krea 2). Keep prose short. **After the closing ``` of the JSON fence, STOP.** **One turn = one patch.** Several options → `variants` (2–4). Prompt structure lives in skill `prompting`.
|
||||||
|
|
||||||
"Live SwarmUI context" JSON is ground truth for this turn:
|
## Live context (chat session)
|
||||||
|
|
||||||
- Use only LoRA/checkpoint **names** from `selected_loras` / `available_loras` (or Civitai hop results). `selected_loras` = currently enabled. Prefer listed `triggers` / `trigger_phrase` / `blurb` — **never invent**.
|
Compact session JSON is ground truth for this turn:
|
||||||
- Rich entries (blurbs/triggers) are selected + top krea-likely. Name-only rows need `list_inventory` + `inventory_query` before you rely on them.
|
|
||||||
- `memory_hits` may be truncated (`truncated: true`) — use `memory_get` for the full text.
|
|
||||||
- `has_vision_image` true means a real board frame exists. `images_in_request` true means JPEG bytes are in **this** request. **Do not** `look_at` just because a frame exists. Emit `look_at` only when you cannot continue without pixels (user asked to look/critique/describe/compare, or a defect you cannot infer from the prompt). Never invent what the image looks like. A new Generate / «ещё» / prompt edit does **not** need vision.
|
|
||||||
- Prefer `krea_likely` / Krea architecture; ignore FLUX/SDXL. Respect current params unless asked or pack is `fix_params`.
|
|
||||||
- Init/inpaint flags and `image_slots` are in the JSON. Extra pack fields are documented in the active pack.
|
|
||||||
|
|
||||||
## Memory (short)
|
- `selected_loras` / checkpoint — use only names present there (or after `ask:["inventory"]`).
|
||||||
|
- Board: `board.has_generate`, ref ids — for `look_at` only when you need pixels.
|
||||||
- Craft RAG write: `memory_upsert` / `memory_forget` + `memories: [{kind,key,text,scope}]` (default personal).
|
- For full numeric/Exact dump: `"ask": ["settings"]`. For LoRA/ckpt list: `"ask": ["inventory"]`.
|
||||||
- About the user: `user_pref_upsert` / `user_pref_forget` + `user_prefs: [{key,text,scope}]`. Do **not** put human taste into craft `memories`.
|
|
||||||
- Fat memory skill text: `skill_load` + `skills: ["memory"]` when you need the full write/read playbook.
|
|
||||||
|
|
||||||
## Output contract (mandatory)
|
## Output contract (mandatory)
|
||||||
|
|
||||||
1. Short helpful reply in the user's language (RU or EN).
|
1. Short helpful reply in the user's language (RU or EN).
|
||||||
2. **Frame turns only:** one fenced JSON patch with **only fields you want to change**. Chat / Q&A / opinion / remember: **prose only — omit the JSON patch.**
|
2. **Only when changing settings or commanding generate/look/ask:** one fenced JSON with **only fields you want to change** (+ optional commands). Pure chat / Q&A / opinion: **prose only — omit the JSON.**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"prompt": "A fluffy red fox in fresh powder snow, soft morning light, 85mm f/2.8…",
|
"prompt": "A fluffy red fox in fresh powder snow, soft morning light, 85mm f/2.8…",
|
||||||
"negative": "bad quality, worst quality",
|
|
||||||
"loras": [{"name": "exact_name_from_list", "weight": 0.8, "triggers": ["listed_trigger"]}],
|
|
||||||
"aspect": "16:9",
|
"aspect": "16:9",
|
||||||
"actions": ["generate"],
|
"generate": true
|
||||||
"notes": "one-line why"
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Several options in one ask (still one fence):
|
Several options (still one fence):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"prompt": "same subject base…",
|
"prompt": "same subject base…",
|
||||||
"aspect": "16:9",
|
"generate": true,
|
||||||
"actions": ["generate"],
|
|
||||||
"variants": [
|
"variants": [
|
||||||
{ "label": "warm light", "prompt": "… warm window light …" },
|
{ "label": "warm light", "prompt": "… warm window light …" },
|
||||||
{ "label": "cool light", "prompt": "… cool moonlight …" },
|
{ "label": "cool light", "prompt": "… cool moonlight …" }
|
||||||
{ "label": "portrait 9:16", "aspect": "9:16" }
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Patch rules
|
### Patch rules
|
||||||
|
|
||||||
- Omit unchanged **params** (`steps`/`cfg`/`sigma_shift`/`aspect`). For Generate, still include **`negative`**: create if live is empty, lightly supplement if the scene needs a specific omit, or echo the live/Exact box unchanged — never drop it.
|
- Omit unchanged params. Include `negative` when starting Generate if live/session negative is empty or the scene needs a specific omit.
|
||||||
- Prefer omitting Exact-matching **`controls`** (e.g. do not re-emit `"horny": 35` / `"preference_bias": 0.35` when unchanged) — echoing defaults in a Generate patch resets the UI sliders.
|
- `loras` replaces the full intended set for this chat when you change LoRAs.
|
||||||
- `loras` replaces the full intended set for Apply. Prefer `aspect` over raw width/height.
|
- Prefer `aspect` over raw width/height.
|
||||||
- Optional keys (seed, vary, init/mask, creativity/sliders, pack, **controls**, persona authoring, search/memory queries, **`variants`**) — use when needed; packs list the ones for that mode.
|
- Optional: seed, vary, init/mask, controls, pack, `variants`.
|
||||||
- **`controls`** — only keys declared in this persona's `controls.json` (Exact). Clamp to min/max. Example: `"controls": { "horny": 55 }`. Do not invent control ids.
|
|
||||||
- Do not invent model or LoRA filenames.
|
- Do not invent model or LoRA filenames.
|
||||||
|
|
||||||
### Actions / hops
|
### Commands
|
||||||
|
|
||||||
- `"generate"` — Apply + start generation when this turn is a **new/updated frame** (they described a shot, asked to draw/edit/«ещё», or clearly want to see a result). They do **not** have to type «генерируй». **Do not** emit `generate` for chat, opinions («нравится»), trivia, look/critique without a redraw, remember/save, describe_ref, Cards/authoring. Chat-only turns: prose, no JSON patch (or prompt-only without `actions`).
|
- `"generate": true` — merge this delta into the chat session and run Generate (new/updated frame). **Omit** for chat, opinions, remember/save, look-only. Legacy `actions:["generate"]` is accepted as the same.
|
||||||
- If the user only asks to **remember / save** a prompt as base/template («запомни», «как базовый промпт», «шаблон») and did **not** ask for a new image: **omit** `actions: ["generate"]`, do **not** `look_at`. Prefer `memory_upsert` (kind `note`, key like `base_prompt`) or a short ack; you may echo `prompt` in the patch only to sync the SwarmUI box — the UI will not Generate on remember turns.
|
- If the user says not to generate / only remember / only answer — **omit** `generate` and do not look.
|
||||||
- `"search_civitai"` + **required** short `search_query` — Civitai hop (user Confirms downloads). Without `search_query` the hop is skipped (never search the whole user message).
|
- `"look_at": ["generate"|"ref1"|…]` — vision hop (JPEG on follow-up). Use when you need pixels; do not pair with `generate` on the same normal write turn.
|
||||||
- `"interrupt"` — stop generation.
|
- `"ask": ["settings"]` — request full settings dump (Exact + all fields).
|
||||||
- `"memory_get"` / `"memory_search"` / `"lookup_tags"` — read hops.
|
- `"ask": ["inventory"]` — request LoRA/checkpoint list.
|
||||||
- `"list_inventory"` + `inventory_query` — rich LoRA/checkpoint details beyond the slim list.
|
|
||||||
- `"skill_load"` + `skills: ["memory"]` — load fat skill text.
|
|
||||||
- `"persona_read"` — load lore shelves not in always-on identity (e.g. `roleplay` / `craft` / `humor` when NSFW tone or craft detail needs them).
|
|
||||||
- `"memory_upsert"` / `"memory_forget"` / `"user_pref_upsert"` / `"user_pref_forget"` — writes.
|
|
||||||
- `"persona_clone"` / `"persona_write"` / `"persona_switch"` — `author_persona` only. Never `"persona_delete"`.
|
|
||||||
- `look_at: ["generate"|"ref1"|…]` — vision hop (JPEG arrives on the follow-up). Opt-in: user asked, or you truly need pixels. Do not pair `look_at` with `actions:["generate"]` on a normal write turn (that stares at the *old* frame and delays the new one).
|
|
||||||
- Pure Q&A: omit the JSON patch.
|
- Pure Q&A: omit the JSON patch.
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "catalog_card",
|
|
||||||
"title": "Карточка модели",
|
|
||||||
"order": 70,
|
|
||||||
"aliases": ["card", "catalog"],
|
|
||||||
"prompt_file": "catalog_card.md"
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
# Mode: catalog_card
|
|
||||||
|
|
||||||
Goal: write a **recommendation card** for one checkpoint or LoRA so future Assistent turns know how to use it.
|
|
||||||
|
|
||||||
## Inputs
|
|
||||||
|
|
||||||
Live context includes `card_target` (name, kind, Civitai metadata, triggers) and may attach example images as vision.
|
|
||||||
|
|
||||||
## Output
|
|
||||||
|
|
||||||
Reply briefly in the user's language, then **one** fenced JSON object (not a generation patch):
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"kind": "lora",
|
|
||||||
"name": "exact_filename_or_swarm_name",
|
|
||||||
"civitai_url": "https://civitai.red/models/…?modelVersionId=…",
|
|
||||||
"version_id": 123,
|
|
||||||
"triggers": ["exact", "from", "metadata"],
|
|
||||||
"weight": 0.8,
|
|
||||||
"when": "when to enable this model",
|
|
||||||
"avoid": "when not to use it",
|
|
||||||
"prompt_hint": "how to weave triggers into a Krea 2 prompt",
|
|
||||||
"notes": "1–3 sentences for the agent"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Rules
|
|
||||||
|
|
||||||
- Prefer triggers from metadata / trainedWords — **never invent**.
|
|
||||||
- `weight` typical 0.6–1.0 for LoRA; omit or 1.0 for checkpoints.
|
|
||||||
- Do **not** emit `actions: ["generate"]`. This mode does not start Generate.
|
|
||||||
- Do not invent other LoRAs. Stay on the single `card_target`.
|
|
||||||
- Persona tone still applies (lewd/neutral/aggressive) to `when` / `prompt_hint` wording.
|
|
||||||
@@ -9,7 +9,7 @@ Goal: co-create a scene / moodboard direction for **Krea 2** (local Swarm).
|
|||||||
- **Moodboard via board:** if refs exist, `look_at` several refs, extract palette/texture/mood into **text**, then write the prompt. Prefer text distillation over dumping refs as Prompt Images.
|
- **Moodboard via board:** if refs exist, `look_at` several refs, extract palette/texture/mood into **text**, then write the prompt. Prefer text distillation over dumping refs as Prompt Images.
|
||||||
- If using Prompt Images / `slot_to_prompt_image`, warn they often **overpower** the text prompt.
|
- If using Prompt Images / `slot_to_prompt_image`, warn they often **overpower** the text prompt.
|
||||||
- Suggest available LoRAs only from the live list, with triggers.
|
- Suggest available LoRAs only from the live list, with triggers.
|
||||||
- Missing style LoRA → `search_civitai` + `search_query` (Krea-compatible).
|
- Missing style LoRA → ask the user or use `"ask": ["inventory"]` for names already on disk (Krea-compatible).
|
||||||
- Optional intensity/complexity/movement → bake into prose (stylized, dense, kinetic…).
|
- Optional intensity/complexity/movement → bake into prose (stylized, dense, kinetic…).
|
||||||
|
|
||||||
## Deliverable
|
## Deliverable
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"id": "compress_history",
|
||||||
|
"title": "Compress history",
|
||||||
|
"order": 998,
|
||||||
|
"hidden": true,
|
||||||
|
"enabled": true,
|
||||||
|
"aliases": ["compress_history", "compress"],
|
||||||
|
"prompt_file": "compress_history.md"
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Mode: compress_history (hidden)
|
||||||
|
|
||||||
|
You compress older chat turns into a rolling memory for the next Assistent turns. This is not a generation turn and not a Q&A with the user.
|
||||||
|
|
||||||
|
## Hard rules
|
||||||
|
|
||||||
|
- Write in the **user's language** (match the dialogue).
|
||||||
|
- **No** fenced JSON. **No** `### JSON Patch`. **No** `actions`. **No** `generate`. **No** `look_at`. **No** tool hops.
|
||||||
|
- Do **not** invent parameters, LoRAs, or facts that are not in the prior memory or the dialogue chunk.
|
||||||
|
- Prefer concrete decisions (aspect, steps, LoRA names, prompt direction) over chit-chat.
|
||||||
|
- Keep the whole reply under ~600 words.
|
||||||
|
|
||||||
|
## Input
|
||||||
|
|
||||||
|
You receive:
|
||||||
|
1. Optional **previous conversation memory** (already compressed).
|
||||||
|
2. A chunk of **older user/assistant turns** that must be folded into memory.
|
||||||
|
3. Recent turns may be omitted — they stay as raw history.
|
||||||
|
|
||||||
|
## Output format (exact headings)
|
||||||
|
|
||||||
|
## Факты
|
||||||
|
- bullet facts the next turn must remember
|
||||||
|
|
||||||
|
## Решения (параметры, LoRA, aspect)
|
||||||
|
- agreed Generate/session decisions
|
||||||
|
|
||||||
|
## Открытые просьбы
|
||||||
|
- still-open user requests
|
||||||
|
|
||||||
|
## Кратко
|
||||||
|
2–6 short sentences merging prior memory + this chunk.
|
||||||
@@ -1,31 +1,30 @@
|
|||||||
# Mode: ordinary (комбайн)
|
# Mode: ordinary (комбайн)
|
||||||
|
|
||||||
Default all-rounder. Handle this turn from the user message + live context — do **not** wait for a specialized pack.
|
Default all-rounder. Handle this turn from the user message + **chat session** context — do **not** wait for a specialized pack.
|
||||||
|
|
||||||
## What you cover here
|
## What you cover here
|
||||||
|
|
||||||
- **Write / improve prompt** → patch with `prompt` + `negative` (+ `loras`) and `actions: ["generate"]` only when they want a **new frame** (scene to draw, edit, «ещё») — context is enough, magic word is not required. Chat / «нравится» / Q&A → prose only, **no** generate. Do **not** `look_at` the last frame first. `negative` on Generate: create / supplement / echo live.
|
- **Write / improve prompt** → sparse JSON with only changed fields (`prompt`, optional `negative`/`loras`) and `"generate": true` when they want a **new frame**. Chat / «нравится» / Q&A → prose only, **no** JSON. Do **not** `look_at` the last frame first.
|
||||||
- **Light critique / improve last frame** → only when the user asks to look / critique / describe the picture. Then `look_at: ["generate"]` if `images_in_request` is false. Otherwise edit the prompt from text; `has_vision_image` alone is not a reason to look.
|
- **Light critique** → only when they ask to look / critique. Then `look_at: ["generate"]` if pixels are not already in the request.
|
||||||
- **Scene / mood** → compose direction into the prompt (same patch rules).
|
- **Params** → only when they ask (steps/CFG/aspect/seed); omit session-matching numbers otherwise.
|
||||||
- **Params** → only when they ask (steps/CFG/aspect/seed); omit Exact-matching numbers otherwise.
|
- **Inpaint / img2img** → set init/mask fields when they ask.
|
||||||
- **Inpaint / img2img** → set init/mask fields when they ask and flags allow; else say what is missing.
|
- Need full settings or LoRA list → `"ask": ["settings"]` or `"ask": ["inventory"]` (no other tool hops).
|
||||||
- **Describe a ref** → only with a real attached / look_at frame.
|
|
||||||
|
|
||||||
Prompt prose recipe = skill `prompting`. Creativity sliders = skill `creativity_sliders`.
|
Prompt prose recipe = skill `prompting`. Creativity sliders = skill `creativity_sliders`.
|
||||||
|
|
||||||
## When to leave this mode
|
## When to leave this mode
|
||||||
|
|
||||||
Emit `"pack": "<id>"` in the JSON patch only if the user clearly needs a dedicated workflow:
|
Emit `"pack": "<id>"` only if they clearly need a dedicated workflow:
|
||||||
|
|
||||||
- `critique_image` — deep frame critique loop
|
- `critique_image` — deep frame critique loop
|
||||||
- `inpaint_edit` — regional edit / mask workflow
|
- `inpaint_edit` — regional edit / mask workflow
|
||||||
- `catalog_card` / `author_persona` — Cards or persona authoring
|
- `author_persona` — persona authoring
|
||||||
- `describe_ref` — reverse-prompt a reference at length
|
- `describe_ref` — reverse-prompt a reference at length
|
||||||
|
|
||||||
Otherwise **stay in ordinary** and just do the work.
|
Otherwise **stay in ordinary**.
|
||||||
|
|
||||||
## Deliverable
|
## Deliverable
|
||||||
|
|
||||||
Short reply + fenced JSON **only on frame turns** (`actions: ["generate"]` when they want a new/updated image). Chat/Q&A/opinion/remember: prose only — no fence.
|
Short reply + fenced JSON **only when changing session fields or commanding generate/look/ask**. Chat/Q&A: prose only.
|
||||||
«давай дальше» / next frame = new English `prompt` + `negative` (echo live if unchanged) + `actions:["generate"]` in the **same** turn — never leave an empty `### JSON Patch` header. Chat may be RU; **Generate `prompt` is always EN** (skill `prompting`).
|
«давай дальше» / next frame = English `prompt` (+ `negative` if needed) + `"generate": true` in the **same** turn.
|
||||||
Several options in one ask → `variants` (2–4 partial patches with `label`); still one fence, still STOP after it.
|
Several options → `variants` (2–4); still one fence, still STOP after it.
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
{
|
{
|
||||||
"keys": [
|
"keys": [
|
||||||
"prompt", "negative", "loras", "width", "height", "steps", "cfg", "seed", "sigma_shift", "sampler", "scheduler",
|
"prompt", "negative", "loras", "width", "height", "steps", "cfg", "seed", "sigma_shift", "sampler", "scheduler",
|
||||||
"actions", "search_query", "civitai_query",
|
"actions", "generate", "ask",
|
||||||
"use_init_image", "clear_init_image", "init_creativity", "denoise",
|
"use_init_image", "clear_init_image", "init_creativity", "denoise",
|
||||||
"use_mask_image", "clear_mask_image", "mask_blur", "mask_grow",
|
"use_mask_image", "clear_mask_image", "mask_blur", "mask_grow",
|
||||||
"look_at", "vision_from", "vision_slots", "slot_to_init", "slot_to_mask",
|
"look_at", "vision_from", "vision_slots", "slot_to_init", "slot_to_mask",
|
||||||
"snapshot_generate", "select_slot", "aspect", "images", "batch", "vary", "lock_seed",
|
"snapshot_generate", "select_slot", "aspect", "images", "batch", "vary", "lock_seed",
|
||||||
"creativity", "intensity", "complexity", "movement",
|
"creativity", "intensity", "complexity", "movement",
|
||||||
"clear_prompt_images", "slot_to_prompt_image", "pack", "memories", "memory",
|
"clear_prompt_images", "slot_to_prompt_image", "pack", "persona", "controls",
|
||||||
"memory_query", "memory_kind", "tag_query", "user_prefs",
|
"inventory_query", "variants"
|
||||||
"inventory_query", "skills", "persona_shelves", "persona_clone", "persona", "controls",
|
|
||||||
"variants"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,15 +12,16 @@ The **chat model** prepares the Generate-box text for **Krea 2** (Qwen3-VL). Do
|
|||||||
5. **`negative` on every Generate** — create if live is empty (Exact `generation.negative`), supplement if the scene needs a specific omit, or echo live unchanged. Put “no blur / no people” ideas as positives in `prompt` instead of stuffing the negative box. Never clear `negative`.
|
5. **`negative` on every Generate** — create if live is empty (Exact `generation.negative`), supplement if the scene needs a specific omit, or echo live unchanged. Put “no blur / no people” ideas as positives in `prompt` instead of stuffing the negative box. Never clear `negative`.
|
||||||
6. Short ideas: expand into a full Krea paragraph. Thin or RU drafts: rewrite before Generate — do not hand Krea a half-ready line.
|
6. Short ideas: expand into a full Krea paragraph. Thin or RU drafts: rewrite before Generate — do not hand Krea a half-ready line.
|
||||||
|
|
||||||
## Prep checklist (before `actions:["generate"]`)
|
## Prep checklist (before `"generate": true`)
|
||||||
|
|
||||||
- English only in `prompt`
|
- English only in `prompt`
|
||||||
- `negative` present (new / supplemented / echoed — not omitted)
|
- `negative` present when needed (new / supplemented / echoed — not omitted if live empty)
|
||||||
- Subject and action clear in the first sentence
|
- Subject and action clear in the first sentence
|
||||||
- Wardrobe / body / setting concrete
|
- Wardrobe / body / setting concrete
|
||||||
- Camera + lighting present
|
- Camera + lighting present
|
||||||
- One coherent scene; NSFW stated in plain English if needed
|
- One coherent scene; NSFW stated in plain English if needed
|
||||||
- Triggers placed next to what they modify
|
- Triggers placed next to what they modify
|
||||||
|
- Only changed fields in the JSON — session already holds the rest
|
||||||
|
|
||||||
## Deliverable
|
## Deliverable
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"welcome_html": "<div class=\"sa-welcome-title\">Assistent · Krea 2</div><ul><li><strong>Generate</strong> слева — живой просмотр. Несколько вариантов → сетка + клик для просмотра.</li><li><strong>Refs</strong> — референсы на отдельной вкладке: drop / paste / Снимок gen.</li><li>Галочка vision на окне — отправить кадр модели.</li><li>Чипсы aspect / seed / Vary / Turbo·RAW. В чате: <code>/help</code>.</li><li>Кнопки патча только у последнего предложения.</li></ul>Напиши, что сгенерировать — или кинь референс и попроси правку.",
|
"welcome_html": "<div class=\"sa-welcome-title\">Assistent · Krea 2</div><ul><li><strong>Generate</strong> слева — живой просмотр. Несколько вариантов → сетка + клик для просмотра.</li><li><strong>Refs</strong> — референсы на отдельной вкладке: drop / paste / Снимок gen.</li><li>У каждого чата свои параметры, LoRA, последний кадр и refs.</li><li>Чипсы aspect / seed / Vary / Turbo·RAW. В чате: <code>/help</code>.</li><li>Модель шлёт только дельту настроек + <code>generate</code>.</li></ul>Напиши, что сгенерировать — или кинь референс и попроси правку.",
|
||||||
"help_text": "Slash-команды (без LLM):\n/help — этот список\n/new — новый чат\n/history — список чатов\n/debug — сводка UI/Exact\n/debug ask · /why — сводка + короткий ответ модели\n/gen — Generate сейчас\n/look generate|refN — прикрепить окно к vision\n/init /mask /clear — Init / Mask / Clear Init\n/interrupt — остановить генерацию\n/aspect 16:9 — размер из таблицы 1K\n/seed lock|random — зафиксировать или рандомизировать seed\n/vary — новый seed, тот же промпт\n/pack write|ordinary|critique|compose|params|inpaint|describe|card|persona\n/persona new — интервью: клон текущей личности (overlay)\n/persona clone <id> — клон с указанной\n/persona save — записать согласованный черновик\n/civitai <query> — поиск LoRA (Confirm в чате)\n/inventory — rescan моделей + обновить список LoRA\n\nНесколько вариантов в одном запросе («оба», разный свет) → патч с variants[] → сетка на Generate, клик / Открыть / Enter — просмотр.\nЧипсы над полем ввода делают то же для aspect / seed / vary / Turbo·RAW.\nПри старте всегда новый чат; смена чата восстанавливает параметры.\nOverlay-личности удаляет только кнопка ✕ рядом с селектом (не модель).",
|
"help_text": "Slash-команды (без LLM):\n/help — этот список\n/new — новый чат\n/history — список чатов\n/compress — сжать старые ходы в саммари (та же модель)\n/debug — сводка UI/Exact\n/debug ask · /why — сводка + короткий ответ модели\n/gen — Generate из сессии чата\n/look generate|refN — прикрепить окно к vision\n/init /mask /clear — Init / Mask / Clear Init\n/interrupt — остановить генерацию\n/aspect 16:9 — размер из таблицы 1K\n/seed lock|random — зафиксировать или рандомизировать seed\n/vary — новый seed, тот же промпт\n/pack write|ordinary|critique|compose|params|inpaint|describe|persona\n/persona new — интервью: клон текущей личности (overlay)\n/persona clone <id> — клон с указанной\n/persona save — записать согласованный черновик\n/inventory — rescan моделей + обновить список LoRA\n\nНесколько вариантов в одном запросе («оба», разный свет) → патч с variants[] → сетка на Generate.\nЧипсы над полем ввода делают то же для aspect / seed / vary / Turbo·RAW.\nУ каждого чата свои параметры Generate; смена чата восстанавливает кадр и refs.\nOverlay-личности удаляет только кнопка ✕ рядом с селектом (не модель).\nЧип контекста в шапке чата показывает бюджет окна; клик — панель слоёв и ручное сжатие.",
|
||||||
"chips": [
|
"chips": [
|
||||||
{ "label": "1:1", "action": "aspect", "value": "1:1", "title": "1024×1024" },
|
{ "label": "1:1", "action": "aspect", "value": "1:1", "title": "1024×1024" },
|
||||||
{ "label": "4:5", "action": "aspect", "value": "4:5", "title": "928×1152" },
|
{ "label": "4:5", "action": "aspect", "value": "4:5", "title": "928×1152" },
|
||||||
@@ -19,9 +19,10 @@
|
|||||||
{ "cmd": "/help", "hint": "список команд", "action": "help" },
|
{ "cmd": "/help", "hint": "список команд", "action": "help" },
|
||||||
{ "cmd": "/new", "hint": "новый чат", "action": "new" },
|
{ "cmd": "/new", "hint": "новый чат", "action": "new" },
|
||||||
{ "cmd": "/history", "hint": "история чатов", "action": "history" },
|
{ "cmd": "/history", "hint": "история чатов", "action": "history" },
|
||||||
|
{ "cmd": "/compress", "hint": "сжать старые ходы", "action": "compress" },
|
||||||
{ "cmd": "/debug", "hint": "сводка · ask = с LLM", "action": "debug" },
|
{ "cmd": "/debug", "hint": "сводка · ask = с LLM", "action": "debug" },
|
||||||
{ "cmd": "/why", "hint": "debug + пояснение LLM", "action": "why" },
|
{ "cmd": "/why", "hint": "debug + пояснение LLM", "action": "why" },
|
||||||
{ "cmd": "/gen", "hint": "Generate сейчас", "action": "gen" },
|
{ "cmd": "/gen", "hint": "Generate из сессии", "action": "gen" },
|
||||||
{ "cmd": "/look ", "hint": "generate|refN", "action": "look" },
|
{ "cmd": "/look ", "hint": "generate|refN", "action": "look" },
|
||||||
{ "cmd": "/init", "hint": "как Init", "action": "init" },
|
{ "cmd": "/init", "hint": "как Init", "action": "init" },
|
||||||
{ "cmd": "/mask", "hint": "как Mask", "action": "mask" },
|
{ "cmd": "/mask", "hint": "как Mask", "action": "mask" },
|
||||||
@@ -31,7 +32,6 @@
|
|||||||
{ "cmd": "/seed ", "hint": "lock|random", "action": "seed" },
|
{ "cmd": "/seed ", "hint": "lock|random", "action": "seed" },
|
||||||
{ "cmd": "/vary", "hint": "новый seed", "action": "vary" },
|
{ "cmd": "/vary", "hint": "новый seed", "action": "vary" },
|
||||||
{ "cmd": "/pack ", "hint": "write|critique|…", "action": "pack" },
|
{ "cmd": "/pack ", "hint": "write|critique|…", "action": "pack" },
|
||||||
{ "cmd": "/civitai ", "hint": "запрос LoRA", "action": "civitai" },
|
|
||||||
{ "cmd": "/inventory", "hint": "rescan моделей", "action": "inventory" },
|
{ "cmd": "/inventory", "hint": "rescan моделей", "action": "inventory" },
|
||||||
{ "cmd": "/persona new", "hint": "клон / новая личность", "action": "persona_new" },
|
{ "cmd": "/persona new", "hint": "клон / новая личность", "action": "persona_new" },
|
||||||
{ "cmd": "/persona clone ", "hint": "клон с id", "action": "persona_clone" },
|
{ "cmd": "/persona clone ", "hint": "клон с id", "action": "persona_clone" },
|
||||||
@@ -55,9 +55,6 @@
|
|||||||
"inpaint_edit": "inpaint_edit",
|
"inpaint_edit": "inpaint_edit",
|
||||||
"describe": "describe_ref",
|
"describe": "describe_ref",
|
||||||
"describe_ref": "describe_ref",
|
"describe_ref": "describe_ref",
|
||||||
"card": "catalog_card",
|
|
||||||
"catalog": "catalog_card",
|
|
||||||
"catalog_card": "catalog_card",
|
|
||||||
"persona": "author_persona",
|
"persona": "author_persona",
|
||||||
"author": "author_persona",
|
"author": "author_persona",
|
||||||
"author_persona": "author_persona",
|
"author_persona": "author_persona",
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ SwarmUI extension for **collaborative Krea 2** prompting via **Ollama**: chat +
|
|||||||
|
|
||||||
**Turn model:** one user message is one *turn*. A turn may fan out into nested LLM *hops* — Krea prompt prep, empty-patch retry, vision, auto-critique. Hops share one `HOP_BUDGET`, never re-read the user's text (their prompt is client-authored), and pass the busy gate that blocks new user sends. What a reply does to generation state is decided once, in `resolveTurnIntent`: the model's `actions:["generate"]` / `look_at` win, RU intent heuristics only back it up when the model forgets, and an explicit «запомни, не генерируй» vetoes both.
|
**Turn model:** one user message is one *turn*. A turn may fan out into nested LLM *hops* — Krea prompt prep, empty-patch retry, vision, auto-critique. Hops share one `HOP_BUDGET`, never re-read the user's text (their prompt is client-authored), and pass the busy gate that blocks new user sends. What a reply does to generation state is decided once, in `resolveTurnIntent`: the model's `actions:["generate"]` / `look_at` win, RU intent heuristics only back it up when the model forgets, and an explicit «запомни, не генерируй» vetoes both.
|
||||||
|
|
||||||
|
**Version 0.14.0** — **Чат = сессия генерации**: у каждого чата свои params/LoRA/checkpoint/кадр/refs; модель шлёт sparse-дельту + `generate`/`look_at`/`ask`; без вкладки Карточки и Civitai/wanted hops. **Сжатие контекста**: rolling-саммари той же Ollama-моделью, чип бюджета `N / num_ctx`, авто перед отправкой, `/compress`.
|
||||||
|
|
||||||
**Version 0.13.1** — Сборка 0.13.0: `using` для `WebSocket`/`HttpClient`, instance-методы с `Config`/`FilePath`, Sqlite dll рядом с extension (иначе вкладка не грузится / API пустые).
|
**Version 0.13.1** — Сборка 0.13.0: `using` для `WebSocket`/`HttpClient`, instance-методы с `Config`/`FilePath`, Sqlite dll рядом с extension (иначе вкладка не грузится / API пустые).
|
||||||
|
|
||||||
**Version 0.13.0** — **Реальный QLoRA-пайплайн**: `train_qlora.py` (TRL SFTTrainer + PEFT), HF-датасеты с маппингом (preset fiction title/tags→text), `max_samples`, полный post-train: safetensors → GGUF (`convert_lora_to_gguf.py`) → `ollama create` с `FROM ollama_base` + `ADAPTER`. Раннер: `builtin` + `custom`. Зависимости: `scripts/requirements-train.txt`.
|
**Version 0.13.0** — **Реальный QLoRA-пайплайн**: `train_qlora.py` (TRL SFTTrainer + PEFT), HF-датасеты с маппингом (preset fiction title/tags→text), `max_samples`, полный post-train: safetensors → GGUF (`convert_lora_to_gguf.py`) → `ollama create` с `FROM ollama_base` + `ADAPTER`. Раннер: `builtin` + `custom`. Зависимости: `scripts/requirements-train.txt`.
|
||||||
@@ -150,10 +152,9 @@ Two layers in `memory/assistent.sqlite` (`persona` column; empty = shared):
|
|||||||
| `/aspect 16:9` | Set size from the official 1K table |
|
| `/aspect 16:9` | Set size from the official 1K table |
|
||||||
| `/seed lock\|random` | Lock or randomize seed |
|
| `/seed lock\|random` | Lock or randomize seed |
|
||||||
| `/vary` | New seed, same prompt (+ generate if auto) |
|
| `/vary` | New seed, same prompt (+ generate if auto) |
|
||||||
| `/pack write\|ordinary\|critique\|compose\|params\|inpaint\|describe\|card\|persona` | Switch pack |
|
| `/pack write\|ordinary\|critique\|compose\|params\|inpaint\|describe\|persona` | Switch pack |
|
||||||
| `/persona new\|clone\|save` | Overlay persona authoring |
|
| `/persona new\|clone\|save` | Overlay persona authoring |
|
||||||
| `/civitai <query>` | Ask LLM to search Civitai |
|
| `/inventory` | Rescan моделей + обновить список LoRA |
|
||||||
| `/inventory` | Rescan models + refresh LoRA list |
|
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
@@ -176,7 +177,7 @@ Restart / rebuild SwarmUI after clone. gpu-rent: `seed-extensions` + restart.
|
|||||||
|
|
||||||
## Packs & skills
|
## Packs & skills
|
||||||
|
|
||||||
**Packs** (one active): `ordinary` (default комбайн; covers write/critique/params flows), `write_prompt` (alias → same as ordinary), `critique_image`, `compose_scene`, `fix_params`, `inpaint_edit`, `describe_ref`, `catalog_card`, `author_persona`.
|
**Packs** (one active): `ordinary` (default комбайн; covers write/critique/params flows), `write_prompt` (alias → same as ordinary), `critique_image`, `compose_scene`, `fix_params`, `inpaint_edit`, `describe_ref`, `author_persona`.
|
||||||
|
|
||||||
Patch fence keys: single source `Config/_base/patch-keys.json` → C# + client via `AssistentGetConfig.patch_keys`.
|
Patch fence keys: single source `Config/_base/patch-keys.json` → C# + client via `AssistentGetConfig.patch_keys`.
|
||||||
|
|
||||||
@@ -200,12 +201,8 @@ Patch fence keys: single source `Config/_base/patch-keys.json` → C# + client v
|
|||||||
| `AssistentSaveKnobs` | Overlay `_base/assistant.json` + Exact turbo/raw profiles |
|
| `AssistentSaveKnobs` | Overlay `_base/assistant.json` + Exact turbo/raw profiles |
|
||||||
| `AssistentListInventory` | LoRA / checkpoint / wildcard inventory |
|
| `AssistentListInventory` | LoRA / checkpoint / wildcard inventory |
|
||||||
| `AssistentListPersonas` | Persona catalog |
|
| `AssistentListPersonas` | Persona catalog |
|
||||||
| `AssistentGetCard` / `AssistentSaveCard` | `.assistent.json` cards (+ memory ingest) |
|
|
||||||
| `AssistentGetCardMeta` | Local sidecar + optional Civitai by-hash |
|
|
||||||
| `AssistentEnqueueWanted` / `AssistentListWanted` | Wanted YAML queue (write / read + count) |
|
|
||||||
| `AssistentListUserPrefs` / `AssistentUpsertUserPref` / `AssistentForgetUserPref` / `AssistentClearUserPrefs` | About the user |
|
| `AssistentListUserPrefs` / `AssistentUpsertUserPref` / `AssistentForgetUserPref` / `AssistentClearUserPrefs` | About the user |
|
||||||
| `AssistentSearchCivitai` | Civitai LoRA search |
|
| `AssistentChat` / `AssistentChatWS` | Chat (+ user prefs + hybrid craft memory + ask hops) |
|
||||||
| `AssistentChat` / `AssistentChatWS` | Chat (+ user prefs + hybrid craft memory + hops) |
|
|
||||||
| `AssistentListMemory` / `AssistentUpsertMemory` / `AssistentForgetMemory` / `AssistentClearMemory` | Craft vector store |
|
| `AssistentListMemory` / `AssistentUpsertMemory` / `AssistentForgetMemory` / `AssistentClearMemory` | Craft vector store |
|
||||||
| `AssistentSearchMemory` / `AssistentGetMemory` | Hybrid search / exact kind+key |
|
| `AssistentSearchMemory` / `AssistentGetMemory` | Hybrid search / exact kind+key |
|
||||||
| `AssistentLookupTags` | Danbooru csv FTS (no embeddings) |
|
| `AssistentLookupTags` | Danbooru csv FTS (no embeddings) |
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ public partial class SwarmAssistentExtension : Extension
|
|||||||
ExtensionAuthor = "mrleo1nid";
|
ExtensionAuthor = "mrleo1nid";
|
||||||
Description = "Collaborative Krea 2 assistant: Ollama chat, persona presets, vector memory, model cards, Generate loop.";
|
Description = "Collaborative Krea 2 assistant: Ollama chat, persona presets, vector memory, model cards, Generate loop.";
|
||||||
License = "MIT";
|
License = "MIT";
|
||||||
Version = "0.13.1";
|
Version = "0.14.0";
|
||||||
Tags = ["tabs", "ui", "llm", "ollama", "krea", "inpaint", "memory", "training", "heard", "qlora"];
|
Tags = ["tabs", "ui", "llm", "ollama", "krea", "inpaint", "memory", "training", "heard", "qlora"];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,11 +47,6 @@ public partial class SwarmAssistentExtension : Extension
|
|||||||
API.RegisterAPICall(AssistentGetConfig, false, PermUse);
|
API.RegisterAPICall(AssistentGetConfig, false, PermUse);
|
||||||
API.RegisterAPICall(AssistentSaveSettings, true, PermUse);
|
API.RegisterAPICall(AssistentSaveSettings, true, PermUse);
|
||||||
API.RegisterAPICall(AssistentListInventory, false, PermUse);
|
API.RegisterAPICall(AssistentListInventory, false, PermUse);
|
||||||
API.RegisterAPICall(AssistentGetCard, false, PermUse);
|
|
||||||
API.RegisterAPICall(AssistentSaveCard, true, PermUse);
|
|
||||||
API.RegisterAPICall(AssistentEnqueueWanted, true, PermUse);
|
|
||||||
API.RegisterAPICall(AssistentGetCardMeta, false, PermUse);
|
|
||||||
API.RegisterAPICall(AssistentSearchCivitai, false, PermUse);
|
|
||||||
API.RegisterAPICall(AssistentChat, true, PermUse);
|
API.RegisterAPICall(AssistentChat, true, PermUse);
|
||||||
API.RegisterAPICall(AssistentChatWS, true, PermUse);
|
API.RegisterAPICall(AssistentChatWS, true, PermUse);
|
||||||
API.RegisterAPICall(AssistentListChats, false, PermUse);
|
API.RegisterAPICall(AssistentListChats, false, PermUse);
|
||||||
@@ -68,7 +63,6 @@ public partial class SwarmAssistentExtension : Extension
|
|||||||
API.RegisterAPICall(AssistentSearchMemory, false, PermUse);
|
API.RegisterAPICall(AssistentSearchMemory, false, PermUse);
|
||||||
API.RegisterAPICall(AssistentGetMemory, false, PermUse);
|
API.RegisterAPICall(AssistentGetMemory, false, PermUse);
|
||||||
API.RegisterAPICall(AssistentLookupTags, false, PermUse);
|
API.RegisterAPICall(AssistentLookupTags, false, PermUse);
|
||||||
API.RegisterAPICall(AssistentListWanted, false, PermUse);
|
|
||||||
API.RegisterAPICall(AssistentSaveControls, true, PermUse);
|
API.RegisterAPICall(AssistentSaveControls, true, PermUse);
|
||||||
API.RegisterAPICall(AssistentGetPersonaShelves, false, PermUse);
|
API.RegisterAPICall(AssistentGetPersonaShelves, false, PermUse);
|
||||||
API.RegisterAPICall(AssistentClonePersona, true, PermUse);
|
API.RegisterAPICall(AssistentClonePersona, true, PermUse);
|
||||||
@@ -104,7 +98,7 @@ public partial class SwarmAssistentExtension : Extension
|
|||||||
API.RegisterAPICall(AssistentLinkTrainSampleToAgent, true, PermUse);
|
API.RegisterAPICall(AssistentLinkTrainSampleToAgent, true, PermUse);
|
||||||
API.RegisterAPICall(AssistentUnlinkTrainSampleFromAgent, true, PermUse);
|
API.RegisterAPICall(AssistentUnlinkTrainSampleFromAgent, true, PermUse);
|
||||||
API.RegisterAPICall(AssistentSyncDatasetToAgent, true, PermUse);
|
API.RegisterAPICall(AssistentSyncDatasetToAgent, true, PermUse);
|
||||||
Logs.Init("Swarm Assistent extension loaded (0.13.1 QLoRA pipeline)");
|
Logs.Init("Swarm Assistent extension loaded (0.14.0 chat session)");
|
||||||
}
|
}
|
||||||
|
|
||||||
int CfgInt(string key, int fallback)
|
int CfgInt(string key, int fallback)
|
||||||
|
|||||||
@@ -10,7 +10,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="sa-app-tabs" role="tablist" aria-label="Разделы Assistent">
|
<div class="sa-app-tabs" role="tablist" aria-label="Разделы Assistent">
|
||||||
<button type="button" class="sa-app-tab sa-app-tab-active" data-view="chat" id="sa_tab_chat" role="tab" aria-selected="true">Чат</button>
|
<button type="button" class="sa-app-tab sa-app-tab-active" data-view="chat" id="sa_tab_chat" role="tab" aria-selected="true">Чат</button>
|
||||||
<button type="button" class="sa-app-tab" data-view="cards" id="sa_tab_cards" role="tab" aria-selected="false">Карточки</button>
|
|
||||||
<button type="button" class="sa-app-tab" data-view="train" id="sa_tab_train" role="tab" aria-selected="false">Обучение</button>
|
<button type="button" class="sa-app-tab" data-view="train" id="sa_tab_train" role="tab" aria-selected="false">Обучение</button>
|
||||||
<button type="button" class="sa-app-tab" data-view="settings" id="sa_tab_settings" role="tab" aria-selected="false">Настройки</button>
|
<button type="button" class="sa-app-tab" data-view="settings" id="sa_tab_settings" role="tab" aria-selected="false">Настройки</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -61,6 +60,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="sa-header-right">
|
<div class="sa-header-right">
|
||||||
|
<div class="sa-ctx-wrap" id="sa_ctx_wrap">
|
||||||
|
<button type="button" class="sa-ctx-chip sa-ctx-ok" id="sa_ctx_chip" title="Контекст модели" aria-expanded="false" aria-controls="sa_ctx_panel">
|
||||||
|
<span class="sa-ctx-dot" hidden aria-hidden="true"></span>
|
||||||
|
<span class="sa-ctx-chip-text">— / —</span>
|
||||||
|
</button>
|
||||||
|
<div class="sa-ctx-panel" id="sa_ctx_panel" hidden role="dialog" aria-label="Бюджет контекста">
|
||||||
|
<div class="sa-ctx-panel-head">
|
||||||
|
<strong>Контекст</strong>
|
||||||
|
<button type="button" class="basic-button sa-icon-btn" id="sa_ctx_close" title="Закрыть" aria-label="Закрыть">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="sa-ctx-bar"><div class="sa-ctx-bar-fill" id="sa_ctx_bar_fill"></div></div>
|
||||||
|
<div id="sa_ctx_panel_body" class="sa-ctx-panel-body"></div>
|
||||||
|
<label class="sa-check sa-ctx-auto"><input type="checkbox" id="sa_ctx_auto" checked /> Авто перед отправкой</label>
|
||||||
|
<div class="sa-ctx-actions">
|
||||||
|
<button type="button" class="basic-button sa-primary" id="sa_ctx_compress">Сжать сейчас</button>
|
||||||
|
<button type="button" class="basic-button" id="sa_ctx_reset">Сбросить сжатие</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="sa-persona-wrap">
|
<div class="sa-persona-wrap">
|
||||||
<select id="sa_persona" class="sa-select" title="Характер / тон" aria-label="Характер">
|
<select id="sa_persona" class="sa-select" title="Характер / тон" aria-label="Характер">
|
||||||
<option value="neutral">Нейтральный</option>
|
<option value="neutral">Нейтральный</option>
|
||||||
@@ -82,7 +100,7 @@
|
|||||||
<div class="sa-messages" id="sa_messages">
|
<div class="sa-messages" id="sa_messages">
|
||||||
<div class="sa-chat-empty" id="sa_chat_empty">
|
<div class="sa-chat-empty" id="sa_chat_empty">
|
||||||
<div class="sa-chat-empty-title">Совместная работа с Krea 2</div>
|
<div class="sa-chat-empty-title">Совместная работа с Krea 2</div>
|
||||||
<div class="sa-chat-empty-hint">Напиши промпт, кинь refs, выбери персону или открой <em>Карточки</em> для LoRA.</div>
|
<div class="sa-chat-empty-hint">Напиши промпт, кинь refs, выбери персону — у каждого чата свои параметры Generate.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="sa-livebar" id="sa_livebar" hidden>
|
<div class="sa-livebar" id="sa_livebar" hidden>
|
||||||
@@ -116,62 +134,26 @@
|
|||||||
</section>
|
</section>
|
||||||
<aside class="sa-chats-drawer" id="sa_chats_panel" aria-label="История чатов">
|
<aside class="sa-chats-drawer" id="sa_chats_panel" aria-label="История чатов">
|
||||||
<div class="sa-chats-drawer-head">
|
<div class="sa-chats-drawer-head">
|
||||||
<strong>Чаты</strong>
|
<span class="sa-chats-drawer-label">Чаты</span>
|
||||||
<div class="sa-chats-drawer-actions">
|
<div class="sa-chats-drawer-actions">
|
||||||
<button type="button" class="basic-button sa-icon-btn" id="sa_btn_new_chat" title="Новый чат">+</button>
|
<button type="button" class="sa-chats-icon-btn" id="sa_btn_new_chat" title="Новый чат" aria-label="Новый чат">
|
||||||
<button type="button" class="basic-button sa-icon-btn" id="sa_btn_chats_close" title="Скрыть панель" aria-label="Скрыть историю">×</button>
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M8 3v10M3 8h10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="sa-chats-icon-btn" id="sa_btn_chats_close" title="Скрыть панель" aria-label="Скрыть историю">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<input type="search" class="sa-chats-search" id="sa_chats_search" placeholder="Поиск по истории…" autocomplete="off" />
|
<div class="sa-chats-search-wrap">
|
||||||
<div class="sa-chats-list" id="sa_chats_list"></div>
|
<svg class="sa-chats-search-ico" width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden="true"><circle cx="7" cy="7" r="4.5" stroke="currentColor" stroke-width="1.4"/><path d="M10.5 10.5L14 14" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
|
||||||
<div class="sa-chats-panel-hint">Клик по чату восстанавливает сообщения и параметры Generate.</div>
|
<input type="search" class="sa-chats-search" id="sa_chats_search" placeholder="Поиск…" autocomplete="off" aria-label="Поиск по чатам" />
|
||||||
|
</div>
|
||||||
|
<div class="sa-chats-list" id="sa_chats_list" role="list"></div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sa-view" id="sa_view_cards" hidden>
|
|
||||||
<div class="sa-cards-layout">
|
|
||||||
<div class="sa-cards-list-pane">
|
|
||||||
<div class="sa-cards-filter">
|
|
||||||
<select id="sa_cards_kind" class="sa-select">
|
|
||||||
<option value="all">Все</option>
|
|
||||||
<option value="checkpoint">Checkpoints</option>
|
|
||||||
<option value="lora">LoRAs</option>
|
|
||||||
</select>
|
|
||||||
<button type="button" class="basic-button" id="sa_btn_cards_refresh">Обновить</button>
|
|
||||||
</div>
|
|
||||||
<div class="sa-cards-list" id="sa_cards_list"></div>
|
|
||||||
</div>
|
|
||||||
<div class="sa-cards-editor-pane">
|
|
||||||
<div class="sa-cards-editor-head">
|
|
||||||
<strong id="sa_card_title">Выбери модель</strong>
|
|
||||||
<span class="sa-card-badge" id="sa_card_badge" hidden></span>
|
|
||||||
</div>
|
|
||||||
<div class="sa-card-previews" id="sa_card_previews" hidden></div>
|
|
||||||
<div class="sa-card-form" id="sa_card_form">
|
|
||||||
<label>Triggers <input type="text" id="sa_card_triggers" placeholder="слово1, слово2" /></label>
|
|
||||||
<label>Weight <input type="number" id="sa_card_weight" step="0.05" min="0" max="2" value="0.8" /></label>
|
|
||||||
<label>When <textarea id="sa_card_when" rows="2" placeholder="Когда использовать"></textarea></label>
|
|
||||||
<label>Avoid <textarea id="sa_card_avoid" rows="2" placeholder="Чего избегать"></textarea></label>
|
|
||||||
<label>Prompt hint <textarea id="sa_card_hint" rows="2" placeholder="Подсказка для промпта"></textarea></label>
|
|
||||||
<label>Notes <textarea id="sa_card_notes" rows="2" placeholder="Заметки"></textarea></label>
|
|
||||||
<label>Civitai URL <input type="text" id="sa_card_url" placeholder="https://civitai…" /></label>
|
|
||||||
<label class="sa-card-json-toggle"><input type="checkbox" id="sa_card_show_json" /> Показать JSON</label>
|
|
||||||
<textarea id="sa_card_json" rows="8" hidden placeholder="Card JSON…" spellcheck="false"></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="sa-cards-actions">
|
|
||||||
<button type="button" class="basic-button sa-primary" id="sa_btn_card_to_chat" title="В чат: используй эту модель/LoRA">В чат</button>
|
|
||||||
<button type="button" class="basic-button" id="sa_btn_card_meta">Загрузить Civitai meta</button>
|
|
||||||
<button type="button" class="basic-button" id="sa_btn_card_generate">Сгенерировать с Assistent</button>
|
|
||||||
<button type="button" class="basic-button" id="sa_btn_card_save">Сохранить карточку</button>
|
|
||||||
<button type="button" class="basic-button" id="sa_btn_card_wanted" title="В очередь на следующий up">В wanted</button>
|
|
||||||
<span class="sa-status" id="sa_card_status"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="sa-view" id="sa_view_train" hidden>
|
<div class="sa-view" id="sa_view_train" hidden>
|
||||||
<div class="sa-training" id="sa_training">
|
<div class="sa-training" id="sa_training">
|
||||||
<div class="sa-training-tabs" role="tablist" aria-label="Разделы обучения">
|
<div class="sa-training-tabs" role="tablist" aria-label="Разделы обучения">
|
||||||
@@ -315,7 +297,7 @@
|
|||||||
<p class="sa-settings-hint">Автодействия после ответа модели и скилы текущей личности.</p>
|
<p class="sa-settings-hint">Автодействия после ответа модели и скилы текущей личности.</p>
|
||||||
<label class="sa-check" title="По умолчанию выкл. Кадр смотрит по кнопке «Посмотри результат», /look, или когда модель сама шлёт look_at. Эта галка — после каждого Generate."><input type="checkbox" id="sa_auto_vision" /> После Generate — look_at кадра</label>
|
<label class="sa-check" title="По умолчанию выкл. Кадр смотрит по кнопке «Посмотри результат», /look, или когда модель сама шлёт look_at. Эта галка — после каждого Generate."><input type="checkbox" id="sa_auto_vision" /> После Generate — look_at кадра</label>
|
||||||
<label class="sa-check"><input type="checkbox" id="sa_auto_apply" checked /> Авто-применять патч</label>
|
<label class="sa-check"><input type="checkbox" id="sa_auto_apply" checked /> Авто-применять патч</label>
|
||||||
<label class="sa-check" title="Если ход — кадр, Generate без кнопок Apply. Чат и «нравится» не запускают картинку."><input type="checkbox" id="sa_auto_generate" checked /> Авто-Generate после патча</label>
|
<label class="sa-check" title="Устарело в 0.14: Generate запускает модель через generate:true или кнопки из сессии чата." hidden><input type="checkbox" id="sa_auto_generate" /> Авто-Generate после патча</label>
|
||||||
<label class="sa-check" title="По умолчанию выкл. Критика кадра — кнопка «Посмотри результат» или /look. Галка шлёт JPEG после каждого Generate."><input type="checkbox" id="sa_auto_critique" /> Авто-критика после Generate</label>
|
<label class="sa-check" title="По умолчанию выкл. Критика кадра — кнопка «Посмотри результат» или /look. Галка шлёт JPEG после каждого Generate."><input type="checkbox" id="sa_auto_critique" /> Авто-критика после Generate</label>
|
||||||
<label class="sa-check" title="Выгружает чат-модель перед Generate (keep_alive:0). Для VL 7B обратная загрузка часто 1–2 мин — включай только если Generate падает по VRAM."><input type="checkbox" id="sa_park_llm" /> Park LLM перед Generate (VRAM)</label>
|
<label class="sa-check" title="Выгружает чат-модель перед Generate (keep_alive:0). Для VL 7B обратная загрузка часто 1–2 мин — включай только если Generate падает по VRAM."><input type="checkbox" id="sa_park_llm" /> Park LLM перед Generate (VRAM)</label>
|
||||||
<label class="sa-check sa-danger" title="Опасно — скачивает без Confirm"><input type="checkbox" id="sa_auto_download" /> Авто-скачивание Civitai (выкл)</label>
|
<label class="sa-check sa-danger" title="Опасно — скачивает без Confirm"><input type="checkbox" id="sa_auto_download" /> Авто-скачивание Civitai (выкл)</label>
|
||||||
@@ -414,12 +396,11 @@
|
|||||||
<option value="all">Все типы</option>
|
<option value="all">Все типы</option>
|
||||||
</select>
|
</select>
|
||||||
<input type="search" id="sa_mem_search" class="sa-mem-search" placeholder="Поиск…" autocomplete="off" />
|
<input type="search" id="sa_mem_search" class="sa-mem-search" placeholder="Поиск…" autocomplete="off" />
|
||||||
<button type="button" class="basic-button sa-icon-btn" id="sa_btn_mem_refresh" title="Перечитать память и очередь wanted">⟳</button>
|
<button type="button" class="basic-button sa-icon-btn" id="sa_btn_mem_refresh" title="Перечитать память">⟳</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="sa-mem-list" id="sa_mem_list"></div>
|
<div class="sa-mem-list" id="sa_mem_list"></div>
|
||||||
<div class="sa-mem-foot">
|
<div class="sa-mem-foot">
|
||||||
<span class="sa-mem-total" id="sa_mem_total">Всего: —</span>
|
<span class="sa-mem-total" id="sa_mem_total">Всего: —</span>
|
||||||
<span class="sa-mem-wanted" id="sa_mem_wanted" title="Модели в очереди на следующий gpu-rent up">Очередь wanted: —</span>
|
|
||||||
</div>
|
</div>
|
||||||
<label>memory_top_k <input type="number" id="sa_memory_top_k" min="1" max="30" step="1" value="10" /></label>
|
<label>memory_top_k <input type="number" id="sa_memory_top_k" min="1" max="30" step="1" value="10" /></label>
|
||||||
<div class="sa-settings-row">
|
<div class="sa-settings-row">
|
||||||
@@ -433,6 +414,9 @@
|
|||||||
<p class="sa-settings-hint">Контекст Ollama и Exact Turbo/RAW (пишется в overlay).</p>
|
<p class="sa-settings-hint">Контекст Ollama и Exact Turbo/RAW (пишется в overlay).</p>
|
||||||
<label>num_ctx <input type="number" id="sa_num_ctx" min="2048" max="131072" step="1024" value="16384" /></label>
|
<label>num_ctx <input type="number" id="sa_num_ctx" min="2048" max="131072" step="1024" value="16384" /></label>
|
||||||
<label>history_keep_turns <input type="number" id="sa_history_keep" min="1" max="32" step="1" value="4" /></label>
|
<label>history_keep_turns <input type="number" id="sa_history_keep" min="1" max="32" step="1" value="4" /></label>
|
||||||
|
<label>compress_at <input type="number" id="sa_compress_at" min="0.4" max="0.95" step="0.05" value="0.7" title="Доля от (num_ctx − num_predict)" /></label>
|
||||||
|
<label>chars_per_token <input type="number" id="sa_chars_per_token" min="1.5" max="8" step="0.1" value="3.2" /></label>
|
||||||
|
<label class="sa-check"><input type="checkbox" id="sa_compress_auto" checked /> Автосжатие перед отправкой</label>
|
||||||
<div class="sa-skills-label">Exact · Turbo</div>
|
<div class="sa-skills-label">Exact · Turbo</div>
|
||||||
<div class="sa-settings-row sa-knob-row">
|
<div class="sa-settings-row sa-knob-row">
|
||||||
<label>steps <input type="number" id="sa_exact_turbo_steps" min="1" max="64" step="1" /></label>
|
<label>steps <input type="number" id="sa_exact_turbo_steps" min="1" max="64" step="1" /></label>
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node scripts/build.mjs",
|
"build": "node scripts/build.mjs",
|
||||||
"watch": "node scripts/build.mjs --watch",
|
"watch": "node scripts/build.mjs --watch",
|
||||||
"test": "node --test test/intent.test.js test/patch.test.js"
|
"test": "node --test test/intent.test.js test/patch.test.js test/context.test.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"esbuild": "^0.25.0"
|
"esbuild": "^0.25.0"
|
||||||
|
|||||||
+263
@@ -0,0 +1,263 @@
|
|||||||
|
/**
|
||||||
|
* Turn activity timeline — shows model commands and pipeline steps in chat.
|
||||||
|
* Cursor-like, but compact and chat-native.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STEP_ICONS = {
|
||||||
|
think: '◇',
|
||||||
|
stream: '✎',
|
||||||
|
delta: '⇢',
|
||||||
|
ask: '?',
|
||||||
|
look: '◎',
|
||||||
|
prep: '↻',
|
||||||
|
generate: '▷',
|
||||||
|
merge: '⊕',
|
||||||
|
warm: '▲',
|
||||||
|
park: '▼',
|
||||||
|
inventory: '▤',
|
||||||
|
compress: '▤',
|
||||||
|
done: '✓',
|
||||||
|
skip: '–',
|
||||||
|
error: '!',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createActivityController(opts = {}) {
|
||||||
|
const {
|
||||||
|
getMessagesEl,
|
||||||
|
scrollToBottom,
|
||||||
|
hideEmpty,
|
||||||
|
} = opts;
|
||||||
|
|
||||||
|
let card = null;
|
||||||
|
let listEl = null;
|
||||||
|
let titleEl = null;
|
||||||
|
let steps = [];
|
||||||
|
let open = true;
|
||||||
|
|
||||||
|
function ensureCard() {
|
||||||
|
const box = typeof getMessagesEl === 'function' ? getMessagesEl() : null;
|
||||||
|
if (!box) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (card && card.isConnected) {
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
if (typeof hideEmpty === 'function') {
|
||||||
|
hideEmpty();
|
||||||
|
}
|
||||||
|
card = document.createElement('div');
|
||||||
|
card.className = 'sa-activity sa-activity-live';
|
||||||
|
card.setAttribute('role', 'status');
|
||||||
|
card.setAttribute('aria-live', 'polite');
|
||||||
|
|
||||||
|
const head = document.createElement('button');
|
||||||
|
head.type = 'button';
|
||||||
|
head.className = 'sa-activity-head';
|
||||||
|
head.setAttribute('aria-expanded', 'true');
|
||||||
|
|
||||||
|
const spin = document.createElement('span');
|
||||||
|
spin.className = 'sa-activity-spin';
|
||||||
|
spin.setAttribute('aria-hidden', 'true');
|
||||||
|
|
||||||
|
titleEl = document.createElement('span');
|
||||||
|
titleEl.className = 'sa-activity-title';
|
||||||
|
titleEl.textContent = 'Assistent';
|
||||||
|
|
||||||
|
const chev = document.createElement('span');
|
||||||
|
chev.className = 'sa-activity-chev';
|
||||||
|
chev.setAttribute('aria-hidden', 'true');
|
||||||
|
chev.textContent = '▾';
|
||||||
|
|
||||||
|
head.appendChild(spin);
|
||||||
|
head.appendChild(titleEl);
|
||||||
|
head.appendChild(chev);
|
||||||
|
head.addEventListener('click', () => {
|
||||||
|
open = !open;
|
||||||
|
card.classList.toggle('sa-activity-collapsed', !open);
|
||||||
|
head.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||||
|
});
|
||||||
|
|
||||||
|
listEl = document.createElement('div');
|
||||||
|
listEl.className = 'sa-activity-steps';
|
||||||
|
|
||||||
|
card.appendChild(head);
|
||||||
|
card.appendChild(listEl);
|
||||||
|
box.appendChild(card);
|
||||||
|
if (typeof scrollToBottom === 'function') {
|
||||||
|
scrollToBottom();
|
||||||
|
}
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStep(step) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = `sa-activity-step sa-activity-${step.status || 'running'}`;
|
||||||
|
row.dataset.id = step.id;
|
||||||
|
|
||||||
|
const icon = document.createElement('span');
|
||||||
|
icon.className = 'sa-activity-icon';
|
||||||
|
icon.setAttribute('aria-hidden', 'true');
|
||||||
|
icon.textContent = STEP_ICONS[step.kind] || STEP_ICONS.think;
|
||||||
|
|
||||||
|
const body = document.createElement('div');
|
||||||
|
body.className = 'sa-activity-body';
|
||||||
|
|
||||||
|
const label = document.createElement('div');
|
||||||
|
label.className = 'sa-activity-label';
|
||||||
|
label.textContent = step.label || step.id;
|
||||||
|
|
||||||
|
body.appendChild(label);
|
||||||
|
if (step.detail) {
|
||||||
|
const detail = document.createElement('div');
|
||||||
|
detail.className = 'sa-activity-detail';
|
||||||
|
detail.textContent = step.detail;
|
||||||
|
body.appendChild(detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
row.appendChild(icon);
|
||||||
|
row.appendChild(body);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function paint() {
|
||||||
|
if (!ensureCard() || !listEl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
listEl.replaceChildren(...steps.map(renderStep));
|
||||||
|
const running = steps.find((s) => s.status === 'running');
|
||||||
|
const last = steps[steps.length - 1];
|
||||||
|
if (titleEl) {
|
||||||
|
titleEl.textContent = running
|
||||||
|
? running.label
|
||||||
|
: (last?.label || 'Assistent');
|
||||||
|
}
|
||||||
|
card.classList.toggle('sa-activity-live', steps.some((s) => s.status === 'running'));
|
||||||
|
card.classList.toggle('sa-activity-done', steps.length > 0 && steps.every((s) => s.status === 'done' || s.status === 'skip'));
|
||||||
|
if (typeof scrollToBottom === 'function') {
|
||||||
|
scrollToBottom();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function begin(title) {
|
||||||
|
steps = [];
|
||||||
|
card = null;
|
||||||
|
listEl = null;
|
||||||
|
titleEl = null;
|
||||||
|
open = true;
|
||||||
|
ensureCard();
|
||||||
|
if (titleEl && title) {
|
||||||
|
titleEl.textContent = title;
|
||||||
|
}
|
||||||
|
paint();
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsert(id, patch) {
|
||||||
|
ensureCard();
|
||||||
|
let step = steps.find((s) => s.id === id);
|
||||||
|
if (!step) {
|
||||||
|
step = { id, kind: 'think', label: id, status: 'running', detail: '' };
|
||||||
|
steps.push(step);
|
||||||
|
}
|
||||||
|
Object.assign(step, patch);
|
||||||
|
if (!step.status) {
|
||||||
|
step.status = 'running';
|
||||||
|
}
|
||||||
|
paint();
|
||||||
|
return step;
|
||||||
|
}
|
||||||
|
|
||||||
|
function done(id, patch = {}) {
|
||||||
|
return upsert(id, { ...patch, status: 'done' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function skip(id, patch = {}) {
|
||||||
|
return upsert(id, { ...patch, status: 'skip' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(id, patch = {}) {
|
||||||
|
return upsert(id, { ...patch, status: 'error' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function finish(summary) {
|
||||||
|
steps.forEach((s) => {
|
||||||
|
if (s.status === 'running') {
|
||||||
|
s.status = 'done';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (summary && titleEl) {
|
||||||
|
titleEl.textContent = summary;
|
||||||
|
}
|
||||||
|
paint();
|
||||||
|
if (card) {
|
||||||
|
card.classList.remove('sa-activity-live');
|
||||||
|
card.classList.add('sa-activity-done');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Describe a sparse model patch as human-readable activity steps. */
|
||||||
|
function noteModelCommands(patch) {
|
||||||
|
if (!patch || typeof patch !== 'object') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const keys = Object.keys(patch).filter((k) => patch[k] != null
|
||||||
|
&& !['actions', 'generate', 'ask', 'look_at', 'vision_from', 'vision_slots', 'notes', 'variants'].includes(k));
|
||||||
|
if (keys.length) {
|
||||||
|
done('delta', {
|
||||||
|
kind: 'delta',
|
||||||
|
label: 'Обновил сессию',
|
||||||
|
detail: keys.slice(0, 10).join(', '),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const ask = Array.isArray(patch.ask) ? patch.ask.map(String) : (patch.ask ? [String(patch.ask)] : []);
|
||||||
|
if (ask.length) {
|
||||||
|
upsert('ask', {
|
||||||
|
kind: 'ask',
|
||||||
|
label: `Запросил ${ask.join(', ')}`,
|
||||||
|
detail: 'подгружаю детали…',
|
||||||
|
status: 'running',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (patch.look_at != null || patch.vision_from != null || patch.vision_slots != null) {
|
||||||
|
const slots = [].concat(patch.look_at || patch.vision_from || patch.vision_slots || []);
|
||||||
|
upsert('look', {
|
||||||
|
kind: 'look',
|
||||||
|
label: 'Смотрит на кадр',
|
||||||
|
detail: slots.map(String).slice(0, 4).join(', '),
|
||||||
|
status: 'running',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (patch.generate === true
|
||||||
|
|| (Array.isArray(patch.actions) && patch.actions.map(String).includes('generate'))) {
|
||||||
|
upsert('generate', {
|
||||||
|
kind: 'generate',
|
||||||
|
label: 'Generate',
|
||||||
|
detail: 'ждёт пайплайн…',
|
||||||
|
status: 'running',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (Array.isArray(patch.variants) && patch.variants.length) {
|
||||||
|
upsert('variants', {
|
||||||
|
kind: 'generate',
|
||||||
|
label: `Варианты ×${patch.variants.length}`,
|
||||||
|
status: 'running',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
begin,
|
||||||
|
upsert,
|
||||||
|
done,
|
||||||
|
skip,
|
||||||
|
fail,
|
||||||
|
finish,
|
||||||
|
noteModelCommands,
|
||||||
|
get steps() {
|
||||||
|
return steps.slice();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function attachActivity(SA) {
|
||||||
|
SA.createActivityController = createActivityController;
|
||||||
|
}
|
||||||
+1039
-1528
File diff suppressed because it is too large
Load Diff
+194
@@ -0,0 +1,194 @@
|
|||||||
|
/**
|
||||||
|
* Context budget + conversation memory helpers for chat compression.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function emptyContextMemory() {
|
||||||
|
return {
|
||||||
|
summary: '',
|
||||||
|
untilCount: 0,
|
||||||
|
foldedTurns: 0,
|
||||||
|
at: 0,
|
||||||
|
uiCollapsed: false,
|
||||||
|
promptEvalCount: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeContextMemory(raw) {
|
||||||
|
if (!raw || typeof raw !== 'object') {
|
||||||
|
return emptyContextMemory();
|
||||||
|
}
|
||||||
|
const summary = String(raw.summary || '').trim();
|
||||||
|
return {
|
||||||
|
summary,
|
||||||
|
untilCount: Math.max(0, Number(raw.untilCount) || 0),
|
||||||
|
foldedTurns: Math.max(0, Number(raw.foldedTurns) || 0),
|
||||||
|
at: Number(raw.at) || 0,
|
||||||
|
uiCollapsed: !!raw.uiCollapsed && !!summary,
|
||||||
|
promptEvalCount: raw.promptEvalCount != null ? Number(raw.promptEvalCount) || null : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Estimate tokens from character counts. */
|
||||||
|
export function charsToTokens(chars, charsPerToken = 3.2) {
|
||||||
|
const cpt = Math.max(1.5, Number(charsPerToken) || 3.2);
|
||||||
|
const n = Math.max(0, Number(chars) || 0);
|
||||||
|
return Math.ceil(n / cpt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{
|
||||||
|
* systemChars?: number,
|
||||||
|
* historyChars?: number,
|
||||||
|
* memoryChars?: number,
|
||||||
|
* numCtx?: number,
|
||||||
|
* numPredict?: number,
|
||||||
|
* charsPerToken?: number,
|
||||||
|
* compressAt?: number,
|
||||||
|
* promptEvalCount?: number|null,
|
||||||
|
* }} opts
|
||||||
|
*/
|
||||||
|
export function estimateBudget(opts = {}) {
|
||||||
|
const numCtx = Math.max(1024, Number(opts.numCtx) || 16384);
|
||||||
|
const numPredict = Math.max(256, Number(opts.numPredict) || 3072);
|
||||||
|
const charsPerToken = Math.max(1.5, Number(opts.charsPerToken) || 3.2);
|
||||||
|
const compressAt = Math.min(0.95, Math.max(0.4, Number(opts.compressAt) || 0.7));
|
||||||
|
const systemChars = Math.max(0, Number(opts.systemChars) || 0);
|
||||||
|
const historyChars = Math.max(0, Number(opts.historyChars) || 0);
|
||||||
|
const memoryChars = Math.max(0, Number(opts.memoryChars) || 0);
|
||||||
|
const inputChars = systemChars + historyChars + memoryChars;
|
||||||
|
const estimated = charsToTokens(inputChars, charsPerToken);
|
||||||
|
const used = opts.promptEvalCount != null && Number(opts.promptEvalCount) > 0
|
||||||
|
? Number(opts.promptEvalCount)
|
||||||
|
: estimated;
|
||||||
|
const headroom = Math.max(1024, numCtx - numPredict);
|
||||||
|
const threshold = Math.floor(headroom * compressAt);
|
||||||
|
const ratio = numCtx > 0 ? used / numCtx : 0;
|
||||||
|
let level = 'ok';
|
||||||
|
if (ratio >= 0.85 || used >= threshold) {
|
||||||
|
level = 'hot';
|
||||||
|
} else if (ratio >= 0.65 || used >= threshold * 0.85) {
|
||||||
|
level = 'warn';
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
numCtx,
|
||||||
|
numPredict,
|
||||||
|
headroom,
|
||||||
|
threshold,
|
||||||
|
systemChars,
|
||||||
|
historyChars,
|
||||||
|
memoryChars,
|
||||||
|
inputChars,
|
||||||
|
estimated,
|
||||||
|
used,
|
||||||
|
fromEval: opts.promptEvalCount != null && Number(opts.promptEvalCount) > 0,
|
||||||
|
ratio,
|
||||||
|
level,
|
||||||
|
charsPerToken,
|
||||||
|
compressAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether auto-compress should run before the next chat turn.
|
||||||
|
* @param {ReturnType<typeof estimateBudget>} budget
|
||||||
|
* @param {{ untilCount?: number, summary?: string }} memory
|
||||||
|
* @param {number} historyLen — full transcript length
|
||||||
|
* @param {{ keepMessages?: number }} opts — messages kept raw (turns*2)
|
||||||
|
*/
|
||||||
|
export function shouldCompress(budget, memory, historyLen, opts = {}) {
|
||||||
|
const keep = Math.max(2, Number(opts.keepMessages) || 8);
|
||||||
|
const len = Math.max(0, Number(historyLen) || 0);
|
||||||
|
const until = Math.max(0, Number(memory?.untilCount) || 0);
|
||||||
|
const uncovered = Math.max(0, len - until);
|
||||||
|
if (uncovered <= keep) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const used = budget?.used ?? 0;
|
||||||
|
const threshold = budget?.threshold ?? Infinity;
|
||||||
|
return used >= threshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Messages the model should see: optional covered-by-summary skip + last keep raw.
|
||||||
|
* @param {Array<{role:string,content?:string,systemish?:boolean}>} history
|
||||||
|
* @param {{ untilCount?: number }} memory
|
||||||
|
* @param {number} keepTurns
|
||||||
|
*/
|
||||||
|
export function assembleModelMessages(history, memory, keepTurns) {
|
||||||
|
const keep = Math.max(1, Number(keepTurns) || 4) * 2;
|
||||||
|
const until = Math.max(0, Number(memory?.untilCount) || 0);
|
||||||
|
const list = (history || []).filter((m) => m && (m.role === 'user' || m.role === 'assistant') && !m.systemish);
|
||||||
|
const afterSummary = until > 0 ? list.slice(until) : list;
|
||||||
|
const window = afterSummary.length > keep ? afterSummary.slice(-keep) : afterSummary;
|
||||||
|
return window.map((m) => ({
|
||||||
|
role: m.role,
|
||||||
|
content: String(m.content || '').slice(0, 4000),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How many leading messages can be folded into a new summary (leave keep raw). */
|
||||||
|
export function messagesToFold(history, memory, keepTurns) {
|
||||||
|
const keep = Math.max(1, Number(keepTurns) || 4) * 2;
|
||||||
|
const list = (history || []).filter((m) => m && (m.role === 'user' || m.role === 'assistant') && !m.systemish);
|
||||||
|
const until = Math.max(0, Number(memory?.untilCount) || 0);
|
||||||
|
const foldEnd = Math.max(until, list.length - keep);
|
||||||
|
if (foldEnd <= until) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return list.slice(until, foldEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeSummary(oldSummary, incoming) {
|
||||||
|
const next = String(incoming || '').trim();
|
||||||
|
if (!next) {
|
||||||
|
return String(oldSummary || '').trim();
|
||||||
|
}
|
||||||
|
const prev = String(oldSummary || '').trim();
|
||||||
|
if (!prev) {
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
// Prefer the model output when it already incorporates prior memory.
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTokenShort(n) {
|
||||||
|
const v = Math.max(0, Number(n) || 0);
|
||||||
|
if (v >= 10000) {
|
||||||
|
return `${(v / 1000).toFixed(1)}k`;
|
||||||
|
}
|
||||||
|
if (v >= 1000) {
|
||||||
|
return `${(v / 1000).toFixed(1)}k`;
|
||||||
|
}
|
||||||
|
return String(Math.round(v));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function conversationMemoryBlock(memory, maxChars = 2400) {
|
||||||
|
const m = normalizeContextMemory(memory);
|
||||||
|
if (!m.summary) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let text = m.summary;
|
||||||
|
if (text.length > maxChars) {
|
||||||
|
text = `${text.slice(0, maxChars)}…`;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
summary: text,
|
||||||
|
until_count: m.untilCount,
|
||||||
|
folded_turns: m.foldedTurns,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function attachContext(SA) {
|
||||||
|
SA.context = {
|
||||||
|
emptyContextMemory,
|
||||||
|
normalizeContextMemory,
|
||||||
|
charsToTokens,
|
||||||
|
estimateBudget,
|
||||||
|
shouldCompress,
|
||||||
|
assembleModelMessages,
|
||||||
|
messagesToFold,
|
||||||
|
mergeSummary,
|
||||||
|
formatTokenShort,
|
||||||
|
conversationMemoryBlock,
|
||||||
|
};
|
||||||
|
}
|
||||||
+21
-140
@@ -1,4 +1,4 @@
|
|||||||
/** Turn intent heuristics — pure functions testable with node --test. */
|
/** Turn intent — veto only; generate comes from model `generate: true` (or legacy actions). */
|
||||||
|
|
||||||
export function cyrTokenRe(alts) {
|
export function cyrTokenRe(alts) {
|
||||||
const boundary = '(^|[^0-9A-Za-z_А-Яа-яЁё])';
|
const boundary = '(^|[^0-9A-Za-z_А-Яа-яЁё])';
|
||||||
@@ -6,55 +6,9 @@ export function cyrTokenRe(alts) {
|
|||||||
return new RegExp(`${boundary}(?:${alts})${end}`, 'i');
|
return new RegExp(`${boundary}(?:${alts})${end}`, 'i');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function userAsksGenerate(text) {
|
|
||||||
const t = String(text || '').trim();
|
|
||||||
if (!t) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (/^(gen|generate|go|рисуй|нарисуй)([!.…\s]|$)/i.test(t)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (/^(ещё|еще)(\s+раз)?([!.…\s]|$)/i.test(t)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const letter = '[0-9A-Za-z_А-Яа-яЁё]';
|
|
||||||
const stem = `${letter}*`;
|
|
||||||
return cyrTokenRe(
|
|
||||||
'сгенерируй|сгенерировать|генерируй|generate|нарисуй|перегенерируй|перерисуй|'
|
|
||||||
+ `сделай\\s+(картинк${stem}|изображен${stem}|фото${stem})|`
|
|
||||||
+ `хочу\\s+(картинк${stem}|изображен${stem}|фото${stem})|`
|
|
||||||
+ 'run\\s+generat|/gen',
|
|
||||||
).test(t);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function userAsksContinue(text) {
|
|
||||||
const t = String(text || '').trim();
|
|
||||||
if (!t) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (/^(давай\s+дальше|продолжай|продолжим|go\s+on|continue|keep\s+going|next(\s+one)?|next\s+frame)([!.…\s]|$)/i.test(t)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return cyrTokenRe(
|
|
||||||
'давай\\s+дальше|следующ(ий|ая|ее|ую)\\s+кадр|ещё\\s+кадр|еще\\s+кадр|'
|
|
||||||
+ 'кадр\\s*№?\\s*\\d+|сделай\\s+следующ',
|
|
||||||
).test(t);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isSameButAspectRequest(text) {
|
|
||||||
const t = String(text || '').trim();
|
|
||||||
if (!t) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return cyrTokenRe(
|
|
||||||
'тот\\s+же\\s+(кадр|сцена|промпт|prompt)|так\\s+же\\s+но\\s+(друг|иной)\\s+(формат|размер|aspect|соотношен)|'
|
|
||||||
+ 'same\\s+but\\s+(wider|taller|16:9|4:3|portrait|landscape)',
|
|
||||||
).test(t);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function userAsksNoGenerate(text) {
|
export function userAsksNoGenerate(text) {
|
||||||
const t = String(text || '').trim();
|
const t = String(text || '').trim();
|
||||||
if (!t || userAsksGenerate(t)) {
|
if (!t) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (/\b(remember|save\s+(this\s+)?(as\s+)?(the\s+)?(base\s+)?(prompt|template)|don'?t\s+generat|do\s+not\s+generat|no\s+generat|without\s+generat)\b/i.test(t)) {
|
if (/\b(remember|save\s+(this\s+)?(as\s+)?(the\s+)?(base\s+)?(prompt|template)|don'?t\s+generat|do\s+not\s+generat|no\s+generat|without\s+generat)\b/i.test(t)) {
|
||||||
@@ -63,19 +17,12 @@ export function userAsksNoGenerate(text) {
|
|||||||
return cyrTokenRe(
|
return cyrTokenRe(
|
||||||
'запомн|запомни|запомним|сохрани|сохраним|шаблон|'
|
'запомн|запомни|запомним|сохрани|сохраним|шаблон|'
|
||||||
+ 'базов(ый|ого|ому|ым|ая|ую|ое)?\\s+промпт|'
|
+ 'базов(ый|ого|ому|ым|ая|ую|ое)?\\s+промпт|'
|
||||||
+ 'не\\s+генерир|без\\s+генерац|не\\s+надо\\s+генер|только\\s+запомн|пока\\s+запомн|'
|
+ 'не\\s+генерир[а-яё]*|без\\s+генерац[а-яё]*|не\\s+надо\\s+генер[а-яё]*|только\\s+запомн[а-яё]*|пока\\s+запомн[а-яё]*|'
|
||||||
+ 'не\\s+рисуй|не\\s+запускай\\s+генер',
|
+ 'не\\s+рисуй|не\\s+запускай\\s+генер[а-яё]*|'
|
||||||
|
+ 'только\\s+(ответь|скажи|объясни)',
|
||||||
).test(t);
|
).test(t);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function userCommandsGenerate(text) {
|
|
||||||
const t = String(text || '').trim();
|
|
||||||
if (!t || userAsksNoGenerate(t)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return userAsksGenerate(t) || userAsksContinue(t) || isSameButAspectRequest(t);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function userAsksLook(text) {
|
export function userAsksLook(text) {
|
||||||
const t = String(text || '').trim();
|
const t = String(text || '').trim();
|
||||||
if (!t) {
|
if (!t) {
|
||||||
@@ -93,68 +40,15 @@ export function userAsksLook(text) {
|
|||||||
return /(?:^|[^а-яёa-z0-9_])(посмотри|смотри|глянь)\s+(на\s+)?(результат|картинк[а-яё]*|изображен[а-яё]*|кадр|ген|реф)/i.test(t);
|
return /(?:^|[^а-яёa-z0-9_])(посмотри|смотри|глянь)\s+(на\s+)?(результат|картинк[а-яё]*|изображен[а-яё]*|кадр|ген|реф)/i.test(t);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function userIsChatNotFrame(text) {
|
export function isSameButAspectRequest(text) {
|
||||||
const t = String(text || '').trim();
|
const t = String(text || '').trim();
|
||||||
if (!t) {
|
if (!t) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (/^(ок|окей|ok|okay|ладно|хорошо|понял|ясно|спасибо|thanks)([!.…\s]*)$/i.test(t)) {
|
return cyrTokenRe(
|
||||||
return true;
|
'тот\\s+же\\s+(кадр|сцена|промпт|prompt)|так\\s+же\\s+но\\s+(друг|иной)\\s+(формат|размер|aspect|соотношен)|'
|
||||||
}
|
+ 'same\\s+but\\s+(wider|taller|16:9|4:3|portrait|landscape)',
|
||||||
if (cyrTokenRe(
|
).test(t);
|
||||||
'что\\s+такое|как\\s+работает|зачем\\s+|какие\\s+(лор|модел|чекпоинт)|'
|
|
||||||
+ 'список\\s+лор|где\\s+настрой|что\\s+значит|'
|
|
||||||
+ 'нравит|спасибо|благодар|почему\\s+так|что\\s+ты\\s+(сделал|изменил)|'
|
|
||||||
+ 'только\\s+(ответь|скажи|объясни)|без\\s+(кадр|генерац)|не\\s+надо\\s+кадр',
|
|
||||||
).test(t) && !userAsksGenerate(t) && !userAsksContinue(t)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function userImpliesGenerate(text) {
|
|
||||||
const t = String(text || '').trim();
|
|
||||||
if (!t || userAsksNoGenerate(t) || userIsChatNotFrame(t)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (userCommandsGenerate(t)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (t.length < 8) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const wantsLook = userAsksLook(t);
|
|
||||||
const wantsRedraw = cyrTokenRe('поправь|исправь|перегенерир|перерисуй|улучши|переделай').test(t)
|
|
||||||
|| /\b(fix|redo|redraw|improve)\b/i.test(t);
|
|
||||||
if (wantsLook && !wantsRedraw) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (cyrTokenRe(
|
|
||||||
'нарису|сгенер|перерису|'
|
|
||||||
+ 'сделай\\s+(картинк|изображен|фото|кадр)|'
|
|
||||||
+ 'хочу\\s+(картинк|изображен|фото|увидеть|видеть)|'
|
|
||||||
+ 'покажи\\s+как\\s+(она|он|это)|'
|
|
||||||
+ 'сделай\\s+(её|ее|его|мне)\\s|'
|
|
||||||
+ 'пусть\\s+будет|'
|
|
||||||
+ 'другой\\s+(ракурс|свет|наряд|поза)|'
|
|
||||||
+ 'поменяй\\s+(позу|свет|одежд|фон)|добавь\\s+(свет|детал)|'
|
|
||||||
+ 'ещё\\s+одн|еще\\s+одн',
|
|
||||||
).test(t)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (/\b(draw|paint|render|make her|make him|another one|new frame)\b/i.test(t)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const isQuestion = /[??]\s*$/.test(t);
|
|
||||||
if (isQuestion) {
|
|
||||||
return cyrTokenRe('нарису|сгенер|можешь\\s+(сделать|нарисовать)|можно\\s+(картинк|сгенер)').test(t);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function packBlocksAutoGenerate(pack) {
|
|
||||||
const p = String(pack || '');
|
|
||||||
return p === 'describe_ref' || p === 'catalog_card' || p === 'author_persona' || p === 'debug_explain';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function packWantsVision(pack) {
|
export function packWantsVision(pack) {
|
||||||
@@ -162,30 +56,17 @@ export function packWantsVision(pack) {
|
|||||||
return p === 'critique_image' || p === 'describe_ref' || p === 'compose_scene' || p === 'inpaint_edit';
|
return p === 'critique_image' || p === 'describe_ref' || p === 'compose_scene' || p === 'inpaint_edit';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveTurnIntent(patch, userText, opts = {}, packId = '') {
|
/** Model generate + explicit veto. No RU imply/command heuristics. */
|
||||||
const machine = !!opts.machineTurn;
|
export function resolveTurnIntent(patch, userText, opts = {}) {
|
||||||
const vetoed = !machine && userAsksNoGenerate(userText);
|
const vetoed = !opts.machineTurn && userAsksNoGenerate(userText);
|
||||||
const commanded = !!opts.userWantsGenerate || (!machine && userCommandsGenerate(userText));
|
const modelAsked = patch?.generate === true
|
||||||
const implied = !machine && userImpliesGenerate(userText);
|
|| (Array.isArray(patch?.actions) && patch.actions.map(String).includes('generate'));
|
||||||
const modelAsked = Array.isArray(patch?.actions) && patch.actions.map(String).includes('generate');
|
const generate = !vetoed && !opts.fromAutoCritique && !!modelAsked;
|
||||||
|
|
||||||
let generate;
|
|
||||||
if (vetoed || opts.fromAutoCritique) {
|
|
||||||
generate = false;
|
|
||||||
} else if (commanded) {
|
|
||||||
generate = true;
|
|
||||||
} else if (packBlocksAutoGenerate(packId)) {
|
|
||||||
generate = false;
|
|
||||||
} else {
|
|
||||||
generate = modelAsked || implied;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasLook = !!patch
|
const hasLook = !!patch
|
||||||
&& (patch.look_at != null || patch.vision_from != null || patch.vision_slots != null);
|
&& (patch.look_at != null || patch.vision_from != null || patch.vision_slots != null);
|
||||||
const honorLook = opts.fromAutoCritique || opts.fromVisionHop
|
const look = !!(hasLook && !vetoed && !generate);
|
||||||
|| (!machine && userAsksLook(userText))
|
const ask = Array.isArray(patch?.ask)
|
||||||
|| packWantsVision(packId);
|
? patch.ask.map(String)
|
||||||
const look = !!(hasLook && !vetoed && !generate && honorLook);
|
: (typeof patch?.ask === 'string' && patch.ask ? [patch.ask] : []);
|
||||||
|
return { generate, look, vetoed, ask };
|
||||||
return { generate, look, vetoed };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import { attachApi } from './api.js';
|
import { attachApi } from './api.js';
|
||||||
import { attachPatch, setPatchKeys } from './patch.js';
|
import { attachPatch, setPatchKeys } from './patch.js';
|
||||||
import { attachPersist } from './persist.js';
|
import { attachPersist } from './persist.js';
|
||||||
|
import { attachSession } from './session.js';
|
||||||
|
import { attachContext } from './context.js';
|
||||||
|
import { attachActivity } from './activity.js';
|
||||||
|
|
||||||
window.SA = window.SA || {};
|
window.SA = window.SA || {};
|
||||||
attachApi(window.SA);
|
attachApi(window.SA);
|
||||||
attachPatch(window.SA);
|
attachPatch(window.SA);
|
||||||
attachPersist(window.SA);
|
attachPersist(window.SA);
|
||||||
|
attachSession(window.SA);
|
||||||
|
attachContext(window.SA);
|
||||||
|
attachActivity(window.SA);
|
||||||
|
|
||||||
/** Called from app after AssistentGetConfig — single source: Config/_base/patch-keys.json */
|
/** Called from app after AssistentGetConfig — single source: Config/_base/patch-keys.json */
|
||||||
window.SA.applyConfigPatchKeys = function (config) {
|
window.SA.applyConfigPatchKeys = function (config) {
|
||||||
|
|||||||
+19
-39
@@ -1,17 +1,15 @@
|
|||||||
/** Patch detection / extraction — mirrors AssistentPatch.cs. */
|
/** Patch detection / extraction — mirrors AssistentPatch.cs (0.14 sparse session deltas). */
|
||||||
|
|
||||||
const DEFAULT_PATCH_KEYS = [
|
const DEFAULT_PATCH_KEYS = [
|
||||||
'prompt', 'negative', 'loras', 'width', 'height', 'steps', 'cfg', 'seed', 'sigma_shift', 'sampler', 'scheduler',
|
'prompt', 'negative', 'loras', 'width', 'height', 'steps', 'cfg', 'seed', 'sigma_shift', 'sampler', 'scheduler',
|
||||||
'actions', 'search_query', 'civitai_query',
|
'actions', 'generate', 'ask',
|
||||||
'use_init_image', 'clear_init_image', 'init_creativity', 'denoise',
|
'use_init_image', 'clear_init_image', 'init_creativity', 'denoise',
|
||||||
'use_mask_image', 'clear_mask_image', 'mask_blur', 'mask_grow',
|
'use_mask_image', 'clear_mask_image', 'mask_blur', 'mask_grow',
|
||||||
'look_at', 'vision_from', 'vision_slots', 'slot_to_init', 'slot_to_mask',
|
'look_at', 'vision_from', 'vision_slots', 'slot_to_init', 'slot_to_mask',
|
||||||
'snapshot_generate', 'select_slot', 'aspect', 'images', 'batch', 'vary', 'lock_seed',
|
'snapshot_generate', 'select_slot', 'aspect', 'images', 'batch', 'vary', 'lock_seed',
|
||||||
'creativity', 'intensity', 'complexity', 'movement',
|
'creativity', 'intensity', 'complexity', 'movement',
|
||||||
'clear_prompt_images', 'slot_to_prompt_image', 'pack', 'memories', 'memory',
|
'clear_prompt_images', 'slot_to_prompt_image', 'pack', 'persona', 'controls',
|
||||||
'memory_query', 'memory_kind', 'tag_query', 'user_prefs',
|
'inventory_query', 'variants',
|
||||||
'inventory_query', 'skills', 'persona_shelves', 'persona_clone', 'persona', 'controls',
|
|
||||||
'variants',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
let PATCH_KEYS = DEFAULT_PATCH_KEYS.slice();
|
let PATCH_KEYS = DEFAULT_PATCH_KEYS.slice();
|
||||||
@@ -32,27 +30,10 @@ function has(obj, key) {
|
|||||||
return obj[key] !== undefined && obj[key] !== null;
|
return obj[key] !== undefined && obj[key] !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isCardObject(obj) {
|
|
||||||
if (!obj || typeof obj !== 'object') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const cardish = !!(obj.kind || obj.triggers || obj.when || obj.prompt_hint);
|
|
||||||
const genish = !!(obj.prompt != null || obj.negative != null || obj.loras || obj.actions
|
|
||||||
|| obj.width || obj.height || obj.steps != null || obj.cfg != null || obj.aspect || obj.seed != null
|
|
||||||
|| obj.search_query || obj.civitai_query || obj.look_at || obj.controls);
|
|
||||||
if (cardish && !genish && (obj.name || obj.triggers || obj.when)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return !!(obj.kind && obj.name && (obj.triggers || obj.when || obj.prompt_hint || obj.notes != null));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isPatchObject(obj) {
|
export function isPatchObject(obj) {
|
||||||
if (!obj || typeof obj !== 'object') {
|
if (!obj || typeof obj !== 'object') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (isCardObject(obj)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return PATCH_KEYS.some((k) => has(obj, k));
|
return PATCH_KEYS.some((k) => has(obj, k));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,8 +41,12 @@ export function normalizePatch(patch) {
|
|||||||
if (!patch || typeof patch !== 'object') {
|
if (!patch || typeof patch !== 'object') {
|
||||||
return patch;
|
return patch;
|
||||||
}
|
}
|
||||||
if (!has(patch, 'search_query') && has(patch, 'civitai_query')) {
|
const acts = Array.isArray(patch.actions) ? patch.actions.map(String) : [];
|
||||||
patch.search_query = patch.civitai_query;
|
if (patch.generate === true || acts.includes('generate')) {
|
||||||
|
patch.generate = true;
|
||||||
|
}
|
||||||
|
if (typeof patch.ask === 'string') {
|
||||||
|
patch.ask = [patch.ask];
|
||||||
}
|
}
|
||||||
if (!has(patch, 'init_creativity') && has(patch, 'denoise')) {
|
if (!has(patch, 'init_creativity') && has(patch, 'denoise')) {
|
||||||
patch.init_creativity = patch.denoise;
|
patch.init_creativity = patch.denoise;
|
||||||
@@ -100,7 +85,13 @@ export function isTerminalStreamPatch(obj) {
|
|||||||
if (!obj || typeof obj !== 'object') {
|
if (!obj || typeof obj !== 'object') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (isCardObject(obj)) {
|
if (obj.generate === true) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (Array.isArray(obj.ask) && obj.ask.length) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (typeof obj.ask === 'string' && obj.ask) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (Array.isArray(obj.variants) && obj.variants.length) {
|
if (Array.isArray(obj.variants) && obj.variants.length) {
|
||||||
@@ -109,17 +100,8 @@ export function isTerminalStreamPatch(obj) {
|
|||||||
if (obj.look_at != null || obj.vision_from != null || obj.vision_slots != null) {
|
if (obj.look_at != null || obj.vision_from != null || obj.vision_slots != null) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (obj.search_query != null || obj.civitai_query != null
|
|
||||||
|| obj.memory_query != null || obj.tag_query != null || obj.inventory_query != null) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const acts = Array.isArray(obj.actions) ? obj.actions.map(String) : [];
|
const acts = Array.isArray(obj.actions) ? obj.actions.map(String) : [];
|
||||||
const hopOrGen = [
|
if (acts.includes('generate')) {
|
||||||
'skill_load', 'persona_read', 'memory_get', 'memory_search', 'lookup_tags',
|
|
||||||
'list_inventory', 'search_civitai', 'interrupt', 'generate',
|
|
||||||
'memory_upsert', 'user_pref_upsert',
|
|
||||||
];
|
|
||||||
if (acts.some((a) => hopOrGen.includes(a))) {
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (String(obj.prompt || '').trim().length >= 48) {
|
if (String(obj.prompt || '').trim().length >= 48) {
|
||||||
@@ -127,8 +109,7 @@ export function isTerminalStreamPatch(obj) {
|
|||||||
}
|
}
|
||||||
if (obj.loras != null || obj.aspect != null || obj.steps != null
|
if (obj.loras != null || obj.aspect != null || obj.steps != null
|
||||||
|| obj.width != null || obj.height != null || obj.cfg != null
|
|| obj.width != null || obj.height != null || obj.cfg != null
|
||||||
|| obj.seed != null || obj.controls != null
|
|| obj.seed != null || obj.controls != null) {
|
||||||
|| obj.memories != null || obj.user_prefs != null) {
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -137,7 +118,6 @@ export function isTerminalStreamPatch(obj) {
|
|||||||
export function attachPatch(SA) {
|
export function attachPatch(SA) {
|
||||||
SA.PATCH_KEYS = PATCH_KEYS;
|
SA.PATCH_KEYS = PATCH_KEYS;
|
||||||
SA.setPatchKeys = setPatchKeys;
|
SA.setPatchKeys = setPatchKeys;
|
||||||
SA.isCardObject = isCardObject;
|
|
||||||
SA.isPatchObject = isPatchObject;
|
SA.isPatchObject = isPatchObject;
|
||||||
SA.isTerminalStreamPatch = isTerminalStreamPatch;
|
SA.isTerminalStreamPatch = isTerminalStreamPatch;
|
||||||
SA.normalizePatch = normalizePatch;
|
SA.normalizePatch = normalizePatch;
|
||||||
|
|||||||
+368
@@ -0,0 +1,368 @@
|
|||||||
|
/**
|
||||||
|
* Per-chat generation session — source of truth for Generate params, board, LoRAs.
|
||||||
|
* Sparse model deltas merge into the active session; buttons always generate from it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MAX_DATA_URL_CHARS = 350_000;
|
||||||
|
|
||||||
|
const GEN_KEYS = [
|
||||||
|
'prompt', 'negative', 'width', 'height', 'aspect', 'steps', 'cfg', 'sigma_shift',
|
||||||
|
'seed', 'sampler', 'scheduler', 'batch', 'checkpoint', 'loras', 'controls',
|
||||||
|
'use_init_image', 'clear_init_image', 'init_creativity', 'denoise',
|
||||||
|
'use_mask_image', 'clear_mask_image', 'mask_blur', 'mask_grow',
|
||||||
|
];
|
||||||
|
|
||||||
|
export function emptySession() {
|
||||||
|
return {
|
||||||
|
gen: {
|
||||||
|
prompt: '',
|
||||||
|
negative: '',
|
||||||
|
width: null,
|
||||||
|
height: null,
|
||||||
|
aspect: null,
|
||||||
|
steps: null,
|
||||||
|
cfg: null,
|
||||||
|
sigma_shift: null,
|
||||||
|
seed: null,
|
||||||
|
sampler: null,
|
||||||
|
scheduler: null,
|
||||||
|
batch: null,
|
||||||
|
checkpoint: null,
|
||||||
|
loras: [],
|
||||||
|
controls: {},
|
||||||
|
use_init_image: false,
|
||||||
|
clear_init_image: false,
|
||||||
|
init_creativity: null,
|
||||||
|
denoise: null,
|
||||||
|
use_mask_image: false,
|
||||||
|
clear_mask_image: false,
|
||||||
|
mask_blur: null,
|
||||||
|
mask_grow: null,
|
||||||
|
},
|
||||||
|
board: {
|
||||||
|
slots: [],
|
||||||
|
selectedSlotId: 'ref1',
|
||||||
|
genResults: [],
|
||||||
|
selectedGenResultId: null,
|
||||||
|
refSeq: 1,
|
||||||
|
},
|
||||||
|
persona: 'neutral',
|
||||||
|
pack: 'ordinary',
|
||||||
|
context_memory: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalize boolean generate + legacy actions:["generate"]. */
|
||||||
|
export function normalizeDelta(raw) {
|
||||||
|
if (!raw || typeof raw !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const delta = { ...raw };
|
||||||
|
const acts = Array.isArray(delta.actions) ? delta.actions.map(String) : [];
|
||||||
|
if (delta.generate === true || acts.includes('generate')) {
|
||||||
|
delta.generate = true;
|
||||||
|
}
|
||||||
|
if (typeof delta.ask === 'string') {
|
||||||
|
delta.ask = [delta.ask];
|
||||||
|
}
|
||||||
|
if (!Array.isArray(delta.ask)) {
|
||||||
|
delete delta.ask;
|
||||||
|
} else {
|
||||||
|
delta.ask = delta.ask.map(String).filter(Boolean);
|
||||||
|
}
|
||||||
|
return delta;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function patchWantsGenerate(patch) {
|
||||||
|
if (!patch || typeof patch !== 'object') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (patch.generate === true) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const acts = Array.isArray(patch.actions) ? patch.actions.map(String) : [];
|
||||||
|
return acts.includes('generate');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function patchAskList(patch) {
|
||||||
|
const n = normalizeDelta(patch);
|
||||||
|
return Array.isArray(n?.ask) ? n.ask : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge sparse model delta into session.gen (and pack/persona if present).
|
||||||
|
* Does not run Generate — caller decides via patchWantsGenerate + veto.
|
||||||
|
*/
|
||||||
|
export function mergeDelta(session, rawDelta) {
|
||||||
|
const base = session && typeof session === 'object' ? structuredCloneSession(session) : emptySession();
|
||||||
|
const delta = normalizeDelta(rawDelta);
|
||||||
|
if (!delta) {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
if (!base.gen) {
|
||||||
|
base.gen = emptySession().gen;
|
||||||
|
}
|
||||||
|
for (const key of GEN_KEYS) {
|
||||||
|
if (delta[key] === undefined || delta[key] === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (key === 'loras' && Array.isArray(delta.loras)) {
|
||||||
|
base.gen.loras = delta.loras.map((l) => ({
|
||||||
|
name: l?.name || l,
|
||||||
|
weight: l?.weight != null ? Number(l.weight) : 1,
|
||||||
|
triggers: Array.isArray(l?.triggers) ? l.triggers : undefined,
|
||||||
|
trigger_phrase: l?.trigger_phrase || undefined,
|
||||||
|
})).filter((l) => l.name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (key === 'controls' && typeof delta.controls === 'object') {
|
||||||
|
base.gen.controls = { ...(base.gen.controls || {}), ...delta.controls };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (key === 'checkpoint') {
|
||||||
|
base.gen.checkpoint = typeof delta.checkpoint === 'object'
|
||||||
|
? { ...delta.checkpoint }
|
||||||
|
: { name: String(delta.checkpoint) };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
base.gen[key] = delta[key];
|
||||||
|
}
|
||||||
|
if (delta.images != null && delta.batch == null) {
|
||||||
|
base.gen.batch = delta.images;
|
||||||
|
}
|
||||||
|
if (delta.pack) {
|
||||||
|
base.pack = String(delta.pack);
|
||||||
|
}
|
||||||
|
if (delta.persona) {
|
||||||
|
base.persona = String(delta.persona);
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
function structuredCloneSession(session) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(JSON.stringify(session));
|
||||||
|
} catch {
|
||||||
|
return emptySession();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function slimSrc(src) {
|
||||||
|
if (!src || typeof src !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const s = src.trim();
|
||||||
|
if (!s || s.startsWith('#')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (s.startsWith('data:') && s.length > MAX_DATA_URL_CHARS) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Snapshot live UI + board into a persistable session object. */
|
||||||
|
export function snapshotFromLive({
|
||||||
|
genFields,
|
||||||
|
board,
|
||||||
|
persona,
|
||||||
|
pack,
|
||||||
|
context_memory,
|
||||||
|
}) {
|
||||||
|
const session = emptySession();
|
||||||
|
if (genFields && typeof genFields === 'object') {
|
||||||
|
for (const key of GEN_KEYS) {
|
||||||
|
if (genFields[key] !== undefined) {
|
||||||
|
session.gen[key] = genFields[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
session.persona = persona || 'neutral';
|
||||||
|
session.pack = pack || 'ordinary';
|
||||||
|
if (context_memory && typeof context_memory === 'object') {
|
||||||
|
session.context_memory = context_memory;
|
||||||
|
}
|
||||||
|
if (board && typeof board === 'object') {
|
||||||
|
session.board = {
|
||||||
|
slots: (board.slots || []).map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
type: s.type,
|
||||||
|
label: s.label,
|
||||||
|
src: slimSrc(s.src),
|
||||||
|
attach: !!s.attach,
|
||||||
|
note: s.note || null,
|
||||||
|
})),
|
||||||
|
selectedSlotId: board.selectedSlotId || 'ref1',
|
||||||
|
genResults: (board.genResults || []).map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
label: r.label,
|
||||||
|
src: slimSrc(r.src),
|
||||||
|
patch: r.patch || null,
|
||||||
|
})),
|
||||||
|
selectedGenResultId: board.selectedGenResultId || null,
|
||||||
|
refSeq: board.refSeq || 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert legacy flat chat.params into session shape. */
|
||||||
|
export function sessionFromLegacyParams(params) {
|
||||||
|
if (!params || typeof params !== 'object') {
|
||||||
|
return emptySession();
|
||||||
|
}
|
||||||
|
if (params.gen && typeof params.gen === 'object') {
|
||||||
|
const s = emptySession();
|
||||||
|
s.gen = { ...s.gen, ...params.gen };
|
||||||
|
if (params.board && typeof params.board === 'object') {
|
||||||
|
s.board = { ...s.board, ...params.board };
|
||||||
|
}
|
||||||
|
s.persona = params.persona || s.persona;
|
||||||
|
s.pack = params.pack || s.pack;
|
||||||
|
if (params.context_memory && typeof params.context_memory === 'object') {
|
||||||
|
s.context_memory = params.context_memory;
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
const s = emptySession();
|
||||||
|
for (const key of GEN_KEYS) {
|
||||||
|
if (params[key] !== undefined && params[key] !== null) {
|
||||||
|
s.gen[key] = params[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Array.isArray(params.loras)) {
|
||||||
|
s.gen.loras = params.loras;
|
||||||
|
}
|
||||||
|
if (params.checkpoint) {
|
||||||
|
s.gen.checkpoint = typeof params.checkpoint === 'object'
|
||||||
|
? params.checkpoint
|
||||||
|
: { name: String(params.checkpoint) };
|
||||||
|
}
|
||||||
|
s.persona = params.persona || 'neutral';
|
||||||
|
s.pack = params.pack || 'ordinary';
|
||||||
|
if (params.context_memory && typeof params.context_memory === 'object') {
|
||||||
|
s.context_memory = params.context_memory;
|
||||||
|
}
|
||||||
|
s.board.genResults = Array.isArray(params.genResults) ? params.genResults : [];
|
||||||
|
s.board.selectedGenResultId = params.selectedGenResultId || null;
|
||||||
|
if (Array.isArray(params.slots)) {
|
||||||
|
s.board.slots = params.slots;
|
||||||
|
}
|
||||||
|
if (params.selectedSlotId) {
|
||||||
|
s.board.selectedSlotId = params.selectedSlotId;
|
||||||
|
}
|
||||||
|
if (params.refSeq) {
|
||||||
|
s.board.refSeq = params.refSeq;
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist blob for AssistentSaveChat.params */
|
||||||
|
export function toPersistParams(session) {
|
||||||
|
const s = session && typeof session === 'object' ? session : emptySession();
|
||||||
|
const out = {
|
||||||
|
gen: s.gen || emptySession().gen,
|
||||||
|
board: s.board || emptySession().board,
|
||||||
|
persona: s.persona || 'neutral',
|
||||||
|
pack: s.pack || 'ordinary',
|
||||||
|
};
|
||||||
|
if (s.context_memory && typeof s.context_memory === 'object') {
|
||||||
|
out.context_memory = s.context_memory;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function slimText(t, max) {
|
||||||
|
const s = String(t || '');
|
||||||
|
if (s.length <= max) {
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
return `${s.slice(0, max)}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compact context for every LLM turn. */
|
||||||
|
export function compactContext(session, extras = {}) {
|
||||||
|
const s = session && typeof session === 'object' ? session : emptySession();
|
||||||
|
const g = s.gen || {};
|
||||||
|
const board = s.board || {};
|
||||||
|
const slots = board.slots || [];
|
||||||
|
const genSlot = slots.find((x) => x.type === 'generate' || x.id === 'generate');
|
||||||
|
const refs = slots.filter((x) => x.type === 'ref' || String(x.id || '').startsWith('ref'));
|
||||||
|
return {
|
||||||
|
session: true,
|
||||||
|
prompt: slimText(g.prompt, extras.promptMax || 2000),
|
||||||
|
negative: slimText(g.negative, 500),
|
||||||
|
aspect: g.aspect || null,
|
||||||
|
width: g.width ?? null,
|
||||||
|
height: g.height ?? null,
|
||||||
|
steps: g.steps ?? null,
|
||||||
|
cfg: g.cfg ?? null,
|
||||||
|
seed: g.seed ?? null,
|
||||||
|
sigma_shift: g.sigma_shift ?? null,
|
||||||
|
sampler: g.sampler || null,
|
||||||
|
scheduler: g.scheduler || null,
|
||||||
|
batch: g.batch ?? null,
|
||||||
|
checkpoint: g.checkpoint?.name || g.checkpoint || null,
|
||||||
|
selected_loras: (g.loras || []).map((l) => ({
|
||||||
|
name: l.name || l,
|
||||||
|
weight: l.weight != null ? l.weight : 1,
|
||||||
|
})),
|
||||||
|
persona: s.persona || 'neutral',
|
||||||
|
pack: s.pack || 'ordinary',
|
||||||
|
board: {
|
||||||
|
has_generate: !!(genSlot?.src || (board.genResults || []).some((r) => r.src)),
|
||||||
|
refs: refs.map((r) => ({ id: r.id, has_image: !!r.src, attach: !!r.attach })),
|
||||||
|
gen_results: (board.genResults || []).map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
label: r.label,
|
||||||
|
has_image: !!r.src,
|
||||||
|
selected: r.id === board.selectedGenResultId,
|
||||||
|
})),
|
||||||
|
selected_slot: board.selectedSlotId || null,
|
||||||
|
},
|
||||||
|
architecture_ok: extras.architecture_ok !== false,
|
||||||
|
...extras.extra,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full dump for ask:settings hop. */
|
||||||
|
export function fullSettingsDump(session, extras = {}) {
|
||||||
|
const compact = compactContext(session, extras);
|
||||||
|
const s = session && typeof session === 'object' ? session : emptySession();
|
||||||
|
return {
|
||||||
|
...compact,
|
||||||
|
detail: 'settings',
|
||||||
|
gen: { ...(s.gen || {}) },
|
||||||
|
controls: s.gen?.controls || {},
|
||||||
|
exact: extras.exact || null,
|
||||||
|
krea_profiles: extras.kreaProfiles || null,
|
||||||
|
session_exact: extras.sessionExact || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveTurnIntent(patch, userText, { vetoFn } = {}) {
|
||||||
|
const delta = normalizeDelta(patch) || {};
|
||||||
|
const vetoed = typeof vetoFn === 'function' ? !!vetoFn(userText) : false;
|
||||||
|
const generate = !vetoed && patchWantsGenerate(delta);
|
||||||
|
const hasLook = delta.look_at != null || delta.vision_from != null || delta.vision_slots != null;
|
||||||
|
const look = !!(hasLook && !generate && !vetoed);
|
||||||
|
const ask = patchAskList(delta);
|
||||||
|
return { generate, look, vetoed, ask };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function attachSession(SA) {
|
||||||
|
SA.session = {
|
||||||
|
emptySession,
|
||||||
|
normalizeDelta,
|
||||||
|
mergeDelta,
|
||||||
|
patchWantsGenerate,
|
||||||
|
patchAskList,
|
||||||
|
snapshotFromLive,
|
||||||
|
sessionFromLegacyParams,
|
||||||
|
toPersistParams,
|
||||||
|
compactContext,
|
||||||
|
fullSettingsDump,
|
||||||
|
resolveTurnIntent,
|
||||||
|
GEN_KEYS,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
emptyContextMemory,
|
||||||
|
normalizeContextMemory,
|
||||||
|
charsToTokens,
|
||||||
|
estimateBudget,
|
||||||
|
shouldCompress,
|
||||||
|
assembleModelMessages,
|
||||||
|
messagesToFold,
|
||||||
|
mergeSummary,
|
||||||
|
conversationMemoryBlock,
|
||||||
|
} from '../src/context.js';
|
||||||
|
|
||||||
|
describe('context.js', () => {
|
||||||
|
it('charsToTokens ceil divide', () => {
|
||||||
|
assert.equal(charsToTokens(32, 3.2), 10);
|
||||||
|
assert.equal(charsToTokens(0, 3.2), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('estimateBudget prefers prompt_eval_count', () => {
|
||||||
|
const est = estimateBudget({
|
||||||
|
systemChars: 3200,
|
||||||
|
historyChars: 3200,
|
||||||
|
memoryChars: 0,
|
||||||
|
numCtx: 16384,
|
||||||
|
numPredict: 3072,
|
||||||
|
charsPerToken: 3.2,
|
||||||
|
compressAt: 0.7,
|
||||||
|
});
|
||||||
|
assert.equal(est.fromEval, false);
|
||||||
|
assert.ok(est.estimated > 0);
|
||||||
|
assert.equal(est.used, est.estimated);
|
||||||
|
|
||||||
|
const fact = estimateBudget({
|
||||||
|
...est,
|
||||||
|
systemChars: 3200,
|
||||||
|
historyChars: 3200,
|
||||||
|
promptEvalCount: 14000,
|
||||||
|
numCtx: 16384,
|
||||||
|
numPredict: 3072,
|
||||||
|
compressAt: 0.7,
|
||||||
|
});
|
||||||
|
assert.equal(fact.fromEval, true);
|
||||||
|
assert.equal(fact.used, 14000);
|
||||||
|
assert.equal(fact.level, 'hot');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shouldCompress only when over threshold and uncovered > keep', () => {
|
||||||
|
const budget = estimateBudget({
|
||||||
|
systemChars: 20000,
|
||||||
|
historyChars: 20000,
|
||||||
|
numCtx: 16384,
|
||||||
|
numPredict: 3072,
|
||||||
|
compressAt: 0.7,
|
||||||
|
charsPerToken: 3.2,
|
||||||
|
});
|
||||||
|
assert.equal(shouldCompress(budget, emptyContextMemory(), 20, { keepMessages: 8 }), true);
|
||||||
|
assert.equal(shouldCompress(budget, emptyContextMemory(), 6, { keepMessages: 8 }), false);
|
||||||
|
assert.equal(
|
||||||
|
shouldCompress(budget, { untilCount: 12, summary: 'x' }, 20, { keepMessages: 8 }),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('assembleModelMessages skips covered prefix then keeps last window', () => {
|
||||||
|
const history = [];
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
history.push({ role: i % 2 === 0 ? 'user' : 'assistant', content: `m${i}` });
|
||||||
|
}
|
||||||
|
const msgs = assembleModelMessages(history, { untilCount: 4 }, 2);
|
||||||
|
assert.equal(msgs.length, 4);
|
||||||
|
assert.equal(msgs[0].content, 'm8');
|
||||||
|
assert.equal(msgs[3].content, 'm11');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('messagesToFold leaves keepTurns raw', () => {
|
||||||
|
const history = Array.from({ length: 10 }, (_, i) => ({
|
||||||
|
role: i % 2 === 0 ? 'user' : 'assistant',
|
||||||
|
content: `m${i}`,
|
||||||
|
}));
|
||||||
|
const fold = messagesToFold(history, emptyContextMemory(), 2);
|
||||||
|
assert.equal(fold.length, 6);
|
||||||
|
assert.equal(fold[0].content, 'm0');
|
||||||
|
assert.equal(fold[5].content, 'm5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalize + merge + conversation block', () => {
|
||||||
|
const m = normalizeContextMemory({ summary: ' hello ', untilCount: 4, uiCollapsed: true });
|
||||||
|
assert.equal(m.summary, 'hello');
|
||||||
|
assert.equal(m.uiCollapsed, true);
|
||||||
|
assert.equal(mergeSummary('old', 'new'), 'new');
|
||||||
|
assert.equal(mergeSummary('old', ''), 'old');
|
||||||
|
const block = conversationMemoryBlock(m, 100);
|
||||||
|
assert.equal(block.summary, 'hello');
|
||||||
|
assert.equal(block.until_count, 4);
|
||||||
|
assert.equal(conversationMemoryBlock(emptyContextMemory()), null);
|
||||||
|
});
|
||||||
|
});
|
||||||
+17
-9
@@ -2,7 +2,6 @@ import { describe, it } from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {
|
import {
|
||||||
userAsksLook,
|
userAsksLook,
|
||||||
userCommandsGenerate,
|
|
||||||
userAsksNoGenerate,
|
userAsksNoGenerate,
|
||||||
resolveTurnIntent,
|
resolveTurnIntent,
|
||||||
packWantsVision,
|
packWantsVision,
|
||||||
@@ -14,17 +13,26 @@ describe('intent.js', () => {
|
|||||||
assert.equal(userAsksLook('нарисуй лису'), false);
|
assert.equal(userAsksLook('нарисуй лису'), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('userCommandsGenerate respects veto', () => {
|
it('userAsksNoGenerate vetoes remember / no-gen', () => {
|
||||||
assert.equal(userCommandsGenerate('сгенерируй кадр'), true);
|
|
||||||
assert.equal(userCommandsGenerate('только запомни промпт'), false);
|
|
||||||
assert.equal(userAsksNoGenerate('только запомни промпт'), true);
|
assert.equal(userAsksNoGenerate('только запомни промпт'), true);
|
||||||
|
assert.equal(userAsksNoGenerate('не генерируй'), true);
|
||||||
|
assert.equal(userAsksNoGenerate('нарисуй лису'), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resolveTurnIntent honors look without generate', () => {
|
it('resolveTurnIntent uses model generate only + veto', () => {
|
||||||
const patch = { look_at: ['generate'] };
|
const lookOnly = resolveTurnIntent({ look_at: ['generate'] }, 'что не так с кадром?', {});
|
||||||
const intent = resolveTurnIntent(patch, 'что не так с кадром?', {}, 'ordinary');
|
assert.equal(lookOnly.generate, false);
|
||||||
assert.equal(intent.generate, false);
|
assert.equal(lookOnly.look, true);
|
||||||
assert.equal(intent.look, true);
|
|
||||||
|
const gen = resolveTurnIntent({ prompt: 'fox', generate: true }, 'нарисуй лису', {});
|
||||||
|
assert.equal(gen.generate, true);
|
||||||
|
|
||||||
|
const vetoed = resolveTurnIntent({ prompt: 'fox', generate: true }, 'только запомни промпт', {});
|
||||||
|
assert.equal(vetoed.generate, false);
|
||||||
|
assert.equal(vetoed.vetoed, true);
|
||||||
|
|
||||||
|
const noHeuristic = resolveTurnIntent({ prompt: 'fox' }, 'нарисуй красивую лису в снегу', {});
|
||||||
|
assert.equal(noHeuristic.generate, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('packWantsVision for critique pack', () => {
|
it('packWantsVision for critique pack', () => {
|
||||||
|
|||||||
+54
-14
@@ -3,35 +3,75 @@ import assert from 'node:assert/strict';
|
|||||||
import {
|
import {
|
||||||
extractPatch,
|
extractPatch,
|
||||||
isPatchObject,
|
isPatchObject,
|
||||||
isCardObject,
|
|
||||||
normalizePatch,
|
normalizePatch,
|
||||||
setPatchKeys,
|
setPatchKeys,
|
||||||
} from '../src/patch.js';
|
} from '../src/patch.js';
|
||||||
|
import {
|
||||||
|
mergeDelta,
|
||||||
|
emptySession,
|
||||||
|
patchWantsGenerate,
|
||||||
|
sessionFromLegacyParams,
|
||||||
|
toPersistParams,
|
||||||
|
resolveTurnIntent,
|
||||||
|
} from '../src/session.js';
|
||||||
|
|
||||||
describe('patch.js', () => {
|
describe('patch.js', () => {
|
||||||
it('extractPatch finds generation patch in fence', () => {
|
it('extractPatch finds generation patch in fence', () => {
|
||||||
const text = 'Here you go\n```json\n{"prompt":"A red fox in snow","actions":["generate"]}\n```';
|
const text = 'Here you go\n```json\n{"prompt":"A red fox in snow","generate":true}\n```';
|
||||||
const { prose, patch } = extractPatch(text);
|
const { prose, patch } = extractPatch(text);
|
||||||
assert.ok(patch);
|
assert.ok(patch);
|
||||||
assert.equal(patch.prompt, 'A red fox in snow');
|
assert.equal(patch.prompt, 'A red fox in snow');
|
||||||
|
assert.equal(patch.generate, true);
|
||||||
assert.ok(!prose.includes('```'));
|
assert.ok(!prose.includes('```'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('isCardObject vs isPatchObject', () => {
|
it('normalizePatch maps legacy actions generate', () => {
|
||||||
const card = { kind: 'lora', name: 'Foo', triggers: ['bar'] };
|
const p = normalizePatch({ prompt: 'x', actions: ['generate'] });
|
||||||
const gen = { prompt: 'test', actions: ['generate'] };
|
assert.equal(p.generate, true);
|
||||||
assert.equal(isCardObject(card), true);
|
|
||||||
assert.equal(isPatchObject(card), false);
|
|
||||||
assert.equal(isPatchObject(gen), true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('normalizePatch aliases civitai_query', () => {
|
|
||||||
const p = normalizePatch({ civitai_query: 'anime style' });
|
|
||||||
assert.equal(p.search_query, 'anime style');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('scheduler-only patch is detected with full key list', () => {
|
it('scheduler-only patch is detected with full key list', () => {
|
||||||
setPatchKeys(['prompt', 'scheduler']);
|
setPatchKeys(['prompt', 'scheduler', 'generate', 'ask']);
|
||||||
assert.equal(isPatchObject({ scheduler: 'euler' }), true);
|
assert.equal(isPatchObject({ scheduler: 'euler' }), true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('session.js', () => {
|
||||||
|
it('mergeDelta is sparse and keeps other fields', () => {
|
||||||
|
let s = emptySession();
|
||||||
|
s.gen.prompt = 'old';
|
||||||
|
s.gen.steps = 8;
|
||||||
|
s = mergeDelta(s, { aspect: '16:9', generate: true });
|
||||||
|
assert.equal(s.gen.prompt, 'old');
|
||||||
|
assert.equal(s.gen.steps, 8);
|
||||||
|
assert.equal(s.gen.aspect, '16:9');
|
||||||
|
assert.equal(patchWantsGenerate({ generate: true }), true);
|
||||||
|
assert.equal(patchWantsGenerate({ actions: ['generate'] }), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('legacy flat params upgrade to session shape', () => {
|
||||||
|
const s = sessionFromLegacyParams({
|
||||||
|
prompt: 'fox',
|
||||||
|
steps: 28,
|
||||||
|
loras: [{ name: 'a', weight: 0.8 }],
|
||||||
|
genResults: [{ id: 'var1', src: '/View/x.png' }],
|
||||||
|
persona: 'leonid',
|
||||||
|
});
|
||||||
|
assert.equal(s.gen.prompt, 'fox');
|
||||||
|
assert.equal(s.gen.steps, 28);
|
||||||
|
assert.equal(s.board.genResults.length, 1);
|
||||||
|
const blob = toPersistParams(s);
|
||||||
|
assert.ok(blob.gen);
|
||||||
|
assert.ok(blob.board);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolveTurnIntent vetoes generate', () => {
|
||||||
|
const intent = resolveTurnIntent(
|
||||||
|
{ generate: true, prompt: 'x' },
|
||||||
|
'не генерируй',
|
||||||
|
{ vetoFn: (t) => /не\s+генерир/i.test(t) },
|
||||||
|
);
|
||||||
|
assert.equal(intent.generate, false);
|
||||||
|
assert.equal(intent.vetoed, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user