@codori/client 0.0.7 → 0.0.9
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/app/assets/css/main.css +22 -0
- package/app/components/BottomDrawerShell.vue +62 -0
- package/app/components/ChatWorkspace.vue +1009 -13
- package/app/components/LocalFileViewerModal.vue +314 -0
- package/app/components/MessagePartRenderer.ts +1 -0
- package/app/components/PendingUserRequestDrawer.vue +88 -0
- package/app/components/ProjectStatusDot.vue +1 -2
- package/app/components/ReviewStartDrawer.vue +186 -0
- package/app/components/SubagentTranscriptPanel.vue +5 -1
- package/app/components/UsageStatusModal.vue +265 -0
- package/app/components/VisualSubagentStack.vue +2 -0
- package/app/components/message-part/Event.vue +42 -8
- package/app/components/message-part/LocalFileLink.vue +51 -0
- package/app/components/message-part/ReviewPriorityBadge.vue +38 -0
- package/app/components/message-part/Text.vue +159 -10
- package/app/components/pending-request/McpElicitationForm.vue +264 -0
- package/app/components/pending-request/McpElicitationUrlPrompt.vue +100 -0
- package/app/components/pending-request/RequestUserInputForm.vue +235 -0
- package/app/composables/useChatSession.ts +1 -0
- package/app/composables/useLocalFileViewer.ts +46 -0
- package/app/composables/usePendingUserRequest.ts +124 -0
- package/app/composables/useThreadSummaries.ts +28 -2
- package/app/pages/index.vue +0 -1
- package/app/pages/projects/[...projectId]/threads/[threadId].vue +2 -0
- package/app/utils/chat-turn-engagement.ts +11 -2
- package/app/utils/review-priority-badge.ts +90 -0
- package/app/utils/slash-prompt-focus.ts +8 -0
- package/package.json +8 -1
- package/server/api/codori/projects/[projectId]/git/branches.get.ts +15 -0
- package/server/api/codori/projects/[projectId]/local-file.get.ts +44 -0
- package/shared/account-rate-limits.ts +190 -0
- package/shared/codex-chat.ts +72 -2
- package/shared/codex-rpc.ts +79 -3
- package/shared/codori.ts +20 -0
- package/shared/file-autocomplete.ts +166 -0
- package/shared/file-highlighting.ts +127 -0
- package/shared/local-files.ts +122 -0
- package/shared/pending-user-request.ts +374 -0
- package/shared/slash-commands.ts +97 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { computed, ref, type Ref } from 'vue'
|
|
2
|
+
import type { CodexRpcServerRequest } from '../../shared/codex-rpc'
|
|
3
|
+
import {
|
|
4
|
+
buildPendingUserRequestDismissResponse,
|
|
5
|
+
parsePendingUserRequest,
|
|
6
|
+
type PendingUserRequest
|
|
7
|
+
} from '../../shared/pending-user-request'
|
|
8
|
+
|
|
9
|
+
type PendingUserRequestQueueEntry = {
|
|
10
|
+
request: PendingUserRequest
|
|
11
|
+
resolve: (value: unknown) => void
|
|
12
|
+
reject: (error: unknown) => void
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type PendingUserRequestSession = {
|
|
16
|
+
current: Ref<PendingUserRequest | null>
|
|
17
|
+
queue: PendingUserRequestQueueEntry[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const sessions = new Map<string, PendingUserRequestSession>()
|
|
21
|
+
|
|
22
|
+
const sessionKey = (projectId: string, threadId: string | null) =>
|
|
23
|
+
`${projectId}::${threadId ?? '__draft__'}`
|
|
24
|
+
|
|
25
|
+
const getSession = (projectId: string, threadId: string | null): PendingUserRequestSession => {
|
|
26
|
+
const key = sessionKey(projectId, threadId)
|
|
27
|
+
const existing = sessions.get(key)
|
|
28
|
+
if (existing) {
|
|
29
|
+
return existing
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const session: PendingUserRequestSession = {
|
|
33
|
+
current: ref<PendingUserRequest | null>(null),
|
|
34
|
+
queue: []
|
|
35
|
+
}
|
|
36
|
+
sessions.set(key, session)
|
|
37
|
+
return session
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const promoteNextRequest = (session: PendingUserRequestSession) => {
|
|
41
|
+
session.current.value = session.queue[0]?.request ?? null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const resolveQueuedRequest = (session: PendingUserRequestSession, responseFactory: (request: PendingUserRequest) => unknown) => {
|
|
45
|
+
while (session.queue.length > 0) {
|
|
46
|
+
const entry = session.queue.shift()
|
|
47
|
+
if (!entry) {
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
entry.resolve(responseFactory(entry.request))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
session.current.value = null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const usePendingUserRequest = (projectId: string, activeThreadId: Ref<string | null>) => {
|
|
58
|
+
const resolveSession = (threadId: string | null) => getSession(projectId, threadId)
|
|
59
|
+
|
|
60
|
+
const handleServerRequest = async (request: CodexRpcServerRequest) => {
|
|
61
|
+
const normalized = parsePendingUserRequest(request)
|
|
62
|
+
if (!normalized) {
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const session = resolveSession(normalized.threadId ?? activeThreadId.value)
|
|
67
|
+
|
|
68
|
+
return await new Promise((resolve, reject) => {
|
|
69
|
+
session.queue.push({
|
|
70
|
+
request: normalized,
|
|
71
|
+
resolve,
|
|
72
|
+
reject
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
if (!session.current.value) {
|
|
76
|
+
promoteNextRequest(session)
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const resolveCurrentRequest = (response: unknown) => {
|
|
82
|
+
const session = resolveSession(activeThreadId.value)
|
|
83
|
+
const current = session.queue.shift()
|
|
84
|
+
if (!current) {
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
current.resolve(response)
|
|
89
|
+
promoteNextRequest(session)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const rejectCurrentRequest = (error: unknown) => {
|
|
93
|
+
const session = resolveSession(activeThreadId.value)
|
|
94
|
+
const current = session.queue.shift()
|
|
95
|
+
if (!current) {
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
current.reject(error)
|
|
100
|
+
promoteNextRequest(session)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const cancelAllPendingRequests = () => {
|
|
104
|
+
const projectSessionPrefix = `${projectId}::`
|
|
105
|
+
|
|
106
|
+
for (const [key, session] of sessions.entries()) {
|
|
107
|
+
if (!key.startsWith(projectSessionPrefix)) {
|
|
108
|
+
continue
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
resolveQueuedRequest(session, buildPendingUserRequestDismissResponse)
|
|
112
|
+
sessions.delete(key)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
pendingRequest: computed(() => resolveSession(activeThreadId.value).current.value),
|
|
118
|
+
hasPendingRequest: computed(() => resolveSession(activeThreadId.value).current.value !== null),
|
|
119
|
+
handleServerRequest,
|
|
120
|
+
resolveCurrentRequest,
|
|
121
|
+
rejectCurrentRequest,
|
|
122
|
+
cancelAllPendingRequests
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -23,8 +23,34 @@ type UseThreadSummariesResult = ThreadSummariesState & {
|
|
|
23
23
|
|
|
24
24
|
const states = new Map<string, ThreadSummariesState>()
|
|
25
25
|
|
|
26
|
+
export const normalizeThreadTitleCandidate = (value: string | null | undefined) => {
|
|
27
|
+
const raw = value?.trim() ?? ''
|
|
28
|
+
if (!raw) {
|
|
29
|
+
return ''
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const stripped = raw
|
|
33
|
+
.replace(/<\/?[a-z_]+>/gi, ' ')
|
|
34
|
+
.replace(/\s+/g, ' ')
|
|
35
|
+
.trim()
|
|
36
|
+
|
|
37
|
+
if (!stripped) {
|
|
38
|
+
return ''
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (
|
|
42
|
+
/<(?:user_action|context)>/i.test(raw)
|
|
43
|
+
|| /user initiated a review task/i.test(stripped)
|
|
44
|
+
|| /review output from reviewer mode/i.test(stripped)
|
|
45
|
+
) {
|
|
46
|
+
return 'Code Review'
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return stripped
|
|
50
|
+
}
|
|
51
|
+
|
|
26
52
|
export const resolveThreadSummaryTitle = (thread: Pick<CodexThread, 'id' | 'name' | 'preview'>) => {
|
|
27
|
-
const nextTitle = thread.name
|
|
53
|
+
const nextTitle = normalizeThreadTitleCandidate(thread.name) || normalizeThreadTitleCandidate(thread.preview)
|
|
28
54
|
return nextTitle || `Thread ${thread.id}`
|
|
29
55
|
}
|
|
30
56
|
|
|
@@ -41,7 +67,7 @@ export const renameThreadSummary = (
|
|
|
41
67
|
updatedAt?: number
|
|
42
68
|
}
|
|
43
69
|
) => {
|
|
44
|
-
const nextTitle = input.title
|
|
70
|
+
const nextTitle = normalizeThreadTitleCandidate(input.title)
|
|
45
71
|
if (!nextTitle) {
|
|
46
72
|
return threads
|
|
47
73
|
}
|
package/app/pages/index.vue
CHANGED
|
@@ -376,6 +376,7 @@ watch(
|
|
|
376
376
|
<template #body>
|
|
377
377
|
<VisualSubagentStack
|
|
378
378
|
:agents="availablePanels"
|
|
379
|
+
:project-id="projectId"
|
|
379
380
|
class="h-full min-h-0"
|
|
380
381
|
@expand="openExpandedSubagent"
|
|
381
382
|
/>
|
|
@@ -441,6 +442,7 @@ watch(
|
|
|
441
442
|
<SubagentTranscriptPanel
|
|
442
443
|
v-if="expandedSubagentPanel"
|
|
443
444
|
:agent="expandedSubagentPanel"
|
|
445
|
+
:project-id="projectId"
|
|
444
446
|
:accent="expandedSubagentAccent"
|
|
445
447
|
scroll-scope="expanded"
|
|
446
448
|
expanded
|
|
@@ -48,14 +48,23 @@ export const shouldRetrySteerWithTurnStart = (message: string) =>
|
|
|
48
48
|
|
|
49
49
|
export const shouldApplyNotificationToCurrentTurn = (input: {
|
|
50
50
|
liveStreamTurnId: string | null
|
|
51
|
+
lockedTurnId?: string | null
|
|
51
52
|
notificationMethod: string
|
|
52
53
|
notificationTurnId: string | null
|
|
53
54
|
}) =>
|
|
54
|
-
input.liveStreamTurnId === null
|
|
55
|
+
(input.lockedTurnId ?? input.liveStreamTurnId) === null
|
|
55
56
|
|| input.notificationTurnId === null
|
|
56
|
-
|| input.notificationTurnId === input.liveStreamTurnId
|
|
57
|
+
|| input.notificationTurnId === (input.lockedTurnId ?? input.liveStreamTurnId)
|
|
57
58
|
|| input.notificationMethod === 'turn/started'
|
|
58
59
|
|
|
60
|
+
export const shouldAdvanceLiveStreamTurn = (input: {
|
|
61
|
+
lockedTurnId?: string | null
|
|
62
|
+
nextTurnId: string | null
|
|
63
|
+
}) =>
|
|
64
|
+
!input.lockedTurnId
|
|
65
|
+
|| !input.nextTurnId
|
|
66
|
+
|| input.nextTurnId === input.lockedTurnId
|
|
67
|
+
|
|
59
68
|
export const resolvePromptSubmitStatus = (input: {
|
|
60
69
|
status: PromptSubmitStatus
|
|
61
70
|
hasDraftContent: boolean
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { ComarkElement, ComarkNode, ComarkPlugin, ComarkTree } from '@comark/vue'
|
|
2
|
+
|
|
3
|
+
export const REVIEW_PRIORITY_BADGE_TAG = 'review-priority-badge'
|
|
4
|
+
|
|
5
|
+
const REVIEW_PRIORITY_PATTERN = /^P([1-3])$/i
|
|
6
|
+
|
|
7
|
+
const isElementNode = (node: ComarkNode): node is ComarkElement => {
|
|
8
|
+
return Array.isArray(node) && typeof node[0] === 'string'
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const getPriorityLabel = (node: ComarkElement) => {
|
|
12
|
+
if (node[0] !== 'span') {
|
|
13
|
+
return null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const children = node.slice(2)
|
|
17
|
+
|
|
18
|
+
if (children.length !== 1 || typeof children[0] !== 'string') {
|
|
19
|
+
return null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const label = children[0].trim().toUpperCase()
|
|
23
|
+
|
|
24
|
+
return REVIEW_PRIORITY_PATTERN.test(label) ? label as `P${1 | 2 | 3}` : null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const createPriorityBadgeNode = (label: `P${1 | 2 | 3}`): ComarkElement => {
|
|
28
|
+
return [REVIEW_PRIORITY_BADGE_TAG, { priority: label.slice(1) }, label]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const replaceLeadingPriorityBadge = (children: ComarkNode[]) => {
|
|
32
|
+
const targetIndex = children.findIndex((child) => {
|
|
33
|
+
return typeof child !== 'string' || child.trim().length > 0
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
if (targetIndex === -1) {
|
|
37
|
+
return children
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const target = children[targetIndex]
|
|
41
|
+
|
|
42
|
+
if (!target) {
|
|
43
|
+
return children
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!isElementNode(target)) {
|
|
47
|
+
return children
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const label = getPriorityLabel(target)
|
|
51
|
+
|
|
52
|
+
if (!label) {
|
|
53
|
+
return children
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return children.map((child, index) => {
|
|
57
|
+
return index === targetIndex ? createPriorityBadgeNode(label) : child
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const transformNode = (node: ComarkNode, parentTag?: string): ComarkNode => {
|
|
62
|
+
if (!isElementNode(node)) {
|
|
63
|
+
return node
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const [tag, props, ...children] = node
|
|
67
|
+
let nextChildren = children.map(child => transformNode(child, tag))
|
|
68
|
+
|
|
69
|
+
if (tag === 'li' || (tag === 'p' && parentTag === 'li')) {
|
|
70
|
+
nextChildren = replaceLeadingPriorityBadge(nextChildren)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return [tag, props, ...nextChildren]
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const transformReviewPriorityBadges = (tree: ComarkTree): ComarkTree => {
|
|
77
|
+
return {
|
|
78
|
+
...tree,
|
|
79
|
+
nodes: tree.nodes.map(node => transformNode(node))
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const reviewPriorityBadgePlugin = (): ComarkPlugin => {
|
|
84
|
+
return {
|
|
85
|
+
name: 'review-priority-badge',
|
|
86
|
+
post: (state) => {
|
|
87
|
+
state.tree = transformReviewPriorityBadges(state.tree)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Slash-command popups are rendered as inert overlays. This helper is only for
|
|
2
|
+
// the composer blur guard to recognize "still inside the slash popup" targets.
|
|
3
|
+
export const isFocusWithinContainer = (
|
|
4
|
+
target: EventTarget | null | undefined,
|
|
5
|
+
container: ParentNode | null | undefined
|
|
6
|
+
) =>
|
|
7
|
+
target instanceof Node
|
|
8
|
+
&& Boolean(container?.contains(target))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@codori/client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.9",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Codori Nuxt dashboard for project browsing, Codex chat, and thread resume.",
|
|
6
6
|
"type": "module",
|
|
@@ -41,10 +41,17 @@
|
|
|
41
41
|
"@comark/vue": "^0.2.0",
|
|
42
42
|
"@iconify-json/lucide": "^1.2.87",
|
|
43
43
|
"@nuxt/ui": "^4.6.0",
|
|
44
|
+
"beautiful-mermaid": "^1.1.3",
|
|
45
|
+
"katex": "^0.16.45",
|
|
44
46
|
"nuxt": "^4.3.0",
|
|
45
47
|
"ofetch": "^1.4.1",
|
|
46
48
|
"shiki": "^3.23.0",
|
|
47
49
|
"tailwindcss": "^4.1.14",
|
|
48
50
|
"vue": "^3.5.22"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@vitejs/plugin-vue": "^6.0.6",
|
|
54
|
+
"@vue/test-utils": "^2.4.6",
|
|
55
|
+
"jsdom": "^29.0.2"
|
|
49
56
|
}
|
|
50
57
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { defineEventHandler, getRouterParam } from 'h3'
|
|
2
|
+
import { encodeProjectIdSegment, type ProjectGitBranchesResponse } from '~~/shared/codori'
|
|
3
|
+
import { proxyServerRequest } from '../../../../../utils/server-proxy'
|
|
4
|
+
|
|
5
|
+
export default defineEventHandler(async (event) => {
|
|
6
|
+
const projectId = getRouterParam(event, 'projectId')
|
|
7
|
+
if (!projectId) {
|
|
8
|
+
throw new Error('Missing project id.')
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
return await proxyServerRequest<ProjectGitBranchesResponse>(
|
|
12
|
+
event,
|
|
13
|
+
`/api/projects/${encodeProjectIdSegment(projectId)}/git/branches`
|
|
14
|
+
)
|
|
15
|
+
})
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createError,
|
|
3
|
+
defineEventHandler,
|
|
4
|
+
getQuery,
|
|
5
|
+
getRouterParam
|
|
6
|
+
} from 'h3'
|
|
7
|
+
import { encodeProjectIdSegment } from '~~/shared/codori'
|
|
8
|
+
import type { ProjectLocalFileResponse } from '~~/shared/local-files'
|
|
9
|
+
import { proxyServerRequest } from '../../../../utils/server-proxy'
|
|
10
|
+
|
|
11
|
+
export default defineEventHandler(async (event) => {
|
|
12
|
+
const projectId = getRouterParam(event, 'projectId')
|
|
13
|
+
if (!projectId) {
|
|
14
|
+
throw createError({
|
|
15
|
+
statusCode: 400,
|
|
16
|
+
statusMessage: 'Missing project id.'
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const queryValue = getQuery(event).path
|
|
21
|
+
const path = typeof queryValue === 'string'
|
|
22
|
+
? queryValue
|
|
23
|
+
: ''
|
|
24
|
+
|
|
25
|
+
if (!path) {
|
|
26
|
+
throw createError({
|
|
27
|
+
statusCode: 400,
|
|
28
|
+
statusMessage: 'Missing local file path.'
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
return await proxyServerRequest<ProjectLocalFileResponse>(
|
|
34
|
+
event,
|
|
35
|
+
`/api/projects/${encodeProjectIdSegment(projectId)}/local-file?${new URLSearchParams({ path }).toString()}`
|
|
36
|
+
)
|
|
37
|
+
} catch (error) {
|
|
38
|
+
const details = error as { statusCode?: number, statusMessage?: string }
|
|
39
|
+
throw createError({
|
|
40
|
+
statusCode: details.statusCode ?? 500,
|
|
41
|
+
statusMessage: details.statusMessage ?? 'Local file preview failed.'
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
})
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
export type RateLimitWindow = {
|
|
2
|
+
usedPercent: number | null
|
|
3
|
+
resetsAt: string | null
|
|
4
|
+
windowDurationMins: number | null
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export type RateLimitBucket = {
|
|
8
|
+
limitId: string
|
|
9
|
+
limitName: string | null
|
|
10
|
+
primary: RateLimitWindow | null
|
|
11
|
+
secondary: RateLimitWindow | null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
|
|
15
|
+
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
16
|
+
|
|
17
|
+
const toFiniteNumber = (value: unknown) => {
|
|
18
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
19
|
+
return value
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (typeof value === 'bigint') {
|
|
23
|
+
return Number(value)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (typeof value === 'string' && value.trim()) {
|
|
27
|
+
const parsed = Number(value)
|
|
28
|
+
if (Number.isFinite(parsed)) {
|
|
29
|
+
return parsed
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const toPercent = (value: unknown) => {
|
|
37
|
+
const parsed = toFiniteNumber(value)
|
|
38
|
+
if (parsed == null) {
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const normalized = parsed > 0 && parsed <= 1 ? parsed * 100 : parsed
|
|
43
|
+
return Math.max(0, Math.min(100, normalized))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const toTimestamp = (value: unknown) => {
|
|
47
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return Number.isNaN(Date.parse(value)) ? null : value
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const normalizeRateLimitWindow = (value: unknown): RateLimitWindow | null => {
|
|
55
|
+
const record = isObjectRecord(value) ? value : null
|
|
56
|
+
if (!record) {
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const usedPercent = toPercent(record.usedPercent)
|
|
61
|
+
const resetsAt = toTimestamp(record.resetsAt)
|
|
62
|
+
const windowDurationMins = toFiniteNumber(record.windowDurationMins)
|
|
63
|
+
|
|
64
|
+
if (usedPercent == null && resetsAt == null && windowDurationMins == null) {
|
|
65
|
+
return null
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
usedPercent,
|
|
70
|
+
resetsAt,
|
|
71
|
+
windowDurationMins
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const mergeRateLimitWindow = (
|
|
76
|
+
existing: RateLimitWindow | null,
|
|
77
|
+
incoming: RateLimitWindow | null
|
|
78
|
+
): RateLimitWindow | null => {
|
|
79
|
+
if (!existing) {
|
|
80
|
+
return incoming
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!incoming) {
|
|
84
|
+
return existing
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
usedPercent: incoming.usedPercent ?? existing.usedPercent,
|
|
89
|
+
resetsAt: incoming.resetsAt ?? existing.resetsAt,
|
|
90
|
+
windowDurationMins: incoming.windowDurationMins ?? existing.windowDurationMins
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const normalizeRateLimitBucket = (value: unknown): RateLimitBucket | null => {
|
|
95
|
+
const record = isObjectRecord(value) ? value : null
|
|
96
|
+
if (!record) {
|
|
97
|
+
return null
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const limitId = typeof record.limitId === 'string' && record.limitId.trim()
|
|
101
|
+
? record.limitId.trim()
|
|
102
|
+
: null
|
|
103
|
+
const limitName = typeof record.limitName === 'string' && record.limitName.trim()
|
|
104
|
+
? record.limitName.trim()
|
|
105
|
+
: null
|
|
106
|
+
|
|
107
|
+
if (!limitId && !limitName) {
|
|
108
|
+
return null
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const primary = normalizeRateLimitWindow(record.primary)
|
|
112
|
+
const secondary = normalizeRateLimitWindow(record.secondary)
|
|
113
|
+
if (!primary && !secondary) {
|
|
114
|
+
return null
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
limitId: limitId ?? limitName ?? 'unknown',
|
|
119
|
+
limitName,
|
|
120
|
+
primary,
|
|
121
|
+
secondary
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const collectRateLimitCandidates = (value: unknown) => {
|
|
126
|
+
const root = isObjectRecord(value) ? value : null
|
|
127
|
+
const rateLimits = Array.isArray(root?.rateLimits) ? root.rateLimits : []
|
|
128
|
+
const rateLimitsByLimitId = isObjectRecord(root?.rateLimitsByLimitId)
|
|
129
|
+
? Object.values(root.rateLimitsByLimitId)
|
|
130
|
+
: []
|
|
131
|
+
|
|
132
|
+
if (rateLimits.length > 0 || rateLimitsByLimitId.length > 0) {
|
|
133
|
+
return [...rateLimits, ...rateLimitsByLimitId]
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return Array.isArray(value) ? value : []
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export const normalizeAccountRateLimits = (value: unknown): RateLimitBucket[] => {
|
|
140
|
+
const bucketsById = new Map<string, RateLimitBucket>()
|
|
141
|
+
|
|
142
|
+
for (const candidate of collectRateLimitCandidates(value)) {
|
|
143
|
+
const bucket = normalizeRateLimitBucket(candidate)
|
|
144
|
+
if (!bucket) {
|
|
145
|
+
continue
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const existing = bucketsById.get(bucket.limitId)
|
|
149
|
+
if (!existing) {
|
|
150
|
+
bucketsById.set(bucket.limitId, bucket)
|
|
151
|
+
continue
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
bucketsById.set(bucket.limitId, {
|
|
155
|
+
...existing,
|
|
156
|
+
limitName: existing.limitName ?? bucket.limitName,
|
|
157
|
+
primary: mergeRateLimitWindow(existing.primary, bucket.primary),
|
|
158
|
+
secondary: mergeRateLimitWindow(existing.secondary, bucket.secondary)
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return [...bucketsById.values()].sort((left, right) => {
|
|
163
|
+
const leftLabel = (left.limitName ?? left.limitId).toLowerCase()
|
|
164
|
+
const rightLabel = (right.limitName ?? right.limitId).toLowerCase()
|
|
165
|
+
return leftLabel.localeCompare(rightLabel)
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export const formatRateLimitWindowDuration = (value: number | null) => {
|
|
170
|
+
if (value == null || value <= 0) {
|
|
171
|
+
return null
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (value % (60 * 24 * 7) === 0) {
|
|
175
|
+
const weeks = value / (60 * 24 * 7)
|
|
176
|
+
return `${weeks}w window`
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (value % (60 * 24) === 0) {
|
|
180
|
+
const days = value / (60 * 24)
|
|
181
|
+
return `${days}d window`
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (value % 60 === 0) {
|
|
185
|
+
const hours = value / 60
|
|
186
|
+
return `${hours}h window`
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return `${value}m window`
|
|
190
|
+
}
|
package/shared/codex-chat.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CodexThread, CodexThreadItem, CodexUserInput } from './codex-rpc'
|
|
1
|
+
import type { CodexThread, CodexThreadItem, CodexTurn, CodexUserInput } from './codex-rpc'
|
|
2
2
|
|
|
3
3
|
export const EVENT_PART = 'data-thread-event' as const
|
|
4
4
|
export const ITEM_PART = 'data-thread-item' as const
|
|
@@ -8,6 +8,13 @@ export type ThreadEventData =
|
|
|
8
8
|
kind: 'thread.started' | 'thread.ended' | 'thread.title' | 'turn.started' | 'turn.completed'
|
|
9
9
|
title?: string | null
|
|
10
10
|
}
|
|
11
|
+
| {
|
|
12
|
+
kind: 'review.started'
|
|
13
|
+
summary: string | null
|
|
14
|
+
}
|
|
15
|
+
| {
|
|
16
|
+
kind: 'review.completed'
|
|
17
|
+
}
|
|
11
18
|
| {
|
|
12
19
|
kind: 'turn.failed'
|
|
13
20
|
error: {
|
|
@@ -161,6 +168,29 @@ const userInputToParts = (input: CodexUserInput): ChatPart[] => {
|
|
|
161
168
|
}]
|
|
162
169
|
}
|
|
163
170
|
|
|
171
|
+
const getUserMessageText = (item: Extract<CodexThreadItem, { type: 'userMessage' }>) =>
|
|
172
|
+
item.content
|
|
173
|
+
.filter((input): input is Extract<CodexUserInput, { type: 'text' }> => input.type === 'text')
|
|
174
|
+
.map(input => input.text.trim())
|
|
175
|
+
.filter(Boolean)
|
|
176
|
+
.join('\n')
|
|
177
|
+
|
|
178
|
+
const shouldHideReviewBootstrapUserMessage = (
|
|
179
|
+
item: Extract<CodexThreadItem, { type: 'userMessage' }>,
|
|
180
|
+
turn: CodexTurn
|
|
181
|
+
) => {
|
|
182
|
+
const reviewLifecycle = turn.items.find((candidate): candidate is Extract<CodexThreadItem, { type: 'enteredReviewMode' | 'exitedReviewMode' }> =>
|
|
183
|
+
(candidate.type === 'enteredReviewMode' || candidate.type === 'exitedReviewMode')
|
|
184
|
+
&& candidate.id === item.id
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
if (!reviewLifecycle) {
|
|
188
|
+
return false
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return getUserMessageText(item) === reviewLifecycle.review.trim()
|
|
192
|
+
}
|
|
193
|
+
|
|
164
194
|
export const itemToMessages = (item: CodexThreadItem): ChatMessage[] => {
|
|
165
195
|
switch (item.type) {
|
|
166
196
|
case 'userMessage':
|
|
@@ -300,13 +330,53 @@ export const itemToMessages = (item: CodexThreadItem): ChatMessage[] => {
|
|
|
300
330
|
}
|
|
301
331
|
}]
|
|
302
332
|
}]
|
|
333
|
+
case 'enteredReviewMode':
|
|
334
|
+
return [{
|
|
335
|
+
id: `${item.id}-review-started`,
|
|
336
|
+
role: 'system',
|
|
337
|
+
parts: [{
|
|
338
|
+
type: EVENT_PART,
|
|
339
|
+
data: {
|
|
340
|
+
kind: 'review.started',
|
|
341
|
+
summary: item.review.trim() || null
|
|
342
|
+
}
|
|
343
|
+
}]
|
|
344
|
+
}]
|
|
345
|
+
case 'exitedReviewMode': {
|
|
346
|
+
return [{
|
|
347
|
+
id: `${item.id}-review-completed`,
|
|
348
|
+
role: 'system',
|
|
349
|
+
parts: [{
|
|
350
|
+
type: EVENT_PART,
|
|
351
|
+
data: {
|
|
352
|
+
kind: 'review.completed'
|
|
353
|
+
}
|
|
354
|
+
}]
|
|
355
|
+
}, {
|
|
356
|
+
id: `${item.id}-review-output`,
|
|
357
|
+
role: 'assistant',
|
|
358
|
+
parts: [{
|
|
359
|
+
type: 'text',
|
|
360
|
+
text: item.review,
|
|
361
|
+
state: 'done'
|
|
362
|
+
}]
|
|
363
|
+
}]
|
|
364
|
+
}
|
|
303
365
|
default:
|
|
304
366
|
return []
|
|
305
367
|
}
|
|
306
368
|
}
|
|
307
369
|
|
|
308
370
|
export const threadToMessages = (thread: CodexThread) =>
|
|
309
|
-
thread.turns.flatMap(turn =>
|
|
371
|
+
thread.turns.flatMap((turn) =>
|
|
372
|
+
turn.items.flatMap((item) => {
|
|
373
|
+
if (item.type === 'userMessage' && shouldHideReviewBootstrapUserMessage(item, turn)) {
|
|
374
|
+
return []
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return itemToMessages(item)
|
|
378
|
+
})
|
|
379
|
+
)
|
|
310
380
|
|
|
311
381
|
export const eventToMessage = (id: string, data: ThreadEventData): ChatMessage => ({
|
|
312
382
|
id,
|