@things-factory/ai-assistant 10.1.233 → 10.1.241

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.
Files changed (35) hide show
  1. package/client/components/assistant-chat.ts +11 -3
  2. package/dist-client/components/assistant-chat.js +11 -4
  3. package/dist-client/components/assistant-chat.js.map +1 -1
  4. package/dist-client/tsconfig.tsbuildinfo +1 -1
  5. package/dist-server/service/assistant-chat-resolver.js +1 -1
  6. package/dist-server/service/assistant-chat-resolver.js.map +1 -1
  7. package/dist-server/service/chat-message/chat-message.js +3 -3
  8. package/dist-server/service/chat-message/chat-message.js.map +1 -1
  9. package/dist-server/service/chat-session/chat-session.js +2 -2
  10. package/dist-server/service/chat-session/chat-session.js.map +1 -1
  11. package/dist-server/service/chat-session/session-name.d.ts +8 -0
  12. package/dist-server/service/chat-session/session-name.js +32 -0
  13. package/dist-server/service/chat-session/session-name.js.map +1 -0
  14. package/dist-server/service/chat-session-participant/chat-session-participant.js +1 -1
  15. package/dist-server/service/chat-session-participant/chat-session-participant.js.map +1 -1
  16. package/dist-server/service/chat-session-resolver.js +24 -18
  17. package/dist-server/service/chat-session-resolver.js.map +1 -1
  18. package/dist-server/service/unattended-run/assistant-run.js +1 -1
  19. package/dist-server/service/unattended-run/assistant-run.js.map +1 -1
  20. package/dist-server/service/unattended-run/run-unattended.js +1 -1
  21. package/dist-server/service/unattended-run/run-unattended.js.map +1 -1
  22. package/dist-server/tsconfig.tsbuildinfo +1 -1
  23. package/package.json +8 -8
  24. package/server/service/assistant-chat-resolver.ts +1 -1
  25. package/server/service/chat-message/chat-message.ts +3 -3
  26. package/server/service/chat-session/chat-session.ts +2 -2
  27. package/server/service/chat-session/group-room.test.ts +1 -1
  28. package/server/service/chat-session/session-name.ts +32 -0
  29. package/server/service/chat-session-participant/chat-session-participant.ts +1 -1
  30. package/server/service/chat-session-resolver.ts +27 -16
  31. package/server/service/unattended-run/assistant-run.ts +1 -1
  32. package/server/service/unattended-run/run-unattended.ts +1 -1
  33. package/test/refusals-in-korean.test.ts +80 -0
  34. package/translations/en.json +22 -2
  35. package/translations/ko.json +22 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/ai-assistant",
3
- "version": "10.1.233",
3
+ "version": "10.1.241",
4
4
  "main": "dist-server/index.js",
5
5
  "things-factory": true,
6
6
  "author": "heartyoh",
@@ -35,13 +35,13 @@
35
35
  "@operato/shell": "^10.29.39",
36
36
  "@operato/styles": "^10.0.0",
37
37
  "@operato/utils": "^10.29.39",
38
- "@things-factory/ai-client-base": "^10.1.232",
39
- "@things-factory/apptool-base": "^10.1.228",
40
- "@things-factory/attachment-base": "^10.1.233",
41
- "@things-factory/auth-base": "^10.1.232",
38
+ "@things-factory/ai-client-base": "^10.1.241",
39
+ "@things-factory/apptool-base": "^10.1.234",
40
+ "@things-factory/attachment-base": "^10.1.241",
41
+ "@things-factory/auth-base": "^10.1.241",
42
42
  "@things-factory/env": "^10.1.149",
43
- "@things-factory/setting-base": "^10.1.232",
44
- "@things-factory/shell": "^10.1.228",
43
+ "@things-factory/setting-base": "^10.1.241",
44
+ "@things-factory/shell": "^10.1.234",
45
45
  "dompurify": "^3.0.0",
46
46
  "exceljs": "4.2.0",
47
47
  "graphql-scalars": "^1.22.0",
@@ -57,5 +57,5 @@
57
57
  "copyfiles": "^2.4.1",
58
58
  "rimraf": "^5.0.0"
59
59
  },
60
- "gitHead": "8ce73f35075a23dafe2785eb31735b459659a757"
60
+ "gitHead": "d7fe2e1a1eb7c02cb21b897da26d385df014f908"
61
61
  }
@@ -261,7 +261,7 @@ export class AssistantChatResolver {
261
261
  let session: ChatSession | undefined
262
262
  if (input.sessionId) {
263
263
  const found = await sessionRepo.findOneBy({ id: input.sessionId, domain: { id: domain.id } as any })
264
- if (!found) throw new Error(`ChatSession ${input.sessionId} not found`)
264
+ if (!found) throw new Error(t('ai-assistant.text.session-not-found', { defaultValue: 'this conversation is not here any more. reload the list.' }))
265
265
  /*
266
266
  * A private conversation is written by its people only - and here more than anywhere: the answer is
267
267
  * built on this conversation's history, so an outsider's question would read it back to them.
@@ -42,7 +42,7 @@ export class ChatMessage {
42
42
  @RelationId((message: ChatMessage) => message.session)
43
43
  sessionId?: string
44
44
 
45
- @Column({ type: 'varchar', length: 16 })
45
+ @Column({ length: 16 })
46
46
  @Field({ description: "'user' | 'assistant' | 'system'" })
47
47
  role!: string
48
48
 
@@ -71,7 +71,7 @@ export class ChatMessage {
71
71
  * 사람의 행에만 뜻이 있다. 어시스턴트의 답은 `parentMessage` 로 자기를 부른 턴에 귀속되고,
72
72
  * `system` 행은 누구에게 한 말이 아니다.
73
73
  */
74
- @Column({ type: 'varchar', length: 16, nullable: true })
74
+ @Column({ length: 16, nullable: true })
75
75
  @Field({ nullable: true, description: "Who this turn was for: 'assistant' | 'room'. Written by the door that stored it, never sent by the client." })
76
76
  addressee?: string
77
77
 
@@ -210,7 +210,7 @@ export class ChatMessage {
210
210
  }
211
211
  }
212
212
 
213
- @Column({ type: 'varchar', length: 1024, nullable: true })
213
+ @Column({ type: 'text', nullable: true })
214
214
  groundingWarnings?: string
215
215
 
216
216
  /** GraphQL 노출용 — 식별자 배열로 parse. */
@@ -136,7 +136,7 @@ export class ChatSession {
136
136
  * 값이 없는 세션은 이 칸이 서기 전에 난 것이다. 다음 턴에 요청한 kind 로 한 번 찍고, 찍힌 뒤에는
137
137
  * 「요청한 모드와 다르면 거절」 하나만 남는다. 마이그레이션은 만들지 않는다.
138
138
  */
139
- @Column({ type: 'varchar', length: 64, nullable: true })
139
+ @Column({ length: 64, nullable: true })
140
140
  @Field({ nullable: true, description: "Which conversation this session is — the registered mode kind. Written by the server, never sent by the client." })
141
141
  kind?: string
142
142
 
@@ -156,7 +156,7 @@ export class ChatSession {
156
156
  *
157
157
  * open 이어도 답 행의 문 판정(ADR-0073 판정 1)은 그대로다 — 방에 들어온 것과 값을 읽는 것은 다르다.
158
158
  */
159
- @Column({ type: 'varchar', length: 16, default: 'open' })
159
+ @Column({ length: 16, default: 'open' })
160
160
  @Field({ description: "Who may read this conversation: 'open' (anyone in the domain), 'group' (the owner and the people the owner brought in) or 'private' (the owner alone)." })
161
161
  visibility!: string
162
162
 
@@ -212,7 +212,7 @@ describe('private is the owner alone', () => {
212
212
  it('only the owner changes who reads it, and only to the three it knows', async () => {
213
213
  await doors.inviteToAISession(session.id, [guest.id], as(owner))
214
214
  expect(await doors.setSessionVisibility(session.id, 'open', as(guest))).toBe(false)
215
- await expect(doors.setSessionVisibility(session.id, 'team', as(owner))).rejects.toThrow(/unknown visibility/)
215
+ await expect(doors.setSessionVisibility(session.id, 'team', as(owner))).rejects.toThrow(/open, group or private/)
216
216
  })
217
217
  })
218
218
 
@@ -0,0 +1,32 @@
1
+ /*
2
+ * Words a conversation's doors say about its name and its attachments - pure, so the rule is tested alone.
3
+ *
4
+ * A name longer than the column was cut at 200 without a word, and an empty one was answered with `false`;
5
+ * the person saw the old name come back and no reason (inline help audit 2026-10-30). Both are refused in words now.
6
+ */
7
+
8
+ type Translate = (key: string, options: any) => string
9
+
10
+ /** A session name is kept up to the column's length; a longer one is refused, not cut. */
11
+ export const SESSION_NAME_MAX = 200
12
+
13
+ /** The kinds a mode takes, in the reader's words - the kind ids are not words a person reads. */
14
+ export function attachmentKindWords(kinds: string[], t: Translate): string {
15
+ return kinds.map(kind => t(`ai-assistant.label.attachment-kind-${kind}`, { defaultValue: kind })).join(', ')
16
+ }
17
+
18
+ /** The name a person typed, or a refusal naming the bound. Empty means "no name" only where the door allows it. */
19
+ export function sessionNameOf(name: string | null | undefined, t: Translate, allowEmpty: boolean): string {
20
+ const trimmed = (name ?? '').trim()
21
+ if (!trimmed && !allowEmpty) throw new Error(t('ai-assistant.text.session-name-empty', { defaultValue: 'a conversation name cannot be empty.' }))
22
+ if (trimmed.length > SESSION_NAME_MAX) {
23
+ throw new Error(
24
+ t('ai-assistant.text.session-name-too-long', {
25
+ max: SESSION_NAME_MAX,
26
+ length: trimmed.length,
27
+ defaultValue: 'a conversation name takes up to {max} characters; this one has {length}.'
28
+ })
29
+ )
30
+ }
31
+ return trimmed
32
+ }
@@ -73,7 +73,7 @@ export class ChatSessionParticipant {
73
73
  *
74
74
  * 시작은 'owner' / 'member' 둘로 충분. 더 세분화 필요해지면 enum 확장.
75
75
  */
76
- @Column({ type: 'varchar', length: 16, default: 'member' })
76
+ @Column({ length: 16, default: 'member' })
77
77
  @Field({ description: 'Role of this participant: owner | member | viewer.' })
78
78
  role!: string
79
79
 
@@ -81,6 +81,7 @@ import GraphQLUpload from 'graphql-upload/GraphQLUpload.js'
81
81
  import type { FileUpload } from 'graphql-upload/GraphQLUpload.js'
82
82
  import { getAssistantMode } from './assistant-mode/registry.js'
83
83
  import { refuseAttachments, type AttachmentRefusal } from './chat-session/attachment-policy.js'
84
+ import { attachmentKindWords, sessionNameOf } from './chat-session/session-name.js'
84
85
  import { removeAttachmentRows, saveChatAttachments } from './chat-session/session-attachments.js'
85
86
 
86
87
  @ObjectType({
@@ -195,6 +196,10 @@ export class ChatAttachmentInfo {
195
196
  size!: number
196
197
  }
197
198
 
199
+ function sessionNotFound(context: any): Error {
200
+ return new Error(requestTranslator(context)('ai-assistant.text.session-not-found', { defaultValue: 'this conversation is not here any more. reload the list.' }))
201
+ }
202
+
198
203
  /** A refusal said in the reader's words - code first, the sentence from it (ADR-0054). */
199
204
  function attachmentRefusalMessage(refusal: AttachmentRefusal, context: any): string {
200
205
  const t = (key: string, options: any) => (context.t ? context.t(key, options) : String(options?.defaultValue ?? key))
@@ -204,7 +209,7 @@ function attachmentRefusalMessage(refusal: AttachmentRefusal, context: any): str
204
209
  case 'attachment-kind-not-accepted':
205
210
  return t('ai-assistant.text.attachment-kind-not-accepted', {
206
211
  name: refusal.name,
207
- kinds: refusal.kinds.join(', '),
212
+ kinds: attachmentKindWords(refusal.kinds, t),
208
213
  defaultValue: '{name} cannot be attached here - this conversation takes {kinds}.'
209
214
  })
210
215
  case 'attachment-too-large':
@@ -421,7 +426,7 @@ export class ChatSessionResolver {
421
426
  const session = await repo.findOne({ where: { id: sessionId, domain: { id: domain.id } } as any, withDeleted: true })
422
427
  if (!session || !(session as any).deletedAt) return false
423
428
  if (!user?.id || (session as any).creatorId !== user.id) {
424
- throw new Error('Only the user who created this session may bring it back.')
429
+ throw new Error(requestTranslator(context)('ai-assistant.text.restore-creator-only', { defaultValue: 'only the person who started this conversation can bring it back.' }))
425
430
  }
426
431
  await repo.restore(sessionId)
427
432
  publishSessionActivity({
@@ -520,7 +525,11 @@ export class ChatSessionResolver {
520
525
  if (!session) return false
521
526
  if (!user?.id || (session as any).creatorId !== user.id) {
522
527
  /* 조용한 무동작이 아니라 명시 실패 — 지워진 줄 알고 넘어가면 안 된다. */
523
- throw new Error('Only the user who created this session may delete it. Hide it from your own list instead.')
528
+ throw new Error(
529
+ requestTranslator(context)('ai-assistant.text.delete-creator-only', {
530
+ defaultValue: 'only the person who started this conversation can delete it. hide it from your own list instead.'
531
+ })
532
+ )
524
533
  }
525
534
  await repo.softDelete(sessionId)
526
535
  publishSessionActivity({
@@ -607,7 +616,7 @@ export class ChatSessionResolver {
607
616
  ): Promise<boolean> {
608
617
  const { domain, user } = context.state
609
618
  if (!SESSION_VISIBILITIES.includes(visibility as any)) {
610
- throw new Error(`unknown visibility '${visibility}' — use ${SESSION_VISIBILITIES.join(' or ')}`)
619
+ throw new Error(requestTranslator(context)('ai-assistant.text.visibility-unknown', { defaultValue: 'a conversation is open, group or private.' }))
611
620
  }
612
621
 
613
622
  const session = await getRepository(ChatSession).findOneBy({ id: sessionId, domain: { id: domain.id } as any })
@@ -657,7 +666,7 @@ export class ChatSessionResolver {
657
666
  ): Promise<string[]> {
658
667
  const { domain, user } = context.state
659
668
  const session = await getRepository(ChatSession).findOneBy({ id: sessionId, domain: { id: domain.id } as any })
660
- if (!session) throw new Error('chat session not found')
669
+ if (!session) throw sessionNotFound(context)
661
670
 
662
671
  /* Only people of this domain - an id from elsewhere is not a colleague here. */
663
672
  const wanted = [...new Set((userIds ?? []).filter(id => typeof id === 'string' && id))].slice(0, 50)
@@ -789,8 +798,8 @@ export class ChatSessionResolver {
789
798
  @Ctx() context: ResolverContext
790
799
  ): Promise<boolean> {
791
800
  const { domain, user } = context.state
792
- const trimmed = (name ?? '').trim().slice(0, 200)
793
- if (!trimmed) return false // 빈 이름으로 지우지 않는다 — 지우려면 별도 의도가 필요하다
801
+ /* 빈 이름으로 지우지 않는다 — 지우려면 별도 의도가 필요하다. 거절은 말로 한다. */
802
+ const trimmed = sessionNameOf(name, requestTranslator(context), false)
794
803
  const participant = await getRepository(ChatSessionParticipant).findOne({
795
804
  where: { domain: { id: domain.id } as any, session: { id: sessionId } as any, user: { id: user.id } as any }
796
805
  })
@@ -1035,10 +1044,10 @@ export class ChatSessionResolver {
1035
1044
  const { domain, user, tx } = context.state
1036
1045
  const repo = getRepository(ChatSession, tx)
1037
1046
  const session = await repo.findOneBy({ id: sessionId, domain: { id: domain.id } })
1038
- if (!session) throw new Error(`ChatSession ${sessionId} not found`)
1047
+ if (!session) throw sessionNotFound(context)
1039
1048
  /* The board-era rename door had no gate at all - a private conversation's name was anyone's to change. */
1040
1049
  await writeGate(session, context, tx)
1041
- await repo.update(sessionId, { name: name.trim() || null, updater: user } as any)
1050
+ await repo.update(sessionId, { name: sessionNameOf(name, requestTranslator(context), true) || null, updater: user } as any)
1042
1051
  return true
1043
1052
  }
1044
1053
 
@@ -1056,7 +1065,7 @@ export class ChatSessionResolver {
1056
1065
  ): Promise<boolean> {
1057
1066
  const { domain, user, tx } = context.state
1058
1067
  const session = await getRepository(ChatSession, tx).findOneBy({ id: sessionId, domain: { id: domain.id } })
1059
- if (!session) throw new Error(`ChatSession ${sessionId} not found`)
1068
+ if (!session) throw sessionNotFound(context)
1060
1069
  await writeGate(session, context, tx)
1061
1070
 
1062
1071
  const key = (proposalKey ?? '').trim()
@@ -1105,7 +1114,7 @@ export class ChatSessionResolver {
1105
1114
  ): Promise<boolean> {
1106
1115
  const { domain, user, tx } = context.state
1107
1116
  const session = await getRepository(ChatSession, tx).findOneBy({ id: sessionId, domain: { id: domain.id } })
1108
- if (!session) throw new Error(`ChatSession ${sessionId} not found`)
1117
+ if (!session) throw sessionNotFound(context)
1109
1118
  await writeGate(session, context, tx)
1110
1119
 
1111
1120
  const key = (proposalKey ?? '').trim()
@@ -1154,7 +1163,7 @@ export class ChatSessionResolver {
1154
1163
  ): Promise<ChatAttachmentInfo[]> {
1155
1164
  const { domain, tx } = context.state
1156
1165
  const session = await getRepository(ChatSession, tx).findOneBy({ id: sessionId, domain: { id: domain.id } })
1157
- if (!session) throw new Error(`ChatSession ${sessionId} not found`)
1166
+ if (!session) throw sessionNotFound(context)
1158
1167
  await writeGate(session, context, tx)
1159
1168
 
1160
1169
  const policy = getAssistantMode((session as any).kind || kind || '')?.attachments
@@ -1186,7 +1195,7 @@ export class ChatSessionResolver {
1186
1195
  ): Promise<string | null> {
1187
1196
  const { domain, user, tx } = context.state
1188
1197
  const session = await getRepository(ChatSession, tx).findOneBy({ id: sessionId, domain: { id: domain.id } })
1189
- if (!session) throw new Error(`ChatSession ${sessionId} not found`)
1198
+ if (!session) throw sessionNotFound(context)
1190
1199
  await writeGate(session, context, tx)
1191
1200
 
1192
1201
  const body = roomMessageBody(content)
@@ -1196,7 +1205,9 @@ export class ChatSessionResolver {
1196
1205
  const previousLast = replyToId
1197
1206
  ? await messages.findOneBy({ id: replyToId, session: { id: session.id } as any })
1198
1207
  : await messages.findOne({ where: { session: { id: session.id } as any }, order: { createdAt: 'DESC' } })
1199
- if (replyToId && !previousLast) throw new Error('The message to reply to is not in this conversation')
1208
+ if (replyToId && !previousLast) {
1209
+ throw new Error(requestTranslator(context)('ai-assistant.text.reply-target-missing', { defaultValue: 'the message you are replying to is not in this conversation.' }))
1210
+ }
1200
1211
  const saved = await getRepository(ChatMessage, tx).save({
1201
1212
  session: { id: session.id } as any,
1202
1213
  role: 'user',
@@ -1256,7 +1267,7 @@ export class ChatSessionResolver {
1256
1267
  ): Promise<boolean> {
1257
1268
  const { domain, user, tx } = context.state
1258
1269
  const session = await getRepository(ChatSession, tx).findOneBy({ id: sessionId, domain: { id: domain.id } })
1259
- if (!session) throw new Error(`ChatSession ${sessionId} not found`)
1270
+ if (!session) throw sessionNotFound(context)
1260
1271
  await writeGate(session, context, tx)
1261
1272
  /* 프롬프트가 한 기록으로 폭증하지 않도록 자른다 — 맥락 전달이 목적이고 전문 보존이 아니다. */
1262
1273
  const body = (content ?? '').trim().slice(0, 4000)
@@ -1307,7 +1318,7 @@ export class ChatSessionResolver {
1307
1318
  ): Promise<boolean> {
1308
1319
  const { domain, user, tx } = context.state
1309
1320
  const session = await getRepository(ChatSession, tx).findOneBy({ id: sessionId, domain: { id: domain.id } })
1310
- if (!session) throw new Error(`ChatSession ${sessionId} not found`)
1321
+ if (!session) throw sessionNotFound(context)
1311
1322
  await writeGate(session, context, tx)
1312
1323
 
1313
1324
  /* 옮겨온 대화는 **그 대화의 발언 그대로** 들어간다 — 한 덩어리 기록으로 뭉치면 사람이 읽을 수 없고
@@ -148,7 +148,7 @@ export class AssistantRun {
148
148
  return parsed(this.attempts)
149
149
  }
150
150
 
151
- @Column({ type: 'varchar', length: 1024, nullable: true })
151
+ @Column({ type: 'text', nullable: true })
152
152
  groundingWarnings?: string
153
153
 
154
154
  @Column({ nullable: true })
@@ -233,7 +233,7 @@ export async function runAssistantUnattended(input: UnattendedRunInput, deps: Un
233
233
  draft: result.baseText,
234
234
  doors,
235
235
  cardsDropped: result.loop.proposals?.length ?? 0,
236
- groundingWarnings: result.groundingWarnings?.length ? JSON.stringify(result.groundingWarnings).slice(0, 1024) : null,
236
+ groundingWarnings: result.groundingWarnings?.length ? JSON.stringify(result.groundingWarnings) : null,
237
237
  ...spent()
238
238
  } as any)
239
239
  }
@@ -0,0 +1,80 @@
1
+ /*
2
+ * What this holds: **a Korean reader is refused in Korean, with the bound, and nothing is cut without a word.**
3
+ *
4
+ * Found by the inline help audit of 2026-10-30: the conversation doors threw English sentences
5
+ * (「ChatSession … not found」, 「Only the user who created this session…」), four owner and invite
6
+ * refusals had no Korean entry, the attachment refusal listed the kind ids `image, text, …` inside a
7
+ * Korean sentence, the roster said `3 online / 5 total` and `owner`, and a session name was cut at 200
8
+ * characters while an empty one came back as `false` with no reason.
9
+ */
10
+ import { readFileSync, readdirSync } from 'fs'
11
+ import { join } from 'path'
12
+
13
+ import { attachmentKindWords, sessionNameOf, SESSION_NAME_MAX } from '../server/service/chat-session/session-name'
14
+
15
+ const ROOT = join(__dirname, '..')
16
+ const read = (file: string) => readFileSync(join(ROOT, file), 'utf8')
17
+ const ko: Record<string, string> = JSON.parse(read('translations/ko.json'))
18
+
19
+ /* The framework's single-brace placeholders, in Korean. */
20
+ const t = (key: string, options: any = {}) =>
21
+ String(ko[key] ?? options.defaultValue ?? key).replace(/\{(\w+)\}/g, (whole, name) => (name in options ? String(options[name]) : whole))
22
+
23
+ function serverFiles(dir: string): string[] {
24
+ return readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
25
+ const path = join(dir, entry.name)
26
+ if (entry.isDirectory()) return serverFiles(path)
27
+ return /\.ts$/.test(entry.name) && !/\.test\.ts$/.test(entry.name) ? [path] : []
28
+ })
29
+ }
30
+
31
+ describe('a conversation door refuses in the reader’s words', () => {
32
+ const files = serverFiles(join(ROOT, 'server'))
33
+
34
+ it('reads the server source of this package', () => {
35
+ expect(files.length).toBeGreaterThanOrEqual(20)
36
+ })
37
+
38
+ it('★ every key the server translates has a Korean entry - a missing one is read in English', () => {
39
+ const used = new Set(files.flatMap(file => [...readFileSync(file, 'utf8').matchAll(/\(\s*'(ai-assistant\.[a-z0-9.-]+)'/g)].map(m => m[1])))
40
+ expect(used.size).toBeGreaterThanOrEqual(20)
41
+ expect([...used].filter(key => !(key in ko))).toEqual([])
42
+ })
43
+
44
+ it('★ the session doors throw no English sentence', () => {
45
+ for (const file of ['server/service/chat-session-resolver.ts', 'server/service/assistant-chat-resolver.ts']) {
46
+ expect({ file, english: read(file).match(/throw new Error\(\s*['`][A-Za-z][^'`]*['`]/g) ?? [] }).toEqual({ file, english: [] })
47
+ }
48
+ })
49
+ })
50
+
51
+ describe('a session name', () => {
52
+ it('★ a name over the bound is refused with the bound, not cut', () => {
53
+ const long = '가'.repeat(SESSION_NAME_MAX + 1)
54
+ expect(() => sessionNameOf(long, t, false)).toThrow(`대화 이름은 ${SESSION_NAME_MAX}자까지입니다. 지금 ${SESSION_NAME_MAX + 1}자입니다.`)
55
+ expect(sessionNameOf(' ' + '가'.repeat(SESSION_NAME_MAX) + ' ', t, false)).toBe('가'.repeat(SESSION_NAME_MAX))
56
+ expect(read('server/service/chat-session-resolver.ts')).not.toMatch(/\.slice\(0, 200\)/)
57
+ })
58
+
59
+ it('★ an empty name is refused in words where the door does not take it', () => {
60
+ expect(() => sessionNameOf(' ', t, false)).toThrow('대화 이름을 비울 수 없습니다.')
61
+ expect(sessionNameOf(' ', t, true)).toBe('')
62
+ })
63
+ })
64
+
65
+ describe('words on the screen', () => {
66
+ it('★ attachment kinds are named in Korean, on the door and on the screen', () => {
67
+ expect(attachmentKindWords(['image', 'text', 'spreadsheet', 'document'], t)).toBe('이미지, 텍스트 파일, 스프레드시트(엑셀·CSV), 문서(PDF·Word·PowerPoint)')
68
+ expect(read('server/service/chat-session-resolver.ts')).not.toMatch(/kinds\.join\(/)
69
+ expect(read('client/components/assistant-chat.ts')).not.toMatch(/params\.kinds = refusal\.kinds\.join\(/)
70
+ })
71
+
72
+ it('★ the roster says online and the role in the reader’s words', () => {
73
+ const chat = read('client/components/assistant-chat.ts')
74
+ expect(chat).not.toMatch(/ online \/ |' · online'| · \$\{p\.role\}/)
75
+ for (const key of ['roster-count', 'participant-role-owner', 'participant-role-member', 'participant-online']) {
76
+ const full = key === 'roster-count' ? `ai-assistant.text.${key}` : `ai-assistant.label.${key}`
77
+ expect(ko[full]).toBeTruthy()
78
+ }
79
+ })
80
+ })
@@ -75,7 +75,7 @@
75
75
  "ai-assistant.text.history-folded": "this conversation grew long — the earlier {count} messages were left out. a new conversation is better for a different topic.",
76
76
  "ai-assistant.text.history-folded-summarized": "this conversation grew long — the earlier {count} messages were folded into a summary. a new conversation is better for a different topic.",
77
77
  "ai-assistant.button.attach": "Attach",
78
- "ai-assistant.tooltip.attach-file": "attach an image (drag & drop or paste supported)",
78
+ "ai-assistant.tooltip.attach-file": "attach a file (drag & drop or paste supported)",
79
79
  "ai-assistant.text.drop-files-here": "Drop images or files here",
80
80
  "ai-assistant.button.remove-attachment": "Remove attachment",
81
81
  "ai-assistant.session.all": "All History",
@@ -493,5 +493,25 @@
493
493
  "ai-assistant.selection.region": "Work with selection",
494
494
  "ai-assistant.selection.pick-rows": "Select rows to work on with AI.",
495
495
  "ai-assistant.selection.save-first": "Save or cancel pending edits before working with the selected rows.",
496
- "ai-assistant.selection.ask": "Read the {n} selected records from {title} and suggest what can be done with them now."
496
+ "ai-assistant.selection.ask": "Read the {n} selected records from {title} and suggest what can be done with them now.",
497
+ "ai-assistant.label.attachment-kind-image": "images",
498
+ "ai-assistant.label.attachment-kind-text": "text files",
499
+ "ai-assistant.label.attachment-kind-spreadsheet": "spreadsheets (Excel, CSV)",
500
+ "ai-assistant.label.attachment-kind-document": "documents (PDF, Word, PowerPoint)",
501
+ "ai-assistant.text.roster-count": "{online} online of {total}",
502
+ "ai-assistant.label.participant-role-owner": "started this conversation",
503
+ "ai-assistant.label.participant-role-member": "participant",
504
+ "ai-assistant.label.participant-online": "online",
505
+ "ai-assistant.text.invite-private-refused": "a private conversation is yours alone. make it a group to bring people in.",
506
+ "ai-assistant.text.invite-owner-only": "only the person who started this group conversation can bring people in.",
507
+ "ai-assistant.text.remove-owner-only": "only the person who started this conversation can send someone out.",
508
+ "ai-assistant.text.owner-cannot-leave": "you started this conversation, so you cannot leave it. delete it, or make it private.",
509
+ "ai-assistant.text.session-not-found": "this conversation is not here any more. reload the list.",
510
+ "ai-assistant.text.restore-creator-only": "only the person who started this conversation can bring it back.",
511
+ "ai-assistant.text.delete-creator-only": "only the person who started this conversation can delete it. hide it from your own list instead.",
512
+ "ai-assistant.text.visibility-unknown": "a conversation is open, group or private.",
513
+ "ai-assistant.text.reply-target-missing": "the message you are replying to is not in this conversation.",
514
+ "ai-assistant.text.session-name-empty": "a conversation name cannot be empty.",
515
+ "ai-assistant.text.session-name-too-long": "a conversation name takes up to {max} characters; this one has {length}.",
516
+ "ai-assistant.text.private-session-refused": "this conversation is private to the people in it - only they can write here. ask the person who started it to bring you in."
497
517
  }
@@ -75,7 +75,7 @@
75
75
  "ai-assistant.text.mention-not-found": "#{token} 을(를) 찾을 수 없습니다",
76
76
  "ai-assistant.text.session-tab-label": "세션 {n}",
77
77
  "ai-assistant.button.attach": "첨부",
78
- "ai-assistant.tooltip.attach-file": "이미지 첨부 (클립보드 붙여넣기·드래그앤드롭 지원)",
78
+ "ai-assistant.tooltip.attach-file": "파일 첨부 (클립보드 붙여넣기·드래그앤드롭 지원)",
79
79
  "ai-assistant.text.drop-files-here": "여기에 이미지나 파일을 놓으세요",
80
80
  "ai-assistant.button.remove-attachment": "첨부 삭제",
81
81
  "ai-assistant.session.all": "전체 이력",
@@ -493,5 +493,25 @@
493
493
  "ai-assistant.selection.region": "선택 항목 작업",
494
494
  "ai-assistant.selection.pick-rows": "AI와 함께 작업할 행을 먼저 선택하세요.",
495
495
  "ai-assistant.selection.save-first": "편집 중인 내용을 먼저 저장하거나 취소한 뒤 선택한 행으로 작업하세요.",
496
- "ai-assistant.selection.ask": "{title}에서 선택한 {n}건을 읽고 지금 할 수 있는 일을 바로 제안해 줘."
496
+ "ai-assistant.selection.ask": "{title}에서 선택한 {n}건을 읽고 지금 할 수 있는 일을 바로 제안해 줘.",
497
+ "ai-assistant.label.attachment-kind-image": "이미지",
498
+ "ai-assistant.label.attachment-kind-text": "텍스트 파일",
499
+ "ai-assistant.label.attachment-kind-spreadsheet": "스프레드시트(엑셀·CSV)",
500
+ "ai-assistant.label.attachment-kind-document": "문서(PDF·Word·PowerPoint)",
501
+ "ai-assistant.text.roster-count": "{total}명 중 {online}명 접속 중",
502
+ "ai-assistant.label.participant-role-owner": "대화를 시작한 사람",
503
+ "ai-assistant.label.participant-role-member": "참여자",
504
+ "ai-assistant.label.participant-online": "접속 중",
505
+ "ai-assistant.text.invite-private-refused": "비공개 대화는 나만 읽습니다. 사람을 들이려면 그룹으로 바꾸세요.",
506
+ "ai-assistant.text.invite-owner-only": "그룹 대화에는 대화를 시작한 사람만 사람을 들일 수 있습니다.",
507
+ "ai-assistant.text.remove-owner-only": "대화를 시작한 사람만 참여자를 내보낼 수 있습니다.",
508
+ "ai-assistant.text.owner-cannot-leave": "이 대화를 시작한 사람은 나갈 수 없습니다. 대화를 지우거나 비공개로 바꾸세요.",
509
+ "ai-assistant.text.session-not-found": "이 대화를 찾을 수 없습니다. 목록을 새로 불러오세요.",
510
+ "ai-assistant.text.restore-creator-only": "대화를 시작한 사람만 되살릴 수 있습니다.",
511
+ "ai-assistant.text.delete-creator-only": "대화를 시작한 사람만 지울 수 있습니다. 내 목록에서 숨기기는 할 수 있습니다.",
512
+ "ai-assistant.text.visibility-unknown": "대화의 공개 범위는 공개·그룹·비공개 중 하나입니다.",
513
+ "ai-assistant.text.reply-target-missing": "답하려는 메시지가 이 대화에 없습니다.",
514
+ "ai-assistant.text.session-name-empty": "대화 이름을 비울 수 없습니다.",
515
+ "ai-assistant.text.session-name-too-long": "대화 이름은 {max}자까지입니다. 지금 {length}자입니다.",
516
+ "ai-assistant.text.private-session-refused": "이 대화는 참여자만 쓸 수 있습니다. 대화를 시작한 사람에게 초대를 부탁하세요."
497
517
  }