@things-factory/ai-assistant 10.1.58 → 10.1.81
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 +102 -13
- package/client/components/assistant-dock.ts +160 -18
- package/client/components/assistant-mode.test.ts +16 -2
- package/client/components/assistant-session-manager.ts +52 -25
- package/client/components/assistant-session-toolbar.ts +4 -2
- package/client/components/chat-echo-dedup.ts +4 -0
- package/client/components/proposal-outcome-line.ts +36 -0
- package/client/components/proposal-request.test.ts +52 -0
- package/client/utils/assistant-navigation.test.ts +38 -0
- package/client/utils/assistant-navigation.ts +6 -0
- package/client/utils/assistant-surface.ts +21 -0
- package/dist-client/components/assistant-chat.d.ts +15 -0
- package/dist-client/components/assistant-chat.js +106 -13
- package/dist-client/components/assistant-chat.js.map +1 -1
- package/dist-client/components/assistant-dock.d.ts +13 -0
- package/dist-client/components/assistant-dock.js +175 -20
- package/dist-client/components/assistant-dock.js.map +1 -1
- package/dist-client/components/assistant-mode.test.js +14 -2
- package/dist-client/components/assistant-mode.test.js.map +1 -1
- package/dist-client/components/assistant-session-manager.d.ts +4 -0
- package/dist-client/components/assistant-session-manager.js +68 -26
- package/dist-client/components/assistant-session-manager.js.map +1 -1
- package/dist-client/components/assistant-session-toolbar.d.ts +1 -0
- package/dist-client/components/assistant-session-toolbar.js +8 -2
- package/dist-client/components/assistant-session-toolbar.js.map +1 -1
- package/dist-client/components/chat-echo-dedup.d.ts +2 -0
- package/dist-client/components/chat-echo-dedup.js +2 -0
- package/dist-client/components/chat-echo-dedup.js.map +1 -1
- package/dist-client/components/proposal-outcome-line.d.ts +29 -0
- package/dist-client/components/proposal-outcome-line.js +15 -0
- package/dist-client/components/proposal-outcome-line.js.map +1 -1
- package/dist-client/components/proposal-request.test.d.ts +1 -0
- package/dist-client/components/proposal-request.test.js +45 -0
- package/dist-client/components/proposal-request.test.js.map +1 -0
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-client/utils/assistant-navigation.d.ts +6 -0
- package/dist-client/utils/assistant-navigation.js +9 -0
- package/dist-client/utils/assistant-navigation.js.map +1 -0
- package/dist-client/utils/assistant-navigation.test.d.ts +1 -0
- package/dist-client/utils/assistant-navigation.test.js +37 -0
- package/dist-client/utils/assistant-navigation.test.js.map +1 -0
- package/dist-client/utils/assistant-surface.d.ts +19 -0
- package/dist-client/utils/assistant-surface.js.map +1 -1
- package/dist-server/service/assistant-chat-resolver.js +1 -0
- package/dist-server/service/assistant-chat-resolver.js.map +1 -1
- package/dist-server/service/chat-message/chat-message.d.ts +10 -0
- package/dist-server/service/chat-message/chat-message.js +29 -0
- package/dist-server/service/chat-message/chat-message.js.map +1 -1
- package/dist-server/service/chat-message/proposal-outcome.d.ts +8 -0
- package/dist-server/service/chat-message/proposal-outcome.js +35 -1
- package/dist-server/service/chat-message/proposal-outcome.js.map +1 -1
- package/dist-server/service/chat-session/session-inbox.d.ts +7 -0
- package/dist-server/service/chat-session/session-inbox.js +7 -0
- package/dist-server/service/chat-session/session-inbox.js.map +1 -1
- package/dist-server/service/chat-session-resolver.d.ts +1 -0
- package/dist-server/service/chat-session-resolver.js +63 -9
- package/dist-server/service/chat-session-resolver.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +7 -7
- package/server/service/assistant-chat-resolver.ts +1 -0
- package/server/service/chat-message/chat-message.ts +32 -0
- package/server/service/chat-message/proposal-outcome.test.ts +53 -2
- package/server/service/chat-message/proposal-outcome.ts +33 -0
- package/server/service/chat-session/session-inbox.ts +7 -0
- package/server/service/chat-session-resolver.ts +71 -12
- package/test/assistant-dock.test.ts +8 -3
- package/test/assistant-session-toolbar.test.ts +7 -1
- package/test/open-doors.test.ts +2 -1
- package/test/session-discovery.test.ts +42 -0
- package/translations/en.json +26 -1
- package/translations/ja.json +26 -1
- package/translations/ko.json +26 -1
- package/translations/ms.json +26 -1
- package/translations/zh.json +26 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/ai-assistant",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.81",
|
|
4
4
|
"main": "dist-server/index.js",
|
|
5
5
|
"things-factory": true,
|
|
6
6
|
"author": "heartyoh",
|
|
@@ -33,12 +33,12 @@
|
|
|
33
33
|
"@operato/popup": "^10.16.0",
|
|
34
34
|
"@operato/shell": "^10.0.0",
|
|
35
35
|
"@operato/styles": "^10.0.0",
|
|
36
|
-
"@things-factory/ai-client-base": "^10.1.
|
|
37
|
-
"@things-factory/apptool-base": "^10.1.
|
|
38
|
-
"@things-factory/attachment-base": "^10.1.
|
|
39
|
-
"@things-factory/auth-base": "^10.1.
|
|
36
|
+
"@things-factory/ai-client-base": "^10.1.81",
|
|
37
|
+
"@things-factory/apptool-base": "^10.1.79",
|
|
38
|
+
"@things-factory/attachment-base": "^10.1.79",
|
|
39
|
+
"@things-factory/auth-base": "^10.1.79",
|
|
40
40
|
"@things-factory/env": "^10.1.20",
|
|
41
|
-
"@things-factory/shell": "^10.1.
|
|
41
|
+
"@things-factory/shell": "^10.1.79",
|
|
42
42
|
"dompurify": "^3.0.0",
|
|
43
43
|
"graphql-scalars": "^1.22.0",
|
|
44
44
|
"graphql-tag": "^2.12.6",
|
|
@@ -51,5 +51,5 @@
|
|
|
51
51
|
"copyfiles": "^2.4.1",
|
|
52
52
|
"rimraf": "^5.0.0"
|
|
53
53
|
},
|
|
54
|
-
"gitHead": "
|
|
54
|
+
"gitHead": "0c4f91d92c8214a9b2eca6690a7ed48bd2d828cc"
|
|
55
55
|
}
|
|
@@ -521,6 +521,7 @@ export class AssistantChatResolver {
|
|
|
521
521
|
*/
|
|
522
522
|
doors: answerDoors,
|
|
523
523
|
toolUsages: result.loop.toolUsages?.length ? JSON.stringify(result.loop.toolUsages) : undefined,
|
|
524
|
+
proposals: result.loop.proposals?.length ? JSON.stringify(result.loop.proposals) : undefined,
|
|
524
525
|
groundingWarnings: result.groundingWarnings?.length ? JSON.stringify(result.groundingWarnings) : undefined,
|
|
525
526
|
creator: user,
|
|
526
527
|
updater: user,
|
|
@@ -151,6 +151,38 @@ export class ChatMessage {
|
|
|
151
151
|
}
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
/**
|
|
155
|
+
* The proposals this answer made - JSON-stringified, like `toolUsages` (ADR-0097 Q-D4 ④).
|
|
156
|
+
*
|
|
157
|
+
* A card lived only in the mutation's answer, so a refresh took it away, and with it the one place the
|
|
158
|
+
* person could see that a proposal was requested and never answered. Stored, the card comes back with
|
|
159
|
+
* its state read from the conversation's rows. Cleared with the body when the row is redacted
|
|
160
|
+
* (`message-doors.ts` VALUE_CARRYING) - a proposal carries the same values as the answer.
|
|
161
|
+
*/
|
|
162
|
+
@Column({
|
|
163
|
+
type:
|
|
164
|
+
DATABASE_TYPE == 'mysql' || DATABASE_TYPE == 'mariadb'
|
|
165
|
+
? 'longtext'
|
|
166
|
+
: DATABASE_TYPE == 'oracle'
|
|
167
|
+
? 'clob'
|
|
168
|
+
: DATABASE_TYPE == 'mssql'
|
|
169
|
+
? 'nvarchar'
|
|
170
|
+
: 'text',
|
|
171
|
+
length: DATABASE_TYPE == 'mssql' ? 'MAX' : undefined,
|
|
172
|
+
nullable: true
|
|
173
|
+
})
|
|
174
|
+
proposals?: string
|
|
175
|
+
|
|
176
|
+
@Field(() => GraphQLJSON, { nullable: true, description: 'Proposals this answer made - the cards the panel draws. Only on assistant messages.' })
|
|
177
|
+
get proposalsJson(): any {
|
|
178
|
+
if (!this.proposals) return null
|
|
179
|
+
try {
|
|
180
|
+
return JSON.parse(this.proposals)
|
|
181
|
+
} catch {
|
|
182
|
+
return null
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
154
186
|
/**
|
|
155
187
|
* 접지 경고 — 이 답이 언급했으나 근거(프롬프트·보드 문맥·이력·도구 결과)에 없던 식별자 목록.
|
|
156
188
|
*
|
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
* root and move every emitted path (`dist-client/client/…`). The server build excludes tests, so
|
|
11
11
|
* reaching the other way costs nothing.
|
|
12
12
|
*/
|
|
13
|
-
import { formatProposalOutcomeLine, PROPOSAL_VERDICTS } from './proposal-outcome'
|
|
14
|
-
import { parseProposalOutcomeLine } from '../../../client/components/proposal-outcome-line'
|
|
13
|
+
import { formatProposalOutcomeLine, formatProposalRequestedLine, proposalAlreadyTaken, PROPOSAL_VERDICTS } from './proposal-outcome'
|
|
14
|
+
import { parseProposalOutcomeLine, parseProposalRequestLine, proposalCardState } from '../../../client/components/proposal-outcome-line'
|
|
15
15
|
|
|
16
16
|
const key = 'proposeSceneEdit|{"kind":"scene-edit"}'
|
|
17
17
|
|
|
@@ -46,3 +46,54 @@ it('a key containing colons and spaces still parses', () => {
|
|
|
46
46
|
|
|
47
47
|
expect(parseProposalOutcomeLine(line)?.key).toBe(awkward)
|
|
48
48
|
})
|
|
49
|
+
|
|
50
|
+
/*
|
|
51
|
+
* ADR-0097 Q-D4 ④ — a proposal is requested once, and a lost answer never becomes a second application.
|
|
52
|
+
*/
|
|
53
|
+
describe('requested', () => {
|
|
54
|
+
const uuid = '3f1c2a90-3b4d-4e5f-8a6b-7c8d9e0f1a2b'
|
|
55
|
+
|
|
56
|
+
it('★ the requested line round-trips, and neither line is read as the other', () => {
|
|
57
|
+
const requested = formatProposalRequestedLine(uuid)
|
|
58
|
+
expect(parseProposalRequestLine(requested)).toBe(uuid)
|
|
59
|
+
expect(parseProposalOutcomeLine(requested)).toBeUndefined()
|
|
60
|
+
expect(parseProposalRequestLine(formatProposalOutcomeLine(uuid, { verdict: 'accepted' }))).toBeUndefined()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('★ a proposal is taken once it is requested or answered — then the host is not asked again', () => {
|
|
64
|
+
expect(proposalAlreadyTaken(uuid, [])).toBe(false)
|
|
65
|
+
expect(proposalAlreadyTaken(uuid, ['User directly edited the board', null])).toBe(false)
|
|
66
|
+
expect(proposalAlreadyTaken(uuid, [formatProposalRequestedLine(uuid)])).toBe(true)
|
|
67
|
+
for (const verdict of PROPOSAL_VERDICTS) {
|
|
68
|
+
expect(proposalAlreadyTaken(uuid, [formatProposalOutcomeLine(uuid, { verdict, errorCode: 'x', applied: 1, missed: 1 })])).toBe(true)
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('another proposal is not taken by this one, even when one key begins with the other', () => {
|
|
73
|
+
expect(proposalAlreadyTaken('a', [formatProposalRequestedLine('a: b')])).toBe(false)
|
|
74
|
+
expect(proposalAlreadyTaken('a', [formatProposalOutcomeLine('a: b', { verdict: 'accepted' })])).toBe(false)
|
|
75
|
+
expect(proposalAlreadyTaken(uuid, [formatProposalRequestedLine('other')])).toBe(false)
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
describe('the card state', () => {
|
|
80
|
+
const none = { outcomes: new Map(), requested: new Set<string>(), sentHere: false }
|
|
81
|
+
|
|
82
|
+
it('nothing taken — the run button', () => {
|
|
83
|
+
expect(proposalCardState('k', none).state).toBe('ready')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('this page asked and no answer came — applying, however long it has been', () => {
|
|
87
|
+
expect(proposalCardState('k', { ...none, requested: new Set(['k']), sentHere: true }).state).toBe('applying')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('★ requested before a refresh or elsewhere, with no answer — "check result", never "run"', () => {
|
|
91
|
+
expect(proposalCardState('k', { ...none, requested: new Set(['k']) }).state).toBe('unconfirmed')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('★ an answer wins over everything', () => {
|
|
95
|
+
const outcome = parseProposalOutcomeLine(formatProposalOutcomeLine('k', { verdict: 'refused', errorCode: 'stale' }))!
|
|
96
|
+
const state = proposalCardState('k', { outcomes: new Map([['k', outcome]]), requested: new Set(['k']), sentHere: true })
|
|
97
|
+
expect(state).toEqual({ state: 'answered', outcome })
|
|
98
|
+
})
|
|
99
|
+
})
|
|
@@ -40,3 +40,36 @@ export function formatProposalOutcomeLine(key: string, outcome: ProposalOutcome)
|
|
|
40
40
|
: ''
|
|
41
41
|
return `${PROPOSAL_OUTCOME_PREFIX}${key}: ${outcome.verdict}${reason}${counted}`
|
|
42
42
|
}
|
|
43
|
+
|
|
44
|
+
/*
|
|
45
|
+
* "Requested" — written before the host is asked to run a proposal (ADR-0097 Q-D4 ④).
|
|
46
|
+
*
|
|
47
|
+
* The card used to remember that it was pressed in the page's memory only. After a refresh a
|
|
48
|
+
* proposal whose outcome never came back had its button again, and pressing it asked the host to
|
|
49
|
+
* apply it a second time. The request is now a row, like the outcome, so every reader sees it: the
|
|
50
|
+
* card after a refresh, another window, and the model ("requested, no answer yet").
|
|
51
|
+
*
|
|
52
|
+
* It is not a verdict — nothing happened yet. It only says the host was asked.
|
|
53
|
+
*/
|
|
54
|
+
export const PROPOSAL_REQUESTED = 'requested'
|
|
55
|
+
|
|
56
|
+
/** `proposal <key>: requested` */
|
|
57
|
+
export function formatProposalRequestedLine(key: string): string {
|
|
58
|
+
return `${PROPOSAL_OUTCOME_PREFIX}${key}: ${PROPOSAL_REQUESTED}`
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Whether this proposal was already taken in this conversation — requested, or answered.
|
|
63
|
+
* The caller passes the conversation's system lines; the answer is the rule, kept testable.
|
|
64
|
+
*/
|
|
65
|
+
export function proposalAlreadyTaken(key: string, systemLines: readonly (string | null | undefined)[]): boolean {
|
|
66
|
+
const requested = formatProposalRequestedLine(key)
|
|
67
|
+
const answered = `${PROPOSAL_OUTCOME_PREFIX}${key}: `
|
|
68
|
+
return systemLines.some(line => {
|
|
69
|
+
const text = (line ?? '').trim()
|
|
70
|
+
if (text === requested) return true
|
|
71
|
+
if (!text.startsWith(answered)) return false
|
|
72
|
+
const rest = text.slice(answered.length)
|
|
73
|
+
return PROPOSAL_VERDICTS.some(verdict => rest === verdict || rest.startsWith(`${verdict} `))
|
|
74
|
+
})
|
|
75
|
+
}
|
|
@@ -179,6 +179,13 @@ export function searchInbox(entries: InboxEntry[], search?: string): InboxEntry[
|
|
|
179
179
|
)
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
/** Only readable user/assistant prose participates; never match a redaction notice or execution log. */
|
|
183
|
+
export function searchReadableMessages(rows: readonly { sessionId?: string; content?: string; role?: string; redacted?: boolean }[], search: string): Set<string> {
|
|
184
|
+
const q = search.trim().toLowerCase()
|
|
185
|
+
return new Set(rows.filter(row => q && !row.redacted && (row.role === 'user' || row.role === 'assistant') && row.content?.toLowerCase().includes(q))
|
|
186
|
+
.map(row => row.sessionId).filter((id): id is string => !!id))
|
|
187
|
+
}
|
|
188
|
+
|
|
182
189
|
/** 한 번에 훑는 세션 상한 — 이보다 많으면 잘렸다고 알린다(정직). */
|
|
183
190
|
export const INBOX_SCAN_CAP = 500
|
|
184
191
|
|
|
@@ -10,11 +10,11 @@ import { User } from '@things-factory/auth-base'
|
|
|
10
10
|
import { buildDomainUsersQueryBuilder } from '@things-factory/auth-base/dist-server/utils/get-domain-users.js'
|
|
11
11
|
|
|
12
12
|
import { getRepository } from '@things-factory/shell'
|
|
13
|
-
import { In } from 'typeorm'
|
|
13
|
+
import { In, Like } from 'typeorm'
|
|
14
14
|
|
|
15
15
|
import { ChatSession } from './chat-session/chat-session.js'
|
|
16
16
|
import { anchorColumns, anchorWhere } from './chat-session/session-anchor.js'
|
|
17
|
-
import { buildInbox, applyHidden, activityPatch, searchInbox, filterInbox, INBOX_SCAN_CAP, type InboxFilter } from './chat-session/session-inbox.js'
|
|
17
|
+
import { buildInbox, applyHidden, activityPatch, searchInbox, filterInbox, searchReadableMessages, INBOX_SCAN_CAP, type InboxFilter } from './chat-session/session-inbox.js'
|
|
18
18
|
import { carriedOriginalWhere, planCarriedTurns, quoteCarriedAnswer } from './chat-message/carried-turns.js'
|
|
19
19
|
import { roomMessageBody } from './chat-message/room-message.js'
|
|
20
20
|
import { readerGate } from './chat-message/reader-gate.js'
|
|
@@ -51,7 +51,14 @@ async function writeGate(session: any, context: any, tx?: any): Promise<void> {
|
|
|
51
51
|
t: (key, options) => (context.t ? context.t(key, options) : String(options?.defaultValue ?? key))
|
|
52
52
|
})
|
|
53
53
|
}
|
|
54
|
-
import {
|
|
54
|
+
import {
|
|
55
|
+
formatProposalOutcomeLine,
|
|
56
|
+
formatProposalRequestedLine,
|
|
57
|
+
proposalAlreadyTaken,
|
|
58
|
+
PROPOSAL_OUTCOME_PREFIX,
|
|
59
|
+
PROPOSAL_VERDICTS,
|
|
60
|
+
type ProposalOutcome
|
|
61
|
+
} from './chat-message/proposal-outcome.js'
|
|
55
62
|
import { publishSessionActivity } from './chat-session/session-activity-publish.js'
|
|
56
63
|
import { ChatMessage } from './chat-message/chat-message.js'
|
|
57
64
|
import { ChatSessionParticipant } from './chat-session-participant/chat-session-participant.js'
|
|
@@ -262,7 +269,21 @@ export class ChatSessionResolver {
|
|
|
262
269
|
const all = await this._inboxEntries(anchorType, anchorId, context)
|
|
263
270
|
let entries = opts.showHidden ? all : all.filter(e => !e.hidden)
|
|
264
271
|
entries = filterInbox(entries as any, opts) as any
|
|
265
|
-
|
|
272
|
+
if (!opts.search?.trim() || !entries.length) return entries
|
|
273
|
+
// Search only readable rooms, then pass their messages through the SAME reader gate.
|
|
274
|
+
// A match in a redacted answer must not reveal even that a conversation contains it.
|
|
275
|
+
const messages = await getRepository(ChatMessage).find({
|
|
276
|
+
where: { session: { id: In(entries.map(entry => entry.id)) } },
|
|
277
|
+
order: { createdAt: 'DESC', id: 'DESC' },
|
|
278
|
+
take: 2001
|
|
279
|
+
})
|
|
280
|
+
if (messages.length > 2000) {
|
|
281
|
+
throw new Error(context.t('ai-assistant.session.search-too-broad', { defaultValue: 'Too many messages to search here. Narrow the conversation filters and try again.' }))
|
|
282
|
+
}
|
|
283
|
+
const readable = await readerGate(context)(messages)
|
|
284
|
+
const matched = searchReadableMessages(readable, opts.search)
|
|
285
|
+
const names = new Set(searchInbox(entries as any, opts.search).map(entry => entry.id))
|
|
286
|
+
return entries.filter(entry => names.has(entry.id) || matched.has(entry.id))
|
|
266
287
|
}
|
|
267
288
|
|
|
268
289
|
/**
|
|
@@ -663,19 +684,14 @@ export class ChatSessionResolver {
|
|
|
663
684
|
async createAISession(
|
|
664
685
|
@Arg('anchorType') anchorType: string,
|
|
665
686
|
@Arg('anchorId') anchorId: string,
|
|
666
|
-
@Arg('name', { nullable: true, description: 'Optional name.
|
|
687
|
+
@Arg('name', { nullable: true, description: 'Optional name. If omitted, the first question names the conversation.' }) name: string | undefined,
|
|
667
688
|
@Ctx() context: ResolverContext
|
|
668
689
|
): Promise<ChatSession> {
|
|
669
690
|
const { domain, user, tx } = context.state
|
|
670
691
|
const repo = getRepository(ChatSession, tx)
|
|
671
692
|
|
|
672
|
-
//
|
|
673
|
-
|
|
674
|
-
if (!finalName) {
|
|
675
|
-
const count = await repo.count({ where: anchorWhere(domain.id, anchorType, anchorId) })
|
|
676
|
-
/* The name is stored, so it is written in the language of whoever created it (context.t), and read back as is. */
|
|
677
|
-
finalName = context.t('ai-assistant.session.default-name', { n: count + 1, defaultValue: 'conversation {n}' })
|
|
678
|
-
}
|
|
693
|
+
// Do not prefill a numbered name: that prevents the first question from naming it.
|
|
694
|
+
const finalName = name?.trim() || null
|
|
679
695
|
|
|
680
696
|
const session = await repo.save({
|
|
681
697
|
domain,
|
|
@@ -767,6 +783,49 @@ export class ChatSessionResolver {
|
|
|
767
783
|
return true
|
|
768
784
|
}
|
|
769
785
|
|
|
786
|
+
@Directive('@transaction')
|
|
787
|
+
@Mutation(() => Boolean, {
|
|
788
|
+
description:
|
|
789
|
+
"Ask before running a proposal: true when this conversation has not taken it yet, and the request is recorded as a system row; false when it was already requested or answered - then the host must not be asked again. The card reads its state from these rows, so a refreshed panel or another window does not offer the same proposal twice (ADR-0097)."
|
|
790
|
+
})
|
|
791
|
+
@Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
|
|
792
|
+
async requestProposal(
|
|
793
|
+
@Arg('sessionId') sessionId: string,
|
|
794
|
+
@Arg('proposalKey') proposalKey: string,
|
|
795
|
+
@Ctx() context: ResolverContext
|
|
796
|
+
): Promise<boolean> {
|
|
797
|
+
const { domain, user, tx } = context.state
|
|
798
|
+
const session = await getRepository(ChatSession, tx).findOneBy({ id: sessionId, domain: { id: domain.id } })
|
|
799
|
+
if (!session) throw new Error(`ChatSession ${sessionId} not found`)
|
|
800
|
+
await writeGate(session, context, tx)
|
|
801
|
+
|
|
802
|
+
const key = (proposalKey ?? '').trim()
|
|
803
|
+
if (!key) return false
|
|
804
|
+
|
|
805
|
+
/* Only this conversation's proposal lines - a conversation is bounded, and the rule reads them all. */
|
|
806
|
+
const lines = await getRepository(ChatMessage, tx).find({
|
|
807
|
+
select: ['content'] as any,
|
|
808
|
+
where: { session: { id: session.id } as any, role: 'system', content: Like(`${PROPOSAL_OUTCOME_PREFIX}%`) } as any
|
|
809
|
+
})
|
|
810
|
+
if (proposalAlreadyTaken(key, lines.map(line => (line as any).content))) return false
|
|
811
|
+
|
|
812
|
+
const previousLast = await getRepository(ChatMessage, tx).findOne({
|
|
813
|
+
where: { session: { id: session.id } as any },
|
|
814
|
+
order: { createdAt: 'DESC' }
|
|
815
|
+
})
|
|
816
|
+
const row = await getRepository(ChatMessage, tx).save({
|
|
817
|
+
session: { id: session.id } as any,
|
|
818
|
+
role: 'system',
|
|
819
|
+
content: formatProposalRequestedLine(key),
|
|
820
|
+
creator: user,
|
|
821
|
+
updater: user,
|
|
822
|
+
parentMessage: previousLast ? ({ id: previousLast.id } as any) : undefined
|
|
823
|
+
} as any)
|
|
824
|
+
|
|
825
|
+
publishChatMessage({ domainId: domain.id, sessionId: session.id, message: row })
|
|
826
|
+
return true
|
|
827
|
+
}
|
|
828
|
+
|
|
770
829
|
/**
|
|
771
830
|
* Stores files with a conversation before the turn that carries them (design/ai/shared-dock.md §9).
|
|
772
831
|
* Passes the write gate - a private conversation takes files from its people only - and the mode's
|
|
@@ -24,6 +24,11 @@ describe('no empty panel', () => {
|
|
|
24
24
|
it('an unreadable capability offers a retry', () => {
|
|
25
25
|
assert.match(dock, /this\.capability === 'unknown'\s*\?\s*html`<div><button class="sbtn" @click=\$\{\(\) => this\.open\(\)\}>/)
|
|
26
26
|
})
|
|
27
|
+
|
|
28
|
+
it('a surface can give the person one concrete next route', () => {
|
|
29
|
+
assert.match(dock, /surface\?\.nextStep \? html`<div class="next-step" role="status">/)
|
|
30
|
+
assert.match(dock, /<a href=\$\{surface\.nextStep\.href\}>\$\{surface\.nextStep\.label\(\)\}/)
|
|
31
|
+
})
|
|
27
32
|
})
|
|
28
33
|
|
|
29
34
|
describe('the dock opens on every screen', () => {
|
|
@@ -52,7 +57,7 @@ describe('every request the chat makes is taken', () => {
|
|
|
52
57
|
|
|
53
58
|
describe('every proposal ends in one reported outcome', () => {
|
|
54
59
|
it('the dock reports whatever the handler returned', () => {
|
|
55
|
-
assert.match(dock, /const outcome =
|
|
60
|
+
assert.match(dock, /const outcome: ProposalOutcome =[\s\S]*await runProposal\(proposal, this\.proposalContext\(sessionId\)\)[\s\S]*await this\.report\(sessionId, proposalKey, outcome\)/)
|
|
56
61
|
})
|
|
57
62
|
|
|
58
63
|
it('a kind nobody handles is unavailable, by name', async () => {
|
|
@@ -89,7 +94,7 @@ describe('sessions', () => {
|
|
|
89
94
|
|
|
90
95
|
describe('the screen is checked at send time', () => {
|
|
91
96
|
it('a send after the screen moved on is refused', () => {
|
|
92
|
-
assert.match(dock, /if \(openedFor !== this\.scopeKey\(currentAssistantSurface\(\)
|
|
97
|
+
assert.match(dock, /if \(openedFor !== this\.scopeKey\(currentAssistantSurface\(\)\) \|\| this\.openedIdentity !== this\.identityKey\(\)\) throw new Error/)
|
|
93
98
|
})
|
|
94
99
|
|
|
95
100
|
it('the draft is read at send time, never cached', () => {
|
|
@@ -125,7 +130,7 @@ describe('attachments in the chat follow the mode', () => {
|
|
|
125
130
|
|
|
126
131
|
describe('what twin needed of the dock', () => {
|
|
127
132
|
it('a surface that says its words changed repaints the header without reopening the conversation', () => {
|
|
128
|
-
assert.match(dock, /
|
|
133
|
+
assert.match(dock, /transition === 'refresh'\) \{\s*this\.surface = next\s*this\.screenChanged = false\s*this\.requestUpdate\(\)\s*return/)
|
|
129
134
|
})
|
|
130
135
|
|
|
131
136
|
it('a question with no conversation to go to is said now, never sent later; a draft waits', () => {
|
|
@@ -10,7 +10,7 @@ test('toolbar emits domain-neutral intent and never calls persistence APIs', ()
|
|
|
10
10
|
})
|
|
11
11
|
test('busy/error/temporary states and accessible controls are explicit', () => {
|
|
12
12
|
assert.match(toolbar, /this\.disabled \|\| this\.loading \|\| !!this\.error/)
|
|
13
|
-
assert.match(toolbar, /this\.temporary \? nothing/)
|
|
13
|
+
assert.match(toolbar, /this\.temporary \|\| this\.singleConversation \? nothing/)
|
|
14
14
|
assert.match(toolbar, /aria-label=/)
|
|
15
15
|
assert.match(toolbar, /role="tablist"/)
|
|
16
16
|
assert.match(toolbar, /role="tab"/)
|
|
@@ -18,6 +18,12 @@ test('busy/error/temporary states and accessible controls are explicit', () => {
|
|
|
18
18
|
assert.match(toolbar, /role="alert"/)
|
|
19
19
|
assert.match(toolbar, /role="status"/)
|
|
20
20
|
})
|
|
21
|
+
test('shared dock has one list entry, not tabs and a second history link', () => {
|
|
22
|
+
assert.match(toolbar, /this\.historyEnabled && !this\.singleConversation/)
|
|
23
|
+
const dock = read('../client/components/assistant-dock.ts')
|
|
24
|
+
assert.match(dock, /historyEnabled\s+singleConversation/)
|
|
25
|
+
assert.match(dock, /ready && this\.showingHistory/)
|
|
26
|
+
})
|
|
21
27
|
test('legacy Board chat forwards shared controls through existing host events', () => {
|
|
22
28
|
const chat = read('../client/components/assistant-chat.ts')
|
|
23
29
|
assert.match(chat, /import '\.\/assistant-session-toolbar\.js'/)
|
package/test/open-doors.test.ts
CHANGED
|
@@ -141,11 +141,12 @@ describe('what the walk itself has to be true for', () => {
|
|
|
141
141
|
/*
|
|
142
142
|
* 2026-09-24: 27 root fields, 25 declared, 2 open — both streams.
|
|
143
143
|
* 2026-09-25: 28 — attachToConversation (a conversation's files, shared dock §9), declared.
|
|
144
|
+
* 2026-09-28: 29 — requestProposal (a proposal is requested once, ADR-0097 Q-D4), declared.
|
|
144
145
|
*/
|
|
145
146
|
const all = fieldsOf('Query').length + fieldsOf('Mutation').length + fieldsOf('Subscription').length
|
|
146
147
|
const open = openFields('Query').length + openFields('Mutation').length + openFields('Subscription').length
|
|
147
148
|
|
|
148
|
-
expect(all).toBe(
|
|
149
|
+
expect(all).toBe(29)
|
|
149
150
|
expect(open).toBe(2)
|
|
150
151
|
})
|
|
151
152
|
})
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { autoSessionName, searchReadableMessages } from '../server/service/chat-session/session-inbox.js'
|
|
4
|
+
|
|
5
|
+
describe('returning to work by its subject', () => {
|
|
6
|
+
it('finds an earlier question, not just the final execution log', () => {
|
|
7
|
+
expect([...searchReadableMessages([
|
|
8
|
+
{ sessionId: 'lift', role: 'user', content: '리프트 테이블을 만들어 줘' },
|
|
9
|
+
{ sessionId: 'lift', role: 'system', content: 'accepted 13/13' }
|
|
10
|
+
], '리프트')]).toEqual(['lift'])
|
|
11
|
+
})
|
|
12
|
+
it('cannot discover redacted answers or internal execution notes', () => {
|
|
13
|
+
expect([...searchReadableMessages([
|
|
14
|
+
{ sessionId: 'secret', role: 'assistant', content: 'restricted value', redacted: true },
|
|
15
|
+
{ sessionId: 'log', role: 'system', content: 'restricted value' },
|
|
16
|
+
{ sessionId: 'public', role: 'assistant', content: 'Restricted value is unavailable' }
|
|
17
|
+
], 'restricted')]).toEqual(['public'])
|
|
18
|
+
expect(searchReadableMessages([{ sessionId: 'a', role: 'user', content: 'hi' }], ' ').size).toBe(0)
|
|
19
|
+
})
|
|
20
|
+
it('names a new conversation from the first question', () => {
|
|
21
|
+
expect(autoSessionName('리프트 테이블을 만들어 줘. 폭은 600')).toBe('리프트 테이블을 만들어 줘')
|
|
22
|
+
const resolver = readFileSync(join(__dirname, '../server/service/chat-session-resolver.ts'), 'utf8')
|
|
23
|
+
expect(resolver).toContain("const finalName = name?.trim() || null")
|
|
24
|
+
expect(resolver).toContain('await readerGate(context)(messages)')
|
|
25
|
+
expect(resolver).toContain('take: 2001')
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
describe('discoverable conversation controls', () => {
|
|
30
|
+
const manager = readFileSync(join(__dirname, '../client/components/assistant-session-manager.ts'), 'utf8')
|
|
31
|
+
it('uses a native button to open a conversation and a visible disclosure to manage it', () => {
|
|
32
|
+
expect(manager).toContain('<button class="open-row"')
|
|
33
|
+
expect(manager).toContain('<summary aria-label=')
|
|
34
|
+
expect(manager).not.toContain('.row:hover .acts')
|
|
35
|
+
})
|
|
36
|
+
it('distinguishes filtered emptiness, supplies reset, and folds advanced filters', () => {
|
|
37
|
+
expect(manager).toContain("t('filtered-empty'")
|
|
38
|
+
expect(manager).toContain('this.resetFilters()')
|
|
39
|
+
expect(manager).toContain('?hidden=${!this.filtersOpen}')
|
|
40
|
+
expect(manager).toContain('e.stopPropagation(); this.onSearch')
|
|
41
|
+
})
|
|
42
|
+
})
|
package/translations/en.json
CHANGED
|
@@ -1,4 +1,25 @@
|
|
|
1
1
|
{
|
|
2
|
+
"ai-assistant.dock.wait-before-switch": "Wait for the current response before changing conversations.",
|
|
3
|
+
"ai-assistant.dock.switch-title": "Change conversation?",
|
|
4
|
+
"ai-assistant.dock.switch-draft": "Your unsent text and attachments will not move to the other conversation.",
|
|
5
|
+
"ai-assistant.dock.switch-confirm": "Discard draft and switch",
|
|
6
|
+
"ai-assistant.dock.stay": "Keep writing",
|
|
7
|
+
"ai-assistant.dock.now-about": "Conversation subject: {name}",
|
|
8
|
+
"ai-assistant.dock.ask-kept-current": "The current conversation was kept. Switch to the intended subject before asking again.",
|
|
9
|
+
"ai-assistant.dock.return-to-subject": "Return to the conversation subject before sending or applying changes.",
|
|
10
|
+
"ai-assistant.dock.screen-and-conversation": "Screen: {screen}. Conversation: {conversation}. Your conversation has been kept.",
|
|
11
|
+
"ai-assistant.dock.no-subject": "No subject",
|
|
12
|
+
"ai-assistant.dock.switch-to-screen": "Talk about this screen",
|
|
13
|
+
"ai-assistant.session.filters": "Filters",
|
|
14
|
+
"ai-assistant.session.reset-filters": "Clear search and filters",
|
|
15
|
+
"ai-assistant.session.filtered-empty": "No conversations match these filters here.",
|
|
16
|
+
"ai-assistant.session.scope-empty": "No conversations for this subject yet.",
|
|
17
|
+
"ai-assistant.session.audience-private": "Participants only",
|
|
18
|
+
"ai-assistant.session.audience-open": "Everyone in this domain",
|
|
19
|
+
"ai-assistant.session.manage": "Manage conversation",
|
|
20
|
+
"ai-assistant.session.list": "Conversations",
|
|
21
|
+
"ai-assistant.dock.conversation-scope": "Conversations · {name}",
|
|
22
|
+
"ai-assistant.session.search-too-broad": "Too many messages to search here. Narrow the conversation filters and try again.",
|
|
2
23
|
"ai-assistant.session.new": "New conversation",
|
|
3
24
|
"ai-assistant.session.history": "Conversation history",
|
|
4
25
|
"ai-assistant.session.reset": "New conversation",
|
|
@@ -102,6 +123,10 @@
|
|
|
102
123
|
"ai-assistant.verdict.unavailable": "could not run",
|
|
103
124
|
"ai-assistant.verdict.declined": "cancelled",
|
|
104
125
|
"ai-assistant.text.proposal-waiting": "sending — {seconds}s, no answer yet",
|
|
126
|
+
"ai-assistant.text.proposal-unconfirmed": "sent earlier — no answer received",
|
|
127
|
+
"ai-assistant.button.check-proposal": "check result",
|
|
128
|
+
"ai-assistant.dock.proposal-result-uncheckable": "this screen cannot check that result. open the screen where it was applied.",
|
|
129
|
+
"ai-assistant.dock.proposal-result-unknown": "the result is still unknown. it was not applied again.",
|
|
105
130
|
"ai-assistant.text.proposal-applied-count": " {applied} of {total}",
|
|
106
131
|
"ai-assistant.text.say-to-room-tooltip": "say this to the people here — the assistant does not answer",
|
|
107
132
|
"ai-assistant.text.no-room-yet": "this conversation is not saved yet, so there is nobody else in it to talk to",
|
|
@@ -111,7 +136,7 @@
|
|
|
111
136
|
"ai-assistant.label.conversation": "conversation",
|
|
112
137
|
"ai-assistant.text.switch-conversation": "switch to {name}",
|
|
113
138
|
"ai-assistant.text.session-kind-mismatch": "this conversation is {sessionKind}, and you are writing as {requested}. open the conversation you mean.",
|
|
114
|
-
"ai-assistant.session.search": "Search names and
|
|
139
|
+
"ai-assistant.session.search": "Search conversation names and messages",
|
|
115
140
|
"ai-assistant.session.filter": "Which conversations",
|
|
116
141
|
"ai-assistant.session.filter-all": "All",
|
|
117
142
|
"ai-assistant.session.filter-mine": "Started by me",
|
package/translations/ja.json
CHANGED
|
@@ -1,4 +1,25 @@
|
|
|
1
1
|
{
|
|
2
|
+
"ai-assistant.dock.wait-before-switch": "回答が終わってから会話を切り替えてください。",
|
|
3
|
+
"ai-assistant.dock.switch-title": "会話を切り替えますか?",
|
|
4
|
+
"ai-assistant.dock.switch-draft": "未送信の文章と添付は別の会話に移りません。",
|
|
5
|
+
"ai-assistant.dock.switch-confirm": "下書きを破棄して切り替え",
|
|
6
|
+
"ai-assistant.dock.stay": "入力を続ける",
|
|
7
|
+
"ai-assistant.dock.now-about": "会話の対象: {name}",
|
|
8
|
+
"ai-assistant.dock.ask-kept-current": "現在の会話を維持しました。対象を切り替えてから質問してください。",
|
|
9
|
+
"ai-assistant.dock.return-to-subject": "送信や変更の適用には会話の対象画面へ戻ってください。",
|
|
10
|
+
"ai-assistant.dock.screen-and-conversation": "画面: {screen} · 会話の対象: {conversation}。会話を維持しました。",
|
|
11
|
+
"ai-assistant.dock.no-subject": "対象なし",
|
|
12
|
+
"ai-assistant.dock.switch-to-screen": "この画面について話す",
|
|
13
|
+
"ai-assistant.session.filters": "フィルター",
|
|
14
|
+
"ai-assistant.session.reset-filters": "検索とフィルターを解除",
|
|
15
|
+
"ai-assistant.session.filtered-empty": "この対象で条件に合う会話はありません。",
|
|
16
|
+
"ai-assistant.session.scope-empty": "この対象の会話はまだありません。",
|
|
17
|
+
"ai-assistant.session.audience-private": "参加者のみ",
|
|
18
|
+
"ai-assistant.session.audience-open": "事業場内に公開",
|
|
19
|
+
"ai-assistant.session.manage": "会話を管理",
|
|
20
|
+
"ai-assistant.session.list": "会話一覧",
|
|
21
|
+
"ai-assistant.dock.conversation-scope": "会話一覧 · {name}",
|
|
22
|
+
"ai-assistant.session.search-too-broad": "検索対象が多すぎます。フィルターで会話を絞ってください。",
|
|
2
23
|
"ai-assistant.session.new": "新しい会話",
|
|
3
24
|
"ai-assistant.session.history": "会話履歴",
|
|
4
25
|
"ai-assistant.session.reset": "会話をリセット",
|
|
@@ -102,6 +123,10 @@
|
|
|
102
123
|
"ai-assistant.verdict.unavailable": "実行できませんでした",
|
|
103
124
|
"ai-assistant.verdict.declined": "取り消しました",
|
|
104
125
|
"ai-assistant.text.proposal-waiting": "送信中 — {seconds}秒、まだ返事がありません",
|
|
126
|
+
"ai-assistant.text.proposal-unconfirmed": "以前に送信 — 結果を受け取っていません",
|
|
127
|
+
"ai-assistant.button.check-proposal": "結果を確認",
|
|
128
|
+
"ai-assistant.dock.proposal-result-uncheckable": "この画面では結果を確認できません。適用した画面で確認してください。",
|
|
129
|
+
"ai-assistant.dock.proposal-result-unknown": "結果はまだ分かりません。再適用はしていません。",
|
|
105
130
|
"ai-assistant.text.proposal-applied-count": " {total} 件中 {applied} 件",
|
|
106
131
|
"ai-assistant.text.say-to-room-tooltip": "ここにいる人たちに話します。AI は答えません",
|
|
107
132
|
"ai-assistant.text.no-room-yet": "まだ保存されていない会話なので、一緒にいる人がいません",
|
|
@@ -111,7 +136,7 @@
|
|
|
111
136
|
"ai-assistant.label.conversation": "会話",
|
|
112
137
|
"ai-assistant.text.switch-conversation": "{name} の会話に切り替える",
|
|
113
138
|
"ai-assistant.text.session-kind-mismatch": "この会話は {sessionKind} です。{requested} として書いています。目的の会話を開いてください。",
|
|
114
|
-
"ai-assistant.session.search": "
|
|
139
|
+
"ai-assistant.session.search": "会話の名前と内容を検索",
|
|
115
140
|
"ai-assistant.session.filter": "表示する会話",
|
|
116
141
|
"ai-assistant.session.filter-all": "すべて",
|
|
117
142
|
"ai-assistant.session.filter-mine": "自分が始めた会話",
|
package/translations/ko.json
CHANGED
|
@@ -1,4 +1,25 @@
|
|
|
1
1
|
{
|
|
2
|
+
"ai-assistant.dock.wait-before-switch": "답변이 끝난 뒤 대화를 전환할 수 있습니다.",
|
|
3
|
+
"ai-assistant.dock.switch-title": "대화를 전환할까요?",
|
|
4
|
+
"ai-assistant.dock.switch-draft": "작성 중인 글과 첨부는 다른 대화로 옮겨지지 않습니다.",
|
|
5
|
+
"ai-assistant.dock.switch-confirm": "입력을 버리고 전환",
|
|
6
|
+
"ai-assistant.dock.stay": "계속 작성",
|
|
7
|
+
"ai-assistant.dock.now-about": "대화 대상: {name}",
|
|
8
|
+
"ai-assistant.dock.ask-kept-current": "현재 대화를 유지했습니다. 원하는 대상으로 전환한 뒤 다시 질문해 주세요.",
|
|
9
|
+
"ai-assistant.dock.return-to-subject": "질문을 보내거나 변경을 적용하려면 대화 대상 화면으로 돌아가 주세요.",
|
|
10
|
+
"ai-assistant.dock.screen-and-conversation": "현재 화면: {screen} · 대화 대상: {conversation}. 이어 가던 대화를 유지했습니다.",
|
|
11
|
+
"ai-assistant.dock.no-subject": "대상 없음",
|
|
12
|
+
"ai-assistant.dock.switch-to-screen": "현재 화면에 대해 대화하기",
|
|
13
|
+
"ai-assistant.session.filters": "필터",
|
|
14
|
+
"ai-assistant.session.reset-filters": "검색·필터 초기화",
|
|
15
|
+
"ai-assistant.session.filtered-empty": "이 대상에서 조건에 맞는 대화가 없습니다.",
|
|
16
|
+
"ai-assistant.session.scope-empty": "이 대상에 대한 대화가 아직 없습니다.",
|
|
17
|
+
"ai-assistant.session.audience-private": "참여자만",
|
|
18
|
+
"ai-assistant.session.audience-open": "사업장 공개",
|
|
19
|
+
"ai-assistant.session.manage": "대화 관리",
|
|
20
|
+
"ai-assistant.session.list": "대화 목록",
|
|
21
|
+
"ai-assistant.dock.conversation-scope": "대화 목록 · {name}",
|
|
22
|
+
"ai-assistant.session.search-too-broad": "검색할 메시지가 너무 많습니다. 대화 필터로 범위를 좁혀 다시 검색하세요.",
|
|
2
23
|
"ai-assistant.session.new": "새 대화",
|
|
3
24
|
"ai-assistant.session.history": "대화 이력",
|
|
4
25
|
"ai-assistant.session.reset": "대화 리셋",
|
|
@@ -102,6 +123,10 @@
|
|
|
102
123
|
"ai-assistant.verdict.unavailable": "실행하지 못했습니다",
|
|
103
124
|
"ai-assistant.verdict.declined": "취소했습니다",
|
|
104
125
|
"ai-assistant.text.proposal-waiting": "보내는 중 — {seconds}초, 아직 답이 없습니다",
|
|
126
|
+
"ai-assistant.text.proposal-unconfirmed": "앞서 보냈습니다 — 결과를 받지 못했습니다",
|
|
127
|
+
"ai-assistant.button.check-proposal": "결과 확인",
|
|
128
|
+
"ai-assistant.dock.proposal-result-uncheckable": "이 화면에서는 결과를 확인할 수 없습니다. 적용한 화면에서 확인하세요.",
|
|
129
|
+
"ai-assistant.dock.proposal-result-unknown": "결과를 아직 알 수 없습니다. 다시 적용하지 않았습니다.",
|
|
105
130
|
"ai-assistant.text.proposal-applied-count": " {total} 중 {applied}",
|
|
106
131
|
"ai-assistant.text.say-to-room-tooltip": "여기 있는 사람들에게 말합니다. AI 는 답하지 않습니다",
|
|
107
132
|
"ai-assistant.text.no-room-yet": "아직 저장되지 않은 대화라 같이 있는 사람이 없습니다",
|
|
@@ -111,7 +136,7 @@
|
|
|
111
136
|
"ai-assistant.label.conversation": "대화",
|
|
112
137
|
"ai-assistant.text.switch-conversation": "{name} 대화로 바꾸기",
|
|
113
138
|
"ai-assistant.text.session-kind-mismatch": "이 대화는 {sessionKind} 입니다. {requested} 로 쓰고 계십니다. 하시려는 대화를 열어 주십시오.",
|
|
114
|
-
"ai-assistant.session.search": "
|
|
139
|
+
"ai-assistant.session.search": "대화 이름·내용 검색",
|
|
115
140
|
"ai-assistant.session.filter": "어떤 대화",
|
|
116
141
|
"ai-assistant.session.filter-all": "전체",
|
|
117
142
|
"ai-assistant.session.filter-mine": "내가 시작한 대화",
|
package/translations/ms.json
CHANGED
|
@@ -1,4 +1,25 @@
|
|
|
1
1
|
{
|
|
2
|
+
"ai-assistant.dock.wait-before-switch": "Tunggu jawapan selesai sebelum menukar perbualan.",
|
|
3
|
+
"ai-assistant.dock.switch-title": "Tukar perbualan?",
|
|
4
|
+
"ai-assistant.dock.switch-draft": "Teks dan lampiran belum dihantar tidak akan dipindahkan.",
|
|
5
|
+
"ai-assistant.dock.switch-confirm": "Buang draf dan tukar",
|
|
6
|
+
"ai-assistant.dock.stay": "Terus menulis",
|
|
7
|
+
"ai-assistant.dock.now-about": "Subjek perbualan: {name}",
|
|
8
|
+
"ai-assistant.dock.ask-kept-current": "Perbualan dikekalkan. Tukar kepada subjek yang dimaksudkan sebelum bertanya semula.",
|
|
9
|
+
"ai-assistant.dock.return-to-subject": "Kembali ke skrin subjek perbualan sebelum menghantar atau menerapkan perubahan.",
|
|
10
|
+
"ai-assistant.dock.screen-and-conversation": "Skrin: {screen} · Subjek perbualan: {conversation}. Perbualan dikekalkan.",
|
|
11
|
+
"ai-assistant.dock.no-subject": "Tiada subjek",
|
|
12
|
+
"ai-assistant.dock.switch-to-screen": "Bincangkan skrin ini",
|
|
13
|
+
"ai-assistant.session.filters": "Penapis",
|
|
14
|
+
"ai-assistant.session.reset-filters": "Kosongkan carian dan penapis",
|
|
15
|
+
"ai-assistant.session.filtered-empty": "Tiada perbualan yang sepadan untuk subjek ini.",
|
|
16
|
+
"ai-assistant.session.scope-empty": "Belum ada perbualan untuk subjek ini.",
|
|
17
|
+
"ai-assistant.session.audience-private": "Peserta sahaja",
|
|
18
|
+
"ai-assistant.session.audience-open": "Semua dalam domain ini",
|
|
19
|
+
"ai-assistant.session.manage": "Urus perbualan",
|
|
20
|
+
"ai-assistant.session.list": "Senarai perbualan",
|
|
21
|
+
"ai-assistant.dock.conversation-scope": "Perbualan · {name}",
|
|
22
|
+
"ai-assistant.session.search-too-broad": "Terlalu banyak mesej untuk dicari. Kecilkan skop dengan penapis perbualan.",
|
|
2
23
|
"ai-assistant.session.new": "Perbualan baharu",
|
|
3
24
|
"ai-assistant.session.history": "Sejarah perbualan",
|
|
4
25
|
"ai-assistant.session.reset": "Set semula perbualan",
|
|
@@ -102,6 +123,10 @@
|
|
|
102
123
|
"ai-assistant.verdict.unavailable": "tidak dapat dijalankan",
|
|
103
124
|
"ai-assistant.verdict.declined": "dibatalkan",
|
|
104
125
|
"ai-assistant.text.proposal-waiting": "menghantar — {seconds}s, belum ada jawapan",
|
|
126
|
+
"ai-assistant.text.proposal-unconfirmed": "dihantar sebelum ini — tiada jawapan diterima",
|
|
127
|
+
"ai-assistant.button.check-proposal": "semak hasil",
|
|
128
|
+
"ai-assistant.dock.proposal-result-uncheckable": "skrin ini tidak dapat menyemak hasil itu. buka skrin tempat ia digunakan.",
|
|
129
|
+
"ai-assistant.dock.proposal-result-unknown": "hasilnya masih tidak diketahui. ia tidak digunakan semula.",
|
|
105
130
|
"ai-assistant.text.proposal-applied-count": " {applied} daripada {total}",
|
|
106
131
|
"ai-assistant.text.say-to-room-tooltip": "cakap kepada orang di sini — pembantu tidak menjawab",
|
|
107
132
|
"ai-assistant.text.no-room-yet": "perbualan ini belum disimpan, jadi tiada orang lain di dalamnya",
|
|
@@ -111,7 +136,7 @@
|
|
|
111
136
|
"ai-assistant.label.conversation": "perbualan",
|
|
112
137
|
"ai-assistant.text.switch-conversation": "tukar ke perbualan {name}",
|
|
113
138
|
"ai-assistant.text.session-kind-mismatch": "perbualan ini ialah {sessionKind}, dan anda menulis sebagai {requested}. buka perbualan yang anda maksudkan.",
|
|
114
|
-
"ai-assistant.session.search": "Cari nama dan
|
|
139
|
+
"ai-assistant.session.search": "Cari nama dan kandungan perbualan",
|
|
115
140
|
"ai-assistant.session.filter": "Perbualan mana",
|
|
116
141
|
"ai-assistant.session.filter-all": "Semua",
|
|
117
142
|
"ai-assistant.session.filter-mine": "Dimulakan oleh saya",
|