@things-factory/ai-assistant 10.1.108 → 10.1.110
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/components/assistant-chat.ts +299 -19
- package/client/utils/composer-frame.ts +49 -0
- package/client/utils/pasted-table.ts +2 -2
- package/dist-client/components/assistant-chat.d.ts +18 -0
- package/dist-client/components/assistant-chat.js +301 -19
- package/dist-client/components/assistant-chat.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-client/utils/composer-frame.d.ts +28 -0
- package/dist-client/utils/composer-frame.js +46 -0
- package/dist-client/utils/composer-frame.js.map +1 -0
- package/dist-client/utils/pasted-table.d.ts +1 -0
- package/dist-client/utils/pasted-table.js +1 -1
- package/dist-client/utils/pasted-table.js.map +1 -1
- package/package.json +2 -2
- package/test/composer-frame.test.ts +109 -0
- package/test/entry-card-styles.test.ts +18 -0
- package/test/pasted-table.test.ts +8 -1
- package/translations/en.json +7 -1
- package/translations/ja.json +7 -1
- package/translations/ko.json +7 -1
- package/translations/ms.json +7 -1
- package/translations/zh.json +7 -1
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
} from './markdown.js'
|
|
35
35
|
import { acceptOf, chatAttachmentUrl, refuseAttachments, textCharsOf } from '../utils/attachment-policy.js'
|
|
36
36
|
import { pastedTableFile } from '../utils/pasted-table.js'
|
|
37
|
+
import { appendWords, composerHeight, composerKeyAction, sendShortcut, tableAsWords } from '../utils/composer-frame.js'
|
|
37
38
|
import { readSpreadsheet, spreadsheetKindOf, type SpreadsheetRefusal } from '../utils/spreadsheet-text.js'
|
|
38
39
|
import { entryReviewAddress, offersWorkspaceReview } from '../utils/entry-workspace.js'
|
|
39
40
|
import { buildAssistantChatInput, buildChatMutationInput } from './chat-input-builder.js'
|
|
@@ -141,6 +142,8 @@ export interface ChatAttachment {
|
|
|
141
142
|
type: string
|
|
142
143
|
url: string
|
|
143
144
|
base64?: string
|
|
145
|
+
/** A table pasted into the composer - its shape for the chip, its rows for the preview, its words to give back. */
|
|
146
|
+
table?: { rows: string[][]; columns: number; text?: string }
|
|
144
147
|
}
|
|
145
148
|
|
|
146
149
|
function formatFileSize(bytes: number): string {
|
|
@@ -605,6 +608,14 @@ export class OxAssistantChat extends LitElement {
|
|
|
605
608
|
@state()
|
|
606
609
|
private dragOver = false
|
|
607
610
|
|
|
611
|
+
/** The wide box (넓게 쓰기) covers the conversation inside the dock - shared-dock.md §10. */
|
|
612
|
+
@state()
|
|
613
|
+
private composerWide = false
|
|
614
|
+
|
|
615
|
+
/** The pasted table whose rows cover the conversation, or null. */
|
|
616
|
+
@state()
|
|
617
|
+
private previewAttachmentId: string | null = null
|
|
618
|
+
|
|
608
619
|
/*
|
|
609
620
|
* ── 왜 이미지만 받나 ──────────────────────────────────────────────────────
|
|
610
621
|
* `.json` · `.txt` · `.csv` 도 받고 있었다. 그 파일들은 `hostContext.attachments` 로 실려 갔고
|
|
@@ -640,7 +651,7 @@ export class OxAssistantChat extends LitElement {
|
|
|
640
651
|
return String(i18next.t(`ai-assistant.text.${refusal.code}`, { ...params, defaultValue: refusal.code }))
|
|
641
652
|
}
|
|
642
653
|
|
|
643
|
-
private async onAttachFiles(files: FileList | File[] | null | undefined) {
|
|
654
|
+
private async onAttachFiles(files: FileList | File[] | null | undefined, table?: ChatAttachment['table']) {
|
|
644
655
|
if (!files || files.length === 0) return
|
|
645
656
|
let list = Array.from(files)
|
|
646
657
|
if (this.usesAttachmentPolicy && this.modeView?.attachments?.kinds?.includes('spreadsheet')) {
|
|
@@ -678,7 +689,8 @@ export class OxAssistantChat extends LitElement {
|
|
|
678
689
|
size: file.size,
|
|
679
690
|
type: file.type || 'application/octet-stream',
|
|
680
691
|
url,
|
|
681
|
-
base64
|
|
692
|
+
base64,
|
|
693
|
+
table
|
|
682
694
|
})
|
|
683
695
|
}
|
|
684
696
|
|
|
@@ -722,6 +734,39 @@ export class OxAssistantChat extends LitElement {
|
|
|
722
734
|
URL.revokeObjectURL(target.url)
|
|
723
735
|
}
|
|
724
736
|
this.attachments = this.attachments.filter(a => a.id !== id)
|
|
737
|
+
if (this.previewAttachmentId === id) this.previewAttachmentId = null
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/** A pasted table goes back into the box as the words it was copied as - the conversion was not the person's choice. */
|
|
741
|
+
private tableToWords(id: string) {
|
|
742
|
+
const target = this.attachments.find(a => a.id === id)
|
|
743
|
+
if (!target?.table) return
|
|
744
|
+
this.input = appendWords(this.input, tableAsWords(target.table))
|
|
745
|
+
this.removeAttachment(id)
|
|
746
|
+
this.updateComplete.then(() => (this.renderRoot.querySelector('textarea') as HTMLTextAreaElement | null)?.focus())
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/** The small box stands as tall as its text, up to 40% of the chat; the wide box fills what it covers. */
|
|
750
|
+
private fitComposer() {
|
|
751
|
+
const ta = this.renderRoot.querySelector('.input-row textarea') as HTMLTextAreaElement | null
|
|
752
|
+
if (!ta) return
|
|
753
|
+
if (this.composerWide) {
|
|
754
|
+
ta.style.height = ''
|
|
755
|
+
ta.style.overflowY = ''
|
|
756
|
+
return
|
|
757
|
+
}
|
|
758
|
+
ta.style.height = '0px'
|
|
759
|
+
const border = ta.offsetHeight - ta.clientHeight
|
|
760
|
+
const resting = parseFloat(getComputedStyle(ta).minHeight) || 0
|
|
761
|
+
const { height, scrolls } = composerHeight(ta.scrollHeight + border, this.clientHeight, resting)
|
|
762
|
+
ta.style.height = `${height}px`
|
|
763
|
+
ta.style.overflowY = scrolls ? 'auto' : 'hidden'
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
private setComposerWide(wide: boolean) {
|
|
767
|
+
this.composerWide = wide
|
|
768
|
+
if (wide) this.previewAttachmentId = null
|
|
769
|
+
this.updateComplete.then(() => (this.renderRoot.querySelector('.input-row textarea') as HTMLTextAreaElement | null)?.focus())
|
|
725
770
|
}
|
|
726
771
|
|
|
727
772
|
private onComposerPaste(e: ClipboardEvent) {
|
|
@@ -744,7 +789,8 @@ export class OxAssistantChat extends LitElement {
|
|
|
744
789
|
this.errorMessage = String(i18next.t(`ai-assistant.text.${pasted.refusal.code}`, { ...pasted.refusal, defaultValue: pasted.refusal.code }))
|
|
745
790
|
return
|
|
746
791
|
}
|
|
747
|
-
|
|
792
|
+
const words = e.clipboardData?.getData('text/plain')
|
|
793
|
+
this.onAttachFiles([new File([pasted.text], pasted.name, { type: 'text/csv' })], { ...pasted.table, text: words })
|
|
748
794
|
return
|
|
749
795
|
}
|
|
750
796
|
}
|
|
@@ -2019,6 +2065,42 @@ export class OxAssistantChat extends LitElement {
|
|
|
2019
2065
|
color: inherit;
|
|
2020
2066
|
}
|
|
2021
2067
|
|
|
2068
|
+
/* The entry card's way to the large review - a quiet action under its table, never a second run button. */
|
|
2069
|
+
.workspace-review {
|
|
2070
|
+
display: flex;
|
|
2071
|
+
align-items: center;
|
|
2072
|
+
gap: 5px;
|
|
2073
|
+
margin: 6px 0 0 auto;
|
|
2074
|
+
padding: 5px 11px 5px 9px;
|
|
2075
|
+
border: 1px solid transparent;
|
|
2076
|
+
border-radius: 999px;
|
|
2077
|
+
background: transparent;
|
|
2078
|
+
color: var(--md-sys-color-primary, #0f766e);
|
|
2079
|
+
font-family: inherit;
|
|
2080
|
+
font-size: 12px;
|
|
2081
|
+
font-weight: 600;
|
|
2082
|
+
letter-spacing: 0.01em;
|
|
2083
|
+
cursor: pointer;
|
|
2084
|
+
transition:
|
|
2085
|
+
background-color 0.14s ease-in-out,
|
|
2086
|
+
border-color 0.14s ease-in-out;
|
|
2087
|
+
}
|
|
2088
|
+
.workspace-review md-icon {
|
|
2089
|
+
--md-icon-size: 15px;
|
|
2090
|
+
color: inherit;
|
|
2091
|
+
}
|
|
2092
|
+
.workspace-review:hover {
|
|
2093
|
+
background: color-mix(in srgb, var(--md-sys-color-primary, #0f766e) 8%, transparent);
|
|
2094
|
+
border-color: color-mix(in srgb, var(--md-sys-color-primary, #0f766e) 24%, transparent);
|
|
2095
|
+
}
|
|
2096
|
+
.workspace-review:active {
|
|
2097
|
+
background: color-mix(in srgb, var(--md-sys-color-primary, #0f766e) 14%, transparent);
|
|
2098
|
+
}
|
|
2099
|
+
.workspace-review:focus-visible {
|
|
2100
|
+
outline: 2px solid var(--md-sys-color-primary, #0f766e);
|
|
2101
|
+
outline-offset: 1px;
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2022
2104
|
/* ── 접지 경고 — 근거에 없는 대상을 지목한 답 ───── */
|
|
2023
2105
|
.grounding-warning {
|
|
2024
2106
|
display: flex;
|
|
@@ -2327,6 +2409,115 @@ export class OxAssistantChat extends LitElement {
|
|
|
2327
2409
|
flex-direction: column;
|
|
2328
2410
|
transition: background 0.15s, border-color 0.15s;
|
|
2329
2411
|
}
|
|
2412
|
+
/* The wide box and a table's rows cover the conversation, inside the dock - the page beside it stays. */
|
|
2413
|
+
.messages.covered {
|
|
2414
|
+
flex: 0 0 0;
|
|
2415
|
+
padding: 0;
|
|
2416
|
+
overflow: hidden;
|
|
2417
|
+
visibility: hidden;
|
|
2418
|
+
}
|
|
2419
|
+
.composer.wide,
|
|
2420
|
+
.composer.previewing {
|
|
2421
|
+
flex: 1 1 auto;
|
|
2422
|
+
min-height: 0;
|
|
2423
|
+
}
|
|
2424
|
+
.composer.wide .input-row {
|
|
2425
|
+
flex: 1;
|
|
2426
|
+
min-height: 0;
|
|
2427
|
+
align-items: stretch;
|
|
2428
|
+
}
|
|
2429
|
+
.composer.wide .input-row > :not(.textarea-wrap) {
|
|
2430
|
+
align-self: flex-end;
|
|
2431
|
+
}
|
|
2432
|
+
.composer.wide textarea {
|
|
2433
|
+
min-height: 0;
|
|
2434
|
+
}
|
|
2435
|
+
.composer-size {
|
|
2436
|
+
margin-left: auto;
|
|
2437
|
+
}
|
|
2438
|
+
.send-shortcut {
|
|
2439
|
+
font-size: 10px;
|
|
2440
|
+
color: var(--md-sys-color-outline, #64748b);
|
|
2441
|
+
white-space: nowrap;
|
|
2442
|
+
padding-bottom: 10px;
|
|
2443
|
+
}
|
|
2444
|
+
.table-preview {
|
|
2445
|
+
flex: 1;
|
|
2446
|
+
min-height: 0;
|
|
2447
|
+
display: flex;
|
|
2448
|
+
flex-direction: column;
|
|
2449
|
+
margin: 4px 14px;
|
|
2450
|
+
border: 1px solid var(--md-sys-color-outline-variant, #e2e8f0);
|
|
2451
|
+
border-radius: 8px;
|
|
2452
|
+
background: var(--md-sys-color-surface, #ffffff);
|
|
2453
|
+
overflow: hidden;
|
|
2454
|
+
}
|
|
2455
|
+
.table-preview-head {
|
|
2456
|
+
display: flex;
|
|
2457
|
+
align-items: center;
|
|
2458
|
+
gap: 6px;
|
|
2459
|
+
padding: 6px 8px;
|
|
2460
|
+
border-bottom: 1px solid var(--md-sys-color-outline-variant, #e2e8f0);
|
|
2461
|
+
flex-wrap: wrap;
|
|
2462
|
+
}
|
|
2463
|
+
.table-preview-head > md-icon {
|
|
2464
|
+
--md-icon-size: 18px;
|
|
2465
|
+
color: var(--md-sys-color-primary, #0284c7);
|
|
2466
|
+
}
|
|
2467
|
+
.table-preview-name {
|
|
2468
|
+
font-weight: 500;
|
|
2469
|
+
color: var(--md-sys-color-on-surface, #0f172a);
|
|
2470
|
+
min-width: 0;
|
|
2471
|
+
overflow: hidden;
|
|
2472
|
+
text-overflow: ellipsis;
|
|
2473
|
+
white-space: nowrap;
|
|
2474
|
+
}
|
|
2475
|
+
.table-preview-shape {
|
|
2476
|
+
color: var(--md-sys-color-on-surface-variant, #475569);
|
|
2477
|
+
margin-right: auto;
|
|
2478
|
+
}
|
|
2479
|
+
.table-preview-body {
|
|
2480
|
+
flex: 1;
|
|
2481
|
+
min-height: 0;
|
|
2482
|
+
overflow: auto;
|
|
2483
|
+
}
|
|
2484
|
+
.table-preview-body table {
|
|
2485
|
+
border-collapse: collapse;
|
|
2486
|
+
font-size: 11px;
|
|
2487
|
+
}
|
|
2488
|
+
.table-preview-body th,
|
|
2489
|
+
.table-preview-body td {
|
|
2490
|
+
border-bottom: 1px solid var(--md-sys-color-outline-variant, #e2e8f0);
|
|
2491
|
+
padding: 4px 8px;
|
|
2492
|
+
text-align: left;
|
|
2493
|
+
white-space: nowrap;
|
|
2494
|
+
color: var(--md-sys-color-on-surface, #0f172a);
|
|
2495
|
+
}
|
|
2496
|
+
.table-preview-body th {
|
|
2497
|
+
position: sticky;
|
|
2498
|
+
left: 0;
|
|
2499
|
+
font-weight: 400;
|
|
2500
|
+
text-align: right;
|
|
2501
|
+
color: var(--md-sys-color-on-surface-variant, #475569);
|
|
2502
|
+
background: var(--md-sys-color-surface-container, #f1f5f9);
|
|
2503
|
+
}
|
|
2504
|
+
.attachment-open {
|
|
2505
|
+
all: unset;
|
|
2506
|
+
display: flex;
|
|
2507
|
+
align-items: center;
|
|
2508
|
+
gap: 6px;
|
|
2509
|
+
min-width: 0;
|
|
2510
|
+
flex: 1;
|
|
2511
|
+
cursor: pointer;
|
|
2512
|
+
border-radius: 6px;
|
|
2513
|
+
}
|
|
2514
|
+
.attachment-open:focus-visible {
|
|
2515
|
+
outline: 2px solid var(--md-sys-color-primary, #0284c7);
|
|
2516
|
+
outline-offset: 2px;
|
|
2517
|
+
}
|
|
2518
|
+
.attachment-chip.open {
|
|
2519
|
+
border-color: var(--md-sys-color-primary, #0284c7);
|
|
2520
|
+
}
|
|
2330
2521
|
.composer.drag-over {
|
|
2331
2522
|
background: var(--md-sys-color-surface-container, #f1f5f9);
|
|
2332
2523
|
border-top-color: var(--md-sys-color-primary, #0284c7);
|
|
@@ -2593,8 +2784,9 @@ export class OxAssistantChat extends LitElement {
|
|
|
2593
2784
|
font: inherit;
|
|
2594
2785
|
letter-spacing: inherit;
|
|
2595
2786
|
resize: none;
|
|
2596
|
-
|
|
2597
|
-
|
|
2787
|
+
/* The resting height of one line as it stood; the box grows from here by composerHeight (shared-dock.md §10). */
|
|
2788
|
+
box-sizing: border-box;
|
|
2789
|
+
min-height: 54px;
|
|
2598
2790
|
outline: none;
|
|
2599
2791
|
transition:
|
|
2600
2792
|
border-color 0.2s,
|
|
@@ -2714,6 +2906,8 @@ export class OxAssistantChat extends LitElement {
|
|
|
2714
2906
|
}
|
|
2715
2907
|
|
|
2716
2908
|
updated(changed: Map<string, any>) {
|
|
2909
|
+
if (changed.has('input') || changed.has('composerWide') || changed.has('previewAttachmentId') || changed.has('attachments')) this.fitComposer()
|
|
2910
|
+
|
|
2717
2911
|
/*
|
|
2718
2912
|
* 밖에서 넣어 준 질문을 자동 전송한다(오류 토스트의 "AI에게 묻기" 등).
|
|
2719
2913
|
*
|
|
@@ -2953,7 +3147,7 @@ export class OxAssistantChat extends LitElement {
|
|
|
2953
3147
|
${this.renderModeSubject()}
|
|
2954
3148
|
${this.renderSessionTabs()}
|
|
2955
3149
|
${this.renderRoster()}
|
|
2956
|
-
<div class="messages" @click=${this.onBubbleClick}>
|
|
3150
|
+
<div class="messages ${this.composerWide || this.previewedTable ? 'covered' : ''}" @click=${this.onBubbleClick}>
|
|
2957
3151
|
${!hasMessages ? this.renderEmpty() : nothing}
|
|
2958
3152
|
${(() => {
|
|
2959
3153
|
const myEmail = this._myEmail
|
|
@@ -3076,7 +3270,7 @@ export class OxAssistantChat extends LitElement {
|
|
|
3076
3270
|
: nothing}
|
|
3077
3271
|
${this.renderFoldNotice()}
|
|
3078
3272
|
<div
|
|
3079
|
-
class="composer ${this.dragOver ? 'drag-over' : ''}"
|
|
3273
|
+
class="composer ${this.dragOver ? 'drag-over' : ''} ${this.composerWide ? 'wide' : ''} ${this.previewedTable ? 'previewing' : ''}"
|
|
3080
3274
|
@dragover=${(e: DragEvent) => this.onComposerDragOver(e)}
|
|
3081
3275
|
@dragleave=${(e: DragEvent) => this.onComposerDragLeave(e)}
|
|
3082
3276
|
@drop=${(e: DragEvent) => this.onComposerDrop(e)}
|
|
@@ -3128,21 +3322,44 @@ export class OxAssistantChat extends LitElement {
|
|
|
3128
3322
|
</button>
|
|
3129
3323
|
`
|
|
3130
3324
|
: nothing}
|
|
3325
|
+
<button
|
|
3326
|
+
type="button"
|
|
3327
|
+
class="action composer-size"
|
|
3328
|
+
aria-pressed=${this.composerWide ? 'true' : 'false'}
|
|
3329
|
+
@click=${() => this.setComposerWide(!this.composerWide)}>
|
|
3330
|
+
<md-icon>${this.composerWide ? 'close_fullscreen' : 'open_in_full'}</md-icon>
|
|
3331
|
+
${this.composerWide
|
|
3332
|
+
? i18next.t('ai-assistant.button.write-narrow', { defaultValue: 'Narrow' })
|
|
3333
|
+
: i18next.t('ai-assistant.button.write-wide', { defaultValue: 'Write wide' })}
|
|
3334
|
+
</button>
|
|
3131
3335
|
</div>
|
|
3336
|
+
${this.renderTablePreview()}
|
|
3132
3337
|
${this.examplesOpen ? this.renderInlineExamples() : nothing}
|
|
3133
3338
|
${this.attachments.length > 0
|
|
3134
3339
|
? html`
|
|
3135
3340
|
<div class="attachment-strip">
|
|
3136
3341
|
${this.attachments.map(
|
|
3137
3342
|
att => html`
|
|
3138
|
-
<div class="attachment-chip" title=${att.name}>
|
|
3139
|
-
${att.
|
|
3140
|
-
? html`<
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
|
|
3145
|
-
|
|
3343
|
+
<div class="attachment-chip ${att.id === this.previewAttachmentId ? 'open' : ''}" title=${att.name}>
|
|
3344
|
+
${att.table
|
|
3345
|
+
? html`<button
|
|
3346
|
+
type="button"
|
|
3347
|
+
class="attachment-open"
|
|
3348
|
+
aria-expanded=${att.id === this.previewAttachmentId ? 'true' : 'false'}
|
|
3349
|
+
@click=${() => (this.previewAttachmentId = att.id === this.previewAttachmentId ? null : att.id)}>
|
|
3350
|
+
<md-icon class="attachment-icon">table_chart</md-icon>
|
|
3351
|
+
<span class="attachment-meta">
|
|
3352
|
+
<span class="attachment-name">${att.name}</span>
|
|
3353
|
+
<span class="attachment-size">${this.tableShape(att.table)}</span>
|
|
3354
|
+
</span>
|
|
3355
|
+
</button>`
|
|
3356
|
+
: html`${att.type.startsWith('image/')
|
|
3357
|
+
? html`<img class="attachment-thumb" src=${att.url} alt=${att.name} />`
|
|
3358
|
+
: html`<md-icon class="attachment-icon">description</md-icon>`}
|
|
3359
|
+
<div class="attachment-meta">
|
|
3360
|
+
<span class="attachment-name">${att.name}</span>
|
|
3361
|
+
<span class="attachment-size">${formatFileSize(att.size)}</span>
|
|
3362
|
+
</div>`}
|
|
3146
3363
|
<button
|
|
3147
3364
|
type="button"
|
|
3148
3365
|
class="attachment-remove"
|
|
@@ -3214,10 +3431,16 @@ export class OxAssistantChat extends LitElement {
|
|
|
3214
3431
|
</button>
|
|
3215
3432
|
`
|
|
3216
3433
|
: nothing}
|
|
3434
|
+
${this.composerWide
|
|
3435
|
+
? html`<span class="send-shortcut">${i18next.t('ai-assistant.text.send-shortcut', { key: sendShortcut(navigator.platform), defaultValue: '{key} to send' })}</span>`
|
|
3436
|
+
: nothing}
|
|
3217
3437
|
<button
|
|
3218
3438
|
?disabled=${this.busy || !!this.sendBlocked || (!this.input.trim() && this.attachments.length === 0)}
|
|
3219
3439
|
@click=${this.send}
|
|
3220
|
-
title=${this.sendBlocked ||
|
|
3440
|
+
title=${this.sendBlocked ||
|
|
3441
|
+
(this.composerWide
|
|
3442
|
+
? i18next.t('ai-assistant.text.send-tooltip-wide', { key: sendShortcut(navigator.platform), defaultValue: 'Send ({key})' })
|
|
3443
|
+
: i18next.t('ai-assistant.text.send-tooltip'))}>
|
|
3221
3444
|
${this.busy
|
|
3222
3445
|
? html`<span class="spinner"></span>`
|
|
3223
3446
|
: html`<md-icon>arrow_upward</md-icon>`}
|
|
@@ -3304,7 +3527,57 @@ export class OxAssistantChat extends LitElement {
|
|
|
3304
3527
|
`
|
|
3305
3528
|
}
|
|
3306
3529
|
|
|
3530
|
+
private get previewedTable(): ChatAttachment | undefined {
|
|
3531
|
+
return this.previewAttachmentId ? this.attachments.find(a => a.id === this.previewAttachmentId && a.table) : undefined
|
|
3532
|
+
}
|
|
3533
|
+
|
|
3534
|
+
private tableShape(table: NonNullable<ChatAttachment['table']>) {
|
|
3535
|
+
return i18next.t('ai-assistant.text.table-shape', { rows: table.rows.length, columns: table.columns, defaultValue: '{rows} rows × {columns} columns' })
|
|
3536
|
+
}
|
|
3537
|
+
|
|
3538
|
+
/*
|
|
3539
|
+
The rows of a pasted table, over the conversation - numbered as the file numbers them, which is the number the
|
|
3540
|
+
entry card names. Every row: the paste is held under the mode's limit already.
|
|
3541
|
+
*/
|
|
3542
|
+
private renderTablePreview() {
|
|
3543
|
+
const att = this.previewedTable
|
|
3544
|
+
if (!att?.table) return nothing
|
|
3545
|
+
const { rows, columns } = att.table
|
|
3546
|
+
return html`
|
|
3547
|
+
<div class="table-preview" role="region" aria-label=${att.name}>
|
|
3548
|
+
<div class="table-preview-head">
|
|
3549
|
+
<md-icon>table_chart</md-icon>
|
|
3550
|
+
<span class="table-preview-name">${att.name}</span>
|
|
3551
|
+
<span class="table-preview-shape">${this.tableShape(att.table)}</span>
|
|
3552
|
+
<button type="button" class="action" @click=${() => this.tableToWords(att.id)}>
|
|
3553
|
+
<md-icon>notes</md-icon>
|
|
3554
|
+
${i18next.t('ai-assistant.button.table-to-words', { defaultValue: 'Put in as words' })}
|
|
3555
|
+
</button>
|
|
3556
|
+
<button type="button" class="action" @click=${() => (this.previewAttachmentId = null)}>
|
|
3557
|
+
<md-icon>close</md-icon>
|
|
3558
|
+
${i18next.t('ai-assistant.button.close')}
|
|
3559
|
+
</button>
|
|
3560
|
+
</div>
|
|
3561
|
+
<div class="table-preview-body">
|
|
3562
|
+
<table>
|
|
3563
|
+
${rows.map(
|
|
3564
|
+
(row, i) => html`<tr>
|
|
3565
|
+
<th scope="row">${i + 1}</th>
|
|
3566
|
+
${Array.from({ length: columns }, (_, c) => html`<td>${row[c] ?? ''}</td>`)}
|
|
3567
|
+
</tr>`
|
|
3568
|
+
)}
|
|
3569
|
+
</table>
|
|
3570
|
+
</div>
|
|
3571
|
+
</div>
|
|
3572
|
+
`
|
|
3573
|
+
}
|
|
3574
|
+
|
|
3307
3575
|
private onKeyDown(e: KeyboardEvent) {
|
|
3576
|
+
/*
|
|
3577
|
+
The Enter that ends a Korean (or Japanese, Chinese) syllable belongs to the input method - sending on it
|
|
3578
|
+
sends the line without its last syllable, which then lands in the emptied box.
|
|
3579
|
+
*/
|
|
3580
|
+
if (e.isComposing || e.keyCode === 229) return
|
|
3308
3581
|
// Mention popup 활성 시 — 화살표/Enter/Tab/Esc 가로채서 popup 네비게이션
|
|
3309
3582
|
if (this.mentionOpen) {
|
|
3310
3583
|
if (e.key === 'ArrowDown') {
|
|
@@ -3341,9 +3614,13 @@ export class OxAssistantChat extends LitElement {
|
|
|
3341
3614
|
return
|
|
3342
3615
|
}
|
|
3343
3616
|
}
|
|
3344
|
-
|
|
3617
|
+
const action = composerKeyAction(e, this.composerWide)
|
|
3618
|
+
if (action === 'narrow') {
|
|
3345
3619
|
e.preventDefault()
|
|
3346
|
-
this.
|
|
3620
|
+
this.setComposerWide(false)
|
|
3621
|
+
} else if (action === 'send') {
|
|
3622
|
+
e.preventDefault()
|
|
3623
|
+
if (!this.busy) this.send()
|
|
3347
3624
|
}
|
|
3348
3625
|
}
|
|
3349
3626
|
|
|
@@ -4078,7 +4355,7 @@ export class OxAssistantChat extends LitElement {
|
|
|
4078
4355
|
${entry ? html`<ox-assistant-entry-review .proposal=${p}></ox-assistant-entry-review>` : nothing}
|
|
4079
4356
|
${entry && this.sessionId && offersWorkspaceReview(p)
|
|
4080
4357
|
? html`<button
|
|
4081
|
-
class="
|
|
4358
|
+
class="workspace-review"
|
|
4082
4359
|
@click=${() =>
|
|
4083
4360
|
this.dispatchEvent(new CustomEvent('assistant-open-page', { detail: { href: entryReviewAddress(this.sessionId!, key) }, bubbles: true, composed: true }))}
|
|
4084
4361
|
>
|
|
@@ -4646,6 +4923,9 @@ export class OxAssistantChat extends LitElement {
|
|
|
4646
4923
|
const attachmentsToSend = [...this.attachments]
|
|
4647
4924
|
if ((!text && attachmentsToSend.length === 0) || this.busy) return
|
|
4648
4925
|
this.attachments = []
|
|
4926
|
+
/* What was written wide is sent to be answered - the conversation comes back to show it. */
|
|
4927
|
+
this.composerWide = false
|
|
4928
|
+
this.previewAttachmentId = null
|
|
4649
4929
|
|
|
4650
4930
|
const promptText = text || (attachmentsToSend.length > 0
|
|
4651
4931
|
? (attachmentsToSend[0].type.startsWith('image/')
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The composer's frame (operato-application/design/ai/shared-dock.md §10) - how tall it stands, what Enter does,
|
|
3
|
+
* and what a pasted table gives back as words. Pure: the chat hands in its measures and the key.
|
|
4
|
+
*
|
|
5
|
+
* - The box grows with its text up to 40% of the chat's height, then scrolls. It never stands under its resting
|
|
6
|
+
* height, and on a chat too short for 40% to hold that height the resting height wins.
|
|
7
|
+
* - The wide box (넓게 쓰기) covers the conversation inside the dock; the page beside it stays where it was.
|
|
8
|
+
* - In the small box Enter sends and Shift+Enter breaks the line, as in every chat. In the wide box Enter breaks
|
|
9
|
+
* the line - long writing is made of lines - and Ctrl/Cmd+Enter sends. Ctrl/Cmd+Enter sends in both.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** The part of the chat's height the small box may take before it scrolls. */
|
|
13
|
+
export const COMPOSER_GROWTH = 0.4
|
|
14
|
+
|
|
15
|
+
/** The box's height for its content, both border-box pixels. */
|
|
16
|
+
export function composerHeight(content: number, chat: number, resting: number): { height: number; scrolls: boolean } {
|
|
17
|
+
const cap = Math.max(resting, Math.floor(chat * COMPOSER_GROWTH))
|
|
18
|
+
return { height: Math.min(Math.max(content, resting), cap), scrolls: content > cap }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type ComposerKey = { key: string; shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean; altKey?: boolean }
|
|
22
|
+
|
|
23
|
+
/** What a key does in the box: send, close the wide box, or nothing of ours (the textarea keeps it). */
|
|
24
|
+
export function composerKeyAction(e: ComposerKey, wide: boolean): 'send' | 'narrow' | null {
|
|
25
|
+
if (e.key === 'Escape') return wide ? 'narrow' : null
|
|
26
|
+
if (e.key !== 'Enter' || e.altKey) return null
|
|
27
|
+
if (e.ctrlKey || e.metaKey) return 'send'
|
|
28
|
+
if (wide || e.shiftKey) return null
|
|
29
|
+
return 'send'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The shortcut the wide box's send button names, in the keys of this machine. */
|
|
33
|
+
export function sendShortcut(platform: string | undefined): string {
|
|
34
|
+
return /mac|iphone|ipad/i.test(platform ?? '') ? '⌘ Enter' : 'Ctrl+Enter'
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A pasted table given back as words: what the clipboard held as text, or - when it held only HTML - the rows with
|
|
39
|
+
* a tab between cells, as a spreadsheet copies them.
|
|
40
|
+
*/
|
|
41
|
+
export function tableAsWords(table: { rows: string[][]; text?: string | null }): string {
|
|
42
|
+
const text = (table.text ?? '').replace(/\r\n?/g, '\n').replace(/\n+$/, '')
|
|
43
|
+
return text.trim() ? text : table.rows.map(row => row.join('\t')).join('\n')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The words appended to what is already written - on a line of their own. */
|
|
47
|
+
export function appendWords(written: string, words: string): string {
|
|
48
|
+
return written.trim() ? `${written.replace(/\s+$/, '')}\n${words}` : words
|
|
49
|
+
}
|
|
@@ -133,7 +133,7 @@ export function pastedTableFile(
|
|
|
133
133
|
clipboard: { html?: string | null; text?: string | null },
|
|
134
134
|
policy: AttachmentPolicy | null | undefined,
|
|
135
135
|
nameOf: (rows: number) => string
|
|
136
|
-
): { name: string; text: string } | { refusal: PasteRefusal } | null {
|
|
136
|
+
): { name: string; text: string; table: PastedTable } | { refusal: PasteRefusal } | null {
|
|
137
137
|
if (!policy?.kinds?.some(kind => kind === 'text' || kind === 'spreadsheet')) return null
|
|
138
138
|
const table = pastedTable(clipboard)
|
|
139
139
|
if (!table) return null
|
|
@@ -141,5 +141,5 @@ export function pastedTableFile(
|
|
|
141
141
|
const text = pastedTableText(table, name)
|
|
142
142
|
const maxChars = textCharsOf(policy)
|
|
143
143
|
if (text.length > maxChars) return { refusal: { code: 'attachment-paste-too-long', rows: table.rows.length, maxChars } }
|
|
144
|
-
return { name: `${name.replace(/[\\/:*?"<>|]/g, '_')}.csv`, text }
|
|
144
|
+
return { name: `${name.replace(/[\\/:*?"<>|]/g, '_')}.csv`, text, table }
|
|
145
145
|
}
|
|
@@ -29,6 +29,12 @@ export interface ChatAttachment {
|
|
|
29
29
|
type: string;
|
|
30
30
|
url: string;
|
|
31
31
|
base64?: string;
|
|
32
|
+
/** A table pasted into the composer - its shape for the chip, its rows for the preview, its words to give back. */
|
|
33
|
+
table?: {
|
|
34
|
+
rows: string[][];
|
|
35
|
+
columns: number;
|
|
36
|
+
text?: string;
|
|
37
|
+
};
|
|
32
38
|
}
|
|
33
39
|
export declare class OxAssistantChat extends LitElement {
|
|
34
40
|
/** 영속 세션 id. 없으면 ad-hoc (메시지 영속 안 됨). */
|
|
@@ -227,6 +233,10 @@ export declare class OxAssistantChat extends LitElement {
|
|
|
227
233
|
private attachments;
|
|
228
234
|
/** 드래그 오버 상태 (시각 피드백용) */
|
|
229
235
|
private dragOver;
|
|
236
|
+
/** The wide box (넓게 쓰기) covers the conversation inside the dock - shared-dock.md §10. */
|
|
237
|
+
private composerWide;
|
|
238
|
+
/** The pasted table whose rows cover the conversation, or null. */
|
|
239
|
+
private previewAttachmentId;
|
|
230
240
|
/** This conversation talks to the neutral door - its attachments follow the mode's policy (§9). */
|
|
231
241
|
private get usesAttachmentPolicy();
|
|
232
242
|
/** Whether the attach button stands: a mode that declares nothing takes nothing (the board door keeps its images). */
|
|
@@ -238,6 +248,11 @@ export declare class OxAssistantChat extends LitElement {
|
|
|
238
248
|
private readSpreadsheets;
|
|
239
249
|
private readFileAsBase64;
|
|
240
250
|
private removeAttachment;
|
|
251
|
+
/** A pasted table goes back into the box as the words it was copied as - the conversion was not the person's choice. */
|
|
252
|
+
private tableToWords;
|
|
253
|
+
/** The small box stands as tall as its text, up to 40% of the chat; the wide box fills what it covers. */
|
|
254
|
+
private fitComposer;
|
|
255
|
+
private setComposerWide;
|
|
241
256
|
private onComposerPaste;
|
|
242
257
|
private onComposerDragOver;
|
|
243
258
|
private onComposerDragLeave;
|
|
@@ -427,6 +442,9 @@ export declare class OxAssistantChat extends LitElement {
|
|
|
427
442
|
* 일어나지 않는 것이 가장 나쁘다.
|
|
428
443
|
*/
|
|
429
444
|
private renderInviteNotice;
|
|
445
|
+
private get previewedTable();
|
|
446
|
+
private tableShape;
|
|
447
|
+
private renderTablePreview;
|
|
430
448
|
private onKeyDown;
|
|
431
449
|
/** input 이벤트 — value 즉시 반영 + popup 갱신은 150ms 디바운스 (F-Finder 와 동일).
|
|
432
450
|
* big board 에서 매 keystroke filter 비용 절감 + 한국어 IME 조합 중 깜빡임 완화. */
|