mirror of
https://github.com/ZSeven-W/openpencil.git
synced 2026-06-01 03:14:29 +07:00
V0.0.2 (#9)
* chore(docs): add video demo to README.md for enhanced project visibility * refactor(ai): centralize Claude Agent SDK env resolution with debug logging and model retry Extract shared buildClaudeAgentEnv() and getClaudeAgentDebugFilePath() into resolve-claude-agent-env.ts to eliminate duplicated env setup across all server endpoints. Add model fallback retry logic (retry without explicit model on exit code 1) and diagnostic hints from debug log tail on failures. * fix(canvas): normalize layout justify/align values from CSS aliases AI-generated nodes may use CSS-style values like "flex-start", "space-between", "middle" etc. Add normalizeJustifyContent and normalizeAlignItems to map these to internal enum values in both the layout engine and the property panel. * fix(canvas): improve text and path rendering accuracy - Remove icon-specific bounding box override on path scaling to prevent pathOffset drift that visually offsets icons in logo containers - Only use Textbox for explicit fixed-width text modes instead of any node with width > 0, preventing unwanted word wrapping on auto-width text - Add widthSafetyFactor for Latin text estimation (1.14x vs 1.06x CJK) to reduce accidental line wraps from font width variation * feat(ai): add input icon affordance rules and trailing icon alignment Prompt AI to include semantic icons in form inputs (search, password, email). Add normalizeInputTrailingIconAlignment post-pass in role-resolver to auto-set justifyContent="space_between" on input frames with a trailing icon node. * feat(ai): persist model selection across sessions via localStorage Add preferredModel and selectModel to ai-store with localStorage read/write. Hydrate on mount to restore user's last chosen model. Add isHydrated flag to agent-settings-store to prevent race conditions during provider list construction. --------- Co-authored-by: Fini <fini.yang@gmail.com>
This commit is contained in:
parent
24ed397f0f
commit
8dde4c43e0
19 changed files with 608 additions and 124 deletions
|
|
@ -13,6 +13,8 @@ Design visually on an infinite canvas, generate code from designs, and let AI bu
|
|||
|
||||

|
||||
|
||||
<video src="https://github.com/user-attachments/assets/5bf66acb-724f-4f7e-9f9b-cc219ce8f484" controls width="800"></video>
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Infinite canvas** — pan, zoom, smart alignment guides, drag-and-drop into auto-layout frames
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import { defineEventHandler, readBody, setResponseHeaders } from 'h3'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { resolveClaudeCli } from '../../utils/resolve-claude-cli'
|
||||
import { runCodexExec } from '../../utils/codex-client'
|
||||
import {
|
||||
buildClaudeAgentEnv,
|
||||
getClaudeAgentDebugFilePath,
|
||||
} from '../../utils/resolve-claude-agent-env'
|
||||
|
||||
interface ChatBody {
|
||||
system: string
|
||||
|
|
@ -12,6 +17,41 @@ interface ChatBody {
|
|||
effort?: 'low' | 'medium' | 'high' | 'max'
|
||||
}
|
||||
|
||||
async function readDebugTail(path?: string, maxLines = 40): Promise<string[] | undefined> {
|
||||
if (!path) return undefined
|
||||
try {
|
||||
const raw = await readFile(path, 'utf-8')
|
||||
const lines = raw.split('\n').filter((l) => l.trim().length > 0)
|
||||
return lines.slice(-maxLines)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function shouldRetryClaudeWithoutModel(raw: string): boolean {
|
||||
return /process exited with code 1|invalid model|unknown model|model.*not/i.test(raw)
|
||||
}
|
||||
|
||||
function buildClaudeExitHint(rawError: string, debugTail?: string[]): string | undefined {
|
||||
if (!/process exited with code 1/i.test(rawError)) return undefined
|
||||
if (!debugTail || debugTail.length === 0) return undefined
|
||||
const text = debugTail.join('\n')
|
||||
|
||||
const hints: string[] = []
|
||||
if (/Failed to save config with lock: Error: EPERM|operation not permitted, .*\.claude\.json/i.test(text)) {
|
||||
hints.push('Claude Code cannot write ~/.claude.json in the current runtime (permission denied).')
|
||||
}
|
||||
if (/Connection error|Could not resolve host|Failed to connect/i.test(text)) {
|
||||
hints.push('Upstream API connection failed (check proxy/DNS/network reachability to your ANTHROPIC_BASE_URL).')
|
||||
}
|
||||
if (/ANTHROPIC_CUSTOM_HEADERS present: false, has Authorization header: false/i.test(text)) {
|
||||
hints.push('No API auth header detected by Claude runtime; verify token/header env mapping.')
|
||||
}
|
||||
|
||||
if (hints.length === 0) return undefined
|
||||
return `${rawError}\n${hints.join(' ')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming chat endpoint.
|
||||
* Tries ANTHROPIC_API_KEY first (via Anthropic SDK);
|
||||
|
|
@ -145,6 +185,8 @@ function streamViaAgentSDK(body: ChatBody, model?: string) {
|
|||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'ping', content: '' })}\n\n`))
|
||||
} catch { /* stream already closed */ }
|
||||
}, KEEPALIVE_INTERVAL_MS)
|
||||
let emittedText = false
|
||||
let debugFile: string | undefined
|
||||
|
||||
try {
|
||||
const { query } = await import('@anthropic-ai/claude-agent-sdk')
|
||||
|
|
@ -154,52 +196,76 @@ function streamViaAgentSDK(body: ChatBody, model?: string) {
|
|||
let prompt = lastUserMsg?.content ?? ''
|
||||
|
||||
// Remove CLAUDECODE env to allow running from within a CC terminal
|
||||
const env = { ...process.env } as Record<string, string | undefined>
|
||||
delete env.CLAUDECODE
|
||||
const env = buildClaudeAgentEnv()
|
||||
debugFile = getClaudeAgentDebugFilePath()
|
||||
|
||||
const claudePath = resolveClaudeCli()
|
||||
const thinking = getAgentThinkingConfig(body)
|
||||
|
||||
const q = query({
|
||||
prompt,
|
||||
options: {
|
||||
systemPrompt: body.system,
|
||||
model: model || 'claude-sonnet-4-6',
|
||||
maxTurns: 1,
|
||||
includePartialMessages: true,
|
||||
tools: [],
|
||||
plugins: [],
|
||||
permissionMode: 'plan',
|
||||
persistSession: false,
|
||||
...(body.effort ? { effort: body.effort } : {}),
|
||||
...(thinking ? { thinking } : {}),
|
||||
env,
|
||||
...(claudePath ? { pathToClaudeCodeExecutable: claudePath } : {}),
|
||||
},
|
||||
})
|
||||
const runQuery = async (modelOverride?: string) => {
|
||||
const q = query({
|
||||
prompt,
|
||||
options: {
|
||||
systemPrompt: body.system,
|
||||
...(modelOverride ? { model: modelOverride } : {}),
|
||||
maxTurns: 1,
|
||||
includePartialMessages: true,
|
||||
tools: [],
|
||||
plugins: [],
|
||||
permissionMode: 'plan',
|
||||
persistSession: false,
|
||||
...(body.effort ? { effort: body.effort } : {}),
|
||||
...(thinking ? { thinking } : {}),
|
||||
env,
|
||||
...(debugFile ? { debugFile } : {}),
|
||||
...(claudePath ? { pathToClaudeCodeExecutable: claudePath } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
for await (const message of q) {
|
||||
if (message.type === 'stream_event') {
|
||||
const ev = message.event
|
||||
if (ev.type === 'content_block_delta') {
|
||||
if (ev.delta.type === 'text_delta') {
|
||||
clearInterval(pingTimer)
|
||||
const data = JSON.stringify({ type: 'text', content: ev.delta.text })
|
||||
controller.enqueue(encoder.encode(`data: ${data}\n\n`))
|
||||
} else if (ev.delta.type === 'thinking_delta') {
|
||||
// Keep pings alive during thinking — only stop on text output
|
||||
const data = JSON.stringify({ type: 'thinking', content: (ev.delta as any).thinking })
|
||||
controller.enqueue(encoder.encode(`data: ${data}\n\n`))
|
||||
try {
|
||||
for await (const message of q) {
|
||||
if (message.type === 'stream_event') {
|
||||
const ev = message.event
|
||||
if (ev.type === 'content_block_delta') {
|
||||
if (ev.delta.type === 'text_delta') {
|
||||
emittedText = true
|
||||
clearInterval(pingTimer)
|
||||
const data = JSON.stringify({ type: 'text', content: ev.delta.text })
|
||||
controller.enqueue(encoder.encode(`data: ${data}\n\n`))
|
||||
} else if (ev.delta.type === 'thinking_delta') {
|
||||
// Keep pings alive during thinking — only stop on text output
|
||||
const data = JSON.stringify({ type: 'thinking', content: (ev.delta as any).thinking })
|
||||
controller.enqueue(encoder.encode(`data: ${data}\n\n`))
|
||||
}
|
||||
}
|
||||
} else if (message.type === 'result') {
|
||||
const isErrorResult = 'is_error' in message && Boolean((message as { is_error?: boolean }).is_error)
|
||||
if (message.subtype !== 'success' || isErrorResult) {
|
||||
const errors = 'errors' in message ? (message.errors as string[]) : []
|
||||
const resultText = 'result' in message ? String(message.result ?? '') : ''
|
||||
const content = errors.join('; ') || resultText || `Query ended with: ${message.subtype}`
|
||||
if (modelOverride && !emittedText && shouldRetryClaudeWithoutModel(content)) {
|
||||
throw new Error(content)
|
||||
}
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: 'error', content })}\n\n`),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (message.type === 'result') {
|
||||
if (message.subtype !== 'success') {
|
||||
const errors = 'errors' in message ? (message.errors as string[]) : []
|
||||
const content = errors.join('; ') || `Query ended with: ${message.subtype}`
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: 'error', content })}\n\n`),
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
q.close()
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await runQuery(model)
|
||||
} catch (error) {
|
||||
const raw = error instanceof Error ? error.message : String(error)
|
||||
if (model && !emittedText && shouldRetryClaudeWithoutModel(raw)) {
|
||||
await runQuery(undefined)
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -207,7 +273,10 @@ function streamViaAgentSDK(body: ChatBody, model?: string) {
|
|||
encoder.encode(`data: ${JSON.stringify({ type: 'done', content: '' })}\n\n`),
|
||||
)
|
||||
} catch (error) {
|
||||
const content = error instanceof Error ? error.message : 'Unknown error'
|
||||
const rawContent = error instanceof Error ? error.message : 'Unknown error'
|
||||
const tail = await readDebugTail(debugFile)
|
||||
const hintedContent = buildClaudeExitHint(rawContent, tail)
|
||||
const content = hintedContent ?? rawContent
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: 'error', content })}\n\n`),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import { defineEventHandler, readBody, setResponseHeaders } from 'h3'
|
||||
import type { GroupedModel } from '../../../src/types/agent-settings'
|
||||
import { resolveClaudeCli } from '../../utils/resolve-claude-cli'
|
||||
import {
|
||||
buildClaudeAgentEnv,
|
||||
getClaudeAgentDebugFilePath,
|
||||
} from '../../utils/resolve-claude-agent-env'
|
||||
|
||||
interface ConnectBody {
|
||||
agent: 'claude-code' | 'codex-cli' | 'opencode'
|
||||
|
|
@ -44,20 +48,20 @@ async function connectClaudeCode(): Promise<ConnectResult> {
|
|||
try {
|
||||
const { query } = await import('@anthropic-ai/claude-agent-sdk')
|
||||
|
||||
const env = { ...process.env } as Record<string, string | undefined>
|
||||
delete env.CLAUDECODE
|
||||
const env = buildClaudeAgentEnv()
|
||||
const debugFile = getClaudeAgentDebugFilePath()
|
||||
|
||||
const claudePath = resolveClaudeCli()
|
||||
|
||||
const q = query({
|
||||
prompt: '',
|
||||
options: {
|
||||
model: 'claude-sonnet-4-6',
|
||||
maxTurns: 1,
|
||||
tools: [],
|
||||
permissionMode: 'plan',
|
||||
persistSession: false,
|
||||
env,
|
||||
...(debugFile ? { debugFile } : {}),
|
||||
...(claudePath ? { pathToClaudeCodeExecutable: claudePath } : {}),
|
||||
},
|
||||
})
|
||||
|
|
@ -81,8 +85,11 @@ async function connectClaudeCode(): Promise<ConnectResult> {
|
|||
|
||||
/** Map raw Agent SDK errors to user-friendly messages */
|
||||
function friendlyClaudeError(raw: string): string {
|
||||
if (/process exited with code 1|invalid model|unknown model|model.*not/i.test(raw)) {
|
||||
return 'Claude Code exited with code 1. Check your model mapping (e.g. ANTHROPIC_MODEL / default Sonnet model) and run "claude login" if needed.'
|
||||
}
|
||||
if (/exited with code/i.test(raw)) {
|
||||
return 'Unable to connect. Please run "claude login" in your terminal first.'
|
||||
return 'Unable to connect. Claude Code process exited unexpectedly.'
|
||||
}
|
||||
if (/not found|ENOENT/i.test(raw)) {
|
||||
return 'Claude Code CLI not found. Please install it first.'
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import { defineEventHandler, readBody, setResponseHeaders } from 'h3'
|
||||
import { resolveClaudeCli } from '../../utils/resolve-claude-cli'
|
||||
import { runCodexExec } from '../../utils/codex-client'
|
||||
import {
|
||||
buildClaudeAgentEnv,
|
||||
getClaudeAgentDebugFilePath,
|
||||
} from '../../utils/resolve-claude-agent-env'
|
||||
|
||||
interface GenerateBody {
|
||||
system: string
|
||||
|
|
@ -12,6 +16,10 @@ interface GenerateBody {
|
|||
effort?: 'low' | 'medium' | 'high' | 'max'
|
||||
}
|
||||
|
||||
function shouldRetryClaudeWithoutModel(raw: string): boolean {
|
||||
return /process exited with code 1|invalid model|unknown model|model.*not/i.test(raw)
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-streaming AI generation endpoint.
|
||||
* Tries ANTHROPIC_API_KEY first (via Anthropic SDK);
|
||||
|
|
@ -67,12 +75,12 @@ async function generateViaAnthropicSDK(apiKey: string, body: GenerateBody, model
|
|||
|
||||
/** Generate via Claude Agent SDK (uses local Claude Code OAuth login, no API key needed) */
|
||||
async function generateViaAgentSDK(body: GenerateBody, model?: string): Promise<{ text?: string; error?: string }> {
|
||||
try {
|
||||
const runQuery = async (modelOverride?: string): Promise<{ text?: string; error?: string }> => {
|
||||
const { query } = await import('@anthropic-ai/claude-agent-sdk')
|
||||
|
||||
// Remove CLAUDECODE env to allow running from within a CC terminal
|
||||
const env = { ...process.env } as Record<string, string | undefined>
|
||||
delete env.CLAUDECODE
|
||||
const env = buildClaudeAgentEnv()
|
||||
const debugFile = getClaudeAgentDebugFilePath()
|
||||
|
||||
const claudePath = resolveClaudeCli()
|
||||
|
||||
|
|
@ -80,30 +88,53 @@ async function generateViaAgentSDK(body: GenerateBody, model?: string): Promise<
|
|||
prompt: body.message,
|
||||
options: {
|
||||
systemPrompt: body.system,
|
||||
model: model || 'claude-sonnet-4-6',
|
||||
...(modelOverride ? { model: modelOverride } : {}),
|
||||
maxTurns: 1,
|
||||
tools: [],
|
||||
plugins: [],
|
||||
permissionMode: 'plan',
|
||||
persistSession: false,
|
||||
env,
|
||||
...(debugFile ? { debugFile } : {}),
|
||||
...(claudePath ? { pathToClaudeCodeExecutable: claudePath } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
for await (const message of q) {
|
||||
if (message.type === 'result') {
|
||||
if (message.subtype === 'success') {
|
||||
return { text: message.result }
|
||||
try {
|
||||
for await (const message of q) {
|
||||
if (message.type === 'result') {
|
||||
const isErrorResult = 'is_error' in message && Boolean((message as { is_error?: boolean }).is_error)
|
||||
if (message.subtype === 'success' && !isErrorResult) {
|
||||
return { text: message.result }
|
||||
}
|
||||
const errors = 'errors' in message ? (message.errors as string[]) : []
|
||||
const resultText = 'result' in message ? String(message.result ?? '') : ''
|
||||
return { error: errors.join('; ') || resultText || `Query ended with: ${message.subtype}` }
|
||||
}
|
||||
const errors = 'errors' in message ? (message.errors as string[]) : []
|
||||
return { error: errors.join('; ') || `Query ended with: ${message.subtype}` }
|
||||
}
|
||||
} finally {
|
||||
q.close()
|
||||
}
|
||||
|
||||
return { error: 'No result received from Claude Agent SDK' }
|
||||
}
|
||||
|
||||
try {
|
||||
const first = await runQuery(model)
|
||||
if (model && first.error && shouldRetryClaudeWithoutModel(first.error)) {
|
||||
return await runQuery(undefined)
|
||||
}
|
||||
return first
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (model && shouldRetryClaudeWithoutModel(message)) {
|
||||
try {
|
||||
return await runQuery(undefined)
|
||||
} catch (retryError) {
|
||||
const retryMessage = retryError instanceof Error ? retryError.message : String(retryError)
|
||||
return { error: retryMessage }
|
||||
}
|
||||
}
|
||||
return { error: message }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
import { defineEventHandler } from 'h3'
|
||||
import {
|
||||
buildClaudeAgentEnv,
|
||||
getClaudeAgentDebugFilePath,
|
||||
} from '../../utils/resolve-claude-agent-env'
|
||||
|
||||
interface ModelInfo {
|
||||
value: string
|
||||
|
|
@ -20,18 +24,18 @@ export default defineEventHandler(async () => {
|
|||
try {
|
||||
const { query } = await import('@anthropic-ai/claude-agent-sdk')
|
||||
|
||||
const env = { ...process.env } as Record<string, string | undefined>
|
||||
delete env.CLAUDECODE
|
||||
const env = buildClaudeAgentEnv()
|
||||
const debugFile = getClaudeAgentDebugFilePath()
|
||||
|
||||
const q = query({
|
||||
prompt: '',
|
||||
options: {
|
||||
model: 'claude-sonnet-4-6',
|
||||
maxTurns: 1,
|
||||
tools: [],
|
||||
permissionMode: 'plan',
|
||||
persistSession: false,
|
||||
env,
|
||||
...(debugFile ? { debugFile } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import { defineEventHandler, readBody, setResponseHeaders } from 'h3'
|
||||
import { resolveClaudeCli } from '../../utils/resolve-claude-cli'
|
||||
import {
|
||||
buildClaudeAgentEnv,
|
||||
getClaudeAgentDebugFilePath,
|
||||
} from '../../utils/resolve-claude-agent-env'
|
||||
import { writeFile, unlink, mkdtemp } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
|
@ -12,6 +16,10 @@ interface ValidateBody {
|
|||
provider?: string
|
||||
}
|
||||
|
||||
function shouldRetryClaudeWithoutModel(raw: string): boolean {
|
||||
return /process exited with code 1|invalid model|unknown model|model.*not/i.test(raw)
|
||||
}
|
||||
|
||||
/**
|
||||
* Vision-based validation endpoint.
|
||||
* Accepts a base64 PNG screenshot and a text prompt, sends multimodal
|
||||
|
|
@ -111,8 +119,8 @@ async function validateViaAgentSDK(
|
|||
|
||||
const { query } = await import('@anthropic-ai/claude-agent-sdk')
|
||||
|
||||
const env = { ...process.env } as Record<string, string | undefined>
|
||||
delete env.CLAUDECODE
|
||||
const env = buildClaudeAgentEnv()
|
||||
const debugFile = getClaudeAgentDebugFilePath()
|
||||
|
||||
const claudePath = resolveClaudeCli()
|
||||
|
||||
|
|
@ -125,31 +133,59 @@ ${body.system}
|
|||
|
||||
Output ONLY the JSON object, no markdown fences, no explanation.`
|
||||
|
||||
const q = query({
|
||||
prompt,
|
||||
options: {
|
||||
model: model || 'claude-sonnet-4-6',
|
||||
maxTurns: 2,
|
||||
tools: [],
|
||||
plugins: [],
|
||||
permissionMode: 'plan',
|
||||
persistSession: false,
|
||||
env,
|
||||
...(claudePath ? { pathToClaudeCodeExecutable: claudePath } : {}),
|
||||
},
|
||||
})
|
||||
const runQuery = async (modelOverride?: string): Promise<{ text: string; skipped?: boolean; error?: string }> => {
|
||||
const q = query({
|
||||
prompt,
|
||||
options: {
|
||||
...(modelOverride ? { model: modelOverride } : {}),
|
||||
maxTurns: 2,
|
||||
tools: [],
|
||||
plugins: [],
|
||||
permissionMode: 'plan',
|
||||
persistSession: false,
|
||||
env,
|
||||
...(debugFile ? { debugFile } : {}),
|
||||
...(claudePath ? { pathToClaudeCodeExecutable: claudePath } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
for await (const message of q) {
|
||||
if (message.type === 'result') {
|
||||
if (message.subtype === 'success') {
|
||||
return { text: message.result }
|
||||
try {
|
||||
for await (const message of q) {
|
||||
if (message.type === 'result') {
|
||||
const isErrorResult = 'is_error' in message && Boolean((message as { is_error?: boolean }).is_error)
|
||||
if (message.subtype === 'success' && !isErrorResult) {
|
||||
return { text: message.result }
|
||||
}
|
||||
const errors = 'errors' in message ? (message.errors as string[]) : []
|
||||
const resultText = 'result' in message ? String(message.result ?? '') : ''
|
||||
return { error: errors.join('; ') || resultText || `Query ended with: ${message.subtype}`, text: '' }
|
||||
}
|
||||
}
|
||||
const errors = 'errors' in message ? (message.errors as string[]) : []
|
||||
return { error: errors.join('; ') || `Query ended with: ${message.subtype}`, text: '' }
|
||||
} finally {
|
||||
q.close()
|
||||
}
|
||||
|
||||
return { text: '', skipped: true }
|
||||
}
|
||||
|
||||
return { text: '', skipped: true }
|
||||
try {
|
||||
const first = await runQuery(model)
|
||||
if (model && first.error && shouldRetryClaudeWithoutModel(first.error)) {
|
||||
return await runQuery(undefined)
|
||||
}
|
||||
return first
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (model && shouldRetryClaudeWithoutModel(message)) {
|
||||
try {
|
||||
return await runQuery(undefined)
|
||||
} catch (retryError) {
|
||||
const retryMessage = retryError instanceof Error ? retryError.message : String(retryError)
|
||||
return { error: retryMessage, text: '' }
|
||||
}
|
||||
}
|
||||
return { error: message, text: '' }
|
||||
}
|
||||
} finally {
|
||||
// Clean up temp file
|
||||
try {
|
||||
|
|
|
|||
79
server/utils/resolve-claude-agent-env.ts
Normal file
79
server/utils/resolve-claude-agent-env.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { mkdirSync, readFileSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
type EnvLike = Record<string, string | undefined>
|
||||
|
||||
interface ClaudeSettings {
|
||||
env?: Record<string, unknown>
|
||||
}
|
||||
|
||||
function normalizeEnvValue(value: unknown): string | undefined {
|
||||
if (value == null) return undefined
|
||||
if (typeof value === 'string') return value
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function readClaudeSettingsEnv(): EnvLike {
|
||||
try {
|
||||
const path = join(homedir(), '.claude', 'settings.json')
|
||||
const raw = readFileSync(path, 'utf-8')
|
||||
const parsed = JSON.parse(raw) as ClaudeSettings
|
||||
if (!parsed.env || typeof parsed.env !== 'object') return {}
|
||||
|
||||
const env: EnvLike = {}
|
||||
for (const [key, value] of Object.entries(parsed.env)) {
|
||||
const normalized = normalizeEnvValue(value)
|
||||
if (normalized !== undefined) {
|
||||
env[key] = normalized
|
||||
}
|
||||
}
|
||||
return env
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build env passed to Claude Agent SDK.
|
||||
* Priority: current process env > ~/.claude/settings.json env.
|
||||
*/
|
||||
export function buildClaudeAgentEnv(): EnvLike {
|
||||
const merged: EnvLike = {
|
||||
...readClaudeSettingsEnv(),
|
||||
...(process.env as EnvLike),
|
||||
}
|
||||
|
||||
// Compatibility: some Claude-compatible gateways expose token as ANTHROPIC_AUTH_TOKEN.
|
||||
// Claude Code primarily understands ANTHROPIC_API_KEY / ANTHROPIC_CUSTOM_HEADERS.
|
||||
const authToken = merged.ANTHROPIC_AUTH_TOKEN
|
||||
if (authToken && !merged.ANTHROPIC_API_KEY) {
|
||||
merged.ANTHROPIC_API_KEY = authToken
|
||||
}
|
||||
if (authToken && !merged.ANTHROPIC_CUSTOM_HEADERS) {
|
||||
merged.ANTHROPIC_CUSTOM_HEADERS = JSON.stringify({
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
})
|
||||
}
|
||||
|
||||
// Running inside Claude terminal can break nested Claude invocations.
|
||||
delete merged.CLAUDECODE
|
||||
return merged
|
||||
}
|
||||
|
||||
/**
|
||||
* Force Claude CLI debug output into a writable temp location.
|
||||
* This avoids crashes in restricted environments where ~/.claude/debug is not writable.
|
||||
*/
|
||||
export function getClaudeAgentDebugFilePath(): string | undefined {
|
||||
try {
|
||||
const dir = join('/tmp', 'openpencil-claude-debug')
|
||||
mkdirSync(dir, { recursive: true })
|
||||
return join(dir, 'claude-agent.log')
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
|
@ -223,8 +223,8 @@ export function computeLayoutPositions(
|
|||
const parentH = typeof pH === 'number' ? pH : 100
|
||||
const pad = resolvePadding(c.padding)
|
||||
const gap = typeof c.gap === 'number' ? c.gap : 0
|
||||
const justify = c.justifyContent ?? 'start'
|
||||
const align = c.alignItems ?? 'start'
|
||||
const justify = normalizeJustifyContent(c.justifyContent)
|
||||
const align = normalizeAlignItems(c.alignItems)
|
||||
|
||||
const isVertical = layout === 'vertical'
|
||||
const availW = parentW - pad.left - pad.right
|
||||
|
|
@ -359,5 +359,57 @@ export function computeLayoutPositions(
|
|||
})
|
||||
}
|
||||
|
||||
function normalizeJustifyContent(
|
||||
value: unknown,
|
||||
): 'start' | 'center' | 'end' | 'space_between' | 'space_around' {
|
||||
if (typeof value !== 'string') return 'start'
|
||||
const v = value.trim().toLowerCase()
|
||||
switch (v) {
|
||||
case 'start':
|
||||
case 'flex-start':
|
||||
case 'left':
|
||||
case 'top':
|
||||
return 'start'
|
||||
case 'center':
|
||||
case 'middle':
|
||||
return 'center'
|
||||
case 'end':
|
||||
case 'flex-end':
|
||||
case 'right':
|
||||
case 'bottom':
|
||||
return 'end'
|
||||
case 'space_between':
|
||||
case 'space-between':
|
||||
return 'space_between'
|
||||
case 'space_around':
|
||||
case 'space-around':
|
||||
return 'space_around'
|
||||
default:
|
||||
return 'start'
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAlignItems(value: unknown): 'start' | 'center' | 'end' {
|
||||
if (typeof value !== 'string') return 'start'
|
||||
const v = value.trim().toLowerCase()
|
||||
switch (v) {
|
||||
case 'start':
|
||||
case 'flex-start':
|
||||
case 'left':
|
||||
case 'top':
|
||||
return 'start'
|
||||
case 'center':
|
||||
case 'middle':
|
||||
return 'center'
|
||||
case 'end':
|
||||
case 'flex-end':
|
||||
case 'right':
|
||||
case 'bottom':
|
||||
return 'end'
|
||||
default:
|
||||
return 'start'
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export estimateLineWidth for convenience (used by drag-into-layout etc.)
|
||||
export { estimateLineWidth }
|
||||
|
|
|
|||
|
|
@ -148,6 +148,11 @@ function shouldSplitByGrapheme(text: string): boolean {
|
|||
return hasCjk && hasLongCjkRun
|
||||
}
|
||||
|
||||
function isFixedWidthText(node: PenNode): boolean {
|
||||
if (node.type !== 'text') return false
|
||||
return node.textGrowth === 'fixed-width' || node.textGrowth === 'fixed-width-height'
|
||||
}
|
||||
|
||||
function sizeToNumber(
|
||||
val: number | string | undefined,
|
||||
fallback: number,
|
||||
|
|
@ -320,14 +325,9 @@ export function createFabricObject(
|
|||
if (pw > 0 && ph > 0 && obj.width && obj.height) {
|
||||
// Uniform scale — preserve aspect ratio so icons don't get squished
|
||||
const uniformScale = Math.min(pw / obj.width, ph / obj.height)
|
||||
if (node.iconId) {
|
||||
// For icon nodes: expand the Fabric bounding box to pw×ph so the selection
|
||||
// handles always form a perfect square. The path content is naturally centered
|
||||
// by Fabric's pathOffset (center of path's bbox).
|
||||
obj.set({ width: pw / uniformScale, height: ph / uniformScale, scaleX: uniformScale, scaleY: uniformScale })
|
||||
} else {
|
||||
obj.set({ scaleX: uniformScale, scaleY: uniformScale })
|
||||
}
|
||||
// Keep native path width/height. Overriding width/height can shift pathOffset
|
||||
// and make icons appear visually off-center in logos.
|
||||
obj.set({ scaleX: uniformScale, scaleY: uniformScale })
|
||||
}
|
||||
break
|
||||
}
|
||||
|
|
@ -352,8 +352,7 @@ export function createFabricObject(
|
|||
}
|
||||
// Use Textbox for fixed-width / fixed-size modes (word wrapping).
|
||||
// Use IText for auto-width mode (no wrapping, expands horizontally).
|
||||
const growth = node.textGrowth
|
||||
const useTextbox = growth === 'fixed-width' || growth === 'fixed-width-height' || w > 0
|
||||
const useTextbox = isFixedWidthText(node)
|
||||
if (useTextbox) {
|
||||
obj = new fabric.Textbox(textContent, {
|
||||
...textProps,
|
||||
|
|
|
|||
|
|
@ -37,6 +37,11 @@ function shouldSplitByGrapheme(text: string): boolean {
|
|||
return hasCjk && hasLongCjkRun
|
||||
}
|
||||
|
||||
function isFixedWidthText(node: PenNode): boolean {
|
||||
if (node.type !== 'text') return false
|
||||
return node.textGrowth === 'fixed-width' || node.textGrowth === 'fixed-width-height'
|
||||
}
|
||||
|
||||
export function syncFabricObject(
|
||||
obj: FabricObjectWithPenId,
|
||||
node: PenNode,
|
||||
|
|
@ -122,6 +127,7 @@ export function syncFabricObject(
|
|||
? node.content
|
||||
: node.content.map((s) => s.text).join('')
|
||||
const w = sizeToNumber(node.width, 0)
|
||||
const fixedWidthText = isFixedWidthText(node)
|
||||
const fontSize = node.fontSize ?? 16
|
||||
const splitByGrapheme = shouldSplitByGrapheme(content)
|
||||
obj.set({
|
||||
|
|
@ -139,8 +145,8 @@ export function syncFabricObject(
|
|||
})
|
||||
if (obj instanceof fabric.Textbox) {
|
||||
obj.set({ splitByGrapheme } as Partial<fabric.Textbox>)
|
||||
if (fixedWidthText && w > 0) obj.set({ width: w })
|
||||
}
|
||||
if (w > 0) obj.set({ width: w })
|
||||
break
|
||||
}
|
||||
case 'polygon':
|
||||
|
|
@ -187,13 +193,9 @@ export function syncFabricObject(
|
|||
if (node.type === 'path') {
|
||||
// Uniform scale — preserve aspect ratio so icons don't get squished
|
||||
const uniformScale = Math.min(w / nw, h / nh)
|
||||
if (node.iconId) {
|
||||
// Expand bounding box to w×h so selection handles form a perfect square.
|
||||
// Path content is centered automatically by Fabric's pathOffset.
|
||||
obj.set({ width: w / uniformScale, height: h / uniformScale, scaleX: uniformScale, scaleY: uniformScale })
|
||||
} else {
|
||||
obj.set({ width: nw, height: nh, scaleX: uniformScale, scaleY: uniformScale })
|
||||
}
|
||||
// Keep native width/height to avoid pathOffset drift that can visually
|
||||
// offset icons inside logo containers.
|
||||
obj.set({ width: nw, height: nh, scaleX: uniformScale, scaleY: uniformScale })
|
||||
} else {
|
||||
obj.set({ width: nw, height: nh, scaleX: w / nw, scaleY: h / nh })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,10 +69,19 @@ export function estimateLineWidth(
|
|||
return Math.max(0, width)
|
||||
}
|
||||
|
||||
function widthSafetyFactor(text: string): number {
|
||||
// Latin fonts vary a lot by weight/family; use a larger safety margin to
|
||||
// avoid underestimating width and causing accidental wraps.
|
||||
return hasCjkText(text) ? 1.06 : 1.14
|
||||
}
|
||||
|
||||
export function estimateTextWidth(text: string, fontSize: number, letterSpacing = 0): number {
|
||||
const lines = text.split(/\r?\n/)
|
||||
const maxLine = lines.reduce((max, line) =>
|
||||
Math.max(max, estimateLineWidth(line, fontSize, letterSpacing)), 0)
|
||||
const maxLine = lines.reduce((max, line) => {
|
||||
const lineWidth = estimateLineWidth(line, fontSize, letterSpacing)
|
||||
const safeLineWidth = lineWidth * widthSafetyFactor(line)
|
||||
return Math.max(max, safeLineWidth)
|
||||
}, 0)
|
||||
return maxLine
|
||||
}
|
||||
|
||||
|
|
@ -154,7 +163,7 @@ export function estimateTextHeight(node: PenNode, availableWidth?: number): numb
|
|||
const letterSpacing = (typeof n.letterSpacing === 'number' ? n.letterSpacing : 0)
|
||||
const rawLines = content.split(/\r?\n/)
|
||||
const wrappedLineCount = rawLines.reduce((sum, line) => {
|
||||
const lineWidth = estimateLineWidth(line, fontSize, letterSpacing)
|
||||
const lineWidth = estimateLineWidth(line, fontSize, letterSpacing) * widthSafetyFactor(line)
|
||||
return sum + Math.max(1, Math.ceil(lineWidth / textWidth))
|
||||
}, 0)
|
||||
|
||||
|
|
|
|||
|
|
@ -435,8 +435,7 @@ export function useCanvasSync() {
|
|||
// (IText ↔ Textbox are different Fabric classes).
|
||||
if (existingObj && node.type === 'text') {
|
||||
const growth = node.textGrowth
|
||||
const w = typeof node.width === 'number' ? node.width : 0
|
||||
const needsTextbox = growth === 'fixed-width' || growth === 'fixed-width-height' || w > 0
|
||||
const needsTextbox = growth === 'fixed-width' || growth === 'fixed-width-height'
|
||||
const isTextbox = existingObj instanceof fabric.Textbox
|
||||
if (needsTextbox !== isTextbox) {
|
||||
canvas.remove(existingObj)
|
||||
|
|
|
|||
|
|
@ -50,6 +50,17 @@ const CORNER_CLASSES: Record<PanelCorner, string> = {
|
|||
'bottom-right': 'bottom-3 right-3',
|
||||
}
|
||||
|
||||
function resolveNextModel(
|
||||
models: Array<{ value: string }>,
|
||||
currentModel: string,
|
||||
preferredModel: string,
|
||||
): string | null {
|
||||
if (models.length === 0) return null
|
||||
if (models.some((m) => m.value === currentModel)) return currentModel
|
||||
if (models.some((m) => m.value === preferredModel)) return preferredModel
|
||||
return models[0].value
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimized AI bar — a compact clickable pill.
|
||||
* Parent is responsible for placing it in the layout.
|
||||
|
|
@ -97,8 +108,10 @@ export default function AIChatPanel() {
|
|||
const chatTitle = useAIStore((s) => s.chatTitle)
|
||||
const selectedIds = useCanvasStore((s) => s.selection.selectedIds)
|
||||
const toggleMinimize = useAIStore((s) => s.toggleMinimize)
|
||||
const hydrateModelPreference = useAIStore((s) => s.hydrateModelPreference)
|
||||
const model = useAIStore((s) => s.model)
|
||||
const setModel = useAIStore((s) => s.setModel)
|
||||
const selectModel = useAIStore((s) => s.selectModel)
|
||||
const availableModels = useAIStore((s) => s.availableModels)
|
||||
const setAvailableModels = useAIStore((s) => s.setAvailableModels)
|
||||
const modelGroups = useAIStore((s) => s.modelGroups)
|
||||
|
|
@ -106,6 +119,7 @@ export default function AIChatPanel() {
|
|||
const isLoadingModels = useAIStore((s) => s.isLoadingModels)
|
||||
const setLoadingModels = useAIStore((s) => s.setLoadingModels)
|
||||
const providers = useAgentSettingsStore((s) => s.providers)
|
||||
const providersHydrated = useAgentSettingsStore((s) => s.isHydrated)
|
||||
const [modelDropdownOpen, setModelDropdownOpen] = useState(false)
|
||||
const { input, setInput, handleSend } = useChatHandlers()
|
||||
|
||||
|
|
@ -113,9 +127,20 @@ export default function AIChatPanel() {
|
|||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages])
|
||||
|
||||
// Ensure model preference is restored from localStorage on page refresh.
|
||||
useEffect(() => {
|
||||
hydrateModelPreference()
|
||||
}, [hydrateModelPreference])
|
||||
|
||||
// Build model list from connected providers in agent-settings-store.
|
||||
// Falls back to Agent SDK legacy fetch when no providers are connected.
|
||||
useEffect(() => {
|
||||
if (!providersHydrated) {
|
||||
setLoadingModels(true)
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
const connectedProviders = (Object.keys(providers) as AIProviderType[]).filter(
|
||||
(p) => providers[p].isConnected && (providers[p].models?.length ?? 0) > 0,
|
||||
)
|
||||
|
|
@ -141,26 +166,49 @@ export default function AIChatPanel() {
|
|||
)
|
||||
setModelGroups(groups)
|
||||
setAvailableModels(flat)
|
||||
// If current model not in list, select first
|
||||
if (!flat.some((m) => m.value === model)) {
|
||||
setModel(flat[0].value)
|
||||
// Keep current when valid; otherwise restore remembered model; otherwise fallback to first.
|
||||
const { model: currentModel, preferredModel } = useAIStore.getState()
|
||||
const nextModel = resolveNextModel(flat, currentModel, preferredModel)
|
||||
if (nextModel && nextModel !== currentModel) {
|
||||
setModel(nextModel)
|
||||
}
|
||||
setLoadingModels(false)
|
||||
} else {
|
||||
// No providers connected — fall back to Agent SDK legacy model list
|
||||
setModelGroups([])
|
||||
setLoadingModels(true)
|
||||
fetchAvailableModels().then((models) => {
|
||||
return
|
||||
}
|
||||
|
||||
// No providers connected — fall back to Agent SDK legacy model list.
|
||||
setModelGroups([])
|
||||
setLoadingModels(true)
|
||||
fetchAvailableModels()
|
||||
.then((models) => {
|
||||
if (cancelled) return
|
||||
|
||||
// Provider state might have changed while request was in flight.
|
||||
const latestProviders = useAgentSettingsStore.getState().providers
|
||||
const stillNoConnected = (Object.keys(latestProviders) as AIProviderType[]).every(
|
||||
(p) => !(latestProviders[p].isConnected && (latestProviders[p].models?.length ?? 0) > 0),
|
||||
)
|
||||
if (!stillNoConnected) return
|
||||
|
||||
if (models.length > 0) {
|
||||
setAvailableModels(models)
|
||||
if (!models.some((m) => m.value === model)) {
|
||||
setModel(models[0].value)
|
||||
const { model: currentModel, preferredModel } = useAIStore.getState()
|
||||
const nextModel = resolveNextModel(models, currentModel, preferredModel)
|
||||
if (nextModel && nextModel !== currentModel) {
|
||||
setModel(nextModel)
|
||||
}
|
||||
}
|
||||
setLoadingModels(false)
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setLoadingModels(false)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [providers]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [providers, providersHydrated]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Close model dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
|
|
@ -549,7 +597,7 @@ export default function AIChatPanel() {
|
|||
key={m.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setModel(m.value)
|
||||
selectModel(m.value)
|
||||
setModelDropdownOpen(false)
|
||||
}}
|
||||
className={cn(
|
||||
|
|
@ -581,7 +629,7 @@ export default function AIChatPanel() {
|
|||
key={m.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setModel(m.value)
|
||||
selectModel(m.value)
|
||||
setModelDropdownOpen(false)
|
||||
}}
|
||||
className={cn(
|
||||
|
|
|
|||
|
|
@ -19,6 +19,58 @@ const POSITIONS = ['start', 'center', 'end'] as const
|
|||
|
||||
type GapMode = 'numeric' | 'space_between' | 'space_around'
|
||||
type PaddingMode = 'single' | 'axis' | 'individual'
|
||||
type JustifyValue = 'start' | 'center' | 'end' | 'space_between' | 'space_around'
|
||||
type AlignValue = 'start' | 'center' | 'end'
|
||||
|
||||
function normalizeJustifyValue(value: unknown): JustifyValue {
|
||||
if (typeof value !== 'string') return 'start'
|
||||
const v = value.trim().toLowerCase()
|
||||
switch (v) {
|
||||
case 'start':
|
||||
case 'flex-start':
|
||||
case 'left':
|
||||
case 'top':
|
||||
return 'start'
|
||||
case 'center':
|
||||
case 'middle':
|
||||
return 'center'
|
||||
case 'end':
|
||||
case 'flex-end':
|
||||
case 'right':
|
||||
case 'bottom':
|
||||
return 'end'
|
||||
case 'space_between':
|
||||
case 'space-between':
|
||||
return 'space_between'
|
||||
case 'space_around':
|
||||
case 'space-around':
|
||||
return 'space_around'
|
||||
default:
|
||||
return 'start'
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAlignValue(value: unknown): AlignValue {
|
||||
if (typeof value !== 'string') return 'start'
|
||||
const v = value.trim().toLowerCase()
|
||||
switch (v) {
|
||||
case 'start':
|
||||
case 'flex-start':
|
||||
case 'left':
|
||||
case 'top':
|
||||
return 'start'
|
||||
case 'center':
|
||||
case 'middle':
|
||||
return 'center'
|
||||
case 'end':
|
||||
case 'flex-end':
|
||||
case 'right':
|
||||
case 'bottom':
|
||||
return 'end'
|
||||
default:
|
||||
return 'start'
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Padding Icons (small SVG indicators for V/H padding)
|
||||
|
|
@ -109,8 +161,8 @@ function AlignmentGrid({
|
|||
onUpdate,
|
||||
}: {
|
||||
layout: 'none' | 'vertical' | 'horizontal'
|
||||
justifyContent: string
|
||||
alignItems: string
|
||||
justifyContent: JustifyValue
|
||||
alignItems: AlignValue
|
||||
isSpaceMode: boolean
|
||||
onUpdate: (updates: Partial<PenNode>) => void
|
||||
}) {
|
||||
|
|
@ -615,8 +667,8 @@ export default function LayoutSection({
|
|||
const layout = node.layout ?? 'none'
|
||||
const hasLayout = layout !== 'none'
|
||||
|
||||
const justifyContent = node.justifyContent ?? 'start'
|
||||
const alignItems = node.alignItems ?? 'start'
|
||||
const justifyContent = normalizeJustifyValue(node.justifyContent)
|
||||
const alignItems = normalizeAlignValue(node.alignItems)
|
||||
const rawGap = node.gap
|
||||
const gap = typeof rawGap === 'number' ? rawGap : 0
|
||||
|
||||
|
|
|
|||
|
|
@ -58,6 +58,9 @@ RULES:
|
|||
- Use "fill_container" to stretch, "fit_content" to shrink-wrap
|
||||
- Use clipContent: true on cards/containers with cornerRadius + image children to prevent overflow
|
||||
- Use justifyContent="space_between" to spread items across full width (great for navbars, footers)
|
||||
- INPUT ICON AFFORDANCE: for semantic inputs (search/password/email/login), include one path icon when appropriate.
|
||||
For trailing icons (e.g. password visibility), use horizontal input layout with justifyContent="space_between".
|
||||
For leading icons (e.g. search/email), use justifyContent="start" with gap 8-12.
|
||||
|
||||
OVERFLOW PREVENTION (CRITICAL — violations cause visual glitches):
|
||||
- TEXT WIDTH: for text inside vertical layout frames, use width="fill_container" + textGrowth="fixed-width". For text inside horizontal rows (nav/footer/button rows), default to width="fit_content" (or omit width) + textGrowth="auto". NEVER set fixed pixel width on text inside a layout.
|
||||
|
|
@ -207,6 +210,10 @@ DESIGN GUIDELINES:
|
|||
- Badges/tags ("NEW", "SALE", "PRO"): only for short labels (CJK <=8 chars / Latin <=16 chars). For longer copy, use a normal text row/card instead of badge/chip style.
|
||||
- Button + icon-button row: horizontal, gap=8-12. Primary button width="fill_container"; icon-only button fixed square 44-48px.
|
||||
- Inputs: height 44px, light bg, subtle border. Use width="fill_container" in form contexts.
|
||||
- Semantic inputs should include affordance icons when appropriate:
|
||||
- search bars: leading SearchIcon
|
||||
- password fields: trailing EyeIcon or EyeOffIcon
|
||||
- email/account fields: leading MailIcon or UserIcon
|
||||
- Fixed-width children must NOT exceed their parent's content area (parent width minus padding).
|
||||
- Consistent color palette
|
||||
- Default to light neutral styling unless user explicitly asks for dark/neon/terminal
|
||||
|
|
|
|||
|
|
@ -97,6 +97,10 @@ DESIGN RULES:
|
|||
- CJK fonts: use "Noto Sans SC" (CN) / "Noto Sans JP" (JP) / "Noto Sans KR" (KR) for headings. Never "Space Grotesk"/"Manrope" for CJK. CJK lineHeight: 1.3-1.4 headings, 1.6-1.8 body. CJK letterSpacing: 0, never negative.
|
||||
- Card rows: ALL cards use width="fill_container" + height="fill_container" for even distribution and equal height. Dense rows (5+): use short titles, max 2 text blocks per card.
|
||||
- Icons: "path" nodes with descriptive names ("SearchIcon", "MenuIcon" etc.). System auto-resolves to SVG. Size 16-24px. Never use emoji as icons.
|
||||
- Semantic inputs should include affordance icons when appropriate:
|
||||
- search bars: leading SearchIcon
|
||||
- password fields: trailing EyeIcon or EyeOffIcon (use justifyContent="space_between")
|
||||
- email/account fields: leading MailIcon or UserIcon
|
||||
- Phone mockup: ONE frame, width 260-300, height 520-580, cornerRadius 32, solid fill + 1px stroke. No ellipse for mockups. At most ONE centered text child inside.
|
||||
- Never ellipse for decorative shapes — use frame/rectangle with cornerRadius.
|
||||
- Use style guide colors/fonts consistently. No random colors.
|
||||
|
|
|
|||
|
|
@ -203,6 +203,11 @@ export function resolveTreePostPass(
|
|||
normalizeFormInputWidths(root, children)
|
||||
}
|
||||
|
||||
// --- Input trailing icon alignment ---
|
||||
if (root.layout === 'horizontal' && children.length >= 2) {
|
||||
normalizeInputTrailingIconAlignment(root, children)
|
||||
}
|
||||
|
||||
// --- Text height estimation ---
|
||||
if (root.layout && root.layout !== 'none') {
|
||||
fixTextHeights(root, children, canvasWidth)
|
||||
|
|
@ -361,6 +366,40 @@ function normalizeFormInputWidths(
|
|||
}
|
||||
}
|
||||
|
||||
function normalizeInputTrailingIconAlignment(
|
||||
parent: FrameNode,
|
||||
children: PenNode[],
|
||||
): void {
|
||||
if (parent.role !== 'input' && parent.role !== 'form-input') return
|
||||
if (parent.justifyContent && parent.justifyContent !== 'start') return
|
||||
|
||||
const visibleChildren = children.filter((c) => c.visible !== false)
|
||||
if (visibleChildren.length < 2) return
|
||||
|
||||
const trailing = visibleChildren[visibleChildren.length - 1]
|
||||
if (!isIconLikeNode(trailing)) return
|
||||
|
||||
const hasTextBeforeTrailing = visibleChildren
|
||||
.slice(0, -1)
|
||||
.some((child) => child.type === 'text')
|
||||
if (!hasTextBeforeTrailing) return
|
||||
|
||||
;(parent as unknown as Record<string, unknown>).justifyContent = 'space_between'
|
||||
}
|
||||
|
||||
function isIconLikeNode(node: PenNode): boolean {
|
||||
if (node.type === 'path' || node.type === 'image') return true
|
||||
|
||||
if (node.type === 'frame') {
|
||||
if (node.role === 'icon' || node.role === 'icon-button') return true
|
||||
const w = toSizeNumber(node.width, 0)
|
||||
const h = toSizeNumber(node.height, 0)
|
||||
if (w > 0 && h > 0 && Math.max(w, h) <= 32) return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function fixTextHeights(
|
||||
parent: FrameNode,
|
||||
children: PenNode[],
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ interface PersistedState {
|
|||
|
||||
interface AgentSettingsState extends PersistedState {
|
||||
dialogOpen: boolean
|
||||
isHydrated: boolean
|
||||
|
||||
connectProvider: (
|
||||
provider: AIProviderType,
|
||||
|
|
@ -64,6 +65,7 @@ export const useAgentSettingsStore = create<AgentSettingsState>((set, get) => ({
|
|||
providers: { ...DEFAULT_PROVIDERS },
|
||||
mcpIntegrations: [...DEFAULT_MCP_INTEGRATIONS],
|
||||
dialogOpen: false,
|
||||
isHydrated: false,
|
||||
|
||||
connectProvider: (provider, method, models) =>
|
||||
set((s) => ({
|
||||
|
|
@ -126,6 +128,8 @@ export const useAgentSettingsStore = create<AgentSettingsState>((set, get) => ({
|
|||
if (data.mcpIntegrations) set({ mcpIntegrations: data.mcpIntegrations })
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
set({ isHydrated: true })
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -4,6 +4,33 @@ import type { ModelGroup } from '@/types/agent-settings'
|
|||
|
||||
export type PanelCorner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
|
||||
|
||||
const DEFAULT_MODEL = 'claude-sonnet-4-5-20250929'
|
||||
const MODEL_PREFERENCE_STORAGE_KEY = 'openpencil-ai-model-preference'
|
||||
|
||||
function readStoredModelPreference(): string | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
try {
|
||||
const value = window.localStorage.getItem(MODEL_PREFERENCE_STORAGE_KEY)
|
||||
if (!value || value.trim().length === 0) return null
|
||||
return value
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function writeStoredModelPreference(model: string): void {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
window.localStorage.setItem(MODEL_PREFERENCE_STORAGE_KEY, model)
|
||||
} catch {
|
||||
// Ignore storage failures (private mode, quota, etc.)
|
||||
}
|
||||
}
|
||||
|
||||
// Keep SSR/CSR first render deterministic to avoid hydration mismatch.
|
||||
// Real preference is loaded on mount via hydrateModelPreference().
|
||||
const initialPreferredModel = DEFAULT_MODEL
|
||||
|
||||
export interface AIModelInfo {
|
||||
value: string
|
||||
displayName: string
|
||||
|
|
@ -18,6 +45,7 @@ interface AIState {
|
|||
generatedCode: string
|
||||
codeFormat: 'react-tailwind' | 'html-css' | 'react-inline'
|
||||
model: string
|
||||
preferredModel: string
|
||||
availableModels: AIModelInfo[]
|
||||
modelGroups: ModelGroup[]
|
||||
isLoadingModels: boolean
|
||||
|
|
@ -29,6 +57,8 @@ interface AIState {
|
|||
setChatTitle: (title: string) => void
|
||||
setGenerationProgress: (progress: { current: number; total: number } | null) => void
|
||||
|
||||
hydrateModelPreference: () => void
|
||||
selectModel: (model: string) => void
|
||||
setModel: (model: string) => void
|
||||
setAvailableModels: (models: AIModelInfo[]) => void
|
||||
setModelGroups: (groups: ModelGroup[]) => void
|
||||
|
|
@ -53,7 +83,8 @@ export const useAIStore = create<AIState>((set) => ({
|
|||
activeTab: 'chat',
|
||||
generatedCode: '',
|
||||
codeFormat: 'react-tailwind',
|
||||
model: 'claude-sonnet-4-5-20250929',
|
||||
model: initialPreferredModel,
|
||||
preferredModel: initialPreferredModel,
|
||||
availableModels: [],
|
||||
modelGroups: [],
|
||||
isLoadingModels: false,
|
||||
|
|
@ -65,6 +96,12 @@ export const useAIStore = create<AIState>((set) => ({
|
|||
setChatTitle: (chatTitle) => set({ chatTitle }),
|
||||
setGenerationProgress: (generationProgress) => set({ generationProgress }),
|
||||
|
||||
hydrateModelPreference: () => {
|
||||
const stored = readStoredModelPreference()
|
||||
if (!stored) return
|
||||
set({ model: stored, preferredModel: stored })
|
||||
},
|
||||
|
||||
addMessage: (msg) =>
|
||||
set((s) => ({ messages: [...s.messages, msg] })),
|
||||
|
||||
|
|
@ -90,6 +127,10 @@ export const useAIStore = create<AIState>((set) => ({
|
|||
|
||||
setCodeFormat: (codeFormat) => set({ codeFormat }),
|
||||
|
||||
selectModel: (model) => {
|
||||
writeStoredModelPreference(model)
|
||||
set({ model, preferredModel: model })
|
||||
},
|
||||
setModel: (model) => set({ model }),
|
||||
setAvailableModels: (availableModels) => set({ availableModels }),
|
||||
setModelGroups: (modelGroups) => set({ modelGroups }),
|
||||
|
|
|
|||
Loading…
Reference in a new issue