@things-factory/ai-assistant 10.1.157 → 10.1.160
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 +25 -213
- package/client/components/assistant-dock.ts +11 -32
- package/client/components/assistant-mode.test.ts +1 -43
- package/client/components/assistant-mode.ts +0 -47
- package/client/utils/assistant-surface.ts +1 -1
- package/client/utils/composer-frame.ts +6 -4
- package/dist-client/components/assistant-chat.d.ts +5 -69
- package/dist-client/components/assistant-chat.js +25 -205
- package/dist-client/components/assistant-chat.js.map +1 -1
- package/dist-client/components/assistant-dock.d.ts +0 -3
- package/dist-client/components/assistant-dock.js +10 -34
- package/dist-client/components/assistant-dock.js.map +1 -1
- package/dist-client/components/assistant-mode.d.ts +0 -33
- package/dist-client/components/assistant-mode.js +0 -37
- package/dist-client/components/assistant-mode.js.map +1 -1
- package/dist-client/components/assistant-mode.test.js +1 -35
- package/dist-client/components/assistant-mode.test.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-client/utils/assistant-surface.d.ts +1 -1
- package/dist-client/utils/assistant-surface.js.map +1 -1
- package/dist-client/utils/composer-frame.d.ts +5 -3
- package/dist-client/utils/composer-frame.js +4 -4
- package/dist-client/utils/composer-frame.js.map +1 -1
- package/dist-server/index.d.ts +1 -0
- package/dist-server/index.js +2 -0
- package/dist-server/index.js.map +1 -1
- package/dist-server/service/assistant-chat-resolver.d.ts +1 -0
- package/dist-server/service/assistant-chat-resolver.js +30 -6
- package/dist-server/service/assistant-chat-resolver.js.map +1 -1
- package/dist-server/service/assistant-mode/assistant-mode-resolver.d.ts +1 -2
- package/dist-server/service/assistant-mode/assistant-mode-resolver.js +13 -24
- package/dist-server/service/assistant-mode/assistant-mode-resolver.js.map +1 -1
- package/dist-server/service/assistant-mode/fallback-subjects.d.ts +1 -0
- package/dist-server/service/assistant-mode/fallback-subjects.js +37 -0
- package/dist-server/service/assistant-mode/fallback-subjects.js.map +1 -0
- package/dist-server/service/assistant-mode/index.d.ts +2 -1
- package/dist-server/service/assistant-mode/index.js +7 -2
- package/dist-server/service/assistant-mode/index.js.map +1 -1
- package/dist-server/service/assistant-mode/registry.d.ts +2 -6
- package/dist-server/service/assistant-mode/registry.js +16 -56
- package/dist-server/service/assistant-mode/registry.js.map +1 -1
- package/dist-server/service/assistant-mode/resolve.js +3 -1
- package/dist-server/service/assistant-mode/resolve.js.map +1 -1
- package/dist-server/service/assistant-mode/screen-rules.d.ts +25 -0
- package/dist-server/service/assistant-mode/screen-rules.js +47 -0
- package/dist-server/service/assistant-mode/screen-rules.js.map +1 -0
- package/dist-server/service/assistant-mode/types.d.ts +8 -25
- package/dist-server/service/assistant-mode/types.js.map +1 -1
- package/dist-server/service/testing/mode-conformance.d.ts +11 -0
- package/dist-server/service/testing/mode-conformance.js +28 -0
- package/dist-server/service/testing/mode-conformance.js.map +1 -1
- package/dist-server/tool-registry-boot-report.d.ts +1 -1
- package/dist-server/tool-registry-boot-report.js +16 -7
- package/dist-server/tool-registry-boot-report.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +6 -6
- package/server/index.ts +2 -0
- package/server/service/assistant-chat-resolver.ts +33 -7
- package/server/service/assistant-mode/assistant-mode-resolver.ts +12 -25
- package/server/service/assistant-mode/fallback-subjects.ts +36 -0
- package/server/service/assistant-mode/index.ts +2 -1
- package/server/service/assistant-mode/one-conversation-per-subject.test.ts +37 -0
- package/server/service/assistant-mode/open-subject-door.test.ts +29 -0
- package/server/service/assistant-mode/registry.ts +14 -55
- package/server/service/assistant-mode/resolve.test.ts +10 -1
- package/server/service/assistant-mode/resolve.ts +3 -1
- package/server/service/assistant-mode/screen-rules.test.ts +39 -0
- package/server/service/assistant-mode/screen-rules.ts +47 -0
- package/server/service/assistant-mode/types.ts +8 -31
- package/server/service/testing/mode-conformance.ts +27 -1
- package/server/tool-registry-boot-report.test.ts +14 -1
- package/server/tool-registry-boot-report.ts +16 -7
- package/test/assistant-dock.test.ts +27 -4
- package/test/composer-frame.test.ts +12 -2
- package/server/service/assistant-mode/subject-defaults.test.ts +0 -86
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.160",
|
|
4
4
|
"main": "dist-server/index.js",
|
|
5
5
|
"things-factory": true,
|
|
6
6
|
"author": "heartyoh",
|
|
@@ -34,12 +34,12 @@
|
|
|
34
34
|
"@operato/popup": "^10.16.0",
|
|
35
35
|
"@operato/shell": "^10.0.0",
|
|
36
36
|
"@operato/styles": "^10.0.0",
|
|
37
|
-
"@things-factory/ai-client-base": "^10.1.
|
|
37
|
+
"@things-factory/ai-client-base": "^10.1.159",
|
|
38
38
|
"@things-factory/apptool-base": "^10.1.157",
|
|
39
|
-
"@things-factory/attachment-base": "^10.1.
|
|
40
|
-
"@things-factory/auth-base": "^10.1.
|
|
39
|
+
"@things-factory/attachment-base": "^10.1.159",
|
|
40
|
+
"@things-factory/auth-base": "^10.1.159",
|
|
41
41
|
"@things-factory/env": "^10.1.149",
|
|
42
|
-
"@things-factory/setting-base": "^10.1.
|
|
42
|
+
"@things-factory/setting-base": "^10.1.159",
|
|
43
43
|
"@things-factory/shell": "^10.1.157",
|
|
44
44
|
"dompurify": "^3.0.0",
|
|
45
45
|
"exceljs": "4.2.0",
|
|
@@ -54,5 +54,5 @@
|
|
|
54
54
|
"copyfiles": "^2.4.1",
|
|
55
55
|
"rimraf": "^5.0.0"
|
|
56
56
|
},
|
|
57
|
-
"gitHead": "
|
|
57
|
+
"gitHead": "b5e86be374e8b913a3f2b8459b6001e8f44e345b"
|
|
58
58
|
}
|
package/server/index.ts
CHANGED
|
@@ -27,3 +27,5 @@
|
|
|
27
27
|
*/
|
|
28
28
|
export * from './service/index.js'
|
|
29
29
|
import './tool-registry-boot-report.js'
|
|
30
|
+
/* The site, an application's home and a board are named without an application's help (design/ai/screen-pattern.md §2-1). */
|
|
31
|
+
import './service/assistant-mode/fallback-subjects.js'
|
|
@@ -27,7 +27,7 @@ import { GraphQLJSON } from 'graphql-scalars'
|
|
|
27
27
|
import '@things-factory/auth-base'
|
|
28
28
|
|
|
29
29
|
import { getRepository, rethrowSpoken } from '@things-factory/shell'
|
|
30
|
-
import { aiClientFor, findToolSpec, registryAssistantChat, type AgentClient } from '@things-factory/ai-client-base'
|
|
30
|
+
import { aiClientFor, findToolSpec, openSubjectLine, registryAssistantChat, type AgentClient } from '@things-factory/ai-client-base'
|
|
31
31
|
|
|
32
32
|
import { ChatSession } from './chat-session/chat-session.js'
|
|
33
33
|
import { assertStationSession } from './chat-session/station-session-guard.js'
|
|
@@ -52,6 +52,7 @@ import { attachmentHistoryLine, refuseAttachments, textCharsOf } from './chat-se
|
|
|
52
52
|
import { attachmentForModel, describeAttachment, findChatAttachment } from './chat-session/session-attachments.js'
|
|
53
53
|
import { getAssistantMode } from './assistant-mode/registry.js'
|
|
54
54
|
import { resolveModeSettings } from './assistant-mode/resolve.js'
|
|
55
|
+
import { splitNextRequest } from './assistant-mode/screen-rules.js'
|
|
55
56
|
import { requestTranslator } from './request-translator.js'
|
|
56
57
|
|
|
57
58
|
/**
|
|
@@ -138,6 +139,13 @@ class AssistantChatOutput {
|
|
|
138
139
|
@Field({ description: 'The reply, including the notice when the loop stopped early.' })
|
|
139
140
|
reply: string
|
|
140
141
|
|
|
142
|
+
@Field(() => String, {
|
|
143
|
+
nullable: true,
|
|
144
|
+
description:
|
|
145
|
+
'The request the person is likely to make next, as the reply named it. The input shows it and Tab writes it in. Not stored with the reply.'
|
|
146
|
+
})
|
|
147
|
+
suggestion?: string | null
|
|
148
|
+
|
|
141
149
|
@Field({ description: 'AI client identifier (provider:model).' })
|
|
142
150
|
clientId: string
|
|
143
151
|
|
|
@@ -290,6 +298,20 @@ export class AssistantChatResolver {
|
|
|
290
298
|
|
|
291
299
|
assertStationSession(session, input.hostContext, t)
|
|
292
300
|
|
|
301
|
+
/*
|
|
302
|
+
* 열린 대상 — 서버가 저장한 앵커에서 턴마다 한 줄(design/ai/screen-pattern.md §2-1). 화면이 보낸 hostContext 는
|
|
303
|
+
* 쓰지 않는다. 대상이 바뀌면 다음 턴의 줄도 바뀌므로 옛 대상이 대화에 남지 않는다.
|
|
304
|
+
*/
|
|
305
|
+
const subjectLine = session
|
|
306
|
+
? await openSubjectLine({
|
|
307
|
+
anchorType: (session as any).anchorType,
|
|
308
|
+
anchorId: (session as any).anchorId,
|
|
309
|
+
domain,
|
|
310
|
+
state: context.state
|
|
311
|
+
})
|
|
312
|
+
: undefined
|
|
313
|
+
const systemPrompt = subjectLine ? `${settings.systemPrompt}\n\n${subjectLine}` : settings.systemPrompt
|
|
314
|
+
|
|
293
315
|
const lastUserMessage = input.messages[input.messages.length - 1]
|
|
294
316
|
let userMessageId: string | undefined
|
|
295
317
|
|
|
@@ -480,7 +502,7 @@ export class AssistantChatResolver {
|
|
|
480
502
|
const result = await registryAssistantChat({
|
|
481
503
|
client,
|
|
482
504
|
messages: conversation as any,
|
|
483
|
-
systemPrompt
|
|
505
|
+
systemPrompt,
|
|
484
506
|
toolCategories: settings.toolCategories,
|
|
485
507
|
/* 도구가 「어느 대화에서 불렸나」를 안다 — 서버의 값이다(ADR-0061 결정 3). */
|
|
486
508
|
mode: { kind: input.mode.kind, ...(input.mode.target ? { target: input.mode.target } : {}) },
|
|
@@ -495,7 +517,7 @@ export class AssistantChatResolver {
|
|
|
495
517
|
* 자체를 안 보게 된다.
|
|
496
518
|
*/
|
|
497
519
|
groundingSources: groundingSourcesFor({
|
|
498
|
-
systemPrompt
|
|
520
|
+
systemPrompt,
|
|
499
521
|
messages: conversation as any,
|
|
500
522
|
hostContext: input.hostContext
|
|
501
523
|
}),
|
|
@@ -509,6 +531,9 @@ export class AssistantChatResolver {
|
|
|
509
531
|
* 이 답이 지나온 문 — ADR-0073 판정 1. 행에도, 목록 미리보기에도 같은 값이 간다. 한쪽만 적으면
|
|
510
532
|
* 가려진 답이 목록에는 그대로 뜬다.
|
|
511
533
|
*/
|
|
534
|
+
/* The reply ends with the next request when one is natural; the input offers it and the stored answer does not keep it (§4-1). */
|
|
535
|
+
const { reply, suggestion } = splitNextRequest(result.reply)
|
|
536
|
+
|
|
512
537
|
const answerDoors = doorsForAnswer(
|
|
513
538
|
(result.loop.toolUsages ?? []).map((usage: any) => usage?.name).filter(Boolean),
|
|
514
539
|
name => (findToolSpec(name, settings.toolCategories) as any)?.doors
|
|
@@ -519,7 +544,7 @@ export class AssistantChatResolver {
|
|
|
519
544
|
const stored = await messageRepo.save({
|
|
520
545
|
session: { id: session.id } as any,
|
|
521
546
|
role: 'assistant',
|
|
522
|
-
content:
|
|
547
|
+
content: reply,
|
|
523
548
|
/*
|
|
524
549
|
* 이 답이 지나온 문을 함께 적는다 — ADR-0073 판정 1. 방은 도메인 안에서 공개이므로, 이 문장이
|
|
525
550
|
* 실어 나르는 값을 나중에 읽는 사람이 열 수 있는지 판정할 근거가 행에 있어야 한다. 적지 않으면
|
|
@@ -543,7 +568,7 @@ export class AssistantChatResolver {
|
|
|
543
568
|
kind: 'message',
|
|
544
569
|
anchorType: (session as any).anchorType,
|
|
545
570
|
anchorId: (session as any).anchorId,
|
|
546
|
-
preview:
|
|
571
|
+
preview: reply,
|
|
547
572
|
actorId: user?.id
|
|
548
573
|
})
|
|
549
574
|
|
|
@@ -551,7 +576,7 @@ export class AssistantChatResolver {
|
|
|
551
576
|
aiClientId: client.id,
|
|
552
577
|
agentId: client.agent.id,
|
|
553
578
|
updater: user,
|
|
554
|
-
...activityPatch(
|
|
579
|
+
...activityPatch(reply, new Date(), answerDoors)
|
|
555
580
|
} as any)
|
|
556
581
|
|
|
557
582
|
/*
|
|
@@ -573,7 +598,8 @@ export class AssistantChatResolver {
|
|
|
573
598
|
}
|
|
574
599
|
|
|
575
600
|
return {
|
|
576
|
-
reply
|
|
601
|
+
reply,
|
|
602
|
+
suggestion: suggestion ?? null,
|
|
577
603
|
clientId: client.id,
|
|
578
604
|
agentId: client.agent.id,
|
|
579
605
|
sessionId: session?.id,
|
|
@@ -12,7 +12,7 @@ import { GraphQLJSON } from 'graphql-scalars'
|
|
|
12
12
|
import '@things-factory/auth-base'
|
|
13
13
|
|
|
14
14
|
import { assistantModeView } from './resolve.js'
|
|
15
|
-
import {
|
|
15
|
+
import { assistantModeForSubject } from './registry.js'
|
|
16
16
|
import { requestTranslator } from '../request-translator.js'
|
|
17
17
|
|
|
18
18
|
@ObjectType({ description: 'One `/` sentence, in the words of this request.' })
|
|
@@ -63,45 +63,32 @@ class AssistantModeView {
|
|
|
63
63
|
attachments?: any
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
@ObjectType({ description: '
|
|
66
|
+
@ObjectType({ description: 'The conversation a screen opens for what it is showing.' })
|
|
67
67
|
class AssistantModeChoice {
|
|
68
68
|
@Field({ description: 'The mode kind to open.' })
|
|
69
69
|
kind: string
|
|
70
70
|
|
|
71
|
-
@Field({ description: 'What
|
|
71
|
+
@Field({ description: 'What the conversation is called in the conversation list, in the words of this request.' })
|
|
72
72
|
label: string
|
|
73
|
-
|
|
74
|
-
@Field({ description: 'Whether this is the one to open when the person has not chosen.' })
|
|
75
|
-
isDefault: boolean
|
|
76
73
|
}
|
|
77
74
|
|
|
78
75
|
@Resolver()
|
|
79
76
|
export class AssistantModeResolver {
|
|
80
77
|
/*
|
|
81
|
-
* 이
|
|
82
|
-
*
|
|
83
|
-
* 화면은 자기가 무엇을 보여 주는지(`subject`)만 안다. 그 위에 어느 대화가 서는지는 앱 서버가
|
|
84
|
-
* 선언한다. 화면이 종류 목록을 손으로 들고 있으면, 대화를 하나 더 세운 날 그 화면만 모른 채로 남는다.
|
|
78
|
+
* 이 화면의 대화 — 화면에 대화 하나(design/ai/screen-pattern.md §3).
|
|
85
79
|
*
|
|
86
|
-
*
|
|
80
|
+
* 화면은 자기가 무엇을 보여 주는지(`subject`)만 안다. 그 위에 어느 대화가 서는지는 앱 서버가 선언한다.
|
|
87
81
|
*/
|
|
88
|
-
@Query(() =>
|
|
89
|
-
|
|
90
|
-
|
|
82
|
+
@Query(() => AssistantModeChoice, {
|
|
83
|
+
nullable: true,
|
|
84
|
+
description: 'The one conversation for this kind of subject. Null when no application declared one.'
|
|
91
85
|
})
|
|
92
86
|
@Directive('@privilege(category: "ai-assistant", privilege: "query", domainOwnerGranted: true)')
|
|
93
|
-
async
|
|
87
|
+
async assistantModeFor(@Arg('subject') subject: string, @Ctx() context: any): Promise<AssistantModeChoice | null> {
|
|
88
|
+
const mode = assistantModeForSubject(subject)
|
|
89
|
+
if (!mode) return null
|
|
94
90
|
const t = requestTranslator(context)
|
|
95
|
-
|
|
96
|
-
return assistantModesForSubject(subject).map(mode => ({
|
|
97
|
-
kind: mode.kind,
|
|
98
|
-
/*
|
|
99
|
-
* 이름이 없는 대화는 종류를 그대로 쓴다. 부팅 보고가 이미 그것을 경고로 말하므로 여기서 숨기지
|
|
100
|
-
* 않는다 — 화면에 `twin.space` 가 뜨면 그것이 빠진 선언을 가리킨다.
|
|
101
|
-
*/
|
|
102
|
-
label: mode.labelKey ? t(mode.labelKey) : mode.kind,
|
|
103
|
-
isDefault: (mode.defaultFor ?? []).includes(subject)
|
|
104
|
-
}))
|
|
91
|
+
return { kind: mode.kind, label: mode.labelKey ? t(mode.labelKey) : mode.kind }
|
|
105
92
|
}
|
|
106
93
|
@Query(() => AssistantModeView, {
|
|
107
94
|
description: 'What this conversation offers on screen — `/` sentences, the `@` catalog and the notices, translated.'
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The open subjects the shared parts can name without an application's help (design/ai/screen-pattern.md §2-1):
|
|
3
|
+
* the site (`domain`), an application's home (`application`) and a board (`board`). An application that names
|
|
4
|
+
* one of these differently registers its own describer, which is read first.
|
|
5
|
+
*
|
|
6
|
+
* A board is read by entity name: the board entity belongs to board-service, and this package does not depend on
|
|
7
|
+
* it. A deployment without boards has no board anchors, and a read that fails says the subject could not be read.
|
|
8
|
+
*/
|
|
9
|
+
import { Domain, getRepository } from '@things-factory/shell'
|
|
10
|
+
import { registerFallbackSubject } from '@things-factory/ai-client-base'
|
|
11
|
+
|
|
12
|
+
registerFallbackSubject({
|
|
13
|
+
anchorType: 'domain',
|
|
14
|
+
async describe({ anchorId, domain }) {
|
|
15
|
+
/* The site of this conversation only - another domain's name is not this person's to read. */
|
|
16
|
+
if (anchorId !== domain.id) return undefined
|
|
17
|
+
const found = await getRepository(Domain).findOne({ where: { id: anchorId } })
|
|
18
|
+
return found ? { noun: 'site', name: found.description || found.name } : undefined
|
|
19
|
+
}
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
registerFallbackSubject({
|
|
23
|
+
anchorType: 'application',
|
|
24
|
+
async describe({ anchorId }) {
|
|
25
|
+
/* The application's home is a place, not a record: nothing narrower is open. */
|
|
26
|
+
return { noun: 'home of the application', name: anchorId }
|
|
27
|
+
}
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
registerFallbackSubject({
|
|
31
|
+
anchorType: 'board',
|
|
32
|
+
async describe({ anchorId, domain }) {
|
|
33
|
+
const found: any = await getRepository('Board').findOne({ where: { id: anchorId, domain: { id: domain.id } } })
|
|
34
|
+
return found?.name ? { noun: 'board', name: found.name } : undefined
|
|
35
|
+
}
|
|
36
|
+
})
|
|
@@ -7,10 +7,11 @@ export {
|
|
|
7
7
|
registerAssistantMode,
|
|
8
8
|
getAssistantMode,
|
|
9
9
|
listAssistantModes,
|
|
10
|
-
|
|
10
|
+
assistantModeForSubject,
|
|
11
11
|
getAssistantModeWarnings,
|
|
12
12
|
clearAssistantModes
|
|
13
13
|
} from './registry.js'
|
|
14
14
|
export { resolveModeSettings, assistantModeView } from './resolve.js'
|
|
15
|
+
export { ABSENCE_RULE, NEXT_REQUEST_RULE, conversationPrompt, splitNextRequest } from './screen-rules.js'
|
|
15
16
|
export type { AssistantModeSpec, AssistantModeSlash, AssistantModeWarning } from './types.js'
|
|
16
17
|
export type { AssistantModeRef, ModeSettings, AssistantModeView, AssistantModeSlashView, Translate } from './resolve.js'
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* A screen has one conversation (operato-application design/ai/screen-pattern.md §3).
|
|
3
|
+
*
|
|
4
|
+
* Two conversations on one subject put a picker in front of the person, a choice between our internal units. The
|
|
5
|
+
* boot report names the subject instead, and the screen opens the one conversation for what it shows.
|
|
6
|
+
*/
|
|
7
|
+
import { assistantModeForSubject, clearAssistantModes, getAssistantModeWarnings, registerAssistantMode } from './registry'
|
|
8
|
+
|
|
9
|
+
const persona = 'you are here'
|
|
10
|
+
|
|
11
|
+
describe('assistant mode — one conversation per subject', () => {
|
|
12
|
+
beforeEach(() => clearAssistantModes())
|
|
13
|
+
afterAll(() => clearAssistantModes())
|
|
14
|
+
|
|
15
|
+
it('★ two conversations on one subject is a boot warning naming both', () => {
|
|
16
|
+
registerAssistantMode({ kind: 'twin.board', systemPrompt: persona, subjects: ['board'] })
|
|
17
|
+
registerAssistantMode({ kind: 'twin.space', systemPrompt: persona, subjects: ['space', 'board'] })
|
|
18
|
+
|
|
19
|
+
const twice = getAssistantModeWarnings().filter(w => w.code === 'assistant-mode-subject-claimed-twice')
|
|
20
|
+
expect(twice.map(w => w.params.subject)).toEqual(['board'])
|
|
21
|
+
expect(twice[0].params.kinds).toEqual(['twin.board', 'twin.space'])
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('one conversation on each subject says nothing', () => {
|
|
25
|
+
registerAssistantMode({ kind: 'twin.board', systemPrompt: persona, subjects: ['board'] })
|
|
26
|
+
registerAssistantMode({ kind: 'twin.space', systemPrompt: persona, subjects: ['space'] })
|
|
27
|
+
|
|
28
|
+
expect(getAssistantModeWarnings()).toEqual([])
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('★ a screen opens the conversation for its subject, and nothing for a subject nobody declared', () => {
|
|
32
|
+
registerAssistantMode({ kind: 'plant.station', systemPrompt: persona, subjects: ['station'] })
|
|
33
|
+
|
|
34
|
+
expect(assistantModeForSubject('station')?.kind).toBe('plant.station')
|
|
35
|
+
expect(assistantModeForSubject('nothing-shows-this')).toBeUndefined()
|
|
36
|
+
})
|
|
37
|
+
})
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The door says what is open from the stored anchor (operato-application design/ai/screen-pattern.md §2-1 · §4-1).
|
|
3
|
+
*/
|
|
4
|
+
import { readFileSync } from 'fs'
|
|
5
|
+
import { join } from 'path'
|
|
6
|
+
|
|
7
|
+
const door = readFileSync(join(__dirname, '../assistant-chat-resolver.ts'), 'utf8')
|
|
8
|
+
|
|
9
|
+
describe('the door', () => {
|
|
10
|
+
it('★ builds the line from the stored session anchor every turn, never from what the screen sent', () => {
|
|
11
|
+
const block = door.slice(door.indexOf('const subjectLine = session'), door.indexOf('const systemPrompt = subjectLine'))
|
|
12
|
+
expect(block).toMatch(/anchorType: \(session as any\)\.anchorType/)
|
|
13
|
+
expect(block).toMatch(/anchorId: \(session as any\)\.anchorId/)
|
|
14
|
+
expect(block).not.toMatch(/hostContext/)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('★ the model and the grounding check both read the prompt with the line', () => {
|
|
18
|
+
expect(door.match(/^\s+systemPrompt,$/gm)).toHaveLength(2)
|
|
19
|
+
expect(door).not.toMatch(/systemPrompt: settings\.systemPrompt/)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('★ the stored answer, the list preview and the reply leave out the Next: line', () => {
|
|
23
|
+
expect(door).toMatch(/const \{ reply, suggestion \} = splitNextRequest\(result\.reply\)/)
|
|
24
|
+
expect(door).toMatch(/content: reply,/)
|
|
25
|
+
expect(door).toMatch(/preview: reply,/)
|
|
26
|
+
expect(door).toMatch(/activityPatch\(reply, /)
|
|
27
|
+
expect(door).not.toMatch(/content: result\.reply/)
|
|
28
|
+
})
|
|
29
|
+
})
|
|
@@ -62,47 +62,20 @@ export function getAssistantModeWarnings(): ReadonlyArray<AssistantModeWarning>
|
|
|
62
62
|
const out: AssistantModeWarning[] = []
|
|
63
63
|
|
|
64
64
|
/*
|
|
65
|
-
* 한
|
|
66
|
-
*
|
|
65
|
+
* 한 화면에 대화 하나 — design/ai/screen-pattern.md §3. 같은 subject 를 두 대화가 적으면 화면은 어느 쪽을
|
|
66
|
+
* 열지 모르고, 고르개로 사람에게 넘기면 원칙 3 을 어긴다. 성격이 다른 물음은 그 대화가 도구를 더 연다.
|
|
67
67
|
*/
|
|
68
|
-
const
|
|
69
|
-
const claimed = new Set<string>()
|
|
68
|
+
const owners = new Map<string, string[]>()
|
|
70
69
|
for (const mode of modes.values()) {
|
|
71
|
-
for (const subject of mode.subjects ?? [])
|
|
72
|
-
for (const subject of mode.defaultFor ?? []) {
|
|
73
|
-
defaults.set(subject, [...(defaults.get(subject) ?? []), mode.kind])
|
|
74
|
-
}
|
|
70
|
+
for (const subject of mode.subjects ?? []) owners.set(subject, [...(owners.get(subject) ?? []), mode.kind])
|
|
75
71
|
}
|
|
76
|
-
for (const subject of
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
})
|
|
84
|
-
} else if (owners.length > 1) {
|
|
85
|
-
out.push({
|
|
86
|
-
code: 'assistant-mode-subject-default-twice',
|
|
87
|
-
params: { kind: owners[0], subject, kinds: owners },
|
|
88
|
-
message: `subject '${subject}' has more than one default (${owners.join(', ')}) — boot order would decide it`
|
|
89
|
-
})
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
for (const mode of modes.values()) {
|
|
94
|
-
/*
|
|
95
|
-
* 고를 수 있는 대화에는 부를 이름이 있어야 한다 — 고르개에 종류 이름(`twin.space`)이 뜨면 그것은
|
|
96
|
-
* 우리 내부 단위를 사람에게 떠넘기는 것이다.
|
|
97
|
-
*/
|
|
98
|
-
const shared = (mode.subjects ?? []).some(subject => countModesFor(subject) > 1)
|
|
99
|
-
if (shared && !mode.labelKey) {
|
|
100
|
-
out.push({
|
|
101
|
-
code: 'assistant-mode-offered-without-label',
|
|
102
|
-
params: { kind: mode.kind },
|
|
103
|
-
message: `mode '${mode.kind}' can be offered beside another on the same subject but has no labelKey`
|
|
104
|
-
})
|
|
105
|
-
}
|
|
72
|
+
for (const [subject, kinds] of owners) {
|
|
73
|
+
if (kinds.length < 2) continue
|
|
74
|
+
out.push({
|
|
75
|
+
code: 'assistant-mode-subject-claimed-twice',
|
|
76
|
+
params: { kind: kinds[0], subject, kinds },
|
|
77
|
+
message: `subject '${subject}' has more than one conversation (${kinds.join(', ')}) — a screen has one conversation; open more tools in one of them`
|
|
78
|
+
})
|
|
106
79
|
}
|
|
107
80
|
|
|
108
81
|
for (const mode of modes.values()) {
|
|
@@ -135,23 +108,9 @@ export function getAssistantModeWarnings(): ReadonlyArray<AssistantModeWarning>
|
|
|
135
108
|
return out
|
|
136
109
|
}
|
|
137
110
|
|
|
138
|
-
/**
|
|
139
|
-
function
|
|
140
|
-
|
|
141
|
-
for (const mode of modes.values()) {
|
|
142
|
-
if ((mode.subjects ?? []).includes(subject)) count++
|
|
143
|
-
}
|
|
144
|
-
return count
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/**
|
|
148
|
-
* 이 subject 종류에 서는 대화들 — 등록 순서대로, 기본이 먼저.
|
|
149
|
-
*
|
|
150
|
-
* 화면은 이 목록으로 고르개를 낸다. 하나뿐이면 고르개를 내지 않는다(ADR-0072 A17 ①).
|
|
151
|
-
*/
|
|
152
|
-
export function assistantModesForSubject(subject: string): ReadonlyArray<AssistantModeSpec> {
|
|
153
|
-
const standing = [...modes.values()].filter(mode => (mode.subjects ?? []).includes(subject))
|
|
154
|
-
return standing.sort((a, b) => Number((b.defaultFor ?? []).includes(subject)) - Number((a.defaultFor ?? []).includes(subject)))
|
|
111
|
+
/** The conversation a screen showing this kind of subject opens — one per subject (design/ai/screen-pattern.md §3). */
|
|
112
|
+
export function assistantModeForSubject(subject: string): AssistantModeSpec | undefined {
|
|
113
|
+
return [...modes.values()].find(mode => (mode.subjects ?? []).includes(subject))
|
|
155
114
|
}
|
|
156
115
|
|
|
157
116
|
/** Test only. */
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { clearAssistantModes, registerAssistantMode } from './registry'
|
|
10
10
|
import { assistantModeView, resolveModeSettings } from './resolve'
|
|
11
|
+
import { ABSENCE_RULE, NEXT_REQUEST_RULE, conversationPrompt } from './screen-rules'
|
|
11
12
|
|
|
12
13
|
/** Stands in for `context.t` — returns the key and params so the test can see what was translated. */
|
|
13
14
|
const t = (key: string, params?: Record<string, any>) => `t(${key}${params?.count !== undefined ? `,${params.count}` : ''})`
|
|
@@ -32,7 +33,8 @@ it('★ the settings come from the registered mode', () => {
|
|
|
32
33
|
const settings = resolveModeSettings({ kind: 'twin.space', target: 'S-12' }, t)
|
|
33
34
|
|
|
34
35
|
expect(settings).toEqual({
|
|
35
|
-
|
|
36
|
+
/* The app's persona, then the rules every conversation follows (design/ai/screen-pattern.md §4). */
|
|
37
|
+
systemPrompt: conversationPrompt('you answer about one space'),
|
|
36
38
|
/* Every conversation may put a large result on the AI 작업판 (design/ai/workspace.md). */
|
|
37
39
|
toolCategories: ['twin-ops', 'assistant-workspace'],
|
|
38
40
|
requireToolOnFirstTurn: true,
|
|
@@ -41,6 +43,13 @@ it('★ the settings come from the registered mode', () => {
|
|
|
41
43
|
})
|
|
42
44
|
})
|
|
43
45
|
|
|
46
|
+
it('★ every conversation carries the absence rule and the next-request rule once, after its persona', () => {
|
|
47
|
+
const { systemPrompt } = resolveModeSettings({ kind: 'twin.space' }, t)
|
|
48
|
+
expect(systemPrompt.startsWith('you answer about one space')).toBe(true)
|
|
49
|
+
expect(systemPrompt.split(ABSENCE_RULE)).toHaveLength(2)
|
|
50
|
+
expect(systemPrompt.split(NEXT_REQUEST_RULE)).toHaveLength(2)
|
|
51
|
+
})
|
|
52
|
+
|
|
44
53
|
it('★ an unregistered kind is refused — no plain-conversation fallback', () => {
|
|
45
54
|
expect(() => resolveModeSettings({ kind: 'twin.nothing' }, t)).toThrow(/t\(ai-assistant.text.unknown-mode\)/)
|
|
46
55
|
try {
|
|
@@ -6,6 +6,7 @@ import type { AttachmentPolicy } from '../chat-session/attachment-policy.js'
|
|
|
6
6
|
* the server would answer differently.
|
|
7
7
|
*/
|
|
8
8
|
import { getAssistantMode } from './registry'
|
|
9
|
+
import { conversationPrompt } from './screen-rules'
|
|
9
10
|
import type { AssistantModeSlash } from './types'
|
|
10
11
|
|
|
11
12
|
export type Translate = (key: string, params?: Record<string, any>) => string
|
|
@@ -65,7 +66,8 @@ export function resolveModeSettings(ref: AssistantModeRef, t: Translate): ModeSe
|
|
|
65
66
|
if (!mode) throw unknownModeError(kind, t)
|
|
66
67
|
|
|
67
68
|
return {
|
|
68
|
-
|
|
69
|
+
/* The app's persona, with the rules every conversation follows (design/ai/screen-pattern.md §4). */
|
|
70
|
+
systemPrompt: conversationPrompt(mode.systemPrompt),
|
|
69
71
|
/* A mode that takes attachments can open the files sent in earlier turns (design/ai/shared-dock.md §9-4). */
|
|
70
72
|
toolCategories: modeToolCategories(mode),
|
|
71
73
|
requireToolOnFirstTurn: mode.requireToolOnFirstTurn,
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The rules every conversation follows (operato-application design/ai/screen-pattern.md §4).
|
|
3
|
+
*/
|
|
4
|
+
import { ABSENCE_RULE, NEXT_REQUEST_RULE, conversationPrompt, splitNextRequest } from './screen-rules'
|
|
5
|
+
|
|
6
|
+
describe('the next request an answer names', () => {
|
|
7
|
+
it('★ the last line is taken off the reply and becomes the suggestion', () => {
|
|
8
|
+
expect(splitNextRequest('The fan has 3 blades.\n\nNext: 날개를 5개로 늘려 줘')).toEqual({
|
|
9
|
+
reply: 'The fan has 3 blades.',
|
|
10
|
+
suggestion: '날개를 5개로 늘려 줘'
|
|
11
|
+
})
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('quotes and bold around the line are not part of the request', () => {
|
|
15
|
+
expect(splitNextRequest('Done.\n**Next:** "재질을 바꿔 줘"').suggestion).toBe('재질을 바꿔 줘')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('★ a Next: that is not the last line is part of the answer', () => {
|
|
19
|
+
const reply = 'Next: we read the figure.\nIt has 3 blades.'
|
|
20
|
+
expect(splitNextRequest(reply)).toEqual({ reply })
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('a reply with no such line has no suggestion', () => {
|
|
24
|
+
expect(splitNextRequest('It has 3 blades.')).toEqual({ reply: 'It has 3 blades.' })
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('a reply that is only the line keeps it - an empty answer is worse than an odd one', () => {
|
|
28
|
+
expect(splitNextRequest('Next: 날개를 늘려 줘')).toEqual({ reply: 'Next: 날개를 늘려 줘' })
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
describe('the prompt every conversation gets', () => {
|
|
33
|
+
it('★ the persona first, then the absence rule and the next-request rule', () => {
|
|
34
|
+
const prompt = conversationPrompt('You edit figures.')
|
|
35
|
+
expect(prompt.indexOf('You edit figures.')).toBe(0)
|
|
36
|
+
expect(prompt.indexOf(ABSENCE_RULE)).toBeGreaterThan(0)
|
|
37
|
+
expect(prompt.indexOf(NEXT_REQUEST_RULE)).toBeGreaterThan(prompt.indexOf(ABSENCE_RULE))
|
|
38
|
+
})
|
|
39
|
+
})
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rules every conversation follows, whatever app declared it (operato-application design/ai/screen-pattern.md).
|
|
3
|
+
*
|
|
4
|
+
* Five apps carried their own copy of the absence rule, word for word or nearly. A rule every conversation needs
|
|
5
|
+
* belongs to the door that builds every conversation's prompt, so an app's prompt says only who the assistant is
|
|
6
|
+
* there and what it may not claim.
|
|
7
|
+
*
|
|
8
|
+
* Model-facing text is English (the AI standard's A11); the person's language comes from the request.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** §4-2 — an absence or a refusal is a fact, with what the person can do. */
|
|
12
|
+
export const ABSENCE_RULE =
|
|
13
|
+
'When a tool says something is absent or refuses, state that and what the person can do. ' +
|
|
14
|
+
'Do not guess why, and do not describe our machinery - words like passed, delivered or loaded name our plumbing, not their situation.'
|
|
15
|
+
|
|
16
|
+
/** The line a reply ends with when a next request is natural (§4-1). */
|
|
17
|
+
const NEXT_PREFIX = 'Next:'
|
|
18
|
+
|
|
19
|
+
/** §4-1 — the reply names the next request, and the input offers it. */
|
|
20
|
+
export const NEXT_REQUEST_RULE =
|
|
21
|
+
`After answering, when there is a natural next request, end the reply with one last line "${NEXT_PREFIX} <request>": ` +
|
|
22
|
+
"the request the person is most likely to make next, written as they would type it, in the language they wrote in. " +
|
|
23
|
+
'Offer only what this conversation can do with its tools. When nothing natural follows, leave the line out.'
|
|
24
|
+
|
|
25
|
+
/** A mode's persona with the rules every conversation follows. */
|
|
26
|
+
export function conversationPrompt(modePrompt: string): string {
|
|
27
|
+
return [modePrompt.trim(), ABSENCE_RULE, NEXT_REQUEST_RULE].join('\n\n')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The reply without its `Next:` line, and the request that line named.
|
|
32
|
+
*
|
|
33
|
+
* Only the last non-empty line counts: a `Next:` in the middle of an answer is part of the answer. The line is
|
|
34
|
+
* taken off before the reply is stored, so a conversation read later shows the answer alone.
|
|
35
|
+
*/
|
|
36
|
+
export function splitNextRequest(reply: string): { reply: string; suggestion?: string } {
|
|
37
|
+
const text = String(reply ?? '')
|
|
38
|
+
const lines = text.replace(/\s+$/, '').split('\n')
|
|
39
|
+
const last = lines[lines.length - 1] ?? ''
|
|
40
|
+
const match = /^\s*(?:\*\*)?Next:(?:\*\*)?\s*(.+?)\s*$/.exec(last)
|
|
41
|
+
if (!match) return { reply: text }
|
|
42
|
+
|
|
43
|
+
const suggestion = match[1].replace(/^["“'`]+|["”'`]+$/g, '').trim()
|
|
44
|
+
const rest = lines.slice(0, -1).join('\n').replace(/\s+$/, '')
|
|
45
|
+
if (!suggestion || !rest) return { reply: text }
|
|
46
|
+
return { reply: rest, suggestion }
|
|
47
|
+
}
|
|
@@ -29,32 +29,15 @@ export interface AssistantModeSpec {
|
|
|
29
29
|
kind: string
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
|
-
* Which kinds of subject this conversation
|
|
32
|
+
* Which kinds of subject this conversation is for — `['board']`, `['space']`.
|
|
33
33
|
*
|
|
34
|
-
* A screen says what it is showing (`context.subject`)
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
* On the twin modeller nothing could be asked about operations, because an open board pinned the
|
|
39
|
-
* authoring conversation and that conversation opens one category. A conversation that also
|
|
40
|
-
* stands for `board` is how the same screen carries both.
|
|
34
|
+
* A screen says what it is showing (`context.subject`) and opens the one conversation for it: a screen has one
|
|
35
|
+
* conversation (design/ai/screen-pattern.md §3). A list of types, so it can be counted at boot and pinned by a
|
|
36
|
+
* test; two modes naming one subject is a boot warning.
|
|
41
37
|
*/
|
|
42
38
|
subjects?: string[]
|
|
43
39
|
|
|
44
|
-
/**
|
|
45
|
-
* The conversation a screen opens when the person has not chosen — one per subject type
|
|
46
|
-
* (ADR-0072 A17 ②).
|
|
47
|
-
*
|
|
48
|
-
* Registration order must not decide this: whichever module booted last would silently become
|
|
49
|
-
* the default. Exactly one mode declares each subject type, and the boot report says so when a
|
|
50
|
-
* type has none or more than one.
|
|
51
|
-
*/
|
|
52
|
-
defaultFor?: string[]
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* i18n key for what this conversation is called where a person chooses between them —
|
|
56
|
-
* "이 그림 고치기", "이 현장 지금 어때". A mode a screen may offer needs one.
|
|
57
|
-
*/
|
|
40
|
+
/** i18n key for what this conversation is called in the conversation list. */
|
|
58
41
|
labelKey?: string
|
|
59
42
|
/** Who the assistant is here, and what it must not claim to do. The server's, not the caller's. */
|
|
60
43
|
systemPrompt: string
|
|
@@ -118,16 +101,10 @@ export interface AssistantModeSpec {
|
|
|
118
101
|
export interface AssistantModeWarning {
|
|
119
102
|
/**
|
|
120
103
|
* `assistant-mode-slash-outside-tools` — a `/` sentence needs a tool this mode does not open.
|
|
121
|
-
* `assistant-mode-subject-
|
|
122
|
-
* `assistant-mode-
|
|
123
|
-
* `assistant-mode-offered-without-label` — a conversation a screen may offer with nothing to call it.
|
|
104
|
+
* `assistant-mode-subject-claimed-twice` — two conversations name the same subject type.
|
|
105
|
+
* `assistant-mode-entry-target-unknown` — a record the mode enters that no application registered.
|
|
124
106
|
*/
|
|
125
|
-
code:
|
|
126
|
-
| 'assistant-mode-slash-outside-tools'
|
|
127
|
-
| 'assistant-mode-subject-without-default'
|
|
128
|
-
| 'assistant-mode-subject-default-twice'
|
|
129
|
-
| 'assistant-mode-offered-without-label'
|
|
130
|
-
| 'assistant-mode-entry-target-unknown'
|
|
107
|
+
code: 'assistant-mode-slash-outside-tools' | 'assistant-mode-subject-claimed-twice' | 'assistant-mode-entry-target-unknown'
|
|
131
108
|
params: { kind: string; slash?: string; tool?: string; subject?: string; kinds?: string[]; target?: string }
|
|
132
109
|
/** For a person reading the boot log; callers act on the code and params. */
|
|
133
110
|
message: string
|