@things-factory/ai-assistant 10.1.107 → 10.1.109
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 +108 -2
- package/client/components/assistant-dock.ts +4 -4
- package/client/components/assistant-mode.ts +1 -1
- package/client/components/entry-review.ts +23 -3
- package/client/pages/assistant-workspace-page.ts +82 -2
- package/client/utils/attachment-policy.ts +22 -3
- package/client/utils/entry-hash.ts +35 -0
- package/client/utils/entry-proposal.ts +10 -3
- package/client/utils/entry-workspace.ts +70 -0
- package/client/utils/pasted-table.ts +145 -0
- package/client/utils/spreadsheet-text.ts +125 -0
- package/dist-client/components/assistant-chat.d.ts +2 -0
- package/dist-client/components/assistant-chat.js +105 -2
- package/dist-client/components/assistant-chat.js.map +1 -1
- package/dist-client/components/assistant-dock.js +4 -4
- package/dist-client/components/assistant-dock.js.map +1 -1
- package/dist-client/components/assistant-mode.d.ts +2 -1
- package/dist-client/components/assistant-mode.js.map +1 -1
- package/dist-client/components/entry-review.d.ts +2 -0
- package/dist-client/components/entry-review.js +30 -3
- package/dist-client/components/entry-review.js.map +1 -1
- package/dist-client/pages/assistant-workspace-page.d.ts +8 -0
- package/dist-client/pages/assistant-workspace-page.js +98 -2
- package/dist-client/pages/assistant-workspace-page.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-client/utils/attachment-policy.d.ts +7 -1
- package/dist-client/utils/attachment-policy.js +14 -2
- package/dist-client/utils/attachment-policy.js.map +1 -1
- package/dist-client/utils/entry-hash.d.ts +2 -0
- package/dist-client/utils/entry-hash.js +36 -0
- package/dist-client/utils/entry-hash.js.map +1 -0
- package/dist-client/utils/entry-proposal.d.ts +1 -1
- package/dist-client/utils/entry-proposal.js +9 -2
- package/dist-client/utils/entry-proposal.js.map +1 -1
- package/dist-client/utils/entry-workspace.d.ts +20 -0
- package/dist-client/utils/entry-workspace.js +68 -0
- package/dist-client/utils/entry-workspace.js.map +1 -0
- package/dist-client/utils/pasted-table.d.ts +34 -0
- package/dist-client/utils/pasted-table.js +150 -0
- package/dist-client/utils/pasted-table.js.map +1 -0
- package/dist-client/utils/spreadsheet-text.d.ts +48 -0
- package/dist-client/utils/spreadsheet-text.js +110 -0
- package/dist-client/utils/spreadsheet-text.js.map +1 -0
- package/dist-server/service/assistant-chat-resolver.js +1 -1
- package/dist-server/service/assistant-chat-resolver.js.map +1 -1
- package/dist-server/service/assistant-mode/assistant-mode-resolver.js +1 -1
- package/dist-server/service/assistant-mode/assistant-mode-resolver.js.map +1 -1
- package/dist-server/service/chat-session/attachment-policy.d.ts +5 -2
- package/dist-server/service/chat-session/attachment-policy.js +10 -1
- package/dist-server/service/chat-session/attachment-policy.js.map +1 -1
- package/dist-server/service/chat-session/attachment-tools.js +3 -1
- package/dist-server/service/chat-session/attachment-tools.js.map +1 -1
- package/dist-server/service/chat-session/session-attachments.d.ts +3 -2
- package/dist-server/service/chat-session/session-attachments.js +5 -4
- package/dist-server/service/chat-session/session-attachments.js.map +1 -1
- package/dist-server/service/data-entry/entry-apply.d.ts +2 -0
- package/dist-server/service/data-entry/entry-apply.js +4 -0
- package/dist-server/service/data-entry/entry-apply.js.map +1 -1
- package/dist-server/service/data-entry/entry-hash.d.ts +2 -0
- package/dist-server/service/data-entry/entry-hash.js +39 -0
- package/dist-server/service/data-entry/entry-hash.js.map +1 -0
- package/dist-server/service/data-entry/entry-resolver.d.ts +1 -1
- package/dist-server/service/data-entry/entry-resolver.js +5 -3
- package/dist-server/service/data-entry/entry-resolver.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -2
- package/server/service/assistant-chat-resolver.ts +2 -2
- package/server/service/assistant-mode/assistant-mode-resolver.ts +1 -1
- package/server/service/chat-session/attachment-policy.ts +18 -2
- package/server/service/chat-session/attachment-tools.ts +3 -1
- package/server/service/chat-session/session-attachments.ts +5 -4
- package/server/service/data-entry/data-entry.test.ts +10 -0
- package/server/service/data-entry/entry-apply.ts +5 -0
- package/server/service/data-entry/entry-hash.ts +35 -0
- package/server/service/data-entry/entry-resolver.ts +4 -2
- package/test/entry-card-styles.test.ts +18 -0
- package/test/entry-workspace.test.ts +89 -0
- package/test/pasted-table.test.ts +147 -0
- package/test/spreadsheet-text.test.ts +92 -0
- package/translations/en.json +12 -1
- package/translations/ja.json +12 -1
- package/translations/ko.json +12 -1
- package/translations/ms.json +12 -1
- package/translations/zh.json +12 -1
|
@@ -32,7 +32,10 @@ import {
|
|
|
32
32
|
injectMentionUserIds,
|
|
33
33
|
stripMentionRefids
|
|
34
34
|
} from './markdown.js'
|
|
35
|
-
import { acceptOf, chatAttachmentUrl, refuseAttachments } from '../utils/attachment-policy.js'
|
|
35
|
+
import { acceptOf, chatAttachmentUrl, refuseAttachments, textCharsOf } from '../utils/attachment-policy.js'
|
|
36
|
+
import { pastedTableFile } from '../utils/pasted-table.js'
|
|
37
|
+
import { readSpreadsheet, spreadsheetKindOf, type SpreadsheetRefusal } from '../utils/spreadsheet-text.js'
|
|
38
|
+
import { entryReviewAddress, offersWorkspaceReview } from '../utils/entry-workspace.js'
|
|
36
39
|
import { buildAssistantChatInput, buildChatMutationInput } from './chat-input-builder.js'
|
|
37
40
|
import { receiveRemoteMessage, reconcileSentLine, remoteMessageToLine } from './chat-echo-dedup.js'
|
|
38
41
|
import './mention-popup.js'
|
|
@@ -639,7 +642,16 @@ export class OxAssistantChat extends LitElement {
|
|
|
639
642
|
|
|
640
643
|
private async onAttachFiles(files: FileList | File[] | null | undefined) {
|
|
641
644
|
if (!files || files.length === 0) return
|
|
642
|
-
|
|
645
|
+
let list = Array.from(files)
|
|
646
|
+
if (this.usesAttachmentPolicy && this.modeView?.attachments?.kinds?.includes('spreadsheet')) {
|
|
647
|
+
/* A spreadsheet travels as the text of its sheets (§9-5) - read here, refused by name when it cannot be. */
|
|
648
|
+
const read = await this.readSpreadsheets(list)
|
|
649
|
+
if ('refusal' in read) {
|
|
650
|
+
this.errorMessage = String(i18next.t(`ai-assistant.text.${read.refusal.code}`, { ...(read.refusal as any), defaultValue: read.refusal.code }))
|
|
651
|
+
return
|
|
652
|
+
}
|
|
653
|
+
list = read.files
|
|
654
|
+
}
|
|
643
655
|
if (this.usesAttachmentPolicy) {
|
|
644
656
|
/* Said before anything is sent - never cut silently (§9-1). */
|
|
645
657
|
const refusal = refuseAttachments(this.modeView?.attachments, [...this.attachments, ...list].map(f => ({ name: f.name, type: f.type, size: f.size })))
|
|
@@ -673,6 +685,28 @@ export class OxAssistantChat extends LitElement {
|
|
|
673
685
|
this.attachments = [...this.attachments, ...newAttachments]
|
|
674
686
|
}
|
|
675
687
|
|
|
688
|
+
/** Each .xlsx replaced by one CSV file per sheet; any other file passes as it is. */
|
|
689
|
+
private async readSpreadsheets(list: File[]): Promise<{ files: File[] } | { refusal: SpreadsheetRefusal }> {
|
|
690
|
+
const out: File[] = []
|
|
691
|
+
let Workbook: any
|
|
692
|
+
for (const file of list) {
|
|
693
|
+
const kind = spreadsheetKindOf(file)
|
|
694
|
+
if (!kind) {
|
|
695
|
+
out.push(file)
|
|
696
|
+
continue
|
|
697
|
+
}
|
|
698
|
+
if (kind === 'xls') return { refusal: { code: 'attachment-xls-not-read', name: file.name } }
|
|
699
|
+
if (!Workbook) {
|
|
700
|
+
const mod: any = await import('exceljs')
|
|
701
|
+
Workbook = (mod.default ?? mod).Workbook
|
|
702
|
+
}
|
|
703
|
+
const read = await readSpreadsheet(await file.arrayBuffer(), file.name, textCharsOf(this.modeView?.attachments), Workbook)
|
|
704
|
+
if ('refusal' in read) return read
|
|
705
|
+
for (const sheet of read.files) out.push(new File([sheet.text], sheet.name, { type: 'text/csv' }))
|
|
706
|
+
}
|
|
707
|
+
return { files: out }
|
|
708
|
+
}
|
|
709
|
+
|
|
676
710
|
private readFileAsBase64(file: File): Promise<string> {
|
|
677
711
|
return new Promise((resolve, reject) => {
|
|
678
712
|
const reader = new FileReader()
|
|
@@ -694,6 +728,27 @@ export class OxAssistantChat extends LitElement {
|
|
|
694
728
|
const items = e.clipboardData?.items
|
|
695
729
|
if (!items || items.length === 0) return
|
|
696
730
|
|
|
731
|
+
/*
|
|
732
|
+
Rows copied from a spreadsheet or a web page travel as a file, so the entry card can check each row against
|
|
733
|
+
its line. A spreadsheet also puts a picture of the cells on the clipboard - with the table taken, it is left.
|
|
734
|
+
*/
|
|
735
|
+
if (this.usesAttachmentPolicy) {
|
|
736
|
+
const pasted = pastedTableFile(
|
|
737
|
+
{ html: e.clipboardData?.getData('text/html'), text: e.clipboardData?.getData('text/plain') },
|
|
738
|
+
this.modeView?.attachments,
|
|
739
|
+
rows => String(i18next.t('ai-assistant.text.pasted-table-name', { rows, defaultValue: 'pasted table ({rows} rows)' }))
|
|
740
|
+
)
|
|
741
|
+
if (pasted) {
|
|
742
|
+
e.preventDefault()
|
|
743
|
+
if ('refusal' in pasted) {
|
|
744
|
+
this.errorMessage = String(i18next.t(`ai-assistant.text.${pasted.refusal.code}`, { ...pasted.refusal, defaultValue: pasted.refusal.code }))
|
|
745
|
+
return
|
|
746
|
+
}
|
|
747
|
+
this.onAttachFiles([new File([pasted.text], pasted.name, { type: 'text/csv' })])
|
|
748
|
+
return
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
697
752
|
const files: File[] = []
|
|
698
753
|
for (let i = 0; i < items.length; i++) {
|
|
699
754
|
const item = items[i]
|
|
@@ -1964,6 +2019,42 @@ export class OxAssistantChat extends LitElement {
|
|
|
1964
2019
|
color: inherit;
|
|
1965
2020
|
}
|
|
1966
2021
|
|
|
2022
|
+
/* The entry card's way to the large review - a quiet action under its table, never a second run button. */
|
|
2023
|
+
.workspace-review {
|
|
2024
|
+
display: flex;
|
|
2025
|
+
align-items: center;
|
|
2026
|
+
gap: 5px;
|
|
2027
|
+
margin: 6px 0 0 auto;
|
|
2028
|
+
padding: 5px 11px 5px 9px;
|
|
2029
|
+
border: 1px solid transparent;
|
|
2030
|
+
border-radius: 999px;
|
|
2031
|
+
background: transparent;
|
|
2032
|
+
color: var(--md-sys-color-primary, #0f766e);
|
|
2033
|
+
font-family: inherit;
|
|
2034
|
+
font-size: 12px;
|
|
2035
|
+
font-weight: 600;
|
|
2036
|
+
letter-spacing: 0.01em;
|
|
2037
|
+
cursor: pointer;
|
|
2038
|
+
transition:
|
|
2039
|
+
background-color 0.14s ease-in-out,
|
|
2040
|
+
border-color 0.14s ease-in-out;
|
|
2041
|
+
}
|
|
2042
|
+
.workspace-review md-icon {
|
|
2043
|
+
--md-icon-size: 15px;
|
|
2044
|
+
color: inherit;
|
|
2045
|
+
}
|
|
2046
|
+
.workspace-review:hover {
|
|
2047
|
+
background: color-mix(in srgb, var(--md-sys-color-primary, #0f766e) 8%, transparent);
|
|
2048
|
+
border-color: color-mix(in srgb, var(--md-sys-color-primary, #0f766e) 24%, transparent);
|
|
2049
|
+
}
|
|
2050
|
+
.workspace-review:active {
|
|
2051
|
+
background: color-mix(in srgb, var(--md-sys-color-primary, #0f766e) 14%, transparent);
|
|
2052
|
+
}
|
|
2053
|
+
.workspace-review:focus-visible {
|
|
2054
|
+
outline: 2px solid var(--md-sys-color-primary, #0f766e);
|
|
2055
|
+
outline-offset: 1px;
|
|
2056
|
+
}
|
|
2057
|
+
|
|
1967
2058
|
/* ── 접지 경고 — 근거에 없는 대상을 지목한 답 ───── */
|
|
1968
2059
|
.grounding-warning {
|
|
1969
2060
|
display: flex;
|
|
@@ -3250,6 +3341,11 @@ export class OxAssistantChat extends LitElement {
|
|
|
3250
3341
|
}
|
|
3251
3342
|
|
|
3252
3343
|
private onKeyDown(e: KeyboardEvent) {
|
|
3344
|
+
/*
|
|
3345
|
+
The Enter that ends a Korean (or Japanese, Chinese) syllable belongs to the input method - sending on it
|
|
3346
|
+
sends the line without its last syllable, which then lands in the emptied box.
|
|
3347
|
+
*/
|
|
3348
|
+
if (e.isComposing || e.keyCode === 229) return
|
|
3253
3349
|
// Mention popup 활성 시 — 화살표/Enter/Tab/Esc 가로채서 popup 네비게이션
|
|
3254
3350
|
if (this.mentionOpen) {
|
|
3255
3351
|
if (e.key === 'ArrowDown') {
|
|
@@ -4021,6 +4117,16 @@ export class OxAssistantChat extends LitElement {
|
|
|
4021
4117
|
` : nothing}
|
|
4022
4118
|
</div>
|
|
4023
4119
|
${entry ? html`<ox-assistant-entry-review .proposal=${p}></ox-assistant-entry-review>` : nothing}
|
|
4120
|
+
${entry && this.sessionId && offersWorkspaceReview(p)
|
|
4121
|
+
? html`<button
|
|
4122
|
+
class="workspace-review"
|
|
4123
|
+
@click=${() =>
|
|
4124
|
+
this.dispatchEvent(new CustomEvent('assistant-open-page', { detail: { href: entryReviewAddress(this.sessionId!, key) }, bubbles: true, composed: true }))}
|
|
4125
|
+
>
|
|
4126
|
+
<md-icon>open_in_full</md-icon>
|
|
4127
|
+
<span>${i18next.t('ai-assistant.entry.review-in-workspace', { defaultValue: 'review in the workspace' })}</span>
|
|
4128
|
+
</button>`
|
|
4129
|
+
: nothing}
|
|
4024
4130
|
${rawChoices.length ? html`
|
|
4025
4131
|
<div class="proposal-choices" role="group" aria-label=${i18next.t('ai-assistant.text.proposal-choices', { defaultValue: 'follow-up choices' })}>
|
|
4026
4132
|
${rawChoices.map((c: any) => html`
|
|
@@ -42,14 +42,14 @@ const t = (key: string, defaultValue: string, options: Record<string, unknown> =
|
|
|
42
42
|
/* Pressing a data entry card asks the server to enter the rows it stored - the screen sends none (design/ai/data-entry.md §4). */
|
|
43
43
|
setEntryProposalHandler(
|
|
44
44
|
entryProposalHandler(
|
|
45
|
-
async (sessionId, proposalKey) => {
|
|
45
|
+
async (sessionId, proposalKey, reviewedHash) => {
|
|
46
46
|
const result = await client.mutate({
|
|
47
47
|
mutation: gql`
|
|
48
|
-
mutation ($id: String!, $key: String
|
|
49
|
-
applyEntryProposal(sessionId: $id, proposalKey: $key)
|
|
48
|
+
mutation ($id: String!, $key: String!, $reviewed: String) {
|
|
49
|
+
applyEntryProposal(sessionId: $id, proposalKey: $key, reviewedHash: $reviewed)
|
|
50
50
|
}
|
|
51
51
|
`,
|
|
52
|
-
variables: { id: sessionId, key: proposalKey }
|
|
52
|
+
variables: { id: sessionId, key: proposalKey, reviewed: reviewedHash ?? null }
|
|
53
53
|
})
|
|
54
54
|
if (result.errors?.length) throw new Error(result.errors[0].message)
|
|
55
55
|
return result.data?.applyEntryProposal as EntryApplyAnswer
|
|
@@ -54,7 +54,7 @@ export interface AssistantModeView {
|
|
|
54
54
|
footerNotice?: string
|
|
55
55
|
stagedNotice?: string
|
|
56
56
|
/** What this conversation accepts as attachments; absent - none (design/ai/shared-dock.md §9-1). */
|
|
57
|
-
attachments?: { kinds: ('image' | 'text')[]; maxBytes?: number; maxFiles?: number }
|
|
57
|
+
attachments?: { kinds: ('image' | 'text' | 'spreadsheet')[]; maxBytes?: number; maxFiles?: number; maxChars?: number }
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
/** True when these two describe different conversations, so the old one should be closed. */
|
|
@@ -178,6 +178,24 @@ export class AssistantEntryReview extends LitElement {
|
|
|
178
178
|
color: var(--md-sys-color-primary);
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
+
/* In the AI 작업판: every row, the page's height, the reading size of a page (design/ai/workspace.md §6-1). */
|
|
182
|
+
:host([wide]) {
|
|
183
|
+
font-size: 13px;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
:host([wide]) .scroll {
|
|
187
|
+
max-height: calc(100vh - 260px);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
:host([wide]) th,
|
|
191
|
+
:host([wide]) td {
|
|
192
|
+
padding: 6px 10px;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
:host([wide]) .source {
|
|
196
|
+
max-width: 320px;
|
|
197
|
+
}
|
|
198
|
+
|
|
181
199
|
.more,
|
|
182
200
|
.check-failed {
|
|
183
201
|
margin-top: 4px;
|
|
@@ -187,6 +205,8 @@ export class AssistantEntryReview extends LitElement {
|
|
|
187
205
|
]
|
|
188
206
|
|
|
189
207
|
@property({ attribute: false }) proposal: any
|
|
208
|
+
/** The 작업판's review - every row is drawn, not the first fifty. */
|
|
209
|
+
@property({ type: Boolean, reflect: true }) wide = false
|
|
190
210
|
|
|
191
211
|
/** The sum of each totalled field, beside the total the material prints - a missing row shows here without reading every row. */
|
|
192
212
|
private renderTotals(totals: EntryTotalView[], columns: { name: string; labelKey?: string }[]) {
|
|
@@ -236,7 +256,7 @@ export class AssistantEntryReview extends LitElement {
|
|
|
236
256
|
</tr>
|
|
237
257
|
</thead>
|
|
238
258
|
<tbody>
|
|
239
|
-
${rows.slice(0, SHOWN).map(row => {
|
|
259
|
+
${rows.slice(0, this.wide ? rows.length : SHOWN).map(row => {
|
|
240
260
|
const blocked = (row.issues ?? []).some((issue: EntryIssueView) => issue.blocking)
|
|
241
261
|
const whole = entryCellIssues(row)
|
|
242
262
|
return html`<tr class=${blocked ? 'blocked' : ''}>
|
|
@@ -286,7 +306,7 @@ export class AssistantEntryReview extends LitElement {
|
|
|
286
306
|
</tr>
|
|
287
307
|
</thead>
|
|
288
308
|
<tbody>
|
|
289
|
-
${rows.slice(0, SHOWN).map(row => {
|
|
309
|
+
${rows.slice(0, this.wide ? rows.length : SHOWN).map(row => {
|
|
290
310
|
const blocked = (row.issues ?? []).some(issue => issue.blocking)
|
|
291
311
|
const whole = entryCellIssues(row)
|
|
292
312
|
return html`
|
|
@@ -319,7 +339,7 @@ export class AssistantEntryReview extends LitElement {
|
|
|
319
339
|
</table>
|
|
320
340
|
</div>
|
|
321
341
|
${this.renderTotals(p.totals ?? [], columns)}
|
|
322
|
-
${rows.length > SHOWN ? html`<div class="more">${t('more-rows', '{count} more rows are not shown - they are entered too.', { count: rows.length - SHOWN })}</div>` : nothing}
|
|
342
|
+
${!this.wide && rows.length > SHOWN ? html`<div class="more">${t('more-rows', '{count} more rows are not shown - they are entered too.', { count: rows.length - SHOWN })}</div>` : nothing}
|
|
323
343
|
${p.checkFailed ? html`<div class="check-failed">${t('check-failed', 'the application could not check these rows; they are checked again when entered.')}</div>` : nothing}
|
|
324
344
|
`
|
|
325
345
|
}
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
/*
|
|
2
2
|
* The AI 작업판 page (operato-application/design/ai/workspace.md).
|
|
3
3
|
*
|
|
4
|
-
* `assistant-workspace?session=<id>&name=<name>[&version=<n>]
|
|
4
|
+
* `assistant-workspace?session=<id>&name=<name>[&version=<n>]`, or `?session=<id>&entry=<proposal key>` to review a
|
|
5
|
+
* 입력안 wide (§6-1) - read only; the card in the dock is where it is entered. It reads the conversation through its own
|
|
5
6
|
* door (`chatMessages`), so a private conversation shows nothing to someone outside it and a version behind a
|
|
6
7
|
* door the reader cannot pass is not among the versions (ADR-0073). A new version arriving while the page is
|
|
7
8
|
* open is announced, never swapped in under the reader.
|
|
8
9
|
*/
|
|
9
10
|
import '@material/web/icon/icon.js'
|
|
10
11
|
import '../components/workspace-view.js'
|
|
12
|
+
import '../components/entry-review.js'
|
|
11
13
|
|
|
12
14
|
import gql from 'graphql-tag'
|
|
13
15
|
import { css, html, nothing } from 'lit'
|
|
@@ -20,6 +22,8 @@ import { ScrollbarStyles } from '@operato/styles'
|
|
|
20
22
|
|
|
21
23
|
import { startSessionActivityFeed, subscribeSessionActivity } from '../utils/assistant-activity.js'
|
|
22
24
|
import { workspaceAddress, workspaceVersions, type WorkspaceVersion } from '../utils/workspace.js'
|
|
25
|
+
import { entryForReview, rememberReviewed } from '../utils/entry-workspace.js'
|
|
26
|
+
import { entryTitle } from '../utils/entry-proposal.js'
|
|
23
27
|
|
|
24
28
|
const MESSAGES = gql`
|
|
25
29
|
query ($sessionId: String!, $limit: Int, $offset: Int) {
|
|
@@ -55,6 +59,11 @@ export class AssistantWorkspacePage extends localize(i18next)(PageView) {
|
|
|
55
59
|
margin: 0 auto;
|
|
56
60
|
}
|
|
57
61
|
|
|
62
|
+
/* A 입력안 is as wide as its columns - the reason it left the dock. */
|
|
63
|
+
.page.entry {
|
|
64
|
+
max-width: none;
|
|
65
|
+
}
|
|
66
|
+
|
|
58
67
|
header {
|
|
59
68
|
display: flex;
|
|
60
69
|
align-items: flex-start;
|
|
@@ -124,10 +133,14 @@ export class AssistantWorkspacePage extends localize(i18next)(PageView) {
|
|
|
124
133
|
@state() private failure = ''
|
|
125
134
|
/** A version that arrived after the page was drawn - announced, not shown until asked. */
|
|
126
135
|
@state() private newer = 0
|
|
136
|
+
/** Reviewing a 입력안: its key, what was found, and whether a newer card replaced it. */
|
|
137
|
+
@state() private entryKey = ''
|
|
138
|
+
@state() private entry?: { proposal?: any; createdAt?: string; superseded: boolean }
|
|
127
139
|
|
|
128
140
|
private unsubscribe?: () => void
|
|
129
141
|
|
|
130
142
|
get context() {
|
|
143
|
+
if (this.entryKey) return { title: this.entry?.proposal ? entryTitle(this.entry.proposal, (key, options) => String(i18next.t(key, options))) : t('title', 'AI workspace') }
|
|
131
144
|
const shown = this.shown
|
|
132
145
|
return { title: shown?.workspace.title || t('title', 'AI workspace') }
|
|
133
146
|
}
|
|
@@ -141,7 +154,7 @@ export class AssistantWorkspacePage extends localize(i18next)(PageView) {
|
|
|
141
154
|
super.connectedCallback()
|
|
142
155
|
startSessionActivityFeed()
|
|
143
156
|
this.unsubscribe = subscribeSessionActivity(activity => {
|
|
144
|
-
if (activity.sessionId === this.sessionId && activity.kind === 'message') void this.load(true)
|
|
157
|
+
if (activity.sessionId === this.sessionId && activity.kind === 'message') void (this.entryKey ? this.loadEntry() : this.load(true))
|
|
145
158
|
})
|
|
146
159
|
}
|
|
147
160
|
|
|
@@ -155,6 +168,16 @@ export class AssistantWorkspacePage extends localize(i18next)(PageView) {
|
|
|
155
168
|
const sessionId = String(params.session || '')
|
|
156
169
|
const name = String(params.name || '')
|
|
157
170
|
const wanted = Number(params.version) || 0
|
|
171
|
+
const entryKey = String(params.entry || '')
|
|
172
|
+
if (entryKey) {
|
|
173
|
+
const changedEntry = sessionId !== this.sessionId || entryKey !== this.entryKey
|
|
174
|
+
this.sessionId = sessionId
|
|
175
|
+
this.entryKey = entryKey
|
|
176
|
+
this.name = ''
|
|
177
|
+
if (changedEntry) await this.loadEntry()
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
this.entryKey = ''
|
|
158
181
|
const changed = sessionId !== this.sessionId || name !== this.name
|
|
159
182
|
this.sessionId = sessionId
|
|
160
183
|
this.name = name
|
|
@@ -189,12 +212,69 @@ export class AssistantWorkspacePage extends localize(i18next)(PageView) {
|
|
|
189
212
|
}
|
|
190
213
|
}
|
|
191
214
|
|
|
215
|
+
private async readMessages(): Promise<any[]> {
|
|
216
|
+
const messages: any[] = []
|
|
217
|
+
for (let offset = 0; offset < MOST; offset += PAGE) {
|
|
218
|
+
const result = await client.query({ query: MESSAGES, variables: { sessionId: this.sessionId, limit: PAGE, offset }, fetchPolicy: 'no-cache' })
|
|
219
|
+
if (result.errors?.length) throw new Error(result.errors[0].message)
|
|
220
|
+
const page = result.data?.chatMessages ?? []
|
|
221
|
+
messages.push(...page)
|
|
222
|
+
if (page.length < PAGE) break
|
|
223
|
+
}
|
|
224
|
+
return messages
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** The 입력안 under review - remembered as shown, so the card enters only this one (§6-1). */
|
|
228
|
+
private async loadEntry() {
|
|
229
|
+
if (!this.sessionId || !this.entryKey) return
|
|
230
|
+
this.loading = !this.entry
|
|
231
|
+
this.failure = ''
|
|
232
|
+
try {
|
|
233
|
+
const found = entryForReview(await this.readMessages(), this.entryKey)
|
|
234
|
+
this.entry = found
|
|
235
|
+
if (found.proposal && !found.superseded) rememberReviewed(found.proposal)
|
|
236
|
+
} catch (error: any) {
|
|
237
|
+
this.failure = error?.message || String(error)
|
|
238
|
+
} finally {
|
|
239
|
+
this.loading = false
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private renderEntry() {
|
|
244
|
+
const found = this.entry
|
|
245
|
+
if (this.loading && !found) return html`<div class="empty">${t('loading', 'loading the workspace…')}</div>`
|
|
246
|
+
if (this.failure) return html`<div class="empty">${t('unreadable-page', 'could not read this workspace: {reason}', { reason: this.failure })}</div>`
|
|
247
|
+
if (!found?.proposal) return html`<div class="empty">${t('entry-not-found', 'there is no proposal here that you can see.')}</div>`
|
|
248
|
+
const p = found.proposal
|
|
249
|
+
return html`<div class="page entry">
|
|
250
|
+
${found.superseded
|
|
251
|
+
? html`<div class="notice" role="status">
|
|
252
|
+
<md-icon>update</md-icon>
|
|
253
|
+
<span>${t('entry-superseded', 'a newer card replaced this one in the conversation - review that one.')}</span>
|
|
254
|
+
</div>`
|
|
255
|
+
: html`<div class="notice" role="note">
|
|
256
|
+
<md-icon>touch_app</md-icon>
|
|
257
|
+
<span>${t('entry-press-in-dock', 'this is a review. Enter the rows with the button on the card in the AI dock.')}</span>
|
|
258
|
+
</div>`}
|
|
259
|
+
<header>
|
|
260
|
+
<div class="what">
|
|
261
|
+
<h1>${entryTitle(p, (key, options) => String(i18next.t(key, options)))}</h1>
|
|
262
|
+
${p.reason || found.createdAt
|
|
263
|
+
? html`<div class="summary">${[p.reason, found.createdAt ? new Date(found.createdAt).toLocaleString(i18next.language || undefined) : ''].filter(Boolean).join(' · ')}</div>`
|
|
264
|
+
: nothing}
|
|
265
|
+
</div>
|
|
266
|
+
</header>
|
|
267
|
+
<ox-assistant-entry-review wide .proposal=${p}></ox-assistant-entry-review>
|
|
268
|
+
</div>`
|
|
269
|
+
}
|
|
270
|
+
|
|
192
271
|
private go(version?: number) {
|
|
193
272
|
this.newer = 0
|
|
194
273
|
navigate(workspaceAddress(this.sessionId, this.name, version))
|
|
195
274
|
}
|
|
196
275
|
|
|
197
276
|
render() {
|
|
277
|
+
if (this.entryKey) return this.renderEntry()
|
|
198
278
|
const shown = this.shown
|
|
199
279
|
if (this.loading && !shown) return html`<div class="empty">${t('loading', 'loading the workspace…')}</div>`
|
|
200
280
|
if (this.failure) return html`<div class="empty">${t('unreadable-page', 'could not read this workspace: {reason}', { reason: this.failure })}</div>`
|
|
@@ -6,16 +6,24 @@
|
|
|
6
6
|
* the authority. `test/attachment-policy-agree.test.ts` runs both copies over one table so they cannot drift.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
/*
|
|
10
|
+
* 'spreadsheet' is a kind a mode opens, never a file the door receives: the screen reads each .xlsx sheet into CSV
|
|
11
|
+
* text before sending (client/utils/spreadsheet-text.ts), so a mode that takes spreadsheets takes that text.
|
|
12
|
+
*/
|
|
13
|
+
export type AttachmentKind = 'image' | 'text' | 'spreadsheet'
|
|
10
14
|
|
|
11
15
|
export interface AttachmentPolicy {
|
|
12
16
|
kinds: AttachmentKind[]
|
|
13
17
|
maxBytes?: number
|
|
14
18
|
maxFiles?: number
|
|
19
|
+
/** Per text file, the characters the model reads - a sheet read into text longer than this is refused, not cut. */
|
|
20
|
+
maxChars?: number
|
|
15
21
|
}
|
|
16
22
|
|
|
17
23
|
export const DEFAULT_MAX_BYTES = 5 * 1024 * 1024
|
|
18
24
|
export const DEFAULT_MAX_FILES = 5
|
|
25
|
+
/** How much of a text file reaches the model - the server's TEXT_CHARS_FOR_MODEL. */
|
|
26
|
+
export const TEXT_CHARS_FOR_MODEL = 20_000
|
|
19
27
|
|
|
20
28
|
const TEXT_TYPES = ['text/plain', 'text/csv', 'text/markdown', 'application/json']
|
|
21
29
|
const TEXT_EXTENSIONS = ['.txt', '.csv', '.md', '.json']
|
|
@@ -35,6 +43,16 @@ export type AttachmentRefusal =
|
|
|
35
43
|
| { code: 'attachment-too-large'; name: string; maxBytes: number }
|
|
36
44
|
| { code: 'too-many-attachments'; maxFiles: number }
|
|
37
45
|
|
|
46
|
+
/** A kind the policy takes - text also when it takes spreadsheets, which arrive as the text of their sheets. */
|
|
47
|
+
function takes(policy: AttachmentPolicy, kind: AttachmentKind): boolean {
|
|
48
|
+
return policy.kinds.includes(kind) || (kind === 'text' && policy.kinds.includes('spreadsheet'))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The characters of a text file the model reads under this policy. */
|
|
52
|
+
export function textCharsOf(policy: AttachmentPolicy | null | undefined): number {
|
|
53
|
+
return policy?.maxChars ?? TEXT_CHARS_FOR_MODEL
|
|
54
|
+
}
|
|
55
|
+
|
|
38
56
|
export function refuseAttachments(
|
|
39
57
|
policy: AttachmentPolicy | null | undefined,
|
|
40
58
|
files: { name?: string; type?: string; size?: number }[]
|
|
@@ -48,7 +66,7 @@ export function refuseAttachments(
|
|
|
48
66
|
const maxBytes = policy.maxBytes ?? DEFAULT_MAX_BYTES
|
|
49
67
|
for (const file of files) {
|
|
50
68
|
const kind = attachmentKindOf(file)
|
|
51
|
-
if (!kind || !policy
|
|
69
|
+
if (!kind || !takes(policy, kind)) {
|
|
52
70
|
return { code: 'attachment-kind-not-accepted', name: file.name || '', kinds: policy.kinds }
|
|
53
71
|
}
|
|
54
72
|
if ((file.size ?? 0) > maxBytes) return { code: 'attachment-too-large', name: file.name || '', maxBytes }
|
|
@@ -60,7 +78,8 @@ export function refuseAttachments(
|
|
|
60
78
|
export function acceptOf(policy: AttachmentPolicy | null | undefined): string {
|
|
61
79
|
const accept: string[] = []
|
|
62
80
|
if (policy?.kinds?.includes('image')) accept.push('image/*')
|
|
63
|
-
if (policy?.kinds?.includes('text')) accept.push(...TEXT_EXTENSIONS, ...TEXT_TYPES)
|
|
81
|
+
if (policy?.kinds?.includes('text') || policy?.kinds?.includes('spreadsheet')) accept.push(...TEXT_EXTENSIONS, ...TEXT_TYPES)
|
|
82
|
+
if (policy?.kinds?.includes('spreadsheet')) accept.push('.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
|
|
64
83
|
return accept.join(',')
|
|
65
84
|
}
|
|
66
85
|
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* One 입력안's fingerprint - what the AI 작업판 showed and what the card enters are the same proposal
|
|
3
|
+
* (operato-application/design/ai/workspace.md §6-1). The screen's copy and the server's are one rule;
|
|
4
|
+
* `test/entry-hash-agree.test.ts` runs both over one table.
|
|
5
|
+
*
|
|
6
|
+
* It covers what is entered: the key, the target, create or change, and each row's index, record and values.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const canonical = (value: unknown): string => {
|
|
10
|
+
if (value === null || value === undefined) return 'null'
|
|
11
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`
|
|
12
|
+
if (typeof value === 'object')
|
|
13
|
+
return `{${Object.keys(value as object)
|
|
14
|
+
.filter(name => (value as any)[name] !== undefined)
|
|
15
|
+
.sort()
|
|
16
|
+
.map(name => `${JSON.stringify(name)}:${canonical((value as any)[name])}`)
|
|
17
|
+
.join(',')}}`
|
|
18
|
+
return JSON.stringify(value)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** FNV-1a over the canonical text - a fingerprint to compare, not a secret. */
|
|
22
|
+
export function entryProposalHash(proposal: any): string {
|
|
23
|
+
const text = canonical({
|
|
24
|
+
key: proposal?.key,
|
|
25
|
+
target: proposal?.target,
|
|
26
|
+
op: proposal?.op ?? 'create',
|
|
27
|
+
rows: (proposal?.rows ?? []).map((row: any) => ({ index: row?.index, id: row?.id, values: row?.values ?? {} }))
|
|
28
|
+
})
|
|
29
|
+
let hash = 0x811c9dc5
|
|
30
|
+
for (let i = 0; i < text.length; i++) {
|
|
31
|
+
hash ^= text.charCodeAt(i)
|
|
32
|
+
hash = Math.imul(hash, 0x01000193) >>> 0
|
|
33
|
+
}
|
|
34
|
+
return `${hash.toString(16).padStart(8, '0')}-${text.length.toString(16)}`
|
|
35
|
+
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* screen sends no rows of its own.
|
|
7
7
|
*/
|
|
8
8
|
import type { ProposalContext, ProposalOutcome } from './assistant-proposals.js'
|
|
9
|
+
import { reviewedHashOf, reviewStillHolds } from './entry-workspace.js'
|
|
9
10
|
|
|
10
11
|
export interface EntryIssueView {
|
|
11
12
|
field?: string
|
|
@@ -93,12 +94,18 @@ export function entryOutcomeNote(answer: EntryApplyAnswer, t: (key: string, opti
|
|
|
93
94
|
|
|
94
95
|
/** The handler every entry card runs - built with the mutation so the rules are tested without a server. */
|
|
95
96
|
export function entryProposalHandler(
|
|
96
|
-
apply: (sessionId: string, proposalKey: string) => Promise<EntryApplyAnswer>,
|
|
97
|
-
t: (key: string, options?: any) => string
|
|
97
|
+
apply: (sessionId: string, proposalKey: string, reviewedHash?: string) => Promise<EntryApplyAnswer>,
|
|
98
|
+
t: (key: string, options?: any) => string,
|
|
99
|
+
reviewedOf: (key: string) => string | undefined = reviewedHashOf
|
|
98
100
|
) {
|
|
99
101
|
return async (proposal: any, context: ProposalContext): Promise<ProposalOutcome> => {
|
|
100
102
|
if (!context.sessionId || !proposal?.key) return { verdict: 'unavailable', errorCode: 'no-conversation' }
|
|
101
|
-
|
|
103
|
+
/* Reviewed in the 작업판: entered only as it was shown there (design/ai/workspace.md §6-1) - here, and again at the server. */
|
|
104
|
+
const reviewed = reviewedOf(proposal.key)
|
|
105
|
+
if (!reviewStillHolds(proposal, reviewed)) return { verdict: 'refused', errorCode: 'review-stale', note: t('ai-assistant.entry.review-stale', { defaultValue: 'this proposal changed after it was reviewed in the workspace - review it again before entering.' }) } as any
|
|
106
|
+
const answer = { ...(await apply(context.sessionId, proposal.key, reviewed)), op: proposal.op }
|
|
107
|
+
if (answer.errorCode === 'review-stale')
|
|
108
|
+
return { verdict: 'refused', errorCode: 'review-stale', applied: 0, missed: 0, note: t('ai-assistant.entry.review-stale', { defaultValue: 'this proposal changed after it was reviewed in the workspace - review it again before entering.' }) }
|
|
102
109
|
return {
|
|
103
110
|
verdict: answer.verdict,
|
|
104
111
|
...(answer.errorCode ? { errorCode: answer.errorCode } : {}),
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Reviewing a 입력안 in the AI 작업판 (operato-application/design/ai/workspace.md §6-1). Pure but for the
|
|
3
|
+
* session store - tested without a browser.
|
|
4
|
+
*
|
|
5
|
+
* The page is read-only: every column and every row, sorted as stored, issues marked. The one place that enters
|
|
6
|
+
* is the dock's card. What the page showed is remembered by its fingerprint, and the card sends it - a proposal
|
|
7
|
+
* that changed in between is refused, and the person reviews it again.
|
|
8
|
+
*/
|
|
9
|
+
import { entryProposalHash } from './entry-hash.js'
|
|
10
|
+
|
|
11
|
+
/** A card wide or long enough not to be read in the dock. */
|
|
12
|
+
export const WORKSPACE_ROWS = 20
|
|
13
|
+
export const WORKSPACE_COLUMNS = 6
|
|
14
|
+
|
|
15
|
+
export function offersWorkspaceReview(proposal: any): boolean {
|
|
16
|
+
return (proposal?.rows?.length ?? 0) > WORKSPACE_ROWS || (proposal?.columns?.length ?? 0) > WORKSPACE_COLUMNS
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function entryReviewAddress(sessionId: string, proposalKey: string): string {
|
|
20
|
+
return `assistant-workspace?${new URLSearchParams({ session: sessionId, entry: proposalKey }).toString()}`
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The proposal of that key among the conversation's messages, and whether a newer card of the same record replaced it. */
|
|
24
|
+
export function entryForReview(messages: { id: string; createdAt?: string; proposalsJson?: any[] | null }[], key: string): { proposal?: any; createdAt?: string; superseded: boolean } {
|
|
25
|
+
let at = -1
|
|
26
|
+
let proposal: any
|
|
27
|
+
let createdAt: string | undefined
|
|
28
|
+
messages.forEach((message, i) => {
|
|
29
|
+
for (const one of message.proposalsJson ?? []) if (one?.kind === 'entry' && one?.key === key) (at = i), (proposal = one), (createdAt = message.createdAt)
|
|
30
|
+
})
|
|
31
|
+
if (!proposal) return { superseded: false }
|
|
32
|
+
const superseded = messages
|
|
33
|
+
.slice(at + 1)
|
|
34
|
+
.some(message => (message.proposalsJson ?? []).some(one => one?.kind === 'entry' && one?.target === proposal.target && (one?.op ?? 'create') === (proposal.op ?? 'create')))
|
|
35
|
+
return { proposal, createdAt, superseded }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const STORE = 'ai-assistant.entry-reviewed:'
|
|
39
|
+
|
|
40
|
+
/** Remembers what the page showed for this key. A browser that keeps nothing keeps nothing - the card then sends no fingerprint. */
|
|
41
|
+
export function rememberReviewed(proposal: any, store: Pick<Storage, 'setItem'> | undefined = safeSession()): string {
|
|
42
|
+
const hash = entryProposalHash(proposal)
|
|
43
|
+
try {
|
|
44
|
+
store?.setItem(STORE + proposal.key, hash)
|
|
45
|
+
} catch {
|
|
46
|
+
/* private window or blocked storage */
|
|
47
|
+
}
|
|
48
|
+
return hash
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function reviewedHashOf(key: string, store: Pick<Storage, 'getItem'> | undefined = safeSession()): string | undefined {
|
|
52
|
+
try {
|
|
53
|
+
return store?.getItem(STORE + key) ?? undefined
|
|
54
|
+
} catch {
|
|
55
|
+
return undefined
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Whether the card may be pressed: not reviewed, or reviewed as it stands now. */
|
|
60
|
+
export function reviewStillHolds(proposal: any, reviewed: string | undefined): boolean {
|
|
61
|
+
return !reviewed || reviewed === entryProposalHash(proposal)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function safeSession(): Storage | undefined {
|
|
65
|
+
try {
|
|
66
|
+
return typeof sessionStorage === 'undefined' ? undefined : sessionStorage
|
|
67
|
+
} catch {
|
|
68
|
+
return undefined
|
|
69
|
+
}
|
|
70
|
+
}
|