@things-factory/ai-assistant 10.1.87 → 10.1.90
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 +19 -5
- package/client/components/assistant-dock.ts +36 -5
- package/client/components/assistant-session-manager.ts +4 -2
- package/client/components/entry-review.ts +246 -0
- package/client/utils/assistant-proposals.ts +11 -1
- package/client/utils/entry-proposal.ts +103 -0
- package/dist-client/components/assistant-chat.d.ts +1 -0
- package/dist-client/components/assistant-chat.js +19 -5
- package/dist-client/components/assistant-chat.js.map +1 -1
- package/dist-client/components/assistant-dock.js +31 -5
- package/dist-client/components/assistant-dock.js.map +1 -1
- package/dist-client/components/assistant-session-manager.js +4 -2
- package/dist-client/components/assistant-session-manager.js.map +1 -1
- package/dist-client/components/entry-review.d.ts +9 -0
- package/dist-client/components/entry-review.js +247 -0
- package/dist-client/components/entry-review.js.map +1 -0
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-client/utils/assistant-proposals.d.ts +1 -0
- package/dist-client/utils/assistant-proposals.js +9 -1
- package/dist-client/utils/assistant-proposals.js.map +1 -1
- package/dist-client/utils/entry-proposal.d.ts +55 -0
- package/dist-client/utils/entry-proposal.js +52 -0
- package/dist-client/utils/entry-proposal.js.map +1 -0
- package/dist-server/service/assistant-mode/registry.js +13 -0
- package/dist-server/service/assistant-mode/registry.js.map +1 -1
- package/dist-server/service/assistant-mode/resolve.d.ts +11 -0
- package/dist-server/service/assistant-mode/resolve.js +13 -3
- package/dist-server/service/assistant-mode/resolve.js.map +1 -1
- package/dist-server/service/assistant-mode/types.d.ts +7 -1
- package/dist-server/service/assistant-mode/types.js.map +1 -1
- package/dist-server/service/data-entry/entry-apply.d.ts +46 -0
- package/dist-server/service/data-entry/entry-apply.js +102 -0
- package/dist-server/service/data-entry/entry-apply.js.map +1 -0
- package/dist-server/service/data-entry/entry-resolver.d.ts +4 -0
- package/dist-server/service/data-entry/entry-resolver.js +80 -0
- package/dist-server/service/data-entry/entry-resolver.js.map +1 -0
- package/dist-server/service/data-entry/entry-sources.d.ts +1 -0
- package/dist-server/service/data-entry/entry-sources.js +41 -0
- package/dist-server/service/data-entry/entry-sources.js.map +1 -0
- package/dist-server/service/data-entry/entry-target.d.ts +157 -0
- package/dist-server/service/data-entry/entry-target.js +343 -0
- package/dist-server/service/data-entry/entry-target.js.map +1 -0
- package/dist-server/service/data-entry/entry-tools.d.ts +43 -0
- package/dist-server/service/data-entry/entry-tools.js +185 -0
- package/dist-server/service/data-entry/entry-tools.js.map +1 -0
- package/dist-server/service/index.d.ts +4 -1
- package/dist-server/service/index.js +10 -2
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/server/service/assistant-mode/registry.ts +13 -0
- package/server/service/assistant-mode/resolve.ts +13 -3
- package/server/service/assistant-mode/types.ts +7 -1
- package/server/service/data-entry/data-entry.test.ts +311 -0
- package/server/service/data-entry/entry-apply.ts +134 -0
- package/server/service/data-entry/entry-resolver.ts +74 -0
- package/server/service/data-entry/entry-sources.ts +35 -0
- package/server/service/data-entry/entry-target.ts +442 -0
- package/server/service/data-entry/entry-tools.ts +174 -0
- package/server/service/index.ts +7 -1
- package/test/entry-proposal.test.ts +67 -0
- package/test/open-doors.test.ts +2 -1
- package/translations/en.json +36 -1
- package/translations/ja.json +36 -1
- package/translations/ko.json +36 -1
- package/translations/ms.json +36 -1
- package/translations/zh.json +36 -1
|
@@ -63,6 +63,8 @@ import { chatDefaults } from './chat-defaults.js'
|
|
|
63
63
|
import { isDifferentConversation, resolveConversation, type AssistantMode, type AssistantModeView, pickInitialMode } from './assistant-mode.js'
|
|
64
64
|
import { parseProposalOutcomeLine, parseProposalRequestLine, proposalCardState, type ParsedProposalOutcome } from './proposal-outcome-line.js'
|
|
65
65
|
import { availableTriggers, type TriggerChip } from './chat-triggers.js'
|
|
66
|
+
import './entry-review.js'
|
|
67
|
+
import { entryReadyCount, isEntryProposal, latestEntryKeys } from '../utils/entry-proposal.js'
|
|
66
68
|
|
|
67
69
|
interface ChatLine {
|
|
68
70
|
role: 'user' | 'assistant' | 'system'
|
|
@@ -3945,12 +3947,17 @@ export class OxAssistantChat extends LitElement {
|
|
|
3945
3947
|
/* The card's state is the conversation's rows - read once for every card of this line. */
|
|
3946
3948
|
const outcomes = this.proposalOutcomes
|
|
3947
3949
|
const requested = this.proposalRequests
|
|
3950
|
+
/* A data entry card replaced by a newer one for the same record cannot be entered (design/ai/data-entry.md §5). */
|
|
3951
|
+
const latestEntry = items.some(isEntryProposal) ? latestEntryKeys(this.lines.map(one => one.proposals ?? [])) : undefined
|
|
3948
3952
|
return html`
|
|
3949
3953
|
<div class="proposals">
|
|
3950
3954
|
${items.map(p => {
|
|
3951
3955
|
const key = this.proposalKey(p)
|
|
3952
3956
|
const card = proposalCardState(key, { outcomes, requested, sentHere: this.sentProposals.has(key) })
|
|
3953
|
-
const
|
|
3957
|
+
const entry = isEntryProposal(p)
|
|
3958
|
+
const replaced = entry && latestEntry?.get(p.target) !== key && card.state === 'ready'
|
|
3959
|
+
const ready = entry ? entryReadyCount(p) : 0
|
|
3960
|
+
const sent = card.state !== 'ready' || replaced || (entry && ready === 0)
|
|
3954
3961
|
/*
|
|
3955
3962
|
Follow-up chips are the host's words, never this component's. The fallback that used
|
|
3956
3963
|
to live here named a 3D modeller's operations in hardcoded Korean, and it reached the
|
|
@@ -3962,7 +3969,7 @@ export class OxAssistantChat extends LitElement {
|
|
|
3962
3969
|
<div class="proposal ${sent ? 'sent' : ''}">
|
|
3963
3970
|
<md-icon>${p.icon || (p.source ? 'view_in_ar' : 'bolt')}</md-icon>
|
|
3964
3971
|
<span class="pbody">
|
|
3965
|
-
<span class="plabel">${p.label || p.command}</span>
|
|
3972
|
+
<span class="plabel">${entry && p.labelKey ? i18next.t(p.labelKey) : p.label || p.command}</span>
|
|
3966
3973
|
${p.reason ? html`<span class="preason">${p.reason}</span>` : nothing}
|
|
3967
3974
|
</span>
|
|
3968
3975
|
${/*
|
|
@@ -3984,9 +3991,15 @@ export class OxAssistantChat extends LitElement {
|
|
|
3984
3991
|
class="run"
|
|
3985
3992
|
?disabled=${sent}
|
|
3986
3993
|
@click=${() => this.executeProposal(p, key)}>
|
|
3987
|
-
${
|
|
3988
|
-
?
|
|
3989
|
-
:
|
|
3994
|
+
${replaced
|
|
3995
|
+
? i18next.t('ai-assistant.entry.replaced', { defaultValue: 'replaced by a newer card' })
|
|
3996
|
+
: card.state !== 'ready'
|
|
3997
|
+
? this.proposalStateText(key)
|
|
3998
|
+
: entry
|
|
3999
|
+
? ready
|
|
4000
|
+
? i18next.t('ai-assistant.entry.enter', { defaultValue: 'enter {count} rows', count: ready })
|
|
4001
|
+
: i18next.t('ai-assistant.entry.nothing-to-enter', { defaultValue: 'nothing to enter' })
|
|
4002
|
+
: i18next.t('ai-assistant.button.run-proposal', { defaultValue: 'run' })}
|
|
3990
4003
|
</button>
|
|
3991
4004
|
`}
|
|
3992
4005
|
${/*
|
|
@@ -4002,6 +4015,7 @@ export class OxAssistantChat extends LitElement {
|
|
|
4002
4015
|
</span>
|
|
4003
4016
|
` : nothing}
|
|
4004
4017
|
</div>
|
|
4018
|
+
${entry ? html`<ox-assistant-entry-review .proposal=${p}></ox-assistant-entry-review>` : nothing}
|
|
4005
4019
|
${rawChoices.length ? html`
|
|
4006
4020
|
<div class="proposal-choices" role="group" aria-label=${i18next.t('ai-assistant.text.proposal-choices', { defaultValue: 'follow-up choices' })}>
|
|
4007
4021
|
${rawChoices.map((c: any) => html`
|
|
@@ -29,7 +29,8 @@ import {
|
|
|
29
29
|
type AnchorResult,
|
|
30
30
|
type AssistantSurface
|
|
31
31
|
} from '../utils/assistant-surface.js'
|
|
32
|
-
import { runProposal, type ProposalContext, type ProposalOutcome } from '../utils/assistant-proposals.js'
|
|
32
|
+
import { runProposal, setEntryProposalHandler, type ProposalContext, type ProposalOutcome } from '../utils/assistant-proposals.js'
|
|
33
|
+
import { entryProposalHandler, type EntryApplyAnswer } from '../utils/entry-proposal.js'
|
|
33
34
|
import { holdAssistantAsk, subscribeAssistantAsk, takeAssistantAsk, type AssistantAsk } from '../utils/assistant-ask.js'
|
|
34
35
|
import { surfaceTransition } from '../utils/assistant-navigation.js'
|
|
35
36
|
|
|
@@ -38,6 +39,25 @@ export const ASSISTANT_DOCK_VIEWPART = 'assistant-dock'
|
|
|
38
39
|
const t = (key: string, defaultValue: string, options: Record<string, unknown> = {}) =>
|
|
39
40
|
i18next.t(`ai-assistant.dock.${key}`, { defaultValue, ...options })
|
|
40
41
|
|
|
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
|
+
setEntryProposalHandler(
|
|
44
|
+
entryProposalHandler(
|
|
45
|
+
async (sessionId, proposalKey) => {
|
|
46
|
+
const result = await client.mutate({
|
|
47
|
+
mutation: gql`
|
|
48
|
+
mutation ($id: String!, $key: String!) {
|
|
49
|
+
applyEntryProposal(sessionId: $id, proposalKey: $key)
|
|
50
|
+
}
|
|
51
|
+
`,
|
|
52
|
+
variables: { id: sessionId, key: proposalKey }
|
|
53
|
+
})
|
|
54
|
+
if (result.errors?.length) throw new Error(result.errors[0].message)
|
|
55
|
+
return result.data?.applyEntryProposal as EntryApplyAnswer
|
|
56
|
+
},
|
|
57
|
+
(key, options) => String(i18next.t(key, options))
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
|
|
41
61
|
const MODES_QUERY = gql`
|
|
42
62
|
query ($subject: String!) {
|
|
43
63
|
assistantModes(subject: $subject) {
|
|
@@ -165,8 +185,19 @@ export class AssistantDock extends localize(i18next)(LitElement) {
|
|
|
165
185
|
* only in the list (2026-09-30: open and private were one grey word apart, and an owner's question about
|
|
166
186
|
* the books sat in every employee's list). A private one draws nothing extra: it is the default.
|
|
167
187
|
*/
|
|
188
|
+
/*
|
|
189
|
+
* One warning colour in every application (2026-09-30, product owner): the theme's tertiary was a pale
|
|
190
|
+
* lavender in figure and something else in each app, so "everyone reads this" was as loud as the theme
|
|
191
|
+
* happened to be. Amber with near-black ink reads on light and dark themes alike - ink on amber 8.2:1, the edge 3.3:1 on white and 5.1:1 on a dark surface.
|
|
192
|
+
*/
|
|
193
|
+
:host {
|
|
194
|
+
--assistant-open-edge: #c47f00;
|
|
195
|
+
--assistant-open-surface: #f2b01e;
|
|
196
|
+
--assistant-open-ink: #2e2100;
|
|
197
|
+
}
|
|
198
|
+
|
|
168
199
|
:host([audience='open']) {
|
|
169
|
-
border-left:
|
|
200
|
+
border-left: 4px solid var(--assistant-open-edge);
|
|
170
201
|
}
|
|
171
202
|
|
|
172
203
|
.audience {
|
|
@@ -175,9 +206,9 @@ export class AssistantDock extends localize(i18next)(LitElement) {
|
|
|
175
206
|
gap: 6px;
|
|
176
207
|
flex: none;
|
|
177
208
|
padding: 6px 10px;
|
|
178
|
-
background: var(--
|
|
179
|
-
color: var(--
|
|
180
|
-
border-bottom: 1px solid var(--
|
|
209
|
+
background: var(--assistant-open-surface);
|
|
210
|
+
color: var(--assistant-open-ink);
|
|
211
|
+
border-bottom: 1px solid var(--assistant-open-edge);
|
|
181
212
|
font-size: 12px;
|
|
182
213
|
font-weight: 600;
|
|
183
214
|
line-height: 1.35;
|
|
@@ -56,8 +56,10 @@ export class AssistantSessionManager extends LitElement {
|
|
|
56
56
|
/* Open and private must part at a glance - an open row is read by everyone in the domain (2026-09-30). */
|
|
57
57
|
.audience { display: inline-flex; align-items: center; gap: 3px; vertical-align: middle; }
|
|
58
58
|
.audience md-icon { --md-icon-size: 14px; }
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
/* The dock's warning colour, fixed across applications - see assistant-dock.ts. */
|
|
60
|
+
:host { --assistant-open-edge: #c47f00; --assistant-open-surface: #f2b01e; --assistant-open-ink: #2e2100; }
|
|
61
|
+
.audience.open { padding: 1px 6px 1px 4px; border-radius: 6px; background: var(--assistant-open-surface); color: var(--assistant-open-ink); font-weight: 600; }
|
|
62
|
+
.row.open-audience { box-shadow: inset 4px 0 0 var(--assistant-open-edge); }
|
|
61
63
|
.actions summary { cursor: pointer; padding: 8px; font-size: 18px; list-style: none; }
|
|
62
64
|
.actions[open] .acts { display: flex; flex-direction: column; position: absolute; right: 10px; top: 40px; z-index: 2; min-width: 180px; padding: 6px; background: var(--md-sys-color-surface-container-lowest); border: 1px solid var(--md-sys-color-outline-variant); border-radius: 8px; box-shadow: 0 4px 16px #0002; }
|
|
63
65
|
.actions button.icon { min-height: 40px; padding: 8px 12px; font: inherit; text-align: start; }
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The rows of a data entry proposal, as the person reviews them (operato-application/design/ai/data-entry.md §5).
|
|
3
|
+
*
|
|
4
|
+
* Read-only on purpose: a correction is said in the conversation ("row 2 is 12,000", "drop the last one")
|
|
5
|
+
* and the model proposes again. What is wrong in a cell is marked on the cell - blocking in the error
|
|
6
|
+
* colour, a note in the outline - and where each row came from is on the row.
|
|
7
|
+
*/
|
|
8
|
+
import '@material/web/icon/icon.js'
|
|
9
|
+
|
|
10
|
+
import { LitElement, css, html, nothing } from 'lit'
|
|
11
|
+
import { customElement, property } from 'lit/decorators.js'
|
|
12
|
+
|
|
13
|
+
import { i18next } from '@operato/i18n'
|
|
14
|
+
|
|
15
|
+
import { entryCellIssues, entryIssueKey, type EntryIssueView, type EntryRowView, type EntryTotalView } from '../utils/entry-proposal.js'
|
|
16
|
+
|
|
17
|
+
const SHOWN = 50
|
|
18
|
+
|
|
19
|
+
const t = (key: string, defaultValue: string, options: Record<string, unknown> = {}) =>
|
|
20
|
+
i18next.t(`ai-assistant.entry.${key}`, { defaultValue, ...options })
|
|
21
|
+
|
|
22
|
+
function issueText(issue: EntryIssueView): string {
|
|
23
|
+
const params = { ...(issue.params ?? {}) } as any
|
|
24
|
+
if (Array.isArray(params.values)) params.values = params.values.join(', ')
|
|
25
|
+
return String(i18next.t(entryIssueKey(issue), { defaultValue: issue.code, ...params }))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function shown(value: unknown): string {
|
|
29
|
+
if (value === undefined || value === null) return ''
|
|
30
|
+
if (typeof value === 'number') return value.toLocaleString(i18next.language || undefined)
|
|
31
|
+
if (typeof value === 'boolean') return value ? '✓' : '—'
|
|
32
|
+
return String(value)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
@customElement('ox-assistant-entry-review')
|
|
36
|
+
export class AssistantEntryReview extends LitElement {
|
|
37
|
+
static styles = css`
|
|
38
|
+
:host {
|
|
39
|
+
display: block;
|
|
40
|
+
margin: 4px 0 2px;
|
|
41
|
+
font-size: 12px;
|
|
42
|
+
color: var(--md-sys-color-on-surface);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.summary {
|
|
46
|
+
display: flex;
|
|
47
|
+
flex-wrap: wrap;
|
|
48
|
+
gap: 4px 10px;
|
|
49
|
+
margin-bottom: 6px;
|
|
50
|
+
color: var(--md-sys-color-on-surface-variant);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.summary .blocked {
|
|
54
|
+
color: var(--md-sys-color-error);
|
|
55
|
+
font-weight: 600;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.scroll {
|
|
59
|
+
max-height: 320px;
|
|
60
|
+
overflow: auto;
|
|
61
|
+
border: 1px solid var(--md-sys-color-outline-variant);
|
|
62
|
+
border-radius: 6px;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
table {
|
|
66
|
+
width: 100%;
|
|
67
|
+
border-collapse: collapse;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
th,
|
|
71
|
+
td {
|
|
72
|
+
padding: 4px 6px;
|
|
73
|
+
border-bottom: 1px solid var(--md-sys-color-outline-variant);
|
|
74
|
+
text-align: start;
|
|
75
|
+
vertical-align: top;
|
|
76
|
+
white-space: nowrap;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
th {
|
|
80
|
+
position: sticky;
|
|
81
|
+
top: 0;
|
|
82
|
+
background: var(--md-sys-color-surface-container);
|
|
83
|
+
font-weight: 600;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
td.num {
|
|
87
|
+
text-align: end;
|
|
88
|
+
font-variant-numeric: tabular-nums;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
tr.blocked td {
|
|
92
|
+
background: color-mix(in srgb, var(--md-sys-color-error) 6%, transparent);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
td.bad {
|
|
96
|
+
color: var(--md-sys-color-error);
|
|
97
|
+
text-decoration: underline wavy var(--md-sys-color-error);
|
|
98
|
+
text-underline-offset: 3px;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
td.note {
|
|
102
|
+
text-decoration: underline dotted var(--md-sys-color-outline);
|
|
103
|
+
text-underline-offset: 3px;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.missing {
|
|
107
|
+
color: var(--md-sys-color-error);
|
|
108
|
+
font-style: italic;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.source {
|
|
112
|
+
color: var(--md-sys-color-on-surface-variant);
|
|
113
|
+
max-width: 180px;
|
|
114
|
+
overflow: hidden;
|
|
115
|
+
text-overflow: ellipsis;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.row-issue {
|
|
119
|
+
display: block;
|
|
120
|
+
color: var(--md-sys-color-on-surface-variant);
|
|
121
|
+
white-space: normal;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.row-issue.blocking {
|
|
125
|
+
color: var(--md-sys-color-error);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.verified {
|
|
129
|
+
color: var(--md-sys-color-primary);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.verified md-icon,
|
|
133
|
+
.totals md-icon {
|
|
134
|
+
--md-icon-size: 14px;
|
|
135
|
+
vertical-align: -2px;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.totals {
|
|
139
|
+
display: flex;
|
|
140
|
+
flex-wrap: wrap;
|
|
141
|
+
gap: 2px 12px;
|
|
142
|
+
margin: 6px 0 0;
|
|
143
|
+
font-variant-numeric: tabular-nums;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.totals .differs {
|
|
147
|
+
color: var(--md-sys-color-error);
|
|
148
|
+
font-weight: 600;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.totals .matches {
|
|
152
|
+
color: var(--md-sys-color-primary);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.more,
|
|
156
|
+
.check-failed {
|
|
157
|
+
margin-top: 4px;
|
|
158
|
+
color: var(--md-sys-color-on-surface-variant);
|
|
159
|
+
}
|
|
160
|
+
`
|
|
161
|
+
|
|
162
|
+
@property({ attribute: false }) proposal: any
|
|
163
|
+
|
|
164
|
+
/** The sum of each totalled field, beside the total the material prints - a missing row shows here without reading every row. */
|
|
165
|
+
private renderTotals(totals: EntryTotalView[], columns: { name: string; labelKey?: string }[]) {
|
|
166
|
+
if (!totals.length) return nothing
|
|
167
|
+
const name = (field: string) => {
|
|
168
|
+
const column = columns.find(one => one.name === field)
|
|
169
|
+
return column?.labelKey ? i18next.t(column.labelKey) : field
|
|
170
|
+
}
|
|
171
|
+
return html`<div class="totals" role="status">
|
|
172
|
+
${totals.map(total => html`
|
|
173
|
+
<span>${t('total', '{field} total {sum}', { field: name(total.field), sum: shown(total.all) })}</span>
|
|
174
|
+
${total.ready !== total.all ? html`<span>${t('total-ready', 'entered {sum}', { sum: shown(total.ready) })}</span>` : nothing}
|
|
175
|
+
${total.declared !== undefined
|
|
176
|
+
? html`<span>${t('total-declared', 'the material says {sum}', { sum: shown(total.declared) })}${total.declaredFound === false ? ` (${t('total-not-found', 'not found in the file')})` : ''}</span>
|
|
177
|
+
${total.difference
|
|
178
|
+
? html`<span class="differs">${t('total-differs', 'differs by {sum}', { sum: shown(total.difference) })}</span>`
|
|
179
|
+
: html`<span class="matches"><md-icon>task_alt</md-icon> ${t('total-matches', 'matches')}</span>`}`
|
|
180
|
+
: nothing}
|
|
181
|
+
`)}
|
|
182
|
+
</div>`
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
render() {
|
|
186
|
+
const p = this.proposal ?? {}
|
|
187
|
+
const columns: { name: string; type: string; required?: boolean; labelKey?: string }[] = p.columns ?? []
|
|
188
|
+
const rows: EntryRowView[] = p.rows ?? []
|
|
189
|
+
const counts = p.counts ?? {}
|
|
190
|
+
return html`
|
|
191
|
+
<div class="summary" role="status">
|
|
192
|
+
<span>${t('rows', '{count} rows', { count: counts.rows ?? rows.length })}</span>
|
|
193
|
+
<span>${t('ready', '{count} ready', { count: counts.ready ?? 0 })}</span>
|
|
194
|
+
${counts.blocked ? html`<span class="blocked">${t('blocked', '{count} will not be entered', { count: counts.blocked })}</span>` : nothing}
|
|
195
|
+
${counts.cut ? html`<span class="blocked">${t('cut', '{count} left out over the limit', { count: counts.cut })}</span>` : nothing}
|
|
196
|
+
${counts.verified ? html`<span class="verified"><md-icon>task_alt</md-icon> ${t('verified', '{count} found in their file', { count: counts.verified })}</span>` : nothing}
|
|
197
|
+
${counts.unmatched ? html`<span class="blocked">${t('unmatched', '{count} with a value their file does not have', { count: counts.unmatched })}</span>` : nothing}
|
|
198
|
+
</div>
|
|
199
|
+
<div class="scroll">
|
|
200
|
+
<table>
|
|
201
|
+
<thead>
|
|
202
|
+
<tr>
|
|
203
|
+
<th>#</th>
|
|
204
|
+
${columns.map(column => html`<th>${column.labelKey ? i18next.t(column.labelKey) : column.name}${column.required ? ' *' : ''}</th>`)}
|
|
205
|
+
<th>${t('source', 'from')}</th>
|
|
206
|
+
</tr>
|
|
207
|
+
</thead>
|
|
208
|
+
<tbody>
|
|
209
|
+
${rows.slice(0, SHOWN).map(row => {
|
|
210
|
+
const blocked = (row.issues ?? []).some(issue => issue.blocking)
|
|
211
|
+
const whole = entryCellIssues(row)
|
|
212
|
+
return html`
|
|
213
|
+
<tr class=${blocked ? 'blocked' : ''}>
|
|
214
|
+
<td class="num">${row.index + 1}</td>
|
|
215
|
+
${columns.map(column => {
|
|
216
|
+
const issues = entryCellIssues(row, column.name)
|
|
217
|
+
const bad = issues.some(issue => issue.blocking)
|
|
218
|
+
const value = row.values?.[column.name]
|
|
219
|
+
const text = shown(value)
|
|
220
|
+
return html`<td
|
|
221
|
+
class="${column.type === 'number' || column.type === 'integer' ? 'num' : ''} ${bad ? 'bad' : issues.length ? 'note' : ''}"
|
|
222
|
+
title=${issues.map(issueText).join('\n')}
|
|
223
|
+
>
|
|
224
|
+
${text || (issues.some(issue => issue.code === 'required') ? html`<span class="missing">${t('missing', 'missing')}</span>` : '')}
|
|
225
|
+
</td>`
|
|
226
|
+
})}
|
|
227
|
+
<td class="source" title=${(row.sources ?? []).map(source => source.where || source.attachmentId).join(' · ')}>
|
|
228
|
+
${row.evidence ? html`<span class="verified" title=${t('evidence', 'found on line {line} of the file', { line: row.evidence.line })}><md-icon>task_alt</md-icon></span> ` : nothing}
|
|
229
|
+
${(row.sources ?? []).map(source => source.where || source.attachmentId).join(' · ')}
|
|
230
|
+
${whole
|
|
231
|
+
.filter(issue => issue.code !== 'no-source')
|
|
232
|
+
.map(issue => html`<span class="row-issue ${issue.blocking ? 'blocking' : ''}">${issueText(issue)}</span>`)}
|
|
233
|
+
${whole.some(issue => issue.code === 'no-source') ? html`<span class="row-issue">${issueText({ code: 'no-source', blocking: false })}</span>` : nothing}
|
|
234
|
+
</td>
|
|
235
|
+
</tr>
|
|
236
|
+
`
|
|
237
|
+
})}
|
|
238
|
+
</tbody>
|
|
239
|
+
</table>
|
|
240
|
+
</div>
|
|
241
|
+
${this.renderTotals(p.totals ?? [], columns)}
|
|
242
|
+
${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}
|
|
243
|
+
${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}
|
|
244
|
+
`
|
|
245
|
+
}
|
|
246
|
+
}
|
|
@@ -37,9 +37,19 @@ export function proposalHandlerOf(tool: string | undefined): ProposalHandler | u
|
|
|
37
37
|
return tool ? handlers.get(tool) : undefined
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
/*
|
|
41
|
+
* Data entry cards are the framework's, whatever record they enter (design/ai/data-entry.md §5): one
|
|
42
|
+
* handler asks the server to enter the stored rows. The dock sets it; no app registers one per record.
|
|
43
|
+
*/
|
|
44
|
+
let entryHandler: ProposalHandler | undefined
|
|
45
|
+
|
|
46
|
+
export function setEntryProposalHandler(handler: ProposalHandler): void {
|
|
47
|
+
entryHandler = handler
|
|
48
|
+
}
|
|
49
|
+
|
|
40
50
|
/** Runs the proposal - never throws; a failure is a `refused` outcome with its message as the code. */
|
|
41
51
|
export async function runProposal(proposal: any, context: ProposalContext): Promise<ProposalOutcome> {
|
|
42
|
-
const handler = proposalHandlerOf(proposal?.tool)
|
|
52
|
+
const handler = proposal?.kind === 'entry' && entryHandler ? entryHandler : proposalHandlerOf(proposal?.tool)
|
|
43
53
|
if (!handler) return { verdict: 'unavailable', errorCode: 'unknown-proposal-kind' }
|
|
44
54
|
try {
|
|
45
55
|
const outcome = await handler(proposal, context)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The data entry card's rules and its handler (operato-application/design/ai/data-entry.md §5).
|
|
3
|
+
*
|
|
4
|
+
* The card is a review, not an editor: a correction is said in the conversation and the model proposes
|
|
5
|
+
* again - the newer card replaces the older one. Pressing asks the server to enter the stored rows; the
|
|
6
|
+
* screen sends no rows of its own.
|
|
7
|
+
*/
|
|
8
|
+
import type { ProposalContext, ProposalOutcome } from './assistant-proposals.js'
|
|
9
|
+
|
|
10
|
+
export interface EntryIssueView {
|
|
11
|
+
field?: string
|
|
12
|
+
code: string
|
|
13
|
+
messageKey?: string
|
|
14
|
+
params?: Record<string, unknown>
|
|
15
|
+
blocking: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface EntryRowView {
|
|
19
|
+
index: number
|
|
20
|
+
values: Record<string, unknown>
|
|
21
|
+
sources: { attachmentId?: string; where?: string }[]
|
|
22
|
+
issues: EntryIssueView[]
|
|
23
|
+
/** The server found this row's numbers and dates together on this line of the cited file. */
|
|
24
|
+
evidence?: { attachmentId: string; line: number }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface EntryTotalView {
|
|
28
|
+
field: string
|
|
29
|
+
all: number
|
|
30
|
+
ready: number
|
|
31
|
+
declared?: number
|
|
32
|
+
declaredFound?: boolean
|
|
33
|
+
difference?: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface EntryRowResult {
|
|
37
|
+
index: number
|
|
38
|
+
ok: boolean
|
|
39
|
+
id?: string
|
|
40
|
+
label?: string
|
|
41
|
+
error?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface EntryApplyAnswer extends ProposalOutcome {
|
|
45
|
+
rows?: EntryRowResult[]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function isEntryProposal(proposal: any): boolean {
|
|
49
|
+
return proposal?.kind === 'entry' && Array.isArray(proposal?.rows)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function entryReadyCount(proposal: any): number {
|
|
53
|
+
return (proposal?.rows ?? []).filter((row: EntryRowView) => !row.issues?.some(issue => issue.blocking)).length
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The i18n key of an issue - the target's own, or the framework's by code. */
|
|
57
|
+
export function entryIssueKey(issue: EntryIssueView): string {
|
|
58
|
+
return issue.messageKey || `ai-assistant.entry.issue.${issue.code}`
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The issues of one cell, and of the row as a whole (no field). */
|
|
62
|
+
export function entryCellIssues(row: EntryRowView, field?: string): EntryIssueView[] {
|
|
63
|
+
return (row.issues ?? []).filter(issue => (field ? issue.field === field : !issue.field))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** What stays in the conversation - the model reads it on its next turn, and the person sees what went in. */
|
|
67
|
+
export function entryOutcomeNote(answer: EntryApplyAnswer, t: (key: string, options?: any) => string): string {
|
|
68
|
+
const refused = (answer.rows ?? []).filter(row => !row.ok)
|
|
69
|
+
const head = answer.applied
|
|
70
|
+
? t('ai-assistant.entry.entered', { defaultValue: 'entered {count} rows.', count: answer.applied })
|
|
71
|
+
: t('ai-assistant.entry.entered-none', { defaultValue: 'no row was entered.' })
|
|
72
|
+
const lines = refused.slice(0, 20).map(row => t('ai-assistant.entry.row-refused', { defaultValue: 'row {n}: {reason}', n: row.index + 1, reason: row.error || '' }))
|
|
73
|
+
const more = refused.length > 20 ? [t('ai-assistant.entry.more-refused', { defaultValue: '{count} more rows were not entered.', count: refused.length - 20 })] : []
|
|
74
|
+
return [head, ...lines, ...more].join('\n')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The handler every entry card runs - built with the mutation so the rules are tested without a server. */
|
|
78
|
+
export function entryProposalHandler(
|
|
79
|
+
apply: (sessionId: string, proposalKey: string) => Promise<EntryApplyAnswer>,
|
|
80
|
+
t: (key: string, options?: any) => string
|
|
81
|
+
) {
|
|
82
|
+
return async (proposal: any, context: ProposalContext): Promise<ProposalOutcome> => {
|
|
83
|
+
if (!context.sessionId || !proposal?.key) return { verdict: 'unavailable', errorCode: 'no-conversation' }
|
|
84
|
+
const answer = await apply(context.sessionId, proposal.key)
|
|
85
|
+
return {
|
|
86
|
+
verdict: answer.verdict,
|
|
87
|
+
...(answer.errorCode ? { errorCode: answer.errorCode } : {}),
|
|
88
|
+
applied: answer.applied ?? 0,
|
|
89
|
+
missed: answer.missed ?? 0,
|
|
90
|
+
note: entryOutcomeNote(answer, t)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The newest card per record, over the conversation's proposals in order - an older one is replaced and
|
|
97
|
+
* cannot be entered (the server refuses it as `superseded`; the card says so before anyone presses).
|
|
98
|
+
*/
|
|
99
|
+
export function latestEntryKeys(proposalsInOrder: any[][]): Map<string, string> {
|
|
100
|
+
const latest = new Map<string, string>()
|
|
101
|
+
for (const list of proposalsInOrder) for (const one of list ?? []) if (isEntryProposal(one) && one.key) latest.set(one.target, one.key)
|
|
102
|
+
return latest
|
|
103
|
+
}
|
|
@@ -20,6 +20,7 @@ import { LitElement } from 'lit';
|
|
|
20
20
|
import './mention-popup.js';
|
|
21
21
|
import { type FilterResult } from './mention-popup.js';
|
|
22
22
|
import { type AssistantMode } from './assistant-mode.js';
|
|
23
|
+
import './entry-review.js';
|
|
23
24
|
export interface ChatAttachment {
|
|
24
25
|
id: string;
|
|
25
26
|
file: File;
|
|
@@ -47,6 +47,8 @@ import { chatDefaults } from './chat-defaults.js';
|
|
|
47
47
|
import { isDifferentConversation, resolveConversation, pickInitialMode } from './assistant-mode.js';
|
|
48
48
|
import { parseProposalOutcomeLine, parseProposalRequestLine, proposalCardState } from './proposal-outcome-line.js';
|
|
49
49
|
import { availableTriggers } from './chat-triggers.js';
|
|
50
|
+
import './entry-review.js';
|
|
51
|
+
import { entryReadyCount, isEntryProposal, latestEntryKeys } from '../utils/entry-proposal.js';
|
|
50
52
|
function formatFileSize(bytes) {
|
|
51
53
|
if (!bytes || bytes < 1024)
|
|
52
54
|
return `${bytes || 0} B`;
|
|
@@ -3500,12 +3502,17 @@ let OxAssistantChat = class OxAssistantChat extends LitElement {
|
|
|
3500
3502
|
/* The card's state is the conversation's rows - read once for every card of this line. */
|
|
3501
3503
|
const outcomes = this.proposalOutcomes;
|
|
3502
3504
|
const requested = this.proposalRequests;
|
|
3505
|
+
/* A data entry card replaced by a newer one for the same record cannot be entered (design/ai/data-entry.md §5). */
|
|
3506
|
+
const latestEntry = items.some(isEntryProposal) ? latestEntryKeys(this.lines.map(one => one.proposals ?? [])) : undefined;
|
|
3503
3507
|
return html `
|
|
3504
3508
|
<div class="proposals">
|
|
3505
3509
|
${items.map(p => {
|
|
3506
3510
|
const key = this.proposalKey(p);
|
|
3507
3511
|
const card = proposalCardState(key, { outcomes, requested, sentHere: this.sentProposals.has(key) });
|
|
3508
|
-
const
|
|
3512
|
+
const entry = isEntryProposal(p);
|
|
3513
|
+
const replaced = entry && latestEntry?.get(p.target) !== key && card.state === 'ready';
|
|
3514
|
+
const ready = entry ? entryReadyCount(p) : 0;
|
|
3515
|
+
const sent = card.state !== 'ready' || replaced || (entry && ready === 0);
|
|
3509
3516
|
/*
|
|
3510
3517
|
Follow-up chips are the host's words, never this component's. The fallback that used
|
|
3511
3518
|
to live here named a 3D modeller's operations in hardcoded Korean, and it reached the
|
|
@@ -3516,7 +3523,7 @@ let OxAssistantChat = class OxAssistantChat extends LitElement {
|
|
|
3516
3523
|
<div class="proposal ${sent ? 'sent' : ''}">
|
|
3517
3524
|
<md-icon>${p.icon || (p.source ? 'view_in_ar' : 'bolt')}</md-icon>
|
|
3518
3525
|
<span class="pbody">
|
|
3519
|
-
<span class="plabel">${p.label || p.command}</span>
|
|
3526
|
+
<span class="plabel">${entry && p.labelKey ? i18next.t(p.labelKey) : p.label || p.command}</span>
|
|
3520
3527
|
${p.reason ? html `<span class="preason">${p.reason}</span>` : nothing}
|
|
3521
3528
|
</span>
|
|
3522
3529
|
${ /*
|
|
@@ -3538,9 +3545,15 @@ let OxAssistantChat = class OxAssistantChat extends LitElement {
|
|
|
3538
3545
|
class="run"
|
|
3539
3546
|
?disabled=${sent}
|
|
3540
3547
|
@click=${() => this.executeProposal(p, key)}>
|
|
3541
|
-
${
|
|
3542
|
-
?
|
|
3543
|
-
:
|
|
3548
|
+
${replaced
|
|
3549
|
+
? i18next.t('ai-assistant.entry.replaced', { defaultValue: 'replaced by a newer card' })
|
|
3550
|
+
: card.state !== 'ready'
|
|
3551
|
+
? this.proposalStateText(key)
|
|
3552
|
+
: entry
|
|
3553
|
+
? ready
|
|
3554
|
+
? i18next.t('ai-assistant.entry.enter', { defaultValue: 'enter {count} rows', count: ready })
|
|
3555
|
+
: i18next.t('ai-assistant.entry.nothing-to-enter', { defaultValue: 'nothing to enter' })
|
|
3556
|
+
: i18next.t('ai-assistant.button.run-proposal', { defaultValue: 'run' })}
|
|
3544
3557
|
</button>
|
|
3545
3558
|
`}
|
|
3546
3559
|
${ /*
|
|
@@ -3556,6 +3569,7 @@ let OxAssistantChat = class OxAssistantChat extends LitElement {
|
|
|
3556
3569
|
</span>
|
|
3557
3570
|
` : nothing}
|
|
3558
3571
|
</div>
|
|
3572
|
+
${entry ? html `<ox-assistant-entry-review .proposal=${p}></ox-assistant-entry-review>` : nothing}
|
|
3559
3573
|
${rawChoices.length ? html `
|
|
3560
3574
|
<div class="proposal-choices" role="group" aria-label=${i18next.t('ai-assistant.text.proposal-choices', { defaultValue: 'follow-up choices' })}>
|
|
3561
3575
|
${rawChoices.map((c) => html `
|